mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-21 13:01:27 -03:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d15a8aa9a2 | |||
| 74a7d12ca4 | |||
| 2f94a9773e | |||
| 37bdfa21ea | |||
| f0bf2728c9 | |||
| dc715aa273 | |||
| 7ee2361e87 |
@@ -359,6 +359,47 @@ class Config:
|
|||||||
"Failed to rename legacy 'default' library: %s", rename_error
|
"Failed to rename legacy 'default' library: %s", rename_error
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Clean up a stale "default" library entry that has no meaningful
|
||||||
|
# paths configured (e.g. leftover bootstrap artifact). This only
|
||||||
|
# fires when "comfyui" already exists so we never delete the last
|
||||||
|
# remaining library.
|
||||||
|
if (
|
||||||
|
"default" in libraries
|
||||||
|
and "comfyui" in libraries
|
||||||
|
and isinstance(default_library, Mapping)
|
||||||
|
):
|
||||||
|
default_folder_paths = _normalize_library_folder_paths(
|
||||||
|
default_library
|
||||||
|
)
|
||||||
|
default_extra_paths = default_library.get("extra_folder_paths", {})
|
||||||
|
has_meaningful_paths = bool(default_folder_paths) or bool(
|
||||||
|
default_extra_paths
|
||||||
|
) or any(
|
||||||
|
default_library.get(key)
|
||||||
|
for key in (
|
||||||
|
"default_lora_root",
|
||||||
|
"default_checkpoint_root",
|
||||||
|
"default_unet_root",
|
||||||
|
"default_embedding_root",
|
||||||
|
"recipes_path",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not has_meaningful_paths:
|
||||||
|
try:
|
||||||
|
settings_service.delete_library("default")
|
||||||
|
libraries_changed = True
|
||||||
|
logger.info(
|
||||||
|
"Removed stale 'default' library entry "
|
||||||
|
"with no meaningful paths configured"
|
||||||
|
)
|
||||||
|
libraries = settings_service.get_libraries()
|
||||||
|
comfy_library = libraries.get("comfyui", {})
|
||||||
|
except Exception as delete_error:
|
||||||
|
logger.debug(
|
||||||
|
"Failed to remove stale 'default' library: %s",
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
|
||||||
default_lora_root = _resolve_valid_default_root(
|
default_lora_root = _resolve_valid_default_root(
|
||||||
comfy_library.get("default_lora_root", ""),
|
comfy_library.get("default_lora_root", ""),
|
||||||
list(self.loras_roots or []),
|
list(self.loras_roots or []),
|
||||||
|
|||||||
@@ -3471,7 +3471,7 @@ class NodeRegistryHandler:
|
|||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not isinstance(value, str) or not value:
|
if value is None or (isinstance(value, str) and not value):
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": False, "error": "Missing value parameter"}, status=400
|
{"success": False, "error": "Missing value parameter"}, status=400
|
||||||
)
|
)
|
||||||
@@ -3578,7 +3578,7 @@ class NodeRegistryHandler:
|
|||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not isinstance(value, str) or not value:
|
if value is None or (isinstance(value, str) and not value):
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": False, "error": "Missing value parameter"}, status=400
|
{"success": False, "error": "Missing value parameter"}, status=400
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1787,14 +1787,20 @@ class ModelDownloadHandler:
|
|||||||
|
|
||||||
async def delete_download_history_item(self, request: web.Request) -> web.Response:
|
async def delete_download_history_item(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
item_id = int(request.query.get("id", "0"))
|
download_id = request.query.get("download_id")
|
||||||
if not item_id:
|
id_str = request.query.get("id")
|
||||||
|
item_id = int(id_str) if id_str else None
|
||||||
|
|
||||||
|
if not download_id and not item_id:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": False, "error": "id is required"}, status=400
|
{"success": False, "error": "id or download_id is required"},
|
||||||
|
status=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
service = await DownloadQueueService.get_instance()
|
service = await DownloadQueueService.get_instance()
|
||||||
deleted = await service.delete_history_item(item_id)
|
deleted = await service.delete_history_item(
|
||||||
|
id=item_id, download_id=download_id
|
||||||
|
)
|
||||||
return web.json_response({"success": deleted})
|
return web.json_response({"success": deleted})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._logger.error(
|
self._logger.error(
|
||||||
@@ -1804,14 +1810,20 @@ class ModelDownloadHandler:
|
|||||||
|
|
||||||
async def retry_download_from_history(self, request: web.Request) -> web.Response:
|
async def retry_download_from_history(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
item_id = int(request.query.get("id", "0"))
|
download_id = request.query.get("download_id")
|
||||||
if not item_id:
|
id_str = request.query.get("id")
|
||||||
|
item_id = int(id_str) if id_str else None
|
||||||
|
|
||||||
|
if not download_id and not item_id:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": False, "error": "id is required"}, status=400
|
{"success": False, "error": "id or download_id is required"},
|
||||||
|
status=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
service = await DownloadQueueService.get_instance()
|
service = await DownloadQueueService.get_instance()
|
||||||
item = await service.retry_from_history(item_id)
|
item = await service.retry_from_history(
|
||||||
|
item_id=item_id, download_id=download_id
|
||||||
|
)
|
||||||
if item is None:
|
if item is None:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": False, "error": "History item not found or not retryable"},
|
{"success": False, "error": "History item not found or not retryable"},
|
||||||
|
|||||||
@@ -682,7 +682,10 @@ class DownloadManager:
|
|||||||
u for u in download_urls if not u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
u for u in download_urls if not u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
||||||
]
|
]
|
||||||
download_urls = non_civitai_urls + civitai_urls
|
download_urls = non_civitai_urls + civitai_urls
|
||||||
else:
|
|
||||||
|
# Fallback: when mirrors is empty or all mirrors have been deleted,
|
||||||
|
# use the file's downloadUrl directly (e.g. CivitAI download endpoint).
|
||||||
|
if not download_urls:
|
||||||
download_url = file_info.get("downloadUrl")
|
download_url = file_info.get("downloadUrl")
|
||||||
if download_url:
|
if download_url:
|
||||||
download_urls.append(normalize_civitai_download_url(download_url))
|
download_urls.append(normalize_civitai_download_url(download_url))
|
||||||
@@ -1520,35 +1523,8 @@ class DownloadManager:
|
|||||||
|
|
||||||
if not file_info:
|
if not file_info:
|
||||||
return {"success": False, "error": "No suitable file found in metadata"}
|
return {"success": False, "error": "No suitable file found in metadata"}
|
||||||
mirrors = file_info.get("mirrors") or []
|
|
||||||
download_urls = []
|
|
||||||
if mirrors:
|
|
||||||
for mirror in mirrors:
|
|
||||||
if mirror.get("deletedAt") is None and mirror.get("url"):
|
|
||||||
download_urls.append(
|
|
||||||
normalize_civitai_download_url(mirror["url"])
|
|
||||||
)
|
|
||||||
|
|
||||||
# When source is 'civarchive', prioritize non-Civitai URLs
|
download_urls = self._build_download_urls_from_file_info(file_info, source=source)
|
||||||
# This avoids failed downloads from deleted Civitai models
|
|
||||||
if source == "civarchive" and len(download_urls) > 1:
|
|
||||||
civitai_urls = [
|
|
||||||
u
|
|
||||||
for u in download_urls
|
|
||||||
if u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
|
||||||
]
|
|
||||||
non_civitai_urls = [
|
|
||||||
u
|
|
||||||
for u in download_urls
|
|
||||||
if not u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
|
||||||
]
|
|
||||||
download_urls = non_civitai_urls + civitai_urls
|
|
||||||
else:
|
|
||||||
download_url = file_info.get("downloadUrl")
|
|
||||||
if download_url:
|
|
||||||
download_urls.append(
|
|
||||||
normalize_civitai_download_url(download_url)
|
|
||||||
)
|
|
||||||
|
|
||||||
if not download_urls:
|
if not download_urls:
|
||||||
return {"success": False, "error": "No mirror URL found"}
|
return {"success": False, "error": "No mirror URL found"}
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ class DownloadQueueService:
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_dh_completed ON download_history(completed_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_dh_completed ON download_history(completed_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_dh_status ON download_history(status);
|
CREATE INDEX IF NOT EXISTS idx_dh_status ON download_history(status);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_dh_download_id
|
||||||
|
ON download_history(download_id) WHERE download_id IS NOT NULL;
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -390,7 +392,7 @@ class DownloadQueueService:
|
|||||||
)
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO download_history (
|
INSERT OR IGNORE INTO download_history (
|
||||||
download_id, model_id, model_version_id, model_name,
|
download_id, model_id, model_version_id, model_name,
|
||||||
version_name, thumbnail_url, status, error, file_path,
|
version_name, thumbnail_url, status, error, file_path,
|
||||||
bytes_downloaded, total_bytes, completed_at
|
bytes_downloaded, total_bytes, completed_at
|
||||||
@@ -547,17 +549,27 @@ class DownloadQueueService:
|
|||||||
"offset": offset,
|
"offset": offset,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def delete_history_item(self, id: int) -> bool:
|
async def delete_history_item(
|
||||||
"""Delete a single history entry by its *id*.
|
self, id: Optional[int] = None, download_id: Optional[str] = None
|
||||||
|
) -> bool:
|
||||||
|
"""Delete a single history entry by *download_id* (preferred) or *id*.
|
||||||
|
|
||||||
Returns ``True`` if a row was deleted.
|
Returns ``True`` if a row was deleted.
|
||||||
"""
|
"""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
conn = self._get_conn()
|
conn = self._get_conn()
|
||||||
|
if download_id:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"DELETE FROM download_history WHERE download_id = ?",
|
||||||
|
(download_id,),
|
||||||
|
)
|
||||||
|
elif id is not None:
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"DELETE FROM download_history WHERE id = ?",
|
"DELETE FROM download_history WHERE id = ?",
|
||||||
(id,),
|
(id,),
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
return False
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return cursor.rowcount > 0
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
@@ -614,21 +626,34 @@ class DownloadQueueService:
|
|||||||
# Retry
|
# Retry
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def retry_from_history(self, item_id: int) -> Optional[dict[str, Any]]:
|
async def retry_from_history(
|
||||||
|
self,
|
||||||
|
item_id: Optional[int] = None,
|
||||||
|
download_id: Optional[str] = None,
|
||||||
|
) -> Optional[dict[str, Any]]:
|
||||||
"""Re-queue a failed or canceled download from history.
|
"""Re-queue a failed or canceled download from history.
|
||||||
|
|
||||||
Looks up the history record by its primary key. If the status is
|
Looks up the history record by *download_id* (preferred) or
|
||||||
``failed`` or ``canceled`` a new queue entry is created with the
|
*item_id*. If the status is ``failed`` or ``canceled`` a new
|
||||||
same model metadata and a fresh download id, and the original
|
queue entry is created with the same model metadata and a fresh
|
||||||
history entry is **deleted** to prevent exponential growth when
|
download id, and the original history entry is **deleted** to
|
||||||
the retried item is later canceled or fails again and re-retried.
|
prevent exponential growth when the retried item is later
|
||||||
|
canceled or fails again and re-retried.
|
||||||
"""
|
"""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
conn = self._get_conn()
|
conn = self._get_conn()
|
||||||
|
if download_id:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM download_history WHERE download_id = ?",
|
||||||
|
(download_id,),
|
||||||
|
).fetchone()
|
||||||
|
elif item_id is not None:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT * FROM download_history WHERE id = ?",
|
"SELECT * FROM download_history WHERE id = ?",
|
||||||
(item_id,),
|
(item_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
else:
|
||||||
|
return None
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
status = str(row["status"])
|
status = str(row["status"])
|
||||||
@@ -660,7 +685,7 @@ class DownloadQueueService:
|
|||||||
)
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"DELETE FROM download_history WHERE id = ?",
|
"DELETE FROM download_history WHERE id = ?",
|
||||||
(item_id,),
|
(row["id"],),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
queued = conn.execute(
|
queued = conn.execute(
|
||||||
|
|||||||
@@ -113,6 +113,35 @@ def get_model_folder(model_hash: str, library_name: Optional[str] = None) -> str
|
|||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
return legacy_folder
|
return legacy_folder
|
||||||
|
elif not os.path.exists(resolved_folder):
|
||||||
|
# Reverse migration: when consolidating from multi-library to
|
||||||
|
# single-library mode (e.g. after "default" was cleaned up), look
|
||||||
|
# for existing example images inside library-named subdirectories
|
||||||
|
# and bring them back to the root level.
|
||||||
|
root = get_example_images_root()
|
||||||
|
if root:
|
||||||
|
try:
|
||||||
|
for entry in os.listdir(root):
|
||||||
|
entry_path = os.path.join(root, entry)
|
||||||
|
if not os.path.isdir(entry_path):
|
||||||
|
continue
|
||||||
|
if is_hash_folder(entry) or entry == "_deleted":
|
||||||
|
continue
|
||||||
|
if not _library_folder_has_only_hash_dirs(entry_path):
|
||||||
|
continue
|
||||||
|
legacy = os.path.join(entry_path, normalized_hash)
|
||||||
|
if os.path.exists(legacy):
|
||||||
|
shutil.move(legacy, resolved_folder)
|
||||||
|
logger.info(
|
||||||
|
"Consolidated example images from '%s' to '%s'",
|
||||||
|
legacy, resolved_folder,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error(
|
||||||
|
"Failed to consolidate example images during "
|
||||||
|
"library merge: %s", exc,
|
||||||
|
)
|
||||||
|
|
||||||
return resolved_folder
|
return resolved_folder
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
|
# Ensure the script's directory is on sys.path so that py.* imports resolve
|
||||||
|
# regardless of the current working directory (e.g. when launched via
|
||||||
|
# ComfyUI's python_embeded from the ComfyUI root directory).
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
from py.middleware.cache_middleware import cache_control
|
from py.middleware.cache_middleware import cache_control
|
||||||
from py.middleware.error_middleware import api_json_error
|
from py.middleware.error_middleware import api_json_error
|
||||||
from py.utils.settings_paths import ensure_settings_file
|
from py.utils.settings_paths import ensure_settings_file
|
||||||
|
|||||||
@@ -141,6 +141,20 @@ const PARAM_TO_WIDGET_CANDIDATES = {
|
|||||||
scheduler: ['scheduler'],
|
scheduler: ['scheduler'],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Node-type-specific widget name overrides.
|
||||||
|
// Keys are ComfyUI node class names (e.g. "GlobalSeed //Inspire").
|
||||||
|
// Values are partial PARAM_TO_WIDGET_CANDIDATES maps; the per-node candidates
|
||||||
|
// are tried *before* the global ones. Only the params listed here are
|
||||||
|
// overridden — every other param still uses the global candidates.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const NODE_TYPE_WIDGET_OVERRIDES = {
|
||||||
|
// Inspire Pack — Global Seed node stores the seed in a widget named "value"
|
||||||
|
'GlobalSeed //Inspire': {
|
||||||
|
seed: ['value'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Parse a combined sampler+scheduler value (space-separated or underscore)
|
// Parse a combined sampler+scheduler value (space-separated or underscore)
|
||||||
// e.g., "Euler a Karras", "DPM++ 2M beta", "er_sde_beta"
|
// e.g., "Euler a Karras", "DPM++ 2M beta", "er_sde_beta"
|
||||||
@@ -235,7 +249,7 @@ function resolveSamplerScheduler(rawValue) {
|
|||||||
// Find which gen params can be sent to a given node, matching by widget names
|
// Find which gen params can be sent to a given node, matching by widget names
|
||||||
// Returns array of { widgetName, value } objects
|
// Returns array of { widgetName, value } objects
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function findMatchingWidgets(nodeWidgetNames, resolvedParams) {
|
function findMatchingWidgets(nodeWidgetNames, resolvedParams, nodeType) {
|
||||||
if (!nodeWidgetNames || !Array.isArray(nodeWidgetNames) || nodeWidgetNames.length === 0) {
|
if (!nodeWidgetNames || !Array.isArray(nodeWidgetNames) || nodeWidgetNames.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -243,6 +257,26 @@ function findMatchingWidgets(nodeWidgetNames, resolvedParams) {
|
|||||||
const widgetSet = new Set(nodeWidgetNames.map(w => String(w).toLowerCase()));
|
const widgetSet = new Set(nodeWidgetNames.map(w => String(w).toLowerCase()));
|
||||||
const updates = [];
|
const updates = [];
|
||||||
|
|
||||||
|
// Resolve node-type-specific overrides (if any)
|
||||||
|
const typeOverrides =
|
||||||
|
nodeType && typeof nodeType === 'string'
|
||||||
|
? (NODE_TYPE_WIDGET_OVERRIDES[nodeType] || {})
|
||||||
|
: {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the effective candidate list for a parameter:
|
||||||
|
* type-specific overrides (if any) come first, then the global candidates.
|
||||||
|
*/
|
||||||
|
function getCandidates(key) {
|
||||||
|
const global = PARAM_TO_WIDGET_CANDIDATES[key] || [key];
|
||||||
|
const extra = typeOverrides[key];
|
||||||
|
if (extra && Array.isArray(extra) && extra.length > 0) {
|
||||||
|
// Prepend type-specific candidates; keep global as fallback
|
||||||
|
return [...extra, ...global];
|
||||||
|
}
|
||||||
|
return global;
|
||||||
|
}
|
||||||
|
|
||||||
// Simple numeric/string params: seed, steps, cfg
|
// Simple numeric/string params: seed, steps, cfg
|
||||||
const simpleParams = [
|
const simpleParams = [
|
||||||
{ key: 'seed', value: resolvedParams.seed },
|
{ key: 'seed', value: resolvedParams.seed },
|
||||||
@@ -251,10 +285,10 @@ function findMatchingWidgets(nodeWidgetNames, resolvedParams) {
|
|||||||
];
|
];
|
||||||
for (const { key, value } of simpleParams) {
|
for (const { key, value } of simpleParams) {
|
||||||
if (value === undefined || value === null || value === '') continue;
|
if (value === undefined || value === null || value === '') continue;
|
||||||
const candidates = PARAM_TO_WIDGET_CANDIDATES[key] || [key];
|
const candidates = getCandidates(key);
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (widgetSet.has(candidate.toLowerCase())) {
|
if (widgetSet.has(candidate.toLowerCase())) {
|
||||||
updates.push({ widgetName: candidate, value: String(value) });
|
updates.push({ widgetName: candidate, value });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -262,7 +296,7 @@ function findMatchingWidgets(nodeWidgetNames, resolvedParams) {
|
|||||||
|
|
||||||
// Sampler
|
// Sampler
|
||||||
if (resolvedParams.sampler) {
|
if (resolvedParams.sampler) {
|
||||||
const candidates = PARAM_TO_WIDGET_CANDIDATES.sampler;
|
const candidates = getCandidates('sampler');
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (widgetSet.has(candidate.toLowerCase())) {
|
if (widgetSet.has(candidate.toLowerCase())) {
|
||||||
updates.push({ widgetName: candidate, value: resolvedParams.sampler });
|
updates.push({ widgetName: candidate, value: resolvedParams.sampler });
|
||||||
@@ -273,7 +307,7 @@ function findMatchingWidgets(nodeWidgetNames, resolvedParams) {
|
|||||||
|
|
||||||
// Scheduler
|
// Scheduler
|
||||||
if (resolvedParams.scheduler) {
|
if (resolvedParams.scheduler) {
|
||||||
const candidates = PARAM_TO_WIDGET_CANDIDATES.scheduler;
|
const candidates = getCandidates('scheduler');
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (widgetSet.has(candidate.toLowerCase())) {
|
if (widgetSet.has(candidate.toLowerCase())) {
|
||||||
updates.push({ widgetName: candidate, value: resolvedParams.scheduler });
|
updates.push({ widgetName: candidate, value: resolvedParams.scheduler });
|
||||||
@@ -290,6 +324,7 @@ export {
|
|||||||
SCHEDULER_SUFFIXES,
|
SCHEDULER_SUFFIXES,
|
||||||
SCHEDULER_ONLY_VALUES,
|
SCHEDULER_ONLY_VALUES,
|
||||||
PARAM_TO_WIDGET_CANDIDATES,
|
PARAM_TO_WIDGET_CANDIDATES,
|
||||||
|
NODE_TYPE_WIDGET_OVERRIDES,
|
||||||
parseCombinedSamplerName,
|
parseCombinedSamplerName,
|
||||||
resolveSamplerScheduler,
|
resolveSamplerScheduler,
|
||||||
findMatchingWidgets,
|
findMatchingWidgets,
|
||||||
|
|||||||
@@ -605,7 +605,7 @@ function isNodeEnabled(node) {
|
|||||||
if (!node) {
|
if (!node) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// ComfyUI node mode: 0 = Normal/Enabled, others = Always/Never/OnEvent
|
// ComfyUI node mode (LGraphEventMode): 0 = Always, 2 = Never, 4 = Bypass
|
||||||
return node.mode === undefined || node.mode === 0;
|
return node.mode === undefined || node.mode === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1144,8 +1144,8 @@ export async function sendGenParamsToWorkflow(genParams) {
|
|||||||
const node = targetNodes[nodeKey];
|
const node = targetNodes[nodeKey];
|
||||||
if (!node) continue;
|
if (!node) continue;
|
||||||
|
|
||||||
const widgetNames = node.widget_names || [];
|
const widgetNames = getWidgetNames(node);
|
||||||
const updates = findMatchingWidgets(widgetNames, raw);
|
const updates = findMatchingWidgets(widgetNames, raw, node.type_name);
|
||||||
|
|
||||||
if (updates.length === 0) {
|
if (updates.length === 0) {
|
||||||
showToast(`Node "${node.title || node.type}" has no matching widgets for these parameters`, {}, 'warning');
|
showToast(`Node "${node.title || node.type}" has no matching widgets for these parameters`, {}, 'warning');
|
||||||
|
|||||||
@@ -823,3 +823,73 @@ def test_apply_library_settings_ignores_extra_lora_path_overlapping_primary_root
|
|||||||
"same lora folder" in record.message.lower()
|
"same lora folder" in record.message.lower()
|
||||||
for record in caplog.records
|
for record in caplog.records
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_paths_removes_stale_empty_default_when_comfyui_exists(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path,
|
||||||
|
):
|
||||||
|
"""When an empty-shell 'default' library coexists with 'comfyui', the
|
||||||
|
stale 'default' entry should be removed and 'comfyui' activated."""
|
||||||
|
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
|
||||||
|
|
||||||
|
class FakeSettingsService:
|
||||||
|
def __init__(self):
|
||||||
|
# Replicate the user's settings.json: empty default + populated comfyui
|
||||||
|
self.libraries = {
|
||||||
|
"default": {
|
||||||
|
"folder_paths": {},
|
||||||
|
"extra_folder_paths": {},
|
||||||
|
"default_lora_root": "",
|
||||||
|
"default_checkpoint_root": "",
|
||||||
|
"default_unet_root": "",
|
||||||
|
"default_embedding_root": "",
|
||||||
|
"recipes_path": "",
|
||||||
|
},
|
||||||
|
"comfyui": {
|
||||||
|
"folder_paths": {
|
||||||
|
key: list(value) for key, value in folder_paths.items()
|
||||||
|
},
|
||||||
|
"default_lora_root": folder_paths["loras"][0],
|
||||||
|
"default_checkpoint_root": folder_paths["checkpoints"][0],
|
||||||
|
"default_embedding_root": folder_paths["embeddings"][0],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
# No active_library key — get_active_library_name() falls back to
|
||||||
|
# dict order, returning "default".
|
||||||
|
self.active_library = "default"
|
||||||
|
self.delete_calls: list[str] = []
|
||||||
|
self.upsert_calls: list[tuple[str, dict]] = []
|
||||||
|
|
||||||
|
def get_libraries(self):
|
||||||
|
return dict(self.libraries)
|
||||||
|
|
||||||
|
def delete_library(self, name: str):
|
||||||
|
self.delete_calls.append(name)
|
||||||
|
self.libraries.pop(name, None)
|
||||||
|
|
||||||
|
def rename_library(self, *_):
|
||||||
|
raise AssertionError("rename_library should not be invoked")
|
||||||
|
|
||||||
|
def get_active_library_name(self):
|
||||||
|
return self.active_library
|
||||||
|
|
||||||
|
def upsert_library(self, name: str, **payload):
|
||||||
|
self.upsert_calls.append((name, payload))
|
||||||
|
self.libraries[name] = {**payload}
|
||||||
|
if payload.get("activate"):
|
||||||
|
self.active_library = name
|
||||||
|
|
||||||
|
fake_settings = FakeSettingsService()
|
||||||
|
monkeypatch.setattr(settings_manager_module, "settings", fake_settings)
|
||||||
|
|
||||||
|
config_module.Config()
|
||||||
|
|
||||||
|
assert fake_settings.delete_calls == ["default"]
|
||||||
|
assert "default" not in fake_settings.libraries
|
||||||
|
assert set(fake_settings.libraries.keys()) == {"comfyui"}
|
||||||
|
|
||||||
|
assert len(fake_settings.upsert_calls) == 1
|
||||||
|
name, payload = fake_settings.upsert_calls[0]
|
||||||
|
assert name == "comfyui"
|
||||||
|
assert payload["activate"] is True
|
||||||
|
assert fake_settings.active_library == "comfyui"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
parseCombinedSamplerName,
|
parseCombinedSamplerName,
|
||||||
resolveSamplerScheduler,
|
resolveSamplerScheduler,
|
||||||
findMatchingWidgets,
|
findMatchingWidgets,
|
||||||
|
NODE_TYPE_WIDGET_OVERRIDES,
|
||||||
} from '../../../static/js/utils/genParamsMapper.js';
|
} from '../../../static/js/utils/genParamsMapper.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -204,9 +205,9 @@ describe('findMatchingWidgets', () => {
|
|||||||
|
|
||||||
it('matches seed to seed widget', () => {
|
it('matches seed to seed widget', () => {
|
||||||
const updates = findMatchingWidgets(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved);
|
const updates = findMatchingWidgets(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved);
|
||||||
expect(updates).toContainEqual({ widgetName: 'seed', value: '42' });
|
expect(updates).toContainEqual({ widgetName: 'seed', value: 42 });
|
||||||
expect(updates).toContainEqual({ widgetName: 'steps', value: '30' });
|
expect(updates).toContainEqual({ widgetName: 'steps', value: 30 });
|
||||||
expect(updates).toContainEqual({ widgetName: 'cfg', value: '7' });
|
expect(updates).toContainEqual({ widgetName: 'cfg', value: 7 });
|
||||||
expect(updates).toContainEqual({ widgetName: 'sampler_name', value: 'euler_ancestral' });
|
expect(updates).toContainEqual({ widgetName: 'sampler_name', value: 'euler_ancestral' });
|
||||||
expect(updates).toContainEqual({ widgetName: 'scheduler', value: 'karras' });
|
expect(updates).toContainEqual({ widgetName: 'scheduler', value: 'karras' });
|
||||||
});
|
});
|
||||||
@@ -221,7 +222,7 @@ describe('findMatchingWidgets', () => {
|
|||||||
const updates = findMatchingWidgets(['noise_seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved);
|
const updates = findMatchingWidgets(['noise_seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved);
|
||||||
const seedUpdate = updates.find(u => u.widgetName === 'noise_seed');
|
const seedUpdate = updates.find(u => u.widgetName === 'noise_seed');
|
||||||
expect(seedUpdate).toBeDefined();
|
expect(seedUpdate).toBeDefined();
|
||||||
expect(seedUpdate.value).toBe('42');
|
expect(seedUpdate.value).toBe(42);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('matches rgthree-style sampler widget name', () => {
|
it('matches rgthree-style sampler widget name', () => {
|
||||||
@@ -243,4 +244,53 @@ describe('findMatchingWidgets', () => {
|
|||||||
const updates = findMatchingWidgets(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved);
|
const updates = findMatchingWidgets(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved);
|
||||||
expect(updates.map(u => u.widgetName)).toEqual(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler']);
|
expect(updates.map(u => u.widgetName)).toEqual(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- node-type-specific overrides ---
|
||||||
|
it('matches GlobalSeed //Inspire value widget for seed param', () => {
|
||||||
|
const updates = findMatchingWidgets(
|
||||||
|
['value', 'mode', 'action', 'last_seed'],
|
||||||
|
{ seed: 42 },
|
||||||
|
'GlobalSeed //Inspire'
|
||||||
|
);
|
||||||
|
expect(updates).toHaveLength(1);
|
||||||
|
expect(updates[0]).toEqual({ widgetName: 'value', value: 42 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores nodeType when it does not match any override entry', () => {
|
||||||
|
const updates = findMatchingWidgets(
|
||||||
|
['value', 'mode', 'action', 'last_seed'],
|
||||||
|
{ seed: 42 },
|
||||||
|
'SomeOtherNode'
|
||||||
|
);
|
||||||
|
expect(updates).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still falls back to global candidates when override candidates do not match', () => {
|
||||||
|
// GlobalSeed override does not include steps — should use global candidate "steps"
|
||||||
|
const updates = findMatchingWidgets(
|
||||||
|
['steps', 'cfg', 'sampler_name'],
|
||||||
|
{ steps: 20 },
|
||||||
|
'GlobalSeed //Inspire'
|
||||||
|
);
|
||||||
|
expect(updates).toHaveLength(1);
|
||||||
|
expect(updates[0]).toEqual({ widgetName: 'steps', value: 20 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers overrides when both override and global candidates match', () => {
|
||||||
|
// If a hypothetical node has both "value" and "seed" widgets AND a
|
||||||
|
// GlobalSeed override, the override candidate "value" should take precedence
|
||||||
|
const updates = findMatchingWidgets(
|
||||||
|
['seed', 'noise_seed', 'value', 'mode'],
|
||||||
|
{ seed: 99 },
|
||||||
|
'GlobalSeed //Inspire'
|
||||||
|
);
|
||||||
|
expect(updates).toHaveLength(1);
|
||||||
|
expect(updates[0].widgetName).toBe('value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits nodeType argument and still matches via global candidates', () => {
|
||||||
|
const updates = findMatchingWidgets(['seed', 'steps', 'cfg'], { seed: 7 });
|
||||||
|
expect(updates).toHaveLength(1);
|
||||||
|
expect(updates[0]).toEqual({ widgetName: 'seed', value: 7 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
"""Unit tests for DownloadQueueService history operations.
|
||||||
|
|
||||||
|
Covers the new ``download_id``-based code paths in
|
||||||
|
``delete_history_item`` and ``retry_from_history``, plus backward
|
||||||
|
compatibility with ``id``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from py.services.download_queue_service import DownloadQueueService
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_service(tmp_path: Path) -> DownloadQueueService:
|
||||||
|
"""Create a DownloadQueueService backed by a temporary database."""
|
||||||
|
return DownloadQueueService(db_path=str(tmp_path / "queue.sqlite"))
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed(
|
||||||
|
svc: DownloadQueueService,
|
||||||
|
download_id: str,
|
||||||
|
status: str = "failed",
|
||||||
|
) -> tuple[int, str]:
|
||||||
|
"""Insert a history row and return (autoincrement id, download_id)."""
|
||||||
|
row_id = await svc.add_to_history(
|
||||||
|
download_id=download_id,
|
||||||
|
model_id=1,
|
||||||
|
model_version_id=100,
|
||||||
|
model_name="TestModel",
|
||||||
|
version_name="v1",
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
return row_id, download_id
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# delete_history_item
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_by_download_id(tmp_path: Path) -> None:
|
||||||
|
"""delete_history_item(download_id=...) removes the correct row."""
|
||||||
|
svc = _make_service(tmp_path)
|
||||||
|
rid, did = await _seed(svc, "dl-aaa")
|
||||||
|
|
||||||
|
deleted = await svc.delete_history_item(download_id=did)
|
||||||
|
assert deleted is True
|
||||||
|
|
||||||
|
# Verify gone from history
|
||||||
|
history = await svc.get_history()
|
||||||
|
assert len(history["items"]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_by_id_legacy(tmp_path: Path) -> None:
|
||||||
|
"""delete_history_item(id=...) still works (backward compat)."""
|
||||||
|
svc = _make_service(tmp_path)
|
||||||
|
rid, _did = await _seed(svc, "dl-bbb")
|
||||||
|
|
||||||
|
deleted = await svc.delete_history_item(id=rid)
|
||||||
|
assert deleted is True
|
||||||
|
|
||||||
|
history = await svc.get_history()
|
||||||
|
assert len(history["items"]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_no_params_returns_false(tmp_path: Path) -> None:
|
||||||
|
"""Calling delete_history_item with no params returns False."""
|
||||||
|
svc = _make_service(tmp_path)
|
||||||
|
await _seed(svc, "dl-ccc")
|
||||||
|
|
||||||
|
deleted = await svc.delete_history_item()
|
||||||
|
assert deleted is False
|
||||||
|
|
||||||
|
# Row is still there
|
||||||
|
history = await svc.get_history()
|
||||||
|
assert len(history["items"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_download_id_precedence(tmp_path: Path) -> None:
|
||||||
|
"""When both id and download_id are given, download_id is used."""
|
||||||
|
svc = _make_service(tmp_path)
|
||||||
|
# Insert two rows
|
||||||
|
rid_a, did_a = await _seed(svc, "dl-aaa")
|
||||||
|
rid_b, did_b = await _seed(svc, "dl-bbb")
|
||||||
|
|
||||||
|
# Delete by download_id while also passing the *wrong* id
|
||||||
|
deleted = await svc.delete_history_item(id=rid_b, download_id=did_a)
|
||||||
|
assert deleted is True
|
||||||
|
|
||||||
|
history = await svc.get_history()
|
||||||
|
ids_left = [it["id"] for it in history["items"]]
|
||||||
|
assert rid_a not in ids_left # dl-aaa was deleted
|
||||||
|
assert rid_b in ids_left # dl-bbb (wrong id) was ignored
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# retry_from_history
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_by_download_id(tmp_path: Path) -> None:
|
||||||
|
"""retry_from_history(download_id=...) re-queues and deletes history."""
|
||||||
|
svc = _make_service(tmp_path)
|
||||||
|
rid, did = await _seed(svc, "dl-fail", status="failed")
|
||||||
|
|
||||||
|
item = await svc.retry_from_history(download_id=did)
|
||||||
|
assert item is not None
|
||||||
|
assert item["status"] == "queued"
|
||||||
|
|
||||||
|
# History row must be deleted (the bug fix)
|
||||||
|
history = await svc.get_history()
|
||||||
|
ids_in_history = [it["id"] for it in history["items"]]
|
||||||
|
assert rid not in ids_in_history
|
||||||
|
|
||||||
|
# Queue must contain the new item
|
||||||
|
queue = await svc.get_queue()
|
||||||
|
assert len(queue) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_by_download_id_canceled(tmp_path: Path) -> None:
|
||||||
|
"""retry_from_history works for 'canceled' status too."""
|
||||||
|
svc = _make_service(tmp_path)
|
||||||
|
rid, did = await _seed(svc, "dl-cancel", status="canceled")
|
||||||
|
|
||||||
|
item = await svc.retry_from_history(download_id=did)
|
||||||
|
assert item is not None
|
||||||
|
assert item["status"] == "queued"
|
||||||
|
|
||||||
|
history = await svc.get_history()
|
||||||
|
assert len(history["items"]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_by_id_legacy(tmp_path: Path) -> None:
|
||||||
|
"""retry_from_history(item_id=...) still works (backward compat)."""
|
||||||
|
svc = _make_service(tmp_path)
|
||||||
|
rid, _did = await _seed(svc, "dl-legacy", status="failed")
|
||||||
|
|
||||||
|
item = await svc.retry_from_history(item_id=rid)
|
||||||
|
assert item is not None
|
||||||
|
assert item["status"] == "queued"
|
||||||
|
|
||||||
|
history = await svc.get_history()
|
||||||
|
assert len(history["items"]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_no_params_returns_none(tmp_path: Path) -> None:
|
||||||
|
"""Calling retry_from_history with no params returns None."""
|
||||||
|
svc = _make_service(tmp_path)
|
||||||
|
await _seed(svc, "dl-none", status="failed")
|
||||||
|
|
||||||
|
item = await svc.retry_from_history()
|
||||||
|
assert item is None
|
||||||
|
|
||||||
|
# History untouched
|
||||||
|
history = await svc.get_history()
|
||||||
|
assert len(history["items"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_non_retryable_status(tmp_path: Path) -> None:
|
||||||
|
"""retry_from_history returns None for 'completed' status."""
|
||||||
|
svc = _make_service(tmp_path)
|
||||||
|
_rid, did = await _seed(svc, "dl-ok", status="completed")
|
||||||
|
|
||||||
|
item = await svc.retry_from_history(download_id=did)
|
||||||
|
assert item is None
|
||||||
|
|
||||||
|
# History untouched
|
||||||
|
history = await svc.get_history()
|
||||||
|
assert len(history["items"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_unknown_download_id(tmp_path: Path) -> None:
|
||||||
|
"""retry_from_history returns None for a non-existent download_id."""
|
||||||
|
svc = _make_service(tmp_path)
|
||||||
|
await _seed(svc, "dl-real", status="failed")
|
||||||
|
|
||||||
|
item = await svc.retry_from_history(download_id="dl-nope")
|
||||||
|
assert item is None
|
||||||
@@ -30,6 +30,7 @@ function createMockToast() {
|
|||||||
function createMockWidget(value?: unknown) {
|
function createMockWidget(value?: unknown) {
|
||||||
type PendingInfo = { name: string; notes: string; filePath: string; activeTab?: string } | null
|
type PendingInfo = { name: string; notes: string; filePath: string; activeTab?: string } | null
|
||||||
const widget = {
|
const widget = {
|
||||||
|
options: {} as { getValue?: () => unknown; setValue?: (v: unknown) => void },
|
||||||
serializeValue: (async () => null) as () => Promise<unknown>,
|
serializeValue: (async () => null) as () => Promise<unknown>,
|
||||||
value: (value ?? undefined) as unknown,
|
value: (value ?? undefined) as unknown,
|
||||||
onSetValue: undefined as unknown as ((v: unknown) => void),
|
onSetValue: undefined as unknown as ((v: unknown) => void),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { app } from "../../scripts/app.js";
|
import { app } from "../../scripts/app.js";
|
||||||
import { api } from "../../scripts/api.js";
|
import { api } from "../../scripts/api.js";
|
||||||
import { getAllGraphNodes, getNodeReference, getNodeFromGraph, chainCallback } from "./utils.js";
|
import { getAllGraphNodes, getNodeReference, getNodeFromGraph, chainCallback, getLinkFromGraph } from "./utils.js";
|
||||||
import { ensureLmStyles } from "./lm_styles_loader.js";
|
import { ensureLmStyles } from "./lm_styles_loader.js";
|
||||||
|
|
||||||
const DEBOUNCE_DELAY = 500;
|
const DEBOUNCE_DELAY = 500;
|
||||||
@@ -76,6 +76,84 @@ function fadeWidgetTextColor(widget, fromColor, toColor, duration) {
|
|||||||
return () => { if (rafId) cancelAnimationFrame(rafId); };
|
return () => { if (rafId) cancelAnimationFrame(rafId); };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Primitive node helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set of node type names that represent Primitive value nodes.
|
||||||
|
* Includes both the dynamic PrimitiveNode (created by double-clicking
|
||||||
|
* a widget input) and the static typed primitives from the node library.
|
||||||
|
*/
|
||||||
|
const PRIMITIVE_NODE_TYPES = new Set([
|
||||||
|
"PrimitiveNode", // dynamic (double-click a widget input)
|
||||||
|
"PrimitiveInt",
|
||||||
|
"PrimitiveFloat",
|
||||||
|
"PrimitiveString",
|
||||||
|
"PrimitiveBoolean",
|
||||||
|
"PrimitiveStringMultiline",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return true when `node` is any flavour of Primitive node.
|
||||||
|
* @param {Object} node - LiteGraph node instance
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isPrimitiveNodeType(node) {
|
||||||
|
return PRIMITIVE_NODE_TYPES.has(node?.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the 0-based input slot index whose widget name matches `widgetName`.
|
||||||
|
* Returns -1 when no matching input is found.
|
||||||
|
*
|
||||||
|
* Matching strategy (in order):
|
||||||
|
* 1. `input.widget?.name === widgetName` — direct widget ref (preferred)
|
||||||
|
* 2. `input.name === widgetName` — fallback by slot name
|
||||||
|
*
|
||||||
|
* @param {Object} node - LiteGraph node instance
|
||||||
|
* @param {string} widgetName
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function findInputSlotForWidget(node, widgetName) {
|
||||||
|
if (!node || !Array.isArray(node.inputs)) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return node.inputs.findIndex(
|
||||||
|
(inp) => inp?.widget?.name === widgetName || inp?.name === widgetName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the input slot that backs `widgetName` on `node` is connected to a
|
||||||
|
* Primitive node, return that Primitive node. Otherwise return null.
|
||||||
|
*
|
||||||
|
* This is the key bridge for the "send gen params → Primitive" flow:
|
||||||
|
* when a KSampler widget (e.g. "steps") has an incoming wire from a
|
||||||
|
* Primitive node, we want to update the Primitive's value instead of the
|
||||||
|
* KSampler widget, because ComfyUI's execution engine reads from the
|
||||||
|
* connected input, not the widget.
|
||||||
|
*
|
||||||
|
* @param {Object} node - the target node (e.g. KSampler)
|
||||||
|
* @param {string} widgetName - e.g. "steps", "cfg", "seed"
|
||||||
|
* @returns {Object|null} - the connected Primitive node, or null
|
||||||
|
*/
|
||||||
|
function tryResolvePrimitiveConnection(node, widgetName) {
|
||||||
|
const slotIndex = findInputSlotForWidget(node, widgetName);
|
||||||
|
if (slotIndex === -1) return null;
|
||||||
|
|
||||||
|
const input = node.inputs[slotIndex];
|
||||||
|
if (input?.link == null) return null;
|
||||||
|
|
||||||
|
const link = getLinkFromGraph(node.graph, input.link);
|
||||||
|
if (!link) return null;
|
||||||
|
|
||||||
|
const originNode = node.graph?.getNodeById?.(link.origin_id);
|
||||||
|
if (!originNode) return null;
|
||||||
|
|
||||||
|
return isPrimitiveNodeType(originNode) ? originNode : null;
|
||||||
|
}
|
||||||
|
|
||||||
app.registerExtension({
|
app.registerExtension({
|
||||||
name: "LoraManager.WorkflowRegistry",
|
name: "LoraManager.WorkflowRegistry",
|
||||||
|
|
||||||
@@ -309,6 +387,60 @@ app.registerExtension({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Redirect to connected Primitive node when present ----
|
||||||
|
// When a widget input (e.g. "steps", "cfg", "seed" on KSampler)
|
||||||
|
// is wired to a Primitive node, the Primitive's value overrides
|
||||||
|
// the widget value during execution. Update the Primitive
|
||||||
|
// directly so the change actually takes effect.
|
||||||
|
if (widgetName) {
|
||||||
|
const primitiveNode = tryResolvePrimitiveConnection(node, widgetName);
|
||||||
|
if (primitiveNode) {
|
||||||
|
const primWidget = primitiveNode.widgets?.[0];
|
||||||
|
if (primWidget) {
|
||||||
|
let primNewValue = value;
|
||||||
|
if (mode === "append") {
|
||||||
|
const sep =
|
||||||
|
primWidget.value && primWidget.value.length > 0
|
||||||
|
? " "
|
||||||
|
: "";
|
||||||
|
primNewValue = primWidget.value + sep + value;
|
||||||
|
}
|
||||||
|
primWidget.value = primNewValue;
|
||||||
|
if (
|
||||||
|
Array.isArray(primitiveNode.widgets_values) &&
|
||||||
|
primitiveNode.widgets_values.length > 0
|
||||||
|
) {
|
||||||
|
primitiveNode.widgets_values[0] = primNewValue;
|
||||||
|
}
|
||||||
|
if (typeof primWidget.callback === "function") {
|
||||||
|
try {
|
||||||
|
primWidget.callback(primNewValue);
|
||||||
|
} catch (callbackError) {
|
||||||
|
console.error(
|
||||||
|
"LoRA Manager: primitive widget callback failed",
|
||||||
|
callbackError
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof primitiveNode.setDirtyCanvas === "function") {
|
||||||
|
primitiveNode.setDirtyCanvas(true);
|
||||||
|
}
|
||||||
|
if (typeof app.graph?.setDirtyCanvas === "function") {
|
||||||
|
app.graph.setDirtyCanvas(true, true);
|
||||||
|
}
|
||||||
|
this.flashWidget(primitiveNode, primWidget);
|
||||||
|
console.debug(
|
||||||
|
"LoRA Manager: redirected widget update to Primitive node %s (id=%d) ← %s = %o",
|
||||||
|
primitiveNode.type,
|
||||||
|
primitiveNode.id,
|
||||||
|
widgetName,
|
||||||
|
primNewValue
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Update widget value ----
|
// ---- Update widget value ----
|
||||||
const widgetIndex = node.widgets.indexOf(targetWidget);
|
const widgetIndex = node.widgets.indexOf(targetWidget);
|
||||||
let newValue = value;
|
let newValue = value;
|
||||||
|
|||||||
Reference in New Issue
Block a user