mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-06 22:10:14 -03:00
Compare commits
7 Commits
e04c22f83f
...
d15a8aa9a2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d15a8aa9a2 | ||
|
|
74a7d12ca4 | ||
|
|
2f94a9773e | ||
|
|
37bdfa21ea | ||
|
|
f0bf2728c9 | ||
|
|
dc715aa273 | ||
|
|
7ee2361e87 |
41
py/config.py
41
py/config.py
@@ -359,6 +359,47 @@ class Config:
|
||||
"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(
|
||||
comfy_library.get("default_lora_root", ""),
|
||||
list(self.loras_roots or []),
|
||||
|
||||
@@ -3471,7 +3471,7 @@ class NodeRegistryHandler:
|
||||
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(
|
||||
{"success": False, "error": "Missing value parameter"}, status=400
|
||||
)
|
||||
@@ -3578,7 +3578,7 @@ class NodeRegistryHandler:
|
||||
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(
|
||||
{"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:
|
||||
try:
|
||||
item_id = int(request.query.get("id", "0"))
|
||||
if not item_id:
|
||||
download_id = request.query.get("download_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(
|
||||
{"success": False, "error": "id is required"}, status=400
|
||||
{"success": False, "error": "id or download_id is required"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
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})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
@@ -1804,14 +1810,20 @@ class ModelDownloadHandler:
|
||||
|
||||
async def retry_download_from_history(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
item_id = int(request.query.get("id", "0"))
|
||||
if not item_id:
|
||||
download_id = request.query.get("download_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(
|
||||
{"success": False, "error": "id is required"}, status=400
|
||||
{"success": False, "error": "id or download_id is required"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
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:
|
||||
return web.json_response(
|
||||
{"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)
|
||||
]
|
||||
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")
|
||||
if download_url:
|
||||
download_urls.append(normalize_civitai_download_url(download_url))
|
||||
@@ -1520,35 +1523,8 @@ class DownloadManager:
|
||||
|
||||
if not file_info:
|
||||
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
|
||||
# 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)
|
||||
)
|
||||
download_urls = self._build_download_urls_from_file_info(file_info, source=source)
|
||||
|
||||
if not download_urls:
|
||||
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_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
|
||||
@@ -390,7 +392,7 @@ class DownloadQueueService:
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO download_history (
|
||||
INSERT OR IGNORE INTO download_history (
|
||||
download_id, model_id, model_version_id, model_name,
|
||||
version_name, thumbnail_url, status, error, file_path,
|
||||
bytes_downloaded, total_bytes, completed_at
|
||||
@@ -547,17 +549,27 @@ class DownloadQueueService:
|
||||
"offset": offset,
|
||||
}
|
||||
|
||||
async def delete_history_item(self, id: int) -> bool:
|
||||
"""Delete a single history entry by its *id*.
|
||||
async def delete_history_item(
|
||||
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.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM download_history WHERE id = ?",
|
||||
(id,),
|
||||
)
|
||||
if download_id:
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM download_history WHERE download_id = ?",
|
||||
(download_id,),
|
||||
)
|
||||
elif id is not None:
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM download_history WHERE id = ?",
|
||||
(id,),
|
||||
)
|
||||
else:
|
||||
return False
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@@ -614,21 +626,34 @@ class DownloadQueueService:
|
||||
# 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.
|
||||
|
||||
Looks up the history record by its primary key. If the status is
|
||||
``failed`` or ``canceled`` a new queue entry is created with the
|
||||
same model metadata and a fresh download id, and the original
|
||||
history entry is **deleted** to prevent exponential growth when
|
||||
the retried item is later canceled or fails again and re-retried.
|
||||
Looks up the history record by *download_id* (preferred) or
|
||||
*item_id*. If the status is ``failed`` or ``canceled`` a new
|
||||
queue entry is created with the same model metadata and a fresh
|
||||
download id, and the original history entry is **deleted** to
|
||||
prevent exponential growth when the retried item is later
|
||||
canceled or fails again and re-retried.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM download_history WHERE id = ?",
|
||||
(item_id,),
|
||||
).fetchone()
|
||||
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(
|
||||
"SELECT * FROM download_history WHERE id = ?",
|
||||
(item_id,),
|
||||
).fetchone()
|
||||
else:
|
||||
return None
|
||||
if row is None:
|
||||
return None
|
||||
status = str(row["status"])
|
||||
@@ -660,7 +685,7 @@ class DownloadQueueService:
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM download_history WHERE id = ?",
|
||||
(item_id,),
|
||||
(row["id"],),
|
||||
)
|
||||
conn.commit()
|
||||
queued = conn.execute(
|
||||
|
||||
@@ -113,6 +113,35 @@ def get_model_folder(model_hash: str, library_name: Optional[str] = None) -> str
|
||||
exc,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import os
|
||||
import sys
|
||||
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.error_middleware import api_json_error
|
||||
from py.utils.settings_paths import ensure_settings_file
|
||||
|
||||
@@ -141,6 +141,20 @@ const PARAM_TO_WIDGET_CANDIDATES = {
|
||||
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)
|
||||
// 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
|
||||
// Returns array of { widgetName, value } objects
|
||||
// ---------------------------------------------------------------------------
|
||||
function findMatchingWidgets(nodeWidgetNames, resolvedParams) {
|
||||
function findMatchingWidgets(nodeWidgetNames, resolvedParams, nodeType) {
|
||||
if (!nodeWidgetNames || !Array.isArray(nodeWidgetNames) || nodeWidgetNames.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@@ -243,6 +257,26 @@ function findMatchingWidgets(nodeWidgetNames, resolvedParams) {
|
||||
const widgetSet = new Set(nodeWidgetNames.map(w => String(w).toLowerCase()));
|
||||
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
|
||||
const simpleParams = [
|
||||
{ key: 'seed', value: resolvedParams.seed },
|
||||
@@ -251,10 +285,10 @@ function findMatchingWidgets(nodeWidgetNames, resolvedParams) {
|
||||
];
|
||||
for (const { key, value } of simpleParams) {
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
const candidates = PARAM_TO_WIDGET_CANDIDATES[key] || [key];
|
||||
const candidates = getCandidates(key);
|
||||
for (const candidate of candidates) {
|
||||
if (widgetSet.has(candidate.toLowerCase())) {
|
||||
updates.push({ widgetName: candidate, value: String(value) });
|
||||
updates.push({ widgetName: candidate, value });
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -262,7 +296,7 @@ function findMatchingWidgets(nodeWidgetNames, resolvedParams) {
|
||||
|
||||
// Sampler
|
||||
if (resolvedParams.sampler) {
|
||||
const candidates = PARAM_TO_WIDGET_CANDIDATES.sampler;
|
||||
const candidates = getCandidates('sampler');
|
||||
for (const candidate of candidates) {
|
||||
if (widgetSet.has(candidate.toLowerCase())) {
|
||||
updates.push({ widgetName: candidate, value: resolvedParams.sampler });
|
||||
@@ -273,7 +307,7 @@ function findMatchingWidgets(nodeWidgetNames, resolvedParams) {
|
||||
|
||||
// Scheduler
|
||||
if (resolvedParams.scheduler) {
|
||||
const candidates = PARAM_TO_WIDGET_CANDIDATES.scheduler;
|
||||
const candidates = getCandidates('scheduler');
|
||||
for (const candidate of candidates) {
|
||||
if (widgetSet.has(candidate.toLowerCase())) {
|
||||
updates.push({ widgetName: candidate, value: resolvedParams.scheduler });
|
||||
@@ -290,6 +324,7 @@ export {
|
||||
SCHEDULER_SUFFIXES,
|
||||
SCHEDULER_ONLY_VALUES,
|
||||
PARAM_TO_WIDGET_CANDIDATES,
|
||||
NODE_TYPE_WIDGET_OVERRIDES,
|
||||
parseCombinedSamplerName,
|
||||
resolveSamplerScheduler,
|
||||
findMatchingWidgets,
|
||||
|
||||
@@ -605,7 +605,7 @@ function isNodeEnabled(node) {
|
||||
if (!node) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1144,8 +1144,8 @@ export async function sendGenParamsToWorkflow(genParams) {
|
||||
const node = targetNodes[nodeKey];
|
||||
if (!node) continue;
|
||||
|
||||
const widgetNames = node.widget_names || [];
|
||||
const updates = findMatchingWidgets(widgetNames, raw);
|
||||
const widgetNames = getWidgetNames(node);
|
||||
const updates = findMatchingWidgets(widgetNames, raw, node.type_name);
|
||||
|
||||
if (updates.length === 0) {
|
||||
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()
|
||||
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,
|
||||
resolveSamplerScheduler,
|
||||
findMatchingWidgets,
|
||||
NODE_TYPE_WIDGET_OVERRIDES,
|
||||
} from '../../../static/js/utils/genParamsMapper.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -204,9 +205,9 @@ describe('findMatchingWidgets', () => {
|
||||
|
||||
it('matches seed to seed widget', () => {
|
||||
const updates = findMatchingWidgets(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved);
|
||||
expect(updates).toContainEqual({ widgetName: 'seed', value: '42' });
|
||||
expect(updates).toContainEqual({ widgetName: 'steps', value: '30' });
|
||||
expect(updates).toContainEqual({ widgetName: 'cfg', value: '7' });
|
||||
expect(updates).toContainEqual({ widgetName: 'seed', value: 42 });
|
||||
expect(updates).toContainEqual({ widgetName: 'steps', value: 30 });
|
||||
expect(updates).toContainEqual({ widgetName: 'cfg', value: 7 });
|
||||
expect(updates).toContainEqual({ widgetName: 'sampler_name', value: 'euler_ancestral' });
|
||||
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 seedUpdate = updates.find(u => u.widgetName === 'noise_seed');
|
||||
expect(seedUpdate).toBeDefined();
|
||||
expect(seedUpdate.value).toBe('42');
|
||||
expect(seedUpdate.value).toBe(42);
|
||||
});
|
||||
|
||||
it('matches rgthree-style sampler widget name', () => {
|
||||
@@ -243,4 +244,53 @@ describe('findMatchingWidgets', () => {
|
||||
const updates = findMatchingWidgets(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved);
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
193
tests/services/test_download_queue_service.py
Normal file
193
tests/services/test_download_queue_service.py
Normal file
@@ -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) {
|
||||
type PendingInfo = { name: string; notes: string; filePath: string; activeTab?: string } | null
|
||||
const widget = {
|
||||
options: {} as { getValue?: () => unknown; setValue?: (v: unknown) => void },
|
||||
serializeValue: (async () => null) as () => Promise<unknown>,
|
||||
value: (value ?? undefined) as unknown,
|
||||
onSetValue: undefined as unknown as ((v: unknown) => void),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { app } from "../../scripts/app.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";
|
||||
|
||||
const DEBOUNCE_DELAY = 500;
|
||||
@@ -76,6 +76,84 @@ function fadeWidgetTextColor(widget, fromColor, toColor, duration) {
|
||||
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({
|
||||
name: "LoraManager.WorkflowRegistry",
|
||||
|
||||
@@ -309,6 +387,60 @@ app.registerExtension({
|
||||
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 ----
|
||||
const widgetIndex = node.widgets.indexOf(targetWidget);
|
||||
let newValue = value;
|
||||
|
||||
Reference in New Issue
Block a user