Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:22:06 +08:00

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