Save Current State: zmongo nodes refactor
@@ -0,0 +1,224 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from aiohttp import web
|
||||
from bson import ObjectId
|
||||
|
||||
from .zmongo_record_editor_node import ZMongoRecordEditorNode
|
||||
from .zmongo_toolbag.zmongo import ZMongo
|
||||
from .zmongo_toolbag.data_processor import DataProcessor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def register_zmongo_record_editor_routes(prompt_server_instance):
|
||||
if not prompt_server_instance:
|
||||
logger.warning("PromptServer instance not available; record editor routes not registered.")
|
||||
return
|
||||
|
||||
routes = prompt_server_instance.routes
|
||||
|
||||
def _clean_record_id(value: Any) -> str:
|
||||
raw = "" if value is None else str(value)
|
||||
cleaned = raw.strip()
|
||||
|
||||
if len(cleaned) >= 2 and cleaned[0] == cleaned[-1] and cleaned[0] in {"'", '"'}:
|
||||
cleaned = cleaned[1:-1].strip()
|
||||
|
||||
return cleaned
|
||||
|
||||
def _build_id_candidates(value: Any) -> List[Any]:
|
||||
cleaned = _clean_record_id(value)
|
||||
if not cleaned:
|
||||
return []
|
||||
|
||||
candidates: List[Any] = [cleaned]
|
||||
if ObjectId.is_valid(cleaned):
|
||||
oid = ObjectId(cleaned)
|
||||
candidates.insert(0, oid)
|
||||
|
||||
return candidates
|
||||
|
||||
def _parse_json_or_string(value: Any) -> Any:
|
||||
raw = "" if value is None else str(value)
|
||||
stripped = raw.strip()
|
||||
if not stripped:
|
||||
return ""
|
||||
try:
|
||||
return json.loads(stripped)
|
||||
except Exception:
|
||||
return raw
|
||||
|
||||
def _result_succeeded(result: Any) -> bool:
|
||||
return bool(result) and bool(getattr(result, "success", False))
|
||||
|
||||
def _result_data(result: Any) -> Dict[str, Any]:
|
||||
data = getattr(result, "data", {}) or {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
@routes.get("/zmongo/record_editor/load")
|
||||
async def zmongo_record_editor_load(request):
|
||||
try:
|
||||
collection_name = request.rel_url.query.get("collection_name", "").strip()
|
||||
record_id = _clean_record_id(request.rel_url.query.get("record_id", ""))
|
||||
selected_record_json = request.rel_url.query.get("selected_record_json", "")
|
||||
|
||||
record = ZMongoRecordEditorNode.fetch_record(
|
||||
collection_name=collection_name,
|
||||
record_id=record_id,
|
||||
fallback_record_json=selected_record_json,
|
||||
)
|
||||
|
||||
if not record:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"collection_name": collection_name,
|
||||
"record_id": record_id,
|
||||
"record": {},
|
||||
"fields": [],
|
||||
}
|
||||
)
|
||||
|
||||
resolved_record_id = str(record.get("_id", record_id))
|
||||
|
||||
pairs = ZMongoRecordEditorNode.get_flattened_record_pairs(
|
||||
collection_name=collection_name,
|
||||
record_id=resolved_record_id,
|
||||
fallback_record_json=selected_record_json,
|
||||
)
|
||||
|
||||
fields = [{"path": path, "value": value} for path, value in pairs]
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"collection_name": collection_name,
|
||||
"record_id": resolved_record_id,
|
||||
"record": DataProcessor.to_json_compatible(record),
|
||||
"fields": fields,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to load record editor payload: %s", exc)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": str(exc),
|
||||
"record": {},
|
||||
"fields": [],
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
|
||||
@routes.post("/zmongo/record_editor/save")
|
||||
async def zmongo_record_editor_save(request):
|
||||
try:
|
||||
payload: Dict[str, Any] = await request.json()
|
||||
|
||||
collection_name = str(payload.get("collection_name", "")).strip()
|
||||
record_id = _clean_record_id(payload.get("record_id", ""))
|
||||
changes = payload.get("changes", {})
|
||||
|
||||
if not collection_name:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "collection_name is required"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
if not record_id:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "record_id is required"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
if not isinstance(changes, dict) or not changes:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "changes must be a non-empty object"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
normalized_changes = {
|
||||
str(path).strip(): _parse_json_or_string(value)
|
||||
for path, value in changes.items()
|
||||
if str(path).strip()
|
||||
}
|
||||
|
||||
if not normalized_changes:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "No valid change paths were provided"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
zmongo = ZMongo()
|
||||
update_doc = {"$set": normalized_changes}
|
||||
|
||||
result = None
|
||||
matched_any_candidate = False
|
||||
|
||||
for candidate_id in _build_id_candidates(record_id):
|
||||
result = zmongo.update_one(
|
||||
collection_name,
|
||||
{"_id": candidate_id},
|
||||
update_doc,
|
||||
upsert=False,
|
||||
)
|
||||
|
||||
if _result_succeeded(result):
|
||||
data = _result_data(result)
|
||||
matched_count = int(data.get("matched_count", 0) or 0)
|
||||
modified_count = int(data.get("modified_count", 0) or 0)
|
||||
upserted_id = data.get("upserted_id")
|
||||
|
||||
if matched_count > 0:
|
||||
matched_any_candidate = True
|
||||
|
||||
if modified_count > 0 or upserted_id is not None or matched_count > 0:
|
||||
break
|
||||
|
||||
if result is None:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "record_id is required"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
refreshed = ZMongoRecordEditorNode.fetch_record(
|
||||
collection_name=collection_name,
|
||||
record_id=record_id,
|
||||
fallback_record_json="",
|
||||
)
|
||||
|
||||
if not matched_any_candidate and not refreshed:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"No record found for _id '{record_id}' in collection '{collection_name}'",
|
||||
"result": getattr(result, "to_dict", lambda: {})(),
|
||||
"collection_name": collection_name,
|
||||
"record_id": record_id,
|
||||
"record": {},
|
||||
},
|
||||
status=404,
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": bool(getattr(result, "success", False)),
|
||||
"message": getattr(result, "message", "Saved"),
|
||||
"error": getattr(result, "error", None),
|
||||
"result": getattr(result, "to_dict", lambda: {})(),
|
||||
"collection_name": collection_name,
|
||||
"record_id": record_id,
|
||||
"record": DataProcessor.to_json_compatible(refreshed),
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to save record editor changes: %s", exc)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": str(exc),
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
@@ -0,0 +1,93 @@
|
||||
import logging
|
||||
from aiohttp import web
|
||||
|
||||
from .zmongo_tabular_record_view_node import ZMongoTabularRecordViewNode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def register_zmongo_tabular_record_view_routes(prompt_server_instance):
|
||||
if not prompt_server_instance:
|
||||
logger.warning("PromptServer instance not available; tabular record routes not registered.")
|
||||
return
|
||||
|
||||
routes = prompt_server_instance.routes
|
||||
|
||||
@routes.get("/zmongo/tabular_records")
|
||||
async def zmongo_get_tabular_records(request):
|
||||
try:
|
||||
collection_name = request.rel_url.query.get("collection_name", "").strip()
|
||||
|
||||
headings, flat_records, record_ids = ZMongoTabularRecordViewNode.get_table_payload(collection_name)
|
||||
default_record_id = record_ids[0] if record_ids else ""
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"collection_name": collection_name,
|
||||
"headings": headings,
|
||||
"flat_records": flat_records,
|
||||
"record_ids": record_ids,
|
||||
"default_record_id": default_record_id,
|
||||
"record_count": len(flat_records),
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to get tabular records: %s", exc)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": str(exc),
|
||||
"headings": [],
|
||||
"flat_records": [],
|
||||
"record_ids": [],
|
||||
"default_record_id": "",
|
||||
"record_count": 0,
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
|
||||
@routes.get("/zmongo/tabular_records_search")
|
||||
async def zmongo_get_tabular_records_search(request):
|
||||
try:
|
||||
collection_name = request.rel_url.query.get("collection_name", "").strip()
|
||||
search_text = request.rel_url.query.get("search_text", "")
|
||||
flattened_field_name = request.rel_url.query.get("flattened_field_name", "").strip()
|
||||
|
||||
headings, flat_records, record_ids = (
|
||||
ZMongoTabularRecordViewNode.get_filtered_table_payload(
|
||||
collection_name=collection_name,
|
||||
search_text=search_text,
|
||||
flattened_field_name=flattened_field_name,
|
||||
)
|
||||
)
|
||||
|
||||
default_record_id = record_ids[0] if record_ids else ""
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"collection_name": collection_name,
|
||||
"search_text": search_text,
|
||||
"flattened_field_name": flattened_field_name,
|
||||
"headings": headings,
|
||||
"flat_records": flat_records,
|
||||
"record_ids": record_ids,
|
||||
"default_record_id": default_record_id,
|
||||
"record_count": len(flat_records),
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to search tabular records: %s", exc)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": str(exc),
|
||||
"headings": [],
|
||||
"flat_records": [],
|
||||
"record_ids": [],
|
||||
"default_record_id": "",
|
||||
"record_count": 0,
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
@@ -0,0 +1,17 @@
|
||||
# SPECIALIZED MODEL NODE IMPORTS 🤖
|
||||
from .zmongo_model_compatibility_display_node import ZMongoModelCompatibilityDisplayNode
|
||||
from .zmongo_model_introspector_node import ZMongoModelIntrospectorNode
|
||||
from .zmongo_universal_model_selector_node import ZMongoUniversalModelSelectorNode
|
||||
from .zmongo_universal_model_loader_node import ZMongoUniversalModelLoaderNode
|
||||
from .zmongo_universal_adapter_node import ZMongoUniversalModelAdapterNode
|
||||
from .zmongo_builtin_loader_adapter_node import ZMongoBuiltInLoaderAdapterNode
|
||||
|
||||
# EXPORTING THE SUITE 📦
|
||||
__all__ = [
|
||||
"ZMongoModelCompatibilityDisplayNode",
|
||||
"ZMongoModelIntrospectorNode",
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoUniversalModelLoaderNode",
|
||||
"ZMongoUniversalModelAdapterNode",
|
||||
"ZMongoBuiltInLoaderAdapterNode",
|
||||
]
|
||||
@@ -0,0 +1,437 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
import folder_paths
|
||||
from nodes import (
|
||||
CLIPLoader,
|
||||
CLIPVisionLoader,
|
||||
CheckpointLoaderSimple,
|
||||
ControlNetLoader,
|
||||
StyleModelLoader,
|
||||
UNETLoader,
|
||||
VAELoader,
|
||||
)
|
||||
from comfy_extras.nodes_upscale_model import UpscaleModelLoader
|
||||
|
||||
class ZMongoBuiltInLoaderAdapterNode:
|
||||
"""
|
||||
Universal adapter that reuses ComfyUI built-in loaders wherever possible.
|
||||
"""
|
||||
|
||||
CATEGORY = "ZMongo/Models"
|
||||
FUNCTION = "adapt"
|
||||
|
||||
RETURN_TYPES = (
|
||||
"MODEL",
|
||||
"CLIP",
|
||||
"VAE",
|
||||
"MODEL",
|
||||
"CONTROL_NET",
|
||||
"CLIP_VISION",
|
||||
"UPSCALE_MODEL",
|
||||
"STYLE_MODEL",
|
||||
"STRING",
|
||||
"BOOLEAN",
|
||||
"STRING",
|
||||
"STRING",
|
||||
)
|
||||
RETURN_NAMES = (
|
||||
"model",
|
||||
"clip",
|
||||
"vae",
|
||||
"unet_model",
|
||||
"control_net",
|
||||
"clip_vision",
|
||||
"upscale_model",
|
||||
"style_model",
|
||||
"status_text",
|
||||
"is_compatible",
|
||||
"model_type",
|
||||
"built_in_loader_name",
|
||||
)
|
||||
|
||||
MODEL_TYPE_TO_FOLDER = {
|
||||
"checkpoint": "checkpoints",
|
||||
"vae": "vae",
|
||||
"clip": "clip",
|
||||
"text_encoder": "text_encoders",
|
||||
"unet": "diffusion_models",
|
||||
"diffusion": "diffusion_models",
|
||||
"controlnet": "controlnet",
|
||||
"clip_vision": "clip_vision",
|
||||
"upscaler": "upscale_models",
|
||||
"style_model": "style_models",
|
||||
}
|
||||
|
||||
BUILTIN_BY_MODEL_TYPE = {
|
||||
"checkpoint": "CheckpointLoaderSimple",
|
||||
"vae": "VAELoader",
|
||||
"clip": "CLIPLoader",
|
||||
"text_encoder": "CLIPLoader",
|
||||
"unet": "UNETLoader",
|
||||
"diffusion": "UNETLoader",
|
||||
"controlnet": "ControlNetLoader",
|
||||
"clip_vision": "CLIPVisionLoader",
|
||||
"upscaler": "UpscaleModelLoader",
|
||||
"style_model": "StyleModelLoader",
|
||||
}
|
||||
|
||||
SUPPORTED_MODEL_TYPES = set(BUILTIN_BY_MODEL_TYPE.keys())
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"full_path": ("STRING", {"forceInput": True}),
|
||||
},
|
||||
"optional": {
|
||||
"model_info_json": ("STRING", {"forceInput": True}),
|
||||
"clip_type": (
|
||||
[
|
||||
"stable_diffusion",
|
||||
"stable_cascade",
|
||||
"sd3",
|
||||
"stable_audio",
|
||||
"mochi",
|
||||
"ltxv",
|
||||
"pixart",
|
||||
"cosmos",
|
||||
"lumina2",
|
||||
"wan",
|
||||
"hidream",
|
||||
],
|
||||
{"default": "stable_diffusion"},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(
|
||||
cls,
|
||||
full_path: str,
|
||||
model_info_json: str = "",
|
||||
clip_type: str = "stable_diffusion",
|
||||
):
|
||||
return full_path, model_info_json, clip_type
|
||||
|
||||
@staticmethod
|
||||
def _none_outputs() -> Tuple[Any, Any, Any, Any, Any, Any, Any]:
|
||||
return None, None, None, None, None, None, None
|
||||
|
||||
@staticmethod
|
||||
def _safe_parse_model_info(model_info_json: str) -> Dict[str, Any]:
|
||||
if not model_info_json:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(model_info_json)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_path(full_path: str) -> Optional[Path]:
|
||||
if not full_path:
|
||||
return None
|
||||
try:
|
||||
return Path(full_path).expanduser().resolve()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _infer_model_type_from_path(path: Path) -> str:
|
||||
parts = [part.lower() for part in path.parts]
|
||||
|
||||
if "checkpoints" in parts:
|
||||
return "checkpoint"
|
||||
if "vae" in parts:
|
||||
return "vae"
|
||||
if "clip_vision" in parts:
|
||||
return "clip_vision"
|
||||
if "clip" in parts:
|
||||
return "clip"
|
||||
if "text_encoders" in parts:
|
||||
return "text_encoder"
|
||||
if "controlnet" in parts:
|
||||
return "controlnet"
|
||||
if "upscale_models" in parts:
|
||||
return "upscaler"
|
||||
if "style_models" in parts:
|
||||
return "style_model"
|
||||
if "diffusion_models" in parts:
|
||||
return "diffusion"
|
||||
if "unet" in parts:
|
||||
return "unet"
|
||||
|
||||
ext = path.suffix.lower()
|
||||
if ext == ".ckpt":
|
||||
return "checkpoint"
|
||||
if ext in {".pt", ".pth", ".bin", ".safetensors"}:
|
||||
return "unknown"
|
||||
|
||||
return "unknown"
|
||||
|
||||
@classmethod
|
||||
def _resolve_model_type(cls, full_path: Path, model_info: Dict[str, Any]) -> str:
|
||||
model_type = str(model_info.get("model_type", "")).strip()
|
||||
if model_type:
|
||||
return model_type
|
||||
return cls._infer_model_type_from_path(full_path)
|
||||
|
||||
@classmethod
|
||||
def _relative_name_for_folder_type(cls, full_path: Path, folder_type: str) -> str:
|
||||
normalized = full_path.resolve()
|
||||
available = folder_paths.get_filename_list(folder_type)
|
||||
|
||||
for entry in available:
|
||||
candidate = Path(folder_paths.get_full_path(folder_type, entry)).resolve()
|
||||
if candidate == normalized:
|
||||
return entry.replace("\\", "/")
|
||||
|
||||
raise ValueError(
|
||||
f"Selected file is not registered under Comfy models folder type "
|
||||
f"'{folder_type}': {full_path}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _call_first_available(instance: Any, methods_with_kwargs):
|
||||
last_error = None
|
||||
|
||||
for method_name, kwargs in methods_with_kwargs:
|
||||
method = getattr(instance, method_name, None)
|
||||
if method is None:
|
||||
continue
|
||||
try:
|
||||
return method(**kwargs)
|
||||
except TypeError as exc:
|
||||
last_error = exc
|
||||
continue
|
||||
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
|
||||
raise AttributeError(
|
||||
f"No compatible loader method found on {instance.__class__.__name__}"
|
||||
)
|
||||
|
||||
def _load_checkpoint(self, relative_name: str):
|
||||
loader = CheckpointLoaderSimple()
|
||||
result = self._call_first_available(
|
||||
loader,
|
||||
[
|
||||
("load_checkpoint", {"ckpt_name": relative_name}),
|
||||
],
|
||||
)
|
||||
if not isinstance(result, tuple) or len(result) < 3:
|
||||
raise ValueError("CheckpointLoaderSimple returned an unexpected result.")
|
||||
return result[0], result[1], result[2]
|
||||
|
||||
def _load_vae(self, relative_name: str):
|
||||
loader = VAELoader()
|
||||
result = self._call_first_available(
|
||||
loader,
|
||||
[
|
||||
("load_vae", {"vae_name": relative_name}),
|
||||
],
|
||||
)
|
||||
if not isinstance(result, tuple) or len(result) < 1:
|
||||
raise ValueError("VAELoader returned an unexpected result.")
|
||||
return result[0]
|
||||
|
||||
def _load_clip(self, relative_name: str, clip_type: str):
|
||||
loader = CLIPLoader()
|
||||
result = self._call_first_available(
|
||||
loader,
|
||||
[
|
||||
("load_clip", {"clip_name": relative_name, "type": clip_type}),
|
||||
("load_clip", {"clip_name": relative_name}),
|
||||
],
|
||||
)
|
||||
if not isinstance(result, tuple) or len(result) < 1:
|
||||
raise ValueError("CLIPLoader returned an unexpected result.")
|
||||
return result[0]
|
||||
|
||||
def _load_unet(self, relative_name: str):
|
||||
loader = UNETLoader()
|
||||
result = self._call_first_available(
|
||||
loader,
|
||||
[
|
||||
("load_unet", {"unet_name": relative_name}),
|
||||
("load_model", {"unet_name": relative_name}),
|
||||
],
|
||||
)
|
||||
if not isinstance(result, tuple) or len(result) < 1:
|
||||
raise ValueError("UNETLoader returned an unexpected result.")
|
||||
return result[0]
|
||||
|
||||
def _load_controlnet(self, relative_name: str):
|
||||
loader = ControlNetLoader()
|
||||
result = self._call_first_available(
|
||||
loader,
|
||||
[
|
||||
("load_controlnet", {"control_net_name": relative_name}),
|
||||
("load_controlnet", {"controlnet_name": relative_name}),
|
||||
],
|
||||
)
|
||||
if not isinstance(result, tuple) or len(result) < 1:
|
||||
raise ValueError("ControlNetLoader returned an unexpected result.")
|
||||
return result[0]
|
||||
|
||||
def _load_clip_vision(self, relative_name: str):
|
||||
loader = CLIPVisionLoader()
|
||||
result = self._call_first_available(
|
||||
loader,
|
||||
[
|
||||
("load_clip", {"clip_name": relative_name}),
|
||||
("load_clip_vision", {"clip_name": relative_name}),
|
||||
("load_clip_vision", {"clip_vision_name": relative_name}),
|
||||
],
|
||||
)
|
||||
if not isinstance(result, tuple) or len(result) < 1:
|
||||
raise ValueError("CLIPVisionLoader returned an unexpected result.")
|
||||
return result[0]
|
||||
|
||||
def _load_upscale_model(self, relative_name: str):
|
||||
loader = UpscaleModelLoader()
|
||||
result = self._call_first_available(
|
||||
loader,
|
||||
[
|
||||
("load_model", {"model_name": relative_name}),
|
||||
("load_model", {"upscale_model": relative_name}),
|
||||
],
|
||||
)
|
||||
if not isinstance(result, tuple) or len(result) < 1:
|
||||
raise ValueError("UpscaleModelLoader returned an unexpected result.")
|
||||
return result[0]
|
||||
|
||||
def _load_style_model(self, relative_name: str):
|
||||
loader = StyleModelLoader()
|
||||
result = self._call_first_available(
|
||||
loader,
|
||||
[
|
||||
("load_style_model", {"style_model_name": relative_name}),
|
||||
("load_model", {"style_model_name": relative_name}),
|
||||
("load_model", {"model_name": relative_name}),
|
||||
],
|
||||
)
|
||||
if not isinstance(result, tuple) or len(result) < 1:
|
||||
raise ValueError("StyleModelLoader returned an unexpected result.")
|
||||
return result[0]
|
||||
|
||||
def adapt(
|
||||
self,
|
||||
full_path: str,
|
||||
model_info_json: str = "",
|
||||
clip_type: str = "stable_diffusion",
|
||||
):
|
||||
model_obj, clip_obj, vae_obj, unet_obj, controlnet_obj, clipvision_obj, upscale_obj = self._none_outputs()
|
||||
style_obj = None
|
||||
|
||||
path = self._normalize_path(full_path)
|
||||
if path is None or not path.exists() or not path.is_file():
|
||||
return (
|
||||
model_obj,
|
||||
clip_obj,
|
||||
vae_obj,
|
||||
unet_obj,
|
||||
controlnet_obj,
|
||||
clipvision_obj,
|
||||
upscale_obj,
|
||||
style_obj,
|
||||
"Invalid or missing model path.",
|
||||
False,
|
||||
"unknown",
|
||||
"",
|
||||
)
|
||||
|
||||
model_info = self._safe_parse_model_info(model_info_json)
|
||||
model_type = self._resolve_model_type(path, model_info)
|
||||
built_in_loader_name = self.BUILTIN_BY_MODEL_TYPE.get(model_type, "")
|
||||
|
||||
if model_type not in self.SUPPORTED_MODEL_TYPES:
|
||||
return (
|
||||
model_obj,
|
||||
clip_obj,
|
||||
vae_obj,
|
||||
unet_obj,
|
||||
controlnet_obj,
|
||||
clipvision_obj,
|
||||
upscale_obj,
|
||||
style_obj,
|
||||
(
|
||||
f"No compatible built-in Comfy loader for model type "
|
||||
f"'{model_type}'. Path: {path}"
|
||||
),
|
||||
False,
|
||||
model_type,
|
||||
built_in_loader_name,
|
||||
)
|
||||
|
||||
folder_type = self.MODEL_TYPE_TO_FOLDER[model_type]
|
||||
|
||||
try:
|
||||
relative_name = self._relative_name_for_folder_type(path, folder_type)
|
||||
|
||||
if model_type == "checkpoint":
|
||||
model_obj, clip_obj, vae_obj = self._load_checkpoint(relative_name)
|
||||
elif model_type == "vae":
|
||||
vae_obj = self._load_vae(relative_name)
|
||||
elif model_type in {"clip", "text_encoder"}:
|
||||
clip_obj = self._load_clip(relative_name, clip_type)
|
||||
elif model_type in {"unet", "diffusion"}:
|
||||
unet_obj = self._load_unet(relative_name)
|
||||
elif model_type == "controlnet":
|
||||
controlnet_obj = self._load_controlnet(relative_name)
|
||||
elif model_type == "clip_vision":
|
||||
clipvision_obj = self._load_clip_vision(relative_name)
|
||||
elif model_type == "upscaler":
|
||||
upscale_obj = self._load_upscale_model(relative_name)
|
||||
elif model_type == "style_model":
|
||||
style_obj = self._load_style_model(relative_name)
|
||||
|
||||
status_text = (
|
||||
f"Loaded with built-in loader '{built_in_loader_name}'.\n"
|
||||
f"Model type: {model_type}\n"
|
||||
f"Folder type: {folder_type}\n"
|
||||
f"Relative name: {relative_name}\n"
|
||||
f"Path: {path}"
|
||||
)
|
||||
|
||||
return (
|
||||
model_obj,
|
||||
clip_obj,
|
||||
vae_obj,
|
||||
unet_obj,
|
||||
controlnet_obj,
|
||||
clipvision_obj,
|
||||
upscale_obj,
|
||||
style_obj,
|
||||
status_text,
|
||||
True,
|
||||
model_type,
|
||||
built_in_loader_name,
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
return (
|
||||
model_obj,
|
||||
clip_obj,
|
||||
vae_obj,
|
||||
unet_obj,
|
||||
controlnet_obj,
|
||||
clipvision_obj,
|
||||
upscale_obj,
|
||||
style_obj,
|
||||
(
|
||||
f"Built-in loader adapter failed.\n"
|
||||
f"Model type: {model_type}\n"
|
||||
f"Built-in loader: {built_in_loader_name}\n"
|
||||
f"Path: {path}\n"
|
||||
f"Error: {exc}"
|
||||
),
|
||||
False,
|
||||
model_type,
|
||||
built_in_loader_name,
|
||||
)
|
||||
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
|
||||
class ZMongoModelCompatibilityDisplayNode:
|
||||
CATEGORY = "ZMongo/Models"
|
||||
FUNCTION = "describe_compatibility"
|
||||
RETURN_TYPES = (
|
||||
"BOOLEAN", # is_compatible
|
||||
"STRING", # status_text
|
||||
"STRING", # compatible_targets
|
||||
"STRING", # compatible_sources
|
||||
"STRING", # selected_model_type
|
||||
"STRING", # selected_architecture
|
||||
)
|
||||
RETURN_NAMES = (
|
||||
"is_compatible",
|
||||
"status_text",
|
||||
"compatible_targets",
|
||||
"compatible_sources",
|
||||
"selected_model_type",
|
||||
"selected_architecture",
|
||||
)
|
||||
|
||||
EXPECTED_NODE_TO_MODEL_TYPES: Dict[str, List[str]] = {
|
||||
"ZMongoLLMLoaderNode": ["llm"],
|
||||
"ZMongoPromptRunnerNode": ["llm"],
|
||||
"ZMongoChatModelNode": ["llm"],
|
||||
|
||||
"ZMongoEmbeddingLoaderNode": ["embedding", "text_encoder", "onnx_model"],
|
||||
"ZMongoVectorIndexNode": ["embedding"],
|
||||
"ZMongoEmbeddingInferenceNode": ["embedding", "text_encoder", "onnx_model"],
|
||||
|
||||
"ZMongoCLIPLoaderNode": ["clip", "onnx_model"],
|
||||
"ZMongoCLIPVisionLoaderNode": ["clip_vision", "onnx_model"],
|
||||
"ZMongoVisionInferenceNode": ["clip", "clip_vision", "onnx_model", "vision"],
|
||||
|
||||
"ZMongoCheckpointLoaderNode": ["checkpoint", "torch_model"],
|
||||
"ZMongoTorchLoaderNode": ["torch_model", "checkpoint"],
|
||||
"ZMongoTorchInferenceNode": ["torch_model", "checkpoint", "diffusion"],
|
||||
|
||||
"ZMongoVAELoaderNode": ["vae"],
|
||||
"ZMongoDiffusionLoaderNode": ["diffusion", "checkpoint"],
|
||||
"ZMongoControlNetLoaderNode": ["controlnet"],
|
||||
"ZMongoUpscaleLoaderNode": ["upscaler"],
|
||||
"ZMongoLatentUpscaleLoaderNode": ["latent_upscaler"],
|
||||
|
||||
"ZMongoAudioLoaderNode": ["audio_model"],
|
||||
"ZMongoAudioInferenceNode": ["audio_model"],
|
||||
"ZMongoSpeechLoaderNode": ["speech_model"],
|
||||
"ZMongoSpeechInferenceNode": ["speech_model"],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"model_info_json": ("STRING", {"forceInput": True}),
|
||||
"expected_target_node": (sorted(list(cls.EXPECTED_NODE_TO_MODEL_TYPES.keys())),),
|
||||
}
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, model_info_json: str, expected_target_node: str):
|
||||
return model_info_json, expected_target_node
|
||||
|
||||
@staticmethod
|
||||
def _safe_load_model_info(model_info_json: str) -> dict:
|
||||
try:
|
||||
value = json.loads(model_info_json)
|
||||
return value if isinstance(value, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _csv(items: List[str]) -> str:
|
||||
return ", ".join(items) if items else "None"
|
||||
|
||||
def describe_compatibility(self, model_info_json: str, expected_target_node: str):
|
||||
model_info = self._safe_load_model_info(model_info_json)
|
||||
|
||||
selected_model_type = str(model_info.get("model_type", "unknown"))
|
||||
selected_architecture = str(model_info.get("architecture_family", "unknown"))
|
||||
compatible_targets = list(model_info.get("can_connect_to", []))
|
||||
compatible_sources = list(model_info.get("can_receive_from", []))
|
||||
|
||||
expected_types = self.EXPECTED_NODE_TO_MODEL_TYPES.get(expected_target_node, [])
|
||||
is_compatible = selected_model_type in expected_types
|
||||
|
||||
if is_compatible:
|
||||
status_text = (
|
||||
f"Compatible: model_type '{selected_model_type}' can connect to "
|
||||
f"'{expected_target_node}'."
|
||||
)
|
||||
else:
|
||||
status_text = (
|
||||
f"Incompatible: model_type '{selected_model_type}' does not match "
|
||||
f"'{expected_target_node}'. Expected one of: {self._csv(expected_types)}."
|
||||
)
|
||||
|
||||
status_text += (
|
||||
f"\nSelected architecture: {selected_architecture}"
|
||||
f"\nCan connect to: {self._csv(compatible_targets)}"
|
||||
f"\nCan receive from: {self._csv(compatible_sources)}"
|
||||
)
|
||||
|
||||
return (
|
||||
is_compatible,
|
||||
status_text,
|
||||
self._csv(compatible_targets),
|
||||
self._csv(compatible_sources),
|
||||
selected_model_type,
|
||||
selected_architecture,
|
||||
)
|
||||
@@ -0,0 +1,395 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
|
||||
class ZMongoModelIntrospectorNode:
|
||||
CATEGORY = "ZMongo/Models"
|
||||
FUNCTION = "inspect_model"
|
||||
RETURN_TYPES = (
|
||||
"STRING", # model_info_json
|
||||
"STRING", # summary_text
|
||||
"STRING", # model_type
|
||||
"STRING", # architecture_family
|
||||
"STRING", # can_connect_to
|
||||
"STRING", # can_receive_from
|
||||
"STRING", # full_path
|
||||
)
|
||||
RETURN_NAMES = (
|
||||
"model_info_json",
|
||||
"summary_text",
|
||||
"model_type",
|
||||
"architecture_family",
|
||||
"can_connect_to",
|
||||
"can_receive_from",
|
||||
"full_path",
|
||||
)
|
||||
|
||||
FOLDER_TYPE_HINTS: Dict[str, Tuple[str, str]] = {
|
||||
"llm": ("llm", "llama_cpp"),
|
||||
"embeddings": ("embedding", "embedding_loader"),
|
||||
"encoders": ("embedding", "embedding_loader"),
|
||||
"clip": ("clip", "clip_loader"),
|
||||
"clip_vision": ("clip_vision", "clip_vision_loader"),
|
||||
"vae": ("vae", "vae_loader"),
|
||||
"unet": ("unet", "torch_loader"),
|
||||
"checkpoints": ("checkpoint", "checkpoint_loader"),
|
||||
"diffusion_models": ("diffusion", "diffusion_loader"),
|
||||
"loras": ("lora", "lora_loader"),
|
||||
"controlnet": ("controlnet", "controlnet_loader"),
|
||||
"upscale_models": ("upscaler", "upscale_loader"),
|
||||
"latent_upscale_models": ("latent_upscaler", "latent_upscale_loader"),
|
||||
"onnx": ("onnx_model", "onnxruntime"),
|
||||
"vision": ("vision", "vision_loader"),
|
||||
"audio": ("audio_model", "audio_loader"),
|
||||
"speech_models": ("speech_model", "audio_loader"),
|
||||
"text_encoders": ("text_encoder", "text_encoder_loader"),
|
||||
}
|
||||
|
||||
EXTENSION_HINTS: Dict[str, Tuple[str, str]] = {
|
||||
".gguf": ("llm", "llama_cpp"),
|
||||
".onnx": ("onnx_model", "onnxruntime"),
|
||||
".safetensors": ("torch_model", "torch_loader"),
|
||||
".ckpt": ("checkpoint", "checkpoint_loader"),
|
||||
".pt": ("torch_model", "torch_loader"),
|
||||
".pth": ("torch_model", "torch_loader"),
|
||||
".bin": ("binary_model", "custom_loader"),
|
||||
".json": ("config", "config_parser"),
|
||||
".yaml": ("config", "config_parser"),
|
||||
".yml": ("config", "config_parser"),
|
||||
".model": ("generic_model", "custom_loader"),
|
||||
".index": ("index", "index_loader"),
|
||||
}
|
||||
|
||||
ARCHITECTURE_HINTS = {
|
||||
"bge": "bge",
|
||||
"e5": "e5",
|
||||
"gte": "gte",
|
||||
"clip": "clip",
|
||||
"siglip": "siglip",
|
||||
"llama": "llama",
|
||||
"mistral": "mistral",
|
||||
"qwen": "qwen",
|
||||
"gemma": "gemma",
|
||||
"bert": "bert",
|
||||
"t5": "t5",
|
||||
"whisper": "whisper",
|
||||
}
|
||||
|
||||
COMPATIBILITY_MAP: Dict[str, Dict[str, List[str]]] = {
|
||||
"llm": {
|
||||
"can_connect_to": [
|
||||
"ZMongoLLMLoaderNode",
|
||||
"ZMongoPromptRunnerNode",
|
||||
"ZMongoChatModelNode",
|
||||
],
|
||||
"can_receive_from": [
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoModelTypeRouterNode",
|
||||
],
|
||||
},
|
||||
"embedding": {
|
||||
"can_connect_to": [
|
||||
"ZMongoEmbeddingLoaderNode",
|
||||
"ZMongoVectorIndexNode",
|
||||
"ZMongoEmbeddingInferenceNode",
|
||||
],
|
||||
"can_receive_from": [
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoModelTypeRouterNode",
|
||||
],
|
||||
},
|
||||
"clip": {
|
||||
"can_connect_to": [
|
||||
"ZMongoCLIPLoaderNode",
|
||||
"ZMongoVisionInferenceNode",
|
||||
],
|
||||
"can_receive_from": [
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoModelTypeRouterNode",
|
||||
],
|
||||
},
|
||||
"clip_vision": {
|
||||
"can_connect_to": [
|
||||
"ZMongoCLIPVisionLoaderNode",
|
||||
"ZMongoVisionInferenceNode",
|
||||
],
|
||||
"can_receive_from": [
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoModelTypeRouterNode",
|
||||
],
|
||||
},
|
||||
"onnx_model": {
|
||||
"can_connect_to": [
|
||||
"ZMongoONNXLoaderNode",
|
||||
"ZMongoVisionInferenceNode",
|
||||
"ZMongoEmbeddingInferenceNode",
|
||||
],
|
||||
"can_receive_from": [
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoModelTypeRouterNode",
|
||||
],
|
||||
},
|
||||
"checkpoint": {
|
||||
"can_connect_to": [
|
||||
"ZMongoCheckpointLoaderNode",
|
||||
"ZMongoTorchInferenceNode",
|
||||
],
|
||||
"can_receive_from": [
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoModelTypeRouterNode",
|
||||
],
|
||||
},
|
||||
"torch_model": {
|
||||
"can_connect_to": [
|
||||
"ZMongoTorchLoaderNode",
|
||||
"ZMongoTorchInferenceNode",
|
||||
],
|
||||
"can_receive_from": [
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoModelTypeRouterNode",
|
||||
],
|
||||
},
|
||||
"text_encoder": {
|
||||
"can_connect_to": [
|
||||
"ZMongoTextEncoderLoaderNode",
|
||||
"ZMongoEmbeddingInferenceNode",
|
||||
],
|
||||
"can_receive_from": [
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoModelTypeRouterNode",
|
||||
],
|
||||
},
|
||||
"vae": {
|
||||
"can_connect_to": [
|
||||
"ZMongoVAELoaderNode",
|
||||
],
|
||||
"can_receive_from": [
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoModelTypeRouterNode",
|
||||
],
|
||||
},
|
||||
"diffusion": {
|
||||
"can_connect_to": [
|
||||
"ZMongoDiffusionLoaderNode",
|
||||
],
|
||||
"can_receive_from": [
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoModelTypeRouterNode",
|
||||
],
|
||||
},
|
||||
"controlnet": {
|
||||
"can_connect_to": [
|
||||
"ZMongoControlNetLoaderNode",
|
||||
],
|
||||
"can_receive_from": [
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoModelTypeRouterNode",
|
||||
],
|
||||
},
|
||||
"upscaler": {
|
||||
"can_connect_to": [
|
||||
"ZMongoUpscaleLoaderNode",
|
||||
],
|
||||
"can_receive_from": [
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoModelTypeRouterNode",
|
||||
],
|
||||
},
|
||||
"latent_upscaler": {
|
||||
"can_connect_to": [
|
||||
"ZMongoLatentUpscaleLoaderNode",
|
||||
],
|
||||
"can_receive_from": [
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoModelTypeRouterNode",
|
||||
],
|
||||
},
|
||||
"audio_model": {
|
||||
"can_connect_to": [
|
||||
"ZMongoAudioLoaderNode",
|
||||
"ZMongoAudioInferenceNode",
|
||||
],
|
||||
"can_receive_from": [
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoModelTypeRouterNode",
|
||||
],
|
||||
},
|
||||
"speech_model": {
|
||||
"can_connect_to": [
|
||||
"ZMongoSpeechLoaderNode",
|
||||
"ZMongoSpeechInferenceNode",
|
||||
],
|
||||
"can_receive_from": [
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoModelTypeRouterNode",
|
||||
],
|
||||
},
|
||||
"unknown": {
|
||||
"can_connect_to": [],
|
||||
"can_receive_from": [
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoModelTypeRouterNode",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"full_path": ("STRING", {"forceInput": True}),
|
||||
}
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, full_path: str):
|
||||
return full_path
|
||||
|
||||
@staticmethod
|
||||
def _normalize_path(full_path: str) -> Path | None:
|
||||
if not full_path:
|
||||
return None
|
||||
try:
|
||||
return Path(full_path).expanduser().resolve()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _folder_hint(cls, path: Path) -> Tuple[str, str] | None:
|
||||
for part in reversed([p.lower() for p in path.parts]):
|
||||
if part in cls.FOLDER_TYPE_HINTS:
|
||||
return cls.FOLDER_TYPE_HINTS[part]
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _extension_hint(cls, path: Path) -> Tuple[str, str] | None:
|
||||
return cls.EXTENSION_HINTS.get(path.suffix.lower())
|
||||
|
||||
@classmethod
|
||||
def _architecture_family(cls, path: Path) -> str:
|
||||
haystack = " ".join([part.lower() for part in path.parts])
|
||||
for token, family in cls.ARCHITECTURE_HINTS.items():
|
||||
if token in haystack:
|
||||
return family
|
||||
return "unknown"
|
||||
|
||||
@classmethod
|
||||
def _classify(cls, path: Path) -> Tuple[str, str]:
|
||||
ext = path.suffix.lower()
|
||||
folder_hint = cls._folder_hint(path)
|
||||
ext_hint = cls._extension_hint(path)
|
||||
|
||||
if ext == ".gguf":
|
||||
return "llm", "llama_cpp"
|
||||
if ext == ".onnx":
|
||||
return "onnx_model", "onnxruntime"
|
||||
if ext in {".json", ".yaml", ".yml"}:
|
||||
if folder_hint:
|
||||
return f"config_for_{folder_hint[0]}", folder_hint[1]
|
||||
return "config", "config_parser"
|
||||
if ext in {".safetensors", ".pt", ".pth", ".ckpt", ".bin", ".model"}:
|
||||
if folder_hint:
|
||||
return folder_hint
|
||||
if ext_hint:
|
||||
return ext_hint
|
||||
if folder_hint:
|
||||
return folder_hint
|
||||
if ext_hint:
|
||||
return ext_hint
|
||||
return "unknown", "manual_loader_required"
|
||||
|
||||
@staticmethod
|
||||
def _find_sidecar_files(path: Path) -> List[str]:
|
||||
candidates = [
|
||||
"config.json",
|
||||
"tokenizer.json",
|
||||
"tokenizer_config.json",
|
||||
"model_index.json",
|
||||
"params.json",
|
||||
"metadata.json",
|
||||
"config.yaml",
|
||||
"config.yml",
|
||||
]
|
||||
found: List[str] = []
|
||||
for candidate in candidates:
|
||||
candidate_path = path.parent / candidate
|
||||
if candidate_path.exists() and candidate_path.is_file():
|
||||
found.append(str(candidate_path))
|
||||
return found
|
||||
|
||||
@staticmethod
|
||||
def _format_list(items: List[str]) -> str:
|
||||
return ", ".join(items) if items else "None"
|
||||
|
||||
def inspect_model(self, full_path: str):
|
||||
path = self._normalize_path(full_path)
|
||||
if path is None or not path.exists() or not path.is_file():
|
||||
model_info = {
|
||||
"full_path": "",
|
||||
"model_type": "unknown",
|
||||
"loader_hint": "invalid_path",
|
||||
"architecture_family": "unknown",
|
||||
"filename": "",
|
||||
"extension": "",
|
||||
"parent_folder": "",
|
||||
"sidecar_files": [],
|
||||
"can_connect_to": [],
|
||||
"can_receive_from": [],
|
||||
}
|
||||
summary = "Invalid or missing model path."
|
||||
return (
|
||||
json.dumps(model_info, indent=2),
|
||||
summary,
|
||||
"unknown",
|
||||
"unknown",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
)
|
||||
|
||||
model_type, loader_hint = self._classify(path)
|
||||
architecture_family = self._architecture_family(path)
|
||||
compatibility = self.COMPATIBILITY_MAP.get(model_type, self.COMPATIBILITY_MAP["unknown"])
|
||||
can_connect_to = compatibility["can_connect_to"]
|
||||
can_receive_from = compatibility["can_receive_from"]
|
||||
sidecar_files = self._find_sidecar_files(path)
|
||||
|
||||
model_info = {
|
||||
"full_path": str(path),
|
||||
"model_type": model_type,
|
||||
"loader_hint": loader_hint,
|
||||
"architecture_family": architecture_family,
|
||||
"filename": path.name,
|
||||
"stem": path.stem,
|
||||
"extension": path.suffix.lower(),
|
||||
"parent_folder": path.parent.name,
|
||||
"sidecar_files": sidecar_files,
|
||||
"can_connect_to": can_connect_to,
|
||||
"can_receive_from": can_receive_from,
|
||||
}
|
||||
|
||||
summary_lines = [
|
||||
f"Model: {path.name}",
|
||||
f"Type: {model_type}",
|
||||
f"Architecture: {architecture_family}",
|
||||
f"Loader hint: {loader_hint}",
|
||||
f"Extension: {path.suffix.lower()}",
|
||||
f"Folder: {path.parent.name}",
|
||||
f"Can connect to: {self._format_list(can_connect_to)}",
|
||||
f"Can receive from: {self._format_list(can_receive_from)}",
|
||||
f"Sidecar files: {self._format_list(sidecar_files)}",
|
||||
]
|
||||
summary_text = "\n".join(summary_lines)
|
||||
|
||||
return (
|
||||
json.dumps(model_info, indent=2),
|
||||
summary_text,
|
||||
model_type,
|
||||
architecture_family,
|
||||
self._format_list(can_connect_to),
|
||||
self._format_list(can_receive_from),
|
||||
str(path),
|
||||
)
|
||||
@@ -0,0 +1,222 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
|
||||
class ZMongoUniversalModelAdapterNode:
|
||||
"""
|
||||
Universal adapter that consumes ZMongo model metadata and exposes a fixed
|
||||
set of outputs. It uses smart-check compatibility metadata to decide which
|
||||
outputs are active.
|
||||
|
||||
Important:
|
||||
- Output socket types remain statically declared for ComfyUI.
|
||||
- Inactive outputs are returned as None and should be treated as disabled.
|
||||
- A companion JS extension can visually dim inactive sockets/widgets.
|
||||
"""
|
||||
|
||||
CATEGORY = "ZMongo/Models"
|
||||
FUNCTION = "adapt"
|
||||
|
||||
RETURN_TYPES = (
|
||||
"MODEL",
|
||||
"CLIP",
|
||||
"VAE",
|
||||
"STRING",
|
||||
"STRING",
|
||||
"BOOLEAN",
|
||||
"STRING",
|
||||
)
|
||||
RETURN_NAMES = (
|
||||
"model",
|
||||
"clip",
|
||||
"vae",
|
||||
"status_text",
|
||||
"active_outputs_json",
|
||||
"is_compatible",
|
||||
"model_type",
|
||||
)
|
||||
|
||||
# Which outputs are allowed for each detected model type.
|
||||
OUTPUTS_BY_MODEL_TYPE: Dict[str, List[str]] = {
|
||||
"checkpoint": ["model", "clip", "vae"],
|
||||
"torch_model": ["model"],
|
||||
"diffusion": ["model"],
|
||||
"controlnet": ["model"],
|
||||
"upscaler": ["model"],
|
||||
"latent_upscaler": ["model"],
|
||||
"unet": ["model"],
|
||||
"clip": ["clip"],
|
||||
"clip_vision": ["clip"],
|
||||
"vae": ["vae"],
|
||||
"llm": [],
|
||||
"embedding": [],
|
||||
"text_encoder": [],
|
||||
"onnx_model": [],
|
||||
"audio_model": [],
|
||||
"speech_model": [],
|
||||
"unknown": [],
|
||||
}
|
||||
|
||||
# Which target families the selected model is allowed to drive.
|
||||
TARGETS_BY_MODEL_TYPE: Dict[str, List[str]] = {
|
||||
"checkpoint": ["MODEL", "CLIP", "VAE"],
|
||||
"torch_model": ["MODEL"],
|
||||
"diffusion": ["MODEL"],
|
||||
"controlnet": ["MODEL"],
|
||||
"upscaler": ["MODEL"],
|
||||
"latent_upscaler": ["MODEL"],
|
||||
"unet": ["MODEL"],
|
||||
"clip": ["CLIP"],
|
||||
"clip_vision": ["CLIP"],
|
||||
"vae": ["VAE"],
|
||||
"llm": [],
|
||||
"embedding": [],
|
||||
"text_encoder": [],
|
||||
"onnx_model": [],
|
||||
"audio_model": [],
|
||||
"speech_model": [],
|
||||
"unknown": [],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"zmongo_model_json": ("STRING", {"forceInput": True}),
|
||||
},
|
||||
"optional": {
|
||||
"expected_output_family": (
|
||||
["AUTO", "MODEL", "CLIP", "VAE"],
|
||||
{"default": "AUTO"},
|
||||
),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, zmongo_model_json: str, expected_output_family: str = "AUTO", unique_id=None):
|
||||
return (zmongo_model_json, expected_output_family, unique_id)
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, input_types):
|
||||
# Keep backend validation permissive here; real validation happens after
|
||||
# parsing the metadata payload because the smart-check logic depends on
|
||||
# model metadata, not just the declared socket type.
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _safe_parse(payload: str) -> Dict[str, Any]:
|
||||
try:
|
||||
data = json.loads(payload)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def _compute_active_outputs(
|
||||
cls,
|
||||
model_type: str,
|
||||
expected_output_family: str,
|
||||
) -> Tuple[List[str], bool, str]:
|
||||
allowed = cls.OUTPUTS_BY_MODEL_TYPE.get(model_type, [])
|
||||
allowed_families = cls.TARGETS_BY_MODEL_TYPE.get(model_type, [])
|
||||
|
||||
if expected_output_family == "AUTO":
|
||||
return allowed, bool(allowed), "AUTO mode"
|
||||
|
||||
is_compatible = expected_output_family in allowed_families
|
||||
if not is_compatible:
|
||||
return [], False, (
|
||||
f"Model type '{model_type}' is not compatible with requested "
|
||||
f"output family '{expected_output_family}'. Allowed: "
|
||||
f"{', '.join(allowed_families) if allowed_families else 'None'}"
|
||||
)
|
||||
|
||||
# Keep only the outputs that map to the requested family.
|
||||
family_to_outputs = {
|
||||
"MODEL": ["model"],
|
||||
"CLIP": ["clip"],
|
||||
"VAE": ["vae"],
|
||||
}
|
||||
requested_outputs = family_to_outputs.get(expected_output_family, [])
|
||||
active = [name for name in allowed if name in requested_outputs]
|
||||
return active, True, f"Compatible with requested family '{expected_output_family}'"
|
||||
|
||||
@staticmethod
|
||||
def _status_text(
|
||||
model_type: str,
|
||||
architecture: str,
|
||||
full_path: str,
|
||||
active_outputs: List[str],
|
||||
compatible: bool,
|
||||
reason: str,
|
||||
) -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
f"Model type: {model_type}",
|
||||
f"Architecture: {architecture}",
|
||||
f"Compatible: {compatible}",
|
||||
f"Active outputs: {', '.join(active_outputs) if active_outputs else 'None'}",
|
||||
f"Reason: {reason}",
|
||||
f"Path: {full_path}",
|
||||
]
|
||||
)
|
||||
|
||||
def adapt(
|
||||
self,
|
||||
zmongo_model_json: str,
|
||||
expected_output_family: str = "AUTO",
|
||||
unique_id: str | None = None,
|
||||
):
|
||||
del unique_id
|
||||
|
||||
payload = self._safe_parse(zmongo_model_json)
|
||||
model_type = str(payload.get("model_type", "unknown"))
|
||||
architecture = str(payload.get("architecture_family", "unknown"))
|
||||
full_path = str(payload.get("full_path", ""))
|
||||
|
||||
active_outputs, is_compatible, reason = self._compute_active_outputs(
|
||||
model_type=model_type,
|
||||
expected_output_family=expected_output_family,
|
||||
)
|
||||
|
||||
# Deliberately conservative:
|
||||
# We do not fabricate real Comfy MODEL/CLIP/VAE objects here.
|
||||
# Those should be created by specialized loader/adapter nodes.
|
||||
model_obj = None
|
||||
clip_obj = None
|
||||
vae_obj = None
|
||||
|
||||
status_text = self._status_text(
|
||||
model_type=model_type,
|
||||
architecture=architecture,
|
||||
full_path=full_path,
|
||||
active_outputs=active_outputs,
|
||||
compatible=is_compatible,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
active_outputs_json = json.dumps(
|
||||
{
|
||||
"active_outputs": active_outputs,
|
||||
"expected_output_family": expected_output_family,
|
||||
"model_type": model_type,
|
||||
"architecture_family": architecture,
|
||||
"compatible": is_compatible,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
return (
|
||||
model_obj if "model" in active_outputs else None,
|
||||
clip_obj if "clip" in active_outputs else None,
|
||||
vae_obj if "vae" in active_outputs else None,
|
||||
status_text,
|
||||
active_outputs_json,
|
||||
is_compatible,
|
||||
model_type,
|
||||
)
|
||||
@@ -0,0 +1,378 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
class ZMongoUniversalModelLoaderNode:
|
||||
"""
|
||||
Universal model loader facade for ZMongo.
|
||||
|
||||
This node does not try to fully instantiate every possible model backend.
|
||||
Instead, it:
|
||||
|
||||
1. Accepts a selected model path.
|
||||
2. Classifies the model.
|
||||
3. Returns a universal internal payload (JSON string).
|
||||
4. Activates only the outputs that make sense for the selected model.
|
||||
5. Exposes status / compatibility information for downstream nodes.
|
||||
|
||||
Important:
|
||||
- Comfy output socket types are static.
|
||||
- So this node declares a fixed set of outputs and populates only the valid ones.
|
||||
- Core Comfy outputs such as MODEL / CLIP / VAE are only populated when the
|
||||
selected file appears compatible with those families.
|
||||
"""
|
||||
|
||||
CATEGORY = "ZMongo/Models"
|
||||
FUNCTION = "load_model"
|
||||
|
||||
RETURN_TYPES = (
|
||||
"STRING", # zmongo_model_json
|
||||
"MODEL", # model
|
||||
"CLIP", # clip
|
||||
"VAE", # vae
|
||||
"STRING", # model_info_json
|
||||
"STRING", # status_text
|
||||
"BOOLEAN", # is_valid
|
||||
"STRING", # model_type
|
||||
"STRING", # allowed_outputs
|
||||
)
|
||||
RETURN_NAMES = (
|
||||
"zmongo_model_json",
|
||||
"model",
|
||||
"clip",
|
||||
"vae",
|
||||
"model_info_json",
|
||||
"status_text",
|
||||
"is_valid",
|
||||
"model_type",
|
||||
"allowed_outputs",
|
||||
)
|
||||
|
||||
FOLDER_TYPE_HINTS: Dict[str, Tuple[str, str]] = {
|
||||
"llm": ("llm", "llama_cpp"),
|
||||
"embeddings": ("embedding", "embedding_loader"),
|
||||
"encoders": ("embedding", "embedding_loader"),
|
||||
"clip": ("clip", "clip_loader"),
|
||||
"clip_vision": ("clip_vision", "clip_vision_loader"),
|
||||
"vae": ("vae", "vae_loader"),
|
||||
"unet": ("unet", "torch_loader"),
|
||||
"checkpoints": ("checkpoint", "checkpoint_loader"),
|
||||
"diffusion_models": ("diffusion", "diffusion_loader"),
|
||||
"loras": ("lora", "lora_loader"),
|
||||
"controlnet": ("controlnet", "controlnet_loader"),
|
||||
"upscale_models": ("upscaler", "upscale_loader"),
|
||||
"latent_upscale_models": ("latent_upscaler", "latent_upscale_loader"),
|
||||
"onnx": ("onnx_model", "onnxruntime"),
|
||||
"vision": ("vision", "vision_loader"),
|
||||
"audio": ("audio_model", "audio_loader"),
|
||||
"speech_models": ("speech_model", "audio_loader"),
|
||||
"text_encoders": ("text_encoder", "text_encoder_loader"),
|
||||
}
|
||||
|
||||
EXTENSION_HINTS: Dict[str, Tuple[str, str]] = {
|
||||
".gguf": ("llm", "llama_cpp"),
|
||||
".onnx": ("onnx_model", "onnxruntime"),
|
||||
".safetensors": ("torch_model", "torch_loader"),
|
||||
".ckpt": ("checkpoint", "checkpoint_loader"),
|
||||
".pt": ("torch_model", "torch_loader"),
|
||||
".pth": ("torch_model", "torch_loader"),
|
||||
".bin": ("binary_model", "custom_loader"),
|
||||
".json": ("config", "config_parser"),
|
||||
".yaml": ("config", "config_parser"),
|
||||
".yml": ("config", "config_parser"),
|
||||
".model": ("generic_model", "custom_loader"),
|
||||
".index": ("index", "index_loader"),
|
||||
}
|
||||
|
||||
ARCHITECTURE_HINTS = {
|
||||
"bge": "bge",
|
||||
"e5": "e5",
|
||||
"gte": "gte",
|
||||
"clip": "clip",
|
||||
"siglip": "siglip",
|
||||
"llama": "llama",
|
||||
"mistral": "mistral",
|
||||
"qwen": "qwen",
|
||||
"gemma": "gemma",
|
||||
"bert": "bert",
|
||||
"t5": "t5",
|
||||
"whisper": "whisper",
|
||||
"sd": "stable_diffusion",
|
||||
"flux": "flux",
|
||||
}
|
||||
|
||||
MODEL_OUTPUT_COMPATIBILITY: Dict[str, List[str]] = {
|
||||
"llm": ["zmongo_model_json"],
|
||||
"embedding": ["zmongo_model_json"],
|
||||
"text_encoder": ["zmongo_model_json"],
|
||||
"onnx_model": ["zmongo_model_json"],
|
||||
"audio_model": ["zmongo_model_json"],
|
||||
"speech_model": ["zmongo_model_json"],
|
||||
"clip": ["zmongo_model_json", "clip"],
|
||||
"clip_vision": ["zmongo_model_json", "clip"],
|
||||
"vae": ["zmongo_model_json", "vae"],
|
||||
"checkpoint": ["zmongo_model_json", "model", "clip", "vae"],
|
||||
"torch_model": ["zmongo_model_json", "model"],
|
||||
"diffusion": ["zmongo_model_json", "model"],
|
||||
"controlnet": ["zmongo_model_json", "model"],
|
||||
"upscaler": ["zmongo_model_json", "model"],
|
||||
"latent_upscaler": ["zmongo_model_json", "model"],
|
||||
"unet": ["zmongo_model_json", "model"],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"full_path": ("STRING", {"forceInput": True}),
|
||||
},
|
||||
"optional": {
|
||||
"model_info_json": ("STRING", {"forceInput": True}),
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, full_path: str, model_info_json: str = ""):
|
||||
return full_path, model_info_json
|
||||
|
||||
@staticmethod
|
||||
def _normalize_path(full_path: str) -> Optional[Path]:
|
||||
if not full_path:
|
||||
return None
|
||||
try:
|
||||
return Path(full_path).expanduser().resolve()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _folder_hint(cls, path: Path) -> Optional[Tuple[str, str]]:
|
||||
for part in reversed([p.lower() for p in path.parts]):
|
||||
if part in cls.FOLDER_TYPE_HINTS:
|
||||
return cls.FOLDER_TYPE_HINTS[part]
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _extension_hint(cls, path: Path) -> Optional[Tuple[str, str]]:
|
||||
return cls.EXTENSION_HINTS.get(path.suffix.lower())
|
||||
|
||||
@classmethod
|
||||
def _architecture_family(cls, path: Path) -> str:
|
||||
haystack = " ".join([part.lower() for part in path.parts])
|
||||
for token, family in cls.ARCHITECTURE_HINTS.items():
|
||||
if token in haystack:
|
||||
return family
|
||||
return "unknown"
|
||||
|
||||
@classmethod
|
||||
def _classify(cls, path: Path) -> Tuple[str, str]:
|
||||
ext = path.suffix.lower()
|
||||
folder_hint = cls._folder_hint(path)
|
||||
ext_hint = cls._extension_hint(path)
|
||||
|
||||
if ext == ".gguf":
|
||||
return "llm", "llama_cpp"
|
||||
if ext == ".onnx":
|
||||
return "onnx_model", "onnxruntime"
|
||||
if ext in {".json", ".yaml", ".yml"}:
|
||||
if folder_hint:
|
||||
return f"config_for_{folder_hint[0]}", folder_hint[1]
|
||||
return "config", "config_parser"
|
||||
if ext in {".safetensors", ".pt", ".pth", ".ckpt", ".bin", ".model"}:
|
||||
if folder_hint:
|
||||
return folder_hint
|
||||
if ext_hint:
|
||||
return ext_hint
|
||||
if folder_hint:
|
||||
return folder_hint
|
||||
if ext_hint:
|
||||
return ext_hint
|
||||
return "unknown", "manual_loader_required"
|
||||
|
||||
@staticmethod
|
||||
def _find_sidecar_files(path: Path) -> List[str]:
|
||||
candidates = [
|
||||
"config.json",
|
||||
"tokenizer.json",
|
||||
"tokenizer_config.json",
|
||||
"model_index.json",
|
||||
"params.json",
|
||||
"metadata.json",
|
||||
"config.yaml",
|
||||
"config.yml",
|
||||
]
|
||||
found: List[str] = []
|
||||
for candidate in candidates:
|
||||
candidate_path = path.parent / candidate
|
||||
if candidate_path.exists() and candidate_path.is_file():
|
||||
found.append(str(candidate_path))
|
||||
return found
|
||||
|
||||
@staticmethod
|
||||
def _try_parse_model_info(model_info_json: str) -> Dict[str, Any]:
|
||||
if not model_info_json:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(model_info_json)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def _build_model_info(cls, path: Path) -> Dict[str, Any]:
|
||||
model_type, loader_hint = cls._classify(path)
|
||||
architecture_family = cls._architecture_family(path)
|
||||
allowed_outputs = cls.MODEL_OUTPUT_COMPATIBILITY.get(model_type, ["zmongo_model_json"])
|
||||
|
||||
return {
|
||||
"full_path": str(path),
|
||||
"filename": path.name,
|
||||
"stem": path.stem,
|
||||
"extension": path.suffix.lower(),
|
||||
"parent_folder": path.parent.name,
|
||||
"model_type": model_type,
|
||||
"loader_hint": loader_hint,
|
||||
"architecture_family": architecture_family,
|
||||
"sidecar_files": cls._find_sidecar_files(path),
|
||||
"allowed_outputs": allowed_outputs,
|
||||
"exists": path.exists(),
|
||||
"is_file": path.is_file(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _make_status(model_info: Dict[str, Any]) -> str:
|
||||
allowed_outputs = model_info.get("allowed_outputs", [])
|
||||
return "\n".join(
|
||||
[
|
||||
f"Loaded: {model_info.get('filename', '')}",
|
||||
f"Type: {model_info.get('model_type', 'unknown')}",
|
||||
f"Architecture: {model_info.get('architecture_family', 'unknown')}",
|
||||
f"Loader hint: {model_info.get('loader_hint', 'unknown')}",
|
||||
f"Allowed outputs: {', '.join(allowed_outputs) if allowed_outputs else 'None'}",
|
||||
f"Path: {model_info.get('full_path', '')}",
|
||||
]
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _empty_core_outputs() -> Tuple[Any, Any, Any]:
|
||||
return None, None, None
|
||||
|
||||
def _build_zmongo_model_payload(self, model_info: Dict[str, Any]) -> str:
|
||||
payload = {
|
||||
"kind": "zmongo_model",
|
||||
"full_path": model_info.get("full_path", ""),
|
||||
"model_type": model_info.get("model_type", "unknown"),
|
||||
"architecture_family": model_info.get("architecture_family", "unknown"),
|
||||
"loader_hint": model_info.get("loader_hint", "unknown"),
|
||||
"allowed_outputs": model_info.get("allowed_outputs", []),
|
||||
"sidecar_files": model_info.get("sidecar_files", []),
|
||||
}
|
||||
return json.dumps(payload, indent=2)
|
||||
|
||||
def _activate_core_outputs(
|
||||
self,
|
||||
model_info: Dict[str, Any],
|
||||
) -> Tuple[Any, Any, Any]:
|
||||
"""
|
||||
Returns placeholders for MODEL / CLIP / VAE-compatible cases.
|
||||
|
||||
Note:
|
||||
This node intentionally does not instantiate arbitrary backend objects
|
||||
for every model family. For now:
|
||||
- checkpoint-like families can mark MODEL/CLIP/VAE as logically enabled
|
||||
- actual object creation should be delegated to adapter/loader nodes later
|
||||
"""
|
||||
model_type = model_info.get("model_type", "unknown")
|
||||
allowed_outputs = set(model_info.get("allowed_outputs", []))
|
||||
|
||||
model_obj = None
|
||||
clip_obj = None
|
||||
vae_obj = None
|
||||
|
||||
# Deliberately conservative:
|
||||
# We only advertise activation through metadata/status here.
|
||||
# Returning real core objects should be done in a dedicated adapter node.
|
||||
if model_type == "checkpoint":
|
||||
model_obj = None
|
||||
clip_obj = None
|
||||
vae_obj = None
|
||||
elif "model" in allowed_outputs:
|
||||
model_obj = None
|
||||
elif "clip" in allowed_outputs:
|
||||
clip_obj = None
|
||||
elif "vae" in allowed_outputs:
|
||||
vae_obj = None
|
||||
|
||||
return model_obj, clip_obj, vae_obj
|
||||
|
||||
def load_model(self, full_path: str, model_info_json: str = ""):
|
||||
parsed_path = self._normalize_path(full_path)
|
||||
if parsed_path is None or not parsed_path.exists() or not parsed_path.is_file():
|
||||
empty_payload = json.dumps(
|
||||
{
|
||||
"kind": "zmongo_model",
|
||||
"full_path": "",
|
||||
"model_type": "unknown",
|
||||
"architecture_family": "unknown",
|
||||
"loader_hint": "invalid_path",
|
||||
"allowed_outputs": [],
|
||||
"sidecar_files": [],
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
empty_info = json.dumps(
|
||||
{
|
||||
"full_path": "",
|
||||
"model_type": "unknown",
|
||||
"loader_hint": "invalid_path",
|
||||
"architecture_family": "unknown",
|
||||
"allowed_outputs": [],
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
return (
|
||||
empty_payload,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
empty_info,
|
||||
"Invalid or missing model path.",
|
||||
False,
|
||||
"unknown",
|
||||
"",
|
||||
)
|
||||
|
||||
provided_info = self._try_parse_model_info(model_info_json)
|
||||
if provided_info and provided_info.get("full_path") == str(parsed_path):
|
||||
model_info = provided_info
|
||||
model_info.setdefault("allowed_outputs", self.MODEL_OUTPUT_COMPATIBILITY.get(
|
||||
model_info.get("model_type", "unknown"),
|
||||
["zmongo_model_json"],
|
||||
))
|
||||
model_info.setdefault("architecture_family", self._architecture_family(parsed_path))
|
||||
model_info.setdefault("loader_hint", self._classify(parsed_path)[1])
|
||||
model_info.setdefault("sidecar_files", self._find_sidecar_files(parsed_path))
|
||||
else:
|
||||
model_info = self._build_model_info(parsed_path)
|
||||
|
||||
zmongo_model_json = self._build_zmongo_model_payload(model_info)
|
||||
model_obj, clip_obj, vae_obj = self._activate_core_outputs(model_info)
|
||||
|
||||
status_text = self._make_status(model_info)
|
||||
is_valid = model_info.get("model_type", "unknown") != "unknown"
|
||||
model_type = str(model_info.get("model_type", "unknown"))
|
||||
allowed_outputs = ", ".join(model_info.get("allowed_outputs", []))
|
||||
|
||||
return (
|
||||
zmongo_model_json,
|
||||
model_obj,
|
||||
clip_obj,
|
||||
vae_obj,
|
||||
json.dumps(model_info, indent=2),
|
||||
status_text,
|
||||
is_valid,
|
||||
model_type,
|
||||
allowed_outputs,
|
||||
)
|
||||
@@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Iterable, List, Tuple
|
||||
|
||||
import folder_paths
|
||||
|
||||
|
||||
class ZMongoUniversalModelSelectorNode:
|
||||
"""
|
||||
Universal recursive model selector for files stored under:
|
||||
|
||||
ComfyUI/models/zmongo/
|
||||
|
||||
It does not load the model itself. It only returns normalized path data so
|
||||
downstream ZMongo nodes can decide how to use the selected file.
|
||||
"""
|
||||
|
||||
CATEGORY = "ZMongo/Models"
|
||||
FUNCTION = "select_model"
|
||||
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING")
|
||||
RETURN_NAMES = ("full_path", "relative_path", "filename", "stem", "extension")
|
||||
|
||||
# File extensions commonly used by Comfy / ML / embeddings / LLM ecosystems.
|
||||
ALLOWED_EXTENSIONS = {
|
||||
".bin",
|
||||
".ckpt",
|
||||
".gguf",
|
||||
".index",
|
||||
".json",
|
||||
".mar",
|
||||
".model",
|
||||
".onnx",
|
||||
".pt",
|
||||
".pth",
|
||||
".py",
|
||||
".safetensors",
|
||||
".t7",
|
||||
".tar",
|
||||
".uf2",
|
||||
".yaml",
|
||||
".yml",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _zmongo_root(cls) -> Path:
|
||||
return Path(folder_paths.models_dir) / "checkpoints"
|
||||
|
||||
@classmethod
|
||||
def _iter_model_files(cls, root: Path) -> Iterable[Path]:
|
||||
if not root.exists() or not root.is_dir():
|
||||
return []
|
||||
|
||||
files: List[Path] = []
|
||||
for path in root.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if path.suffix.lower() not in cls.ALLOWED_EXTENSIONS:
|
||||
continue
|
||||
files.append(path)
|
||||
|
||||
files.sort(key=lambda p: str(p.relative_to(root)).lower())
|
||||
return files
|
||||
|
||||
@classmethod
|
||||
def _relative_choices(cls) -> List[str]:
|
||||
root = cls._zmongo_root()
|
||||
files = cls._iter_model_files(root)
|
||||
|
||||
choices = [str(path.relative_to(root)).replace("\\", "/") for path in files]
|
||||
|
||||
if not choices:
|
||||
choices = ["<no models found in models/zmongo>"]
|
||||
|
||||
return choices
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"model_relative_path": (cls._relative_choices(),),
|
||||
},
|
||||
"optional": {
|
||||
"refresh_token": (
|
||||
"INT",
|
||||
{
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 999999,
|
||||
"step": 1,
|
||||
"display": "number",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, model_relative_path: str, refresh_token: int = 0):
|
||||
"""
|
||||
Re-executes when selection changes or when refresh_token changes.
|
||||
This does not rebuild the combo live in the UI, but it helps rerun nodes
|
||||
after you change the filesystem and bump the token.
|
||||
"""
|
||||
return (model_relative_path, refresh_token)
|
||||
|
||||
@staticmethod
|
||||
def _empty_result() -> Tuple[str, str, str, str, str]:
|
||||
return ("", "", "", "", "")
|
||||
|
||||
def select_model(
|
||||
self,
|
||||
model_relative_path: str,
|
||||
refresh_token: int = 0,
|
||||
) -> Tuple[str, str, str, str, str]:
|
||||
del refresh_token # execution-only knob for Comfy cache / rerun behavior
|
||||
|
||||
root = self._zmongo_root()
|
||||
|
||||
if model_relative_path == "<no models found in models/zmongo>":
|
||||
return self._empty_result()
|
||||
|
||||
full_path = (root / model_relative_path).resolve()
|
||||
|
||||
if not full_path.exists() or not full_path.is_file():
|
||||
return self._empty_result()
|
||||
|
||||
return (
|
||||
str(full_path),
|
||||
model_relative_path.replace("\\", "/"),
|
||||
full_path.name,
|
||||
full_path.stem,
|
||||
full_path.suffix.lower(),
|
||||
)
|
||||
@@ -0,0 +1,213 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from ..zmongo_toolbag.zmongo import ZMongo
|
||||
from ..zmongo_toolbag.data_processor import DataProcessor
|
||||
|
||||
class ZMongoFieldSelectorNode:
|
||||
CATEGORY = "ZMongo"
|
||||
FUNCTION = "select_field"
|
||||
RETURN_TYPES = ("STRING", "STRING", "INT", "STRING", "STRING")
|
||||
RETURN_NAMES = (
|
||||
"collection_name",
|
||||
"selected_field_name",
|
||||
"selected_field_index",
|
||||
"selected_field_value",
|
||||
"flattened_field_names_json",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _get_zmongo(cls) -> ZMongo:
|
||||
return ZMongo()
|
||||
|
||||
@staticmethod
|
||||
def _clamp_index(value: int, max_len: int) -> int:
|
||||
if max_len <= 0:
|
||||
return 0
|
||||
return max(0, min(int(value), max_len - 1))
|
||||
|
||||
@staticmethod
|
||||
def _stringify_value(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, (dict, list, tuple, bool, int, float)):
|
||||
try:
|
||||
return json.dumps(
|
||||
DataProcessor.to_json_compatible(value),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
default=str,
|
||||
)
|
||||
except Exception:
|
||||
return str(value)
|
||||
return str(value)
|
||||
|
||||
@classmethod
|
||||
def get_collection_names(cls) -> List[str]:
|
||||
zmongo = None
|
||||
try:
|
||||
zmongo = cls._get_zmongo()
|
||||
result = zmongo.run_sync(zmongo.list_collections_async)
|
||||
|
||||
if not result or not getattr(result, "success", False):
|
||||
logger.warning(
|
||||
"Could not load collection names: %s",
|
||||
getattr(result, "error", None),
|
||||
)
|
||||
return ["<no_collections_found>"]
|
||||
|
||||
data = result.data or {}
|
||||
if not isinstance(data, dict):
|
||||
return ["<no_collections_found>"]
|
||||
|
||||
names = data.get("collections", [])
|
||||
if not isinstance(names, list):
|
||||
return ["<no_collections_found>"]
|
||||
|
||||
names = [str(name) for name in names if str(name).strip()]
|
||||
return names or ["<no_collections_found>"]
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("Error loading collection names: %s", exc)
|
||||
return ["<mongo_error>"]
|
||||
finally:
|
||||
if zmongo:
|
||||
try:
|
||||
zmongo.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def get_sample_document(cls, collection_name: str) -> Dict[str, Any]:
|
||||
zmongo = None
|
||||
try:
|
||||
if not collection_name or collection_name.startswith("<"):
|
||||
return {}
|
||||
|
||||
zmongo = cls._get_zmongo()
|
||||
result = zmongo.find_one(collection_name, {})
|
||||
|
||||
if not result or not getattr(result, "success", False):
|
||||
logger.warning(
|
||||
"Could not load sample document for '%s': %s",
|
||||
collection_name,
|
||||
getattr(result, "error", None),
|
||||
)
|
||||
return {}
|
||||
|
||||
doc = result.original() if hasattr(result, "original") else result.data
|
||||
return doc if isinstance(doc, dict) else {}
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"Error loading sample document for collection '%s': %s",
|
||||
collection_name,
|
||||
exc,
|
||||
)
|
||||
return {}
|
||||
finally:
|
||||
if zmongo:
|
||||
try:
|
||||
|
||||
|
||||
zmongo.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def get_flattened_field_names(cls, collection_name: str) -> List[str]:
|
||||
try:
|
||||
doc = cls.get_sample_document(collection_name)
|
||||
if not doc:
|
||||
return []
|
||||
|
||||
flattened = DataProcessor.flatten_json(doc)
|
||||
if not isinstance(flattened, dict):
|
||||
return []
|
||||
|
||||
return sorted(str(k) for k in flattened.keys())
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"Error flattening fields for collection '%s': %s",
|
||||
collection_name,
|
||||
exc,
|
||||
)
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> Dict[str, Any]:
|
||||
collections = cls.get_collection_names()
|
||||
default_collection = collections[0] if collections else "<no_collections_found>"
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"collection_name": (collections, {"default": default_collection}),
|
||||
"field_name": (["<dynamic_field_name>"], {"default": "<dynamic_field_name>"}),
|
||||
"field_index": (
|
||||
"INT",
|
||||
{
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 100000,
|
||||
"step": 1,
|
||||
"display": "number",
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, collection_name, field_name, field_index):
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, collection_name: str, field_name: str, field_index: int):
|
||||
return f"{collection_name}|{field_name}|{field_index}"
|
||||
|
||||
def select_field(self, collection_name: str, field_name: str, field_index: int):
|
||||
doc = self.get_sample_document(collection_name)
|
||||
field_names = self.get_flattened_field_names(collection_name)
|
||||
|
||||
if not doc or not field_names:
|
||||
return (
|
||||
str(collection_name),
|
||||
"",
|
||||
0,
|
||||
"",
|
||||
json.dumps([], indent=2),
|
||||
)
|
||||
|
||||
normalized_index = self._clamp_index(field_index, len(field_names))
|
||||
|
||||
selected_field_name = field_name
|
||||
if (
|
||||
not selected_field_name
|
||||
or selected_field_name.startswith("<")
|
||||
or selected_field_name not in field_names
|
||||
):
|
||||
selected_field_name = field_names[normalized_index]
|
||||
|
||||
selected_index = field_names.index(selected_field_name)
|
||||
selected_value = DataProcessor.get_value(doc, selected_field_name)
|
||||
selected_value_str = self._stringify_value(selected_value)
|
||||
|
||||
return (
|
||||
str(collection_name),
|
||||
str(selected_field_name),
|
||||
int(selected_index),
|
||||
selected_value_str,
|
||||
json.dumps(field_names, indent=2),
|
||||
)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"ZMongoFlattenedFieldDropdownNode": ZMongoFieldSelectorNode,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"ZMongoFlattenedFieldDropdownNode": "🧩 ZMongo Flattened Field Dropdown Node",
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from .zmongo_toolbag.zmongo import ZMongo
|
||||
from .zmongo_toolbag.data_processor import DataProcessor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ZMongoFlattenedFieldSelectorNode:
|
||||
CATEGORY = "ZMongo"
|
||||
FUNCTION = "select_field"
|
||||
RETURN_TYPES = ("STRING", "STRING", "INT", "STRING", "STRING")
|
||||
RETURN_NAMES = (
|
||||
"collection_name",
|
||||
"selected_field_name",
|
||||
"selected_field_index",
|
||||
"selected_field_value",
|
||||
"flattened_field_names_json",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _get_zmongo(cls) -> ZMongo:
|
||||
return ZMongo()
|
||||
|
||||
@staticmethod
|
||||
def _clamp_index(value: int, max_len: int) -> int:
|
||||
if max_len <= 0:
|
||||
return 0
|
||||
return max(0, min(int(value), max_len - 1))
|
||||
|
||||
@staticmethod
|
||||
def _stringify_value(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, (dict, list, tuple, bool, int, float)):
|
||||
try:
|
||||
return json.dumps(
|
||||
DataProcessor.to_json_compatible(value),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
default=str,
|
||||
)
|
||||
except Exception:
|
||||
return str(value)
|
||||
return str(value)
|
||||
|
||||
@classmethod
|
||||
def get_collection_names(cls) -> List[str]:
|
||||
zmongo = None
|
||||
try:
|
||||
zmongo = cls._get_zmongo()
|
||||
result = zmongo.run_sync(zmongo.list_collections_async)
|
||||
|
||||
if not result or not getattr(result, "success", False):
|
||||
logger.warning(
|
||||
"Could not load collection names: %s",
|
||||
getattr(result, "error", None),
|
||||
)
|
||||
return ["<no_collections_found>"]
|
||||
|
||||
data = result.data or {}
|
||||
if not isinstance(data, dict):
|
||||
return ["<no_collections_found>"]
|
||||
|
||||
names = data.get("collections", [])
|
||||
if not isinstance(names, list):
|
||||
return ["<no_collections_found>"]
|
||||
|
||||
names = [str(name) for name in names if str(name).strip()]
|
||||
return names or ["<no_collections_found>"]
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("Error loading collection names: %s", exc)
|
||||
return ["<mongo_error>"]
|
||||
finally:
|
||||
if zmongo:
|
||||
try:
|
||||
zmongo.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def get_sample_document(cls, collection_name: str) -> Dict[str, Any]:
|
||||
zmongo = None
|
||||
try:
|
||||
if not collection_name or collection_name.startswith("<"):
|
||||
return {}
|
||||
|
||||
zmongo = cls._get_zmongo()
|
||||
result = zmongo.find_one(collection_name, {})
|
||||
|
||||
if not result or not getattr(result, "success", False):
|
||||
logger.warning(
|
||||
"Could not load sample document for '%s': %s",
|
||||
collection_name,
|
||||
getattr(result, "error", None),
|
||||
)
|
||||
return {}
|
||||
|
||||
doc = result.original() if hasattr(result, "original") else result.data
|
||||
return doc if isinstance(doc, dict) else {}
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"Error loading sample document for collection '%s': %s",
|
||||
collection_name,
|
||||
exc,
|
||||
)
|
||||
return {}
|
||||
finally:
|
||||
if zmongo:
|
||||
try:
|
||||
zmongo.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def get_flattened_field_names(cls, collection_name: str) -> List[str]:
|
||||
try:
|
||||
doc = cls.get_sample_document(collection_name)
|
||||
if not doc:
|
||||
return []
|
||||
|
||||
flattened = DataProcessor.flatten_json(doc)
|
||||
if not isinstance(flattened, dict):
|
||||
return []
|
||||
|
||||
return sorted(str(k) for k in flattened.keys())
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"Error flattening fields for collection '%s': %s",
|
||||
collection_name,
|
||||
exc,
|
||||
)
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> Dict[str, Any]:
|
||||
collections = cls.get_collection_names()
|
||||
default_collection = collections[0] if collections else "<no_collections_found>"
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"collection_name": (collections, {"default": default_collection}),
|
||||
"field_name": (["<dynamic_field_name>"], {"default": "<dynamic_field_name>"}),
|
||||
"field_index": (
|
||||
"INT",
|
||||
{
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 100000,
|
||||
"step": 1,
|
||||
"display": "number",
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, collection_name, field_name, field_index):
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, collection_name: str, field_name: str, field_index: int):
|
||||
return f"{collection_name}|{field_name}|{field_index}"
|
||||
|
||||
def select_field(self, collection_name: str, field_name: str, field_index: int):
|
||||
doc = self.get_sample_document(collection_name)
|
||||
field_names = self.get_flattened_field_names(collection_name)
|
||||
|
||||
if not doc or not field_names:
|
||||
return (
|
||||
str(collection_name),
|
||||
"",
|
||||
0,
|
||||
"",
|
||||
json.dumps([], indent=2),
|
||||
)
|
||||
|
||||
normalized_index = self._clamp_index(field_index, len(field_names))
|
||||
|
||||
selected_field_name = field_name
|
||||
if (
|
||||
not selected_field_name
|
||||
or selected_field_name.startswith("<")
|
||||
or selected_field_name not in field_names
|
||||
):
|
||||
selected_field_name = field_names[normalized_index]
|
||||
|
||||
selected_index = field_names.index(selected_field_name)
|
||||
selected_value = DataProcessor.get_value(doc, selected_field_name)
|
||||
selected_value_str = self._stringify_value(selected_value)
|
||||
|
||||
return (
|
||||
str(collection_name),
|
||||
str(selected_field_name),
|
||||
int(selected_index),
|
||||
selected_value_str,
|
||||
json.dumps(field_names, indent=2),
|
||||
)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"ZMongoFlattenedFieldSelectorNode": ZMongoFlattenedFieldSelectorNode,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"ZMongoFlattenedFieldSelectorNode": "🧩 ZMongo Flattened Field Selector Node",
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from bson import ObjectId
|
||||
|
||||
from .zmongo_toolbag.zmongo import ZMongo
|
||||
from .zmongo_toolbag.data_processor import DataProcessor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ZMongoRecordEditorNode:
|
||||
CATEGORY = "ZMongo"
|
||||
FUNCTION = "get_record"
|
||||
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING")
|
||||
RETURN_NAMES = (
|
||||
"record_json",
|
||||
"record_id",
|
||||
"save_status",
|
||||
"flattened_field_names_json",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _get_zmongo(cls) -> ZMongo:
|
||||
return ZMongo()
|
||||
|
||||
@staticmethod
|
||||
def _clean_record_id(value: Any) -> str:
|
||||
raw = "" if value is None else str(value)
|
||||
cleaned = raw.strip()
|
||||
|
||||
if len(cleaned) >= 2 and cleaned[0] == cleaned[-1] and cleaned[0] in {"'", '"'}:
|
||||
cleaned = cleaned[1:-1].strip()
|
||||
|
||||
return cleaned
|
||||
|
||||
@classmethod
|
||||
def _build_id_candidates(cls, record_id: Any) -> List[Any]:
|
||||
cleaned = cls._clean_record_id(record_id)
|
||||
if not cleaned:
|
||||
return []
|
||||
|
||||
candidates: List[Any] = [cleaned]
|
||||
if ObjectId.is_valid(cleaned):
|
||||
candidates.insert(0, ObjectId(cleaned))
|
||||
return candidates
|
||||
|
||||
@staticmethod
|
||||
def _stringify_value(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
try:
|
||||
return json.dumps(
|
||||
DataProcessor.to_json_compatible(value),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
default=str,
|
||||
)
|
||||
except Exception:
|
||||
return str(value)
|
||||
|
||||
@classmethod
|
||||
def get_collection_names(cls) -> List[str]:
|
||||
zmongo = None
|
||||
try:
|
||||
zmongo = cls._get_zmongo()
|
||||
result = zmongo.run_sync(zmongo.list_collections_async)
|
||||
|
||||
if not result or not getattr(result, "success", False):
|
||||
logger.warning(
|
||||
"Could not load collection names: %s",
|
||||
getattr(result, "error", None),
|
||||
)
|
||||
return ["<no_collections_found>"]
|
||||
|
||||
data = result.data or {}
|
||||
if not isinstance(data, dict):
|
||||
return ["<no_collections_found>"]
|
||||
|
||||
names = data.get("collections", [])
|
||||
if not isinstance(names, list):
|
||||
return ["<no_collections_found>"]
|
||||
|
||||
names = [str(name) for name in names if str(name).strip()]
|
||||
return names or ["<no_collections_found>"]
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("Error loading collection names: %s", exc)
|
||||
return ["<mongo_error>"]
|
||||
finally:
|
||||
if zmongo:
|
||||
try:
|
||||
zmongo.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def fetch_record(
|
||||
cls,
|
||||
collection_name: str,
|
||||
record_id: str,
|
||||
fallback_record_json: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
zmongo = None
|
||||
try:
|
||||
clean_id = cls._clean_record_id(record_id)
|
||||
|
||||
if clean_id:
|
||||
if not collection_name or collection_name.startswith("<"):
|
||||
return {}
|
||||
|
||||
zmongo = cls._get_zmongo()
|
||||
|
||||
for candidate_id in cls._build_id_candidates(clean_id):
|
||||
result = zmongo.find_one(collection_name, {"_id": candidate_id})
|
||||
|
||||
if result and getattr(result, "success", False):
|
||||
doc = result.original() if hasattr(result, "original") else result.data
|
||||
if isinstance(doc, dict) and doc:
|
||||
return doc
|
||||
|
||||
logger.warning(
|
||||
"Could not fetch record '%s' from '%s' using string/ObjectId lookup.",
|
||||
clean_id,
|
||||
collection_name,
|
||||
)
|
||||
return {}
|
||||
|
||||
if fallback_record_json and str(fallback_record_json).strip():
|
||||
parsed = json.loads(fallback_record_json)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
return {}
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"Error fetching record for collection='%s', record_id='%s': %s",
|
||||
collection_name,
|
||||
record_id,
|
||||
exc,
|
||||
)
|
||||
return {}
|
||||
finally:
|
||||
if zmongo:
|
||||
try:
|
||||
zmongo.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def get_flattened_record_pairs(
|
||||
cls,
|
||||
collection_name: str,
|
||||
record_id: str,
|
||||
fallback_record_json: str = "",
|
||||
) -> List[Tuple[str, str]]:
|
||||
record = cls.fetch_record(collection_name, record_id, fallback_record_json)
|
||||
if not record:
|
||||
return []
|
||||
|
||||
flat = DataProcessor.flatten_json(record)
|
||||
if not isinstance(flat, dict):
|
||||
return []
|
||||
|
||||
return [
|
||||
(str(path), cls._stringify_value(value))
|
||||
for path, value in sorted(flat.items(), key=lambda item: str(item[0]))
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> Dict[str, Any]:
|
||||
collections = cls.get_collection_names()
|
||||
default_collection = collections[0] if collections else "<no_collections_found>"
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"collection_name": (collections, {"default": default_collection}),
|
||||
"record_id": ("STRING", {"default": "", "multiline": False}),
|
||||
},
|
||||
"optional": {
|
||||
"selected_record_json": ("STRING", {"default": "", "forceInput": True}),
|
||||
"save_status_in": ("STRING", {"default": "", "forceInput": True}),
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(
|
||||
cls,
|
||||
collection_name: str,
|
||||
record_id: str,
|
||||
selected_record_json: str = "",
|
||||
save_status_in: str = "",
|
||||
):
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(
|
||||
cls,
|
||||
collection_name: str,
|
||||
record_id: str,
|
||||
selected_record_json: str = "",
|
||||
save_status_in: str = "",
|
||||
):
|
||||
return f"{collection_name}|{record_id}|{selected_record_json}|{save_status_in}"
|
||||
|
||||
def get_record(
|
||||
self,
|
||||
collection_name: str,
|
||||
record_id: str,
|
||||
selected_record_json: str = "",
|
||||
save_status_in: str = "",
|
||||
):
|
||||
record = self.fetch_record(
|
||||
collection_name=collection_name,
|
||||
record_id=record_id,
|
||||
fallback_record_json=selected_record_json,
|
||||
)
|
||||
|
||||
if not record:
|
||||
return (
|
||||
"{}",
|
||||
self._clean_record_id(record_id),
|
||||
str(save_status_in or ""),
|
||||
json.dumps([], indent=2),
|
||||
)
|
||||
|
||||
resolved_record_id = str(record.get("_id", self._clean_record_id(record_id)))
|
||||
flattened_paths = [
|
||||
path
|
||||
for path, _ in self.get_flattened_record_pairs(
|
||||
collection_name=collection_name,
|
||||
record_id=resolved_record_id,
|
||||
fallback_record_json=selected_record_json,
|
||||
)
|
||||
]
|
||||
|
||||
return (
|
||||
json.dumps(
|
||||
DataProcessor.to_json_compatible(record),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
default=str,
|
||||
),
|
||||
resolved_record_id,
|
||||
str(save_status_in or ""),
|
||||
json.dumps(flattened_paths, ensure_ascii=False, indent=2),
|
||||
)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"ZMongoRecordEditorNode": ZMongoRecordEditorNode,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"ZMongoRecordEditorNode": "📝 ZMongo Record Editor",
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import json
|
||||
from .zmongo_toolbag.data_processor import DataProcessor
|
||||
|
||||
|
||||
class ZMongoRecordSplitter:
|
||||
"""Converts JSON arrays into individual processable strings for batching."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {"records_json": ("STRING", {"multiline": True})}}
|
||||
|
||||
RETURN_TYPES = ("STRING", "INT")
|
||||
RETURN_NAMES = ("record_json_list", "count")
|
||||
OUTPUT_IS_LIST = (True, False)
|
||||
FUNCTION = "split"
|
||||
CATEGORY = "ZMongo/Database"
|
||||
|
||||
def split(self, records_json):
|
||||
try:
|
||||
# Handles concatenated objects and arrays
|
||||
data = json.loads(records_json)
|
||||
records = data if isinstance(data, list) else [data]
|
||||
output = [DataProcessor.to_json(r) for r in records]
|
||||
return (output, len(output))
|
||||
except Exception as e:
|
||||
return ([json.dumps({"error": str(e)})], 0)
|
||||
|
||||
|
||||
class ZMongoFieldSelector:
|
||||
"""Dynamic dot-notation field extractor with metadata for UI dropdowns."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"record_json": ("STRING", {"forceInput": True}),
|
||||
"field_path": ("STRING", {"default": "metadata.case_name"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING")
|
||||
RETURN_NAMES = ("field_value", "available_paths")
|
||||
FUNCTION = "select_field"
|
||||
CATEGORY = "ZMongo/Database"
|
||||
|
||||
def select_field(self, record_json, field_path):
|
||||
record = json.loads(record_json)
|
||||
# Extract nested values using DataProcessor dot-notation
|
||||
val = DataProcessor.get_value(record, field_path)
|
||||
|
||||
# Output paths to allow JS extension to build dropdowns
|
||||
flattened = DataProcessor.flatten_dict(record)
|
||||
available_paths = "\n".join(sorted(flattened.keys()))
|
||||
|
||||
return (str(val), available_paths)
|
||||
@@ -0,0 +1,367 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from .zmongo_toolbag.zmongo import ZMongo
|
||||
from .zmongo_toolbag.data_processor import DataProcessor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ZMongoTabularRecordViewNode:
|
||||
CATEGORY = "ZMongo"
|
||||
FUNCTION = "select_record"
|
||||
RETURN_TYPES = ("STRING", "STRING", "STRING", "INT", "STRING", "STRING")
|
||||
RETURN_NAMES = (
|
||||
"selected_record_json",
|
||||
"selected_record_id",
|
||||
"selected_field_name",
|
||||
"selected_record_index",
|
||||
"selected_field_value",
|
||||
"flattened_headings_json",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@staticmethod
|
||||
def _value_to_searchable_string(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, (bool, int, float)):
|
||||
return str(value)
|
||||
try:
|
||||
return json.dumps(
|
||||
DataProcessor.to_json_compatible(value),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
default=str,
|
||||
)
|
||||
except Exception:
|
||||
return str(value)
|
||||
|
||||
@staticmethod
|
||||
def _wildcard_to_regex(pattern: str) -> str:
|
||||
"""
|
||||
Convert a simple wildcard pattern to regex.
|
||||
Supported:
|
||||
* => any characters
|
||||
? => any single character
|
||||
"""
|
||||
escaped = re.escape(pattern)
|
||||
escaped = escaped.replace(r"\*", ".*")
|
||||
escaped = escaped.replace(r"\?", ".")
|
||||
return f"^{escaped}$"
|
||||
|
||||
@classmethod
|
||||
def _matches_search_text(cls, value: Any, search_text: str) -> bool:
|
||||
"""
|
||||
Matching rules:
|
||||
- empty search_text => match everything
|
||||
- if search_text contains * or ?, use wildcard matching
|
||||
- otherwise do case-insensitive substring matching
|
||||
"""
|
||||
haystack = cls._value_to_searchable_string(value)
|
||||
needle = str(search_text or "")
|
||||
|
||||
if not needle:
|
||||
return True
|
||||
|
||||
haystack_lower = haystack.lower()
|
||||
needle_lower = needle.lower()
|
||||
|
||||
if "*" in needle or "?" in needle:
|
||||
regex = cls._wildcard_to_regex(needle_lower)
|
||||
return re.search(regex, haystack_lower) is not None
|
||||
|
||||
return needle_lower in haystack_lower
|
||||
|
||||
@classmethod
|
||||
def get_filtered_table_payload(
|
||||
cls,
|
||||
collection_name: str,
|
||||
search_text: str = "",
|
||||
flattened_field_name: str = "",
|
||||
) -> Tuple[List[str], List[Dict[str, Any]], List[str]]:
|
||||
records = cls.get_all_records(collection_name)
|
||||
search_text = str(search_text or "").strip()
|
||||
flattened_field_name = str(flattened_field_name or "").strip()
|
||||
|
||||
headings: List[str] = []
|
||||
seen = set()
|
||||
flat_records: List[Dict[str, Any]] = []
|
||||
record_ids: List[str] = []
|
||||
|
||||
for record in records:
|
||||
flat = DataProcessor.flatten_json(record)
|
||||
normalized_flat = {
|
||||
str(k): DataProcessor.to_json_compatible(v)
|
||||
for k, v in flat.items()
|
||||
}
|
||||
|
||||
if search_text:
|
||||
if flattened_field_name:
|
||||
target_value = normalized_flat.get(flattened_field_name)
|
||||
matched = cls._matches_search_text(target_value, search_text)
|
||||
else:
|
||||
matched = any(
|
||||
cls._matches_search_text(v, search_text)
|
||||
for v in normalized_flat.values()
|
||||
)
|
||||
|
||||
if not matched:
|
||||
continue
|
||||
|
||||
flat_records.append(normalized_flat)
|
||||
|
||||
record_id = str(record.get("_id", ""))
|
||||
record_ids.append(record_id)
|
||||
|
||||
for key in normalized_flat.keys():
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
headings.append(key)
|
||||
|
||||
return headings, flat_records, record_ids
|
||||
|
||||
@classmethod
|
||||
def _get_zmongo(cls) -> ZMongo:
|
||||
return ZMongo()
|
||||
|
||||
@staticmethod
|
||||
def _clamp_index(value: int, max_len: int) -> int:
|
||||
if max_len <= 0:
|
||||
return 0
|
||||
return max(0, min(int(value), max_len - 1))
|
||||
|
||||
@staticmethod
|
||||
def _stringify_value(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
try:
|
||||
return json.dumps(
|
||||
DataProcessor.to_json_compatible(value),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
default=str,
|
||||
)
|
||||
except Exception:
|
||||
return str(value)
|
||||
|
||||
@classmethod
|
||||
def get_collection_names(cls) -> List[str]:
|
||||
zmongo = None
|
||||
try:
|
||||
zmongo = cls._get_zmongo()
|
||||
result = zmongo.run_sync(zmongo.list_collections_async)
|
||||
|
||||
if not result or not getattr(result, "success", False):
|
||||
logger.warning(
|
||||
"Could not load collection names: %s",
|
||||
getattr(result, "error", None),
|
||||
)
|
||||
return ["<no_collections_found>"]
|
||||
|
||||
data = result.data or {}
|
||||
if not isinstance(data, dict):
|
||||
return ["<no_collections_found>"]
|
||||
|
||||
names = data.get("collections", [])
|
||||
if not isinstance(names, list):
|
||||
return ["<no_collections_found>"]
|
||||
|
||||
names = [str(name) for name in names if str(name).strip()]
|
||||
return names or ["<no_collections_found>"]
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("Error loading collection names: %s", exc)
|
||||
return ["<mongo_error>"]
|
||||
finally:
|
||||
if zmongo:
|
||||
try:
|
||||
zmongo.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def get_all_records(cls, collection_name: str) -> List[Dict[str, Any]]:
|
||||
zmongo = None
|
||||
try:
|
||||
if not collection_name or collection_name.startswith("<"):
|
||||
return []
|
||||
|
||||
zmongo = cls._get_zmongo()
|
||||
|
||||
result = zmongo.find_many(
|
||||
collection_name,
|
||||
query={},
|
||||
sort=[("_id", 1)],
|
||||
limit=None, # all records
|
||||
)
|
||||
|
||||
if not result or not getattr(result, "success", False):
|
||||
logger.warning(
|
||||
"Could not load records for '%s': %s",
|
||||
collection_name,
|
||||
getattr(result, "error", None),
|
||||
)
|
||||
return []
|
||||
|
||||
data = result.original() if hasattr(result, "original") else result.data
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
|
||||
return [record for record in data if isinstance(record, dict)]
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("Error loading records for '%s': %s", collection_name, exc)
|
||||
return []
|
||||
finally:
|
||||
if zmongo:
|
||||
try:
|
||||
zmongo.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def get_table_payload(cls, collection_name: str) -> Tuple[List[str], List[Dict[str, Any]], List[str]]:
|
||||
records = cls.get_all_records(collection_name)
|
||||
|
||||
headings: List[str] = []
|
||||
seen = set()
|
||||
flat_records: List[Dict[str, Any]] = []
|
||||
record_ids: List[str] = []
|
||||
|
||||
for record in records:
|
||||
flat = DataProcessor.flatten_json(record)
|
||||
normalized_flat = {
|
||||
str(k): DataProcessor.to_json_compatible(v)
|
||||
for k, v in flat.items()
|
||||
}
|
||||
flat_records.append(normalized_flat)
|
||||
|
||||
record_id = str(record.get("_id", ""))
|
||||
record_ids.append(record_id)
|
||||
|
||||
for key in normalized_flat.keys():
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
headings.append(key)
|
||||
|
||||
return headings, flat_records, record_ids
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> Dict[str, Any]:
|
||||
collections = cls.get_collection_names()
|
||||
default_collection = collections[0] if collections else "<no_collections_found>"
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"collection_name": (collections, {"default": default_collection}),
|
||||
"flattened_field_name": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"multiline": False,
|
||||
},
|
||||
),
|
||||
"record_id": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"multiline": False,
|
||||
},
|
||||
),
|
||||
"selected_record_index": (
|
||||
"INT",
|
||||
{
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 1000000,
|
||||
"step": 1,
|
||||
"display": "number",
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, collection_name, flattened_field_name, record_id, selected_record_index):
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, collection_name: str, flattened_field_name: str, record_id: str, selected_record_index: int):
|
||||
return f"{collection_name}|{flattened_field_name}|{record_id}|{selected_record_index}"
|
||||
|
||||
def select_record(
|
||||
self,
|
||||
collection_name: str,
|
||||
flattened_field_name: str,
|
||||
record_id: str,
|
||||
selected_record_index: int,
|
||||
):
|
||||
records = self.get_all_records(collection_name)
|
||||
headings, _, record_ids = self.get_table_payload(collection_name)
|
||||
|
||||
if not records:
|
||||
return (
|
||||
"{}",
|
||||
"",
|
||||
str(flattened_field_name or ""),
|
||||
0,
|
||||
"",
|
||||
json.dumps([], indent=2),
|
||||
)
|
||||
|
||||
selected_idx = None
|
||||
|
||||
if record_id:
|
||||
record_id = str(record_id).strip()
|
||||
for idx, rid in enumerate(record_ids):
|
||||
if rid == record_id:
|
||||
selected_idx = idx
|
||||
break
|
||||
|
||||
if selected_idx is None:
|
||||
selected_idx = self._clamp_index(selected_record_index, len(records))
|
||||
|
||||
selected_record = records[selected_idx]
|
||||
selected_record_id = str(selected_record.get("_id", ""))
|
||||
|
||||
# default record_id behavior: if blank, use record 0 / selected record
|
||||
if not record_id:
|
||||
record_id = selected_record_id
|
||||
|
||||
selected_field_name = str(flattened_field_name or "").strip()
|
||||
selected_field_value = ""
|
||||
if selected_field_name:
|
||||
value = DataProcessor.get_value(selected_record, selected_field_name)
|
||||
selected_field_value = self._stringify_value(value)
|
||||
|
||||
selected_record_json = json.dumps(
|
||||
DataProcessor.to_json_compatible(selected_record),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
default=str,
|
||||
)
|
||||
|
||||
return (
|
||||
selected_record_json,
|
||||
selected_record_id,
|
||||
selected_field_name,
|
||||
int(selected_idx),
|
||||
selected_field_value,
|
||||
json.dumps(headings, ensure_ascii=False, indent=2),
|
||||
)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"ZMongoTabularRecordViewNode": ZMongoTabularRecordViewNode,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"ZMongoTabularRecordViewNode": "📊 ZMongo Tabular Record View",
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { app } from "/scripts/app.web";
|
||||
|
||||
function findWidget(node, name) {
|
||||
return node.widgets?.find((w) => w.name === name);
|
||||
}
|
||||
|
||||
async function fetchFlattenedFields(collectionName) {
|
||||
if (!collectionName || String(collectionName).startsWith("<")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`/zmongo/flattened_fields?collection_name=${encodeURIComponent(collectionName)}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success || !Array.isArray(data.fields)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return data.fields;
|
||||
}
|
||||
|
||||
function setComboValues(widget, values, preferredValue = null) {
|
||||
const safeValues = values.length ? values : ["<no_fields_found>"];
|
||||
|
||||
widget.options = widget.options || {};
|
||||
widget.options.values = safeValues;
|
||||
|
||||
if (preferredValue && safeValues.includes(preferredValue)) {
|
||||
widget.value = preferredValue;
|
||||
} else if (!safeValues.includes(widget.value)) {
|
||||
widget.value = safeValues[0];
|
||||
}
|
||||
}
|
||||
|
||||
function clampIndex(idx, len) {
|
||||
if (!len || len <= 0) return 0;
|
||||
if (Number.isNaN(idx)) return 0;
|
||||
return Math.max(0, Math.min(idx, len - 1));
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ZMongo.FlattenedFieldDropdown",
|
||||
|
||||
async nodeCreated(node) {
|
||||
if (node.comfyClass !== "ZMongoFlattenedFieldDropdownNode") {
|
||||
return;
|
||||
}
|
||||
|
||||
const collectionWidget = findWidget(node, "collection_name");
|
||||
const fieldWidget = findWidget(node, "field_name");
|
||||
const indexWidget = findWidget(node, "field_index");
|
||||
|
||||
if (!collectionWidget || !fieldWidget || !indexWidget) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isUpdating = false;
|
||||
|
||||
const syncIndexFromField = () => {
|
||||
const values = fieldWidget.options?.values || [];
|
||||
const idx = values.indexOf(fieldWidget.value);
|
||||
indexWidget.value = idx >= 0 ? idx : 0;
|
||||
node.setDirtyCanvas(true, true);
|
||||
};
|
||||
|
||||
const syncFieldFromIndex = () => {
|
||||
const values = fieldWidget.options?.values || [];
|
||||
if (!values.length) {
|
||||
fieldWidget.value = "<no_fields_found>";
|
||||
indexWidget.value = 0;
|
||||
node.setDirtyCanvas(true, true);
|
||||
return;
|
||||
}
|
||||
|
||||
let idx = Number(indexWidget.value ?? 0);
|
||||
idx = clampIndex(idx, values.length);
|
||||
indexWidget.value = idx;
|
||||
fieldWidget.value = values[idx];
|
||||
node.setDirtyCanvas(true, true);
|
||||
};
|
||||
|
||||
const refreshFields = async ({ preserveField = true } = {}) => {
|
||||
if (isUpdating) return;
|
||||
isUpdating = true;
|
||||
|
||||
try {
|
||||
const collectionName = collectionWidget.value;
|
||||
const oldFieldValue = fieldWidget.value;
|
||||
const fields = await fetchFlattenedFields(collectionName);
|
||||
|
||||
let preferredValue = null;
|
||||
if (
|
||||
preserveField &&
|
||||
oldFieldValue &&
|
||||
!String(oldFieldValue).startsWith("<") &&
|
||||
fields.includes(oldFieldValue)
|
||||
) {
|
||||
preferredValue = oldFieldValue;
|
||||
}
|
||||
|
||||
setComboValues(fieldWidget, fields, preferredValue);
|
||||
|
||||
const values = fieldWidget.options?.values || [];
|
||||
let idx = Number(indexWidget.value ?? 0);
|
||||
idx = clampIndex(idx, values.length);
|
||||
indexWidget.value = idx;
|
||||
|
||||
if (!preferredValue) {
|
||||
fieldWidget.value = values[idx] ?? "<no_fields_found>";
|
||||
}
|
||||
|
||||
syncIndexFromField();
|
||||
node.setDirtyCanvas(true, true);
|
||||
} catch (err) {
|
||||
console.error("ZMongo flattened field dropdown refresh failed:", err);
|
||||
setComboValues(fieldWidget, ["<error_loading_fields>"]);
|
||||
fieldWidget.value = "<error_loading_fields>";
|
||||
indexWidget.value = 0;
|
||||
node.setDirtyCanvas(true, true);
|
||||
} finally {
|
||||
isUpdating = false;
|
||||
}
|
||||
};
|
||||
|
||||
const originalCollectionCallback = collectionWidget.callback;
|
||||
collectionWidget.callback = async (...args) => {
|
||||
if (originalCollectionCallback) {
|
||||
originalCollectionCallback.apply(collectionWidget, args);
|
||||
}
|
||||
await refreshFields({ preserveField: false });
|
||||
};
|
||||
|
||||
const originalFieldCallback = fieldWidget.callback;
|
||||
fieldWidget.callback = (...args) => {
|
||||
if (originalFieldCallback) {
|
||||
originalFieldCallback.apply(fieldWidget, args);
|
||||
}
|
||||
syncIndexFromField();
|
||||
};
|
||||
|
||||
const originalIndexCallback = indexWidget.callback;
|
||||
indexWidget.callback = (...args) => {
|
||||
if (originalIndexCallback) {
|
||||
originalIndexCallback.apply(indexWidget, args);
|
||||
}
|
||||
syncFieldFromIndex();
|
||||
};
|
||||
|
||||
// Initial population from the default selected collection.
|
||||
await refreshFields({ preserveField: false });
|
||||
|
||||
// Run again on next tick because ComfyUI sometimes finishes widget restoration
|
||||
// after nodeCreated, which can otherwise leave the placeholder stuck.
|
||||
setTimeout(async () => {
|
||||
await refreshFields({ preserveField: false });
|
||||
}, 0);
|
||||
|
||||
// One more delayed refresh for restored workflows / startup timing issues.
|
||||
setTimeout(async () => {
|
||||
await refreshFields({ preserveField: false });
|
||||
}, 150);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,336 @@
|
||||
import { app } from "../../scripts/app.web";
|
||||
|
||||
const EXT_ID = "ZMongo.PresetPanel";
|
||||
const PANEL_ID = "zmongo-preset-panel";
|
||||
|
||||
function getWorkflowNodes() {
|
||||
return app.graph?._nodes ?? [];
|
||||
}
|
||||
|
||||
function getNodeTitle(node) {
|
||||
return String(node?.title || "").trim();
|
||||
}
|
||||
|
||||
function findNodeByTitle(title) {
|
||||
return getWorkflowNodes().find((node) => getNodeTitle(node) === title) || null;
|
||||
}
|
||||
|
||||
function findWidget(node, widgetName) {
|
||||
return node?.widgets?.find((w) => w?.name === widgetName) || null;
|
||||
}
|
||||
|
||||
function coerceValueForWidget(widget, value) {
|
||||
if (!widget) return value;
|
||||
const current = widget.value;
|
||||
if (typeof current === "number" && typeof value === "string") {
|
||||
const n = Number(value);
|
||||
return Number.isNaN(n) ? value : n;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function setWidgetValue(node, widgetName, value) {
|
||||
const widget = findWidget(node, widgetName);
|
||||
if (!widget) return false;
|
||||
|
||||
const finalValue = coerceValueForWidget(widget, value);
|
||||
widget.value = finalValue;
|
||||
|
||||
if (typeof widget.callback === "function") {
|
||||
try {
|
||||
widget.callback(finalValue);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (typeof node.onWidgetChanged === "function") {
|
||||
try {
|
||||
node.onWidgetChanged(widgetName, finalValue, widget);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyPreset(preset) {
|
||||
const failures = [];
|
||||
const updates = preset?.updates ?? {};
|
||||
|
||||
for (const [nodeTitle, widgetValues] of Object.entries(updates)) {
|
||||
const node = findNodeByTitle(nodeTitle);
|
||||
if (!node) {
|
||||
failures.push(`Missing node: ${nodeTitle}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [widgetName, value] of Object.entries(widgetValues)) {
|
||||
const ok = setWidgetValue(node, widgetName, value);
|
||||
if (!ok) failures.push(`Missing widget: ${nodeTitle}.${widgetName}`);
|
||||
}
|
||||
}
|
||||
|
||||
app.graph?.setDirtyCanvas?.(true, true);
|
||||
|
||||
if (failures.length) {
|
||||
alert(`Preset applied with warnings:\n\n${failures.join("\n")}`);
|
||||
}
|
||||
}
|
||||
|
||||
function queuePrompt() {
|
||||
try {
|
||||
app.queuePrompt?.(0);
|
||||
} catch (err) {
|
||||
console.warn("[ZMongo PresetPanel] Queue failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPresets(workflow = "default") {
|
||||
const res = await fetch(`/zmongo/presets?workflow=${encodeURIComponent(workflow)}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load presets (${res.status})`);
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || "Failed to load presets");
|
||||
}
|
||||
|
||||
const groups = {};
|
||||
for (const p of data.presets ?? []) {
|
||||
const g = p.group || "Default";
|
||||
if (!groups[g]) groups[g] = [];
|
||||
groups[g].push(p);
|
||||
}
|
||||
|
||||
return {
|
||||
groups: Object.entries(groups).map(([name, presets]) => ({ name, presets }))
|
||||
};
|
||||
}
|
||||
|
||||
function extractCurrentValues(nodeTitles) {
|
||||
const out = {};
|
||||
for (const title of nodeTitles) {
|
||||
const node = findNodeByTitle(title);
|
||||
if (!node || !node.widgets) continue;
|
||||
|
||||
out[title] = {};
|
||||
for (const widget of node.widgets) {
|
||||
if (!widget?.name) continue;
|
||||
out[title][widget.name] = widget.value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function savePreset(workflow, name, group, updates) {
|
||||
const res = await fetch("/zmongo/presets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
workflow,
|
||||
name,
|
||||
group,
|
||||
updates
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.success) {
|
||||
throw new Error(data.error || "Failed to save preset");
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePreset(workflow, name) {
|
||||
const res = await fetch(`/zmongo/presets/${encodeURIComponent(workflow)}/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.success) {
|
||||
throw new Error(data.error || "Failed to delete preset");
|
||||
}
|
||||
}
|
||||
|
||||
function makeButton(label, onClick) {
|
||||
const btn = document.createElement("button");
|
||||
btn.textContent = label;
|
||||
btn.style.width = "100%";
|
||||
btn.style.marginBottom = "8px";
|
||||
btn.style.padding = "8px";
|
||||
btn.style.borderRadius = "6px";
|
||||
btn.style.cursor = "pointer";
|
||||
btn.onclick = onClick;
|
||||
return btn;
|
||||
}
|
||||
|
||||
function makeSectionTitle(text) {
|
||||
const el = document.createElement("div");
|
||||
el.textContent = text;
|
||||
el.style.fontWeight = "bold";
|
||||
el.style.margin = "12px 0 8px";
|
||||
el.style.fontSize = "14px";
|
||||
return el;
|
||||
}
|
||||
|
||||
function makeInfo(text) {
|
||||
const el = document.createElement("div");
|
||||
el.textContent = text;
|
||||
el.style.fontSize = "12px";
|
||||
el.style.opacity = "0.8";
|
||||
el.style.marginBottom = "10px";
|
||||
return el;
|
||||
}
|
||||
|
||||
async function buildPanel(container, workflow = "default") {
|
||||
container.innerHTML = "";
|
||||
container.appendChild(makeInfo(`Workflow key: ${workflow}`));
|
||||
|
||||
const config = await loadPresets(workflow);
|
||||
|
||||
for (const group of config.groups ?? []) {
|
||||
container.appendChild(makeSectionTitle(group.name));
|
||||
|
||||
for (const preset of group.presets ?? []) {
|
||||
const row = document.createElement("div");
|
||||
row.style.display = "flex";
|
||||
row.style.gap = "6px";
|
||||
row.style.marginBottom = "6px";
|
||||
|
||||
const applyBtn = makeButton(preset.name, () => applyPreset(preset));
|
||||
applyBtn.style.flex = "1";
|
||||
|
||||
const applyQueueBtn = makeButton("Apply + Queue", () => {
|
||||
applyPreset(preset);
|
||||
queuePrompt();
|
||||
});
|
||||
applyQueueBtn.style.flex = "1";
|
||||
|
||||
const delBtn = makeButton("Del", async () => {
|
||||
if (!confirm(`Delete preset "${preset.name}"?`)) return;
|
||||
try {
|
||||
await deletePreset(workflow, preset.name);
|
||||
await buildPanel(container, workflow);
|
||||
} catch (err) {
|
||||
alert(String(err));
|
||||
}
|
||||
});
|
||||
delBtn.style.flex = "0 0 64px";
|
||||
|
||||
row.appendChild(applyBtn);
|
||||
row.appendChild(applyQueueBtn);
|
||||
row.appendChild(delBtn);
|
||||
container.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
container.appendChild(makeSectionTitle("Save Current Preset"));
|
||||
|
||||
const nameInput = document.createElement("input");
|
||||
nameInput.placeholder = "Preset name";
|
||||
nameInput.style.width = "100%";
|
||||
nameInput.style.marginBottom = "6px";
|
||||
nameInput.style.padding = "8px";
|
||||
|
||||
const groupInput = document.createElement("input");
|
||||
groupInput.placeholder = "Group name";
|
||||
groupInput.value = "Default";
|
||||
groupInput.style.width = "100%";
|
||||
groupInput.style.marginBottom = "6px";
|
||||
groupInput.style.padding = "8px";
|
||||
|
||||
const nodesInput = document.createElement("textarea");
|
||||
nodesInput.placeholder = "Node titles, one per line";
|
||||
nodesInput.style.width = "100%";
|
||||
nodesInput.style.minHeight = "120px";
|
||||
nodesInput.style.marginBottom = "6px";
|
||||
nodesInput.style.padding = "8px";
|
||||
nodesInput.value = [
|
||||
"MAIN_MODEL",
|
||||
"MAIN_LORA",
|
||||
"MAIN_SAMPLER",
|
||||
"IMAGE_SETTINGS",
|
||||
"VIDEO_MODEL",
|
||||
"VIDEO_UNET",
|
||||
"VIDEO_LORA",
|
||||
"VIDEO_SAMPLER",
|
||||
"VIDEO_SETTINGS"
|
||||
].join("\n");
|
||||
|
||||
container.appendChild(nameInput);
|
||||
container.appendChild(groupInput);
|
||||
container.appendChild(nodesInput);
|
||||
|
||||
container.appendChild(
|
||||
makeButton("Save Current Preset to ZMongo", async () => {
|
||||
const name = nameInput.value.trim();
|
||||
const group = groupInput.value.trim() || "Default";
|
||||
const nodeTitles = nodesInput.value
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (!name) {
|
||||
alert("Enter a preset name.");
|
||||
return;
|
||||
}
|
||||
|
||||
const updates = extractCurrentValues(nodeTitles);
|
||||
|
||||
try {
|
||||
await savePreset(workflow, name, group, updates);
|
||||
await buildPanel(container, workflow);
|
||||
} catch (err) {
|
||||
alert(String(err));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
container.appendChild(makeSectionTitle("Utility"));
|
||||
|
||||
container.appendChild(makeButton("Queue Prompt", () => queuePrompt()));
|
||||
|
||||
container.appendChild(
|
||||
makeButton("Refresh Presets", async () => {
|
||||
try {
|
||||
await buildPanel(container, workflow);
|
||||
} catch (err) {
|
||||
alert(String(err));
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: EXT_ID,
|
||||
|
||||
async setup() {
|
||||
const panel = document.createElement("div");
|
||||
panel.id = PANEL_ID;
|
||||
panel.style.padding = "10px";
|
||||
panel.style.height = "100%";
|
||||
panel.style.overflowY = "auto";
|
||||
panel.style.boxSizing = "border-box";
|
||||
|
||||
const workflow = "default";
|
||||
|
||||
try {
|
||||
await buildPanel(panel, workflow);
|
||||
} catch (err) {
|
||||
panel.textContent = String(err);
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
if (app.extensionManager?.registerSidebarTab) {
|
||||
app.extensionManager.registerSidebarTab({
|
||||
id: PANEL_ID,
|
||||
icon: "list",
|
||||
title: "Presets",
|
||||
tooltip: "ZMongo preset panel",
|
||||
type: "custom",
|
||||
render: (el) => {
|
||||
el.innerHTML = "";
|
||||
el.appendChild(panel);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { app } from "/scripts/app.web";
|
||||
|
||||
function findWidget(node, name) {
|
||||
return node.widgets?.find((w) => w.name === name);
|
||||
}
|
||||
|
||||
async function fetchFlattenedFields(collectionName) {
|
||||
if (!collectionName || String(collectionName).startsWith("<")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const url = `/zmongo/flattened_fields?collection_name=${encodeURIComponent(collectionName)}`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success || !Array.isArray(data.fields)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return data.fields;
|
||||
}
|
||||
|
||||
function setWidgetOptions(widget, values) {
|
||||
const safeValues = values.length ? values : ["<no_fields_found>"];
|
||||
|
||||
widget.options = widget.options || {};
|
||||
widget.options.values = safeValues;
|
||||
|
||||
if (!safeValues.includes(widget.value)) {
|
||||
widget.value = safeValues[0];
|
||||
}
|
||||
}
|
||||
|
||||
function clampIndex(value, maxLen) {
|
||||
if (!maxLen || maxLen <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const n = Number(value);
|
||||
if (Number.isNaN(n)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, Math.min(n, maxLen - 1));
|
||||
}
|
||||
|
||||
async function refreshFieldWidget(node, collectionWidget, fieldWidget, indexWidget) {
|
||||
const collectionName = collectionWidget.value;
|
||||
const fields = await fetchFlattenedFields(collectionName);
|
||||
|
||||
setWidgetOptions(fieldWidget, fields);
|
||||
|
||||
const values = fieldWidget.options?.values || [];
|
||||
const idx = clampIndex(indexWidget.value, values.length);
|
||||
|
||||
indexWidget.value = idx;
|
||||
fieldWidget.value = values[idx] ?? "<no_fields_found>";
|
||||
|
||||
node.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ZMongo.FlattenedFieldDropdown",
|
||||
|
||||
async nodeCreated(node) {
|
||||
if (node.comfyClass !== "ZMongoFlattenedFieldDropdownNode") {
|
||||
return;
|
||||
}
|
||||
|
||||
const collectionWidget = findWidget(node, "collection_name");
|
||||
const fieldWidget = findWidget(node, "field_name");
|
||||
const indexWidget = findWidget(node, "field_index");
|
||||
|
||||
if (!collectionWidget || !fieldWidget || !indexWidget) {
|
||||
console.warn("ZMongo node widgets not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const syncIndexFromField = () => {
|
||||
const values = fieldWidget.options?.values || [];
|
||||
const idx = values.indexOf(fieldWidget.value);
|
||||
indexWidget.value = idx >= 0 ? idx : 0;
|
||||
node.setDirtyCanvas(true, true);
|
||||
};
|
||||
|
||||
const syncFieldFromIndex = () => {
|
||||
const values = fieldWidget.options?.values || [];
|
||||
if (!values.length) {
|
||||
fieldWidget.value = "<no_fields_found>";
|
||||
indexWidget.value = 0;
|
||||
node.setDirtyCanvas(true, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const idx = clampIndex(indexWidget.value, values.length);
|
||||
indexWidget.value = idx;
|
||||
fieldWidget.value = values[idx];
|
||||
node.setDirtyCanvas(true, true);
|
||||
};
|
||||
|
||||
const originalCollectionCallback = collectionWidget.callback;
|
||||
collectionWidget.callback = async (...args) => {
|
||||
if (originalCollectionCallback) {
|
||||
originalCollectionCallback.apply(collectionWidget, args);
|
||||
}
|
||||
|
||||
try {
|
||||
await refreshFieldWidget(node, collectionWidget, fieldWidget, indexWidget);
|
||||
} catch (err) {
|
||||
console.error("Failed refreshing fields after collection change:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const originalFieldCallback = fieldWidget.callback;
|
||||
fieldWidget.callback = (...args) => {
|
||||
if (originalFieldCallback) {
|
||||
originalFieldCallback.apply(fieldWidget, args);
|
||||
}
|
||||
syncIndexFromField();
|
||||
};
|
||||
|
||||
const originalIndexCallback = indexWidget.callback;
|
||||
indexWidget.callback = (...args) => {
|
||||
if (originalIndexCallback) {
|
||||
originalIndexCallback.apply(indexWidget, args);
|
||||
}
|
||||
syncFieldFromIndex();
|
||||
};
|
||||
|
||||
// initial startup refresh from default selected collection
|
||||
try {
|
||||
await refreshFieldWidget(node, collectionWidget, fieldWidget, indexWidget);
|
||||
} catch (err) {
|
||||
console.error("Initial ZMongo field refresh failed:", err);
|
||||
}
|
||||
|
||||
// delayed retry to handle restored workflow timing
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await refreshFieldWidget(node, collectionWidget, fieldWidget, indexWidget);
|
||||
} catch (err) {
|
||||
console.error("Delayed ZMongo field refresh failed:", err);
|
||||
}
|
||||
}, 200);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { app } from "/scripts/app.web";
|
||||
|
||||
function findWidget(node, name) {
|
||||
return node.widgets?.find((w) => w.name === name);
|
||||
}
|
||||
|
||||
async function fetchFlattenedFields(collectionName) {
|
||||
if (!collectionName || String(collectionName).startsWith("<")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const url = `/zmongo/flattened_fields?collection_name=${encodeURIComponent(collectionName)}`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success || !Array.isArray(data.fields)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return data.fields;
|
||||
}
|
||||
|
||||
function setWidgetOptions(widget, values) {
|
||||
const safeValues = values.length ? values : ["<no_fields_found>"];
|
||||
widget.options = widget.options || {};
|
||||
widget.options.values = safeValues;
|
||||
|
||||
if (!safeValues.includes(widget.value)) {
|
||||
widget.value = safeValues[0];
|
||||
}
|
||||
}
|
||||
|
||||
function clampIndex(value, maxLen) {
|
||||
if (!maxLen || maxLen <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const n = Number(value);
|
||||
if (Number.isNaN(n)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, Math.min(n, maxLen - 1));
|
||||
}
|
||||
|
||||
async function refreshFieldWidget(node, collectionWidget, fieldWidget, indexWidget) {
|
||||
const collectionName = collectionWidget.value;
|
||||
const fields = await fetchFlattenedFields(collectionName);
|
||||
|
||||
setWidgetOptions(fieldWidget, fields);
|
||||
|
||||
const values = fieldWidget.options?.values || [];
|
||||
const idx = clampIndex(indexWidget.value, values.length);
|
||||
|
||||
indexWidget.value = idx;
|
||||
fieldWidget.value = values[idx] ?? "<no_fields_found>";
|
||||
|
||||
node.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ZMongo.FlattenedFieldDropdown",
|
||||
|
||||
async nodeCreated(node) {
|
||||
if (node.comfyClass !== "ZMongoFlattenedFieldDropdownNode") {
|
||||
return;
|
||||
}
|
||||
|
||||
const collectionWidget = findWidget(node, "collection_name");
|
||||
const fieldWidget = findWidget(node, "field_name");
|
||||
const indexWidget = findWidget(node, "field_index");
|
||||
|
||||
if (!collectionWidget || !fieldWidget || !indexWidget) {
|
||||
console.warn("ZMongo node widgets not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const syncIndexFromField = () => {
|
||||
const values = fieldWidget.options?.values || [];
|
||||
const idx = values.indexOf(fieldWidget.value);
|
||||
indexWidget.value = idx >= 0 ? idx : 0;
|
||||
node.setDirtyCanvas(true, true);
|
||||
};
|
||||
|
||||
const syncFieldFromIndex = () => {
|
||||
const values = fieldWidget.options?.values || [];
|
||||
if (!values.length) {
|
||||
fieldWidget.value = "<no_fields_found>";
|
||||
indexWidget.value = 0;
|
||||
node.setDirtyCanvas(true, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const idx = clampIndex(indexWidget.value, values.length);
|
||||
indexWidget.value = idx;
|
||||
fieldWidget.value = values[idx];
|
||||
node.setDirtyCanvas(true, true);
|
||||
};
|
||||
|
||||
const originalCollectionCallback = collectionWidget.callback;
|
||||
collectionWidget.callback = async (...args) => {
|
||||
if (originalCollectionCallback) {
|
||||
originalCollectionCallback.apply(collectionWidget, args);
|
||||
}
|
||||
|
||||
try {
|
||||
await refreshFieldWidget(node, collectionWidget, fieldWidget, indexWidget);
|
||||
} catch (err) {
|
||||
console.error("Failed refreshing fields after collection change:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const originalFieldCallback = fieldWidget.callback;
|
||||
fieldWidget.callback = (...args) => {
|
||||
if (originalFieldCallback) {
|
||||
originalFieldCallback.apply(fieldWidget, args);
|
||||
}
|
||||
syncIndexFromField();
|
||||
};
|
||||
|
||||
const originalIndexCallback = indexWidget.callback;
|
||||
indexWidget.callback = (...args) => {
|
||||
if (originalIndexCallback) {
|
||||
originalIndexCallback.apply(indexWidget, args);
|
||||
}
|
||||
syncFieldFromIndex();
|
||||
};
|
||||
|
||||
try {
|
||||
await refreshFieldWidget(node, collectionWidget, fieldWidget, indexWidget);
|
||||
} catch (err) {
|
||||
console.error("Initial ZMongo field refresh failed:", err);
|
||||
}
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await refreshFieldWidget(node, collectionWidget, fieldWidget, indexWidget);
|
||||
} catch (err) {
|
||||
console.error("Delayed ZMongo field refresh failed:", err);
|
||||
}
|
||||
}, 200);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,332 @@
|
||||
import { app } from "/scripts/app.web";
|
||||
|
||||
function findWidget(node, name) {
|
||||
return node.widgets?.find((w) => w.name === name);
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || "Unknown error");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function postJson(url, body) {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || "Unknown error");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadRecordEditorPayload(collectionName, recordId, selectedRecordJson) {
|
||||
const url =
|
||||
`/zmongo/record_editor/load?collection_name=${encodeURIComponent(collectionName || "")}` +
|
||||
`&record_id=${encodeURIComponent(recordId || "")}` +
|
||||
`&selected_record_json=${encodeURIComponent(selectedRecordJson || "")}`;
|
||||
|
||||
return await fetchJson(url);
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ZMongo.RecordEditor",
|
||||
|
||||
async nodeCreated(node) {
|
||||
if (node.comfyClass !== "ZMongoRecordEditorNode") {
|
||||
return;
|
||||
}
|
||||
|
||||
const collectionWidget = findWidget(node, "collection_name");
|
||||
const recordIdWidget = findWidget(node, "record_id");
|
||||
const selectedRecordJsonWidget = findWidget(node, "selected_record_json");
|
||||
const saveStatusWidget = findWidget(node, "save_status_in");
|
||||
|
||||
if (!collectionWidget || !recordIdWidget) {
|
||||
console.warn("ZMongoRecordEditorNode widgets not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.style.display = "flex";
|
||||
container.style.flexDirection = "column";
|
||||
container.style.gap = "8px";
|
||||
container.style.width = "100%";
|
||||
container.style.minHeight = "320px";
|
||||
container.style.maxHeight = "620px";
|
||||
container.style.background = "#111";
|
||||
container.style.border = "1px solid #333";
|
||||
container.style.borderRadius = "8px";
|
||||
container.style.padding = "8px";
|
||||
container.style.boxSizing = "border-box";
|
||||
|
||||
const infoBar = document.createElement("div");
|
||||
infoBar.style.fontSize = "12px";
|
||||
infoBar.style.color = "#aaa";
|
||||
infoBar.textContent = "Load a record to edit.";
|
||||
|
||||
const toolbar = document.createElement("div");
|
||||
toolbar.style.display = "flex";
|
||||
toolbar.style.gap = "8px";
|
||||
toolbar.style.alignItems = "center";
|
||||
toolbar.style.flexWrap = "wrap";
|
||||
|
||||
const loadButton = document.createElement("button");
|
||||
loadButton.textContent = "Load Record";
|
||||
loadButton.style.padding = "6px 10px";
|
||||
loadButton.style.borderRadius = "6px";
|
||||
loadButton.style.border = "1px solid #555";
|
||||
loadButton.style.background = "#2a2a2a";
|
||||
loadButton.style.color = "#eee";
|
||||
loadButton.style.cursor = "pointer";
|
||||
|
||||
const saveButton = document.createElement("button");
|
||||
saveButton.textContent = "Save";
|
||||
saveButton.style.padding = "6px 10px";
|
||||
saveButton.style.borderRadius = "6px";
|
||||
saveButton.style.border = "1px solid #555";
|
||||
saveButton.style.background = "#2a2a2a";
|
||||
saveButton.style.color = "#eee";
|
||||
saveButton.style.cursor = "pointer";
|
||||
|
||||
const saveStatusLabel = document.createElement("div");
|
||||
saveStatusLabel.style.fontSize = "11px";
|
||||
saveStatusLabel.style.color = "#8fb3ff";
|
||||
saveStatusLabel.textContent = "";
|
||||
|
||||
toolbar.appendChild(loadButton);
|
||||
toolbar.appendChild(saveButton);
|
||||
|
||||
const editorWrap = document.createElement("div");
|
||||
editorWrap.style.flex = "1";
|
||||
editorWrap.style.overflow = "auto";
|
||||
editorWrap.style.border = "1px solid #333";
|
||||
editorWrap.style.borderRadius = "6px";
|
||||
editorWrap.style.background = "#181818";
|
||||
editorWrap.style.padding = "8px";
|
||||
editorWrap.style.display = "flex";
|
||||
editorWrap.style.flexDirection = "column";
|
||||
editorWrap.style.gap = "8px";
|
||||
|
||||
container.appendChild(infoBar);
|
||||
container.appendChild(toolbar);
|
||||
container.appendChild(saveStatusLabel);
|
||||
container.appendChild(editorWrap);
|
||||
|
||||
if (node.addDOMWidget) {
|
||||
node.addDOMWidget("record_editor", "div", container, {
|
||||
serialize: false,
|
||||
hideOnZoom: false,
|
||||
});
|
||||
}
|
||||
|
||||
let currentRecord = {};
|
||||
let currentFields = [];
|
||||
let fieldEditors = new Map();
|
||||
|
||||
function getSelectedRecordJsonValue() {
|
||||
if (!selectedRecordJsonWidget) {
|
||||
return "";
|
||||
}
|
||||
return String(selectedRecordJsonWidget.value || "");
|
||||
}
|
||||
|
||||
function getRecordIdValue() {
|
||||
return String(recordIdWidget.value || "").trim();
|
||||
}
|
||||
|
||||
function buildFieldEditor(path, value) {
|
||||
const row = document.createElement("div");
|
||||
row.style.display = "flex";
|
||||
row.style.flexDirection = "column";
|
||||
row.style.gap = "4px";
|
||||
row.style.padding = "6px";
|
||||
row.style.border = "1px solid #2f2f2f";
|
||||
row.style.borderRadius = "6px";
|
||||
row.style.background = "#141414";
|
||||
|
||||
const label = document.createElement("div");
|
||||
label.textContent = path;
|
||||
label.title = path;
|
||||
label.style.fontSize = "12px";
|
||||
label.style.fontWeight = "600";
|
||||
label.style.color = "#cfd8ff";
|
||||
label.style.wordBreak = "break-word";
|
||||
|
||||
const editor = document.createElement("textarea");
|
||||
editor.value = value ?? "";
|
||||
editor.style.width = "100%";
|
||||
editor.style.minHeight = "48px";
|
||||
editor.style.maxHeight = "220px";
|
||||
editor.style.resize = "vertical";
|
||||
editor.style.padding = "6px 8px";
|
||||
editor.style.borderRadius = "6px";
|
||||
editor.style.border = "1px solid #444";
|
||||
editor.style.background = "#1b1b1b";
|
||||
editor.style.color = "#ddd";
|
||||
editor.style.whiteSpace = "pre-wrap";
|
||||
editor.style.overflowWrap = "anywhere";
|
||||
editor.style.boxSizing = "border-box";
|
||||
|
||||
row.appendChild(label);
|
||||
row.appendChild(editor);
|
||||
|
||||
return { row, editor };
|
||||
}
|
||||
|
||||
function renderEditors(fields) {
|
||||
editorWrap.innerHTML = "";
|
||||
fieldEditors = new Map();
|
||||
|
||||
if (!Array.isArray(fields) || !fields.length) {
|
||||
const empty = document.createElement("div");
|
||||
empty.textContent = "No editable fields loaded.";
|
||||
empty.style.color = "#888";
|
||||
empty.style.fontSize = "12px";
|
||||
editorWrap.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const field of fields) {
|
||||
const path = String(field.path || "");
|
||||
const value = String(field.value ?? "");
|
||||
const { row, editor } = buildFieldEditor(path, value);
|
||||
fieldEditors.set(path, editor);
|
||||
editorWrap.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function collectChanges() {
|
||||
const changes = {};
|
||||
for (const [path, editor] of fieldEditors.entries()) {
|
||||
changes[path] = editor.value;
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
async function loadRecord() {
|
||||
const collectionName = String(collectionWidget.value || "").trim();
|
||||
const recordId = getRecordIdValue();
|
||||
const selectedRecordJson = getSelectedRecordJsonValue();
|
||||
|
||||
const payload = await loadRecordEditorPayload(
|
||||
collectionName,
|
||||
recordId,
|
||||
selectedRecordJson
|
||||
);
|
||||
|
||||
currentRecord = payload.record || {};
|
||||
currentFields = Array.isArray(payload.fields) ? payload.fields : [];
|
||||
|
||||
if (payload.record_id) {
|
||||
recordIdWidget.value = payload.record_id;
|
||||
}
|
||||
|
||||
renderEditors(currentFields);
|
||||
|
||||
const shownRecordId = payload.record_id || recordId || "";
|
||||
infoBar.textContent = shownRecordId
|
||||
? `Editing ${collectionName} / ${shownRecordId}`
|
||||
: `Editing ${collectionName}`;
|
||||
|
||||
saveStatusLabel.textContent = "";
|
||||
node.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
async function saveRecord() {
|
||||
const collectionName = String(collectionWidget.value || "").trim();
|
||||
const recordId = getRecordIdValue();
|
||||
|
||||
if (!collectionName) {
|
||||
throw new Error("collection_name is required");
|
||||
}
|
||||
if (!recordId) {
|
||||
throw new Error("record_id is required");
|
||||
}
|
||||
|
||||
const changes = collectChanges();
|
||||
|
||||
const payload = await postJson("/zmongo/record_editor/save", {
|
||||
collection_name: collectionName,
|
||||
record_id: recordId,
|
||||
changes,
|
||||
});
|
||||
|
||||
currentRecord = payload.record || {};
|
||||
saveStatusLabel.textContent = `Saved ${collectionName} / ${recordId}`;
|
||||
|
||||
if (saveStatusWidget) {
|
||||
saveStatusWidget.value = `Saved ${collectionName} / ${recordId}`;
|
||||
}
|
||||
|
||||
await loadRecord();
|
||||
node.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
loadButton.addEventListener("click", async () => {
|
||||
try {
|
||||
await loadRecord();
|
||||
} catch (err) {
|
||||
console.error("Failed to load record editor:", err);
|
||||
infoBar.textContent = `Error: ${err.message}`;
|
||||
}
|
||||
});
|
||||
|
||||
saveButton.addEventListener("click", async () => {
|
||||
try {
|
||||
await saveRecord();
|
||||
} catch (err) {
|
||||
console.error("Failed to save record editor:", err);
|
||||
saveStatusLabel.textContent = `Error: ${err.message}`;
|
||||
}
|
||||
});
|
||||
|
||||
const originalCollectionCallback = collectionWidget.callback;
|
||||
collectionWidget.callback = async (...args) => {
|
||||
if (originalCollectionCallback) {
|
||||
originalCollectionCallback.apply(collectionWidget, args);
|
||||
}
|
||||
try {
|
||||
await loadRecord();
|
||||
} catch (err) {
|
||||
console.error("Failed to reload editor after collection change:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const originalRecordIdCallback = recordIdWidget.callback;
|
||||
recordIdWidget.callback = async (...args) => {
|
||||
if (originalRecordIdCallback) {
|
||||
originalRecordIdCallback.apply(recordIdWidget, args);
|
||||
}
|
||||
try {
|
||||
await loadRecord();
|
||||
} catch (err) {
|
||||
console.error("Failed to reload editor after record_id change:", err);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await loadRecord();
|
||||
} catch (err) {
|
||||
console.error("Initial record editor load failed:", err);
|
||||
infoBar.textContent = `Error: ${err.message}`;
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,508 @@
|
||||
import { app } from "/scripts/app.web";
|
||||
|
||||
function findWidget(node, name) {
|
||||
return node.widgets?.find((w) => w.name === name);
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || "Unknown error");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async function fetchTabularRecords(collectionName) {
|
||||
if (!collectionName || String(collectionName).startsWith("<")) {
|
||||
return {
|
||||
headings: [],
|
||||
flat_records: [],
|
||||
record_ids: [],
|
||||
default_record_id: "",
|
||||
record_count: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const url = `/zmongo/tabular_records?collection_name=${encodeURIComponent(collectionName)}`;
|
||||
return await fetchJson(url);
|
||||
}
|
||||
|
||||
async function fetchTabularRecordsSearch(collectionName, searchText, flattenedFieldName) {
|
||||
if (!collectionName || String(collectionName).startsWith("<")) {
|
||||
return {
|
||||
headings: [],
|
||||
flat_records: [],
|
||||
record_ids: [],
|
||||
default_record_id: "",
|
||||
record_count: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const url =
|
||||
`/zmongo/tabular_records_search?collection_name=${encodeURIComponent(collectionName)}` +
|
||||
`&search_text=${encodeURIComponent(searchText ?? "")}` +
|
||||
`&flattened_field_name=${encodeURIComponent(flattenedFieldName ?? "")}`;
|
||||
|
||||
return await fetchJson(url);
|
||||
}
|
||||
|
||||
function clampIndex(value, maxLen) {
|
||||
if (!maxLen || maxLen <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const n = Number(value);
|
||||
if (Number.isNaN(n)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(n, maxLen - 1));
|
||||
}
|
||||
|
||||
function buildCell(content, isHeader = false) {
|
||||
const cell = document.createElement(isHeader ? "th" : "td");
|
||||
cell.style.border = "1px solid #444";
|
||||
cell.style.padding = "0";
|
||||
cell.style.verticalAlign = "top";
|
||||
cell.style.background = isHeader ? "#2a2a2a" : "#1d1d1d";
|
||||
cell.style.color = "#ddd";
|
||||
|
||||
const inner = document.createElement("div");
|
||||
inner.textContent = content ?? "";
|
||||
inner.style.minWidth = "140px";
|
||||
inner.style.maxWidth = "480px";
|
||||
inner.style.padding = "6px";
|
||||
inner.style.whiteSpace = "pre-wrap";
|
||||
inner.style.overflowWrap = "anywhere";
|
||||
inner.style.wordBreak = "break-word";
|
||||
inner.style.resize = "horizontal";
|
||||
inner.style.overflow = "auto";
|
||||
inner.style.boxSizing = "border-box";
|
||||
|
||||
if (isHeader) {
|
||||
inner.style.fontWeight = "600";
|
||||
inner.style.cursor = "pointer";
|
||||
inner.style.userSelect = "none";
|
||||
}
|
||||
|
||||
cell.appendChild(inner);
|
||||
return { cell, inner };
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ZMongo.TabularRecordView",
|
||||
|
||||
async nodeCreated(node) {
|
||||
if (node.comfyClass !== "ZMongoTabularRecordViewNode") {
|
||||
return;
|
||||
}
|
||||
|
||||
function updateSearchModeLabel() {
|
||||
const flattenedFieldName = String(fieldWidget.value || "").trim();
|
||||
const searchText = String(exactSearchInput.value || "").trim();
|
||||
|
||||
const targetText = flattenedFieldName
|
||||
? `field "${flattenedFieldName}"`
|
||||
: "all fields";
|
||||
|
||||
const queryText = searchText
|
||||
? ` | query: "${searchText}"`
|
||||
: "";
|
||||
|
||||
searchModeLabel.textContent = `Search target: ${targetText}${queryText}`;
|
||||
}
|
||||
|
||||
const collectionWidget = findWidget(node, "collection_name");
|
||||
const fieldWidget = findWidget(node, "flattened_field_name");
|
||||
const recordIdWidget = findWidget(node, "record_id");
|
||||
const indexWidget = findWidget(node, "selected_record_index");
|
||||
|
||||
if (!collectionWidget || !fieldWidget || !recordIdWidget || !indexWidget) {
|
||||
console.warn("ZMongoTabularRecordViewNode widgets not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.style.display = "flex";
|
||||
container.style.flexDirection = "column";
|
||||
container.style.gap = "6px";
|
||||
container.style.width = "100%";
|
||||
container.style.minHeight = "260px";
|
||||
container.style.maxHeight = "520px";
|
||||
container.style.background = "#111";
|
||||
container.style.border = "1px solid #333";
|
||||
container.style.borderRadius = "8px";
|
||||
container.style.padding = "8px";
|
||||
container.style.boxSizing = "border-box";
|
||||
|
||||
const infoBar = document.createElement("div");
|
||||
infoBar.style.fontSize = "12px";
|
||||
infoBar.style.color = "#aaa";
|
||||
infoBar.textContent = "Loading records...";
|
||||
|
||||
const searchModeLabel = document.createElement("div");
|
||||
searchModeLabel.style.fontSize = "11px";
|
||||
searchModeLabel.style.color = "#8fb3ff";
|
||||
searchModeLabel.textContent = "Search target: all fields";
|
||||
|
||||
const searchBar = document.createElement("div");
|
||||
searchBar.style.display = "flex";
|
||||
searchBar.style.gap = "8px";
|
||||
searchBar.style.alignItems = "center";
|
||||
searchBar.style.flexWrap = "wrap";
|
||||
|
||||
const exactSearchInput = document.createElement("input");
|
||||
exactSearchInput.type = "text";
|
||||
exactSearchInput.placeholder = "Search values (* and ? wildcards supported)";
|
||||
exactSearchInput.style.flex = "1";
|
||||
exactSearchInput.style.minWidth = "220px";
|
||||
exactSearchInput.style.padding = "6px 8px";
|
||||
exactSearchInput.style.borderRadius = "6px";
|
||||
exactSearchInput.style.border = "1px solid #444";
|
||||
exactSearchInput.style.background = "#1b1b1b";
|
||||
exactSearchInput.style.color = "#ddd";
|
||||
exactSearchInput.style.outline = "none";
|
||||
|
||||
const exactSearchButton = document.createElement("button");
|
||||
exactSearchButton.textContent = "Search";
|
||||
exactSearchButton.style.padding = "6px 10px";
|
||||
exactSearchButton.style.borderRadius = "6px";
|
||||
exactSearchButton.style.border = "1px solid #555";
|
||||
exactSearchButton.style.background = "#2a2a2a";
|
||||
exactSearchButton.style.color = "#eee";
|
||||
exactSearchButton.style.cursor = "pointer";
|
||||
|
||||
const clearSearchButton = document.createElement("button");
|
||||
clearSearchButton.textContent = "Show All";
|
||||
clearSearchButton.style.padding = "6px 10px";
|
||||
clearSearchButton.style.borderRadius = "6px";
|
||||
clearSearchButton.style.border = "1px solid #555";
|
||||
clearSearchButton.style.background = "#2a2a2a";
|
||||
clearSearchButton.style.color = "#eee";
|
||||
clearSearchButton.style.cursor = "pointer";
|
||||
|
||||
searchBar.appendChild(exactSearchInput);
|
||||
searchBar.appendChild(exactSearchButton);
|
||||
searchBar.appendChild(clearSearchButton);
|
||||
|
||||
const tableWrap = document.createElement("div");
|
||||
tableWrap.style.flex = "1";
|
||||
tableWrap.style.overflow = "auto";
|
||||
tableWrap.style.border = "1px solid #333";
|
||||
tableWrap.style.borderRadius = "6px";
|
||||
tableWrap.style.background = "#181818";
|
||||
|
||||
const table = document.createElement("table");
|
||||
table.style.borderCollapse = "collapse";
|
||||
table.style.width = "max-content";
|
||||
table.style.minWidth = "100%";
|
||||
table.style.tableLayout = "auto";
|
||||
table.style.fontSize = "12px";
|
||||
|
||||
tableWrap.appendChild(table);
|
||||
container.appendChild(infoBar);
|
||||
container.appendChild(searchModeLabel);
|
||||
container.appendChild(searchBar);
|
||||
container.appendChild(tableWrap);
|
||||
|
||||
if (node.addDOMWidget) {
|
||||
node.addDOMWidget("record_table", "div", container, {
|
||||
serialize: false,
|
||||
hideOnZoom: false,
|
||||
});
|
||||
}
|
||||
|
||||
let lastPayload = {
|
||||
headings: [],
|
||||
flat_records: [],
|
||||
record_ids: [],
|
||||
default_record_id: "",
|
||||
record_count: 0,
|
||||
};
|
||||
|
||||
function getSafeRecordIds() {
|
||||
return Array.isArray(lastPayload.record_ids) ? lastPayload.record_ids : [];
|
||||
}
|
||||
|
||||
function resolveSelectedIndex() {
|
||||
const recordIds = getSafeRecordIds();
|
||||
const recordId = String(recordIdWidget.value || "").trim();
|
||||
|
||||
if (recordId) {
|
||||
const idx = recordIds.indexOf(recordId);
|
||||
if (idx >= 0) {
|
||||
return idx;
|
||||
}
|
||||
}
|
||||
|
||||
return clampIndex(indexWidget.value, recordIds.length);
|
||||
}
|
||||
|
||||
function syncWidgetsFromIndex(preferredIndex = null) {
|
||||
const recordIds = getSafeRecordIds();
|
||||
|
||||
if (!recordIds.length) {
|
||||
indexWidget.value = 0;
|
||||
recordIdWidget.value = "";
|
||||
node.setDirtyCanvas(true, true);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const idx = clampIndex(
|
||||
preferredIndex !== null ? preferredIndex : indexWidget.value,
|
||||
recordIds.length
|
||||
);
|
||||
|
||||
indexWidget.value = idx;
|
||||
recordIdWidget.value = recordIds[idx] || "";
|
||||
node.setDirtyCanvas(true, true);
|
||||
return idx;
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
table.innerHTML = "";
|
||||
|
||||
const headings = Array.isArray(lastPayload.headings) ? lastPayload.headings : [];
|
||||
const records = Array.isArray(lastPayload.flat_records) ? lastPayload.flat_records : [];
|
||||
const recordIds = getSafeRecordIds();
|
||||
|
||||
if (!headings.length || !records.length) {
|
||||
infoBar.textContent = "No records found.";
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedIndex = resolveSelectedIndex();
|
||||
infoBar.textContent = `${records.length} record(s) loaded. Click a row to select it. Click a header to set flattened_field_name.`;
|
||||
|
||||
const thead = document.createElement("thead");
|
||||
const headRow = document.createElement("tr");
|
||||
|
||||
const indexHeader = buildCell("#", true);
|
||||
indexHeader.inner.style.minWidth = "50px";
|
||||
indexHeader.inner.style.maxWidth = "80px";
|
||||
headRow.appendChild(indexHeader.cell);
|
||||
|
||||
for (const heading of headings) {
|
||||
const { cell, inner } = buildCell(heading, true);
|
||||
inner.title = heading;
|
||||
inner.addEventListener("click", () => {
|
||||
fieldWidget.value = heading;
|
||||
updateSearchModeLabel();
|
||||
node.setDirtyCanvas(true, true);
|
||||
});
|
||||
headRow.appendChild(cell);
|
||||
}
|
||||
|
||||
thead.appendChild(headRow);
|
||||
table.appendChild(thead);
|
||||
|
||||
const tbody = document.createElement("tbody");
|
||||
|
||||
records.forEach((record, rowIndex) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.style.cursor = "pointer";
|
||||
|
||||
if (rowIndex === selectedIndex) {
|
||||
tr.style.background = "#24364d";
|
||||
}
|
||||
|
||||
tr.addEventListener("click", () => {
|
||||
indexWidget.value = rowIndex;
|
||||
recordIdWidget.value = recordIds[rowIndex] || "";
|
||||
renderTable();
|
||||
node.setDirtyCanvas(true, true);
|
||||
});
|
||||
|
||||
const indexCell = buildCell(String(rowIndex), false);
|
||||
indexCell.inner.style.minWidth = "50px";
|
||||
indexCell.inner.style.maxWidth = "80px";
|
||||
tr.appendChild(indexCell.cell);
|
||||
|
||||
for (const heading of headings) {
|
||||
let value = record[heading];
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
value = "";
|
||||
} else if (typeof value !== "string") {
|
||||
try {
|
||||
value = JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
value = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
const { cell, inner } = buildCell(value, false);
|
||||
inner.title = value;
|
||||
tr.appendChild(cell);
|
||||
}
|
||||
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
table.appendChild(tbody);
|
||||
}
|
||||
|
||||
async function refreshTable() {
|
||||
const collectionName = collectionWidget.value;
|
||||
lastPayload = await fetchTabularRecords(collectionName);
|
||||
|
||||
const recordIds = getSafeRecordIds();
|
||||
if (recordIds.length) {
|
||||
const currentId = String(recordIdWidget.value || "").trim();
|
||||
|
||||
if (!currentId || !recordIds.includes(currentId)) {
|
||||
recordIdWidget.value = lastPayload.default_record_id || recordIds[0] || "";
|
||||
}
|
||||
|
||||
const idx = recordIds.indexOf(recordIdWidget.value);
|
||||
indexWidget.value = idx >= 0 ? idx : 0;
|
||||
} else {
|
||||
recordIdWidget.value = "";
|
||||
indexWidget.value = 0;
|
||||
}
|
||||
|
||||
renderTable();
|
||||
node.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
async function refreshTableSearch(searchText) {
|
||||
const collectionName = collectionWidget.value;
|
||||
const flattenedFieldName = String(fieldWidget.value || "").trim();
|
||||
|
||||
lastPayload = await fetchTabularRecordsSearch(
|
||||
collectionName,
|
||||
searchText,
|
||||
flattenedFieldName
|
||||
);
|
||||
|
||||
const recordIds = getSafeRecordIds();
|
||||
if (recordIds.length) {
|
||||
recordIdWidget.value = lastPayload.default_record_id || recordIds[0] || "";
|
||||
indexWidget.value = 0;
|
||||
} else {
|
||||
recordIdWidget.value = "";
|
||||
indexWidget.value = 0;
|
||||
}
|
||||
|
||||
renderTable();
|
||||
node.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
exactSearchButton.addEventListener("click", async () => {
|
||||
updateSearchModeLabel();
|
||||
try {
|
||||
await refreshTableSearch(exactSearchInput.value);
|
||||
updateSearchModeLabel();
|
||||
infoBar.textContent = `Search returned ${lastPayload.record_count || 0} record(s).`;
|
||||
} catch (err) {
|
||||
console.error("Search failed:", err);
|
||||
infoBar.textContent = `Error: ${err.message}`;
|
||||
}
|
||||
});
|
||||
|
||||
exactSearchInput.addEventListener("keydown", async (event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
updateSearchModeLabel();
|
||||
try {
|
||||
await refreshTableSearch(exactSearchInput.value);
|
||||
updateSearchModeLabel();
|
||||
infoBar.textContent = `Search returned ${lastPayload.record_count || 0} record(s).`;
|
||||
} catch (err) {
|
||||
console.error("Search failed:", err);
|
||||
infoBar.textContent = `Error: ${err.message}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
clearSearchButton.addEventListener("click", async () => {
|
||||
updateSearchModeLabel();
|
||||
try {
|
||||
exactSearchInput.value = "";
|
||||
await refreshTable();
|
||||
updateSearchModeLabel();
|
||||
} catch (err) {
|
||||
console.error("Refresh all records failed:", err);
|
||||
infoBar.textContent = `Error: ${err.message}`;
|
||||
}
|
||||
});
|
||||
|
||||
const originalCollectionCallback = collectionWidget.callback;
|
||||
collectionWidget.callback = async (...args) => {
|
||||
if (originalCollectionCallback) {
|
||||
originalCollectionCallback.apply(collectionWidget, args);
|
||||
}
|
||||
updateSearchModeLabel();
|
||||
try {
|
||||
exactSearchInput.value = "";
|
||||
await refreshTable();
|
||||
} catch (err) {
|
||||
console.error("Failed to refresh tabular records after collection change:", err);
|
||||
infoBar.textContent = `Error: ${err.message}`;
|
||||
}
|
||||
};
|
||||
|
||||
const originalFieldCallback = fieldWidget.callback;
|
||||
fieldWidget.callback = (...args) => {
|
||||
if (originalFieldCallback) {
|
||||
originalFieldCallback.apply(fieldWidget, args);
|
||||
}
|
||||
updateSearchModeLabel();
|
||||
node.setDirtyCanvas(true, true);
|
||||
};
|
||||
|
||||
const originalIndexCallback = indexWidget.callback;
|
||||
indexWidget.callback = (...args) => {
|
||||
if (originalIndexCallback) {
|
||||
originalIndexCallback.apply(indexWidget, args);
|
||||
}
|
||||
syncWidgetsFromIndex(indexWidget.value);
|
||||
renderTable();
|
||||
};
|
||||
|
||||
const originalRecordIdCallback = recordIdWidget.callback;
|
||||
recordIdWidget.callback = (...args) => {
|
||||
if (originalRecordIdCallback) {
|
||||
originalRecordIdCallback.apply(recordIdWidget, args);
|
||||
}
|
||||
|
||||
const ids = getSafeRecordIds();
|
||||
const idx = ids.indexOf(String(recordIdWidget.value || "").trim());
|
||||
|
||||
if (idx >= 0) {
|
||||
indexWidget.value = idx;
|
||||
} else if (ids.length) {
|
||||
recordIdWidget.value = ids[0];
|
||||
indexWidget.value = 0;
|
||||
} else {
|
||||
recordIdWidget.value = "";
|
||||
indexWidget.value = 0;
|
||||
}
|
||||
|
||||
renderTable();
|
||||
node.setDirtyCanvas(true, true);
|
||||
};
|
||||
updateSearchModeLabel();
|
||||
try {
|
||||
await refreshTable();
|
||||
} catch (err) {
|
||||
console.error("Initial ZMongo tabular record refresh failed:", err);
|
||||
infoBar.textContent = `Error: ${err.message}`;
|
||||
}
|
||||
|
||||
setTimeout(async () => {
|
||||
updateSearchModeLabel();
|
||||
try {
|
||||
await refreshTable();
|
||||
} catch (err) {
|
||||
console.error("Delayed ZMongo tabular record refresh failed:", err);
|
||||
}
|
||||
}, 200);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "ZMongo.UniversalModelAdapter",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== "ZMongoUniversalModelAdapterNode") {
|
||||
return;
|
||||
}
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const result = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
||||
|
||||
this.zmongoActiveOutputs = [];
|
||||
this.zmongoCompatible = false;
|
||||
|
||||
const statusWidget = this.addWidget(
|
||||
"text",
|
||||
"adapter_status",
|
||||
"No model metadata yet",
|
||||
() => {},
|
||||
{ multiline: true }
|
||||
);
|
||||
statusWidget.readOnly = true;
|
||||
|
||||
const updateVisualState = () => {
|
||||
const active = new Set(this.zmongoActiveOutputs || []);
|
||||
const outputs = this.outputs || [];
|
||||
|
||||
for (const output of outputs) {
|
||||
if (!output) continue;
|
||||
const isActive = active.has(output.name);
|
||||
|
||||
// Visual hint only
|
||||
output.label = isActive
|
||||
? `${output.name} ✓`
|
||||
: `${output.name} ✗`;
|
||||
|
||||
output.color_on = isActive ? "#6aa84f" : "#666666";
|
||||
output.color_off = isActive ? "#38761d" : "#333333";
|
||||
}
|
||||
|
||||
this.bgcolor = this.zmongoCompatible ? "#1f3b24" : "#3b1f1f";
|
||||
this.setDirtyCanvas(true, true);
|
||||
};
|
||||
|
||||
const onExecuted = this.onExecuted;
|
||||
this.onExecuted = function (message) {
|
||||
if (onExecuted) {
|
||||
onExecuted.apply(this, arguments);
|
||||
}
|
||||
|
||||
try {
|
||||
// RETURN_NAMES order:
|
||||
// model, clip, vae, status_text, active_outputs_json, is_compatible, model_type
|
||||
const statusText = message?.text?.[0] ?? "";
|
||||
const activeOutputsJson = message?.text?.[1] ?? "";
|
||||
const compatible = message?.text?.[2];
|
||||
|
||||
const widget = this.widgets?.find((w) => w.name === "adapter_status");
|
||||
if (widget) {
|
||||
widget.value = statusText || "No status";
|
||||
}
|
||||
|
||||
let parsed = {};
|
||||
try {
|
||||
parsed = activeOutputsJson ? JSON.parse(activeOutputsJson) : {};
|
||||
} catch {
|
||||
parsed = {};
|
||||
}
|
||||
|
||||
this.zmongoActiveOutputs = parsed.active_outputs || [];
|
||||
this.zmongoCompatible = Boolean(
|
||||
parsed.compatible ?? compatible ?? false
|
||||
);
|
||||
|
||||
updateVisualState();
|
||||
} catch (err) {
|
||||
console.warn("ZMongo adapter UI update failed:", err);
|
||||
}
|
||||
};
|
||||
|
||||
updateVisualState();
|
||||
return result;
|
||||
};
|
||||
},
|
||||
});
|
||||