Compare commits

..
Author SHA1 Message Date
Will Miao ed7cf418b4 fix(autocomplete): never show a stale /activefilters Tip when the toggle is on
The one-time dropdown Tip ('type /activefilters ...') was inserted while the
active-filters search was OFF and then stuck around: _maybeShowFirstRunHint
bailed out while a hint element existed, so toggling the search ON from the
node's filter chip (which can keep the dropdown open in ComfyUI) left the
enable-hint visible even though the feature was now active.

- _maybeShowFirstRunHint now removes any existing hint and recomputes from the
  live setting on every dropdown render, so a Tip is never kept for the
  previous state.
- AutoComplete listens for the lora-manager:setting-toggled window event and
  refreshes an open dropdown's state hints (Tip in plain suggestions, footer
  text in the slash command list) immediately when the toggle changes, instead
  of waiting for the next hide/show cycle.

Regression tests cover: stale Tip removed when toggled ON while the dropdown
stays open (and not resurrected by further typing), and the command-list
footer flipping to ON.
2026-09-04 12:41:44 +08:00
Will Miao 634ea7f299 fix(ui): keep autocomplete corner buttons clear of the textarea scrollbar
The absolutely-positioned clear (x) and active-filters filter buttons sit at
the textarea's right edge, so when content overflows and a classic
(non-overlay) vertical scrollbar appears the buttons overlap it. Measure the
scrollbar gutter (offsetWidth - clientWidth) when content overflows and
expose it as --lm-vscrollbar-width on .input-wrapper; the buttons' right is
now calc(base + var) so they shift left of the scrollbar only while one is
present (0 otherwise, incl. overlay-scrollbar platforms).

Refreshed on input, mount, canvas/Vue-DOM mode change and via a ResizeObserver
on the textarea (widget resize). Rebuilt the vue-widgets bundle.
2026-09-04 12:41:31 +08:00
Will Miao 6ba64ebb3c feat(ui): improve /activefilters discoverability on loras nodes
Mirror the /noautocomplete discoverability pattern for the loras\nactive-filters search toggle:\n\n- autocomplete.js: extend the slash-command-list footer and the\n  one-time first-run hint to loras nodes, advertising\n  /activefilters and /noactivefilters\n- lora_loader.js: add an 'Active Filters Search: ON/OFF' entry to the\n  right-click menu of all loras-autocomplete node classes\n- settings.js: broadcast a 'lora-manager:setting-toggled' window event\n  on every setLoraManagerSettingValue write\n- AutocompleteTextWidget.vue: add a persistent filter indicator chip\n  (loras mode only) that reflects and toggles the setting and stays in\n  sync via the setting-toggled event\n- tests: footer/hint/event coverage, context-menu tests for all four\n  node classes, widget indicator tests; rebuild vue-widgets bundle
2026-09-03 22:42:45 +08:00
Will Miao 03569c62df feat(ui): point help new-content indicator at the updated tabs and elements
- Replace timestamp comparison (help_last_viewed vs a hardcoded date) with
  a content-version marker (data-help-content-version) read from the
  rendered modal markup, so badge state always reflects the content
  actually served
- Only mark content as viewed when the modal is opened while it contains
  new content; opening a stale pre-upgrade page no longer suppresses the
  badge after a refresh
- Flag the Replay Tutorial button itself with a 'New' chip (hidden by
  default, one-time glow animation) and scroll it into view when
  revealed; tab-level dots now mark getting-started and shortcuts
  instead of documentation
- Translate help.newContentBadge into all 9 locales, reusing the
  established help.documentation.newBadge renderings
- Add HelpManager content-version unit tests (12 cases)
2026-09-03 21:40:18 +08:00
Will Miao a61840b366 i18n: translate onboarding/shortcuts/trigger-word keys into all 9 locales
- Fill all 41 [TODO: Translate] placeholders per locale (new onboarding
  steps, Shortcuts cheat-sheet tab, trigger-word copy/edit tooltip)
- Retranslate stale onboarding bulk/contextMenu step contents to match
  the updated en.json source
- Follows docs/i18n-translation-guidelines.md term maps, register, and
  punctuation rules; HTML tags and key names preserved verbatim
2026-09-03 19:20:54 +08:00
Will Miao 726fc178f1 feat(ui): add R/F/D action shortcuts and unify keycap hint style
- Bind R=refresh, F=fetch metadata, D=download in PageControls via
  eventManager (plain letters only, skipped while typing or when a
  modal is open); triggers reuse the buttons' existing click handlers
- Show key-hint chips on the refresh/fetch/download/bulk toolbar
  buttons; convert the bulk chip to a semantic <kbd>
- Redesign shortcut hints as a neutral theme-adaptive keycap:
  --shortcut-* variables in base.css now derive from --text-muted
  with a bottom-edge shadow, shared by the toolbar chips, the header
  search cue, the help-modal cheat sheet, and onboarding key hints
- Add shared isTypingContext() helper to uiHelpers
- Add an Actions group (R/F/D) to the Shortcuts cheat-sheet tab

Verified with vitest (926 passing, incl. 6 new shortcut cases) and a
sandboxed E2E run in real Chrome (light/dark rendering, hover state,
'?' opening the Shortcuts tab, clean console)
2026-09-03 19:10:16 +08:00
Will Miao 8260bd022d feat(ui): improve discoverability of hidden interactions
- Expand onboarding tour from 8 to 11 steps: marquee drag-select,
  drag card to sidebar folder, and the three context menus
  (card / bulk / global); enrich bulk-mode step with range-select
  and exit tips
- Add Replay Tutorial button to help modal Getting Started tab
- Add Shortcuts cheat-sheet tab to help modal, opened directly via
  the '?' key when not typing
- Fix trigger-word tooltip to mention double-click to edit
- Keep checkpoint/embedding send tooltips truthful (no replace mode)

Sync new i18n keys to all locales (placeholders pending translation)
2026-09-03 18:16:00 +08:00
Will Miao b309becdf9 fix(recipes): keep source_path empty on local-fallback re-import
Re-importing a file-imported recipe fell back to its own saved preview
image, then recorded that internal path as the new recipe's source_path.
Since the old preview is deleted with the old recipe, this left a
dangling source_path that showed up as a bogus source URL and blocked
any further re-import with 'no re-importable source'.

Only persist source_path when the re-import source is an accessible
external file; otherwise keep it empty. Also let a dangling non-URL
source_path fall back to the recipe's own image so existing affected
recipes can re-import again.
2026-09-03 17:13:24 +08:00
Will Miao 1e375bb8d9 i18n: translate common.scanProgress into all 9 locales 2026-09-03 11:47:42 +08:00
Will Miao 14da8a6f17 feat(ui): show live scan progress and ETA for cache refresh
Broadcast typed scan_progress messages over /ws/fetch-progress from the
manual refresh/rebuild paths of ModelScanner and RecipeScanner, and
render percent, processed/total, current file name and an EMA-smoothed
ETA in the loading overlay. Hardcoded refresh strings move to i18n
(common.scanProgress); WS connection failure falls back to the previous
static loading behavior.
2026-09-03 11:38:27 +08:00
Will Miao da71985c3e fix(autocomplete): strip lastAccepted boundary from exported workflows (#1093)
The hidden __lm_autocomplete_meta_* widget persisted lastAccepted
(insertedText/textSnapshot) into exported workflow JSON, leaking old
prompt text even after the user deleted it.

Patch app.graphToPrompt (shared by workflow export, Export API and
queueing) to strip lastAccepted from the serialized result's
widgets_values / widgets_values_named / output inputs. Only the
exported artifact is touched; live node state, undo snapshots,
copy/paste and local saves keep the boundary intact.
2026-09-03 08:29:55 +08:00
Will Miao 7c4c8b8f30 fix(ui): add disabled state and feedback to usage tips Add button
The Add button silently returned when no parameter or value was
provided, looking clickable but doing nothing. Keep it disabled until
both inputs are filled, validate the numeric value, surface save
failures via toast without clearing user input, and confirm additions
vs overwrites with success toasts. Includes translations for all
locales.
2026-09-02 23:15:23 +08:00
Will Miao 77109b3cf8 feat(autocomplete): group relative-path results by folder (#1091)
Autocomplete suggestions were ranked purely by relevance across the whole
library, so same-named loras from different subfolders interleaved and were
hard to tell apart. Results are now bucketed by folder (root first, then
alphabetically, with nested paths sorting naturally) while keeping the
existing relevance ordering within each folder group.
2026-09-02 22:01:44 +08:00
Will Miao 00095a5398 fix(autocomplete): sync active filters via server-side store (#1091)
The LoRA Manager page kept its active filters in localStorage, which the
ComfyUI-side autocomplete read directly. When the two run in different
browsers, origins, or the ComfyUI Desktop Electron shell, localStorage is
not shared and the active-filters search silently did nothing.

The manager page now mirrors its filter state to a server-side in-memory
store (PUT /api/lm/{prefix}/active-filters), pushed on every change via a
storage-listener hook and once on page load. The autocomplete widget sends
only use_active_filters=true, and the relative-paths endpoint injects the
stored filters into the search, with explicit query params taking
precedence.
2026-09-02 14:33:44 +08:00
Will Miao 6b41c3bbb4 fix(tests): deflake recipe modal resource item tests by disposing modal instances
RecipeModal instances keep fire-and-forget async chains (hydration
re-renders, mark-hash-invalid re-renders, 500ms reconnect/restore
re-renders) and deferred DOM wiring timers alive across tests. On slow
CI runners these land in the next test's window and overwrite or re-wire
the shared document.body with stale content and stale instance handlers,
failing a different test on every run.

Add a tracked-timer helper and a dispose() teardown hook to RecipeModal:
pending deferred work is cancelled, in-flight async chains become no-ops
after disposal, and the global click listener is detached. The test
afterEach now disposes every modal instance, making the file hermetic.
2026-09-02 12:40:37 +08:00
Will Miao b37238d790 fix(ui): disable modal backdrop blur under software rendering (#1092)
With hardware acceleration disabled, Chrome rasterizes in software and a
full-viewport backdrop-filter forces a per-frame CPU blur over everything
behind the modal, freezing the whole browser.

Detect software rendering via the unmasked WebGL renderer string at app
startup and drop the backdrop blur in that case. Also route the download
modal's sticky toolbar through the shared --modal-backdrop-blur variable
instead of a hardcoded blur(8px).
2026-09-02 12:24:32 +08:00
Will Miao bc33e32c6f feat(showcase): add wheel, swipe and keyboard navigation to the example gallery
- Wheel on the main viewer: horizontal deltas always switch examples;
  vertical deltas switch only at the modal scroll boundary, then stay in a
  sticky session (down = next, up = prev) until the pointer leaves the area
- Touch/pen horizontal swipe switches examples; the synthesized click after
  a swipe is swallowed so the media viewer does not open
- '[' / ']' switch examples while the gallery is expanded; ArrowLeft/Right
  stay reserved for model-level navigation
- Direction-aware slide transition on every switch for visual feedback
  (respects prefers-reduced-motion)
2026-09-02 11:52:59 +08:00
Will Miao 49704d801c fix: show lora info regardless of toggle state 2026-09-02 11:38:30 +08:00
75 changed files with 6528 additions and 415 deletions
+89 -9
View File
@@ -50,6 +50,27 @@
"mb": "MB", "mb": "MB",
"gb": "GB", "gb": "GB",
"tb": "TB" "tb": "TB"
},
"scanProgress": {
"refreshing": "{type} werden aktualisiert...",
"fullRebuilding": "{type} werden vollständig neu aufgebaut...",
"actionRefresh": "Aktualisierung",
"actionFullRebuild": "Vollständiger Neuaufbau",
"actionRefreshLower": "Aktualisieren",
"actionRebuildLower": "Neuaufbau",
"stages": {
"scan_folders": "Ordner werden gescannt...",
"count_models": "{total} Dateien gefunden",
"process_models": "Modelle werden verarbeitet",
"reconcile_scan": "Änderungen werden geprüft...",
"process_new": "Neue Modelle werden verarbeitet",
"finalizing": "Abschließen..."
},
"eta": {
"lessThanMinute": "Weniger als eine Minute verbleibend",
"minutes": "~{minutes} Min. verbleibend",
"hours": "~{hours} Std. {minutes} Min. verbleibend"
}
} }
}, },
"onboarding": { "onboarding": {
@@ -75,7 +96,7 @@
}, },
"bulk": { "bulk": {
"title": "Massenoperationen", "title": "Massenoperationen",
"content": "Wechseln Sie in den Massenmodus, indem Sie auf diese Schaltfläche klicken oder <span class=\"onboarding-shortcut\">B</span> drücken. Wählen Sie mehrere Modelle aus und führen Sie Stapeloperationen durch. Mit <span class=\"onboarding-shortcut\">Strg+A</span> können Sie alle sichtbaren Modelle auswählen." "content": "Wechseln Sie in den Massenmodus, indem Sie auf diese Schaltfläche klicken oder <span class=\"onboarding-shortcut\">B</span> drücken, um mehrere Modelle auszuwählen und Stapeloperationen durchzuführen.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> wählt alle sichtbaren Modelle aus, <span class=\"onboarding-shortcut\">Shift+Click</span> wählt einen Bereich aus.<br>• <span class=\"onboarding-shortcut\">Esc</span> oder ein Klick auf einen leeren Bereich verlässt den Massenmodus."
}, },
"searchOptions": { "searchOptions": {
"title": "Suchoptionen", "title": "Suchoptionen",
@@ -95,7 +116,19 @@
}, },
"contextMenu": { "contextMenu": {
"title": "Kontextmenü", "title": "Kontextmenü",
"content": "<strong>Rechtsklick</strong> auf eine Modellkarte öffnet ein Kontextmenü mit weiteren Aktionen." "content": "<strong>Rechtsklick</strong> auf eine beliebige Modellkarte öffnet ein Kontextmenü mit Kartenaktionen wie Verschieben, Löschen oder Bearbeiten von Metadaten."
},
"marqueeSelect": {
"title": "Durch Ziehen auswählen",
"content": "Halten Sie die <strong>linke Maustaste</strong> auf einem leeren Bereich des Rasters gedrückt und ziehen Sie, um einen Auswahlrahmen aufzuziehen, der mehrere Karten gleichzeitig auswählt."
},
"dragToSidebar": {
"title": "Organisieren durch Ziehen",
"content": "Ziehen Sie eine Modellkarte auf einen Ordner in der Seitenleiste, um die Datei dorthin zu verschieben. Dies funktioniert auch mit mehreren ausgewählten Karten im Massenmodus."
},
"contextMenus": {
"title": "Weitere Kontextmenüs",
"content": "<strong>Rechtsklick auf eine ausgewählte Karte</strong> im Massenmodus öffnet die Massenaktionen. <strong>Rechtsklick auf einen leeren Bereich</strong> der Seite öffnet globale Aktionen wie das Prüfen auf Updates und das Verwalten ausgeschlossener Modelle."
} }
} }
}, },
@@ -1593,7 +1626,11 @@
"clipSkip": "Clip Skip", "clipSkip": "Clip Skip",
"valuePlaceholder": "Wert", "valuePlaceholder": "Wert",
"add": "Hinzufügen", "add": "Hinzufügen",
"invalidRange": "Ungültiges Bereichsformat. Verwenden Sie x.x-y.y" "invalidRange": "Ungültiges Bereichsformat. Verwenden Sie x.x-y.y",
"invalidValue": "Bitte geben Sie eine gültige Zahl ein",
"saveFailed": "Fehler beim Speichern des voreingestellten Parameters",
"added": "Voreingestellter Parameter hinzugefügt",
"updated": "Voreingestellter Parameter aktualisiert"
}, },
"triggerWords": { "triggerWords": {
"label": "Trigger Words", "label": "Trigger Words",
@@ -1604,7 +1641,7 @@
"addPlaceholder": "Tippen zum Hinzufügen oder klicken Sie auf Vorschläge unten", "addPlaceholder": "Tippen zum Hinzufügen oder klicken Sie auf Vorschläge unten",
"editWord": "Trigger Word bearbeiten", "editWord": "Trigger Word bearbeiten",
"editPlaceholder": "Trigger Word bearbeiten", "editPlaceholder": "Trigger Word bearbeiten",
"copyWord": "Trigger Word kopieren", "copyOrEditWord": "Klicken zum Kopieren, Doppelklick zum Bearbeiten",
"deleteWord": "Trigger Word löschen", "deleteWord": "Trigger Word löschen",
"suggestions": { "suggestions": {
"noSuggestions": "Keine Vorschläge verfügbar", "noSuggestions": "Keine Vorschläge verfügbar",
@@ -1664,8 +1701,8 @@
"showCount": "Beispiele anzeigen ({count})", "showCount": "Beispiele anzeigen ({count})",
"hideExamples": "Beispiele ausblenden", "hideExamples": "Beispiele ausblenden",
"addExamples": "Beispiele hinzufügen", "addExamples": "Beispiele hinzufügen",
"previousExample": "Vorheriges Beispiel", "previousExample": "Vorheriges Beispiel ([)",
"nextExample": "Nächstes Beispiel", "nextExample": "Nächstes Beispiel (])",
"noExamples": "Keine Beispielbilder verfügbar", "noExamples": "Keine Beispielbilder verfügbar",
"addMoreExamples": "Weitere Beispiele hinzufügen", "addMoreExamples": "Weitere Beispiele hinzufügen",
"dragDrop": "Bilder oder Videos hierher ziehen & ablegen", "dragDrop": "Bilder oder Videos hierher ziehen & ablegen",
@@ -1929,10 +1966,52 @@
"tabs": { "tabs": {
"gettingStarted": "Erste Schritte", "gettingStarted": "Erste Schritte",
"updateVlogs": "Update-Vlogs", "updateVlogs": "Update-Vlogs",
"documentation": "Dokumentation" "documentation": "Dokumentation",
"shortcuts": "Tastenkürzel"
}, },
"gettingStarted": { "gettingStarted": {
"title": "Erste Schritte mit LoRA Manager" "title": "Erste Schritte mit LoRA Manager",
"replayTutorial": "Tutorial erneut abspielen"
},
"shortcuts": {
"title": "Tastatur- & Mauskürzel",
"groups": {
"general": "Allgemein",
"actions": "Aktionen",
"selection": "Auswahl & Massenmodus",
"navigation": "Navigation",
"modelModal": "Modell- / Rezept-Dialog",
"mediaViewer": "Medienanzeige / Beispielgalerie"
},
"keys": {
"click": "Klick",
"drag": "Ziehen",
"rightClick": "Rechtsklick",
"letter": "Buchstabe",
"swipe": "Wischen"
},
"entries": {
"focusSearch": "Suche fokussieren",
"closeModal": "Dialog / Panel schließen",
"openShortcuts": "Dieses Tastenkürzel-Panel öffnen",
"refresh": "Modellliste aktualisieren",
"fetchMetadata": "Metadaten von CivitAI abrufen (nur Modellseiten)",
"downloadModel": "Ein Modell herunterladen (nur Modellseiten)",
"toggleBulkMode": "Massenmodus umschalten",
"selectAll": "Alle sichtbaren Modelle auswählen",
"rangeSelect": "Bereich auswählen",
"marqueeSelect": "Karten mit Auswahlrahmen auswählen (auf leerem Rasterbereich)",
"exitBulkMode": "Massenmodus verlassen",
"bulkActions": "Auf ausgewählter Karte: Menü für Massenaktionen",
"globalActions": "Auf leerem Seitenbereich: Menü für globale Aktionen (Updates prüfen, ausgeschlossene Modelle verwalten)",
"scrollPages": "Seiten scrollen",
"jumpAlphabet": "Zur Alphabetleiste springen",
"prevNext": "Vorheriges / nächstes Modell",
"deleteEntry": "Löschen",
"cycleMedia": "Medien durchblättern ([ / ] in der Beispielgalerie)",
"swipeTouch": "Medien auf Touch-Geräten durchblättern",
"closeViewer": "Medienanzeige schließen"
}
}, },
"updateVlogs": { "updateVlogs": {
"title": "Neueste Updates", "title": "Neueste Updates",
@@ -1949,7 +2028,8 @@
"settings": "Einstellungen & Konfiguration", "settings": "Einstellungen & Konfiguration",
"extensions": "Erweiterungen", "extensions": "Erweiterungen",
"newBadge": "NEU" "newBadge": "NEU"
} },
"newContentBadge": "NEU"
}, },
"update": { "update": {
"title": "Nach Updates suchen", "title": "Nach Updates suchen",
+89 -9
View File
@@ -50,6 +50,27 @@
"mb": "MB", "mb": "MB",
"gb": "GB", "gb": "GB",
"tb": "TB" "tb": "TB"
},
"scanProgress": {
"refreshing": "Refreshing {type}s...",
"fullRebuilding": "Full rebuild {type}s...",
"actionRefresh": "Refresh",
"actionFullRebuild": "Full rebuild",
"actionRefreshLower": "refresh",
"actionRebuildLower": "rebuild",
"stages": {
"scan_folders": "Scanning folders...",
"count_models": "Found {total} files",
"process_models": "Processing models",
"reconcile_scan": "Checking for changes...",
"process_new": "Processing new models",
"finalizing": "Finalizing..."
},
"eta": {
"lessThanMinute": "Less than a minute remaining",
"minutes": "~{minutes} min remaining",
"hours": "~{hours} hr {minutes} min remaining"
}
} }
}, },
"onboarding": { "onboarding": {
@@ -75,7 +96,7 @@
}, },
"bulk": { "bulk": {
"title": "Bulk Operations", "title": "Bulk Operations",
"content": "Enter bulk mode by clicking this button or pressing <span class=\"onboarding-shortcut\">B</span>. Select multiple models and perform batch operations. Use <span class=\"onboarding-shortcut\">Ctrl+A</span> to select all visible models." "content": "Enter bulk mode by clicking this button or pressing <span class=\"onboarding-shortcut\">B</span> to select multiple models and perform batch operations.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> select all visible models, <span class=\"onboarding-shortcut\">Shift+Click</span> select a range.<br>• <span class=\"onboarding-shortcut\">Esc</span> or clicking an empty area exits bulk mode."
}, },
"searchOptions": { "searchOptions": {
"title": "Search Options", "title": "Search Options",
@@ -95,7 +116,19 @@
}, },
"contextMenu": { "contextMenu": {
"title": "Context Menu", "title": "Context Menu",
"content": "<strong>Right-click</strong> any model card for a context menu with additional actions." "content": "<strong>Right-click</strong> any model card for a context menu with card actions like moving, deleting, or editing metadata."
},
"marqueeSelect": {
"title": "Drag to Select",
"content": "Hold the <strong>left mouse button</strong> on an empty area of the grid and drag to draw a marquee that selects multiple cards at once."
},
"dragToSidebar": {
"title": "Organize by Dragging",
"content": "Drag a model card onto a folder in the sidebar to move the file there. This also works with multiple selected cards in bulk mode."
},
"contextMenus": {
"title": "More Context Menus",
"content": "In bulk mode, <strong>right-click a selected card</strong> for bulk actions. <strong>Right-click an empty area</strong> of the page for global actions like update checks and managing excluded models."
} }
} }
}, },
@@ -1593,7 +1626,11 @@
"clipSkip": "Clip Skip", "clipSkip": "Clip Skip",
"valuePlaceholder": "Value", "valuePlaceholder": "Value",
"add": "Add", "add": "Add",
"invalidRange": "Invalid range format. Use x.x-y.y" "invalidRange": "Invalid range format. Use x.x-y.y",
"invalidValue": "Please enter a valid number",
"saveFailed": "Failed to save preset parameter",
"added": "Preset parameter added",
"updated": "Preset parameter updated"
}, },
"triggerWords": { "triggerWords": {
"label": "Trigger Words", "label": "Trigger Words",
@@ -1604,7 +1641,7 @@
"addPlaceholder": "Type to add or click suggestions below", "addPlaceholder": "Type to add or click suggestions below",
"editWord": "Edit trigger word", "editWord": "Edit trigger word",
"editPlaceholder": "Edit trigger word", "editPlaceholder": "Edit trigger word",
"copyWord": "Copy trigger word", "copyOrEditWord": "Click to copy, double-click to edit",
"deleteWord": "Delete trigger word", "deleteWord": "Delete trigger word",
"suggestions": { "suggestions": {
"noSuggestions": "No suggestions available", "noSuggestions": "No suggestions available",
@@ -1664,8 +1701,8 @@
"showCount": "Show examples ({count})", "showCount": "Show examples ({count})",
"hideExamples": "Hide examples", "hideExamples": "Hide examples",
"addExamples": "Add examples", "addExamples": "Add examples",
"previousExample": "Previous example", "previousExample": "Previous example ([)",
"nextExample": "Next example", "nextExample": "Next example (])",
"noExamples": "No example images available", "noExamples": "No example images available",
"addMoreExamples": "Add more examples", "addMoreExamples": "Add more examples",
"dragDrop": "Drag & drop images or videos here", "dragDrop": "Drag & drop images or videos here",
@@ -1929,10 +1966,52 @@
"tabs": { "tabs": {
"gettingStarted": "Getting Started", "gettingStarted": "Getting Started",
"updateVlogs": "Update Vlogs", "updateVlogs": "Update Vlogs",
"documentation": "Documentation" "documentation": "Documentation",
"shortcuts": "Shortcuts"
}, },
"gettingStarted": { "gettingStarted": {
"title": "Getting Started with LoRA Manager" "title": "Getting Started with LoRA Manager",
"replayTutorial": "Replay Tutorial"
},
"shortcuts": {
"title": "Keyboard & Mouse Shortcuts",
"groups": {
"general": "General",
"actions": "Actions",
"selection": "Selection & Bulk Mode",
"navigation": "Navigation",
"modelModal": "Model / Recipe Modal",
"mediaViewer": "Media Viewer / Showcase"
},
"keys": {
"click": "Click",
"drag": "Drag",
"rightClick": "Right-click",
"letter": "Letter",
"swipe": "Swipe"
},
"entries": {
"focusSearch": "Focus search",
"closeModal": "Close modal / panel",
"openShortcuts": "Open this shortcuts panel",
"refresh": "Refresh model list",
"fetchMetadata": "Fetch metadata from CivitAI (model pages only)",
"downloadModel": "Download a model (model pages only)",
"toggleBulkMode": "Toggle bulk mode",
"selectAll": "Select all visible models",
"rangeSelect": "Range select",
"marqueeSelect": "Marquee-select cards (on empty grid area)",
"exitBulkMode": "Exit bulk mode",
"bulkActions": "On selected card: bulk actions menu",
"globalActions": "On empty page area: global actions menu (update check, manage excluded models)",
"scrollPages": "Scroll pages",
"jumpAlphabet": "Jump alphabet bar",
"prevNext": "Previous / next model",
"deleteEntry": "Delete",
"cycleMedia": "Cycle media ([ / ] in showcase gallery)",
"swipeTouch": "Cycle media on touch devices",
"closeViewer": "Close viewer"
}
}, },
"updateVlogs": { "updateVlogs": {
"title": "Latest Updates", "title": "Latest Updates",
@@ -1949,7 +2028,8 @@
"settings": "Settings & Configuration", "settings": "Settings & Configuration",
"extensions": "Extensions", "extensions": "Extensions",
"newBadge": "NEW" "newBadge": "NEW"
} },
"newContentBadge": "New"
}, },
"update": { "update": {
"title": "Check for Updates", "title": "Check for Updates",
+89 -9
View File
@@ -50,6 +50,27 @@
"mb": "MB", "mb": "MB",
"gb": "GB", "gb": "GB",
"tb": "TB" "tb": "TB"
},
"scanProgress": {
"refreshing": "Actualizando {type}...",
"fullRebuilding": "Reconstrucción completa de {type}...",
"actionRefresh": "Actualización",
"actionFullRebuild": "Reconstrucción completa",
"actionRefreshLower": "actualizar",
"actionRebuildLower": "reconstruir",
"stages": {
"scan_folders": "Escaneando carpetas...",
"count_models": "Se encontraron {total} archivos",
"process_models": "Procesando modelos",
"reconcile_scan": "Comprobando cambios...",
"process_new": "Procesando modelos nuevos",
"finalizing": "Finalizando..."
},
"eta": {
"lessThanMinute": "Queda menos de un minuto",
"minutes": "Quedan ~{minutes} min",
"hours": "Quedan ~{hours} h {minutes} min"
}
} }
}, },
"onboarding": { "onboarding": {
@@ -75,7 +96,7 @@
}, },
"bulk": { "bulk": {
"title": "Operaciones por lotes", "title": "Operaciones por lotes",
"content": "Entra en el modo por lotes haciendo clic en este botón o presionando <span class=\"onboarding-shortcut\">B</span>. Selecciona varios modelos y realiza operaciones por lotes. Usa <span class=\"onboarding-shortcut\">Ctrl+A</span> para seleccionar todos los modelos visibles." "content": "Entra en el modo por lotes haciendo clic en este botón o presionando <span class=\"onboarding-shortcut\">B</span> para seleccionar varios modelos y realizar operaciones por lotes.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> selecciona todos los modelos visibles, <span class=\"onboarding-shortcut\">Shift+Click</span> selecciona un rango.<br>• <span class=\"onboarding-shortcut\">Esc</span> o hacer clic en un área vacía sale del modo por lotes."
}, },
"searchOptions": { "searchOptions": {
"title": "Opciones de búsqueda", "title": "Opciones de búsqueda",
@@ -95,7 +116,19 @@
}, },
"contextMenu": { "contextMenu": {
"title": "Menú contextual", "title": "Menú contextual",
"content": "<strong>Clic derecho</strong> en cualquier tarjeta de modelo para ver un menú contextual con acciones adicionales." "content": "<strong>Clic derecho</strong> en cualquier tarjeta de modelo para ver un menú contextual con acciones de la tarjeta como mover, eliminar o editar metadatos."
},
"marqueeSelect": {
"title": "Arrastrar para seleccionar",
"content": "Mantén pulsado el <strong>botón izquierdo del ratón</strong> en un área vacía de la cuadrícula y arrastra para dibujar un rectángulo de selección que selecciona varias tarjetas a la vez."
},
"dragToSidebar": {
"title": "Organizar arrastrando",
"content": "Arrastra una tarjeta de modelo hasta una carpeta de la barra lateral para mover el archivo allí. Esto también funciona con varias tarjetas seleccionadas en el modo por lotes."
},
"contextMenus": {
"title": "Más menús contextuales",
"content": "En el modo por lotes, <strong>haz clic derecho en una tarjeta seleccionada</strong> para ver las acciones por lotes. <strong>Haz clic derecho en un área vacía</strong> de la página para ver acciones globales como comprobar actualizaciones y gestionar modelos excluidos."
} }
} }
}, },
@@ -1593,7 +1626,11 @@
"clipSkip": "Clip Skip", "clipSkip": "Clip Skip",
"valuePlaceholder": "Valor", "valuePlaceholder": "Valor",
"add": "Añadir", "add": "Añadir",
"invalidRange": "Formato de rango inválido. Use x.x-y.y" "invalidRange": "Formato de rango inválido. Use x.x-y.y",
"invalidValue": "Introduce un número válido",
"saveFailed": "Error al guardar el parámetro preajustado",
"added": "Parámetro preajustado añadido",
"updated": "Parámetro preajustado actualizado"
}, },
"triggerWords": { "triggerWords": {
"label": "Palabras clave", "label": "Palabras clave",
@@ -1604,7 +1641,7 @@
"addPlaceholder": "Escribe para añadir o haz clic en sugerencias de abajo", "addPlaceholder": "Escribe para añadir o haz clic en sugerencias de abajo",
"editWord": "Editar palabra de activación", "editWord": "Editar palabra de activación",
"editPlaceholder": "Editar palabra de activación", "editPlaceholder": "Editar palabra de activación",
"copyWord": "Copiar palabra de activación", "copyOrEditWord": "Haz clic para copiar, doble clic para editar",
"deleteWord": "Eliminar palabra de activación", "deleteWord": "Eliminar palabra de activación",
"suggestions": { "suggestions": {
"noSuggestions": "No hay sugerencias disponibles", "noSuggestions": "No hay sugerencias disponibles",
@@ -1664,8 +1701,8 @@
"showCount": "Mostrar ejemplos ({count})", "showCount": "Mostrar ejemplos ({count})",
"hideExamples": "Ocultar ejemplos", "hideExamples": "Ocultar ejemplos",
"addExamples": "Añadir ejemplos", "addExamples": "Añadir ejemplos",
"previousExample": "Ejemplo anterior", "previousExample": "Ejemplo anterior ([)",
"nextExample": "Ejemplo siguiente", "nextExample": "Ejemplo siguiente (])",
"noExamples": "No hay imágenes de ejemplo disponibles", "noExamples": "No hay imágenes de ejemplo disponibles",
"addMoreExamples": "Añadir más ejemplos", "addMoreExamples": "Añadir más ejemplos",
"dragDrop": "Arrastra y suelta imágenes o videos aquí", "dragDrop": "Arrastra y suelta imágenes o videos aquí",
@@ -1929,10 +1966,52 @@
"tabs": { "tabs": {
"gettingStarted": "Comenzando", "gettingStarted": "Comenzando",
"updateVlogs": "Vlogs de actualización", "updateVlogs": "Vlogs de actualización",
"documentation": "Documentación" "documentation": "Documentación",
"shortcuts": "Atajos"
}, },
"gettingStarted": { "gettingStarted": {
"title": "Comenzando con el gestor de LoRA" "title": "Comenzando con el gestor de LoRA",
"replayTutorial": "Repetir tutorial"
},
"shortcuts": {
"title": "Atajos de teclado y ratón",
"groups": {
"general": "General",
"actions": "Acciones",
"selection": "Selección y modo por lotes",
"navigation": "Navegación",
"modelModal": "Modal de modelo / receta",
"mediaViewer": "Visor de medios / Ejemplos"
},
"keys": {
"click": "Clic",
"drag": "Arrastrar",
"rightClick": "Clic derecho",
"letter": "Letra",
"swipe": "Deslizar"
},
"entries": {
"focusSearch": "Enfocar la búsqueda",
"closeModal": "Cerrar modal / panel",
"openShortcuts": "Abrir este panel de atajos",
"refresh": "Actualizar la lista de modelos",
"fetchMetadata": "Obtener metadatos de CivitAI (solo páginas de modelos)",
"downloadModel": "Descargar un modelo (solo páginas de modelos)",
"toggleBulkMode": "Activar/desactivar el modo por lotes",
"selectAll": "Seleccionar todos los modelos visibles",
"rangeSelect": "Selección por rango",
"marqueeSelect": "Seleccionar tarjetas con un rectángulo de selección (en un área vacía de la cuadrícula)",
"exitBulkMode": "Salir del modo por lotes",
"bulkActions": "En una tarjeta seleccionada: menú de acciones por lotes",
"globalActions": "En un área vacía de la página: menú de acciones globales (comprobar actualizaciones, gestionar modelos excluidos)",
"scrollPages": "Desplazarse por las páginas",
"jumpAlphabet": "Saltar con la barra alfabética",
"prevNext": "Modelo anterior / siguiente",
"deleteEntry": "Eliminar",
"cycleMedia": "Cambiar de medio ([ / ] en la galería de ejemplos)",
"swipeTouch": "Cambiar de medio en dispositivos táctiles",
"closeViewer": "Cerrar el visor"
}
}, },
"updateVlogs": { "updateVlogs": {
"title": "Últimas actualizaciones", "title": "Últimas actualizaciones",
@@ -1949,7 +2028,8 @@
"settings": "Configuración", "settings": "Configuración",
"extensions": "Extensiones", "extensions": "Extensiones",
"newBadge": "NUEVO" "newBadge": "NUEVO"
} },
"newContentBadge": "NUEVO"
}, },
"update": { "update": {
"title": "Comprobar actualizaciones", "title": "Comprobar actualizaciones",
+89 -9
View File
@@ -50,6 +50,27 @@
"mb": "Mo", "mb": "Mo",
"gb": "Go", "gb": "Go",
"tb": "To" "tb": "To"
},
"scanProgress": {
"refreshing": "Actualisation des {type}...",
"fullRebuilding": "Reconstruction complète des {type}...",
"actionRefresh": "Actualisation",
"actionFullRebuild": "Reconstruction complète",
"actionRefreshLower": "lactualisation",
"actionRebuildLower": "la reconstruction",
"stages": {
"scan_folders": "Scan des dossiers...",
"count_models": "{total} fichiers trouvés",
"process_models": "Traitement des modèles",
"reconcile_scan": "Vérification des modifications...",
"process_new": "Traitement des nouveaux modèles",
"finalizing": "Finalisation..."
},
"eta": {
"lessThanMinute": "Moins dune minute restante",
"minutes": "~{minutes} min restantes",
"hours": "~{hours} h {minutes} min restantes"
}
} }
}, },
"onboarding": { "onboarding": {
@@ -75,7 +96,7 @@
}, },
"bulk": { "bulk": {
"title": "Opérations groupées", "title": "Opérations groupées",
"content": "Activez le mode groupé en cliquant sur ce bouton ou en appuyant sur <span class=\"onboarding-shortcut\">B</span>. Sélectionnez plusieurs modèles et effectuez des opérations groupées. Utilisez <span class=\"onboarding-shortcut\">Ctrl+A</span> pour sélectionner tous les modèles visibles." "content": "Activez le mode groupé en cliquant sur ce bouton ou en appuyant sur <span class=\"onboarding-shortcut\">B</span> pour sélectionner plusieurs modèles et effectuer des opérations groupées.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> sélectionne tous les modèles visibles, <span class=\"onboarding-shortcut\">Shift+Click</span> sélectionne une plage.<br>• <span class=\"onboarding-shortcut\">Esc</span> ou un clic sur une zone vide quitte le mode groupé."
}, },
"searchOptions": { "searchOptions": {
"title": "Options de recherche", "title": "Options de recherche",
@@ -95,7 +116,19 @@
}, },
"contextMenu": { "contextMenu": {
"title": "Menu contextuel", "title": "Menu contextuel",
"content": "<strong>Clic droit</strong> sur une carte de modèle pour accéder à un menu contextuel avec des actions supplémentaires." "content": "<strong>Clic droit</strong> sur n'importe quelle carte de modèle pour ouvrir un menu contextuel avec des actions sur la carte comme déplacer, supprimer ou modifier les métadonnées."
},
"marqueeSelect": {
"title": "Glisser pour sélectionner",
"content": "Maintenez le <strong>bouton gauche de la souris</strong> enfoncé sur une zone vide de la grille et glissez pour tracer un rectangle de sélection qui sélectionne plusieurs cartes à la fois."
},
"dragToSidebar": {
"title": "Organiser par glisser-déposer",
"content": "Glissez une carte de modèle sur un dossier de la barre latérale pour y déplacer le fichier. Cela fonctionne aussi avec plusieurs cartes sélectionnées en mode groupé."
},
"contextMenus": {
"title": "Plus de menus contextuels",
"content": "En mode groupé, <strong>faites un clic droit sur une carte sélectionnée</strong> pour accéder aux actions groupées. <strong>Faites un clic droit sur une zone vide</strong> de la page pour accéder aux actions globales comme la vérification des mises à jour et la gestion des modèles exclus."
} }
} }
}, },
@@ -1593,7 +1626,11 @@
"clipSkip": "Clip Skip", "clipSkip": "Clip Skip",
"valuePlaceholder": "Valeur", "valuePlaceholder": "Valeur",
"add": "Ajouter", "add": "Ajouter",
"invalidRange": "Format de plage invalide. Utilisez x.x-y.y" "invalidRange": "Format de plage invalide. Utilisez x.x-y.y",
"invalidValue": "Veuillez saisir un nombre valide",
"saveFailed": "Échec de l'enregistrement du paramètre préréglé",
"added": "Paramètre préréglé ajouté",
"updated": "Paramètre préréglé mis à jour"
}, },
"triggerWords": { "triggerWords": {
"label": "Mots-clés", "label": "Mots-clés",
@@ -1604,7 +1641,7 @@
"addPlaceholder": "Tapez pour ajouter ou cliquez sur les suggestions ci-dessous", "addPlaceholder": "Tapez pour ajouter ou cliquez sur les suggestions ci-dessous",
"editWord": "Modifier le mot-clé", "editWord": "Modifier le mot-clé",
"editPlaceholder": "Modifier le mot-clé", "editPlaceholder": "Modifier le mot-clé",
"copyWord": "Copier le mot-clé", "copyOrEditWord": "Cliquez pour copier, double-cliquez pour modifier",
"deleteWord": "Supprimer le mot-clé", "deleteWord": "Supprimer le mot-clé",
"suggestions": { "suggestions": {
"noSuggestions": "Aucune suggestion disponible", "noSuggestions": "Aucune suggestion disponible",
@@ -1664,8 +1701,8 @@
"showCount": "Afficher les exemples ({count})", "showCount": "Afficher les exemples ({count})",
"hideExamples": "Masquer les exemples", "hideExamples": "Masquer les exemples",
"addExamples": "Ajouter des exemples", "addExamples": "Ajouter des exemples",
"previousExample": "Exemple précédent", "previousExample": "Exemple précédent ([)",
"nextExample": "Exemple suivant", "nextExample": "Exemple suivant (])",
"noExamples": "Aucune image d'exemple disponible", "noExamples": "Aucune image d'exemple disponible",
"addMoreExamples": "Ajouter d'autres exemples", "addMoreExamples": "Ajouter d'autres exemples",
"dragDrop": "Glissez-déposez des images ou des vidéos ici", "dragDrop": "Glissez-déposez des images ou des vidéos ici",
@@ -1929,10 +1966,52 @@
"tabs": { "tabs": {
"gettingStarted": "Commencer", "gettingStarted": "Commencer",
"updateVlogs": "Vlogs de mise à jour", "updateVlogs": "Vlogs de mise à jour",
"documentation": "Documentation" "documentation": "Documentation",
"shortcuts": "Raccourcis"
}, },
"gettingStarted": { "gettingStarted": {
"title": "Premiers pas avec le Gestionnaire LoRA" "title": "Premiers pas avec le Gestionnaire LoRA",
"replayTutorial": "Rejouer le tutoriel"
},
"shortcuts": {
"title": "Raccourcis clavier et souris",
"groups": {
"general": "Général",
"actions": "Actions",
"selection": "Sélection et mode groupé",
"navigation": "Navigation",
"modelModal": "Modale Modèle / Recipe",
"mediaViewer": "Visionneuse de médias / Galerie d'exemples"
},
"keys": {
"click": "Clic",
"drag": "Glisser",
"rightClick": "Clic droit",
"letter": "Lettre",
"swipe": "Balayage"
},
"entries": {
"focusSearch": "Donner le focus au champ de recherche",
"closeModal": "Fermer la fenêtre modale / le panneau",
"openShortcuts": "Ouvrir ce panneau de raccourcis",
"refresh": "Actualiser la liste des modèles",
"fetchMetadata": "Récupérer les métadonnées depuis CivitAI (pages de modèles uniquement)",
"downloadModel": "Télécharger un modèle (pages de modèles uniquement)",
"toggleBulkMode": "Activer/désactiver le mode groupé",
"selectAll": "Sélectionner tous les modèles visibles",
"rangeSelect": "Sélection d'une plage",
"marqueeSelect": "Sélection par glisser-déposer des cartes (sur une zone vide de la grille)",
"exitBulkMode": "Quitter le mode groupé",
"bulkActions": "Sur une carte sélectionnée : menu des actions groupées",
"globalActions": "Sur une zone vide de la page : menu des actions globales (vérification des mises à jour, gestion des modèles exclus)",
"scrollPages": "Faire défiler les pages",
"jumpAlphabet": "Sauter via la barre alphabétique",
"prevNext": "Modèle précédent / suivant",
"deleteEntry": "Supprimer",
"cycleMedia": "Parcourir les médias ([ / ] dans la galerie d'exemples)",
"swipeTouch": "Parcourir les médias sur les appareils tactiles",
"closeViewer": "Fermer la visionneuse"
}
}, },
"updateVlogs": { "updateVlogs": {
"title": "Dernières mises à jour", "title": "Dernières mises à jour",
@@ -1949,7 +2028,8 @@
"settings": "Paramètres & Configuration", "settings": "Paramètres & Configuration",
"extensions": "Extensions", "extensions": "Extensions",
"newBadge": "NOUVEAU" "newBadge": "NOUVEAU"
} },
"newContentBadge": "NOUVEAU"
}, },
"update": { "update": {
"title": "Vérifier les mises à jour", "title": "Vérifier les mises à jour",
+89 -9
View File
@@ -50,6 +50,27 @@
"mb": "MB", "mb": "MB",
"gb": "GB", "gb": "GB",
"tb": "TB" "tb": "TB"
},
"scanProgress": {
"refreshing": "מרענן {type}...",
"fullRebuilding": "בונה מחדש את כל ה-{type}...",
"actionRefresh": "רענון",
"actionFullRebuild": "רענון מלא",
"actionRefreshLower": "רענון",
"actionRebuildLower": "רענון מלא",
"stages": {
"scan_folders": "סורק תיקיות...",
"count_models": "נמצאו {total} קבצים",
"process_models": "מעבד מודלים",
"reconcile_scan": "בודק שינויים...",
"process_new": "מעבד מודלים חדשים",
"finalizing": "מסיים..."
},
"eta": {
"lessThanMinute": "נותרה פחות מדקה",
"minutes": "נותרו ~{minutes} דקות",
"hours": "נותרו ~{hours} שעות ו-{minutes} דקות"
}
} }
}, },
"onboarding": { "onboarding": {
@@ -75,7 +96,7 @@
}, },
"bulk": { "bulk": {
"title": "פעולות בכמות גדולה", "title": "פעולות בכמות גדולה",
"content": "היכנס למצב פעולות בכמות גדולה על ידי לחיצה על כפתור זה או על <span class=\"onboarding-shortcut\">B</span>. בחר מספר מודלים ובצע פעולות בכמות גדולה. השתמש ב-<span class=\"onboarding-shortcut\">Ctrl+A</span> כדי לבחור את כל המודלים הגלויים." "content": "היכנס למצב פעולות בכמות גדולה על ידי לחיצה על כפתור זה או על <span class=\"onboarding-shortcut\">B</span> כדי לבחור מספר מודלים ולבצע פעולות בכמות גדולה.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> בחר את כל המודלים הגלויים, <span class=\"onboarding-shortcut\">Shift+Click</span> בחר טווח.<br>• <span class=\"onboarding-shortcut\">Esc</span> או לחיצה על אזור ריק מוציאים ממצב בכמות גדולה."
}, },
"searchOptions": { "searchOptions": {
"title": "אפשרויות חיפוש", "title": "אפשרויות חיפוש",
@@ -95,7 +116,19 @@
}, },
"contextMenu": { "contextMenu": {
"title": "תפריט הקשר", "title": "תפריט הקשר",
"content": "<strong>לחיצה ימנית</strong> על כל כרטיס מודל לתפריט הקשר עם פעולות נוספות." "content": "<strong>לחיצה ימנית</strong> על כל כרטיס מודל לתפריט הקשר עם פעולות כרטיס כמו העברה, מחיקה או עריכת מטא-נתונים."
},
"marqueeSelect": {
"title": "גרור כדי לבחור",
"content": "החזק את <strong>לחצן העכבר השמאלי</strong> לחוץ על אזור ריק של הרשת וגרור כדי לצייר מסגרת בחירה שבוחרת מספר כרטיסים בבת אחת."
},
"dragToSidebar": {
"title": "ארגון באמצעות גרירה",
"content": "גרור כרטיס מודל אל תיקייה בסרגל הצד כדי להעביר את הקובץ לשם. פעולה זו עובדת גם עם מספר כרטיסים נבחרים במצב בכמות גדולה."
},
"contextMenus": {
"title": "תפריטי הקשר נוספים",
"content": "במצב בכמות גדולה, <strong>לחץ לחיצה ימנית על כרטיס נבחר</strong> לפעולות בכמות גדולה. <strong>לחץ לחיצה ימנית על אזור ריק</strong> בדף לפעולות גלובליות כמו בדיקת עדכונים וניהול מודלים מוחרגים."
} }
} }
}, },
@@ -1593,7 +1626,11 @@
"clipSkip": "Clip Skip", "clipSkip": "Clip Skip",
"valuePlaceholder": "ערך", "valuePlaceholder": "ערך",
"add": "הוסף", "add": "הוסף",
"invalidRange": "פורמט טווח לא תקין. השתמש ב-x.x-y.y" "invalidRange": "פורמט טווח לא תקין. השתמש ב-x.x-y.y",
"invalidValue": "נא להזין מספר תקין",
"saveFailed": "שמירת הפרמטר הקבוע מראש נכשלה",
"added": "הפרמטר הקבוע מראש נוסף",
"updated": "הפרמטר הקבוע מראש עודכן"
}, },
"triggerWords": { "triggerWords": {
"label": "מילות טריגר", "label": "מילות טריגר",
@@ -1604,7 +1641,7 @@
"addPlaceholder": "הקלד להוספה או לחץ על הצעות למטה", "addPlaceholder": "הקלד להוספה או לחץ על הצעות למטה",
"editWord": "עריכת מילת טריגר", "editWord": "עריכת מילת טריגר",
"editPlaceholder": "עריכת מילת טריגר", "editPlaceholder": "עריכת מילת טריגר",
"copyWord": "העתק מילת טריגר", "copyOrEditWord": "לחץ כדי להעתיק, לחץ פעמיים כדי לערוך",
"deleteWord": "מחק מילת טריגר", "deleteWord": "מחק מילת טריגר",
"suggestions": { "suggestions": {
"noSuggestions": "אין הצעות זמינות", "noSuggestions": "אין הצעות זמינות",
@@ -1664,8 +1701,8 @@
"showCount": "הצג דוגמאות ({count})", "showCount": "הצג דוגמאות ({count})",
"hideExamples": "הסתר דוגמאות", "hideExamples": "הסתר דוגמאות",
"addExamples": "הוסף דוגמאות", "addExamples": "הוסף דוגמאות",
"previousExample": "דוגמה קודמת", "previousExample": "דוגמה קודמת ([)",
"nextExample": "דוגמה הבאה", "nextExample": "דוגמה הבאה (])",
"noExamples": "אין תמונות דוגמה זמינות", "noExamples": "אין תמונות דוגמה זמינות",
"addMoreExamples": "הוסף עוד דוגמאות", "addMoreExamples": "הוסף עוד דוגמאות",
"dragDrop": "גרור ושחרר תמונות או סרטונים כאן", "dragDrop": "גרור ושחרר תמונות או סרטונים כאן",
@@ -1929,10 +1966,52 @@
"tabs": { "tabs": {
"gettingStarted": "תחילת עבודה", "gettingStarted": "תחילת עבודה",
"updateVlogs": "בלוגי וידאו של עדכונים", "updateVlogs": "בלוגי וידאו של עדכונים",
"documentation": "תיעוד" "documentation": "תיעוד",
"shortcuts": "קיצורי דרך"
}, },
"gettingStarted": { "gettingStarted": {
"title": "תחילת עבודה עם מנהל LoRA" "title": "תחילת עבודה עם מנהל LoRA",
"replayTutorial": "הפעל את המדריך מחדש"
},
"shortcuts": {
"title": "קיצורי מקלדת ועכבר",
"groups": {
"general": "כללי",
"actions": "פעולות",
"selection": "בחירה ומצב בכמות גדולה",
"navigation": "ניווט",
"modelModal": "חלון מודל / מתכון",
"mediaViewer": "מציג מדיה / גלריית דוגמאות"
},
"keys": {
"click": "לחיצה",
"drag": "גרירה",
"rightClick": "לחיצה ימנית",
"letter": "אות",
"swipe": "החלקה"
},
"entries": {
"focusSearch": "העבר מיקוד לחיפוש",
"closeModal": "סגור חלון / פאנל",
"openShortcuts": "פתח את פאנל קיצורי הדרך הזה",
"refresh": "רענן את רשימת המודלים",
"fetchMetadata": "אחזר מטא-נתונים מ-CivitAI (דפי מודלים בלבד)",
"downloadModel": "הורד מודל (דפי מודלים בלבד)",
"toggleBulkMode": "הפעל/כבה מצב בכמות גדולה",
"selectAll": "בחר את כל המודלים הגלויים",
"rangeSelect": "בחר טווח",
"marqueeSelect": "בחר כרטיסים במסגרת בחירה (באזור ריק של הרשת)",
"exitBulkMode": "צא ממצב בכמות גדולה",
"bulkActions": "על כרטיס נבחר: תפריט פעולות בכמות גדולה",
"globalActions": "באזור ריק בדף: תפריט פעולות גלובליות (בדיקת עדכונים, ניהול מודלים מוחרגים)",
"scrollPages": "גלול בין דפים",
"jumpAlphabet": "קפוץ בעזרת סרגל האותיות",
"prevNext": "מודל קודם / הבא",
"deleteEntry": "מחק",
"cycleMedia": "עבור בין פריטי מדיה ([ / ] בגלריית הדוגמאות)",
"swipeTouch": "עבור בין פריטי מדיה במכשירי מגע",
"closeViewer": "סגור את המציג"
}
}, },
"updateVlogs": { "updateVlogs": {
"title": "עדכונים אחרונים", "title": "עדכונים אחרונים",
@@ -1949,7 +2028,8 @@
"settings": "הגדרות ותצורה", "settings": "הגדרות ותצורה",
"extensions": "הרחבות", "extensions": "הרחבות",
"newBadge": "חדש" "newBadge": "חדש"
} },
"newContentBadge": "חדש"
}, },
"update": { "update": {
"title": "בדוק עדכונים", "title": "בדוק עדכונים",
+89 -9
View File
@@ -50,6 +50,27 @@
"mb": "MB", "mb": "MB",
"gb": "GB", "gb": "GB",
"tb": "TB" "tb": "TB"
},
"scanProgress": {
"refreshing": "{type}を更新中...",
"fullRebuilding": "{type}を完全に再構築中...",
"actionRefresh": "更新",
"actionFullRebuild": "完全な再構築",
"actionRefreshLower": "更新",
"actionRebuildLower": "再構築",
"stages": {
"scan_folders": "フォルダをスキャン中...",
"count_models": "{total} 件のファイルが見つかりました",
"process_models": "モデルを処理中",
"reconcile_scan": "変更を確認中...",
"process_new": "新しいモデルを処理中",
"finalizing": "最終処理中..."
},
"eta": {
"lessThanMinute": "残り1分未満",
"minutes": "残り約 {minutes} 分",
"hours": "残り約 {hours} 時間 {minutes} 分"
}
} }
}, },
"onboarding": { "onboarding": {
@@ -75,7 +96,7 @@
}, },
"bulk": { "bulk": {
"title": "一括操作", "title": "一括操作",
"content": "このボタンをクリックするか、<span class=\"onboarding-shortcut\">B</span>キーを押して一括モードに入ります。複数のモデルを選択して一括操作が可能です。<span class=\"onboarding-shortcut\">Ctrl+A</span>で表示中のモデルをすべて選択できます。" "content": "このボタンをクリックするか、<span class=\"onboarding-shortcut\">B</span>キーを押して一括モードに入り複数のモデルを選択して一括操作を実行できます。<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span>で表示中のモデルをすべて選択、<span class=\"onboarding-shortcut\">Shift+Click</span>で範囲選択。<br>• <span class=\"onboarding-shortcut\">Esc</span>キーまたは空白部分をクリックすると一括モードを終了します。"
}, },
"searchOptions": { "searchOptions": {
"title": "検索オプション", "title": "検索オプション",
@@ -95,7 +116,19 @@
}, },
"contextMenu": { "contextMenu": {
"title": "コンテキストメニュー", "title": "コンテキストメニュー",
"content": "<strong>モデルカードを右クリック</strong>すると追加の操作ができるコンテキストメニューが表示されます。" "content": "<strong>モデルカードを右クリック</strong>すると、移動、削除、メタデータの編集などのカード操作を含むコンテキストメニューが表示されます。"
},
"marqueeSelect": {
"title": "ドラッグで選択",
"content": "グリッドの空白部分で<strong>マウスの左ボタン</strong>を押したままドラッグすると、複数のカードを一度に選択する矩形(マーキー)を描画できます。"
},
"dragToSidebar": {
"title": "ドラッグで整理",
"content": "モデルカードをサイドバーのフォルダにドラッグすると、ファイルをそこに移動できます。一括モードで複数選択したカードでも同様に機能します。"
},
"contextMenus": {
"title": "その他のコンテキストメニュー",
"content": "一括モードでは、<strong>選択したカードを右クリック</strong>すると一括操作メニューが表示されます。<strong>ページの空白部分を右クリック</strong>すると、更新の確認や除外モデルの管理などのグローバル操作メニューが表示されます。"
} }
} }
}, },
@@ -1593,7 +1626,11 @@
"clipSkip": "Clip Skip", "clipSkip": "Clip Skip",
"valuePlaceholder": "値", "valuePlaceholder": "値",
"add": "追加", "add": "追加",
"invalidRange": "無効な範囲形式です。x.x-y.y を使用してください" "invalidRange": "無効な範囲形式です。x.x-y.y を使用してください",
"invalidValue": "有効な数値を入力してください",
"saveFailed": "プリセットパラメータの保存に失敗しました",
"added": "プリセットパラメータを追加しました",
"updated": "プリセットパラメータを更新しました"
}, },
"triggerWords": { "triggerWords": {
"label": "トリガーワード", "label": "トリガーワード",
@@ -1604,7 +1641,7 @@
"addPlaceholder": "入力して追加するか、下の提案をクリック", "addPlaceholder": "入力して追加するか、下の提案をクリック",
"editWord": "トリガーワードを編集", "editWord": "トリガーワードを編集",
"editPlaceholder": "トリガーワードを編集", "editPlaceholder": "トリガーワードを編集",
"copyWord": "トリガーワードをコピー", "copyOrEditWord": "クリックでコピー、ダブルクリックで編集",
"deleteWord": "トリガーワードを削除", "deleteWord": "トリガーワードを削除",
"suggestions": { "suggestions": {
"noSuggestions": "提案はありません", "noSuggestions": "提案はありません",
@@ -1664,8 +1701,8 @@
"showCount": "例を表示({count}", "showCount": "例を表示({count}",
"hideExamples": "例を非表示", "hideExamples": "例を非表示",
"addExamples": "例を追加", "addExamples": "例を追加",
"previousExample": "前の例", "previousExample": "前の例[",
"nextExample": "次の例", "nextExample": "次の例]",
"noExamples": "利用可能な例画像がありません", "noExamples": "利用可能な例画像がありません",
"addMoreExamples": "さらに例を追加", "addMoreExamples": "さらに例を追加",
"dragDrop": "画像または動画をここにドラッグ&ドロップ", "dragDrop": "画像または動画をここにドラッグ&ドロップ",
@@ -1929,10 +1966,52 @@
"tabs": { "tabs": {
"gettingStarted": "はじめに", "gettingStarted": "はじめに",
"updateVlogs": "更新Vlog", "updateVlogs": "更新Vlog",
"documentation": "ドキュメント" "documentation": "ドキュメント",
"shortcuts": "ショートカット"
}, },
"gettingStarted": { "gettingStarted": {
"title": "LoRA Managerを始める" "title": "LoRA Managerを始める",
"replayTutorial": "チュートリアルをもう一度再生"
},
"shortcuts": {
"title": "キーボード & マウスのショートカット",
"groups": {
"general": "一般",
"actions": "操作",
"selection": "選択 & 一括モード",
"navigation": "ナビゲーション",
"modelModal": "モデル / レシピモーダル",
"mediaViewer": "メディアビューア / ショーケース"
},
"keys": {
"click": "クリック",
"drag": "ドラッグ",
"rightClick": "右クリック",
"letter": "文字キー",
"swipe": "スワイプ"
},
"entries": {
"focusSearch": "検索にフォーカス",
"closeModal": "モーダル / パネルを閉じる",
"openShortcuts": "このショートカットパネルを開く",
"refresh": "モデルリストを更新",
"fetchMetadata": "CivitAIからメタデータを取得(モデルページのみ)",
"downloadModel": "モデルをダウンロード(モデルページのみ)",
"toggleBulkMode": "一括モードを切り替え",
"selectAll": "表示中のモデルをすべて選択",
"rangeSelect": "範囲選択",
"marqueeSelect": "カードを矩形選択(グリッドの空白部分で)",
"exitBulkMode": "一括モードを終了",
"bulkActions": "選択したカード上:一括操作メニュー",
"globalActions": "ページの空白部分:グローバル操作メニュー(更新の確認、除外モデルの管理)",
"scrollPages": "ページをスクロール",
"jumpAlphabet": "アルファベットバーへジャンプ",
"prevNext": "前 / 次のモデル",
"deleteEntry": "削除",
"cycleMedia": "メディアを切り替え(ショーケースギャラリーでは [ / ])",
"swipeTouch": "タッチデバイスでメディアを切り替え",
"closeViewer": "ビューアを閉じる"
}
}, },
"updateVlogs": { "updateVlogs": {
"title": "最新の更新", "title": "最新の更新",
@@ -1949,7 +2028,8 @@
"settings": "設定&構成", "settings": "設定&構成",
"extensions": "拡張機能", "extensions": "拡張機能",
"newBadge": "新着" "newBadge": "新着"
} },
"newContentBadge": "新着"
}, },
"update": { "update": {
"title": "更新確認", "title": "更新確認",
+89 -9
View File
@@ -50,6 +50,27 @@
"mb": "MB", "mb": "MB",
"gb": "GB", "gb": "GB",
"tb": "TB" "tb": "TB"
},
"scanProgress": {
"refreshing": "{type} 새로고침 중...",
"fullRebuilding": "{type} 전체 재구성 중...",
"actionRefresh": "새로고침",
"actionFullRebuild": "전체 재구성",
"actionRefreshLower": "새로고침",
"actionRebuildLower": "재구성",
"stages": {
"scan_folders": "폴더 스캔 중...",
"count_models": "파일 {total}개 발견",
"process_models": "모델 처리 중",
"reconcile_scan": "변경 사항 확인 중...",
"process_new": "새 모델 처리 중",
"finalizing": "마무리 중..."
},
"eta": {
"lessThanMinute": "남은 시간 1분 미만",
"minutes": "약 {minutes}분 남음",
"hours": "약 {hours}시간 {minutes}분 남음"
}
} }
}, },
"onboarding": { "onboarding": {
@@ -75,7 +96,7 @@
}, },
"bulk": { "bulk": {
"title": "일괄 작업", "title": "일괄 작업",
"content": "이 버튼을 클릭하거나 <span class=\"onboarding-shortcut\">B</span> 키를 눌러 일괄 모드로 진입하세요. 여러 모델을 선택하 일괄 작업을 수행할 수 있습니다. <span class=\"onboarding-shortcut\">Ctrl+A</span>로 모든 표시된 모델을 선택하세요." "content": "이 버튼을 클릭하거나 <span class=\"onboarding-shortcut\">B</span> 키를 눌러 일괄 모드로 진입하 여러 모델을 선택하 일괄 작업을 수행하세요.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span>로 모든 표시된 모델을 선택하고, <span class=\"onboarding-shortcut\">Shift+Click</span>으로 범위를 선택할 수 있습니다.<br>• <span class=\"onboarding-shortcut\">Esc</span> 키를 누르거나 빈 영역을 클릭하면 일괄 모드가 종료됩니다."
}, },
"searchOptions": { "searchOptions": {
"title": "검색 옵션", "title": "검색 옵션",
@@ -95,7 +116,19 @@
}, },
"contextMenu": { "contextMenu": {
"title": "컨텍스트 메뉴", "title": "컨텍스트 메뉴",
"content": "<strong>오른쪽 클릭</strong>으로 모델 카드의 추가 작업 메뉴를 사용할 수 있습니다." "content": "모델 카드를 <strong>오른쪽 클릭</strong>하면 이동, 삭제, 메타데이터 편집 같은 카드 작업이 담긴 컨텍스트 메뉴를 사용할 수 있습니다."
},
"marqueeSelect": {
"title": "드래그로 선택",
"content": "그리드의 빈 영역에서 <strong>마우스 왼쪽 버튼</strong>을 누른 채 드래그하여 여러 카드를 한 번에 선택하는 선택 영역을 그리세요."
},
"dragToSidebar": {
"title": "드래그로 정리",
"content": "모델 카드를 사이드바의 폴더로 드래그하면 파일이 해당 폴더로 이동합니다. 일괄 모드에서 선택한 여러 카드에도 적용됩니다."
},
"contextMenus": {
"title": "더 많은 컨텍스트 메뉴",
"content": "일괄 모드에서는 <strong>선택한 카드를 오른쪽 클릭</strong>하여 일괄 작업을 사용할 수 있습니다. 페이지의 <strong>빈 영역을 오른쪽 클릭</strong>하면 업데이트 확인이나 제외된 모델 관리 같은 전역 작업을 사용할 수 있습니다."
} }
} }
}, },
@@ -1593,7 +1626,11 @@
"clipSkip": "클립 스킵", "clipSkip": "클립 스킵",
"valuePlaceholder": "값", "valuePlaceholder": "값",
"add": "추가", "add": "추가",
"invalidRange": "잘못된 범위 형식입니다. x.x-y.y를 사용하세요" "invalidRange": "잘못된 범위 형식입니다. x.x-y.y를 사용하세요",
"invalidValue": "유효한 숫자를 입력하세요",
"saveFailed": "프리셋 매개변수 저장에 실패했습니다",
"added": "프리셋 매개변수가 추가되었습니다",
"updated": "프리셋 매개변수가 업데이트되었습니다"
}, },
"triggerWords": { "triggerWords": {
"label": "트리거 단어", "label": "트리거 단어",
@@ -1604,7 +1641,7 @@
"addPlaceholder": "입력하거나 아래 제안을 클릭하세요", "addPlaceholder": "입력하거나 아래 제안을 클릭하세요",
"editWord": "트리거 단어 편집", "editWord": "트리거 단어 편집",
"editPlaceholder": "트리거 단어 편집", "editPlaceholder": "트리거 단어 편집",
"copyWord": "트리거 단어 복사", "copyOrEditWord": "클릭하여 복사, 더블 클릭하여 편집",
"deleteWord": "트리거 단어 삭제", "deleteWord": "트리거 단어 삭제",
"suggestions": { "suggestions": {
"noSuggestions": "사용 가능한 제안이 없습니다", "noSuggestions": "사용 가능한 제안이 없습니다",
@@ -1664,8 +1701,8 @@
"showCount": "예시 보기 ({count})", "showCount": "예시 보기 ({count})",
"hideExamples": "예시 숨기기", "hideExamples": "예시 숨기기",
"addExamples": "예시 추가", "addExamples": "예시 추가",
"previousExample": "이전 예시", "previousExample": "이전 예시([)",
"nextExample": "다음 예시", "nextExample": "다음 예시(])",
"noExamples": "사용 가능한 예시 이미지가 없습니다", "noExamples": "사용 가능한 예시 이미지가 없습니다",
"addMoreExamples": "예시 더 추가", "addMoreExamples": "예시 더 추가",
"dragDrop": "이미지 또는 비디오를 여기로 끌어다 놓으세요", "dragDrop": "이미지 또는 비디오를 여기로 끌어다 놓으세요",
@@ -1929,10 +1966,52 @@
"tabs": { "tabs": {
"gettingStarted": "시작하기", "gettingStarted": "시작하기",
"updateVlogs": "업데이트 영상", "updateVlogs": "업데이트 영상",
"documentation": "문서" "documentation": "문서",
"shortcuts": "단축키"
}, },
"gettingStarted": { "gettingStarted": {
"title": "LoRA Manager 시작하기" "title": "LoRA Manager 시작하기",
"replayTutorial": "튜토리얼 다시 보기"
},
"shortcuts": {
"title": "키보드 & 마우스 단축키",
"groups": {
"general": "일반",
"actions": "작업",
"selection": "선택 & 일괄 모드",
"navigation": "내비게이션",
"modelModal": "모델 / 레시피 모달",
"mediaViewer": "미디어 뷰어 / 쇼케이스"
},
"keys": {
"click": "클릭",
"drag": "드래그",
"rightClick": "오른쪽 클릭",
"letter": "문자 키",
"swipe": "스와이프"
},
"entries": {
"focusSearch": "검색창으로 포커스 이동",
"closeModal": "모달 / 패널 닫기",
"openShortcuts": "이 단축키 패널 열기",
"refresh": "모델 목록 새로고침",
"fetchMetadata": "CivitAI에서 메타데이터 가져오기 (모델 페이지만)",
"downloadModel": "모델 다운로드 (모델 페이지만)",
"toggleBulkMode": "일괄 모드 전환",
"selectAll": "표시된 모든 모델 선택",
"rangeSelect": "범위 선택",
"marqueeSelect": "드래그로 카드 선택 (빈 그리드 영역에서)",
"exitBulkMode": "일괄 모드 종료",
"bulkActions": "선택한 카드에서: 일괄 작업 메뉴",
"globalActions": "페이지 빈 영역에서: 전역 작업 메뉴 (업데이트 확인, 제외된 모델 관리)",
"scrollPages": "페이지 스크롤",
"jumpAlphabet": "알파벳 바로 이동",
"prevNext": "이전 / 다음 모델",
"deleteEntry": "삭제",
"cycleMedia": "미디어 전환 (쇼케이스 갤러리에서 [ / ])",
"swipeTouch": "터치 기기에서 미디어 전환",
"closeViewer": "뷰어 닫기"
}
}, },
"updateVlogs": { "updateVlogs": {
"title": "최신 업데이트", "title": "최신 업데이트",
@@ -1949,7 +2028,8 @@
"settings": "설정 & 구성", "settings": "설정 & 구성",
"extensions": "확장", "extensions": "확장",
"newBadge": "신규" "newBadge": "신규"
} },
"newContentBadge": "신규"
}, },
"update": { "update": {
"title": "업데이트 확인", "title": "업데이트 확인",
+89 -9
View File
@@ -50,6 +50,27 @@
"mb": "МБ", "mb": "МБ",
"gb": "ГБ", "gb": "ГБ",
"tb": "ТБ" "tb": "ТБ"
},
"scanProgress": {
"refreshing": "Обновление {type}...",
"fullRebuilding": "Полная пересборка {type}...",
"actionRefresh": "Обновление",
"actionFullRebuild": "Полная пересборка",
"actionRefreshLower": "обновить",
"actionRebuildLower": "пересобрать",
"stages": {
"scan_folders": "Сканирование папок...",
"count_models": "Найдено файлов: {total}",
"process_models": "Обработка моделей",
"reconcile_scan": "Проверка изменений...",
"process_new": "Обработка новых моделей",
"finalizing": "Завершение..."
},
"eta": {
"lessThanMinute": "Осталось меньше минуты",
"minutes": "Осталось ~{minutes} мин",
"hours": "Осталось ~{hours} ч {minutes} мин"
}
} }
}, },
"onboarding": { "onboarding": {
@@ -75,7 +96,7 @@
}, },
"bulk": { "bulk": {
"title": "Массовые операции", "title": "Массовые операции",
"content": "Войдите в массовый режим, нажав эту кнопку или клавишу <span class=\"onboarding-shortcut\">B</span>. Выберите несколько моделей и выполните пакетные операции. Используйте <span class=\"onboarding-shortcut\">Ctrl+A</span> для выбора всех видимых моделей." "content": "Войдите в массовый режим, нажав эту кнопку или клавишу <span class=\"onboarding-shortcut\">B</span>, чтобы выбрать несколько моделей и выполнить пакетные операции.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> выбрать все видимые модели, <span class=\"onboarding-shortcut\">Shift+Click</span> — выбрать диапазон.<br>• <span class=\"onboarding-shortcut\">Esc</span> или клик по пустой области выходит из массового режима."
}, },
"searchOptions": { "searchOptions": {
"title": "Опции поиска", "title": "Опции поиска",
@@ -95,7 +116,19 @@
}, },
"contextMenu": { "contextMenu": {
"title": "Контекстное меню", "title": "Контекстное меню",
"content": "<strong>Правый клик</strong> по карточке модели откроет контекстное меню с дополнительными действиями." "content": "<strong>Правый клик</strong> по любой карточке модели открывает контекстное меню с действиями над карточкой, такими как перемещение, удаление или редактирование метаданных."
},
"marqueeSelect": {
"title": "Выделение рамкой",
"content": "Удерживайте <strong>левую кнопку мыши</strong> на пустой области сетки и перетащите, чтобы нарисовать рамку, выделяющую сразу несколько карточек."
},
"dragToSidebar": {
"title": "Организация перетаскиванием",
"content": "Перетащите карточку модели на папку в боковой панели, чтобы переместить туда файл. Это также работает с несколькими выделенными карточками в массовом режиме."
},
"contextMenus": {
"title": "Другие контекстные меню",
"content": "В массовом режиме <strong>правый клик по выделенной карточке</strong> открывает меню массовых операций. <strong>Правый клик по пустой области</strong> страницы открывает глобальные действия, такие как проверка обновлений и управление исключёнными моделями."
} }
} }
}, },
@@ -1593,7 +1626,11 @@
"clipSkip": "Clip Skip", "clipSkip": "Clip Skip",
"valuePlaceholder": "Значение", "valuePlaceholder": "Значение",
"add": "Добавить", "add": "Добавить",
"invalidRange": "Неверный формат диапазона. Используйте x.x-y.y" "invalidRange": "Неверный формат диапазона. Используйте x.x-y.y",
"invalidValue": "Введите корректное число",
"saveFailed": "Не удалось сохранить предустановленный параметр",
"added": "Предустановленный параметр добавлен",
"updated": "Предустановленный параметр обновлён"
}, },
"triggerWords": { "triggerWords": {
"label": "Триггерные слова", "label": "Триггерные слова",
@@ -1604,7 +1641,7 @@
"addPlaceholder": "Введите для добавления или нажмите на предложения ниже", "addPlaceholder": "Введите для добавления или нажмите на предложения ниже",
"editWord": "Редактировать триггерное слово", "editWord": "Редактировать триггерное слово",
"editPlaceholder": "Редактировать триггерное слово", "editPlaceholder": "Редактировать триггерное слово",
"copyWord": "Копировать триггерное слово", "copyOrEditWord": "Клик — скопировать, двойной клик — редактировать",
"deleteWord": "Удалить триггерное слово", "deleteWord": "Удалить триггерное слово",
"suggestions": { "suggestions": {
"noSuggestions": "Предложения недоступны", "noSuggestions": "Предложения недоступны",
@@ -1664,8 +1701,8 @@
"showCount": "Показать примеры ({count})", "showCount": "Показать примеры ({count})",
"hideExamples": "Скрыть примеры", "hideExamples": "Скрыть примеры",
"addExamples": "Добавить примеры", "addExamples": "Добавить примеры",
"previousExample": "Предыдущий пример", "previousExample": "Предыдущий пример ([)",
"nextExample": "Следующий пример", "nextExample": "Следующий пример (])",
"noExamples": "Примеры изображений недоступны", "noExamples": "Примеры изображений недоступны",
"addMoreExamples": "Добавить ещё примеры", "addMoreExamples": "Добавить ещё примеры",
"dragDrop": "Перетащите изображения или видео сюда", "dragDrop": "Перетащите изображения или видео сюда",
@@ -1929,10 +1966,52 @@
"tabs": { "tabs": {
"gettingStarted": "Начало работы", "gettingStarted": "Начало работы",
"updateVlogs": "Видео обновлений", "updateVlogs": "Видео обновлений",
"documentation": "Документация" "documentation": "Документация",
"shortcuts": "Горячие клавиши"
}, },
"gettingStarted": { "gettingStarted": {
"title": "Начало работы с LoRA Manager" "title": "Начало работы с LoRA Manager",
"replayTutorial": "Повторить обучение"
},
"shortcuts": {
"title": "Горячие клавиши и действия мыши",
"groups": {
"general": "Общие",
"actions": "Действия",
"selection": "Выделение и массовый режим",
"navigation": "Навигация",
"modelModal": "Окно модели / рецепта",
"mediaViewer": "Просмотр медиа / Витрина"
},
"keys": {
"click": "Клик",
"drag": "Перетаскивание",
"rightClick": "Правый клик",
"letter": "Буква",
"swipe": "Свайп"
},
"entries": {
"focusSearch": "Переход к поиску",
"closeModal": "Закрыть модальное окно / панель",
"openShortcuts": "Открыть эту панель горячих клавиш",
"refresh": "Обновить список моделей",
"fetchMetadata": "Получить метаданные с CivitAI (только на страницах моделей)",
"downloadModel": "Загрузить модель (только на страницах моделей)",
"toggleBulkMode": "Переключить массовый режим",
"selectAll": "Выбрать все видимые модели",
"rangeSelect": "Выбор диапазона",
"marqueeSelect": "Выделение карточек рамкой (на пустой области сетки)",
"exitBulkMode": "Выйти из массового режима",
"bulkActions": "На выделенной карточке: меню массовых операций",
"globalActions": "На пустой области страницы: меню глобальных действий (проверка обновлений, управление исключёнными моделями)",
"scrollPages": "Прокрутка страниц",
"jumpAlphabet": "Переход по алфавитной панели",
"prevNext": "Предыдущая / следующая модель",
"deleteEntry": "Удалить",
"cycleMedia": "Переключение медиа ([ / ] в галерее витрины)",
"swipeTouch": "Переключение медиа на сенсорных устройствах",
"closeViewer": "Закрыть окно просмотра"
}
}, },
"updateVlogs": { "updateVlogs": {
"title": "Последние обновления", "title": "Последние обновления",
@@ -1949,7 +2028,8 @@
"settings": "Настройки и конфигурация", "settings": "Настройки и конфигурация",
"extensions": "Расширения", "extensions": "Расширения",
"newBadge": "НОВОЕ" "newBadge": "НОВОЕ"
} },
"newContentBadge": "НОВОЕ"
}, },
"update": { "update": {
"title": "Проверить обновления", "title": "Проверить обновления",
+89 -9
View File
@@ -50,6 +50,27 @@
"mb": "MB", "mb": "MB",
"gb": "GB", "gb": "GB",
"tb": "TB" "tb": "TB"
},
"scanProgress": {
"refreshing": "正在刷新 {type}...",
"fullRebuilding": "正在完全重建 {type}...",
"actionRefresh": "刷新",
"actionFullRebuild": "完全重建",
"actionRefreshLower": "刷新",
"actionRebuildLower": "重建",
"stages": {
"scan_folders": "正在扫描文件夹...",
"count_models": "找到 {total} 个文件",
"process_models": "正在处理模型",
"reconcile_scan": "正在检查变更...",
"process_new": "正在处理新模型",
"finalizing": "正在收尾..."
},
"eta": {
"lessThanMinute": "剩余时间不到一分钟",
"minutes": "剩余约 {minutes} 分钟",
"hours": "剩余约 {hours} 小时 {minutes} 分钟"
}
} }
}, },
"onboarding": { "onboarding": {
@@ -75,7 +96,7 @@
}, },
"bulk": { "bulk": {
"title": "批量操作", "title": "批量操作",
"content": "点击此按钮或按 <span class=\"onboarding-shortcut\">B</span> 进入批量模式可多选模型并行批量操作。使用 <span class=\"onboarding-shortcut\">Ctrl+A</span> 全选所有可见模型。" "content": "点击此按钮或按 <span class=\"onboarding-shortcut\">B</span> 进入批量模式可多选模型并行批量操作。<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> 全选所有可见模型<span class=\"onboarding-shortcut\">Shift+Click</span> 选择一个范围。<br>• 按 <span class=\"onboarding-shortcut\">Esc</span> 或点击空白区域退出批量模式。"
}, },
"searchOptions": { "searchOptions": {
"title": "搜索选项", "title": "搜索选项",
@@ -95,7 +116,19 @@
}, },
"contextMenu": { "contextMenu": {
"title": "右键菜单", "title": "右键菜单",
"content": "<strong>右键点击</strong>任意模型卡片可打开更多操作菜单。" "content": "<strong>右键点击</strong>任意模型卡片可打开包含移动、删除或编辑元数据等卡片操作菜单。"
},
"marqueeSelect": {
"title": "拖动框选",
"content": "在网格的空白区域按住<strong>鼠标左键</strong>并拖动,绘制一个可同时选中多张卡片的框选区域。"
},
"dragToSidebar": {
"title": "拖放整理",
"content": "将模型卡片拖到侧边栏的文件夹上,即可把文件移动到该文件夹。批量模式下选中的多张卡片也可如此操作。"
},
"contextMenus": {
"title": "更多右键菜单",
"content": "在批量模式下,<strong>右键点击已选中的卡片</strong>可进行批量操作。<strong>右键点击页面空白区域</strong>可使用检查更新、管理已排除的模型等全局操作。"
} }
} }
}, },
@@ -1593,7 +1626,11 @@
"clipSkip": "Clip Skip", "clipSkip": "Clip Skip",
"valuePlaceholder": "数值", "valuePlaceholder": "数值",
"add": "添加", "add": "添加",
"invalidRange": "无效的范围格式。请使用 x.x-y.y" "invalidRange": "无效的范围格式。请使用 x.x-y.y",
"invalidValue": "请输入有效的数值",
"saveFailed": "保存预设参数失败",
"added": "已添加预设参数",
"updated": "已更新预设参数"
}, },
"triggerWords": { "triggerWords": {
"label": "触发词", "label": "触发词",
@@ -1604,7 +1641,7 @@
"addPlaceholder": "输入或点击下方建议添加", "addPlaceholder": "输入或点击下方建议添加",
"editWord": "编辑触发词", "editWord": "编辑触发词",
"editPlaceholder": "编辑触发词", "editPlaceholder": "编辑触发词",
"copyWord": "复制触发词", "copyOrEditWord": "单击复制,双击编辑",
"deleteWord": "删除触发词", "deleteWord": "删除触发词",
"suggestions": { "suggestions": {
"noSuggestions": "暂无建议", "noSuggestions": "暂无建议",
@@ -1664,8 +1701,8 @@
"showCount": "显示示例({count}", "showCount": "显示示例({count}",
"hideExamples": "隐藏示例", "hideExamples": "隐藏示例",
"addExamples": "添加示例", "addExamples": "添加示例",
"previousExample": "上一个示例", "previousExample": "上一个示例[",
"nextExample": "下一个示例", "nextExample": "下一个示例]",
"noExamples": "暂无示例图片", "noExamples": "暂无示例图片",
"addMoreExamples": "添加更多示例", "addMoreExamples": "添加更多示例",
"dragDrop": "将图片或视频拖放到此处", "dragDrop": "将图片或视频拖放到此处",
@@ -1929,10 +1966,52 @@
"tabs": { "tabs": {
"gettingStarted": "新手入门", "gettingStarted": "新手入门",
"updateVlogs": "更新日志", "updateVlogs": "更新日志",
"documentation": "文档" "documentation": "文档",
"shortcuts": "快捷键"
}, },
"gettingStarted": { "gettingStarted": {
"title": "LoRA 管理器新手入门" "title": "LoRA 管理器新手入门",
"replayTutorial": "重播教程"
},
"shortcuts": {
"title": "键盘与鼠标快捷键",
"groups": {
"general": "通用",
"actions": "操作",
"selection": "选择与批量模式",
"navigation": "导航",
"modelModal": "模型 / 配方弹窗",
"mediaViewer": "媒体查看器 / 示例展示"
},
"keys": {
"click": "单击",
"drag": "拖动",
"rightClick": "右键点击",
"letter": "字母",
"swipe": "滑动"
},
"entries": {
"focusSearch": "聚焦搜索框",
"closeModal": "关闭弹窗 / 面板",
"openShortcuts": "打开本快捷键面板",
"refresh": "刷新模型列表",
"fetchMetadata": "从 CivitAI 获取元数据(仅模型页面)",
"downloadModel": "下载模型(仅模型页面)",
"toggleBulkMode": "切换批量模式",
"selectAll": "全选所有可见模型",
"rangeSelect": "范围选择",
"marqueeSelect": "框选卡片(在网格空白区域)",
"exitBulkMode": "退出批量模式",
"bulkActions": "在已选中的卡片上:批量操作菜单",
"globalActions": "在页面空白区域:全局操作菜单(检查更新、管理已排除的模型)",
"scrollPages": "滚动页面",
"jumpAlphabet": "字母索引栏跳转",
"prevNext": "上一个 / 下一个模型",
"deleteEntry": "删除",
"cycleMedia": "切换媒体(在示例展示中按 [ / ])",
"swipeTouch": "在触屏设备上切换媒体",
"closeViewer": "关闭查看器"
}
}, },
"updateVlogs": { "updateVlogs": {
"title": "最新更新", "title": "最新更新",
@@ -1949,7 +2028,8 @@
"settings": "设置与配置", "settings": "设置与配置",
"extensions": "扩展", "extensions": "扩展",
"newBadge": "新" "newBadge": "新"
} },
"newContentBadge": "新"
}, },
"update": { "update": {
"title": "检查更新", "title": "检查更新",
+89 -9
View File
@@ -50,6 +50,27 @@
"mb": "MB", "mb": "MB",
"gb": "GB", "gb": "GB",
"tb": "TB" "tb": "TB"
},
"scanProgress": {
"refreshing": "正在重新整理 {type}...",
"fullRebuilding": "正在完整重建 {type}...",
"actionRefresh": "重新整理",
"actionFullRebuild": "完整重建",
"actionRefreshLower": "重新整理",
"actionRebuildLower": "重建",
"stages": {
"scan_folders": "正在掃描資料夾...",
"count_models": "找到 {total} 個檔案",
"process_models": "正在處理模型",
"reconcile_scan": "正在檢查變更...",
"process_new": "正在處理新模型",
"finalizing": "正在收尾..."
},
"eta": {
"lessThanMinute": "剩餘時間不到一分鐘",
"minutes": "剩餘約 {minutes} 分鐘",
"hours": "剩餘約 {hours} 小時 {minutes} 分鐘"
}
} }
}, },
"onboarding": { "onboarding": {
@@ -75,7 +96,7 @@
}, },
"bulk": { "bulk": {
"title": "批次操作", "title": "批次操作",
"content": "點擊此按鈕或按下 <span class=\"onboarding-shortcut\">B</span> 進入批模式。可選取多個模型並執行批量操作。使用 <span class=\"onboarding-shortcut\">Ctrl+A</span> 選取所有可見模型。" "content": "點擊此按鈕或按下 <span class=\"onboarding-shortcut\">B</span> 進入批模式選取多個模型並執行批量操作。<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> 選取所有可見模型<span class=\"onboarding-shortcut\">Shift+Click</span> 選取一段範圍。<br>• <span class=\"onboarding-shortcut\">Esc</span> 或點擊空白處離開批量模式。"
}, },
"searchOptions": { "searchOptions": {
"title": "搜尋選項", "title": "搜尋選項",
@@ -95,7 +116,19 @@
}, },
"contextMenu": { "contextMenu": {
"title": "右鍵選單", "title": "右鍵選單",
"content": "<strong>右鍵點擊</strong>任一模型卡片可開啟更多操作選單。" "content": "<strong>右鍵點擊</strong>任一模型卡片可開啟包含移動、刪除或編輯中繼資料等卡片操作的右鍵選單。"
},
"marqueeSelect": {
"title": "拖曳框選",
"content": "在網格空白處按住<strong>滑鼠左鍵</strong>並拖曳,畫出框選範圍,一次選取多張卡片。"
},
"dragToSidebar": {
"title": "拖曳整理",
"content": "將模型卡片拖曳到側邊欄的資料夾上,即可將檔案移動到該處。在批量模式下選取多張卡片也可一起拖曳。"
},
"contextMenus": {
"title": "更多右鍵選單",
"content": "在批量模式下,<strong>右鍵點擊已選取的卡片</strong>可開啟批量操作選單。<strong>右鍵點擊頁面空白處</strong>可開啟全域操作選單,例如檢查更新與管理已排除的模型。"
} }
} }
}, },
@@ -1593,7 +1626,11 @@
"clipSkip": "Clip Skip", "clipSkip": "Clip Skip",
"valuePlaceholder": "數值", "valuePlaceholder": "數值",
"add": "新增", "add": "新增",
"invalidRange": "無效的範圍格式。請使用 x.x-y.y" "invalidRange": "無效的範圍格式。請使用 x.x-y.y",
"invalidValue": "請輸入有效的數值",
"saveFailed": "儲存預設參數失敗",
"added": "已新增預設參數",
"updated": "已更新預設參數"
}, },
"triggerWords": { "triggerWords": {
"label": "觸發詞", "label": "觸發詞",
@@ -1604,7 +1641,7 @@
"addPlaceholder": "輸入或點擊下方建議", "addPlaceholder": "輸入或點擊下方建議",
"editWord": "編輯觸發詞", "editWord": "編輯觸發詞",
"editPlaceholder": "編輯觸發詞", "editPlaceholder": "編輯觸發詞",
"copyWord": "複製觸發詞", "copyOrEditWord": "點擊複製,雙擊編輯",
"deleteWord": "刪除觸發詞", "deleteWord": "刪除觸發詞",
"suggestions": { "suggestions": {
"noSuggestions": "無可用建議", "noSuggestions": "無可用建議",
@@ -1664,8 +1701,8 @@
"showCount": "顯示範例({count}", "showCount": "顯示範例({count}",
"hideExamples": "隱藏範例", "hideExamples": "隱藏範例",
"addExamples": "新增範例", "addExamples": "新增範例",
"previousExample": "上一個範例", "previousExample": "上一個範例[",
"nextExample": "下一個範例", "nextExample": "下一個範例]",
"noExamples": "沒有可用的範例圖片", "noExamples": "沒有可用的範例圖片",
"addMoreExamples": "新增更多範例", "addMoreExamples": "新增更多範例",
"dragDrop": "拖放圖片或影片到此處", "dragDrop": "拖放圖片或影片到此處",
@@ -1929,10 +1966,52 @@
"tabs": { "tabs": {
"gettingStarted": "快速開始", "gettingStarted": "快速開始",
"updateVlogs": "更新影片", "updateVlogs": "更新影片",
"documentation": "文件" "documentation": "文件",
"shortcuts": "快捷鍵"
}, },
"gettingStarted": { "gettingStarted": {
"title": "LoRA 管理器快速開始" "title": "LoRA 管理器快速開始",
"replayTutorial": "重新播放教學"
},
"shortcuts": {
"title": "鍵盤與滑鼠快捷鍵",
"groups": {
"general": "一般",
"actions": "操作",
"selection": "選取與批量模式",
"navigation": "導覽",
"modelModal": "模型 / 配方彈窗",
"mediaViewer": "媒體檢視器 / 範例展示"
},
"keys": {
"click": "點擊",
"drag": "拖曳",
"rightClick": "右鍵點擊",
"letter": "字母鍵",
"swipe": "滑動"
},
"entries": {
"focusSearch": "聚焦搜尋欄",
"closeModal": "關閉彈窗 / 面板",
"openShortcuts": "開啟此快捷鍵面板",
"refresh": "重新整理模型列表",
"fetchMetadata": "從 CivitAI 擷取中繼資料(僅限模型頁面)",
"downloadModel": "下載模型(僅限模型頁面)",
"toggleBulkMode": "切換批量模式",
"selectAll": "選取所有可見模型",
"rangeSelect": "範圍選取",
"marqueeSelect": "框選卡片(在網格空白處拖曳)",
"exitBulkMode": "離開批量模式",
"bulkActions": "在已選取的卡片上:批量操作選單",
"globalActions": "在頁面空白處:全域操作選單(檢查更新、管理已排除的模型)",
"scrollPages": "捲動頁面",
"jumpAlphabet": "字母列跳轉",
"prevNext": "上一個 / 下一個模型",
"deleteEntry": "刪除",
"cycleMedia": "切換媒體(範例展示中的 [ / ])",
"swipeTouch": "在觸控裝置上滑動切換媒體",
"closeViewer": "關閉檢視器"
}
}, },
"updateVlogs": { "updateVlogs": {
"title": "最新更新", "title": "最新更新",
@@ -1949,7 +2028,8 @@
"settings": "設定與配置", "settings": "設定與配置",
"extensions": "擴充功能", "extensions": "擴充功能",
"newBadge": "新" "newBadge": "新"
} },
"newContentBadge": "新"
}, },
"update": { "update": {
"title": "檢查更新", "title": "檢查更新",
+89 -1
View File
@@ -15,6 +15,10 @@ from aiohttp import web
import jinja2 import jinja2
from ...config import config from ...config import config
from ...services.active_filters_store import (
ActiveFiltersStore,
active_filters_to_query_kwargs,
)
from ...services.download_coordinator import DownloadCoordinator from ...services.download_coordinator import DownloadCoordinator
from ...services.connectivity_guard import ( from ...services.connectivity_guard import (
OFFLINE_FRIENDLY_MESSAGE, OFFLINE_FRIENDLY_MESSAGE,
@@ -1595,12 +1599,50 @@ class ModelQueryHandler:
allow_selling_generated_content.lower() not in ("false", "0", "") allow_selling_generated_content.lower() not in ("false", "0", "")
) )
# When requested, merge the manager page's active filters stored
# server-side. Explicit query parameters take precedence over the
# stored values.
use_active_filters = (
request.query.get("use_active_filters", "").lower() in ("1", "true")
)
if use_active_filters:
stored = ActiveFiltersStore.get_instance().get_filters(
self._service.model_type
)
injected = active_filters_to_query_kwargs(stored)
if folder is None and "folder" in injected:
folder = injected["folder"]
if "recursive" not in request.query and "recursive" in injected:
recursive = injected["recursive"]
if not base_models and injected.get("base_models"):
base_models = injected["base_models"]
if not model_types and injected.get("model_types"):
model_types = injected["model_types"]
if not tag_filters and injected.get("tags"):
tag_filters = injected["tags"]
if not auto_tag_filters and injected.get("auto_tags"):
auto_tag_filters = injected["auto_tags"]
if "tag_logic" not in request.query and injected.get("tag_logic"):
injected_logic = str(injected["tag_logic"]).lower()
if injected_logic in ("any", "all"):
tag_logic = injected_logic
if credit_required is None and "credit_required" in injected:
credit_required = injected["credit_required"]
if (
allow_selling_generated_content is None
and "allow_selling_generated_content" in injected
):
allow_selling_generated_content = injected[
"allow_selling_generated_content"
]
# The presence of the recursive param (always sent by the loras # The presence of the recursive param (always sent by the loras
# widget when filter mode is on) signals that the filter pipeline # widget when filter mode is on) signals that the filter pipeline
# must run even when no concrete filter is set, so global settings # must run even when no concrete filter is set, so global settings
# like show_only_sfw stay consistent with the list endpoint. # like show_only_sfw stay consistent with the list endpoint.
apply_filters = ( apply_filters = (
"recursive" in request.query use_active_filters
or "recursive" in request.query
or folder is not None or folder is not None
or bool(base_models) or bool(base_models)
or bool(model_types) or bool(model_types)
@@ -1634,6 +1676,50 @@ class ModelQueryHandler:
) )
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def update_active_filters(self, request: web.Request) -> web.Response:
"""Store the manager page's active filters for this model type."""
try:
payload = await request.json()
except Exception:
return web.json_response(
{"success": False, "error": "Invalid JSON body"}, status=400
)
if not isinstance(payload, dict):
return web.json_response(
{"success": False, "error": "Body must be a JSON object"}, status=400
)
try:
ActiveFiltersStore.get_instance().set_filters(
self._service.model_type, payload
)
return web.json_response({"success": True})
except Exception as exc:
self._logger.error(
"Error updating active filters for %s: %s",
self._service.model_type,
exc,
exc_info=True,
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_active_filters(self, request: web.Request) -> web.Response:
"""Return the stored active filters for this model type."""
try:
filters = ActiveFiltersStore.get_instance().get_filters(
self._service.model_type
)
return web.json_response({"success": True, "filters": filters})
except Exception as exc:
self._logger.error(
"Error getting active filters for %s: %s",
self._service.model_type,
exc,
exc_info=True,
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
class ModelDownloadHandler: class ModelDownloadHandler:
"""Coordinate downloads and progress reporting.""" """Coordinate downloads and progress reporting."""
@@ -3339,6 +3425,8 @@ class ModelHandlerSet:
"get_model_metadata": self.query.get_model_metadata, "get_model_metadata": self.query.get_model_metadata,
"get_model_description": self.query.get_model_description, "get_model_description": self.query.get_model_description,
"get_relative_paths": self.query.get_relative_paths, "get_relative_paths": self.query.get_relative_paths,
"update_active_filters": self.query.update_active_filters,
"get_active_filters": self.query.get_active_filters,
"refresh_model_updates": self.updates.refresh_model_updates, "refresh_model_updates": self.updates.refresh_model_updates,
"fetch_missing_civitai_license_data": self.updates.fetch_missing_civitai_license_data, "fetch_missing_civitai_license_data": self.updates.fetch_missing_civitai_license_data,
"set_model_update_ignore": self.updates.set_model_update_ignore, "set_model_update_ignore": self.updates.set_model_update_ignore,
+19 -5
View File
@@ -1128,15 +1128,21 @@ class RecipeManagementHandler:
image_id = extract_civitai_image_id(source_path) if source_path else None image_id = extract_civitai_image_id(source_path) if source_path else None
# Local re-import sources: an explicit local source_path, or — when # Local re-import sources: an explicit local source_path, or — when
# no source_path was recorded (drag & drop / file-picker imports) — # no usable source_path was recorded (drag & drop / file-picker
# the recipe's own saved image, which still carries the original # imports, or a dangling path left by an earlier re-import) — the
# recipe's own saved image, which still carries the original
# embedded generation metadata next to the recipe metadata block. # embedded generation metadata next to the recipe metadata block.
# In the fallback case nothing is persisted as source_path: the
# recipe's own previous preview is not an external source, and it
# is deleted together with the old recipe below.
local_source = None local_source = None
persisted_source_path = ""
if not image_id and source_path and os.path.isfile(source_path): if not image_id and source_path and os.path.isfile(source_path):
local_source = source_path local_source = source_path
persisted_source_path = source_path
elif ( elif (
not image_id not image_id
and not source_path and not source_path.startswith(("http://", "https://"))
and old_file_path and old_file_path
and os.path.isfile(old_file_path) and os.path.isfile(old_file_path)
): ):
@@ -1170,6 +1176,7 @@ class RecipeManagementHandler:
target_dir=old_folder, target_dir=old_folder,
user_edits=user_edits, user_edits=user_edits,
old_title=old_recipe.get("title", ""), old_title=old_recipe.get("title", ""),
persisted_source_path=persisted_source_path,
) )
async with self._import_semaphore: async with self._import_semaphore:
@@ -2512,6 +2519,7 @@ class RecipeManagementHandler:
target_dir: str | None, target_dir: str | None,
user_edits: dict[str, Any], user_edits: dict[str, Any],
old_title: str, old_title: str,
persisted_source_path: str,
) -> web.Response: ) -> web.Response:
"""Re-import a recipe from a local image file. """Re-import a recipe from a local image file.
@@ -2519,6 +2527,12 @@ class RecipeManagementHandler:
generation metadata (the appended recipe metadata block is ignored so generation metadata (the appended recipe metadata block is ignored so
the current parser gets a fresh pass), saves a new recipe, then deletes the current parser gets a fresh pass), saves a new recipe, then deletes
the old one. the old one.
``persisted_source_path`` is the source_path recorded on the new
recipe: the external source file when one exists, or empty when the
re-import fell back to the recipe's own previous preview image (that
file is deleted with the old recipe, so recording it would leave a
dangling path that blocks future re-imports).
""" """
normalized = os.path.normpath(file_path) normalized = os.path.normpath(file_path)
if not os.path.isfile(normalized): if not os.path.isfile(normalized):
@@ -2547,7 +2561,7 @@ class RecipeManagementHandler:
"base_model": base_model, "base_model": base_model,
"loras": loras, "loras": loras,
"gen_params": gen_params, "gen_params": gen_params,
"source_path": normalized, "source_path": persisted_source_path,
} }
if checkpoint: if checkpoint:
metadata["checkpoint"] = checkpoint metadata["checkpoint"] = checkpoint
@@ -2610,7 +2624,7 @@ class RecipeManagementHandler:
"success": True, "success": True,
"old_recipe_id": recipe_id, "old_recipe_id": recipe_id,
"recipe_id": new_recipe_id, "recipe_id": new_recipe_id,
"source_path": normalized, "source_path": persisted_source_path,
} }
) )
+2
View File
@@ -68,6 +68,8 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
"GET", "/api/lm/{prefix}/model-description", "get_model_description" "GET", "/api/lm/{prefix}/model-description", "get_model_description"
), ),
RouteDefinition("GET", "/api/lm/{prefix}/relative-paths", "get_relative_paths"), RouteDefinition("GET", "/api/lm/{prefix}/relative-paths", "get_relative_paths"),
RouteDefinition("PUT", "/api/lm/{prefix}/active-filters", "update_active_filters"),
RouteDefinition("GET", "/api/lm/{prefix}/active-filters", "get_active_filters"),
RouteDefinition( RouteDefinition(
"GET", "/api/lm/{prefix}/civitai/versions/{model_id}", "get_civitai_versions" "GET", "/api/lm/{prefix}/civitai/versions/{model_id}", "get_civitai_versions"
), ),
+135
View File
@@ -0,0 +1,135 @@
"""In-memory store for the LoRA Manager page's active filters.
The manager page keeps its filter state in localStorage for its own
restoration, but the ComfyUI node autocomplete runs in a potentially
different browser/origin (or Electron shell) where that storage is not
shared. This store mirrors the active filters server-side so the
``/api/lm/{prefix}/relative-paths`` endpoint can inject them into
autocomplete searches regardless of which client set them.
State is process-local and intentionally not persisted; the manager page
re-pushes its restored state on load.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
# Keys copied from the manager page's persisted filter snapshot.
_FILTER_KEYS = (
"baseModel",
"tags",
"autoTags",
"modelTypes",
"tagLogic",
"license",
)
class ActiveFiltersStore:
"""Process-local store of active filters, keyed by model type."""
_instance: Optional["ActiveFiltersStore"] = None
def __init__(self) -> None:
self._filters: Dict[str, Dict[str, Any]] = {}
@classmethod
def get_instance(cls) -> "ActiveFiltersStore":
if cls._instance is None:
cls._instance = cls()
return cls._instance
@classmethod
def reset_instance(cls) -> None:
"""Drop the singleton (test isolation)."""
cls._instance = None
def set_filters(self, model_type: str, payload: Dict[str, Any]) -> None:
"""Replace the stored active filters for a model type.
Only recognized keys are kept; everything else is discarded.
"""
filters = payload.get("filters")
sanitized: Dict[str, Any] = {
"activeFolder": payload.get("activeFolder"),
"recursiveSearch": bool(payload.get("recursiveSearch", True)),
"filters": (
{key: filters[key] for key in _FILTER_KEYS if key in filters}
if isinstance(filters, dict)
else None
),
}
self._filters[model_type] = sanitized
def get_filters(self, model_type: str) -> Optional[Dict[str, Any]]:
"""Return the stored payload for a model type, or None if unset."""
return self._filters.get(model_type)
def clear(self, model_type: str) -> None:
self._filters.pop(model_type, None)
def active_filters_to_query_kwargs(payload: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""Map a stored active-filters payload to ``search_relative_paths`` kwargs.
Mirrors the query-param mapping that the ComfyUI autocomplete used to
build client-side from localStorage (web/comfyui/autocomplete.js).
"""
kwargs: Dict[str, Any] = {}
if not payload:
return kwargs
active_folder = payload.get("activeFolder")
recursive = payload.get("recursiveSearch", True)
if active_folder and active_folder != "null":
kwargs["folder"] = active_folder
elif not recursive:
# Root folder with recursion disabled mirrors the page list,
# which matches only root-level files via folder=''.
kwargs["folder"] = ""
filters = payload.get("filters")
if isinstance(filters, dict):
base_models = filters.get("baseModel")
if isinstance(base_models, list):
kwargs["base_models"] = [m for m in base_models if m]
for source_key, target_key in (("tags", "tags"), ("autoTags", "auto_tags")):
states = filters.get(source_key)
if isinstance(states, dict):
mapped = {
tag: state
for tag, state in states.items()
if state in ("include", "exclude")
}
if mapped:
kwargs[target_key] = mapped
model_types = filters.get("modelTypes")
if isinstance(model_types, list):
kwargs["model_types"] = [t for t in model_types if t]
tag_logic = filters.get("tagLogic")
if tag_logic:
kwargs["tag_logic"] = tag_logic
license_filter = filters.get("license")
if isinstance(license_filter, dict):
no_credit = license_filter.get("noCredit")
if no_credit == "include":
kwargs["credit_required"] = False
elif no_credit == "exclude":
kwargs["credit_required"] = True
allow_selling = license_filter.get("allowSelling")
if allow_selling == "include":
kwargs["allow_selling_generated_content"] = True
elif allow_selling == "exclude":
kwargs["allow_selling_generated_content"] = False
kwargs["recursive"] = recursive
return kwargs
+27 -2
View File
@@ -1295,6 +1295,27 @@ class BaseModelService(ABC):
path_for_sorting, path_for_sorting,
) )
@staticmethod
def _relative_path_folder_group_sort_key(
relative_path: str, include_terms: List[str]
) -> tuple:
"""Group paths by folder, then sort by relevance within each group.
Folders are ordered alphabetically (case-insensitive) by their full
folder path, with root-level files (empty folder) first. Within a
folder, paths keep the relevance ordering of
``_relative_path_sort_key``. This keeps same-folder entries together
in the autocomplete dropdown instead of interleaving them by filename.
"""
path_for_sorting = BaseModelService._remove_model_extension(
relative_path.lower()
)
folder = path_for_sorting.rpartition(os.sep)[0]
return (folder,) + BaseModelService._relative_path_sort_key(
relative_path, include_terms
)
async def search_relative_paths( async def search_relative_paths(
self, self,
search_term: str, search_term: str,
@@ -1404,9 +1425,13 @@ class BaseModelService(ABC):
): ):
matching_paths.append(relative_path) matching_paths.append(relative_path)
# Sort by relevance (prefix and earliest hits first, then by length and alphabetically) # Group by folder (root first, then alphabetically) and sort by
# relevance (prefix and earliest hits, then length and alphabetically)
# within each folder group.
matching_paths.sort( matching_paths.sort(
key=lambda relative: self._relative_path_sort_key(relative, include_terms) key=lambda relative: self._relative_path_folder_group_sort_key(
relative, include_terms
)
) )
# Apply offset and limit # Apply offset and limit
+135 -14
View File
@@ -66,6 +66,14 @@ def _is_hidden_relative_path(rel_path: str) -> bool:
# requests (modal open + autocomplete) do not re-walk the model roots. # requests (modal open + autocomplete) do not re-walk the model roots.
ALL_FOLDERS_CACHE_TTL_SECONDS = 5.0 ALL_FOLDERS_CACHE_TTL_SECONDS = 5.0
# Maps a scanner model type to the manager page type used in progress
# broadcasts (e.g. 'lora' -> 'loras').
PAGE_TYPE_MAP = {
'lora': 'loras',
'checkpoint': 'checkpoints',
'embedding': 'embeddings',
}
def _is_pending_delete_path(path: str) -> bool: def _is_pending_delete_path(path: str) -> bool:
"""Return True when any path component is the pending-delete staging dir.""" """Return True when any path component is the pending-delete staging dir."""
@@ -149,6 +157,38 @@ class ModelScanner:
# Register this service # Register this service
asyncio.create_task(self._register_service()) asyncio.create_task(self._register_service())
@property
def page_type(self) -> str:
"""Manager page type used in progress broadcasts (e.g. 'loras')."""
return PAGE_TYPE_MAP.get(self.model_type, self.model_type)
async def _broadcast_scan_progress(
self,
status: str,
stage: str,
progress: int,
full_rebuild: bool,
**extra: Any,
) -> None:
"""Broadcast manual-refresh scan progress on the generic WS channel.
Best-effort only: broadcast failures must never affect the scan itself.
"""
payload: Dict[str, Any] = {
'type': 'scan_progress',
'status': status,
'model_type': self.model_type,
'pageType': self.page_type,
'stage': stage,
'full_rebuild': full_rebuild,
'progress': progress,
}
payload.update(extra)
try:
await ws_manager.broadcast(payload)
except Exception as exc: # pragma: no cover - defensive logging
logger.error(f"Error broadcasting scan progress for {self.model_type}: {exc}")
@property @property
def cache_version(self) -> int: def cache_version(self) -> int:
"""Monotonic version counter for the in-memory cache. """Monotonic version counter for the in-memory cache.
@@ -434,12 +474,7 @@ class ModelScanner:
self._is_initializing = True self._is_initializing = True
# Determine the page type based on model type # Determine the page type based on model type
page_type_map = { page_type = self.page_type
'lora': 'loras',
'checkpoint': 'checkpoints',
'embedding': 'embeddings'
}
page_type = page_type_map.get(self.model_type, self.model_type)
# First, try to load from cache # First, try to load from cache
await ws_manager.broadcast_init_progress({ await ws_manager.broadcast_init_progress({
@@ -804,7 +839,7 @@ class ModelScanner:
last_progress_time = time.time() last_progress_time = time.time()
last_progress_percent = 0 last_progress_percent = 0
async def progress_callback(processed_files: int, expected_total: int) -> None: async def progress_callback(processed_files: int, expected_total: int, current_name: str = '') -> None:
nonlocal last_progress_time, last_progress_percent nonlocal last_progress_time, last_progress_percent
if expected_total <= 0: if expected_total <= 0:
@@ -871,32 +906,84 @@ class ModelScanner:
async def _initialize_cache(self) -> None: async def _initialize_cache(self) -> None:
"""Initialize or refresh the cache""" """Initialize or refresh the cache"""
self._is_initializing = True # Set flag self._is_initializing = True # Set flag
last_progress_percent = 0
try: try:
start_time = time.time() start_time = time.time()
await self._broadcast_scan_progress('started', 'scan_folders', 0, True)
# Manually trigger a symlink rescan during a full rebuild. # Manually trigger a symlink rescan during a full rebuild.
# This ensures that any new symlink mappings are correctly picked up. # This ensures that any new symlink mappings are correctly picked up.
config.rebuild_symlink_cache() config.rebuild_symlink_cache()
# Determine the page type based on model type # Count files in a thread so the event loop stays responsive
loop = asyncio.get_running_loop()
total_files = await loop.run_in_executor(None, self._count_model_files)
await self._broadcast_scan_progress(
'processing', 'count_models', 1, True,
processed=0, total=total_files,
)
last_progress_time = time.time()
async def progress_callback(processed_files: int, expected_total: int, current_name: str = '') -> None:
nonlocal last_progress_time, last_progress_percent
if expected_total <= 0:
return
current_time = time.time()
progress_percent = min(99, int(1 + (processed_files / expected_total) * 98))
if progress_percent <= last_progress_percent:
return
if current_time - last_progress_time <= 0.5 and processed_files != expected_total:
return
last_progress_percent = progress_percent
last_progress_time = current_time
await self._broadcast_scan_progress(
'processing', 'process_models', progress_percent, True,
processed=processed_files, total=expected_total,
current_name=current_name,
)
# Scan for new data # Scan for new data
scan_result = await self._gather_model_data() scan_result = await self._gather_model_data(
total_files=total_files,
progress_callback=progress_callback,
)
if not self.is_cancelled(): if not self.is_cancelled():
await self._broadcast_scan_progress('finalizing', 'finalizing', 99, True)
await self._apply_scan_result(scan_result) await self._apply_scan_result(scan_result)
await self._save_persistent_cache(scan_result) await self._save_persistent_cache(scan_result)
await self._sync_download_history(scan_result.raw_data, source='scan') await self._sync_download_history(scan_result.raw_data, source='scan')
await self._broadcast_scan_progress(
'completed', 'finalizing', 100, True,
elapsed_seconds=time.time() - start_time,
)
logger.info( logger.info(
f"{self.model_type.capitalize()} Scanner: Cache initialization completed in {time.time() - start_time:.2f} seconds, " f"{self.model_type.capitalize()} Scanner: Cache initialization completed in {time.time() - start_time:.2f} seconds, "
f"found {len(scan_result.raw_data)} models" f"found {len(scan_result.raw_data)} models"
) )
else: else:
await self._broadcast_scan_progress(
'cancelled', 'process_models', last_progress_percent, True,
elapsed_seconds=time.time() - start_time,
)
logger.info( logger.info(
f"{self.model_type.capitalize()} Scanner: Cache initialization cancelled " f"{self.model_type.capitalize()} Scanner: Cache initialization cancelled "
f"after {time.time() - start_time:.2f} seconds" f"after {time.time() - start_time:.2f} seconds"
) )
except Exception as e: except Exception as e:
logger.error(f"{self.model_type.capitalize()} Scanner: Error initializing cache: {e}") logger.error(f"{self.model_type.capitalize()} Scanner: Error initializing cache: {e}")
await self._broadcast_scan_progress(
'error', 'process_models', last_progress_percent, True,
error=str(e),
)
# Ensure cache is at least an empty structure on error # Ensure cache is at least an empty structure on error
if self._cache is None: if self._cache is None:
self._cache = ModelCache( self._cache = ModelCache(
@@ -915,6 +1002,8 @@ class ModelScanner:
start_time = time.time() start_time = time.time()
logger.info(f"{self.model_type.capitalize()} Scanner: Starting fast cache reconciliation...") logger.info(f"{self.model_type.capitalize()} Scanner: Starting fast cache reconciliation...")
await self._broadcast_scan_progress('started', 'reconcile_scan', 0, False)
# Get current cached file paths # Get current cached file paths
cached_paths = {item['file_path'] for item in self._cache.raw_data} cached_paths = {item['file_path'] for item in self._cache.raw_data}
path_to_item = {item['file_path']: item for item in self._cache.raw_data} path_to_item = {item['file_path']: item for item in self._cache.raw_data}
@@ -987,6 +1076,10 @@ class ModelScanner:
await asyncio.sleep(0) await asyncio.sleep(0)
if self.is_cancelled(): if self.is_cancelled():
logger.info(f"{self.model_type.capitalize()} Scanner: Reconcile scan cancelled") logger.info(f"{self.model_type.capitalize()} Scanner: Reconcile scan cancelled")
await self._broadcast_scan_progress(
'cancelled', 'reconcile_scan', 0, False,
elapsed_seconds=time.time() - start_time,
)
return return
# Process new files in batches # Process new files in batches
@@ -994,10 +1087,14 @@ class ModelScanner:
if new_files: if new_files:
logger.info(f"{self.model_type.capitalize()} Scanner: Found {len(new_files)} new files to process") logger.info(f"{self.model_type.capitalize()} Scanner: Found {len(new_files)} new files to process")
batch_size = 50 batch_size = 50
for i in range(0, len(new_files), batch_size): total_new = len(new_files)
processed_new = 0
last_progress_time = time.time()
for i in range(0, total_new, batch_size):
batch = new_files[i:i+batch_size] batch = new_files[i:i+batch_size]
for path in batch: for path in batch:
logger.info(f"{self.model_type.capitalize()} Scanner: Processing {path}") logger.info(f"{self.model_type.capitalize()} Scanner: Processing {path}")
processed_new += 1
try: try:
# Find the appropriate root path for this file # Find the appropriate root path for this file
root_path = None root_path = None
@@ -1054,8 +1151,23 @@ class ModelScanner:
except Exception as e: except Exception as e:
logger.error(f"Error adding {path} to cache: {e}") logger.error(f"Error adding {path} to cache: {e}")
current_time = time.time()
if current_time - last_progress_time > 0.5 or processed_new == total_new:
last_progress_time = current_time
await self._broadcast_scan_progress(
'processing', 'process_new',
min(99, int(1 + (processed_new / total_new) * 98)), False,
processed=processed_new, total=total_new,
current_name=os.path.basename(path),
)
if self.is_cancelled(): if self.is_cancelled():
logger.info(f"{self.model_type.capitalize()} Scanner: Reconcile processing cancelled") logger.info(f"{self.model_type.capitalize()} Scanner: Reconcile processing cancelled")
await self._broadcast_scan_progress(
'cancelled', 'process_new',
min(99, int(1 + (processed_new / total_new) * 98)), False,
elapsed_seconds=time.time() - start_time,
)
return return
# Find missing files (in cache but not in filesystem) # Find missing files (in cache but not in filesystem)
@@ -1121,8 +1233,17 @@ class ModelScanner:
await self._persist_current_cache() await self._persist_current_cache()
logger.info(f"{self.model_type.capitalize()} Scanner: Cache reconciliation completed in {time.time() - start_time:.2f} seconds. Added {total_added}, removed {total_removed} models.") logger.info(f"{self.model_type.capitalize()} Scanner: Cache reconciliation completed in {time.time() - start_time:.2f} seconds. Added {total_added}, removed {total_removed} models.")
await self._broadcast_scan_progress(
'completed', 'process_new', 100, False,
added=total_added, removed=total_removed,
elapsed_seconds=time.time() - start_time,
)
except Exception as e: except Exception as e:
logger.error(f"{self.model_type.capitalize()} Scanner: Error reconciling cache: {e}", exc_info=True) logger.error(f"{self.model_type.capitalize()} Scanner: Error reconciling cache: {e}", exc_info=True)
await self._broadcast_scan_progress(
'error', 'reconcile_scan', 0, False,
error=str(e),
)
finally: finally:
self._is_initializing = False # Unset flag self._is_initializing = False # Unset flag
self.bump_cache_version() self.bump_cache_version()
@@ -1498,7 +1619,7 @@ class ModelScanner:
self, self,
*, *,
total_files: int = 0, total_files: int = 0,
progress_callback: Optional[Callable[[int, int], Awaitable[None]]] = None progress_callback: Optional[Callable[[int, int, str], Awaitable[None]]] = None
) -> CacheBuildResult: ) -> CacheBuildResult:
"""Collect metadata for all model files.""" """Collect metadata for all model files."""
@@ -1510,11 +1631,11 @@ class ModelScanner:
processed_real_files: Set[str] = set() processed_real_files: Set[str] = set()
visited_real_dirs: Set[str] = set() visited_real_dirs: Set[str] = set()
async def handle_progress() -> None: async def handle_progress(current_name: str = '') -> None:
if progress_callback is None: if progress_callback is None:
return return
try: try:
await progress_callback(processed_files, total_files) await progress_callback(processed_files, total_files, current_name)
except Exception as exc: # pragma: no cover - defensive logging except Exception as exc: # pragma: no cover - defensive logging
logger.error(f"Error reporting progress for {self.model_type}: {exc}") logger.error(f"Error reporting progress for {self.model_type}: {exc}")
@@ -1580,7 +1701,7 @@ class ModelScanner:
for tag in result.get('tags') or []: for tag in result.get('tags') or []:
tags_count[tag] = tags_count.get(tag, 0) + 1 tags_count[tag] = tags_count.get(tag, 0) + 1
await handle_progress() await handle_progress(entry.name)
await asyncio.sleep(0) await asyncio.sleep(0)
if self.is_cancelled(): if self.is_cancelled():
return return
+102 -4
View File
@@ -1753,7 +1753,36 @@ class RecipeScanner:
# Mark initialization as complete regardless of outcome # Mark initialization as complete regardless of outcome
self._is_initializing = False self._is_initializing = False
def _initialize_recipe_cache_sync(self): async def _broadcast_scan_progress(
self,
status: str,
stage: str,
progress: int,
full_rebuild: bool,
**extra: Any,
) -> None:
"""Broadcast manual-refresh scan progress on the generic WS channel.
Mirrors ``ModelScanner._broadcast_scan_progress`` so the recipes page
can reuse the same frontend contract. Best-effort only: broadcast
failures must never affect the scan itself.
"""
payload: Dict[str, Any] = {
'type': 'scan_progress',
'status': status,
'model_type': 'recipe',
'pageType': 'recipes',
'stage': stage,
'full_rebuild': full_rebuild,
'progress': progress,
}
payload.update(extra)
try:
await ws_manager.broadcast(payload)
except Exception as exc: # pragma: no cover - defensive logging
logger.error(f"Error broadcasting scan progress for recipe: {exc}")
def _initialize_recipe_cache_sync(self, report_progress: bool = False):
"""Synchronous version of recipe cache initialization for thread pool execution. """Synchronous version of recipe cache initialization for thread pool execution.
Uses persistent cache for fast startup when available: Uses persistent cache for fast startup when available:
@@ -1761,8 +1790,14 @@ class RecipeScanner:
2. Reconcile with filesystem (check mtime/size for changes) 2. Reconcile with filesystem (check mtime/size for changes)
3. Fall back to full directory scan if cache miss or reconciliation fails 3. Fall back to full directory scan if cache miss or reconciliation fails
4. Persist results for next startup 4. Persist results for next startup
Args:
report_progress: When True (manual force-refresh only), broadcast
scan_progress messages during the full directory scan. Startup
initialization leaves this False and behaves as before.
""" """
loop = None loop = None
scan_start_time: Optional[float] = None
try: try:
# Ensure cache exists to avoid None reference errors # Ensure cache exists to avoid None reference errors
if self._cache is None: if self._cache is None:
@@ -1844,7 +1879,17 @@ class RecipeScanner:
# Fall back to full directory scan # Fall back to full directory scan
logger.info("Recipe cache miss: performing full directory scan") logger.info("Recipe cache miss: performing full directory scan")
recipes, json_paths = self._full_directory_scan_sync(recipes_dir) if report_progress:
scan_start_time = time.time()
# Broadcast from the worker thread via its own event loop,
# mirroring ModelScanner._initialize_cache_sync.
loop.run_until_complete(
self._broadcast_scan_progress('started', 'scan_folders', 0, True)
)
recipes, json_paths = self._full_directory_scan_sync(
recipes_dir,
progress_loop=loop if report_progress else None,
)
self._json_path_map = json_paths self._json_path_map = json_paths
# Update cache with the collected data # Update cache with the collected data
@@ -1858,12 +1903,30 @@ class RecipeScanner:
recipes, json_paths, self._cache.image_id_map recipes, json_paths, self._cache.image_id_map
) )
if report_progress:
loop.run_until_complete(
self._broadcast_scan_progress(
'completed', 'finalizing', 100, True,
elapsed_seconds=time.time() - (scan_start_time or time.time()),
total=len(recipes),
)
)
return self._cache return self._cache
except Exception as e: except Exception as e:
logger.error(f"Error in thread-based recipe cache initialization: {e}") logger.error(f"Error in thread-based recipe cache initialization: {e}")
import traceback import traceback
traceback.print_exc(file=sys.stderr) traceback.print_exc(file=sys.stderr)
if report_progress and loop is not None:
try:
loop.run_until_complete(
self._broadcast_scan_progress(
'error', 'process_models', 0, True, error=str(e)
)
)
except Exception: # pragma: no cover - defensive logging
logger.error("Error broadcasting recipe scan failure", exc_info=True)
return self._cache if hasattr(self, "_cache") else None return self._cache if hasattr(self, "_cache") else None
finally: finally:
# Clean up the event loop # Clean up the event loop
@@ -2017,12 +2080,16 @@ class RecipeScanner:
return updated return updated
def _full_directory_scan_sync( def _full_directory_scan_sync(
self, recipes_dir: str self,
recipes_dir: str,
progress_loop: Optional[asyncio.AbstractEventLoop] = None,
) -> Tuple[List[Dict[str, Any]], Dict[str, str]]: ) -> Tuple[List[Dict[str, Any]], Dict[str, str]]:
"""Perform a full synchronous directory scan for recipes. """Perform a full synchronous directory scan for recipes.
Args: Args:
recipes_dir: Path to the recipes directory. recipes_dir: Path to the recipes directory.
progress_loop: When set (manual force-refresh only), broadcast
scan_progress messages through this thread-local event loop.
Returns: Returns:
Tuple of (recipes list, json_paths dict). Tuple of (recipes list, json_paths dict).
@@ -2037,6 +2104,17 @@ class RecipeScanner:
if file.lower().endswith(".recipe.json"): if file.lower().endswith(".recipe.json"):
recipe_files.append(os.path.join(root, file)) recipe_files.append(os.path.join(root, file))
total_files = len(recipe_files)
if progress_loop is not None:
progress_loop.run_until_complete(
self._broadcast_scan_progress(
'processing', 'count_models', 1, True,
processed=0, total=total_files,
)
)
last_progress_time = time.time()
# Process each recipe file # Process each recipe file
for i, recipe_path in enumerate(recipe_files): for i, recipe_path in enumerate(recipe_files):
recipe_data = self._load_recipe_file_sync(recipe_path) recipe_data = self._load_recipe_file_sync(recipe_path)
@@ -2044,6 +2122,23 @@ class RecipeScanner:
recipe_id = str(recipe_data.get("id", "")) recipe_id = str(recipe_data.get("id", ""))
recipes.append(recipe_data) recipes.append(recipe_data)
json_paths[recipe_id] = recipe_path json_paths[recipe_id] = recipe_path
if progress_loop is not None and total_files > 0:
processed = i + 1
current_time = time.time()
# Throttle to one update per 0.5s; always send the final one.
if (
processed == total_files
or current_time - last_progress_time > 0.5
):
last_progress_time = current_time
progress_percent = min(99, int(1 + (processed / total_files) * 98))
progress_loop.run_until_complete(
self._broadcast_scan_progress(
'processing', 'process_models', progress_percent, True,
processed=processed, total=total_files,
current_name=os.path.basename(recipe_path),
)
)
# Periodically release GIL so the event loop thread can run # Periodically release GIL so the event loop thread can run
if i % 100 == 0: if i % 100 == 0:
time.sleep(0) time.sleep(0)
@@ -2613,11 +2708,14 @@ class RecipeScanner:
start_time = time.time() start_time = time.time()
# Run the heavy lifting in a thread pool same path # Run the heavy lifting in a thread pool same path
# used by initialize_in_background(). # used by initialize_in_background(). Pass
# report_progress=True so manual refreshes broadcast
# scan_progress updates; startup init keeps it off.
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
cache = await loop.run_in_executor( cache = await loop.run_in_executor(
None, None,
self._initialize_recipe_cache_sync, self._initialize_recipe_cache_sync,
True,
) )
if cache is not None: if cache is not None:
self._cache = cache self._cache = cache
+8 -3
View File
@@ -31,9 +31,14 @@ body {
--header-height: 48px; --header-height: 48px;
--scrollbar-width: 8px; --scrollbar-width: 8px;
--shortcut-bg: var(--color-accent-subtle); /* Neutral "keycap" style for keyboard shortcut hints (GitHub/Linear-like).
--shortcut-border: var(--color-accent-border); Derived from --text-muted so it adapts to every theme/preset. */
--shortcut-text: var(--text-primary); --shortcut-bg: color-mix(in oklch, var(--text-muted) 10%, transparent);
--shortcut-bg-hover: color-mix(in oklch, var(--text-muted) 16%, transparent);
--shortcut-border: color-mix(in oklch, var(--text-muted) 30%, transparent);
--shortcut-border-hover: color-mix(in oklch, var(--text-muted) 45%, transparent);
--shortcut-text: var(--text-muted);
--shortcut-shadow: 0 1.5px 0 color-mix(in oklch, var(--text-muted) 30%, transparent);
--lora-accent-transparent: var(--color-accent-transparent); --lora-accent-transparent: var(--color-accent-transparent);
+4 -4
View File
@@ -249,10 +249,10 @@
font-family: inherit; font-family: inherit;
font-size: 0.68rem; font-size: 0.68rem;
font-weight: 500; font-weight: 500;
color: var(--text-muted); color: var(--shortcut-text);
/* Subtle tint derived from text color so it adapts to both light & dark themes */ background: var(--shortcut-bg);
background: color-mix(in oklch, var(--text-muted) 12%, transparent); border: 1px solid var(--shortcut-border);
border: 1px solid color-mix(in oklch, var(--text-muted) 25%, transparent); box-shadow: var(--shortcut-shadow);
border-radius: var(--border-radius-xs, 3px); border-radius: var(--border-radius-xs, 3px);
line-height: 1; line-height: 1;
} }
@@ -66,3 +66,12 @@
.add-preset-btn:hover { .add-preset-btn:hover {
opacity: 0.9; opacity: 0.9;
} }
.add-preset-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.add-preset-btn:hover:disabled {
opacity: 0.5;
}
@@ -115,6 +115,9 @@
position: relative; position: relative;
overflow: hidden; overflow: hidden;
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
/* Horizontal touch pans are claimed for swipe navigation (ShowcaseView);
vertical pans still scroll the modal */
touch-action: pan-y;
} }
.main-media-container { .main-media-container {
@@ -134,6 +137,32 @@
margin-bottom: 0; margin-bottom: 0;
} }
/* Direction-aware slide on example switches (set by updateMainDisplay) */
.main-media-container.slide-from-right .media-wrapper {
animation: gallery-slide-from-right 0.25s ease;
}
.main-media-container.slide-from-left .media-wrapper {
animation: gallery-slide-from-left 0.25s ease;
}
@keyframes gallery-slide-from-right {
from { transform: translateX(32px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes gallery-slide-from-left {
from { transform: translateX(-32px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.main-media-container.slide-from-right .media-wrapper,
.main-media-container.slide-from-left .media-wrapper {
animation: none;
}
}
.main-media-container .media-wrapper img, .main-media-container .media-wrapper img,
.main-media-container .media-wrapper video { .main-media-container .media-wrapper video {
position: absolute; position: absolute;
+10
View File
@@ -13,6 +13,16 @@
overflow: auto; /* Change from hidden to auto to allow scrolling */ overflow: auto; /* Change from hidden to auto to allow scrolling */
} }
/* Software-rendering fallback (set by applyModalBackdropBlurPolicy): a
full-viewport backdrop-filter forces per-frame CPU rasterization of
everything behind the modal and freezes the browser (issue #1092) */
html.no-modal-backdrop-blur .modal,
html.no-modal-backdrop-blur .delete-modal,
html.no-modal-backdrop-blur .batch-preview-select-all {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
/* Prevent body scroll when modal is open */ /* Prevent body scroll when modal is open */
body.modal-open { body.modal-open {
position: fixed; position: fixed;
@@ -921,8 +921,8 @@
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 1; z-index: 1;
backdrop-filter: blur(8px); backdrop-filter: blur(var(--modal-backdrop-blur, 6px));
-webkit-backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(var(--modal-backdrop-blur, 6px));
} }
.batch-preview-select-all input[type="checkbox"] { .batch-preview-select-all input[type="checkbox"] {
+106
View File
@@ -167,6 +167,29 @@
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
} }
/* Replay Tutorial button: badge hidden until the button is flagged as new content */
.replay-tutorial-btn .new-content-badge {
display: none;
background-color: rgba(255, 255, 255, 0.22);
color: #fff;
box-shadow: none;
margin-left: 2px;
}
.replay-tutorial-btn.has-new-content .new-content-badge {
display: inline-flex;
}
/* One-time attention pulse when the button is flagged as new content */
@keyframes new-content-glow {
0% { box-shadow: 0 0 0 0 oklch(from var(--lora-accent) l c h / 55%); }
100% { box-shadow: 0 0 0 16px transparent; }
}
.replay-tutorial-btn.has-new-content {
animation: new-content-glow 1.2s ease-out 3;
}
/* Update video list styles */ /* Update video list styles */
.video-list { .video-list {
display: flex; display: flex;
@@ -305,3 +328,86 @@
[data-theme="dark"] .video-container { [data-theme="dark"] .video-container {
background-color: var(--surface-hover); background-color: var(--surface-hover);
} }
/* Replay tutorial button styles */
.help-actions {
margin-top: var(--space-3);
}
.replay-tutorial-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-radius: var(--border-radius-sm);
font-weight: 500;
cursor: pointer;
transition: var(--transition-base);
background-color: var(--lora-accent);
color: white;
border: none;
}
.replay-tutorial-btn:hover {
background-color: oklch(from var(--lora-accent) l c h / 85%);
}
/* Shortcuts tab styles */
.shortcuts-section {
margin-bottom: var(--space-3);
}
.shortcuts-section h4 {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: var(--space-1);
}
.shortcuts-list {
list-style-type: none;
padding-left: var(--space-3);
}
.shortcuts-list li {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: var(--space-1);
}
.shortcut-keys {
display: inline-flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
min-width: 150px;
}
.shortcut-sep {
color: var(--text-muted);
font-size: 0.75rem;
margin: 0 1px;
}
.shortcuts-list kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 4px;
font-family: inherit;
font-size: 0.68rem;
font-weight: 500;
color: var(--shortcut-text);
background: var(--shortcut-bg);
border: 1px solid var(--shortcut-border);
box-shadow: var(--shortcut-shadow);
border-radius: var(--border-radius-xs, 3px);
line-height: 1;
}
.shortcut-description {
font-size: 0.9em;
opacity: 0.85;
}
+7 -9
View File
@@ -202,15 +202,17 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-left: 6px; margin-left: 6px;
min-width: 16px; min-width: 18px;
height: 16px; height: 18px;
padding: 0 3px; padding: 0 5px;
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
line-height: 1; line-height: 1;
text-transform: uppercase;
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
background-color: var(--shortcut-bg); background-color: var(--shortcut-bg);
border: 1px solid var(--shortcut-border); border: 1px solid var(--shortcut-border);
box-shadow: var(--shortcut-shadow);
color: var(--shortcut-text); color: var(--shortcut-text);
vertical-align: middle; vertical-align: middle;
opacity: 0.8; opacity: 0.8;
@@ -219,12 +221,8 @@
.control-group button:hover .shortcut-key { .control-group button:hover .shortcut-key {
opacity: 1; opacity: 1;
background-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.2); background-color: var(--shortcut-bg-hover);
} border-color: var(--shortcut-border-hover);
[data-theme="dark"] .shortcut-key {
--shortcut-bg: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.15);
--shortcut-border: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.3);
} }
/* Ensure correct vertical alignment for text+shortcut */ /* Ensure correct vertical alignment for text+shortcut */
+1
View File
@@ -205,6 +205,7 @@
display: inline-block; display: inline-block;
background: var(--shortcut-bg); background: var(--shortcut-bg);
border: 1px solid var(--shortcut-border); border: 1px solid var(--shortcut-border);
box-shadow: var(--shortcut-shadow);
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
padding: 2px 6px; padding: 2px 6px;
font-size: 0.8em; font-size: 0.8em;
+108 -7
View File
@@ -12,6 +12,11 @@ import {
} from './apiConfig.js'; } from './apiConfig.js';
import { resetAndReload } from './modelApiFactory.js'; import { resetAndReload } from './modelApiFactory.js';
import { sidebarManager } from '../components/SidebarManager.js'; import { sidebarManager } from '../components/SidebarManager.js';
// Shared scan ETA helpers live in a dependency-light module so pages that do
// not use BaseModelApiClient (e.g. recipes) can reuse them without pulling
// this module's import cycle (modelApiFactory -> loraApi -> baseModelApi).
import { createScanEtaTracker, formatScanRemainingTime } from '../utils/scanEtaUtils.js';
export { createScanEtaTracker, formatScanRemainingTime };
/** /**
* Abstract base class for all model API clients * Abstract base class for all model API clients
@@ -507,23 +512,67 @@ export class BaseModelApiClient {
async refreshModels(fullRebuild = false) { async refreshModels(fullRebuild = false) {
const abortController = new AbortController(); const abortController = new AbortController();
try { const displayName = this.apiConfig.config.displayName;
state.loadingManager.show( const singularName = this.apiConfig.config.singularName;
`${fullRebuild ? 'Full rebuild' : 'Refreshing'} ${this.apiConfig.config.displayName}s...`, const actionText = translate(
0 fullRebuild ? 'common.scanProgress.actionFullRebuild' : 'common.scanProgress.actionRefresh',
{},
fullRebuild ? 'Full rebuild' : 'Refresh'
); );
const actionLowerText = translate(
fullRebuild ? 'common.scanProgress.actionRebuildLower' : 'common.scanProgress.actionRefreshLower',
{},
fullRebuild ? 'rebuild' : 'refresh'
);
const initialMessage = translate(
fullRebuild ? 'common.scanProgress.fullRebuilding' : 'common.scanProgress.refreshing',
{ type: displayName },
`${fullRebuild ? 'Full rebuild' : 'Refreshing'} ${displayName}s...`
);
const etaTracker = createScanEtaTracker();
let ws = null;
const handleScanProgress = (data) => {
if (typeof data.progress === 'number') {
state.loadingManager.setProgress(data.progress);
}
let statusText = translate(
`common.scanProgress.stages.${data.stage}`,
{ total: data.total },
data.stage || ''
);
if (data.status === 'processing' && data.total > 0) {
statusText += ` (${data.processed}/${data.total})`;
if (data.current_name) {
statusText += ` ${data.current_name}`;
}
const etaText = etaTracker.update(data.processed, data.total);
if (etaText) {
statusText += ` | ${etaText}`;
}
}
state.loadingManager.setStatus(statusText);
};
try {
state.loadingManager.show(initialMessage, 0);
state.loadingManager.showCancelButton(() => { state.loadingManager.showCancelButton(() => {
this.cancelTask(); this.cancelTask();
abortController.abort(); abortController.abort();
}); });
// Connect to the shared progress channel for live scan updates.
// Failure to connect must not block the refresh itself — fall back
// to the plain loading indicator.
ws = await this._connectScanProgressSocket(handleScanProgress, singularName);
const url = new URL(this.apiConfig.endpoints.scan, window.location.origin); const url = new URL(this.apiConfig.endpoints.scan, window.location.origin);
url.searchParams.append('full_rebuild', fullRebuild); url.searchParams.append('full_rebuild', fullRebuild);
const response = await fetch(url, { signal: abortController.signal }); const response = await fetch(url, { signal: abortController.signal });
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to refresh ${this.apiConfig.config.displayName}s: ${response.status} ${response.statusText}`); throw new Error(`Failed to refresh ${displayName}s: ${response.status} ${response.statusText}`);
} }
const data = await response.json(); const data = await response.json();
@@ -534,20 +583,69 @@ export class BaseModelApiClient {
resetAndReload(true); resetAndReload(true);
showToast('toast.api.refreshComplete', { action: fullRebuild ? 'Full rebuild' : 'Refresh' }, 'success'); showToast('toast.api.refreshComplete', { action: actionText }, 'success');
} catch (error) { } catch (error) {
if (error.name === 'AbortError') { if (error.name === 'AbortError') {
showToast('toast.api.operationCancelled', {}, 'info'); showToast('toast.api.operationCancelled', {}, 'info');
return; return;
} }
console.error('Refresh failed:', error); console.error('Refresh failed:', error);
showToast('toast.api.refreshFailed', { action: fullRebuild ? 'rebuild' : 'refresh', type: this.apiConfig.config.displayName }, 'error'); showToast('toast.api.refreshFailed', { action: actionLowerText, type: displayName }, 'error');
} finally { } finally {
if (ws) {
ws.close();
}
state.loadingManager.hide(); state.loadingManager.hide();
state.loadingManager.restoreProgressBar(); state.loadingManager.restoreProgressBar();
} }
} }
/**
* Connect to the shared fetch-progress WebSocket for scan progress updates.
* Returns null when the connection cannot be established (silent fallback).
* @param {Function} onScanProgress - Handler for scan_progress messages
* @param {string} singularName - Model type filter (e.g. 'lora')
* @returns {Promise<WebSocket|null>}
*/
async _connectScanProgressSocket(onScanProgress, singularName) {
let socket = null;
try {
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
socket = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`);
await new Promise((resolve, reject) => {
socket.onopen = resolve;
socket.onerror = reject;
});
socket.onmessage = (event) => {
let data;
try {
data = JSON.parse(event.data);
} catch (parseError) {
return;
}
// Only handle scan progress for this client's model type;
// other operations share this channel and must be ignored.
if (data.type !== 'scan_progress' || data.model_type !== singularName) {
return;
}
onScanProgress(data);
};
return socket;
} catch (error) {
if (socket) {
try {
socket.close();
} catch (closeError) {
// Ignore close errors during fallback
}
}
return null;
}
}
async refreshSingleModelMetadata(filePath) { async refreshSingleModelMetadata(filePath) {
try { try {
state.loadingManager.showSimpleLoading('Refreshing metadata...'); state.loadingManager.showSimpleLoading('Refreshing metadata...');
@@ -605,6 +703,9 @@ export class BaseModelApiClient {
ws.onmessage = (event) => { ws.onmessage = (event) => {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
// Scan progress shares this channel; it is handled by refreshModels
if (data.type === 'scan_progress') return;
switch (data.status) { switch (data.status) {
case 'started': case 'started':
loading.setStatus('Starting metadata fetch...'); loading.setStatus('Starting metadata fetch...');
+100 -5
View File
@@ -1,7 +1,12 @@
import { RecipeCard } from '../components/RecipeCard.js'; import { RecipeCard } from '../components/RecipeCard.js';
import { state, getCurrentPageState } from '../state/index.js'; import { state, getCurrentPageState } from '../state/index.js';
import { showToast } from '../utils/uiHelpers.js'; import { showToast } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js';
import { captureScrollPosition, restoreScrollPosition } from '../utils/infiniteScroll.js'; import { captureScrollPosition, restoreScrollPosition } from '../utils/infiniteScroll.js';
import { WS_ENDPOINTS } from './apiConfig.js';
// Import from the dependency-light utils module, not baseModelApi.js, to
// avoid the baseModelApi <-> modelApiFactory import cycle on this page.
import { createScanEtaTracker } from '../utils/scanEtaUtils.js';
const RECIPE_ENDPOINTS = { const RECIPE_ENDPOINTS = {
list: '/api/lm/recipes', list: '/api/lm/recipes',
@@ -333,11 +338,53 @@ export async function syncChanges() {
} }
export async function refreshRecipes(fullRebuild = true) { export async function refreshRecipes(fullRebuild = true) {
const actionLabel = fullRebuild ? 'Rebuilding recipe cache' : 'Refreshing recipes'; const actionText = translate(
const actionToast = fullRebuild ? 'Full rebuild' : 'Refresh'; fullRebuild ? 'common.scanProgress.actionFullRebuild' : 'common.scanProgress.actionRefresh',
{},
fullRebuild ? 'Full rebuild' : 'Refresh'
);
const actionLowerText = translate(
fullRebuild ? 'common.scanProgress.actionRebuildLower' : 'common.scanProgress.actionRefreshLower',
{},
fullRebuild ? 'rebuild' : 'refresh'
);
const initialMessage = translate(
fullRebuild ? 'common.scanProgress.fullRebuilding' : 'common.scanProgress.refreshing',
{ type: RECIPE_SIDEBAR_CONFIG.config.displayName },
`${fullRebuild ? 'Full rebuild' : 'Refreshing'} Recipes...`
);
const etaTracker = createScanEtaTracker();
let ws = null;
const handleScanProgress = (data) => {
if (typeof data.progress === 'number') {
state.loadingManager.setProgress(data.progress);
}
let statusText = translate(
`common.scanProgress.stages.${data.stage}`,
{ total: data.total },
data.stage || ''
);
if (data.status === 'processing' && data.total > 0) {
statusText += ` (${data.processed}/${data.total})`;
if (data.current_name) {
statusText += ` ${data.current_name}`;
}
const etaText = etaTracker.update(data.processed, data.total);
if (etaText) {
statusText += ` | ${etaText}`;
}
}
state.loadingManager.setStatus(statusText);
};
try { try {
state.loadingManager.show(`${actionLabel}...`, 0); state.loadingManager.show(initialMessage, 0);
// Connect to the shared progress channel for live scan updates.
// Failure to connect must not block the refresh itself — fall back
// to the plain loading indicator.
ws = await connectScanProgressSocket(handleScanProgress);
const url = new URL(RECIPE_ENDPOINTS.scan, window.location.origin); const url = new URL(RECIPE_ENDPOINTS.scan, window.location.origin);
url.searchParams.append('full_rebuild', fullRebuild); url.searchParams.append('full_rebuild', fullRebuild);
@@ -356,16 +403,64 @@ export async function refreshRecipes(fullRebuild = true) {
await resetAndReload(false); await resetAndReload(false);
showToast('toast.api.refreshComplete', { action: actionToast }, 'success'); showToast('toast.api.refreshComplete', { action: actionText }, 'success');
} catch (error) { } catch (error) {
console.error('Error refreshing recipes:', error); console.error('Error refreshing recipes:', error);
showToast('toast.api.refreshFailed', { action: fullRebuild ? 'rebuild' : 'refresh', type: 'recipe' }, 'error'); showToast('toast.api.refreshFailed', { action: actionLowerText, type: 'recipe' }, 'error');
} finally { } finally {
if (ws) {
ws.close();
}
state.loadingManager.hide(); state.loadingManager.hide();
state.loadingManager.restoreProgressBar(); state.loadingManager.restoreProgressBar();
} }
} }
/**
* Connect to the shared fetch-progress WebSocket for recipe scan progress.
* Returns null when the connection cannot be established (silent fallback).
* @param {Function} onScanProgress - Handler for scan_progress messages
* @returns {Promise<WebSocket|null>}
*/
async function connectScanProgressSocket(onScanProgress) {
let socket = null;
try {
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
socket = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`);
await new Promise((resolve, reject) => {
socket.onopen = resolve;
socket.onerror = reject;
});
socket.onmessage = (event) => {
let data;
try {
data = JSON.parse(event.data);
} catch (parseError) {
return;
}
// Only handle recipe scan progress; other operations share this
// channel and must be ignored.
if (data.type !== 'scan_progress' || data.model_type !== 'recipe') {
return;
}
onScanProgress(data);
};
return socket;
} catch (error) {
if (socket) {
try {
socket.close();
} catch (closeError) {
// Ignore close errors during fallback
}
}
return null;
}
}
/** /**
* Load more recipes with pagination - updated to work with VirtualScroller * Load more recipes with pagination - updated to work with VirtualScroller
* @param {boolean} resetPage - Whether to reset to the first page * @param {boolean} resetPage - Whether to reset to the first page
+4
View File
@@ -3,6 +3,7 @@ import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } fr
import { createPageControls } from './components/controls/index.js'; import { createPageControls } from './components/controls/index.js';
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js'; import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
import { MODEL_TYPES } from './api/apiConfig.js'; import { MODEL_TYPES } from './api/apiConfig.js';
import { initActiveFiltersSync } from './utils/activeFiltersSync.js';
// Initialize the Checkpoints page // Initialize the Checkpoints page
export class CheckpointsPageManager { export class CheckpointsPageManager {
@@ -32,6 +33,9 @@ export class CheckpointsPageManager {
// Initialize common page features (including context menus) // Initialize common page features (including context menus)
appCore.initializePageFeatures(); appCore.initializePageFeatures();
// Mirror active filters to the backend for the ComfyUI-side autocomplete
initActiveFiltersSync(MODEL_TYPES.CHECKPOINT);
console.log('Checkpoints Manager initialized'); console.log('Checkpoints Manager initialized');
} }
} }
+76 -12
View File
@@ -128,6 +128,9 @@ class RecipeModal {
this.recipeHydrationRequestId = 0; this.recipeHydrationRequestId = 0;
this.navigationKeyHandler = null; this.navigationKeyHandler = null;
this.navigationInProgress = false; this.navigationInProgress = false;
this._disposed = false;
this._deferredTimerIds = new Set();
this._documentClickHandler = null;
this.resetLocalEditState(); this.resetLocalEditState();
this.init(); this.init();
} }
@@ -197,7 +200,7 @@ class RecipeModal {
this.setupDeleteControl(); this.setupDeleteControl();
// Set up document click handler to close edit fields // Set up document click handler to close edit fields
document.addEventListener('click', (event) => { this._documentClickHandler = (event) => {
const recipeModal = document.getElementById('recipeModal'); const recipeModal = document.getElementById('recipeModal');
if (recipeModal && recipeModal.style.display !== 'none') { if (recipeModal && recipeModal.style.display !== 'none') {
const mediaEl = event.target.closest('.recipe-preview-media'); const mediaEl = event.target.closest('.recipe-preview-media');
@@ -235,7 +238,8 @@ class RecipeModal {
this.hideReconnectInput(container); this.hideReconnectInput(container);
} }
}); });
}); };
document.addEventListener('click', this._documentClickHandler);
} }
setupNavigationControls() { setupNavigationControls() {
@@ -299,6 +303,39 @@ class RecipeModal {
this._destroyAllReconnectComboboxes(); this._destroyAllReconnectComboboxes();
} }
/**
* Run a callback on a tracked timer so it can be cancelled when the
* modal is disposed. Used for render/wiring work that must never touch
* the DOM after the modal has been torn down (test teardown, close).
*/
_scheduleDeferred(fn, delay) {
const timerId = setTimeout(() => {
this._deferredTimerIds.delete(timerId);
fn();
}, delay);
this._deferredTimerIds.add(timerId);
return timerId;
}
/**
* Tear the modal down: mark it disposed (in-flight async chains and any
* later render/wiring become no-ops), cancel pending deferred work, and
* detach global listeners. Safe to call multiple times.
*/
dispose() {
if (this._disposed) {
return;
}
this._disposed = true;
this._deferredTimerIds.forEach(timerId => clearTimeout(timerId));
this._deferredTimerIds.clear();
if (this._documentClickHandler) {
document.removeEventListener('click', this._documentClickHandler);
this._documentClickHandler = null;
}
this.cleanupNavigationShortcuts();
}
setupNavigationShortcuts() { setupNavigationShortcuts() {
const modalElement = document.getElementById('recipeModal'); const modalElement = document.getElementById('recipeModal');
if (!modalElement) return; if (!modalElement) return;
@@ -353,6 +390,9 @@ class RecipeModal {
} }
showRecipeDetails(recipe) { showRecipeDetails(recipe) {
if (this._disposed) {
return;
}
const hydratedRecipe = recipe || {}; const hydratedRecipe = recipe || {};
this.resetLocalEditState(); this.resetLocalEditState();
// Store the full recipe for editing // Store the full recipe for editing
@@ -443,7 +483,7 @@ class RecipeModal {
// Delay binding slightly so modal layout is stable, but skip if this render was torn down. // Delay binding slightly so modal layout is stable, but skip if this render was torn down.
const sourceUrlContainerRef = sourceUrlContainer; const sourceUrlContainerRef = sourceUrlContainer;
const sourceUrlEditorRef = sourceUrlEditor; const sourceUrlEditorRef = sourceUrlEditor;
setTimeout(() => { this._scheduleDeferred(() => {
if (!document.body.contains(sourceUrlContainerRef) || !document.body.contains(sourceUrlEditorRef)) { if (!document.body.contains(sourceUrlContainerRef) || !document.body.contains(sourceUrlEditorRef)) {
return; return;
} }
@@ -556,7 +596,7 @@ class RecipeModal {
async hydrateRecipeDetails(recipeId, requestId, requestEditVersions = {}) { async hydrateRecipeDetails(recipeId, requestId, requestEditVersions = {}) {
try { try {
const fullRecipe = await fetchRecipeDetails(recipeId); const fullRecipe = await fetchRecipeDetails(recipeId);
if (requestId !== this.recipeHydrationRequestId || !fullRecipe) { if (this._disposed || requestId !== this.recipeHydrationRequestId || !fullRecipe) {
return; return;
} }
@@ -952,6 +992,9 @@ class RecipeModal {
} }
syncResourcesSection(recipe = {}) { syncResourcesSection(recipe = {}) {
if (this._disposed) {
return;
}
const checkpointContainer = document.getElementById('recipeCheckpoint'); const checkpointContainer = document.getElementById('recipeCheckpoint');
const resourceDivider = document.getElementById('recipeResourceDivider'); const resourceDivider = document.getElementById('recipeResourceDivider');
const lorasListElement = document.getElementById('recipeLorasList'); const lorasListElement = document.getElementById('recipeLorasList');
@@ -1016,7 +1059,7 @@ class RecipeModal {
missingStatus.addEventListener('click', () => this.showDownloadMissingLorasModal()); missingStatus.addEventListener('click', () => this.showDownloadMissingLorasModal());
} }
setTimeout(() => { this._scheduleDeferred(() => {
const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn'); const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn');
if (viewRecipeLorasBtn) { if (viewRecipeLorasBtn) {
viewRecipeLorasBtn.addEventListener('click', () => this.navigateToLorasPage()); viewRecipeLorasBtn.addEventListener('click', () => this.navigateToLorasPage());
@@ -1157,7 +1200,7 @@ class RecipeModal {
`; `;
}).join(''); }).join('');
setTimeout(() => { this._scheduleDeferred(() => {
this.setupReconnectButtons(); this.setupReconnectButtons();
this.setupLoraItemActions(); this.setupLoraItemActions();
this.setupLoraItemsClickable(); this.setupLoraItemsClickable();
@@ -2113,13 +2156,13 @@ class RecipeModal {
try { try {
const suggestions = await this._fetchReconnectSuggestions(loraIndex); const suggestions = await this._fetchReconnectSuggestions(loraIndex);
// Stale guard: panel closed or another item opened while fetching // Stale guard: panel closed or another item opened while fetching
if (token !== this._reconnectSuggestionsToken || !container.classList.contains('active')) { if (this._disposed || token !== this._reconnectSuggestionsToken || !container.classList.contains('active')) {
return; return;
} }
this._renderReconnectSuggestions(container, suggestions, loraIndex); this._renderReconnectSuggestions(container, suggestions, loraIndex);
} catch (error) { } catch (error) {
console.error('Error fetching reconnect suggestions:', error); console.error('Error fetching reconnect suggestions:', error);
if (token !== this._reconnectSuggestionsToken || !container.classList.contains('active')) { if (this._disposed || token !== this._reconnectSuggestionsToken || !container.classList.contains('active')) {
return; return;
} }
this._renderReconnectSuggestions(container, [], loraIndex); this._renderReconnectSuggestions(container, [], loraIndex);
@@ -2231,6 +2274,9 @@ class RecipeModal {
}); });
const result = await response.json(); const result = await response.json();
if (this._disposed) {
return;
}
if (result.success) { if (result.success) {
// Hide the reconnect input // Hide the reconnect input
@@ -2256,7 +2302,7 @@ class RecipeModal {
} }
// Refresh modal to show updated content // Refresh modal to show updated content
setTimeout(() => { this._scheduleDeferred(() => {
this.showRecipeDetails(this.currentRecipe); this.showRecipeDetails(this.currentRecipe);
}, 500); }, 500);
@@ -2290,6 +2336,9 @@ class RecipeModal {
}); });
const result = await response.json(); const result = await response.json();
if (this._disposed) {
return;
}
if (result.success) { if (result.success) {
// Swap the entry back to its pre-reconnect state // Swap the entry back to its pre-reconnect state
@@ -2297,7 +2346,7 @@ class RecipeModal {
showToast('toast.recipes.loraRestored', {}, 'success'); showToast('toast.recipes.loraRestored', {}, 'success');
setTimeout(() => { this._scheduleDeferred(() => {
this.showRecipeDetails(this.currentRecipe); this.showRecipeDetails(this.currentRecipe);
}, 500); }, 500);
@@ -2342,6 +2391,9 @@ class RecipeModal {
}); });
const result = await response.json(); const result = await response.json();
if (this._disposed) {
return;
}
if (result.success) { if (result.success) {
// Hide the reconnect input // Hide the reconnect input
@@ -2367,7 +2419,7 @@ class RecipeModal {
} }
// Refresh modal to show updated content // Refresh modal to show updated content
setTimeout(() => { this._scheduleDeferred(() => {
this.showRecipeDetails(this.currentRecipe); this.showRecipeDetails(this.currentRecipe);
}, 500); }, 500);
@@ -2400,6 +2452,9 @@ class RecipeModal {
}); });
const result = await response.json(); const result = await response.json();
if (this._disposed) {
return;
}
if (result.success) { if (result.success) {
// Swap the entry back to its pre-reconnect state // Swap the entry back to its pre-reconnect state
@@ -2407,7 +2462,7 @@ class RecipeModal {
showToast('toast.recipes.checkpointRestored', {}, 'success'); showToast('toast.recipes.checkpointRestored', {}, 'success');
setTimeout(() => { this._scheduleDeferred(() => {
this.showRecipeDetails(this.currentRecipe); this.showRecipeDetails(this.currentRecipe);
}, 500); }, 500);
@@ -2442,6 +2497,9 @@ class RecipeModal {
recipe_id: recipeId, recipe_id: recipeId,
}), }),
}); });
if (this._disposed) {
return;
}
if (this.currentRecipe?.checkpoint) { if (this.currentRecipe?.checkpoint) {
this.currentRecipe.checkpoint.hashInvalid = true; this.currentRecipe.checkpoint.hashInvalid = true;
this.syncResourcesSection(this.currentRecipe); this.syncResourcesSection(this.currentRecipe);
@@ -2975,6 +3033,9 @@ class RecipeModal {
return; return;
} }
const updated = await fetchRecipeDetails(recipeId); const updated = await fetchRecipeDetails(recipeId);
if (this._disposed) {
return;
}
if (!updated) { if (!updated) {
return; return;
} }
@@ -3083,6 +3144,9 @@ class RecipeModal {
lora_index: loraIndex, lora_index: loraIndex,
}), }),
}); });
if (this._disposed) {
return;
}
if (this.currentRecipe?.loras?.[loraIndex]) { if (this.currentRecipe?.loras?.[loraIndex]) {
this.currentRecipe.loras[loraIndex].hashInvalid = true; this.currentRecipe.loras[loraIndex].hashInvalid = true;
this.syncResourcesSection(this.currentRecipe); this.syncResourcesSection(this.currentRecipe);
+58 -1
View File
@@ -1,7 +1,8 @@
// PageControls.js - Manages controls for both LoRAs and Checkpoints pages // PageControls.js - Manages controls for both LoRAs and Checkpoints pages
import { state, getCurrentPageState, setCurrentPageType } from '../../state/index.js'; import { state, getCurrentPageState, setCurrentPageType } from '../../state/index.js';
import { getStorageItem, setStorageItem, removeStorageItem, getSessionItem, setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js'; import { getStorageItem, setStorageItem, removeStorageItem, getSessionItem, setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
import { showToast, openCivitaiByMetadata } from '../../utils/uiHelpers.js'; import { showToast, openCivitaiByMetadata, isTypingContext } from '../../utils/uiHelpers.js';
import { eventManager } from '../../utils/EventManager.js';
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js'; import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
import { sidebarManager } from '../SidebarManager.js'; import { sidebarManager } from '../SidebarManager.js';
import { initSortDropdown, applySortToSelect, randomizeSortValue } from './SortDropdown.js'; import { initSortDropdown, applySortToSelect, randomizeSortValue } from './SortDropdown.js';
@@ -146,6 +147,62 @@ export class PageControls {
// Page-specific event listeners // Page-specific event listeners
this.initPageSpecificListeners(); this.initPageSpecificListeners();
// Keyboard shortcuts for the actions toolbar (R / F / D)
this.registerKeyboardShortcuts();
}
/**
* Register keyboard shortcuts for the actions toolbar buttons
* (R = refresh, F = fetch metadata, D = download)
*/
registerKeyboardShortcuts() {
eventManager.addHandler('keydown', 'pageControls-actions', (e) => {
return this.handleActionShortcut(e);
}, {
priority: 90,
skipWhenModalOpen: true
});
}
/**
* Handle a keydown event for the actions toolbar shortcuts
* @param {KeyboardEvent} e
* @returns {boolean} True when the event was handled and propagation should stop
*/
handleActionShortcut(e) {
// Plain letters only — leave modified combos (Ctrl/Cmd/Alt) alone
if (e.ctrlKey || e.metaKey || e.altKey) {
return false;
}
// Don't hijack keys while typing in a text entry context
if (isTypingContext(e.target)) {
return false;
}
const actionByKey = {
r: 'refresh',
f: 'fetch',
d: 'download'
};
const action = actionByKey[e.key.toLowerCase()];
if (!action) {
return false;
}
// The button may not exist on this page (e.g. recipes has no
// fetch/download) — let other handlers run in that case
const button = document.querySelector(`[data-action="${action}"]`);
if (!button) {
return false;
}
e.preventDefault();
// Native disabled buttons ignore .click(), so an in-progress
// refresh is safe
button.click();
return true;
} }
initExcludedViewControls() { initExcludedViewControls() {
+2
View File
@@ -607,9 +607,11 @@ export function createModelCard(model, modelType) {
sendTitle = translate('modelCard.actions.sendToWorkflow', {}, 'Send to ComfyUI (Click: Append, Shift+Click: Replace)'); sendTitle = translate('modelCard.actions.sendToWorkflow', {}, 'Send to ComfyUI (Click: Append, Shift+Click: Replace)');
copyTitle = translate('modelCard.actions.copyLoRASyntax', {}, 'Copy LoRA Syntax'); copyTitle = translate('modelCard.actions.copyLoRASyntax', {}, 'Copy LoRA Syntax');
} else if (modelType === MODEL_TYPES.CHECKPOINT) { } else if (modelType === MODEL_TYPES.CHECKPOINT) {
// Checkpoint send sets the widget value directly; no append/replace modes.
sendTitle = translate('modelCard.actions.sendCheckpointToWorkflow', {}, 'Send to ComfyUI'); sendTitle = translate('modelCard.actions.sendCheckpointToWorkflow', {}, 'Send to ComfyUI');
copyTitle = translate('modelCard.actions.copyCheckpointName', {}, 'Copy checkpoint name'); copyTitle = translate('modelCard.actions.copyCheckpointName', {}, 'Copy checkpoint name');
} else if (modelType === MODEL_TYPES.EMBEDDING) { } else if (modelType === MODEL_TYPES.EMBEDDING) {
// Embedding send always appends to the prompt; no replace mode.
sendTitle = translate('modelCard.actions.sendEmbeddingToWorkflow', {}, 'Send to ComfyUI'); sendTitle = translate('modelCard.actions.sendEmbeddingToWorkflow', {}, 'Send to ComfyUI');
copyTitle = translate('modelCard.actions.copyEmbeddingName', {}, 'Copy embedding name'); copyTitle = translate('modelCard.actions.copyEmbeddingName', {}, 'Copy embedding name');
} else { } else {
+35 -4
View File
@@ -877,8 +877,9 @@ function renderLoraSpecificContent(lora, escapedWords) {
<option value="clip_strength">${translate('modals.model.usageTips.clipStrength', {}, 'Clip Strength')}</option> <option value="clip_strength">${translate('modals.model.usageTips.clipStrength', {}, 'Clip Strength')}</option>
<option value="clip_skip">${translate('modals.model.usageTips.clipSkip', {}, 'Clip Skip')}</option> <option value="clip_skip">${translate('modals.model.usageTips.clipSkip', {}, 'Clip Skip')}</option>
</select> </select>
<input type="number" id="preset-value" step="0.01" placeholder="${translate('modals.model.usageTips.valuePlaceholder', {}, 'Value')}" style="display:none;"> <!-- autofill opt-out attrs prevent password managers / email-alias extensions from attaching popups -->
<button class="add-preset-btn">${translate('modals.model.usageTips.add', {}, 'Add')}</button> <input type="number" id="preset-value" step="0.01" placeholder="${translate('modals.model.usageTips.valuePlaceholder', {}, 'Value')}" style="display:none;" autocomplete="off" data-1p-ignore data-lpignore="true" data-bwignore data-form-type="other">
<button class="add-preset-btn" disabled>${translate('modals.model.usageTips.add', {}, 'Add')}</button>
</div> </div>
<div class="preset-tags"> <div class="preset-tags">
${renderPresetTags(parsePresets(lora.usage_tips))} ${renderPresetTags(parsePresets(lora.usage_tips))}
@@ -1086,6 +1087,11 @@ function setupLoraSpecificFields(filePath) {
if (!presetSelector || !presetValue || !addPresetBtn || !presetTags) return; if (!presetSelector || !presetValue || !addPresetBtn || !presetTags) return;
// Add button stays disabled until both a parameter and a value are provided
const updateAddPresetButtonState = () => {
addPresetBtn.disabled = !(presetSelector.value && presetValue.value.trim());
};
presetSelector.addEventListener('change', function () { presetSelector.addEventListener('change', function () {
const selected = this.value; const selected = this.value;
if (selected) { if (selected) {
@@ -1111,12 +1117,16 @@ function setupLoraSpecificFields(filePath) {
} else { } else {
presetValue.style.display = 'none'; presetValue.style.display = 'none';
} }
updateAddPresetButtonState();
}); });
presetValue.addEventListener('input', updateAddPresetButtonState);
addPresetBtn.addEventListener('click', async function () { addPresetBtn.addEventListener('click', async function () {
const key = presetSelector.value; const key = presetSelector.value;
const value = presetValue.value; const value = presetValue.value.trim();
// Unreachable via UI while the button is disabled; kept as a safety net
if (!key || !value) return; if (!key || !value) return;
const currentPath = resolveFilePath(); const currentPath = resolveFilePath();
@@ -1131,9 +1141,11 @@ function setupLoraSpecificFields(filePath) {
document.querySelector(`.model-card[data-filepath="${escapedFilePath}"]`); document.querySelector(`.model-card[data-filepath="${escapedFilePath}"]`);
const currentPresets = parsePresets(loraCard?.dataset.usage_tips); const currentPresets = parsePresets(loraCard?.dataset.usage_tips);
let isUpdate;
if (key === 'strength_range') { if (key === 'strength_range') {
const rangeMatch = value.match(/^(-?\d*\.?\d+)\s*[-~]\s*(-?\d*\.?\d+)$/); const rangeMatch = value.match(/^(-?\d*\.?\d+)\s*[-~]\s*(-?\d*\.?\d+)$/);
if (rangeMatch) { if (rangeMatch) {
isUpdate = 'strength_min' in currentPresets || 'strength_max' in currentPresets;
currentPresets['strength_min'] = parseFloat(rangeMatch[1]); currentPresets['strength_min'] = parseFloat(rangeMatch[1]);
currentPresets['strength_max'] = parseFloat(rangeMatch[2]); currentPresets['strength_max'] = parseFloat(rangeMatch[2]);
} else { } else {
@@ -1141,17 +1153,36 @@ function setupLoraSpecificFields(filePath) {
return; return;
} }
} else { } else {
currentPresets[key] = parseFloat(value); const numericValue = parseFloat(value);
if (!Number.isFinite(numericValue)) {
showToast('modals.model.usageTips.invalidValue', {}, 'error', 'Please enter a valid number');
return;
}
isUpdate = key in currentPresets;
currentPresets[key] = numericValue;
} }
const newPresetsJson = JSON.stringify(currentPresets); const newPresetsJson = JSON.stringify(currentPresets);
try {
await getModelApiClient().saveModelMetadata(currentPath, { usage_tips: newPresetsJson }); await getModelApiClient().saveModelMetadata(currentPath, { usage_tips: newPresetsJson });
} catch (error) {
console.error('Failed to save preset parameter:', error);
showToast('modals.model.usageTips.saveFailed', {}, 'error', 'Failed to save preset parameter');
return;
}
presetTags.innerHTML = renderPresetTags(currentPresets); presetTags.innerHTML = renderPresetTags(currentPresets);
showToast(
isUpdate ? 'modals.model.usageTips.updated' : 'modals.model.usageTips.added',
{},
'success',
isUpdate ? 'Preset parameter updated' : 'Preset parameter added'
);
presetSelector.value = ''; presetSelector.value = '';
presetValue.value = ''; presetValue.value = '';
presetValue.style.display = 'none'; presetValue.style.display = 'none';
addPresetBtn.disabled = true;
}); });
// Add keydown event for preset value // Add keydown event for preset value
+4 -4
View File
@@ -227,7 +227,7 @@ export function renderTriggerWords(words, filePath) {
const escapedWord = escapeHtml(word); const escapedWord = escapeHtml(word);
const escapedAttr = escapeAttribute(word); const escapedAttr = escapeAttribute(word);
return ` return `
<div class="trigger-word-tag" data-word="${escapedAttr}" title="${translate('modals.model.triggerWords.copyWord')}"> <div class="trigger-word-tag" data-word="${escapedAttr}" title="${translate('modals.model.triggerWords.copyOrEditWord')}">
<span class="trigger-word-content">${escapedWord}</span> <span class="trigger-word-content">${escapedWord}</span>
<span class="trigger-word-copy"> <span class="trigger-word-copy">
<i class="fas fa-copy"></i> <i class="fas fa-copy"></i>
@@ -455,7 +455,7 @@ function resetTriggerWordsUIState(section) {
// Restore click-to-copy functionality // Restore click-to-copy functionality
tag.removeEventListener('click', startEditTriggerWord); tag.removeEventListener('click', startEditTriggerWord);
setupDisplayTriggerWordTag(tag); setupDisplayTriggerWordTag(tag);
tag.title = translate('modals.model.triggerWords.copyWord'); tag.title = translate('modals.model.triggerWords.copyOrEditWord');
// Show copy icon, hide delete button // Show copy icon, hide delete button
if (copyIcon) copyIcon.style.display = ''; if (copyIcon) copyIcon.style.display = '';
@@ -503,7 +503,7 @@ function createTriggerWordTag(word, isEditMode = false) {
const tag = document.createElement('div'); const tag = document.createElement('div');
tag.className = 'trigger-word-tag'; tag.className = 'trigger-word-tag';
tag.dataset.word = word; tag.dataset.word = word;
tag.title = translate(isEditMode ? 'modals.model.triggerWords.editWord' : 'modals.model.triggerWords.copyWord'); tag.title = translate(isEditMode ? 'modals.model.triggerWords.editWord' : 'modals.model.triggerWords.copyOrEditWord');
const escapedWord = escapeHtml(word); const escapedWord = escapeHtml(word);
tag.innerHTML = ` tag.innerHTML = `
@@ -537,7 +537,7 @@ function setupDisplayTriggerWordTag(tag) {
tag.addEventListener('click', handleDisplayTriggerWordClick); tag.addEventListener('click', handleDisplayTriggerWordClick);
tag.addEventListener('dblclick', handleDisplayTriggerWordDoubleClick); tag.addEventListener('dblclick', handleDisplayTriggerWordDoubleClick);
tag.title = translate('modals.model.triggerWords.copyWord'); tag.title = translate('modals.model.triggerWords.copyOrEditWord');
} }
/** /**
@@ -23,7 +23,7 @@ import {
import { generateMetadataPanel } from './MetadataPanel.js'; import { generateMetadataPanel } from './MetadataPanel.js';
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js'; import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
import { getShowcaseUrl, getDisplayUrl, getGalleryThumbnailUrl } from '../../../utils/civitaiUtils.js'; import { getShowcaseUrl, getDisplayUrl, getGalleryThumbnailUrl } from '../../../utils/civitaiUtils.js';
import { openMediaViewer } from '../MediaViewer.js'; import { openMediaViewer, isMediaViewerOpen } from '../MediaViewer.js';
import { escapeAttribute } from '../utils.js'; import { escapeAttribute } from '../utils.js';
/** /**
@@ -231,10 +231,10 @@ export function renderShowcaseContent(images, exampleFiles = [], previewUrl = ''
${renderMediaItem(activeImg, galleryState.activeIndex, exampleFiles)} ${renderMediaItem(activeImg, galleryState.activeIndex, exampleFiles)}
${renderPositionBadge(positionText)} ${renderPositionBadge(positionText)}
</div> </div>
${showNav ? `<button class="gallery-nav prev" id="galleryPrevBtn" title="${translate('modals.model.showcase.previousExample', {}, 'Previous example')}"> ${showNav ? `<button class="gallery-nav prev" id="galleryPrevBtn" title="${translate('modals.model.showcase.previousExample', {}, 'Previous example ([)')}">
<i class="fas fa-chevron-left"></i> <i class="fas fa-chevron-left"></i>
</button> </button>
<button class="gallery-nav next" id="galleryNextBtn" title="${translate('modals.model.showcase.nextExample', {}, 'Next example')}"> <button class="gallery-nav next" id="galleryNextBtn" title="${translate('modals.model.showcase.nextExample', {}, 'Next example (])')}">
<i class="fas fa-chevron-right"></i> <i class="fas fa-chevron-right"></i>
</button>` : ''} </button>` : ''}
</div> </div>
@@ -508,6 +508,13 @@ export function updateMainDisplay(index) {
const activeImg = galleryState.images[galleryState.activeIndex]; const activeImg = galleryState.images[galleryState.activeIndex];
container.style.setProperty('--media-aspect', mediaAspectRatio(activeImg)); container.style.setProperty('--media-aspect', mediaAspectRatio(activeImg));
// Direction-aware slide makes every switch (wheel, keys, buttons,
// thumbnails) perceivable instead of an instant, unexplained swap
container.classList.remove('slide-from-left', 'slide-from-right');
if (delta !== 0) {
void container.offsetWidth; // restart the animation on rapid switches
container.classList.add(delta > 0 ? 'slide-from-right' : 'slide-from-left');
}
// The badge lives inside the container, so rebuild it together with the media // The badge lives inside the container, so rebuild it together with the media
container.innerHTML = renderMediaItem( container.innerHTML = renderMediaItem(
activeImg, activeImg,
@@ -680,6 +687,177 @@ function setupScrollToExpand(gallery) {
}, { passive: true }); }, { passive: true });
} }
// Wheel-navigation tuning: one gesture = one step. Trackpads emit a stream
// of small deltas, so deltas accumulate until the threshold; the cooldown
// keeps the tail of the same gesture from stepping again
const WHEEL_STEP_THRESHOLD = 50;
const WHEEL_COOLDOWN_MS = 250;
const WHEEL_ACCUM_RESET_MS = 200;
/**
* Wheel navigation on the main viewer area. Bound to .gallery-main (not the
* media element) so it works wherever the cursor rests within the viewer
* including over the nav buttons and the dead zones beside the media, and
* regardless of whether the hover-triggered metadata panel is showing.
*
* - Horizontal-dominant deltas (trackpad two-finger swipe) always navigate;
* the modal never scrolls horizontally, so nothing is hijacked.
* - Vertical deltas navigate only when the modal content cannot scroll
* further in that direction (same boundary pass-through pattern as the
* metadata panel's wheel handler), so wheel-scrolling the modal through
* the gallery is never trapped mid-way.
* - Once a boundary crossing triggers a vertical switch, a "wheel session"
* starts: while the pointer stays over .gallery-main, vertical wheel in
* BOTH directions switches examples (down = next, up = prev the reverse
* gesture must undo, not scroll the modal away). The session ends when the
* pointer leaves the area, returning vertical scroll to the modal.
* @param {HTMLElement} gallery - The .showcase-gallery element
*/
function initWheelNavigation(gallery) {
const main = gallery.querySelector('.gallery-main');
if (!main || galleryState.images.length < 2) return;
let accumulated = 0;
let lastEventAt = 0;
let lastStepAt = 0;
let verticalSession = false;
// Leaving the viewer area releases the vertical wheel back to the modal
main.addEventListener('pointerleave', () => {
verticalSession = false;
accumulated = 0;
});
main.addEventListener('wheel', (event) => {
// The metadata panel and media controls keep their own behavior;
// the panel passes boundary scrolls through to the modal by itself
if (event.target.closest('.image-metadata-panel, .media-controls')) return;
const horizontal = Math.abs(event.deltaX) > Math.abs(event.deltaY);
const delta = horizontal ? event.deltaX : event.deltaY;
if (delta === 0) return;
if (!horizontal && !verticalSession) {
const scroller = main.closest('.modal-content');
if (scroller) {
const atTop = scroller.scrollTop <= 0;
const atBottom = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight <= 1;
if ((delta < 0 && !atTop) || (delta > 0 && !atBottom)) return;
}
}
event.preventDefault();
const now = performance.now();
if (now - lastEventAt > WHEEL_ACCUM_RESET_MS) accumulated = 0;
lastEventAt = now;
if (now - lastStepAt < WHEEL_COOLDOWN_MS) return;
accumulated += delta;
if (Math.abs(accumulated) < WHEEL_STEP_THRESHOLD) return;
const direction = accumulated > 0 ? 1 : -1;
accumulated = 0;
lastStepAt = now;
if (!horizontal) verticalSession = true;
updateMainDisplay(galleryState.activeIndex + direction);
}, { passive: false });
}
// Touch/pen swipe tuning. Mouse is excluded: it already has wheel, keys and
// buttons, and mouse-drag would fight the media's click-to-view gesture
const SWIPE_THRESHOLD_PX = 50;
const SWIPE_CLICK_SUPPRESS_MS = 400;
/**
* Horizontal swipe navigation on the main viewer area (touch/pen). Requires
* `touch-action: pan-y` on .gallery-main so horizontal pans reach these
* handlers while vertical pans still scroll the modal.
* @param {HTMLElement} gallery - The .showcase-gallery element
*/
function initSwipeNavigation(gallery) {
const main = gallery.querySelector('.gallery-main');
if (!main || galleryState.images.length < 2) return;
let startX = 0;
let startY = 0;
let tracking = false;
let lastSwipeAt = 0;
main.addEventListener('pointerdown', (event) => {
if (event.pointerType === 'mouse') return;
// Native video controls own their pointer gestures (scrubbing etc.)
if (event.target.closest('video, .image-metadata-panel, .media-controls, .gallery-nav')) return;
startX = event.clientX;
startY = event.clientY;
tracking = true;
});
main.addEventListener('pointercancel', () => { tracking = false; });
main.addEventListener('pointerup', (event) => {
if (!tracking) return;
tracking = false;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
if (Math.abs(dx) < SWIPE_THRESHOLD_PX || Math.abs(dx) < Math.abs(dy) * 1.5) return;
lastSwipeAt = performance.now();
updateMainDisplay(galleryState.activeIndex + (dx < 0 ? 1 : -1));
});
// A completed swipe still produces a click on the media — swallow it in
// the capture phase (beats the media element's own handler) so the
// full-size viewer does not open
main.addEventListener('click', (event) => {
if (performance.now() - lastSwipeAt < SWIPE_CLICK_SUPPRESS_MS) {
event.stopPropagation();
event.preventDefault();
}
}, true);
}
/**
* True when the showcase tab is the active pane of an open modal
* @returns {boolean}
*/
function isShowcaseTabVisible() {
const showcaseTab = document.getElementById('showcase-tab');
if (!showcaseTab || !showcaseTab.classList.contains('active')) return false;
const modalEl = showcaseTab.closest('.modal');
// No .modal ancestor: standalone/test rendering, treat as visible
if (!modalEl) return true;
return modalEl.classList.contains('show') || modalEl.style.display === 'block';
}
/**
* Typing-target guard for the example shortcuts. Unlike the model-level
* navigation guard, buttons are NOT excluded: clicking a thumbnail or nav
* button leaves focus on it, which would make [ ] feel dead right after the
* most common interaction and buttons consume Space/Enter natively, never
* bracket keys.
* @param {EventTarget|null} target - keydown event target
* @returns {boolean}
*/
function isTypingTarget(target) {
if (!target) return false;
const tagName = target.tagName ? target.tagName.toLowerCase() : '';
return target.isContentEditable || ['input', 'textarea', 'select'].includes(tagName);
}
// '[' / ']' switch examples while the gallery is expanded. ArrowLeft/Right
// stay reserved for model-level navigation (ModelModal), and the full-size
// media viewer owns its keys while open.
document.addEventListener('keydown', (event) => {
if (event.key !== '[' && event.key !== ']') return;
if (!galleryState.expanded || galleryState.images.length < 2) return;
if (isTypingTarget(event.target)) return;
if (isMediaViewerOpen()) return;
if (!isShowcaseTabVisible()) return;
event.preventDefault();
updateMainDisplay(galleryState.activeIndex + (event.key === ']' ? 1 : -1));
});
/** /**
* Defer metadata fetches for video thumbnails until they scroll into view: * Defer metadata fetches for video thumbnails until they scroll into view:
* with preload="metadata" on every strip video, expanding the gallery would * with preload="metadata" on every strip video, expanding the gallery would
@@ -773,6 +951,8 @@ export function initShowcaseContent(gallery) {
const container = gallery.querySelector('.main-media-container'); const container = gallery.querySelector('.main-media-container');
if (container && galleryState.expanded) { if (container && galleryState.expanded) {
initMainMediaInteractions(container); initMainMediaInteractions(container);
initWheelNavigation(gallery);
initSwipeNavigation(gallery);
// Gallery just (re)rendered expanded: warm the cache for the // Gallery just (re)rendered expanded: warm the cache for the
// examples adjacent to the active one // examples adjacent to the active one
prefetchAdjacentMedia(); prefetchAdjacentMedia();
+5
View File
@@ -12,6 +12,7 @@ import { helpManager } from './managers/HelpManager.js';
import { doctorManager } from './managers/DoctorManager.js'; import { doctorManager } from './managers/DoctorManager.js';
import { bannerService } from './managers/BannerService.js'; import { bannerService } from './managers/BannerService.js';
import { initTheme, initBackToTop } from './utils/uiHelpers.js'; import { initTheme, initBackToTop } from './utils/uiHelpers.js';
import { applyModalBackdropBlurPolicy } from './utils/renderingCapability.js';
import { initializeInfiniteScroll } from './utils/infiniteScroll.js'; import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
import { i18n } from './i18n/index.js'; import { i18n } from './i18n/index.js';
import { onboardingManager } from './managers/OnboardingManager.js'; import { onboardingManager } from './managers/OnboardingManager.js';
@@ -34,6 +35,10 @@ export class AppCore {
console.log('AppCore: Initializing...'); console.log('AppCore: Initializing...');
// Disable full-viewport backdrop blur under software rendering before
// anything can open a modal (issue #1092)
applyModalBackdropBlurPolicy();
// Initialize i18n first // Initialize i18n first
window.i18n = i18n; window.i18n = i18n;
// Wait for i18n to be ready // Wait for i18n to be ready
+4
View File
@@ -3,6 +3,7 @@ import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } fr
import { createPageControls } from './components/controls/index.js'; import { createPageControls } from './components/controls/index.js';
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js'; import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
import { MODEL_TYPES } from './api/apiConfig.js'; import { MODEL_TYPES } from './api/apiConfig.js';
import { initActiveFiltersSync } from './utils/activeFiltersSync.js';
// Initialize the Embeddings page // Initialize the Embeddings page
class EmbeddingsPageManager { class EmbeddingsPageManager {
@@ -32,6 +33,9 @@ class EmbeddingsPageManager {
// Initialize common page features (including context menus) // Initialize common page features (including context menus)
appCore.initializePageFeatures(); appCore.initializePageFeatures();
// Mirror active filters to the backend for the ComfyUI-side autocomplete
initActiveFiltersSync(MODEL_TYPES.EMBEDDING);
console.log('Embeddings Manager initialized'); console.log('Embeddings Manager initialized');
} }
} }
+4
View File
@@ -4,6 +4,7 @@ import { updateCardsForBulkMode } from './components/shared/ModelCard.js';
import { createPageControls } from './components/controls/index.js'; import { createPageControls } from './components/controls/index.js';
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js'; import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js'; import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
import { initActiveFiltersSync } from './utils/activeFiltersSync.js';
// Initialize the LoRA page // Initialize the LoRA page
export class LoraPageManager { export class LoraPageManager {
@@ -41,6 +42,9 @@ export class LoraPageManager {
// Initialize common page features (including context menus and virtual scroll) // Initialize common page features (including context menus and virtual scroll)
appCore.initializePageFeatures(); appCore.initializePageFeatures();
// Mirror active filters to the backend for the ComfyUI-side autocomplete
initActiveFiltersSync('loras');
} }
} }
+111 -51
View File
@@ -1,23 +1,16 @@
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js'; import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { onboardingManager } from './OnboardingManager.js';
/** /**
* Manages help modal functionality and tutorial update notifications * Manages help modal functionality and tutorial update notifications
*/ */
export class HelpManager { export class HelpManager {
constructor() { constructor() {
this.lastViewedTimestamp = getStorageItem('help_last_viewed', 0); // Version of the help content the user has seen. Compared against the
this.latestContentTimestamp = new Date('2025-10-11').getTime(); // Will be updated from server or config // data-help-content-version marker rendered into the help modal markup,
// so badge state is always derived from the content actually served.
this.viewedContentVersion = getStorageItem('help_viewed_content_version', null);
this.isInitialized = false; this.isInitialized = false;
// Default latest content data - could be fetched from server
this.latestVideoData = {
timestamp: new Date('2024-06-09').getTime(), // Default timestamp
walkthrough: {
id: 'hvKw31YpE-U',
title: 'Getting Started with LoRA Manager'
},
playlistUpdated: true
};
} }
/** /**
@@ -34,9 +27,6 @@ export class HelpManager {
// Check if we need to show the badge // Check if we need to show the badge
this.updateHelpBadge(); this.updateHelpBadge();
// Fetch latest video data (could be implemented to fetch from remote source)
this.fetchLatestVideoData();
this.isInitialized = true; this.isInitialized = true;
return this; return this;
} }
@@ -55,6 +45,53 @@ export class HelpManager {
const tabButtons = document.querySelectorAll('.help-tabs .tab-btn'); const tabButtons = document.querySelectorAll('.help-tabs .tab-btn');
tabButtons.forEach(button => { tabButtons.forEach(button => {
button.addEventListener('click', (event) => { button.addEventListener('click', (event) => {
this.activateHelpTab(event.currentTarget.getAttribute('data-tab'));
});
});
// Replay tutorial button in the Getting Started tab
const replayTutorialBtn = document.getElementById('replayTutorialBtn');
if (replayTutorialBtn) {
replayTutorialBtn.addEventListener('click', () => {
// Close the help modal, then restart the onboarding tutorial
if (window.modalManager) {
window.modalManager.closeModal('helpModal');
}
onboardingManager.reset();
onboardingManager.startTutorial();
});
}
// Global "?" shortcut opens the help modal on the Shortcuts tab
document.addEventListener('keydown', (event) => {
if (event.key !== '?') return;
if (this.isTypingContext(event.target)) return;
if (window.modalManager?.isAnyModalOpen()) return;
event.preventDefault();
this.openHelpModal('shortcuts');
});
}
/**
* Check if the event target is a text entry context where "?" is literal input
*/
isTypingContext(target) {
if (!(target instanceof Element)) return false;
const tagName = target.tagName?.toLowerCase();
return target.isContentEditable || tagName === 'input' || tagName === 'textarea' || tagName === 'select';
}
/**
* Activate a specific help modal tab by its data-tab id
* @param {string} tabId - The tab id (matches data-tab and pane element id)
*/
activateHelpTab(tabId) {
const tabButton = document.querySelector(`.help-tabs .tab-btn[data-tab="${tabId}"]`);
const tabPane = document.getElementById(tabId);
if (!tabButton || !tabPane) return;
// Remove active class from all buttons and panes // Remove active class from all buttons and panes
document.querySelectorAll('.help-tabs .tab-btn').forEach(btn => { document.querySelectorAll('.help-tabs .tab-btn').forEach(btn => {
btn.classList.remove('active'); btn.classList.remove('active');
@@ -63,69 +100,92 @@ export class HelpManager {
pane.classList.remove('active'); pane.classList.remove('active');
}); });
// Add active class to clicked button // Activate the requested tab
event.currentTarget.classList.add('active'); tabButton.classList.add('active');
tabPane.classList.add('active');
// Show corresponding tab content
const tabId = event.currentTarget.getAttribute('data-tab');
document.getElementById(tabId).classList.add('active');
});
});
} }
/** /**
* Open the help modal * Open the help modal
* @param {string} [tabId] - Optional tab id to activate after opening
*/ */
openHelpModal() { openHelpModal(tabId) {
// Use modalManager to open the help modal // Use modalManager to open the help modal
if (window.modalManager) { if (!window.modalManager) return;
const hadNewContent = this.hasNewContent();
window.modalManager.toggleModal('helpModal'); window.modalManager.toggleModal('helpModal');
// Add visual indicator to Documentation tab if there's new content if (tabId) {
this.updateDocumentationTabIndicator(); this.activateHelpTab(tabId);
}
// Update the last viewed timestamp // Only acknowledge the content as viewed when the user opened the
// modal while it actually contained new content. Opening a stale
// (pre-upgrade) page must not suppress the badge after a refresh.
if (hadNewContent) {
this.updateNewContentTabIndicators();
this.markContentAsViewed(); this.markContentAsViewed();
}
// Hide the badge // Hide the badge
this.hideHelpBadge(); this.hideHelpBadge();
} }
}
/** /**
* Add visual indicator to Documentation tab for new content * Add visual indicator to tabs that received new content
*/ */
updateDocumentationTabIndicator() { updateNewContentTabIndicators() {
const docTab = document.querySelector('.tab-btn[data-tab="documentation"]'); if (!this.hasNewContent()) return;
if (docTab && this.hasNewContent()) {
docTab.classList.add('has-new-content'); // Tabs updated in the 2026-09-03 discoverability release:
// getting-started (Replay Tutorial button) and shortcuts (new cheat-sheet tab)
const NEW_CONTENT_TABS = ['getting-started', 'shortcuts'];
NEW_CONTENT_TABS.forEach(tabId => {
const tab = document.querySelector(`.help-tabs .tab-btn[data-tab="${tabId}"]`);
if (tab) {
tab.classList.add('has-new-content');
}
});
// Point the indicator at the specific new element inside the
// Getting Started tab, and scroll it into view so it is not lost
// below the fold of the modal body.
const replayBtn = document.getElementById('replayTutorialBtn');
if (replayBtn) {
replayBtn.classList.add('has-new-content');
const gettingStartedActive = document.querySelector('#getting-started.tab-pane.active');
if (gettingStartedActive && typeof replayBtn.scrollIntoView === 'function') {
replayBtn.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
} }
} }
/** /**
* Mark content as viewed by saving current timestamp * Mark content as viewed by persisting the version rendered in the DOM.
* No-op when the served markup carries no version marker (stale assets),
* so viewing old content never suppresses the badge for new content.
*/ */
markContentAsViewed() { markContentAsViewed() {
this.lastViewedTimestamp = Date.now(); const currentVersion = this.getCurrentContentVersion();
setStorageItem('help_last_viewed', this.lastViewedTimestamp); if (!currentVersion) return;
this.viewedContentVersion = currentVersion;
setStorageItem('help_viewed_content_version', this.viewedContentVersion);
} }
/** /**
* Fetch latest video data (could be implemented to actually fetch from a remote source) * Read the help content version from the rendered modal markup
* @returns {string|null} Version marker, or null if the served markup has none
*/ */
fetchLatestVideoData() { getCurrentContentVersion() {
// In a real implementation, you'd fetch this from your server const marker = document.querySelector('[data-help-content-version]');
// For now, we'll just use the hardcoded data from constructor return marker ? marker.getAttribute('data-help-content-version') : null;
// Update the timestamp with the latest data
this.latestContentTimestamp = Math.max(this.latestContentTimestamp, this.latestVideoData.timestamp);
// Check again if we need to show the badge with this new data
this.updateHelpBadge();
} }
/** /**
* Update help badge visibility based on timestamps * Update help badge visibility based on viewed vs. served content version
*/ */
updateHelpBadge() { updateHelpBadge() {
if (this.hasNewContent()) { if (this.hasNewContent()) {
@@ -136,11 +196,11 @@ export class HelpManager {
} }
/** /**
* Check if there's new content the user hasn't seen * Check if the served help content is newer than what the user has viewed
*/ */
hasNewContent() { hasNewContent() {
// If user has never viewed the help, or the content is newer than last viewed const currentVersion = this.getCurrentContentVersion();
return this.lastViewedTimestamp === 0 || this.latestContentTimestamp > this.lastViewedTimestamp; return Boolean(currentVersion) && currentVersion !== this.viewedContentVersion;
} }
/** /**
+22 -2
View File
@@ -43,7 +43,7 @@ export class OnboardingManager {
{ {
target: '.controls .action-buttons [data-action="bulk"]', target: '.controls .action-buttons [data-action="bulk"]',
title: () => translate('onboarding.steps.bulk.title', {}, 'Bulk Operations'), title: () => translate('onboarding.steps.bulk.title', {}, 'Bulk Operations'),
content: () => translate('onboarding.steps.bulk.content', {}, 'Enter bulk mode by clicking this button or pressing <span class="onboarding-shortcut">B</span>. Select multiple models and perform batch operations. Use <span class="onboarding-shortcut">Ctrl+A</span> to select all visible models.'), content: () => translate('onboarding.steps.bulk.content', {}, 'Enter bulk mode by clicking this button or pressing <span class="onboarding-shortcut">B</span> to select multiple models and perform batch operations.<br>• <span class="onboarding-shortcut">Ctrl/Cmd+A</span> select all visible models, <span class="onboarding-shortcut">Shift+Click</span> select a range.<br>• <span class="onboarding-shortcut">Esc</span> or clicking an empty area exits bulk mode.'),
position: 'bottom' position: 'bottom'
}, },
{ {
@@ -71,10 +71,30 @@ export class OnboardingManager {
position: 'top', position: 'top',
customPosition: { top: '20%', left: '50%' } customPosition: { top: '20%', left: '50%' }
}, },
{
target: '.card-grid',
title: () => translate('onboarding.steps.marqueeSelect.title', {}, 'Drag to Select'),
content: () => translate('onboarding.steps.marqueeSelect.content', {}, 'Hold the <strong>left mouse button</strong> on an empty area of the grid and drag to draw a marquee that selects multiple cards at once.'),
position: 'top',
customPosition: { top: '20%', left: '50%' }
},
{
target: '#folderSidebar',
title: () => translate('onboarding.steps.dragToSidebar.title', {}, 'Organize by Dragging'),
content: () => translate('onboarding.steps.dragToSidebar.content', {}, 'Drag a model card onto a folder in the sidebar to move the file there. This also works with multiple selected cards in bulk mode.'),
position: 'right'
},
{ {
target: '.card-grid', target: '.card-grid',
title: () => translate('onboarding.steps.contextMenu.title', {}, 'Context Menu'), title: () => translate('onboarding.steps.contextMenu.title', {}, 'Context Menu'),
content: () => translate('onboarding.steps.contextMenu.content', {}, '<strong>Right-click</strong> any model card for a context menu with additional actions.'), content: () => translate('onboarding.steps.contextMenu.content', {}, '<strong>Right-click</strong> any model card for a context menu with card actions like moving, deleting, or editing metadata.'),
position: 'top',
customPosition: { top: '20%', left: '50%' }
},
{
target: '.card-grid',
title: () => translate('onboarding.steps.contextMenus.title', {}, 'More Context Menus'),
content: () => translate('onboarding.steps.contextMenus.content', {}, 'In bulk mode, <strong>right-click a selected card</strong> for bulk actions. <strong>Right-click an empty area</strong> of the page for global actions like update checks and managing excluded models.'),
position: 'top', position: 'top',
customPosition: { top: '20%', left: '50%' } customPosition: { top: '20%', left: '50%' }
} }
+61
View File
@@ -0,0 +1,61 @@
/**
* Mirrors the manager page's active filter state to the backend's in-memory
* store, so the ComfyUI-side autocomplete can apply it even when the manager
* page and ComfyUI run in different browsers/origins (localStorage is not
* shared there).
*/
import { getStorageItem, setActiveFiltersListener } from './storageHelpers.js';
import { debounce } from './debounce.js';
const SYNC_DEBOUNCE_MS = 300;
const debouncedPushByPage = {};
function buildActiveFiltersPayload(pageType) {
const activeFolder = getStorageItem(`${pageType}_activeFolder`);
const recursiveSearch = getStorageItem(`${pageType}_recursiveSearch`, true);
const filters = getStorageItem(`${pageType}_filters`);
return {
// null stays null; legacy "null" string is normalized to null
activeFolder: activeFolder && activeFolder !== 'null' ? activeFolder : null,
recursiveSearch: recursiveSearch !== false,
filters: filters && typeof filters === 'object' ? filters : null,
};
}
export async function pushActiveFilters(pageType) {
try {
const response = await fetch(`/api/lm/${pageType}/active-filters`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildActiveFiltersPayload(pageType)),
});
if (!response.ok) {
console.warn(`[Lora Manager] Failed to sync active filters for ${pageType}: HTTP ${response.status}`);
}
} catch (error) {
console.warn(`[Lora Manager] Failed to sync active filters for ${pageType}:`, error);
}
}
export function syncActiveFilters(pageType) {
if (!debouncedPushByPage[pageType]) {
debouncedPushByPage[pageType] = debounce(() => {
pushActiveFilters(pageType);
}, SYNC_DEBOUNCE_MS);
}
debouncedPushByPage[pageType]();
}
/**
* Register the storage listener and push the current (restored) state once.
* The initial push covers server restarts, where the backend store is empty
* until the manager page re-publishes its localStorage-restored filters.
* @param {string} pageType - 'loras' | 'checkpoints' | 'embeddings'
*/
export function initActiveFiltersSync(pageType) {
setActiveFiltersListener((changedPageType) => syncActiveFilters(changedPageType));
pushActiveFilters(pageType);
}
+66
View File
@@ -0,0 +1,66 @@
/**
* Software-rendering detection for degrading expensive visual effects.
*
* With hardware acceleration disabled (or a GPU blocklisted), Chrome rasterizes
* in software. A full-viewport `backdrop-filter: blur()` then forces a per-frame
* CPU blur over everything painted behind the modal, freezing the entire
* browser (issue #1092). When software rendering is detected we add the
* `no-modal-backdrop-blur` class to <html>, and CSS drops the backdrop blur.
*/
const SOFTWARE_RENDERER_PATTERN = /swiftshader|llvmpipe|softpipe|software|basic render/i;
/**
* Check a WebGL renderer string against known software rasterizers.
* @param {string} renderer - UNMASKED_RENDERER_WEBGL string
* @returns {boolean}
*/
export function isSoftwareRendererString(renderer) {
return SOFTWARE_RENDERER_PATTERN.test(renderer || '');
}
/**
* Read the unmasked WebGL renderer string, or null when unavailable/masked.
* @returns {string|null}
*/
function getWebGLRendererString() {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
if (!gl) return null;
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
const renderer = debugInfo
? String(gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) || '')
: '';
const loseContext = gl.getExtension('WEBGL_lose_context');
if (loseContext) loseContext.loseContext();
return renderer || null;
}
/**
* Heuristic: is the browser rasterizing in software?
* - No WebGL at all: no evidence of GPU acceleration, assume software.
* - Masked renderer string or detection failure: cannot tell, keep effects on.
* @returns {boolean}
*/
export function isSoftwareRendering() {
try {
const renderer = getWebGLRendererString();
if (renderer === null) {
return true;
}
return isSoftwareRendererString(renderer);
} catch (error) {
return true;
}
}
/**
* Toggle the blur-disabling class on <html>. Runs once at app startup.
* @param {boolean} [isSoftware] - Override for tests; defaults to detection.
*/
export function applyModalBackdropBlurPolicy(isSoftware = isSoftwareRendering()) {
document.documentElement.classList.toggle('no-modal-backdrop-blur', isSoftware);
}
+61
View File
@@ -0,0 +1,61 @@
import { translate } from './i18nHelpers.js';
/**
* Format a remaining-time estimate for scan progress display.
* @param {number} remainingMs - Estimated remaining time in milliseconds
* @returns {string} Localized ETA text
*/
export function formatScanRemainingTime(remainingMs) {
if (remainingMs < 60000) {
return translate('common.scanProgress.eta.lessThanMinute', {}, 'Less than a minute remaining');
}
if (remainingMs < 3600000) {
const minutes = Math.round(remainingMs / 60000);
return translate('common.scanProgress.eta.minutes', { minutes }, `~${minutes} min remaining`);
}
const hours = Math.floor(remainingMs / 3600000);
const minutes = Math.round((remainingMs % 3600000) / 60000);
return translate('common.scanProgress.eta.hours', { hours, minutes }, `~${hours} hr ${minutes} min remaining`);
}
/**
* Create an ETA tracker for scan progress. Uses an exponential moving
* average (0.7/0.3) over the observed per-file processing time, mirroring
* the estimator in components/initialization.js.
* @returns {{ update: (processed: number, total: number) => (string|null) }}
*/
export function createScanEtaTracker() {
let startTime = null;
let lastProcessed = 0;
let averageMsPerFile = null;
return {
/**
* Update with the latest counters.
* @returns {string|null} Localized ETA text, or null when not applicable
*/
update(processed, total) {
if (!total || total <= 0 || processed >= total) {
return null;
}
const now = Date.now();
if (startTime === null) {
// First sample only anchors the timer; not enough data yet
startTime = now;
lastProcessed = processed;
return translate('initialization.estimatingTime', {}, 'Estimating time...');
}
if (processed > lastProcessed) {
const msPerFile = (now - startTime) / processed;
averageMsPerFile = averageMsPerFile === null
? msPerFile
: averageMsPerFile * 0.7 + msPerFile * 0.3;
lastProcessed = processed;
}
if (averageMsPerFile === null) {
return translate('initialization.estimatingTime', {}, 'Estimating time...');
}
return formatScanRemainingTime((total - lastProcessed) * averageMsPerFile);
}
};
}
+29
View File
@@ -6,6 +6,31 @@
// Namespace prefix for all localStorage keys // Namespace prefix for all localStorage keys
const STORAGE_PREFIX = 'lora_manager_'; const STORAGE_PREFIX = 'lora_manager_';
// Matches keys that carry the manager page's active filter state
// (e.g. 'loras_activeFolder', 'checkpoints_filters').
const ACTIVE_FILTER_KEY_PATTERN = /^(loras|checkpoints|embeddings)_(activeFolder|recursiveSearch|filters)$/;
let activeFiltersListener = null;
/**
* Register a listener invoked with the page type whenever one of the
* active-filter storage keys changes. Used to mirror filter state to the
* backend so the ComfyUI-side autocomplete can pick it up across
* browsers/origins where localStorage is not shared.
* @param {function(string): void} listener
*/
export function setActiveFiltersListener(listener) {
activeFiltersListener = listener;
}
function notifyActiveFiltersChanged(key) {
if (!activeFiltersListener) return;
const match = ACTIVE_FILTER_KEY_PATTERN.exec(key);
if (match) {
activeFiltersListener(match[1]);
}
}
/** /**
* Get an item from localStorage with namespace support and fallback to legacy keys * Get an item from localStorage with namespace support and fallback to legacy keys
* @param {string} key - The key without prefix * @param {string} key - The key without prefix
@@ -58,6 +83,8 @@ export function setStorageItem(key, value) {
} else { } else {
localStorage.setItem(prefixedKey, value); localStorage.setItem(prefixedKey, value);
} }
notifyActiveFiltersChanged(key);
} }
/** /**
@@ -67,6 +94,8 @@ export function setStorageItem(key, value) {
export function removeStorageItem(key) { export function removeStorageItem(key) {
localStorage.removeItem(STORAGE_PREFIX + key); localStorage.removeItem(STORAGE_PREFIX + key);
localStorage.removeItem(key); // Also remove legacy key localStorage.removeItem(key); // Also remove legacy key
notifyActiveFiltersChanged(key);
} }
/** /**
+14
View File
@@ -311,6 +311,20 @@ export function showActionToast(key, params = {}, type = 'info', options = {}) {
toast.append(closeBtn); toast.append(closeBtn);
} }
/**
* Check whether the event target is a text-entry context (input, textarea,
* select, or contenteditable) where single-letter shortcuts should be treated
* as literal input.
* @param {EventTarget|null} target - The DOM event target
* @returns {boolean}
*/
export function isTypingContext(target) {
if (!(target instanceof Element)) return false;
const tagName = target.tagName?.toLowerCase();
return target.isContentEditable || tagName === 'input' || tagName === 'textarea' || tagName === 'select';
}
export function restoreFolderFilter() { export function restoreFolderFilter() {
const activeFolder = getStorageItem('activeFolder'); const activeFolder = getStorageItem('activeFolder');
const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`); const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`);
+4 -4
View File
@@ -65,7 +65,7 @@
</select> </select>
</div> </div>
<div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.refresh.title') }}{% else %}{{ t('loras.controls.refresh.title') }}{% endif %}" class="control-group dropdown-group"> <div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.refresh.title') }}{% else %}{{ t('loras.controls.refresh.title') }}{% endif %}" class="control-group dropdown-group">
<button data-action="refresh" class="dropdown-main"><i class="fas fa-sync"></i> <span>{{ t('common.actions.refresh') }}</span></button> <button data-action="refresh" class="dropdown-main"><i class="fas fa-sync"></i> <span><span>{{ t('common.actions.refresh') }}</span> <kbd class="shortcut-key">R</kbd></span></button>
<button class="dropdown-toggle" aria-label="Show refresh options"> <button class="dropdown-toggle" aria-label="Show refresh options">
<i class="fas fa-caret-down"></i> <i class="fas fa-caret-down"></i>
</button> </button>
@@ -78,11 +78,11 @@
{% if page_id != 'recipes' %} {% if page_id != 'recipes' %}
<div class="control-group"> <div class="control-group">
<button data-action="fetch" title="{{ t('loras.controls.fetch.title') }}"><i class="fas fa-download"></i> <span>{{ t('loras.controls.fetch.action') }}</span></button> <button data-action="fetch" title="{{ t('loras.controls.fetch.title') }}"><i class="fas fa-download"></i> <span><span>{{ t('loras.controls.fetch.action') }}</span> <kbd class="shortcut-key">F</kbd></span></button>
</div> </div>
<div class="control-group"> <div class="control-group">
<button data-action="download" title="{{ t('loras.controls.download.title') }}"> <button data-action="download" title="{{ t('loras.controls.download.title') }}">
<i class="fas fa-cloud-download-alt"></i> <span>{{ t('loras.controls.download.action') }}</span> <i class="fas fa-cloud-download-alt"></i> <span><span>{{ t('loras.controls.download.action') }}</span> <kbd class="shortcut-key">D</kbd></span>
</button> </button>
</div> </div>
{% endif %} {% endif %}
@@ -96,7 +96,7 @@
{% endif %} {% endif %}
<div class="control-group"> <div class="control-group">
<button id="bulkOperationsBtn" data-action="bulk" title="{{ t('loras.controls.bulk.title') }}"> <button id="bulkOperationsBtn" data-action="bulk" title="{{ t('loras.controls.bulk.title') }}">
<i class="fas fa-th-large"></i> <span><span>{{ t('loras.controls.bulk.action') }}</span> <div class="shortcut-key">B</div></span> <i class="fas fa-th-large"></i> <span><span>{{ t('loras.controls.bulk.action') }}</span> <kbd class="shortcut-key">B</kbd></span>
</button> </button>
</div> </div>
<div class="control-group"> <div class="control-group">
+129 -1
View File
@@ -1,5 +1,5 @@
<!-- Help Modal --> <!-- Help Modal -->
<div id="helpModal" class="modal"> <div id="helpModal" class="modal" data-help-content-version="2026-09-03">
<div class="modal-content help-modal"> <div class="modal-content help-modal">
<button class="close" onclick="modalManager.closeModal('helpModal')">&times;</button> <button class="close" onclick="modalManager.closeModal('helpModal')">&times;</button>
<div class="help-header"> <div class="help-header">
@@ -10,6 +10,7 @@
<button class="tab-btn active" data-tab="getting-started">{{ t('help.tabs.gettingStarted') }}</button> <button class="tab-btn active" data-tab="getting-started">{{ t('help.tabs.gettingStarted') }}</button>
<button class="tab-btn" data-tab="update-vlogs">{{ t('help.tabs.updateVlogs') }}</button> <button class="tab-btn" data-tab="update-vlogs">{{ t('help.tabs.updateVlogs') }}</button>
<button class="tab-btn" data-tab="documentation">{{ t('help.tabs.documentation') }}</button> <button class="tab-btn" data-tab="documentation">{{ t('help.tabs.documentation') }}</button>
<button class="tab-btn" data-tab="shortcuts">{{ t('help.tabs.shortcuts') }}</button>
</div> </div>
<div class="help-content"> <div class="help-content">
@@ -39,6 +40,13 @@
<li><strong>Recipe System:</strong> Create, save and share your perfect combinations</li> <li><strong>Recipe System:</strong> Create, save and share your perfect combinations</li>
</ul> </ul>
</div> </div>
<div class="help-actions">
<button id="replayTutorialBtn" class="replay-tutorial-btn">
<i class="fas fa-graduation-cap"></i>
<span>{{ t('help.gettingStarted.replayTutorial') }}</span>
<span class="new-content-badge">{{ t('help.newContentBadge') }}</span>
</button>
</div>
</div> </div>
<!-- Update Vlogs Tab --> <!-- Update Vlogs Tab -->
@@ -136,6 +144,126 @@
</ul> </ul>
</div> </div>
</div> </div>
<!-- Shortcuts Tab -->
<div class="tab-pane" id="shortcuts">
<h3>{{ t('help.shortcuts.title') }}</h3>
<div class="shortcuts-section">
<h4><i class="fas fa-keyboard"></i> {{ t('help.shortcuts.groups.general') }}</h4>
<ul class="shortcuts-list">
<li>
<span class="shortcut-keys"><kbd>Ctrl</kbd><span class="shortcut-sep">/</span><kbd>Cmd</kbd><span class="shortcut-sep">+</span><kbd>F</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.focusSearch') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>Esc</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.closeModal') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>?</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.openShortcuts') }}</span>
</li>
</ul>
</div>
<div class="shortcuts-section">
<h4><i class="fas fa-bolt"></i> {{ t('help.shortcuts.groups.actions') }}</h4>
<ul class="shortcuts-list">
<li>
<span class="shortcut-keys"><kbd>R</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.refresh') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>F</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.fetchMetadata') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>D</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.downloadModel') }}</span>
</li>
</ul>
</div>
<div class="shortcuts-section">
<h4><i class="fas fa-object-group"></i> {{ t('help.shortcuts.groups.selection') }}</h4>
<ul class="shortcuts-list">
<li>
<span class="shortcut-keys"><kbd>B</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.toggleBulkMode') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>Ctrl</kbd><span class="shortcut-sep">/</span><kbd>Cmd</kbd><span class="shortcut-sep">+</span><kbd>A</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.selectAll') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>Shift</kbd><span class="shortcut-sep">+</span><kbd>{{ t('help.shortcuts.keys.click') }}</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.rangeSelect') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>{{ t('help.shortcuts.keys.drag') }}</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.marqueeSelect') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>Esc</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.exitBulkMode') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>{{ t('help.shortcuts.keys.rightClick') }}</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.bulkActions') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>{{ t('help.shortcuts.keys.rightClick') }}</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.globalActions') }}</span>
</li>
</ul>
</div>
<div class="shortcuts-section">
<h4><i class="fas fa-arrows-alt-v"></i> {{ t('help.shortcuts.groups.navigation') }}</h4>
<ul class="shortcuts-list">
<li>
<span class="shortcut-keys"><kbd>PageUp</kbd><span class="shortcut-sep">/</span><kbd>PageDown</kbd><span class="shortcut-sep">/</span><kbd>Home</kbd><span class="shortcut-sep">/</span><kbd>End</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.scrollPages') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>Alt</kbd><span class="shortcut-sep">+</span><kbd>{{ t('help.shortcuts.keys.letter') }}</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.jumpAlphabet') }}</span>
</li>
</ul>
</div>
<div class="shortcuts-section">
<h4><i class="fas fa-window-restore"></i> {{ t('help.shortcuts.groups.modelModal') }}</h4>
<ul class="shortcuts-list">
<li>
<span class="shortcut-keys"><kbd>&larr;</kbd><span class="shortcut-sep">/</span><kbd>&rarr;</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.prevNext') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>Delete</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.deleteEntry') }}</span>
</li>
</ul>
</div>
<div class="shortcuts-section">
<h4><i class="fas fa-images"></i> {{ t('help.shortcuts.groups.mediaViewer') }}</h4>
<ul class="shortcuts-list">
<li>
<span class="shortcut-keys"><kbd>&larr;</kbd><span class="shortcut-sep">/</span><kbd>&rarr;</kbd><span class="shortcut-sep">/</span><kbd>[</kbd><span class="shortcut-sep">/</span><kbd>]</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.cycleMedia') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>{{ t('help.shortcuts.keys.swipe') }}</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.swipeTouch') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>Esc</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.closeViewer') }}</span>
</li>
</ul>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,348 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const {
BASE_MODEL_API_MODULE,
STATE_MODULE,
UI_HELPERS_MODULE,
I18N_MODULE,
STORAGE_MODULE,
API_CONFIG_MODULE,
API_FACTORY_MODULE,
SIDEBAR_MANAGER_MODULE,
} = vi.hoisted(() => ({
BASE_MODEL_API_MODULE: new URL('../../../static/js/api/baseModelApi.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
STORAGE_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
API_CONFIG_MODULE: new URL('../../../static/js/api/apiConfig.js', import.meta.url).pathname,
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname,
}));
const showToastMock = vi.fn();
const showMock = vi.fn();
const showCancelButtonMock = vi.fn();
const hideMock = vi.fn();
const restoreProgressBarMock = vi.fn();
const setProgressMock = vi.fn();
const setStatusMock = vi.fn();
const resetAndReloadMock = vi.fn();
vi.mock(STATE_MODULE, () => ({
state: {
loadingManager: {
show: showMock,
showCancelButton: showCancelButtonMock,
hide: hideMock,
restoreProgressBar: restoreProgressBarMock,
setProgress: setProgressMock,
setStatus: setStatusMock,
},
},
getCurrentPageState: vi.fn(() => ({})),
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: showToastMock,
}));
vi.mock(I18N_MODULE, () => ({
translate: vi.fn((key, params, fallback) => {
if (fallback) {
return Object.entries(params || {}).reduce(
(text, [name, value]) => text.replaceAll(`{${name}}`, value),
fallback
);
}
return key;
}),
}));
vi.mock(STORAGE_MODULE, () => ({
getStorageItem: vi.fn(),
getSessionItem: vi.fn(),
removeSessionItem: vi.fn(),
saveMapToStorage: vi.fn(),
}));
vi.mock(API_CONFIG_MODULE, () => ({
getCompleteApiConfig: vi.fn(() => ({
endpoints: { scan: '/api/lm/loras/scan' },
config: { displayName: 'LoRA', singularName: 'lora' },
})),
getCurrentModelType: vi.fn(() => 'loras'),
isValidModelType: vi.fn(() => true),
DOWNLOAD_ENDPOINTS: {},
HF_ENDPOINTS: {},
WS_ENDPOINTS: { fetchProgress: '/ws/fetch-progress' },
}));
vi.mock(API_FACTORY_MODULE, () => ({
resetAndReload: resetAndReloadMock,
}));
vi.mock(SIDEBAR_MANAGER_MODULE, () => ({
sidebarManager: { refresh: vi.fn() },
}));
class FakeWebSocket {
static instances = [];
static failNextConnection = false;
constructor(url) {
this.url = url;
this.onopen = null;
this.onerror = null;
this.onmessage = null;
this.close = vi.fn();
FakeWebSocket.instances.push(this);
const shouldFail = FakeWebSocket.failNextConnection;
FakeWebSocket.failNextConnection = false;
queueMicrotask(() => {
if (shouldFail) {
this.onerror?.(new Error('connection refused'));
} else {
this.onopen?.();
}
});
}
emit(data) {
this.onmessage?.({ data: JSON.stringify(data) });
}
}
async function createClient() {
const { BaseModelApiClient } = await import(BASE_MODEL_API_MODULE);
class TestClient extends BaseModelApiClient {}
return new TestClient('loras');
}
async function flushMicrotasks() {
await new Promise((resolve) => setTimeout(resolve, 0));
}
describe('BaseModelApiClient.refreshModels scan progress', () => {
beforeEach(() => {
showToastMock.mockReset();
showMock.mockReset();
showCancelButtonMock.mockReset();
hideMock.mockReset();
restoreProgressBarMock.mockReset();
setProgressMock.mockReset();
setStatusMock.mockReset();
resetAndReloadMock.mockReset();
FakeWebSocket.instances = [];
FakeWebSocket.failNextConnection = false;
vi.stubGlobal('WebSocket', FakeWebSocket);
});
afterEach(() => {
delete global.fetch;
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function mockFetchPending() {
let resolveFetch;
global.fetch = vi.fn(() => new Promise((resolve) => { resolveFetch = resolve; }));
return {
resolveOk: (payload = { status: 'success' }) =>
resolveFetch({ ok: true, json: async () => payload }),
};
}
async function startRefresh(client, fullRebuild = false) {
const promise = client.refreshModels(fullRebuild);
await vi.waitFor(() => {
expect(FakeWebSocket.instances.length).toBe(1);
});
await flushMicrotasks();
const socket = FakeWebSocket.instances[0];
await vi.waitFor(() => {
expect(socket.onmessage).toBeTruthy();
});
return { promise, socket };
}
it('shows scan progress updates from the WebSocket channel', async () => {
const fetchControl = mockFetchPending();
const client = await createClient();
const { promise, socket } = await startRefresh(client);
expect(socket.url).toBe(`ws://${window.location.host}/ws/fetch-progress`);
socket.emit({
type: 'scan_progress',
status: 'started',
stage: 'scan_folders',
model_type: 'lora',
pageType: 'loras',
full_rebuild: false,
progress: 0,
});
socket.emit({
type: 'scan_progress',
status: 'processing',
stage: 'process_models',
model_type: 'lora',
pageType: 'loras',
full_rebuild: false,
progress: 50,
processed: 5,
total: 10,
current_name: 'style.safetensors',
});
expect(setProgressMock).toHaveBeenCalledWith(0);
expect(setProgressMock).toHaveBeenCalledWith(50);
const lastStatus = setStatusMock.mock.calls.at(-1)[0];
expect(lastStatus).toContain('(5/10)');
expect(lastStatus).toContain('style.safetensors');
// First ETA sample only anchors the timer
expect(lastStatus).toContain('Estimating time...');
fetchControl.resolveOk();
await promise;
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
expect(showToastMock).toHaveBeenCalledWith(
'toast.api.refreshComplete',
{ action: 'Refresh' },
'success'
);
expect(socket.close).toHaveBeenCalled();
expect(hideMock).toHaveBeenCalled();
});
it('ignores messages for other types or other model types', async () => {
const fetchControl = mockFetchPending();
const client = await createClient();
const { promise, socket } = await startRefresh(client);
socket.emit({
type: 'scan_progress',
status: 'processing',
stage: 'process_models',
model_type: 'checkpoint',
progress: 33,
processed: 1,
total: 3,
});
socket.emit({
type: 'example_images_progress',
status: 'running',
model_type: 'lora',
progress: 66,
processed: 2,
total: 3,
});
expect(setProgressMock).not.toHaveBeenCalled();
expect(setStatusMock).not.toHaveBeenCalled();
fetchControl.resolveOk();
await promise;
});
it('falls back to plain loading when the WebSocket connection fails', async () => {
FakeWebSocket.failNextConnection = true;
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ status: 'success' }),
});
const client = await createClient();
await client.refreshModels(true);
expect(global.fetch).toHaveBeenCalled();
const [url] = global.fetch.mock.calls[0];
expect(url.searchParams.get('full_rebuild')).toBe('true');
expect(showMock).toHaveBeenCalledWith('Full rebuild LoRAs...', 0);
expect(showToastMock).toHaveBeenCalledWith(
'toast.api.refreshComplete',
{ action: 'Full rebuild' },
'success'
);
});
it('computes an ETA with EMA smoothing once enough samples arrive', async () => {
const fetchControl = mockFetchPending();
let now = 1000;
vi.spyOn(Date, 'now').mockImplementation(() => now);
const client = await createClient();
const { promise, socket } = await startRefresh(client);
const emitProcessing = (processed, total) => socket.emit({
type: 'scan_progress',
status: 'processing',
stage: 'process_models',
model_type: 'lora',
progress: Math.floor((processed / total) * 100),
processed,
total,
});
// First sample anchors the timer
emitProcessing(1, 10);
expect(setStatusMock.mock.calls.at(-1)[0]).toContain('Estimating time...');
// 100s elapsed for 2 files -> 50s per file -> 400s remaining -> ~7 min
now = 101000;
emitProcessing(2, 10);
expect(setStatusMock.mock.calls.at(-1)[0]).toContain('~7 min remaining');
// 110s elapsed for 4 files -> EMA = 50000*0.7 + 27500*0.3 = 43250ms/file
// remaining 6 files -> 259.5s -> ~4 min
now = 111000;
emitProcessing(4, 10);
expect(setStatusMock.mock.calls.at(-1)[0]).toContain('~4 min remaining');
fetchControl.resolveOk();
await promise;
});
it('shows the cancelled toast when the server reports cancellation', async () => {
const fetchControl = mockFetchPending();
const client = await createClient();
const { promise } = await startRefresh(client);
fetchControl.resolveOk({ status: 'cancelled' });
await promise;
expect(showToastMock).toHaveBeenCalledWith('toast.api.operationCancelled', {}, 'info');
expect(resetAndReloadMock).not.toHaveBeenCalled();
});
});
describe('createScanEtaTracker / formatScanRemainingTime', () => {
it('estimates remaining time from EMA of per-file cost', async () => {
const { createScanEtaTracker } = await import(BASE_MODEL_API_MODULE);
let now = 0;
vi.spyOn(Date, 'now').mockImplementation(() => now);
const tracker = createScanEtaTracker();
expect(tracker.update(1, 10)).toBe('Estimating time...');
now = 60000; // 60s for 3 files -> 20s/file -> 7 * 20s = 140s -> ~2 min
expect(tracker.update(3, 10)).toBe('~2 min remaining');
now = 61000; // tiny delta keeps EMA near 20s/file
expect(tracker.update(4, 10)).toBe('~2 min remaining');
// Done: no ETA
expect(tracker.update(10, 10)).toBeNull();
expect(tracker.update(0, 0)).toBeNull();
vi.restoreAllMocks();
});
it('formats hours and sub-minute remainders', async () => {
const { formatScanRemainingTime } = await import(BASE_MODEL_API_MODULE);
expect(formatScanRemainingTime(30000)).toBe('Less than a minute remaining');
expect(formatScanRemainingTime(5 * 60000)).toBe('~5 min remaining');
expect(formatScanRemainingTime(3600000 + 30 * 60000)).toBe('~1 hr 30 min remaining');
});
});
@@ -0,0 +1,285 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const showToastMock = vi.hoisted(() => vi.fn());
const loadingManagerMock = vi.hoisted(() => ({
show: vi.fn(),
hide: vi.fn(),
restoreProgressBar: vi.fn(),
setProgress: vi.fn(),
setStatus: vi.fn(),
}));
const virtualScrollerMock = vi.hoisted(() => ({
refreshWithData: vi.fn(),
}));
const getCurrentPageStateMock = vi.hoisted(() => vi.fn());
const etaUpdateMock = vi.hoisted(() => vi.fn(() => 'ETA soon'));
vi.mock('../../../static/js/components/RecipeCard.js', () => ({
RecipeCard: vi.fn(() => ({ element: document.createElement('div') })),
}));
vi.mock('../../../static/js/state/index.js', () => ({
state: {
loadingManager: loadingManagerMock,
virtualScroller: virtualScrollerMock,
},
getCurrentPageState: getCurrentPageStateMock,
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: vi.fn((key, params, fallback) => {
if (fallback) {
return Object.entries(params || {}).reduce(
(text, [name, value]) => text.replaceAll(`{${name}}`, value),
fallback
);
}
return key;
}),
}));
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
captureScrollPosition: vi.fn(),
restoreScrollPosition: vi.fn(),
}));
vi.mock('../../../static/js/api/apiConfig.js', () => ({
WS_ENDPOINTS: { fetchProgress: '/ws/fetch-progress' },
}));
vi.mock('../../../static/js/utils/scanEtaUtils.js', () => ({
createScanEtaTracker: () => ({ update: etaUpdateMock }),
}));
import { refreshRecipes } from '../../../static/js/api/recipeApi.js';
class FakeWebSocket {
static instances = [];
static failNextConnection = false;
constructor(url) {
this.url = url;
this.onopen = null;
this.onerror = null;
this.onmessage = null;
this.close = vi.fn();
FakeWebSocket.instances.push(this);
const shouldFail = FakeWebSocket.failNextConnection;
FakeWebSocket.failNextConnection = false;
queueMicrotask(() => {
if (shouldFail) {
this.onerror?.(new Error('connection refused'));
} else {
this.onopen?.();
}
});
}
emit(data) {
this.onmessage?.({ data: JSON.stringify(data) });
}
}
async function flushMicrotasks() {
await new Promise((resolve) => setTimeout(resolve, 0));
}
describe('refreshRecipes scan progress', () => {
beforeEach(() => {
vi.clearAllMocks();
getCurrentPageStateMock.mockReturnValue({
pageSize: 50,
currentPage: 1,
hasMore: true,
isLoading: false,
sortBy: 'date:desc',
showFavoritesOnly: false,
activeFolder: null,
searchOptions: { recursive: true },
customFilter: { active: false },
filters: {},
});
FakeWebSocket.instances = [];
FakeWebSocket.failNextConnection = false;
vi.stubGlobal('WebSocket', FakeWebSocket);
});
afterEach(() => {
delete global.fetch;
vi.unstubAllGlobals();
});
function mockFetchPendingScan() {
let resolveScan;
global.fetch = vi.fn((input) => {
const url = String(input);
if (url.includes('/scan')) {
return new Promise((resolve) => { resolveScan = resolve; });
}
// Recipe list reload after the scan completes
return Promise.resolve({
ok: true,
json: async () => ({ items: [], total: 0, total_pages: 0 }),
});
});
return {
resolveOk: (payload = { status: 'success' }) =>
resolveScan({ ok: true, json: async () => payload }),
resolveNotOk: () =>
resolveScan({ ok: false, status: 500, statusText: 'Server Error' }),
};
}
async function startRefresh(fullRebuild = true) {
const promise = refreshRecipes(fullRebuild);
await vi.waitFor(() => {
expect(FakeWebSocket.instances.length).toBe(1);
});
await flushMicrotasks();
const socket = FakeWebSocket.instances[0];
await vi.waitFor(() => {
expect(socket.onmessage).toBeTruthy();
});
return { promise, socket };
}
it('shows scan progress updates from the WebSocket channel', async () => {
const fetchControl = mockFetchPendingScan();
const { promise, socket } = await startRefresh();
expect(socket.url).toBe(`ws://${window.location.host}/ws/fetch-progress`);
socket.emit({
type: 'scan_progress',
status: 'started',
stage: 'scan_folders',
model_type: 'recipe',
pageType: 'recipes',
full_rebuild: true,
progress: 0,
});
socket.emit({
type: 'scan_progress',
status: 'processing',
stage: 'process_models',
model_type: 'recipe',
pageType: 'recipes',
full_rebuild: true,
progress: 50,
processed: 5,
total: 10,
current_name: 'style.recipe.json',
});
expect(loadingManagerMock.setProgress).toHaveBeenCalledWith(0);
expect(loadingManagerMock.setProgress).toHaveBeenCalledWith(50);
const lastStatus = loadingManagerMock.setStatus.mock.calls.at(-1)[0];
expect(lastStatus).toContain('(5/10)');
expect(lastStatus).toContain('style.recipe.json');
expect(lastStatus).toContain('ETA soon');
expect(etaUpdateMock).toHaveBeenCalledWith(5, 10);
fetchControl.resolveOk();
await promise;
expect(showToastMock).toHaveBeenCalledWith(
'toast.api.refreshComplete',
{ action: 'Full rebuild' },
'success'
);
expect(socket.close).toHaveBeenCalled();
expect(loadingManagerMock.hide).toHaveBeenCalled();
});
it('ignores messages for other types or other model types', async () => {
const fetchControl = mockFetchPendingScan();
const { promise, socket } = await startRefresh();
socket.emit({
type: 'scan_progress',
status: 'processing',
stage: 'process_models',
model_type: 'lora',
progress: 33,
processed: 1,
total: 3,
});
socket.emit({
type: 'example_images_progress',
status: 'running',
model_type: 'recipe',
progress: 66,
processed: 2,
total: 3,
});
expect(loadingManagerMock.setProgress).not.toHaveBeenCalled();
expect(loadingManagerMock.setStatus).not.toHaveBeenCalled();
fetchControl.resolveOk();
await promise;
});
it('falls back to plain loading when the WebSocket connection fails', async () => {
FakeWebSocket.failNextConnection = true;
global.fetch = vi.fn((input) => {
const url = String(input);
if (url.includes('/scan')) {
return Promise.resolve({
ok: true,
json: async () => ({ status: 'success' }),
});
}
return Promise.resolve({
ok: true,
json: async () => ({ items: [], total: 0, total_pages: 0 }),
});
});
await refreshRecipes(false);
expect(global.fetch).toHaveBeenCalled();
const [url] = global.fetch.mock.calls[0];
expect(url.searchParams.get('full_rebuild')).toBe('false');
expect(loadingManagerMock.show).toHaveBeenCalledWith('Refreshing Recipes...', 0);
expect(showToastMock).toHaveBeenCalledWith(
'toast.api.refreshComplete',
{ action: 'Refresh' },
'success'
);
});
it('shows the cancelled toast when the server reports cancellation', async () => {
const fetchControl = mockFetchPendingScan();
const { promise } = await startRefresh();
fetchControl.resolveOk({ status: 'cancelled' });
await promise;
expect(showToastMock).toHaveBeenCalledWith('toast.api.operationCancelled', {}, 'info');
expect(showToastMock).not.toHaveBeenCalledWith(
'toast.api.refreshComplete',
expect.anything(),
expect.anything()
);
});
it('reports refresh failures through the error toast', async () => {
const fetchControl = mockFetchPendingScan();
const { promise } = await startRefresh();
fetchControl.resolveNotOk();
await promise;
expect(showToastMock).toHaveBeenCalledWith(
'toast.api.refreshFailed',
{ action: 'rebuild', type: 'recipe' },
'error'
);
expect(loadingManagerMock.hide).toHaveBeenCalled();
});
});
@@ -0,0 +1,378 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const {
API_MODULE,
APP_MODULE,
CARET_HELPER_MODULE,
PREVIEW_COMPONENT_MODULE,
AUTOCOMPLETE_MODULE,
} = vi.hoisted(() => ({
API_MODULE: new URL('../../../scripts/api.js', import.meta.url).pathname,
APP_MODULE: new URL('../../../scripts/app.js', import.meta.url).pathname,
CARET_HELPER_MODULE: new URL('../../../web/comfyui/textarea_caret_helper.js', import.meta.url).pathname,
PREVIEW_COMPONENT_MODULE: new URL('../../../web/comfyui/preview_tooltip.js', import.meta.url).pathname,
AUTOCOMPLETE_MODULE: new URL('../../../web/comfyui/autocomplete.js', import.meta.url).pathname,
}));
const fetchApiMock = vi.fn();
const settingGetMock = vi.fn();
const caretHelperInstance = {
getBeforeCursor: vi.fn(() => ''),
getCursorOffset: vi.fn(() => ({ left: 0, top: 0 })),
};
vi.mock(API_MODULE, () => ({
api: {
fetchApi: fetchApiMock,
},
}));
vi.mock(APP_MODULE, () => ({
app: {
canvas: {
ds: { scale: 1 },
},
extensionManager: {
setting: {
get: settingGetMock,
set: vi.fn(),
},
},
registerExtension: vi.fn(),
},
}));
vi.mock(CARET_HELPER_MODULE, () => ({
TextAreaCaretHelper: vi.fn(() => caretHelperInstance),
}));
vi.mock(PREVIEW_COMPONENT_MODULE, () => ({
PreviewTooltip: vi.fn(() => ({ show: vi.fn(), hide: vi.fn(), cleanup: vi.fn() })),
}));
async function createAutoComplete(modelType, activeFiltersEnabled) {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.lora_active_filters_autocomplete') {
return activeFiltersEnabled;
}
if (key === 'loramanager.autocomplete_append_comma') return false;
if (key === 'loramanager.autocomplete_auto_format') return false;
if (key === 'loramanager.autocomplete_accept_key') return 'both';
return undefined;
});
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, relative_paths: [] }),
});
const input = document.createElement('textarea');
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, modelType, { debounceDelay: 0, showPreview: false });
input.value = 'example';
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
return autoComplete;
}
describe('AutoComplete active-filters flag', () => {
beforeEach(() => {
vi.useFakeTimers();
document.body.innerHTML = '';
document.head.querySelectorAll('style').forEach((styleEl) => styleEl.remove());
Element.prototype.scrollIntoView = vi.fn();
fetchApiMock.mockReset();
settingGetMock.mockReset();
caretHelperInstance.getBeforeCursor.mockReset();
caretHelperInstance.getCursorOffset.mockReset();
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 0, top: 0 });
});
afterEach(() => {
vi.useRealTimers();
});
it('sends use_active_filters for loras when the setting is enabled', async () => {
await createAutoComplete('loras', true);
expect(fetchApiMock).toHaveBeenCalledWith(
'/lm/loras/relative-paths?search=example&limit=100&use_active_filters=true'
);
});
it('omits the flag when the setting is disabled', async () => {
await createAutoComplete('loras', false);
expect(fetchApiMock).toHaveBeenCalledWith('/lm/loras/relative-paths?search=example&limit=100');
});
it('omits the flag for non-lora model types even when enabled', async () => {
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, words: [] }),
});
await createAutoComplete('prompt', true);
for (const call of fetchApiMock.mock.calls) {
expect(call[0]).not.toContain('use_active_filters');
}
});
it('does not read filter state from localStorage anymore', async () => {
localStorage.setItem('lora_manager_loras_activeFolder', 'SD_XL');
localStorage.setItem('lora_manager_loras_filters', JSON.stringify({ baseModel: ['SDXL 1.0'] }));
await createAutoComplete('loras', true);
for (const call of fetchApiMock.mock.calls) {
expect(call[0]).not.toContain('folder=');
expect(call[0]).not.toContain('base_model=');
}
});
const typeLorasSlashCommand = async () => {
const input = document.createElement('textarea');
input.value = '/';
input.selectionStart = 1;
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 });
input.dispatchEvent(new Event('input', { bubbles: true }));
return autoComplete;
};
it('shows the active-filters state below the loras slash command list', async () => {
await typeLorasSlashCommand();
const footer = document.querySelector('.lm-autocomplete-command-footer');
expect(footer).not.toBeNull();
expect(footer.textContent).toContain('Active Filters Search: OFF');
expect(footer.textContent).toContain('/activefilters to enable');
});
it('shows how to disable active-filters search in the footer when it is on', async () => {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.lora_active_filters_autocomplete') {
return true;
}
return undefined;
});
await typeLorasSlashCommand();
const footer = document.querySelector('.lm-autocomplete-command-footer');
expect(footer).not.toBeNull();
expect(footer.textContent).toContain('Active Filters Search: ON');
expect(footer.textContent).toContain('/noactivefilters to disable');
});
it('shows a dismissible first-run hint on loras suggestions and remembers dismissal', async () => {
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({
success: true,
relative_paths: ['models/example.safetensors'],
}),
});
const triggerSearch = async () => {
const input = document.createElement('textarea');
input.value = 'example';
input.selectionStart = 7;
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runOnlyPendingTimersAsync();
await vi.runOnlyPendingTimersAsync();
await Promise.resolve();
return autoComplete;
};
const autoComplete = await triggerSearch();
const hint = autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint');
expect(hint).not.toBeNull();
expect(hint.textContent).toContain('/activefilters');
hint.querySelector('button').click();
expect(autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint')).toBeNull();
expect(localStorage.getItem('lm:activefilters-tip-dismissed')).toBe('1');
// A fresh instance no longer shows the hint once dismissed
const autoComplete2 = await triggerSearch();
expect(autoComplete2.dropdown.querySelector('.lm-autocomplete-first-run-hint')).toBeNull();
});
it('does not show the loras first-run hint when active-filters search is already on', async () => {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.lora_active_filters_autocomplete') {
return true;
}
return undefined;
});
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({
success: true,
relative_paths: ['models/example.safetensors'],
}),
});
const input = document.createElement('textarea');
input.value = 'example';
input.selectionStart = 7;
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runOnlyPendingTimersAsync();
await vi.runOnlyPendingTimersAsync();
await Promise.resolve();
expect(autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint')).toBeNull();
});
it('broadcasts a setting-toggled window event when /activefilters is accepted', async () => {
const events = [];
const listener = (event) => events.push(event.detail);
window.addEventListener('lora-manager:setting-toggled', listener);
try {
const input = document.createElement('textarea');
input.value = '/activefilters';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('/activefilters');
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 });
input.dispatchEvent(new Event('input', { bubbles: true }));
// The command token is cleared after acceptance; simulate the caret
// helper seeing the cleared input so the synthetic input event does
// not re-trigger command parsing (same pattern as behavior tests).
caretHelperInstance.getBeforeCursor.mockReturnValue('');
await Promise.resolve();
await Promise.resolve();
expect(events).toContainEqual({
settingId: 'loramanager.lora_active_filters_autocomplete',
value: true,
});
} finally {
window.removeEventListener('lora-manager:setting-toggled', listener);
}
});
it('removes a stale first-run hint when the toggle is switched on while the dropdown stays open', async () => {
localStorage.removeItem('lm:activefilters-tip-dismissed');
let enabled = false;
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.lora_active_filters_autocomplete') return enabled;
if (key === 'loramanager.autocomplete_append_comma') return false;
if (key === 'loramanager.autocomplete_auto_format') return false;
if (key === 'loramanager.autocomplete_accept_key') return 'both';
return undefined;
});
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
});
const input = document.createElement('textarea');
input.value = 'example';
input.selectionStart = 7;
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
const triggerShow = async () => {
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runOnlyPendingTimersAsync();
await vi.runOnlyPendingTimersAsync();
await Promise.resolve();
return autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint');
};
// OFF → the enable hint is shown in the suggestions dropdown.
expect(await triggerShow()).not.toBeNull();
// The node's filter chip toggles the setting ON while the dropdown is
// still open (ComfyUI can keep focus in the textarea, so no blur/hide
// fires). settings.js broadcasts the setting-toggled event.
enabled = true;
window.dispatchEvent(new CustomEvent('lora-manager:setting-toggled', {
detail: { settingId: 'loramanager.lora_active_filters_autocomplete', value: true },
}));
// The stale OFF hint must be gone even though the dropdown never closed.
expect(autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint')).toBeNull();
// Further typing while ON must not resurrect the enable hint.
expect(await triggerShow()).toBeNull();
});
it('updates the command-list footer when the toggle changes while the command list is open', async () => {
let enabled = false;
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.lora_active_filters_autocomplete') return enabled;
return undefined;
});
const input = document.createElement('textarea');
input.value = '/';
input.selectionStart = 1;
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 });
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runOnlyPendingTimersAsync();
await Promise.resolve();
const footer = () => autoComplete.dropdown.querySelector('.lm-autocomplete-command-footer');
expect(footer()).not.toBeNull();
expect(footer().textContent).toContain('Active Filters Search: OFF');
expect(footer().textContent).toContain('/activefilters to enable');
enabled = true;
window.dispatchEvent(new CustomEvent('lora-manager:setting-toggled', {
detail: { settingId: 'loramanager.lora_active_filters_autocomplete', value: true },
}));
expect(footer()).not.toBeNull();
expect(footer().textContent).toContain('Active Filters Search: ON');
expect(footer().textContent).toContain('/noactivefilters to disable');
});
});
@@ -1789,7 +1789,7 @@ describe('AutoComplete widget interactions', () => {
expect(settingSetMock).toHaveBeenCalledWith('loramanager.lora_active_filters_autocomplete', true); expect(settingSetMock).toHaveBeenCalledWith('loramanager.lora_active_filters_autocomplete', true);
}); });
it('appends active filter params to loras autocomplete requests when enabled', async () => { it('sends only the use_active_filters flag when enabled (filters resolved server-side)', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
settingGetMock.mockImplementation((key) => { settingGetMock.mockImplementation((key) => {
@@ -1799,12 +1799,11 @@ describe('AutoComplete widget interactions', () => {
return undefined; return undefined;
}); });
// Stored manager-page filters must NOT leak into the request URL; the
// backend injects them from its server-side store.
localStorage.setItem('lora_manager_loras_filters', JSON.stringify({ localStorage.setItem('lora_manager_loras_filters', JSON.stringify({
baseModel: ['SD 1.5'], baseModel: ['SD 1.5'],
tags: { anime: 'include', nsfw: 'exclude', __no_tags__: 'exclude' }, tags: { anime: 'include', nsfw: 'exclude' },
autoTags: { I2V: 'include' },
modelTypes: ['standard'],
tagLogic: 'all',
license: { noCredit: 'include', allowSelling: 'exclude' }, license: { noCredit: 'include', allowSelling: 'exclude' },
})); }));
localStorage.setItem('lora_manager_loras_activeFolder', 'MyLoras'); localStorage.setItem('lora_manager_loras_activeFolder', 'MyLoras');
@@ -1830,19 +1829,7 @@ describe('AutoComplete widget interactions', () => {
await Promise.resolve(); await Promise.resolve();
const calledUrl = fetchApiMock.mock.calls[0][0]; const calledUrl = fetchApiMock.mock.calls[0][0];
expect(calledUrl).toContain('/lm/loras/relative-paths?search=example&limit=100'); expect(calledUrl).toBe('/lm/loras/relative-paths?search=example&limit=100&use_active_filters=true');
expect(calledUrl).toContain('folder=MyLoras');
expect(calledUrl).toContain('recursive=true');
expect(calledUrl).toContain('tag_include=anime');
expect(calledUrl).toContain('tag_exclude=nsfw');
expect(calledUrl).toContain('tag_exclude=__no_tags__');
expect(calledUrl).toContain('auto_tag_include=I2V');
expect(calledUrl).toContain('tag_logic=all');
expect(calledUrl).toContain('credit_required=false');
expect(calledUrl).toContain('allow_selling_generated_content=false');
const parsed = new URL(calledUrl, 'https://example.com');
expect(parsed.searchParams.get('base_model')).toBe('SD 1.5');
expect(parsed.searchParams.get('model_type')).toBe('standard');
}); });
it('keeps the default loras autocomplete URL when active-filters mode is off', async () => { it('keeps the default loras autocomplete URL when active-filters mode is off', async () => {
@@ -1870,10 +1857,12 @@ describe('AutoComplete widget interactions', () => {
expect(fetchApiMock).toHaveBeenCalledWith('/lm/loras/relative-paths?search=example&limit=100'); expect(fetchApiMock).toHaveBeenCalledWith('/lm/loras/relative-paths?search=example&limit=100');
}); });
it('sends the filter-pipeline signal even when no filters are stored', async () => { it('sends the filter-pipeline flag even when no filters are stored', async () => {
// Regression: with filter mode on but no folder/filters stored, the request // Regression: with filter mode on but no folder/filters stored, the request
// carried no params, so the backend skipped the filter pipeline and global // carried no signal, so the backend skipped the filter pipeline and global
// settings like show_only_sfw diverged from the list endpoint. // settings like show_only_sfw diverged from the list endpoint. The flag
// makes the backend run the pipeline (injecting nothing when its store
// is empty).
vi.useFakeTimers(); vi.useFakeTimers();
settingGetMock.mockImplementation((key) => { settingGetMock.mockImplementation((key) => {
@@ -1907,10 +1896,13 @@ describe('AutoComplete widget interactions', () => {
await Promise.resolve(); await Promise.resolve();
const calledUrl = fetchApiMock.mock.calls[0][0]; const calledUrl = fetchApiMock.mock.calls[0][0];
expect(calledUrl).toContain('recursive=true'); expect(calledUrl).toContain('use_active_filters=true');
}); });
it('omits folder param when active folder is root and recursion is enabled', async () => { it('leaves folder params to the backend when active folder is root with recursion enabled', async () => {
// The root-folder/recursion semantics now live server-side (see
// active_filters_store.active_filters_to_query_kwargs); the client only
// sends the flag.
vi.useFakeTimers(); vi.useFakeTimers();
settingGetMock.mockImplementation((key) => { settingGetMock.mockImplementation((key) => {
@@ -1948,10 +1940,12 @@ describe('AutoComplete widget interactions', () => {
const calledUrl = fetchApiMock.mock.calls[0][0]; const calledUrl = fetchApiMock.mock.calls[0][0];
expect(calledUrl).not.toContain('folder='); expect(calledUrl).not.toContain('folder=');
expect(calledUrl).toContain('recursive=true'); expect(calledUrl).toContain('use_active_filters=true');
}); });
it('sends an empty folder param for root with recursion disabled, mirroring the page list', async () => { it('leaves the root+non-recursive folder mapping to the backend', async () => {
// Root with recursion disabled maps to folder='' server-side (mirroring
// the page list); the client no longer encodes this in the URL.
vi.useFakeTimers(); vi.useFakeTimers();
settingGetMock.mockImplementation((key) => { settingGetMock.mockImplementation((key) => {
@@ -1988,15 +1982,14 @@ describe('AutoComplete widget interactions', () => {
await Promise.resolve(); await Promise.resolve();
const calledUrl = fetchApiMock.mock.calls[0][0]; const calledUrl = fetchApiMock.mock.calls[0][0];
expect(calledUrl).toContain('folder='); expect(calledUrl).not.toContain('folder=');
expect(calledUrl).toContain('recursive=false'); expect(calledUrl).toContain('use_active_filters=true');
const parsed = new URL(calledUrl, 'https://example.com');
expect(parsed.searchParams.get('folder')).toBe('');
}); });
it('applies the active folder even when no filter-panel filters are set', async () => { it('sends the flag even when only a folder is stored (no filter-panel filters)', async () => {
// Regression: folder was skipped when lora_manager_loras_filters was // Regression: folder was skipped when lora_manager_loras_filters was
// missing because the filters key gate returned early. // missing because the filters key gate returned early. The flag is now
// unconditional, and the backend injects the folder from its store.
vi.useFakeTimers(); vi.useFakeTimers();
settingGetMock.mockImplementation((key) => { settingGetMock.mockImplementation((key) => {
@@ -2029,8 +2022,8 @@ describe('AutoComplete widget interactions', () => {
await Promise.resolve(); await Promise.resolve();
const calledUrl = fetchApiMock.mock.calls[0][0]; const calledUrl = fetchApiMock.mock.calls[0][0];
expect(calledUrl).toContain('folder=Flux.1+D%2Fstyle'); expect(calledUrl).toContain('use_active_filters=true');
expect(calledUrl).toContain('recursive=true'); expect(calledUrl).not.toContain('folder=');
}); });
describe('discoverability hints', () => { describe('discoverability hints', () => {
@@ -0,0 +1,234 @@
import { describe, it, expect, vi } from 'vitest';
const {
API_MODULE,
APP_MODULE,
CARET_HELPER_MODULE,
PREVIEW_COMPONENT_MODULE,
AUTOCOMPLETE_MODULE,
} = vi.hoisted(() => ({
API_MODULE: new URL('../../../scripts/api.js', import.meta.url).pathname,
APP_MODULE: new URL('../../../scripts/app.js', import.meta.url).pathname,
CARET_HELPER_MODULE: new URL('../../../web/comfyui/textarea_caret_helper.js', import.meta.url).pathname,
PREVIEW_COMPONENT_MODULE: new URL('../../../web/comfyui/preview_tooltip.js', import.meta.url).pathname,
AUTOCOMPLETE_MODULE: new URL('../../../web/comfyui/autocomplete.js', import.meta.url).pathname,
}));
vi.mock(API_MODULE, () => ({
api: { fetchApi: vi.fn() },
}));
vi.mock(APP_MODULE, () => ({
app: {
canvas: { ds: { scale: 1 } },
extensionManager: {
setting: { get: vi.fn(), set: vi.fn() },
},
registerExtension: vi.fn(),
},
}));
vi.mock(CARET_HELPER_MODULE, () => ({
TextAreaCaretHelper: vi.fn(() => ({
getBeforeCursor: vi.fn(() => ''),
getCursorOffset: vi.fn(() => ({ left: 0, top: 0 })),
})),
}));
vi.mock(PREVIEW_COMPONENT_MODULE, () => ({
PreviewTooltip: vi.fn(() => ({ show: vi.fn(), hide: vi.fn(), cleanup: vi.fn() })),
}));
const METADATA_NAME = '__lm_autocomplete_meta_text';
function makeMetadataValue() {
return {
version: 1,
textWidgetName: 'text',
lastAccepted: {
start: 0,
end: 6,
insertedText: '1girl ',
textSnapshot: 'old prompt text, 1girl ',
},
};
}
describe('stripAutocompleteLastAccepted', () => {
let stripAutocompleteLastAccepted;
beforeAll(async () => {
const module = await import(AUTOCOMPLETE_MODULE);
stripAutocompleteLastAccepted = module.stripAutocompleteLastAccepted;
});
it('removes lastAccepted while keeping the metadata base fields', () => {
const value = makeMetadataValue();
const stripped = stripAutocompleteLastAccepted(value);
expect(stripped).toEqual({ version: 1, textWidgetName: 'text' });
expect('lastAccepted' in stripped).toBe(false);
// Original value must not be mutated
expect(value.lastAccepted).toBeDefined();
});
it('returns values without lastAccepted as-is (same reference)', () => {
const value = { version: 1, textWidgetName: 'text' };
expect(stripAutocompleteLastAccepted(value)).toBe(value);
});
it('returns non-object values as-is', () => {
expect(stripAutocompleteLastAccepted(null)).toBe(null);
expect(stripAutocompleteLastAccepted(undefined)).toBe(undefined);
expect(stripAutocompleteLastAccepted('text')).toBe('text');
expect(stripAutocompleteLastAccepted([1, 2])).toEqual([1, 2]);
});
});
describe('stripAutocompleteMetadataFromPromptResult', () => {
let stripResult;
beforeAll(async () => {
const module = await import(AUTOCOMPLETE_MODULE);
stripResult = module.stripAutocompleteMetadataFromPromptResult;
});
function makeWorkflowNode() {
const metadataValue = makeMetadataValue();
return {
properties: { __lm_widget_ids: ['text', METADATA_NAME] },
widgets_values: ['current text', metadataValue],
widgets_values_named: {
text: 'current text',
[METADATA_NAME]: metadataValue,
},
};
}
it('strips lastAccepted from workflow widgets_values using __lm_widget_ids alignment', () => {
const result = {
workflow: { nodes: [makeWorkflowNode()] },
output: {},
};
const returned = stripResult(result);
expect(returned).toBe(result);
expect(result.workflow.nodes[0].widgets_values[1])
.toEqual({ version: 1, textWidgetName: 'text' });
});
it('strips lastAccepted from widgets_values_named and leaves other widgets untouched', () => {
const result = {
workflow: { nodes: [makeWorkflowNode()] },
output: {},
};
stripResult(result);
const node = result.workflow.nodes[0];
expect(node.widgets_values_named[METADATA_NAME])
.toEqual({ version: 1, textWidgetName: 'text' });
expect(node.widgets_values_named.text).toBe('current text');
expect(node.widgets_values[0]).toBe('current text');
});
it('handles null entries in widgets_values (bypass compatibility padding)', () => {
const node = makeWorkflowNode();
node.properties.__lm_widget_ids = ['text', 'seed', METADATA_NAME];
node.widgets_values = ['current text', null, makeMetadataValue()];
const result = { workflow: { nodes: [node] }, output: {} };
stripResult(result);
expect(result.workflow.nodes[0].widgets_values[1]).toBe(null);
expect(result.workflow.nodes[0].widgets_values[2])
.toEqual({ version: 1, textWidgetName: 'text' });
});
it('still strips widgets_values_named when __lm_widget_ids is missing (legacy files)', () => {
const node = makeWorkflowNode();
delete node.properties;
const arrayValue = node.widgets_values[1];
const result = { workflow: { nodes: [node] }, output: {} };
stripResult(result);
// Array entries cannot be located without widget ids — left untouched
expect(result.workflow.nodes[0].widgets_values[1]).toBe(arrayValue);
expect(result.workflow.nodes[0].widgets_values_named[METADATA_NAME])
.toEqual({ version: 1, textWidgetName: 'text' });
});
it('strips lastAccepted from output (API prompt) inputs', () => {
const result = {
workflow: { nodes: [] },
output: {
'7': {
class_type: 'Prompt (LoraManager)',
inputs: {
text: 'current text',
[METADATA_NAME]: makeMetadataValue(),
},
},
},
};
stripResult(result);
const inputs = result.output['7'].inputs;
expect(inputs[METADATA_NAME]).toEqual({ version: 1, textWidgetName: 'text' });
expect(inputs.text).toBe('current text');
});
it('strips lastAccepted inside subgraph definitions', () => {
const result = {
workflow: {
nodes: [],
definitions: {
subgraphs: [{ nodes: [makeWorkflowNode()] }],
},
},
output: {},
};
stripResult(result);
const subgraphNode = result.workflow.definitions.subgraphs[0].nodes[0];
expect(subgraphNode.widgets_values_named[METADATA_NAME])
.toEqual({ version: 1, textWidgetName: 'text' });
});
it('leaves results without lastAccepted unchanged', () => {
const metadataValue = { version: 1, textWidgetName: 'text' };
const result = {
workflow: {
nodes: [{
properties: { __lm_widget_ids: ['text', METADATA_NAME] },
widgets_values: ['abc', metadataValue],
widgets_values_named: { text: 'abc', [METADATA_NAME]: metadataValue },
}],
},
output: {
'1': { inputs: { text: 'abc', [METADATA_NAME]: metadataValue } },
},
};
stripResult(result);
expect(result.workflow.nodes[0].widgets_values[1]).toBe(metadataValue);
expect(result.output['1'].inputs[METADATA_NAME]).toBe(metadataValue);
});
it('tolerates malformed results', () => {
expect(stripResult(null)).toBe(null);
expect(stripResult(undefined)).toBe(undefined);
expect(stripResult({})).toEqual({});
const result = {
workflow: { nodes: [null, { widgets_values: null }] },
output: { '1': { inputs: null }, '2': {} },
};
expect(() => stripResult(result)).not.toThrow();
});
});
@@ -0,0 +1,141 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
const {
APP_MODULE,
API_MODULE,
UTILS_MODULE,
SETTINGS_MODULE,
LORA_LOADER_MODULE,
} = vi.hoisted(() => ({
APP_MODULE: new URL("../../../scripts/app.js", import.meta.url).pathname,
API_MODULE: new URL("../../../scripts/api.js", import.meta.url).pathname,
UTILS_MODULE: new URL("../../../web/comfyui/utils.js", import.meta.url).pathname,
SETTINGS_MODULE: new URL("../../../web/comfyui/settings.js", import.meta.url).pathname,
LORA_LOADER_MODULE: new URL("../../../web/comfyui/lora_loader.js", import.meta.url).pathname,
}));
const extensionState = { current: null };
const registerExtensionMock = vi.fn((extension) => {
extensionState.current = extension;
});
vi.mock(APP_MODULE, () => ({
app: {
registerExtension: registerExtensionMock,
graph: {},
},
}));
vi.mock(API_MODULE, () => ({
api: {
addEventListener: vi.fn(),
},
}));
const showToastMock = vi.fn();
vi.mock(UTILS_MODULE, () => ({
collectActiveLorasFromChain: vi.fn(),
updateConnectedTriggerWords: vi.fn(),
mergeLoras: vi.fn(),
chainCallback: (proto, property, callback) => {
proto[property] = callback;
},
getAllGraphNodes: vi.fn(),
getNodeFromGraph: vi.fn(),
getWidgetByName: vi.fn(),
getWidgetSerializedValue: vi.fn(),
showToast: showToastMock,
}));
const getActiveFiltersPreferenceMock = vi.fn();
const setSettingValueMock = vi.fn();
vi.mock(SETTINGS_MODULE, () => ({
LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID:
"loramanager.lora_active_filters_autocomplete",
SETTING_TOGGLED_EVENT_NAME: "lora-manager:setting-toggled",
getLoraActiveFiltersAutocompletePreference: getActiveFiltersPreferenceMock,
setLoraManagerSettingValue: setSettingValueMock,
}));
async function registerNodeType(comfyClass) {
await import(LORA_LOADER_MODULE);
const extension = extensionState.current;
expect(extension).toBeDefined();
const nodeType = { comfyClass, prototype: {} };
await extension.beforeRegisterNodeDef(nodeType, {}, {});
return nodeType;
}
function getMenuOption(nodeType, enabled) {
getActiveFiltersPreferenceMock.mockReturnValue(enabled);
const options = [];
nodeType.prototype.getExtraMenuOptions(null, options);
return options.find(
(option) =>
option &&
typeof option.content === "string" &&
option.content.startsWith("Active Filters Search:")
);
}
describe("Lora Loader active-filters context menu", () => {
beforeEach(() => {
vi.resetModules();
extensionState.current = null;
registerExtensionMock.mockClear();
showToastMock.mockClear();
getActiveFiltersPreferenceMock.mockReset();
setSettingValueMock.mockReset();
setSettingValueMock.mockResolvedValue(true);
});
it.each([
"Lora Loader (LoraManager)",
"Lora Stacker (LoraManager)",
"WanVideo Lora Select (LoraManager)",
"Create Hook LoRA (LoraManager)",
])("adds the toggle entry to the %s context menu", async (comfyClass) => {
const nodeType = await registerNodeType(comfyClass);
const option = getMenuOption(nodeType, false);
expect(option).toBeDefined();
expect(option.content).toContain("Active Filters Search: OFF");
expect(option.content).toContain("/activefilters to enable");
});
it("shows the disable hint when active-filters search is on", async () => {
const nodeType = await registerNodeType("Lora Loader (LoraManager)");
const option = getMenuOption(nodeType, true);
expect(option.content).toContain("Active Filters Search: ON");
expect(option.content).toContain("/noactivefilters to disable");
});
it("toggles the setting and toasts feedback", async () => {
const nodeType = await registerNodeType("Lora Loader (LoraManager)");
const enableOption = getMenuOption(nodeType, false);
await enableOption.callback();
expect(setSettingValueMock).toHaveBeenCalledWith(
"loramanager.lora_active_filters_autocomplete",
true
);
expect(showToastMock).toHaveBeenCalledWith(
expect.objectContaining({ summary: "Active Filters Search Enabled" })
);
const disableOption = getMenuOption(nodeType, true);
await disableOption.callback();
expect(setSettingValueMock).toHaveBeenCalledWith(
"loramanager.lora_active_filters_autocomplete",
false
);
expect(showToastMock).toHaveBeenCalledWith(
expect.objectContaining({ summary: "Active Filters Search Disabled" })
);
});
});
@@ -43,6 +43,12 @@ vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock, showToast: showToastMock,
openCivitaiByMetadata: openCivitaiByMetadataMock, openCivitaiByMetadata: openCivitaiByMetadataMock,
updatePanelPositions: updatePanelPositionsMock, updatePanelPositions: updatePanelPositionsMock,
// Faithful stand-in for the real helper in uiHelpers.js
isTypingContext: (target) => {
if (!(target instanceof Element)) return false;
const tagName = target.tagName?.toLowerCase();
return target.isContentEditable || tagName === 'input' || tagName === 'textarea' || tagName === 'select';
},
})); }));
vi.mock('../../../static/js/managers/DownloadManager.js', () => ({ vi.mock('../../../static/js/managers/DownloadManager.js', () => ({
@@ -1201,3 +1207,86 @@ describe('PageControls favorites, sorting, and duplicates scenarios', () => {
}); });
}); });
}); });
describe('PageControls action keyboard shortcuts', () => {
async function setupLorasControls() {
renderControlsDom('loras');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('loras');
const { LorasControls } = await import('../../../static/js/components/controls/LorasControls.js');
return new LorasControls();
}
function keydownEvent(key, { target = document.body, ...init } = {}) {
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init });
Object.defineProperty(event, 'target', { value: target });
return event;
}
it('registers a pageControls-actions keydown handler with the event manager', async () => {
await setupLorasControls();
const { eventManager } = await import('../../../static/js/utils/EventManager.js');
const keydownHandlers = eventManager.handlers.get('keydown') || [];
expect(keydownHandlers.some((h) => h.source === 'pageControls-actions')).toBe(true);
});
it('triggers refresh, fetch, and download via the R / F / D keys', async () => {
const controls = await setupLorasControls();
expect(controls.handleActionShortcut(keydownEvent('r'))).toBe(true);
expect(refreshModelsMock).toHaveBeenCalledWith(false);
expect(controls.handleActionShortcut(keydownEvent('f'))).toBe(true);
expect(fetchCivitaiMetadataMock).toHaveBeenCalledTimes(1);
expect(controls.handleActionShortcut(keydownEvent('d'))).toBe(true);
expect(downloadManagerMock.showDownloadModal).toHaveBeenCalledTimes(1);
});
it('handles a real keydown dispatched on the document', async () => {
await setupLorasControls();
const event = new KeyboardEvent('keydown', { key: 'r', bubbles: true, cancelable: true });
document.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(refreshModelsMock).toHaveBeenCalledWith(false);
});
it('ignores R / F / D while typing in an input', async () => {
const controls = await setupLorasControls();
const input = document.getElementById('searchInput');
for (const key of ['r', 'f', 'd']) {
const event = keydownEvent(key, { target: input });
expect(controls.handleActionShortcut(event)).toBe(false);
expect(event.defaultPrevented).toBe(false);
}
expect(refreshModelsMock).not.toHaveBeenCalled();
expect(fetchCivitaiMetadataMock).not.toHaveBeenCalled();
expect(downloadManagerMock.showDownloadModal).not.toHaveBeenCalled();
});
it('ignores R / F / D when combined with modifier keys', async () => {
const controls = await setupLorasControls();
const event = keydownEvent('r', { ctrlKey: true });
expect(controls.handleActionShortcut(event)).toBe(false);
expect(event.defaultPrevented).toBe(false);
expect(refreshModelsMock).not.toHaveBeenCalled();
});
it('passes the event through when the action button does not exist', async () => {
const controls = await setupLorasControls();
// Recipes page has no fetch/download buttons
document.querySelector('[data-action="fetch"]').closest('.control-group').remove();
const event = keydownEvent('f');
expect(controls.handleActionShortcut(event)).toBe(false);
expect(event.defaultPrevented).toBe(false);
expect(fetchCivitaiMetadataMock).not.toHaveBeenCalled();
});
});
@@ -13,6 +13,12 @@ vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: vi.fn(), showToast: vi.fn(),
openCivitaiByMetadata: vi.fn(), openCivitaiByMetadata: vi.fn(),
updatePanelPositions: vi.fn(), updatePanelPositions: vi.fn(),
// Faithful stand-in for the real helper in uiHelpers.js
isTypingContext: (target) => {
if (!(target instanceof Element)) return false;
const tagName = target.tagName?.toLowerCase();
return target.isContentEditable || tagName === 'input' || tagName === 'textarea' || tagName === 'select';
},
})); }));
vi.mock('../../../static/js/managers/DownloadManager.js', () => ({ vi.mock('../../../static/js/managers/DownloadManager.js', () => ({
@@ -210,7 +210,11 @@ describe('RecipeModal resource item interactions', () => {
}); });
afterEach(() => { afterEach(() => {
createdModals.forEach(recipeModal => recipeModal.cleanupNavigationShortcuts()); // dispose() marks each modal instance dead: pending deferred timers
// (wiring, 500ms reconnect re-renders) are cancelled and in-flight
// async chains become no-ops, so nothing from this test can touch the
// DOM of the next one.
createdModals.forEach(recipeModal => recipeModal.dispose());
createdModals.length = 0; createdModals.length = 0;
document.body.innerHTML = ''; document.body.innerHTML = '';
delete global.modalManager; delete global.modalManager;
@@ -16,6 +16,7 @@ vi.mock(MEDIA_UTILS_MODULE, () => ({
vi.mock(MEDIA_VIEWER_MODULE, () => ({ vi.mock(MEDIA_VIEWER_MODULE, () => ({
openMediaViewer: vi.fn(), openMediaViewer: vi.fn(),
isMediaViewerOpen: vi.fn(() => false),
})); }));
const PREVIEW_URL = '/loras_static/preview/abc.png'; const PREVIEW_URL = '/loras_static/preview/abc.png';
@@ -340,4 +341,218 @@ describe('Showcase gallery', () => {
expect(loadSpy).toHaveBeenCalled(); expect(loadSpy).toHaveBeenCalled();
loadSpy.mockRestore(); loadSpy.mockRestore();
}); });
it('switches examples on wheel over the main viewer', async () => {
const { renderShowcaseContent, initShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>`;
initShowcaseContent(document.querySelector('.showcase-gallery'));
updateMainDisplay(0);
const main = document.querySelector('.gallery-main');
main.dispatchEvent(new WheelEvent('wheel', { deltaX: 120, deltaY: 0, bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('1');
// The one-step-per-gesture cooldown intentionally blocks an immediate
// second step; a fresh gallery (new listener) accepts the next gesture.
// No .modal-content ancestor → no boundary guard, vertical also navigates
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>`;
initShowcaseContent(document.querySelector('.showcase-gallery'));
updateMainDisplay(0);
document.querySelector('.gallery-main')
.dispatchEvent(new WheelEvent('wheel', { deltaX: 0, deltaY: 120, bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('1');
});
it('vertical wheel only hijacks at the modal scroll boundary', async () => {
const { renderShowcaseContent, initShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
document.body.innerHTML = `
<div class="modal-content">
<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>
</div>`;
initShowcaseContent(document.querySelector('.showcase-gallery'));
updateMainDisplay(0);
const scroller = document.querySelector('.modal-content');
// Mid-scroll: the modal owns vertical wheel, the gallery must not navigate
Object.defineProperties(scroller, {
scrollTop: { value: 100, configurable: true },
scrollHeight: { value: 1000, configurable: true },
clientHeight: { value: 500, configurable: true },
});
const main = document.querySelector('.gallery-main');
main.dispatchEvent(new WheelEvent('wheel', { deltaY: 120, bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
// Bottom of the modal: further down-scroll switches to the next example
// (and starts a vertical wheel session — covered by the next test)
Object.defineProperty(scroller, 'scrollTop', { value: 500, configurable: true });
main.dispatchEvent(new WheelEvent('wheel', { deltaY: 120, bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('1');
// Leaving the viewer area ends the session: up-scroll away from the top
// belongs to the modal again
main.dispatchEvent(new Event('pointerleave'));
main.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('1');
});
it('keeps vertical wheel in a sticky session once engaged, until pointer leaves', async () => {
const { renderShowcaseContent, initShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
document.body.innerHTML = `
<div class="modal-content">
<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>
</div>`;
initShowcaseContent(document.querySelector('.showcase-gallery'));
updateMainDisplay(0);
const scroller = document.querySelector('.modal-content');
// Modal sits at its bottom: down-scroll engages the gallery
Object.defineProperties(scroller, {
scrollTop: { value: 500, configurable: true },
scrollHeight: { value: 1000, configurable: true },
clientHeight: { value: 500, configurable: true },
});
// The one-step-per-gesture cooldown would block consecutive steps; fake
// the clock so each gesture lands after it
let now = 10000;
const nowSpy = vi.spyOn(performance, 'now').mockImplementation(() => now);
const main = document.querySelector('.gallery-main');
const wheelUp = () => main.dispatchEvent(
new WheelEvent('wheel', { deltaY: -120, bubbles: true, cancelable: true }));
const wheelDown = () => main.dispatchEvent(
new WheelEvent('wheel', { deltaY: 120, bubbles: true, cancelable: true }));
wheelDown();
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('1');
// Reverse gesture must undo: up-scroll switches back to the previous
// example even though the modal is not at its top
now += 300;
wheelUp();
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
// Pointer leaving the viewer area releases vertical wheel to the modal
now += 300;
main.dispatchEvent(new Event('pointerleave'));
wheelUp();
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
nowSpy.mockRestore();
});
it('ignores wheel events coming from the metadata panel', async () => {
const { renderShowcaseContent, initShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>`;
initShowcaseContent(document.querySelector('.showcase-gallery'));
updateMainDisplay(0);
// MediaUtils is mocked, so hoist a panel manually (real code appends it
// as a direct child of .gallery-main)
const main = document.querySelector('.gallery-main');
const panel = document.createElement('div');
panel.className = 'image-metadata-panel visible';
main.appendChild(panel);
panel.dispatchEvent(new WheelEvent('wheel', { deltaX: 120, bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
});
it('switches examples with [ and ] while expanded, with guards', async () => {
const { renderShowcaseContent, initShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
const { isMediaViewerOpen } = await import(MEDIA_VIEWER_MODULE);
document.body.innerHTML = `<div id="showcase-tab" class="tab-pane active">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>`;
initShowcaseContent(document.querySelector('.showcase-gallery'));
updateMainDisplay(0);
document.dispatchEvent(new KeyboardEvent('keydown', { key: ']', bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('1');
document.dispatchEvent(new KeyboardEvent('keydown', { key: '[', bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
// Typing in a field: the key belongs to the field
const input = document.createElement('input');
document.body.appendChild(input);
input.dispatchEvent(new KeyboardEvent('keydown', { key: ']', bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
input.remove();
// Focus resting on a button (e.g. right after clicking a thumbnail or nav
// button) must NOT deaden the keys — buttons consume Space/Enter natively
document.querySelector('#galleryNextBtn')
.dispatchEvent(new KeyboardEvent('keydown', { key: ']', bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('1');
document.dispatchEvent(new KeyboardEvent('keydown', { key: '[', bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
// Full-size media viewer open: it owns the keys
isMediaViewerOpen.mockReturnValueOnce(true);
document.dispatchEvent(new KeyboardEvent('keydown', { key: ']', bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
// Another tab active: examples must not change behind the scenes
document.getElementById('showcase-tab').classList.remove('active');
document.dispatchEvent(new KeyboardEvent('keydown', { key: ']', bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
});
it('switches examples on touch swipe and suppresses the follow-up click', async () => {
const { renderShowcaseContent, initShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
const { openMediaViewer } = await import(MEDIA_VIEWER_MODULE);
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>`;
initShowcaseContent(document.querySelector('.showcase-gallery'));
updateMainDisplay(0);
const main = document.querySelector('.gallery-main');
const img = main.querySelector('.media-wrapper img');
// A plain tap still opens the full-size viewer
img.click();
expect(openMediaViewer).toHaveBeenCalledTimes(1);
// jsdom lacks PointerEvent; MouseEvent carries clientX/clientY and its
// undefined pointerType passes the non-mouse guard
main.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 300, clientY: 100 }));
main.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 100, clientY: 110 }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('1');
// The click synthesized after the swipe must not open the viewer
document.querySelector('.gallery-main .media-wrapper img').click();
expect(openMediaViewer).toHaveBeenCalledTimes(1);
// A short drag below the threshold neither navigates nor eats the click
main.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 300, clientY: 100 }));
main.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 280, clientY: 100 }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('1');
});
it('marks the main viewer with a direction-aware slide class on switches', async () => {
const { renderShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>`;
const container = document.getElementById('mainMediaContainer');
// galleryState.activeIndex leaks across tests → anchor on the actual index
const start = Number(document.querySelector('.gallery-thumb.active')?.dataset.index || 0);
updateMainDisplay(start); // same index: no direction, no slide
expect(container.classList.contains('slide-from-right')).toBe(false);
expect(container.classList.contains('slide-from-left')).toBe(false);
updateMainDisplay(start + 1); // forward
expect(container.classList.contains('slide-from-right')).toBe(true);
expect(container.classList.contains('slide-from-left')).toBe(false);
updateMainDisplay(start); // backward
expect(container.classList.contains('slide-from-left')).toBe(true);
expect(container.classList.contains('slide-from-right')).toBe(false);
});
}); });
@@ -0,0 +1,162 @@
import { describe, it, beforeEach, expect, vi } from 'vitest';
const HELP_MANAGER_MODULE = new URL('../../../static/js/managers/HelpManager.js', import.meta.url).pathname;
const VIEWED_KEY = 'lora_manager_help_viewed_content_version';
function setupDom({ versionMarker = null } = {}) {
const markerAttr = versionMarker ? ` data-help-content-version="${versionMarker}"` : '';
document.body.innerHTML = `
<div class="help-toggle" id="helpToggleBtn">
<span class="update-badge"></span>
</div>
<div id="helpModal" class="modal"${markerAttr}>
<div class="help-tabs">
<button class="tab-btn" data-tab="getting-started"></button>
<button class="tab-btn" data-tab="shortcuts"></button>
</div>
<div class="tab-pane active" id="getting-started">
<button id="replayTutorialBtn" class="replay-tutorial-btn"></button>
</div>
</div>
`;
}
describe('HelpManager content-version badge logic', () => {
let HelpManager;
beforeEach(async () => {
({ HelpManager } = await import(HELP_MANAGER_MODULE));
});
function badgeIsVisible() {
return document.querySelector('#helpToggleBtn .update-badge').classList.contains('visible');
}
it('has no new content when the served markup carries no version marker', () => {
setupDom({ versionMarker: null });
const manager = new HelpManager();
expect(manager.hasNewContent()).toBe(false);
manager.updateHelpBadge();
expect(badgeIsVisible()).toBe(false);
});
it('has new content when a version marker exists and nothing has been viewed yet', () => {
setupDom({ versionMarker: '2026-09-03' });
const manager = new HelpManager();
expect(manager.hasNewContent()).toBe(true);
manager.updateHelpBadge();
expect(badgeIsVisible()).toBe(true);
});
it('has no new content once the stored viewed version matches the marker', () => {
setupDom({ versionMarker: '2026-09-03' });
localStorage.setItem(VIEWED_KEY, '2026-09-03');
const manager = new HelpManager();
expect(manager.hasNewContent()).toBe(false);
manager.updateHelpBadge();
expect(badgeIsVisible()).toBe(false);
});
it('has new content again when the marker moves to a newer version', () => {
setupDom({ versionMarker: '2026-09-03' });
localStorage.setItem(VIEWED_KEY, '2025-10-11');
const manager = new HelpManager();
expect(manager.hasNewContent()).toBe(true);
});
it('markContentAsViewed persists the DOM marker version', () => {
setupDom({ versionMarker: '2026-09-03' });
const manager = new HelpManager();
manager.markContentAsViewed();
expect(localStorage.getItem(VIEWED_KEY)).toBe('2026-09-03');
expect(manager.hasNewContent()).toBe(false);
});
it('markContentAsViewed is a no-op without a version marker (stale assets)', () => {
setupDom({ versionMarker: null });
const manager = new HelpManager();
manager.markContentAsViewed();
expect(localStorage.getItem(VIEWED_KEY)).toBeNull();
});
it('opening the help modal without new content does not mark it as viewed', () => {
// Regression test: on a stale (pre-upgrade) page the user may open the
// help modal before refreshing; that must not suppress the badge for
// the new content they have not seen yet.
setupDom({ versionMarker: null });
window.modalManager = { toggleModal: vi.fn() };
const manager = new HelpManager();
manager.openHelpModal();
expect(localStorage.getItem(VIEWED_KEY)).toBeNull();
expect(manager.hasNewContent()).toBe(false);
delete window.modalManager;
});
it('opening the help modal with new content marks it as viewed and hides the badge', () => {
setupDom({ versionMarker: '2026-09-03' });
window.modalManager = { toggleModal: vi.fn() };
const manager = new HelpManager();
manager.updateHelpBadge();
expect(badgeIsVisible()).toBe(true);
manager.openHelpModal();
expect(localStorage.getItem(VIEWED_KEY)).toBe('2026-09-03');
expect(badgeIsVisible()).toBe(false);
delete window.modalManager;
});
it('adds new-content indicators to the getting-started and shortcuts tabs', () => {
setupDom({ versionMarker: '2026-09-03' });
const manager = new HelpManager();
manager.updateNewContentTabIndicators();
expect(document.querySelector('.help-tabs .tab-btn[data-tab="getting-started"]').classList.contains('has-new-content')).toBe(true);
expect(document.querySelector('.help-tabs .tab-btn[data-tab="shortcuts"]').classList.contains('has-new-content')).toBe(true);
});
it('flags the Replay Tutorial button and scrolls it into view', () => {
setupDom({ versionMarker: '2026-09-03' });
const replayBtn = document.getElementById('replayTutorialBtn');
replayBtn.scrollIntoView = vi.fn();
const manager = new HelpManager();
manager.updateNewContentTabIndicators();
expect(replayBtn.classList.contains('has-new-content')).toBe(true);
expect(replayBtn.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'nearest' });
});
it('does not flag the Replay Tutorial button when the content is not new', () => {
setupDom({ versionMarker: '2026-09-03' });
localStorage.setItem(VIEWED_KEY, '2026-09-03');
const manager = new HelpManager();
manager.updateNewContentTabIndicators();
expect(document.getElementById('replayTutorialBtn').classList.contains('has-new-content')).toBe(false);
});
it('does not scroll the Replay Tutorial button when the getting-started tab is inactive', () => {
setupDom({ versionMarker: '2026-09-03' });
document.getElementById('getting-started').classList.remove('active');
const replayBtn = document.getElementById('replayTutorialBtn');
replayBtn.scrollIntoView = vi.fn();
const manager = new HelpManager();
manager.updateNewContentTabIndicators();
expect(replayBtn.scrollIntoView).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,130 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
import { setStorageItem, removeStorageItem, setActiveFiltersListener } from '../../../static/js/utils/storageHelpers.js';
import { initActiveFiltersSync, pushActiveFilters } from '../../../static/js/utils/activeFiltersSync.js';
const okResponse = () => ({ ok: true, status: 200 });
describe('activeFiltersSync', () => {
let fetchMock;
beforeEach(() => {
fetchMock = vi.fn(() => Promise.resolve(okResponse()));
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
it('pushes current state immediately on init', async () => {
setStorageItem('loras_activeFolder', 'SD_XL');
setStorageItem('loras_recursiveSearch', false);
setStorageItem('loras_filters', { baseModel: ['SDXL 1.0'], tags: { anime: 'include' } });
initActiveFiltersSync('loras');
await Promise.resolve();
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, options] = fetchMock.mock.calls[0];
expect(url).toBe('/api/lm/loras/active-filters');
expect(options.method).toBe('PUT');
expect(JSON.parse(options.body)).toEqual({
activeFolder: 'SD_XL',
recursiveSearch: false,
filters: { baseModel: ['SDXL 1.0'], tags: { anime: 'include' } },
});
});
it('syncs with debounce when a filter key changes', async () => {
vi.useFakeTimers();
initActiveFiltersSync('loras');
fetchMock.mockClear();
setStorageItem('loras_activeFolder', 'anime');
setStorageItem('loras_activeFolder', 'anime/sub');
expect(fetchMock).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(400);
expect(fetchMock).toHaveBeenCalledTimes(1);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.activeFolder).toBe('anime/sub');
});
it('does not sync for unrelated storage keys', async () => {
vi.useFakeTimers();
initActiveFiltersSync('loras');
fetchMock.mockClear();
setStorageItem('loras_sort', 'name');
setStorageItem('theme', 'dark');
await vi.advanceTimersByTimeAsync(1000);
expect(fetchMock).not.toHaveBeenCalled();
});
it('pushes null filters after the filters key is removed', async () => {
vi.useFakeTimers();
setStorageItem('loras_filters', { baseModel: ['Pony'] });
initActiveFiltersSync('loras');
fetchMock.mockClear();
removeStorageItem('loras_filters');
await vi.advanceTimersByTimeAsync(400);
expect(fetchMock).toHaveBeenCalledTimes(1);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.filters).toBeNull();
});
it('normalizes the legacy "null" folder string to null', async () => {
localStorage.setItem('lora_manager_loras_activeFolder', 'null');
await pushActiveFilters('loras');
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.activeFolder).toBeNull();
expect(body.recursiveSearch).toBe(true);
});
it('warns instead of throwing when the request fails', async () => {
fetchMock.mockRejectedValue(new Error('network down'));
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
await expect(pushActiveFilters('loras')).resolves.toBeUndefined();
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
});
describe('storageHelpers active-filter listener', () => {
afterEach(() => {
setActiveFiltersListener(null);
});
it('notifies with the page type for filter keys', () => {
const listener = vi.fn();
setActiveFiltersListener(listener);
setStorageItem('loras_activeFolder', 'a');
setStorageItem('checkpoints_recursiveSearch', true);
removeStorageItem('embeddings_filters');
expect(listener.mock.calls.map((call) => call[0])).toEqual([
'loras',
'checkpoints',
'embeddings',
]);
});
it('ignores non-filter keys', () => {
const listener = vi.fn();
setActiveFiltersListener(listener);
setStorageItem('loras_sort', 'name');
removeStorageItem('version_info');
expect(listener).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,59 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
isSoftwareRendererString,
applyModalBackdropBlurPolicy,
} from '../../../static/js/utils/renderingCapability.js';
describe('isSoftwareRendererString', () => {
it('detects SwiftShader (Chrome with hardware acceleration disabled)', () => {
expect(isSoftwareRendererString(
'WebKit WebGL SwiftShader'
)).toBe(true);
expect(isSoftwareRendererString(
'ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)'
)).toBe(true);
});
it('detects Mesa software rasterizers (Linux)', () => {
expect(isSoftwareRendererString('llvmpipe (LLVM 17.0.6, 256 bits)')).toBe(true);
expect(isSoftwareRendererString('softpipe')).toBe(true);
});
it('detects generic software renderer strings', () => {
expect(isSoftwareRendererString('Software Renderer')).toBe(true);
expect(isSoftwareRendererString('Microsoft Basic Render Driver')).toBe(true);
});
it('accepts hardware GPU strings', () => {
expect(isSoftwareRendererString(
'ANGLE (NVIDIA, NVIDIA GeForce RTX 4090 Direct3D11 vs_5_0 ps_5_0, D3D11)'
)).toBe(false);
expect(isSoftwareRendererString(
'ANGLE (AMD, AMD Radeon RX 7900 XTX (0x0000744C) Direct3D11 vs_5_0 ps_5_0, D3D11)'
)).toBe(false);
expect(isSoftwareRendererString('Apple M4 Pro')).toBe(false);
expect(isSoftwareRendererString('Mesa Intel(R) UHD Graphics 620 (KBL GT2)')).toBe(false);
});
it('handles empty input', () => {
expect(isSoftwareRendererString('')).toBe(false);
expect(isSoftwareRendererString(null)).toBe(false);
});
});
describe('applyModalBackdropBlurPolicy', () => {
beforeEach(() => {
document.documentElement.classList.remove('no-modal-backdrop-blur');
});
it('adds the disabling class under software rendering', () => {
applyModalBackdropBlurPolicy(true);
expect(document.documentElement.classList.contains('no-modal-backdrop-blur')).toBe(true);
});
it('removes the disabling class under hardware rendering', () => {
document.documentElement.classList.add('no-modal-backdrop-blur');
applyModalBackdropBlurPolicy(false);
expect(document.documentElement.classList.contains('no-modal-backdrop-blur')).toBe(false);
});
});
@@ -0,0 +1,197 @@
import json
import logging
from types import SimpleNamespace
import pytest
from multidict import MultiDict
from py.routes.handlers.model_handlers import ModelQueryHandler
from py.services.active_filters_store import ActiveFiltersStore
class DummyService:
model_type = "loras"
def __init__(self):
self.calls = []
async def search_relative_paths(self, search, limit, offset, **kwargs):
self.calls.append((search, limit, offset, kwargs))
return []
def make_handler(service=None):
return ModelQueryHandler(
service=service or DummyService(), logger=logging.getLogger(__name__)
)
def make_request(query=None, body=None, raise_on_json=False):
async def json_body():
if raise_on_json:
raise ValueError("bad json")
return body
return SimpleNamespace(
query=MultiDict(query or {}),
json=json_body,
)
@pytest.fixture(autouse=True)
def reset_store():
ActiveFiltersStore.reset_instance()
yield
ActiveFiltersStore.reset_instance()
@pytest.mark.asyncio
async def test_update_active_filters_stores_sanitized_payload():
handler = make_handler()
response = await handler.update_active_filters(
make_request(
body={
"activeFolder": "SD_XL",
"recursiveSearch": False,
"filters": {"baseModel": ["SDXL 1.0"], "rogue": "dropped"},
"rogue": "dropped",
}
)
)
assert response.status == 200
stored = ActiveFiltersStore.get_instance().get_filters("loras")
assert stored == {
"activeFolder": "SD_XL",
"recursiveSearch": False,
"filters": {"baseModel": ["SDXL 1.0"]},
}
@pytest.mark.asyncio
async def test_update_active_filters_rejects_invalid_json():
handler = make_handler()
response = await handler.update_active_filters(
make_request(raise_on_json=True)
)
assert response.status == 400
@pytest.mark.asyncio
async def test_update_active_filters_rejects_non_object_body():
handler = make_handler()
response = await handler.update_active_filters(make_request(body=["not", "dict"]))
assert response.status == 400
@pytest.mark.asyncio
async def test_get_active_filters_returns_stored_payload():
ActiveFiltersStore.get_instance().set_filters(
"loras", {"activeFolder": "anime", "recursiveSearch": True, "filters": None}
)
handler = make_handler()
response = await handler.get_active_filters(make_request())
payload = json.loads(response.text)
assert payload["success"] is True
assert payload["filters"]["activeFolder"] == "anime"
@pytest.mark.asyncio
async def test_get_active_filters_returns_null_when_unset():
handler = make_handler()
response = await handler.get_active_filters(make_request())
payload = json.loads(response.text)
assert payload["success"] is True
assert payload["filters"] is None
@pytest.mark.asyncio
async def test_relative_paths_injects_stored_active_filters():
ActiveFiltersStore.get_instance().set_filters(
"loras",
{
"activeFolder": "SD_XL",
"recursiveSearch": False,
"filters": {
"baseModel": ["SDXL 1.0"],
"tags": {"anime": "include"},
"tagLogic": "all",
},
},
)
service = DummyService()
handler = make_handler(service)
response = await handler.get_relative_paths(
make_request({"search": "cartoon", "use_active_filters": "true"})
)
assert response.status == 200
_, _, _, kwargs = service.calls[0]
assert kwargs["folder"] == "SD_XL"
assert kwargs["recursive"] is False
assert kwargs["base_models"] == ["SDXL 1.0"]
assert kwargs["tags"] == {"anime": "include"}
assert kwargs["tag_logic"] == "all"
assert kwargs["apply_filters"] is True
@pytest.mark.asyncio
async def test_relative_paths_explicit_params_take_precedence():
ActiveFiltersStore.get_instance().set_filters(
"loras",
{
"activeFolder": "SD_XL",
"recursiveSearch": True,
"filters": {"baseModel": ["SDXL 1.0"]},
},
)
service = DummyService()
handler = make_handler(service)
await handler.get_relative_paths(
make_request(
{
"search": "cartoon",
"use_active_filters": "true",
"folder": "pony",
"base_model": "Pony",
}
)
)
_, _, _, kwargs = service.calls[0]
assert kwargs["folder"] == "pony"
assert kwargs["base_models"] == ["Pony"]
@pytest.mark.asyncio
async def test_relative_paths_empty_store_still_runs_filter_pipeline():
service = DummyService()
handler = make_handler(service)
await handler.get_relative_paths(
make_request({"search": "cartoon", "use_active_filters": "true"})
)
_, _, _, kwargs = service.calls[0]
assert kwargs["apply_filters"] is True
assert kwargs["folder"] is None
assert kwargs["base_models"] == []
@pytest.mark.asyncio
async def test_relative_paths_without_flag_ignores_store():
ActiveFiltersStore.get_instance().set_filters(
"loras", {"activeFolder": "SD_XL", "recursiveSearch": True, "filters": None}
)
service = DummyService()
handler = make_handler(service)
await handler.get_relative_paths(make_request({"search": "cartoon"}))
_, _, _, kwargs = service.calls[0]
assert kwargs["folder"] is None
assert kwargs["apply_filters"] is False
+82
View File
@@ -2222,12 +2222,94 @@ async def test_reimport_without_source_path_falls_back_to_recipe_file(
# The already-optimized preview image must be stored verbatim. # The already-optimized preview image must be stored verbatim.
assert harness.persistence.save_calls[-1]["skip_optimize"] is True assert harness.persistence.save_calls[-1]["skip_optimize"] is True
assert harness.persistence.save_calls[-1]["image_bytes"] == b"fake-image" assert harness.persistence.save_calls[-1]["image_bytes"] == b"fake-image"
# The fallback source is the recipe's own previous preview, which gets
# deleted with the old recipe — it must not be recorded as source_path.
assert harness.persistence.save_calls[-1]["metadata"]["source_path"] == ""
# User edits (title, tags) are carried over to the new recipe. # User edits (title, tags) are carried over to the new recipe.
assert harness.persistence.update_calls[-1]["recipe_id"] == "new-rec" assert harness.persistence.update_calls[-1]["recipe_id"] == "new-rec"
assert harness.persistence.update_calls[-1]["updates"]["title"] == "Old title" assert harness.persistence.update_calls[-1]["updates"]["title"] == "Old title"
assert harness.persistence.update_calls[-1]["updates"]["tags"] == ["tag1"] assert harness.persistence.update_calls[-1]["updates"]["tags"] == ["tag1"]
async def test_reimport_with_dangling_source_path_falls_back_to_recipe_file(
monkeypatch, tmp_path: Path
) -> None:
"""A source_path pointing to a deleted file (left by an earlier re-import)
must not block re-import: fall back to the recipe's own saved image and
clear the dangling source_path."""
async with recipe_harness(monkeypatch, tmp_path) as harness:
recipe_file = harness.tmp_dir / "recipes" / "rec3.webp"
recipe_file.parent.mkdir(parents=True, exist_ok=True)
recipe_file.write_bytes(b"fake-image")
harness.scanner.recipes["rec3"] = {
"id": "rec3",
"title": "Dangling source",
"file_path": str(recipe_file),
"tags": [],
# Dangling local path: the file no longer exists.
"source_path": str(harness.tmp_dir / "recipes" / "deleted.webp"),
}
harness.analysis.result = SimpleNamespace(
payload={"success": True, "recipe_id": "new-rec-3", "loras": []},
status=200,
)
harness.persistence.save_result = SimpleNamespace(
payload={"success": True, "recipe_id": "new-rec-3"}, status=200
)
response = await harness.client.post("/api/lm/recipe/rec3/reimport")
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
assert payload["recipe_id"] == "new-rec-3"
assert harness.analysis.local_calls == [str(recipe_file)]
assert harness.persistence.delete_calls == ["rec3"]
# The dangling path is not carried over to the new recipe.
assert harness.persistence.save_calls[-1]["metadata"]["source_path"] == ""
async def test_reimport_with_accessible_local_source_keeps_source_path(
monkeypatch, tmp_path: Path
) -> None:
"""When the recorded source_path is an existing external file, it remains
the source of truth and stays recorded on the new recipe."""
async with recipe_harness(monkeypatch, tmp_path) as harness:
source_file = harness.tmp_dir / "imports" / "original.png"
source_file.parent.mkdir(parents=True, exist_ok=True)
source_file.write_bytes(b"original-image")
recipe_file = harness.tmp_dir / "recipes" / "rec4.webp"
recipe_file.parent.mkdir(parents=True, exist_ok=True)
recipe_file.write_bytes(b"fake-image")
harness.scanner.recipes["rec4"] = {
"id": "rec4",
"title": "External source",
"file_path": str(recipe_file),
"tags": [],
"source_path": str(source_file),
}
harness.analysis.result = SimpleNamespace(
payload={"success": True, "recipe_id": "new-rec-4", "loras": []},
status=200,
)
harness.persistence.save_result = SimpleNamespace(
payload={"success": True, "recipe_id": "new-rec-4"}, status=200
)
response = await harness.client.post("/api/lm/recipe/rec4/reimport")
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
# The external source file is re-parsed, not the recipe preview.
assert harness.analysis.local_calls == [str(source_file)]
assert harness.persistence.save_calls[-1]["metadata"]["source_path"] == str(
source_file
)
async def test_reimport_without_any_source_returns_400( async def test_reimport_without_any_source_returns_400(
monkeypatch, tmp_path: Path monkeypatch, tmp_path: Path
) -> None: ) -> None:
+128
View File
@@ -0,0 +1,128 @@
import pytest
from py.services.active_filters_store import (
ActiveFiltersStore,
active_filters_to_query_kwargs,
)
@pytest.fixture(autouse=True)
def reset_store():
ActiveFiltersStore.reset_instance()
yield
ActiveFiltersStore.reset_instance()
def test_store_roundtrip():
store = ActiveFiltersStore.get_instance()
payload = {
"activeFolder": "SD_XL",
"recursiveSearch": False,
"filters": {"baseModel": ["SDXL 1.0"], "tags": {"anime": "include"}},
}
store.set_filters("loras", payload)
assert store.get_filters("loras") == payload
assert store.get_filters("checkpoints") is None
def test_store_sanitizes_payload():
store = ActiveFiltersStore.get_instance()
store.set_filters(
"loras",
{
"activeFolder": "anime",
"recursiveSearch": True,
"filters": {"baseModel": [], "unexpected": "dropped"},
"extra": "dropped",
},
)
stored = store.get_filters("loras")
assert stored == {
"activeFolder": "anime",
"recursiveSearch": True,
"filters": {"baseModel": []},
}
def test_store_non_dict_filters_become_none():
store = ActiveFiltersStore.get_instance()
store.set_filters("loras", {"activeFolder": None, "filters": "garbage"})
assert store.get_filters("loras")["filters"] is None
def test_store_clear():
store = ActiveFiltersStore.get_instance()
store.set_filters("loras", {"activeFolder": "x"})
store.clear("loras")
assert store.get_filters("loras") is None
def test_mapping_empty_payload():
assert active_filters_to_query_kwargs(None) == {}
assert active_filters_to_query_kwargs({}) == {}
assert active_filters_to_query_kwargs({"activeFolder": None}) == {"recursive": True}
def test_mapping_folder():
assert active_filters_to_query_kwargs(
{"activeFolder": "SD_XL", "recursiveSearch": True}
) == {"folder": "SD_XL", "recursive": True}
def test_mapping_root_folder_non_recursive():
# Root folder with recursion disabled matches only root-level files
assert active_filters_to_query_kwargs(
{"activeFolder": None, "recursiveSearch": False}
) == {"folder": "", "recursive": False}
def test_mapping_legacy_null_string_folder():
assert active_filters_to_query_kwargs(
{"activeFolder": "null", "recursiveSearch": True}
) == {"recursive": True}
def test_mapping_full_filters():
kwargs = active_filters_to_query_kwargs(
{
"activeFolder": "anime",
"recursiveSearch": True,
"filters": {
"baseModel": ["SDXL 1.0", "Pony"],
"tags": {"anime": "include", "3d": "exclude", "junk": "ignored"},
"autoTags": {"cute": "include"},
"modelTypes": ["LoRA"],
"tagLogic": "all",
"license": {"noCredit": "include", "allowSelling": "exclude"},
},
}
)
assert kwargs == {
"folder": "anime",
"recursive": True,
"base_models": ["SDXL 1.0", "Pony"],
"tags": {"anime": "include", "3d": "exclude"},
"auto_tags": {"cute": "include"},
"model_types": ["LoRA"],
"tag_logic": "all",
"credit_required": False,
"allow_selling_generated_content": False,
}
def test_mapping_license_exclude_variants():
kwargs = active_filters_to_query_kwargs(
{
"filters": {
"license": {"noCredit": "exclude", "allowSelling": "include"},
},
}
)
assert kwargs["credit_required"] is True
assert kwargs["allow_selling_generated_content"] is True
+186
View File
@@ -30,10 +30,14 @@ from py.utils.models import BaseModelMetadata
class RecordingWebSocketManager: class RecordingWebSocketManager:
def __init__(self) -> None: def __init__(self) -> None:
self.payloads: List[Dict[str, Any]] = [] self.payloads: List[Dict[str, Any]] = []
self.broadcasts: List[Dict[str, Any]] = []
async def broadcast_init_progress(self, payload: Dict[str, Any]) -> None: async def broadcast_init_progress(self, payload: Dict[str, Any]) -> None:
self.payloads.append(payload) self.payloads.append(payload)
async def broadcast(self, payload: Dict[str, Any]) -> None:
self.broadcasts.append(payload)
def _normalize_path(path: Path) -> str: def _normalize_path(path: Path) -> str:
return str(path).replace(os.sep, "/") return str(path).replace(os.sep, "/")
@@ -1395,3 +1399,185 @@ async def test_get_all_folders_invalidated_after_move(tmp_path: Path):
assert "new" in all_folders assert "new" in all_folders
assert "new/deep" in all_folders assert "new/deep" in all_folders
assert set(cache.folders) <= set(all_folders) assert set(cache.folders) <= set(all_folders)
@pytest.mark.asyncio
async def test_initialize_cache_broadcasts_scan_progress(tmp_path: Path, monkeypatch):
_create_files(tmp_path)
scanner = DummyScanner(tmp_path)
ws_stub = RecordingWebSocketManager()
monkeypatch.setattr(model_scanner, "ws_manager", ws_stub)
await scanner._initialize_cache()
messages = ws_stub.broadcasts
assert messages, "expected scan_progress broadcasts"
started = messages[0]
assert started["type"] == "scan_progress"
assert started["status"] == "started"
assert started["stage"] == "scan_folders"
assert started["progress"] == 0
assert started["model_type"] == "dummy"
assert started["pageType"] == "dummy"
assert started["full_rebuild"] is True
count_messages = [m for m in messages if m["stage"] == "count_models"]
assert count_messages and count_messages[0]["total"] == 3
process_messages = [
m for m in messages
if m["stage"] == "process_models" and m["status"] == "processing"
]
assert process_messages, "expected at least one process_models update"
final_process = process_messages[-1]
assert final_process["processed"] == 3
assert final_process["total"] == 3
assert final_process["current_name"].endswith(".txt")
for message in process_messages:
assert 0 < message["progress"] <= 99
stages = [m["stage"] for m in messages]
assert "finalizing" in stages
completed = messages[-1]
assert completed["status"] == "completed"
assert completed["progress"] == 100
assert completed["elapsed_seconds"] >= 0
@pytest.mark.asyncio
async def test_initialize_cache_broadcasts_cancelled(tmp_path: Path, monkeypatch):
_create_files(tmp_path)
scanner = DummyScanner(tmp_path)
ws_stub = RecordingWebSocketManager()
monkeypatch.setattr(model_scanner, "ws_manager", ws_stub)
original_process = DummyScanner._process_model_file
async def cancelling_process(self, file_path, root_path, **kwargs):
scanner.cancel_task()
return await original_process(self, file_path, root_path, **kwargs)
monkeypatch.setattr(DummyScanner, "_process_model_file", cancelling_process)
await scanner._initialize_cache()
messages = ws_stub.broadcasts
assert messages[0]["status"] == "started"
assert messages[-1]["status"] == "cancelled"
assert messages[-1]["elapsed_seconds"] >= 0
assert not any(m["status"] == "completed" for m in messages)
@pytest.mark.asyncio
async def test_initialize_cache_broadcasts_error(tmp_path: Path, monkeypatch):
scanner = DummyScanner(tmp_path)
ws_stub = RecordingWebSocketManager()
monkeypatch.setattr(model_scanner, "ws_manager", ws_stub)
async def raising_gather(**_kwargs):
raise RuntimeError("boom")
monkeypatch.setattr(scanner, "_gather_model_data", raising_gather)
await scanner._initialize_cache()
messages = ws_stub.broadcasts
assert messages[0]["status"] == "started"
assert messages[-1]["status"] == "error"
assert messages[-1]["error"] == "boom"
@pytest.mark.asyncio
async def test_reconcile_cache_broadcasts_scan_progress(tmp_path: Path, monkeypatch):
_create_files(tmp_path)
scanner = DummyScanner(tmp_path)
await scanner._initialize_cache()
ws_stub = RecordingWebSocketManager()
monkeypatch.setattr(model_scanner, "ws_manager", ws_stub)
new_file = tmp_path / "three.txt"
new_file.write_text("three", encoding="utf-8")
await scanner._reconcile_cache()
messages = ws_stub.broadcasts
assert messages, "expected scan_progress broadcasts"
started = messages[0]
assert started["type"] == "scan_progress"
assert started["status"] == "started"
assert started["stage"] == "reconcile_scan"
assert started["progress"] == 0
assert started["full_rebuild"] is False
process_messages = [
m for m in messages
if m["stage"] == "process_new" and m["status"] == "processing"
]
assert process_messages, "expected process_new progress updates"
assert process_messages[-1]["processed"] == 1
assert process_messages[-1]["total"] == 1
assert process_messages[-1]["current_name"] == "three.txt"
completed = messages[-1]
assert completed["status"] == "completed"
assert completed["progress"] == 100
assert completed["added"] == 1
assert completed["removed"] == 0
assert completed["elapsed_seconds"] >= 0
@pytest.mark.asyncio
async def test_reconcile_cache_broadcasts_cancelled(tmp_path: Path, monkeypatch):
_create_files(tmp_path)
scanner = DummyScanner(tmp_path)
await scanner._initialize_cache()
ws_stub = RecordingWebSocketManager()
monkeypatch.setattr(model_scanner, "ws_manager", ws_stub)
new_file = tmp_path / "four.txt"
new_file.write_text("four", encoding="utf-8")
original_process = DummyScanner._process_model_file
async def cancelling_process(self, file_path, root_path, **kwargs):
scanner.cancel_task()
return await original_process(self, file_path, root_path, **kwargs)
monkeypatch.setattr(DummyScanner, "_process_model_file", cancelling_process)
await scanner._reconcile_cache()
messages = ws_stub.broadcasts
assert messages[0]["status"] == "started"
assert messages[-1]["status"] == "cancelled"
assert messages[-1]["elapsed_seconds"] >= 0
assert not any(m["status"] == "completed" for m in messages)
@pytest.mark.asyncio
async def test_reconcile_cache_broadcasts_error(tmp_path: Path, monkeypatch):
_create_files(tmp_path)
scanner = DummyScanner(tmp_path)
await scanner._initialize_cache()
ws_stub = RecordingWebSocketManager()
monkeypatch.setattr(model_scanner, "ws_manager", ws_stub)
def raising_walk(*_args, **_kwargs):
raise RuntimeError("walk failed")
monkeypatch.setattr(model_scanner.os, "walk", raising_walk)
await scanner._reconcile_cache()
messages = ws_stub.broadcasts
assert messages[0]["status"] == "started"
assert messages[-1]["status"] == "error"
assert messages[-1]["error"] == "walk failed"
+131
View File
@@ -9,6 +9,7 @@ import pytest
from py.config import config from py.config import config
from py.services import model_scanner as model_scanner_module from py.services import model_scanner as model_scanner_module
from py.services import recipe_scanner as recipe_scanner_module
from py.services.model_cache import ModelCache from py.services.model_cache import ModelCache
from py.services.model_hash_index import ModelHashIndex from py.services.model_hash_index import ModelHashIndex
from py.services.model_scanner import CacheBuildResult, ModelScanner from py.services.model_scanner import CacheBuildResult, ModelScanner
@@ -4965,3 +4966,133 @@ async def test_find_all_duplicate_recipes_include_prompt_missing_gen_params(reci
groups = await scanner.find_all_duplicate_recipes(include_prompt=True) groups = await scanner.find_all_duplicate_recipes(include_prompt=True)
# Recipes without gen_params/prompt normalize to empty prompt and match # Recipes without gen_params/prompt normalize to empty prompt and match
assert groups == {"abc:0.8\x1f": ["r1", "r2"]} assert groups == {"abc:0.8\x1f": ["r1", "r2"]}
class RecordingRecipeWebSocketManager:
"""Minimal ws_manager stand-in that records broadcasts."""
def __init__(self) -> None:
self.payloads: list[Dict[str, Any]] = []
self.broadcasts: list[Dict[str, Any]] = []
async def broadcast_init_progress(self, payload: Dict[str, Any]) -> None:
self.payloads.append(payload)
async def broadcast(self, payload: Dict[str, Any]) -> None:
self.broadcasts.append(payload)
def _write_progress_recipe_files(recipes_dir: Path, count: int) -> None:
recipes_dir.mkdir(parents=True, exist_ok=True)
for idx in range(count):
recipe_path = recipes_dir / f"progress-recipe-{idx}.recipe.json"
recipe_path.write_text(
json.dumps(
{
"id": f"progress-recipe-{idx}",
"file_path": str(recipes_dir / f"img-{idx}.png"),
"title": f"Recipe {idx}",
"modified": 0.0,
"created_date": 0.0,
"loras": [],
}
),
encoding="utf-8",
)
@pytest.mark.asyncio
async def test_force_refresh_broadcasts_scan_progress(
tmp_path: Path, monkeypatch, recipe_scanner
):
scanner, _stub = recipe_scanner
recipes_dir = Path(config.loras_roots[0]) / "recipes"
_write_progress_recipe_files(recipes_dir, 3)
ws_stub = RecordingRecipeWebSocketManager()
monkeypatch.setattr(recipe_scanner_module, "ws_manager", ws_stub)
await scanner.get_cached_data(force_refresh=True)
# Wait for the FTS index build so no background task outlives the loop.
if scanner._fts_index_task:
await scanner._fts_index_task
messages = ws_stub.broadcasts
assert messages, "expected scan_progress broadcasts"
started = messages[0]
assert started["type"] == "scan_progress"
assert started["status"] == "started"
assert started["stage"] == "scan_folders"
assert started["progress"] == 0
assert started["model_type"] == "recipe"
assert started["pageType"] == "recipes"
assert started["full_rebuild"] is True
count_messages = [m for m in messages if m["stage"] == "count_models"]
assert count_messages and count_messages[0]["total"] == 3
process_messages = [
m
for m in messages
if m["stage"] == "process_models" and m["status"] == "processing"
]
assert process_messages, "expected at least one process_models update"
final_process = process_messages[-1]
assert final_process["processed"] == 3
assert final_process["total"] == 3
assert final_process["current_name"].endswith(".recipe.json")
for message in process_messages:
assert 0 < message["progress"] <= 99
completed = messages[-1]
assert completed["status"] == "completed"
assert completed["progress"] == 100
assert completed["elapsed_seconds"] >= 0
assert completed["total"] == 3
def test_sync_init_without_report_progress_does_not_broadcast(
tmp_path: Path, monkeypatch, recipe_scanner
):
"""Startup path (initialize_in_background) must not emit scan_progress."""
scanner, _stub = recipe_scanner
recipes_dir = Path(config.loras_roots[0]) / "recipes"
_write_progress_recipe_files(recipes_dir, 2)
ws_stub = RecordingRecipeWebSocketManager()
monkeypatch.setattr(recipe_scanner_module, "ws_manager", ws_stub)
# Invalidate the persistent cache so the sync path performs a full
# directory scan, exactly like a force refresh but without progress
# reporting (this is how initialize_in_background invokes it).
scanner._persistent_cache.save_cache([], {})
scanner._initialize_recipe_cache_sync()
assert ws_stub.broadcasts == []
def test_sync_init_reports_error_broadcast(
tmp_path: Path, monkeypatch, recipe_scanner
):
scanner, _stub = recipe_scanner
recipes_dir = Path(config.loras_roots[0]) / "recipes"
_write_progress_recipe_files(recipes_dir, 1)
ws_stub = RecordingRecipeWebSocketManager()
monkeypatch.setattr(recipe_scanner_module, "ws_manager", ws_stub)
scanner._persistent_cache.save_cache([], {})
def raising_scan(self, recipes_dir, progress_loop=None):
raise RuntimeError("boom")
monkeypatch.setattr(RecipeScanner, "_full_directory_scan_sync", raising_scan)
scanner._initialize_recipe_cache_sync(report_progress=True)
messages = ws_stub.broadcasts
assert messages[0]["status"] == "started"
assert messages[-1]["status"] == "error"
assert messages[-1]["error"] == "boom"
+75 -1
View File
@@ -49,9 +49,83 @@ async def test_search_relative_paths_supports_multiple_tokens():
matching = await service.search_relative_paths("flux detail") matching = await service.search_relative_paths("flux detail")
# Folder grouping takes precedence over cross-folder relevance:
# the "detail" folder sorts before "flux" alphabetically.
assert matching == [ assert matching == [
f"flux{os.sep}detail-model.safetensors",
f"detail{os.sep}flux-trained.safetensors", f"detail{os.sep}flux-trained.safetensors",
f"flux{os.sep}detail-model.safetensors",
]
@pytest.mark.asyncio
async def test_search_relative_paths_groups_by_folder_alphabetically():
"""Same-folder entries cluster together; folders sort alphabetically."""
scanner = FakeScanner(
[
{"file_path": "/models/zeta/model-z1.safetensors"},
{"file_path": "/models/alpha/model-a1.safetensors"},
{"file_path": "/models/zeta/model-z2.safetensors"},
{"file_path": "/models/alpha/model-a2.safetensors"},
{"file_path": "/models/model-root.safetensors"},
],
["/models"],
)
service = DummyService("stub", scanner, BaseModelMetadata)
matching = await service.search_relative_paths("model")
assert matching == [
# Root-level files (empty folder) come first
"model-root.safetensors",
f"alpha{os.sep}model-a1.safetensors",
f"alpha{os.sep}model-a2.safetensors",
f"zeta{os.sep}model-z1.safetensors",
f"zeta{os.sep}model-z2.safetensors",
]
@pytest.mark.asyncio
async def test_search_relative_paths_relevance_within_folder_group():
"""Within a folder group, the relevance ordering still applies."""
scanner = FakeScanner(
[
{"file_path": "/models/flux/x-detail-model.safetensors"},
{"file_path": "/models/flux/detail-model.safetensors"},
{"file_path": "/models/flux/a-very-long-detail-model-name.safetensors"},
],
["/models"],
)
service = DummyService("stub", scanner, BaseModelMetadata)
matching = await service.search_relative_paths("flux detail")
assert matching == [
# Prefix hit on the full path wins
f"flux{os.sep}detail-model.safetensors",
# Then earliest match position, then shorter path
f"flux{os.sep}x-detail-model.safetensors",
f"flux{os.sep}a-very-long-detail-model-name.safetensors",
]
@pytest.mark.asyncio
async def test_search_relative_paths_nested_folders_sort_naturally():
scanner = FakeScanner(
[
{"file_path": "/models/styles/anime/model-b.safetensors"},
{"file_path": "/models/styles/model-a.safetensors"},
{"file_path": "/models/other/model-c.safetensors"},
],
["/models"],
)
service = DummyService("stub", scanner, BaseModelMetadata)
matching = await service.search_relative_paths("model")
assert matching == [
f"other{os.sep}model-c.safetensors",
f"styles{os.sep}model-a.safetensors",
f"styles{os.sep}anime{os.sep}model-b.safetensors",
] ]
@@ -1,6 +1,10 @@
<template> <template>
<div class="autocomplete-text-widget"> <div class="autocomplete-text-widget">
<div class="input-wrapper"> <div
ref="inputWrapperRef"
class="input-wrapper"
:style="{ '--lm-vscrollbar-width': vScrollbarWidth + 'px' }"
>
<textarea <textarea
ref="textareaRef" ref="textareaRef"
:placeholder="placeholder" :placeholder="placeholder"
@@ -23,6 +27,18 @@
<line x1="6" y1="6" x2="18" y2="18" /> <line x1="6" y1="6" x2="18" y2="18" />
</svg> </svg>
</button> </button>
<button
v-if="isLorasMode"
type="button"
class="active-filters-toggle"
:class="{ 'is-active': activeFiltersEnabled }"
:title="activeFiltersToggleTitle"
@click="toggleActiveFiltersSearch"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
</svg>
</button>
</div> </div>
</div> </div>
</template> </template>
@@ -30,6 +46,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue' import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useAutocomplete } from '@/composables/useAutocomplete' import { useAutocomplete } from '@/composables/useAutocomplete'
// @ts-ignore - ComfyUI external module
import { LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID, SETTING_TOGGLED_EVENT_NAME, getLoraActiveFiltersAutocompletePreference, setLoraManagerSettingValue } from '../../../web/comfyui/settings.js'
// Access LiteGraph global for initial mode detection // Access LiteGraph global for initial mode detection
declare const LiteGraph: { vueNodesMode?: boolean } | undefined declare const LiteGraph: { vueNodesMode?: boolean } | undefined
@@ -59,14 +77,96 @@ const isVueDomMode = ref(typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesM
const onModeChange = (event: Event) => { const onModeChange = (event: Event) => {
const customEvent = event as CustomEvent<{ isVueDomMode: boolean }> const customEvent = event as CustomEvent<{ isVueDomMode: boolean }>
isVueDomMode.value = customEvent.detail.isVueDomMode isVueDomMode.value = customEvent.detail.isVueDomMode
// Canvas vs Vue DOM mode use different paddings/geometry.
updateVScrollbarWidth()
} }
const textareaRef = ref<HTMLTextAreaElement | null>(null) const textareaRef = ref<HTMLTextAreaElement | null>(null)
const inputWrapperRef = ref<HTMLElement | null>(null)
// Width of the textarea's own vertical scrollbar gutter. When the content
// overflows and a classic (non-overlay) scrollbar is shown, the scrollbar
// occupies the textarea's rightmost pixels and the absolutely-positioned
// corner buttons (clear x / active-filters filter) would overlap it. We
// expose this width as a CSS var so those buttons can shift left of the
// scrollbar; it is 0 when there is no scrollbar (content fits, or platform
// overlay scrollbars that float over the content).
const vScrollbarWidth = ref(0)
let scrollbarResizeObserver: ResizeObserver | null = null
const updateVScrollbarWidth = () => {
const ta = textareaRef.value
if (!ta) return
const overflowsY = ta.scrollHeight > ta.clientHeight
vScrollbarWidth.value = overflowsY ? ta.offsetWidth - ta.clientWidth : 0
}
const observeScrollbarWidth = () => {
unobserveScrollbarWidth()
const ta = textareaRef.value
if (!ta || typeof ResizeObserver === 'undefined') {
return
}
scrollbarResizeObserver = new ResizeObserver(() => {
updateVScrollbarWidth()
})
scrollbarResizeObserver.observe(ta)
}
const unobserveScrollbarWidth = () => {
if (scrollbarResizeObserver) {
scrollbarResizeObserver.disconnect()
scrollbarResizeObserver = null
}
}
const hasText = ref(false) const hasText = ref(false)
// Show clear button when there is text // Show clear button when there is text
const showClearButton = computed(() => hasText.value) const showClearButton = computed(() => hasText.value)
// Active-filters search indicator (loras nodes only). Mirrors the
// loramanager.lora_active_filters_autocomplete setting so users can
// discover and toggle the /activefilters mode without opening the
// dropdown or the settings dialog.
const isLorasMode = (props.modelType ?? 'loras') === 'loras'
const activeFiltersEnabled = ref(false)
const refreshActiveFiltersState = () => {
if (isLorasMode) {
activeFiltersEnabled.value = getLoraActiveFiltersAutocompletePreference()
}
}
const onSettingToggled = (event: Event) => {
const detail = (event as CustomEvent<{ settingId?: string; value?: unknown }>).detail
if (detail?.settingId === LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID) {
activeFiltersEnabled.value = detail.value === true
}
}
const activeFiltersToggleTitle = computed(() =>
activeFiltersEnabled.value
? 'Active Filters Search is ON: suggestions respect the LoRA Manager page filters. Click to disable, or type /noactivefilters.'
: 'Active Filters Search is OFF: suggestions search the full library. Click to enable, or type /activefilters.'
)
const toggleActiveFiltersSearch = async () => {
const newValue = !activeFiltersEnabled.value
try {
const success = await setLoraManagerSettingValue(
LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID,
newValue
)
if (!success) {
throw new Error('settings API unavailable')
}
activeFiltersEnabled.value = newValue
} catch (error) {
console.error('[Lora Manager] Failed to toggle active filters search:', error)
}
}
// Initialize autocomplete with direct ref access // Initialize autocomplete with direct ref access
useAutocomplete( useAutocomplete(
textareaRef, textareaRef,
@@ -79,6 +179,9 @@ const updateHasTextState = () => {
} }
const onInput = (event: Event) => { const onInput = (event: Event) => {
// Content may grow/shrink past the overflow threshold on each edit.
updateVScrollbarWidth()
// A clear via execCommand captures the full-text selection in the browser's // A clear via execCommand captures the full-text selection in the browser's
// undo entry; Ctrl+Z restores the content together with that selection. // undo entry; Ctrl+Z restores the content together with that selection.
// Collapse the caret so the restored text is not left selected. // Collapse the caret so the restored text is not left selected.
@@ -252,11 +355,22 @@ onMounted(() => {
// Setup widget.onSetValue callback // Setup widget.onSetValue callback
setupWidgetOnSetValue() setupWidgetOnSetValue()
// Active-filters indicator: read initial state and stay in sync with
// slash-command / context-menu toggles dispatched via settings.js
refreshActiveFiltersState()
window.addEventListener(SETTING_TOGGLED_EVENT_NAME, onSettingToggled)
// Keep the corner buttons clear of the textarea's vertical scrollbar.
updateVScrollbarWidth()
observeScrollbarWidth()
// Listen for custom event dispatched by main.ts // Listen for custom event dispatched by main.ts
document.addEventListener('lora-manager:vue-mode-change', onModeChange) document.addEventListener('lora-manager:vue-mode-change', onModeChange)
}) })
onUnmounted(() => { onUnmounted(() => {
unobserveScrollbarWidth()
// Clean up textarea reference // Clean up textarea reference
if (props.widget.inputEl === textareaRef.value) { if (props.widget.inputEl === textareaRef.value) {
props.widget.inputEl = undefined props.widget.inputEl = undefined
@@ -277,6 +391,7 @@ onUnmounted(() => {
// Remove event listener // Remove event listener
document.removeEventListener('lora-manager:vue-mode-change', onModeChange) document.removeEventListener('lora-manager:vue-mode-change', onModeChange)
window.removeEventListener(SETTING_TOGGLED_EVENT_NAME, onSettingToggled)
}) })
</script> </script>
@@ -333,7 +448,7 @@ onUnmounted(() => {
/* Clear button styles */ /* Clear button styles */
.clear-button { .clear-button {
position: absolute; position: absolute;
right: 6px; right: calc(6px + var(--lm-vscrollbar-width, 0px));
bottom: 6px; /* Changed from top to bottom */ bottom: 6px; /* Changed from top to bottom */
width: 18px; width: 18px;
height: 18px; height: 18px;
@@ -369,9 +484,61 @@ onUnmounted(() => {
height: 12px; height: 12px;
} }
/* Active-filters search indicator (loras nodes only) */
.active-filters-toggle {
position: absolute;
top: 3px;
right: calc(3px + var(--lm-vscrollbar-width, 0px));
width: 16px;
height: 16px;
padding: 2px;
margin: 0;
border: none;
border-radius: 4px;
background: rgba(128, 128, 128, 0.25);
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.7;
transition: opacity 0.2s ease, background-color 0.2s ease, color 0.2s ease;
z-index: 10;
}
.active-filters-toggle:hover {
opacity: 1;
background: rgba(128, 128, 128, 0.45);
color: rgba(255, 255, 255, 0.85);
}
.active-filters-toggle.is-active {
background: rgba(59, 130, 246, 0.35);
color: #7db8ff;
opacity: 1;
}
.active-filters-toggle svg {
width: 11px;
height: 11px;
}
/* Vue DOM mode adjustments for the indicator */
.text-input.vue-dom-mode ~ .active-filters-toggle {
top: 8px;
right: calc(8px + var(--lm-vscrollbar-width, 0px));
width: 20px;
height: 20px;
}
.text-input.vue-dom-mode ~ .active-filters-toggle svg {
width: 13px;
height: 13px;
}
/* Vue DOM mode adjustments for clear button */ /* Vue DOM mode adjustments for clear button */
.text-input.vue-dom-mode ~ .clear-button { .text-input.vue-dom-mode ~ .clear-button {
right: 8px; right: calc(8px + var(--lm-vscrollbar-width, 0px));
bottom: 10px; /* Changed from top to bottom, adjusted for Vue DOM padding */ bottom: 10px; /* Changed from top to bottom, adjusted for Vue DOM padding */
width: 20px; width: 20px;
height: 20px; height: 20px;
+17
View File
@@ -45,6 +45,23 @@ import { app } from '../../../scripts/app.js'
import { api } from '../../../scripts/api.js' import { api } from '../../../scripts/api.js'
// @ts-ignore // @ts-ignore
import { getPoolConfigFromConnectedNode, getActiveLorasFromNode, updateConnectedTriggerWords, updateDownstreamLoaders } from '../../web/comfyui/utils.js' import { getPoolConfigFromConnectedNode, getActiveLorasFromNode, updateConnectedTriggerWords, updateDownstreamLoaders } from '../../web/comfyui/utils.js'
// @ts-ignore
import { stripAutocompleteMetadataFromPromptResult } from '../../web/comfyui/autocomplete.js'
// Strip the autocomplete lastAccepted boundary from exported workflows.
// lastAccepted carries old prompt text (insertedText/textSnapshot) and is
// session-only state; it must not leak into exported JSON (#1093).
// graphToPrompt is shared by workflow export, Export API and queueing.
// Local saves go through the change-tracker snapshot (no graphToPrompt) and
// are intentionally left untouched so cross-session caret continuity is kept.
// Post-processing the resolved result avoids any window/race with
// change-tracker or copy/paste serialization of live state.
const originalGraphToPrompt = app.graphToPrompt.bind(app)
app.graphToPrompt = async (...args: unknown[]) => {
const result = await originalGraphToPrompt(...args)
stripAutocompleteMetadataFromPromptResult(result)
return result
}
function forwardMiddleMouseToCanvas(container: HTMLElement) { function forwardMiddleMouseToCanvas(container: HTMLElement) {
if (!container) return if (!container) return
@@ -10,7 +10,7 @@
import { nextTick } from 'vue' import { nextTick } from 'vue'
import { shallowMount } from '@vue/test-utils' import { shallowMount } from '@vue/test-utils'
import { describe, expect, it, vi, afterEach } from 'vitest' import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import AutocompleteTextWidget from '@/components/AutocompleteTextWidget.vue' import AutocompleteTextWidget from '@/components/AutocompleteTextWidget.vue'
function createMockWidget() { function createMockWidget() {
@@ -134,3 +134,192 @@ describe('AutocompleteTextWidget clear button', () => {
expect(widget.callback).toHaveBeenLastCalledWith('hello world') expect(widget.callback).toHaveBeenLastCalledWith('hello world')
}) })
}) })
/**
* Tests for the active-filters search indicator (loras mode only).
*
* The small filter chip in the textarea corner mirrors the
* loramanager.lora_active_filters_autocomplete setting: it reflects the
* current state, can toggle it, and stays in sync with slash-command /
* context-menu toggles via the lora-manager:setting-toggled window event.
*/
const settingsMocks = vi.hoisted(() => ({
getPreference: vi.fn(),
setValue: vi.fn(),
}))
vi.mock('../../../web/comfyui/settings.js', () => ({
LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID:
'loramanager.lora_active_filters_autocomplete',
SETTING_TOGGLED_EVENT_NAME: 'lora-manager:setting-toggled',
getLoraActiveFiltersAutocompletePreference: settingsMocks.getPreference,
setLoraManagerSettingValue: settingsMocks.setValue,
}))
const getActiveFiltersPreferenceMock = settingsMocks.getPreference
const setSettingValueMock = settingsMocks.setValue
function mountLorasWidget() {
const widget = createMockWidget()
const node = { id: 1 }
const wrapper = shallowMount(AutocompleteTextWidget, {
props: { widget, node, modelType: 'loras' },
attachTo: document.body,
})
return { wrapper, widget }
}
describe('AutocompleteTextWidget active-filters indicator', () => {
beforeEach(() => {
getActiveFiltersPreferenceMock.mockReset()
getActiveFiltersPreferenceMock.mockReturnValue(false)
setSettingValueMock.mockReset()
setSettingValueMock.mockResolvedValue(true)
})
it('renders only in loras mode', () => {
const loras = mountLorasWidget()
expect(loras.wrapper.find('.active-filters-toggle').exists()).toBe(true)
const widget = createMockWidget()
const prompt = shallowMount(AutocompleteTextWidget, {
props: { widget, node: { id: 2 }, modelType: 'prompt' },
attachTo: document.body,
})
expect(prompt.find('.active-filters-toggle').exists()).toBe(false)
})
it('reflects the current setting state', async () => {
const { wrapper } = mountLorasWidget()
await nextTick()
expect(wrapper.find('.active-filters-toggle').classes()).not.toContain('is-active')
getActiveFiltersPreferenceMock.mockReturnValue(true)
const wrapper2 = mountLorasWidget().wrapper
await nextTick()
expect(wrapper2.find('.active-filters-toggle').classes()).toContain('is-active')
})
it('toggles the setting when clicked', async () => {
const { wrapper } = mountLorasWidget()
await nextTick()
await wrapper.find('.active-filters-toggle').trigger('click')
expect(setSettingValueMock).toHaveBeenCalledWith(
'loramanager.lora_active_filters_autocomplete',
true
)
await nextTick()
expect(wrapper.find('.active-filters-toggle').classes()).toContain('is-active')
})
it('stays in sync with setting-toggled window events', async () => {
const { wrapper } = mountLorasWidget()
await nextTick()
expect(wrapper.find('.active-filters-toggle').classes()).not.toContain('is-active')
window.dispatchEvent(
new CustomEvent('lora-manager:setting-toggled', {
detail: {
settingId: 'loramanager.lora_active_filters_autocomplete',
value: true,
},
})
)
await nextTick()
expect(wrapper.find('.active-filters-toggle').classes()).toContain('is-active')
window.dispatchEvent(
new CustomEvent('lora-manager:setting-toggled', {
detail: { settingId: 'loramanager.some_other_setting', value: true },
})
)
await nextTick()
// Unrelated settings must not flip the indicator
expect(wrapper.find('.active-filters-toggle').classes()).toContain('is-active')
})
})
/**
* Tests for the vertical-scrollbar inset.
*
* When the textarea content overflows and a classic (non-overlay) scrollbar
* is shown, the absolutely-positioned corner buttons (clear x, active-filters
* filter chip) would sit on top of the scrollbar. The component measures the
* scrollbar gutter and exposes it as the --lm-vscrollbar-width CSS var on
* .input-wrapper so the buttons shift left of the scrollbar. jsdom does no
* layout, so overflow is simulated by overriding the scroll/dimension props.
*/
describe('AutocompleteTextWidget vertical scrollbar inset', () => {
function overrideTextareaMetrics(
ta: HTMLTextAreaElement,
metrics: { scrollHeight: number; clientHeight: number; offsetWidth: number; clientWidth: number }
) {
Object.defineProperty(ta, 'scrollHeight', { configurable: true, value: metrics.scrollHeight })
Object.defineProperty(ta, 'clientHeight', { configurable: true, value: metrics.clientHeight })
Object.defineProperty(ta, 'offsetWidth', { configurable: true, value: metrics.offsetWidth })
Object.defineProperty(ta, 'clientWidth', { configurable: true, value: metrics.clientWidth })
}
it('exposes the scrollbar width as a CSS var when the content overflows', async () => {
const { wrapper } = mountWidget()
const textarea = wrapper.find('textarea').element as HTMLTextAreaElement
overrideTextareaMetrics(textarea, {
scrollHeight: 200,
clientHeight: 100,
offsetWidth: 320,
clientWidth: 305, // 15px scrollbar gutter
})
textarea.dispatchEvent(new Event('input'))
await nextTick()
const wrapperEl = wrapper.find('.input-wrapper').element as HTMLElement
expect(wrapperEl.style.getPropertyValue('--lm-vscrollbar-width')).toBe('15px')
})
it('keeps the CSS var at 0px when there is no vertical scrollbar', async () => {
const { wrapper } = mountWidget()
const textarea = wrapper.find('textarea').element as HTMLTextAreaElement
overrideTextareaMetrics(textarea, {
scrollHeight: 100,
clientHeight: 100,
offsetWidth: 320,
clientWidth: 320,
})
textarea.dispatchEvent(new Event('input'))
await nextTick()
const wrapperEl = wrapper.find('.input-wrapper').element as HTMLElement
expect(wrapperEl.style.getPropertyValue('--lm-vscrollbar-width')).toBe('0px')
})
it('clears the inset once overflowing content is removed', async () => {
const { wrapper } = mountWidget()
const textarea = wrapper.find('textarea').element as HTMLTextAreaElement
overrideTextareaMetrics(textarea, {
scrollHeight: 200,
clientHeight: 100,
offsetWidth: 320,
clientWidth: 305,
})
textarea.dispatchEvent(new Event('input'))
await nextTick()
const wrapperEl = wrapper.find('.input-wrapper').element as HTMLElement
expect(wrapperEl.style.getPropertyValue('--lm-vscrollbar-width')).toBe('15px')
// Content now fits: no scrollbar → inset cleared
overrideTextareaMetrics(textarea, {
scrollHeight: 100,
clientHeight: 100,
offsetWidth: 320,
clientWidth: 320,
})
textarea.dispatchEvent(new Event('input'))
await nextTick()
expect(wrapperEl.style.getPropertyValue('--lm-vscrollbar-width')).toBe('0px')
})
})
+208 -94
View File
@@ -14,15 +14,19 @@ import {
getAutocompleteAppendCommaPreference, getAutocompleteAppendCommaPreference,
getAutocompleteAutoFormatPreference, getAutocompleteAutoFormatPreference,
getAutocompleteAcceptKeyPreference, getAutocompleteAcceptKeyPreference,
LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID,
getLoraActiveFiltersAutocompletePreference, getLoraActiveFiltersAutocompletePreference,
getPromptTagAutocompletePreference, getPromptTagAutocompletePreference,
getTagSpaceReplacementPreference, getTagSpaceReplacementPreference,
SETTING_TOGGLED_EVENT_NAME,
setLoraManagerSettingValue, setLoraManagerSettingValue,
} from "./settings.js"; } from "./settings.js";
import { showToast } from "./utils.js"; import { showToast } from "./utils.js";
// localStorage key for the one-time "how to disable" hint in the dropdown // localStorage key for the one-time "how to disable" hint in the dropdown
const FIRST_RUN_HINT_DISMISSED_KEY = 'lm:autocomplete-disable-tip-dismissed'; const FIRST_RUN_HINT_DISMISSED_KEY = 'lm:autocomplete-disable-tip-dismissed';
// localStorage key for the one-time "try active filters search" hint (loras nodes)
const ACTIVE_FILTERS_HINT_DISMISSED_KEY = 'lm:activefilters-tip-dismissed';
// Command definitions for category filtering // Command definitions for category filtering
const TAG_COMMANDS = { const TAG_COMMANDS = {
@@ -278,6 +282,112 @@ function createAutocompleteMetadataBase(textWidgetName = 'text') {
}; };
} }
const AUTOCOMPLETE_METADATA_WIDGET_PREFIX = '__lm_autocomplete_meta_';
const LORA_MANAGER_WIDGET_IDS_PROPERTY = '__lm_widget_ids'; // Must match vue-widgets/src/main.ts
/**
* Return a copy of an autocomplete metadata value without the lastAccepted
* boundary. lastAccepted carries insertedText/textSnapshot (old prompt text)
* and is session-only state; it must not leak into exported workflow JSON.
* Values without lastAccepted are returned as-is.
*
* @param {*} value - Widget metadata value (or any other widget value)
* @returns {*} The stripped copy, or the original value when untouched
*/
export function stripAutocompleteLastAccepted(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return value;
}
if (!('lastAccepted' in value)) {
return value;
}
const stripped = { ...value };
delete stripped.lastAccepted;
return stripped;
}
/**
* Strip lastAccepted from autocomplete metadata widgets on a serialized
* node's widgets_values / widgets_values_named. Array entries are aligned
* via properties.__lm_widget_ids (written by the extension's onSerialize).
* Operates on graph.serialize() output, which is already a deep copy.
*
* @param {Array} nodes - Serialized node array
*/
function stripAutocompleteMetadataFromNodes(nodes) {
if (!Array.isArray(nodes)) {
return;
}
for (const node of nodes) {
if (!node || typeof node !== 'object') {
continue;
}
const widgetIds = node.properties?.[LORA_MANAGER_WIDGET_IDS_PROPERTY];
if (Array.isArray(node.widgets_values) && Array.isArray(widgetIds)) {
for (let i = 0; i < node.widgets_values.length && i < widgetIds.length; i++) {
if (typeof widgetIds[i] === 'string'
&& widgetIds[i].startsWith(AUTOCOMPLETE_METADATA_WIDGET_PREFIX)) {
node.widgets_values[i] = stripAutocompleteLastAccepted(node.widgets_values[i]);
}
}
}
const named = node.widgets_values_named;
if (named && typeof named === 'object') {
for (const [key, value] of Object.entries(named)) {
if (key.startsWith(AUTOCOMPLETE_METADATA_WIDGET_PREFIX)) {
named[key] = stripAutocompleteLastAccepted(value);
}
}
}
}
}
/**
* Strip lastAccepted from autocomplete metadata widgets in a graphToPrompt()
* result (both the workflow document and the API prompt). Used by the widget
* bundle to keep exported workflows free of old prompt text while leaving
* live node state untouched.
*
* @param {*} result - graphToPrompt() result: { workflow, output }
* @returns {*} The same result object, with metadata entries replaced in place
*/
export function stripAutocompleteMetadataFromPromptResult(result) {
if (!result || typeof result !== 'object') {
return result;
}
const workflow = result.workflow;
if (workflow && typeof workflow === 'object') {
stripAutocompleteMetadataFromNodes(workflow.nodes);
const subgraphs = workflow.definitions?.subgraphs;
if (Array.isArray(subgraphs)) {
for (const subgraph of subgraphs) {
stripAutocompleteMetadataFromNodes(subgraph?.nodes);
}
}
}
const output = result.output;
if (output && typeof output === 'object') {
for (const nodeOutput of Object.values(output)) {
const inputs = nodeOutput?.inputs;
if (!inputs || typeof inputs !== 'object') {
continue;
}
for (const [key, value] of Object.entries(inputs)) {
if (key.startsWith(AUTOCOMPLETE_METADATA_WIDGET_PREFIX)) {
inputs[key] = stripAutocompleteLastAccepted(value);
}
}
}
}
return result;
}
function createDefaultBehavior(modelType) { function createDefaultBehavior(modelType) {
return { return {
enablePreview: false, enablePreview: false,
@@ -499,6 +609,7 @@ class AutoComplete {
this.onBlur = null; this.onBlur = null;
this.onDocumentClick = null; this.onDocumentClick = null;
this.onScroll = null; this.onScroll = null;
this.onSettingToggled = null;
this.init(); this.init();
} }
@@ -666,6 +777,22 @@ class AutoComplete {
}; };
document.addEventListener('click', this.onDocumentClick); document.addEventListener('click', this.onDocumentClick);
// React to setting changes that happen underneath an open dropdown
// (e.g. toggling the active-filters search from the node's filter
// chip can keep the dropdown open in ComfyUI). Refresh the state
// hints so they never show a message for the previous state.
if (this.onSettingToggled) {
window.removeEventListener(SETTING_TOGGLED_EVENT_NAME, this.onSettingToggled);
}
this.onSettingToggled = (e) => {
const detail = e && e.detail;
if (!detail || detail.settingId !== LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID) {
return;
}
this._refreshStateHints();
};
window.addEventListener(SETTING_TOGGLED_EVENT_NAME, this.onSettingToggled);
// Mark this element as having autocomplete events bound // Mark this element as having autocomplete events bound
this.inputElement._autocompleteEventsBound = true; this.inputElement._autocompleteEventsBound = true;
@@ -1317,88 +1444,17 @@ class AutoComplete {
} }
/** /**
* Build a URL-encoded query string from the LoRA Manager page's active * Return the query flag that tells the backend to inject the LoRA Manager
* filters in localStorage, or null when not applicable. * page's active filters (stored server-side) into the search, or null when
* not applicable. The filters themselves are synced to the backend by the
* manager page, so this works across browsers/origins where localStorage
* is not shared.
*/ */
_getActiveLoraFilters() { _getActiveLoraFilters() {
if (this.modelType !== 'loras' || !getLoraActiveFiltersAutocompletePreference()) { if (this.modelType !== 'loras' || !getLoraActiveFiltersAutocompletePreference()) {
return null; return null;
} }
try { return 'use_active_filters=true';
const params = new URLSearchParams();
const folder = localStorage.getItem('lora_manager_loras_activeFolder');
const recursiveRaw = localStorage.getItem('lora_manager_loras_recursiveSearch');
const recursive = recursiveRaw === null ? true : recursiveRaw.toLowerCase() === 'true';
if (folder && folder !== 'null') {
params.append('folder', folder);
} else if (!recursive) {
// Root folder with recursion disabled mirrors the page list,
// which matches only root-level files via folder=''.
params.append('folder', '');
}
const raw = localStorage.getItem('lora_manager_loras_filters');
if (raw) {
const filters = JSON.parse(raw);
if (Array.isArray(filters.baseModel)) {
filters.baseModel.forEach((m) => m && params.append('base_model', m));
}
if (filters.tags && typeof filters.tags === 'object') {
Object.entries(filters.tags).forEach(([tag, state]) => {
if (state === 'include') {
params.append('tag_include', tag);
} else if (state === 'exclude') {
params.append('tag_exclude', tag);
}
});
}
if (filters.autoTags && typeof filters.autoTags === 'object') {
Object.entries(filters.autoTags).forEach(([tag, state]) => {
if (state === 'include') {
params.append('auto_tag_include', tag);
} else if (state === 'exclude') {
params.append('auto_tag_exclude', tag);
}
});
}
if (Array.isArray(filters.modelTypes)) {
filters.modelTypes.forEach((t) => t && params.append('model_type', t));
}
if (filters.tagLogic) {
params.append('tag_logic', filters.tagLogic);
}
if (filters.license) {
if (filters.license.noCredit === 'include') {
params.append('credit_required', 'false');
} else if (filters.license.noCredit === 'exclude') {
params.append('credit_required', 'true');
}
if (filters.license.allowSelling === 'include') {
params.append('allow_selling_generated_content', 'true');
} else if (filters.license.allowSelling === 'exclude') {
params.append('allow_selling_generated_content', 'false');
}
}
}
// Always send recursive in filter mode — its presence also signals
// the backend to run the filter pipeline (e.g. show_only_sfw) even
// when no concrete filter is set, matching the list endpoint.
params.append('recursive', String(recursive));
return params.toString();
} catch (error) {
console.warn('[Lora Manager] Failed to read active filters for autocomplete:', error);
return null;
}
} }
async search(term = '', endpoint = null) { async search(term = '', endpoint = null) {
@@ -1469,7 +1525,8 @@ class AutoComplete {
} }
// Merge and deduplicate results while preserving order from backend // Merge and deduplicate results while preserving order from backend
// Backend returns results sorted by relevance, so we maintain that order // Backend returns results grouped by folder and sorted by relevance
// within each group, so we maintain that order
const seen = new Set(); const seen = new Set();
const mergedItems = []; const mergedItems = [];
@@ -1727,22 +1784,31 @@ class AutoComplete {
} }
/** /**
* Render a state hint below the slash command list so the autocomplete * Render a state hint below the slash command list so the toggle commands
* toggle commands explain themselves. Only applies to prompt nodes. * explain themselves. Prompt nodes advertise /autocomplete, loras nodes
* advertise /activefilters.
*/ */
_renderCommandListFooter() { _renderCommandListFooter() {
this._removeCommandListFooter(); this._removeCommandListFooter();
if (this.modelType !== 'prompt') { let text = null;
if (this.modelType === 'prompt') {
const enabled = getPromptTagAutocompletePreference();
text = enabled
? 'Tag autocomplete is ON — /noautocomplete to disable'
: 'Tag autocomplete is OFF — /autocomplete to enable';
} else if (this.modelType === 'loras') {
const enabled = getLoraActiveFiltersAutocompletePreference();
text = enabled
? 'Active Filters Search: ON — /noactivefilters to disable'
: 'Active Filters Search: OFF — /activefilters to enable';
} else {
return; return;
} }
const enabled = getPromptTagAutocompletePreference();
const footer = document.createElement('div'); const footer = document.createElement('div');
footer.className = 'lm-autocomplete-command-footer'; footer.className = 'lm-autocomplete-command-footer';
footer.textContent = enabled footer.textContent = text;
? 'Tag autocomplete is ON — /noautocomplete to disable'
: 'Tag autocomplete is OFF — /autocomplete to enable';
footer.style.cssText = ` footer.style.cssText = `
padding: 6px 12px; padding: 6px 12px;
font-size: 11px; font-size: 11px;
@@ -1765,23 +1831,66 @@ class AutoComplete {
} }
/** /**
* Show a one-time, dismissible hint inside the dropdown telling users how * Refresh state-dependent dropdown decorations (first-run Tip and the
* to disable tag autocomplete. Dismissal is persisted in localStorage. * slash-command-list footer) from the live setting. Called when the
* active-filters toggle changes underneath an open dropdown, so the
* dropdown never keeps showing a message for the previous state.
*/ */
_maybeShowFirstRunHint() { _refreshStateHints() {
if (this.firstRunHint) { if (!this.isVisible || this.modelType !== 'loras') {
return; return;
} }
if (this.modelType !== 'prompt' if (this.showingCommands) {
|| this.showingCommands // Command list: footer text carries the ON/OFF state.
this._renderCommandListFooter();
} else {
// Plain suggestions: the first-run Tip advertises /activefilters
// only while the feature is disabled.
this._maybeShowFirstRunHint();
}
}
/**
* Show a one-time, dismissible hint inside the dropdown surfacing the
* toggle commands: prompt nodes advertise /noautocomplete, loras nodes
* advertise /activefilters. Dismissal is persisted in localStorage.
*/
_maybeShowFirstRunHint() {
// Dropdown decorations can outlive a state change (the dropdown may
// stay open when the setting is toggled from the node, e.g. clicking
// the active-filters filter chip). Always recompute from the live
// state so a stale Tip is never kept for the previous state.
this._removeFirstRunHint();
let hintText = null;
let storageKey = null;
if (this.modelType === 'prompt') {
// Only hint during plain tag searches, not command/embedding modes
if (this.showingCommands
|| this.searchType !== 'custom_words' || this.searchType !== 'custom_words'
|| this.activeCommand) { || this.activeCommand) {
return; return;
} }
hintText = 'Tip: type /noautocomplete to turn off these suggestions';
storageKey = FIRST_RUN_HINT_DISMISSED_KEY;
} else if (this.modelType === 'loras') {
// Only advertise active-filters search while it is disabled
if (this.showingCommands || this.activeCommand) {
return;
}
if (getLoraActiveFiltersAutocompletePreference()) {
return;
}
hintText = 'Tip: type /activefilters to search within the LoRA Manager page filters';
storageKey = ACTIVE_FILTERS_HINT_DISMISSED_KEY;
} else {
return;
}
let dismissed = false; let dismissed = false;
try { try {
dismissed = localStorage.getItem(FIRST_RUN_HINT_DISMISSED_KEY) === '1'; dismissed = localStorage.getItem(storageKey) === '1';
} catch (e) { } catch (e) {
// localStorage unavailable - fall through and show the hint // localStorage unavailable - fall through and show the hint
} }
@@ -1803,7 +1912,7 @@ class AutoComplete {
`; `;
const text = document.createElement('span'); const text = document.createElement('span');
text.textContent = 'Tip: type /noautocomplete to turn off these suggestions'; text.textContent = hintText;
const closeBtn = document.createElement('button'); const closeBtn = document.createElement('button');
closeBtn.type = 'button'; closeBtn.type = 'button';
@@ -1820,7 +1929,7 @@ class AutoComplete {
`; `;
closeBtn.addEventListener('click', () => { closeBtn.addEventListener('click', () => {
try { try {
localStorage.setItem(FIRST_RUN_HINT_DISMISSED_KEY, '1'); localStorage.setItem(storageKey, '1');
} catch (e) { } catch (e) {
} }
this._removeFirstRunHint(); this._removeFirstRunHint();
@@ -3110,6 +3219,11 @@ class AutoComplete {
this.onDocumentClick = null; this.onDocumentClick = null;
} }
if (this.onSettingToggled) {
window.removeEventListener(SETTING_TOGGLED_EVENT_NAME, this.onSettingToggled);
this.onSettingToggled = null;
}
if (this.onScroll && this.scrollContainer) { if (this.onScroll && this.scrollContainer) {
this.scrollContainer.removeEventListener('scroll', this.onScroll); this.scrollContainer.removeEventListener('scroll', this.onScroll);
this.onScroll = null; this.onScroll = null;
+2 -2
View File
@@ -58,8 +58,8 @@ export async function updateConnectedLoraInfoNodes(node, selection) {
return; return;
} }
// No selection or inactive — clear the display on all connected info nodes // No selection — clear the display on all connected info nodes
if (!selection?.name || !selection?.active) { if (!selection?.name) {
for (const infoNode of infoNodes) { for (const infoNode of infoNodes) {
infoNode.__loraInfoReqId = (infoNode.__loraInfoReqId || 0) + 1; infoNode.__loraInfoReqId = (infoNode.__loraInfoReqId || 0) + 1;
if (typeof infoNode._setLoraInfo === "function") { if (typeof infoNode._setLoraInfo === "function") {
+70 -6
View File
@@ -1,5 +1,11 @@
import { app } from "../../scripts/app.js"; import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js"; import { api } from "../../scripts/api.js";
import {
LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID,
getLoraActiveFiltersAutocompletePreference,
setLoraManagerSettingValue,
} from "./settings.js";
import { showToast } from "./utils.js";
import { import {
collectActiveLorasFromChain, collectActiveLorasFromChain,
updateConnectedTriggerWords, updateConnectedTriggerWords,
@@ -12,6 +18,65 @@ import {
} from "./utils.js"; } from "./utils.js";
import { applyLoraValuesToText, debounce } from "./lora_syntax_utils.js"; import { applyLoraValuesToText, debounce } from "./lora_syntax_utils.js";
// Node classes whose "text" widget uses the loras autocomplete. Kept in sync
// with the broadcast-compatible classes in handleLoraCodeUpdate below.
const LORA_AUTOCOMPLETE_NODE_CLASSES = [
"Lora Loader (LoraManager)",
"Lora Stacker (LoraManager)",
"WanVideo Lora Select (LoraManager)",
"Create Hook LoRA (LoraManager)",
];
// Expose the active-filters search toggle in the node's right-click menu so
// users can discover the switch where the behavior actually happens, instead
// of only via the /activefilters and /noactivefilters slash commands. Mirrors
// the tag-autocomplete menu entry on Prompt (LoraManager) nodes.
function addActiveFiltersSearchMenuOption(nodeType) {
const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (_, options) {
getExtraMenuOptions?.apply?.(this, arguments);
options.push(null);
const filtersSearchEnabled = getLoraActiveFiltersAutocompletePreference();
options.push({
content: filtersSearchEnabled
? "Active Filters Search: ON (/noactivefilters to disable)"
: "Active Filters Search: OFF (/activefilters to enable)",
callback: async () => {
const newValue = !filtersSearchEnabled;
try {
const success = await setLoraManagerSettingValue(
LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID,
newValue
);
if (!success) {
throw new Error("settings API unavailable");
}
showToast({
severity: newValue ? "success" : "secondary",
summary: newValue
? "Active Filters Search Enabled"
: "Active Filters Search Disabled",
detail: newValue
? "LoRA autocomplete now respects the active filters of the LoRA Manager page. Type /noactivefilters in the LoRA field to disable."
: "LoRA autocomplete searches the full library again. Type /activefilters in the LoRA field to re-enable.",
life: 3000,
});
} catch (error) {
console.error("[Lora Manager] Failed to toggle setting:", error);
showToast({
severity: "error",
summary: "Error",
detail: "Failed to toggle active filters search setting",
life: 3000,
});
}
},
});
};
}
app.registerExtension({ app.registerExtension({
name: "LoraManager.LoraLoader", name: "LoraManager.LoraLoader",
@@ -35,12 +100,7 @@ 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 compatible nodes in the current graph // Find all compatible nodes in the current graph
const compatibleClasses = new Set([ const compatibleClasses = new Set(LORA_AUTOCOMPLETE_NODE_CLASSES);
"Lora Loader (LoraManager)",
"Lora Stacker (LoraManager)",
"WanVideo Lora Select (LoraManager)",
"Create Hook LoRA (LoraManager)",
]);
const targetNodes = getAllGraphNodes(app.graph) const targetNodes = getAllGraphNodes(app.graph)
.map(({ node }) => node) .map(({ node }) => node)
.filter((node) => compatibleClasses.has(node?.comfyClass)); .filter((node) => compatibleClasses.has(node?.comfyClass));
@@ -108,6 +168,10 @@ app.registerExtension({
}, },
async beforeRegisterNodeDef(nodeType, nodeData, app) { async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (LORA_AUTOCOMPLETE_NODE_CLASSES.includes(nodeType.comfyClass)) {
addActiveFiltersSearchMenuOption(nodeType);
}
if (nodeType.comfyClass == "Lora Loader (LoraManager)") { if (nodeType.comfyClass == "Lora Loader (LoraManager)") {
chainCallback(nodeType.prototype, "onNodeCreated", function () { chainCallback(nodeType.prototype, "onNodeCreated", function () {
// Enable widget serialization // Enable widget serialization
+21
View File
@@ -175,23 +175,42 @@ const getPromptTagAutocompletePreference = (() => {
/** /**
* Persist a LoRA Manager setting through ComfyUI's setting API. * Persist a LoRA Manager setting through ComfyUI's setting API.
* Returns true when the setting was written successfully. * Returns true when the setting was written successfully.
*
* Every successful write broadcasts a "lora-manager:setting-toggled" window
* event (see SETTING_TOGGLED_EVENT_NAME) so widgets mirroring the setting
* (e.g. the active-filters indicator in the autocomplete text widget) stay in
* sync with slash-command / context-menu toggles.
*/ */
const SETTING_TOGGLED_EVENT_NAME = "lora-manager:setting-toggled";
const setLoraManagerSettingValue = async (settingId, value) => { const setLoraManagerSettingValue = async (settingId, value) => {
const settingManager = app?.extensionManager?.setting; const settingManager = app?.extensionManager?.setting;
if (settingManager && typeof settingManager.set === "function") { if (settingManager && typeof settingManager.set === "function") {
await settingManager.set(settingId, value); await settingManager.set(settingId, value);
_notifySettingToggled(settingId, value);
return true; return true;
} }
const setting = app?.ui?.settings?.settingsById?.[settingId]; const setting = app?.ui?.settings?.settingsById?.[settingId];
if (setting) { if (setting) {
app.ui.settings.setSettingValue(settingId, value); app.ui.settings.setSettingValue(settingId, value);
_notifySettingToggled(settingId, value);
return true; return true;
} }
return false; return false;
}; };
const _notifySettingToggled = (settingId, value) => {
try {
window.dispatchEvent(new CustomEvent(SETTING_TOGGLED_EVENT_NAME, {
detail: { settingId, value },
}));
} catch (error) {
// Best-effort notification; ignore non-browser environments
}
};
const getAutocompleteAppendCommaPreference = (() => { const getAutocompleteAppendCommaPreference = (() => {
let settingsUnavailableLogged = false; let settingsUnavailableLogged = false;
@@ -597,6 +616,8 @@ app.registerExtension({
export { export {
PROMPT_TAG_AUTOCOMPLETE_SETTING_ID, PROMPT_TAG_AUTOCOMPLETE_SETTING_ID,
LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID,
SETTING_TOGGLED_EVENT_NAME,
getWheelSensitivity, getWheelSensitivity,
getAutoPathCorrectionPreference, getAutoPathCorrectionPreference,
getAutocompleteAppendCommaPreference, getAutocompleteAppendCommaPreference,
+294 -22
View File
@@ -2118,14 +2118,14 @@ to { transform: rotate(360deg);
padding: 20px 0; padding: 20px 0;
} }
.autocomplete-text-widget[data-v-4e322fec] { .autocomplete-text-widget[data-v-22743258] {
background: transparent; background: transparent;
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-sizing: border-box; box-sizing: border-box;
} }
.input-wrapper[data-v-4e322fec] { .input-wrapper[data-v-22743258] {
position: relative; position: relative;
flex: 1; flex: 1;
display: flex; display: flex;
@@ -2133,7 +2133,7 @@ to { transform: rotate(360deg);
} }
/* Canvas mode styles (default) - matches built-in comfy-multiline-input */ /* Canvas mode styles (default) - matches built-in comfy-multiline-input */
.text-input[data-v-4e322fec] { .text-input[data-v-22743258] {
flex: 1; flex: 1;
width: 100%; width: 100%;
background-color: var(--comfy-input-bg, #222); background-color: var(--comfy-input-bg, #222);
@@ -2152,7 +2152,7 @@ to { transform: rotate(360deg);
} }
/* Vue DOM mode styles - matches built-in p-textarea in Vue DOM mode */ /* Vue DOM mode styles - matches built-in p-textarea in Vue DOM mode */
.text-input.vue-dom-mode[data-v-4e322fec] { .text-input.vue-dom-mode[data-v-22743258] {
background-color: var(--color-charcoal-400, #313235); background-color: var(--color-charcoal-400, #313235);
color: #fff; color: #fff;
padding: 8px 12px 30px 12px; /* Reserve bottom space for clear button */ padding: 8px 12px 30px 12px; /* Reserve bottom space for clear button */
@@ -2161,14 +2161,14 @@ to { transform: rotate(360deg);
font-size: 12px; font-size: 12px;
font-family: inherit; font-family: inherit;
} }
.text-input[data-v-4e322fec]:focus { .text-input[data-v-22743258]:focus {
outline: none; outline: none;
} }
/* Clear button styles */ /* Clear button styles */
.clear-button[data-v-4e322fec] { .clear-button[data-v-22743258] {
position: absolute; position: absolute;
right: 6px; right: calc(6px + var(--lm-vscrollbar-width, 0px));
bottom: 6px; /* Changed from top to bottom */ bottom: 6px; /* Changed from top to bottom */
width: 18px; width: 18px;
height: 18px; height: 18px;
@@ -2189,31 +2189,79 @@ to { transform: rotate(360deg);
} }
/* Show clear button when hovering over input wrapper */ /* Show clear button when hovering over input wrapper */
.input-wrapper:hover .clear-button[data-v-4e322fec] { .input-wrapper:hover .clear-button[data-v-22743258] {
opacity: 0.7; opacity: 0.7;
pointer-events: auto; pointer-events: auto;
} }
.clear-button[data-v-4e322fec]:hover { .clear-button[data-v-22743258]:hover {
opacity: 1; opacity: 1;
background: rgba(255, 100, 100, 0.8); background: rgba(255, 100, 100, 0.8);
} }
.clear-button svg[data-v-4e322fec] { .clear-button svg[data-v-22743258] {
width: 12px; width: 12px;
height: 12px; height: 12px;
} }
/* Active-filters search indicator (loras nodes only) */
.active-filters-toggle[data-v-22743258] {
position: absolute;
top: 3px;
right: calc(3px + var(--lm-vscrollbar-width, 0px));
width: 16px;
height: 16px;
padding: 2px;
margin: 0;
border: none;
border-radius: 4px;
background: rgba(128, 128, 128, 0.25);
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.7;
transition: opacity 0.2s ease, background-color 0.2s ease, color 0.2s ease;
z-index: 10;
}
.active-filters-toggle[data-v-22743258]:hover {
opacity: 1;
background: rgba(128, 128, 128, 0.45);
color: rgba(255, 255, 255, 0.85);
}
.active-filters-toggle.is-active[data-v-22743258] {
background: rgba(59, 130, 246, 0.35);
color: #7db8ff;
opacity: 1;
}
.active-filters-toggle svg[data-v-22743258] {
width: 11px;
height: 11px;
}
/* Vue DOM mode adjustments for the indicator */
.text-input.vue-dom-mode ~ .active-filters-toggle[data-v-22743258] {
top: 8px;
right: calc(8px + var(--lm-vscrollbar-width, 0px));
width: 20px;
height: 20px;
}
.text-input.vue-dom-mode ~ .active-filters-toggle svg[data-v-22743258] {
width: 13px;
height: 13px;
}
/* Vue DOM mode adjustments for clear button */ /* Vue DOM mode adjustments for clear button */
.text-input.vue-dom-mode ~ .clear-button[data-v-4e322fec] { .text-input.vue-dom-mode ~ .clear-button[data-v-22743258] {
right: 8px; right: calc(8px + var(--lm-vscrollbar-width, 0px));
bottom: 10px; /* Changed from top to bottom, adjusted for Vue DOM padding */ bottom: 10px; /* Changed from top to bottom, adjusted for Vue DOM padding */
width: 20px; width: 20px;
height: 20px; height: 20px;
background: rgba(107, 114, 128, 0.6); background: rgba(107, 114, 128, 0.6);
} }
.text-input.vue-dom-mode ~ .clear-button[data-v-4e322fec]:hover { .text-input.vue-dom-mode ~ .clear-button[data-v-22743258]:hover {
background: oklch(62% 0.18 25); background: oklch(62% 0.18 25);
} }
.text-input.vue-dom-mode ~ .clear-button svg[data-v-4e322fec] { .text-input.vue-dom-mode ~ .clear-button svg[data-v-22743258] {
width: 14px; width: 14px;
height: 14px; height: 14px;
} }
@@ -15136,9 +15184,71 @@ function useAutocomplete(textareaRef, modelType = "loras", options = {}) {
refreshCaretHelper refreshCaretHelper
}; };
} }
const settingsStore = /* @__PURE__ */ new Map();
const app = {
extensionManager: {
setting: {
get: (id) => settingsStore.has(id) ? settingsStore.get(id) : void 0,
set: async (id, value) => {
settingsStore.set(id, value);
}
}
}
};
const LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID = "loramanager.lora_active_filters_autocomplete";
const LORA_ACTIVE_FILTERS_AUTOCOMPLETE_DEFAULT = false;
const SETTING_TOGGLED_EVENT_NAME = "lora-manager:setting-toggled";
const setLoraManagerSettingValue = async (settingId, value) => {
var _a2, _b, _c, _d;
const settingManager = (_a2 = app == null ? void 0 : app.extensionManager) == null ? void 0 : _a2.setting;
if (settingManager && typeof settingManager.set === "function") {
await settingManager.set(settingId, value);
_notifySettingToggled(settingId, value);
return true;
}
const setting = (_d = (_c = (_b = app == null ? void 0 : app.ui) == null ? void 0 : _b.settings) == null ? void 0 : _c.settingsById) == null ? void 0 : _d[settingId];
if (setting) {
app.ui.settings.setSettingValue(settingId, value);
_notifySettingToggled(settingId, value);
return true;
}
return false;
};
const _notifySettingToggled = (settingId, value) => {
try {
window.dispatchEvent(new CustomEvent(SETTING_TOGGLED_EVENT_NAME, {
detail: { settingId, value }
}));
} catch (error) {
}
};
const getLoraActiveFiltersAutocompletePreference = /* @__PURE__ */ (() => {
let settingsUnavailableLogged = false;
return () => {
var _a2;
const settingManager = (_a2 = app == null ? void 0 : app.extensionManager) == null ? void 0 : _a2.setting;
if (!settingManager || typeof settingManager.get !== "function") {
if (!settingsUnavailableLogged) {
console.warn("LoRA Manager: settings API unavailable, using default lora active filters autocomplete setting.");
settingsUnavailableLogged = true;
}
return LORA_ACTIVE_FILTERS_AUTOCOMPLETE_DEFAULT;
}
try {
const value = settingManager.get(LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID);
return value ?? LORA_ACTIVE_FILTERS_AUTOCOMPLETE_DEFAULT;
} catch (error) {
if (!settingsUnavailableLogged) {
console.warn("LoRA Manager: unable to read lora active filters autocomplete setting, using default.", error);
settingsUnavailableLogged = true;
}
return LORA_ACTIVE_FILTERS_AUTOCOMPLETE_DEFAULT;
}
};
})();
const _hoisted_1$1 = { class: "autocomplete-text-widget" }; const _hoisted_1$1 = { class: "autocomplete-text-widget" };
const _hoisted_2$1 = { class: "input-wrapper" }; const _hoisted_2$1 = ["placeholder", "spellcheck"];
const _hoisted_3$1 = ["placeholder", "spellcheck"]; const _hoisted_3$1 = ["title"];
const _sfc_main$1 = /* @__PURE__ */ defineComponent({ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
__name: "AutocompleteTextWidget", __name: "AutocompleteTextWidget",
props: { props: {
@@ -15156,10 +15266,68 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
const onModeChange = (event) => { const onModeChange = (event) => {
const customEvent = event; const customEvent = event;
isVueDomMode.value = customEvent.detail.isVueDomMode; isVueDomMode.value = customEvent.detail.isVueDomMode;
updateVScrollbarWidth();
}; };
const textareaRef = ref(null); const textareaRef = ref(null);
const inputWrapperRef = ref(null);
const vScrollbarWidth = ref(0);
let scrollbarResizeObserver = null;
const updateVScrollbarWidth = () => {
const ta = textareaRef.value;
if (!ta) return;
const overflowsY = ta.scrollHeight > ta.clientHeight;
vScrollbarWidth.value = overflowsY ? ta.offsetWidth - ta.clientWidth : 0;
};
const observeScrollbarWidth = () => {
unobserveScrollbarWidth();
const ta = textareaRef.value;
if (!ta || typeof ResizeObserver === "undefined") {
return;
}
scrollbarResizeObserver = new ResizeObserver(() => {
updateVScrollbarWidth();
});
scrollbarResizeObserver.observe(ta);
};
const unobserveScrollbarWidth = () => {
if (scrollbarResizeObserver) {
scrollbarResizeObserver.disconnect();
scrollbarResizeObserver = null;
}
};
const hasText = ref(false); const hasText = ref(false);
const showClearButton = computed(() => hasText.value); const showClearButton = computed(() => hasText.value);
const isLorasMode = (props.modelType ?? "loras") === "loras";
const activeFiltersEnabled = ref(false);
const refreshActiveFiltersState = () => {
if (isLorasMode) {
activeFiltersEnabled.value = getLoraActiveFiltersAutocompletePreference();
}
};
const onSettingToggled = (event) => {
const detail = event.detail;
if ((detail == null ? void 0 : detail.settingId) === LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID) {
activeFiltersEnabled.value = detail.value === true;
}
};
const activeFiltersToggleTitle = computed(
() => activeFiltersEnabled.value ? "Active Filters Search is ON: suggestions respect the LoRA Manager page filters. Click to disable, or type /noactivefilters." : "Active Filters Search is OFF: suggestions search the full library. Click to enable, or type /activefilters."
);
const toggleActiveFiltersSearch = async () => {
const newValue = !activeFiltersEnabled.value;
try {
const success = await setLoraManagerSettingValue(
LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID,
newValue
);
if (!success) {
throw new Error("settings API unavailable");
}
activeFiltersEnabled.value = newValue;
} catch (error) {
console.error("[Lora Manager] Failed to toggle active filters search:", error);
}
};
useAutocomplete( useAutocomplete(
textareaRef, textareaRef,
props.modelType ?? "loras", props.modelType ?? "loras",
@@ -15169,6 +15337,7 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
hasText.value = textareaRef.value ? textareaRef.value.value.length > 0 : false; hasText.value = textareaRef.value ? textareaRef.value.value.length > 0 : false;
}; };
const onInput = (event) => { const onInput = (event) => {
updateVScrollbarWidth();
if (event.inputType === "historyUndo") { if (event.inputType === "historyUndo") {
const ta = textareaRef.value; const ta = textareaRef.value;
if (ta && ta.selectionStart === 0 && ta.selectionEnd === ta.value.length) { if (ta && ta.selectionStart === 0 && ta.selectionEnd === ta.value.length) {
@@ -15266,9 +15435,14 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
props.widget.callback(textareaRef.value.value); props.widget.callback(textareaRef.value.value);
} }
setupWidgetOnSetValue(); setupWidgetOnSetValue();
refreshActiveFiltersState();
window.addEventListener(SETTING_TOGGLED_EVENT_NAME, onSettingToggled);
updateVScrollbarWidth();
observeScrollbarWidth();
document.addEventListener("lora-manager:vue-mode-change", onModeChange); document.addEventListener("lora-manager:vue-mode-change", onModeChange);
}); });
onUnmounted(() => { onUnmounted(() => {
unobserveScrollbarWidth();
if (props.widget.inputEl === textareaRef.value) { if (props.widget.inputEl === textareaRef.value) {
props.widget.inputEl = void 0; props.widget.inputEl = void 0;
} }
@@ -15282,10 +15456,16 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
props.widget.onSetValue = void 0; props.widget.onSetValue = void 0;
} }
document.removeEventListener("lora-manager:vue-mode-change", onModeChange); document.removeEventListener("lora-manager:vue-mode-change", onModeChange);
window.removeEventListener(SETTING_TOGGLED_EVENT_NAME, onSettingToggled);
}); });
return (_ctx, _cache) => { return (_ctx, _cache) => {
return openBlock(), createElementBlock("div", _hoisted_1$1, [ return openBlock(), createElementBlock("div", _hoisted_1$1, [
createBaseVNode("div", _hoisted_2$1, [ createBaseVNode("div", {
ref_key: "inputWrapperRef",
ref: inputWrapperRef,
class: "input-wrapper",
style: normalizeStyle({ "--lm-vscrollbar-width": vScrollbarWidth.value + "px" })
}, [
createBaseVNode("textarea", { createBaseVNode("textarea", {
ref_key: "textareaRef", ref_key: "textareaRef",
ref: textareaRef, ref: textareaRef,
@@ -15296,7 +15476,7 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
"data-capture-wheel": "true", "data-capture-wheel": "true",
onInput, onInput,
onWheel onWheel
}, null, 46, _hoisted_3$1), }, null, 46, _hoisted_2$1),
showClearButton.value ? (openBlock(), createElementBlock("button", { showClearButton.value ? (openBlock(), createElementBlock("button", {
key: 0, key: 0,
type: "button", type: "button",
@@ -15323,13 +15503,29 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
y2: "18" y2: "18"
}) })
], -1) ], -1)
])])) : createCommentVNode("", true) ])])) : createCommentVNode("", true),
]) isLorasMode ? (openBlock(), createElementBlock("button", {
key: 1,
type: "button",
class: normalizeClass(["active-filters-toggle", { "is-active": activeFiltersEnabled.value }]),
title: activeFiltersToggleTitle.value,
onClick: toggleActiveFiltersSearch
}, [..._cache[1] || (_cache[1] = [
createBaseVNode("svg", {
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
"stroke-width": "2"
}, [
createBaseVNode("polygon", { points: "22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" })
], -1)
])], 10, _hoisted_3$1)) : createCommentVNode("", true)
], 4)
]); ]);
}; };
} }
}); });
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main$1, [["__scopeId", "data-v-4e322fec"]]); const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main$1, [["__scopeId", "data-v-22743258"]]);
const _hoisted_1 = { class: "lora-info-tabs" }; const _hoisted_1 = { class: "lora-info-tabs" };
const _hoisted_2 = { class: "tab-content notes-tab" }; const _hoisted_2 = { class: "tab-content notes-tab" };
const _hoisted_3 = { class: "info-field" }; const _hoisted_3 = { class: "info-field" };
@@ -15746,7 +15942,6 @@ function createModeChangeCallback(node, updateDownstreamLoaders2, nodeSpecificCa
updateDownstreamLoaders2(node); updateDownstreamLoaders2(node);
}; };
} }
const app = {};
const api = { const api = {
fetchApi: (...args) => fetch(...args), fetchApi: (...args) => fetch(...args),
addEventListener: (eventName, handler) => document.addEventListener(eventName, handler), addEventListener: (eventName, handler) => document.addEventListener(eventName, handler),
@@ -15795,6 +15990,77 @@ function _initLoraSyntaxFormatReactive() {
}); });
} }
_initLoraSyntaxFormatReactive(); _initLoraSyntaxFormatReactive();
const AUTOCOMPLETE_METADATA_WIDGET_PREFIX = "__lm_autocomplete_meta_";
const LORA_MANAGER_WIDGET_IDS_PROPERTY$1 = "__lm_widget_ids";
function stripAutocompleteLastAccepted(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return value;
}
if (!("lastAccepted" in value)) {
return value;
}
const stripped = { ...value };
delete stripped.lastAccepted;
return stripped;
}
function stripAutocompleteMetadataFromNodes(nodes) {
var _a2;
if (!Array.isArray(nodes)) {
return;
}
for (const node of nodes) {
if (!node || typeof node !== "object") {
continue;
}
const widgetIds = (_a2 = node.properties) == null ? void 0 : _a2[LORA_MANAGER_WIDGET_IDS_PROPERTY$1];
if (Array.isArray(node.widgets_values) && Array.isArray(widgetIds)) {
for (let i2 = 0; i2 < node.widgets_values.length && i2 < widgetIds.length; i2++) {
if (typeof widgetIds[i2] === "string" && widgetIds[i2].startsWith(AUTOCOMPLETE_METADATA_WIDGET_PREFIX)) {
node.widgets_values[i2] = stripAutocompleteLastAccepted(node.widgets_values[i2]);
}
}
}
const named = node.widgets_values_named;
if (named && typeof named === "object") {
for (const [key, value] of Object.entries(named)) {
if (key.startsWith(AUTOCOMPLETE_METADATA_WIDGET_PREFIX)) {
named[key] = stripAutocompleteLastAccepted(value);
}
}
}
}
}
function stripAutocompleteMetadataFromPromptResult(result) {
var _a2;
if (!result || typeof result !== "object") {
return result;
}
const workflow = result.workflow;
if (workflow && typeof workflow === "object") {
stripAutocompleteMetadataFromNodes(workflow.nodes);
const subgraphs = (_a2 = workflow.definitions) == null ? void 0 : _a2.subgraphs;
if (Array.isArray(subgraphs)) {
for (const subgraph of subgraphs) {
stripAutocompleteMetadataFromNodes(subgraph == null ? void 0 : subgraph.nodes);
}
}
}
const output = result.output;
if (output && typeof output === "object") {
for (const nodeOutput of Object.values(output)) {
const inputs = nodeOutput == null ? void 0 : nodeOutput.inputs;
if (!inputs || typeof inputs !== "object") {
continue;
}
for (const [key, value] of Object.entries(inputs)) {
if (key.startsWith(AUTOCOMPLETE_METADATA_WIDGET_PREFIX)) {
inputs[key] = stripAutocompleteLastAccepted(value);
}
}
}
}
return result;
}
const ROOT_GRAPH_ID = "root"; const ROOT_GRAPH_ID = "root";
const LORA_PROVIDER_NODE_TYPES = [ const LORA_PROVIDER_NODE_TYPES = [
"Lora Stacker (LoraManager)", "Lora Stacker (LoraManager)",
@@ -16037,6 +16303,12 @@ const AUTOCOMPLETE_TEXT_MIN_WIDTH_DEFAULT = 400;
const AUTOCOMPLETE_TEXT_MIN_HEIGHT_DEFAULT = 300; const AUTOCOMPLETE_TEXT_MIN_HEIGHT_DEFAULT = 300;
const AUTOCOMPLETE_METADATA_VERSION = 1; const AUTOCOMPLETE_METADATA_VERSION = 1;
const LORA_MANAGER_WIDGET_IDS_PROPERTY = "__lm_widget_ids"; const LORA_MANAGER_WIDGET_IDS_PROPERTY = "__lm_widget_ids";
const originalGraphToPrompt = app$1.graphToPrompt.bind(app$1);
app$1.graphToPrompt = async (...args) => {
const result = await originalGraphToPrompt(...args);
stripAutocompleteMetadataFromPromptResult(result);
return result;
};
function forwardMiddleMouseToCanvas(container) { function forwardMiddleMouseToCanvas(container) {
if (!container) return; if (!container) return;
container.addEventListener("pointerdown", (event) => { container.addEventListener("pointerdown", (event) => {
File diff suppressed because one or more lines are too long