cddb07a176
docs / deploy (push) Has been cancelled
docs / changes (push) Has been cancelled
docs / check-and-build (push) Has been cancelled
build container image / cpu (push) Has been cancelled
build container image / cuda (push) Has been cancelled
build container image / rocm (push) Has been cancelled
frontend checks / frontend-checks (push) Has been cancelled
frontend tests / frontend-tests (push) Has been cancelled
lfs checks / lfs-check (push) Has been cancelled
python checks / python-checks (push) Has been cancelled
python tests / py3.12: macos-default (push) Has been cancelled
python tests / py3.11: windows-cpu (push) Has been cancelled
python tests / py3.12: windows-cpu (push) Has been cancelled
python tests / py3.11: linux-cpu (push) Has been cancelled
typegen checks / typegen-checks (push) Has been cancelled
uv lock checks / uv-lock-checks (push) Has been cancelled
openapi checks / openapi-checks (push) Has been cancelled
python tests / py3.11: macos-default (push) Has been cancelled
python tests / py3.12: linux-cpu (push) Has been cancelled
505 lines
19 KiB
Python
505 lines
19 KiB
Python
"""FastAPI routes for custom node management."""
|
|
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import traceback
|
|
from importlib.util import module_from_spec, spec_from_file_location
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from fastapi import Body
|
|
from fastapi.routing import APIRouter
|
|
from pydantic import BaseModel, Field
|
|
|
|
from invokeai.app.api.auth_dependencies import AdminUserOrDefault
|
|
from invokeai.app.api.dependencies import ApiDependencies
|
|
from invokeai.app.invocations.baseinvocation import InvocationRegistry
|
|
from invokeai.app.services.config.config_default import get_config
|
|
from invokeai.app.services.workflow_records.workflow_records_common import WorkflowWithoutIDValidator
|
|
from invokeai.backend.util.logging import InvokeAILogger
|
|
|
|
custom_nodes_router = APIRouter(prefix="/v2/custom_nodes", tags=["custom_nodes"])
|
|
|
|
logger = InvokeAILogger.get_logger()
|
|
|
|
# Name of the manifest file written inside a pack directory to track which workflows
|
|
# were imported by that pack. Used on uninstall to delete only pack-imported workflows
|
|
# — deleting by tag alone is unsafe because users can edit tags on their own workflows.
|
|
PACK_MANIFEST_FILENAME = ".invokeai_pack_manifest.json"
|
|
|
|
|
|
class NodePackInfo(BaseModel):
|
|
"""Information about an installed node pack."""
|
|
|
|
name: str = Field(description="The name of the node pack.")
|
|
path: str = Field(description="The path to the node pack directory.")
|
|
node_count: int = Field(description="The number of nodes in the pack.")
|
|
node_types: list[str] = Field(description="The invocation types provided by this node pack.")
|
|
|
|
|
|
class NodePackListResponse(BaseModel):
|
|
"""Response for listing installed node packs."""
|
|
|
|
node_packs: list[NodePackInfo] = Field(description="List of installed node packs.")
|
|
custom_nodes_path: str = Field(description="The configured custom nodes directory path.")
|
|
|
|
|
|
class InstallNodePackRequest(BaseModel):
|
|
"""Request to install a node pack from a git URL."""
|
|
|
|
source: str = Field(description="Git URL of the node pack to install.")
|
|
|
|
|
|
class InstallNodePackResponse(BaseModel):
|
|
"""Response after installing a node pack."""
|
|
|
|
name: str = Field(description="The name of the installed node pack.")
|
|
success: bool = Field(description="Whether the installation was successful.")
|
|
message: str = Field(description="Status message.")
|
|
workflows_imported: int = Field(default=0, description="Number of workflows imported from the pack.")
|
|
requires_dependencies: bool = Field(
|
|
default=False,
|
|
description="Whether the pack ships a dependency manifest (requirements.txt or pyproject.toml) "
|
|
"that the user must install manually following the pack's documentation.",
|
|
)
|
|
dependency_file: Optional[str] = Field(
|
|
default=None,
|
|
description="Name of the detected dependency manifest file, if any.",
|
|
)
|
|
|
|
|
|
class UninstallNodePackResponse(BaseModel):
|
|
"""Response after uninstalling a node pack."""
|
|
|
|
name: str = Field(description="The name of the uninstalled node pack.")
|
|
success: bool = Field(description="Whether the uninstall was successful.")
|
|
message: str = Field(description="Status message.")
|
|
|
|
|
|
def _get_custom_nodes_path() -> Path:
|
|
"""Returns the configured custom nodes directory path."""
|
|
config = get_config()
|
|
return config.custom_nodes_path
|
|
|
|
|
|
def _get_installed_packs() -> list[NodePackInfo]:
|
|
"""Scans the custom nodes directory and returns info about installed packs."""
|
|
custom_nodes_path = _get_custom_nodes_path()
|
|
|
|
if not custom_nodes_path.exists():
|
|
return []
|
|
|
|
packs: list[NodePackInfo] = []
|
|
|
|
# Get all node types grouped by node_pack
|
|
node_types_by_pack: dict[str, list[str]] = {}
|
|
for inv_class in InvocationRegistry._invocation_classes:
|
|
node_pack = inv_class.UIConfig.node_pack
|
|
inv_type = inv_class.get_type()
|
|
if node_pack not in node_types_by_pack:
|
|
node_types_by_pack[node_pack] = []
|
|
node_types_by_pack[node_pack].append(inv_type)
|
|
|
|
for d in sorted(custom_nodes_path.iterdir()):
|
|
if not d.is_dir():
|
|
continue
|
|
if d.name.startswith("_") or d.name.startswith("."):
|
|
continue
|
|
init = d / "__init__.py"
|
|
if not init.exists():
|
|
continue
|
|
|
|
pack_name = d.name
|
|
node_types = node_types_by_pack.get(pack_name, [])
|
|
|
|
packs.append(
|
|
NodePackInfo(
|
|
name=pack_name,
|
|
path=str(d),
|
|
node_count=len(node_types),
|
|
node_types=node_types,
|
|
)
|
|
)
|
|
|
|
return packs
|
|
|
|
|
|
@custom_nodes_router.get(
|
|
"/",
|
|
operation_id="list_custom_node_packs",
|
|
response_model=NodePackListResponse,
|
|
)
|
|
async def list_custom_node_packs(current_admin: AdminUserOrDefault) -> NodePackListResponse:
|
|
"""Lists all installed custom node packs.
|
|
|
|
Admin-only: the response includes absolute filesystem paths, and non-admins have no
|
|
legitimate use for pack management data (install/uninstall/reload are also admin-only).
|
|
"""
|
|
packs = _get_installed_packs()
|
|
return NodePackListResponse(node_packs=packs, custom_nodes_path=str(_get_custom_nodes_path()))
|
|
|
|
|
|
@custom_nodes_router.post(
|
|
"/install",
|
|
operation_id="install_custom_node_pack",
|
|
response_model=InstallNodePackResponse,
|
|
)
|
|
async def install_custom_node_pack(
|
|
current_admin: AdminUserOrDefault,
|
|
request: InstallNodePackRequest = Body(description="The source URL to install from."),
|
|
) -> InstallNodePackResponse:
|
|
"""Installs a custom node pack from a git URL by cloning it into the nodes directory."""
|
|
custom_nodes_path = _get_custom_nodes_path()
|
|
custom_nodes_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
source = request.source.strip()
|
|
|
|
# Extract pack name from URL
|
|
pack_name = source.rstrip("/").split("/")[-1]
|
|
if pack_name.endswith(".git"):
|
|
pack_name = pack_name[:-4]
|
|
|
|
target_dir = custom_nodes_path / pack_name
|
|
|
|
if target_dir.exists():
|
|
return InstallNodePackResponse(
|
|
name=pack_name,
|
|
success=False,
|
|
message=f"Node pack '{pack_name}' already exists. Uninstall it first to reinstall.",
|
|
)
|
|
|
|
try:
|
|
# Clone the repository
|
|
result = subprocess.run(
|
|
["git", "clone", source, str(target_dir)],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=120,
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
# Clean up on failure
|
|
if target_dir.exists():
|
|
shutil.rmtree(target_dir)
|
|
return InstallNodePackResponse(
|
|
name=pack_name,
|
|
success=False,
|
|
message=f"Git clone failed: {result.stderr.strip()}",
|
|
)
|
|
|
|
# Detect dependency manifests but do NOT install them automatically.
|
|
# The user is responsible for installing dependencies per the pack's documentation,
|
|
# since arbitrary pip installs can break the InvokeAI environment.
|
|
dependency_file: Optional[str] = None
|
|
for candidate in ("requirements.txt", "pyproject.toml"):
|
|
if (target_dir / candidate).exists():
|
|
dependency_file = candidate
|
|
logger.info(f"Node pack '{pack_name}' ships a {candidate}; user must install dependencies manually.")
|
|
break
|
|
|
|
# Check for __init__.py
|
|
init_file = target_dir / "__init__.py"
|
|
if not init_file.exists():
|
|
shutil.rmtree(target_dir)
|
|
return InstallNodePackResponse(
|
|
name=pack_name,
|
|
success=False,
|
|
message=f"Node pack '{pack_name}' does not contain an __init__.py file.",
|
|
)
|
|
|
|
# Load the node pack at runtime
|
|
_load_node_pack(pack_name, target_dir)
|
|
|
|
# Import any workflows found in the pack, owned by the installing admin and shared with all users
|
|
imported_workflow_ids = _import_workflows_from_pack(target_dir, pack_name, owner_user_id=current_admin.user_id)
|
|
_write_pack_manifest(target_dir, imported_workflow_ids)
|
|
workflows_imported = len(imported_workflow_ids)
|
|
workflow_msg = f" Imported {workflows_imported} workflow(s)." if workflows_imported > 0 else ""
|
|
dependency_msg = (
|
|
f" This pack includes a {dependency_file} — install its dependencies manually following the pack's documentation."
|
|
if dependency_file
|
|
else ""
|
|
)
|
|
|
|
return InstallNodePackResponse(
|
|
name=pack_name,
|
|
success=True,
|
|
message=f"Successfully installed node pack '{pack_name}'.{workflow_msg}{dependency_msg}",
|
|
workflows_imported=workflows_imported,
|
|
requires_dependencies=dependency_file is not None,
|
|
dependency_file=dependency_file,
|
|
)
|
|
|
|
except subprocess.TimeoutExpired:
|
|
if target_dir.exists():
|
|
shutil.rmtree(target_dir)
|
|
return InstallNodePackResponse(
|
|
name=pack_name,
|
|
success=False,
|
|
message="Installation timed out.",
|
|
)
|
|
except Exception:
|
|
if target_dir.exists():
|
|
shutil.rmtree(target_dir)
|
|
error = traceback.format_exc()
|
|
logger.error(f"Failed to install node pack {pack_name}: {error}")
|
|
return InstallNodePackResponse(
|
|
name=pack_name,
|
|
success=False,
|
|
message=f"Installation failed: {error}",
|
|
)
|
|
|
|
|
|
@custom_nodes_router.delete(
|
|
"/{pack_name}",
|
|
operation_id="uninstall_custom_node_pack",
|
|
response_model=UninstallNodePackResponse,
|
|
)
|
|
async def uninstall_custom_node_pack(
|
|
current_admin: AdminUserOrDefault,
|
|
pack_name: str,
|
|
) -> UninstallNodePackResponse:
|
|
"""Uninstalls a custom node pack by removing its directory.
|
|
|
|
Note: A restart is required for the node removal to take full effect.
|
|
Installed nodes from the pack will remain registered until restart.
|
|
"""
|
|
custom_nodes_path = _get_custom_nodes_path()
|
|
target_dir = custom_nodes_path / pack_name
|
|
|
|
if not target_dir.exists():
|
|
return UninstallNodePackResponse(
|
|
name=pack_name,
|
|
success=False,
|
|
message=f"Node pack '{pack_name}' not found.",
|
|
)
|
|
|
|
try:
|
|
# Read the manifest BEFORE removing the directory — it records exactly which
|
|
# workflow IDs this pack imported, so uninstall doesn't accidentally delete
|
|
# user workflows that happen to share the pack tag.
|
|
imported_workflow_ids = _read_pack_manifest(target_dir)
|
|
|
|
shutil.rmtree(target_dir)
|
|
|
|
# Unregister the nodes from the registry so they disappear immediately
|
|
removed_types = InvocationRegistry.unregister_pack(pack_name)
|
|
if removed_types:
|
|
# Invalidate OpenAPI schema cache so frontend gets updated node definitions
|
|
from invokeai.app.api_app import app
|
|
|
|
app.openapi_schema = None
|
|
logger.info(
|
|
f"Unregistered {len(removed_types)} node(s) from pack '{pack_name}': {', '.join(removed_types)}"
|
|
)
|
|
|
|
# Remove the pack's module subtree from sys.modules. Only dropping the
|
|
# root module would leave submodules cached; on reinstall the cached
|
|
# submodules would be reused without re-running their @invocation
|
|
# decorators, so the pack would show up with 0 nodes until restart.
|
|
_purge_pack_modules(pack_name)
|
|
|
|
# Remove only workflows this pack imported, using the manifest-recorded IDs
|
|
workflows_removed = _remove_workflows_by_ids(imported_workflow_ids, pack_name)
|
|
workflow_msg = f" Removed {workflows_removed} workflow(s)." if workflows_removed > 0 else ""
|
|
|
|
return UninstallNodePackResponse(
|
|
name=pack_name,
|
|
success=True,
|
|
message=f"Successfully uninstalled node pack '{pack_name}'.{workflow_msg}",
|
|
)
|
|
except Exception:
|
|
error = traceback.format_exc()
|
|
logger.error(f"Failed to uninstall node pack {pack_name}: {error}")
|
|
return UninstallNodePackResponse(
|
|
name=pack_name,
|
|
success=False,
|
|
message=f"Uninstall failed: {error}",
|
|
)
|
|
|
|
|
|
@custom_nodes_router.post(
|
|
"/reload",
|
|
operation_id="reload_custom_nodes",
|
|
)
|
|
async def reload_custom_nodes(current_admin: AdminUserOrDefault) -> dict[str, str]:
|
|
"""Triggers a reload of all custom nodes.
|
|
|
|
This re-scans the nodes directory and loads any new node packs.
|
|
Already loaded packs are skipped.
|
|
"""
|
|
config = get_config()
|
|
custom_nodes_path = config.custom_nodes_path
|
|
|
|
if not custom_nodes_path.exists():
|
|
return {"status": "No custom nodes directory found."}
|
|
|
|
from invokeai.app.invocations.load_custom_nodes import load_custom_nodes
|
|
|
|
load_custom_nodes(custom_nodes_path, logger)
|
|
|
|
# Invalidate the OpenAPI schema cache so the frontend gets updated node definitions
|
|
from invokeai.app.api_app import app
|
|
|
|
app.openapi_schema = None
|
|
|
|
return {"status": "Custom nodes reloaded successfully."}
|
|
|
|
|
|
def _purge_pack_modules(pack_name: str) -> list[str]:
|
|
"""Removes the pack's root module and all of its submodules from sys.modules.
|
|
|
|
After uninstall, cached submodules (e.g. `pack_name.nodes`, `pack_name.foo.bar`)
|
|
must be evicted as well — otherwise a subsequent reinstall reuses the cached
|
|
objects, the @invocation decorators never re-run, and the pack ends up loaded
|
|
with zero registered nodes until a full process restart.
|
|
"""
|
|
prefix = f"{pack_name}."
|
|
to_remove = [name for name in sys.modules if name == pack_name or name.startswith(prefix)]
|
|
for name in to_remove:
|
|
del sys.modules[name]
|
|
return to_remove
|
|
|
|
|
|
def _load_node_pack(pack_name: str, pack_dir: Path) -> None:
|
|
"""Loads a single node pack at runtime."""
|
|
init = pack_dir / "__init__.py"
|
|
if not init.exists():
|
|
return
|
|
|
|
if pack_name in sys.modules:
|
|
logger.info(f"Node pack {pack_name} already loaded, skipping.")
|
|
return
|
|
|
|
spec = spec_from_file_location(pack_name, init.absolute())
|
|
if spec is None or spec.loader is None:
|
|
logger.warning(f"Could not load {init}")
|
|
return
|
|
|
|
logger.info(f"Loading node pack {pack_name}")
|
|
module = module_from_spec(spec)
|
|
sys.modules[spec.name] = module
|
|
spec.loader.exec_module(module)
|
|
|
|
# Invalidate OpenAPI schema cache
|
|
from invokeai.app.api_app import app
|
|
|
|
app.openapi_schema = None
|
|
|
|
logger.info(f"Successfully loaded node pack {pack_name}")
|
|
|
|
|
|
def _import_workflows_from_pack(pack_dir: Path, pack_name: str, owner_user_id: str) -> list[str]:
|
|
"""Scans a node pack directory for workflow JSON files and imports them into the workflow library.
|
|
|
|
A JSON file is considered a workflow if it contains 'nodes' and 'edges' keys at the top level.
|
|
Workflows are imported as user workflows owned by the installing admin and marked public so all
|
|
users can see them — a pack is an admin-installed shared resource, not a private asset.
|
|
|
|
Returns the list of workflow IDs successfully created, in import order.
|
|
"""
|
|
imported_ids: list[str] = []
|
|
|
|
# Search for .json files recursively
|
|
for json_file in pack_dir.rglob("*.json"):
|
|
# Skip our own manifest file
|
|
if json_file.name == PACK_MANIFEST_FILENAME:
|
|
continue
|
|
try:
|
|
with open(json_file, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
|
|
# Check if this looks like a workflow (must have nodes and edges)
|
|
if not isinstance(data, dict):
|
|
continue
|
|
if "nodes" not in data or "edges" not in data:
|
|
continue
|
|
|
|
# Ensure the workflow has a meta section with category set to "user"
|
|
if "meta" not in data:
|
|
data["meta"] = {"version": "3.0.0", "category": "user"}
|
|
else:
|
|
data["meta"]["category"] = "user"
|
|
|
|
# Add the node pack name to tags for discoverability (display only — uninstall
|
|
# does not rely on this tag, since users can edit tags on their own workflows).
|
|
existing_tags = data.get("tags", "")
|
|
pack_tag = f"node-pack:{pack_name}"
|
|
if pack_tag not in existing_tags:
|
|
data["tags"] = f"{existing_tags}, {pack_tag}".strip(", ") if existing_tags else pack_tag
|
|
|
|
# Remove the 'id' field if present — the system will assign a new one
|
|
data.pop("id", None)
|
|
|
|
# Validate and import the workflow
|
|
workflow = WorkflowWithoutIDValidator.validate_python(data)
|
|
created = ApiDependencies.invoker.services.workflow_records.create(
|
|
workflow=workflow, user_id=owner_user_id, is_public=True
|
|
)
|
|
imported_ids.append(created.workflow_id)
|
|
logger.info(f"Imported workflow '{workflow.name}' from node pack '{pack_name}'")
|
|
|
|
except Exception:
|
|
logger.warning(f"Skipped non-workflow or invalid JSON file: {json_file}")
|
|
continue
|
|
|
|
if imported_ids:
|
|
logger.info(f"Imported {len(imported_ids)} workflow(s) from node pack '{pack_name}'")
|
|
|
|
return imported_ids
|
|
|
|
|
|
def _write_pack_manifest(pack_dir: Path, workflow_ids: list[str]) -> None:
|
|
"""Writes the pack manifest recording which workflow IDs were imported from the pack."""
|
|
manifest_path = pack_dir / PACK_MANIFEST_FILENAME
|
|
try:
|
|
with open(manifest_path, "w", encoding="utf-8") as f:
|
|
json.dump({"workflow_ids": workflow_ids}, f)
|
|
except Exception:
|
|
logger.warning(f"Failed to write pack manifest at {manifest_path}")
|
|
|
|
|
|
def _read_pack_manifest(pack_dir: Path) -> list[str]:
|
|
"""Reads workflow IDs that this pack's install recorded in its manifest.
|
|
|
|
Returns an empty list if the manifest is missing or malformed. We deliberately do NOT
|
|
fall back to tag-based lookup: workflow tags are user-editable and could collide with
|
|
unrelated workflows, so we only delete what we recorded ourselves at install time.
|
|
"""
|
|
manifest_path = pack_dir / PACK_MANIFEST_FILENAME
|
|
if not manifest_path.exists():
|
|
return []
|
|
try:
|
|
with open(manifest_path, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
ids = data.get("workflow_ids", [])
|
|
if not isinstance(ids, list):
|
|
return []
|
|
return [str(x) for x in ids if isinstance(x, str)]
|
|
except Exception:
|
|
logger.warning(f"Failed to read pack manifest at {manifest_path}")
|
|
return []
|
|
|
|
|
|
def _remove_workflows_by_ids(workflow_ids: list[str], pack_name: str) -> int:
|
|
"""Deletes the given workflow IDs. Used during uninstall to remove only the workflows
|
|
this pack's install recorded in its manifest.
|
|
"""
|
|
if not workflow_ids:
|
|
return 0
|
|
|
|
removed_count = 0
|
|
for workflow_id in workflow_ids:
|
|
try:
|
|
ApiDependencies.invoker.services.workflow_records.delete(workflow_id)
|
|
removed_count += 1
|
|
except Exception:
|
|
logger.warning(f"Failed to remove workflow '{workflow_id}' (from node pack '{pack_name}')")
|
|
|
|
if removed_count > 0:
|
|
logger.info(f"Removed {removed_count} workflow(s) from node pack '{pack_name}'")
|
|
|
|
return removed_count
|