diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..ec06b27a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,70 @@ +name: CI + +on: + push: + branches: [ main, feat/*, fix/* ] + pull_request: + branches: [ main ] + +jobs: + validate-openapi: + name: Validate OpenAPI Specification + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check if OpenAPI changed + id: openapi-changed + uses: tj-actions/changed-files@v44 + with: + files: openapi.yaml + + - name: Setup Node.js + if: steps.openapi-changed.outputs.any_changed == 'true' + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Install Redoc CLI + if: steps.openapi-changed.outputs.any_changed == 'true' + run: | + npm install -g @redocly/cli + + - name: Validate OpenAPI specification + if: steps.openapi-changed.outputs.any_changed == 'true' + run: | + redocly lint openapi.yaml + + code-quality: + name: Code Quality Checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch all history for proper diff + + - name: Get changed Python files + id: changed-py-files + uses: tj-actions/changed-files@v44 + with: + files: | + **/*.py + files_ignore: | + comfyui_manager/legacy/** + + - name: Setup Python + if: steps.changed-py-files.outputs.any_changed == 'true' + uses: actions/setup-python@v5 + with: + python-version: '3.9' + + - name: Install dependencies + if: steps.changed-py-files.outputs.any_changed == 'true' + run: | + pip install ruff + + - name: Run ruff linting on changed files + if: steps.changed-py-files.outputs.any_changed == 'true' + run: | + echo "Changed files: ${{ steps.changed-py-files.outputs.all_changed_files }}" + echo "${{ steps.changed-py-files.outputs.all_changed_files }}" | xargs -r ruff check \ No newline at end of file diff --git a/.gitignore b/.gitignore index bdbc2962..f00db96b 100644 --- a/.gitignore +++ b/.gitignore @@ -19,5 +19,6 @@ pip_overrides.json check2.sh /venv/ build +dist *.egg-info .env \ No newline at end of file diff --git a/comfyui_manager/data_models/README.md b/comfyui_manager/data_models/README.md new file mode 100644 index 00000000..adf840f0 --- /dev/null +++ b/comfyui_manager/data_models/README.md @@ -0,0 +1,67 @@ +# Data Models + +This directory contains Pydantic models for ComfyUI Manager, providing type safety, validation, and serialization for the API and internal data structures. + +## Overview + +- `generated_models.py` - All models auto-generated from OpenAPI spec +- `__init__.py` - Package exports for all models + +**Note**: All models are now auto-generated from the OpenAPI specification. Manual model files (`task_queue.py`, `state_management.py`) have been deprecated in favor of a single source of truth. + +## Generating Types from OpenAPI + +The state management models are automatically generated from the OpenAPI specification using `datamodel-codegen`. This ensures type safety and consistency between the API specification and the Python code. + +### Prerequisites + +Install the code generator: +```bash +pipx install datamodel-code-generator +``` + +### Generation Command + +To regenerate all models after updating the OpenAPI spec: + +```bash +datamodel-codegen \ + --use-subclass-enum \ + --field-constraints \ + --strict-types bytes \ + --input openapi.yaml \ + --output comfyui_manager/data_models/generated_models.py \ + --output-model-type pydantic_v2.BaseModel +``` + +### When to Regenerate + +You should regenerate the models when: + +1. **Adding new API endpoints** that return new data structures +2. **Modifying existing schemas** in the OpenAPI specification +3. **Adding new state management features** that require new models + +### Important Notes + +- **Single source of truth**: All models are now generated from `openapi.yaml` +- **No manual models**: All previously manual models have been migrated to the OpenAPI spec +- **OpenAPI requirements**: New schemas must be referenced in API paths to be generated by datamodel-codegen +- **Validation**: Always validate the OpenAPI spec before generation: + ```bash + python3 -c "import yaml; yaml.safe_load(open('openapi.yaml'))" + ``` + +### Example: Adding New State Models + +1. Add your schema to `openapi.yaml` under `components/schemas/` +2. Reference the schema in an API endpoint response +3. Run the generation command above +4. Update `__init__.py` to export the new models +5. Import and use the models in your code + +### Troubleshooting + +- **Models not generated**: Ensure schemas are under `components/schemas/` (not `parameters/`) +- **Missing models**: Verify schemas are referenced in at least one API path +- **Import errors**: Check that new models are added to `__init__.py` exports \ No newline at end of file diff --git a/comfyui_manager/data_models/__init__.py b/comfyui_manager/data_models/__init__.py new file mode 100644 index 00000000..5d115e6a --- /dev/null +++ b/comfyui_manager/data_models/__init__.py @@ -0,0 +1,125 @@ +""" +Data models for ComfyUI Manager. + +This package contains Pydantic models used throughout the ComfyUI Manager +for data validation, serialization, and type safety. + +All models are auto-generated from the OpenAPI specification to ensure +consistency between the API and implementation. +""" + +from .generated_models import ( + # Core Task Queue Models + QueueTaskItem, + TaskHistoryItem, + TaskStateMessage, + TaskExecutionStatus, + + # WebSocket Message Models + MessageTaskDone, + MessageTaskStarted, + MessageTaskFailed, + MessageUpdate, + ManagerMessageName, + + # State Management Models + BatchExecutionRecord, + ComfyUISystemState, + BatchOperation, + InstalledNodeInfo, + InstalledModelInfo, + ComfyUIVersionInfo, + + # Other models + OperationType, + OperationResult, + ManagerPackInfo, + ManagerPackInstalled, + SelectedVersion, + ManagerChannel, + ManagerDatabaseSource, + ManagerPackState, + ManagerPackInstallType, + ManagerPack, + InstallPackParams, + UpdatePackParams, + UpdateAllPacksParams, + UpdateComfyUIParams, + FixPackParams, + UninstallPackParams, + DisablePackParams, + EnablePackParams, + UpdateAllQueryParams, + UpdateComfyUIQueryParams, + ComfyUISwitchVersionQueryParams, + QueueStatus, + ManagerMappings, + ModelMetadata, + NodePackageMetadata, + SnapshotItem, + Error, + InstalledPacksResponse, + HistoryResponse, + HistoryListResponse, + InstallType, + SecurityLevel, + RiskLevel, +) + +__all__ = [ + # Core Task Queue Models + "QueueTaskItem", + "TaskHistoryItem", + "TaskStateMessage", + "TaskExecutionStatus", + + # WebSocket Message Models + "MessageTaskDone", + "MessageTaskStarted", + "MessageTaskFailed", + "MessageUpdate", + "ManagerMessageName", + + # State Management Models + "BatchExecutionRecord", + "ComfyUISystemState", + "BatchOperation", + "InstalledNodeInfo", + "InstalledModelInfo", + "ComfyUIVersionInfo", + + # Other models + "OperationType", + "OperationResult", + "ManagerPackInfo", + "ManagerPackInstalled", + "SelectedVersion", + "ManagerChannel", + "ManagerDatabaseSource", + "ManagerPackState", + "ManagerPackInstallType", + "ManagerPack", + "InstallPackParams", + "UpdatePackParams", + "UpdateAllPacksParams", + "UpdateComfyUIParams", + "FixPackParams", + "UninstallPackParams", + "DisablePackParams", + "EnablePackParams", + "UpdateAllQueryParams", + "UpdateComfyUIQueryParams", + "ComfyUISwitchVersionQueryParams", + "QueueStatus", + "ManagerMappings", + "ModelMetadata", + "NodePackageMetadata", + "SnapshotItem", + "Error", + "InstalledPacksResponse", + "HistoryResponse", + "HistoryListResponse", + "InstallType", + "SecurityLevel", + "RiskLevel", +] \ No newline at end of file diff --git a/comfyui_manager/data_models/generated_models.py b/comfyui_manager/data_models/generated_models.py new file mode 100644 index 00000000..33450409 --- /dev/null +++ b/comfyui_manager/data_models/generated_models.py @@ -0,0 +1,537 @@ +# generated by datamodel-codegen: +# filename: openapi.yaml +# timestamp: 2025-06-21T23:40:24+00:00 + +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import Any, Dict, List, Optional, Union + +from pydantic import BaseModel, Field, RootModel + + +class OperationType(str, Enum): + install = "install" + uninstall = "uninstall" + update = "update" + update_comfyui = "update-comfyui" + fix = "fix" + disable = "disable" + enable = "enable" + install_model = "install-model" + + +class OperationResult(str, Enum): + success = "success" + failed = "failed" + skipped = "skipped" + error = "error" + skip = "skip" + + +class TaskExecutionStatus(BaseModel): + status_str: OperationResult + completed: bool = Field(..., description="Whether the task completed") + messages: List[str] = Field(..., description="Additional status messages") + + +class ManagerMessageName(str, Enum): + cm_task_completed = "cm-task-completed" + cm_task_started = "cm-task-started" + cm_queue_status = "cm-queue-status" + + +class ManagerPackInfo(BaseModel): + id: str = Field( + ..., + description="Either github-author/github-repo or name of pack from the registry", + ) + version: str = Field(..., description="Semantic version or Git commit hash") + ui_id: Optional[str] = Field(None, description="Task ID - generated internally") + + +class ManagerPackInstalled(BaseModel): + ver: str = Field( + ..., + description="The version of the pack that is installed (Git commit hash or semantic version)", + ) + cnr_id: Optional[str] = Field( + None, description="The name of the pack if installed from the registry" + ) + aux_id: Optional[str] = Field( + None, + description="The name of the pack if installed from github (author/repo-name format)", + ) + enabled: bool = Field(..., description="Whether the pack is enabled") + + +class SelectedVersion(str, Enum): + latest = "latest" + nightly = "nightly" + + +class ManagerChannel(str, Enum): + default = "default" + recent = "recent" + legacy = "legacy" + forked = "forked" + dev = "dev" + tutorial = "tutorial" + + +class ManagerDatabaseSource(str, Enum): + remote = "remote" + local = "local" + cache = "cache" + + +class ManagerPackState(str, Enum): + installed = "installed" + disabled = "disabled" + not_installed = "not_installed" + import_failed = "import_failed" + needs_update = "needs_update" + + +class ManagerPackInstallType(str, Enum): + git_clone = "git-clone" + copy = "copy" + cnr = "cnr" + + +class SecurityLevel(str, Enum): + strong = "strong" + normal = "normal" + normal_ = "normal-" + weak = "weak" + + +class RiskLevel(str, Enum): + block = "block" + high = "high" + middle = "middle" + + +class UpdateState(Enum): + false = "false" + true = "true" + + +class ManagerPack(ManagerPackInfo): + author: Optional[str] = Field( + None, description="Pack author name or 'Unclaimed' if added via GitHub crawl" + ) + files: Optional[List[str]] = Field( + None, + description="Repository URLs for installation (typically contains one GitHub URL)", + ) + reference: Optional[str] = Field( + None, description="The type of installation reference" + ) + title: Optional[str] = Field(None, description="The display name of the pack") + cnr_latest: Optional[SelectedVersion] = None + repository: Optional[str] = Field(None, description="GitHub repository URL") + state: Optional[ManagerPackState] = None + update_state: Optional[UpdateState] = Field( + None, alias="update-state", description="Update availability status" + ) + stars: Optional[int] = Field(None, description="GitHub stars count") + last_update: Optional[datetime] = Field(None, description="Last update timestamp") + health: Optional[str] = Field(None, description="Health status of the pack") + description: Optional[str] = Field(None, description="Pack description") + trust: Optional[bool] = Field(None, description="Whether the pack is trusted") + install_type: Optional[ManagerPackInstallType] = None + + +class InstallPackParams(ManagerPackInfo): + selected_version: Union[str, SelectedVersion] = Field( + ..., description="Semantic version, Git commit hash, latest, or nightly" + ) + repository: Optional[str] = Field( + None, + description="GitHub repository URL (required if selected_version is nightly)", + ) + pip: Optional[List[str]] = Field(None, description="PyPi dependency names") + mode: ManagerDatabaseSource + channel: ManagerChannel + skip_post_install: Optional[bool] = Field( + None, description="Whether to skip post-installation steps" + ) + + +class UpdateAllPacksParams(BaseModel): + mode: Optional[ManagerDatabaseSource] = None + ui_id: Optional[str] = Field(None, description="Task ID - generated internally") + + +class UpdatePackParams(BaseModel): + node_name: str = Field(..., description="Name of the node package to update") + node_ver: Optional[str] = Field( + None, description="Current version of the node package" + ) + + +class UpdateComfyUIParams(BaseModel): + is_stable: Optional[bool] = Field( + True, + description="Whether to update to stable version (true) or nightly (false)", + ) + target_version: Optional[str] = Field( + None, + description="Specific version to switch to (for version switching operations)", + ) + + +class FixPackParams(BaseModel): + node_name: str = Field(..., description="Name of the node package to fix") + node_ver: str = Field(..., description="Version of the node package") + + +class UninstallPackParams(BaseModel): + node_name: str = Field(..., description="Name of the node package to uninstall") + is_unknown: Optional[bool] = Field( + False, description="Whether this is an unknown/unregistered package" + ) + + +class DisablePackParams(BaseModel): + node_name: str = Field(..., description="Name of the node package to disable") + is_unknown: Optional[bool] = Field( + False, description="Whether this is an unknown/unregistered package" + ) + + +class EnablePackParams(BaseModel): + cnr_id: str = Field( + ..., description="ComfyUI Node Registry ID of the package to enable" + ) + + +class UpdateAllQueryParams(BaseModel): + client_id: str = Field( + ..., description="Client identifier that initiated the request" + ) + ui_id: str = Field(..., description="Base UI identifier for task tracking") + mode: Optional[ManagerDatabaseSource] = None + + +class UpdateComfyUIQueryParams(BaseModel): + client_id: str = Field( + ..., description="Client identifier that initiated the request" + ) + ui_id: str = Field(..., description="UI identifier for task tracking") + stable: Optional[bool] = Field( + True, + description="Whether to update to stable version (true) or nightly (false)", + ) + + +class ComfyUISwitchVersionQueryParams(BaseModel): + ver: str = Field(..., description="Version to switch to") + client_id: str = Field( + ..., description="Client identifier that initiated the request" + ) + ui_id: str = Field(..., description="UI identifier for task tracking") + + +class QueueStatus(BaseModel): + total_count: int = Field( + ..., description="Total number of tasks (pending + running)" + ) + done_count: int = Field(..., description="Number of completed tasks") + in_progress_count: int = Field(..., description="Number of tasks currently running") + pending_count: Optional[int] = Field( + None, description="Number of tasks waiting to be executed" + ) + is_processing: bool = Field(..., description="Whether the task worker is active") + client_id: Optional[str] = Field( + None, description="Client ID (when filtered by client)" + ) + + +class ManagerMappings1(BaseModel): + title_aux: Optional[str] = Field(None, description="The display name of the pack") + + +class ManagerMappings( + RootModel[Optional[Dict[str, List[Union[List[str], ManagerMappings1]]]]] +): + root: Optional[Dict[str, List[Union[List[str], ManagerMappings1]]]] = Field( + None, description="Tuple of [node_names, metadata]" + ) + + +class ModelMetadata(BaseModel): + name: str = Field(..., description="Name of the model") + type: str = Field(..., description="Type of model") + base: Optional[str] = Field(None, description="Base model type") + save_path: Optional[str] = Field(None, description="Path for saving the model") + url: str = Field(..., description="Download URL") + filename: str = Field(..., description="Target filename") + ui_id: Optional[str] = Field(None, description="ID for UI reference") + + +class InstallType(str, Enum): + git = "git" + copy = "copy" + pip = "pip" + + +class NodePackageMetadata(BaseModel): + title: Optional[str] = Field(None, description="Display name of the node package") + name: Optional[str] = Field(None, description="Repository/package name") + files: Optional[List[str]] = Field(None, description="Source URLs for the package") + description: Optional[str] = Field( + None, description="Description of the node package functionality" + ) + install_type: Optional[InstallType] = Field(None, description="Installation method") + version: Optional[str] = Field(None, description="Version identifier") + id: Optional[str] = Field( + None, description="Unique identifier for the node package" + ) + ui_id: Optional[str] = Field(None, description="ID for UI reference") + channel: Optional[str] = Field(None, description="Source channel") + mode: Optional[str] = Field(None, description="Source mode") + + +class SnapshotItem(RootModel[str]): + root: str = Field(..., description="Name of the snapshot") + + +class Error(BaseModel): + error: str = Field(..., description="Error message") + + +class InstalledPacksResponse(RootModel[Optional[Dict[str, ManagerPackInstalled]]]): + root: Optional[Dict[str, ManagerPackInstalled]] = None + + +class HistoryListResponse(BaseModel): + ids: Optional[List[str]] = Field( + None, description="List of available batch history IDs" + ) + + +class InstalledNodeInfo(BaseModel): + name: str = Field(..., description="Node package name") + version: str = Field(..., description="Installed version") + repository_url: Optional[str] = Field(None, description="Git repository URL") + install_method: str = Field( + ..., description="Installation method (cnr, git, pip, etc.)" + ) + enabled: Optional[bool] = Field( + True, description="Whether the node is currently enabled" + ) + install_date: Optional[datetime] = Field( + None, description="ISO timestamp of installation" + ) + + +class InstalledModelInfo(BaseModel): + name: str = Field(..., description="Model filename") + path: str = Field(..., description="Full path to model file") + type: str = Field(..., description="Model type (checkpoint, lora, vae, etc.)") + size_bytes: Optional[int] = Field(None, description="File size in bytes", ge=0) + hash: Optional[str] = Field(None, description="Model file hash for verification") + install_date: Optional[datetime] = Field( + None, description="ISO timestamp when added" + ) + + +class ComfyUIVersionInfo(BaseModel): + version: str = Field(..., description="ComfyUI version string") + commit_hash: Optional[str] = Field(None, description="Git commit hash") + branch: Optional[str] = Field(None, description="Git branch name") + is_stable: Optional[bool] = Field( + False, description="Whether this is a stable release" + ) + last_updated: Optional[datetime] = Field( + None, description="ISO timestamp of last update" + ) + + +class BatchOperation(BaseModel): + operation_id: str = Field(..., description="Unique operation identifier") + operation_type: OperationType + target: str = Field( + ..., description="Target of the operation (node name, model name, etc.)" + ) + target_version: Optional[str] = Field( + None, description="Target version for the operation" + ) + result: OperationResult + error_message: Optional[str] = Field( + None, description="Error message if operation failed" + ) + start_time: datetime = Field( + ..., description="ISO timestamp when operation started" + ) + end_time: Optional[datetime] = Field( + None, description="ISO timestamp when operation completed" + ) + client_id: Optional[str] = Field( + None, description="Client that initiated the operation" + ) + + +class ComfyUISystemState(BaseModel): + snapshot_time: datetime = Field( + ..., description="ISO timestamp when snapshot was taken" + ) + comfyui_version: ComfyUIVersionInfo + frontend_version: Optional[str] = Field( + None, description="ComfyUI frontend version if available" + ) + python_version: str = Field(..., description="Python interpreter version") + platform_info: str = Field( + ..., description="Operating system and platform information" + ) + installed_nodes: Optional[Dict[str, InstalledNodeInfo]] = Field( + None, description="Map of installed node packages by name" + ) + installed_models: Optional[Dict[str, InstalledModelInfo]] = Field( + None, description="Map of installed models by name" + ) + manager_config: Optional[Dict[str, Any]] = Field( + None, description="ComfyUI Manager configuration settings" + ) + comfyui_root_path: Optional[str] = Field( + None, description="ComfyUI root installation directory" + ) + model_paths: Optional[Dict[str, List[str]]] = Field( + None, description="Map of model types to their configured paths" + ) + manager_version: Optional[str] = Field(None, description="ComfyUI Manager version") + security_level: Optional[SecurityLevel] = None + network_mode: Optional[str] = Field( + None, description="Network mode (online, offline, private)" + ) + cli_args: Optional[Dict[str, Any]] = Field( + None, description="Selected ComfyUI CLI arguments" + ) + custom_nodes_count: Optional[int] = Field( + None, description="Total number of custom node packages", ge=0 + ) + failed_imports: Optional[List[str]] = Field( + None, description="List of custom nodes that failed to import" + ) + pip_packages: Optional[Dict[str, str]] = Field( + None, description="Map of installed pip packages to their versions" + ) + embedded_python: Optional[bool] = Field( + None, + description="Whether ComfyUI is running from an embedded Python distribution", + ) + + +class BatchExecutionRecord(BaseModel): + batch_id: str = Field(..., description="Unique batch identifier") + start_time: datetime = Field(..., description="ISO timestamp when batch started") + end_time: Optional[datetime] = Field( + None, description="ISO timestamp when batch completed" + ) + state_before: ComfyUISystemState + state_after: Optional[ComfyUISystemState] = Field( + None, description="System state after batch execution" + ) + operations: Optional[List[BatchOperation]] = Field( + None, description="List of operations performed in this batch" + ) + total_operations: Optional[int] = Field( + 0, description="Total number of operations in batch", ge=0 + ) + successful_operations: Optional[int] = Field( + 0, description="Number of successful operations", ge=0 + ) + failed_operations: Optional[int] = Field( + 0, description="Number of failed operations", ge=0 + ) + skipped_operations: Optional[int] = Field( + 0, description="Number of skipped operations", ge=0 + ) + + +class QueueTaskItem(BaseModel): + ui_id: str = Field(..., description="Unique identifier for the task") + client_id: str = Field(..., description="Client identifier that initiated the task") + kind: OperationType + params: Union[ + InstallPackParams, + UpdatePackParams, + UpdateAllPacksParams, + UpdateComfyUIParams, + FixPackParams, + UninstallPackParams, + DisablePackParams, + EnablePackParams, + ModelMetadata, + ] + + +class TaskHistoryItem(BaseModel): + ui_id: str = Field(..., description="Unique identifier for the task") + client_id: str = Field(..., description="Client identifier that initiated the task") + kind: str = Field(..., description="Type of task that was performed") + timestamp: datetime = Field(..., description="ISO timestamp when task completed") + result: str = Field(..., description="Task result message or details") + status: Optional[TaskExecutionStatus] = None + batch_id: Optional[str] = Field( + None, description="ID of the batch this task belongs to" + ) + end_time: Optional[datetime] = Field( + None, description="ISO timestamp when task execution ended" + ) + + +class TaskStateMessage(BaseModel): + history: Dict[str, TaskHistoryItem] = Field( + ..., description="Map of task IDs to their history items" + ) + running_queue: List[QueueTaskItem] = Field( + ..., description="Currently executing tasks" + ) + pending_queue: List[QueueTaskItem] = Field( + ..., description="Tasks waiting to be executed" + ) + installed_packs: Dict[str, ManagerPackInstalled] = Field( + ..., description="Map of currently installed node packages by name" + ) + + +class MessageTaskDone(BaseModel): + ui_id: str = Field(..., description="Task identifier") + result: str = Field(..., description="Task result message") + kind: str = Field(..., description="Type of task") + status: Optional[TaskExecutionStatus] = None + timestamp: datetime = Field(..., description="ISO timestamp when task completed") + state: TaskStateMessage + + +class MessageTaskStarted(BaseModel): + ui_id: str = Field(..., description="Task identifier") + kind: str = Field(..., description="Type of task") + timestamp: datetime = Field(..., description="ISO timestamp when task started") + state: TaskStateMessage + + +class MessageTaskFailed(BaseModel): + ui_id: str = Field(..., description="Task identifier") + error: str = Field(..., description="Error message") + kind: str = Field(..., description="Type of task") + timestamp: datetime = Field(..., description="ISO timestamp when task failed") + state: TaskStateMessage + + +class MessageUpdate( + RootModel[Union[MessageTaskDone, MessageTaskStarted, MessageTaskFailed]] +): + root: Union[MessageTaskDone, MessageTaskStarted, MessageTaskFailed] = Field( + ..., description="Union type for all possible WebSocket message updates" + ) + + +class HistoryResponse(BaseModel): + history: Optional[Dict[str, TaskHistoryItem]] = Field( + None, description="Map of task IDs to their history items" + ) diff --git a/comfyui_manager/glob/CLAUDE.md b/comfyui_manager/glob/CLAUDE.md new file mode 100644 index 00000000..1c96bea2 --- /dev/null +++ b/comfyui_manager/glob/CLAUDE.md @@ -0,0 +1,11 @@ +- Anytime you make a change to the data being sent or received, you should follow this process: + 1. Adjust the openapi.yaml file first + 2. Verify the syntax of the openapi.yaml file using `yaml.safe_load` + 3. Regenerate the types following the instructions in the `data_models/README.md` file + 4. Verify the new data model is generated + 5. Verify the syntax of the generated types files + 6. Run formatting and linting on the generated types files + 7. Adjust the `__init__.py` files in the `data_models` directory to match/export the new data model + 8. Only then, make the changes to the rest of the codebase + 9. Run the CI tests to verify that the changes are working +- The comfyui_manager is a python package that is used to manage the comfyui server. There are two sub-packages `glob` and `legacy`. These represent the current version (`glob`) and the previous version (`legacy`), not including common utilities and data models. When developing, we work in the `glob` package. You can ignore the `legacy` package entirely, unless you have a very good reason to research how things were done in the legacy or prior major versions of the package. But in those cases, you should just look for the sake of knowledge or reflection, not for changing code (unless explicitly asked to do so). \ No newline at end of file diff --git a/comfyui_manager/glob/constants.py b/comfyui_manager/glob/constants.py new file mode 100644 index 00000000..9ec94e54 --- /dev/null +++ b/comfyui_manager/glob/constants.py @@ -0,0 +1,54 @@ + +SECURITY_MESSAGE_MIDDLE_OR_BELOW = "ERROR: To use this action, a security_level of `middle or below` is required. Please contact the administrator.\nReference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy" +SECURITY_MESSAGE_NORMAL_MINUS = "ERROR: To use this feature, you must either set '--listen' to a local IP and set the security level to 'normal-' or lower, or set the security level to 'middle' or 'weak'. Please contact the administrator.\nReference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy" +SECURITY_MESSAGE_GENERAL = "ERROR: This installation is not allowed in this security_level. Please contact the administrator.\nReference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy" +SECURITY_MESSAGE_NORMAL_MINUS_MODEL = "ERROR: Downloading models that are not in '.safetensors' format is only allowed for models registered in the 'default' channel at this security level. If you want to download this model, set the security level to 'normal-' or lower." + + +def is_loopback(address): + import ipaddress + + try: + return ipaddress.ip_address(address).is_loopback + except ValueError: + return False + + +model_dir_name_map = { + "checkpoints": "checkpoints", + "checkpoint": "checkpoints", + "unclip": "checkpoints", + "text_encoders": "text_encoders", + "clip": "text_encoders", + "vae": "vae", + "lora": "loras", + "t2i-adapter": "controlnet", + "t2i-style": "controlnet", + "controlnet": "controlnet", + "clip_vision": "clip_vision", + "gligen": "gligen", + "upscale": "upscale_models", + "embedding": "embeddings", + "embeddings": "embeddings", + "unet": "diffusion_models", + "diffusion_model": "diffusion_models", +} + +# List of all model directory names used for checking installed models +MODEL_DIR_NAMES = [ + "checkpoints", + "loras", + "vae", + "text_encoders", + "diffusion_models", + "clip_vision", + "embeddings", + "diffusers", + "vae_approx", + "controlnet", + "gligen", + "upscale_models", + "hypernetworks", + "photomaker", + "classifiers", +] diff --git a/comfyui_manager/glob/manager_server.py b/comfyui_manager/glob/manager_server.py index c20a46cc..049b46fd 100644 --- a/comfyui_manager/glob/manager_server.py +++ b/comfyui_manager/glob/manager_server.py @@ -1,22 +1,46 @@ -import traceback +""" +ComfyUI Manager Server -import folder_paths -import locale -import subprocess # don't remove this -import concurrent -import nodes +Main server implementation providing REST API endpoints for ComfyUI Manager functionality. +Handles task queue management, custom node operations, model installation, and system configuration. +""" + +import asyncio +import copy +import heapq +import json +import logging import os -import sys -import threading +import platform import re import shutil -import git -from datetime import datetime +import subprocess # don't remove this +import sys +import threading +import traceback +import urllib.request +import uuid +import zipfile +from datetime import datetime, timedelta +from typing import Any, Optional + +import folder_paths +import latent_preview +import nodes +from aiohttp import web +from comfy.cli_args import args +from pydantic import ValidationError + +from comfyui_manager.glob.utils import ( + formatting_utils, + model_utils, + security_utils, + node_pack_utils, + environment_utils, +) + from server import PromptServer -import logging -import asyncio -from collections import deque from . import manager_core as core from ..common import manager_util @@ -25,108 +49,80 @@ from ..common import manager_downloader from ..common import context -logging.info(f"### Loading: ComfyUI-Manager ({core.version_str})") +from ..data_models import ( + QueueTaskItem, + TaskHistoryItem, + TaskStateMessage, + TaskExecutionStatus, + MessageTaskDone, + MessageTaskStarted, + MessageUpdate, + ManagerMessageName, + BatchExecutionRecord, + ComfyUISystemState, + BatchOperation, + InstalledNodeInfo, + ComfyUIVersionInfo, + InstallPackParams, + UpdatePackParams, + UpdateComfyUIParams, + FixPackParams, + UninstallPackParams, + DisablePackParams, + EnablePackParams, + ModelMetadata, + OperationType, + OperationResult, + ManagerDatabaseSource, + SecurityLevel, + UpdateAllQueryParams, + UpdateComfyUIQueryParams, + ComfyUISwitchVersionQueryParams, +) + +from .constants import ( + model_dir_name_map, + SECURITY_MESSAGE_MIDDLE_OR_BELOW, +) if not manager_util.is_manager_pip_package(): network_mode_description = "offline" else: - network_mode_description = core.get_config()['network_mode'] + network_mode_description = core.get_config()["network_mode"] logging.info("[ComfyUI-Manager] network_mode: " + network_mode_description) -comfy_ui_hash = "-" -comfyui_tag = None - -SECURITY_MESSAGE_MIDDLE_OR_BELOW = "ERROR: To use this action, a security_level of `middle or below` is required. Please contact the administrator.\nReference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy" -SECURITY_MESSAGE_NORMAL_MINUS = "ERROR: To use this feature, you must either set '--listen' to a local IP and set the security level to 'normal-' or lower, or set the security level to 'middle' or 'weak'. Please contact the administrator.\nReference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy" -SECURITY_MESSAGE_GENERAL = "ERROR: This installation is not allowed in this security_level. Please contact the administrator.\nReference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy" -SECURITY_MESSAGE_NORMAL_MINUS_MODEL = "ERROR: Downloading models that are not in '.safetensors' format is only allowed for models registered in the 'default' channel at this security level. If you want to download this model, set the security level to 'normal-' or lower." +MAXIMUM_HISTORY_SIZE = 10000 routes = PromptServer.instance.routes -def handle_stream(stream, prefix): - stream.reconfigure(encoding=locale.getpreferredencoding(), errors='replace') - for msg in stream: - if prefix == '[!]' and ('it/s]' in msg or 's/it]' in msg) and ('%|' in msg or 'it [' in msg): - if msg.startswith('100%'): - print('\r' + msg, end="", file=sys.stderr), - else: - print('\r' + msg[:-1], end="", file=sys.stderr), - else: - if prefix == '[!]': - print(prefix, msg, end="", file=sys.stderr) - else: - print(prefix, msg, end="") - - -from comfy.cli_args import args -import latent_preview def is_loopback(address): import ipaddress + try: return ipaddress.ip_address(address).is_loopback except ValueError: return False -is_local_mode = is_loopback(args.listen) +def error_response( + status: int, message: str, error_type: Optional[str] = None +) -> web.Response: + """Create a standardized error response. -model_dir_name_map = { - "checkpoints": "checkpoints", - "checkpoint": "checkpoints", - "unclip": "checkpoints", - "text_encoders": "text_encoders", - "clip": "text_encoders", - "vae": "vae", - "lora": "loras", - "t2i-adapter": "controlnet", - "t2i-style": "controlnet", - "controlnet": "controlnet", - "clip_vision": "clip_vision", - "gligen": "gligen", - "upscale": "upscale_models", - "embedding": "embeddings", - "embeddings": "embeddings", - "unet": "diffusion_models", - "diffusion_model": "diffusion_models", -} + Args: + status: HTTP status code + message: Error message + error_type: Optional error type/category + Returns: + web.Response with JSON error body + """ + error_data = {"error": message} + if error_type: + error_data["error_type"] = error_type -def is_allowed_security_level(level): - if level == 'block': - return False - elif level == 'high': - if is_local_mode: - return core.get_config()['security_level'] in ['weak', 'normal-'] - else: - return core.get_config()['security_level'] == 'weak' - elif level == 'middle': - return core.get_config()['security_level'] in ['weak', 'normal', 'normal-'] - else: - return True - - -async def get_risky_level(files, pip_packages): - json_data1 = await core.get_data_by_mode('local', 'custom-node-list.json') - json_data2 = await core.get_data_by_mode('cache', 'custom-node-list.json', channel_url='https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main') - - all_urls = set() - for x in json_data1['custom_nodes'] + json_data2['custom_nodes']: - all_urls.update(x.get('files', [])) - - for x in files: - if x not in all_urls: - return "high" - - all_pip_packages = set() - for x in json_data1['custom_nodes'] + json_data2['custom_nodes']: - all_pip_packages.update(x.get('pip', [])) - - for p in pip_packages: - if p not in all_pip_packages: - return "block" - - return "middle" + return web.json_response(error_data, status=status) class ManagerFuncsInComfyUI(core.ManagerFuncs): @@ -140,15 +136,27 @@ class ManagerFuncsInComfyUI(core.ManagerFuncs): else: return "none" - def run_script(self, cmd, cwd='.'): + def run_script(self, cmd, cwd="."): if len(cmd) > 0 and cmd[0].startswith("#"): logging.error(f"[ComfyUI-Manager] Unexpected behavior: `{cmd}`") return 0 - process = subprocess.Popen(cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1, env=core.get_script_env()) + process = subprocess.Popen( + cmd, + cwd=cwd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + env=core.get_script_env(), + ) - stdout_thread = threading.Thread(target=handle_stream, args=(process.stdout, "")) - stderr_thread = threading.Thread(target=handle_stream, args=(process.stderr, "[!]")) + stdout_thread = threading.Thread( + target=formatting_utils.handle_stream, args=(process.stdout, "") + ) + stderr_thread = threading.Thread( + target=formatting_utils.handle_stream, args=(process.stderr, "[!]") + ) stdout_thread.start() stderr_thread.start() @@ -161,327 +169,706 @@ class ManagerFuncsInComfyUI(core.ManagerFuncs): core.manager_funcs = ManagerFuncsInComfyUI() -from comfyui_manager.common.manager_downloader import download_url, download_url_with_agent - -context.comfy_path = os.path.dirname(folder_paths.__file__) -core.js_path = os.path.join(context.comfy_path, "web", "extensions") - -local_db_model = os.path.join(manager_util.comfyui_manager_path, "model-list.json") -local_db_alter = os.path.join(manager_util.comfyui_manager_path, "alter-list.json") -local_db_custom_node_list = os.path.join(manager_util.comfyui_manager_path, "custom-node-list.json") -local_db_extension_node_mappings = os.path.join(manager_util.comfyui_manager_path, "extension-node-map.json") +from comfyui_manager.common.manager_downloader import ( + download_url, + download_url_with_agent, +) -def set_preview_method(method): - if method == 'auto': - args.preview_method = latent_preview.LatentPreviewMethod.Auto - elif method == 'latent2rgb': - args.preview_method = latent_preview.LatentPreviewMethod.Latent2RGB - elif method == 'taesd': - args.preview_method = latent_preview.LatentPreviewMethod.TAESD - else: - args.preview_method = latent_preview.LatentPreviewMethod.NoPreviews +class TaskQueue: + instance = None - core.get_config()['preview_method'] = method + def __init__(self): + TaskQueue.instance = self + self.mutex = threading.RLock() + self.not_empty = threading.Condition(self.mutex) + self.current_index = 0 + self.pending_tasks = [] + self.running_tasks = {} + self.history_tasks = {} + self.task_counter = 0 + self.batch_id = None + self.batch_start_time = None + self.batch_state_before = None + self._worker_task = None + self._cleanup_performed = False + def is_processing(self) -> bool: + """Check if the queue is currently processing tasks""" + return self._worker_task is not None and self._worker_task.is_alive() -if args.preview_method == latent_preview.LatentPreviewMethod.NoPreviews: - set_preview_method(core.get_config()['preview_method']) -else: - logging.warning("[ComfyUI-Manager] Since --preview-method is set, ComfyUI-Manager's preview method feature will be ignored.") + def start_worker(self) -> bool: + """Start the task worker if not already running. Returns True if started, False if already running.""" + if self._worker_task is not None and self._worker_task.is_alive(): + logging.debug("[ComfyUI-Manager] Worker already running, skipping start") + return False + logging.debug("[ComfyUI-Manager] Starting task worker thread") + self._worker_task = threading.Thread(target=lambda: asyncio.run(task_worker())) + self._worker_task.start() + return True -def set_component_policy(mode): - core.get_config()['component_policy'] = mode + def get_current_state(self) -> TaskStateMessage: + return TaskStateMessage( + history=self.get_history(), + running_queue=self.get_current_queue()[0], + pending_queue=self.get_current_queue()[1], + installed_packs=core.get_installed_node_packs(), + ) -def set_update_policy(mode): - core.get_config()['update_policy'] = mode + @staticmethod + def send_queue_state_update( + msg: str, update: MessageUpdate, client_id: Optional[str] = None + ) -> None: + """Send queue state update to clients. -def set_db_mode(mode): - core.get_config()['db_mode'] = mode + Args: + msg: Message type/event name + update: Update data to send + client_id: Optional client ID. If None, broadcasts to all clients. + If provided, sends only to that specific client. + """ + PromptServer.instance.send_sync(msg, update.model_dump(mode="json"), client_id) -def print_comfyui_version(): - global comfy_ui_hash - global comfyui_tag + def put(self, item) -> None: + """Add a task to the queue. Item can be a dict or QueueTaskItem model.""" + with self.mutex: + # Start a new batch if this is the first task after queue was empty + if ( + self.batch_id is None + and len(self.pending_tasks) == 0 + and len(self.running_tasks) == 0 + ): + self._start_new_batch() - is_detached = False - try: - repo = git.Repo(os.path.dirname(folder_paths.__file__)) - core.comfy_ui_revision = len(list(repo.iter_commits('HEAD'))) + # Convert to Pydantic model if it's a dict + if isinstance(item, dict): + item = QueueTaskItem(**item) - comfy_ui_hash = repo.head.commit.hexsha - cm_global.variables['comfyui.revision'] = core.comfy_ui_revision + # Use current_index as priority (earlier tasks have lower numbers) + priority = self.current_index + self.current_index += 1 - core.comfy_ui_commit_datetime = repo.head.commit.committed_datetime - cm_global.variables['comfyui.commit_datetime'] = core.comfy_ui_commit_datetime + # Push tuple: (priority, task_counter, item) + # task_counter ensures stable sort for items with same priority + heapq.heappush(self.pending_tasks, (priority, self.task_counter, item)) + logging.debug( + "[ComfyUI-Manager] Task added to queue: kind=%s, ui_id=%s, client_id=%s, pending_count=%d", + item.kind, + item.ui_id, + item.client_id, + len(self.pending_tasks), + ) + self.not_empty.notify() - is_detached = repo.head.is_detached - current_branch = repo.active_branch.name + def _start_new_batch(self) -> None: + """Start a new batch session for tracking operations.""" + self.batch_id = ( + f"batch_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}" + ) + self.batch_start_time = datetime.now().isoformat() + self.batch_state_before = self._capture_system_state() + logging.debug("[ComfyUI-Manager] Started new batch: %s", self.batch_id) - comfyui_tag = context.get_comfyui_tag() + def get( + self, timeout: Optional[float] = None + ) -> tuple[Optional[QueueTaskItem], int]: + with self.not_empty: + while len(self.pending_tasks) == 0: + self.not_empty.wait(timeout=timeout) + if timeout is not None and len(self.pending_tasks) == 0: + logging.debug("[ComfyUI-Manager] Task queue get timed out") + return None + # Pop tuple and extract the item + priority, counter, item = heapq.heappop(self.pending_tasks) + task_index = self.task_counter + self.running_tasks[task_index] = copy.deepcopy(item) + self.task_counter += 1 + logging.debug( + "[ComfyUI-Manager] Task retrieved from queue: kind=%s, ui_id=%s, task_index=%d, running_count=%d, pending_count=%d", + item.kind, + item.ui_id, + task_index, + len(self.running_tasks), + len(self.pending_tasks), + ) + TaskQueue.send_queue_state_update( + ManagerMessageName.cm_task_started.value, + MessageTaskStarted( + ui_id=item.ui_id, + kind=item.kind, + timestamp=datetime.now(), + state=self.get_current_state(), + ), + client_id=item.client_id, # Send task started only to the client that requested it + ) + return item, task_index + + async def task_done( + self, + item: QueueTaskItem, + task_index: int, + result_msg: str, + status: Optional[TaskExecutionStatus] = None, + ) -> None: + """Mark task as completed and add to history""" + + with self.mutex: + now = datetime.now() + timestamp = now.isoformat() + + # Remove task from running_tasks using the task_index + self.running_tasks.pop(task_index, None) + logging.debug( + "[ComfyUI-Manager] Task completed: kind=%s, ui_id=%s, task_index=%d, status=%s, running_count=%d", + item.kind, + item.ui_id, + task_index, + status.status_str if status else "unknown", + len(self.running_tasks), + ) + + # Manage history size + if len(self.history_tasks) > MAXIMUM_HISTORY_SIZE: + self.history_tasks.pop(next(iter(self.history_tasks))) + + # Update history + self.history_tasks[item.ui_id] = TaskHistoryItem( + ui_id=item.ui_id, + client_id=item.client_id, + timestamp=now, + result=result_msg, + kind=item.kind, + status=status, + batch_id=self.batch_id, + end_time=now, + ) + + # Force cache refresh for successful pack-modifying operations + pack_modifying_tasks = { + OperationType.install.value, + OperationType.uninstall.value, + OperationType.enable.value, + OperationType.disable.value, + } + if ( + item.kind in pack_modifying_tasks + and status + and status.status_str == OperationResult.success.value + ): + try: + logging.debug( + "[ComfyUI-Manager] Refreshing cache after successful %s operation", + item.kind, + ) + # Force unified_manager to refresh its installed packages cache + await core.unified_manager.reload( + ManagerDatabaseSource.cache.value, + dont_wait=True, + update_cnr_map=False, + ) + except Exception as e: + logging.warning( + f"[ComfyUI-Manager] Failed to refresh cache after {item.kind}: {e}" + ) + + # Send WebSocket message indicating task is complete + TaskQueue.send_queue_state_update( + ManagerMessageName.cm_task_completed.value, + MessageTaskDone( + ui_id=item.ui_id, + result=result_msg, + kind=item.kind, + status=status, + timestamp=datetime.fromisoformat(timestamp), + state=self.get_current_state(), + ), + client_id=item.client_id, # Send completion only to the client that requested it + ) + + def get_current_queue(self) -> tuple[list[QueueTaskItem], list[QueueTaskItem]]: + """Get current running and remaining tasks""" + with self.mutex: + running = list(self.running_tasks.values()) + # Extract items from tuples, maintaining heap order + remaining = [item for priority, counter, item in sorted(self.pending_tasks)] + return running, remaining + + def get_tasks_remaining(self) -> int: + """Get number of tasks remaining""" + with self.mutex: + return len(self.pending_tasks) + len(self.running_tasks) + + def wipe_queue(self) -> None: + """Clear all task queue""" + with self.mutex: + pending_count = len(self.pending_tasks) + self.pending_tasks = [] + logging.debug( + "[ComfyUI-Manager] Queue wiped: cleared %d pending tasks", pending_count + ) + + def abort(self) -> None: + """Abort current operations""" + with self.mutex: + pending_count = len(self.pending_tasks) + running_count = len(self.running_tasks) + self.pending_tasks = [] + self.running_tasks = {} + logging.debug( + "[ComfyUI-Manager] Queue aborted: cleared %d pending and %d running tasks", + pending_count, + running_count, + ) + + def delete_history_item(self, ui_id: str) -> None: + """Delete specific task from history""" + with self.mutex: + self.history_tasks.pop(ui_id, None) + + def get_history( + self, + ui_id: Optional[str] = None, + max_items: Optional[int] = None, + offset: int = -1, + ) -> dict[str, TaskHistoryItem]: + """Get task history. If ui_id (task id) is passsed, only return that task's history item entry.""" + with self.mutex: + if ui_id is None: + out = {} + i = 0 + if offset < 0 and max_items is not None: + offset = len(self.history_tasks) - max_items + for k in self.history_tasks: + if i >= offset: + out[k] = self.history_tasks[k] + if max_items is not None and len(out) >= max_items: + break + i += 1 + return out + elif ui_id in self.history_tasks: + return self.history_tasks[ui_id] + else: + return {} + + def done_count(self) -> int: + """Get the number of completed tasks in history. + + Returns: + int: Number of tasks that have been completed and are stored in history. + Returns 0 if history_tasks is None (defensive programming). + """ + return len(self.history_tasks) if self.history_tasks is not None else 0 + + def total_count(self) -> int: + """Get the total number of tasks currently in the system (pending + running). + + Returns: + int: Combined count of pending and running tasks. + Returns 0 if either collection is None (defensive programming). + """ + return ( + len(self.pending_tasks) + len(self.running_tasks) + if self.pending_tasks is not None and self.running_tasks is not None + else 0 + ) + + def finalize(self) -> None: + """Finalize a completed task batch by saving execution history to disk. + + This method is intended to be called when the queue transitions from having + tasks to being completely empty (no pending or running tasks). It will create + a comprehensive snapshot of the ComfyUI state and all operations performed. + """ + if self.batch_id is not None: + batch_path = os.path.join( + context.manager_batch_history_path, self.batch_id + ".json" + ) + logging.debug( + "[ComfyUI-Manager] Finalizing batch: batch_id=%s, history_count=%d", + self.batch_id, + len(self.history_tasks), + ) + + try: + end_time = datetime.now().isoformat() + state_after = self._capture_system_state() + operations = self._extract_batch_operations() + + batch_record = BatchExecutionRecord( + batch_id=self.batch_id, + start_time=self.batch_start_time, + end_time=end_time, + state_before=self.batch_state_before, + state_after=state_after, + operations=operations, + total_operations=len(operations), + successful_operations=len( + [ + op + for op in operations + if op.result == OperationResult.success.value + ] + ), + failed_operations=len( + [ + op + for op in operations + if op.result == OperationResult.failed.value + ] + ), + skipped_operations=len( + [ + op + for op in operations + if op.result == OperationResult.skipped.value + ] + ), + ) + + # Save to disk + with open(batch_path, "w", encoding="utf-8") as json_file: + json.dump( + batch_record.model_dump(), json_file, indent=4, default=str + ) + + logging.debug( + "[ComfyUI-Manager] Batch history saved: batch_id=%s, path=%s, total_ops=%d, successful=%d, failed=%d, skipped=%d", + self.batch_id, + batch_path, + batch_record.total_operations, + batch_record.successful_operations, + batch_record.failed_operations, + batch_record.skipped_operations, + ) + + # Reset batch tracking + self.batch_id = None + self.batch_start_time = None + self.batch_state_before = None + + # Cleanup old batch files once per session + if not self._cleanup_performed: + self._cleanup_old_batches() + self._cleanup_performed = True + + except Exception as e: + logging.error(f"[ComfyUI-Manager] Failed to save batch history: {e}") + + def _capture_system_state(self) -> ComfyUISystemState: + """Capture current ComfyUI system state for batch record.""" + logging.debug("[ComfyUI-Manager] Capturing system state for batch record") + return ComfyUISystemState( + snapshot_time=datetime.now().isoformat(), + comfyui_version=self._get_comfyui_version_info(), + frontend_version=self._get_frontend_version(), + python_version=platform.python_version(), + platform_info=f"{platform.system()} {platform.release()} ({platform.machine()})", + installed_nodes=self._get_installed_nodes(), + comfyui_root_path=self._get_comfyui_root_path(), + model_paths=self._get_model_paths(), + manager_version=self._get_manager_version(), + security_level=self._get_security_level(), + network_mode=self._get_network_mode(), + cli_args=self._get_cli_args(), + custom_nodes_count=self._get_custom_nodes_count(), + failed_imports=self._get_failed_imports(), + pip_packages=self._get_pip_packages(), + manager_config=core.get_config(), + embedded_python=os.path.split(os.path.split(sys.executable)[0])[1] == "python_embeded", + ) + + def _get_comfyui_version_info(self) -> ComfyUIVersionInfo: + """Get ComfyUI version information.""" + try: + version_info = core.get_comfyui_versions() + current_version = version_info[1] if len(version_info) > 1 else "unknown" + return ComfyUIVersionInfo(version=current_version) + except Exception: + return ComfyUIVersionInfo(version="unknown") + + def _get_frontend_version(self) -> Optional[str]: + """Get ComfyUI frontend version.""" + try: + # Check if front-end-root is specified (overrides version) + if hasattr(args, "front_end_root") and args.front_end_root: + return f"custom-root: {args.front_end_root}" + + # Check if front-end-version is specified + if hasattr(args, "front_end_version") and args.front_end_version: + if "@" in args.front_end_version: + return args.front_end_version.split("@")[1] + else: + return args.front_end_version + + # Otherwise, check for installed package + pip_packages = self._get_pip_packages() + for package_name in ["comfyui-frontend", "comfyui_frontend"]: + if package_name in pip_packages: + return pip_packages[package_name] + + return None + except Exception: + return None + + def _get_installed_nodes(self) -> dict[str, InstalledNodeInfo]: + """Get information about installed node packages.""" + installed_nodes = {} try: - if not os.environ.get('__COMFYUI_DESKTOP_VERSION__') and core.comfy_ui_commit_datetime.date() < core.comfy_ui_required_commit_datetime.date(): - logging.warning(f"\n\n## [WARN] ComfyUI-Manager: Your ComfyUI version ({core.comfy_ui_revision})[{core.comfy_ui_commit_datetime.date()}] is too old. Please update to the latest version. ##\n\n") + node_packs = core.get_installed_node_packs() + for pack_name, pack_info in node_packs.items(): + # Determine install method and repository URL + install_method = "git" if pack_info.get("aux_id") else "cnr" + repository_url = None + + if pack_info.get("aux_id"): + # It's a git-based node, construct GitHub URL + repository_url = f"https://github.com/{pack_info['aux_id']}" + + installed_nodes[pack_name] = InstalledNodeInfo( + name=pack_name, + version=pack_info.get("ver", "unknown"), + install_method=install_method, + repository_url=repository_url, + enabled=pack_info.get("enabled", True), + ) + except Exception as e: + logging.warning(f"[ComfyUI-Manager] Failed to get installed nodes: {e}") + + return installed_nodes + + def _get_comfyui_root_path(self) -> str: + """Get ComfyUI root installation directory.""" + try: + return os.path.dirname(folder_paths.__file__) except Exception: + return None + + def _get_model_paths(self) -> dict[str, list[str]]: + """Get model paths for different model types.""" + try: + model_paths = {} + for model_type in model_dir_name_map.keys(): + try: + paths = folder_paths.get_folder_paths(model_type) + if paths: + model_paths[model_type] = paths + except Exception: + continue + return model_paths + except Exception: + return {} + + def _get_manager_version(self) -> str: + """Get ComfyUI Manager version.""" + try: + version_code = getattr(core, "version_code", [4, 0]) + return f"V{version_code[0]}.{version_code[1]}" + except Exception: + return None + + def _get_security_level(self) -> SecurityLevel: + """Get current security level.""" + try: + config = core.get_config() + level_str = config.get("security_level", "normal") + # Map the string to SecurityLevel enum + level_mapping = { + "strong": SecurityLevel.strong, + "normal": SecurityLevel.normal, + "normal-": SecurityLevel.normal_, + "weak": SecurityLevel.weak, + } + return level_mapping.get(level_str, SecurityLevel.normal) + except Exception: + return None + + def _get_network_mode(self) -> str: + """Get current network mode.""" + try: + config = core.get_config() + return config.get("network_mode", "online") + except Exception: + return None + + def _get_cli_args(self) -> dict[str, Any]: + """Get selected CLI arguments.""" + try: + cli_args = {} + if hasattr(args, "listen"): + cli_args["listen"] = args.listen + if hasattr(args, "port"): + cli_args["port"] = args.port + if hasattr(args, "preview_method"): + cli_args["preview_method"] = str(args.preview_method) + if hasattr(args, "enable_manager_legacy_ui"): + cli_args["enable_manager_legacy_ui"] = args.enable_manager_legacy_ui + if hasattr(args, "front_end_version"): + cli_args["front_end_version"] = args.front_end_version + if hasattr(args, "front_end_root"): + cli_args["front_end_root"] = args.front_end_root + return cli_args + except Exception: + return {} + + def _get_custom_nodes_count(self) -> int: + """Get total number of custom node packages.""" + try: + node_packs = core.get_installed_node_packs() + return len(node_packs) + except Exception: + return 0 + + def _get_failed_imports(self) -> list[str]: + """Get list of custom nodes that failed to import.""" + try: + # Check if the import_failed_extensions set is available + if hasattr(sys, "__comfyui_manager_import_failed_extensions"): + failed_set = getattr(sys, "__comfyui_manager_import_failed_extensions") + return list(failed_set) if failed_set else [] + return [] + except Exception: + return [] + + def _get_pip_packages(self) -> dict[str, str]: + """Get installed pip packages.""" + try: + return core.get_installed_pip_packages() + except Exception: + return {} + + def _extract_batch_operations(self) -> list[BatchOperation]: + """Extract operations from completed task history for this batch.""" + operations = [] + + try: + for ui_id, task in self.history_tasks.items(): + # Only include operations from the current batch + if task.batch_id != self.batch_id: + continue + + result_status = OperationResult.success + if task.status: + status_str = ( + task.status.status_str + if hasattr(task.status, "status_str") + else task.status.get( + "status_str", OperationResult.success.value + ) + ) + if status_str == OperationResult.error.value: + result_status = OperationResult.failed + elif status_str == OperationResult.skip.value: + result_status = OperationResult.skipped + + operation = BatchOperation( + operation_id=ui_id, + operation_type=task.kind, + target=f"task_{ui_id}", + result=result_status.value, + start_time=task.timestamp, + end_time=task.end_time, + client_id=task.client_id, + ) + operations.append(operation) + except Exception as e: + logging.warning( + f"[ComfyUI-Manager] Failed to extract batch operations: {e}" + ) + + return operations + + def _cleanup_old_batches(self) -> None: + """Clean up batch history files older than 90 days. + + This is a best-effort cleanup that silently ignores any errors + to avoid disrupting normal operations. + """ + try: + cutoff = datetime.now() - timedelta(days=16) + cutoff_timestamp = cutoff.timestamp() + + pattern = os.path.join(context.manager_batch_history_path, "batch_*.json") + removed_count = 0 + + import glob + + for file_path in glob.glob(pattern): + try: + if os.path.getmtime(file_path) < cutoff_timestamp: + os.remove(file_path) + removed_count += 1 + except Exception: + pass + + if removed_count > 0: + logging.debug( + "[ComfyUI-Manager] Cleaned up %d old batch history files", + removed_count, + ) + + except Exception: + # Silently ignore all errors - this is non-critical functionality pass - # process on_revision_detected --> - if 'cm.on_revision_detected_handler' in cm_global.variables: - for k, f in cm_global.variables['cm.on_revision_detected_handler']: - try: - f(core.comfy_ui_revision) - except Exception: - logging.error(f"[ERROR] '{k}' on_revision_detected_handler") - traceback.print_exc() - del cm_global.variables['cm.on_revision_detected_handler'] - else: - logging.warning("[ComfyUI-Manager] Some features are restricted due to your ComfyUI being outdated.") - # <-- +task_queue = TaskQueue() - if current_branch == "master": - if comfyui_tag: - logging.info(f"### ComfyUI Version: {comfyui_tag} | Released on '{core.comfy_ui_commit_datetime.date()}'") - else: - logging.info(f"### ComfyUI Revision: {core.comfy_ui_revision} [{comfy_ui_hash[:8]}] | Released on '{core.comfy_ui_commit_datetime.date()}'") - else: - if comfyui_tag: - logging.info(f"### ComfyUI Version: {comfyui_tag} on '{current_branch}' | Released on '{core.comfy_ui_commit_datetime.date()}'") - else: - logging.info(f"### ComfyUI Revision: {core.comfy_ui_revision} on '{current_branch}' [{comfy_ui_hash[:8]}] | Released on '{core.comfy_ui_commit_datetime.date()}'") - except Exception: - if is_detached: - logging.info(f"### ComfyUI Revision: {core.comfy_ui_revision} [{comfy_ui_hash[:8]}] *DETACHED | Released on '{core.comfy_ui_commit_datetime.date()}'") - else: - logging.info("### ComfyUI Revision: UNKNOWN (The currently installed ComfyUI is not a Git repository)") - - -print_comfyui_version() -core.check_invalid_nodes() - - - -def setup_environment(): - git_exe = core.get_config()['git_exe'] - - if git_exe != '': - git.Git().update_environment(GIT_PYTHON_GIT_EXECUTABLE=git_exe) - - -setup_environment() - -# Expand Server api - -from aiohttp import web -import aiohttp -import json -import zipfile -import urllib.request - - -def get_model_dir(data, show_log=False): - if 'download_model_base' in folder_paths.folder_names_and_paths: - models_base = folder_paths.folder_names_and_paths['download_model_base'][0][0] - else: - models_base = folder_paths.models_dir - - # NOTE: Validate to prevent path traversal. - if any(char in data['filename'] for char in {'/', '\\', ':'}): - return None - - def resolve_custom_node(save_path): - save_path = save_path[13:] # remove 'custom_nodes/' - - # NOTE: Validate to prevent path traversal. - if save_path.startswith(os.path.sep) or ':' in save_path: - return None - - repo_name = save_path.replace('\\','/').split('/')[0] # get custom node repo name - - # NOTE: The creation of files within the custom node path should be removed in the future. - repo_path = core.lookup_installed_custom_nodes_legacy(repo_name) - if repo_path is not None and repo_path[0]: - # Returns the retargeted path based on the actually installed repository - return os.path.join(os.path.dirname(repo_path[1]), save_path) - else: - return None - - if data['save_path'] != 'default': - if '..' in data['save_path'] or data['save_path'].startswith('/'): - if show_log: - logging.info(f"[WARN] '{data['save_path']}' is not allowed path. So it will be saved into 'models/etc'.") - base_model = os.path.join(models_base, "etc") - else: - if data['save_path'].startswith("custom_nodes"): - base_model = resolve_custom_node(data['save_path']) - if base_model is None: - if show_log: - logging.info(f"[ComfyUI-Manager] The target custom node for model download is not installed: {data['save_path']}") - return None - else: - base_model = os.path.join(models_base, data['save_path']) - else: - model_dir_name = model_dir_name_map.get(data['type'].lower()) - if model_dir_name is not None: - base_model = folder_paths.folder_names_and_paths[model_dir_name][0][0] - else: - base_model = os.path.join(models_base, "etc") - - return base_model - - -def get_model_path(data, show_log=False): - base_model = get_model_dir(data, show_log) - if base_model is None: - return None - else: - if data['filename'] == '': - return os.path.join(base_model, os.path.basename(data['url'])) - else: - return os.path.join(base_model, data['filename']) - - -def check_state_of_git_node_pack(node_packs, do_fetch=False, do_update_check=True, do_update=False): - if do_fetch: - print("Start fetching...", end="") - elif do_update: - print("Start updating...", end="") - elif do_update_check: - print("Start update check...", end="") - - def process_custom_node(item): - core.check_state_of_git_node_pack_single(item, do_fetch, do_update_check, do_update) - - with concurrent.futures.ThreadPoolExecutor(4) as executor: - for k, v in node_packs.items(): - if v.get('active_version') in ['unknown', 'nightly']: - executor.submit(process_custom_node, v) - - if do_fetch: - print("\x1b[2K\rFetching done.") - elif do_update: - update_exists = any(item.get('updatable', False) for item in node_packs.values()) - if update_exists: - print("\x1b[2K\rUpdate done.") - else: - print("\x1b[2K\rAll extensions are already up-to-date.") - elif do_update_check: - print("\x1b[2K\rUpdate check done.") - - -def nickname_filter(json_obj): - preemptions_map = {} - - for k, x in json_obj.items(): - if 'preemptions' in x[1]: - for y in x[1]['preemptions']: - preemptions_map[y] = k - elif k.endswith("/ComfyUI"): - for y in x[0]: - preemptions_map[y] = k - - updates = {} - for k, x in json_obj.items(): - removes = set() - for y in x[0]: - k2 = preemptions_map.get(y) - if k2 is not None and k != k2: - removes.add(y) - - if len(removes) > 0: - updates[k] = [y for y in x[0] if y not in removes] - - for k, v in updates.items(): - json_obj[k][0] = v - - return json_obj - - -class TaskBatch: - def __init__(self, batch_json, tasks, failed): - self.nodepack_result = {} - self.model_result = {} - self.batch_id = batch_json.get('batch_id') if batch_json is not None else None - self.batch_json = batch_json - self.tasks = tasks - self.current_index = 0 - self.stats = {} - self.failed = failed if failed is not None else set() - self.is_aborted = False - - def is_done(self): - return len(self.tasks) <= self.current_index - - def get_next(self): - if self.is_done(): - return None - - item = self.tasks[self.current_index] - self.current_index += 1 - return item - - def done_count(self): - return len(self.nodepack_result) + len(self.model_result) - - def total_count(self): - return len(self.tasks) - - def abort(self): - self.is_aborted = True - - def finalize(self): - if self.batch_id is not None: - batch_path = os.path.join(context.manager_batch_history_path, self.batch_id+".json") - json_obj = { - "batch": self.batch_json, - "nodepack_result": self.nodepack_result, - "model_result": self.model_result, - "failed": list(self.failed) - } - with open(batch_path, "w") as json_file: - json.dump(json_obj, json_file, indent=4) - - -temp_queue_batch = [] -task_batch_queue = deque() -tasks_in_progress = set() -task_worker_lock = threading.Lock() -aborted_batch = None - - -def finalize_temp_queue_batch(batch_json=None, failed=None): - """ - make temp_queue_batch as a batch snapshot and add to batch_queue - """ - - global temp_queue_batch - - if len(temp_queue_batch): - batch = TaskBatch(batch_json, temp_queue_batch, failed) - task_batch_queue.append(batch) - temp_queue_batch = [] +# Preview method initialization +if args.preview_method == latent_preview.LatentPreviewMethod.NoPreviews: + environment_utils.set_preview_method(core.get_config()["preview_method"]) +else: + logging.warning( + "[ComfyUI-Manager] Since --preview-method is set, ComfyUI-Manager's preview method feature will be ignored." + ) async def task_worker(): - global task_queue - global tasks_in_progress + logging.debug("[ComfyUI-Manager] Task worker started") + await core.unified_manager.reload(ManagerDatabaseSource.cache.value) - await core.unified_manager.reload('cache') + async def do_install(params: InstallPackParams) -> str: + node_id = params.id + node_version = params.selected_version + channel = params.channel + mode = params.mode + skip_post_install = params.skip_post_install - async def do_install(item) -> str: - ui_id, node_spec_str, channel, mode, skip_post_install = item + logging.debug( + "[ComfyUI-Manager] Installing node: id=%s, version=%s, channel=%s, mode=%s", + node_id, + node_version, + channel, + mode, + ) try: - node_spec = core.unified_manager.resolve_node_spec(node_spec_str) + node_spec = core.unified_manager.resolve_node_spec( + f"{node_id}@{node_version}" + ) if node_spec is None: - logging.error(f"Cannot resolve install target: '{node_spec_str}'") - return f"Cannot resolve install target: '{node_spec_str}'" + logging.error( + f"Cannot resolve install target: '{node_id}@{node_version}'" + ) + return f"Cannot resolve install target: '{node_id}@{node_version}'" node_name, version_spec, is_specified = node_spec - res = await core.unified_manager.install_by_id(node_name, version_spec, channel, mode, return_postinstall=skip_post_install) # discard post install if skip_post_install mode + res = await core.unified_manager.install_by_id( + node_name, + version_spec, + channel, + mode, + return_postinstall=skip_post_install, + ) # discard post install if skip_post_install mode - if res.action not in ['skip', 'enable', 'install-git', 'install-cnr', 'switch-cnr']: + if res.action not in [ + "skip", + "enable", + "install-git", + "install-cnr", + "switch-cnr", + ]: logging.error(f"[ComfyUI-Manager] Installation failed:\n{res.msg}") return res.msg @@ -489,171 +876,227 @@ async def task_worker(): logging.error(f"[ComfyUI-Manager] Installation failed:\n{res.msg}") return res.msg - return 'success' + return OperationResult.success.value except Exception: traceback.print_exc() - return f"Installation failed:\n{node_spec_str}" + return "Installation failed" - async def do_enable(item) -> str: - ui_id, cnr_id = item + async def do_enable(params: EnablePackParams) -> str: + cnr_id = params.cnr_id + logging.debug("[ComfyUI-Manager] Enabling node: cnr_id=%s", cnr_id) core.unified_manager.unified_enable(cnr_id) - return 'success' + return OperationResult.success.value - async def do_update(item): - ui_id, node_name, node_ver = item + async def do_update(params: UpdatePackParams) -> str: + node_name = params.node_name + node_ver = params.node_ver + + logging.debug( + "[ComfyUI-Manager] Updating node: name=%s, version=%s", node_name, node_ver + ) try: res = core.unified_manager.unified_update(node_name, node_ver) - if res.ver == 'unknown': + if res.ver == "unknown": url = core.unified_manager.unknown_active_nodes[node_name][0] try: title = os.path.basename(url) except Exception: title = node_name else: - url = core.unified_manager.cnr_map[node_name].get('repository') - title = core.unified_manager.cnr_map[node_name]['name'] + url = core.unified_manager.cnr_map[node_name].get("repository") + title = core.unified_manager.cnr_map[node_name]["name"] manager_util.clear_pip_cache() if url is not None: - base_res = {'url': url, 'title': title} + base_res = {"url": url, "title": title} else: - base_res = {'title': title} + base_res = {"title": title} if res.result: - if res.action == 'skip': - base_res['msg'] = 'skip' + if res.action == "skip": + base_res["msg"] = OperationResult.skip.value return base_res else: - base_res['msg'] = 'success' + base_res["msg"] = OperationResult.success.value return base_res - base_res['msg'] = f"An error occurred while updating '{node_name}'." - logging.error(f"\nERROR: An error occurred while updating '{node_name}'. (res.result={res.result}, res.action={res.action})") + base_res["msg"] = f"An error occurred while updating '{node_name}'." + logging.error( + f"\nERROR: An error occurred while updating '{node_name}'. (res.result={res.result}, res.action={res.action})" + ) return base_res except Exception: traceback.print_exc() - return {'msg':f"An error occurred while updating '{node_name}'."} + return {"msg": f"An error occurred while updating '{node_name}'."} - async def do_update_comfyui(is_stable) -> str: + async def do_update_comfyui(params: UpdateComfyUIParams) -> str: try: repo_path = os.path.dirname(folder_paths.__file__) - latest_tag = None - if is_stable: - res, latest_tag = core.update_to_stable_comfyui(repo_path) + + # Check if this is a version switch operation + if params.target_version: + # Switch to specific version + logging.info(f"Switching ComfyUI to version: {params.target_version}") + core.switch_comfyui(params.target_version) + return f"success-switched-{params.target_version}" else: - res = core.update_path(repo_path) - - if res == "fail": - logging.error("ComfyUI update failed") - return "fail" - elif res == "updated": + # Regular update operation + is_stable = params.is_stable if params.is_stable is not None else True + logging.debug( + "[ComfyUI-Manager] Updating ComfyUI: is_stable=%s, repo_path=%s", + is_stable, + repo_path, + ) + latest_tag = None if is_stable: - logging.info("ComfyUI is updated to latest stable version.") - return "success-stable-"+latest_tag + res, latest_tag = core.update_to_stable_comfyui(repo_path) else: - logging.info("ComfyUI is updated to latest nightly version.") - return "success-nightly" - else: # skipped - logging.info("ComfyUI is up-to-date.") - return "skip" + res = core.update_path(repo_path) + + if res == "fail": + logging.error("ComfyUI update failed") + return "fail" + elif res == "updated": + if is_stable: + logging.info("ComfyUI is updated to latest stable version.") + return "success-stable-" + latest_tag + else: + logging.info("ComfyUI is updated to latest nightly version.") + return "success-nightly" + else: # skipped + logging.info("ComfyUI is up-to-date.") + return OperationResult.skip.value except Exception: traceback.print_exc() return "An error occurred while updating 'comfyui'." - async def do_fix(item) -> str: - ui_id, node_name, node_ver = item + async def do_fix(params: FixPackParams) -> str: + node_name = params.node_name + node_ver = params.node_ver try: res = core.unified_manager.unified_fix(node_name, node_ver) if res.result: - return 'success' + return OperationResult.success.value else: logging.error(res.msg) - logging.error(f"\nERROR: An error occurred while fixing '{node_name}@{node_ver}'.") + logging.error( + f"\nERROR: An error occurred while fixing '{node_name}@{node_ver}'." + ) except Exception: traceback.print_exc() return f"An error occurred while fixing '{node_name}@{node_ver}'." - async def do_uninstall(item) -> str: - ui_id, node_name, is_unknown = item + async def do_uninstall(params: UninstallPackParams) -> str: + node_name = params.node_name + is_unknown = params.is_unknown + + logging.debug( + "[ComfyUI-Manager] Uninstalling node: name=%s, is_unknown=%s", + node_name, + is_unknown, + ) try: res = core.unified_manager.unified_uninstall(node_name, is_unknown) if res.result: - return 'success' + return OperationResult.success.value - logging.error(f"\nERROR: An error occurred while uninstalling '{node_name}'.") + logging.error( + f"\nERROR: An error occurred while uninstalling '{node_name}'." + ) except Exception: traceback.print_exc() return f"An error occurred while uninstalling '{node_name}'." - async def do_disable(item) -> str: - ui_id, node_name, is_unknown = item + async def do_disable(params: DisablePackParams) -> str: + node_name = params.node_name + + logging.debug( + "[ComfyUI-Manager] Disabling node: name=%s, is_unknown=%s", + node_name, + params.is_unknown, + ) try: - res = core.unified_manager.unified_disable(node_name, is_unknown) + res = core.unified_manager.unified_disable(node_name, params.is_unknown) if res: - return 'success' + return OperationResult.success.value except Exception: traceback.print_exc() return f"Failed to disable: '{node_name}'" - async def do_install_model(item) -> str: - ui_id, json_data = item + async def do_install_model(params: ModelMetadata) -> str: + json_data = params.model_dump() - model_path = get_model_path(json_data) - model_url = json_data['url'] + model_path = model_utils.get_model_path(json_data) + model_url = json_data.get("url") res = False try: if model_path is not None: - logging.info(f"Install model '{json_data['name']}' from '{model_url}' into '{model_path}'") + logging.info( + f"Install model '{json_data['name']}' from '{model_url}' into '{model_path}'" + ) - if json_data['filename'] == '': - if os.path.exists(os.path.join(model_path, os.path.dirname(json_data['url']))): - logging.error(f"[ComfyUI-Manager] the model path already exists: {model_path}") + if json_data["filename"] == "": + if os.path.exists( + os.path.join(model_path, os.path.dirname(json_data["url"])) + ): + logging.error( + f"[ComfyUI-Manager] the model path already exists: {model_path}" + ) return f"The model path already exists: {model_path}" - logging.info(f"[ComfyUI-Manager] Downloading '{model_url}' into '{model_path}'") - manager_downloader.download_repo_in_bytes(repo_id=model_url, local_dir=model_path) + logging.info( + f"[ComfyUI-Manager] Downloading '{model_url}' into '{model_path}'" + ) + manager_downloader.download_repo_in_bytes( + repo_id=model_url, local_dir=model_path + ) - return 'success' + return OperationResult.success.value - elif not core.get_config()['model_download_by_agent'] and ( - model_url.startswith('https://github.com') or model_url.startswith('https://huggingface.co') or model_url.startswith('https://heibox.uni-heidelberg.de')): - model_dir = get_model_dir(json_data, True) - download_url(model_url, model_dir, filename=json_data['filename']) - if model_path.endswith('.zip'): + elif not core.get_config()["model_download_by_agent"] and ( + model_url.startswith("https://github.com") + or model_url.startswith("https://huggingface.co") + or model_url.startswith("https://heibox.uni-heidelberg.de") + ): + model_dir = model_utils.get_model_dir(json_data, True) + download_url(model_url, model_dir, filename=json_data["filename"]) + if model_path.endswith(".zip"): res = core.unzip(model_path) else: res = True if res: - return 'success' + return OperationResult.success.value else: res = download_url_with_agent(model_url, model_path) - if res and model_path.endswith('.zip'): + if res and model_path.endswith(".zip"): res = core.unzip(model_path) else: - logging.error(f"[ComfyUI-Manager] Model installation error: invalid model type - {json_data['type']}") + logging.error( + f"[ComfyUI-Manager] Model installation error: invalid model type - {json_data['type']}" + ) if res: - return 'success' + return OperationResult.success.value except Exception as e: logging.error(f"[ComfyUI-Manager] ERROR: {e}", file=sys.stderr) @@ -661,169 +1104,173 @@ async def task_worker(): return f"Model installation error: {model_url}" while True: - with task_worker_lock: - if len(task_batch_queue) > 0: - cur_batch = task_batch_queue[0] - else: - logging.info("\n[ComfyUI-Manager] All tasks are completed.") + timeout = 4.0 + task = task_queue.get(timeout) + if task is None: + is_empty_queue = ( + task_queue.total_count() == 0 and len(task_queue.running_tasks) == 0 + ) + if is_empty_queue: + logging.debug("[ComfyUI-Manager] Queue empty - all tasks completed") + + did_complete_tasks = task_queue.done_count() > 0 + if did_complete_tasks: + logging.debug( + "[ComfyUI-Manager] Finalizing batch history with %d completed tasks", + task_queue.done_count(), + ) + task_queue.finalize() + logging.debug("[ComfyUI-Manager] Batch finalization complete") + logging.info("\nAfter restarting ComfyUI, please refresh the browser.") - res = {'status': 'all-done'} + res = {"status": "all-done"} + # Broadcast general status updates to all clients + logging.debug("[ComfyUI-Manager] Broadcasting queue all-done status") PromptServer.instance.send_sync("cm-queue-status", res) + logging.debug("[ComfyUI-Manager] Task worker exiting") return - if cur_batch.is_done(): - logging.info(f"\n[ComfyUI-Manager] A tasks batch(batch_id={cur_batch.batch_id}) is completed.\nstat={cur_batch.stats}") + item, task_index = task + kind = item.kind - res = {'status': 'batch-done', - 'nodepack_result': cur_batch.nodepack_result, - 'model_result': cur_batch.model_result, - 'total_count': cur_batch.total_count(), - 'done_count': cur_batch.done_count(), - 'batch_id': cur_batch.batch_id, - 'remaining_batch_count': len(task_batch_queue) } - - PromptServer.instance.send_sync("cm-queue-status", res) - cur_batch.finalize() - task_batch_queue.popleft() - continue - - with task_worker_lock: - kind, item = cur_batch.get_next() - tasks_in_progress.add((kind, item[0])) + logging.debug( + "[ComfyUI-Manager] Processing task: kind=%s, ui_id=%s, client_id=%s, task_index=%d", + kind, + item.ui_id, + item.client_id, + task_index, + ) try: - if kind == 'install': - msg = await do_install(item) - elif kind == 'enable': - msg = await do_enable(item) - elif kind == 'install-model': - msg = await do_install_model(item) - elif kind == 'update': - msg = await do_update(item) - elif kind == 'update-main': - msg = await do_update(item) - elif kind == 'update-comfyui': - msg = await do_update_comfyui(item[1]) - elif kind == 'fix': - msg = await do_fix(item) - elif kind == 'uninstall': - msg = await do_uninstall(item) - elif kind == 'disable': - msg = await do_disable(item) + if kind == OperationType.install.value: + msg = await do_install(item.params) + elif kind == OperationType.enable.value: + msg = await do_enable(item.params) + elif kind == OperationType.install_model.value: + msg = await do_install_model(item.params) + elif kind == OperationType.update.value: + msg = await do_update(item.params) + elif kind == "update-main": + msg = await do_update(item.params) + elif kind == OperationType.update_comfyui.value: + msg = await do_update_comfyui(item.params) + elif kind == OperationType.fix.value: + msg = await do_fix(item.params) + elif kind == OperationType.uninstall.value: + msg = await do_uninstall(item.params) + elif kind == OperationType.disable.value: + msg = await do_disable(item.params) else: msg = "Unexpected kind: " + kind except Exception: - traceback.print_exc() msg = f"Exception: {(kind, item)}" + logging.error( + "[ComfyUI-Manager] Task execution exception: kind=%s, ui_id=%s, error=%s", + kind, + item.ui_id, + traceback.format_exc(), + ) + await task_queue.task_done( + item, + task_index, + msg, + TaskExecutionStatus( + status_str=OperationResult.error, completed=True, messages=[msg] + ), + ) + return - with task_worker_lock: - tasks_in_progress.remove((kind, item[0])) - - ui_id = item[0] - if kind == 'install-model': - cur_batch.model_result[ui_id] = msg - ui_target = "model_manager" - elif kind == 'update-main': - cur_batch.nodepack_result[ui_id] = msg - ui_target = "main" - elif kind == 'update-comfyui': - cur_batch.nodepack_result['comfyui'] = msg - ui_target = "main" - elif kind == 'update': - cur_batch.nodepack_result[ui_id] = msg['msg'] - ui_target = "nodepack_manager" + # Determine status and message for task completion + if isinstance(msg, dict) and "msg" in msg: + result_msg = msg["msg"] else: - cur_batch.nodepack_result[ui_id] = msg - ui_target = "nodepack_manager" + result_msg = msg - cur_batch.stats[kind] = cur_batch.stats.get(kind, 0) + 1 + # Determine status + if result_msg == OperationResult.success.value: + status = TaskExecutionStatus( + status_str=OperationResult.success, completed=True, messages=[] + ) + elif result_msg == OperationResult.skip.value: + status = TaskExecutionStatus( + status_str=OperationResult.skip, completed=True, messages=[] + ) + else: + status = TaskExecutionStatus( + status_str=OperationResult.error, completed=True, messages=[result_msg] + ) - PromptServer.instance.send_sync("cm-queue-status", - {'status': 'in_progress', - 'target': item[0], - 'batch_id': cur_batch.batch_id, - 'ui_target': ui_target, - 'total_count': cur_batch.total_count(), - 'done_count': cur_batch.done_count()}) + logging.debug( + "[ComfyUI-Manager] Task execution completed: kind=%s, ui_id=%s, status=%s, result=%s", + kind, + item.ui_id, + status.status_str, + result_msg, + ) + await task_queue.task_done(item, task_index, result_msg, status) -@routes.post("/v2/manager/queue/batch") -async def queue_batch(request): - json_data = await request.json() +@routes.post("/v2/manager/queue/task") +async def queue_task(request) -> web.Response: + """Add a new task to the processing queue. - failed = set() + Accepts task data via JSON POST and adds it to the TaskQueue for processing. + The task worker will automatically pick up and process queued tasks. - for k, v in json_data.items(): - if k == 'update_all': - await _update_all({'mode': v}) + Args: + request: aiohttp request containing JSON task data - elif k == 'reinstall': - for x in v: - res = await _uninstall_custom_node(x) - if res.status != 200: - failed.add(x[0]) - else: - res = await _install_custom_node(x) - if res.status != 200: - failed.add(x[0]) - - elif k == 'install': - for x in v: - res = await _install_custom_node(x) - if res.status != 200: - failed.add(x[0]) - - elif k == 'uninstall': - for x in v: - res = await _uninstall_custom_node(x) - if res.status != 200: - failed.add(x[0]) - - elif k == 'update': - for x in v: - res = await _update_custom_node(x) - if res.status != 200: - failed.add(x[0]) - - elif k == 'update_comfyui': - await update_comfyui(None) - - elif k == 'disable': - for x in v: - await _disable_node(x) - - elif k == 'install_model': - for x in v: - res = await _install_model(x) - if res.status != 200: - failed.add(x[0]) - - elif k == 'fix': - for x in v: - res = await _fix_custom_node(x) - if res.status != 200: - failed.add(x[0]) - - with task_worker_lock: - finalize_temp_queue_batch(json_data, failed) - _queue_start() - - return web.json_response({"failed": list(failed)}, content_type='application/json') + Returns: + web.Response: HTTP 200 on successful queueing, HTTP 400 on validation error + """ + try: + json_data = await request.json() + # Validate input using Pydantic model + task_item = QueueTaskItem.model_validate(json_data) + logging.debug( + "[ComfyUI-Manager] Queueing task via API: kind=%s, ui_id=%s, client_id=%s", + task_item.kind, + task_item.ui_id, + task_item.client_id, + ) + TaskQueue.instance.put(task_item) + # maybe start worker + return web.Response(status=200) + except ValidationError as e: + logging.error(f"[ComfyUI-Manager] Invalid task data: {e}") + return web.Response(status=400, text=f"Invalid task data: {e}") + except Exception as e: + logging.error(f"[ComfyUI-Manager] Error processing task: {e}") + return web.Response(status=500, text="Internal server error") @routes.get("/v2/manager/queue/history_list") -async def get_history_list(request): +async def get_history_list(request) -> web.Response: + """Get list of available batch history files. + + Returns a list of batch history IDs sorted by modification time (newest first). + These IDs can be used with the history endpoint to retrieve detailed batch information. + + Returns: + web.Response: JSON response with 'ids' array of history file IDs + """ history_path = context.manager_batch_history_path try: - files = [os.path.join(history_path, f) for f in os.listdir(history_path) if os.path.isfile(os.path.join(history_path, f))] + files = [ + os.path.join(history_path, f) + for f in os.listdir(history_path) + if os.path.isfile(os.path.join(history_path, f)) + ] files.sort(key=lambda x: os.path.getmtime(x), reverse=True) history_ids = [os.path.basename(f)[:-5] for f in files] - return web.json_response({"ids": list(history_ids)}, content_type='application/json') + return web.json_response( + {"ids": list(history_ids)}, content_type="application/json" + ) except Exception as e: logging.error(f"[ComfyUI-Manager] /v2/manager/queue/history_list - {e}") return web.Response(status=400) @@ -831,14 +1278,67 @@ async def get_history_list(request): @routes.get("/v2/manager/queue/history") async def get_history(request): - try: - json_name = request.rel_url.query["id"]+'.json' - batch_path = os.path.join(context.manager_batch_history_path, json_name) + """Get task history with optional client filtering. - with open(batch_path, 'r', encoding='utf-8') as file: - json_str = file.read() - json_obj = json.loads(json_str) - return web.json_response(json_obj, content_type='application/json') + Query parameters: + id: Batch history ID (for file-based history) + client_id: Optional client ID to filter current session history + ui_id: Optional specific task ID to get single task history + max_items: Maximum number of items to return + offset: Offset for pagination + + Returns: + JSON with filtered history data + """ + try: + # Handle file-based batch history + if "id" in request.rel_url.query: + json_name = request.rel_url.query["id"] + ".json" + batch_path = os.path.join(context.manager_batch_history_path, json_name) + logging.debug( + "[ComfyUI-Manager] Fetching batch history: id=%s", + request.rel_url.query["id"], + ) + + with open(batch_path, "r", encoding="utf-8") as file: + json_str = file.read() + json_obj = json.loads(json_str) + return web.json_response(json_obj, content_type="application/json") + + # Handle current session history with optional filtering + client_id = request.rel_url.query.get("client_id") + ui_id = request.rel_url.query.get("ui_id") + max_items = request.rel_url.query.get("max_items") + offset = request.rel_url.query.get("offset", -1) + + logging.debug( + "[ComfyUI-Manager] Fetching history: client_id=%s, ui_id=%s, max_items=%s", + client_id, + ui_id, + max_items, + ) + + if max_items: + max_items = int(max_items) + if offset: + offset = int(offset) + + # Get history from TaskQueue + if ui_id: + history = task_queue.get_history(ui_id=ui_id) + else: + history = task_queue.get_history(max_items=max_items, offset=offset) + + # Filter by client_id if provided + if client_id and isinstance(history, dict): + filtered_history = { + task_id: task_data + for task_id, task_data in history.items() + if hasattr(task_data, "client_id") and task_data.client_id == client_id + } + history = filtered_history + + return web.json_response({"history": history}, content_type="application/json") except Exception as e: logging.error(f"[ComfyUI-Manager] /v2/manager/queue/history - {e}") @@ -858,19 +1358,19 @@ async def fetch_customnode_mappings(request): mode = "local" nickname_mode = True - json_obj = await core.get_data_by_mode(mode, 'extension-node-map.json') + json_obj = await core.get_data_by_mode(mode, "extension-node-map.json") json_obj = core.map_to_unified_keys(json_obj) if nickname_mode: - json_obj = nickname_filter(json_obj) + json_obj = node_pack_utils.nickname_filter(json_obj) all_nodes = set() patterns = [] for k, x in json_obj.items(): all_nodes.update(set(x[0])) - if 'nodename_pattern' in x[1]: - patterns.append((x[1]['nodename_pattern'], x[0])) + if "nodename_pattern" in x[1]: + patterns.append((x[1]["nodename_pattern"], x[0])) missing_nodes = set(nodes.NODE_CLASS_MAPPINGS.keys()) - all_nodes @@ -879,126 +1379,103 @@ async def fetch_customnode_mappings(request): if re.match(pat, x): item.append(x) - return web.json_response(json_obj, content_type='application/json') + return web.json_response(json_obj, content_type="application/json") @routes.get("/v2/customnode/fetch_updates") async def fetch_updates(request): - try: - if request.rel_url.query["mode"] == "local": - channel = 'local' - else: - channel = core.get_config()['channel_url'] + """ + DEPRECATED: This endpoint is no longer supported. - await core.unified_manager.reload(request.rel_url.query["mode"]) - await core.unified_manager.get_custom_nodes(channel, request.rel_url.query["mode"]) - - res = core.unified_manager.fetch_or_pull_git_repo(is_pull=False) - - for x in res['failed']: - logging.error(f"FETCH FAILED: {x}") - - logging.info("\nDone.") - - if len(res['updated']) > 0: - return web.Response(status=201) - - return web.Response(status=200) - except Exception: - traceback.print_exc() - return web.Response(status=400) + Repository fetching has been removed from the API. + Updates should be performed through the queue system using update operations. + """ + return web.json_response( + { + "error": "This endpoint has been deprecated", + "message": "Repository fetching is no longer supported. Please use the update operations through the queue system.", + "deprecated": True, + }, + status=410, # 410 Gone + ) @routes.get("/v2/manager/queue/update_all") -async def update_all(request): - json_data = dict(request.rel_url.query) - return await _update_all(json_data) +async def update_all(request: web.Request) -> web.Response: + try: + # Validate query parameters using Pydantic model + query_params = UpdateAllQueryParams.model_validate(dict(request.rel_url.query)) + return await _update_all(query_params) + except ValidationError as e: + return web.json_response( + {"error": "Validation error", "details": e.errors()}, status=400 + ) -async def _update_all(json_data): - if not is_allowed_security_level('middle'): +async def _update_all(params: UpdateAllQueryParams) -> web.Response: + if not security_utils.is_allowed_security_level("middle"): logging.error(SECURITY_MESSAGE_MIDDLE_OR_BELOW) return web.Response(status=403) - with task_worker_lock: - is_processing = task_worker_thread is not None and task_worker_thread.is_alive() - if is_processing: - return web.Response(status=401) - - await core.save_snapshot_with_postfix('autosave') + # Extract client info from validated params + base_ui_id = params.ui_id + client_id = params.client_id + mode = params.mode.value if params.mode else ManagerDatabaseSource.remote.value - if json_data["mode"] == "local": - channel = 'local' + logging.debug( + "[ComfyUI-Manager] Update all requested: client_id=%s, base_ui_id=%s, mode=%s", + client_id, + base_ui_id, + mode, + ) + + if mode == ManagerDatabaseSource.local.value: + channel = "local" else: - channel = core.get_config()['channel_url'] + channel = core.get_config()["channel_url"] - await core.unified_manager.reload(json_data["mode"]) - await core.unified_manager.get_custom_nodes(channel, json_data["mode"]) + await core.unified_manager.reload(mode) + await core.unified_manager.get_custom_nodes(channel, mode) + update_count = 0 for k, v in core.unified_manager.active_nodes.items(): - if k == 'comfyui-manager': + if k == "comfyui-manager": # skip updating comfyui-manager if desktop version - if os.environ.get('__COMFYUI_DESKTOP_VERSION__'): + if os.environ.get("__COMFYUI_DESKTOP_VERSION__"): continue - update_item = k, k, v[0] - temp_queue_batch.append(("update-main", update_item)) + update_task = QueueTaskItem( + kind=OperationType.update.value, + ui_id=f"{base_ui_id}_{k}", # Use client's base ui_id + node name + client_id=client_id, + params=UpdatePackParams(node_name=k, node_ver=v[0]), + ) + task_queue.put(update_task) + update_count += 1 for k, v in core.unified_manager.unknown_active_nodes.items(): - if k == 'comfyui-manager': + if k == "comfyui-manager": # skip updating comfyui-manager if desktop version - if os.environ.get('__COMFYUI_DESKTOP_VERSION__'): + if os.environ.get("__COMFYUI_DESKTOP_VERSION__"): continue - update_item = k, k, 'unknown' - temp_queue_batch.append(("update-main", update_item)) + update_task = QueueTaskItem( + kind=OperationType.update.value, + ui_id=f"{base_ui_id}_{k}", # Use client's base ui_id + node name + client_id=client_id, + params=UpdatePackParams(node_name=k, node_ver="unknown"), + ) + task_queue.put(update_task) + update_count += 1 + logging.debug( + "[ComfyUI-Manager] Update all queued %d tasks for client_id=%s", + update_count, + client_id, + ) return web.Response(status=200) -def convert_markdown_to_html(input_text): - pattern_a = re.compile(r'\[a/([^]]+)]\(([^)]+)\)') - pattern_w = re.compile(r'\[w/([^]]+)]') - pattern_i = re.compile(r'\[i/([^]]+)]') - pattern_bold = re.compile(r'\*\*([^*]+)\*\*') - pattern_white = re.compile(r'%%([^*]+)%%') - - def replace_a(match): - return f"{match.group(1)}" - - def replace_w(match): - return f"

{match.group(1)}

" - - def replace_i(match): - return f"

{match.group(1)}

" - - def replace_bold(match): - return f"{match.group(1)}" - - def replace_white(match): - return f"{match.group(1)}" - - input_text = input_text.replace('\\[', '[').replace('\\]', ']').replace('<', '<').replace('>', '>') - - result_text = re.sub(pattern_a, replace_a, input_text) - result_text = re.sub(pattern_w, replace_w, result_text) - result_text = re.sub(pattern_i, replace_i, result_text) - result_text = re.sub(pattern_bold, replace_bold, result_text) - result_text = re.sub(pattern_white, replace_white, result_text) - - return result_text.replace("\n", "
") - - -def populate_markdown(x): - if 'description' in x: - x['description'] = convert_markdown_to_html(manager_util.sanitize_tag(x['description'])) - - if 'name' in x: - x['name'] = manager_util.sanitize_tag(x['name']) - - if 'title' in x: - x['title'] = manager_util.sanitize_tag(x['title']) - @routes.get("/v2/manager/is_legacy_manager_ui") async def is_legacy_manager_ui(request): return web.json_response( @@ -1007,156 +1484,35 @@ async def is_legacy_manager_ui(request): status=200, ) + # freeze imported version startup_time_installed_node_packs = core.get_installed_node_packs() + + @routes.get("/v2/customnode/installed") async def installed_list(request): - mode = request.query.get('mode', 'default') + mode = request.query.get("mode", "default") - if mode == 'imported': + if mode == "imported": res = startup_time_installed_node_packs else: res = core.get_installed_node_packs() - return web.json_response(res, content_type='application/json') + return web.json_response(res, content_type="application/json") -@routes.get("/v2/customnode/getlist") -async def fetch_customnode_list(request): - """ - provide unified custom node list - """ - if request.rel_url.query.get("skip_update", '').lower() == "true": - skip_update = True - else: - skip_update = False - - if request.rel_url.query["mode"] == "local": - channel = 'local' - else: - channel = core.get_config()['channel_url'] - - node_packs = await core.get_unified_total_nodes(channel, request.rel_url.query["mode"], 'cache') - json_obj_github = core.get_data_by_mode(request.rel_url.query["mode"], 'github-stats.json', 'default') - json_obj_extras = core.get_data_by_mode(request.rel_url.query["mode"], 'extras.json', 'default') - - core.populate_github_stats(node_packs, await json_obj_github) - core.populate_favorites(node_packs, await json_obj_extras) - - check_state_of_git_node_pack(node_packs, not skip_update, do_update_check=not skip_update) - - for v in node_packs.values(): - populate_markdown(v) - - if channel != 'local': - found = 'custom' - - for name, url in core.get_channel_dict().items(): - if url == channel: - found = name - break - - channel = found - - result = dict(channel=channel, node_packs=node_packs) - - return web.json_response(result, content_type='application/json') - - -@routes.get("/customnode/alternatives") -async def fetch_customnode_alternatives(request): - alter_json = await core.get_data_by_mode(request.rel_url.query["mode"], 'alter-list.json') - - res = {} - - for item in alter_json['items']: - populate_markdown(item) - res[item['id']] = item - - res = core.map_to_unified_keys(res) - - return web.json_response(res, content_type='application/json') - - -def check_model_installed(json_obj): - def is_exists(model_dir_name, filename, url): - if filename == '': - filename = os.path.basename(url) - - dirs = folder_paths.get_folder_paths(model_dir_name) - - for x in dirs: - if os.path.exists(os.path.join(x, filename)): - return True - - return False - - model_dir_names = ['checkpoints', 'loras', 'vae', 'text_encoders', 'diffusion_models', 'clip_vision', 'embeddings', - 'diffusers', 'vae_approx', 'controlnet', 'gligen', 'upscale_models', 'hypernetworks', - 'photomaker', 'classifiers'] - - total_models_files = set() - for x in model_dir_names: - for y in folder_paths.get_filename_list(x): - total_models_files.add(y) - - def process_model_phase(item): - if 'diffusion' not in item['filename'] and 'pytorch' not in item['filename'] and 'model' not in item['filename']: - # non-general name case - if item['filename'] in total_models_files: - item['installed'] = 'True' - return - - if item['save_path'] == 'default': - model_dir_name = model_dir_name_map.get(item['type'].lower()) - if model_dir_name is not None: - item['installed'] = str(is_exists(model_dir_name, item['filename'], item['url'])) - else: - item['installed'] = 'False' - else: - model_dir_name = item['save_path'].split('/')[0] - if model_dir_name in folder_paths.folder_names_and_paths: - if is_exists(model_dir_name, item['filename'], item['url']): - item['installed'] = 'True' - - if 'installed' not in item: - if item['filename'] == '': - filename = os.path.basename(item['url']) - else: - filename = item['filename'] - - fullpath = os.path.join(folder_paths.models_dir, item['save_path'], filename) - - item['installed'] = 'True' if os.path.exists(fullpath) else 'False' - - with concurrent.futures.ThreadPoolExecutor(8) as executor: - for item in json_obj['models']: - executor.submit(process_model_phase, item) - - -@routes.get("/v2/externalmodel/getlist") -async def fetch_externalmodel_list(request): - # The model list is only allowed in the default channel, yet. - json_obj = await core.get_data_by_mode(request.rel_url.query["mode"], 'model-list.json') - - check_model_installed(json_obj) - - for x in json_obj['models']: - populate_markdown(x) - - return web.json_response(json_obj, content_type='application/json') - - -@PromptServer.instance.routes.get("/v2/snapshot/getlist") +@routes.get("/v2/snapshot/getlist") async def get_snapshot_list(request): - items = [f[:-5] for f in os.listdir(context.manager_snapshot_path) if f.endswith('.json')] + items = [ + f[:-5] for f in os.listdir(context.manager_snapshot_path) if f.endswith(".json") + ] items.sort(reverse=True) - return web.json_response({'items': items}, content_type='application/json') + return web.json_response({"items": items}, content_type="application/json") @routes.get("/v2/snapshot/remove") async def remove_snapshot(request): - if not is_allowed_security_level('middle'): + if not security_utils.is_allowed_security_level("middle"): logging.error(SECURITY_MESSAGE_MIDDLE_OR_BELOW) return web.Response(status=403) @@ -1174,7 +1530,7 @@ async def remove_snapshot(request): @routes.get("/v2/snapshot/restore") async def restore_snapshot(request): - if not is_allowed_security_level('middle'): + if not security_utils.is_allowed_security_level("middle"): logging.error(SECURITY_MESSAGE_MIDDLE_OR_BELOW) return web.Response(status=403) @@ -1186,7 +1542,9 @@ async def restore_snapshot(request): if not os.path.exists(context.manager_startup_script_path): os.makedirs(context.manager_startup_script_path) - target_path = os.path.join(context.manager_startup_script_path, "restore-snapshot.json") + target_path = os.path.join( + context.manager_startup_script_path, "restore-snapshot.json" + ) shutil.copy(path, target_path) logging.info(f"Snapshot restore scheduled: `{target}`") @@ -1201,7 +1559,9 @@ async def restore_snapshot(request): @routes.get("/v2/snapshot/get_current") async def get_current_snapshot_api(request): try: - return web.json_response(await core.get_current_snapshot(), content_type='application/json') + return web.json_response( + await core.get_current_snapshot(), content_type="application/json" + ) except Exception: return web.Response(status=400) @@ -1209,29 +1569,30 @@ async def get_current_snapshot_api(request): @routes.get("/v2/snapshot/save") async def save_snapshot(request): try: - await core.save_snapshot_with_postfix('snapshot') + await core.save_snapshot_with_postfix("snapshot") return web.Response(status=200) except Exception: return web.Response(status=400) def unzip_install(files): - temp_filename = 'manager-temp.zip' + temp_filename = "manager-temp.zip" for url in files: if url.endswith("/"): url = url[:-1] try: headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3'} + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3" + } req = urllib.request.Request(url, headers=headers) response = urllib.request.urlopen(req) data = response.read() - with open(temp_filename, 'wb') as f: + with open(temp_filename, "wb") as f: f.write(data) - with zipfile.ZipFile(temp_filename, 'r') as zip_ref: + with zipfile.ZipFile(temp_filename, "r") as zip_ref: zip_ref.extractall(core.get_default_custom_nodes_path()) os.remove(temp_filename) @@ -1243,304 +1604,144 @@ def unzip_install(files): return True -@routes.get("/v2/customnode/versions/{node_name}") -async def get_cnr_versions(request): - node_name = request.match_info.get("node_name", None) - versions = core.cnr_utils.all_versions_of_node(node_name) - - if versions is not None: - return web.json_response(versions, content_type='application/json') - - return web.Response(status=400) - - -@routes.get("/v2/customnode/disabled_versions/{node_name}") -async def get_disabled_versions(request): - node_name = request.match_info.get("node_name", None) - versions = [] - if node_name in core.unified_manager.nightly_inactive_nodes: - versions.append(dict(version='nightly')) - - for v in core.unified_manager.cnr_inactive_nodes.get(node_name, {}).keys(): - versions.append(dict(version=v)) - - if versions: - return web.json_response(versions, content_type='application/json') - - return web.Response(status=400) - - @routes.post("/v2/customnode/import_fail_info") async def import_fail_info(request): - json_data = await request.json() + try: + json_data = await request.json() - if 'cnr_id' in json_data: - module_name = core.unified_manager.get_module_name(json_data['cnr_id']) - else: - module_name = core.unified_manager.get_module_name(json_data['url']) + # Basic validation - ensure we have either cnr_id or url + if not isinstance(json_data, dict): + return web.Response(status=400, text="Request body must be a JSON object") - if module_name is not None: - info = cm_global.error_dict.get(module_name) - if info is not None: - return web.json_response(info) + if "cnr_id" not in json_data and "url" not in json_data: + return web.Response( + status=400, text="Either 'cnr_id' or 'url' field is required" + ) - return web.Response(status=400) + if "cnr_id" in json_data: + if not isinstance(json_data["cnr_id"], str): + return web.Response(status=400, text="'cnr_id' must be a string") + module_name = core.unified_manager.get_module_name(json_data["cnr_id"]) + else: + if not isinstance(json_data["url"], str): + return web.Response(status=400, text="'url' must be a string") + module_name = core.unified_manager.get_module_name(json_data["url"]) + if module_name is not None: + info = cm_global.error_dict.get(module_name) + if info is not None: + return web.json_response(info) -@routes.post("/v2/manager/queue/reinstall") -async def reinstall_custom_node(request): - await uninstall_custom_node(request) - await install_custom_node(request) + return web.Response(status=400) + except Exception as e: + logging.error(f"[ComfyUI-Manager] Error processing import fail info: {e}") + return web.Response(status=500, text="Internal server error") @routes.get("/v2/manager/queue/reset") async def reset_queue(request): - global task_batch_queue - global temp_queue_batch - - with task_worker_lock: - temp_queue_batch = [] - task_batch_queue = deque() - - return web.Response(status=200) - - -@routes.get("/v2/manager/queue/abort_current") -async def abort_queue(request): - global task_batch_queue - global temp_queue_batch - - with task_worker_lock: - temp_queue_batch = [] - if len(task_batch_queue) > 0: - task_batch_queue[0].abort() - task_batch_queue.popleft() - + logging.debug("[ComfyUI-Manager] Queue reset requested") + task_queue.wipe_queue() return web.Response(status=200) @routes.get("/v2/manager/queue/status") async def queue_count(request): - global task_queue + """Get current queue status with optional client filtering. - with task_worker_lock: - if len(task_batch_queue) > 0: - cur_batch = task_batch_queue[0] - done_count = cur_batch.done_count() - total_count = cur_batch.total_count() - in_progress_count = len(tasks_in_progress) - is_processing = task_worker_thread is not None and task_worker_thread.is_alive() - else: - done_count = 0 - total_count = 0 - in_progress_count = 0 - is_processing = False + Query parameters: + client_id: Optional client ID to filter tasks - return web.json_response({ - 'total_count': total_count, - 'done_count': done_count, - 'in_progress_count': in_progress_count, - 'is_processing': is_processing}) + Returns: + JSON with queue counts and processing status + """ + client_id = request.query.get("client_id") + if client_id: + # Filter tasks by client_id + running_client_tasks = [ + task + for task in task_queue.running_tasks.values() + if task.client_id == client_id + ] + pending_client_tasks = [ + item + for priority, counter, item in task_queue.pending_tasks + if item.client_id == client_id + ] + history_client_tasks = { + ui_id: task + for ui_id, task in task_queue.history_tasks.items() + if hasattr(task, "client_id") and task.client_id == client_id + } -@routes.post("/v2/manager/queue/install") -async def install_custom_node(request): - json_data = await request.json() - print(f"install={json_data}") - return await _install_custom_node(json_data) - - -async def _install_custom_node(json_data): - if not is_allowed_security_level('middle'): - logging.error(SECURITY_MESSAGE_MIDDLE_OR_BELOW) - return web.Response(status=403, text="A security error has occurred. Please check the terminal logs") - - # non-nightly cnr is safe - risky_level = None - cnr_id = json_data.get('id') - skip_post_install = json_data.get('skip_post_install') - - git_url = None - - selected_version = json_data.get('selected_version') - if json_data['version'] != 'unknown' and selected_version != 'unknown': - if skip_post_install: - if cnr_id in core.unified_manager.nightly_inactive_nodes or cnr_id in core.unified_manager.cnr_inactive_nodes: - enable_item = json_data.get('ui_id'), cnr_id - temp_queue_batch.append(("enable", enable_item)) - return web.Response(status=200) - - elif selected_version is None: - selected_version = 'latest' - - if selected_version != 'nightly': - risky_level = 'low' - node_spec_str = f"{cnr_id}@{selected_version}" - else: - node_spec_str = f"{cnr_id}@nightly" - git_url = [json_data.get('repository')] - if git_url is None: - logging.error(f"[ComfyUI-Manager] Following node pack doesn't provide `nightly` version: ${git_url}") - return web.Response(status=404, text=f"Following node pack doesn't provide `nightly` version: ${git_url}") - - elif json_data['version'] != 'unknown' and selected_version == 'unknown': - logging.error(f"[ComfyUI-Manager] Invalid installation request: {json_data}") - return web.Response(status=400, text="Invalid installation request") - + return web.json_response( + { + "client_id": client_id, + "total_count": len(pending_client_tasks) + len(running_client_tasks), + "done_count": len(history_client_tasks), + "in_progress_count": len(running_client_tasks), + "pending_count": len(pending_client_tasks), + "is_processing": len(running_client_tasks) > 0, + } + ) else: - # unknown - unknown_name = os.path.basename(json_data['files'][0]) - node_spec_str = f"{unknown_name}@unknown" - git_url = json_data.get('files') + # Return overall status + return web.json_response( + { + "total_count": task_queue.total_count(), + "done_count": task_queue.done_count(), + "in_progress_count": len(task_queue.running_tasks), + "pending_count": len(task_queue.pending_tasks), + "is_processing": task_queue.is_processing(), + } + ) - # apply security policy if not cnr node (nightly isn't regarded as cnr node) - if risky_level is None: - if git_url is not None: - risky_level = await get_risky_level(git_url, json_data.get('pip', [])) - else: - return web.Response(status=404, text=f"Following node pack doesn't provide `nightly` version: ${git_url}") - - if not is_allowed_security_level(risky_level): - logging.error(SECURITY_MESSAGE_GENERAL) - return web.Response(status=404, text="A security error has occurred. Please check the terminal logs") - - install_item = json_data.get('ui_id'), node_spec_str, json_data['channel'], json_data['mode'], skip_post_install - temp_queue_batch.append(("install", install_item)) - - return web.Response(status=200) - - -task_worker_thread:threading.Thread = None @routes.get("/v2/manager/queue/start") async def queue_start(request): - with task_worker_lock: - finalize_temp_queue_batch() - return _queue_start() + logging.debug("[ComfyUI-Manager] Queue start requested") + started = task_queue.start_worker() -def _queue_start(): - global task_worker_thread - - if task_worker_thread is not None and task_worker_thread.is_alive(): - return web.Response(status=201) # already in-progress - - task_worker_thread = threading.Thread(target=lambda: asyncio.run(task_worker())) - task_worker_thread.start() - - return web.Response(status=200) - - -@routes.post("/v2/manager/queue/fix") -async def fix_custom_node(request): - json_data = await request.json() - return await _fix_custom_node(json_data) - - -async def _fix_custom_node(json_data): - if not is_allowed_security_level('middle'): - logging.error(SECURITY_MESSAGE_GENERAL) - return web.Response(status=403, text="A security error has occurred. Please check the terminal logs") - - node_id = json_data.get('id') - node_ver = json_data['version'] - if node_ver != 'unknown': - node_name = node_id + if started: + logging.debug("[ComfyUI-Manager] Queue worker started successfully") + return web.Response(status=200) # Started successfully else: - # unknown - node_name = os.path.basename(json_data['files'][0]) - - update_item = json_data.get('ui_id'), node_name, json_data['version'] - temp_queue_batch.append(("fix", update_item)) - - return web.Response(status=200) - - -@routes.post("/v2/customnode/install/git_url") -async def install_custom_node_git_url(request): - if not is_allowed_security_level('high'): - logging.error(SECURITY_MESSAGE_NORMAL_MINUS) - return web.Response(status=403) - - url = await request.text() - res = await core.gitclone_install(url) - - if res.action == 'skip': - logging.info(f"\nAlready installed: '{res.target}'") - return web.Response(status=200) - elif res.result: - logging.info("\nAfter restarting ComfyUI, please refresh the browser.") - return web.Response(status=200) - - logging.error(res.msg) - return web.Response(status=400) - - -@routes.post("/v2/customnode/install/pip") -async def install_custom_node_pip(request): - if not is_allowed_security_level('high'): - logging.error(SECURITY_MESSAGE_NORMAL_MINUS) - return web.Response(status=403) - - packages = await request.text() - core.pip_install(packages.split(' ')) - - return web.Response(status=200) - - -@routes.post("/v2/manager/queue/uninstall") -async def uninstall_custom_node(request): - json_data = await request.json() - return await _uninstall_custom_node(json_data) - - -async def _uninstall_custom_node(json_data): - if not is_allowed_security_level('middle'): - logging.error(SECURITY_MESSAGE_MIDDLE_OR_BELOW) - return web.Response(status=403, text="A security error has occurred. Please check the terminal logs") - - node_id = json_data.get('id') - if json_data['version'] != 'unknown': - is_unknown = False - node_name = node_id - else: - # unknown - is_unknown = True - node_name = os.path.basename(json_data['files'][0]) - - uninstall_item = json_data.get('ui_id'), node_name, is_unknown - temp_queue_batch.append(("uninstall", uninstall_item)) - - return web.Response(status=200) - - -@routes.post("/v2/manager/queue/update") -async def update_custom_node(request): - json_data = await request.json() - return await _update_custom_node(json_data) - - -async def _update_custom_node(json_data): - if not is_allowed_security_level('middle'): - logging.error(SECURITY_MESSAGE_MIDDLE_OR_BELOW) - return web.Response(status=403, text="A security error has occurred. Please check the terminal logs") - - node_id = json_data.get('id') - if json_data['version'] != 'unknown': - node_name = node_id - else: - # unknown - node_name = os.path.basename(json_data['files'][0]) - - update_item = json_data.get('ui_id'), node_name, json_data['version'] - temp_queue_batch.append(("update", update_item)) - - return web.Response(status=200) + logging.debug("[ComfyUI-Manager] Queue worker already in progress") + return web.Response(status=201) # Already in-progress @routes.get("/v2/manager/queue/update_comfyui") async def update_comfyui(request): - is_stable = core.get_config()['update_policy'] != 'nightly-comfyui' - temp_queue_batch.append(("update-comfyui", ('comfyui', is_stable))) + """Queue a ComfyUI update based on the configured update policy.""" + try: + # Validate query parameters using Pydantic model + query_params = UpdateComfyUIQueryParams.model_validate( + dict(request.rel_url.query) + ) + + # Check if stable parameter was provided, otherwise use config + if query_params.stable is None: + is_stable = core.get_config()["update_policy"] != "nightly-comfyui" + else: + is_stable = query_params.stable + + client_id = query_params.client_id + ui_id = query_params.ui_id + except ValidationError as e: + return web.json_response( + {"error": "Validation error", "details": e.errors()}, status=400 + ) + + # Create update-comfyui task + task = QueueTaskItem( + ui_id=ui_id, + client_id=client_id, + kind=OperationType.update_comfyui.value, + params=UpdateComfyUIParams(is_stable=is_stable), + ) + + task_queue.put(task) return web.Response(status=200) @@ -1548,7 +1749,11 @@ async def update_comfyui(request): async def comfyui_versions(request): try: res, current, latest = core.get_comfyui_versions() - return web.json_response({'versions': res, 'current': current}, status=200, content_type='application/json') + return web.json_response( + {"versions": res, "current": current}, + status=200, + content_type="application/json", + ) except Exception as e: logging.error(f"ComfyUI update fail: {e}", file=sys.stderr) @@ -1558,118 +1763,73 @@ async def comfyui_versions(request): @routes.get("/v2/comfyui_manager/comfyui_switch_version") async def comfyui_switch_version(request): try: - if "ver" in request.rel_url.query: - core.switch_comfyui(request.rel_url.query['ver']) + # Validate query parameters using Pydantic model + query_params = ComfyUISwitchVersionQueryParams.model_validate( + dict(request.rel_url.query) + ) + target_version = query_params.ver + client_id = query_params.client_id + ui_id = query_params.ui_id + + # Create update-comfyui task with target version + task = QueueTaskItem( + ui_id=ui_id, + client_id=client_id, + kind=OperationType.update_comfyui.value, + params=UpdateComfyUIParams(target_version=target_version), + ) + + task_queue.put(task) return web.Response(status=200) + except ValidationError as e: + return web.json_response( + {"error": "Validation error", "details": e.errors()}, status=400 + ) except Exception as e: - logging.error(f"ComfyUI update fail: {e}", file=sys.stderr) - - return web.Response(status=400) - - -@routes.post("/v2/manager/queue/disable") -async def disable_node(request): - json_data = await request.json() - await _disable_node(json_data) - return web.Response(status=200) - - -async def _disable_node(json_data): - node_id = json_data.get('id') - if json_data['version'] != 'unknown': - is_unknown = False - node_name = node_id - else: - # unknown - is_unknown = True - node_name = os.path.basename(json_data['files'][0]) - - update_item = json_data.get('ui_id'), node_name, is_unknown - temp_queue_batch.append(("disable", update_item)) - - -async def check_whitelist_for_model(item): - json_obj = await core.get_data_by_mode('cache', 'model-list.json') - - for x in json_obj.get('models', []): - if x['save_path'] == item['save_path'] and x['base'] == item['base'] and x['filename'] == item['filename']: - return True - - json_obj = await core.get_data_by_mode('local', 'model-list.json') - - for x in json_obj.get('models', []): - if x['save_path'] == item['save_path'] and x['base'] == item['base'] and x['filename'] == item['filename']: - return True - - return False + logging.error(f"ComfyUI version switch fail: {e}", file=sys.stderr) + return web.Response(status=400) @routes.post("/v2/manager/queue/install_model") async def install_model(request): - json_data = await request.json() - return await _install_model(json_data) + try: + json_data = await request.json() + # Validate required fields + if "client_id" not in json_data: + return web.Response(status=400, text="Missing required field: client_id") + if "ui_id" not in json_data: + return web.Response(status=400, text="Missing required field: ui_id") -async def _install_model(json_data): - if not is_allowed_security_level('middle'): - logging.error(SECURITY_MESSAGE_MIDDLE_OR_BELOW) - return web.Response(status=403, text="A security error has occurred. Please check the terminal logs") + # Validate model metadata + model_data = ModelMetadata.model_validate(json_data) - # validate request - if not await check_whitelist_for_model(json_data): - logging.error(f"[ComfyUI-Manager] Invalid model install request is detected: {json_data}") - return web.Response(status=400, text="Invalid model install request is detected") + # Create install-model task with client-provided IDs + task = QueueTaskItem( + ui_id=json_data["ui_id"], + client_id=json_data["client_id"], + kind=OperationType.install_model.value, + params=model_data, + ) - if not json_data['filename'].endswith('.safetensors') and not is_allowed_security_level('high'): - models_json = await core.get_data_by_mode('cache', 'model-list.json', 'default') - - is_belongs_to_whitelist = False - for x in models_json['models']: - if x.get('url') == json_data['url']: - is_belongs_to_whitelist = True - break - - if not is_belongs_to_whitelist: - logging.error(SECURITY_MESSAGE_NORMAL_MINUS_MODEL) - return web.Response(status=403, text="A security error has occurred. Please check the terminal logs") - - install_item = json_data.get('ui_id'), json_data - temp_queue_batch.append(("install-model", install_item)) - - return web.Response(status=200) - - -@routes.get("/v2/manager/preview_method") -async def preview_method(request): - if "value" in request.rel_url.query: - set_preview_method(request.rel_url.query['value']) - core.write_config() - else: - return web.Response(text=core.manager_funcs.get_current_preview_method(), status=200) - - return web.Response(status=200) + task_queue.put(task) + return web.Response(status=200) + except ValidationError as e: + logging.error(f"[ComfyUI-Manager] Invalid model data: {e}") + return web.Response(status=400, text=f"Invalid model data: {e}") + except Exception as e: + logging.error(f"[ComfyUI-Manager] Error processing model install: {e}") + return web.Response(status=500, text="Internal server error") @routes.get("/v2/manager/db_mode") async def db_mode(request): if "value" in request.rel_url.query: - set_db_mode(request.rel_url.query['value']) + environment_utils.set_db_mode(request.rel_url.query["value"]) core.write_config() else: - return web.Response(text=core.get_config()['db_mode'], status=200) - - return web.Response(status=200) - - - -@routes.get("/v2/manager/policy/component") -async def component_policy(request): - if "value" in request.rel_url.query: - set_component_policy(request.rel_url.query['value']) - core.write_config() - else: - return web.Response(text=core.get_config()['component_policy'], status=200) + return web.Response(text=core.get_config()["db_mode"], status=200) return web.Response(status=200) @@ -1677,10 +1837,10 @@ async def component_policy(request): @routes.get("/v2/manager/policy/update") async def update_policy(request): if "value" in request.rel_url.query: - set_update_policy(request.rel_url.query['value']) + environment_utils.set_update_policy(request.rel_url.query["value"]) core.write_config() else: - return web.Response(text=core.get_config()['update_policy'], status=200) + return web.Response(text=core.get_config()["update_policy"], status=200) return web.Response(status=200) @@ -1689,95 +1849,28 @@ async def update_policy(request): async def channel_url_list(request): channels = core.get_channel_dict() if "value" in request.rel_url.query: - channel_url = channels.get(request.rel_url.query['value']) + channel_url = channels.get(request.rel_url.query["value"]) if channel_url is not None: - core.get_config()['channel_url'] = channel_url + core.get_config()["channel_url"] = channel_url core.write_config() else: - selected = 'custom' - selected_url = core.get_config()['channel_url'] + selected = "custom" + selected_url = core.get_config()["channel_url"] for name, url in channels.items(): if url == selected_url: selected = name break - res = {'selected': selected, - 'list': core.get_channel_list()} + res = {"selected": selected, "list": core.get_channel_list()} return web.json_response(res, status=200) return web.Response(status=200) -def add_target_blank(html_text): - pattern = r'(]*)(>)' - - def add_target(match): - if 'target=' not in match.group(1): - return match.group(1) + ' target="_blank"' + match.group(2) - return match.group(0) - - modified_html = re.sub(pattern, add_target, html_text) - - return modified_html - - -@routes.get("/v2/manager/notice") -async def get_notice(request): - url = "github.com" - path = "/ltdrdata/ltdrdata.github.io/wiki/News" - - async with aiohttp.ClientSession(trust_env=True, connector=aiohttp.TCPConnector(verify_ssl=False)) as session: - async with session.get(f"https://{url}{path}") as response: - if response.status == 200: - # html_content = response.read().decode('utf-8') - html_content = await response.text() - - pattern = re.compile(r'
([\s\S]*?)
') - match = pattern.search(html_content) - - if match: - markdown_content = match.group(1) - version_tag = os.environ.get('__COMFYUI_DESKTOP_VERSION__') - if version_tag is not None: - markdown_content += f"
ComfyUI: {version_tag} [Desktop]" - else: - version_tag = context.get_comfyui_tag() - if version_tag is None: - markdown_content += f"
ComfyUI: {core.comfy_ui_revision}[{comfy_ui_hash[:6]}]({core.comfy_ui_commit_datetime.date()})" - else: - markdown_content += (f"
ComfyUI: {version_tag}
" - f"         ({core.comfy_ui_commit_datetime.date()})") - # markdown_content += f"
         ()" - markdown_content += f"
Manager: {core.version_str}" - - markdown_content = add_target_blank(markdown_content) - - try: - if '__COMFYUI_DESKTOP_VERSION__' not in os.environ: - if core.comfy_ui_commit_datetime == datetime(1900, 1, 1, 0, 0, 0): - markdown_content = '

Your ComfyUI isn\'t git repo.

' + markdown_content - elif core.comfy_ui_required_commit_datetime.date() > core.comfy_ui_commit_datetime.date(): - markdown_content = '

Your ComfyUI is too OUTDATED!!!

' + markdown_content - except Exception: - pass - - return web.Response(text=markdown_content, status=200) - else: - return web.Response(text="Unable to retrieve Notice", status=200) - else: - return web.Response(text="Unable to retrieve Notice", status=200) - - -# legacy /manager/notice -@routes.get("/manager/notice") -async def get_notice_legacy(request): - return web.Response(text="""Starting from ComfyUI-Manager V4.0+, it should be installed via pip.

Please remove the ComfyUI-Manager installed in the 'custom_nodes' directory.
""", status=200) - - @routes.get("/v2/manager/reboot") def restart(self): - if not is_allowed_security_level('middle'): + if not security_utils.is_allowed_security_level("middle"): logging.error(SECURITY_MESSAGE_MIDDLE_OR_BELOW) return web.Response(status=403) @@ -1786,23 +1879,27 @@ def restart(self): except Exception: pass - if '__COMFY_CLI_SESSION__' in os.environ: - with open(os.path.join(os.environ['__COMFY_CLI_SESSION__'] + '.reboot'), 'w'): + if "__COMFY_CLI_SESSION__" in os.environ: + with open(os.path.join(os.environ["__COMFY_CLI_SESSION__"] + ".reboot"), "w"): pass - print("\nRestarting...\n\n") # This printing should not be logging - that will be ugly + print( + "\nRestarting...\n\n" + ) # This printing should not be logging - that will be ugly exit(0) - print("\nRestarting... [Legacy Mode]\n\n") # This printing should not be logging - that will be ugly + print( + "\nRestarting... [Legacy Mode]\n\n" + ) # This printing should not be logging - that will be ugly sys_argv = sys.argv.copy() - if '--windows-standalone-build' in sys_argv: - sys_argv.remove('--windows-standalone-build') + if "--windows-standalone-build" in sys_argv: + sys_argv.remove("--windows-standalone-build") if sys_argv[0].endswith("__main__.py"): # this is a python module module_name = os.path.basename(os.path.dirname(sys_argv[0])) - cmds = [sys.executable, '-m', module_name] + sys_argv[1:] - elif sys.platform.startswith('win32'): + cmds = [sys.executable, "-m", module_name] + sys_argv[1:] + elif sys.platform.startswith("win32"): cmds = ['"' + sys.executable + '"', '"' + sys_argv[0] + '"'] + sys_argv[1:] else: cmds = [sys.executable] + sys_argv @@ -1812,111 +1909,66 @@ def restart(self): return os.execv(sys.executable, cmds) -@routes.post("/v2/manager/component/save") -async def save_component(request): - try: - data = await request.json() - name = data['name'] - workflow = data['workflow'] - - if not os.path.exists(context.manager_components_path): - os.mkdir(context.manager_components_path) - - if 'packname' in workflow and workflow['packname'] != '': - sanitized_name = manager_util.sanitize_filename(workflow['packname']) + '.pack' - else: - sanitized_name = manager_util.sanitize_filename(name) + '.json' - - filepath = os.path.join(context.manager_components_path, sanitized_name) - components = {} - if os.path.exists(filepath): - with open(filepath) as f: - components = json.load(f) - - components[name] = workflow - - with open(filepath, 'w') as f: - json.dump(components, f, indent=4, sort_keys=True) - return web.Response(text=filepath, status=200) - except Exception: - return web.Response(status=400) - - -@routes.post("/v2/manager/component/loads") -async def load_components(request): - if os.path.exists(context.manager_components_path): - try: - json_files = [f for f in os.listdir(context.manager_components_path) if f.endswith('.json')] - pack_files = [f for f in os.listdir(context.manager_components_path) if f.endswith('.pack')] - - components = {} - for json_file in json_files + pack_files: - file_path = os.path.join(context.manager_components_path, json_file) - with open(file_path, 'r') as file: - try: - # When there is a conflict between the .pack and the .json, the pack takes precedence and overrides. - components.update(json.load(file)) - except json.JSONDecodeError as e: - logging.error(f"[ComfyUI-Manager] Error decoding component file in file {json_file}: {e}") - - return web.json_response(components) - except Exception as e: - logging.error(f"[ComfyUI-Manager] failed to load components\n{e}") - return web.Response(status=400) - else: - return web.json_response({}) - - @routes.get("/v2/manager/version") async def get_version(request): return web.Response(text=core.version_str, status=200) async def _confirm_try_install(sender, custom_node_url, msg): - json_obj = await core.get_data_by_mode('default', 'custom-node-list.json') + json_obj = await core.get_data_by_mode("default", "custom-node-list.json") sender = manager_util.sanitize_tag(sender) msg = manager_util.sanitize_tag(msg) target = core.lookup_customnode_by_url(json_obj, custom_node_url) if target is not None: - PromptServer.instance.send_sync("cm-api-try-install-customnode", - {"sender": sender, "target": target, "msg": msg}) + PromptServer.instance.send_sync( + "cm-api-try-install-customnode", + {"sender": sender, "target": target, "msg": msg}, + ) else: - logging.error(f"[ComfyUI Manager API] Failed to try install - Unknown custom node url '{custom_node_url}'") + logging.error( + f"[ComfyUI Manager API] Failed to try install - Unknown custom node url '{custom_node_url}'" + ) def confirm_try_install(sender, custom_node_url, msg): asyncio.run(_confirm_try_install(sender, custom_node_url, msg)) -cm_global.register_api('cm.try-install-custom-node', confirm_try_install) +cm_global.register_api("cm.try-install-custom-node", confirm_try_install) async def default_cache_update(): core.refresh_channel_dict() - channel_url = core.get_config()['channel_url'] + channel_url = core.get_config()["channel_url"] + async def get_cache(filename): try: - if core.get_config()['default_cache_as_channel_url']: + if core.get_config()["default_cache_as_channel_url"]: uri = f"{channel_url}/{filename}" else: uri = f"{core.DEFAULT_CHANNEL}/{filename}" - cache_uri = str(manager_util.simple_hash(uri)) + '_' + filename + cache_uri = str(manager_util.simple_hash(uri)) + "_" + filename cache_uri = os.path.join(manager_util.cache_dir, cache_uri) json_obj = await manager_util.get_data(uri, True) with manager_util.cache_lock: - with open(cache_uri, "w", encoding='utf-8') as file: + with open(cache_uri, "w", encoding="utf-8") as file: json.dump(json_obj, file, indent=4, sort_keys=True) - logging.info(f"[ComfyUI-Manager] default cache updated: {uri}") + logging.debug(f"[ComfyUI-Manager] default cache updated: {uri}") except Exception as e: - logging.error(f"[ComfyUI-Manager] Failed to perform initial fetching '{filename}': {e}") + logging.error( + f"[ComfyUI-Manager] Failed to perform initial fetching '{filename}': {e}" + ) traceback.print_exc() - if core.get_config()['network_mode'] != 'offline' and not manager_util.is_manager_pip_package(): + if ( + core.get_config()["network_mode"] != "offline" + and not manager_util.is_manager_pip_package() + ): a = get_cache("custom-node-list.json") b = get_cache("extension-node-map.json") c = get_cache("model-list.json") @@ -1925,14 +1977,22 @@ async def default_cache_update(): await asyncio.gather(a, b, c, d, e) - if core.get_config()['network_mode'] == 'private': - logging.info("[ComfyUI-Manager] The private comfyregistry is not yet supported in `network_mode=private`.") + if core.get_config()["network_mode"] == "private": + logging.info( + "[ComfyUI-Manager] The private comfyregistry is not yet supported in `network_mode=private`." + ) else: # load at least once - await core.unified_manager.reload('remote', dont_wait=False) - await core.unified_manager.get_custom_nodes(channel_url, 'remote') + await core.unified_manager.reload( + ManagerDatabaseSource.remote.value, dont_wait=False + ) + await core.unified_manager.get_custom_nodes( + channel_url, ManagerDatabaseSource.remote.value + ) else: - await core.unified_manager.reload('remote', dont_wait=False, update_cnr_map=False) + await core.unified_manager.reload( + ManagerDatabaseSource.remote.value, dont_wait=False, update_cnr_map=False + ) logging.info("[ComfyUI-Manager] All startup tasks have been completed.") @@ -1944,9 +2004,12 @@ if not os.path.exists(context.manager_config_path): core.write_config() -cm_global.register_extension('ComfyUI-Manager', - {'version': core.version, - 'name': 'ComfyUI Manager', - 'nodes': {}, - 'description': 'This extension provides the ability to manage custom nodes in ComfyUI.', }) - +cm_global.register_extension( + "ComfyUI-Manager", + { + "version": core.version, + "name": "ComfyUI Manager", + "nodes": {}, + "description": "This extension provides the ability to manage custom nodes in ComfyUI.", + }, +) diff --git a/comfyui_manager/glob/utils/__init__.py b/comfyui_manager/glob/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/comfyui_manager/glob/utils/environment_utils.py b/comfyui_manager/glob/utils/environment_utils.py new file mode 100644 index 00000000..abcf175b --- /dev/null +++ b/comfyui_manager/glob/utils/environment_utils.py @@ -0,0 +1,142 @@ +import os +import git +import logging +import traceback + +from comfyui_manager.common import context +import folder_paths +from comfy.cli_args import args +import latent_preview + +from comfyui_manager.glob import manager_core as core +from comfyui_manager.common import cm_global + + +comfy_ui_hash = "-" +comfyui_tag = None + + +def print_comfyui_version(): + global comfy_ui_hash + global comfyui_tag + + is_detached = False + try: + repo = git.Repo(os.path.dirname(folder_paths.__file__)) + core.comfy_ui_revision = len(list(repo.iter_commits("HEAD"))) + + comfy_ui_hash = repo.head.commit.hexsha + cm_global.variables["comfyui.revision"] = core.comfy_ui_revision + + core.comfy_ui_commit_datetime = repo.head.commit.committed_datetime + cm_global.variables["comfyui.commit_datetime"] = core.comfy_ui_commit_datetime + + is_detached = repo.head.is_detached + current_branch = repo.active_branch.name + + comfyui_tag = context.get_comfyui_tag() + + try: + if ( + not os.environ.get("__COMFYUI_DESKTOP_VERSION__") + and core.comfy_ui_commit_datetime.date() + < core.comfy_ui_required_commit_datetime.date() + ): + logging.warning( + f"\n\n## [WARN] ComfyUI-Manager: Your ComfyUI version ({core.comfy_ui_revision})[{core.comfy_ui_commit_datetime.date()}] is too old. Please update to the latest version. ##\n\n" + ) + except Exception: + pass + + # process on_revision_detected --> + if "cm.on_revision_detected_handler" in cm_global.variables: + for k, f in cm_global.variables["cm.on_revision_detected_handler"]: + try: + f(core.comfy_ui_revision) + except Exception: + logging.error(f"[ERROR] '{k}' on_revision_detected_handler") + traceback.print_exc() + + del cm_global.variables["cm.on_revision_detected_handler"] + else: + logging.warning( + "[ComfyUI-Manager] Some features are restricted due to your ComfyUI being outdated." + ) + # <-- + + if current_branch == "master": + if comfyui_tag: + logging.info( + f"### ComfyUI Version: {comfyui_tag} | Released on '{core.comfy_ui_commit_datetime.date()}'" + ) + else: + logging.info( + f"### ComfyUI Revision: {core.comfy_ui_revision} [{comfy_ui_hash[:8]}] | Released on '{core.comfy_ui_commit_datetime.date()}'" + ) + else: + if comfyui_tag: + logging.info( + f"### ComfyUI Version: {comfyui_tag} on '{current_branch}' | Released on '{core.comfy_ui_commit_datetime.date()}'" + ) + else: + logging.info( + f"### ComfyUI Revision: {core.comfy_ui_revision} on '{current_branch}' [{comfy_ui_hash[:8]}] | Released on '{core.comfy_ui_commit_datetime.date()}'" + ) + except Exception: + if is_detached: + logging.info( + f"### ComfyUI Revision: {core.comfy_ui_revision} [{comfy_ui_hash[:8]}] *DETACHED | Released on '{core.comfy_ui_commit_datetime.date()}'" + ) + else: + logging.info( + "### ComfyUI Revision: UNKNOWN (The currently installed ComfyUI is not a Git repository)" + ) + + +def set_preview_method(method): + if method == "auto": + args.preview_method = latent_preview.LatentPreviewMethod.Auto + elif method == "latent2rgb": + args.preview_method = latent_preview.LatentPreviewMethod.Latent2RGB + elif method == "taesd": + args.preview_method = latent_preview.LatentPreviewMethod.TAESD + else: + args.preview_method = latent_preview.LatentPreviewMethod.NoPreviews + + core.get_config()["preview_method"] = method + + +def set_update_policy(mode): + core.get_config()["update_policy"] = mode + + +def set_db_mode(mode): + core.get_config()["db_mode"] = mode + + +def setup_environment(): + git_exe = core.get_config()["git_exe"] + + if git_exe != "": + git.Git().update_environment(GIT_PYTHON_GIT_EXECUTABLE=git_exe) + + +def initialize_environment(): + context.comfy_path = os.path.dirname(folder_paths.__file__) + core.js_path = os.path.join(context.comfy_path, "web", "extensions") + + # Legacy database paths - kept for potential future use + # local_db_model = os.path.join(manager_util.comfyui_manager_path, "model-list.json") + # local_db_alter = os.path.join(manager_util.comfyui_manager_path, "alter-list.json") + # local_db_custom_node_list = os.path.join( + # manager_util.comfyui_manager_path, "custom-node-list.json" + # ) + # local_db_extension_node_mappings = os.path.join( + # manager_util.comfyui_manager_path, "extension-node-map.json" + # ) + + set_preview_method(core.get_config()["preview_method"]) + print_comfyui_version() + setup_environment() + + core.check_invalid_nodes() diff --git a/comfyui_manager/glob/utils/formatting_utils.py b/comfyui_manager/glob/utils/formatting_utils.py new file mode 100644 index 00000000..357112eb --- /dev/null +++ b/comfyui_manager/glob/utils/formatting_utils.py @@ -0,0 +1,60 @@ +import locale +import sys +import re + + +def handle_stream(stream, prefix): + stream.reconfigure(encoding=locale.getpreferredencoding(), errors="replace") + for msg in stream: + if ( + prefix == "[!]" + and ("it/s]" in msg or "s/it]" in msg) + and ("%|" in msg or "it [" in msg) + ): + if msg.startswith("100%"): + print("\r" + msg, end="", file=sys.stderr), + else: + print("\r" + msg[:-1], end="", file=sys.stderr), + else: + if prefix == "[!]": + print(prefix, msg, end="", file=sys.stderr) + else: + print(prefix, msg, end="") + + +def convert_markdown_to_html(input_text): + pattern_a = re.compile(r"\[a/([^]]+)]\(([^)]+)\)") + pattern_w = re.compile(r"\[w/([^]]+)]") + pattern_i = re.compile(r"\[i/([^]]+)]") + pattern_bold = re.compile(r"\*\*([^*]+)\*\*") + pattern_white = re.compile(r"%%([^*]+)%%") + + def replace_a(match): + return f"{match.group(1)}" + + def replace_w(match): + return f"

{match.group(1)}

" + + def replace_i(match): + return f"

{match.group(1)}

" + + def replace_bold(match): + return f"{match.group(1)}" + + def replace_white(match): + return f"{match.group(1)}" + + input_text = ( + input_text.replace("\\[", "[") + .replace("\\]", "]") + .replace("<", "<") + .replace(">", ">") + ) + + result_text = re.sub(pattern_a, replace_a, input_text) + result_text = re.sub(pattern_w, replace_w, result_text) + result_text = re.sub(pattern_i, replace_i, result_text) + result_text = re.sub(pattern_bold, replace_bold, result_text) + result_text = re.sub(pattern_white, replace_white, result_text) + + return result_text.replace("\n", "
") diff --git a/comfyui_manager/glob/utils/model_utils.py b/comfyui_manager/glob/utils/model_utils.py new file mode 100644 index 00000000..2225ec07 --- /dev/null +++ b/comfyui_manager/glob/utils/model_utils.py @@ -0,0 +1,161 @@ +import os +import logging +import concurrent.futures +import folder_paths + +from comfyui_manager.glob import manager_core as core +from comfyui_manager.glob.constants import model_dir_name_map, MODEL_DIR_NAMES + + +def get_model_dir(data, show_log=False): + if "download_model_base" in folder_paths.folder_names_and_paths: + models_base = folder_paths.folder_names_and_paths["download_model_base"][0][0] + else: + models_base = folder_paths.models_dir + + # NOTE: Validate to prevent path traversal. + if any(char in data["filename"] for char in {"/", "\\", ":"}): + return None + + def resolve_custom_node(save_path): + save_path = save_path[13:] # remove 'custom_nodes/' + + # NOTE: Validate to prevent path traversal. + if save_path.startswith(os.path.sep) or ":" in save_path: + return None + + repo_name = save_path.replace("\\", "/").split("/")[ + 0 + ] # get custom node repo name + + # NOTE: The creation of files within the custom node path should be removed in the future. + repo_path = core.lookup_installed_custom_nodes_legacy(repo_name) + if repo_path is not None and repo_path[0]: + # Returns the retargeted path based on the actually installed repository + return os.path.join(os.path.dirname(repo_path[1]), save_path) + else: + return None + + if data["save_path"] != "default": + if ".." in data["save_path"] or data["save_path"].startswith("/"): + if show_log: + logging.info( + f"[WARN] '{data['save_path']}' is not allowed path. So it will be saved into 'models/etc'." + ) + base_model = os.path.join(models_base, "etc") + else: + if data["save_path"].startswith("custom_nodes"): + base_model = resolve_custom_node(data["save_path"]) + if base_model is None: + if show_log: + logging.info( + f"[ComfyUI-Manager] The target custom node for model download is not installed: {data['save_path']}" + ) + return None + else: + base_model = os.path.join(models_base, data["save_path"]) + else: + model_dir_name = model_dir_name_map.get(data["type"].lower()) + if model_dir_name is not None: + base_model = folder_paths.folder_names_and_paths[model_dir_name][0][0] + else: + base_model = os.path.join(models_base, "etc") + + return base_model + + +def get_model_path(data, show_log=False): + base_model = get_model_dir(data, show_log) + if base_model is None: + return None + else: + if data["filename"] == "": + return os.path.join(base_model, os.path.basename(data["url"])) + else: + return os.path.join(base_model, data["filename"]) + + +def check_model_installed(json_obj): + def is_exists(model_dir_name, filename, url): + if filename == "": + filename = os.path.basename(url) + + dirs = folder_paths.get_folder_paths(model_dir_name) + + for x in dirs: + if os.path.exists(os.path.join(x, filename)): + return True + + return False + + total_models_files = set() + for x in MODEL_DIR_NAMES: + for y in folder_paths.get_filename_list(x): + total_models_files.add(y) + + def process_model_phase(item): + if ( + "diffusion" not in item["filename"] + and "pytorch" not in item["filename"] + and "model" not in item["filename"] + ): + # non-general name case + if item["filename"] in total_models_files: + item["installed"] = "True" + return + + if item["save_path"] == "default": + model_dir_name = model_dir_name_map.get(item["type"].lower()) + if model_dir_name is not None: + item["installed"] = str( + is_exists(model_dir_name, item["filename"], item["url"]) + ) + else: + item["installed"] = "False" + else: + model_dir_name = item["save_path"].split("/")[0] + if model_dir_name in folder_paths.folder_names_and_paths: + if is_exists(model_dir_name, item["filename"], item["url"]): + item["installed"] = "True" + + if "installed" not in item: + if item["filename"] == "": + filename = os.path.basename(item["url"]) + else: + filename = item["filename"] + + fullpath = os.path.join( + folder_paths.models_dir, item["save_path"], filename + ) + + item["installed"] = "True" if os.path.exists(fullpath) else "False" + + with concurrent.futures.ThreadPoolExecutor(8) as executor: + for item in json_obj["models"]: + executor.submit(process_model_phase, item) + + +async def check_whitelist_for_model(item): + from comfyui_manager.data_models import ManagerDatabaseSource + + json_obj = await core.get_data_by_mode(ManagerDatabaseSource.cache.value, "model-list.json") + + for x in json_obj.get("models", []): + if ( + x["save_path"] == item["save_path"] + and x["base"] == item["base"] + and x["filename"] == item["filename"] + ): + return True + + json_obj = await core.get_data_by_mode(ManagerDatabaseSource.local.value, "model-list.json") + + for x in json_obj.get("models", []): + if ( + x["save_path"] == item["save_path"] + and x["base"] == item["base"] + and x["filename"] == item["filename"] + ): + return True + + return False diff --git a/comfyui_manager/glob/utils/node_pack_utils.py b/comfyui_manager/glob/utils/node_pack_utils.py new file mode 100644 index 00000000..59ee2273 --- /dev/null +++ b/comfyui_manager/glob/utils/node_pack_utils.py @@ -0,0 +1,65 @@ +import concurrent.futures + +from comfyui_manager.glob import manager_core as core + + +def check_state_of_git_node_pack( + node_packs, do_fetch=False, do_update_check=True, do_update=False +): + if do_fetch: + print("Start fetching...", end="") + elif do_update: + print("Start updating...", end="") + elif do_update_check: + print("Start update check...", end="") + + def process_custom_node(item): + core.check_state_of_git_node_pack_single( + item, do_fetch, do_update_check, do_update + ) + + with concurrent.futures.ThreadPoolExecutor(4) as executor: + for k, v in node_packs.items(): + if v.get("active_version") in ["unknown", "nightly"]: + executor.submit(process_custom_node, v) + + if do_fetch: + print("\x1b[2K\rFetching done.") + elif do_update: + update_exists = any( + item.get("updatable", False) for item in node_packs.values() + ) + if update_exists: + print("\x1b[2K\rUpdate done.") + else: + print("\x1b[2K\rAll extensions are already up-to-date.") + elif do_update_check: + print("\x1b[2K\rUpdate check done.") + + +def nickname_filter(json_obj): + preemptions_map = {} + + for k, x in json_obj.items(): + if "preemptions" in x[1]: + for y in x[1]["preemptions"]: + preemptions_map[y] = k + elif k.endswith("/ComfyUI"): + for y in x[0]: + preemptions_map[y] = k + + updates = {} + for k, x in json_obj.items(): + removes = set() + for y in x[0]: + k2 = preemptions_map.get(y) + if k2 is not None and k != k2: + removes.add(y) + + if len(removes) > 0: + updates[k] = [y for y in x[0] if y not in removes] + + for k, v in updates.items(): + json_obj[k][0] = v + + return json_obj diff --git a/comfyui_manager/glob/utils/security_utils.py b/comfyui_manager/glob/utils/security_utils.py new file mode 100644 index 00000000..91964ff4 --- /dev/null +++ b/comfyui_manager/glob/utils/security_utils.py @@ -0,0 +1,54 @@ +from comfyui_manager.glob import manager_core as core +from comfy.cli_args import args +from comfyui_manager.data_models import SecurityLevel, RiskLevel, ManagerDatabaseSource + + +def is_loopback(address): + import ipaddress + try: + return ipaddress.ip_address(address).is_loopback + except ValueError: + return False + + +def is_allowed_security_level(level): + is_local_mode = is_loopback(args.listen) + + if level == RiskLevel.block.value: + return False + elif level == RiskLevel.high.value: + if is_local_mode: + return core.get_config()["security_level"] in [SecurityLevel.weak.value, SecurityLevel.normal_.value] + else: + return core.get_config()["security_level"] == SecurityLevel.weak.value + elif level == RiskLevel.middle.value: + return core.get_config()["security_level"] in [SecurityLevel.weak.value, SecurityLevel.normal.value, SecurityLevel.normal_.value] + else: + return True + + +async def get_risky_level(files, pip_packages): + json_data1 = await core.get_data_by_mode(ManagerDatabaseSource.local.value, "custom-node-list.json") + json_data2 = await core.get_data_by_mode( + ManagerDatabaseSource.cache.value, + "custom-node-list.json", + channel_url="https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main", + ) + + all_urls = set() + for x in json_data1["custom_nodes"] + json_data2["custom_nodes"]: + all_urls.update(x.get("files", [])) + + for x in files: + if x not in all_urls: + return RiskLevel.high.value + + all_pip_packages = set() + for x in json_data1["custom_nodes"] + json_data2["custom_nodes"]: + all_pip_packages.update(x.get("pip", [])) + + for p in pip_packages: + if p not in all_pip_packages: + return RiskLevel.block.value + + return RiskLevel.middle.value diff --git a/openapi.yaml b/openapi.yaml index d79b79ec..fb88960a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -11,17 +11,487 @@ info: servers: - url: '/' description: Default ComfyUI server + +# Default security - can be overridden per operation +security: [] # Common API components components: schemas: - Error: + OperationType: + type: string + enum: [install, uninstall, update, update-comfyui, fix, disable, enable, install-model] + description: Type of operation or task being performed + OperationResult: + type: string + enum: [success, failed, skipped, error, skip] + description: Result status of an operation (failed/error and skipped/skip are aliases) + # Core Task Queue Models + QueueTaskItem: type: object properties: + ui_id: + type: string + description: Unique identifier for the task + client_id: + type: string + description: Client identifier that initiated the task + kind: + $ref: '#/components/schemas/OperationType' + params: + oneOf: + - $ref: '#/components/schemas/InstallPackParams' + - $ref: '#/components/schemas/UpdatePackParams' + - $ref: '#/components/schemas/UpdateAllPacksParams' + - $ref: '#/components/schemas/UpdateComfyUIParams' + - $ref: '#/components/schemas/FixPackParams' + - $ref: '#/components/schemas/UninstallPackParams' + - $ref: '#/components/schemas/DisablePackParams' + - $ref: '#/components/schemas/EnablePackParams' + - $ref: '#/components/schemas/ModelMetadata' + required: [ui_id, client_id, kind, params] + TaskHistoryItem: + type: object + properties: + ui_id: + type: string + description: Unique identifier for the task + client_id: + type: string + description: Client identifier that initiated the task + kind: + type: string + description: Type of task that was performed + timestamp: + type: string + format: date-time + description: ISO timestamp when task completed + result: + type: string + description: Task result message or details + status: + $ref: '#/components/schemas/TaskExecutionStatus' + batch_id: + type: [string, 'null'] + description: ID of the batch this task belongs to + end_time: + type: [string, 'null'] + format: date-time + description: ISO timestamp when task execution ended + required: [ui_id, client_id, kind, timestamp, result] + TaskExecutionStatus: + type: object + properties: + status_str: + $ref: '#/components/schemas/OperationResult' + completed: + type: boolean + description: Whether the task completed + messages: + type: array + items: + type: string + description: Additional status messages + required: [status_str, completed, messages] + TaskStateMessage: + type: object + properties: + history: + type: object + additionalProperties: + $ref: '#/components/schemas/TaskHistoryItem' + description: Map of task IDs to their history items + running_queue: + type: array + items: + $ref: '#/components/schemas/QueueTaskItem' + description: Currently executing tasks + pending_queue: + type: array + items: + $ref: '#/components/schemas/QueueTaskItem' + description: Tasks waiting to be executed + installed_packs: + type: object + additionalProperties: + $ref: '#/components/schemas/ManagerPackInstalled' + description: Map of currently installed node packages by name + required: [history, running_queue, pending_queue, installed_packs] + # WebSocket Message Models + ManagerMessageName: + type: string + enum: [cm-task-completed, cm-task-started, cm-queue-status] + description: WebSocket message type constants for manager events + MessageTaskDone: + type: object + properties: + ui_id: + type: string + description: Task identifier + result: + type: string + description: Task result message + kind: + type: string + description: Type of task + status: + $ref: '#/components/schemas/TaskExecutionStatus' + timestamp: + type: string + format: date-time + description: ISO timestamp when task completed + state: + $ref: '#/components/schemas/TaskStateMessage' + required: [ui_id, result, kind, timestamp, state] + MessageTaskStarted: + type: object + properties: + ui_id: + type: string + description: Task identifier + kind: + type: string + description: Type of task + timestamp: + type: string + format: date-time + description: ISO timestamp when task started + state: + $ref: '#/components/schemas/TaskStateMessage' + required: [ui_id, kind, timestamp, state] + MessageTaskFailed: + type: object + properties: + ui_id: + type: string + description: Task identifier error: type: string description: Error message - + kind: + type: string + description: Type of task + timestamp: + type: string + format: date-time + description: ISO timestamp when task failed + state: + $ref: '#/components/schemas/TaskStateMessage' + required: [ui_id, error, kind, timestamp, state] + MessageUpdate: + oneOf: + - $ref: '#/components/schemas/MessageTaskDone' + - $ref: '#/components/schemas/MessageTaskStarted' + - $ref: '#/components/schemas/MessageTaskFailed' + description: Union type for all possible WebSocket message updates + # Manager Package Models + ManagerPackInfo: + type: object + properties: + id: + type: string + description: Either github-author/github-repo or name of pack from the registry + version: + type: string + description: Semantic version or Git commit hash + ui_id: + type: string + description: Task ID - generated internally + required: [id, version] + ManagerPackInstalled: + type: object + properties: + ver: + type: string + description: The version of the pack that is installed (Git commit hash or semantic version) + cnr_id: + type: [string, 'null'] + description: The name of the pack if installed from the registry + aux_id: + type: [string, 'null'] + description: The name of the pack if installed from github (author/repo-name format) + enabled: + type: boolean + description: Whether the pack is enabled + required: [ver, enabled] + SelectedVersion: + type: string + enum: [latest, nightly] + description: Version selection for pack installation + ManagerChannel: + type: string + enum: [default, recent, legacy, forked, dev, tutorial] + description: Channel for pack sources + ManagerDatabaseSource: + type: string + enum: [remote, local, cache] + description: Source for pack information + ManagerPackState: + type: string + enum: [installed, disabled, not_installed, import_failed, needs_update] + description: Current state of a pack + ManagerPackInstallType: + type: string + enum: [git-clone, copy, cnr] + description: Type of installation used for the pack + SecurityLevel: + type: string + enum: [strong, normal, normal-, weak] + description: Security level configuration (from most to least restrictive) + RiskLevel: + type: string + enum: [block, high, middle] + description: Risk classification for operations + ManagerPack: + allOf: + - $ref: '#/components/schemas/ManagerPackInfo' + - type: object + properties: + author: + type: string + description: Pack author name or 'Unclaimed' if added via GitHub crawl + files: + type: array + items: + type: string + description: Repository URLs for installation (typically contains one GitHub URL) + reference: + type: string + description: The type of installation reference + title: + type: string + description: The display name of the pack + cnr_latest: + $ref: '#/components/schemas/SelectedVersion' + repository: + type: string + description: GitHub repository URL + state: + $ref: '#/components/schemas/ManagerPackState' + update-state: + type: [string, 'null'] + enum: ['false', 'true'] + description: Update availability status + stars: + type: integer + description: GitHub stars count + last_update: + type: string + format: date-time + description: Last update timestamp + health: + type: string + description: Health status of the pack + description: + type: string + description: Pack description + trust: + type: boolean + description: Whether the pack is trusted + install_type: + $ref: '#/components/schemas/ManagerPackInstallType' + # Installation Parameters + InstallPackParams: + allOf: + - $ref: '#/components/schemas/ManagerPackInfo' + - type: object + properties: + selected_version: + oneOf: + - type: string + - $ref: '#/components/schemas/SelectedVersion' + description: Semantic version, Git commit hash, latest, or nightly + repository: + type: string + description: GitHub repository URL (required if selected_version is nightly) + pip: + type: array + items: + type: string + description: PyPi dependency names + mode: + $ref: '#/components/schemas/ManagerDatabaseSource' + channel: + $ref: '#/components/schemas/ManagerChannel' + skip_post_install: + type: boolean + description: Whether to skip post-installation steps + required: [selected_version, mode, channel] + UpdateAllPacksParams: + type: object + properties: + mode: + $ref: '#/components/schemas/ManagerDatabaseSource' + ui_id: + type: string + description: Task ID - generated internally + UpdatePackParams: + type: object + properties: + node_name: + type: string + description: Name of the node package to update + node_ver: + type: [string, 'null'] + description: Current version of the node package + required: [node_name] + UpdateComfyUIParams: + type: object + properties: + is_stable: + type: boolean + description: Whether to update to stable version (true) or nightly (false) + default: true + target_version: + type: [string, 'null'] + description: Specific version to switch to (for version switching operations) + required: [] + FixPackParams: + type: object + properties: + node_name: + type: string + description: Name of the node package to fix + node_ver: + type: string + description: Version of the node package + required: [node_name, node_ver] + UninstallPackParams: + type: object + properties: + node_name: + type: string + description: Name of the node package to uninstall + is_unknown: + type: boolean + description: Whether this is an unknown/unregistered package + default: false + required: [node_name] + DisablePackParams: + type: object + properties: + node_name: + type: string + description: Name of the node package to disable + is_unknown: + type: boolean + description: Whether this is an unknown/unregistered package + default: false + required: [node_name] + EnablePackParams: + type: object + properties: + cnr_id: + type: string + description: ComfyUI Node Registry ID of the package to enable + required: [cnr_id] + # Query Parameter Models + UpdateAllQueryParams: + type: object + properties: + client_id: + type: string + description: Client identifier that initiated the request + ui_id: + type: string + description: Base UI identifier for task tracking + mode: + $ref: '#/components/schemas/ManagerDatabaseSource' + required: [client_id, ui_id] + UpdateComfyUIQueryParams: + type: object + properties: + client_id: + type: string + description: Client identifier that initiated the request + ui_id: + type: string + description: UI identifier for task tracking + stable: + type: boolean + default: true + description: Whether to update to stable version (true) or nightly (false) + required: [client_id, ui_id] + ComfyUISwitchVersionQueryParams: + type: object + properties: + ver: + type: string + description: Version to switch to + client_id: + type: string + description: Client identifier that initiated the request + ui_id: + type: string + description: UI identifier for task tracking + required: [ver, client_id, ui_id] + # Queue Status Models + QueueStatus: + type: object + properties: + total_count: + type: integer + description: Total number of tasks (pending + running) + done_count: + type: integer + description: Number of completed tasks + in_progress_count: + type: integer + description: Number of tasks currently running + pending_count: + type: integer + description: Number of tasks waiting to be executed + is_processing: + type: boolean + description: Whether the task worker is active + client_id: + type: string + description: Client ID (when filtered by client) + required: [total_count, done_count, in_progress_count, is_processing] + # Mappings Model + ManagerMappings: + type: object + additionalProperties: + type: array + description: Tuple of [node_names, metadata] + items: + oneOf: + - type: array + items: + type: string + description: List of ComfyNode names included in the pack + - type: object + properties: + title_aux: + type: string + description: The display name of the pack + # Model Management + ModelMetadata: + type: object + properties: + name: + type: string + description: Name of the model + type: + type: string + description: Type of model + base: + type: string + description: Base model type + save_path: + type: string + description: Path for saving the model + url: + type: string + description: Download URL + filename: + type: string + description: Target filename + ui_id: + type: string + description: ID for UI reference + required: [name, type, url, filename] + # Legacy Node Package Model (for backward compatibility) NodePackageMetadata: type: object properties: @@ -58,67 +528,273 @@ components: mode: type: string description: Source mode - - ModelMetadata: + # Snapshot Models + SnapshotItem: + type: string + description: Name of the snapshot + # Error Models + Error: + type: object + properties: + error: + type: string + description: Error message + required: [error] + # Response Models + InstalledPacksResponse: + type: object + additionalProperties: + $ref: '#/components/schemas/ManagerPackInstalled' + description: Map of pack names to their installation info + HistoryResponse: + type: object + properties: + history: + type: object + additionalProperties: + $ref: '#/components/schemas/TaskHistoryItem' + description: Map of task IDs to their history items + HistoryListResponse: + type: object + properties: + ids: + type: array + items: + type: string + description: List of available batch history IDs + # State Management Models + InstalledNodeInfo: type: object properties: name: type: string - description: Name of the model - type: + description: Node package name + version: type: string - description: Type of model - base: + description: Installed version + repository_url: + type: [string, 'null'] + description: Git repository URL + install_method: type: string - description: Base model type - save_path: - type: string - description: Path for saving the model - url: - type: string - description: Download URL - filename: - type: string - description: Target filename - ui_id: - type: string - description: ID for UI reference - - SnapshotItem: - type: string - description: Name of the snapshot - - QueueStatus: + description: Installation method (cnr, git, pip, etc.) + enabled: + type: boolean + description: Whether the node is currently enabled + default: true + install_date: + type: [string, 'null'] + format: date-time + description: ISO timestamp of installation + required: [name, version, install_method] + InstalledModelInfo: type: object properties: - total_count: - type: integer - description: Total number of tasks - done_count: - type: integer - description: Number of completed tasks - in_progress_count: - type: integer - description: Number of tasks in progress - is_processing: + name: + type: string + description: Model filename + path: + type: string + description: Full path to model file + type: + type: string + description: Model type (checkpoint, lora, vae, etc.) + size_bytes: + type: [integer, 'null'] + description: File size in bytes + minimum: 0 + hash: + type: [string, 'null'] + description: Model file hash for verification + install_date: + type: [string, 'null'] + format: date-time + description: ISO timestamp when added + required: [name, path, type] + ComfyUIVersionInfo: + type: object + properties: + version: + type: string + description: ComfyUI version string + commit_hash: + type: [string, 'null'] + description: Git commit hash + branch: + type: [string, 'null'] + description: Git branch name + is_stable: type: boolean - description: Whether the queue is currently processing - + description: Whether this is a stable release + default: false + last_updated: + type: [string, 'null'] + format: date-time + description: ISO timestamp of last update + required: [version] + BatchOperation: + type: object + properties: + operation_id: + type: string + description: Unique operation identifier + operation_type: + $ref: '#/components/schemas/OperationType' + target: + type: string + description: Target of the operation (node name, model name, etc.) + target_version: + type: [string, 'null'] + description: Target version for the operation + result: + $ref: '#/components/schemas/OperationResult' + error_message: + type: [string, 'null'] + description: Error message if operation failed + start_time: + type: string + format: date-time + description: ISO timestamp when operation started + end_time: + type: [string, 'null'] + format: date-time + description: ISO timestamp when operation completed + client_id: + type: [string, 'null'] + description: Client that initiated the operation + required: [operation_id, operation_type, target, result, start_time] + ComfyUISystemState: + type: object + properties: + snapshot_time: + type: string + format: date-time + description: ISO timestamp when snapshot was taken + comfyui_version: + $ref: '#/components/schemas/ComfyUIVersionInfo' + frontend_version: + type: [string, 'null'] + description: ComfyUI frontend version if available + python_version: + type: string + description: Python interpreter version + platform_info: + type: string + description: Operating system and platform information + installed_nodes: + type: object + additionalProperties: + $ref: '#/components/schemas/InstalledNodeInfo' + description: Map of installed node packages by name + installed_models: + type: object + additionalProperties: + $ref: '#/components/schemas/InstalledModelInfo' + description: Map of installed models by name + manager_config: + type: object + additionalProperties: true + description: ComfyUI Manager configuration settings + comfyui_root_path: + type: [string, 'null'] + description: ComfyUI root installation directory + model_paths: + type: object + additionalProperties: + type: array + items: + type: string + description: Map of model types to their configured paths + manager_version: + type: [string, 'null'] + description: ComfyUI Manager version + security_level: + $ref: '#/components/schemas/SecurityLevel' + network_mode: + type: [string, 'null'] + description: Network mode (online, offline, private) + cli_args: + type: object + additionalProperties: true + description: Selected ComfyUI CLI arguments + custom_nodes_count: + type: [integer, 'null'] + description: Total number of custom node packages + minimum: 0 + failed_imports: + type: array + items: + type: string + description: List of custom nodes that failed to import + pip_packages: + type: object + additionalProperties: + type: string + description: Map of installed pip packages to their versions + embedded_python: + type: [boolean, 'null'] + description: Whether ComfyUI is running from an embedded Python distribution + required: [snapshot_time, comfyui_version, python_version, platform_info] + BatchExecutionRecord: + type: object + properties: + batch_id: + type: string + description: Unique batch identifier + start_time: + type: string + format: date-time + description: ISO timestamp when batch started + end_time: + type: [string, 'null'] + format: date-time + description: ISO timestamp when batch completed + state_before: + $ref: '#/components/schemas/ComfyUISystemState' + state_after: + type: ['null'] + allOf: + - $ref: '#/components/schemas/ComfyUISystemState' + description: System state after batch execution + operations: + type: array + items: + $ref: '#/components/schemas/BatchOperation' + description: List of operations performed in this batch + total_operations: + type: integer + description: Total number of operations in batch + minimum: 0 + default: 0 + successful_operations: + type: integer + description: Number of successful operations + minimum: 0 + default: 0 + failed_operations: + type: integer + description: Number of failed operations + minimum: 0 + default: 0 + skipped_operations: + type: integer + description: Number of skipped operations + minimum: 0 + default: 0 + required: [batch_id, start_time, state_before] securitySchemes: securityLevel: type: apiKey in: header name: Security-Level description: Security level for sensitive operations - parameters: modeParam: name: mode in: query description: Source mode (e.g., "local", "remote") schema: - type: string - enum: [local, remote, default] + $ref: '#/components/schemas/ManagerDatabaseSource' targetParam: name: target @@ -135,11 +811,271 @@ components: required: true schema: type: string - + clientIdParam: + name: client_id + in: query + description: Client ID for filtering tasks + schema: + type: string + uiIdParam: + name: ui_id + in: query + description: Specific task ID to retrieve + schema: + type: string + clientIdRequiredParam: + name: client_id + in: query + required: true + description: Required client ID that initiated the request + schema: + type: string + uiIdRequiredParam: + name: ui_id + in: query + required: true + description: Required unique task identifier + schema: + type: string + maxItemsParam: + name: max_items + in: query + description: Maximum number of items to return + schema: + type: integer + minimum: 1 + offsetParam: + name: offset + in: query + description: Offset for pagination + schema: + type: integer + minimum: 0 # API Paths paths: - # Custom Nodes Endpoints - /customnode/getmappings: + # Task Queue Management (v2 endpoints) + /v2/manager/queue/task: + post: + summary: Add task to queue + description: Adds a new task to the processing queue + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/QueueTaskItem' + examples: + install: + summary: Install a custom node + value: + ui_id: "task_123" + client_id: "client_abc" + kind: "install" + params: + id: "pythongosssss/ComfyUI-Custom-Scripts" + version: "latest" + selected_version: "latest" + mode: "remote" + channel: "default" + update: + summary: Update a custom node + value: + ui_id: "task_124" + client_id: "client_abc" + kind: "update" + params: + node_name: "ComfyUI-Custom-Scripts" + node_ver: "1.0.0" + update-all: + summary: Update all custom nodes + value: + ui_id: "task_125" + client_id: "client_abc" + kind: "update-all" + params: + mode: "remote" + update-comfyui: + summary: Update ComfyUI itself + value: + ui_id: "task_126" + client_id: "client_abc" + kind: "update-comfyui" + params: + is_stable: true + fix: + summary: Fix a custom node + value: + ui_id: "task_127" + client_id: "client_abc" + kind: "fix" + params: + node_name: "ComfyUI-Impact-Pack" + node_ver: "2.0.0" + uninstall: + summary: Uninstall a custom node + value: + ui_id: "task_128" + client_id: "client_abc" + kind: "uninstall" + params: + node_name: "ComfyUI-AnimateDiff-Evolved" + is_unknown: false + disable: + summary: Disable a custom node + value: + ui_id: "task_129" + client_id: "client_abc" + kind: "disable" + params: + node_name: "ComfyUI-Manager" + is_unknown: false + enable: + summary: Enable a custom node + value: + ui_id: "task_130" + client_id: "client_abc" + kind: "enable" + params: + cnr_id: "comfyui-manager" + install-model: + summary: Install a model + value: + ui_id: "task_131" + client_id: "client_abc" + kind: "install-model" + params: + name: "SD 1.5 Base Model" + type: "checkpoint" + base: "SD1.x" + save_path: "default" + url: "https://huggingface.co/runwayml/stable-diffusion-v1-5/resolve/main/v1-5-pruned.safetensors" + filename: "v1-5-pruned.safetensors" + responses: + '200': + description: Task queued successfully + '400': + description: Invalid task data + '500': + description: Internal server error + /v2/manager/queue/status: + get: + summary: Get queue status + description: Returns the current status of the operation queue with optional client filtering + parameters: + - $ref: '#/components/parameters/clientIdParam' + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/QueueStatus' + /v2/manager/queue/history: + get: + summary: Get task history + description: Get task history with optional filtering + parameters: + - name: id + in: query + description: Batch history ID (for file-based history) + schema: + type: string + - $ref: '#/components/parameters/clientIdParam' + - $ref: '#/components/parameters/uiIdParam' + - $ref: '#/components/parameters/maxItemsParam' + - $ref: '#/components/parameters/offsetParam' + responses: + '200': + description: Successful operation + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/HistoryResponse' + - type: object # File-based batch history + '400': + description: Error retrieving history + /v2/manager/queue/history_list: + get: + summary: Get available batch history files + description: Returns a list of batch history IDs sorted by modification time + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/HistoryListResponse' + '400': + description: Error retrieving history list + /v2/manager/queue/start: + get: + summary: Start queue processing + description: Starts processing the operation queue + responses: + '200': + description: Processing started + '201': + description: Processing already in progress + /v2/manager/queue/reset: + get: + summary: Reset queue + description: Resets the operation queue + responses: + '200': + description: Queue reset successfully + /v2/manager/queue/update_all: + get: + summary: Update all custom nodes + description: Queues update operations for all installed custom nodes + security: + - securityLevel: [] + parameters: + - $ref: '#/components/parameters/modeParam' + - $ref: '#/components/parameters/clientIdRequiredParam' + - $ref: '#/components/parameters/uiIdRequiredParam' + responses: + '200': + description: Update queued successfully + '400': + description: Missing required parameters + '401': + description: Processing already in progress + '403': + description: Security policy violation + /v2/manager/queue/update_comfyui: + get: + summary: Update ComfyUI + description: Queues an update operation for ComfyUI itself + parameters: + - $ref: '#/components/parameters/clientIdRequiredParam' + - $ref: '#/components/parameters/uiIdRequiredParam' + responses: + '200': + description: Update queued successfully + '400': + description: Missing required parameters + /v2/manager/queue/install_model: + post: + summary: Install model + description: Queues installation of a model + security: + - securityLevel: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ModelMetadata' + responses: + '200': + description: Installation queued successfully + '400': + description: Invalid model request + '403': + description: Security policy violation + # Custom Nodes Endpoints (v2) + /v2/customnode/getmappings: get: summary: Get node-to-package mappings description: Provides unified mapping between nodes and node packages @@ -151,14 +1087,8 @@ paths: content: application/json: schema: - type: object - additionalProperties: - type: array - items: - type: array - description: Mapping of node packages to node classes - - /customnode/fetch_updates: + $ref: '#/components/schemas/ManagerMappings' + /v2/customnode/fetch_updates: get: summary: Check for updates description: Fetches updates for custom nodes @@ -172,7 +1102,7 @@ paths: '400': description: Error occurred - /customnode/installed: + /v2/customnode/installed: get: summary: Get installed custom nodes description: Returns a list of installed node packages @@ -189,103 +1119,8 @@ paths: content: application/json: schema: - type: object - additionalProperties: - $ref: '#/components/schemas/NodePackageMetadata' - - /customnode/getlist: - get: - summary: Get custom node list - description: Provides a list of available custom nodes - parameters: - - $ref: '#/components/parameters/modeParam' - - name: skip_update - in: query - description: Skip update check - schema: - type: boolean - responses: - '200': - description: Successful operation - content: - application/json: - schema: - type: object - properties: - channel: - type: string - node_packs: - type: object - additionalProperties: - $ref: '#/components/schemas/NodePackageMetadata' - - /customnode/alternatives: - get: - summary: Get alternative node options - description: Provides alternatives for nodes - parameters: - - $ref: '#/components/parameters/modeParam' - responses: - '200': - description: Successful operation - content: - application/json: - schema: - type: object - additionalProperties: - type: object - - /customnode/versions/{node_name}: - get: - summary: Get available versions for a node - description: Lists all available versions for a specific node - parameters: - - name: node_name - in: path - required: true - schema: - type: string - responses: - '200': - description: Successful operation - content: - application/json: - schema: - type: array - items: - type: object - properties: - version: - type: string - '400': - description: Node not found - - /customnode/disabled_versions/{node_name}: - get: - summary: Get disabled versions for a node - description: Lists all disabled versions for a specific node - parameters: - - name: node_name - in: path - required: true - schema: - type: string - responses: - '200': - description: Successful operation - content: - application/json: - schema: - type: array - items: - type: object - properties: - version: - type: string - '400': - description: Node not found - - /customnode/import_fail_info: + $ref: '#/components/schemas/InstalledPacksResponse' + /v2/customnode/import_fail_info: post: summary: Get import failure information description: Returns information about why a node failed to import @@ -305,248 +1140,8 @@ paths: description: Successful operation '400': description: No information available - - /customnode/install/git_url: - post: - summary: Install custom node via Git URL - description: Installs a custom node from a Git repository URL - security: - - securityLevel: [] - requestBody: - required: true - content: - text/plain: - schema: - type: string - responses: - '200': - description: Installation successful or already installed - '400': - description: Installation failed - '403': - description: Security policy violation - - /customnode/install/pip: - post: - summary: Install custom node dependencies via pip - description: Installs Python package dependencies for custom nodes - security: - - securityLevel: [] - requestBody: - required: true - content: - text/plain: - schema: - type: string - responses: - '200': - description: Installation successful - '403': - description: Security policy violation - - # Model Management Endpoints - /externalmodel/getlist: - get: - summary: Get external model list - description: Provides a list of available external models - parameters: - - $ref: '#/components/parameters/modeParam' - responses: - '200': - description: Successful operation - content: - application/json: - schema: - type: object - properties: - models: - type: array - items: - $ref: '#/components/schemas/ModelMetadata' - - # Queue Management Endpoints - /manager/queue/update_all: - get: - summary: Update all custom nodes - description: Queues update operations for all installed custom nodes - security: - - securityLevel: [] - parameters: - - $ref: '#/components/parameters/modeParam' - responses: - '200': - description: Update queued successfully - '401': - description: Processing already in progress - '403': - description: Security policy violation - - /manager/queue/reset: - get: - summary: Reset queue - description: Resets the operation queue - responses: - '200': - description: Queue reset successfully - - /manager/queue/status: - get: - summary: Get queue status - description: Returns the current status of the operation queue - responses: - '200': - description: Successful operation - content: - application/json: - schema: - $ref: '#/components/schemas/QueueStatus' - - /manager/queue/install: - post: - summary: Install custom node - description: Queues installation of a custom node - security: - - securityLevel: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/NodePackageMetadata' - responses: - '200': - description: Installation queued successfully - '403': - description: Security policy violation - '404': - description: Target node not found or security issue - - /manager/queue/start: - get: - summary: Start queue processing - description: Starts processing the operation queue - responses: - '200': - description: Processing started - '201': - description: Processing already in progress - - /manager/queue/fix: - post: - summary: Fix custom node - description: Attempts to fix a broken custom node installation - security: - - securityLevel: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/NodePackageMetadata' - responses: - '200': - description: Fix operation queued successfully - '403': - description: Security policy violation - - /manager/queue/reinstall: - post: - summary: Reinstall custom node - description: Uninstalls and then reinstalls a custom node - security: - - securityLevel: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/NodePackageMetadata' - responses: - '200': - description: Reinstall operation queued successfully - '403': - description: Security policy violation - - /manager/queue/uninstall: - post: - summary: Uninstall custom node - description: Queues uninstallation of a custom node - security: - - securityLevel: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/NodePackageMetadata' - responses: - '200': - description: Uninstallation queued successfully - '403': - description: Security policy violation - - /manager/queue/update: - post: - summary: Update custom node - description: Queues update of a custom node - security: - - securityLevel: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/NodePackageMetadata' - responses: - '200': - description: Update queued successfully - '403': - description: Security policy violation - - /manager/queue/disable: - post: - summary: Disable custom node - description: Disables a custom node without uninstalling it - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/NodePackageMetadata' - responses: - '200': - description: Disable operation queued successfully - - /manager/queue/update_comfyui: - get: - summary: Update ComfyUI - description: Queues an update operation for ComfyUI itself - responses: - '200': - description: Update queued successfully - - /manager/queue/install_model: - post: - summary: Install model - description: Queues installation of a model - security: - - securityLevel: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/ModelMetadata' - responses: - '200': - description: Installation queued successfully - '400': - description: Invalid model request - '403': - description: Security policy violation - - # Snapshot Management Endpoints - /snapshot/getlist: + # Snapshot Management Endpoints (v2) + /v2/snapshot/getlist: get: summary: Get snapshot list description: Returns a list of available snapshots @@ -562,8 +1157,7 @@ paths: type: array items: $ref: '#/components/schemas/SnapshotItem' - - /snapshot/remove: + /v2/snapshot/remove: get: summary: Remove snapshot description: Removes a specified snapshot @@ -578,8 +1172,7 @@ paths: description: Error removing snapshot '403': description: Security policy violation - - /snapshot/restore: + /v2/snapshot/restore: get: summary: Restore snapshot description: Restores a specified snapshot @@ -594,8 +1187,7 @@ paths: description: Error restoring snapshot '403': description: Security policy violation - - /snapshot/get_current: + /v2/snapshot/get_current: get: summary: Get current snapshot description: Returns the current system state as a snapshot @@ -608,8 +1200,7 @@ paths: type: object '400': description: Error creating snapshot - - /snapshot/save: + /v2/snapshot/save: get: summary: Save snapshot description: Saves the current system state as a new snapshot @@ -618,9 +1209,8 @@ paths: description: Snapshot saved successfully '400': description: Error saving snapshot - - # ComfyUI Management Endpoints - /comfyui_manager/comfyui_versions: + # ComfyUI Management Endpoints (v2) + /v2/comfyui_manager/comfyui_versions: get: summary: Get ComfyUI versions description: Returns available and current ComfyUI versions @@ -640,57 +1230,26 @@ paths: type: string '400': description: Error retrieving versions - - /comfyui_manager/comfyui_switch_version: + /v2/comfyui_manager/comfyui_switch_version: get: summary: Switch ComfyUI version description: Switches to a specified ComfyUI version parameters: - name: ver in: query + required: true description: Target version schema: type: string + - $ref: '#/components/parameters/clientIdRequiredParam' + - $ref: '#/components/parameters/uiIdRequiredParam' responses: '200': - description: Version switch successful + description: Version switch queued successfully '400': - description: Error switching version - - /manager/reboot: - get: - summary: Reboot ComfyUI - description: Restarts the ComfyUI server - security: - - securityLevel: [] - responses: - '200': - description: Reboot initiated - '403': - description: Security policy violation - - # Configuration Endpoints - /manager/preview_method: - get: - summary: Get or set preview method - description: Gets or sets the latent preview method - parameters: - - name: value - in: query - required: false - description: New preview method - schema: - type: string - enum: [auto, latent2rgb, taesd, none] - responses: - '200': - description: Setting updated or current value returned - content: - text/plain: - schema: - type: string - - /manager/db_mode: + description: Missing required parameters or error switching version + # Configuration Endpoints (v2) + /v2/manager/db_mode: get: summary: Get or set database mode description: Gets or sets the database mode @@ -709,27 +1268,7 @@ paths: text/plain: schema: type: string - - /manager/policy/component: - get: - summary: Get or set component policy - description: Gets or sets the component policy - parameters: - - name: value - in: query - required: false - description: New component policy - schema: - type: string - responses: - '200': - description: Setting updated or current value returned - content: - text/plain: - schema: - type: string - - /manager/policy/update: + /v2/manager/policy/update: get: summary: Get or set update policy description: Gets or sets the update policy @@ -748,8 +1287,7 @@ paths: text/plain: schema: type: string - - /manager/channel_url_list: + /v2/manager/channel_url_list: get: summary: Get or set channel URL description: Gets or sets the channel URL for custom node sources @@ -779,49 +1317,18 @@ paths: type: string url: type: string - - # Component Management Endpoints - /manager/component/save: - post: - summary: Save component - description: Saves a reusable workflow component - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - name: - type: string - workflow: - type: object + /v2/manager/reboot: + get: + summary: Reboot ComfyUI + description: Restarts the ComfyUI server + security: + - securityLevel: [] responses: '200': - description: Component saved successfully - content: - text/plain: - schema: - type: string - '400': - description: Error saving component - - /manager/component/loads: - post: - summary: Load components - description: Loads all available workflow components - responses: - '200': - description: Components loaded successfully - content: - application/json: - schema: - type: object - '400': - description: Error loading components - - # Miscellaneous Endpoints - /manager/version: + description: Reboot initiated + '403': + description: Security policy violation + /v2/manager/version: get: summary: Get manager version description: Returns the current version of ComfyUI-Manager @@ -832,15 +1339,17 @@ paths: text/plain: schema: type: string - - /manager/notice: + /v2/manager/is_legacy_manager_ui: get: - summary: Get manager notice - description: Returns HTML content with notices and version information + summary: Check if legacy manager UI is enabled + description: Returns whether the legacy manager UI is enabled responses: '200': description: Successful operation content: - text/html: + application/json: schema: - type: string \ No newline at end of file + type: object + properties: + is_legacy_manager_ui: + type: boolean \ No newline at end of file