Save Current State: zmongo nodes refactor

This commit is contained in:
CentralFloridaAttorney
2026-04-03 13:20:49 -04:00
parent e2c8b7f08d
commit 35d908e4d9
29 changed files with 7636 additions and 0 deletions
+224
View File
@@ -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,
)
+93
View File
@@ -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

+17
View File
@@ -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(),
)
+213
View File
@@ -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",
}
File diff suppressed because it is too large Load Diff
+259
View File
@@ -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",
}
+55
View File
@@ -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)
+367
View File
@@ -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",
}
+169
View File
@@ -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);
},
});
+336
View File
@@ -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);
}
});
}
}
});
+148
View File
@@ -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);
},
});
+145
View File
@@ -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);
},
});
+332
View File
@@ -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}`;
}
},
});
+508
View File
@@ -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);
},
});
+88
View File
@@ -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;
};
},
});