Compare commits

..

54 Commits

Author SHA1 Message Date
Will Miao 2228627ff4 chore(release): bump version to v1.2.0 2026-07-31 21:25:38 +08:00
Will Miao 4c647ad9c8 fix(update): throttle nightly update badge to once per day 2026-07-31 21:18:58 +08:00
Will Miao 8ca3e6c33f fix(ui): guard marquee bulk-mode entry against click jitter and stale drag state 2026-07-31 18:40:14 +08:00
Will Miao dd6bdbf297 fix(update): persist update_channel via settings.json instead of hasGit
After b464fdc3 (preserve .git on release switch), the hasGit-based
channel detection is unreliable — .git now exists for both release
and nightly installs, so page refresh always reset the channel.

- Add _resolveChannelFromSettings() with migration heuristic:
  !hasGit → release (ZIP), detached HEAD → release (on tag),
  on branch → nightly. Uses gitInfo.branch from check-updates.
- Persist resolved channel to settings.json on first load
  (one-time migration) and on explicit switchChannel.
- Add update_channel validation (release|nightly) in backend
  update_settings handler.
- Remove hasGit-based guessing from initialize(); defer to
  checkForUpdates where full gitInfo is available.
- Channel resolution runs before checkForUpdates early-returns
  to avoid null channelMode on reload-within-interval.

Tests: 361 passed.
2026-07-31 13:23:54 +08:00
Will Miao b47dde87e4 fix(settings): suppress error toasts when optional model roots are empty 2026-07-31 10:07:52 +08:00
Will Miao 99e65cccd8 fix(update): downgrade settings backup/restore logs from INFO to DEBUG 2026-07-30 20:32:00 +08:00
Will Miao 3bdacb8f46 fix(test): update release channel git test to mock _perform_git_update instead of _download_and_replace_zip 2026-07-30 18:35:43 +08:00
Will Miao b4f9c224d3 fix(example-images): move multi→single-library consolidation to startup, eliminate per-request os.listdir()
Move reverse-migration logic from get_model_folder() (hot path, called on
every metadata/example-images request) to ExampleImagesMigration, where it
runs once at startup.  On network storage this was causing 22-38s delays
per LoRA card click.

Additionally optimize prune_stale_example_images() to read the directory
listing once instead of per image entry (O(N*M) → O(M)).  Also reorder
consolidation checks so regex filters run before filesystem stat calls.
2026-07-30 18:11:40 +08:00
Will Miao 5ec0399c81 fix(i18n): remove redundant 'preserved' sentence from release channel message, sync all 10 locales 2026-07-30 16:38:04 +08:00
Will Miao b464fdc333 fix(update): preserve .git on release channel switch, use git checkout tag
Previously, switching to the release channel would delete .git/ and
fall back to a ZIP download. This broke update.bat, manual git
commands, and CM git-based update detection.

Now the release path uses git checkout <latest-tag> when .git exists,
and only falls back to ZIP when .git is absent (CM CNR installs).
.git is never deleted - the ZIP→nightly path remains a one-way
upgrade via _init_git_repo.

Also updates locale strings (en, zh-CN, zh-TW, ja) to remove the
now-inaccurate "remove the Git repository" wording.
2026-07-29 21:23:39 +08:00
Will Miao 53825500db fix(update): add staging protection to switch_channel
switch_channel has three destructive code paths (git reset + clean,
git init + checkout --force, and rmtree + ZIP replace) that were
missing the _stage_preserved_items / _restore_preserved_items safety
net already applied to perform_update.

Wrap the channel-specific logic in a try/finally so preserved user
data (settings.json, civitai/, cache/, etc.) is physically moved
outside plugin_root before any git operation and always restored.
2026-07-29 20:41:36 +08:00
Will Miao f2ac790752 fix(update): stage preserved items outside repo before git/ZIP update
Move settings.json, civitai/, wildcards/, backups/, stats/, logs/,
cache/, and model_cache/ to a temp directory before git reset/clean
or ZIP replacement, then restore them in a try/finally block.

This prevents data loss on Windows where git clean -e exclusion
patterns can fail due to path-separator mismatches or where file
locks (open SQLite/log handles) cause the restore step to be skipped
on failure.

Also unifies three hardcoded skip lists (_clean_plugin_folder,
skip_items, skip_tracked) to derive from the single _PRESERVE_DIRS
constant, fixing drift where logs/ was missing from the ZIP path.
2026-07-29 19:49:50 +08:00
Will Miao 0d8805cdee fix(recipes): update cards in-place after LoRA download, preventing scroll reset 2026-07-29 11:35:28 +08:00
pixelpaws 656e24ac9b Merge pull request #1044 from d1udiu/fix-filter
fix(filters): prevent search query from being persisted in localStorage
2026-07-29 11:30:40 +08:00
d1udiu 6718b37403 fix(filters): prevent search query from being persisted in localStorage 2026-07-29 10:12:42 +08:00
Will Miao c9e5e784fc fix(metadata-overwrite): use sentinel default for clip_skip to accept wired 0 2026-07-28 23:13:00 +08:00
Will Miao f92f958682 fix(SaveImageLM): correct scheduler mapping and deduplicate sampler map
- Fix incorrect mapping: "normal" -> "Normal" (was "Simple")
- Replace inline sampler_mapping with CIVITAI_SAMPLER_MAP reference
  to eliminate duplicate definition
2026-07-28 21:39:09 +08:00
Will Miao f63fab0676 fix(cache): deduplicate model entries on add and reconcile to prevent duplicate cards (#1041) 2026-07-28 20:44:57 +08:00
Will Miao cfc4903c0c fix(update): read ahead_by from GitHub compare API when status is ahead/diverged
The compare API URL format compare/{local_hash}...main returns
status='ahead' when main is ahead of the local commit. The count is
in the ahead_by field, not behind_by. The old code only read behind_by
which is always 0 in this case, causing the UI to show 'Up to date'
when actually several commits behind.

Also handle status='diverged' (both sides have unique commits) by
reading ahead_by for the remote-ahead count.

Frontend adds a hash comparison fallback: if behind_by is 0 but local
and remote commit hashes differ, show 'Behind main' instead of the
incorrect 'Up to date'.

Tests: _AheadCompareDownloader and _DivergedCompareDownloader mocks
for the two status paths.
2026-07-28 17:47:38 +08:00
Will Miao a527a847fe fix(download): route UNet/diffusion model downloads to unet roots in location step
When downloading a diffusion model (UNet) from the checkpoints page, the
download modal's location step always showed checkpoint roots and paths.
Now the modal detects the file subtype and switches to unet_roots endpoint,
default_unet_root key, and 'unet' path template.
2026-07-28 17:21:12 +08:00
Will Miao 91b0bf8933 fix(download_queue): deduplicate download_history rows before creating unique index (#1041) 2026-07-27 21:36:58 +08:00
Will Miao 66d1c96783 feat(update): add Release/Nightly channel switching
- Add POST /api/lm/switch-channel endpoint with git init / ZIP fallback
- Add _backup_git/_restore_git helpers with safe rollback
- Version-info endpoint now returns has_git flag for auto-detection
- Check-updates always returns releases (changelog) regardless of channel
- Nightly mode shows 'N commits behind main' with commit hash and date
- View on GitHub link points to /commits/main in nightly mode
- Channel toggle UI with pill-style buttons in update modal
- Confirmation dialog with Esc / backdrop-dismiss support
- Channel derived from has_git on every page load, no localStorage
- i18n: 11 new keys translated across 9 non-English locales
- CSS: unified card-style sections in _base.css
- Tests: 8 new tests covering switch-channel, nightly response, init_git_repo
2026-07-27 20:27:05 +08:00
Will Miao 986128076e fix(widget): guard setValue against non-array input to prevent workflow load crash (#1039) 2026-07-26 21:54:37 +08:00
Will Miao 1de0a53241 feat(grouping): version-group library cards by HuggingFace repo for non-Civitai sources (#1040) 2026-07-26 21:46:55 +08:00
Will Miao 0ec7eaf606 fix(wildcards): resolve weighted N::value syntax inside wildcard YAML lists (#1039) 2026-07-26 18:33:31 +08:00
Will Miao d9fcb0e92b fix(filter): preserve search term through filter apply/clear operations 2026-07-26 16:49:57 +08:00
Will Miao f49b4ba4db fix(metadata-overwrite): rename 'checkpoint' input to 'model' 2026-07-26 10:59:38 +08:00
Will Miao 84e708328b fix: correct return_types propagation to GenericNodeExtractor
Two bugs prevented type-signature-based fallback from working:

- metadata_hook.py used getattr(obj.__class__, 'RETURN_TYPES')
  which fails when _async_map_node_over_list is called with
  a class (not instance) — obj.__class__ is the metaclass
  'type', which has no RETURN_TYPES. Fixed: getattr(obj, ...).

- metadata_registry.py used type(extractor) is GenericNodeExtractor
  to dispatch return_types. NODE_EXTRACTORS stores class
  references, not instances; type(Class) is always 'type',
  never the class. Fixed: extractor is GenericNodeExtractor.
2026-07-26 10:34:20 +08:00
Will Miao 125bed3f09 feat: add Metadata Overwrite node for manual generation params override 2026-07-26 08:50:21 +08:00
Will Miao 077e70169d feat: add type-signature-based fallback for unregistered nodes
GenericNodeExtractor (previously a no-op) now inspects
RETURN_TYPES to detect MODEL loaders and CONDITIONING
encoders in nodes not registered in NODE_EXTRACTORS.

- Propagate return_types from the hook layer through the
  registry to GenericNodeExtractor.extract() and update().
- MODEL detection: scan ckpt_name/unet_name/model_path/
  model_name/gguf_name fields, validate by extension.
- CONDITIONING detection: scan text/clip_l/t5xxl/prompt
  fields, store prompt text and conditioning tensor.
- _fill_missing_metadata also checks node_cache, so
  GenericNodeExtractor-handled nodes survive cache.
2026-07-25 22:14:51 +08:00
Will Miao e6dc169a05 feat: add meta hints user marks for metadata heuristic override
Users can now right-click nodes and assign meta hints
(primary_model, primary_sampler, positive_prompt,
negative_prompt) to override the metadata processor's
heuristic inference.

- Store extra_data from the API request so workflow node
  properties (including lm_marker_role) are accessible
  during metadata processing.
- _get_user_marks scans extra_data.extra_pnginfo.workflow
  for meta_* marks, falling back to prompt.original_prompt.
- extract_generation_params checks user marks before
  heuristic inference for sampler, model, and prompts.
- Warn on duplicate marks or invalid marked nodes.
2026-07-25 22:13:52 +08:00
Will Miao f34c02756d fix(recipes): eliminate O(n) fuzzy search fallback over 42k+ recipes
Drop the SequenceMatcher-based fuzzy_match fallback that froze the server
when FTS returned empty results. FTS now returns empty set for zero results
(no fallback), and when the index is not yet ready, search returns empty
rather than scanning all items in Python.
2026-07-25 17:34:37 +08:00
Will Miao 1e4c315481 fix(ModelModal): respect civitai_host setting for creator profile link 2026-07-25 07:15:12 +08:00
Will Miao a8283a0d00 fix(SaveImageLM): clarify embed_workflow tooltip — explains drag-and-drop workflow restoration
The previous tooltip was misleading: users thought workflow embedding was
automatic. New wording explains this opt-in flag stores the complete
workflow inside images, allowing one-click restoration via drag-and-drop.
PNG and WebP only.
2026-07-24 19:53:59 +08:00
Will Miao 55896669fc feat(SaveImageLM): expose webp_method and jpeg_subsampling as conditional node inputs
Add two new optional parameters to the Save Image node:

- webp_method (INT, 0-6, default 6): Controls WebP compression level.
  0=fastest/largest, 6=slowest/smallest. Previously hardcoded to 0.
- jpeg_subsampling (INT, 0-2, default 0): Controls JPEG chroma
  subsampling. 0=4:4:4 (best quality), 1=4:2:2, 2=4:2:0.

Frontend JS extension hides/disables each parameter when the
selected file_format doesn't apply (e.g., webp_method is hidden
when saving as PNG or JPEG). 7 new tests cover parameter plumbing
and default consistency across INPUT_TYPES, save_images(), and
process_image().
2026-07-24 19:32:51 +08:00
Will Miao e341e0b9d2 fix(test): update parameters assertion to include Version: ComfyUI after metadata format upgrade 2026-07-24 18:29:07 +08:00
Will Miao e6538c83bb fix(metadata): restore sha256 after hydrate_model_data to prevent KeyError in CivitAI fetch
hydrate_model_data replaces model_data with .metadata.json content which
may lack sha256 (corrupted file, concurrent write, etc.). Restore the
cached sha256 after hydration and persist the fix back to disk so
subsequent lookups don't hit the same error.

Also improve error log to include file_path for debugging.
2026-07-24 12:07:18 +08:00
Will Miao 92e1285ea5 feat(SaveImageLM): upgrade metadata output to A1111/Civitai-compatible format
- Replace plain-text Lora hashes with Hashes JSON dict matching A1111 convention
- Add Civitai resources JSON array with AIR URNs for direct model version linking
- Add Clip skip, Version: ComfyUI fields to generation params line
- Build AIR strings from local scanner cache (no API calls needed)
- Add complete sampler name mapping (CIVITAI_SAMPLER_MAP) and base model → AIR slug mapping (BASE_MODEL_AIR_SLUG) sourced from civitai ecosystem constants
- Remove lora text prepending from prompt line; LoRA info now in structured JSON sections
2026-07-24 06:20:28 +08:00
Will Miao 2aabd1d90e fix(ai): use json_schema instead of json_object for broader provider compatibility (#1033)
LM Studio and some other OpenAI-compatible servers reject
response_format=json_object but accept json_schema. Switch to the
equivalent json_schema format and add a fallback that retries
without response_format when the provider rejects the format type.
2026-07-23 09:17:29 +08:00
Will Miao 7b8b778f83 fix(widget): restore strength drag on lora entries and header
widget.value is a getter/setter that returns a new array on every read,
so handleStrengthDrag with updateWidget=false mutated a discarded copy.
Introduce __dragActive flag to suppress renderLoras in setValue during
drag, allowing mutations to persist through widget.value without
destroying the DOM. Use try-finally to guarantee flag cleanup.
2026-07-23 08:31:34 +08:00
Will Miao 7c8dc57d55 fix(security): use abspath instead of realpath in containment checks to support symlinks (#1028) 2026-07-23 07:06:41 +08:00
Will Miao fe95fae5f2 fix(workflow): include Create Hook LoRA in lora_code_update handler 2026-07-22 11:40:56 +08:00
Will Miao ce8a95abf7 chore(release): bump version to v1.1.9 2026-07-21 22:22:39 +08:00
Will Miao c8e7e543d6 fix(api): remove overstrict model type validation in getApiEndpoints
The validation in getApiEndpoints threw for page types not in
MODEL_TYPES (e.g. 'recipes'), crashing the recipes page initialization
when FilterManager calls it via createBaseModelTags(). The throw was
synchronous and outside the fetch().catch() chain, causing an uncaught
promise rejection that aborted the entire app initialization.

getApiEndpoints is a URL builder -- validation belongs to callers that
need strict type checking (they already use isValidModelType()). For
non-model-type pages like recipes, the generated URLs are correct
(the backend does have /api/lm/recipes/* routes).

Fixes regression from f53f859a (feat(filter): add debounced tag search).
2026-07-21 22:09:30 +08:00
Will Miao a9dbb15ffa fix(create_hook_lora): lazy import comfy.hooks/comfy.utils to fix CI pipeline (#744) 2026-07-21 18:44:04 +08:00
Will Miao cf64043f7d fix(security): add library root containment check for delete/move/rename operations (#1028) 2026-07-21 15:23:38 +08:00
Will Miao ccaff92c18 fix(nodes): register Create Hook LoRA node in workflow target registries 2026-07-21 14:56:39 +08:00
Will Miao 585b5c922a feat(nodes): add Create Hook LoRA (LoraManager) node for multi-LoRA hook pipelines 2026-07-21 09:44:28 +08:00
Will Miao ea80c2224c fix(download): prevent path traversal in download template resolution (#1028) 2026-07-20 21:08:43 +08:00
willmiao 8b0f56c1a6 docs: auto-update supporters list in README 2026-07-20 12:42:20 +00:00
Will Miao 8022d12f03 chore(release): bump version to v1.1.8 2026-07-20 20:42:04 +08:00
Will Miao 3939f7f91b chore: Update lora manager basic example workflow 2026-07-20 20:20:35 +08:00
Will Miao aebf2e37dd fix(filter): apply preset on full tile click and suppress i18n double-translate warnings
- Move preset apply handler from span.preset-name to div.filter-preset so
  clicking anywhere on the tile triggers the preset, not just the label text.
- Add whitespace heuristic in showToast() to skip translate() for plain
  messages that are already translated at the call site. This prevents
  i18next from logging 'Translation key not found' for pre-translated
  strings like 'Preset "name" applied'.
2026-07-20 17:57:14 +08:00
Will Miao f53f859a71 feat(filter): add debounced tag search with backend search-tags endpoint 2026-07-20 17:37:47 +08:00
93 changed files with 5345 additions and 935 deletions
+7 -1
View File
@@ -137,7 +137,13 @@ npm run test:coverage # Generate coverage report
- Dual mode: ComfyUI plugin (folder_paths) vs standalone (settings.json) - Dual mode: ComfyUI plugin (folder_paths) vs standalone (settings.json)
- Detection: `os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"` - Detection: `os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"`
- Run `python scripts/sync_translation_keys.py` after adding UI strings to `locales/en.json` - Run `python scripts/sync_translation_keys.py` after adding UI strings to `locales/en.json`
- Symlinks require normalized paths - Symlinks require normalized paths.
**Business paths vs real paths**: All stored paths and operation routing use the
original paths as they appear under configured model roots — symlinks are NOT
resolved. `os.path.realpath` is only for scanner dedup and the symlink cache.
Any path passed to `os.remove`/`os.rename`/`shutil.move` or validated by a
containment check MUST use the business path (i.e. `os.path.abspath`, not
`realpath`).
## Git / Commit Messages ## Git / Commit Messages
+2 -2
View File
File diff suppressed because one or more lines are too long
+10
View File
@@ -17,6 +17,8 @@ try: # pragma: no cover - import fallback for pytest collection
from .py.nodes.lora_cycler import LoraCyclerLM from .py.nodes.lora_cycler import LoraCyclerLM
from .py.nodes.lora_info import LoraInfoLM from .py.nodes.lora_info import LoraInfoLM
from .py.nodes.lora_syntax_to_path import LoraSyntaxToPath from .py.nodes.lora_syntax_to_path import LoraSyntaxToPath
from .py.nodes.create_hook_lora import CreateHookLoraLM
from .py.nodes.metadata_overwrite import MetadataOverwriteLM
from .py.metadata_collector import init as init_metadata_collector from .py.metadata_collector import init as init_metadata_collector
except ( except (
ImportError ImportError
@@ -62,6 +64,12 @@ except (
LoraSyntaxToPath = importlib.import_module( LoraSyntaxToPath = importlib.import_module(
"py.nodes.lora_syntax_to_path" "py.nodes.lora_syntax_to_path"
).LoraSyntaxToPath ).LoraSyntaxToPath
CreateHookLoraLM = importlib.import_module(
"py.nodes.create_hook_lora"
).CreateHookLoraLM
MetadataOverwriteLM = importlib.import_module(
"py.nodes.metadata_overwrite"
).MetadataOverwriteLM
init_metadata_collector = importlib.import_module("py.metadata_collector").init init_metadata_collector = importlib.import_module("py.metadata_collector").init
NODE_CLASS_MAPPINGS = { NODE_CLASS_MAPPINGS = {
@@ -83,6 +91,8 @@ NODE_CLASS_MAPPINGS = {
LoraCyclerLM.NAME: LoraCyclerLM, LoraCyclerLM.NAME: LoraCyclerLM,
LoraInfoLM.NAME: LoraInfoLM, LoraInfoLM.NAME: LoraInfoLM,
LoraSyntaxToPath.NAME: LoraSyntaxToPath, LoraSyntaxToPath.NAME: LoraSyntaxToPath,
CreateHookLoraLM.NAME: CreateHookLoraLM,
MetadataOverwriteLM.NAME: MetadataOverwriteLM,
} }
WEB_DIRECTORY = "./web/comfyui" WEB_DIRECTORY = "./web/comfyui"
+374 -339
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+19 -1
View File
@@ -233,7 +233,7 @@
"presetNamePlaceholder": "Voreinstellungsname...", "presetNamePlaceholder": "Voreinstellungsname...",
"baseModel": "Basis-Modell", "baseModel": "Basis-Modell",
"baseModelSearchPlaceholder": "Basismodelle durchsuchen...", "baseModelSearchPlaceholder": "Basismodelle durchsuchen...",
"modelTags": "Tags (Top 20)", "modelTags": "Tags",
"modelTypes": "Modelltypen", "modelTypes": "Modelltypen",
"license": "Lizenz", "license": "Lizenz",
"noCreditRequired": "Kein Credit erforderlich", "noCreditRequired": "Kein Credit erforderlich",
@@ -241,6 +241,8 @@
"allowSellingGeneratedContentTooltip": "Verkauf generierter Bilder erlauben", "allowSellingGeneratedContentTooltip": "Verkauf generierter Bilder erlauben",
"noCreditRequiredTooltip": "Modell ohne Nennung des Erstellers verwenden", "noCreditRequiredTooltip": "Modell ohne Nennung des Erstellers verwenden",
"noTags": "Keine Tags", "noTags": "Keine Tags",
"tagSearchPlaceholder": "Tags durchsuchen...",
"noTagMatches": "Keine Tags entsprechen der aktuellen Suche.",
"autoTags": "Auto-Tags", "autoTags": "Auto-Tags",
"noBaseModelMatches": "Keine Basismodelle entsprechen der aktuellen Suche.", "noBaseModelMatches": "Keine Basismodelle entsprechen der aktuellen Suche.",
"clearAll": "Alle Filter löschen", "clearAll": "Alle Filter löschen",
@@ -1546,6 +1548,7 @@
"empty": "Noch keine Versionshistorie für dieses Modell vorhanden.", "empty": "Noch keine Versionshistorie für dieses Modell vorhanden.",
"error": "Versionen konnten nicht geladen werden.", "error": "Versionen konnten nicht geladen werden.",
"missingModelId": "Für dieses Modell ist keine Civitai-Model-ID vorhanden.", "missingModelId": "Für dieses Modell ist keine Civitai-Model-ID vorhanden.",
"hfGroupInfo": "Dies ist eine HuggingFace-Modellgruppe. Öffnen Sie die Bibliothek, um alle Versionen im Raster zu sehen.",
"confirm": { "confirm": {
"delete": "Diese Version aus Ihrer Bibliothek löschen?" "delete": "Diese Version aus Ihrer Bibliothek löschen?"
}, },
@@ -1749,6 +1752,12 @@
"checkingMessage": "Bitte warten Sie, während wir nach der neuesten Version suchen.", "checkingMessage": "Bitte warten Sie, während wir nach der neuesten Version suchen.",
"showNotifications": "Update-Benachrichtigungen anzeigen", "showNotifications": "Update-Benachrichtigungen anzeigen",
"latestBadge": "Neueste", "latestBadge": "Neueste",
"latestMain": "Main-Branch",
"channel": "Update-Kanal",
"channels": {
"release": "Release",
"nightly": "Nightly"
},
"updateProgress": { "updateProgress": {
"preparing": "Update wird vorbereitet...", "preparing": "Update wird vorbereitet...",
"installing": "Update wird installiert...", "installing": "Update wird installiert...",
@@ -1769,6 +1778,15 @@
"warning": "Warnung: Nightly Builds können experimentelle Funktionen enthalten und könnten instabil sein.", "warning": "Warnung: Nightly Builds können experimentelle Funktionen enthalten und könnten instabil sein.",
"enable": "Nightly Updates aktivieren" "enable": "Nightly Updates aktivieren"
}, },
"channelSwitch": {
"nightlyTitle": "Zu Nightly-Kanal wechseln",
"nightlyMessage": "Der Wechsel zu Nightly initialisiert ein Git-Repository und verfolgt die neuesten Commits des main-Branches. Updates sind häufiger, können aber instabil sein. Sie können jederzeit zu Release zurückwechseln.",
"releaseTitle": "Zu Release-Kanal wechseln",
"releaseMessage": "Der Wechsel zu Release checkt den neuesten stabilen Versions-Tag aus. Sie können jederzeit zu Nightly zurückwechseln.",
"switching": "Wechsle zu {channel}-Kanal...",
"completed": "Erfolgreich zu {channel}-Kanal gewechselt",
"failed": "Kanalwechsel fehlgeschlagen"
},
"banners": { "banners": {
"recent": "Neueste Mitteilungen", "recent": "Neueste Mitteilungen",
"empty": "Keine aktuellen Banner verfügbar.", "empty": "Keine aktuellen Banner verfügbar.",
+19 -1
View File
@@ -233,7 +233,7 @@
"presetNamePlaceholder": "Preset name...", "presetNamePlaceholder": "Preset name...",
"baseModel": "Base Model", "baseModel": "Base Model",
"baseModelSearchPlaceholder": "Search base models...", "baseModelSearchPlaceholder": "Search base models...",
"modelTags": "Tags (Top 20)", "modelTags": "Tags",
"modelTypes": "Model Types", "modelTypes": "Model Types",
"license": "License", "license": "License",
"noCreditRequired": "No Credit Required", "noCreditRequired": "No Credit Required",
@@ -241,6 +241,8 @@
"allowSellingGeneratedContentTooltip": "Allow selling generated images", "allowSellingGeneratedContentTooltip": "Allow selling generated images",
"noCreditRequiredTooltip": "Use the model without crediting the creator", "noCreditRequiredTooltip": "Use the model without crediting the creator",
"noTags": "No tags", "noTags": "No tags",
"tagSearchPlaceholder": "Search tags...",
"noTagMatches": "No tags match the current search.",
"autoTags": "Auto Tags", "autoTags": "Auto Tags",
"noBaseModelMatches": "No base models match the current search.", "noBaseModelMatches": "No base models match the current search.",
"clearAll": "Clear All Filters", "clearAll": "Clear All Filters",
@@ -1546,6 +1548,7 @@
"empty": "No version history available for this model yet.", "empty": "No version history available for this model yet.",
"error": "Failed to load versions.", "error": "Failed to load versions.",
"missingModelId": "This model is missing a Civitai model id.", "missingModelId": "This model is missing a Civitai model id.",
"hfGroupInfo": "This is a HuggingFace model group. Open the library to see all versions in the grid.",
"confirm": { "confirm": {
"delete": "Delete this version from your library?" "delete": "Delete this version from your library?"
}, },
@@ -1749,6 +1752,12 @@
"checkingMessage": "Please wait while we check for the latest version.", "checkingMessage": "Please wait while we check for the latest version.",
"showNotifications": "Show update notifications", "showNotifications": "Show update notifications",
"latestBadge": "Latest", "latestBadge": "Latest",
"latestMain": "Latest main",
"channel": "Update Channel",
"channels": {
"release": "Release",
"nightly": "Nightly"
},
"updateProgress": { "updateProgress": {
"preparing": "Preparing update...", "preparing": "Preparing update...",
"installing": "Installing update...", "installing": "Installing update...",
@@ -1769,6 +1778,15 @@
"warning": "Warning: Nightly builds may contain experimental features and could be unstable.", "warning": "Warning: Nightly builds may contain experimental features and could be unstable.",
"enable": "Enable Nightly Updates" "enable": "Enable Nightly Updates"
}, },
"channelSwitch": {
"nightlyTitle": "Switch to Nightly Channel",
"nightlyMessage": "Switching to Nightly will initialize a Git repository and track the latest main branch commits. Updates will be more frequent but may be unstable. You can switch back to Release at any time.",
"releaseTitle": "Switch to Release Channel",
"releaseMessage": "Switching to Release will checkout the latest stable release tag. You can switch back to Nightly at any time.",
"switching": "Switching to {channel} channel...",
"completed": "Successfully switched to {channel} channel",
"failed": "Failed to switch channel"
},
"banners": { "banners": {
"recent": "Recent messages", "recent": "Recent messages",
"empty": "No recent banners yet.", "empty": "No recent banners yet.",
+20 -2
View File
@@ -233,7 +233,7 @@
"presetNamePlaceholder": "Nombre del preajuste...", "presetNamePlaceholder": "Nombre del preajuste...",
"baseModel": "Modelo base", "baseModel": "Modelo base",
"baseModelSearchPlaceholder": "Buscar modelos base...", "baseModelSearchPlaceholder": "Buscar modelos base...",
"modelTags": "Etiquetas (Top 20)", "modelTags": "Etiquetas",
"modelTypes": "Tipos de modelos", "modelTypes": "Tipos de modelos",
"license": "Licencia", "license": "Licencia",
"noCreditRequired": "Sin crédito requerido", "noCreditRequired": "Sin crédito requerido",
@@ -241,6 +241,8 @@
"allowSellingGeneratedContentTooltip": "Permitir la venta de imágenes generadas", "allowSellingGeneratedContentTooltip": "Permitir la venta de imágenes generadas",
"noCreditRequiredTooltip": "Usar el modelo sin atribuir al creador", "noCreditRequiredTooltip": "Usar el modelo sin atribuir al creador",
"noTags": "Sin etiquetas", "noTags": "Sin etiquetas",
"tagSearchPlaceholder": "Buscar etiquetas...",
"noTagMatches": "Ninguna etiqueta coincide con la búsqueda actual.",
"autoTags": "Etiquetas automáticas", "autoTags": "Etiquetas automáticas",
"noBaseModelMatches": "Ningún modelo base coincide con la búsqueda actual.", "noBaseModelMatches": "Ningún modelo base coincide con la búsqueda actual.",
"clearAll": "Limpiar todos los filtros", "clearAll": "Limpiar todos los filtros",
@@ -1546,6 +1548,7 @@
"empty": "Aún no hay historial de versiones para este modelo.", "empty": "Aún no hay historial de versiones para este modelo.",
"error": "No se pudieron cargar las versiones.", "error": "No se pudieron cargar las versiones.",
"missingModelId": "Este modelo no tiene un ID de modelo de Civitai.", "missingModelId": "Este modelo no tiene un ID de modelo de Civitai.",
"hfGroupInfo": "Este es un grupo de modelos de HuggingFace. Abra la biblioteca para ver todas las versiones en la cuadrícula.",
"confirm": { "confirm": {
"delete": "¿Eliminar esta versión de tu biblioteca?" "delete": "¿Eliminar esta versión de tu biblioteca?"
}, },
@@ -1748,7 +1751,13 @@
"checkingUpdates": "Comprobando actualizaciones...", "checkingUpdates": "Comprobando actualizaciones...",
"checkingMessage": "Por favor espera mientras comprobamos la última versión.", "checkingMessage": "Por favor espera mientras comprobamos la última versión.",
"showNotifications": "Mostrar notificaciones de actualización", "showNotifications": "Mostrar notificaciones de actualización",
"latestBadge": "Último", "latestBadge": "Última",
"latestMain": "Rama main",
"channel": "Canal de actualizacion",
"channels": {
"release": "Release",
"nightly": "Nightly"
},
"updateProgress": { "updateProgress": {
"preparing": "Preparando actualización...", "preparing": "Preparando actualización...",
"installing": "Instalando actualización...", "installing": "Instalando actualización...",
@@ -1769,6 +1778,15 @@
"warning": "Advertencia: Las compilaciones nocturnas pueden contener características experimentales y podrían ser inestables.", "warning": "Advertencia: Las compilaciones nocturnas pueden contener características experimentales y podrían ser inestables.",
"enable": "Habilitar actualizaciones nocturnas" "enable": "Habilitar actualizaciones nocturnas"
}, },
"channelSwitch": {
"nightlyTitle": "Cambiar a canal Nightly",
"nightlyMessage": "Cambiar a Nightly inicializara un repositorio Git y seguira los ultimos commits de la rama main. Las actualizaciones son mas frecuentes pero pueden ser inestables. Puede volver a Release en cualquier momento.",
"releaseTitle": "Cambiar a canal Release",
"releaseMessage": "Cambiar a Release hara checkout de la ultima etiqueta de version estable. Puede volver a Nightly en cualquier momento.",
"switching": "Cambiando a canal {channel}...",
"completed": "Cambio a canal {channel} exitoso",
"failed": "Error al cambiar de canal"
},
"banners": { "banners": {
"recent": "Notificaciones recientes", "recent": "Notificaciones recientes",
"empty": "No hay banners recientes.", "empty": "No hay banners recientes.",
+20 -2
View File
@@ -233,7 +233,7 @@
"presetNamePlaceholder": "Nom du préréglage...", "presetNamePlaceholder": "Nom du préréglage...",
"baseModel": "Modèle de base", "baseModel": "Modèle de base",
"baseModelSearchPlaceholder": "Rechercher des modèles de base...", "baseModelSearchPlaceholder": "Rechercher des modèles de base...",
"modelTags": "Tags (Top 20)", "modelTags": "Tags",
"modelTypes": "Types de modèles", "modelTypes": "Types de modèles",
"license": "Licence", "license": "Licence",
"noCreditRequired": "Crédit non requis", "noCreditRequired": "Crédit non requis",
@@ -241,6 +241,8 @@
"allowSellingGeneratedContentTooltip": "Autoriser la vente d\"images générées", "allowSellingGeneratedContentTooltip": "Autoriser la vente d\"images générées",
"noCreditRequiredTooltip": "Utiliser le modèle sans créditer le créateur", "noCreditRequiredTooltip": "Utiliser le modèle sans créditer le créateur",
"noTags": "Aucun tag", "noTags": "Aucun tag",
"tagSearchPlaceholder": "Rechercher des tags...",
"noTagMatches": "Aucun tag ne correspond à la recherche actuelle.",
"autoTags": "Auto-Tags", "autoTags": "Auto-Tags",
"noBaseModelMatches": "Aucun modèle de base ne correspond à la recherche actuelle.", "noBaseModelMatches": "Aucun modèle de base ne correspond à la recherche actuelle.",
"clearAll": "Effacer tous les filtres", "clearAll": "Effacer tous les filtres",
@@ -1546,6 +1548,7 @@
"empty": "Aucun historique de versions n'est disponible pour ce modèle pour le moment.", "empty": "Aucun historique de versions n'est disponible pour ce modèle pour le moment.",
"error": "Échec du chargement des versions.", "error": "Échec du chargement des versions.",
"missingModelId": "Ce modèle ne possède pas d'identifiant de modèle Civitai.", "missingModelId": "Ce modèle ne possède pas d'identifiant de modèle Civitai.",
"hfGroupInfo": "Ceci est un groupe de modèles HuggingFace. Ouvrez la bibliothèque pour voir toutes les versions dans la grille.",
"confirm": { "confirm": {
"delete": "Supprimer cette version de votre bibliothèque ?" "delete": "Supprimer cette version de votre bibliothèque ?"
}, },
@@ -1748,7 +1751,13 @@
"checkingUpdates": "Vérification des mises à jour...", "checkingUpdates": "Vérification des mises à jour...",
"checkingMessage": "Veuillez patienter pendant la vérification de la dernière version.", "checkingMessage": "Veuillez patienter pendant la vérification de la dernière version.",
"showNotifications": "Afficher les notifications de mise à jour", "showNotifications": "Afficher les notifications de mise à jour",
"latestBadge": "Dernier", "latestBadge": "Dernière",
"latestMain": "Branche main",
"channel": "Canal de mise a jour",
"channels": {
"release": "Release",
"nightly": "Nightly"
},
"updateProgress": { "updateProgress": {
"preparing": "Préparation de la mise à jour...", "preparing": "Préparation de la mise à jour...",
"installing": "Installation de la mise à jour...", "installing": "Installation de la mise à jour...",
@@ -1769,6 +1778,15 @@
"warning": "Attention : Les versions nightly peuvent contenir des fonctionnalités expérimentales et être instables.", "warning": "Attention : Les versions nightly peuvent contenir des fonctionnalités expérimentales et être instables.",
"enable": "Activer les mises à jour nightly" "enable": "Activer les mises à jour nightly"
}, },
"channelSwitch": {
"nightlyTitle": "Passer au canal Nightly",
"nightlyMessage": "Passer a Nightly initialisera un depot Git et suivra les derniers commits de la branche main. Les mises a jour sont plus frequentes mais peuvent etre instables. Vous pouvez revenir a Release a tout moment.",
"releaseTitle": "Passer au canal Release",
"releaseMessage": "Passer a Release passera au dernier tag de version stable. Vous pouvez revenir a Nightly a tout moment.",
"switching": "Passage au canal {channel}...",
"completed": "Basculement vers le canal {channel} reussi",
"failed": "Echec du changement de canal"
},
"banners": { "banners": {
"recent": "Messages récents", "recent": "Messages récents",
"empty": "Aucune bannière récente.", "empty": "Aucune bannière récente.",
+20 -2
View File
@@ -233,7 +233,7 @@
"presetNamePlaceholder": "שם קביעה מראש...", "presetNamePlaceholder": "שם קביעה מראש...",
"baseModel": "מודל בסיס", "baseModel": "מודל בסיס",
"baseModelSearchPlaceholder": "חפש מודלי בסיס...", "baseModelSearchPlaceholder": "חפש מודלי בסיס...",
"modelTags": "תגיות (20 המובילות)", "modelTags": "תגיות",
"modelTypes": "סוגי מודלים", "modelTypes": "סוגי מודלים",
"license": "רישיון", "license": "רישיון",
"noCreditRequired": "ללא קרדיט נדרש", "noCreditRequired": "ללא קרדיט נדרש",
@@ -241,6 +241,8 @@
"allowSellingGeneratedContentTooltip": "אפשר מכירת תמונות שנוצרו", "allowSellingGeneratedContentTooltip": "אפשר מכירת תמונות שנוצרו",
"noCreditRequiredTooltip": "שימוש במודל ללא מתן קרדיט ליוצר", "noCreditRequiredTooltip": "שימוש במודל ללא מתן קרדיט ליוצר",
"noTags": "ללא תגיות", "noTags": "ללא תגיות",
"tagSearchPlaceholder": "חיפוש תגיות...",
"noTagMatches": "אין תגיות שתואמות את החיפוש הנוכחי.",
"autoTags": "תגיות אוטומטיות", "autoTags": "תגיות אוטומטיות",
"noBaseModelMatches": "אין מודלי בסיס התואמים לחיפוש הנוכחי.", "noBaseModelMatches": "אין מודלי בסיס התואמים לחיפוש הנוכחי.",
"clearAll": "נקה את כל המסננים", "clearAll": "נקה את כל המסננים",
@@ -1546,6 +1548,7 @@
"empty": "אין עדיין היסטוריית גרסאות למודל זה.", "empty": "אין עדיין היסטוריית גרסאות למודל זה.",
"error": "טעינת הגרסאות נכשלה.", "error": "טעינת הגרסאות נכשלה.",
"missingModelId": "למודל זה אין מזהה מודל של Civitai.", "missingModelId": "למודל זה אין מזהה מודל של Civitai.",
"hfGroupInfo": "זוהי קבוצת דגמים של HuggingFace. פתח את הספרייה כדי לראות את כל הגרסאות ברשת.",
"confirm": { "confirm": {
"delete": "למחוק גרסה זו מהספרייה שלך?" "delete": "למחוק גרסה זו מהספרייה שלך?"
}, },
@@ -1748,7 +1751,13 @@
"checkingUpdates": "בודק עדכונים...", "checkingUpdates": "בודק עדכונים...",
"checkingMessage": "אנא המתן בזמן שאנו בודקים את הגרסה האחרונה.", "checkingMessage": "אנא המתן בזמן שאנו בודקים את הגרסה האחרונה.",
"showNotifications": "הצג התראות עדכון", "showNotifications": "הצג התראות עדכון",
"latestBadge": "עדכן", "latestBadge": "אחרון",
"latestMain": "ענף main",
"channel": "ערוץ עדכון",
"channels": {
"release": "Release",
"nightly": "Nightly"
},
"updateProgress": { "updateProgress": {
"preparing": "מכין עדכון...", "preparing": "מכין עדכון...",
"installing": "מתקין עדכון...", "installing": "מתקין עדכון...",
@@ -1769,6 +1778,15 @@
"warning": "אזהרה: גרסאות ליליות עשויות להכיל תכונות ניסיוניות ועלולות להיות לא יציבות.", "warning": "אזהרה: גרסאות ליליות עשויות להכיל תכונות ניסיוניות ועלולות להיות לא יציבות.",
"enable": "הפעל עדכונים ליליים" "enable": "הפעל עדכונים ליליים"
}, },
"channelSwitch": {
"nightlyTitle": "מעבר לערוץ Nightly",
"nightlyMessage": "מעבר ל-Nightly יאתחל מאגר Git ויעקוב אחר הקומיטים האחרונים בענף main. העדכונים תכופים יותר אך עשויים להיות לא יציבים. ניתן לחזור ל-Release בכל עת.",
"releaseTitle": "מעבר לערוץ Release",
"releaseMessage": "מעבר ל-Release יעבור לתגית הגרסה היציבה האחרונה. ניתן לחזור ל-Nightly בכל עת.",
"switching": "מעבר לערוץ {channel}...",
"completed": "המעבר לערוץ {channel} הושלם",
"failed": "החלפת ערוץ נכשלה"
},
"banners": { "banners": {
"recent": "הודעות אחרונות", "recent": "הודעות אחרונות",
"empty": "אין כרגע באנרים אחרונים.", "empty": "אין כרגע באנרים אחרונים.",
+19 -1
View File
@@ -233,7 +233,7 @@
"presetNamePlaceholder": "プリセット名...", "presetNamePlaceholder": "プリセット名...",
"baseModel": "ベースモデル", "baseModel": "ベースモデル",
"baseModelSearchPlaceholder": "ベースモデルを検索...", "baseModelSearchPlaceholder": "ベースモデルを検索...",
"modelTags": "タグ(上位20", "modelTags": "タグ",
"modelTypes": "モデルタイプ", "modelTypes": "モデルタイプ",
"license": "ライセンス", "license": "ライセンス",
"noCreditRequired": "クレジット不要", "noCreditRequired": "クレジット不要",
@@ -241,6 +241,8 @@
"allowSellingGeneratedContentTooltip": "生成した画像の販売を許可", "allowSellingGeneratedContentTooltip": "生成した画像の販売を許可",
"noCreditRequiredTooltip": "クレジット表記なしでモデルを使用可能", "noCreditRequiredTooltip": "クレジット表記なしでモデルを使用可能",
"noTags": "タグなし", "noTags": "タグなし",
"tagSearchPlaceholder": "タグを検索...",
"noTagMatches": "現在の検索に一致するタグはありません。",
"autoTags": "自動タグ", "autoTags": "自動タグ",
"noBaseModelMatches": "現在の検索に一致するベースモデルはありません。", "noBaseModelMatches": "現在の検索に一致するベースモデルはありません。",
"clearAll": "すべてのフィルタをクリア", "clearAll": "すべてのフィルタをクリア",
@@ -1546,6 +1548,7 @@
"empty": "このモデルにはまだバージョン履歴がありません。", "empty": "このモデルにはまだバージョン履歴がありません。",
"error": "バージョンの読み込みに失敗しました。", "error": "バージョンの読み込みに失敗しました。",
"missingModelId": "このモデルにはCivitaiのモデルIDがありません。", "missingModelId": "このモデルにはCivitaiのモデルIDがありません。",
"hfGroupInfo": "これは HuggingFace モデルグループです。ライブラリを開いてグリッドですべてのバージョンを表示してください。",
"confirm": { "confirm": {
"delete": "このバージョンをライブラリから削除しますか?" "delete": "このバージョンをライブラリから削除しますか?"
}, },
@@ -1749,6 +1752,12 @@
"checkingMessage": "最新バージョンを確認しています。お待ちください。", "checkingMessage": "最新バージョンを確認しています。お待ちください。",
"showNotifications": "更新通知を表示", "showNotifications": "更新通知を表示",
"latestBadge": "最新", "latestBadge": "最新",
"latestMain": "Main ブランチ",
"channel": "更新チャンネル",
"channels": {
"release": "リリース",
"nightly": "ナイトリー"
},
"updateProgress": { "updateProgress": {
"preparing": "更新を準備中...", "preparing": "更新を準備中...",
"installing": "更新をインストール中...", "installing": "更新をインストール中...",
@@ -1769,6 +1778,15 @@
"warning": "警告:ナイトリービルドには実験的機能が含まれており、不安定な場合があります。", "warning": "警告:ナイトリービルドには実験的機能が含まれており、不安定な場合があります。",
"enable": "ナイトリー更新を有効にする" "enable": "ナイトリー更新を有効にする"
}, },
"channelSwitch": {
"nightlyTitle": "ナイトリーチャンネルに切り替え",
"nightlyMessage": "ナイトリーに切り替えると、Gitリポジトリが初期化され、mainブランチの最新コミットを追跡します。更新頻度は高くなりますが、不安定な場合があります。いつでもリリース版に戻せます。",
"releaseTitle": "リリースチャンネルに切り替え",
"releaseMessage": "リリースに切り替えると、最新の安定版タグにチェックアウトされます。いつでもNightlyに戻せます。",
"switching": "{channel} チャンネルに切り替え中...",
"completed": "{channel} チャンネルに切り替えました",
"failed": "チャンネルの切り替えに失敗しました"
},
"banners": { "banners": {
"recent": "最近の通知", "recent": "最近の通知",
"empty": "最近のバナーはありません。", "empty": "最近のバナーはありません。",
+19 -1
View File
@@ -233,7 +233,7 @@
"presetNamePlaceholder": "프리셋 이름...", "presetNamePlaceholder": "프리셋 이름...",
"baseModel": "베이스 모델", "baseModel": "베이스 모델",
"baseModelSearchPlaceholder": "베이스 모델 검색...", "baseModelSearchPlaceholder": "베이스 모델 검색...",
"modelTags": "태그 (상위 20개)", "modelTags": "태그",
"modelTypes": "모델 유형", "modelTypes": "모델 유형",
"license": "라이선스", "license": "라이선스",
"noCreditRequired": "크레딧 표기 없음", "noCreditRequired": "크레딧 표기 없음",
@@ -241,6 +241,8 @@
"allowSellingGeneratedContentTooltip": "생성된 이미지 판매 허용", "allowSellingGeneratedContentTooltip": "생성된 이미지 판매 허용",
"noCreditRequiredTooltip": "크리에이터 저작자 표시 없이 모델 사용 가능", "noCreditRequiredTooltip": "크리에이터 저작자 표시 없이 모델 사용 가능",
"noTags": "태그 없음", "noTags": "태그 없음",
"tagSearchPlaceholder": "태그 검색...",
"noTagMatches": "현재 검색과 일치하는 태그가 없습니다.",
"autoTags": "자동 태그", "autoTags": "자동 태그",
"noBaseModelMatches": "현재 검색과 일치하는 베이스 모델이 없습니다.", "noBaseModelMatches": "현재 검색과 일치하는 베이스 모델이 없습니다.",
"clearAll": "모든 필터 지우기", "clearAll": "모든 필터 지우기",
@@ -1546,6 +1548,7 @@
"empty": "이 모델에는 아직 버전 기록이 없습니다.", "empty": "이 모델에는 아직 버전 기록이 없습니다.",
"error": "버전을 불러오지 못했습니다.", "error": "버전을 불러오지 못했습니다.",
"missingModelId": "이 모델에는 Civitai 모델 ID가 없습니다.", "missingModelId": "이 모델에는 Civitai 모델 ID가 없습니다.",
"hfGroupInfo": "HuggingFace 모델 그룹입니다. 라이브러리를 열어 그리드에서 모든 버전을 확인하세요.",
"confirm": { "confirm": {
"delete": "이 버전을 라이브러리에서 삭제하시겠습니까?" "delete": "이 버전을 라이브러리에서 삭제하시겠습니까?"
}, },
@@ -1749,6 +1752,12 @@
"checkingMessage": "최신 버전을 확인하는 동안 잠시 기다려주세요.", "checkingMessage": "최신 버전을 확인하는 동안 잠시 기다려주세요.",
"showNotifications": "업데이트 알림 표시", "showNotifications": "업데이트 알림 표시",
"latestBadge": "최신", "latestBadge": "최신",
"latestMain": "Main 브랜치",
"channel": "업데이트 채널",
"channels": {
"release": "릴리스",
"nightly": "나이틀리"
},
"updateProgress": { "updateProgress": {
"preparing": "업데이트 준비 중...", "preparing": "업데이트 준비 중...",
"installing": "업데이트 설치 중...", "installing": "업데이트 설치 중...",
@@ -1769,6 +1778,15 @@
"warning": "경고: 나이틀리 빌드는 실험적 기능을 포함할 수 있으며 불안정할 수 있습니다.", "warning": "경고: 나이틀리 빌드는 실험적 기능을 포함할 수 있으며 불안정할 수 있습니다.",
"enable": "나이틀리 업데이트 활성화" "enable": "나이틀리 업데이트 활성화"
}, },
"channelSwitch": {
"nightlyTitle": "나이틀리 채널로 전환",
"nightlyMessage": "나이틀리로 전환하면 Git 저장소가 초기화되고 main 브랜치의 최신 커밋을 추적합니다. 업데이트 빈도는 높지만 불안정할 수 있습니다. 언제든지 릴리스로 돌아갈 수 있습니다.",
"releaseTitle": "릴리스 채널로 전환",
"releaseMessage": "릴리스로 전환하면 최신 안정 버전 태그로 체크아웃됩니다. 언제든지 나이틀리로 돌아갈 수 있습니다.",
"switching": "{channel} 채널로 전환 중...",
"completed": "{channel} 채널로 전환 완료",
"failed": "채널 전환 실패"
},
"banners": { "banners": {
"recent": "최근 알림", "recent": "최근 알림",
"empty": "최근 배너가 없습니다.", "empty": "최근 배너가 없습니다.",
+20 -2
View File
@@ -233,7 +233,7 @@
"presetNamePlaceholder": "Имя пресета...", "presetNamePlaceholder": "Имя пресета...",
"baseModel": "Базовая модель", "baseModel": "Базовая модель",
"baseModelSearchPlaceholder": "Поиск базовых моделей...", "baseModelSearchPlaceholder": "Поиск базовых моделей...",
"modelTags": "Теги (Топ 20)", "modelTags": "Теги",
"modelTypes": "Типы моделей", "modelTypes": "Типы моделей",
"license": "Лицензия", "license": "Лицензия",
"noCreditRequired": "Без указания авторства", "noCreditRequired": "Без указания авторства",
@@ -241,6 +241,8 @@
"allowSellingGeneratedContentTooltip": "Разрешить продажу сгенерированных изображений", "allowSellingGeneratedContentTooltip": "Разрешить продажу сгенерированных изображений",
"noCreditRequiredTooltip": "Использование модели без указания автора", "noCreditRequiredTooltip": "Использование модели без указания автора",
"noTags": "Без тегов", "noTags": "Без тегов",
"tagSearchPlaceholder": "Поиск тегов...",
"noTagMatches": "Нет тегов, соответствующих текущему поиску.",
"autoTags": "Авто-теги", "autoTags": "Авто-теги",
"noBaseModelMatches": "Нет базовых моделей, соответствующих текущему поиску.", "noBaseModelMatches": "Нет базовых моделей, соответствующих текущему поиску.",
"clearAll": "Очистить все фильтры", "clearAll": "Очистить все фильтры",
@@ -1546,6 +1548,7 @@
"empty": "Для этой модели пока нет истории версий.", "empty": "Для этой модели пока нет истории версий.",
"error": "Не удалось загрузить версии.", "error": "Не удалось загрузить версии.",
"missingModelId": "У этой модели отсутствует идентификатор модели Civitai.", "missingModelId": "У этой модели отсутствует идентификатор модели Civitai.",
"hfGroupInfo": "Это группа моделей HuggingFace. Откройте библиотеку, чтобы увидеть все версии в сетке.",
"confirm": { "confirm": {
"delete": "Удалить эту версию из библиотеки?" "delete": "Удалить эту версию из библиотеки?"
}, },
@@ -1748,7 +1751,13 @@
"checkingUpdates": "Проверка обновлений...", "checkingUpdates": "Проверка обновлений...",
"checkingMessage": "Пожалуйста, подождите, пока мы проверяем последнюю версию.", "checkingMessage": "Пожалуйста, подождите, пока мы проверяем последнюю версию.",
"showNotifications": "Показывать уведомления об обновлениях", "showNotifications": "Показывать уведомления об обновлениях",
"latestBadge": "Последний", "latestBadge": "Последняя",
"latestMain": "Ветка main",
"channel": "Канал обновлений",
"channels": {
"release": "Релиз",
"nightly": "Nightly"
},
"updateProgress": { "updateProgress": {
"preparing": "Подготовка обновления...", "preparing": "Подготовка обновления...",
"installing": "Установка обновления...", "installing": "Установка обновления...",
@@ -1769,6 +1778,15 @@
"warning": "Предупреждение: Ночные сборки могут содержать экспериментальные функции и могут быть нестабильными.", "warning": "Предупреждение: Ночные сборки могут содержать экспериментальные функции и могут быть нестабильными.",
"enable": "Включить ночные обновления" "enable": "Включить ночные обновления"
}, },
"channelSwitch": {
"nightlyTitle": "Переключиться на Nightly",
"nightlyMessage": "Переключение на Nightly инициализирует Git-репозиторий и отслеживает последние коммиты ветки main. Обновления чаще, но могут быть нестабильными. Вы можете вернуться к Release в любое время.",
"releaseTitle": "Переключиться на Release",
"releaseMessage": "Переключение на Release выполнит checkout последнего стабильного тега. Вы можете вернуться к Nightly в любое время.",
"switching": "Переключение на канал {channel}...",
"completed": "Успешно переключено на канал {channel}",
"failed": "Не удалось переключить канал"
},
"banners": { "banners": {
"recent": "Недавние уведомления", "recent": "Недавние уведомления",
"empty": "Недавних баннеров нет.", "empty": "Недавних баннеров нет.",
+19 -1
View File
@@ -233,7 +233,7 @@
"presetNamePlaceholder": "预设名称...", "presetNamePlaceholder": "预设名称...",
"baseModel": "基础模型", "baseModel": "基础模型",
"baseModelSearchPlaceholder": "搜索基础模型...", "baseModelSearchPlaceholder": "搜索基础模型...",
"modelTags": "标签(前20", "modelTags": "标签",
"modelTypes": "模型类型", "modelTypes": "模型类型",
"license": "许可证", "license": "许可证",
"noCreditRequired": "无需署名", "noCreditRequired": "无需署名",
@@ -241,6 +241,8 @@
"allowSellingGeneratedContentTooltip": "允许出售生成的图片", "allowSellingGeneratedContentTooltip": "允许出售生成的图片",
"noCreditRequiredTooltip": "使用模型时无需注明原作者", "noCreditRequiredTooltip": "使用模型时无需注明原作者",
"noTags": "无标签", "noTags": "无标签",
"tagSearchPlaceholder": "搜索标签...",
"noTagMatches": "没有匹配当前搜索的标签。",
"autoTags": "自动标签", "autoTags": "自动标签",
"noBaseModelMatches": "没有基础模型符合当前搜索。", "noBaseModelMatches": "没有基础模型符合当前搜索。",
"clearAll": "清除所有筛选", "clearAll": "清除所有筛选",
@@ -1546,6 +1548,7 @@
"empty": "该模型还没有版本历史。", "empty": "该模型还没有版本历史。",
"error": "加载版本失败。", "error": "加载版本失败。",
"missingModelId": "该模型缺少 Civitai 模型 ID。", "missingModelId": "该模型缺少 Civitai 模型 ID。",
"hfGroupInfo": "这是一个 HuggingFace 模型组。打开库页面即可在网格中查看所有版本。",
"confirm": { "confirm": {
"delete": "从库中删除此版本?" "delete": "从库中删除此版本?"
}, },
@@ -1749,6 +1752,12 @@
"checkingMessage": "请稍候,正在检查最新版本。", "checkingMessage": "请稍候,正在检查最新版本。",
"showNotifications": "显示更新通知", "showNotifications": "显示更新通知",
"latestBadge": "最新", "latestBadge": "最新",
"latestMain": "Main 分支",
"channel": "更新频道",
"channels": {
"release": "稳定版",
"nightly": "Nightly"
},
"updateProgress": { "updateProgress": {
"preparing": "正在准备更新...", "preparing": "正在准备更新...",
"installing": "正在安装更新...", "installing": "正在安装更新...",
@@ -1769,6 +1778,15 @@
"warning": "警告:Nightly 版本可能包含实验性功能,可能不稳定。", "warning": "警告:Nightly 版本可能包含实验性功能,可能不稳定。",
"enable": "启用 Nightly 更新" "enable": "启用 Nightly 更新"
}, },
"channelSwitch": {
"nightlyTitle": "切换到 Nightly",
"nightlyMessage": "切换到 Nightly 将初始化 Git 仓库并跟踪 main 分支的最新提交。更新更频繁但可能不稳定,可随时切回稳定版。",
"releaseTitle": "切换到稳定版",
"releaseMessage": "切换到稳定版将检出最新的发布标签。可随时切换回每日构建版。",
"switching": "正在切换到 {channel} 频道...",
"completed": "已切换到 {channel} 频道",
"failed": "切换频道失败"
},
"banners": { "banners": {
"recent": "最近的通知", "recent": "最近的通知",
"empty": "暂无最近的横幅通知。", "empty": "暂无最近的横幅通知。",
+19 -1
View File
@@ -233,7 +233,7 @@
"presetNamePlaceholder": "預設名稱...", "presetNamePlaceholder": "預設名稱...",
"baseModel": "基礎模型", "baseModel": "基礎模型",
"baseModelSearchPlaceholder": "搜尋基礎模型...", "baseModelSearchPlaceholder": "搜尋基礎模型...",
"modelTags": "標籤(前 20", "modelTags": "標籤",
"modelTypes": "模型類型", "modelTypes": "模型類型",
"license": "授權", "license": "授權",
"noCreditRequired": "無需署名", "noCreditRequired": "無需署名",
@@ -241,6 +241,8 @@
"allowSellingGeneratedContentTooltip": "允許出售生成的圖片", "allowSellingGeneratedContentTooltip": "允許出售生成的圖片",
"noCreditRequiredTooltip": "使用模型時無需註明原作者", "noCreditRequiredTooltip": "使用模型時無需註明原作者",
"noTags": "無標籤", "noTags": "無標籤",
"tagSearchPlaceholder": "搜尋標籤...",
"noTagMatches": "沒有符合目前搜尋的標籤。",
"autoTags": "自動標籤", "autoTags": "自動標籤",
"noBaseModelMatches": "沒有基礎模型符合目前的搜尋。", "noBaseModelMatches": "沒有基礎模型符合目前的搜尋。",
"clearAll": "清除所有篩選", "clearAll": "清除所有篩選",
@@ -1546,6 +1548,7 @@
"empty": "此模型尚無版本歷史。", "empty": "此模型尚無版本歷史。",
"error": "載入版本失敗。", "error": "載入版本失敗。",
"missingModelId": "此模型缺少 Civitai 模型 ID。", "missingModelId": "此模型缺少 Civitai 模型 ID。",
"hfGroupInfo": "這是一個 HuggingFace 模型組。打開庫頁面即可在網格中查看所有版本。",
"confirm": { "confirm": {
"delete": "要從庫中刪除此版本嗎?" "delete": "要從庫中刪除此版本嗎?"
}, },
@@ -1749,6 +1752,12 @@
"checkingMessage": "請稍候,正在檢查最新版本。", "checkingMessage": "請稍候,正在檢查最新版本。",
"showNotifications": "顯示更新通知", "showNotifications": "顯示更新通知",
"latestBadge": "最新", "latestBadge": "最新",
"latestMain": "Main 分支",
"channel": "更新頻道",
"channels": {
"release": "稳定版",
"nightly": "Nightly"
},
"updateProgress": { "updateProgress": {
"preparing": "正在準備更新...", "preparing": "正在準備更新...",
"installing": "正在安裝更新...", "installing": "正在安裝更新...",
@@ -1769,6 +1778,15 @@
"warning": "警告:Nightly 版本可能包含實驗性功能且可能不穩定。", "warning": "警告:Nightly 版本可能包含實驗性功能且可能不穩定。",
"enable": "啟用 Nightly 更新" "enable": "啟用 Nightly 更新"
}, },
"channelSwitch": {
"nightlyTitle": "切换到 Nightly",
"nightlyMessage": "切换到 Nightly 将初始化 Git 仓库并跟踪 main 分支的最新提交。更新更频繁但可能不稳定,可随时切回稳定版。",
"releaseTitle": "切换到稳定版",
"releaseMessage": "切換到穩定版將檢出最新的發布標籤。可隨時切換回每日構建版。",
"switching": "正在切換到 {channel} 頻道...",
"completed": "已切換到 {channel} 頻道",
"failed": "切換頻道失敗"
},
"banners": { "banners": {
"recent": "最新通知", "recent": "最新通知",
"empty": "目前沒有最近的橫幅通知。", "empty": "目前沒有最近的橫幅通知。",
+15 -1
View File
@@ -1,5 +1,11 @@
"""Constants used by the metadata collector""" """Constants used by the metadata collector"""
# Sentinel value for clip_skip to distinguish "unconnected / widget default"
# from "user wired value 0". Both ComfyUI CLIPSetLastLayer (-24..-1) and
# A1111 conventions treat 0 as meaningless for clip skipping, but users may
# explicitly wire 0 to the overwrite node to express "no clip skip / default".
CLIP_SKIP_SENTINEL = -25
# Metadata categories # Metadata categories
MODELS = "models" MODELS = "models"
PROMPTS = "prompts" PROMPTS = "prompts"
@@ -9,6 +15,14 @@ EMBEDDINGS = "embeddings"
SIZE = "size" SIZE = "size"
IMAGES = "images" IMAGES = "images"
IS_SAMPLER = "is_sampler" # New constant to mark sampler nodes IS_SAMPLER = "is_sampler" # New constant to mark sampler nodes
OVERWRITE = "overwrite" # Manual metadata overwrite from MetadataOverwriteLM node
# Field names that the MetadataOverwriteLM node and its extractor share
METADATA_OVERWRITE_FIELDS = (
"prompt", "negative_prompt", "seed", "steps", "cfg_scale",
"sampler", "scheduler", "model", "loras", "size",
"clip_skip", "additional_data",
)
# Complete list of categories to track # Complete list of categories to track
METADATA_CATEGORIES = [MODELS, PROMPTS, SAMPLING, LORAS, EMBEDDINGS, SIZE, IMAGES] METADATA_CATEGORIES = [MODELS, PROMPTS, SAMPLING, LORAS, EMBEDDINGS, SIZE, IMAGES, OVERWRITE]
+16 -6
View File
@@ -83,7 +83,8 @@ class MetadataHook:
# Record inputs before execution # Record inputs before execution
if node_id is not None: if node_id is not None:
registry.record_node_execution(node_id, class_type, input_data_all, None) return_types = getattr(obj, 'RETURN_TYPES', None)
registry.record_node_execution(node_id, class_type, input_data_all, None, return_types=return_types)
except Exception as e: except Exception as e:
logger.error(f"Error collecting metadata (pre-execution): {str(e)}") logger.error(f"Error collecting metadata (pre-execution): {str(e)}")
@@ -114,7 +115,8 @@ class MetadataHook:
# Record outputs after execution # Record outputs after execution
if node_id is not None: if node_id is not None:
registry.update_node_execution(node_id, class_type, results) return_types = getattr(obj, 'RETURN_TYPES', None)
registry.update_node_execution(node_id, class_type, results, return_types=return_types)
except Exception as e: except Exception as e:
logger.error(f"Error collecting metadata (post-execution): {str(e)}") logger.error(f"Error collecting metadata (post-execution): {str(e)}")
@@ -135,10 +137,13 @@ class MetadataHook:
# Store the dynprompt reference for node lookups # Store the dynprompt reference for node lookups
if hasattr(prompt, 'original_prompt'): if hasattr(prompt, 'original_prompt'):
registry.set_current_prompt(prompt) registry.set_current_prompt(prompt)
# Store extra_data for accessing full workflow node properties
registry.set_extra_data(extra_data)
# Execute the original function # Execute the original function
return original_execute(*args, **kwargs) return original_execute(*args, **kwargs)
# Replace the functions # Replace the functions
execution._map_node_over_list = map_node_over_list_with_metadata execution._map_node_over_list = map_node_over_list_with_metadata
execution.execute = execute_with_prompt_tracking execution.execute = execute_with_prompt_tracking
@@ -163,7 +168,8 @@ class MetadataHook:
class_type = obj.__class__.__name__ class_type = obj.__class__.__name__
node_id = unique_id node_id = unique_id
if node_id is not None: if node_id is not None:
registry.record_node_execution(node_id, class_type, input_data_all, None) return_types = getattr(obj, 'RETURN_TYPES', None)
registry.record_node_execution(node_id, class_type, input_data_all, None, return_types=return_types)
except Exception as e: except Exception as e:
logger.error(f"Error collecting metadata (pre-execution): {str(e)}") logger.error(f"Error collecting metadata (pre-execution): {str(e)}")
@@ -180,7 +186,8 @@ class MetadataHook:
class_type = obj.__class__.__name__ class_type = obj.__class__.__name__
node_id = unique_id node_id = unique_id
if node_id is not None: if node_id is not None:
registry.update_node_execution(node_id, class_type, results) return_types = getattr(obj, 'RETURN_TYPES', None)
registry.update_node_execution(node_id, class_type, results, return_types=return_types)
except Exception as e: except Exception as e:
logger.error(f"Error collecting metadata (post-execution): {str(e)}") logger.error(f"Error collecting metadata (post-execution): {str(e)}")
@@ -202,6 +209,9 @@ class MetadataHook:
if hasattr(prompt, 'original_prompt'): if hasattr(prompt, 'original_prompt'):
registry.set_current_prompt(prompt) registry.set_current_prompt(prompt)
# Store extra_data for accessing full workflow node properties
registry.set_extra_data(extra_data)
# Execute the original function # Execute the original function
return await original_execute(*args, **kwargs) return await original_execute(*args, **kwargs)
+138 -14
View File
@@ -1,15 +1,68 @@
import json import json
import logging
import os import os
from .constants import IMAGES from .constants import IMAGES
# Check if running in standalone mode # Check if running in standalone mode
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0" standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IS_SAMPLER from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IS_SAMPLER, OVERWRITE
from .node_extractors import NODE_EXTRACTORS
logger = logging.getLogger(__name__)
# Keys that identify metadata hint marks stored in node.properties.lm_marker_role
_META_MARK_PREFIX = "meta_"
_MARK_PRIMARY_MODEL = "primary_model"
_MARK_PRIMARY_SAMPLER = "primary_sampler"
_MARK_POSITIVE_PROMPT = "positive_prompt"
_MARK_NEGATIVE_PROMPT = "negative_prompt"
class MetadataProcessor: class MetadataProcessor:
"""Process and format collected metadata""" """Process and format collected metadata"""
@staticmethod
def _get_user_marks(metadata):
"""Scan workflow nodes (from extra_data.extra_pnginfo.workflow) for user-assigned
metadata hint marks stored in node.properties.lm_marker_role.
Returns a dict mapping mark type keys to node IDs.
Example: {'primary_model': '42', 'primary_sampler': '17'}
"""
marks: dict[str, str] = {}
# Primary source: extra_data.extra_pnginfo.workflow.nodes (has full properties)
extra_data = metadata.get("extra_data")
if extra_data and isinstance(extra_data, dict):
extra_pnginfo = extra_data.get("extra_pnginfo", {})
if isinstance(extra_pnginfo, dict):
workflow = extra_pnginfo.get("workflow", {})
nodes = workflow.get("nodes", [])
for node in nodes:
node_id = str(node.get("id", ""))
role = node.get("properties", {}).get("lm_marker_role", "")
if role.startswith(_META_MARK_PREFIX):
mark_type = role[len(_META_MARK_PREFIX):]
if mark_type in marks:
logger.warning(
"Duplicate meta hint '%s': node %s (previous: %s), "
"last match wins",
mark_type, node_id, marks[mark_type],
)
marks[mark_type] = node_id
# Fallback: try prompt.original_prompt (API-only submissions may not have workflow)
if not marks:
prompt = metadata.get("current_prompt")
if prompt and getattr(prompt, "original_prompt", None):
for node_id, node_data in prompt.original_prompt.items():
role = node_data.get("properties", {}).get("lm_marker_role", "")
if role.startswith(_META_MARK_PREFIX):
mark_type = role[len(_META_MARK_PREFIX):]
marks[mark_type] = node_id
return marks
@staticmethod @staticmethod
def find_primary_sampler(metadata, downstream_id=None): def find_primary_sampler(metadata, downstream_id=None):
""" """
@@ -471,20 +524,57 @@ class MetadataProcessor:
"checkpoint": None, "checkpoint": None,
"loras": "", "loras": "",
"size": None, "size": None,
"clip_skip": None "clip_skip": None,
"additional_data": "",
} }
# Get the prompt object for node relationship tracing # Get the prompt object for node relationship tracing
prompt = metadata.get("current_prompt") prompt = metadata.get("current_prompt")
# Find the primary KSampler node # ---- User marks: override heuristic inference with user-assigned hints ----
primary_sampler_id, primary_sampler = MetadataProcessor.find_primary_sampler(metadata, id) user_marks = MetadataProcessor._get_user_marks(metadata)
# Directly get checkpoint from metadata instead of tracing # Find the primary KSampler node (user mark takes priority)
# Pass primary_sampler_id to avoid redundant calculation primary_sampler_id = None
checkpoint = MetadataProcessor.find_primary_checkpoint(metadata, id, primary_sampler_id) primary_sampler = None
if checkpoint: if _MARK_PRIMARY_SAMPLER in user_marks:
params["checkpoint"] = checkpoint marked_id = user_marks[_MARK_PRIMARY_SAMPLER]
sampler_data = metadata.get(SAMPLING, {}).get(marked_id)
if sampler_data and sampler_data.get(IS_SAMPLER):
primary_sampler_id = marked_id
primary_sampler = sampler_data
else:
logger.warning(
"User-marked primary sampler %s has no runtime metadata, "
"falling back to heuristic",
marked_id,
)
if primary_sampler is None:
primary_sampler_id, primary_sampler = MetadataProcessor.find_primary_sampler(metadata, id)
# Resolve checkpoint / model (user mark takes priority)
if _MARK_PRIMARY_MODEL in user_marks:
marked_id = user_marks[_MARK_PRIMARY_MODEL]
if marked_id in metadata.get(MODELS, {}):
params["checkpoint"] = metadata[MODELS][marked_id].get("name")
else:
extra_data = metadata.get("extra_data")
extra_pnginfo = extra_data.get("extra_pnginfo", {}) if extra_data and isinstance(extra_data, dict) else {}
workflow = extra_pnginfo.get("workflow", {}) if isinstance(extra_pnginfo, dict) else {}
node_type = "unknown"
for n in workflow.get("nodes", []):
if str(n.get("id", "")) == marked_id:
node_type = n.get("type", "unknown")
break
logger.warning(
"User-marked primary model %s (type=%s, registered=%s) has no runtime metadata, "
"falling back to heuristic",
marked_id, node_type, node_type in NODE_EXTRACTORS,
)
if params["checkpoint"] is None:
checkpoint = MetadataProcessor.find_primary_checkpoint(metadata, id, primary_sampler_id)
if checkpoint:
params["checkpoint"] = checkpoint
# Check if guidance parameter exists in any sampling node # Check if guidance parameter exists in any sampling node
for node_id, sampler_info in metadata.get(SAMPLING, {}).items(): for node_id, sampler_info in metadata.get(SAMPLING, {}).items():
@@ -539,7 +629,22 @@ class MetadataProcessor:
# For SamplerCustom, handle any additional parameters # For SamplerCustom, handle any additional parameters
MetadataProcessor.handle_custom_advanced_sampler(metadata, prompt, primary_sampler_id, params) MetadataProcessor.handle_custom_advanced_sampler(metadata, prompt, primary_sampler_id, params)
# ---- User marks: override prompts with explicitly tagged nodes ----
prompts_data = metadata.get(PROMPTS, {})
if _MARK_POSITIVE_PROMPT in user_marks:
pos_id = user_marks[_MARK_POSITIVE_PROMPT]
if pos_id in prompts_data:
prompt_text = prompts_data[pos_id].get("text") or prompts_data[pos_id].get("positive_text")
if prompt_text:
params["prompt"] = prompt_text
if _MARK_NEGATIVE_PROMPT in user_marks:
neg_id = user_marks[_MARK_NEGATIVE_PROMPT]
if neg_id in prompts_data:
prompt_text = prompts_data[neg_id].get("text") or prompts_data[neg_id].get("negative_text")
if prompt_text:
params["negative_prompt"] = prompt_text
# Size extraction is same for all sampler types # Size extraction is same for all sampler types
# Check if the sampler itself has size information (from latent_image) # Check if the sampler itself has size information (from latent_image)
if primary_sampler_id in metadata.get(SIZE, {}): if primary_sampler_id in metadata.get(SIZE, {}):
@@ -568,7 +673,26 @@ class MetadataProcessor:
break break
if params["clip_skip"] is None: if params["clip_skip"] is None:
params["clip_skip"] = "1" params["clip_skip"] = "1"
# ---- Apply manual metadata overwrites ----
for overwrite_info in metadata.get(OVERWRITE, {}).values():
overwrite_params = overwrite_info.get("parameters", {})
for key, value in overwrite_params.items():
if key == "clip_skip":
# Accept any value from overwrite node (sentinel -25 already
# filtered upstream). Needed because falsy check treats 0
# as "not set" even though 0 is a valid wired input here.
params[key] = value
elif value: # truthy check — only overwrite when user provided a real value
params[key] = value
# Bridge: the overwrite node exposes the field as "model" (more accurate),
# but the internal pipeline key remains "checkpoint" for backward compatibility
# with A1111 metadata format and downstream consumers.
if params.get("model"):
params["checkpoint"] = params["model"]
del params["model"]
return params return params
@staticmethod @staticmethod
+36 -13
View File
@@ -1,7 +1,7 @@
import time import time
from nodes import NODE_CLASS_MAPPINGS # type: ignore from nodes import NODE_CLASS_MAPPINGS # type: ignore
from .node_extractors import NODE_EXTRACTORS, GenericNodeExtractor from .node_extractors import NODE_EXTRACTORS, GenericNodeExtractor
from .constants import METADATA_CATEGORIES, IMAGES from .constants import METADATA_CATEGORIES, IMAGES, OVERWRITE
class MetadataRegistry: class MetadataRegistry:
@@ -61,6 +61,7 @@ class MetadataRegistry:
{ {
"execution_order": [], "execution_order": [],
"current_prompt": None, # Will store the prompt object "current_prompt": None, # Will store the prompt object
"extra_data": None, # Will store the API extra_data for workflow metadata
"timestamp": time.time(), "timestamp": time.time(),
} }
) )
@@ -75,6 +76,11 @@ class MetadataRegistry:
# Store the prompt in the metadata for later relationship tracing # Store the prompt in the metadata for later relationship tracing
self.prompt_metadata[self.current_prompt_id]["current_prompt"] = prompt self.prompt_metadata[self.current_prompt_id]["current_prompt"] = prompt
def set_extra_data(self, extra_data):
"""Store the API extra_data (contains extra_pnginfo.workflow with node properties)"""
if self.current_prompt_id and self.current_prompt_id in self.prompt_metadata:
self.prompt_metadata[self.current_prompt_id]["extra_data"] = extra_data
def get_metadata(self, prompt_id=None): def get_metadata(self, prompt_id=None):
"""Get collected metadata for a prompt""" """Get collected metadata for a prompt"""
key = prompt_id if prompt_id is not None else self.current_prompt_id key = prompt_id if prompt_id is not None else self.current_prompt_id
@@ -122,20 +128,28 @@ class MetadataRegistry:
cache_key = f"{node_id}:{class_type}" cache_key = f"{node_id}:{class_type}"
# Check if this node type is relevant for metadata collection # Check if this node type is relevant for metadata collection
if class_type in NODE_EXTRACTORS: if class_type in NODE_EXTRACTORS or cache_key in self.node_cache:
# Check if we have cached metadata for this node # Check if we have cached metadata for this node
if cache_key in self.node_cache: if cache_key in self.node_cache:
cached_data = self.node_cache[cache_key] cached_data = self.node_cache[cache_key]
# Detect bypass (mode=4) / mute (mode=2) — these nodes
# were intentionally disabled and should not contribute
# overwrite values from a previous execution's cache.
node_mode = node_data.get("mode", 0)
node_is_disabled = node_mode in (2, 4)
# Apply cached metadata to the current metadata # Apply cached metadata to the current metadata
for category in self.metadata_categories: for category in self.metadata_categories:
if category == OVERWRITE and node_is_disabled:
continue
if category in cached_data and node_id in cached_data[category]: if category in cached_data and node_id in cached_data[category]:
if node_id not in metadata[category]: if node_id not in metadata[category]:
metadata[category][node_id] = cached_data[category][ metadata[category][node_id] = cached_data[category][
node_id node_id
] ]
def record_node_execution(self, node_id, class_type, inputs, outputs): def record_node_execution(self, node_id, class_type, inputs, outputs, return_types=None):
"""Record information about a node's execution""" """Record information about a node's execution"""
if not self.current_prompt_id: if not self.current_prompt_id:
return return
@@ -158,17 +172,18 @@ class MetadataRegistry:
# Extract node-specific metadata # Extract node-specific metadata
extractor = NODE_EXTRACTORS.get(class_type, GenericNodeExtractor) extractor = NODE_EXTRACTORS.get(class_type, GenericNodeExtractor)
extractor.extract( if extractor is GenericNodeExtractor:
node_id, extractor.extract(node_id, processed_inputs, outputs,
processed_inputs, self.prompt_metadata[self.current_prompt_id],
outputs, return_types=return_types)
self.prompt_metadata[self.current_prompt_id], else:
) extractor.extract(node_id, processed_inputs, outputs,
self.prompt_metadata[self.current_prompt_id])
# Cache this node's metadata # Cache this node's metadata
self._cache_node_metadata(node_id, class_type) self._cache_node_metadata(node_id, class_type)
def update_node_execution(self, node_id, class_type, outputs): def update_node_execution(self, node_id, class_type, outputs, return_types=None):
"""Update node metadata with output information""" """Update node metadata with output information"""
if not self.current_prompt_id: if not self.current_prompt_id:
return return
@@ -179,9 +194,17 @@ class MetadataRegistry:
# Use the same extractor to update with outputs # Use the same extractor to update with outputs
extractor = NODE_EXTRACTORS.get(class_type, GenericNodeExtractor) extractor = NODE_EXTRACTORS.get(class_type, GenericNodeExtractor)
if hasattr(extractor, "update"): if hasattr(extractor, "update"):
extractor.update( if extractor is GenericNodeExtractor:
node_id, processed_outputs, self.prompt_metadata[self.current_prompt_id] extractor.update(
) node_id, processed_outputs,
self.prompt_metadata[self.current_prompt_id],
return_types=return_types,
)
else:
extractor.update(
node_id, processed_outputs,
self.prompt_metadata[self.current_prompt_id],
)
# Update the cached metadata for this node # Update the cached metadata for this node
self._cache_node_metadata(node_id, class_type) self._cache_node_metadata(node_id, class_type)
+103 -5
View File
@@ -2,7 +2,7 @@ import json
import os import os
import re import re
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IMAGES, IS_SAMPLER from .constants import CLIP_SKIP_SENTINEL, MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IMAGES, IS_SAMPLER, OVERWRITE, METADATA_OVERWRITE_FIELDS
def _store_checkpoint_metadata(metadata, node_id, model_name): def _store_checkpoint_metadata(metadata, node_id, model_name):
@@ -31,11 +31,78 @@ class NodeMetadataExtractor:
pass pass
class GenericNodeExtractor(NodeMetadataExtractor): class GenericNodeExtractor(NodeMetadataExtractor):
"""Default extractor for nodes without specific handling""" """Fallback extractor with type-signature-based detection.
When a node is not in the NODE_EXTRACTORS registry, the hook layer
passes ``return_types`` from ``obj.RETURN_TYPES``:
* ``MODEL`` output: common input fields (ckpt_name, unet_name, etc.)
are checked for a model file name and stored as checkpoint metadata.
* ``CONDITIONING`` output: common text input fields are checked for
prompt text and stored as prompt metadata.
"""
# Input field names that carry a model path in loader-style nodes.
_MODEL_NAME_FIELDS = (
"ckpt_name", "unet_name", "model_path", "model_name", "gguf_name",
)
# Extensions used by checkpoint_scanner.py — only record values that look
# like real model filenames to avoid capturing unrelated string fields.
_MODEL_EXTENSIONS = {
".ckpt", ".pt", ".pt2", ".bin", ".pth", ".safetensors", ".pkl", ".sft", ".gguf",
}
# Input field names that may carry prompt text in encoder-style nodes.
_TEXT_FIELDS = ("text", "clip_l", "t5xxl", "prompt", "positive", "negative")
@staticmethod @staticmethod
def extract(node_id, inputs, outputs, metadata): def extract(node_id, inputs, outputs, metadata, return_types=None):
pass if return_types is None:
return
# — MODEL loader detection (checkpoint / UNET / GGUF) —
if "MODEL" in return_types or any("MODEL" in str(t) for t in return_types):
for field in GenericNodeExtractor._MODEL_NAME_FIELDS:
val = inputs.get(field)
if val and isinstance(val, str) and val.strip():
name = val.strip()
if not any(name.lower().endswith(ext) for ext in GenericNodeExtractor._MODEL_EXTENSIONS):
continue
_store_checkpoint_metadata(metadata, node_id, name)
return
# — CONDITIONING encoder detection (CLIPTextEncode, Flux, custom) —
if "CONDITIONING" in return_types or any("CONDITIONING" in str(t) for t in return_types):
text = None
for field in GenericNodeExtractor._TEXT_FIELDS:
val = inputs.get(field)
if val and isinstance(val, str) and val.strip():
text = val.strip()
break
if text:
prompt_data = metadata.setdefault(PROMPTS, {})
prompt_data[node_id] = {
"text": text,
"node_id": node_id,
}
@staticmethod
def update(node_id, outputs, metadata, return_types=None):
if return_types is None:
return
if "CONDITIONING" not in return_types and not any(
"CONDITIONING" in str(t) for t in return_types
):
return
if node_id not in metadata.get(PROMPTS, {}):
return
if outputs and isinstance(outputs, list) and len(outputs) > 0:
if isinstance(outputs[0], tuple) and len(outputs[0]) > 0:
cond = outputs[0][0]
if cond is not None:
metadata[PROMPTS][node_id]["conditioning"] = cond
class CheckpointLoaderExtractor(NodeMetadataExtractor): class CheckpointLoaderExtractor(NodeMetadataExtractor):
@staticmethod @staticmethod
def extract(node_id, inputs, outputs, metadata): def extract(node_id, inputs, outputs, metadata):
@@ -1154,6 +1221,35 @@ class CR_ApplyControlNetStackExtractor(NodeMetadataExtractor):
metadata[PROMPTS][node_id]["positive_encoded"] = transformed_positive metadata[PROMPTS][node_id]["positive_encoded"] = transformed_positive
metadata[PROMPTS][node_id]["negative_encoded"] = transformed_negative metadata[PROMPTS][node_id]["negative_encoded"] = transformed_negative
class MetadataOverwriteExtractor(NodeMetadataExtractor):
"""Extract manually specified metadata from MetadataOverwriteLM node.
Stores truthy input values under the OVERWRITE category so that
extract_generation_params can merge them over the inferred params.
"""
@staticmethod
def extract(node_id, inputs, outputs, metadata):
if not inputs:
return
overwrite_params = {}
for key in METADATA_OVERWRITE_FIELDS:
value = inputs.get(key)
if key == "clip_skip":
if value != CLIP_SKIP_SENTINEL:
overwrite_params[key] = value
elif value: # truthy — only overwrite when user provided a real value
overwrite_params[key] = value
if overwrite_params:
metadata.setdefault(OVERWRITE, {})
metadata[OVERWRITE][node_id] = {
"parameters": overwrite_params,
"node_id": node_id,
}
# Registry of node-specific extractors # Registry of node-specific extractors
# Keys are node class names # Keys are node class names
NODE_EXTRACTORS = { NODE_EXTRACTORS = {
@@ -1221,5 +1317,7 @@ NODE_EXTRACTORS = {
"CFGGuider": CFGGuiderExtractor, # Add CFGGuider "CFGGuider": CFGGuiderExtractor, # Add CFGGuider
# Image # Image
"VAEDecode": VAEDecodeExtractor, # Added VAEDecode extractor "VAEDecode": VAEDecodeExtractor, # Added VAEDecode extractor
# Metadata overwrite
"MetadataOverwriteLM": MetadataOverwriteExtractor,
# Add other nodes as needed # Add other nodes as needed
} }
+117
View File
@@ -0,0 +1,117 @@
"""Create Hook LoRA (LoraManager) — multi-LoRA hook node compatible with ComfyUI's built-in hook pipeline.
Produces ``("HOOKS",)`` output that chains seamlessly with downstream hook consumers
(ConditioningSetProperties, SetHookKeyframes, CombineHooks, SetClipHooks, etc.).
"""
from __future__ import annotations
import logging
import os
from ..utils.utils import get_lora_info_absolute
from .utils import (
FlexibleOptionalInputType,
any_type,
apply_lora_syntax_format,
get_loras_list,
)
logger = logging.getLogger(__name__)
class CreateHookLoraLM:
NAME = "Create Hook LoRA (LoraManager)"
CATEGORY = "Lora Manager/hooks"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": (
"AUTOCOMPLETE_TEXT_LORAS",
{
"placeholder": "Search LoRAs to add...",
"tooltip": (
"Search and select LoRAs. Each LoRA gets its own "
"model/clip strength. Hooks chain with prev_hooks."
),
},
),
},
"optional": FlexibleOptionalInputType(any_type),
}
RETURN_TYPES = ("HOOKS", "STRING", "STRING")
RETURN_NAMES = ("HOOKS", "trigger_words", "active_loras")
FUNCTION = "create_hook"
def create_hook(self, text: str, **kwargs):
"""Create a HookGroup from the selected LoRAs, chained with prev_hooks.
Each active LoRA from the widget is loaded and wrapped in a WeightHook
via :func:`comfy.hooks.create_hook_lora`. All hooks are combined into a
single group and returned alongside trigger words and a human-readable
summary of the active LoRAs.
"""
del text # used by the frontend widget only
# Lazy imports: comfy is not available in CI/test environment at module level
import comfy.hooks # type: ignore # noqa: C0415
import comfy.utils # type: ignore # noqa: C0415
prev_hooks: comfy.hooks.HookGroup | None = kwargs.get("prev_hooks")
hook_group = prev_hooks.clone() if prev_hooks is not None else comfy.hooks.HookGroup()
all_trigger_words: list[str] = []
active_loras: list[tuple[str, float, float]] = []
for lora in get_loras_list(kwargs):
if not lora.get("active", False):
continue
lora_name = apply_lora_syntax_format(lora["name"])
model_strength = float(lora["strength"])
clip_strength = float(lora.get("clipStrength", model_strength))
# Skip useless no-op entries (both strengths are zero)
if model_strength == 0.0 and clip_strength == 0.0:
continue
lora_path, trigger_words = get_lora_info_absolute(lora_name)
if not lora_path or not os.path.isfile(lora_path):
logger.warning("LoRA '%s' not found — skipping", lora_name)
continue
try:
lora_weights = comfy.utils.load_torch_file(lora_path, safe_load=True)
lora_hooks = comfy.hooks.create_hook_lora(
lora=lora_weights,
strength_model=model_strength,
strength_clip=clip_strength,
)
except Exception:
logger.exception("Failed to load LoRA '%s' — skipping", lora_name)
continue
hook_group = hook_group.clone_and_combine(lora_hooks)
active_loras.append((lora_name, model_strength, clip_strength))
all_trigger_words.extend(trigger_words)
# Format trigger words (group mode separator)
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
# Format active LoRAs summary
formatted_loras = []
for name, model_s, clip_s in active_loras:
if abs(model_s - clip_s) > 0.001:
formatted_loras.append(
f"<lora:{name}:{model_s}:{clip_s}>"
)
else:
formatted_loras.append(f"<lora:{name}:{model_s}>")
active_loras_text = " ".join(formatted_loras)
return (hook_group, trigger_words_text, active_loras_text)
+170
View File
@@ -0,0 +1,170 @@
"""Metadata Overwrite node — allows users to manually specify generation parameters
that override the automatically collected/inferred metadata.
Most inputs have falsy defaults (empty string / 0) which are skipped.
clip_skip uses a sentinel default (-25) so that a wired value of 0 is
preserved both ComfyUI and A1111 conventions have no meaningful 0 value,
but users may wire 0 to express "no clip skip / default".
"""
from typing import Any
from ..metadata_collector.constants import (
CLIP_SKIP_SENTINEL as _CLIP_SKIP_SENTINEL,
METADATA_OVERWRITE_FIELDS,
)
class MetadataOverwriteLM:
NAME = "Metadata Overwrite (LoraManager)"
CATEGORY = "Lora Manager/utils"
DESCRIPTION = (
"Manually specify generation parameters to override automatically collected "
"metadata. Only filled/connected inputs will take effect — empty defaults "
"are ignored."
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"optional": {
"prompt": (
"STRING",
{
"default": "",
"multiline": True,
"tooltip": "Positive prompt. Only overwrites when non-empty.",
},
),
"negative_prompt": (
"STRING",
{
"default": "",
"multiline": True,
"tooltip": "Negative prompt. Only overwrites when non-empty.",
},
),
"seed": (
"INT",
{
"default": 0,
"min": 0,
"max": 0xFFFFFFFFFFFFFFFF,
"control_after_generate": False,
"tooltip": "Seed value. Only overwrites when > 0.",
},
),
"steps": (
"INT",
{
"default": 0,
"min": 0,
"max": 10000,
"tooltip": "Number of steps. Only overwrites when > 0.",
},
),
"cfg_scale": (
"FLOAT",
{
"default": 0.0,
"min": 0.0,
"max": 100.0,
"tooltip": "CFG scale. Only overwrites when > 0.",
},
),
"sampler": (
"STRING",
{
"default": "",
"tooltip": "Sampler name. Only overwrites when non-empty.",
},
),
"scheduler": (
"STRING",
{
"default": "",
"tooltip": "Scheduler name. Only overwrites when non-empty.",
},
),
"model": (
"STRING",
{
"default": "",
"tooltip": (
"The checkpoint or diffusion model (UNet) used "
"for generation. Only overwrites when non-empty."
),
},
),
"loras": (
"STRING",
{
"default": "",
"multiline": True,
"tooltip": (
"LoRA syntax, e.g. <lora:name:strength> "
"or <lora:name:model_strength:clip_strength>, "
"separated by spaces. Only overwrites when non-empty."
),
},
),
"size": (
"STRING",
{
"default": "",
"tooltip": (
"Image size in WIDTHxHEIGHT format (e.g. 512x768). "
"Only overwrites when non-empty."
),
},
),
"clip_skip": (
"INT",
{
"default": _CLIP_SKIP_SENTINEL,
"min": -25,
"max": 24,
"tooltip": (
"Clip skip (ComfyUI: -24..-1, A1111: 1+). "
"Default -25 means not set — any other value "
"overwrites."
),
},
),
"additional_data": (
"STRING",
{
"default": "",
"multiline": True,
"tooltip": (
"Additional data to embed in the image metadata. "
"Inserted between Clip skip and Model hash in the "
"A1111-compatible parameters string. "
'Example: "Copyright": "Some license info"'
),
},
),
},
}
RETURN_TYPES = ("METADATA",)
RETURN_NAMES = ("metadata",)
FUNCTION = "collect_metadata"
OUTPUT_NODE = True
def collect_metadata(self, **kwargs: Any) -> tuple[dict[str, Any]]:
"""Collect non-default input values into a metadata dict.
For most fields, a falsy value (empty string, 0) means "not set"
and is skipped. clip_skip uses a dedicated sentinel (-25) so that
a wired value of 0 is preserved and reaches the metadata pipeline.
"""
result: dict[str, Any] = {}
for key in METADATA_OVERWRITE_FIELDS:
value = kwargs.get(key)
if key == "clip_skip":
if value != _CLIP_SKIP_SENTINEL:
result[key] = value
elif value:
result[key] = value
return (result,)
+346 -127
View File
@@ -16,6 +16,156 @@ from PIL import Image, PngImagePlugin
import piexif import piexif
import logging import logging
# Civitai-compatible sampler name mapping: ComfyUI internal → A1111 display name
CIVITAI_SAMPLER_MAP = {
"euler": "Euler",
"euler_ancestral": "Euler a",
"lms": "LMS",
"heun": "Heun",
"dpm_2": "DPM2",
"dpm_2_ancestral": "DPM2 a",
"dpmpp_2s_ancestral": "DPM++ 2S a",
"dpmpp_2m": "DPM++ 2M",
"dpmpp_sde": "DPM++ SDE",
"dpmpp_sde_gpu": "DPM++ SDE",
"dpmpp_2m_sde": "DPM++ 2M SDE",
"dpmpp_2m_sde_gpu": "DPM++ 2M SDE",
"dpmpp_3m_sde": "DPM++ 3M SDE",
"dpm_fast": "DPM fast",
"dpm_adaptive": "DPM adaptive",
"ddim": "DDIM",
"plms": "PLMS",
"uni_pc_bh2": "UniPC",
"uni_pc": "UniPC",
"lcm": "LCM",
}
# Base model display name → AIR URN slug
# Sourced from civitai source: src/shared/constants/basemodel.constants.ts
BASE_MODEL_AIR_SLUG = {
# Stable Diffusion family
"SD 1.4": "sd1",
"SD 1.5": "sd1",
"SD 1.5 LCM": "sd1",
"SD 1.5 Hyper": "sd1",
"SD 2.0": "sd2",
"SD 2.0 768": "sd2",
"SD 2.1": "sd2",
"SD 2.1 768": "sd2",
"SD 2.1 Unclip": "sd2",
"SD 3.0": "sd3",
"SD 3.5": "sd35",
"SD 3.5 Large": "sd35",
"SD 3.5 Large Turbo": "sd35",
"SD 3.5 Medium": "sd35",
"SDXL 0.9": "sdxl",
"SDXL 1.0": "sdxl",
"SDXL 1.0 LCM": "sdxl",
"SDXL Lightning": "sdxl",
"SDXL Hyper": "sdxl",
"SDXL Turbo": "sdxl",
"SDXL Distilled": "sdxldistilled",
"Stable Cascade": "scascade",
"Stable Video Diffusion": "svd",
"SVD": "svd",
"SVD XT": "svdxt",
# SDXL community fine-tunes
"Pony": "pony",
"Pony Diffusion": "pony",
"Illustrious": "illustrious",
"NoobAI": "noobai",
"Animagine": "illustrious",
# Flux family
"Flux.1": "flux1",
"Flux.1 D": "flux1",
"Flux.1 S": "flux1",
"Flux.1 Krea": "fluxkrea",
"Flux.1 Kontext": "flux1kontext",
"Flux.2": "flux2",
"Flux.2 D": "flux2",
"Flux.2 Klein 9B": "flux2klein_9b",
"Flux.2 Klein 9B Base": "flux2klein_9b_base",
"Flux.2 Klein 4B": "flux2klein_4b",
"Flux.2 Klein 4B Base": "flux2klein_4b_base",
# Other image models (sorted alphabetically)
"AuraFlow": "auraflow",
"Chroma": "chroma",
"HiDream": "hidream",
"HiDream-O1": "hidream-o1",
"Hunyuan DiT": "hydit1",
"Hunyuan Video": "hyv1",
"Kolors": "kolors",
"Lumina": "lumina",
"Mochi": "mochi",
"ODOR": "odor",
"PixArt Alpha": "pixarta",
"PixArt Sigma": "pixarte",
"Playground v2": "playgroundv2",
"Playground v2.5": "playgroundv2",
"Pony Diffusion V7": "ponyv7",
# Video models
"CogVideoX": "cogvideox",
"LTX Video": "ltxv",
"LTX Video 2": "ltxv2",
"LTX Video 2.3": "ltxv23",
"Wan Video": "wanvideo",
"Wan Video 1.3B T2V": "wanvideo_13b_t2v",
"Wan Video 14B T2V": "wanvideo_14b_t2v",
"Wan Video 14B I2V 480p": "wanvideo_14b_i2v_480p",
"Wan Video 14B I2V 720p": "wanvideo_14b_i2v_720p",
# Third-party / proprietary image models
"Boogu": "boogu",
"Ernie": "ernie",
"Grok": "grok",
"HappyHorse": "happyhorse",
"Ideogram": "ideogram",
"Ideogram 4.0": "ideogram",
"Imagen": "imagen4",
"Imagen 4": "imagen4",
"Krea": "krea2",
"Krea 2": "krea2",
"Lens": "lens",
"MAI": "mai",
"Nano Banana": "nanobanana",
"OpenAI": "openai",
"Reve": "reve",
"Reve 2": "reve",
"Reve 2.1": "reve",
"Seedream": "seedream",
"Sora": "sora2",
"Sora 2": "sora2",
"Veo": "veo3",
"Veo 2": "veo3",
"Veo 3": "veo3",
"ZImageTurbo": "zimageturbo",
"ZImageBase": "zimagebase",
"ZImage": "zimagebase",
# Third-party video models
"Hailuo by MiniMax": "minimax",
"Haiper": "haiper",
"Kling": "kling",
"Lightricks": "lightricks",
"Seedance": "seedance",
"Vidu": "vidu",
# Qwen family
"Qwen": "qwen",
"Qwen 2": "qwen2",
# Anima
"Anima": "anima",
# Special
"Upscaler": "upscaler",
"Other": "other",
}
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -70,11 +220,29 @@ class SaveImageLM:
"tooltip": "Compression quality for JPEG and lossy WebP formats (1-100). Higher values mean better quality but larger files.", "tooltip": "Compression quality for JPEG and lossy WebP formats (1-100). Higher values mean better quality but larger files.",
}, },
), ),
"webp_method": (
"INT",
{
"default": 6,
"min": 0,
"max": 6,
"tooltip": "WebP compression method (0-6). 0=fastest/largest, 6=slowest/smallest. Only applies when file_format is 'webp'.",
},
),
"jpeg_subsampling": (
"INT",
{
"default": 0,
"min": 0,
"max": 2,
"tooltip": "JPEG chroma subsampling level. 0=4:4:4 (best quality), 1=4:2:2, 2=4:2:0 (smallest files). Only applies when file_format is 'jpeg'.",
},
),
"embed_workflow": ( "embed_workflow": (
"BOOLEAN", "BOOLEAN",
{ {
"default": False, "default": False,
"tooltip": "Embeds the complete workflow data into the image metadata. Only works with PNG and WebP formats.", "tooltip": "When enabled, saved images store the complete workflow. Drag the image back into ComfyUI to restore the original node graph. PNG and WebP only.",
}, },
), ),
"save_with_metadata": ( "save_with_metadata": (
@@ -142,148 +310,194 @@ class SaveImageLM:
return None return None
def format_metadata(self, metadata_dict): def _resolve_model_cache_entry(self, scanner_type: str, name: str):
"""Format metadata in the requested format similar to userComment example""" """Resolve model hash, civitai metadata, and base_model from scanner cache.
if not metadata_dict: Returns (hash_str, civitai_dict, base_model_str). All values are empty defaults when not found."""
return "" scanner = ServiceRegistry.get_service_sync(scanner_type)
if scanner is None or not name:
return "", {}, ""
# Helper function to only add parameter if value is not None entry = self._get_cached_model_by_name(scanner, name)
def add_param_if_not_none(param_list, label, value): if entry is None:
if value is not None: basename = os.path.splitext(os.path.basename(name))[0]
param_list.append(f"{label}: {value}") hash_val = scanner.get_hash_by_filename(basename)
return (hash_val or "").lower(), {}, ""
hash_val = (entry.get("sha256") or "").lower()
civitai = entry.get("civitai") or {}
base_model = entry.get("base_model") or ""
return hash_val, civitai, base_model
@staticmethod
def _get_civitai_sampler_name(sampler_name: str, scheduler: str) -> str:
if sampler_name in CIVITAI_SAMPLER_MAP:
civitai_name = CIVITAI_SAMPLER_MAP[sampler_name]
if scheduler == "karras":
civitai_name += " Karras"
elif scheduler == "exponential":
civitai_name += " Exponential"
return civitai_name
else:
if scheduler and scheduler != "normal":
return f"{sampler_name}_{scheduler}"
return sampler_name
@staticmethod
def _build_air_string(base_model: str, model_type: str, model_id: int, version_id: int) -> str:
slug = BASE_MODEL_AIR_SLUG.get(base_model, "other")
type_lower = model_type.lower() if model_type else "other"
return f"urn:air:{slug}:{type_lower}:civitai:{model_id}@{version_id}"
def format_metadata(self, metadata_dict: dict) -> str:
"""Format metadata as A1111-compatible parameters string with Hashes JSON and Civitai resources."""
if not metadata_dict: return ""
# Extract the prompt and negative prompt
prompt = metadata_dict.get("prompt", "") prompt = metadata_dict.get("prompt", "")
negative_prompt = metadata_dict.get("negative_prompt", "") negative_prompt = metadata_dict.get("negative_prompt", "")
steps = metadata_dict.get("steps")
# Extract loras from the prompt if present cfg = metadata_dict.get("guidance")
if cfg is None:
cfg = metadata_dict.get("cfg_scale")
if cfg is None:
cfg = metadata_dict.get("cfg")
seed = metadata_dict.get("seed")
size = metadata_dict.get("size")
sampler = metadata_dict.get("sampler") or ""
scheduler = metadata_dict.get("scheduler") or "normal"
checkpoint = metadata_dict.get("checkpoint") or ""
loras_text = metadata_dict.get("loras", "") loras_text = metadata_dict.get("loras", "")
lora_hashes = {} clip_skip = metadata_dict.get("clip_skip")
# If loras are found, add them on a new line after the prompt # Parse LoRA entries from <lora:name:strength> format
lora_entries: list[tuple[str, float]] = []
if loras_text: if loras_text:
prompt_with_loras = f"{prompt}\n{loras_text}" for match in re.findall(r"<lora:([^:]+):([^>]+)>", loras_text):
lora_name, strength_str = match
try:
strength = float(strength_str)
except (ValueError, TypeError):
strength = 1.0
lora_entries.append((lora_name, strength))
# Extract lora names from the format <lora:name:strength> # Resolve checkpoint hash and Civitai data from local cache
lora_matches = re.findall(r"<lora:([^:]+):([^>]+)>", loras_text) ckpt_hash, ckpt_civitai, ckpt_base_model = "", {}, ""
ckpt_display_name = ""
if checkpoint:
ckpt_hash, ckpt_civitai, ckpt_base_model = self._resolve_model_cache_entry(
"checkpoint_scanner", checkpoint
)
ckpt_display_name = os.path.splitext(os.path.basename(checkpoint))[0]
# Get hash for each lora # Resolve LoRA hash and Civitai data from local cache
for lora_name, strength in lora_matches: loras_data: list[dict] = []
hash_value = self.get_lora_hash(lora_name) for lora_name, strength in lora_entries:
if hash_value: lora_hash, lora_civitai, lora_base_model = self._resolve_model_cache_entry(
lora_hashes[lora_name] = hash_value "lora_scanner", lora_name
else: )
prompt_with_loras = prompt loras_data.append({
"name": lora_name,
"strength": strength,
"hash": lora_hash,
"civitai": lora_civitai,
"base_model": lora_base_model,
})
# Format the first part (prompt and loras) # Build Hashes JSON (A1111 / Civitai standard format)
metadata_parts = [prompt_with_loras] hashes: dict[str, str] = {}
if ckpt_hash:
hashes["model"] = ckpt_hash[:10].upper()
for lora in loras_data:
if lora["hash"]:
hashes[f"LORA:{lora['name']}"] = lora["hash"][:10].upper()
# Add negative prompt # Build Civitai resources JSON array
civitai_resources: list[dict] = []
if ckpt_civitai.get("id", 0) > 0:
ckpt_resource: dict = {}
ckpt_type = (ckpt_civitai.get("model") or {}).get("type", "Checkpoint")
model_id = ckpt_civitai.get("modelId", 0)
version_id = ckpt_civitai.get("id", 0)
if model_id and version_id:
ckpt_resource["air"] = self._build_air_string(
ckpt_base_model, ckpt_type, int(model_id), int(version_id)
)
elif version_id:
ckpt_resource["modelVersionId"] = int(version_id)
if ckpt_civitai.get("name"):
ckpt_resource["versionName"] = ckpt_civitai["name"]
if ckpt_resource:
civitai_resources.append(ckpt_resource)
for lora in loras_data:
lora_civitai = lora["civitai"]
if not lora_civitai or lora_civitai.get("id", 0) <= 0:
continue
lora_resource: dict = {"weight": lora["strength"]}
lora_type = (lora_civitai.get("model") or {}).get("type", "LORA")
model_id = lora_civitai.get("modelId", 0)
version_id = lora_civitai.get("id", 0)
if model_id and version_id:
lora_resource["air"] = self._build_air_string(
lora["base_model"], lora_type, int(model_id), int(version_id)
)
elif version_id:
lora_resource["modelVersionId"] = int(version_id)
if lora_civitai.get("name"):
lora_resource["versionName"] = lora_civitai["name"]
civitai_resources.append(lora_resource)
sampler_name = CIVITAI_SAMPLER_MAP.get(sampler, sampler) if sampler else None
scheduler_mapping = {
"normal": "Normal",
"karras": "Karras",
"exponential": "Exponential",
"sgm_uniform": "SGM Uniform",
"sgm_quadratic": "SGM Quadratic",
}
scheduler_name = scheduler_mapping.get(scheduler, scheduler) if scheduler else None
# Build output lines
lines = [prompt] if prompt else [""]
if negative_prompt: if negative_prompt:
metadata_parts.append(f"Negative prompt: {negative_prompt}") lines.append(f"Negative prompt: {negative_prompt}")
# Format the second part (generation parameters) params: list[str] = []
params = [] if steps is not None:
params.append(f"Steps: {steps}")
# Add standard parameters in the correct order
if "steps" in metadata_dict:
add_param_if_not_none(params, "Steps", metadata_dict.get("steps"))
# Combine sampler and scheduler information
sampler_name = None
scheduler_name = None
if "sampler" in metadata_dict:
sampler = metadata_dict.get("sampler")
# Convert ComfyUI sampler names to user-friendly names
sampler_mapping = {
"euler": "Euler",
"euler_ancestral": "Euler a",
"dpm_2": "DPM2",
"dpm_2_ancestral": "DPM2 a",
"heun": "Heun",
"dpm_fast": "DPM fast",
"dpm_adaptive": "DPM adaptive",
"lms": "LMS",
"dpmpp_2s_ancestral": "DPM++ 2S a",
"dpmpp_sde": "DPM++ SDE",
"dpmpp_sde_gpu": "DPM++ SDE",
"dpmpp_2m": "DPM++ 2M",
"dpmpp_2m_sde": "DPM++ 2M SDE",
"dpmpp_2m_sde_gpu": "DPM++ 2M SDE",
"ddim": "DDIM",
}
sampler_name = sampler_mapping.get(sampler, sampler)
if "scheduler" in metadata_dict:
scheduler = metadata_dict.get("scheduler")
scheduler_mapping = {
"normal": "Simple",
"karras": "Karras",
"exponential": "Exponential",
"sgm_uniform": "SGM Uniform",
"sgm_quadratic": "SGM Quadratic",
}
scheduler_name = scheduler_mapping.get(scheduler, scheduler)
# Add combined sampler and scheduler information
if sampler_name: if sampler_name:
if scheduler_name: if scheduler_name:
params.append(f"Sampler: {sampler_name} {scheduler_name}") params.append(f"Sampler: {sampler_name} {scheduler_name}")
else: else:
params.append(f"Sampler: {sampler_name}") params.append(f"Sampler: {sampler_name}")
if cfg is not None:
params.append(f"CFG scale: {cfg}")
if seed is not None:
params.append(f"Seed: {seed}")
if size:
params.append(f"Size: {size}")
if clip_skip is not None:
try:
params.append(f"Clip skip: {abs(int(clip_skip))}")
except (ValueError, TypeError):
pass
additional_data = metadata_dict.get("additional_data", "")
if additional_data:
params.append(additional_data)
if ckpt_hash:
params.append(f"Model hash: {ckpt_hash[:10].upper()}")
if ckpt_display_name:
params.append(f"Model: {ckpt_display_name}")
if hashes:
params.append(f"Hashes: {json.dumps(hashes, separators=(',', ':'))}")
params.append("Version: ComfyUI")
if civitai_resources:
params.append(
f"Civitai resources: {json.dumps(civitai_resources, separators=(',', ':'))}"
)
# CFG scale (Use guidance if available, otherwise fall back to cfg_scale or cfg) lines.append(", ".join(params))
if "guidance" in metadata_dict: return "\n".join(lines)
add_param_if_not_none(params, "CFG scale", metadata_dict.get("guidance"))
elif "cfg_scale" in metadata_dict:
add_param_if_not_none(params, "CFG scale", metadata_dict.get("cfg_scale"))
elif "cfg" in metadata_dict:
add_param_if_not_none(params, "CFG scale", metadata_dict.get("cfg"))
# Seed
if "seed" in metadata_dict:
add_param_if_not_none(params, "Seed", metadata_dict.get("seed"))
# Size
if "size" in metadata_dict:
add_param_if_not_none(params, "Size", metadata_dict.get("size"))
# Model info
if "checkpoint" in metadata_dict:
# Ensure checkpoint is a string before processing
checkpoint = metadata_dict.get("checkpoint")
if checkpoint is not None:
# Get model hash
model_hash = self.get_checkpoint_hash(checkpoint)
# Extract basename without path
checkpoint_name = os.path.basename(checkpoint)
# Remove extension if present
checkpoint_name = os.path.splitext(checkpoint_name)[0]
# Add model hash if available
if model_hash:
params.append(
f"Model hash: {model_hash[:10]}, Model: {checkpoint_name}"
)
else:
params.append(f"Model: {checkpoint_name}")
# Add LoRA hashes if available
if lora_hashes:
lora_hash_parts = []
for lora_name, hash_value in lora_hashes.items():
lora_hash_parts.append(f"{lora_name}: {hash_value[:10]}")
if lora_hash_parts:
params.append(f'Lora hashes: "{", ".join(lora_hash_parts)}"')
# Combine all parameters with commas
metadata_parts.append(", ".join(params))
# Join all parts with a new line
return "\n".join(metadata_parts)
# credit to nkchocoai # credit to nkchocoai
# Add format_filename method to handle pattern substitution # Add format_filename method to handle pattern substitution
@@ -573,6 +787,8 @@ class SaveImageLM:
extra_pnginfo=None, extra_pnginfo=None,
lossless_webp=True, lossless_webp=True,
quality=100, quality=100,
webp_method=6,
jpeg_subsampling=0,
embed_workflow=False, embed_workflow=False,
save_with_metadata=True, save_with_metadata=True,
add_counter_to_filename=True, add_counter_to_filename=True,
@@ -627,15 +843,14 @@ class SaveImageLM:
elif file_format == "jpeg": elif file_format == "jpeg":
file = base_filename + ".jpg" file = base_filename + ".jpg"
file_extension = ".jpg" file_extension = ".jpg"
save_kwargs = {"quality": quality, "optimize": True} save_kwargs = {"quality": quality, "optimize": True, "subsampling": jpeg_subsampling}
elif file_format == "webp": elif file_format == "webp":
file = base_filename + ".webp" file = base_filename + ".webp"
file_extension = ".webp" file_extension = ".webp"
# Add optimization param to control performance
save_kwargs = { save_kwargs = {
"quality": quality, "quality": quality,
"lossless": lossless_webp, "lossless": lossless_webp,
"method": 0, "method": webp_method,
} }
else: else:
raise ValueError(f"Unsupported file format: {file_format}") raise ValueError(f"Unsupported file format: {file_format}")
@@ -722,6 +937,8 @@ class SaveImageLM:
extra_pnginfo=None, extra_pnginfo=None,
lossless_webp=True, lossless_webp=True,
quality=100, quality=100,
webp_method=6,
jpeg_subsampling=0,
embed_workflow=False, embed_workflow=False,
save_with_metadata=True, save_with_metadata=True,
add_counter_to_filename=True, add_counter_to_filename=True,
@@ -751,6 +968,8 @@ class SaveImageLM:
extra_pnginfo, extra_pnginfo,
lossless_webp, lossless_webp,
quality, quality,
webp_method,
jpeg_subsampling,
embed_workflow, embed_workflow,
save_with_metadata, save_with_metadata,
add_counter_to_filename, add_counter_to_filename,
+5
View File
@@ -1562,6 +1562,11 @@ class SettingsHandler:
{"success": False, "error": validation_error} {"success": False, "error": validation_error}
) )
if key == "update_channel" and value not in ("release", "nightly"):
return web.json_response(
{"success": False, "error": "update_channel must be 'release' or 'nightly'"}
)
if value == "__DELETE__" and key in ( if value == "__DELETE__" and key in (
"proxy_username", "proxy_username",
"proxy_password", "proxy_password",
+55 -2
View File
@@ -394,12 +394,14 @@ class ModelListingHandler:
) )
# View-local-versions filter: show all local versions of a specific model # View-local-versions filter: show all local versions of a specific model
# Accepts either a CivitAI modelId (int) or a HF group key like "hf:user/repo"
civitai_model_id = request.query.get("civitai_model_id") civitai_model_id = request.query.get("civitai_model_id")
if civitai_model_id is not None: if civitai_model_id is not None:
try: try:
civitai_model_id = int(civitai_model_id) civitai_model_id = int(civitai_model_id)
except (TypeError, ValueError): except (TypeError, ValueError):
civitai_model_id = None # Keep as string — could be an HF group key (e.g. "hf:user/repo")
pass
return { return {
"page": page, "page": page,
@@ -537,6 +539,7 @@ class ModelManagementHandler:
# Update model_data with new hash # Update model_data with new hash
model_data["sha256"] = sha256 model_data["sha256"] = sha256
model_data["hash_status"] = "completed" model_data["hash_status"] = "completed"
hash_status = "completed"
else: else:
return web.json_response( return web.json_response(
{"success": False, "error": "No SHA256 hash found"}, status=400 {"success": False, "error": "No SHA256 hash found"}, status=400
@@ -544,6 +547,32 @@ class ModelManagementHandler:
await MetadataManager.hydrate_model_data(model_data) await MetadataManager.hydrate_model_data(model_data)
# hydrate_model_data replaces model_data with .metadata.json content,
# which may lack sha256. Restore from cache and persist the fix.
if not model_data.get("sha256"):
if sha256:
model_data["sha256"] = sha256
model_data["hash_status"] = model_data.get("hash_status", hash_status)
data_to_save = model_data.copy()
data_to_save.pop("folder", None)
await MetadataManager.save_metadata(file_path, data_to_save)
else:
sha256 = await calculate_sha256(file_path)
if sha256:
model_data["sha256"] = sha256.lower()
model_data["hash_status"] = "completed"
data_to_save = model_data.copy()
data_to_save.pop("folder", None)
await MetadataManager.save_metadata(file_path, data_to_save)
else:
return web.json_response(
{
"success": False,
"error": "Failed to compute SHA256 hash for model",
},
status=500,
)
success, error = await self._metadata_sync.fetch_and_update_model( success, error = await self._metadata_sync.fetch_and_update_model(
sha256=model_data["sha256"], sha256=model_data["sha256"],
file_path=file_path, file_path=file_path,
@@ -566,7 +595,12 @@ class ModelManagementHandler:
{"success": False, "error": OFFLINE_FRIENDLY_MESSAGE}, {"success": False, "error": OFFLINE_FRIENDLY_MESSAGE},
status=503, status=503,
) )
self._logger.error("Error fetching from CivitAI: %s", exc, exc_info=True) self._logger.error(
"Error fetching from CivitAI for %s: %s",
locals().get("file_path", "unknown"),
exc,
exc_info=True,
)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def relink_civitai(self, request: web.Request) -> web.Response: async def relink_civitai(self, request: web.Request) -> web.Response:
@@ -973,6 +1007,8 @@ class ModelQueryHandler:
limit = int(request.query.get("limit", "20")) limit = int(request.query.get("limit", "20"))
if limit < 0: if limit < 0:
limit = 20 limit = 20
elif limit > 200:
limit = 20
top_tags = await self._service.get_top_tags(limit) top_tags = await self._service.get_top_tags(limit)
return web.json_response({"success": True, "tags": top_tags}) return web.json_response({"success": True, "tags": top_tags})
except Exception as exc: except Exception as exc:
@@ -981,6 +1017,22 @@ class ModelQueryHandler:
{"success": False, "error": "Internal server error"}, status=500 {"success": False, "error": "Internal server error"}, status=500
) )
async def search_tags(self, request: web.Request) -> web.Response:
try:
query = request.query.get("q", "")
limit = int(request.query.get("limit", "20"))
if limit < 0:
limit = 20
elif limit > 200:
limit = 20
tags = await self._service.search_tags(query, limit)
return web.json_response({"success": True, "tags": tags})
except Exception as exc:
self._logger.error("Error searching tags: %s", exc, exc_info=True)
return web.json_response(
{"success": False, "error": "Internal server error"}, status=500
)
async def get_base_models(self, request: web.Request) -> web.Response: async def get_base_models(self, request: web.Request) -> web.Response:
try: try:
limit = int(request.query.get("limit", "20")) limit = int(request.query.get("limit", "20"))
@@ -2947,6 +2999,7 @@ class ModelHandlerSet:
"bulk_delete_models": self.management.bulk_delete_models, "bulk_delete_models": self.management.bulk_delete_models,
"verify_duplicates": self.management.verify_duplicates, "verify_duplicates": self.management.verify_duplicates,
"get_top_tags": self.query.get_top_tags, "get_top_tags": self.query.get_top_tags,
"search_tags": self.query.search_tags,
"get_base_models": self.query.get_base_models, "get_base_models": self.query.get_base_models,
"get_model_types": self.query.get_model_types, "get_model_types": self.query.get_model_types,
"scan_models": self.query.scan_models, "scan_models": self.query.scan_models,
+55 -6
View File
@@ -72,6 +72,7 @@ class RecipeHandlerSet:
"save_recipe": self.management.save_recipe, "save_recipe": self.management.save_recipe,
"delete_recipe": self.management.delete_recipe, "delete_recipe": self.management.delete_recipe,
"get_top_tags": self.query.get_top_tags, "get_top_tags": self.query.get_top_tags,
"search_tags": self.query.search_tags,
"get_base_models": self.query.get_base_models, "get_base_models": self.query.get_base_models,
"get_roots": self.query.get_roots, "get_roots": self.query.get_roots,
"get_folders": self.query.get_folders, "get_folders": self.query.get_folders,
@@ -317,12 +318,11 @@ class RecipeQueryHandler:
raise RuntimeError("Recipe scanner unavailable") raise RuntimeError("Recipe scanner unavailable")
limit = int(request.query.get("limit", "20")) limit = int(request.query.get("limit", "20"))
cache = await recipe_scanner.get_cached_data() if limit < 0:
limit = 20
tag_counts: Dict[str, int] = {} elif limit > 200:
for recipe in getattr(cache, "raw_data", []): limit = 20
for tag in recipe.get("tags", []) or []: tag_counts = await self._get_recipe_tag_counts(recipe_scanner)
tag_counts[tag] = tag_counts.get(tag, 0) + 1
sorted_tags = [ sorted_tags = [
{"tag": tag, "count": count} for tag, count in tag_counts.items() {"tag": tag, "count": count} for tag, count in tag_counts.items()
@@ -333,6 +333,55 @@ class RecipeQueryHandler:
self._logger.error("Error retrieving top tags: %s", exc, exc_info=True) self._logger.error("Error retrieving top tags: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def search_tags(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
query = request.query.get("q", "")
limit = int(request.query.get("limit", "20"))
if limit < 0:
limit = 20
elif limit > 200:
limit = 20
tag_counts = await self._get_recipe_tag_counts(recipe_scanner)
normalized_query = (query or "").strip().lower()
if not normalized_query:
sorted_tags = [
{"tag": tag, "count": count} for tag, count in tag_counts.items()
]
sorted_tags.sort(key=lambda entry: entry["count"], reverse=True)
return web.json_response(
{"success": True, "tags": sorted_tags[: (limit if limit > 0 else 20)]}
)
matched = [
{"tag": tag, "count": count}
for tag, count in tag_counts.items()
if normalized_query in tag.lower()
]
matched.sort(key=lambda entry: entry["count"], reverse=True)
if limit == 0:
result = matched
else:
result = matched[:limit]
return web.json_response({"success": True, "tags": result})
except Exception as exc:
self._logger.error("Error searching recipe tags: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def _get_recipe_tag_counts(self, recipe_scanner) -> Dict[str, int]:
"""Compute tag->count mapping from cached recipe data."""
cache = await recipe_scanner.get_cached_data()
tag_counts: Dict[str, int] = {}
for recipe in getattr(cache, "raw_data", []):
for tag in recipe.get("tags", []) or []:
tag_counts[tag] = tag_counts.get(tag, 0) + 1
return tag_counts
async def get_base_models(self, request: web.Request) -> web.Response: async def get_base_models(self, request: web.Request) -> web.Response:
try: try:
await self._ensure_dependencies_ready() await self._ensure_dependencies_ready()
+1
View File
@@ -46,6 +46,7 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
"GET", "/api/lm/{prefix}/auto-organize-progress", "get_auto_organize_progress" "GET", "/api/lm/{prefix}/auto-organize-progress", "get_auto_organize_progress"
), ),
RouteDefinition("GET", "/api/lm/{prefix}/top-tags", "get_top_tags"), RouteDefinition("GET", "/api/lm/{prefix}/top-tags", "get_top_tags"),
RouteDefinition("GET", "/api/lm/{prefix}/search-tags", "search_tags"),
RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"), RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"),
RouteDefinition("GET", "/api/lm/{prefix}/model-types", "get_model_types"), RouteDefinition("GET", "/api/lm/{prefix}/model-types", "get_model_types"),
RouteDefinition("GET", "/api/lm/{prefix}/scan", "scan_models"), RouteDefinition("GET", "/api/lm/{prefix}/scan", "scan_models"),
+1
View File
@@ -29,6 +29,7 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/recipes/save", "save_recipe"), RouteDefinition("POST", "/api/lm/recipes/save", "save_recipe"),
RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"), RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"),
RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"), RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"),
RouteDefinition("GET", "/api/lm/recipes/search-tags", "search_tags"),
RouteDefinition("GET", "/api/lm/recipes/base-models", "get_base_models"), RouteDefinition("GET", "/api/lm/recipes/base-models", "get_base_models"),
RouteDefinition("GET", "/api/lm/recipes/roots", "get_roots"), RouteDefinition("GET", "/api/lm/recipes/roots", "get_roots"),
RouteDefinition("GET", "/api/lm/recipes/folders", "get_folders"), RouteDefinition("GET", "/api/lm/recipes/folders", "get_folders"),
+313 -45
View File
@@ -38,6 +38,84 @@ def _clean_excludes() -> List[str]:
return excludes return excludes
def _stage_preserved_items(plugin_root: str) -> tuple[str, list[str]]:
"""Move preserved user-data items to a temp directory outside *plugin_root*.
This ensures that ``git reset --hard``, ``git clean -fd``, and ZIP-based
replacement cannot touch these files even when ``-e`` exclusion patterns
are mishandled (e.g. on Windows where forward-slash patterns may not
match backslash-prefixed paths in some Git builds, or where file locks
prevent deletion/recreation).
Returns:
``(backup_root, staged_names)``: the temp directory path and the
list of item names that were successfully moved.
"""
backup_root = tempfile.mkdtemp(prefix='lora_manager_update_')
staged: list[str] = []
for name in _PRESERVE_DIRS:
src = os.path.join(plugin_root, name)
if not os.path.lexists(src):
continue
dst = os.path.join(backup_root, name)
try:
shutil.move(src, dst)
staged.append(name)
logger.debug("Staged '%s' for update safety", name)
except OSError:
# ``shutil.move`` may fail on Windows if a file handle inside
# the directory is still open (e.g. a SQLite WAL file). Fall
# back to copy-then-remove.
logger.debug("Move failed for '%s', falling back to copy", name)
try:
if os.path.isdir(src) and not os.path.islink(src):
shutil.copytree(src, dst, symlinks=True)
shutil.rmtree(src, ignore_errors=True)
else:
shutil.copy2(src, dst)
os.remove(src)
staged.append(name)
logger.info("Copied (then removed) '%s' for update safety", name)
except Exception as exc:
logger.warning(
"Could not stage '%s': %s (will rely on git -e / skip lists)", name, exc
)
return backup_root, staged
def _restore_preserved_items(plugin_root: str, backup_root: str, staged: list[str]) -> None:
"""Move staged items back from *backup_root* into *plugin_root*.
Any leftover placeholder at the destination (created by git checkout or
ZIP extraction) is removed before the move.
"""
for name in staged:
src = os.path.join(backup_root, name)
dst = os.path.join(plugin_root, name)
try:
if os.path.lexists(dst):
if os.path.isdir(dst) and not os.path.islink(dst):
shutil.rmtree(dst, ignore_errors=True)
else:
os.remove(dst)
shutil.move(src, dst)
logger.debug("Restored '%s' after update", name)
except OSError:
logger.debug("Move failed restoring '%s', falling back to copy", name)
try:
if os.path.isdir(src) and not os.path.islink(src):
shutil.copytree(src, dst, symlinks=True, dirs_exist_ok=True)
shutil.rmtree(src, ignore_errors=True)
else:
shutil.copy2(src, dst)
os.remove(src)
logger.info("Copied '%s' back after update", name)
except Exception as exc:
logger.error("Failed to restore '%s': %s", name, exc)
shutil.rmtree(backup_root, ignore_errors=True)
class UpdateRoutes: class UpdateRoutes:
"""Routes for handling plugin update checks""" """Routes for handling plugin update checks"""
@@ -47,6 +125,7 @@ class UpdateRoutes:
app.router.add_get('/api/lm/check-updates', UpdateRoutes.check_updates) app.router.add_get('/api/lm/check-updates', UpdateRoutes.check_updates)
app.router.add_get('/api/lm/version-info', UpdateRoutes.get_version_info) app.router.add_get('/api/lm/version-info', UpdateRoutes.get_version_info)
app.router.add_post('/api/lm/perform-update', UpdateRoutes.perform_update) app.router.add_post('/api/lm/perform-update', UpdateRoutes.perform_update)
app.router.add_post('/api/lm/switch-channel', UpdateRoutes.switch_channel)
@staticmethod @staticmethod
async def check_updates(request): async def check_updates(request):
@@ -65,10 +144,17 @@ class UpdateRoutes:
# Fetch remote version from GitHub # Fetch remote version from GitHub
if nightly: if nightly:
remote_version, changelog = await UpdateRoutes._get_nightly_version() local_hash = git_info.get('short_hash', '')
releases = None nightly_version, releases_result = await asyncio.gather(
UpdateRoutes._get_nightly_version(local_hash),
UpdateRoutes._get_remote_version()
)
remote_version, _, behind_by, commit_date = nightly_version
_, changelog, releases = releases_result
else: else:
remote_version, changelog, releases = await UpdateRoutes._get_remote_version() remote_version, changelog, releases = await UpdateRoutes._get_remote_version()
behind_by = 0
commit_date = ''
# Compare versions # Compare versions
if nightly: if nightly:
@@ -81,6 +167,10 @@ class UpdateRoutes:
remote_version.replace('v', '') remote_version.replace('v', '')
) )
current_dir = os.path.dirname(os.path.abspath(__file__))
plugin_root = os.path.dirname(os.path.dirname(current_dir))
has_git = os.path.exists(os.path.join(plugin_root, '.git'))
response_data = { response_data = {
'success': True, 'success': True,
'current_version': local_version, 'current_version': local_version,
@@ -88,13 +178,13 @@ class UpdateRoutes:
'update_available': update_available, 'update_available': update_available,
'changelog': changelog, 'changelog': changelog,
'git_info': git_info, 'git_info': git_info,
'nightly': nightly 'nightly': nightly,
'has_git': has_git,
'releases': releases,
'behind_by': behind_by,
'commit_date': commit_date
} }
# Include releases list for stable mode
if releases is not None:
response_data['releases'] = releases
return web.json_response(response_data) return web.json_response(response_data)
except NETWORK_EXCEPTIONS as e: except NETWORK_EXCEPTIONS as e:
@@ -126,9 +216,14 @@ class UpdateRoutes:
# Format: version-short_hash # Format: version-short_hash
version_string = f"{local_version}-{short_hash}" version_string = f"{local_version}-{short_hash}"
current_dir = os.path.dirname(os.path.abspath(__file__))
plugin_root = os.path.dirname(os.path.dirname(current_dir))
has_git = os.path.exists(os.path.join(plugin_root, '.git'))
return web.json_response({ return web.json_response({
'success': True, 'success': True,
'version': version_string 'version': version_string,
'has_git': has_git
}) })
except Exception as e: except Exception as e:
@@ -156,20 +251,22 @@ class UpdateRoutes:
if os.path.exists(settings_path): if os.path.exists(settings_path):
with open(settings_path, 'r', encoding='utf-8') as f: with open(settings_path, 'r', encoding='utf-8') as f:
settings_backup = f.read() settings_backup = f.read()
logger.info("Backed up settings.json") logger.debug("Backed up settings.json (%d bytes)", len(settings_backup))
git_folder = os.path.join(plugin_root, '.git') staged_backup_dir, staged_items = _stage_preserved_items(plugin_root)
if os.path.exists(git_folder): try:
# Git update git_folder = os.path.join(plugin_root, '.git')
success, new_version = await UpdateRoutes._perform_git_update(plugin_root, nightly) if os.path.exists(git_folder):
else: success, new_version = await UpdateRoutes._perform_git_update(plugin_root, nightly)
# Fallback: Download ZIP and replace files else:
success, new_version = await UpdateRoutes._download_and_replace_zip(plugin_root) success, new_version = await UpdateRoutes._download_and_replace_zip(plugin_root)
finally:
_restore_preserved_items(plugin_root, staged_backup_dir, staged_items)
if settings_backup and success: if settings_backup and success:
with open(settings_path, 'w', encoding='utf-8') as f: with open(settings_path, 'w', encoding='utf-8') as f:
f.write(settings_backup) f.write(settings_backup)
logger.info("Restored settings.json") logger.debug("Restored settings.json content (%d bytes)", len(settings_backup))
if success: if success:
return web.json_response({ return web.json_response({
@@ -190,6 +287,164 @@ class UpdateRoutes:
'error': str(e) 'error': str(e)
}) })
@staticmethod
async def switch_channel(request):
"""
Switch between release and nightly update channels.
ZIP/CNR install Nightly: git init + checkout main (one-way upgrade)
Git install Release: git checkout latest tag (.git preserved)
ZIP/CNR install Release: ZIP download (no .git, stays in ZIP mode)
Git install Nightly: git checkout main + pull
"""
try:
body = await request.json() if request.has_body else {}
channel = body.get('channel', '')
if channel not in ('release', 'nightly'):
return web.json_response({
'success': False,
'error': f'Invalid channel: {channel}. Must be "release" or "nightly".'
})
current_dir = os.path.dirname(os.path.abspath(__file__))
plugin_root = os.path.dirname(os.path.dirname(current_dir))
settings_path = ensure_settings_file(logger)
settings_backup = None
if os.path.exists(settings_path):
with open(settings_path, 'r', encoding='utf-8') as f:
settings_backup = f.read()
logger.debug("Backed up settings.json before channel switch (%d bytes)", len(settings_backup))
staged_backup_dir, staged_items = _stage_preserved_items(plugin_root)
try:
git_folder = os.path.join(plugin_root, '.git')
if channel == 'nightly':
git_backup = None
if os.path.exists(git_folder):
git_backup = UpdateRoutes._backup_git(git_folder, 'nightly')
success = False
new_version = ''
try:
if os.path.exists(git_folder):
success, new_version = await UpdateRoutes._perform_git_update(
plugin_root, nightly=True
)
else:
success, new_version = UpdateRoutes._init_git_repo(plugin_root)
finally:
UpdateRoutes._restore_git(git_backup, git_folder, success, 'nightly')
else:
success = False
new_version = ''
if os.path.exists(git_folder):
success, new_version = await UpdateRoutes._perform_git_update(
plugin_root, nightly=False
)
else:
tracking_file = os.path.join(plugin_root, '.tracking')
if os.path.exists(tracking_file):
os.remove(tracking_file)
success, new_version = await UpdateRoutes._download_and_replace_zip(plugin_root)
finally:
_restore_preserved_items(plugin_root, staged_backup_dir, staged_items)
if settings_backup and success:
with open(settings_path, 'w', encoding='utf-8') as f:
f.write(settings_backup)
logger.debug("Restored settings.json content after channel switch (%d bytes)", len(settings_backup))
if success:
return web.json_response({
'success': True,
'channel': channel,
'new_version': new_version,
'message': f'Switched to {channel} channel'
})
else:
return web.json_response({
'success': False,
'error': f'Failed to switch to {channel} channel'
})
except Exception as e:
logger.error("Failed to switch channel: %s", e, exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
})
@staticmethod
def _init_git_repo(plugin_root: str) -> tuple[bool, str]:
"""
Initialize a Git repository in a ZIP-installed plugin folder.
Clones the remote history and checks out main branch.
"""
try:
import git
except ImportError:
logger.error(
"GitPython is not available: cannot initialize git repo. "
"Install git or set $GIT_PYTHON_GIT_EXECUTABLE to the git binary path."
)
return False, ""
clean_excludes = _clean_excludes()
try:
repo = git.Repo.init(plugin_root)
origin = repo.create_remote(
'origin',
'https://github.com/willmiao/ComfyUI-Lora-Manager.git'
)
origin.fetch()
repo.create_head('main', origin.refs.main)
repo.git.checkout('main', '--force')
repo.git.reset('--hard')
repo.git.clean('-fd', *clean_excludes)
tracking_file = os.path.join(plugin_root, '.tracking')
if os.path.exists(tracking_file):
os.remove(tracking_file)
logger.info("Removed .tracking file (now in git mode)")
new_version = f"main-{repo.head.commit.hexsha[:7]}"
logger.info("Initialized git repo on main branch: %s", new_version)
return True, new_version
except Exception as e:
logger.error("Failed to initialize git repo: %s", e, exc_info=True)
return False, ""
@staticmethod
def _backup_git(git_folder, label):
try:
backup_dir = tempfile.mkdtemp()
backup = os.path.join(backup_dir, '.git')
shutil.copytree(git_folder, backup)
logger.info("Backed up .git before switching to %s", label)
return backup
except Exception as e:
logger.error("Failed to backup .git before %s switch: %s", label, e)
return None
@staticmethod
def _restore_git(git_backup, git_folder, success, label):
if git_backup and not success:
try:
if os.path.exists(git_folder):
shutil.rmtree(git_folder)
shutil.copytree(git_backup, git_folder)
logger.info("Restored .git after failed %s switch", label)
except Exception as e:
logger.error("Failed to restore .git after %s switch: %s", label, e)
if git_backup:
shutil.rmtree(os.path.dirname(git_backup), ignore_errors=True)
@staticmethod @staticmethod
async def _download_and_replace_zip(plugin_root: str) -> tuple[bool, str]: async def _download_and_replace_zip(plugin_root: str) -> tuple[bool, str]:
""" """
@@ -244,8 +499,7 @@ class UpdateRoutes:
except Exception: except Exception:
logger.debug("Could not close downloaded-version history database", exc_info=True) logger.debug("Could not close downloaded-version history database", exc_info=True)
# Skip settings.json, civitai, model cache and runtime cache folders UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=list(_PRESERVE_DIRS))
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json', 'civitai', 'model_cache', 'cache', 'wildcards', 'backups', 'stats'])
# Extract ZIP to temp dir # Extract ZIP to temp dir
with tempfile.TemporaryDirectory() as tmp_dir: with tempfile.TemporaryDirectory() as tmp_dir:
@@ -255,7 +509,7 @@ class UpdateRoutes:
extracted_root = next(os.scandir(tmp_dir)).path extracted_root = next(os.scandir(tmp_dir)).path
# Copy files, skipping user data that should be preserved # Copy files, skipping user data that should be preserved
skip_items = {'settings.json', 'civitai', 'wildcards', 'backups', 'stats'} skip_items = set(_PRESERVE_DIRS)
for item in os.listdir(extracted_root): for item in os.listdir(extracted_root):
if item in skip_items: if item in skip_items:
continue continue
@@ -272,7 +526,7 @@ class UpdateRoutes:
# for ComfyUI Manager to work properly # for ComfyUI Manager to work properly
tracking_info_file = os.path.join(plugin_root, '.tracking') tracking_info_file = os.path.join(plugin_root, '.tracking')
tracking_files = [] tracking_files = []
skip_tracked = {'civitai', 'wildcards', 'backups', 'stats'} skip_tracked = set(_PRESERVE_DIRS) - {'settings.json'}
for root, dirs, files in os.walk(extracted_root): for root, dirs, files in os.walk(extracted_root):
# Skip user data directories and their contents # Skip user data directories and their contents
rel_root = os.path.relpath(root, extracted_root) rel_root = os.path.relpath(root, extracted_root)
@@ -295,7 +549,8 @@ class UpdateRoutes:
except Exception as e: except Exception as e:
logger.error(f"ZIP update failed: {e}", exc_info=True) logger.error(f"ZIP update failed: {e}", exc_info=True)
return False, "" return False, ""
@staticmethod
def _clean_plugin_folder(plugin_root, skip_files=None): def _clean_plugin_folder(plugin_root, skip_files=None):
skip_files = skip_files or [] skip_files = skip_files or []
for item in os.listdir(plugin_root): for item in os.listdir(plugin_root):
@@ -308,41 +563,54 @@ class UpdateRoutes:
os.remove(path) os.remove(path)
@staticmethod @staticmethod
async def _get_nightly_version() -> tuple[str, List[str]]: async def _get_nightly_version(local_hash: str = "") -> tuple[str, List[str], int, str]:
"""
Fetch latest commit from main branch
"""
repo_owner = "willmiao" repo_owner = "willmiao"
repo_name = "ComfyUI-Lora-Manager" repo_name = "ComfyUI-Lora-Manager"
# Use GitHub API to fetch the latest commit from main branch
github_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/commits/main" github_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/commits/main"
try: try:
downloader = await get_downloader() downloader = await get_downloader()
success, data = await downloader.make_request('GET', github_url, custom_headers={'Accept': 'application/vnd.github+json'}) success, data = await downloader.make_request(
'GET', github_url,
custom_headers={'Accept': 'application/vnd.github+json'}
)
if not success: if not success:
logger.warning(f"Failed to fetch GitHub commit: {data}") logger.warning("Failed to fetch GitHub commit: %s", data)
return "main", [] return "main", [], 0, ""
commit_sha = data.get('sha', '')[:7] # Short hash commit_sha = data.get('sha', '')[:7]
commit_message = data.get('commit', {}).get('message', '') commit_message = data.get('commit', {}).get('message', '')
commit_date = data.get('commit', {}).get('committer', {}).get('date', '')[:10]
# Format as "main-{short_hash}"
version = f"main-{commit_sha}" version = f"main-{commit_sha}"
# Use commit message as changelog
changelog = [commit_message] if commit_message else [] changelog = [commit_message] if commit_message else []
return version, changelog behind_by = 0
if local_hash and local_hash not in ('unknown', 'stable'):
compare_url = (
f"https://api.github.com/repos/{repo_owner}/{repo_name}"
f"/compare/{local_hash}...main"
)
c_ok, c_data = await downloader.make_request(
'GET', compare_url,
custom_headers={'Accept': 'application/vnd.github+json'}
)
if c_ok:
if c_data.get('status') in ('ahead', 'diverged'):
behind_by = c_data.get('ahead_by', 0)
else:
behind_by = c_data.get('behind_by', 0)
return version, changelog, behind_by, commit_date
except NETWORK_EXCEPTIONS as e: except NETWORK_EXCEPTIONS as e:
logger.warning("Unable to reach GitHub for nightly version: %s", e) logger.warning("Unable to reach GitHub for nightly version: %s", e)
return "main", [] return "main", [], 0, ""
except Exception as e: except Exception as e:
logger.error(f"Error fetching nightly version: {e}", exc_info=True) logger.error("Error fetching nightly version: %s", e, exc_info=True)
return "main", [] return "main", [], 0, ""
@staticmethod @staticmethod
def _compare_nightly_versions(local_git_info: Dict[str, str], remote_version: str) -> bool: def _compare_nightly_versions(local_git_info: Dict[str, str], remote_version: str) -> bool:
+51 -9
View File
@@ -1,7 +1,7 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
import asyncio import asyncio
import re import re
from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING from typing import Any, Dict, List, Optional, Type, Union, TYPE_CHECKING
import logging import logging
import os import os
import time import time
@@ -109,12 +109,15 @@ class BaseModelService(ABC):
if civitai_model_id is not None: if civitai_model_id is not None:
sorted_data = [ sorted_data = [
item for item in sorted_data item for item in sorted_data
if self._extract_model_id(item) == civitai_model_id if self._extract_group_key(item) == civitai_model_id
] ]
# VLM mode: always sort by version ID descending (newest version first), # VLM mode: always sort by version ID descending (newest version first),
# regardless of the current sort_by preference. # regardless of the current sort_by preference.
# Fall back to modified timestamp for non-CivitAI sources.
sorted_data.sort( sorted_data.sort(
key=lambda x: self._extract_version_id(x) or 0, key=lambda x: self._extract_version_id(x)
or x.get("modified", 0)
or 0,
reverse=True, reverse=True,
) )
@@ -129,18 +132,21 @@ class BaseModelService(ABC):
ufs = self.settings.get("version_grouping", "same_base") ufs = self.settings.get("version_grouping", "same_base")
group_by_base = ufs == "same_base" group_by_base = ufs == "same_base"
dedup_map = {} # (modelId [,base_model]) -> (item, version_id) dedup_map = {} # (modelId [,base_model]) -> (item, version_or_modified)
version_counter = {} # same-key -> count version_counter = {} # same-key -> count
standalone = [] standalone = []
for item in sorted_data: for item in sorted_data:
mid = self._extract_model_id(item) mid = self._extract_group_key(item)
if mid is None: if mid is None:
standalone.append(item) standalone.append(item)
continue continue
key = (mid, item.get("base_model") or "") if group_by_base else mid key = (mid, item.get("base_model") or "") if group_by_base else mid
# Count all versions per key # Count all versions per key
version_counter[key] = version_counter.get(key, 0) + 1 version_counter[key] = version_counter.get(key, 0) + 1
vid = self._extract_version_id(item) or 0 # Prefer CivitAI version_id; fall back to modified timestamp
vid = self._extract_version_id(item)
if vid is None:
vid = item.get("modified", 0) or 0
if key not in dedup_map or vid > dedup_map[key][1]: if key not in dedup_map or vid > dedup_map[key][1]:
dedup_map[key] = (item, vid) dedup_map[key] = (item, vid)
# Attach version_count to each surviving grouped item (shallow copy # Attach version_count to each surviving grouped item (shallow copy
@@ -174,16 +180,19 @@ class BaseModelService(ABC):
model_groups: Dict[Any, List[Dict]] = {} model_groups: Dict[Any, List[Dict]] = {}
ungrouped_standalone: List[Dict] = [] ungrouped_standalone: List[Dict] = []
for item in sorted_data: for item in sorted_data:
mid = self._extract_model_id(item) mid = self._extract_group_key(item)
if mid is None: if mid is None:
ungrouped_standalone.append(item) ungrouped_standalone.append(item)
continue continue
key = (mid, item.get("base_model") or "") if group_by_base else mid key = (mid, item.get("base_model") or "") if group_by_base else mid
model_groups.setdefault(key, []).append(item) model_groups.setdefault(key, []).append(item)
# Sort versions within each group by version id descending # Sort versions within each group by version id (descending);
# fall back to modified timestamp for non-CivitAI sources.
for items in model_groups.values(): for items in model_groups.values():
items.sort( items.sort(
key=lambda x: self._extract_version_id(x) or 0, key=lambda x: self._extract_version_id(x)
or x.get("modified", 0)
or 0,
reverse=True, reverse=True,
) )
# Sort groups by version count # Sort groups by version count
@@ -697,6 +706,33 @@ class BaseModelService(ABC):
return annotated return annotated
@staticmethod
def _extract_hf_group_key(item: Dict) -> Optional[str]:
"""Extract `hf:{owner}/{repo}` from item's ``hf_url``, or None."""
hf_url = item.get("hf_url") if isinstance(item, dict) else None
if not hf_url or not isinstance(hf_url, str):
return None
m = re.match(
r"https?://huggingface\.co/([^/]+/[^/]+)", hf_url.strip()
)
if not m:
return None
return f"hf:{m.group(1)}"
@staticmethod
def _extract_group_key(item: Dict) -> Union[int, str, None]:
"""Return the group identity key: CivitAI modelId (int) or HF repo (str).
Preference order:
1. CivitAI ``modelId`` (int)
2. HF repo identity ``hf:{owner}/{repo}`` (str)
3. ``None`` (no known grouping source)
"""
mid = BaseModelService._extract_model_id(item)
if mid is not None:
return mid
return BaseModelService._extract_hf_group_key(item)
@staticmethod @staticmethod
def _extract_model_id(item: Dict) -> Optional[int]: def _extract_model_id(item: Dict) -> Optional[int]:
civitai = item.get("civitai") if isinstance(item, dict) else None civitai = item.get("civitai") if isinstance(item, dict) else None
@@ -804,6 +840,12 @@ class BaseModelService(ABC):
"""Get top tags sorted by frequency""" """Get top tags sorted by frequency"""
return await self.scanner.get_top_tags(limit) return await self.scanner.get_top_tags(limit)
async def search_tags(
self, query: str, limit: int = 50
) -> List[Dict]:
"""Search tags by substring, sorted by frequency"""
return await self.scanner.search_tags(query, limit)
async def get_base_models(self, limit: int = 20) -> List[Dict]: async def get_base_models(self, limit: int = 20) -> List[Dict]:
"""Get base models sorted by frequency""" """Get base models sorted by frequency"""
return await self.scanner.get_base_models(limit) return await self.scanner.get_base_models(limit)
+22
View File
@@ -1389,7 +1389,17 @@ class DownloadManager:
# Update save directory with relative path if provided # Update save directory with relative path if provided
if relative_path: if relative_path:
base_save_dir = save_dir
save_dir = os.path.join(save_dir, relative_path) save_dir = os.path.join(save_dir, relative_path)
# Security: validate path containment after joining
resolved_dir = os.path.abspath(os.path.normpath(save_dir))
base_dir = os.path.abspath(os.path.normpath(base_save_dir))
if not resolved_dir.startswith(base_dir + os.sep) and resolved_dir != base_dir:
logger.warning(
"Path traversal detected: %s escapes %s",
resolved_dir, base_dir,
)
return {"success": False, "error": "Download path is outside allowed directory"}
# Create directory if it doesn't exist # Create directory if it doesn't exist
os.makedirs(save_dir, exist_ok=True) os.makedirs(save_dir, exist_ok=True)
@@ -1827,6 +1837,9 @@ class DownloadManager:
model_tags, model_type model_tags, model_type
) )
if not first_tag:
first_tag = "no tags" # Default if no tags available
# Format the template with available data # Format the template with available data
formatted_path = path_template formatted_path = path_template
formatted_path = formatted_path.replace("{base_model}", mapped_base_model) formatted_path = formatted_path.replace("{base_model}", mapped_base_model)
@@ -1842,6 +1855,15 @@ class DownloadManager:
if model_type == "embedding": if model_type == "embedding":
formatted_path = formatted_path.replace(" ", "_") formatted_path = formatted_path.replace(" ", "_")
# Sanitize the resolved path to prevent path traversal:
# - Strip leading slashes (prevents os.path.join from treating path as absolute)
# - Collapse double slashes from empty placeholder substitutions
# - Strip trailing slashes for cleanliness
formatted_path = formatted_path.lstrip("/")
while "//" in formatted_path:
formatted_path = formatted_path.replace("//", "/")
formatted_path = formatted_path.rstrip("/")
return formatted_path return formatted_path
async def _execute_download( async def _execute_download(
+34 -2
View File
@@ -31,7 +31,7 @@ class DownloadQueueService:
_instance: Optional[DownloadQueueService] = None _instance: Optional[DownloadQueueService] = None
_class_lock: asyncio.Lock = asyncio.Lock() _class_lock: asyncio.Lock = asyncio.Lock()
_SCHEMA = """ _SCHEMA_TABLES = """
CREATE TABLE IF NOT EXISTS download_queue ( CREATE TABLE IF NOT EXISTS download_queue (
download_id TEXT PRIMARY KEY, download_id TEXT PRIMARY KEY,
model_id INTEGER, model_id INTEGER,
@@ -74,6 +74,9 @@ 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 = """
CREATE UNIQUE INDEX IF NOT EXISTS idx_dh_download_id CREATE UNIQUE INDEX IF NOT EXISTS idx_dh_download_id
ON download_history(download_id) WHERE download_id IS NOT NULL; ON download_history(download_id) WHERE download_id IS NOT NULL;
""" """
@@ -115,10 +118,39 @@ class DownloadQueueService:
if self._schema_initialized: if self._schema_initialized:
return return
with self._connect() as conn: with self._connect() as conn:
conn.executescript(self._SCHEMA) conn.executescript(self._SCHEMA_TABLES)
# Creating the unique index on download_history.download_id can
# fail if pre-existing rows have duplicate values (e.g. from a
# previous version that lacked the index). Deduplicate first so
# that the migration does not crash on startup.
if not self._index_exists(conn, "idx_dh_download_id"):
self._remove_duplicate_download_ids(conn)
conn.executescript(self._CREATE_UNIQUE_INDEX)
conn.commit() conn.commit()
self._schema_initialized = True self._schema_initialized = True
@staticmethod
def _index_exists(conn: sqlite3.Connection, name: str) -> bool:
return conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='index' AND name=?",
(name,),
).fetchone() is not None
@staticmethod
def _remove_duplicate_download_ids(conn: sqlite3.Connection) -> None:
conn.execute("""
DELETE FROM download_history
WHERE id NOT IN (
SELECT MIN(id)
FROM download_history
WHERE download_id IS NOT NULL
GROUP BY download_id
)
AND download_id IS NOT NULL
""")
def get_database_path(self) -> str: def get_database_path(self) -> str:
"""Return the resolved database file path.""" """Return the resolved database file path."""
return self._db_path return self._db_path
+42 -8
View File
@@ -566,18 +566,52 @@ class LLMService:
if effective_max is None: if effective_max is None:
effective_max = 4096 effective_max = 4096
result = await self.chat_completion( # Use json_schema (not json_object) for broader provider compatibility:
messages=messages, # LM Studio and some other OpenAI-compatible servers reject
model=model, # json_object but accept json_schema. {"type": "object"} is
temperature=temperature, # functionally equivalent — it accepts any JSON object without
response_format={"type": "json_object"}, # constraining specific fields.
max_tokens=effective_max, response_format = {
) "type": "json_schema",
"json_schema": {
"name": "metadata",
"schema": {"type": "object"},
},
}
try:
result = await self.chat_completion(
messages=messages,
model=model,
temperature=temperature,
response_format=response_format,
max_tokens=effective_max,
)
except LLMResponseError as e:
# Only fall back when the provider rejects the response_format
# type value (e.g. "'response_format.type' must be..."). Avoid
# catching unrelated 400 errors whose body happens to mention
# "response_format" (e.g. "model does not support
# response_format restrictions on this endpoint").
if "'response_format.type'" not in str(e).lower():
raise
logger.info(
"Provider rejected response_format, retrying without it. "
"Falling back to prompt-only JSON mode. Error: %s",
e,
)
result = await self.chat_completion(
messages=messages,
model=model,
temperature=temperature,
response_format=None,
max_tokens=effective_max,
)
content = result.get("content", "") or "" content = result.get("content", "") or ""
if not content: if not content:
raise LLMResponseError( raise LLMResponseError(
"LLM returned empty content in json_object mode. " "LLM returned empty content. "
f"Raw response: {json.dumps(result)[:500]}" f"Raw response: {json.dumps(result)[:500]}"
) )
+4
View File
@@ -8,6 +8,7 @@ from abc import ABC, abstractmethod
from ..utils.utils import calculate_relative_path_for_model, remove_empty_dirs from ..utils.utils import calculate_relative_path_for_model, remove_empty_dirs
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE
from ..services.settings_manager import get_settings_manager from ..services.settings_manager import get_settings_manager
from ..services.model_lifecycle_service import _require_path_in_library_roots
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -493,6 +494,9 @@ class ModelMoveService:
Dictionary with move result Dictionary with move result
""" """
try: try:
_require_path_in_library_roots(file_path, self.scanner, label="Source path")
_require_path_in_library_roots(target_path, self.scanner, label="Target path")
if use_default_paths: if use_default_paths:
# Find the model in cache to get metadata # Find the model in cache to get metadata
cache = await self.scanner.get_cached_data() cache = await self.scanner.get_cached_data()
+41
View File
@@ -48,6 +48,36 @@ async def delete_model_artifacts(
return deleted return deleted
def _require_path_in_library_roots(file_path: str, scanner, *, label: str = "path") -> None:
"""Raise ``ValueError`` if *file_path* is not inside a configured model root.
Uses ``os.path.abspath()`` (NOT ``realpath``) to resolve ``..`` and ``.``
while preserving symlinks this keeps the check in business-path space.
Skips when the scanner does not expose ``get_model_roots`` or the list
is empty.
"""
roots = None
if hasattr(scanner, "get_model_roots"):
try:
roots = scanner.get_model_roots()
except NotImplementedError:
roots = None
if not roots:
return
resolved = os.path.abspath(os.path.normpath(file_path))
for root in roots:
root_resolved = os.path.abspath(os.path.normpath(root))
if resolved == root_resolved or resolved.startswith(root_resolved + os.sep):
return
raise ValueError(
f"{label} '{file_path}' is outside configured library directories"
)
class ModelLifecycleService: class ModelLifecycleService:
"""Co-ordinate destructive and mutating model operations.""" """Co-ordinate destructive and mutating model operations."""
@@ -74,6 +104,8 @@ class ModelLifecycleService:
if not file_path: if not file_path:
raise ValueError("Model path is required") raise ValueError("Model path is required")
_require_path_in_library_roots(file_path, self._scanner, label="File path")
cache = await self._scanner.get_cached_data() cache = await self._scanner.get_cached_data()
cached_entry = None cached_entry = None
@@ -182,6 +214,8 @@ class ModelLifecycleService:
if not file_path: if not file_path:
raise ValueError("Model path is required") raise ValueError("Model path is required")
_require_path_in_library_roots(file_path, self._scanner, label="File path")
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json" metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
metadata = await self._metadata_loader(metadata_path) metadata = await self._metadata_loader(metadata_path)
metadata["exclude"] = True metadata["exclude"] = True
@@ -229,6 +263,8 @@ class ModelLifecycleService:
if not file_path: if not file_path:
raise ValueError("Model path is required") raise ValueError("Model path is required")
_require_path_in_library_roots(file_path, self._scanner, label="File path")
if not os.path.exists(file_path): if not os.path.exists(file_path):
raise ValueError("Model file does not exist") raise ValueError("Model file does not exist")
@@ -270,6 +306,9 @@ class ModelLifecycleService:
if not file_paths: if not file_paths:
raise ValueError("No file paths provided for deletion") raise ValueError("No file paths provided for deletion")
for path in file_paths:
_require_path_in_library_roots(path, self._scanner, label="File path")
return await self._scanner.bulk_delete_models(file_paths) return await self._scanner.bulk_delete_models(file_paths)
async def rename_model( async def rename_model(
@@ -280,6 +319,8 @@ class ModelLifecycleService:
if not file_path or not new_file_name: if not file_path or not new_file_name:
raise ValueError("File path and new file name are required") raise ValueError("File path and new file name are required")
_require_path_in_library_roots(file_path, self._scanner, label="File path")
invalid_chars = {"/", "\\", ":", "*", "?", '"', "<", ">", "|"} invalid_chars = {"/", "\\", ":", "*", "?", '"', "<", ">", "|"}
if any(char in new_file_name for char in invalid_chars): if any(char in new_file_name for char in invalid_chars):
raise ValueError("Invalid characters in file name") raise ValueError("Invalid characters in file name")
+67 -11
View File
@@ -14,7 +14,7 @@ from ..utils.metadata_manager import MetadataManager
from ..utils.civitai_utils import resolve_license_info from ..utils.civitai_utils import resolve_license_info
from .model_cache import ModelCache from .model_cache import ModelCache
from .model_hash_index import ModelHashIndex from .model_hash_index import ModelHashIndex
from .model_lifecycle_service import delete_model_artifacts from .model_lifecycle_service import delete_model_artifacts, _require_path_in_library_roots
from .service_registry import ServiceRegistry from .service_registry import ServiceRegistry
from .websocket_manager import ws_manager from .websocket_manager import ws_manager
from .persistent_model_cache import get_persistent_cache from .persistent_model_cache import get_persistent_cache
@@ -927,6 +927,25 @@ class ModelScanner:
# Update cache data # Update cache data
self._cache.raw_data = [item for item in self._cache.raw_data if item['file_path'] not in missing_files] self._cache.raw_data = [item for item in self._cache.raw_data if item['file_path'] not in missing_files]
dedup_removed = 0
seen_paths: set = set()
deduped: list = []
for item in reversed(self._cache.raw_data):
path = item.get('file_path', '')
if path not in seen_paths:
seen_paths.add(path)
deduped.append(item)
else:
for tag in item.get('tags', []):
if tag in self._tags_count:
self._tags_count[tag] = max(0, self._tags_count[tag] - 1)
if self._tags_count[tag] == 0:
del self._tags_count[tag]
dedup_removed += 1
if dedup_removed > 0:
self._cache.raw_data = list(reversed(deduped))
total_removed += dedup_removed
# Resort cache if changes were made # Resort cache if changes were made
if total_added > 0 or total_removed > 0: if total_added > 0 or total_removed > 0:
# Update folders list # Update folders list
@@ -1352,18 +1371,25 @@ class ModelScanner:
# Update folder in metadata # Update folder in metadata
metadata_dict['folder'] = folder metadata_dict['folder'] = folder
# Add to cache file_path = metadata_dict.get('file_path', '')
self._cache.raw_data.append(metadata_dict) if file_path:
self._cache.add_to_version_index(metadata_dict) old_entries = [item for item in self._cache.raw_data if item.get('file_path') == file_path]
for old_entry in old_entries:
for tag in old_entry.get('tags', []):
if tag in self._tags_count:
self._tags_count[tag] = max(0, self._tags_count[tag] - 1)
if self._tags_count[tag] == 0:
del self._tags_count[tag]
self._hash_index.remove_by_path(file_path)
self._cache.raw_data = [item for item in self._cache.raw_data if item.get('file_path') != file_path]
for tag in metadata_dict.get('tags', []):
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
self._cache.raw_data.append(metadata_dict)
# Resort cache data
await self._cache.resort() await self._cache.resort()
# Update folders list
all_folders = set(self._cache.folders)
all_folders.add(folder)
self._cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
# Update the hash index # Update the hash index
self._hash_index.add_entry(metadata_dict['sha256'], metadata_dict['file_path']) self._hash_index.add_entry(metadata_dict['sha256'], metadata_dict['file_path'])
await self._persist_current_cache() await self._persist_current_cache()
@@ -1394,6 +1420,9 @@ class ModelScanner:
base_name = os.path.splitext(os.path.basename(source_path))[0] base_name = os.path.splitext(os.path.basename(source_path))[0]
source_dir = os.path.dirname(source_path) source_dir = os.path.dirname(source_path)
_require_path_in_library_roots(source_path, self, label="Source path")
_require_path_in_library_roots(target_path, self, label="Target path")
os.makedirs(target_path, exist_ok=True) os.makedirs(target_path, exist_ok=True)
@@ -1830,7 +1859,32 @@ class ModelScanner:
if limit == 0: if limit == 0:
return sorted_tags return sorted_tags
return sorted_tags[:limit] return sorted_tags[:limit]
async def search_tags(
self, query: str, limit: int = 50
) -> List[Dict[str, any]]:
"""Search tags by case-insensitive substring match, sorted by count.
If query is empty, behaves like get_top_tags (returns top ``limit``
tags). If limit is 0, all matching tags are returned.
"""
await self.get_cached_data()
normalized_query = (query or "").strip().lower()
if not normalized_query:
return await self.get_top_tags(limit if limit > 0 else 20)
matched = [
{"tag": tag, "count": count}
for tag, count in self._tags_count.items()
if normalized_query in tag.lower()
]
matched.sort(key=lambda x: x["count"], reverse=True)
if limit == 0:
return matched
return matched[:limit]
async def get_base_models(self, limit: int = 20) -> List[Dict[str, any]]: async def get_base_models(self, limit: int = 20) -> List[Dict[str, any]]:
"""Get base models sorted by count. If limit is 0, return all.""" """Get base models sorted by count. If limit is 0, return all."""
cache = await self.get_cached_data() cache = await self.get_cached_data()
@@ -1946,6 +2000,8 @@ class ModelScanner:
break break
try: try:
_require_path_in_library_roots(file_path, self, label="File path")
target_dir = os.path.dirname(file_path) target_dir = os.path.dirname(file_path)
base_name = os.path.basename(file_path) base_name = os.path.basename(file_path)
file_name, main_extension = os.path.splitext(base_name) file_name, main_extension = os.path.splitext(base_name)
+16 -48
View File
@@ -21,7 +21,7 @@ from .checkpoint_scanner import CheckpointScanner
from .settings_manager import get_settings_manager from .settings_manager import get_settings_manager
from .recipes.errors import RecipeNotFoundError from .recipes.errors import RecipeNotFoundError
from ..utils.civitai_utils import extract_civitai_image_id from ..utils.civitai_utils import extract_civitai_image_id
from ..utils.utils import calculate_recipe_fingerprint, fuzzy_match from ..utils.utils import calculate_recipe_fingerprint
from natsort import natsorted from natsort import natsorted
import sys import sys
import re import re
@@ -1020,13 +1020,16 @@ class RecipeScanner:
try: try:
result = self._fts_index.search(search, fields) result = self._fts_index.search(search, fields)
# Return None if empty to trigger fuzzy fallback # Return empty set for empty FTS results — do NOT fall back to
# Empty FTS results may indicate query syntax issues or need for fuzzy matching # Python fuzzy matching, which freezes the server with 10k+ recipes.
# FTS5 prefix matching with unicode61 tokenizer correctly handles
# compound tokens (e.g. "illustrious" matches "path/illustrious/model").
# If FTS returns nothing, there are genuinely no matching recipes.
if not result: if not result:
return None return set()
return result return result
except Exception as exc: except Exception as exc:
logger.debug("FTS search failed, falling back to fuzzy search: %s", exc) logger.debug("FTS search failed, falling back to title-only search: %s", exc)
return None return None
def _update_fts_index_for_recipe( def _update_fts_index_for_recipe(
@@ -2079,49 +2082,14 @@ class RecipeScanner:
if str(item.get("id", "")) in fts_matching_ids if str(item.get("id", "")) in fts_matching_ids
] ]
else: else:
# Fallback to fuzzy_match (slower but always available) # FTS index not yet built — return empty rather than
# Build the search predicate based on search options # scanning 42k+ items in Python. The FTS background build
def matches_search(item): # finishes in seconds; by the time a user navigates here
# Search in title if enabled # and types a search, it is already available.
if search_options.get("title", True): logger.debug(
if fuzzy_match(str(item.get("title", "")), search): "FTS index not ready — search '%s' returning empty", search
return True )
filtered_data = []
# Search in tags if enabled
if search_options.get("tags", True) and "tags" in item:
for tag in item["tags"]:
if fuzzy_match(tag, search):
return True
# Search in lora file names if enabled
if search_options.get("lora_name", True) and "loras" in item:
for lora in item["loras"]:
if fuzzy_match(str(lora.get("file_name", "")), search):
return True
# Search in lora model names if enabled
if search_options.get("lora_model", True) and "loras" in item:
for lora in item["loras"]:
if fuzzy_match(str(lora.get("modelName", "")), search):
return True
# Search in prompt and negative_prompt if enabled
if search_options.get("prompt", True) and "gen_params" in item:
gen_params = item["gen_params"]
if fuzzy_match(str(gen_params.get("prompt", "")), search):
return True
if fuzzy_match(
str(gen_params.get("negative_prompt", "")), search
):
return True
# No match found
return False
# Filter the data using the search predicate
filtered_data = [
item for item in filtered_data if matches_search(item)
]
# Apply additional filters # Apply additional filters
if filters: if filters:
@@ -126,6 +126,7 @@ class BulkMetadataRefreshUseCase:
if sha256: if sha256:
model["sha256"] = sha256 model["sha256"] = sha256
model["hash_status"] = "completed" model["hash_status"] = "completed"
hash_status = "completed"
else: else:
self._logger.error(f"Failed to calculate hash for {file_path}") self._logger.error(f"Failed to calculate hash for {file_path}")
failures.append({"name": model.get("model_name", file_path or "Unknown"), "error": "Failed to calculate hash"}) failures.append({"name": model.get("model_name", file_path or "Unknown"), "error": "Failed to calculate hash"})
@@ -148,6 +149,16 @@ class BulkMetadataRefreshUseCase:
continue continue
await MetadataManager.hydrate_model_data(model) await MetadataManager.hydrate_model_data(model)
# hydrate_model_data replaces model with .metadata.json content,
# which may lack sha256. Restore from cache and persist the fix.
if not model.get("sha256"):
model["sha256"] = sha256
model["hash_status"] = model.get("hash_status", hash_status)
data_to_save = model.copy()
data_to_save.pop("folder", None)
await MetadataManager.save_metadata(file_path, data_to_save)
result, error_msg = await self._metadata_sync.fetch_and_update_model( result, error_msg = await self._metadata_sync.fetch_and_update_model(
sha256=model["sha256"], sha256=model["sha256"],
file_path=model["file_path"], file_path=model["file_path"],
+36 -3
View File
@@ -19,7 +19,7 @@ logger = logging.getLogger(__name__)
_WILDCARD_PATTERN = re.compile(r"__([\w\s.\-+/*\\]+?)__") _WILDCARD_PATTERN = re.compile(r"__([\w\s.\-+/*\\]+?)__")
_OPTION_PATTERN = re.compile(r"{([^{}]*?)}") _OPTION_PATTERN = re.compile(r"{([^{}]*?)}")
_TRIGGER_WORD_PATTERN = re.compile(r"^trigger_words\d+$") _TRIGGER_WORD_PATTERN = re.compile(r"^trigger_words\d+$")
_WEIGHTED_OPTION_PATTERN = re.compile(r"^\s*([0-9.]+)::") _WEIGHTED_OPTION_PATTERN = re.compile(r"^\s*-?\d+(\.\d+)?::")
_NUMERIC_PATTERN = re.compile(r"^-?\d+(\.\d+)?$") _NUMERIC_PATTERN = re.compile(r"^-?\d+(\.\d+)?$")
@@ -390,7 +390,7 @@ class WildcardService:
) -> str | None: ) -> str | None:
keyword = _normalize_wildcard_key(raw_key) keyword = _normalize_wildcard_key(raw_key)
if keyword in wildcard_dict: if keyword in wildcard_dict:
return rng.choice(wildcard_dict[keyword]) return self._pick_weighted_or_plain(wildcard_dict[keyword], rng)
if "*" in keyword: if "*" in keyword:
regex_pattern = keyword.replace("*", ".*").replace("+", r"\+") regex_pattern = keyword.replace("*", ".*").replace("+", r"\+")
@@ -400,7 +400,7 @@ class WildcardService:
if compiled.match(key): if compiled.match(key):
aggregated.extend(values) aggregated.extend(values)
if aggregated: if aggregated:
return rng.choice(aggregated) return self._pick_weighted_or_plain(aggregated, rng)
if "/" not in keyword: if "/" not in keyword:
fallback_keyword = _normalize_wildcard_key(f"*/{keyword}") fallback_keyword = _normalize_wildcard_key(f"*/{keyword}")
@@ -409,6 +409,39 @@ class WildcardService:
return None return None
def _pick_weighted_or_plain(
self, values: list[str], rng: random.Random
) -> str:
"""Pick a value from the list, respecting N::weight prefix if present.
When any value in the list uses the ``N::value`` weighted syntax with a
weight different from 1, the pick uses weighted random selection. When
no such weighting is present, a plain ``rng.choice`` is used (preserving
backward compatibility for unweighted wildcard files).
In either case the ``N::`` prefix is always stripped from the returned
value, matching the behaviour of ``{...}`` option groups.
"""
# Fast path: skip weighting logic entirely when no :: syntax exists
if not any("::" in v for v in values):
return rng.choice(values)
weighted_options: list[tuple[float, str]] = []
for value in values:
weight = 1.0
parts = value.split("::", 1)
if len(parts) == 2 and _is_numeric_string(parts[0].strip()):
weight = float(parts[0].strip())
weighted_options.append((weight, value))
any_weighted = any(w != 1.0 for w, _ in weighted_options)
if any_weighted:
picked = self._weighted_choice(weighted_options, rng)
else:
picked = rng.choice(values)
return self._strip_weight_prefix(picked)
def is_trigger_words_input(name: str) -> bool: def is_trigger_words_input(name: str) -> bool:
return bool(_TRIGGER_WORD_PATTERN.match(name)) return bool(_TRIGGER_WORD_PATTERN.match(name))
+1
View File
@@ -12,6 +12,7 @@ NODE_TYPES = {
"Lora Loader (LoraManager)": 1, "Lora Loader (LoraManager)": 1,
"Lora Stacker (LoraManager)": 2, "Lora Stacker (LoraManager)": 2,
"WanVideo Lora Select (LoraManager)": 3, "WanVideo Lora Select (LoraManager)": 3,
"Create Hook LoRA (LoraManager)": 4,
} }
# Default ComfyUI node color when bgcolor is null # Default ComfyUI node color when bgcolor is null
+17 -33
View File
@@ -475,13 +475,19 @@ class MetadataUpdater:
return False return False
model_folder = get_model_folder(model_hash) model_folder = get_model_folder(model_hash)
if not model_folder: if not model_folder or not os.path.isdir(model_folder):
return False return False
civitai = getattr(metadata, "civitai", None) civitai = getattr(metadata, "civitai", None)
if not isinstance(civitai, dict): if not isinstance(civitai, dict):
return False return False
# Read the directory listing once so every image entry reuses it.
try:
dir_entries = os.listdir(model_folder)
except OSError:
dir_entries = []
has_changes = False has_changes = False
custom_images = civitai.get("customImages") custom_images = civitai.get("customImages")
@@ -493,24 +499,15 @@ class MetadataUpdater:
if not img_id: if not img_id:
continue continue
if not os.path.isdir(model_folder): prefix = f"custom_{img_id}"
found = any(
f.startswith(prefix) and os.path.isfile(
os.path.join(model_folder, f)
)
for f in dir_entries
)
if not found:
stale.append(idx) stale.append(idx)
else:
found = False
try:
prefix = f"custom_{img_id}"
for fname in os.listdir(model_folder):
if fname.startswith(prefix) and os.path.isfile(
os.path.join(model_folder, fname)
):
found = True
break
except OSError:
stale.append(idx)
continue
if not found:
stale.append(idx)
if stale: if stale:
for idx in reversed(stale): for idx in reversed(stale):
@@ -532,22 +529,9 @@ class MetadataUpdater:
# is gone. # is gone.
continue continue
if not os.path.isdir(model_folder): prefix = f"image_{idx}."
if not any(f.startswith(prefix) for f in dir_entries):
stale.append(idx) stale.append(idx)
else:
found = False
try:
prefix = f"image_{idx}."
for fname in os.listdir(model_folder):
if fname.startswith(prefix):
found = True
break
except OSError:
stale.append(idx)
continue
if not found:
stale.append(idx)
if stale: if stale:
for idx in reversed(stale): for idx in reversed(stale):
+96 -1
View File
@@ -3,9 +3,16 @@ import logging
import os import os
import re import re
import json import json
import shutil
from ..services.settings_manager import get_settings_manager from ..services.settings_manager import get_settings_manager
from ..services.service_registry import ServiceRegistry from ..services.service_registry import ServiceRegistry
from ..utils.example_images_paths import iter_library_roots from ..utils.example_images_paths import (
get_example_images_root,
is_hash_folder,
iter_library_roots,
uses_library_scoped_folders,
_library_folder_has_only_hash_dirs,
)
from ..utils.metadata_manager import MetadataManager from ..utils.metadata_manager import MetadataManager
from ..utils.example_images_processor import ExampleImagesProcessor from ..utils.example_images_processor import ExampleImagesProcessor
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
@@ -36,6 +43,90 @@ settings = _SettingsProxy()
class ExampleImagesMigration: class ExampleImagesMigration:
"""Handles migrations for example images naming conventions""" """Handles migrations for example images naming conventions"""
@staticmethod
def _consolidate_library_folders():
"""Move hash folders from library-named subdirectories back to root.
When a user switches from multi-library mode back to single-library
mode, example images previously stored under e.g.
``<root>/default/<hash>/`` need to be moved back to
``<root>/<hash>/``. Running this once at startup removes the need
for ``get_model_folder()`` to perform directory scans on every
request.
"""
if uses_library_scoped_folders():
return
root = get_example_images_root()
if not root or not os.path.isdir(root):
return
moved: list[str] = []
cleaned: list[str] = []
try:
for entry in os.listdir(root):
# Fast regex checks first — no filesystem I/O.
if is_hash_folder(entry) or entry == "_deleted":
continue
entry_path = os.path.join(root, entry)
if not os.path.isdir(entry_path):
continue
if not _library_folder_has_only_hash_dirs(entry_path):
continue
try:
for hash_entry in os.listdir(entry_path):
hash_path = os.path.join(entry_path, hash_entry)
if not os.path.isdir(hash_path) or not is_hash_folder(hash_entry):
continue
target = os.path.join(root, hash_entry)
if not os.path.exists(target):
try:
shutil.move(hash_path, target)
moved.append(hash_entry)
except (OSError, shutil.Error) as exc:
logger.error(
"Failed to move '%s''%s': %s",
hash_path, target, exc,
)
except OSError as exc:
logger.error(
"Failed to list library subdirectory '%s': %s",
entry_path, exc,
)
try:
remaining = os.listdir(entry_path)
except OSError:
remaining = []
if not remaining:
try:
os.rmdir(entry_path)
cleaned.append(entry)
except OSError as exc:
logger.debug(
"Could not remove empty library dir '%s': %s",
entry_path, exc,
)
except OSError as exc:
logger.error(
"Failed to list example images root during consolidation: %s",
exc,
)
if moved:
logger.info(
"Consolidated %d example image folder(s) to root",
len(moved),
)
if cleaned:
logger.info(
"Removed %d empty library directories",
len(cleaned),
)
@staticmethod @staticmethod
async def check_and_run_migrations(): async def check_and_run_migrations():
"""Check if migrations are needed and run them in background""" """Check if migrations are needed and run them in background"""
@@ -44,6 +135,10 @@ class ExampleImagesMigration:
logger.debug("No example images path configured or path doesn't exist, skipping migrations") logger.debug("No example images path configured or path doesn't exist, skipping migrations")
return return
# Run library-to-root consolidation once at startup so the hot
# path (get_model_folder) stays a pure-path computation.
ExampleImagesMigration._consolidate_library_folders()
for library_name, library_path in iter_library_roots(): for library_name, library_path in iter_library_roots():
if not library_path or not os.path.exists(library_path): if not library_path or not os.path.exists(library_path):
continue continue
+6 -30
View File
@@ -83,7 +83,12 @@ def ensure_library_root_exists(library_name: Optional[str] = None) -> str:
def get_model_folder(model_hash: str, library_name: Optional[str] = None) -> str: def get_model_folder(model_hash: str, library_name: Optional[str] = None) -> str:
"""Return the folder path for a model's example images.""" """Return the folder path for a model's example images.
Multi-library single-library consolidation is handled once at startup by
``ExampleImagesMigration._consolidate_library_folders`` this function is a
pure path computation on the hot path (no directory scans).
"""
if not model_hash: if not model_hash:
return "" return ""
@@ -113,35 +118,6 @@ 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
+6
View File
@@ -488,6 +488,12 @@ def calculate_relative_path_for_model(
if model_type == "embedding": if model_type == "embedding":
formatted_path = formatted_path.replace(" ", "_") formatted_path = formatted_path.replace(" ", "_")
# Sanitize the resolved path to prevent path traversal
formatted_path = formatted_path.lstrip("/")
while "//" in formatted_path:
formatted_path = formatted_path.replace("//", "/")
formatted_path = formatted_path.rstrip("/")
return formatted_path return formatted_path
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "comfyui-lora-manager" name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!" description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "1.1.7" version = "1.2.0"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = [ dependencies = [
"aiohttp", "aiohttp",
+1
View File
@@ -151,6 +151,7 @@ body.modal-open {
.support-section, .support-section,
.changelog-section, .changelog-section,
.update-info, .update-info,
.update-channels,
.info-item, .info-item,
.path-preview { .path-preview {
background: var(--surface-subtle); background: var(--surface-subtle);
+126 -9
View File
@@ -93,15 +93,13 @@
.update-content { .update-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-3); gap: var(--space-2);
} }
.update-info { .update-info {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
border-radius: var(--border-radius-sm);
padding: var(--space-3);
} }
.update-info .version-info { .update-info .version-info {
@@ -175,7 +173,6 @@
border: 1px solid var(--lora-border); border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
padding: var(--space-2); padding: var(--space-2);
margin: var(--space-2) 0;
} }
[data-theme="dark"] .update-progress { [data-theme="dark"] .update-progress {
@@ -233,11 +230,6 @@
} }
/* Changelog section */ /* Changelog section */
.changelog-section {
border-radius: var(--border-radius-sm);
padding: var(--space-3);
}
.changelog-section h3 { .changelog-section h3 {
margin-top: 0; margin-top: 0;
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
@@ -349,6 +341,131 @@
text-decoration: underline; text-decoration: underline;
} }
/* Channel Toggle */
.update-channels {
}
.channels-label {
font-size: 0.9em;
color: var(--text-color);
opacity: 0.8;
margin-bottom: 8px;
}
.channel-toggle {
display: flex;
gap: 0;
background: var(--lora-surface);
border-radius: 8px;
padding: 3px;
width: fit-content;
}
.channel-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 20px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-secondary, #999);
cursor: pointer;
font-size: 0.9em;
font-weight: 500;
transition: all 0.2s ease;
white-space: nowrap;
}
.channel-btn:hover {
color: var(--text-primary, #ddd);
background: rgba(255, 255, 255, 0.04);
}
.channel-btn.active {
background: var(--lora-accent, #4285F4);
color: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.channel-btn.active i {
color: #fff;
}
.channel-btn i {
font-size: 0.85em;
}
/* Channel Switch Confirmation Overlay */
.channel-switch-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
backdrop-filter: blur(2px);
}
.channel-switch-dialog {
background: var(--lora-surface);
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
border-radius: 12px;
padding: 28px 32px;
max-width: 420px;
width: 90%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.channel-switch-dialog h3 {
margin: 0 0 12px;
font-size: 1.1em;
color: var(--text-primary, #eee);
}
.channel-switch-dialog p {
margin: 0 0 24px;
font-size: 0.9em;
color: var(--text-secondary, #aaa);
line-height: 1.6;
}
.channel-switch-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.channel-switch-cancel {
padding: 8px 18px;
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
border-radius: 6px;
background: transparent;
color: var(--text-secondary, #aaa);
cursor: pointer;
font-size: 0.9em;
}
.channel-switch-cancel:hover {
background: rgba(255, 255, 255, 0.04);
}
.channel-switch-confirm {
padding: 8px 18px;
border: none;
border-radius: 6px;
background: var(--lora-accent, #4285F4);
color: #fff;
cursor: pointer;
font-size: 0.9em;
font-weight: 500;
}
.channel-switch-confirm:hover {
opacity: 0.9;
}
/* Update preferences section */ /* Update preferences section */
.update-preferences { .update-preferences {
border-top: 1px solid var(--lora-border); border-top: 1px solid var(--lora-border);
+5
View File
@@ -274,6 +274,11 @@
font-style: italic; font-style: italic;
} }
/* Inline extra tags (selected but not in top-20/appended after API results) */
.filter-tag.extra-tag {
border-style: dashed;
}
/* Ensure solid border and full opacity when active or excluded */ /* Ensure solid border and full opacity when active or excluded */
.filter-tag.special-tag.active, .filter-tag.special-tag.active,
.filter-tag.special-tag.exclude { .filter-tag.special-tag.exclude {
+1 -4
View File
@@ -49,10 +49,6 @@ export const MODEL_CONFIG = {
* @returns {Object} Object containing all API endpoints for the model type * @returns {Object} Object containing all API endpoints for the model type
*/ */
export function getApiEndpoints(modelType) { export function getApiEndpoints(modelType) {
if (!Object.values(MODEL_TYPES).includes(modelType)) {
throw new Error(`Invalid model type: ${modelType}`);
}
return { return {
// Base CRUD operations // Base CRUD operations
list: `/api/lm/${modelType}/list`, list: `/api/lm/${modelType}/list`,
@@ -93,6 +89,7 @@ export function getApiEndpoints(modelType) {
// Query operations // Query operations
scan: `/api/lm/${modelType}/scan`, scan: `/api/lm/${modelType}/scan`,
topTags: `/api/lm/${modelType}/top-tags`, topTags: `/api/lm/${modelType}/top-tags`,
searchTags: `/api/lm/${modelType}/search-tags`,
baseModels: `/api/lm/${modelType}/base-models`, baseModels: `/api/lm/${modelType}/base-models`,
roots: `/api/lm/${modelType}/roots`, roots: `/api/lm/${modelType}/roots`,
folders: `/api/lm/${modelType}/folders`, folders: `/api/lm/${modelType}/folders`,
@@ -260,8 +260,9 @@ export class RecipeContextMenu extends BaseContextMenu {
strength: lora.strength || 1.0, strength: lora.strength || 1.0,
// Model identifiers // Model identifiers
modelId: lora.modelId || lora.model_id || civitaiInfo.modelId,
hash: modelFile?.hashes?.SHA256?.toLowerCase() || lora.hash, hash: modelFile?.hashes?.SHA256?.toLowerCase() || lora.hash,
modelVersionId: civitaiInfo.id || lora.modelVersionId, id: civitaiInfo.id || lora.modelVersionId,
// Metadata // Metadata
thumbnailUrl: civitaiInfo.images?.[0]?.url || '', thumbnailUrl: civitaiInfo.images?.[0]?.url || '',
+1
View File
@@ -1421,6 +1421,7 @@ class RecipeModal {
strength: lora.strength || 1.0, strength: lora.strength || 1.0,
// Model identifiers // Model identifiers
modelId: lora.modelId || lora.model_id || civitaiInfo.modelId,
hash: modelFile?.hashes?.SHA256?.toLowerCase() || lora.hash, hash: modelFile?.hashes?.SHA256?.toLowerCase() || lora.hash,
id: civitaiInfo.id || lora.modelVersionId, id: civitaiInfo.id || lora.modelVersionId,
+6
View File
@@ -489,6 +489,12 @@ export function createModelCard(model, modelType) {
const modelId = civitaiData?.modelId ?? civitaiData?.model_id; const modelId = civitaiData?.modelId ?? civitaiData?.model_id;
if (modelId !== undefined && modelId !== null && modelId !== '') { if (modelId !== undefined && modelId !== null && modelId !== '') {
card.dataset.modelId = modelId; card.dataset.modelId = modelId;
} else if (model.hf_url) {
// For HF-only models, derive a group key from hf_url for version grouping
const match = model.hf_url.match(/https?:\/\/huggingface\.co\/([^/]+\/[^/]+)/);
if (match) {
card.dataset.modelId = 'hf:' + match[1];
}
} }
// LoRA specific data // LoRA specific data
+10 -2
View File
@@ -473,7 +473,14 @@ export async function showModelModal(model, modelType) {
const loadingExamplesText = translate('modals.model.loading.examples', {}, 'Loading examples...'); const loadingExamplesText = translate('modals.model.loading.examples', {}, 'Loading examples...');
const loadingVersionsText = translate('modals.model.loading.versions', {}, 'Loading versions...'); const loadingVersionsText = translate('modals.model.loading.versions', {}, 'Loading versions...');
const civitaiModelId = modelWithFullData.civitai?.modelId || ''; // Use CivitAI modelId, or derive HF group key for HF-only models
let civitaiModelId = modelWithFullData.civitai?.modelId || '';
if (!civitaiModelId && modelWithFullData.hf_url) {
const match = modelWithFullData.hf_url.match(/https?:\/\/huggingface\.co\/([^/]+\/[^/]+)/);
if (match) {
civitaiModelId = 'hf:' + match[1];
}
}
const civitaiVersionId = modelWithFullData.civitai?.id || ''; const civitaiVersionId = modelWithFullData.civitai?.id || '';
const navAriaLabel = translate('modals.model.navigation.label', {}, 'Model navigation'); const navAriaLabel = translate('modals.model.navigation.label', {}, 'Model navigation');
const previousTitle = translate('modals.model.navigation.previousWithShortcut', {}, 'Previous model (←)'); const previousTitle = translate('modals.model.navigation.previousWithShortcut', {}, 'Previous model (←)');
@@ -885,7 +892,8 @@ function setupEventHandlers(filePath, modelType) {
case 'view-creator': case 'view-creator':
const username = target.dataset.username; const username = target.dataset.username;
if (username) { if (username) {
window.open(`https://civitai.com/user/${username}`, '_blank'); const host = state.global.settings.civitai_host || 'civitai.com';
window.open(`https://${host}/user/${username}`, '_blank');
} }
break; break;
case 'open-file-location': case 'open-file-location':
@@ -950,6 +950,26 @@ export function initVersionsTab({
renderErrorState(container, translate('modals.model.versions.missingModelId', {}, 'This model is missing a Civitai model id.')); renderErrorState(container, translate('modals.model.versions.missingModelId', {}, 'This model is missing a Civitai model id.'));
return; return;
} }
// HF group keys (e.g. "hf:user/repo") are not real CivitAI model IDs —
// skip the remote API call and show a helpful message instead.
const isHfGroupKey = typeof modelId === 'string' && modelId.startsWith('hf:');
if (isHfGroupKey) {
controller.isLoading = false;
controller.hasLoaded = true;
controller.record = null;
const hfMsg = translate(
'modals.model.versions.hfGroupInfo',
{},
'This is a HuggingFace model group. Open the library to see all versions in the grid.'
);
container.innerHTML = `
<div class="versions-empty-state">
<i class="fas fa-info-circle"></i>
<p>${escapeHtml(hfMsg)}</p>
</div>
`;
return;
}
if (controller.hasLoaded && !forceRefresh) { if (controller.hasLoaded && !forceRefresh) {
return; return;
} }
+41 -1
View File
@@ -27,6 +27,8 @@ export class BulkManager {
// Drag detection properties // Drag detection properties
this.dragThreshold = 5; // Pixels to move before considering it a drag this.dragThreshold = 5; // Pixels to move before considering it a drag
this.dragDelayMs = 100; // Minimum hold time before a drag is treated as a marquee
this.minMarqueeSize = 10; // Minimum drag box (px) before a marquee counts as a selection
this.mouseDownTime = 0; this.mouseDownTime = 0;
this.mouseDownPosition = { x: 0, y: 0 }; this.mouseDownPosition = { x: 0, y: 0 };
@@ -173,6 +175,19 @@ export class BulkManager {
}); });
eventManager.addHandler('mousemove', 'bulkManager-marquee-move', (e) => { eventManager.addHandler('mousemove', 'bulkManager-marquee-move', (e) => {
// Only track marquee/drag while the left button is physically held.
// mouseup can be missed (release outside the window, focus loss, driver quirks),
// so mousemove must verify the button state itself instead of relying on it.
if (!(e.buttons & 1)) {
if (this.isMarqueeActive) {
this.endMarqueeSelection(e);
} else {
this.mouseDownTime = 0;
this.isDragging = false;
}
return false;
}
if (this.isMarqueeActive) { if (this.isMarqueeActive) {
this.lastClientX = e.clientX; this.lastClientX = e.clientX;
this.lastClientY = e.clientY; this.lastClientY = e.clientY;
@@ -184,7 +199,10 @@ export class BulkManager {
const dy = e.clientY - this.mouseDownPosition.y; const dy = e.clientY - this.mouseDownPosition.y;
const distance = Math.sqrt(dx * dx + dy * dy); const distance = Math.sqrt(dx * dx + dy * dy);
if (distance >= this.dragThreshold) { // Require both enough movement AND enough hold time so quick
// click jitter from micro-movement input devices is not a marquee.
const heldTime = Date.now() - this.mouseDownTime;
if (heldTime >= this.dragDelayMs && distance >= this.dragThreshold) {
this.isDragging = true; this.isDragging = true;
this.startMarqueeSelection(e, true); this.startMarqueeSelection(e, true);
} }
@@ -1958,9 +1976,31 @@ export class BulkManager {
// Remove visual feedback class // Remove visual feedback class
document.body.classList.remove('marquee-selecting'); document.body.classList.remove('marquee-selecting');
// Compute the actual drag box size in document coordinates, matching how
// updateMarqueeSelectionFromPosition tracks the rectangle. Client-space
// size would wrongly flag auto-scroll marquees (tiny pointer movement,
// large document-space box) as accidental clicks.
const container = document.querySelector('.page-content');
const scrollX = container?.scrollLeft || 0;
const scrollY = container?.scrollTop || 0;
const dragWidth = Math.abs((e.clientX + scrollX) - this.marqueeStartDoc.x);
const dragHeight = Math.abs((e.clientY + scrollY) - this.marqueeStartDoc.y);
const isTinyMarquee = dragWidth < this.minMarqueeSize && dragHeight < this.minMarqueeSize;
// Get selection count // Get selection count
const selectionCount = state.selectedModels.size; const selectionCount = state.selectedModels.size;
// A tiny box (e.g. click jitter that happened to graze a card) is treated
// as an accidental click: undo any selection and leave bulk mode.
if (isTinyMarquee) {
this.clearSelection();
if (state.bulkMode) {
this.toggleBulkMode();
}
this.initialSelectedModels.clear();
return;
}
// If no models were selected, exit bulk mode // If no models were selected, exit bulk mode
if (selectionCount === 0) { if (selectionCount === 0) {
if (state.bulkMode) { if (state.bulkMode) {
+27 -8
View File
@@ -158,6 +158,7 @@ export class DownloadManager {
this.modelVersionId = null; this.modelVersionId = null;
this.source = null; this.source = null;
this.selectedFile = null; this.selectedFile = null;
this._isDiffusionModel = false;
this.selectedFolder = ''; this.selectedFolder = '';
this.batchModels = []; this.batchModels = [];
@@ -787,24 +788,40 @@ export class DownloadManager {
async proceedToLocationContent() { async proceedToLocationContent() {
try { try {
// Fetch model roots const _isDiffusionModel = this.selectedFile
const rootsData = await this.apiClient.fetchModelRoots(); ? (this.selectedFile.type === 'UNet' || this.selectedFile.type === 'Diffusion Model')
: (this.currentVersion?.files || []).some(
f => f.type === 'UNet' || f.type === 'Diffusion Model'
);
this._isDiffusionModel = _isDiffusionModel;
let rootsData;
if (this._isDiffusionModel && this.apiClient.modelType === 'checkpoints') {
rootsData = await this.apiClient.fetchModelRoots('diffusion_model');
} else {
rootsData = await this.apiClient.fetchModelRoots();
}
const modelRoot = document.getElementById('modelRoot'); const modelRoot = document.getElementById('modelRoot');
modelRoot.innerHTML = rootsData.roots.map(root => modelRoot.innerHTML = rootsData.roots.map(root =>
`<option value="${root}">${root}</option>` `<option value="${root}">${root}</option>`
).join(''); ).join('');
// Set default root if available const singularType = this._isDiffusionModel
const singularType = this.apiClient.modelType.replace(/s$/, ''); ? 'unet'
: this.apiClient.modelType.replace(/s$/, '');
const defaultRootKey = `default_${singularType}_root`; const defaultRootKey = `default_${singularType}_root`;
const defaultRoot = state.global.settings[defaultRootKey]; const defaultRoot = state.global.settings[defaultRootKey];
console.log(`Default root for ${this.apiClient.modelType}:`, defaultRoot); console.log(`Default root for ${singularType}:`, defaultRoot);
console.log('Available roots:', rootsData.roots); console.log('Available roots:', rootsData.roots);
if (defaultRoot && rootsData.roots.includes(defaultRoot)) { if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
console.log(`Setting default root: ${defaultRoot}`); console.log(`Setting default root: ${defaultRoot}`);
modelRoot.value = defaultRoot; modelRoot.value = defaultRoot;
} }
const subtypeDisplay = this._isDiffusionModel ? 'Diffusion Model' : this.apiClient.apiConfig.config.displayName;
document.getElementById('modelRootLabel').textContent =
translate('modals.download.selectTypeRoot', { type: subtypeDisplay });
// Set autocomplete="off" on folderPath input // Set autocomplete="off" on folderPath input
const folderPathInput = document.getElementById('folderPath'); const folderPathInput = document.getElementById('folderPath');
if (folderPathInput) { if (folderPathInput) {
@@ -1776,13 +1793,15 @@ export class DownloadManager {
const modelRoot = document.getElementById('modelRoot').value; const modelRoot = document.getElementById('modelRoot').value;
const config = this.apiClient.apiConfig.config; const config = this.apiClient.apiConfig.config;
let fullPath = modelRoot || translate('modals.download.selectTypeRoot', { type: config.displayName }); const subtypeDisplay = this._isDiffusionModel ? 'Diffusion Model' : config.displayName;
let fullPath = modelRoot || translate('modals.download.selectTypeRoot', { type: subtypeDisplay });
if (modelRoot) { if (modelRoot) {
if (this.useDefaultPath) { if (this.useDefaultPath) {
// Show actual template path
try { try {
const singularType = this.apiClient.modelType.replace(/s$/, ''); const singularType = this._isDiffusionModel
? 'unet'
: this.apiClient.modelType.replace(/s$/, '');
const templates = state.global.settings.download_path_templates; const templates = state.global.settings.download_path_templates;
const template = templates[singularType]; const template = templates[singularType];
fullPath += `/${template}`; fullPath += `/${template}`;
+129 -26
View File
@@ -1,6 +1,7 @@
import { getCurrentPageState } from '../state/index.js'; import { getCurrentPageState } from '../state/index.js';
import { showToast, updatePanelPositions } from '../utils/uiHelpers.js'; import { showToast, updatePanelPositions } from '../utils/uiHelpers.js';
import { getModelApiClient } from '../api/modelApiFactory.js'; import { getModelApiClient } from '../api/modelApiFactory.js';
import { getApiEndpoints } from '../api/apiConfig.js';
import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js'; import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
import { MODEL_TYPE_DISPLAY_NAMES } from '../utils/constants.js'; import { MODEL_TYPE_DISPLAY_NAMES } from '../utils/constants.js';
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
@@ -24,6 +25,12 @@ export class FilterManager {
this.baseModelOptions = []; this.baseModelOptions = [];
this.tagsLoaded = false; this.tagsLoaded = false;
// Tag search state
this.modelTagsSearchInput = document.getElementById('modelTagsSearchInput');
this.tagSearchDebounceTimer = null;
this.tagSearchAbortController = null;
this.tagSearchQuery = '';
// Initialize preset manager // Initialize preset manager
this.presetManager = new FilterPresetManager({ this.presetManager = new FilterPresetManager({
page: this.currentPage, page: this.currentPage,
@@ -123,6 +130,60 @@ export class FilterManager {
this.renderBaseModelTags(); this.renderBaseModelTags();
}); });
} }
if (this.modelTagsSearchInput) {
this.modelTagsSearchInput.addEventListener('input', () => {
clearTimeout(this.tagSearchDebounceTimer);
this.tagSearchDebounceTimer = setTimeout(() => {
this.handleTagSearchInput();
}, 150);
});
}
}
handleTagSearchInput() {
const query = (this.modelTagsSearchInput?.value || '').trim();
const trimmedQuery = query.toLowerCase();
if (trimmedQuery === this.tagSearchQuery) return;
this.tagSearchQuery = trimmedQuery;
if (!trimmedQuery) {
// Empty query: reload top tags (default/common view)
this.loadTopTags();
return;
}
this.searchTags(trimmedQuery);
}
async searchTags(query) {
// Abort any in-flight search request
if (this.tagSearchAbortController) {
this.tagSearchAbortController.abort();
}
this.tagSearchAbortController = new AbortController();
const controller = this.tagSearchAbortController;
try {
const tagsEndpoint = `${getApiEndpoints(this.currentPage).searchTags}?q=${encodeURIComponent(query)}&limit=20`;
const response = await fetch(tagsEndpoint, { signal: controller.signal });
if (!response.ok) throw new Error('Failed to search tags');
const data = await response.json();
if (controller.signal.aborted) return; // stale response
if (data.success && data.tags) {
this.createTagFilterElements(data.tags);
} else {
throw new Error('Invalid response format');
}
} catch (error) {
if (error.name === 'AbortError') return; // expected, ignore
console.error('Error searching tags:', error);
const tagsContainer = document.getElementById('modelTagsFilter');
if (tagsContainer) {
tagsContainer.innerHTML = '<div class="tags-error">Failed to search tags</div>';
}
const emptyState = document.getElementById('modelTagsEmptyState');
if (emptyState) emptyState.hidden = true;
}
} }
getNormalizedSearchQuery(input) { getNormalizedSearchQuery(input) {
@@ -146,15 +207,24 @@ export class FilterManager {
} }
async loadTopTags() { async loadTopTags() {
// Abort any in-flight tag search request
if (this.tagSearchAbortController) {
this.tagSearchAbortController.abort();
this.tagSearchAbortController = null;
}
this.tagSearchQuery = '';
try { try {
// Show loading state // Show loading state
const tagsContainer = document.getElementById('modelTagsFilter'); const tagsContainer = document.getElementById('modelTagsFilter');
const emptyState = document.getElementById('modelTagsEmptyState');
if (!tagsContainer) return; if (!tagsContainer) return;
if (emptyState) emptyState.hidden = true;
tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>'; tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>';
// Determine the API endpoint based on the page type // Determine the API endpoint based on the page type
const tagsEndpoint = `/api/lm/${this.currentPage}/top-tags?limit=20`; const tagsEndpoint = `${getApiEndpoints(this.currentPage).topTags}?limit=20`;
const response = await fetch(tagsEndpoint); const response = await fetch(tagsEndpoint);
if (!response.ok) throw new Error('Failed to fetch tags'); if (!response.ok) throw new Error('Failed to fetch tags');
@@ -179,29 +249,38 @@ export class FilterManager {
createTagFilterElements(tags) { createTagFilterElements(tags) {
const tagsContainer = document.getElementById('modelTagsFilter'); const tagsContainer = document.getElementById('modelTagsFilter');
const emptyState = document.getElementById('modelTagsEmptyState');
if (!tagsContainer) return; if (!tagsContainer) return;
tagsContainer.innerHTML = ''; tagsContainer.innerHTML = '';
if (emptyState) emptyState.hidden = true;
// Collect existing tag names from the API response // Collect existing tag names from the API response
const existingTagNames = new Set(tags.map(t => t.tag)); const existingTagNames = new Set(tags.map(t => t.tag));
// Add any active filter tags that aren't in the top 20 // Collect active filter tags that aren't in the response (excluding __no_tags__)
const missingSelectedTags = [];
if (this.filters.tags) { if (this.filters.tags) {
Object.keys(this.filters.tags).forEach(tagName => { Object.keys(this.filters.tags).forEach(tagName => {
// Skip special tags like __no_tags__
if (tagName.startsWith('__')) return; if (tagName.startsWith('__')) return;
if (!existingTagNames.has(tagName)) { if (!existingTagNames.has(tagName)) {
// Add this tag to the list with count 0 (unknown) missingSelectedTags.push({ tag: tagName, count: 0 });
tags.push({ tag: tagName, count: 0 });
existingTagNames.add(tagName); existingTagNames.add(tagName);
} }
}); });
} }
// Append missing selected tags after the API results so they appear inline
for (const t of missingSelectedTags) {
tags.push(t);
}
if (!tags.length) { if (!tags.length) {
tagsContainer.innerHTML = `<div class="no-tags">No ${this.currentPage === 'recipes' ? 'recipe ' : ''}tags available</div>`; if (this.tagSearchQuery) {
if (emptyState) emptyState.hidden = false;
} else {
tagsContainer.innerHTML = `<div class="no-tags">No ${this.currentPage === 'recipes' ? 'recipe ' : ''}tags available</div>`;
}
return; return;
} }
@@ -209,6 +288,10 @@ export class FilterManager {
const tagEl = document.createElement('div'); const tagEl = document.createElement('div');
tagEl.className = 'filter-tag tag-filter'; tagEl.className = 'filter-tag tag-filter';
const tagName = tag.tag; const tagName = tag.tag;
if (missingSelectedTags.some(t => t.tag === tagName)) {
tagEl.classList.add('extra-tag');
}
tagEl.dataset.tag = tagName; tagEl.dataset.tag = tagName;
// Show count only if it's > 0 (known count) // Show count only if it's > 0 (known count)
@@ -234,26 +317,28 @@ export class FilterManager {
tagsContainer.appendChild(tagEl); tagsContainer.appendChild(tagEl);
}); });
// Add "No tags" as a special filter at the end // Add "No tags" as a special filter at the end (skip during search)
const noTagsEl = document.createElement('div'); if (!this.tagSearchQuery) {
noTagsEl.className = 'filter-tag tag-filter special-tag'; const noTagsEl = document.createElement('div');
const noTagsLabel = translate('header.filter.noTags', {}, 'No tags'); noTagsEl.className = 'filter-tag tag-filter special-tag';
const noTagsKey = '__no_tags__'; const noTagsLabel = translate('header.filter.noTags', {}, 'No tags');
noTagsEl.dataset.tag = noTagsKey; const noTagsKey = '__no_tags__';
noTagsEl.innerHTML = noTagsLabel; noTagsEl.dataset.tag = noTagsKey;
noTagsEl.innerHTML = noTagsLabel;
noTagsEl.addEventListener('click', async () => { noTagsEl.addEventListener('click', async () => {
const currentState = (this.filters.tags && this.filters.tags[noTagsKey]) || 'none'; const currentState = (this.filters.tags && this.filters.tags[noTagsKey]) || 'none';
const newState = this.getNextTriStateState(currentState); const newState = this.getNextTriStateState(currentState);
this.setTagFilterState(noTagsKey, newState); this.setTagFilterState(noTagsKey, newState);
this.applyTagElementState(noTagsEl, newState); this.applyTagElementState(noTagsEl, newState);
this.updateActiveFiltersCount(); this.updateActiveFiltersCount();
await this.applyFilters(false); await this.applyFilters(false);
}); });
tagsContainer.appendChild(noTagsEl); tagsContainer.appendChild(noTagsEl);
}
this.updateTagSelections(); this.updateTagSelections();
} }
@@ -341,7 +426,7 @@ export class FilterManager {
if (!baseModelTagsContainer) return; if (!baseModelTagsContainer) return;
// Set the API endpoint based on current page // Set the API endpoint based on current page
const apiEndpoint = `/api/lm/${this.currentPage}/base-models?limit=0`; const apiEndpoint = `${getApiEndpoints(this.currentPage).baseModels}?limit=0`;
// Fetch base models // Fetch base models
fetch(apiEndpoint) fetch(apiEndpoint)
@@ -644,10 +729,12 @@ export class FilterManager {
const pageState = getCurrentPageState(); const pageState = getCurrentPageState();
const storageKey = `${this.currentPage}_filters`; const storageKey = `${this.currentPage}_filters`;
// Save filters to localStorage (exclude EMPTY_WILDCARD_MARKER) // Save filters to localStorage (exclude EMPTY_WILDCARD_MARKER and transient search)
const filtersSnapshot = this.cloneFilters(); const filtersSnapshot = this.cloneFilters();
// Don't persist EMPTY_WILDCARD_MARKER - it's a runtime-only marker // Don't persist EMPTY_WILDCARD_MARKER - it's a runtime-only marker
filtersSnapshot.baseModel = filtersSnapshot.baseModel.filter(m => m !== EMPTY_WILDCARD_MARKER); filtersSnapshot.baseModel = filtersSnapshot.baseModel.filter(m => m !== EMPTY_WILDCARD_MARKER);
// Don't persist search - it's transient and managed by SearchManager
delete filtersSnapshot.search;
setStorageItem(storageKey, filtersSnapshot); setStorageItem(storageKey, filtersSnapshot);
// Update state with current filters // Update state with current filters
@@ -721,6 +808,16 @@ export class FilterManager {
tagLogic: 'any' tagLogic: 'any'
}); });
// Clear tag search input and reset search state
if (this.modelTagsSearchInput) {
this.modelTagsSearchInput.value = '';
}
this.tagSearchQuery = '';
if (this.tagSearchAbortController) {
this.tagSearchAbortController.abort();
this.tagSearchAbortController = null;
}
// Update tag logic toggle UI // Update tag logic toggle UI
this.updateTagLogicToggleUI(); this.updateTagLogicToggleUI();
@@ -731,6 +828,10 @@ export class FilterManager {
// Update UI // Update UI
this.updateTagSelections(); this.updateTagSelections();
this.updateActiveFiltersCount(); this.updateActiveFiltersCount();
// Reload tag area to drop any non-top-20 tags from the deactivated preset
if (this.tagsLoaded) {
await this.loadTopTags();
}
this.presetManager.renderPresets(); // Re-render to remove active state this.presetManager.renderPresets(); // Re-render to remove active state
// Remove from local Storage // Remove from local Storage
@@ -885,6 +986,7 @@ export class FilterManager {
} }
cloneFilters() { cloneFilters() {
const pageState = getCurrentPageState();
return { return {
...this.filters, ...this.filters,
baseModel: [...(this.filters.baseModel || [])], baseModel: [...(this.filters.baseModel || [])],
@@ -892,7 +994,8 @@ export class FilterManager {
autoTags: { ...(this.filters.autoTags || {}) }, autoTags: { ...(this.filters.autoTags || {}) },
license: { ...(this.filters.license || {}) }, license: { ...(this.filters.license || {}) },
modelTypes: [...(this.filters.modelTypes || [])], modelTypes: [...(this.filters.modelTypes || [])],
tagLogic: this.filters.tagLogic || 'any' tagLogic: this.filters.tagLogic || 'any',
search: pageState?.filters?.search ?? ''
}; };
} }
+13 -19
View File
@@ -478,11 +478,9 @@ export class FilterPresetManager {
const pageState = getCurrentPageState(); const pageState = getCurrentPageState();
pageState.filters = this.filterManager.cloneFilters(); pageState.filters = this.filterManager.cloneFilters();
// If tags haven't been loaded yet, load them first // Refresh tag display so preset's non-top-20 tags appear inline
if (!this.filterManager.tagsLoaded) { await this.filterManager.loadTopTags();
await this.filterManager.loadTopTags(); this.filterManager.tagsLoaded = true;
this.filterManager.tagsLoaded = true;
}
// Check again after async operation // Check again after async operation
if (requestId !== this.applyPresetRequestId) return; if (requestId !== this.applyPresetRequestId) return;
@@ -745,8 +743,16 @@ export class FilterPresetManager {
presetEl.classList.add('active'); presetEl.classList.add('active');
} }
presetEl.addEventListener('click', (e) => { // Apply preset on click (toggle if already active)
e.stopPropagation(); // Bind to the whole .filter-preset div so clicking anywhere inside triggers apply
presetEl.addEventListener('click', async () => {
this.cancelPendingDelete();
if (this.activePreset === preset.name) {
await this.filterManager.clearFilters();
} else {
await this.applyPreset(preset.name);
}
}); });
const presetName = document.createElement('span'); const presetName = document.createElement('span');
@@ -759,18 +765,6 @@ export class FilterPresetManager {
deleteBtn.innerHTML = '<i class="fas fa-times"></i>'; deleteBtn.innerHTML = '<i class="fas fa-times"></i>';
deleteBtn.title = translate('header.filter.presetDeleteTooltip', {}, 'Delete preset'); deleteBtn.title = translate('header.filter.presetDeleteTooltip', {}, 'Delete preset');
// Apply preset on name click (toggle if already active)
presetName.addEventListener('click', async (e) => {
e.stopPropagation();
this.cancelPendingDelete();
if (this.activePreset === preset.name) {
await this.filterManager.clearFilters();
} else {
await this.applyPreset(preset.name);
}
});
// Two-step delete on delete button click // Two-step delete on delete button click
deleteBtn.addEventListener('click', (e) => { deleteBtn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
+38 -17
View File
@@ -1517,11 +1517,20 @@ export class SettingsManager {
return data; return data;
} }
async loadLoraRoots() { showNoRootsPlaceholder(select) {
try { select.innerHTML = '';
const defaultLoraRootSelect = document.getElementById('defaultLoraRoot'); const option = document.createElement('option');
if (!defaultLoraRootSelect) return; option.value = '';
option.textContent = translate('settings.folderSettings.noDefault', {}, 'No Default');
select.appendChild(option);
select.disabled = true;
}
async loadLoraRoots() {
const defaultLoraRootSelect = document.getElementById('defaultLoraRoot');
if (!defaultLoraRootSelect) return;
try {
// Fetch lora roots // Fetch lora roots
const response = await fetch('/api/lm/loras/roots'); const response = await fetch('/api/lm/loras/roots');
if (!response.ok) { if (!response.ok) {
@@ -1530,10 +1539,12 @@ export class SettingsManager {
const data = await response.json(); const data = await response.json();
if (!data.roots || data.roots.length === 0) { if (!data.roots || data.roots.length === 0) {
throw new Error('No LoRA roots found'); this.showNoRootsPlaceholder(defaultLoraRootSelect);
return;
} }
defaultLoraRootSelect.innerHTML = ''; defaultLoraRootSelect.innerHTML = '';
defaultLoraRootSelect.disabled = false;
// Add options for each root // Add options for each root
data.roots.forEach(root => { data.roots.forEach(root => {
@@ -1548,15 +1559,16 @@ export class SettingsManager {
} catch (error) { } catch (error) {
console.error('Error loading LoRA roots:', error); console.error('Error loading LoRA roots:', error);
this.showNoRootsPlaceholder(defaultLoraRootSelect);
showToast('toast.settings.loraRootsFailed', { message: error.message }, 'error'); showToast('toast.settings.loraRootsFailed', { message: error.message }, 'error');
} }
} }
async loadCheckpointRoots() { async loadCheckpointRoots() {
try { const defaultCheckpointRootSelect = document.getElementById('defaultCheckpointRoot');
const defaultCheckpointRootSelect = document.getElementById('defaultCheckpointRoot'); if (!defaultCheckpointRootSelect) return;
if (!defaultCheckpointRootSelect) return;
try {
// Fetch checkpoint roots (checkpoint paths only, not unet) // Fetch checkpoint roots (checkpoint paths only, not unet)
const response = await fetch('/api/lm/checkpoints/checkpoints_roots'); const response = await fetch('/api/lm/checkpoints/checkpoints_roots');
if (!response.ok) { if (!response.ok) {
@@ -1565,10 +1577,12 @@ export class SettingsManager {
const data = await response.json(); const data = await response.json();
if (!data.roots || data.roots.length === 0) { if (!data.roots || data.roots.length === 0) {
throw new Error('No checkpoint roots found'); this.showNoRootsPlaceholder(defaultCheckpointRootSelect);
return;
} }
defaultCheckpointRootSelect.innerHTML = ''; defaultCheckpointRootSelect.innerHTML = '';
defaultCheckpointRootSelect.disabled = false;
// Add options for each root // Add options for each root
data.roots.forEach(root => { data.roots.forEach(root => {
@@ -1583,15 +1597,16 @@ export class SettingsManager {
} catch (error) { } catch (error) {
console.error('Error loading checkpoint roots:', error); console.error('Error loading checkpoint roots:', error);
this.showNoRootsPlaceholder(defaultCheckpointRootSelect);
showToast('toast.settings.checkpointRootsFailed', { message: error.message }, 'error'); showToast('toast.settings.checkpointRootsFailed', { message: error.message }, 'error');
} }
} }
async loadUnetRoots() { async loadUnetRoots() {
try { const defaultUnetRootSelect = document.getElementById('defaultUnetRoot');
const defaultUnetRootSelect = document.getElementById('defaultUnetRoot'); if (!defaultUnetRootSelect) return;
if (!defaultUnetRootSelect) return;
try {
// Fetch unet roots (diffusion model paths only) // Fetch unet roots (diffusion model paths only)
const response = await fetch('/api/lm/checkpoints/unet_roots'); const response = await fetch('/api/lm/checkpoints/unet_roots');
if (!response.ok) { if (!response.ok) {
@@ -1600,10 +1615,12 @@ export class SettingsManager {
const data = await response.json(); const data = await response.json();
if (!data.roots || data.roots.length === 0) { if (!data.roots || data.roots.length === 0) {
throw new Error('No diffusion model roots found'); this.showNoRootsPlaceholder(defaultUnetRootSelect);
return;
} }
defaultUnetRootSelect.innerHTML = ''; defaultUnetRootSelect.innerHTML = '';
defaultUnetRootSelect.disabled = false;
// Add options for each root // Add options for each root
data.roots.forEach(root => { data.roots.forEach(root => {
@@ -1618,15 +1635,16 @@ export class SettingsManager {
} catch (error) { } catch (error) {
console.error('Error loading diffusion model roots:', error); console.error('Error loading diffusion model roots:', error);
this.showNoRootsPlaceholder(defaultUnetRootSelect);
showToast('toast.settings.unetRootsFailed', { message: error.message }, 'error'); showToast('toast.settings.unetRootsFailed', { message: error.message }, 'error');
} }
} }
async loadEmbeddingRoots() { async loadEmbeddingRoots() {
try { const defaultEmbeddingRootSelect = document.getElementById('defaultEmbeddingRoot');
const defaultEmbeddingRootSelect = document.getElementById('defaultEmbeddingRoot'); if (!defaultEmbeddingRootSelect) return;
if (!defaultEmbeddingRootSelect) return;
try {
// Fetch embedding roots // Fetch embedding roots
const response = await fetch('/api/lm/embeddings/roots'); const response = await fetch('/api/lm/embeddings/roots');
if (!response.ok) { if (!response.ok) {
@@ -1635,10 +1653,12 @@ export class SettingsManager {
const data = await response.json(); const data = await response.json();
if (!data.roots || data.roots.length === 0) { if (!data.roots || data.roots.length === 0) {
throw new Error('No embedding roots found'); this.showNoRootsPlaceholder(defaultEmbeddingRootSelect);
return;
} }
defaultEmbeddingRootSelect.innerHTML = ''; defaultEmbeddingRootSelect.innerHTML = '';
defaultEmbeddingRootSelect.disabled = false;
// Add options for each root // Add options for each root
data.roots.forEach(root => { data.roots.forEach(root => {
@@ -1653,6 +1673,7 @@ export class SettingsManager {
} catch (error) { } catch (error) {
console.error('Error loading embedding roots:', error); console.error('Error loading embedding roots:', error);
this.showNoRootsPlaceholder(defaultEmbeddingRootSelect);
showToast('toast.settings.embeddingRootsFailed', { message: error.message }, 'error'); showToast('toast.settings.embeddingRootsFailed', { message: error.message }, 'error');
} }
} }
+291 -48
View File
@@ -1,11 +1,12 @@
import { modalManager } from './ModalManager.js'; import { modalManager } from './ModalManager.js';
import { import {
getStorageItem, getStorageItem,
setStorageItem, setStorageItem,
getStoredVersionInfo, getStoredVersionInfo,
setStoredVersionInfo, setStoredVersionInfo,
isVersionMatch isVersionMatch
} from '../utils/storageHelpers.js'; } from '../utils/storageHelpers.js';
import { state } from '../state/index.js';
import { bannerService } from './BannerService.js'; import { bannerService } from './BannerService.js';
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
@@ -24,7 +25,11 @@ export class UpdateService {
this.updateNotificationsEnabled = getStorageItem('show_update_notifications', true); this.updateNotificationsEnabled = getStorageItem('show_update_notifications', true);
this.lastCheckTime = parseInt(getStorageItem('last_update_check') || '0'); this.lastCheckTime = parseInt(getStorageItem('last_update_check') || '0');
this.isUpdating = false; this.isUpdating = false;
this.nightlyMode = getStorageItem('nightly_updates', false); this.channelMode = null;
this.hasGit = false;
this.nightlyNotifyDate = getStorageItem('nightly_notify_date', '');
this.nightlyBadgeShown = false;
this.progressKeepVisible = false;
this.currentVersionInfo = null; this.currentVersionInfo = null;
this.versionMismatch = false; this.versionMismatch = false;
this.activeNotificationTab = 'updates'; this.activeNotificationTab = 'updates';
@@ -49,43 +54,180 @@ export class UpdateService {
updateBtn.addEventListener('click', () => this.performUpdate()); updateBtn.addEventListener('click', () => this.performUpdate());
} }
// Register event listener for nightly update toggle this.wireChannelButtons();
const nightlyCheckbox = document.getElementById('nightlyUpdateToggle');
if (nightlyCheckbox) {
nightlyCheckbox.checked = this.nightlyMode;
nightlyCheckbox.addEventListener('change', (e) => {
this.nightlyMode = e.target.checked;
setStorageItem('nightly_updates', e.target.checked);
this.updateNightlyWarning();
this.updateModalContent();
// Re-check for updates when switching channels
this.manualCheckForUpdates();
});
this.updateNightlyWarning();
}
this.setupNotificationCenter(); this.setupNotificationCenter();
window.addEventListener('lm:banner-history-updated', this.handleBannerHistoryUpdated); window.addEventListener('lm:banner-history-updated', this.handleBannerHistoryUpdated);
this.updateTabBadges(); this.updateTabBadges();
// Perform update check if needed // Perform update check if needed
this.checkForUpdates().then(() => { this.checkVersionInfo().then(() => {
// Ensure badges are updated after checking this.checkForUpdates().then(() => {
this.updateBadgeVisibility(); this.updateBadgeVisibility();
});
}); });
// Immediately update modal content with current values (even if from default)
this.updateModalContent(); this.updateModalContent();
// Check version info for mismatch after loading basic info
this.checkVersionInfo();
} }
updateNightlyWarning() { wireChannelButtons() {
const warning = document.getElementById('nightlyWarning'); const releaseBtn = document.getElementById('channelRelease');
if (warning) { const nightlyBtn = document.getElementById('channelNightly');
warning.style.display = this.nightlyMode ? 'flex' : 'none'; if (releaseBtn) {
releaseBtn.addEventListener('click', () => this.switchChannel('release'));
} }
if (nightlyBtn) {
nightlyBtn.addEventListener('click', () => this.switchChannel('nightly'));
}
}
async switchChannel(channel) {
if (channel === this.channelMode) {
return;
}
if (this.isUpdating) {
return;
}
if (!this.hasGit && channel === 'nightly') {
const confirmed = await this._confirmChannelSwitch(
'update.channelSwitch.nightlyTitle',
'update.channelSwitch.nightlyMessage'
);
if (!confirmed) return;
}
if (this.hasGit && channel === 'release') {
const confirmed = await this._confirmChannelSwitch(
'update.channelSwitch.releaseTitle',
'update.channelSwitch.releaseMessage'
);
if (!confirmed) return;
}
try {
this.isUpdating = true;
this.showUpdateProgress(true);
this.updateProgress(10, translate('update.channelSwitch.switching', { channel }));
const response = await fetch('/api/lm/switch-channel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel })
});
const data = await response.json();
if (data.success) {
this.channelMode = channel;
// Persist channel preference to settings.json
fetch('/api/lm/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ update_channel: channel })
}).then(r => {
if (!r.ok) console.warn('Failed to persist update channel:', r.status);
}).catch(e => console.warn('Failed to persist update channel:', e));
await this.checkForUpdates({ force: true });
this.updateModalContent();
this.updateChannelUI();
this._showSwitchCompleteMessage(data.new_version);
this.progressKeepVisible = true;
} else {
throw new Error(data.error || translate('update.channelSwitch.failed'));
}
} catch (error) {
console.error('Channel switch failed:', error);
this.updateProgress(0, translate('update.channelSwitch.failed'));
} finally {
if (this.progressKeepVisible) {
this.isUpdating = false;
this.progressKeepVisible = false;
} else {
setTimeout(() => {
this.showUpdateProgress(false);
this.isUpdating = false;
}, 2000);
}
}
}
updateChannelUI() {
const releaseBtn = document.getElementById('channelRelease');
const nightlyBtn = document.getElementById('channelNightly');
if (releaseBtn) {
releaseBtn.classList.toggle('active', this.channelMode === 'release');
}
if (nightlyBtn) {
nightlyBtn.classList.toggle('active', this.channelMode === 'nightly');
}
}
_resolveChannelFromSettings() {
const stored = state?.global?.settings?.update_channel;
if (stored === 'nightly' || stored === 'release') {
return stored;
}
if (!this.hasGit) {
return 'release';
}
if (this.gitInfo?.branch === 'detached') {
return 'release';
}
return 'nightly';
}
async _confirmChannelSwitch(titleKey, messageKey) {
return new Promise((resolve) => {
const title = translate(titleKey);
const message = translate(messageKey);
const cancelText = translate('common.cancel');
const confirmText = translate('common.confirm');
const overlay = document.createElement('div');
overlay.className = 'channel-switch-overlay';
overlay.innerHTML = `
<div class="channel-switch-dialog">
<h3>${title}</h3>
<p>${message}</p>
<div class="channel-switch-actions">
<button class="secondary-btn channel-switch-cancel">${cancelText}</button>
<button class="primary-btn channel-switch-confirm">${confirmText}</button>
</div>
</div>
`;
const dismiss = (result) => {
document.removeEventListener('keydown', onKeydown);
overlay.remove();
resolve(result);
};
const onKeydown = (e) => {
if (e.key === 'Escape') {
e.stopPropagation();
e.preventDefault();
dismiss(false);
}
};
document.addEventListener('keydown', onKeydown, { capture: true });
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
dismiss(false);
}
});
overlay.querySelector('.channel-switch-cancel').addEventListener('click', () => {
dismiss(false);
});
overlay.querySelector('.channel-switch-confirm').addEventListener('click', () => {
dismiss(true);
});
document.body.appendChild(overlay);
});
} }
setupNotificationCenter() { setupNotificationCenter() {
@@ -355,6 +497,18 @@ export class UpdateService {
} }
async checkForUpdates({ force = false } = {}) { async checkForUpdates({ force = false } = {}) {
let needsMigration = false;
if (this.channelMode === null) {
const stored = state?.global?.settings?.update_channel;
if (stored === 'nightly' || stored === 'release') {
this.channelMode = stored;
} else if (!this.hasGit) {
this.channelMode = 'release';
needsMigration = true;
}
// hasGit=true with no stored value: wait for gitInfo.branch
}
if (!force && !this.updateNotificationsEnabled) { if (!force && !this.updateNotificationsEnabled) {
return; return;
} }
@@ -373,7 +527,8 @@ export class UpdateService {
try { try {
// Call backend API to check for updates with nightly flag // Call backend API to check for updates with nightly flag
const response = await fetch(`/api/lm/check-updates?nightly=${this.nightlyMode}`); const nightly = (this.channelMode ?? (this.hasGit ? 'nightly' : 'release')) === 'nightly';
const response = await fetch(`/api/lm/check-updates?nightly=${nightly}`);
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
@@ -381,17 +536,35 @@ export class UpdateService {
this.latestVersion = data.latest_version || "v0.0.0"; this.latestVersion = data.latest_version || "v0.0.0";
this.updateInfo = data; this.updateInfo = data;
this.gitInfo = data.git_info || this.gitInfo; this.gitInfo = data.git_info || this.gitInfo;
this.hasGit = data.has_git || false;
// Explicitly set update availability based on version comparison
this.updateAvailable = this.isNewerVersion(this.latestVersion, this.currentVersion); if (needsMigration || this.channelMode === null) {
this.channelMode = this._resolveChannelFromSettings();
// Update last check time if (state?.global?.settings) {
state.global.settings.update_channel = this.channelMode;
}
fetch('/api/lm/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ update_channel: this.channelMode })
}).then(r => {
if (!r.ok) console.warn('Failed to persist update channel:', r.status);
}).catch(e => console.warn('Failed to persist update channel:', e));
}
this.updateAvailable = data.update_available;
// Nightly channel: surface the update badge at most once per calendar day.
if (this.updateAvailable && this.channelMode === 'nightly' && this.nightlyNotifyDate !== this._getTodayKey()) {
this._markNightlyNotified();
}
this.lastCheckTime = now; this.lastCheckTime = now;
setStorageItem('last_update_check', now.toString()); setStorageItem('last_update_check', now.toString());
// Update UI
this.updateBadgeVisibility(); this.updateBadgeVisibility();
this.updateModalContent(); this.updateModalContent();
this.updateChannelUI();
console.log("Update check complete:", { console.log("Update check complete:", {
currentVersion: this.currentVersion, currentVersion: this.currentVersion,
@@ -435,6 +608,28 @@ export class UpdateService {
return false; return false;
} }
_getTodayKey() {
const now = new Date();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
return `${now.getFullYear()}-${month}-${day}`;
}
_isNightlyBadgeAllowed() {
if (this.channelMode !== 'nightly') {
return true;
}
// Keep the badge visible for the rest of the session once shown, but do
// not show it again on later sessions within the same calendar day.
return this.nightlyNotifyDate !== this._getTodayKey() || this.nightlyBadgeShown;
}
_markNightlyNotified() {
this.nightlyNotifyDate = this._getTodayKey();
this.nightlyBadgeShown = true;
setStorageItem('nightly_notify_date', this.nightlyNotifyDate);
}
updateBadgeVisibility() { updateBadgeVisibility() {
const updateToggle = document.querySelector('.update-toggle'); const updateToggle = document.querySelector('.update-toggle');
@@ -443,9 +638,12 @@ export class UpdateService {
? bannerService.getUnreadBannerCount() ? bannerService.getUnreadBannerCount()
: 0; : 0;
// Force updating badges visibility based on current state
const shouldShowUpdate = this.updateNotificationsEnabled && this.updateAvailable && this._isNightlyBadgeAllowed();
if (updateToggle) { if (updateToggle) {
let tooltipKey = 'header.actions.notifications'; let tooltipKey = 'header.actions.notifications';
if (this.updateNotificationsEnabled && this.updateAvailable) { if (shouldShowUpdate) {
tooltipKey = 'update.updateAvailable'; tooltipKey = 'update.updateAvailable';
} else if (unreadBanners > 0) { } else if (unreadBanners > 0) {
tooltipKey = 'update.tabs.messages'; tooltipKey = 'update.tabs.messages';
@@ -453,8 +651,6 @@ export class UpdateService {
updateToggle.title = translate(tooltipKey); updateToggle.title = translate(tooltipKey);
} }
// Force updating badges visibility based on current state
const shouldShowUpdate = this.updateNotificationsEnabled && this.updateAvailable;
const shouldShow = shouldShowUpdate || unreadBanners > 0; const shouldShow = shouldShowUpdate || unreadBanners > 0;
if (updateBadge) { if (updateBadge) {
@@ -482,8 +678,31 @@ export class UpdateService {
if (currentVersionEl) currentVersionEl.textContent = this.currentVersion; if (currentVersionEl) currentVersionEl.textContent = this.currentVersion;
const newVersionLabel = modal.querySelector('.new-version .label');
if (newVersionLabel) {
newVersionLabel.textContent = (this.updateInfo?.nightly)
? `${translate('update.latestMain')}:`
: `${translate('update.newVersion')}:`;
}
if (newVersionEl) { if (newVersionEl) {
newVersionEl.textContent = this.latestVersion; if (this.updateInfo?.nightly) {
const behind = this.updateInfo.behind_by || 0;
const remoteHash = this.latestVersion.replace('main-', '');
const localHash = this.gitInfo.short_hash || '';
const date = this.updateInfo.commit_date || '';
const datePart = date ? ` · ${date}` : '';
if (behind > 0) {
newVersionEl.textContent = `${behind} commit${behind !== 1 ? 's' : ''} behind main (${remoteHash}${datePart})`;
} else if (localHash !== remoteHash) {
newVersionEl.textContent = `Behind main (${remoteHash}${datePart})`;
} else {
newVersionEl.textContent = `Up to date (${remoteHash}${datePart})`;
}
} else {
newVersionEl.textContent = this.latestVersion;
}
} }
// Update update button state // Update update button state
@@ -599,8 +818,12 @@ export class UpdateService {
// Update GitHub link to point to the specific release if available // Update GitHub link to point to the specific release if available
const githubLink = modal.querySelector('.update-link'); const githubLink = modal.querySelector('.update-link');
if (githubLink && this.latestVersion) { if (githubLink && this.latestVersion) {
const versionTag = this.latestVersion.replace(/^v/, ''); if (this.updateInfo?.nightly) {
githubLink.href = `https://github.com/willmiao/ComfyUI-Lora-Manager/releases/tag/v${versionTag}`; githubLink.href = 'https://github.com/willmiao/ComfyUI-Lora-Manager/commits/main';
} else {
const versionTag = this.latestVersion.replace(/^v/, '');
githubLink.href = `https://github.com/willmiao/ComfyUI-Lora-Manager/releases/tag/v${versionTag}`;
}
} }
} }
@@ -623,7 +846,7 @@ export class UpdateService {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ body: JSON.stringify({
nightly: this.nightlyMode nightly: this.channelMode === 'nightly'
}) })
}); });
@@ -698,7 +921,26 @@ export class UpdateService {
progressText.textContent = text; progressText.textContent = text;
} }
} }
_showSwitchCompleteMessage(version) {
this.showUpdateProgress(true);
this.updateProgress(100, '');
const progressText = document.getElementById('updateProgressText');
if (progressText) {
progressText.innerHTML = `
<div style="text-align: center; color: var(--lora-success);">
<i class="fas fa-check-circle" style="margin-right: 8px;"></i>
${translate('update.completion.successMessage', { version })}
<br><br>
<div style="opacity: 0.95; color: var(--lora-error); font-size: 1em;">
${translate('update.completion.restartMessage')}<br>
${translate('update.completion.reloadMessage')}
</div>
</div>
`;
}
}
showUpdateCompleteMessage(newVersion) { showUpdateCompleteMessage(newVersion) {
const modal = document.getElementById('updateModal'); const modal = document.getElementById('updateModal');
if (!modal) return; if (!modal) return;
@@ -771,6 +1013,7 @@ export class UpdateService {
// Update the modal content immediately with current data // Update the modal content immediately with current data
this.updateModalContent(); this.updateModalContent();
this.updateChannelUI();
this.renderRecentBanners(); this.renderRecentBanners();
// Show the modal with current data // Show the modal with current data
@@ -801,8 +1044,8 @@ export class UpdateService {
if (data.success) { if (data.success) {
this.currentVersionInfo = data.version; this.currentVersionInfo = data.version;
this.hasGit = data.has_git || false;
// Check if version matches stored version
this.versionMismatch = !isVersionMatch(this.currentVersionInfo); this.versionMismatch = !isVersionMatch(this.currentVersionInfo);
if (this.versionMismatch) { if (this.versionMismatch) {
+18 -3
View File
@@ -3,6 +3,7 @@ import { translate } from '../../utils/i18nHelpers.js';
import { getModelApiClient } from '../../api/modelApiFactory.js'; import { getModelApiClient } from '../../api/modelApiFactory.js';
import { MODEL_TYPES } from '../../api/apiConfig.js'; import { MODEL_TYPES } from '../../api/apiConfig.js';
import { getStorageItem } from '../../utils/storageHelpers.js'; import { getStorageItem } from '../../utils/storageHelpers.js';
import { state } from '../../state/index.js';
export class DownloadManager { export class DownloadManager {
constructor(importManager) { constructor(importManager) {
@@ -125,11 +126,25 @@ export class DownloadManager {
showToast('toast.recipes.nameSaved', { name: this.importManager.recipeName }, 'success'); showToast('toast.recipes.nameSaved', { name: this.importManager.recipeName }, 'success');
} }
// Close modal
modalManager.closeModal('importModal'); modalManager.closeModal('importModal');
// Refresh the recipe if (isDownloadOnly && state.virtualScroller) {
window.recipeManager.loadRecipes(true); const recipeId = this.importManager.recipeId;
try {
const detailRes = await fetch(`/api/lm/recipe/${encodeURIComponent(recipeId)}`);
if (detailRes.ok) {
const updated = await detailRes.json();
state.virtualScroller.updateSingleItem(updated.file_path, updated);
} else {
throw new Error(`API returned ${detailRes.status}`);
}
} catch (e) {
console.warn('Failed to update recipe card in-place, falling back to reload:', e);
await window.recipeManager.loadRecipes({ resetPage: true, preserveScroll: true });
}
} else {
window.recipeManager.loadRecipes({ resetPage: true, preserveScroll: true });
}
} catch (error) { } catch (error) {
console.error('Error:', error); console.error('Error:', error);
+7 -3
View File
@@ -333,6 +333,7 @@ export const PATH_TEMPLATE_PLACEHOLDERS = [
export const DEFAULT_PATH_TEMPLATES = { export const DEFAULT_PATH_TEMPLATES = {
lora: '{base_model}/{first_tag}', lora: '{base_model}/{first_tag}',
checkpoint: '{base_model}', checkpoint: '{base_model}',
unet: '{base_model}',
embedding: '{first_tag}' embedding: '{first_tag}'
}; };
@@ -369,21 +370,24 @@ export function getMatureBlurThreshold(settings = {}) {
export const NODE_TYPES = { export const NODE_TYPES = {
LORA_LOADER: 1, LORA_LOADER: 1,
LORA_STACKER: 2, LORA_STACKER: 2,
WAN_VIDEO_LORA_SELECT: 3 WAN_VIDEO_LORA_SELECT: 3,
HOOK_LORA: 4
}; };
// Node type names to IDs mapping // Node type names to IDs mapping
export const NODE_TYPE_NAMES = { export const NODE_TYPE_NAMES = {
"Lora Loader (LoraManager)": NODE_TYPES.LORA_LOADER, "Lora Loader (LoraManager)": NODE_TYPES.LORA_LOADER,
"Lora Stacker (LoraManager)": NODE_TYPES.LORA_STACKER, "Lora Stacker (LoraManager)": NODE_TYPES.LORA_STACKER,
"WanVideo Lora Select (LoraManager)": NODE_TYPES.WAN_VIDEO_LORA_SELECT "WanVideo Lora Select (LoraManager)": NODE_TYPES.WAN_VIDEO_LORA_SELECT,
"Create Hook LoRA (LoraManager)": NODE_TYPES.HOOK_LORA
}; };
// Node type icons // Node type icons
export const NODE_TYPE_ICONS = { export const NODE_TYPE_ICONS = {
[NODE_TYPES.LORA_LOADER]: "fas fa-l", [NODE_TYPES.LORA_LOADER]: "fas fa-l",
[NODE_TYPES.LORA_STACKER]: "fas fa-s", [NODE_TYPES.LORA_STACKER]: "fas fa-s",
[NODE_TYPES.WAN_VIDEO_LORA_SELECT]: "fas fa-w" [NODE_TYPES.WAN_VIDEO_LORA_SELECT]: "fas fa-w",
[NODE_TYPES.HOOK_LORA]: "fas fa-h"
}; };
// Default ComfyUI node color when bgcolor is null // Default ComfyUI node color when bgcolor is null
+4 -1
View File
@@ -134,7 +134,10 @@ export async function copyToClipboard(text, successMessage = null) {
} }
export function showToast(key, params = {}, type = 'info', fallback = null) { export function showToast(key, params = {}, type = 'info', fallback = null) {
const message = translate(key, params, fallback); // Plain messages (contain spaces) are not i18n dot-notation keys — use verbatim
// to avoid spurious "Translation key not found" warnings from i18next
const isPlainMessage = typeof key === 'string' && /\s/.test(key);
const message = isPlainMessage ? key : translate(key, params, fallback);
const toast = document.createElement('div'); const toast = document.createElement('div');
toast.className = `toast toast-${type}`; toast.className = `toast toast-${type}`;
toast.textContent = message; toast.textContent = message;
+5
View File
@@ -251,10 +251,15 @@
<button class="tag-logic-option" data-value="all" title="{{ t('header.filter.tagLogicAll') }}">{{ t('header.filter.all') }}</button> <button class="tag-logic-option" data-value="all" title="{{ t('header.filter.tagLogicAll') }}">{{ t('header.filter.all') }}</button>
</div> </div>
</div> </div>
<input type="text" id="modelTagsSearchInput" class="filter-search-input"
placeholder="{{ t('header.filter.tagSearchPlaceholder') }}" autocomplete="off">
<div class="filter-tags" id="modelTagsFilter"> <div class="filter-tags" id="modelTagsFilter">
<!-- Top tags will be dynamically inserted here --> <!-- Top tags will be dynamically inserted here -->
<div class="tags-loading">{{ t('common.status.loading') }}</div> <div class="tags-loading">{{ t('common.status.loading') }}</div>
</div> </div>
<div id="modelTagsEmptyState" class="filter-empty-state" hidden>
{{ t('header.filter.noTagMatches') }}
</div>
</div> </div>
{% if current_page == 'loras' or current_page == 'checkpoints' %} {% if current_page == 'loras' or current_page == 'checkpoints' %}
<div class="filter-section"> <div class="filter-section">
@@ -19,6 +19,20 @@
<div class="notification-panels"> <div class="notification-panels">
<div class="notification-panel active" id="updatesPanel" role="tabpanel" aria-labelledby="updatesTab" aria-hidden="false" tabindex="0" data-notification-panel="updates"> <div class="notification-panel active" id="updatesPanel" role="tabpanel" aria-labelledby="updatesTab" aria-hidden="false" tabindex="0" data-notification-panel="updates">
<div class="update-content"> <div class="update-content">
<!-- Channel Selector -->
<div class="update-channels" id="updateChannels">
<div class="channels-label">{{ t('update.channel') }}</div>
<div class="channel-toggle">
<button type="button" class="channel-btn" data-channel="release" id="channelRelease">
<i class="fas fa-tag"></i> {{ t('update.channels.release') }}
</button>
<button type="button" class="channel-btn" data-channel="nightly" id="channelNightly">
<i class="fas fa-moon"></i> {{ t('update.channels.nightly') }}
</button>
</div>
</div>
<div class="update-info"> <div class="update-info">
<div class="version-info"> <div class="version-info">
<div class="current-version"> <div class="current-version">
+1
View File
@@ -85,6 +85,7 @@ sys.modules['comfy.utils'] = comfy_mock.utils
sys.modules['comfy.sd'] = comfy_mock.sd sys.modules['comfy.sd'] = comfy_mock.sd
sys.modules['comfy.model_management'] = comfy_mock.model_management sys.modules['comfy.model_management'] = comfy_mock.model_management
sys.modules['comfy.comfy_types'] = comfy_mock.comfy_types sys.modules['comfy.comfy_types'] = comfy_mock.comfy_types
sys.modules['comfy.hooks'] = MockModule("comfy.hooks")
execution_mock = MockModule("execution") execution_mock = MockModule("execution")
execution_mock.PromptExecutor = mock.MagicMock() execution_mock.PromptExecutor = mock.MagicMock()
@@ -113,6 +113,8 @@ function renderControlsDom(pageKey) {
<div id="baseModelEmptyState" hidden></div> <div id="baseModelEmptyState" hidden></div>
<div id="filterPresets" class="filter-presets"></div> <div id="filterPresets" class="filter-presets"></div>
<div id="modelTagsFilter" class="filter-tags"></div> <div id="modelTagsFilter" class="filter-tags"></div>
<input id="modelTagsSearchInput" />
<div id="modelTagsEmptyState" hidden></div>
<button class="clear-filter"></button> <button class="clear-filter"></button>
</div> </div>
<div class="controls"> <div class="controls">
@@ -961,4 +963,198 @@ describe('PageControls favorites, sorting, and duplicates scenarios', () => {
expect(stateModule.state.bulkMode).toBe(true); expect(stateModule.state.bulkMode).toBe(true);
expect(pageState.duplicatesMode).toBe(true); expect(pageState.duplicatesMode).toBe(true);
}); });
describe('tag search', () => {
it('fetches /search-tags when typing in the tag search input (debounced)', async () => {
vi.useFakeTimers();
const searchTagsUrls = [];
global.fetch = vi.fn((url) => {
if (url.includes('/search-tags')) {
searchTagsUrls.push(url);
return Promise.resolve({
ok: true,
json: async () => ({ success: true, tags: [{ tag: 'anime', count: 3 }] }),
});
}
if (url.includes('/top-tags')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [] }) });
}
if (url.includes('/base-models')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, base_models: [] }) });
}
if (url.includes('/model-types')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, model_types: [] }) });
}
return Promise.resolve({ ok: true, json: async () => ({ success: true }) });
});
renderControlsDom('loras');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('loras');
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
const manager = new FilterManager({ page: 'loras' });
// Open the panel so tags load
manager.toggleFilterPanel();
await vi.runAllTimersAsync();
const input = document.getElementById('modelTagsSearchInput');
input.value = 'ani';
input.dispatchEvent(new Event('input', { bubbles: true }));
// Before debounce fires, no search-tags call yet
expect(searchTagsUrls.length).toBe(0);
// Advance past the 150ms debounce
vi.advanceTimersByTime(160);
await vi.runAllTimersAsync();
expect(searchTagsUrls.length).toBe(1);
expect(searchTagsUrls[0]).toContain('/search-tags');
expect(searchTagsUrls[0]).toContain('q=ani');
vi.useRealTimers();
});
it('renders selected-but-missing tags in a dedicated group at the top', async () => {
vi.useFakeTimers();
global.fetch = vi.fn((url) => {
if (url.includes('/search-tags')) {
return Promise.resolve({
ok: true,
json: async () => ({ success: true, tags: [{ tag: 'anime', count: 3 }] }),
});
}
if (url.includes('/top-tags')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [] }) });
}
if (url.includes('/base-models')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, base_models: [] }) });
}
if (url.includes('/model-types')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, model_types: [] }) });
}
return Promise.resolve({ ok: true, json: async () => ({ success: true }) });
});
renderControlsDom('loras');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('loras');
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
const manager = new FilterManager({ page: 'loras' });
// Pre-seed an active tag filter that won't appear in search results
manager.filters.tags = { 'my-custom-tag': 'include' };
// Open panel and let top-tags load (empty)
manager.toggleFilterPanel();
await vi.runAllTimersAsync();
// Type a search query
const input = document.getElementById('modelTagsSearchInput');
input.value = 'ani';
input.dispatchEvent(new Event('input', { bubbles: true }));
vi.advanceTimersByTime(160);
await vi.runAllTimersAsync();
const container = document.getElementById('modelTagsFilter');
const extraTag = container.querySelector('.filter-tag.extra-tag');
expect(extraTag).not.toBeNull();
expect(extraTag.dataset.tag).toBe('my-custom-tag');
// The search result tag should also be present
const resultTag = container.querySelector('.filter-tag.tag-filter[data-tag="anime"]');
expect(resultTag).not.toBeNull();
vi.useRealTimers();
});
it('shows empty state when search returns no matches and no selected tags', async () => {
vi.useFakeTimers();
global.fetch = vi.fn((url) => {
if (url.includes('/search-tags')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [] }) });
}
if (url.includes('/top-tags')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [] }) });
}
if (url.includes('/base-models')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, base_models: [] }) });
}
if (url.includes('/model-types')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, model_types: [] }) });
}
return Promise.resolve({ ok: true, json: async () => ({ success: true }) });
});
renderControlsDom('loras');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('loras');
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
const manager = new FilterManager({ page: 'loras' });
manager.toggleFilterPanel();
await vi.runAllTimersAsync();
const input = document.getElementById('modelTagsSearchInput');
input.value = 'zzz';
input.dispatchEvent(new Event('input', { bubbles: true }));
vi.advanceTimersByTime(160);
await vi.runAllTimersAsync();
const emptyState = document.getElementById('modelTagsEmptyState');
expect(emptyState.hidden).toBe(false);
vi.useRealTimers();
});
it('reloads top tags when search input is cleared', async () => {
vi.useFakeTimers();
let topTagsCallCount = 0;
global.fetch = vi.fn((url) => {
if (url.includes('/search-tags')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [{ tag: 'anime', count: 3 }] }) });
}
if (url.includes('/top-tags')) {
topTagsCallCount++;
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [] }) });
}
if (url.includes('/base-models')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, base_models: [] }) });
}
if (url.includes('/model-types')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, model_types: [] }) });
}
return Promise.resolve({ ok: true, json: async () => ({ success: true }) });
});
renderControlsDom('loras');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('loras');
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
const manager = new FilterManager({ page: 'loras' });
manager.toggleFilterPanel();
await vi.runAllTimersAsync();
const callsAfterOpen = topTagsCallCount;
expect(callsAfterOpen).toBeGreaterThanOrEqual(1);
// Type, then clear
const input = document.getElementById('modelTagsSearchInput');
input.value = 'ani';
input.dispatchEvent(new Event('input', { bubbles: true }));
vi.advanceTimersByTime(160);
await vi.runAllTimersAsync();
input.value = '';
input.dispatchEvent(new Event('input', { bubbles: true }));
vi.advanceTimersByTime(160);
await vi.runAllTimersAsync();
// An additional top-tags call should have happened after clearing
expect(topTagsCallCount).toBeGreaterThan(callsAfterOpen);
vi.useRealTimers();
});
});
}); });
@@ -0,0 +1,186 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
import { state } from '../../../static/js/state/index.js';
import { MODEL_TYPES } from '../../../static/js/api/apiConfig.js';
import { eventManager } from '../../../static/js/utils/EventManager.js';
import { BulkManager } from '../../../static/js/managers/BulkManager.js';
function fire(type, init = {}) {
return new MouseEvent(type, { bubbles: true, cancelable: true, ...init });
}
describe('BulkManager marquee guards', () => {
beforeEach(() => {
vi.useFakeTimers();
// jsdom may not provide requestAnimationFrame; stub it so the auto-scroll loop is a no-op.
window.requestAnimationFrame = vi.fn();
window.cancelAnimationFrame = vi.fn();
eventManager.cleanup();
state.currentPageType = MODEL_TYPES.LORA;
state.bulkMode = false;
state.selectedModels.clear();
document.body.innerHTML = '<div class="page-content"></div>';
const pageContent = document.querySelector('.page-content');
pageContent.getBoundingClientRect = () => ({
top: 0,
left: 0,
right: 1000,
bottom: 1000,
width: 1000,
height: 1000,
x: 0,
y: 0,
toJSON: () => ({}),
});
pageContent.scrollBy = vi.fn();
});
afterEach(() => {
eventManager.cleanup();
vi.useRealTimers();
document.body.innerHTML = '';
});
function createBulkManager() {
const bulk = new BulkManager();
bulk.initialize();
return bulk;
}
it('never starts a marquee when the left button is not held', () => {
const bulk = createBulkManager();
const pageContent = document.querySelector('.page-content');
pageContent.dispatchEvent(fire('mousedown', { button: 0, clientX: 10, clientY: 10 }));
document.dispatchEvent(fire('mousemove', { buttons: 0, clientX: 50, clientY: 50 }));
expect(bulk.mouseDownTime).toBe(0);
expect(bulk.isMarqueeActive).toBe(false);
expect(state.bulkMode).toBe(false);
expect(document.querySelector('.marquee-selection')).toBeNull();
});
it('requires holding the left button for the drag delay before starting a marquee', () => {
const bulk = createBulkManager();
const pageContent = document.querySelector('.page-content');
pageContent.dispatchEvent(fire('mousedown', { button: 0, clientX: 10, clientY: 10 }));
// Fast movement: far enough, but too soon after mousedown.
document.dispatchEvent(fire('mousemove', { buttons: 1, clientX: 30, clientY: 10 }));
expect(state.bulkMode).toBe(false);
expect(bulk.isMarqueeActive).toBe(false);
// Once the hold time has elapsed, the same drag qualifies.
vi.advanceTimersByTime(100);
document.dispatchEvent(fire('mousemove', { buttons: 1, clientX: 35, clientY: 12 }));
expect(state.bulkMode).toBe(true);
expect(bulk.isMarqueeActive).toBe(true);
expect(document.querySelector('.marquee-selection')).not.toBeNull();
});
it('ends an active marquee if the left button is released without a mouseup event', () => {
const bulk = createBulkManager();
bulk.mouseDownPosition = { x: 10, y: 10 };
bulk.startMarqueeSelection({}, true);
expect(state.bulkMode).toBe(true);
expect(document.querySelector('.marquee-selection')).not.toBeNull();
// No mouseup was dispatched; a plain move with the button released finalizes it.
document.dispatchEvent(fire('mousemove', { buttons: 0, clientX: 50, clientY: 50 }));
expect(bulk.isMarqueeActive).toBe(false);
expect(document.querySelector('.marquee-selection')).toBeNull();
expect(state.bulkMode).toBe(false); // zero selected -> auto-exit
});
it('treats a tiny marquee as an accidental click: clears selection and exits bulk mode', () => {
const bulk = createBulkManager();
const card = document.createElement('div');
card.className = 'model-card selected';
card.dataset.filepath = '/models/test.safetensors';
document.body.appendChild(card);
state.selectedModels.add('/models/test.safetensors');
bulk.mouseDownPosition = { x: 100, y: 100 };
bulk.startMarqueeSelection({}, true);
expect(state.bulkMode).toBe(true);
bulk.endMarqueeSelection({ clientX: 103, clientY: 104 });
expect(state.bulkMode).toBe(false);
expect(state.selectedModels.size).toBe(0);
expect(card.classList.contains('selected')).toBe(false);
});
it('keeps selection and bulk mode when the marquee is large enough', () => {
const bulk = createBulkManager();
const card = document.createElement('div');
card.className = 'model-card selected';
card.dataset.filepath = '/models/test.safetensors';
document.body.appendChild(card);
state.selectedModels.add('/models/test.safetensors');
bulk.mouseDownPosition = { x: 100, y: 100 };
bulk.startMarqueeSelection({}, true);
bulk.endMarqueeSelection({ clientX: 130, clientY: 140 });
expect(state.bulkMode).toBe(true);
expect(state.selectedModels.has('/models/test.safetensors')).toBe(true);
expect(card.classList.contains('selected')).toBe(true);
});
it('keeps auto-scroll marquee selections when the pointer only moved a few pixels', () => {
const bulk = createBulkManager();
const pageContent = document.querySelector('.page-content');
// Card just below the press point in document coordinates.
const card = document.createElement('div');
card.className = 'model-card';
card.dataset.filepath = '/models/off-screen.safetensors';
card.getBoundingClientRect = () => ({
top: 950,
left: 400,
right: 600,
bottom: 1050,
width: 200,
height: 100,
x: 400,
y: 950,
toJSON: () => ({}),
});
document.body.appendChild(card);
pageContent.dispatchEvent(fire('mousedown', { button: 0, clientX: 500, clientY: 900 }));
vi.advanceTimersByTime(100);
// Small pointer move: enough to start the marquee, but under minMarqueeSize.
document.dispatchEvent(fire('mousemove', { buttons: 1, clientX: 506, clientY: 906 }));
expect(bulk.isMarqueeActive).toBe(true);
// Auto-scroll grows the document-space box while the pointer stays nearly still.
pageContent.scrollTop = 200;
card.getBoundingClientRect = () => ({
top: 750,
left: 400,
right: 600,
bottom: 850,
width: 200,
height: 100,
x: 400,
y: 750,
toJSON: () => ({}),
});
document.dispatchEvent(fire('mousemove', { buttons: 1, clientX: 506, clientY: 906 }));
expect(state.selectedModels.has('/models/off-screen.safetensors')).toBe(true);
// Release: the client-space box is tiny, but the document-space box is not.
document.dispatchEvent(fire('mouseup', { button: 0, clientX: 506, clientY: 906 }));
expect(state.selectedModels.has('/models/off-screen.safetensors')).toBe(true);
expect(state.bulkMode).toBe(true);
});
});
@@ -106,6 +106,118 @@ afterEach(() => {
}); });
}); });
describe('SettingsManager root selects', () => {
const rootCases = [
{
method: 'loadLoraRoots',
selectId: 'defaultLoraRoot',
endpoint: '/api/lm/loras/roots',
errorKey: 'toast.settings.loraRootsFailed',
},
{
method: 'loadCheckpointRoots',
selectId: 'defaultCheckpointRoot',
endpoint: '/api/lm/checkpoints/checkpoints_roots',
errorKey: 'toast.settings.checkpointRootsFailed',
},
{
method: 'loadUnetRoots',
selectId: 'defaultUnetRoot',
endpoint: '/api/lm/checkpoints/unet_roots',
errorKey: 'toast.settings.unetRootsFailed',
},
{
method: 'loadEmbeddingRoots',
selectId: 'defaultEmbeddingRoot',
endpoint: '/api/lm/embeddings/roots',
errorKey: 'toast.settings.embeddingRootsFailed',
},
];
const appendRootSelect = (id) => {
const select = document.createElement('select');
select.id = id;
document.body.appendChild(select);
return select;
};
it.each(rootCases)(
'populates the $method select with roots and keeps it enabled',
async ({ method, selectId, endpoint }) => {
const manager = createManager();
const select = appendRootSelect(selectId);
select.disabled = true;
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
roots: ['/models/root-a', '/models/root-b'],
}),
});
await manager[method]();
expect(global.fetch).toHaveBeenCalledWith(endpoint);
expect(Array.from(select.options).map(option => option.value)).toEqual([
'/models/root-a',
'/models/root-b',
]);
expect(select.disabled).toBe(false);
expect(showToast).not.toHaveBeenCalled();
}
);
it.each(rootCases)(
'shows a placeholder and no error toast when $method has empty roots',
async ({ method, selectId, endpoint }) => {
const manager = createManager();
const select = appendRootSelect(selectId);
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
roots: [],
}),
});
await manager[method]();
expect(global.fetch).toHaveBeenCalledWith(endpoint);
expect(select.options).toHaveLength(1);
expect(select.options[0].value).toBe('');
expect(select.options[0].textContent).toBe('No Default');
expect(select.disabled).toBe(true);
expect(showToast).not.toHaveBeenCalled();
}
);
it.each(rootCases)(
'shows an error toast when the $method roots request fails',
async ({ method, selectId, errorKey }) => {
const manager = createManager();
const select = appendRootSelect(selectId);
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
});
await manager[method]();
expect(select.options).toHaveLength(1);
expect(select.options[0].value).toBe('');
expect(select.disabled).toBe(true);
expect(showToast).toHaveBeenCalledWith(
errorKey,
expect.objectContaining({ message: expect.any(String) }),
'error',
);
}
);
});
describe('SettingsManager library controls', () => { describe('SettingsManager library controls', () => {
it('loads libraries and populates the select', async () => { it('loads libraries and populates the select', async () => {
const manager = createManager(); const manager = createManager();
+123 -2
View File
@@ -1,12 +1,26 @@
import { describe, beforeEach, afterEach, expect, it, vi } from 'vitest'; import { describe, beforeEach, afterEach, expect, it, vi } from 'vitest';
import { UpdateService } from '../../../static/js/managers/UpdateService.js'; import { UpdateService } from '../../../static/js/managers/UpdateService.js';
import { state } from '../../../static/js/state/index.js';
function createFetchResponse(payload) { function createFetchResponse(payload) {
return { return {
json: vi.fn().mockResolvedValue(payload) json: vi.fn().mockResolvedValue(payload),
ok: true,
}; };
} }
function stubSettingsUpdateChannel(channel) {
state.global = state.global || {};
state.global.settings = state.global.settings || {};
state.global.settings.update_channel = channel;
}
function clearSettingsUpdateChannel() {
if (state.global?.settings) {
delete state.global.settings.update_channel;
}
}
describe('UpdateService passive checks', () => { describe('UpdateService passive checks', () => {
let service; let service;
let fetchMock; let fetchMock;
@@ -16,10 +30,13 @@ describe('UpdateService passive checks', () => {
success: true, success: true,
current_version: 'v1.0.0', current_version: 'v1.0.0',
latest_version: 'v1.0.0', latest_version: 'v1.0.0',
git_info: { short_hash: 'abc123' } git_info: { short_hash: 'abc123' },
has_git: true,
})); }));
global.fetch = fetchMock; global.fetch = fetchMock;
stubSettingsUpdateChannel('release');
service = new UpdateService(); service = new UpdateService();
service.updateNotificationsEnabled = false; service.updateNotificationsEnabled = false;
service.lastCheckTime = 0; service.lastCheckTime = 0;
@@ -28,6 +45,7 @@ describe('UpdateService passive checks', () => {
afterEach(() => { afterEach(() => {
delete global.fetch; delete global.fetch;
clearSettingsUpdateChannel();
}); });
it('skips passive update checks when notifications are disabled', async () => { it('skips passive update checks when notifications are disabled', async () => {
@@ -43,3 +61,106 @@ describe('UpdateService passive checks', () => {
expect(fetchMock).toHaveBeenCalledWith('/api/lm/check-updates?nightly=false'); expect(fetchMock).toHaveBeenCalledWith('/api/lm/check-updates?nightly=false');
}); });
}); });
describe('UpdateService nightly notification throttling', () => {
let fetchMock;
let updateToggle;
let updateBadge;
function stubUpdateBadgeDom() {
updateToggle = document.createElement('div');
updateToggle.className = 'update-toggle';
updateBadge = document.createElement('span');
updateBadge.className = 'update-badge';
updateToggle.appendChild(updateBadge);
document.body.appendChild(updateToggle);
vi.spyOn(document, 'querySelector').mockImplementation((selector) => {
if (selector === '.update-toggle') return updateToggle;
if (selector === '.update-toggle .update-badge') return updateBadge;
return null;
});
}
function makeUpdateResponse(channel) {
return {
success: true,
current_version: 'v1.0.0',
latest_version: channel === 'nightly' ? 'main-abc1234' : 'v1.1.0',
update_available: true,
git_info: { short_hash: 'abc123' },
has_git: true,
nightly: channel === 'nightly',
changelog: ['test: change'],
releases: [],
behind_by: 3,
commit_date: '2026-07-31',
};
}
beforeEach(() => {
fetchMock = vi.fn().mockResolvedValue(createFetchResponse(makeUpdateResponse('release')));
global.fetch = fetchMock;
stubUpdateBadgeDom();
});
afterEach(() => {
vi.restoreAllMocks();
delete global.fetch;
});
it('shows the nightly badge once and keeps it visible for the session', async () => {
stubSettingsUpdateChannel('nightly');
fetchMock.mockResolvedValue(createFetchResponse(makeUpdateResponse('nightly')));
const service = new UpdateService();
service.updateNotificationsEnabled = true;
await service.checkForUpdates({ force: true });
expect(service.updateAvailable).toBe(true);
expect(service.nightlyBadgeShown).toBe(true);
expect(service.nightlyNotifyDate).toBe(service._getTodayKey());
expect(updateBadge.classList.contains('visible')).toBe(true);
// A repeated check within the same session keeps the badge visible.
await service.checkForUpdates({ force: true });
expect(updateBadge.classList.contains('visible')).toBe(true);
});
it('suppresses the nightly badge on a later session in the same day', async () => {
stubSettingsUpdateChannel('nightly');
fetchMock.mockResolvedValue(createFetchResponse(makeUpdateResponse('nightly')));
const firstService = new UpdateService();
firstService.updateNotificationsEnabled = true;
await firstService.checkForUpdates({ force: true });
expect(updateBadge.classList.contains('visible')).toBe(true);
// Simulate a fresh page session on the same calendar day.
const secondService = new UpdateService();
secondService.updateNotificationsEnabled = true;
await secondService.checkForUpdates({ force: true });
expect(secondService.updateAvailable).toBe(true);
expect(secondService.nightlyBadgeShown).toBe(false);
expect(updateBadge.classList.contains('visible')).toBe(false);
});
it('is not affected by the daily limit on the release channel', async () => {
stubSettingsUpdateChannel('release');
fetchMock.mockResolvedValue(createFetchResponse(makeUpdateResponse('release')));
const firstService = new UpdateService();
firstService.updateNotificationsEnabled = true;
await firstService.checkForUpdates({ force: true });
expect(updateBadge.classList.contains('visible')).toBe(true);
const secondService = new UpdateService();
secondService.updateNotificationsEnabled = true;
await secondService.checkForUpdates({ force: true });
expect(secondService.updateAvailable).toBe(true);
expect(updateBadge.classList.contains('visible')).toBe(true);
});
});
@@ -30,10 +30,10 @@ def test_metadata_hook_installs_and_traces_execution(monkeypatch, metadata_regis
calls = [] calls = []
def record_stub(self, node_id, class_type, inputs, outputs): def record_stub(self, node_id, class_type, inputs, outputs, return_types=None):
calls.append(("record", node_id, class_type, inputs)) calls.append(("record", node_id, class_type, inputs))
def update_stub(self, node_id, class_type, outputs): def update_stub(self, node_id, class_type, outputs, return_types=None):
calls.append(("update", node_id, class_type, outputs)) calls.append(("update", node_id, class_type, outputs))
monkeypatch.setattr(MetadataRegistry, "record_node_execution", record_stub) monkeypatch.setattr(MetadataRegistry, "record_node_execution", record_stub)
@@ -820,3 +820,227 @@ def test_lora_manager_checkpoint_and_unet_loaders_extract_models(metadata_regist
"type": "checkpoint", "type": "checkpoint",
"node_id": "unet_node", "node_id": "unet_node",
} }
# ---------------------------------------------------------------------------
# MetadataOverwriteExtractor & overwrite merge tests
# ---------------------------------------------------------------------------
from py.metadata_collector.constants import OVERWRITE, METADATA_OVERWRITE_FIELDS
from py.metadata_collector.node_extractors import MetadataOverwriteExtractor
def test_metadata_overwrite_extractor_stores_truthy_values(metadata_registry):
"""Extractor should store truthy inputs under the OVERWRITE category."""
metadata_registry.start_collection("prompt-ow")
metadata = metadata_registry.prompt_metadata["prompt-ow"]
inputs = {
"prompt": "a beautiful landscape",
"negative_prompt": "",
"seed": 42,
"steps": 0,
"cfg_scale": 7.5,
"sampler": "",
"scheduler": "",
"model": "myModel.safetensors",
"loras": "<lora:detail:0.8>",
"size": "1024x768",
"clip_skip": 0,
"additional_data": '{"Copyright": "CC0"}',
}
MetadataOverwriteExtractor.extract("ow-1", inputs, None, metadata)
assert OVERWRITE in metadata
assert "ow-1" in metadata[OVERWRITE]
params = metadata[OVERWRITE]["ow-1"]["parameters"]
# Truthy values stored
assert params["prompt"] == "a beautiful landscape"
assert params["seed"] == 42
assert params["cfg_scale"] == 7.5
assert params["model"] == "myModel.safetensors"
assert params["loras"] == "<lora:detail:0.8>"
assert params["size"] == "1024x768"
assert params["additional_data"] == '{"Copyright": "CC0"}'
# Falsy values NOT stored
assert "negative_prompt" not in params
assert "steps" not in params
assert "sampler" not in params
assert "scheduler" not in params
# clip_skip=0 is now stored (0 != sentinel -25) — wired 0 is valid
assert params["clip_skip"] == 0
metadata_registry.clear_metadata()
def test_metadata_overwrite_extractor_empty_inputs(metadata_registry):
"""Extractor with all-falsy inputs should NOT create OVERWRITE category."""
metadata_registry.start_collection("prompt-ow2")
metadata = metadata_registry.prompt_metadata["prompt-ow2"]
from py.metadata_collector.constants import CLIP_SKIP_SENTINEL
inputs = {key: "" for key in METADATA_OVERWRITE_FIELDS}
inputs.update({"seed": 0, "steps": 0, "cfg_scale": 0.0, "clip_skip": CLIP_SKIP_SENTINEL})
MetadataOverwriteExtractor.extract("ow-2", inputs, None, metadata)
# start_collection pre-creates empty dicts for all categories,
# but no node should have populated OVERWRITE with any data
assert not metadata[OVERWRITE]
metadata_registry.clear_metadata()
def test_extract_generation_params_applies_overwrite(metadata_registry, populated_registry, monkeypatch):
"""overwrite values should replace inferred params in extract_generation_params."""
import py.metadata_collector.metadata_processor as mp
monkeypatch.setattr(mp, "standalone_mode", False)
metadata = populated_registry["metadata"]
registry_obj = populated_registry["registry"]
# Simulate the MetadataOverwriteLM node having been executed with overwrite values
registry_obj.start_collection("promptA")
# Re-populate with the same data (start_collection resets)
registry_obj.set_current_prompt(populated_registry["prompt"])
metadata2 = registry_obj.prompt_metadata["promptA"]
# Inject overwrite data into metadata
metadata2[OVERWRITE] = {
"ow-1": {
"parameters": {
"seed": 777,
"additional_data": '{"AuthorURL": "https://civitai.com/user/foo"}',
},
"node_id": "ow-1",
}
}
# Copy other categories from original populated metadata
for cat in ("models", "prompts", "sampling", "loras", "size", "images"):
if cat in metadata:
metadata2[cat] = metadata[cat]
metadata2["execution_order"] = metadata["execution_order"]
params = MetadataProcessor.extract_generation_params(metadata2, id="vae")
# Overwritten values
assert params["seed"] == 777
assert params["additional_data"] == '{"AuthorURL": "https://civitai.com/user/foo"}'
# Inferred values still present (not overwritten)
assert params["prompt"] == "A castle on a hill"
assert params["cfg_scale"] == 7.5
assert params["checkpoint"] == "model.safetensors"
registry_obj.clear_metadata()
def test_extract_generation_params_overwrite_falsy_skipped(metadata_registry, populated_registry, monkeypatch):
"""Overwrite entries with falsy values should NOT replace inferred params."""
import py.metadata_collector.metadata_processor as mp
monkeypatch.setattr(mp, "standalone_mode", False)
metadata = populated_registry["metadata"]
registry_obj = populated_registry["registry"]
registry_obj.start_collection("promptA")
registry_obj.set_current_prompt(populated_registry["prompt"])
metadata2 = registry_obj.prompt_metadata["promptA"]
# Inject overwrite with falsy values (except clip_skip=0 which is now
# treated as a valid wired input thanks to the -25 sentinel)
metadata2[OVERWRITE] = {
"ow-1": {
"parameters": {
"seed": 0,
"steps": 0,
"cfg_scale": 0.0,
"prompt": "",
"clip_skip": 0,
},
"node_id": "ow-1",
}
}
for cat in ("models", "prompts", "sampling", "loras", "size", "images"):
if cat in metadata:
metadata2[cat] = metadata[cat]
metadata2["execution_order"] = metadata["execution_order"]
params = MetadataProcessor.extract_generation_params(metadata2, id="vae")
# Falsy overwrites should NOT have replaced inferred values
assert params["prompt"] == "A castle on a hill"
assert params["cfg_scale"] == 7.5
# clip_skip=0 is a valid wired value (not the -25 sentinel) — should be applied
assert params["clip_skip"] == 0
registry_obj.clear_metadata()
def test_fill_missing_metadata_skips_overwrite_for_bypassed_node(metadata_registry):
"""Bypassed (mode=4) node should not have OVERWRITE filled from cache."""
metadata_registry.start_collection("prompt-bypass")
# Simulate a previous execution that cached overwrite data
metadata_registry.record_node_execution(
"ow-1",
"MetadataOverwriteLM",
{"seed": 99, "prompt": "test", "steps": 0, "cfg_scale": 0.0,
"negative_prompt": "", "sampler": "", "scheduler": "", "model": "",
"loras": "", "size": "", "clip_skip": 0, "additional_data": ""},
None,
)
# Now start a new prompt where the node is bypassed (mode=4)
metadata_registry.start_collection("prompt-bypass-2")
original_prompt = {
"ow-1": {"class_type": "MetadataOverwriteLM", "inputs": {}, "mode": 4},
}
metadata_registry.set_current_prompt(
SimpleNamespace(original_prompt=original_prompt)
)
metadata = metadata_registry.get_metadata("prompt-bypass-2")
# The overwrite data should NOT be present (node was bypassed, not
# a cache hit — it should not inherit previous execution's overwrite)
assert "ow-1" not in metadata.get(OVERWRITE, {})
metadata_registry.clear_metadata()
def test_fill_missing_metadata_fills_overwrite_for_muted_node(metadata_registry):
"""Muted (mode=2) node should also not have OVERWRITE filled from cache."""
metadata_registry.start_collection("prompt-mute")
# Simulate a previous execution that cached overwrite data
metadata_registry.record_node_execution(
"ow-1",
"MetadataOverwriteLM",
{"seed": 88, "prompt": "test2", "steps": 0, "cfg_scale": 0.0,
"negative_prompt": "", "sampler": "", "scheduler": "", "model": "",
"loras": "", "size": "", "clip_skip": 0, "additional_data": ""},
None,
)
# Start a new prompt where the node is muted (mode=2)
metadata_registry.start_collection("prompt-mute-2")
original_prompt = {
"ow-1": {"class_type": "MetadataOverwriteLM", "inputs": {}, "mode": 2},
}
metadata_registry.set_current_prompt(
SimpleNamespace(original_prompt=original_prompt)
)
metadata = metadata_registry.get_metadata("prompt-mute-2")
assert "ow-1" not in metadata.get(OVERWRITE, {})
metadata_registry.clear_metadata()
+100 -1
View File
@@ -59,7 +59,7 @@ def test_save_image_defaults_to_writing_png_metadata(monkeypatch, tmp_path):
image_path = tmp_path / "sample_00001_.png" image_path = tmp_path / "sample_00001_.png"
with Image.open(image_path) as img: with Image.open(image_path) as img:
assert img.info["parameters"] == "prompt text\nSeed: 123" assert img.info["parameters"] == "prompt text\nSeed: 123, Version: ComfyUI"
def test_save_image_skips_png_parameters_when_metadata_disabled_and_keeps_workflow( def test_save_image_skips_png_parameters_when_metadata_disabled_and_keeps_workflow(
@@ -363,3 +363,102 @@ def test_save_image_as_recipe_writes_recipe_without_async_scanner_calls(
assert recipe["gen_params"] == {"prompt": "prompt text", "seed": 123} assert recipe["gen_params"] == {"prompt": "prompt text", "seed": 123}
assert scanner._json_path_map[recipe["id"]] == os.path.normpath(str(recipe_files[0])) assert scanner._json_path_map[recipe["id"]] == os.path.normpath(str(recipe_files[0]))
assert scanner.fts_updates == [(recipe["id"], "add")] assert scanner.fts_updates == [(recipe["id"], "add")]
# ---------------------------------------------------------------------------
# Tests for webp_method and jpeg_subsampling parameters
# ---------------------------------------------------------------------------
def _capture_save_kwargs(monkeypatch):
"""Monkeypatch Image.Image.save to capture kwargs while still saving to disk."""
real_save = Image.Image.save
captured_kwargs = {}
def _fake_save(self, fp, *args, **kwargs):
captured_kwargs.update(kwargs)
return real_save(self, fp, *args, **kwargs)
monkeypatch.setattr(Image.Image, "save", _fake_save)
return captured_kwargs
def test_webp_method_default_passed_to_pillow_save(monkeypatch, tmp_path):
_configure_save_paths(monkeypatch, tmp_path)
_configure_metadata(monkeypatch, {"prompt": "test", "seed": 1})
captured = _capture_save_kwargs(monkeypatch)
node = SaveImageLM()
node.save_images([_make_image()], "ComfyUI", "webp", id="node-1")
assert "method" in captured
assert captured["method"] == 6
def test_webp_method_custom_value_passed_to_pillow_save(monkeypatch, tmp_path):
_configure_save_paths(monkeypatch, tmp_path)
_configure_metadata(monkeypatch, {"prompt": "test", "seed": 1})
captured = _capture_save_kwargs(monkeypatch)
node = SaveImageLM()
node.save_images(
[_make_image()], "ComfyUI", "webp", id="node-1", webp_method=3
)
assert captured["method"] == 3
def test_jpeg_subsampling_default_passed_to_pillow_save(monkeypatch, tmp_path):
_configure_save_paths(monkeypatch, tmp_path)
_configure_metadata(monkeypatch, {"prompt": "test", "seed": 1})
captured = _capture_save_kwargs(monkeypatch)
node = SaveImageLM()
node.save_images([_make_image()], "ComfyUI", "jpeg", id="node-1")
assert "subsampling" in captured
assert captured["subsampling"] == 0
def test_jpeg_subsampling_custom_value_passed_to_pillow_save(monkeypatch, tmp_path):
_configure_save_paths(monkeypatch, tmp_path)
_configure_metadata(monkeypatch, {"prompt": "test", "seed": 1})
captured = _capture_save_kwargs(monkeypatch)
node = SaveImageLM()
node.save_images(
[_make_image()], "ComfyUI", "jpeg", id="node-1", jpeg_subsampling=1
)
assert captured["subsampling"] == 1
class TestParameterDefaultConsistency:
"""Verify defaults match across INPUT_TYPES, save_images(), and process_image()."""
def test_webp_method_defaults_are_consistent(self):
input_types = SaveImageLM.INPUT_TYPES()
optional = input_types["optional"]
assert optional["webp_method"][1]["default"] == 6
assert SaveImageLM.save_images.__defaults__[4] == 6 # positional: webp_method=6 is at index 4
assert SaveImageLM.process_image.__defaults__[6] == 6
def test_jpeg_subsampling_defaults_are_consistent(self):
input_types = SaveImageLM.INPUT_TYPES()
optional = input_types["optional"]
assert optional["jpeg_subsampling"][1]["default"] == 0
assert SaveImageLM.save_images.__defaults__[5] == 0
assert SaveImageLM.process_image.__defaults__[7] == 0
def test_png_does_not_pass_webp_method_or_jpeg_subsampling(monkeypatch, tmp_path):
_configure_save_paths(monkeypatch, tmp_path)
_configure_metadata(monkeypatch, {"prompt": "test", "seed": 1})
captured = _capture_save_kwargs(monkeypatch)
node = SaveImageLM()
node.save_images([_make_image()], "ComfyUI", "png", id="node-1")
assert "method" not in captured
assert "subsampling" not in captured
+50
View File
@@ -36,3 +36,53 @@ async def test_model_query_handler_rejects_negative_limit_for_base_models():
await handler.get_base_models(SimpleNamespace(query={"limit": "-1"})) await handler.get_base_models(SimpleNamespace(query={"limit": "-1"}))
assert service.received_limit == 20 assert service.received_limit == 20
class DummySearchTagsService:
"""Minimal service stub recording search_tags arguments."""
def __init__(self, result=None):
self.received_query = None
self.received_limit = None
self._result = result or []
async def search_tags(self, query, limit):
self.received_query = query
self.received_limit = limit
return self._result
@pytest.mark.asyncio
async def test_model_query_handler_search_tags_passes_query_and_limit():
service = DummySearchTagsService(result=[{"tag": "anime", "count": 3}])
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
response = await handler.search_tags(
SimpleNamespace(query={"q": "ani", "limit": "50"})
)
payload = json.loads(response.text)
assert payload["success"] is True
assert payload["tags"] == [{"tag": "anime", "count": 3}]
assert service.received_query == "ani"
assert service.received_limit == 50
@pytest.mark.asyncio
async def test_model_query_handler_search_tags_defaults_limit_to_20():
service = DummySearchTagsService()
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
await handler.search_tags(SimpleNamespace(query={}))
assert service.received_limit == 20
@pytest.mark.asyncio
async def test_model_query_handler_search_tags_clamps_negative_limit():
service = DummySearchTagsService()
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
await handler.search_tags(SimpleNamespace(query={"limit": "-5"}))
assert service.received_limit == 20
+328 -1
View File
@@ -1,10 +1,33 @@
import logging import logging
import os
import shutil
from aiohttp import ClientError from aiohttp import ClientError
from aiohttp import web
import pytest import pytest
from py.routes import update_routes from py.routes import update_routes
def _fake_request(body=None, query_params=None):
from multidict import MultiDict
q = MultiDict(query_params or {})
req = type("Req", (), {
"has_body": body is not None,
"match_info": {},
"rel_url": type("U", (), {"query": q})(),
"query": q,
"app": {},
})()
async def _json():
return body or {}
req.json = _json
return req
class OfflineDownloader: class OfflineDownloader:
async def make_request(self, *_, **__): async def make_request(self, *_, **__):
return False, "Cannot connect to host" return False, "Cannot connect to host"
@@ -53,10 +76,12 @@ async def test_get_nightly_version_network_error_logs_warning(monkeypatch, caplo
caplog.set_level(logging.WARNING) caplog.set_level(logging.WARNING)
monkeypatch.setattr(update_routes, "get_downloader", lambda: _stub_downloader(RaisingDownloader())) monkeypatch.setattr(update_routes, "get_downloader", lambda: _stub_downloader(RaisingDownloader()))
version, changelog = await update_routes.UpdateRoutes._get_nightly_version() version, changelog, behind_by, commit_date = await update_routes.UpdateRoutes._get_nightly_version()
assert version == "main" assert version == "main"
assert changelog == [] assert changelog == []
assert behind_by == 0
assert commit_date == ""
assert "Unable to reach GitHub for nightly version" in caplog.text assert "Unable to reach GitHub for nightly version" in caplog.text
assert "Traceback" not in caplog.text assert "Traceback" not in caplog.text
@@ -236,3 +261,305 @@ async def test_perform_git_update_stable_preserves_user_dirs(monkeypatch, tmp_pa
clean_args = clean_calls[0][1] clean_args = clean_calls[0][1]
for name in update_routes._PRESERVE_DIRS: for name in update_routes._PRESERVE_DIRS:
assert name in clean_args, f"{name} missing from git clean excludes (stable)" assert name in clean_args, f"{name} missing from git clean excludes (stable)"
def test_init_git_repo_creates_valid_repo(tmp_path, monkeypatch):
if not shutil.which("git"):
pytest.skip("git executable not found")
plugin_root = tmp_path / "plugin"
plugin_root.mkdir()
(plugin_root / ".tracking").write_text("pyproject.toml")
(plugin_root / "settings.json").write_text('{"some": "value"}')
try:
success, version = update_routes.UpdateRoutes._init_git_repo(str(plugin_root))
except Exception as e:
pytest.skip(f"Network unavailable for git fetch: {e}")
assert success is True
assert version.startswith("main-")
assert len(version) > len("main-")
assert (plugin_root / ".git").is_dir()
assert not (plugin_root / ".tracking").exists()
assert (plugin_root / "settings.json").exists()
assert (plugin_root / "pyproject.toml").exists()
@pytest.mark.asyncio
async def test_switch_channel_invalid_channel_returns_error():
req = _fake_request({"channel": "bad_channel"})
resp = await update_routes.UpdateRoutes.switch_channel(req)
data = _raw_body(resp)
assert not data["success"]
assert "Invalid channel" in data["error"]
@pytest.mark.asyncio
async def test_switch_channel_to_nightly_without_git_inits_repo(monkeypatch, tmp_path):
routes_file = tmp_path / "py" / "routes" / "update_routes.py"
routes_file.parent.mkdir(parents=True)
routes_file.write_text("")
monkeypatch.setattr(update_routes, "__file__", str(routes_file))
monkeypatch.setattr(update_routes, "ensure_settings_file", lambda logger: str(tmp_path / "settings.json"))
monkeypatch.setattr(
update_routes.UpdateRoutes,
"_init_git_repo",
staticmethod(lambda plugin_root: (True, "main-fedcba9")),
)
req = _fake_request({"channel": "nightly"})
resp = await update_routes.UpdateRoutes.switch_channel(req)
data = _raw_body(resp)
assert data["success"] is True
assert data["channel"] == "nightly"
assert data["new_version"] == "main-fedcba9"
@pytest.mark.asyncio
async def test_switch_channel_to_nightly_with_git_calls_git_update(monkeypatch, tmp_path):
routes_file = tmp_path / "py" / "routes" / "update_routes.py"
routes_file.parent.mkdir(parents=True)
routes_file.write_text("")
monkeypatch.setattr(update_routes, "__file__", str(routes_file))
monkeypatch.setattr(update_routes, "ensure_settings_file", lambda logger: str(tmp_path / "settings.json"))
(tmp_path / ".git").mkdir()
async def _fake_git_update(*args, **kwargs):
return True, "main-1111111"
monkeypatch.setattr(
update_routes.UpdateRoutes, "_perform_git_update", _fake_git_update
)
req = _fake_request({"channel": "nightly"})
resp = await update_routes.UpdateRoutes.switch_channel(req)
data = _raw_body(resp)
assert data["success"] is True
assert data["channel"] == "nightly"
assert data["new_version"] == "main-1111111"
@pytest.mark.asyncio
async def test_switch_channel_to_release_with_git_calls_git_update(monkeypatch, tmp_path):
routes_file = tmp_path / "py" / "routes" / "update_routes.py"
routes_file.parent.mkdir(parents=True)
routes_file.write_text("")
monkeypatch.setattr(update_routes, "__file__", str(routes_file))
monkeypatch.setattr(update_routes, "ensure_settings_file", lambda logger: str(tmp_path / "settings.json"))
(tmp_path / ".git").mkdir()
async def _fake_git_update(*args, **kwargs):
return True, "v9.9.9"
monkeypatch.setattr(
update_routes.UpdateRoutes, "_perform_git_update", _fake_git_update
)
req = _fake_request({"channel": "release"})
resp = await update_routes.UpdateRoutes.switch_channel(req)
data = _raw_body(resp)
assert data["success"] is True
assert data["channel"] == "release"
assert data["new_version"] == "v9.9.9"
@pytest.mark.asyncio
async def test_switch_channel_to_release_without_git_still_downloads_zip(monkeypatch, tmp_path):
routes_file = tmp_path / "py" / "routes" / "update_routes.py"
routes_file.parent.mkdir(parents=True)
routes_file.write_text("")
monkeypatch.setattr(update_routes, "__file__", str(routes_file))
monkeypatch.setattr(update_routes, "ensure_settings_file", lambda logger: str(tmp_path / "settings.json"))
async def _fake_zip(*args, **kwargs):
return True, "v2.0.0"
monkeypatch.setattr(
update_routes.UpdateRoutes, "_download_and_replace_zip", _fake_zip
)
req = _fake_request({"channel": "release"})
resp = await update_routes.UpdateRoutes.switch_channel(req)
data = _raw_body(resp)
assert data["success"] is True
assert data["channel"] == "release"
assert data["new_version"] == "v2.0.0"
class _NightlyDownloader:
"""Returns a fake main-branch commit AND a compare response."""
commit_sha = "7777777"
commit_msg = "test: add nightly feature"
commit_date = "2026-07-27T12:00:00Z"
behind_by = 5
async def make_request(self, method, url, **kwargs):
if "/compare/" in url:
return True, {"behind_by": self.behind_by}
return True, {
"sha": self.commit_sha,
"commit": {
"message": self.commit_msg,
"committer": {"date": self.commit_date},
},
}
@pytest.mark.asyncio
async def test_get_nightly_version_parses_behind_by(monkeypatch):
monkeypatch.setattr(update_routes, "get_downloader", lambda: _stub_downloader(_NightlyDownloader()))
version, changelog, behind_by, commit_date = await update_routes.UpdateRoutes._get_nightly_version(
local_hash="abc1234"
)
assert version == "main-7777777"
assert behind_by == 5
assert commit_date == "2026-07-27"
assert len(changelog) == 1
assert changelog[0] == "test: add nightly feature"
class _AheadCompareDownloader:
"""Fake compare API response with status='ahead' (main is ahead of local)."""
commit_sha = "9999999"
commit_msg = "latest commit"
commit_date = "2026-07-28T00:00:00Z"
ahead_by = 3
async def make_request(self, method, url, **kwargs):
if "/compare/" in url:
return True, {"status": "ahead", "ahead_by": self.ahead_by, "behind_by": 0}
return True, {
"sha": self.commit_sha,
"commit": {
"message": self.commit_msg,
"committer": {"date": self.commit_date},
},
}
@pytest.mark.asyncio
async def test_get_nightly_version_reads_ahead_by_when_ahead(monkeypatch):
"""compare/{local}...main returns status='ahead' → read ahead_by."""
monkeypatch.setattr(update_routes, "get_downloader", lambda: _stub_downloader(_AheadCompareDownloader()))
version, changelog, behind_by, commit_date = await update_routes.UpdateRoutes._get_nightly_version(
local_hash="oldhash"
)
assert version == "main-9999999"
assert behind_by == 3
assert commit_date == "2026-07-28"
class _DivergedCompareDownloader:
"""Fake compare API response with status='diverged' (both have unique commits)."""
commit_sha = "aaaaaaa"
commit_msg = "diverged test"
commit_date = "2026-07-29T00:00:00Z"
async def make_request(self, method, url, **kwargs):
if "/compare/" in url:
return True, {"status": "diverged", "ahead_by": 5, "behind_by": 2}
return True, {
"sha": self.commit_sha,
"commit": {
"message": self.commit_msg,
"committer": {"date": self.commit_date},
},
}
@pytest.mark.asyncio
async def test_get_nightly_version_reads_ahead_by_when_diverged(monkeypatch):
"""compare/{local}...main returns status='diverged' → read ahead_by (remote ahead)."""
monkeypatch.setattr(update_routes, "get_downloader", lambda: _stub_downloader(_DivergedCompareDownloader()))
version, changelog, behind_by, commit_date = await update_routes.UpdateRoutes._get_nightly_version(
local_hash="divhash"
)
assert behind_by == 5
class _CheckUpdatesDownloader:
"""Fake downloader returning both a release list and a nightly commit + compare."""
commit_sha = "8888888"
commit_date = "2026-07-28T00:00:00Z"
async def make_request(self, method, url, **kwargs):
if "/releases" in url:
return True, [
{
"tag_name": "v3.0.0",
"body": "- Feature A\n- Feature B",
"published_at": "2026-07-20T00:00:00Z",
}
]
if "/compare/" in url:
return True, {"behind_by": 3}
return True, {
"sha": self.commit_sha + "0" * 33,
"commit": {
"message": "latest commit",
"committer": {"date": self.commit_date},
},
}
@pytest.mark.asyncio
async def test_check_updates_nightly_response_includes_behind_and_date(monkeypatch, tmp_path):
monkeypatch.setattr(update_routes, "get_downloader", lambda: _stub_downloader(_CheckUpdatesDownloader()))
monkeypatch.setattr(
update_routes.UpdateRoutes,
"_get_local_version",
staticmethod(lambda: "v1.0.0"),
)
monkeypatch.setattr(
update_routes.UpdateRoutes,
"_get_git_info",
staticmethod(lambda: {
"commit_hash": "abc1234",
"short_hash": "abc1234",
"branch": "main",
"commit_date": "2026-01-01",
}),
)
routes_file = tmp_path / "py" / "routes" / "update_routes.py"
routes_file.parent.mkdir(parents=True)
routes_file.write_text("")
monkeypatch.setattr(update_routes, "__file__", str(routes_file))
(tmp_path / ".git").mkdir()
req = _fake_request(query_params={"nightly": "true"})
resp = await update_routes.UpdateRoutes.check_updates(req)
data = _raw_body(resp)
assert data["success"] is True
assert data["nightly"] is True
assert data["has_git"] is True
assert data["behind_by"] == 3
assert data["commit_date"] == "2026-07-28"
assert data["latest_version"] == "main-8888888"
assert isinstance(data["releases"], list)
assert len(data["releases"]) == 1
assert data["releases"][0]["version"] == "v3.0.0"
def _raw_body(response):
import json
return json.loads(response._body.decode())
+66
View File
@@ -1252,3 +1252,69 @@ async def test_get_model_civitai_url_falls_back_when_host_setting_is_not_a_strin
"model_id": "123", "model_id": "123",
"version_id": "456", "version_id": "456",
} }
class TestHfGroupKey:
"""Tests for _extract_hf_group_key and _extract_group_key."""
# --- _extract_hf_group_key ---
def test_hf_group_key_valid_url(self):
"""Standard HF URL returns hf:user/repo."""
item = {"hf_url": "https://huggingface.co/unsloth/qwen-edit"}
assert BaseModelService._extract_hf_group_key(item) == "hf:unsloth/qwen-edit"
def test_hf_group_key_url_with_subpath(self):
"""URL with subpath still extracts just owner/repo."""
item = {"hf_url": "https://huggingface.co/user/repo/resolve/main/file.safetensors"}
assert BaseModelService._extract_hf_group_key(item) == "hf:user/repo"
def test_hf_group_key_empty_url(self):
"""Empty hf_url returns None."""
assert BaseModelService._extract_hf_group_key({"hf_url": ""}) is None
def test_hf_group_key_no_url(self):
"""Missing hf_url key returns None."""
assert BaseModelService._extract_hf_group_key({}) is None
def test_hf_group_key_none_url(self):
"""None hf_url returns None."""
assert BaseModelService._extract_hf_group_key({"hf_url": None}) is None
def test_hf_group_key_invalid_url(self):
"""Malformed HF URL returns None."""
assert BaseModelService._extract_hf_group_key({"hf_url": "not-a-url"}) is None
assert BaseModelService._extract_hf_group_key({"hf_url": "https://example.com"}) is None
# --- _extract_group_key ---
def test_group_key_civitai_only(self):
"""CivitAI modelId returned as int."""
item = {"civitai": {"modelId": 123}}
assert BaseModelService._extract_group_key(item) == 123
def test_group_key_hf_only(self):
"""HF-only item returns hf:user/repo string."""
item = {"hf_url": "https://huggingface.co/user/repo"}
assert BaseModelService._extract_group_key(item) == "hf:user/repo"
def test_group_key_civitai_preferred(self):
"""CivitAI modelId takes precedence over hf_url."""
item = {
"civitai": {"modelId": 456},
"hf_url": "https://huggingface.co/other/repo",
}
assert BaseModelService._extract_group_key(item) == 456
def test_group_key_neither(self):
"""No CivitAI or HF returns None."""
assert BaseModelService._extract_group_key({}) is None
assert BaseModelService._extract_group_key({"some": "data"}) is None
def test_group_key_civitai_none_model_id(self):
"""civitai.modelId=None falls through to HF."""
item = {
"civitai": {"modelId": None},
"hf_url": "https://huggingface.co/user/repo",
}
assert BaseModelService._extract_group_key(item) == "hf:user/repo"
@@ -1189,6 +1189,109 @@ def test_relative_path_sanitizes_model_and_version_placeholders():
assert relative_path == "Fancy_Model/Version_One" assert relative_path == "Fancy_Model/Version_One"
def test_relative_path_empty_first_tag_fallback():
"""Test that empty first_tag falls back to 'no tags'."""
manager = DownloadManager()
settings_manager = get_settings_manager()
settings_manager.settings["download_path_templates"]["lora"] = (
"{base_model}/{first_tag}"
)
version_info = {
"baseModel": "SDXL",
"model": {"name": "Test Model", "tags": []},
"creator": {"username": "Author"},
}
relative_path = manager._calculate_relative_path(version_info, "lora")
assert relative_path == "SDXL/no tags"
def test_relative_path_empty_base_model_and_first_tag():
"""Test that empty base_model + empty first_tag does NOT produce a leading slash."""
manager = DownloadManager()
settings_manager = get_settings_manager()
settings_manager.settings["download_path_templates"]["lora"] = (
"{base_model}/{first_tag}"
)
version_info = {
"baseModel": "",
"model": {"name": "Test Model", "tags": []},
"creator": {"username": "Author"},
}
relative_path = manager._calculate_relative_path(version_info, "lora")
assert not relative_path.startswith("/")
assert relative_path == "no tags"
def test_relative_path_sanitizes_double_slashes():
"""Test that empty placeholder substitutions don't produce double slashes."""
manager = DownloadManager()
settings_manager = get_settings_manager()
settings_manager.settings["download_path_templates"]["lora"] = (
"{base_model}/{first_tag}/{author}"
)
version_info = {
"baseModel": "SDXL",
"model": {"name": "Test Model", "tags": []},
"creator": {"username": "Author"},
}
relative_path = manager._calculate_relative_path(version_info, "lora")
assert "//" not in relative_path
assert relative_path == "SDXL/no tags/Author"
def test_download_containment_accepts_symlink_save_dir(tmp_path):
"""Verify the download path containment check (download_manager.py:1395-1397)
accepts save directories reached through user-created symlinks inside the
library root reproducing the symlink scenario from issue #1028."""
# Library root with a symlink subdirectory pointing to an external drive
lora_root = tmp_path / "loras"
lora_root.mkdir()
external_drive = tmp_path / "external" / "models"
external_drive.mkdir(parents=True)
symlink = lora_root / "Krea 2"
symlink.symlink_to(str(external_drive))
# Simulate a download: base_save_dir = library root,
# relative_path = "Krea 2/concept/NewModel"
base_save_dir = str(lora_root)
save_dir = os.path.join(base_save_dir, "Krea 2", "concept", "NewModel")
# Replicate the exact containment check from download_manager.py
resolved_dir = os.path.abspath(os.path.normpath(save_dir))
base_dir = os.path.abspath(os.path.normpath(base_save_dir))
# Must NOT be rejected — symlinks are legitimate business paths
assert resolved_dir.startswith(base_dir + os.sep)
def test_download_containment_rejects_dot_dot_traversal(tmp_path):
"""Verify the download path containment check still blocks ``..`` traversal
after the realpath abspath change."""
lora_root = tmp_path / "loras"
lora_root.mkdir()
base_save_dir = str(lora_root)
save_dir = os.path.join(base_save_dir, "..", "..", "etc", "passwd")
resolved_dir = os.path.abspath(os.path.normpath(save_dir))
base_dir = os.path.abspath(os.path.normpath(base_save_dir))
# Must be rejected — dot-dot escapes the library root
assert not resolved_dir.startswith(base_dir + os.sep)
assert resolved_dir != base_dir
def test_distribute_preview_to_entries_moves_and_copies(tmp_path): def test_distribute_preview_to_entries_moves_and_copies(tmp_path):
"""Test that preview distribution moves file to first entry and copies to others.""" """Test that preview distribution moves file to first entry and copies to others."""
manager = DownloadManager() manager = DownloadManager()
+50
View File
@@ -243,6 +243,56 @@ class TestLLMServiceChatCompletionJson:
assert result == {"key": "value"} assert result == {"key": "value"}
@pytest.mark.asyncio
async def test_chat_completion_json_falls_back_on_response_format_rejection(
self, llm_service,
):
"""Retry without response_format when provider rejects it (HTTP 400)."""
error_response = MockResponse(
400,
text_data=(
'{"error":"\'response_format.type\' must be '
'\'json_schema\' or \'text\'"}'
),
)
success_response = MockResponse(
200,
json_data={
"choices": [{"message": {"content": '{"key": "value"}'}}],
"usage": {},
"model": "local-model",
},
)
call_index = 0
class FallbackMockSession:
def __init__(self):
self.last_url = None
self.last_json = None
def post(self, url, json=None, headers=None):
nonlocal call_index
self.last_url = url
self.last_json = json
call_index += 1
return error_response if call_index == 1 else success_response
async def __aenter__(self):
return self
async def __aexit__(self, *args):
pass
with mock.patch("aiohttp.ClientSession", return_value=FallbackMockSession()):
result = await llm_service.chat_completion_json(
system_prompt="You are helpful.",
user_prompt="Return JSON.",
)
assert result == {"key": "value"}
assert call_index == 2
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_chat_completion_json_raises_on_non_json(self, llm_service): async def test_chat_completion_json_raises_on_non_json(self, llm_service):
# Non-JSON content raises LLMResponseError (salvage also fails) # Non-JSON content raises LLMResponseError (salvage also fails)
+169 -1
View File
@@ -1,13 +1,181 @@
import json import json
import os
from pathlib import Path from pathlib import Path
import pytest import pytest
from py.services.model_lifecycle_service import ModelLifecycleService from py.services.model_lifecycle_service import ModelLifecycleService, _require_path_in_library_roots
from py.utils.metadata_manager import MetadataManager from py.utils.metadata_manager import MetadataManager
from py.utils.models import LoraMetadata from py.utils.models import LoraMetadata
class ScannerWithRoots:
def __init__(self, roots):
self._roots = list(roots)
def get_model_roots(self):
return self._roots
class TestRequirePathInLibraryRoots:
def test_accepts_path_within_root(self, tmp_path):
root = tmp_path / "loras"
root.mkdir()
model = root / "model.safetensors"
model.write_text("")
scanner = ScannerWithRoots([str(root)])
_require_path_in_library_roots(str(model), scanner)
def test_rejects_path_outside_roots(self, tmp_path):
root = tmp_path / "loras"
root.mkdir()
outside = tmp_path / "outside" / "model.safetensors"
outside.parent.mkdir(parents=True)
outside.write_text("")
scanner = ScannerWithRoots([str(root)])
with pytest.raises(ValueError, match="outside configured library"):
_require_path_in_library_roots(str(outside), scanner)
def test_passes_when_no_roots_configured(self, tmp_path):
f = tmp_path / "model.safetensors"
f.write_text("")
scanner = ScannerWithRoots([])
_require_path_in_library_roots(str(f), scanner)
def test_accepts_path_matching_root_exactly(self, tmp_path):
root = tmp_path / "loras"
root.mkdir()
scanner = ScannerWithRoots([str(root)])
_require_path_in_library_roots(str(root), scanner)
def test_accepts_symlink_within_root(self, tmp_path):
"""Symlinks under a configured root are legitimate business paths
and should be accepted containment works on business-path space,
not resolved physical paths."""
root = tmp_path / "loras"
root.mkdir()
outside_dir = tmp_path / "outside"
outside_dir.mkdir()
outside_file = outside_dir / "escaped.safetensors"
outside_file.write_text("")
symlink = root / "link.safetensors"
symlink.symlink_to(outside_file)
scanner = ScannerWithRoots([str(root)])
# Symlink path is under root in business-path space → accepted
_require_path_in_library_roots(str(symlink), scanner)
def test_rejects_dot_dot_traversal(self, tmp_path):
"""Verify that ``..`` components are still resolved and blocked —
``abspath`` normalises dot-dot but does not resolve symlinks."""
root = tmp_path / "loras"
root.mkdir()
# A path that traverses up out of the root via ..
escaped = os.path.join(str(root), "..", "..", "etc", "passwd")
scanner = ScannerWithRoots([str(root)])
with pytest.raises(ValueError, match="outside configured library"):
_require_path_in_library_roots(escaped, scanner)
class ScannerForDelete:
def __init__(self, raw_data, roots, model_type="lora"):
self.model_type = model_type
self.cache = DummyCache(raw_data)
self._hash_index = DummyHashIndex()
self._roots = list(roots)
self._persist_calls = []
def get_model_roots(self):
return self._roots
async def get_cached_data(self):
return self.cache
async def _persist_current_cache(self):
self._persist_calls.append(True)
@pytest.mark.asyncio
async def test_delete_model_rejects_path_outside_roots(tmp_path: Path):
root = tmp_path / "loras"
root.mkdir()
model = root / "model.safetensors"
model.write_bytes(b"data")
scanner = ScannerForDelete(
raw_data=[{"file_path": str(model)}],
roots=[str(root)],
)
service = ModelLifecycleService(
scanner=scanner,
metadata_manager=DummyMetadataManager({"civitai": {"modelId": 1}}),
metadata_loader=lambda x: {},
)
# Path within root should work (model file exists)
result = await service.delete_model(str(model))
assert result["success"] is True
# Path outside root should be rejected
outside = tmp_path / "outside.safetensors"
outside.write_bytes(b"data")
scanner2 = ScannerForDelete(
raw_data=[],
roots=[str(root)],
)
service2 = ModelLifecycleService(
scanner=scanner2,
metadata_manager=DummyMetadataManager({}),
metadata_loader=lambda x: {},
)
with pytest.raises(ValueError, match="outside configured library"):
await service2.delete_model(str(outside))
@pytest.mark.asyncio
async def test_rename_model_rejects_path_outside_roots(tmp_path: Path):
root = tmp_path / "loras"
root.mkdir()
scanner = ScannerWithRoots([str(root)])
service = ModelLifecycleService(
scanner=scanner,
metadata_manager=DummyMetadataManager({}),
metadata_loader=lambda x: {},
)
outside = tmp_path / "outside.safetensors"
outside.write_bytes(b"data")
with pytest.raises(ValueError, match="outside configured library"):
await service.rename_model(file_path=str(outside), new_file_name="new_name")
@pytest.mark.asyncio
async def test_bulk_delete_rejects_any_path_outside_roots(tmp_path: Path):
root = tmp_path / "loras"
root.mkdir()
model_ok = root / "model.safetensors"
model_ok.write_bytes(b"data")
outside = tmp_path / "outside.safetensors"
outside.write_bytes(b"data")
scanner = ScannerWithRoots([str(root)])
service = ModelLifecycleService(
scanner=scanner,
metadata_manager=DummyMetadataManager({}),
metadata_loader=lambda x: {},
)
with pytest.raises(ValueError, match="outside configured library"):
await service.bulk_delete_models([str(model_ok), str(outside)])
class DummyCache: class DummyCache:
def __init__(self, raw_data): def __init__(self, raw_data):
self.raw_data = raw_data self.raw_data = raw_data
+9 -1
View File
@@ -77,7 +77,15 @@ def recipe_scanner(tmp_path: Path, monkeypatch):
monkeypatch.setattr(config, "loras_roots", [str(tmp_path)]) monkeypatch.setattr(config, "loras_roots", [str(tmp_path)])
stub = StubLoraScanner() stub = StubLoraScanner()
scanner = RecipeScanner(lora_scanner=stub) scanner = RecipeScanner(lora_scanner=stub)
asyncio.run(scanner.refresh_cache(force=True))
async def _init():
await scanner.refresh_cache(force=True)
# Wait for FTS index build to finish — asyncio.run()
# cancels background tasks on return, so we must await it here.
if scanner._fts_index_task:
await scanner._fts_index_task
asyncio.run(_init())
yield scanner, stub yield scanner, stub
RecipeScanner._instance = None RecipeScanner._instance = None
settings_manager_module.reset_settings_manager() settings_manager_module.reset_settings_manager()
+119
View File
@@ -139,3 +139,122 @@ def test_contains_dynamic_syntax_detects_wildcards_and_options():
assert contains_dynamic_syntax("__flower__") is True assert contains_dynamic_syntax("__flower__") is True
assert contains_dynamic_syntax("{red|blue}") is True assert contains_dynamic_syntax("{red|blue}") is True
assert contains_dynamic_syntax("{2$$, $$red|blue|green}") is True assert contains_dynamic_syntax("{2$$, $$red|blue|green}") is True
# ---------------------------------------------------------------------------
# _pick_weighted_or_plain
# ---------------------------------------------------------------------------
def test_pick_weighted_or_plain_plain_values(monkeypatch, tmp_path):
"""Plain values without :: are picked via rng.choice (fast path)."""
service, _ = _make_service(monkeypatch, tmp_path)
import random
rng = random.Random(42)
result = service._pick_weighted_or_plain(["red", "green", "blue"], rng)
assert result in {"red", "green", "blue"}
assert "::" not in result
def test_pick_weighted_or_plain_deterministic_with_seed(monkeypatch, tmp_path):
"""Same seed produces the same result for plain values."""
service, _ = _make_service(monkeypatch, tmp_path)
import random
first = service._pick_weighted_or_plain(["a", "b", "c"], random.Random(99))
second = service._pick_weighted_or_plain(["a", "b", "c"], random.Random(99))
assert first == second
def test_pick_weighted_or_plain_weighted_values(monkeypatch, tmp_path):
"""Weighted values use weighted selection and strip the N:: prefix."""
service, _ = _make_service(monkeypatch, tmp_path)
import random
values = ["3::apple", "1::banana"]
results = {"apple": 0, "banana": 0}
for seed in range(4000):
result = service._pick_weighted_or_plain(values, random.Random(seed))
assert result in results, f"Unexpected result: {result!r}"
assert "::" not in result
results[result] += 1
total = results["apple"] + results["banana"]
# 3:1 weight → apple ≈ 75%, banana ≈ 25%
assert 2700 < results["apple"] < 3300, f"apple count out of range: {results['apple']}"
assert 700 < results["banana"] < 1300, f"banana count out of range: {results['banana']}"
def test_pick_weighted_or_plain_weight_one_values(monkeypatch, tmp_path):
"""Values with explicit 1:: prefix have prefix stripped but are not weighted."""
service, _ = _make_service(monkeypatch, tmp_path)
import random
# All weights are 1.0 → no actual weighting, but :: prefix is stripped
values = ["1::foo", "1::bar"]
rng = random.Random(42)
results = {service._pick_weighted_or_plain(values, rng) for _ in range(200)}
assert results == {"foo", "bar"}
# Ensure the prefix is always stripped
for result in results:
assert "::" not in result
def test_pick_weighted_or_plain_mixed_weighted_and_plain(monkeypatch, tmp_path):
"""Mixed list with some weighted and some unweighted values."""
service, _ = _make_service(monkeypatch, tmp_path)
import random
values = ["5::x", "y", "z"] # x has weight 5, y/z have default weight 1
results = {"x": 0, "y": 0, "z": 0}
for seed in range(4000):
result = service._pick_weighted_or_plain(values, random.Random(seed))
assert result in results
assert "::" not in result
results[result] += 1
# x (5) vs combined y+z (1+1=2) → ~71% / ~29%
x_pct = results["x"] / sum(results.values())
assert 0.65 < x_pct < 0.78, f"x proportion out of range: {x_pct:.3f}"
def test_pick_weighted_or_plain_invalid_weight_prefix(monkeypatch, tmp_path):
"""Invalid numeric prefix (e.g. 1.2.3) is NOT treated as a weight and
the prefix is NOT stripped, matching the updated strict regex."""
service, _ = _make_service(monkeypatch, tmp_path)
import random
rng = random.Random(42)
# "1.2.3::a" is not a valid number → treated as plain text value
result = service._pick_weighted_or_plain(["1.2.3::a", "b"], rng)
# It should keep the full text including :: because the prefix isn't a
# valid numeric weight according to the strict regex
assert result == "1.2.3::a" or result == "b"
def test_pick_weighted_or_plain_glob_aggregation(monkeypatch, tmp_path):
"""Weighted wildcard resolution through glob aggregation (__*__)."""
service, wildcards_dir = _make_service(monkeypatch, tmp_path)
wildcards_dir.mkdir()
(wildcards_dir / "animals").mkdir()
(wildcards_dir / "animals" / "cat.txt").write_text("3::tabby\n1::persian\n", encoding="utf-8")
(wildcards_dir / "animals" / "dog.txt").write_text("retriever\npoodle\n", encoding="utf-8")
# __animals/*__ aggregates all values across both files
# Weighted values should have :: stripped
results = {"tabby": 0, "persian": 0, "retriever": 0, "poodle": 0}
for seed in range(4000):
expanded = service.expand_text("__animals/*__", seed=seed)
assert expanded in results, f"Unexpected result: {expanded!r}"
assert "::" not in expanded
results[expanded] += 1
# tabby (3) vs persian (1) → ~75% / ~25% within the cat subset
cat_total = results["tabby"] + results["persian"]
if cat_total > 0:
tabby_pct = results["tabby"] / cat_total
assert 0.65 < tabby_pct < 0.85, f"tabby proportion out of range: {tabby_pct:.3f}"
+32
View File
@@ -114,6 +114,38 @@ def test_calculate_relative_path_sanitizes_model_and_version_names(isolated_sett
assert relative_path == "Fancy_Model/Version_One" assert relative_path == "Fancy_Model/Version_One"
def test_calculate_relative_path_sanitizes_leading_slash(isolated_settings):
"""Test that empty base_model does NOT produce a leading slash in the path."""
isolated_settings["download_path_templates"]["lora"] = "{base_model}/{first_tag}"
model_data = {
"base_model": "",
"tags": [],
"civitai": {"id": 1, "creator": {"username": "Author"}},
}
relative_path = calculate_relative_path_for_model(model_data, "lora")
assert not relative_path.startswith("/")
assert relative_path == "no tags"
def test_calculate_relative_path_sanitizes_double_slashes(isolated_settings):
"""Test that empty substitutions don't produce double slashes."""
isolated_settings["download_path_templates"]["lora"] = "{base_model}/{first_tag}/{author}"
model_data = {
"base_model": "",
"tags": [],
"civitai": {"id": 1, "creator": {"username": "Author"}},
}
relative_path = calculate_relative_path_for_model(model_data, "lora")
assert "//" not in relative_path
assert relative_path == "no tags/Author"
def test_calculate_recipe_fingerprint_filters_and_sorts(): def test_calculate_recipe_fingerprint_filters_and_sorts():
loras = [ loras = [
{"hash": "ABC", "strength": 0.1234}, {"hash": "ABC", "strength": 0.1234},
+1
View File
@@ -16,6 +16,7 @@ export const LORA_PROVIDER_NODE_TYPES = [
"Lora Stacker (LoraManager)", "Lora Stacker (LoraManager)",
"Lora Randomizer (LoraManager)", "Lora Randomizer (LoraManager)",
"Lora Cycler (LoraManager)", "Lora Cycler (LoraManager)",
"Create Hook LoRA (LoraManager)",
] as const; ] as const;
/** /**
+143
View File
@@ -0,0 +1,143 @@
import { app } from "../../scripts/app.js";
import {
getActiveLorasFromNode,
updateConnectedTriggerWords,
chainCallback,
mergeLoras,
getWidgetByName,
getWidgetSerializedValue,
} from "./utils.js";
import { addLorasWidget } from "./loras_widget.js";
import { applyLoraValuesToText, debounce } from "./lora_syntax_utils.js";
import { applySelectionHighlight } from "./trigger_word_highlight.js";
import { updateConnectedLoraInfoNodes } from "./lora_info.js";
app.registerExtension({
name: "LoraManager.CreateHookLora",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeType.comfyClass === "Create Hook LoRA (LoraManager)") {
chainCallback(nodeType.prototype, "onNodeCreated", function () {
// Enable widget serialization so loras widget state is persisted
this.serialize_widgets = true;
this.addInput("prev_hooks", "HOOKS", {
shape: 7,
});
// Flags to prevent callback loops between text widget ↔ loras widget
let isUpdating = false;
let isSyncingInput = false;
// Get the text input widget (AUTOCOMPLETE_TEXT_LORAS type, created by Vue widgets)
const inputWidget = getWidgetByName(this, "text");
if (!inputWidget) {
console.warn(
"LoRA Manager: text widget not found for Create Hook LoRA"
);
return;
}
this.inputWidget = inputWidget;
const scheduleInputSync = debounce((lorasValue) => {
if (isSyncingInput) {
return;
}
isSyncingInput = true;
isUpdating = true;
try {
const nextText = applyLoraValuesToText(
inputWidget.value,
lorasValue
);
if (inputWidget.value !== nextText) {
inputWidget.value = nextText;
}
} finally {
isUpdating = false;
isSyncingInput = false;
}
});
// Create the LoRA list widget
const result = addLorasWidget(
this,
"loras",
{
onSelectionChange: (selection) => {
applySelectionHighlight(this, selection);
updateConnectedLoraInfoNodes(this, selection);
},
},
(value) => {
// Prevent recursive calls
if (isUpdating) return;
isUpdating = true;
try {
// Update connected trigger word toggles with active LoRA names
const activeLoraNames = new Set();
value.forEach((lora) => {
if (lora.active) {
activeLoraNames.add(lora.name);
}
});
updateConnectedTriggerWords(this, activeLoraNames);
} finally {
isUpdating = false;
}
scheduleInputSync(value);
}
);
this.lorasWidget = result.widget;
// Set up callback for the text input widget to trigger merge logic
inputWidget.callback = (value) => {
if (isUpdating) return;
isUpdating = true;
try {
const currentLoras = this.lorasWidget?.value || [];
const mergedLoras = mergeLoras(value, currentLoras);
if (this.lorasWidget) {
this.lorasWidget.value = mergedLoras;
}
// Update connected trigger word toggles
const activeLoraNames = getActiveLorasFromNode(this);
updateConnectedTriggerWords(this, activeLoraNames);
} finally {
isUpdating = false;
}
};
});
}
},
async loadedGraphNode(node) {
if (node.comfyClass === "Create Hook LoRA (LoraManager)") {
// Restore saved loras widget values on workflow load
let existingLoras = [];
if (node.widgets_values && node.widgets_values.length > 0) {
const savedValue = getWidgetSerializedValue(node, "loras");
existingLoras = savedValue || [];
}
// Merge the loras data from text widget with saved values
const inputWidget =
node.inputWidget || getWidgetByName(node, "text");
if (!inputWidget) {
console.warn(
"LoRA Manager: text widget not found while restoring Create Hook LoRA"
);
return;
}
const mergedLoras = mergeLoras(inputWidget.value, existingLoras);
node.lorasWidget.value = mergedLoras;
}
},
});
+17 -10
View File
@@ -37,22 +37,28 @@ app.registerExtension({
// Handle broadcast mode (for Desktop/non-browser support) // Handle broadcast mode (for Desktop/non-browser support)
if (numericNodeId === -1) { if (numericNodeId === -1) {
// Find all Lora Loader nodes in the current graph // Find all compatible nodes in the current graph
const loraLoaderNodes = getAllGraphNodes(app.graph) const compatibleClasses = new Set([
"Lora Loader (LoraManager)",
"Lora Stacker (LoraManager)",
"WanVideo Lora Select (LoraManager)",
"Create Hook LoRA (LoraManager)",
]);
const targetNodes = getAllGraphNodes(app.graph)
.map(({ node }) => node) .map(({ node }) => node)
.filter((node) => node?.comfyClass === "Lora Loader (LoraManager)"); .filter((node) => compatibleClasses.has(node?.comfyClass));
// Update each Lora Loader node found // Update each node found
if (loraLoaderNodes.length > 0) { if (targetNodes.length > 0) {
loraLoaderNodes.forEach((node) => { targetNodes.forEach((node) => {
this.updateNodeLoraCode(node, loraCode, mode); this.updateNodeLoraCode(node, loraCode, mode);
}); });
console.log( console.log(
`Updated ${loraLoaderNodes.length} Lora Loader nodes in broadcast mode` `Updated ${targetNodes.length} nodes in broadcast mode`
); );
} else { } else {
console.warn( console.warn(
"No Lora Loader nodes found in the workflow for broadcast update" "No compatible LoRA nodes found in the workflow for broadcast update"
); );
} }
@@ -65,10 +71,11 @@ app.registerExtension({
!node || !node ||
(node.comfyClass !== "Lora Loader (LoraManager)" && (node.comfyClass !== "Lora Loader (LoraManager)" &&
node.comfyClass !== "Lora Stacker (LoraManager)" && node.comfyClass !== "Lora Stacker (LoraManager)" &&
node.comfyClass !== "WanVideo Lora Select (LoraManager)") node.comfyClass !== "WanVideo Lora Select (LoraManager)" &&
node.comfyClass !== "Create Hook LoRA (LoraManager)")
) { ) {
console.warn( console.warn(
"Node not found or not a LoraLoader:", "Node not found or not a compatible LoRA node:",
graphId ?? "root", graphId ?? "root",
nodeId nodeId
); );
+8 -2
View File
@@ -718,8 +718,10 @@ export function addLorasWidget(node, name, opts, callback) {
}); });
}, },
setValue: function(v) { setValue: function(v) {
// Ensure v is an array; handle falsy, string, or object values safely
v = Array.isArray(v) ? v : [];
// Remove duplicates by keeping the last occurrence of each lora name // Remove duplicates by keeping the last occurrence of each lora name
const uniqueValue = (v || []).reduce((acc, lora) => { const uniqueValue = v.reduce((acc, lora) => {
// Remove any existing lora with the same name // Remove any existing lora with the same name
const filtered = acc.filter(l => l.name !== lora.name); const filtered = acc.filter(l => l.name !== lora.name);
// Add the current lora // Add the current lora
@@ -751,7 +753,11 @@ export function addLorasWidget(node, name, opts, callback) {
} }
} }
renderLoras(widgetValue, widget); // Skip DOM re-render during drag to preserve pointer capture and event listeners.
// The strength inputs are updated directly via the pointermove handler instead.
if (!widget.__dragActive) {
renderLoras(widgetValue, widget);
}
}, },
hideOnZoom: true, hideOnZoom: true,
selectOn: ['click', 'focus'] selectOn: ['click', 'focus']
+57 -38
View File
@@ -37,17 +37,18 @@ export function handleStrengthDrag(name, initialStrength, initialX, event, widge
syncClipStrengthIfCollapsed(lorasData[loraIndex]); syncClipStrengthIfCollapsed(lorasData[loraIndex]);
} }
// Update the widget value only if updateWidget flag is true // Always write back to widget.value to persist the mutation.
// This allows us to update inputs directly during drag without triggering re-render // During drag (updateWidget=false), setValue skips renderLoras via __dragActive flag,
if (updateWidget) { // so the DOM survives and pointer capture is preserved.
widget.value = formatLoraValue(lorasData); widget.value = formatLoraValue(lorasData);
}
// Force re-render via callback only if updateWidget is true // Only fire callback on the final commit, not during drag
if (updateWidget && widget.callback) { if (updateWidget && widget.callback) {
widget.callback(widget.value); widget.callback(widget.value);
} }
} }
return newStrength;
} }
// Function to handle proportional strength adjustment for all LoRAs via header dragging // Function to handle proportional strength adjustment for all LoRAs via header dragging
@@ -90,12 +91,11 @@ export function handleAllStrengthsDrag(initialStrengths, initialX, event, widget
lorasData[index].clipStrength = Number(newClipStrength); lorasData[index].clipStrength = Number(newClipStrength);
}); });
// Update widget value only if updateWidget flag is true // Always write back to widget.value to persist mutations.
if (updateWidget) { // During drag (updateWidget=false), setValue skips renderLoras via __dragActive flag.
widget.value = formatLoraValue(lorasData); widget.value = formatLoraValue(lorasData);
}
// Force re-render via callback only if updateWidget is true // Only fire callback on the final commit, not during drag
if (updateWidget && widget.callback) { if (updateWidget && widget.callback) {
widget.callback(widget.value); widget.callback(widget.value);
} }
@@ -149,6 +149,13 @@ export function initDrag(
activePointerId = e.pointerId; activePointerId = e.pointerId;
currentDragElement = e.currentTarget; currentDragElement = e.currentTarget;
// Suppress renderLoras in setValue during drag so the DOM survives.
// The getter creates a new array on every read, so mutations to a
// parsed copy are lost unless we write back through widget.value.
// Writing back would normally trigger a full DOM re-render via setValue,
// destroying pointer capture. __dragActive tells setValue to skip the render.
widget.__dragActive = true;
// Capture pointer to receive all subsequent events regardless of stopPropagation // Capture pointer to receive all subsequent events regardless of stopPropagation
const target = e.currentTarget; const target = e.currentTarget;
target.setPointerCapture(e.pointerId); target.setPointerCapture(e.pointerId);
@@ -181,17 +188,12 @@ export function initDrag(
} }
// Call the strength adjustment function without updating widget.value during drag // Call the strength adjustment function without updating widget.value during drag
handleStrengthDrag(name, initialStrength, initialX, e, widget, isClipStrength, false); const newStrength = handleStrengthDrag(name, initialStrength, initialX, e, widget, isClipStrength, false);
// Update strength input directly instead of re-rendering to avoid losing event listeners // Update strength input directly instead of re-rendering to avoid losing event listeners
const strengthInput = currentDragElement.querySelector('.lm-lora-strength-input'); const strengthInput = currentDragElement.querySelector('.lm-lora-strength-input');
if (strengthInput) { if (strengthInput && typeof newStrength === 'number') {
const lorasData = parseLoraValue(widget.value); strengthInput.value = newStrength.toFixed(2);
const loraData = lorasData.find(l => l.name === name);
if (loraData) {
const strengthValue = isClipStrength ? loraData.clipStrength : loraData.strength;
strengthInput.value = Number(strengthValue).toFixed(2);
}
} }
// Prevent showing the preview tooltip during drag // Prevent showing the preview tooltip during drag
@@ -226,23 +228,30 @@ export function initDrag(
// Remove the class to restore normal cursor behavior // Remove the class to restore normal cursor behavior
document.body.classList.remove('lm-lora-strength-dragging'); document.body.classList.remove('lm-lora-strength-dragging');
// Only call onDragEnd and re-render if we actually dragged // Only call onDragEnd and re-render if we actually dragged.
if (wasDragging) { // try-finally guarantees __dragActive is always cleared, preventing a
if (typeof onDragEnd === 'function') { // permanent UI freeze if onDragEnd or setValue throws during cleanup.
onDragEnd(); try {
} if (wasDragging) {
if (typeof onDragEnd === 'function') {
onDragEnd();
}
// Commit final value through options.setValue so external observers are notified. // Re-enable renderLoras in setValue and flush final value through setter.
// During drag, handleStrengthDrag mutates widgetValue in-place (updateWidget=false), // The last handleStrengthDrag call already wrote the final strength to
// bypassing widget.value setter and options.setValue entirely. This assignment // widgetValue via setValue (with render suppressed). widget.value = widget.value
// flushes the in-place mutation through the setter so any setValue wrappers fire. // triggers setValue again, which now calls renderLoras since __dragActive is false.
widget.value = widget.value; widget.__dragActive = false;
if (typeof widget.callback === 'function') { widget.value = widget.value;
widget.callback(widget.value); if (typeof widget.callback === 'function') {
widget.callback(widget.value);
}
} }
} finally {
widget.__dragActive = false;
} }
}; };
dragEl.addEventListener('pointerup', endDrag); dragEl.addEventListener('pointerup', endDrag);
dragEl.addEventListener('pointercancel', endDrag); dragEl.addEventListener('pointercancel', endDrag);
} }
@@ -285,6 +294,9 @@ export function initHeaderDrag(headerEl, widget, renderFunction) {
activePointerId = e.pointerId; activePointerId = e.pointerId;
currentHeaderElement = e.currentTarget; currentHeaderElement = e.currentTarget;
// Suppress renderLoras in setValue during drag (see initDrag for rationale)
widget.__dragActive = true;
// Capture pointer to receive all subsequent events regardless of stopPropagation // Capture pointer to receive all subsequent events regardless of stopPropagation
const target = e.currentTarget; const target = e.currentTarget;
target.setPointerCapture(e.pointerId); target.setPointerCapture(e.pointerId);
@@ -352,13 +364,20 @@ export function initHeaderDrag(headerEl, widget, renderFunction) {
// Remove the class to restore normal cursor behavior // Remove the class to restore normal cursor behavior
document.body.classList.remove('lm-lora-strength-dragging'); document.body.classList.remove('lm-lora-strength-dragging');
// Only re-render if we actually dragged // Only re-render if we actually dragged.
if (wasDragging) { // try-finally guarantees __dragActive is always cleared, preventing a
// Commit final value through options.setValue so external observers are notified. // permanent UI freeze if setValue throws during cleanup.
widget.value = widget.value; try {
if (typeof widget.callback === 'function') { if (wasDragging) {
widget.callback(widget.value); // Re-enable renderLoras in setValue and flush final value through setter
widget.__dragActive = false;
widget.value = widget.value;
if (typeof widget.callback === 'function') {
widget.callback(widget.value);
}
} }
} finally {
widget.__dragActive = false;
} }
}; };
+43 -10
View File
@@ -7,12 +7,16 @@ import { app } from "../../scripts/app.js";
// Roles are stored in ``node.properties.lm_marker_role`` and automatically // Roles are stored in ``node.properties.lm_marker_role`` and automatically
// persist with the workflow JSON. // persist with the workflow JSON.
// //
// Two categories:
// send_* consumed by the standalone UI's "Send to Workflow" feature
// meta_* consumed by the metadata processor to override heuristic inference
//
// The workflow registry reads these markers and makes them available to the // The workflow registry reads these markers and makes them available to the
// standalone UI (e.g. ``sendEmbeddingToWorkflow`` also considers nodes marked // standalone UI (e.g. ``sendEmbeddingToWorkflow`` also considers nodes marked
// as ``send_prompt_target``). // as ``send_prompt_target``).
// ============================================================================= // =============================================================================
const ROLES = { const SEND_ROLES = {
send_prompt_target: { send_prompt_target: {
label: "Send Prompt Target", label: "Send Prompt Target",
emoji: "\uD83D\uDCDD", emoji: "\uD83D\uDCDD",
@@ -23,6 +27,28 @@ const ROLES = {
}, },
}; };
const META_ROLES = {
meta_primary_model: {
label: "Meta hints: Primary Model",
emoji: "\uD83D\uDCA1",
},
meta_primary_sampler: {
label: "Meta hints: Primary Sampler",
emoji: "\uD83D\uDCA1",
},
meta_positive_prompt: {
label: "Meta hints: Positive Prompt",
emoji: "\uD83D\uDCA1",
},
meta_negative_prompt: {
label: "Meta hints: Negative Prompt",
emoji: "\uD83D\uDCA1",
},
};
// Flat lookup for setMarker / getMarker / clearMarker
const ROLES = { ...SEND_ROLES, ...META_ROLES };
// ---- Helpers ---------------------------------------------------------------- // ---- Helpers ----------------------------------------------------------------
function getMarker(node) { function getMarker(node) {
@@ -54,7 +80,7 @@ function clearMarker(node) {
// Restore original title: prefer stripping emoji from current title // Restore original title: prefer stripping emoji from current title
// (captures user renames after marking), fall back to saved original. // (captures user renames after marking), fall back to saved original.
const cleaned = node.title?.replace( const cleaned = node.title?.replace(
/^(\u2709\uFE0F?|\u2699\uFE0F?|\uD83D\uDCDD|\uD83C\uDF9B\uFE0F?|\uD83D\uDD27)\s*/, /^(\u2709\uFE0F?|\u2699\uFE0F?|\uD83D\uDCDD|\uD83C\uDF9B\uFE0F?|\uD83D\uDD27|\uD83D\uDCA1)\s*/,
'' ''
); );
if (cleaned && cleaned !== node.title) { if (cleaned && cleaned !== node.title) {
@@ -84,16 +110,23 @@ function buildSubmenuOptions(node) {
const currentRole = getMarker(node); const currentRole = getMarker(node);
const options = []; const options = [];
for (const [key, def] of Object.entries(ROLES)) { const buildGroup = (roles) => {
const isActive = currentRole === key; for (const [key, def] of Object.entries(roles)) {
options.push({ const isActive = currentRole === key;
content: `${isActive ? "\u2713 " : ""}${def.label}`, options.push({
disabled: isActive, content: `${isActive ? "\u2713 " : ""}${def.label}`,
callback: () => setMarker(node, key), disabled: isActive,
}); callback: () => setMarker(node, key),
} });
}
};
buildGroup(SEND_ROLES);
options.push(null); // separator
buildGroup(META_ROLES);
if (currentRole) { if (currentRole) {
options.push(null); // separator
options.push({ options.push({
content: "Clear marker", content: "Clear marker",
callback: () => clearMarker(node), callback: () => clearMarker(node),
+29
View File
@@ -130,6 +130,35 @@ app.registerExtension({
widget.serializeValue = () => { widget.serializeValue = () => {
return applyTextReplacements(widget.value); return applyTextReplacements(widget.value);
}; };
// --- Conditional widget visibility for webp_method / jpeg_subsampling ---
const formatWidget = getWidgetByName(this, "file_format");
const webpMethodWidget = getWidgetByName(this, "webp_method");
const jpegSubWidget = getWidgetByName(this, "jpeg_subsampling");
function updateFormatConditional() {
const fmt = formatWidget?.value;
if (webpMethodWidget) {
webpMethodWidget.disabled = fmt !== "webp";
webpMethodWidget.hidden = fmt !== "webp";
}
if (jpegSubWidget) {
jpegSubWidget.disabled = fmt !== "jpeg";
jpegSubWidget.hidden = fmt !== "jpeg";
}
}
// Set initial state
updateFormatConditional();
// Watch for format changes
if (formatWidget) {
const origCallback = formatWidget.callback;
formatWidget.callback = function (value) {
origCallback?.call(this, value);
updateFormatConditional();
};
}
}); });
}, },
}); });
+1
View File
@@ -12,6 +12,7 @@ const LORA_NODE_CLASSES = new Set([
"Lora Loader (LoraManager)", "Lora Loader (LoraManager)",
"Lora Stacker (LoraManager)", "Lora Stacker (LoraManager)",
"WanVideo Lora Select (LoraManager)", "WanVideo Lora Select (LoraManager)",
"Create Hook LoRA (LoraManager)",
]); ]);
function normalizeTriggerWordList(triggerWords) { function normalizeTriggerWordList(triggerWords) {
+1
View File
@@ -8,6 +8,7 @@ export const LORA_PROVIDER_NODE_TYPES = [
"Lora Stacker (LoraManager)", "Lora Stacker (LoraManager)",
"Lora Randomizer (LoraManager)", "Lora Randomizer (LoraManager)",
"Lora Cycler (LoraManager)", "Lora Cycler (LoraManager)",
"Create Hook LoRA (LoraManager)",
]; ];
export const LORA_STACK_AGGREGATOR_NODE_TYPES = [ export const LORA_STACK_AGGREGATOR_NODE_TYPES = [
@@ -15656,7 +15656,8 @@ function createVueWidgetCleanup(vueApp, onCleanup) {
const LORA_PROVIDER_NODE_TYPES$1 = [ const LORA_PROVIDER_NODE_TYPES$1 = [
"Lora Stacker (LoraManager)", "Lora Stacker (LoraManager)",
"Lora Randomizer (LoraManager)", "Lora Randomizer (LoraManager)",
"Lora Cycler (LoraManager)" "Lora Cycler (LoraManager)",
"Create Hook LoRA (LoraManager)"
]; ];
const LORA_STACK_AGGREGATOR_NODE_TYPES$1 = [ const LORA_STACK_AGGREGATOR_NODE_TYPES$1 = [
"Lora Stack Combiner (LoraManager)" "Lora Stack Combiner (LoraManager)"
@@ -15781,7 +15782,8 @@ const ROOT_GRAPH_ID = "root";
const LORA_PROVIDER_NODE_TYPES = [ const LORA_PROVIDER_NODE_TYPES = [
"Lora Stacker (LoraManager)", "Lora Stacker (LoraManager)",
"Lora Randomizer (LoraManager)", "Lora Randomizer (LoraManager)",
"Lora Cycler (LoraManager)" "Lora Cycler (LoraManager)",
"Create Hook LoRA (LoraManager)"
]; ];
const LORA_STACK_AGGREGATOR_NODE_TYPES = [ const LORA_STACK_AGGREGATOR_NODE_TYPES = [
"Lora Stack Combiner (LoraManager)" "Lora Stack Combiner (LoraManager)"
File diff suppressed because one or more lines are too long
+1
View File
@@ -9,6 +9,7 @@ const LORA_NODE_CLASSES = new Set([
"Lora Loader (LoraManager)", "Lora Loader (LoraManager)",
"Lora Stacker (LoraManager)", "Lora Stacker (LoraManager)",
"WanVideo Lora Select (LoraManager)", "WanVideo Lora Select (LoraManager)",
"Create Hook LoRA (LoraManager)",
]); ]);
const TARGET_WIDGET_NAMES = new Set(["ckpt_name", "unet_name"]); const TARGET_WIDGET_NAMES = new Set(["ckpt_name", "unet_name"]);