mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 11:11:26 -03:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed7cf418b4 | |||
| 634ea7f299 | |||
| 6ba64ebb3c | |||
| 03569c62df | |||
| a61840b366 | |||
| 726fc178f1 | |||
| 8260bd022d | |||
| b309becdf9 | |||
| 1e375bb8d9 | |||
| 14da8a6f17 | |||
| da71985c3e | |||
| 7c4c8b8f30 | |||
| 77109b3cf8 | |||
| 00095a5398 | |||
| 6b41c3bbb4 | |||
| b37238d790 | |||
| bc33e32c6f | |||
| 49704d801c | |||
| 34ca14d7fc | |||
| f7b247f9e8 | |||
| 3005d2877e | |||
| ed2a17970f | |||
| 9584fa85c9 | |||
| 1fd7cc0123 | |||
| 39e7c1376c |
+104
-9
@@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -868,6 +901,21 @@
|
|||||||
"previousWithShortcut": "Vorheriges Rezept (←)",
|
"previousWithShortcut": "Vorheriges Rezept (←)",
|
||||||
"nextWithShortcut": "Nächstes Rezept (→)"
|
"nextWithShortcut": "Nächstes Rezept (→)"
|
||||||
},
|
},
|
||||||
|
"modal": {
|
||||||
|
"metadata": {
|
||||||
|
"id": "ID"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"openFileLocation": "Dateispeicherort öffnen",
|
||||||
|
"copyId": "Rezept-ID kopieren"
|
||||||
|
},
|
||||||
|
"openFileLocation": {
|
||||||
|
"success": "Dateispeicherort erfolgreich geöffnet",
|
||||||
|
"failed": "Fehler beim Öffnen des Dateispeicherorts",
|
||||||
|
"copied": "Pfad in die Zwischenablage kopiert: {{path}}",
|
||||||
|
"clipboardFallback": "Pfad: {{path}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"workflow": {
|
"workflow": {
|
||||||
"sendWorkflow": "Workflow an ComfyUI senden",
|
"sendWorkflow": "Workflow an ComfyUI senden",
|
||||||
"sent": "Workflow an ComfyUI gesendet",
|
"sent": "Workflow an ComfyUI gesendet",
|
||||||
@@ -1578,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",
|
||||||
@@ -1589,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",
|
||||||
@@ -1649,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",
|
||||||
@@ -1914,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",
|
||||||
@@ -1934,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",
|
||||||
|
|||||||
+104
-9
@@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -868,6 +901,21 @@
|
|||||||
"previousWithShortcut": "Previous recipe (←)",
|
"previousWithShortcut": "Previous recipe (←)",
|
||||||
"nextWithShortcut": "Next recipe (→)"
|
"nextWithShortcut": "Next recipe (→)"
|
||||||
},
|
},
|
||||||
|
"modal": {
|
||||||
|
"metadata": {
|
||||||
|
"id": "ID"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"openFileLocation": "Open File Location",
|
||||||
|
"copyId": "Copy recipe ID"
|
||||||
|
},
|
||||||
|
"openFileLocation": {
|
||||||
|
"success": "File location opened successfully",
|
||||||
|
"failed": "Failed to open file location",
|
||||||
|
"copied": "Path copied to clipboard: {{path}}",
|
||||||
|
"clipboardFallback": "Path: {{path}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"workflow": {
|
"workflow": {
|
||||||
"sendWorkflow": "Send Workflow to ComfyUI",
|
"sendWorkflow": "Send Workflow to ComfyUI",
|
||||||
"sent": "Workflow sent to ComfyUI",
|
"sent": "Workflow sent to ComfyUI",
|
||||||
@@ -1578,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",
|
||||||
@@ -1589,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",
|
||||||
@@ -1649,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",
|
||||||
@@ -1914,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",
|
||||||
@@ -1934,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",
|
||||||
|
|||||||
+104
-9
@@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -868,6 +901,21 @@
|
|||||||
"previousWithShortcut": "Receta anterior (←)",
|
"previousWithShortcut": "Receta anterior (←)",
|
||||||
"nextWithShortcut": "Siguiente receta (→)"
|
"nextWithShortcut": "Siguiente receta (→)"
|
||||||
},
|
},
|
||||||
|
"modal": {
|
||||||
|
"metadata": {
|
||||||
|
"id": "ID"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"openFileLocation": "Abrir ubicación del archivo",
|
||||||
|
"copyId": "Copiar ID de la receta"
|
||||||
|
},
|
||||||
|
"openFileLocation": {
|
||||||
|
"success": "Ubicación del archivo abierta exitosamente",
|
||||||
|
"failed": "Error al abrir la ubicación del archivo",
|
||||||
|
"copied": "Ruta copiada al portapapeles: {{path}}",
|
||||||
|
"clipboardFallback": "Ruta: {{path}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"workflow": {
|
"workflow": {
|
||||||
"sendWorkflow": "Enviar workflow a ComfyUI",
|
"sendWorkflow": "Enviar workflow a ComfyUI",
|
||||||
"sent": "Workflow enviado a ComfyUI",
|
"sent": "Workflow enviado a ComfyUI",
|
||||||
@@ -1578,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",
|
||||||
@@ -1589,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",
|
||||||
@@ -1649,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í",
|
||||||
@@ -1914,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",
|
||||||
@@ -1934,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",
|
||||||
|
|||||||
+104
-9
@@ -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": "l’actualisation",
|
||||||
|
"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 d’une 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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -868,6 +901,21 @@
|
|||||||
"previousWithShortcut": "Recette précédente (←)",
|
"previousWithShortcut": "Recette précédente (←)",
|
||||||
"nextWithShortcut": "Recette suivante (→)"
|
"nextWithShortcut": "Recette suivante (→)"
|
||||||
},
|
},
|
||||||
|
"modal": {
|
||||||
|
"metadata": {
|
||||||
|
"id": "ID"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"openFileLocation": "Ouvrir l’emplacement du fichier",
|
||||||
|
"copyId": "Copier l’ID de la Recipe"
|
||||||
|
},
|
||||||
|
"openFileLocation": {
|
||||||
|
"success": "Emplacement du fichier ouvert avec succès",
|
||||||
|
"failed": "Échec de l’ouverture de l’emplacement du fichier",
|
||||||
|
"copied": "Chemin copié dans le presse-papiers: {{path}}",
|
||||||
|
"clipboardFallback": "Chemin: {{path}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"workflow": {
|
"workflow": {
|
||||||
"sendWorkflow": "Envoyer le workflow vers ComfyUI",
|
"sendWorkflow": "Envoyer le workflow vers ComfyUI",
|
||||||
"sent": "Workflow envoyé vers ComfyUI",
|
"sent": "Workflow envoyé vers ComfyUI",
|
||||||
@@ -1578,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",
|
||||||
@@ -1589,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",
|
||||||
@@ -1649,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",
|
||||||
@@ -1914,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",
|
||||||
@@ -1934,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",
|
||||||
|
|||||||
+104
-9
@@ -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> בדף לפעולות גלובליות כמו בדיקת עדכונים וניהול מודלים מוחרגים."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -868,6 +901,21 @@
|
|||||||
"previousWithShortcut": "המתכון הקודם (←)",
|
"previousWithShortcut": "המתכון הקודם (←)",
|
||||||
"nextWithShortcut": "המתכון הבא (→)"
|
"nextWithShortcut": "המתכון הבא (→)"
|
||||||
},
|
},
|
||||||
|
"modal": {
|
||||||
|
"metadata": {
|
||||||
|
"id": "ID"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"openFileLocation": "פתח מיקום קובץ",
|
||||||
|
"copyId": "העתק מזהה מתכון"
|
||||||
|
},
|
||||||
|
"openFileLocation": {
|
||||||
|
"success": "מיקום הקובץ נפתח בהצלחה",
|
||||||
|
"failed": "פתיחת מיקום הקובץ נכשלה",
|
||||||
|
"copied": "הנתיב הועתק ללוח העריכה: {{path}}",
|
||||||
|
"clipboardFallback": "נתיב: {{path}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"workflow": {
|
"workflow": {
|
||||||
"sendWorkflow": "שלח workflow ל-ComfyUI",
|
"sendWorkflow": "שלח workflow ל-ComfyUI",
|
||||||
"sent": "ה-workflow נשלח ל-ComfyUI",
|
"sent": "ה-workflow נשלח ל-ComfyUI",
|
||||||
@@ -1578,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": "מילות טריגר",
|
||||||
@@ -1589,7 +1641,7 @@
|
|||||||
"addPlaceholder": "הקלד להוספה או לחץ על הצעות למטה",
|
"addPlaceholder": "הקלד להוספה או לחץ על הצעות למטה",
|
||||||
"editWord": "עריכת מילת טריגר",
|
"editWord": "עריכת מילת טריגר",
|
||||||
"editPlaceholder": "עריכת מילת טריגר",
|
"editPlaceholder": "עריכת מילת טריגר",
|
||||||
"copyWord": "העתק מילת טריגר",
|
"copyOrEditWord": "לחץ כדי להעתיק, לחץ פעמיים כדי לערוך",
|
||||||
"deleteWord": "מחק מילת טריגר",
|
"deleteWord": "מחק מילת טריגר",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
"noSuggestions": "אין הצעות זמינות",
|
"noSuggestions": "אין הצעות זמינות",
|
||||||
@@ -1649,8 +1701,8 @@
|
|||||||
"showCount": "הצג דוגמאות ({count})",
|
"showCount": "הצג דוגמאות ({count})",
|
||||||
"hideExamples": "הסתר דוגמאות",
|
"hideExamples": "הסתר דוגמאות",
|
||||||
"addExamples": "הוסף דוגמאות",
|
"addExamples": "הוסף דוגמאות",
|
||||||
"previousExample": "דוגמה קודמת",
|
"previousExample": "דוגמה קודמת ([)",
|
||||||
"nextExample": "דוגמה הבאה",
|
"nextExample": "דוגמה הבאה (])",
|
||||||
"noExamples": "אין תמונות דוגמה זמינות",
|
"noExamples": "אין תמונות דוגמה זמינות",
|
||||||
"addMoreExamples": "הוסף עוד דוגמאות",
|
"addMoreExamples": "הוסף עוד דוגמאות",
|
||||||
"dragDrop": "גרור ושחרר תמונות או סרטונים כאן",
|
"dragDrop": "גרור ושחרר תמונות או סרטונים כאן",
|
||||||
@@ -1914,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": "עדכונים אחרונים",
|
||||||
@@ -1934,7 +2028,8 @@
|
|||||||
"settings": "הגדרות ותצורה",
|
"settings": "הגדרות ותצורה",
|
||||||
"extensions": "הרחבות",
|
"extensions": "הרחבות",
|
||||||
"newBadge": "חדש"
|
"newBadge": "חדש"
|
||||||
}
|
},
|
||||||
|
"newContentBadge": "חדש"
|
||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
"title": "בדוק עדכונים",
|
"title": "בדוק עדכונים",
|
||||||
|
|||||||
+104
-9
@@ -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>すると、更新の確認や除外モデルの管理などのグローバル操作メニューが表示されます。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -868,6 +901,21 @@
|
|||||||
"previousWithShortcut": "前のレシピ(←)",
|
"previousWithShortcut": "前のレシピ(←)",
|
||||||
"nextWithShortcut": "次のレシピ(→)"
|
"nextWithShortcut": "次のレシピ(→)"
|
||||||
},
|
},
|
||||||
|
"modal": {
|
||||||
|
"metadata": {
|
||||||
|
"id": "ID"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"openFileLocation": "ファイルの場所を開く",
|
||||||
|
"copyId": "レシピIDをコピー"
|
||||||
|
},
|
||||||
|
"openFileLocation": {
|
||||||
|
"success": "ファイルの場所を正常に開きました",
|
||||||
|
"failed": "ファイルの場所を開くのに失敗しました",
|
||||||
|
"copied": "パスをクリップボードにコピーしました: {{path}}",
|
||||||
|
"clipboardFallback": "パス: {{path}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"workflow": {
|
"workflow": {
|
||||||
"sendWorkflow": "ワークフローをComfyUIへ送信",
|
"sendWorkflow": "ワークフローをComfyUIへ送信",
|
||||||
"sent": "ワークフローをComfyUIへ送信しました",
|
"sent": "ワークフローをComfyUIへ送信しました",
|
||||||
@@ -1578,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": "トリガーワード",
|
||||||
@@ -1589,7 +1641,7 @@
|
|||||||
"addPlaceholder": "入力して追加するか、下の提案をクリック",
|
"addPlaceholder": "入力して追加するか、下の提案をクリック",
|
||||||
"editWord": "トリガーワードを編集",
|
"editWord": "トリガーワードを編集",
|
||||||
"editPlaceholder": "トリガーワードを編集",
|
"editPlaceholder": "トリガーワードを編集",
|
||||||
"copyWord": "トリガーワードをコピー",
|
"copyOrEditWord": "クリックでコピー、ダブルクリックで編集",
|
||||||
"deleteWord": "トリガーワードを削除",
|
"deleteWord": "トリガーワードを削除",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
"noSuggestions": "提案はありません",
|
"noSuggestions": "提案はありません",
|
||||||
@@ -1649,8 +1701,8 @@
|
|||||||
"showCount": "例を表示({count})",
|
"showCount": "例を表示({count})",
|
||||||
"hideExamples": "例を非表示",
|
"hideExamples": "例を非表示",
|
||||||
"addExamples": "例を追加",
|
"addExamples": "例を追加",
|
||||||
"previousExample": "前の例",
|
"previousExample": "前の例([)",
|
||||||
"nextExample": "次の例",
|
"nextExample": "次の例(])",
|
||||||
"noExamples": "利用可能な例画像がありません",
|
"noExamples": "利用可能な例画像がありません",
|
||||||
"addMoreExamples": "さらに例を追加",
|
"addMoreExamples": "さらに例を追加",
|
||||||
"dragDrop": "画像または動画をここにドラッグ&ドロップ",
|
"dragDrop": "画像または動画をここにドラッグ&ドロップ",
|
||||||
@@ -1914,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": "最新の更新",
|
||||||
@@ -1934,7 +2028,8 @@
|
|||||||
"settings": "設定&構成",
|
"settings": "設定&構成",
|
||||||
"extensions": "拡張機能",
|
"extensions": "拡張機能",
|
||||||
"newBadge": "新着"
|
"newBadge": "新着"
|
||||||
}
|
},
|
||||||
|
"newContentBadge": "新着"
|
||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
"title": "更新確認",
|
"title": "更新確認",
|
||||||
|
|||||||
+104
-9
@@ -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>하면 업데이트 확인이나 제외된 모델 관리 같은 전역 작업을 사용할 수 있습니다."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -868,6 +901,21 @@
|
|||||||
"previousWithShortcut": "이전 레시피(←)",
|
"previousWithShortcut": "이전 레시피(←)",
|
||||||
"nextWithShortcut": "다음 레시피(→)"
|
"nextWithShortcut": "다음 레시피(→)"
|
||||||
},
|
},
|
||||||
|
"modal": {
|
||||||
|
"metadata": {
|
||||||
|
"id": "ID"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"openFileLocation": "파일 위치 열기",
|
||||||
|
"copyId": "레시피 ID 복사"
|
||||||
|
},
|
||||||
|
"openFileLocation": {
|
||||||
|
"success": "파일 위치가 성공적으로 열렸습니다",
|
||||||
|
"failed": "파일 위치 열기에 실패했습니다",
|
||||||
|
"copied": "경로가 클립보드에 복사되었습니다: {{path}}",
|
||||||
|
"clipboardFallback": "경로: {{path}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"workflow": {
|
"workflow": {
|
||||||
"sendWorkflow": "워크플로를 ComfyUI로 보내기",
|
"sendWorkflow": "워크플로를 ComfyUI로 보내기",
|
||||||
"sent": "워크플로를 ComfyUI로 보냈습니다",
|
"sent": "워크플로를 ComfyUI로 보냈습니다",
|
||||||
@@ -1578,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": "트리거 단어",
|
||||||
@@ -1589,7 +1641,7 @@
|
|||||||
"addPlaceholder": "입력하거나 아래 제안을 클릭하세요",
|
"addPlaceholder": "입력하거나 아래 제안을 클릭하세요",
|
||||||
"editWord": "트리거 단어 편집",
|
"editWord": "트리거 단어 편집",
|
||||||
"editPlaceholder": "트리거 단어 편집",
|
"editPlaceholder": "트리거 단어 편집",
|
||||||
"copyWord": "트리거 단어 복사",
|
"copyOrEditWord": "클릭하여 복사, 더블 클릭하여 편집",
|
||||||
"deleteWord": "트리거 단어 삭제",
|
"deleteWord": "트리거 단어 삭제",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
"noSuggestions": "사용 가능한 제안이 없습니다",
|
"noSuggestions": "사용 가능한 제안이 없습니다",
|
||||||
@@ -1649,8 +1701,8 @@
|
|||||||
"showCount": "예시 보기 ({count})",
|
"showCount": "예시 보기 ({count})",
|
||||||
"hideExamples": "예시 숨기기",
|
"hideExamples": "예시 숨기기",
|
||||||
"addExamples": "예시 추가",
|
"addExamples": "예시 추가",
|
||||||
"previousExample": "이전 예시",
|
"previousExample": "이전 예시([)",
|
||||||
"nextExample": "다음 예시",
|
"nextExample": "다음 예시(])",
|
||||||
"noExamples": "사용 가능한 예시 이미지가 없습니다",
|
"noExamples": "사용 가능한 예시 이미지가 없습니다",
|
||||||
"addMoreExamples": "예시 더 추가",
|
"addMoreExamples": "예시 더 추가",
|
||||||
"dragDrop": "이미지 또는 비디오를 여기로 끌어다 놓으세요",
|
"dragDrop": "이미지 또는 비디오를 여기로 끌어다 놓으세요",
|
||||||
@@ -1914,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": "최신 업데이트",
|
||||||
@@ -1934,7 +2028,8 @@
|
|||||||
"settings": "설정 & 구성",
|
"settings": "설정 & 구성",
|
||||||
"extensions": "확장",
|
"extensions": "확장",
|
||||||
"newBadge": "신규"
|
"newBadge": "신규"
|
||||||
}
|
},
|
||||||
|
"newContentBadge": "신규"
|
||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
"title": "업데이트 확인",
|
"title": "업데이트 확인",
|
||||||
|
|||||||
+104
-9
@@ -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> страницы открывает глобальные действия, такие как проверка обновлений и управление исключёнными моделями."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -868,6 +901,21 @@
|
|||||||
"previousWithShortcut": "Предыдущий рецепт (←)",
|
"previousWithShortcut": "Предыдущий рецепт (←)",
|
||||||
"nextWithShortcut": "Следующий рецепт (→)"
|
"nextWithShortcut": "Следующий рецепт (→)"
|
||||||
},
|
},
|
||||||
|
"modal": {
|
||||||
|
"metadata": {
|
||||||
|
"id": "ID"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"openFileLocation": "Открыть расположение файла",
|
||||||
|
"copyId": "Копировать ID рецепта"
|
||||||
|
},
|
||||||
|
"openFileLocation": {
|
||||||
|
"success": "Расположение файла успешно открыто",
|
||||||
|
"failed": "Не удалось открыть расположение файла",
|
||||||
|
"copied": "Путь скопирован в буфер обмена: {{path}}",
|
||||||
|
"clipboardFallback": "Путь: {{path}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"workflow": {
|
"workflow": {
|
||||||
"sendWorkflow": "Отправить workflow в ComfyUI",
|
"sendWorkflow": "Отправить workflow в ComfyUI",
|
||||||
"sent": "Workflow отправлен в ComfyUI",
|
"sent": "Workflow отправлен в ComfyUI",
|
||||||
@@ -1578,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": "Триггерные слова",
|
||||||
@@ -1589,7 +1641,7 @@
|
|||||||
"addPlaceholder": "Введите для добавления или нажмите на предложения ниже",
|
"addPlaceholder": "Введите для добавления или нажмите на предложения ниже",
|
||||||
"editWord": "Редактировать триггерное слово",
|
"editWord": "Редактировать триггерное слово",
|
||||||
"editPlaceholder": "Редактировать триггерное слово",
|
"editPlaceholder": "Редактировать триггерное слово",
|
||||||
"copyWord": "Копировать триггерное слово",
|
"copyOrEditWord": "Клик — скопировать, двойной клик — редактировать",
|
||||||
"deleteWord": "Удалить триггерное слово",
|
"deleteWord": "Удалить триггерное слово",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
"noSuggestions": "Предложения недоступны",
|
"noSuggestions": "Предложения недоступны",
|
||||||
@@ -1649,8 +1701,8 @@
|
|||||||
"showCount": "Показать примеры ({count})",
|
"showCount": "Показать примеры ({count})",
|
||||||
"hideExamples": "Скрыть примеры",
|
"hideExamples": "Скрыть примеры",
|
||||||
"addExamples": "Добавить примеры",
|
"addExamples": "Добавить примеры",
|
||||||
"previousExample": "Предыдущий пример",
|
"previousExample": "Предыдущий пример ([)",
|
||||||
"nextExample": "Следующий пример",
|
"nextExample": "Следующий пример (])",
|
||||||
"noExamples": "Примеры изображений недоступны",
|
"noExamples": "Примеры изображений недоступны",
|
||||||
"addMoreExamples": "Добавить ещё примеры",
|
"addMoreExamples": "Добавить ещё примеры",
|
||||||
"dragDrop": "Перетащите изображения или видео сюда",
|
"dragDrop": "Перетащите изображения или видео сюда",
|
||||||
@@ -1914,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": "Последние обновления",
|
||||||
@@ -1934,7 +2028,8 @@
|
|||||||
"settings": "Настройки и конфигурация",
|
"settings": "Настройки и конфигурация",
|
||||||
"extensions": "Расширения",
|
"extensions": "Расширения",
|
||||||
"newBadge": "НОВОЕ"
|
"newBadge": "НОВОЕ"
|
||||||
}
|
},
|
||||||
|
"newContentBadge": "НОВОЕ"
|
||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
"title": "Проверить обновления",
|
"title": "Проверить обновления",
|
||||||
|
|||||||
+104
-9
@@ -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>可使用检查更新、管理已排除的模型等全局操作。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -868,6 +901,21 @@
|
|||||||
"previousWithShortcut": "上一个配方(←)",
|
"previousWithShortcut": "上一个配方(←)",
|
||||||
"nextWithShortcut": "下一个配方(→)"
|
"nextWithShortcut": "下一个配方(→)"
|
||||||
},
|
},
|
||||||
|
"modal": {
|
||||||
|
"metadata": {
|
||||||
|
"id": "ID"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"openFileLocation": "打开文件位置",
|
||||||
|
"copyId": "复制配方 ID"
|
||||||
|
},
|
||||||
|
"openFileLocation": {
|
||||||
|
"success": "文件位置已成功打开",
|
||||||
|
"failed": "打开文件位置失败",
|
||||||
|
"copied": "路径已复制到剪贴板:{{path}}",
|
||||||
|
"clipboardFallback": "路径:{{path}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"workflow": {
|
"workflow": {
|
||||||
"sendWorkflow": "发送工作流到 ComfyUI",
|
"sendWorkflow": "发送工作流到 ComfyUI",
|
||||||
"sent": "工作流已发送到 ComfyUI",
|
"sent": "工作流已发送到 ComfyUI",
|
||||||
@@ -1578,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": "触发词",
|
||||||
@@ -1589,7 +1641,7 @@
|
|||||||
"addPlaceholder": "输入或点击下方建议添加",
|
"addPlaceholder": "输入或点击下方建议添加",
|
||||||
"editWord": "编辑触发词",
|
"editWord": "编辑触发词",
|
||||||
"editPlaceholder": "编辑触发词",
|
"editPlaceholder": "编辑触发词",
|
||||||
"copyWord": "复制触发词",
|
"copyOrEditWord": "单击复制,双击编辑",
|
||||||
"deleteWord": "删除触发词",
|
"deleteWord": "删除触发词",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
"noSuggestions": "暂无建议",
|
"noSuggestions": "暂无建议",
|
||||||
@@ -1649,8 +1701,8 @@
|
|||||||
"showCount": "显示示例({count})",
|
"showCount": "显示示例({count})",
|
||||||
"hideExamples": "隐藏示例",
|
"hideExamples": "隐藏示例",
|
||||||
"addExamples": "添加示例",
|
"addExamples": "添加示例",
|
||||||
"previousExample": "上一个示例",
|
"previousExample": "上一个示例([)",
|
||||||
"nextExample": "下一个示例",
|
"nextExample": "下一个示例(])",
|
||||||
"noExamples": "暂无示例图片",
|
"noExamples": "暂无示例图片",
|
||||||
"addMoreExamples": "添加更多示例",
|
"addMoreExamples": "添加更多示例",
|
||||||
"dragDrop": "将图片或视频拖放到此处",
|
"dragDrop": "将图片或视频拖放到此处",
|
||||||
@@ -1914,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": "最新更新",
|
||||||
@@ -1934,7 +2028,8 @@
|
|||||||
"settings": "设置与配置",
|
"settings": "设置与配置",
|
||||||
"extensions": "扩展",
|
"extensions": "扩展",
|
||||||
"newBadge": "新"
|
"newBadge": "新"
|
||||||
}
|
},
|
||||||
|
"newContentBadge": "新"
|
||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
"title": "检查更新",
|
"title": "检查更新",
|
||||||
|
|||||||
+104
-9
@@ -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>可開啟全域操作選單,例如檢查更新與管理已排除的模型。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -868,6 +901,21 @@
|
|||||||
"previousWithShortcut": "上一個配方(←)",
|
"previousWithShortcut": "上一個配方(←)",
|
||||||
"nextWithShortcut": "下一個配方(→)"
|
"nextWithShortcut": "下一個配方(→)"
|
||||||
},
|
},
|
||||||
|
"modal": {
|
||||||
|
"metadata": {
|
||||||
|
"id": "ID"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"openFileLocation": "開啟檔案位置",
|
||||||
|
"copyId": "複製配方 ID"
|
||||||
|
},
|
||||||
|
"openFileLocation": {
|
||||||
|
"success": "檔案位置已成功開啟",
|
||||||
|
"failed": "開啟檔案位置失敗",
|
||||||
|
"copied": "路徑已複製到剪貼簿:{{path}}",
|
||||||
|
"clipboardFallback": "路徑:{{path}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"workflow": {
|
"workflow": {
|
||||||
"sendWorkflow": "傳送工作流到 ComfyUI",
|
"sendWorkflow": "傳送工作流到 ComfyUI",
|
||||||
"sent": "工作流已傳送到 ComfyUI",
|
"sent": "工作流已傳送到 ComfyUI",
|
||||||
@@ -1578,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": "觸發詞",
|
||||||
@@ -1589,7 +1641,7 @@
|
|||||||
"addPlaceholder": "輸入或點擊下方建議",
|
"addPlaceholder": "輸入或點擊下方建議",
|
||||||
"editWord": "編輯觸發詞",
|
"editWord": "編輯觸發詞",
|
||||||
"editPlaceholder": "編輯觸發詞",
|
"editPlaceholder": "編輯觸發詞",
|
||||||
"copyWord": "複製觸發詞",
|
"copyOrEditWord": "點擊複製,雙擊編輯",
|
||||||
"deleteWord": "刪除觸發詞",
|
"deleteWord": "刪除觸發詞",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
"noSuggestions": "無可用建議",
|
"noSuggestions": "無可用建議",
|
||||||
@@ -1649,8 +1701,8 @@
|
|||||||
"showCount": "顯示範例({count})",
|
"showCount": "顯示範例({count})",
|
||||||
"hideExamples": "隱藏範例",
|
"hideExamples": "隱藏範例",
|
||||||
"addExamples": "新增範例",
|
"addExamples": "新增範例",
|
||||||
"previousExample": "上一個範例",
|
"previousExample": "上一個範例([)",
|
||||||
"nextExample": "下一個範例",
|
"nextExample": "下一個範例(])",
|
||||||
"noExamples": "沒有可用的範例圖片",
|
"noExamples": "沒有可用的範例圖片",
|
||||||
"addMoreExamples": "新增更多範例",
|
"addMoreExamples": "新增更多範例",
|
||||||
"dragDrop": "拖放圖片或影片到此處",
|
"dragDrop": "拖放圖片或影片到此處",
|
||||||
@@ -1914,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": "最新更新",
|
||||||
@@ -1934,7 +2028,8 @@
|
|||||||
"settings": "設定與配置",
|
"settings": "設定與配置",
|
||||||
"extensions": "擴充功能",
|
"extensions": "擴充功能",
|
||||||
"newBadge": "新"
|
"newBadge": "新"
|
||||||
}
|
},
|
||||||
|
"newContentBadge": "新"
|
||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
"title": "檢查更新",
|
"title": "檢查更新",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from typing import Dict, Any
|
|||||||
from ..base import RecipeMetadataParser
|
from ..base import RecipeMetadataParser
|
||||||
from ..constants import GEN_PARAM_KEYS
|
from ..constants import GEN_PARAM_KEYS
|
||||||
from ...services.metadata_service import get_default_metadata_provider
|
from ...services.metadata_service import get_default_metadata_provider
|
||||||
|
from ...utils.constants import is_empty_placeholder_hash
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -524,6 +525,26 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
weight = prompt_entries[0][1] if len(prompt_entries) == 1 else 1.0
|
weight = prompt_entries[0][1] if len(prompt_entries) == 1 else 1.0
|
||||||
lora_entry = make_lora_entry(lora_type, lora_name, weight, lora_hash)
|
lora_entry = make_lora_entry(lora_type, lora_name, weight, lora_hash)
|
||||||
|
|
||||||
|
if is_empty_placeholder_hash(lora_hash):
|
||||||
|
# The empty-hash placeholder (SHA256 of an empty byte
|
||||||
|
# string) is not a real hash: never look it up in the
|
||||||
|
# local hash index or on CivitAI. Match by filename;
|
||||||
|
# otherwise keep the item as unresolved (no hash, flagged
|
||||||
|
# hashInvalid so the UI shows the unresolvable-hash state
|
||||||
|
# and offers reconnect instead of download) rather than
|
||||||
|
# dropping it.
|
||||||
|
if recipe_scanner and lora_type == 'lora' and basename_key not in queried_local_basenames:
|
||||||
|
local_lora = await recipe_scanner.get_local_lora(lora_name, recipe_base_model)
|
||||||
|
if local_lora:
|
||||||
|
local_entry = self.populate_lora_from_local(lora_entry, local_lora)
|
||||||
|
merge_or_append_local(local_entry)
|
||||||
|
continue
|
||||||
|
lora_entry['hash'] = ''
|
||||||
|
lora_entry['hashInvalid'] = True
|
||||||
|
if not resource_lora_count:
|
||||||
|
loras.append(lora_entry)
|
||||||
|
continue
|
||||||
|
|
||||||
if lora_hash and recipe_scanner and lora_type == 'lora':
|
if lora_hash and recipe_scanner and lora_type == 'lora':
|
||||||
local_lora = await recipe_scanner.get_local_lora_by_hash(lora_hash)
|
local_lora = await recipe_scanner.get_local_lora_by_hash(lora_hash)
|
||||||
if local_lora:
|
if local_lora:
|
||||||
|
|||||||
@@ -208,3 +208,24 @@ class RecipeFormatParser(RecipeMetadataParser):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error parsing recipe format metadata: {e}", exc_info=True)
|
logger.error(f"Error parsing recipe format metadata: {e}", exc_info=True)
|
||||||
return {"error": str(e), "loras": []}
|
return {"error": str(e), "loras": []}
|
||||||
|
|
||||||
|
|
||||||
|
def strip_recipe_metadata(metadata_text: str) -> str:
|
||||||
|
"""Strip the ``Recipe metadata: {...}`` block appended by LoRA Manager.
|
||||||
|
|
||||||
|
The saved recipe image carries the original generation metadata followed
|
||||||
|
by an appended recipe JSON block (see ``ExifUtils.append_recipe_metadata``).
|
||||||
|
Re-import wants to re-parse the original embedded metadata, so this returns
|
||||||
|
only the text before the appended marker. The input is returned unchanged
|
||||||
|
when no marker is present.
|
||||||
|
"""
|
||||||
|
if not metadata_text:
|
||||||
|
return metadata_text
|
||||||
|
match = re.search(
|
||||||
|
RecipeFormatParser.METADATA_MARKER,
|
||||||
|
metadata_text,
|
||||||
|
re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
if not match:
|
||||||
|
return metadata_text
|
||||||
|
return metadata_text[: match.start()].strip()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -353,6 +353,17 @@ class RecipeListingHandler:
|
|||||||
|
|
||||||
if not recipe:
|
if not recipe:
|
||||||
return web.json_response({"error": "Recipe not found"}, status=404)
|
return web.json_response({"error": "Recipe not found"}, status=404)
|
||||||
|
|
||||||
|
# Expose the on-disk recipe JSON path so the modal can offer
|
||||||
|
# "open file location" without guessing the storage layout.
|
||||||
|
recipe = dict(recipe)
|
||||||
|
try:
|
||||||
|
json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
|
||||||
|
except Exception: # pragma: no cover - details must still load
|
||||||
|
json_path = None
|
||||||
|
if json_path:
|
||||||
|
recipe["recipe_json_path"] = json_path
|
||||||
|
|
||||||
return web.json_response(recipe)
|
return web.json_response(recipe)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._logger.error(
|
self._logger.error(
|
||||||
@@ -1090,12 +1101,14 @@ class RecipeManagementHandler:
|
|||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
async def reimport_recipe(self, request: web.Request) -> web.Response:
|
async def reimport_recipe(self, request: web.Request) -> web.Response:
|
||||||
"""Delete a recipe and re-import it from its source URL.
|
"""Delete a recipe and re-import it from its source.
|
||||||
|
|
||||||
This gives the recipe a fresh start — re-downloads the image from
|
Gives the recipe a fresh start: URL-sourced recipes re-download the
|
||||||
CivitAI, re-parses EXIF metadata with the current parser, and
|
image from CivitAI; local ones re-parse the saved recipe image. Both
|
||||||
re-resolves LoRAs / checkpoint. User edits (title, tags, favorite)
|
use the original embedded generation metadata (the appended recipe
|
||||||
are carried over from the old recipe.
|
metadata block is ignored) with the current parser, and re-resolve
|
||||||
|
LoRAs / checkpoint. User edits (title, tags, favorite) are carried
|
||||||
|
over from the old recipe.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
await self._ensure_dependencies_ready()
|
await self._ensure_dependencies_ready()
|
||||||
@@ -1108,13 +1121,40 @@ class RecipeManagementHandler:
|
|||||||
if not old_recipe:
|
if not old_recipe:
|
||||||
raise RecipeNotFoundError(f"Recipe {recipe_id} not found")
|
raise RecipeNotFoundError(f"Recipe {recipe_id} not found")
|
||||||
|
|
||||||
source_path = old_recipe.get("source_path")
|
old_file_path = old_recipe.get("file_path", "")
|
||||||
if not source_path:
|
old_folder = os.path.dirname(old_file_path) if old_file_path else None
|
||||||
|
|
||||||
|
source_path = old_recipe.get("source_path") or ""
|
||||||
|
image_id = extract_civitai_image_id(source_path) if source_path else None
|
||||||
|
|
||||||
|
# Local re-import sources: an explicit local source_path, or — when
|
||||||
|
# no usable source_path was recorded (drag & drop / file-picker
|
||||||
|
# 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.
|
||||||
|
# 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
|
||||||
|
persisted_source_path = ""
|
||||||
|
if not image_id and source_path and os.path.isfile(source_path):
|
||||||
|
local_source = source_path
|
||||||
|
persisted_source_path = source_path
|
||||||
|
elif (
|
||||||
|
not image_id
|
||||||
|
and not source_path.startswith(("http://", "https://"))
|
||||||
|
and old_file_path
|
||||||
|
and os.path.isfile(old_file_path)
|
||||||
|
):
|
||||||
|
local_source = old_file_path
|
||||||
|
|
||||||
|
if not image_id and not local_source:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
"success": False,
|
"success": False,
|
||||||
"error": (
|
"error": (
|
||||||
"Recipe has no source URL — cannot re-import. "
|
"Recipe has no re-importable source (no source URL "
|
||||||
|
"and no accessible local image). "
|
||||||
"Use repair or manual import instead."
|
"Use repair or manual import instead."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -1128,33 +1168,15 @@ class RecipeManagementHandler:
|
|||||||
if "tags" in user_edits and not isinstance(user_edits["tags"], list):
|
if "tags" in user_edits and not isinstance(user_edits["tags"], list):
|
||||||
del user_edits["tags"]
|
del user_edits["tags"]
|
||||||
|
|
||||||
old_file_path = old_recipe.get("file_path", "")
|
if local_source:
|
||||||
old_folder = os.path.dirname(old_file_path) if old_file_path else None
|
|
||||||
|
|
||||||
image_id = extract_civitai_image_id(source_path)
|
|
||||||
is_local_file = not image_id and os.path.isfile(source_path)
|
|
||||||
|
|
||||||
if not image_id and not is_local_file:
|
|
||||||
return web.json_response(
|
|
||||||
{
|
|
||||||
"success": False,
|
|
||||||
"error": (
|
|
||||||
"Recipe source is neither a valid CivitAI image URL "
|
|
||||||
"nor an accessible local file. "
|
|
||||||
"Use repair or manual import instead."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
status=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
if is_local_file:
|
|
||||||
return await self._do_reimport_from_local(
|
return await self._do_reimport_from_local(
|
||||||
source_path,
|
local_source,
|
||||||
recipe_scanner,
|
recipe_scanner,
|
||||||
recipe_id=recipe_id,
|
recipe_id=recipe_id,
|
||||||
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:
|
||||||
@@ -2497,11 +2519,20 @@ 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.
|
||||||
|
|
||||||
Reads the original source file, re-parses its EXIF metadata, saves a
|
Reads the original source file, re-parses its original embedded
|
||||||
fresh recipe, then deletes the old one.
|
generation metadata (the appended recipe metadata block is ignored so
|
||||||
|
the current parser gets a fresh pass), saves a new recipe, then deletes
|
||||||
|
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):
|
||||||
@@ -2517,6 +2548,7 @@ class RecipeManagementHandler:
|
|||||||
analysis_result = await self._analysis_service.analyze_local_image(
|
analysis_result = await self._analysis_service.analyze_local_image(
|
||||||
file_path=normalized,
|
file_path=normalized,
|
||||||
recipe_scanner=recipe_scanner,
|
recipe_scanner=recipe_scanner,
|
||||||
|
ignore_recipe_metadata=True,
|
||||||
)
|
)
|
||||||
analysis_payload: dict[str, Any] = analysis_result.payload
|
analysis_payload: dict[str, Any] = analysis_result.payload
|
||||||
|
|
||||||
@@ -2529,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
|
||||||
@@ -2561,6 +2593,10 @@ class RecipeManagementHandler:
|
|||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
extension=extension,
|
extension=extension,
|
||||||
target_dir=target_dir,
|
target_dir=target_dir,
|
||||||
|
# The source is the recipe's own already-optimized preview image;
|
||||||
|
# store its bytes verbatim instead of re-compressing (which would
|
||||||
|
# only degrade quality) and skip the metadata re-append.
|
||||||
|
skip_optimize=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
await self._persistence_service.delete_recipe(
|
await self._persistence_service.delete_recipe(
|
||||||
@@ -2588,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,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from .model_metadata_provider import (
|
|||||||
from .downloader import get_downloader
|
from .downloader import get_downloader
|
||||||
from .errors import RateLimitError, ResourceNotFoundError
|
from .errors import RateLimitError, ResourceNotFoundError
|
||||||
from ..utils.civitai_utils import resolve_license_payload
|
from ..utils.civitai_utils import resolve_license_payload
|
||||||
from ..utils.constants import MODEL_WEIGHT_FILE_TYPES
|
from ..utils.constants import MODEL_WEIGHT_FILE_TYPES, is_empty_placeholder_hash
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -180,6 +180,11 @@ class CivitaiClient:
|
|||||||
async def get_model_by_hash(
|
async def get_model_by_hash(
|
||||||
self, model_hash: str
|
self, model_hash: str
|
||||||
) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||||
|
if is_empty_placeholder_hash(model_hash):
|
||||||
|
# The empty-hash placeholder (SHA256 of an empty byte string)
|
||||||
|
# matches no real file; CivitAI's by-hash index can contain
|
||||||
|
# polluted entries for it, so never resolve it.
|
||||||
|
return None, "Model not found"
|
||||||
try:
|
try:
|
||||||
success, version = await self._make_request(
|
success, version = await self._make_request(
|
||||||
"GET",
|
"GET",
|
||||||
@@ -503,6 +508,8 @@ class CivitaiClient:
|
|||||||
async def _fetch_version_by_hash(self, model_hash: Optional[str]) -> Optional[Dict[str, Any]]:
|
async def _fetch_version_by_hash(self, model_hash: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||||
if not model_hash:
|
if not model_hash:
|
||||||
return None
|
return None
|
||||||
|
if is_empty_placeholder_hash(model_hash):
|
||||||
|
return None
|
||||||
|
|
||||||
success, version = await self._make_request(
|
success, version = await self._make_request(
|
||||||
"GET",
|
"GET",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from typing import Dict, Optional, Set, List
|
from typing import Dict, Optional, Set, List
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from ..utils.constants import is_empty_placeholder_hash
|
||||||
|
|
||||||
class ModelHashIndex:
|
class ModelHashIndex:
|
||||||
"""Index for looking up models by hash or filename"""
|
"""Index for looking up models by hash or filename"""
|
||||||
|
|
||||||
@@ -81,6 +83,8 @@ class ModelHashIndex:
|
|||||||
# mapping. First-time registrations stay O(1).
|
# mapping. First-time registrations stay O(1).
|
||||||
if autov3:
|
if autov3:
|
||||||
autov3 = autov3.lower()
|
autov3 = autov3.lower()
|
||||||
|
if is_empty_placeholder_hash(autov3):
|
||||||
|
autov3 = None
|
||||||
if is_re_registration and (existing_hash != sha256 or autov3):
|
if is_re_registration and (existing_hash != sha256 or autov3):
|
||||||
stale_autov3_keys = [
|
stale_autov3_keys = [
|
||||||
key for key, mapped_path in self._autov3_to_path.items()
|
key for key, mapped_path in self._autov3_to_path.items()
|
||||||
@@ -93,7 +97,7 @@ class ModelHashIndex:
|
|||||||
|
|
||||||
def add_autov3(self, autov3: str, file_path: str) -> None:
|
def add_autov3(self, autov3: str, file_path: str) -> None:
|
||||||
"""Add or update an AutoV3-only index entry (used when only AutoV3 is known)"""
|
"""Add or update an AutoV3-only index entry (used when only AutoV3 is known)"""
|
||||||
if not autov3:
|
if not autov3 or is_empty_placeholder_hash(autov3):
|
||||||
return
|
return
|
||||||
autov3 = autov3.lower()
|
autov3 = autov3.lower()
|
||||||
self._autov3_to_path[autov3] = file_path
|
self._autov3_to_path[autov3] = file_path
|
||||||
@@ -250,6 +254,8 @@ class ModelHashIndex:
|
|||||||
|
|
||||||
def has_hash(self, hash_value: str) -> bool:
|
def has_hash(self, hash_value: str) -> bool:
|
||||||
"""Check if hash exists in index (SHA256, AutoV2, or AutoV3)"""
|
"""Check if hash exists in index (SHA256, AutoV2, or AutoV3)"""
|
||||||
|
if is_empty_placeholder_hash(hash_value):
|
||||||
|
return False
|
||||||
normalized = hash_value.lower()
|
normalized = hash_value.lower()
|
||||||
if normalized in self._hash_to_path:
|
if normalized in self._hash_to_path:
|
||||||
return True
|
return True
|
||||||
@@ -261,6 +267,8 @@ class ModelHashIndex:
|
|||||||
|
|
||||||
def get_path(self, hash_value: str) -> Optional[str]:
|
def get_path(self, hash_value: str) -> Optional[str]:
|
||||||
"""Get file path for a hash (SHA256, AutoV2, or AutoV3)"""
|
"""Get file path for a hash (SHA256, AutoV2, or AutoV3)"""
|
||||||
|
if is_empty_placeholder_hash(hash_value):
|
||||||
|
return None
|
||||||
normalized = hash_value.lower()
|
normalized = hash_value.lower()
|
||||||
path = self._hash_to_path.get(normalized)
|
path = self._hash_to_path.get(normalized)
|
||||||
if path is not None:
|
if path is not None:
|
||||||
|
|||||||
+137
-16
@@ -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(
|
||||||
@@ -914,6 +1001,8 @@ class ModelScanner:
|
|||||||
try:
|
try:
|
||||||
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}
|
||||||
@@ -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
|
||||||
@@ -1053,9 +1150,24 @@ class ModelScanner:
|
|||||||
logger.error(f"Could not determine root path for {path}")
|
logger.error(f"Could not determine root path for {path}")
|
||||||
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -368,6 +368,7 @@ class RecipeAnalysisService:
|
|||||||
*,
|
*,
|
||||||
file_path: str | None,
|
file_path: str | None,
|
||||||
recipe_scanner,
|
recipe_scanner,
|
||||||
|
ignore_recipe_metadata: bool = False,
|
||||||
) -> AnalysisResult:
|
) -> AnalysisResult:
|
||||||
"""Analyze a file already present on disk."""
|
"""Analyze a file already present on disk."""
|
||||||
|
|
||||||
@@ -389,6 +390,22 @@ class RecipeAnalysisService:
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
if ignore_recipe_metadata:
|
||||||
|
# Re-import: re-parse the original embedded generation metadata
|
||||||
|
# instead of the recipe JSON block LoRA Manager appended on save.
|
||||||
|
from ...recipes.parsers.recipe_format import strip_recipe_metadata
|
||||||
|
|
||||||
|
metadata = strip_recipe_metadata(metadata)
|
||||||
|
if not metadata:
|
||||||
|
result = self._metadata_not_found_response(normalized_path)
|
||||||
|
result.payload["diagnostics"] = {
|
||||||
|
"channel": "local",
|
||||||
|
"exif_present": True,
|
||||||
|
"ignore_recipe_metadata": True,
|
||||||
|
"reason": "only_recipe_metadata",
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
result = await self._parse_metadata(
|
result = await self._parse_metadata(
|
||||||
metadata,
|
metadata,
|
||||||
recipe_scanner=recipe_scanner,
|
recipe_scanner=recipe_scanner,
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ class RecipePersistenceService:
|
|||||||
extension: str | None = None,
|
extension: str | None = None,
|
||||||
recipe_id: str | None = None,
|
recipe_id: str | None = None,
|
||||||
target_dir: str | None = None,
|
target_dir: str | None = None,
|
||||||
|
skip_optimize: bool = False,
|
||||||
) -> PersistenceResult:
|
) -> PersistenceResult:
|
||||||
"""Persist a user uploaded recipe.
|
"""Persist a user uploaded recipe.
|
||||||
|
|
||||||
@@ -67,6 +68,11 @@ class RecipePersistenceService:
|
|||||||
target_dir: If provided, save recipe files to this directory instead
|
target_dir: If provided, save recipe files to this directory instead
|
||||||
of the default recipes_dir. Used by re-import to preserve the
|
of the default recipes_dir. Used by re-import to preserve the
|
||||||
original folder location.
|
original folder location.
|
||||||
|
skip_optimize: If True, store the image bytes verbatim without
|
||||||
|
resizing/re-encoding (recipe metadata is still embedded via a
|
||||||
|
byte-level EXIF update that leaves the pixels untouched). Used
|
||||||
|
by local re-import, where the source is the recipe's own
|
||||||
|
already-optimized preview image.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
missing_fields = []
|
missing_fields = []
|
||||||
@@ -87,9 +93,12 @@ class RecipePersistenceService:
|
|||||||
|
|
||||||
recipe_id = recipe_id or str(uuid.uuid4())
|
recipe_id = recipe_id or str(uuid.uuid4())
|
||||||
|
|
||||||
# Handle video formats by bypassing optimization and metadata embedding
|
# Handle video formats by bypassing optimization and metadata embedding.
|
||||||
|
# Local re-import also bypasses optimization: the source is the
|
||||||
|
# recipe's own already-optimized preview image, so re-compressing it
|
||||||
|
# would only degrade quality.
|
||||||
is_video = extension in [".mp4", ".webm"]
|
is_video = extension in [".mp4", ".webm"]
|
||||||
if is_video:
|
if is_video or skip_optimize:
|
||||||
optimized_image = resolved_image_bytes
|
optimized_image = resolved_image_bytes
|
||||||
# extension is already set
|
# extension is already set
|
||||||
else:
|
else:
|
||||||
@@ -175,7 +184,11 @@ class RecipePersistenceService:
|
|||||||
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
|
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
if not is_video:
|
if not is_video:
|
||||||
self._exif_utils.append_recipe_metadata(normalized_image_path, recipe_data)
|
self._exif_utils.append_recipe_metadata(
|
||||||
|
normalized_image_path,
|
||||||
|
recipe_data,
|
||||||
|
pixel_preserving=skip_optimize,
|
||||||
|
)
|
||||||
|
|
||||||
matching_recipes = await self._find_matching_recipes(recipe_scanner, fingerprint, exclude_id=recipe_id)
|
matching_recipes = await self._find_matching_recipes(recipe_scanner, fingerprint, exclude_id=recipe_id)
|
||||||
await recipe_scanner.add_recipe(recipe_data)
|
await recipe_scanner.add_recipe(recipe_data)
|
||||||
|
|||||||
+26
-5
@@ -1,3 +1,5 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
NSFW_LEVELS = {
|
NSFW_LEVELS = {
|
||||||
"PG": 1,
|
"PG": 1,
|
||||||
"PG13": 2,
|
"PG13": 2,
|
||||||
@@ -99,11 +101,30 @@ DEFAULT_HASH_CHUNK_SIZE_MB = 4
|
|||||||
# absurd 64-bit header length from forcing a multi-GB allocation during scan.
|
# absurd 64-bit header length from forcing a multi-GB allocation during scan.
|
||||||
MAX_SAFETENSORS_HEADER_BYTES = 64 * 1024 * 1024
|
MAX_SAFETENSORS_HEADER_BYTES = 64 * 1024 * 1024
|
||||||
|
|
||||||
# First 12 chars of the SHA256 of an empty byte string. Some (re-packaging)
|
# SHA256 of an empty byte string. Some (re-packaging) training tools write a
|
||||||
# training tools write this placeholder into safetensors metadata instead of a
|
# truncated form of this placeholder into safetensors metadata (as
|
||||||
# real hash; it must never be treated as a valid AutoV3 — several broken
|
# ``modelspec.hash_sha256`` / ``sshs_model_hash``), and hashing an empty or
|
||||||
# models sharing it would collide in the hash index and falsely match recipes.
|
# unreadable file produces it directly. It must never be treated as a valid
|
||||||
INVALID_AUTOV3_EMPTY_HASH = "e3b0c44298fc"
|
# hash: several broken models share it, CivitAI's by-hash index can contain
|
||||||
|
# such polluted entries, and matching it falsely attributes recipes.
|
||||||
|
EMPTY_HASH_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||||
|
INVALID_AUTOV3_EMPTY_HASH = EMPTY_HASH_SHA256[:12]
|
||||||
|
INVALID_AUTOV2_EMPTY_HASH = EMPTY_HASH_SHA256[:10]
|
||||||
|
|
||||||
|
|
||||||
|
def is_empty_placeholder_hash(value: Any) -> bool:
|
||||||
|
"""True for a 10/12/64-hex-char spelling of the empty-hash placeholder.
|
||||||
|
|
||||||
|
These are the AutoV2, AutoV3 and full-SHA256 forms of the placeholder;
|
||||||
|
such values identify no real model and must never be resolved against
|
||||||
|
local files or CivitAI.
|
||||||
|
"""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return False
|
||||||
|
v = value.strip().lower()
|
||||||
|
if len(v) not in (10, 12, 64):
|
||||||
|
return False
|
||||||
|
return v == EMPTY_HASH_SHA256[: len(v)]
|
||||||
|
|
||||||
# Auto-organize settings
|
# Auto-organize settings
|
||||||
AUTO_ORGANIZE_BATCH_SIZE = (
|
AUTO_ORGANIZE_BATCH_SIZE = (
|
||||||
|
|||||||
+67
-3
@@ -348,8 +348,14 @@ class ExifUtils:
|
|||||||
return image_path
|
return image_path
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def append_recipe_metadata(image_path, recipe_data) -> str:
|
def append_recipe_metadata(image_path, recipe_data, pixel_preserving=False) -> str:
|
||||||
"""Append recipe metadata to an image's EXIF data"""
|
"""Append recipe metadata to an image's EXIF data
|
||||||
|
|
||||||
|
When ``pixel_preserving`` is True (and the image is a WebP) only the
|
||||||
|
EXIF container is rewritten at the byte level, so the preview pixels
|
||||||
|
are never re-encoded. Local re-import uses this because its source is
|
||||||
|
the recipe's own already-optimized preview image.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
if image_path:
|
if image_path:
|
||||||
ext = os.path.splitext(image_path)[1].lower()
|
ext = os.path.splitext(image_path)[1].lower()
|
||||||
@@ -417,13 +423,71 @@ class ExifUtils:
|
|||||||
|
|
||||||
# Append to existing metadata or create new one
|
# Append to existing metadata or create new one
|
||||||
new_metadata = f"{metadata} \n {recipe_metadata_marker}" if metadata else recipe_metadata_marker
|
new_metadata = f"{metadata} \n {recipe_metadata_marker}" if metadata else recipe_metadata_marker
|
||||||
|
|
||||||
|
# Write back to the image. Re-import keeps the already-optimized
|
||||||
|
# preview pixels untouched and updates only the WebP EXIF chunk
|
||||||
|
# instead of re-encoding the whole image.
|
||||||
|
if pixel_preserving and image_path.lower().endswith(".webp"):
|
||||||
|
metadata_fields = ExifUtils._load_structured_metadata(image_path)
|
||||||
|
metadata_fields["parameters"] = new_metadata
|
||||||
|
exif_bytes = ExifUtils._build_exif_bytes(metadata_fields)
|
||||||
|
with open(image_path, "rb") as file_obj:
|
||||||
|
image_bytes = file_obj.read()
|
||||||
|
try:
|
||||||
|
updated = ExifUtils._replace_webp_exif(image_bytes, exif_bytes)
|
||||||
|
except ValueError:
|
||||||
|
# Container without an EXIF chunk; fall back to re-encoding.
|
||||||
|
return ExifUtils.update_image_metadata(image_path, new_metadata)
|
||||||
|
with open(image_path, "wb") as file_obj:
|
||||||
|
file_obj.write(updated)
|
||||||
|
return image_path
|
||||||
|
|
||||||
# Write back to the image
|
# Write back to the image
|
||||||
return ExifUtils.update_image_metadata(image_path, new_metadata)
|
return ExifUtils.update_image_metadata(image_path, new_metadata)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error appending recipe metadata: {e}", exc_info=True)
|
logger.error(f"Error appending recipe metadata: {e}", exc_info=True)
|
||||||
return image_path
|
return image_path
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _replace_webp_exif(image_bytes: bytes, exif_bytes: bytes) -> bytes:
|
||||||
|
"""Replace the EXIF chunk of a WebP file without re-encoding pixels."""
|
||||||
|
if image_bytes[:4] != b"RIFF" or image_bytes[8:12] != b"WEBP":
|
||||||
|
raise ValueError("Not a WebP file")
|
||||||
|
# The WebP EXIF chunk stores raw TIFF data; strip the JPEG-style
|
||||||
|
# "Exif\\0\\0" prefix that piexif.dump may prepend.
|
||||||
|
tiff = exif_bytes[6:] if exif_bytes[:6] == b"Exif\x00\x00" else exif_bytes
|
||||||
|
|
||||||
|
out = bytearray(image_bytes[:12])
|
||||||
|
pos = 12
|
||||||
|
exif_payload = None
|
||||||
|
while pos + 8 <= len(image_bytes):
|
||||||
|
fourcc = image_bytes[pos : pos + 4]
|
||||||
|
size = struct.unpack("<I", image_bytes[pos + 4 : pos + 8])[0]
|
||||||
|
chunk_data = image_bytes[pos + 8 : pos + 8 + size]
|
||||||
|
pad = size % 2
|
||||||
|
if fourcc == b"EXIF":
|
||||||
|
exif_payload = tiff
|
||||||
|
else:
|
||||||
|
out += (
|
||||||
|
fourcc
|
||||||
|
+ struct.pack("<I", size)
|
||||||
|
+ chunk_data
|
||||||
|
+ (b"\x00" * pad)
|
||||||
|
)
|
||||||
|
pos += 8 + size + pad
|
||||||
|
|
||||||
|
if exif_payload is None:
|
||||||
|
raise ValueError("WebP has no EXIF chunk")
|
||||||
|
|
||||||
|
out += (
|
||||||
|
b"EXIF"
|
||||||
|
+ struct.pack("<I", len(exif_payload))
|
||||||
|
+ exif_payload
|
||||||
|
+ (b"\x00" * (len(exif_payload) % 2))
|
||||||
|
)
|
||||||
|
out[4:8] = struct.pack("<I", len(out) - 8)
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def remove_recipe_metadata(user_comment):
|
def remove_recipe_metadata(user_comment):
|
||||||
"""Remove recipe metadata from user comment"""
|
"""Remove recipe metadata from user comment"""
|
||||||
|
|||||||
+8
-3
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,4 +65,13 @@
|
|||||||
|
|
||||||
.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;
|
||||||
|
|||||||
@@ -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"] {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -304,4 +327,87 @@
|
|||||||
/* Dark theme adjustments */
|
/* Dark theme adjustments */
|
||||||
[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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1547,3 +1547,93 @@
|
|||||||
width: 20px;
|
width: 20px;
|
||||||
height: calc(1em * 1.3);
|
height: calc(1em * 1.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Meta footer: de-emphasized location + recipe ID line below the modal body,
|
||||||
|
mirroring the hash footnote in the shared model modal. Location sits left
|
||||||
|
(tail of the path survives truncation), ID + copy button sit right. */
|
||||||
|
.recipe-meta-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding-top: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
font-size: 0.75em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-meta-footer[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-meta-location {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-meta-location i {
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-meta-location-path {
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
opacity: 0.7;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-meta-location:hover .recipe-meta-location-path,
|
||||||
|
.recipe-meta-location:focus-visible .recipe-meta-location-path {
|
||||||
|
opacity: 1;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-meta-location:focus-visible {
|
||||||
|
outline: 1px solid var(--lora-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-meta-id {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-meta-id-label {
|
||||||
|
font-size: 0.9em;
|
||||||
|
opacity: 0.5;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-meta-id-value {
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
opacity: 0.7;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-meta-copy-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 2px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.35;
|
||||||
|
font-size: 0.95em;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-meta-copy-btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { downloadManager } from '../managers/DownloadManager.js';
|
|||||||
import { MODEL_TYPES } from '../api/apiConfig.js';
|
import { MODEL_TYPES } from '../api/apiConfig.js';
|
||||||
import { openMediaViewer } from './shared/MediaViewer.js';
|
import { openMediaViewer } from './shared/MediaViewer.js';
|
||||||
import { showRecipeDeleteConfirmation } from './RecipeCard.js';
|
import { showRecipeDeleteConfirmation } from './RecipeCard.js';
|
||||||
import { renderCompactTags, setupTagTooltip } from './shared/utils.js';
|
import { renderCompactTags, setupTagTooltip, escapeAttribute } from './shared/utils.js';
|
||||||
import { setupTagEditMode } from './shared/ModelTags.js';
|
import { setupTagEditMode } from './shared/ModelTags.js';
|
||||||
import { Combobox } from './Combobox.js';
|
import { Combobox } from './Combobox.js';
|
||||||
|
|
||||||
@@ -66,6 +66,39 @@ function escapeHtml(value) {
|
|||||||
.replace(/'/g, ''');
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call backend to open the recipe's file location and select the file.
|
||||||
|
* Mirrors the model modal's openFileLocation, including the Docker
|
||||||
|
* clipboard fallback.
|
||||||
|
* @param {string} filePath
|
||||||
|
*/
|
||||||
|
async function openRecipeFileLocation(filePath) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/lm/open-file-location', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ 'file_path': filePath })
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error('Failed to open file location');
|
||||||
|
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
if (data.mode === 'clipboard' && data.path) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(data.path);
|
||||||
|
showToast('recipes.modal.openFileLocation.copied', { path: data.path }, 'success');
|
||||||
|
} catch (clipboardErr) {
|
||||||
|
console.warn('Clipboard API not available:', clipboardErr);
|
||||||
|
showToast('recipes.modal.openFileLocation.clipboardFallback', { path: data.path }, 'info');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast('recipes.modal.openFileLocation.success', {}, 'success');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast('recipes.modal.openFileLocation.failed', {}, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fallback English strings for the collapsed "Why no LoRAs?" panel.
|
// Fallback English strings for the collapsed "Why no LoRAs?" panel.
|
||||||
// Translations live in locales/*.json under recipes.resources.
|
// Translations live in locales/*.json under recipes.resources.
|
||||||
const NO_LORAS_REASON_FALLBACKS = {
|
const NO_LORAS_REASON_FALLBACKS = {
|
||||||
@@ -95,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();
|
||||||
}
|
}
|
||||||
@@ -164,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');
|
||||||
@@ -202,7 +238,8 @@ class RecipeModal {
|
|||||||
this.hideReconnectInput(container);
|
this.hideReconnectInput(container);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
};
|
||||||
|
document.addEventListener('click', this._documentClickHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
setupNavigationControls() {
|
setupNavigationControls() {
|
||||||
@@ -266,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;
|
||||||
@@ -320,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
|
||||||
@@ -410,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;
|
||||||
}
|
}
|
||||||
@@ -421,6 +494,7 @@ class RecipeModal {
|
|||||||
this.syncGenerationParams(hydratedRecipe.gen_params);
|
this.syncGenerationParams(hydratedRecipe.gen_params);
|
||||||
this.syncResourcesSection(hydratedRecipe);
|
this.syncResourcesSection(hydratedRecipe);
|
||||||
this.syncHeaderActions();
|
this.syncHeaderActions();
|
||||||
|
this.syncMetaFooter();
|
||||||
|
|
||||||
// Show the modal
|
// Show the modal
|
||||||
modalManager.showModal('recipeModal', null, null, () => this.cleanupNavigationShortcuts());
|
modalManager.showModal('recipeModal', null, null, () => this.cleanupNavigationShortcuts());
|
||||||
@@ -446,10 +520,83 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the meta footer: clickable file location (opens the recipe JSON
|
||||||
|
* in the OS file manager) plus the truncated recipe ID with copy button.
|
||||||
|
* De-emphasized by design, mirroring the model modal's hash footnote.
|
||||||
|
*/
|
||||||
|
syncMetaFooter() {
|
||||||
|
const footer = document.getElementById('recipeMetaFooter');
|
||||||
|
if (!footer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipeId = this.currentRecipe?.id || '';
|
||||||
|
const filePath = this.currentRecipe?.file_path || '';
|
||||||
|
const openTarget = this.currentRecipe?.recipe_json_path || filePath;
|
||||||
|
const folderPath = filePath.replace(/[^/\\]+$/, '');
|
||||||
|
|
||||||
|
if (!recipeId && !folderPath) {
|
||||||
|
footer.hidden = true;
|
||||||
|
footer.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncatedId = recipeId.length > 14
|
||||||
|
? `${recipeId.slice(0, 8)}…${recipeId.slice(-4)}`
|
||||||
|
: recipeId;
|
||||||
|
const openLocationLabel = translate('recipes.modal.actions.openFileLocation', {}, 'Open File Location');
|
||||||
|
const copyIdLabel = translate('recipes.modal.actions.copyId', {}, 'Copy recipe ID');
|
||||||
|
|
||||||
|
const locationMarkup = folderPath ? `
|
||||||
|
<span class="recipe-meta-location" role="button" tabindex="0"
|
||||||
|
title="${escapeAttribute(folderPath)}"
|
||||||
|
aria-label="${escapeAttribute(openLocationLabel)}"
|
||||||
|
data-filepath="${escapeAttribute(openTarget)}">
|
||||||
|
<i class="fas fa-folder-open" aria-hidden="true"></i>
|
||||||
|
<span class="recipe-meta-location-path">${escapeHtml(folderPath)}</span>
|
||||||
|
</span>` : '';
|
||||||
|
|
||||||
|
const idMarkup = recipeId ? `
|
||||||
|
<span class="recipe-meta-id">
|
||||||
|
<span class="recipe-meta-id-label">${translate('recipes.modal.metadata.id', {}, 'ID')}</span>
|
||||||
|
<span class="recipe-meta-id-value" title="${escapeAttribute(recipeId)}">${escapeHtml(truncatedId)}</span>
|
||||||
|
<button class="recipe-meta-copy-btn" title="${escapeAttribute(copyIdLabel)}" aria-label="${escapeAttribute(copyIdLabel)}">
|
||||||
|
<i class="fas fa-copy" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</span>` : '';
|
||||||
|
|
||||||
|
footer.innerHTML = locationMarkup + idMarkup;
|
||||||
|
footer.hidden = false;
|
||||||
|
|
||||||
|
const locationEl = footer.querySelector('.recipe-meta-location');
|
||||||
|
if (locationEl) {
|
||||||
|
const openLocation = () => {
|
||||||
|
if (locationEl.dataset.filepath) {
|
||||||
|
openRecipeFileLocation(locationEl.dataset.filepath);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
locationEl.addEventListener('click', openLocation);
|
||||||
|
locationEl.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
openLocation();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyBtn = footer.querySelector('.recipe-meta-copy-btn');
|
||||||
|
if (copyBtn && recipeId) {
|
||||||
|
copyBtn.addEventListener('click', () => {
|
||||||
|
copyToClipboard(recipeId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,6 +618,9 @@ class RecipeModal {
|
|||||||
if (fullRecipe.file_path !== undefined) {
|
if (fullRecipe.file_path !== undefined) {
|
||||||
nextRecipe.file_path = fullRecipe.file_path;
|
nextRecipe.file_path = fullRecipe.file_path;
|
||||||
}
|
}
|
||||||
|
if (fullRecipe.recipe_json_path !== undefined) {
|
||||||
|
nextRecipe.recipe_json_path = fullRecipe.recipe_json_path;
|
||||||
|
}
|
||||||
if (fullRecipe.file_url !== undefined) {
|
if (fullRecipe.file_url !== undefined) {
|
||||||
nextRecipe.file_url = fullRecipe.file_url;
|
nextRecipe.file_url = fullRecipe.file_url;
|
||||||
}
|
}
|
||||||
@@ -568,6 +718,7 @@ class RecipeModal {
|
|||||||
this.updateSourceUrlDisplay(this.currentRecipe.source_path || '');
|
this.updateSourceUrlDisplay(this.currentRecipe.source_path || '');
|
||||||
}
|
}
|
||||||
this.syncHeaderActions();
|
this.syncHeaderActions();
|
||||||
|
this.syncMetaFooter();
|
||||||
}
|
}
|
||||||
|
|
||||||
getPreviewMediaUrl(recipe = {}) {
|
getPreviewMediaUrl(recipe = {}) {
|
||||||
@@ -841,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');
|
||||||
@@ -905,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());
|
||||||
@@ -1046,7 +1200,7 @@ class RecipeModal {
|
|||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
setTimeout(() => {
|
this._scheduleDeferred(() => {
|
||||||
this.setupReconnectButtons();
|
this.setupReconnectButtons();
|
||||||
this.setupLoraItemActions();
|
this.setupLoraItemActions();
|
||||||
this.setupLoraItemsClickable();
|
this.setupLoraItemsClickable();
|
||||||
@@ -2002,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);
|
||||||
@@ -2120,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
|
||||||
@@ -2145,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);
|
||||||
|
|
||||||
@@ -2179,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
|
||||||
@@ -2186,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);
|
||||||
|
|
||||||
@@ -2231,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
|
||||||
@@ -2256,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);
|
||||||
|
|
||||||
@@ -2289,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
|
||||||
@@ -2296,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);
|
||||||
|
|
||||||
@@ -2331,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);
|
||||||
@@ -2864,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;
|
||||||
}
|
}
|
||||||
@@ -2972,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);
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
await getModelApiClient().saveModelMetadata(currentPath, { usage_tips: newPresetsJson });
|
try {
|
||||||
|
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
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export function generateImageWrapper(media, shouldBlur, nsfwText, metadataPanel,
|
|||||||
alt="Preview"
|
alt="Preview"
|
||||||
width="${media.width}"
|
width="${media.width}"
|
||||||
height="${media.height}"
|
height="${media.height}"
|
||||||
|
fetchpriority="high"
|
||||||
class="lazy ${shouldBlur ? 'blurred' : ''}">
|
class="lazy ${shouldBlur ? 'blurred' : ''}">
|
||||||
${shouldBlur ? `
|
${shouldBlur ? `
|
||||||
<div class="nsfw-overlay">
|
<div class="nsfw-overlay">
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ import {
|
|||||||
} from './MediaUtils.js';
|
} from './MediaUtils.js';
|
||||||
import { generateMetadataPanel } from './MetadataPanel.js';
|
import { generateMetadataPanel } from './MetadataPanel.js';
|
||||||
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
|
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
|
||||||
import { getShowcaseUrl, getThumbnailUrl } 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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,6 +54,13 @@ export async function loadExampleImages(images, modelHash, previewUrl = '') {
|
|||||||
const showcaseTab = document.getElementById('showcase-tab');
|
const showcaseTab = document.getElementById('showcase-tab');
|
||||||
if (!showcaseTab) return;
|
if (!showcaseTab) return;
|
||||||
|
|
||||||
|
// Fresh load of a model's examples: reset the gallery position so a
|
||||||
|
// previously viewed model's active index / expansion state never leaks
|
||||||
|
// into this one (the modal is a singleton, state is module-level)
|
||||||
|
galleryState.activeIndex = 0;
|
||||||
|
galleryState.expanded = false;
|
||||||
|
lastNavDirection = 1;
|
||||||
|
|
||||||
// First fetch local example files
|
// First fetch local example files
|
||||||
let localFiles = [];
|
let localFiles = [];
|
||||||
|
|
||||||
@@ -224,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>
|
||||||
@@ -275,7 +282,7 @@ function renderThumbnail(img, index, exampleFiles) {
|
|||||||
originalRemoteUrl.endsWith('.mp4') || originalRemoteUrl.endsWith('.webm');
|
originalRemoteUrl.endsWith('.mp4') || originalRemoteUrl.endsWith('.webm');
|
||||||
const mediaType = isVideo ? 'video' : 'image';
|
const mediaType = isVideo ? 'video' : 'image';
|
||||||
|
|
||||||
const thumbUrl = localFile ? localFile.path : getThumbnailUrl(originalRemoteUrl, mediaType);
|
const thumbUrl = localFile ? localFile.path : getGalleryThumbnailUrl(originalRemoteUrl, mediaType);
|
||||||
|
|
||||||
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
|
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
|
||||||
const matureBlurThreshold = getMatureBlurThreshold(state.settings);
|
const matureBlurThreshold = getMatureBlurThreshold(state.settings);
|
||||||
@@ -284,9 +291,9 @@ function renderThumbnail(img, index, exampleFiles) {
|
|||||||
const activeClass = index === galleryState.activeIndex ? ' active' : '';
|
const activeClass = index === galleryState.activeIndex ? ' active' : '';
|
||||||
const blurClass = shouldBlur ? ' blurred' : '';
|
const blurClass = shouldBlur ? ' blurred' : '';
|
||||||
const mediaHtml = isVideo ?
|
const mediaHtml = isVideo ?
|
||||||
`<video class="thumb-media${blurClass}" src="${escapeAttribute(thumbUrl)}" muted playsinline preload="metadata"></video>
|
`<video class="thumb-media${blurClass}" src="${escapeAttribute(thumbUrl)}" muted playsinline preload="none" data-lazy-video></video>
|
||||||
<i class="fas fa-play thumb-video-badge"></i>` :
|
<i class="fas fa-play thumb-video-badge"></i>` :
|
||||||
`<img class="thumb-media${blurClass}" src="${escapeAttribute(thumbUrl)}" loading="lazy" alt="">`;
|
`<img class="thumb-media${blurClass}" src="${escapeAttribute(thumbUrl)}" loading="lazy" fetchpriority="low" alt="">`;
|
||||||
const nsfwBadge = shouldBlur ? '<i class="fas fa-eye-slash thumb-nsfw-badge"></i>' : '';
|
const nsfwBadge = shouldBlur ? '<i class="fas fa-eye-slash thumb-nsfw-badge"></i>' : '';
|
||||||
|
|
||||||
return `<button class="gallery-thumb${activeClass}" data-index="${index}">${mediaHtml}${nsfwBadge}</button>`;
|
return `<button class="gallery-thumb${activeClass}" data-index="${index}">${mediaHtml}${nsfwBadge}</button>`;
|
||||||
@@ -311,8 +318,9 @@ function renderMediaItem(img, index, exampleFiles) {
|
|||||||
originalRemoteUrl.endsWith('.mp4') || originalRemoteUrl.endsWith('.webm');
|
originalRemoteUrl.endsWith('.mp4') || originalRemoteUrl.endsWith('.webm');
|
||||||
const mediaType = isVideo ? 'video' : 'image';
|
const mediaType = isVideo ? 'video' : 'image';
|
||||||
|
|
||||||
// Optimize CivitAI URLs for showcase display (full quality)
|
// Optimize CivitAI URLs for in-modal display (images capped at width=2400;
|
||||||
const remoteUrl = getShowcaseUrl(originalRemoteUrl, mediaType);
|
// the full-size media viewer uses getShowcaseUrl separately)
|
||||||
|
const remoteUrl = getDisplayUrl(originalRemoteUrl, mediaType);
|
||||||
|
|
||||||
const localUrl = localFile ? localFile.path : '';
|
const localUrl = localFile ? localFile.path : '';
|
||||||
|
|
||||||
@@ -438,6 +446,48 @@ function findLocalFile(img, index, exampleFiles) {
|
|||||||
return localFile;
|
return localFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// URLs already warmed in the HTTP cache, so repeat navigations and re-renders
|
||||||
|
// never issue duplicate prefetch requests
|
||||||
|
const prefetchedUrls = new Set();
|
||||||
|
|
||||||
|
// Direction of the last main-viewer navigation (+1 next / -1 prev); users
|
||||||
|
// tend to keep clicking the same arrow, so prefetch reaches one further
|
||||||
|
// ahead along it. Defaults to forward (Next is the most common navigation)
|
||||||
|
let lastNavDirection = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warm the HTTP cache for the examples most likely to be shown next: both
|
||||||
|
* indices adjacent to the active one, plus one extra ahead along the last
|
||||||
|
* navigation direction, so prev/next navigation feels instant. Images only:
|
||||||
|
* video payloads are too heavy for speculative prefetch, and locally stored
|
||||||
|
* examples need no network fetch at all.
|
||||||
|
*/
|
||||||
|
function prefetchAdjacentMedia() {
|
||||||
|
const { images, exampleFiles, activeIndex, expanded } = galleryState;
|
||||||
|
if (!expanded || images.length < 2) return;
|
||||||
|
|
||||||
|
[1, -1, lastNavDirection * 2].forEach(offset => {
|
||||||
|
const index = ((activeIndex + offset) % images.length + images.length) % images.length;
|
||||||
|
const img = images[index];
|
||||||
|
if (!img?.url || findLocalFile(img, index, exampleFiles)) return;
|
||||||
|
|
||||||
|
const isVideo = img.url.endsWith('.mp4') || img.url.endsWith('.webm');
|
||||||
|
if (isVideo) return;
|
||||||
|
|
||||||
|
// Must match the main viewer's URL (display mode) or the warmed
|
||||||
|
// cache entry is never used
|
||||||
|
const url = getDisplayUrl(img.url, 'image');
|
||||||
|
if (prefetchedUrls.has(url)) return;
|
||||||
|
prefetchedUrls.add(url);
|
||||||
|
|
||||||
|
// Off-DOM image: fills the HTTP/memory cache without affecting layout.
|
||||||
|
// Low priority keeps it from competing with the active media's load.
|
||||||
|
const preloader = new Image();
|
||||||
|
preloader.fetchPriority = 'low';
|
||||||
|
preloader.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switch the main viewer to another example (wraps around)
|
* Switch the main viewer to another example (wraps around)
|
||||||
* @param {number} index - Target index in galleryState.images
|
* @param {number} index - Target index in galleryState.images
|
||||||
@@ -446,6 +496,11 @@ export function updateMainDisplay(index) {
|
|||||||
const count = galleryState.images.length;
|
const count = galleryState.images.length;
|
||||||
if (!count || !galleryState.expanded) return;
|
if (!count || !galleryState.expanded) return;
|
||||||
|
|
||||||
|
// Remember the navigation direction for direction-aware prefetching
|
||||||
|
// (a raw index of -1 / count means wrap-around prev / next)
|
||||||
|
const delta = index - galleryState.activeIndex;
|
||||||
|
if (delta !== 0) lastNavDirection = delta > 0 ? 1 : -1;
|
||||||
|
|
||||||
galleryState.activeIndex = ((index % count) + count) % count;
|
galleryState.activeIndex = ((index % count) + count) % count;
|
||||||
|
|
||||||
const container = document.getElementById('mainMediaContainer');
|
const container = document.getElementById('mainMediaContainer');
|
||||||
@@ -453,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,
|
||||||
@@ -470,6 +532,7 @@ export function updateMainDisplay(index) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
initMainMediaInteractions(container);
|
initMainMediaInteractions(container);
|
||||||
|
prefetchAdjacentMedia();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -624,6 +687,211 @@ 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:
|
||||||
|
* with preload="metadata" on every strip video, expanding the gallery would
|
||||||
|
* otherwise hit the network for all of them at once
|
||||||
|
* @param {HTMLElement} gallery - The .showcase-gallery element
|
||||||
|
*/
|
||||||
|
function initStripVideoLazyLoading(gallery) {
|
||||||
|
const videos = gallery.querySelectorAll('.gallery-strip video[data-lazy-video]');
|
||||||
|
if (!videos.length) return;
|
||||||
|
|
||||||
|
const enable = (video) => {
|
||||||
|
video.preload = 'metadata';
|
||||||
|
video.load();
|
||||||
|
video.removeAttribute('data-lazy-video');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof IntersectionObserver === 'undefined') {
|
||||||
|
videos.forEach(enable);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No explicit root: intersection accounts for the strip's overflow
|
||||||
|
// clipping, so off-screen thumbnails stay at preload="none"
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
enable(entry.target);
|
||||||
|
observer.unobserve(entry.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
videos.forEach(video => observer.observe(video));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize all gallery interactions
|
* Initialize all gallery interactions
|
||||||
* @param {HTMLElement} gallery - The .showcase-gallery element
|
* @param {HTMLElement} gallery - The .showcase-gallery element
|
||||||
@@ -683,6 +951,13 @@ 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
|
||||||
|
// examples adjacent to the active one
|
||||||
|
prefetchAdjacentMedia();
|
||||||
|
// Video thumbnails start at preload="none"; enable them on visibility
|
||||||
|
initStripVideoLazyLoading(gallery);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reposition controls on window resize
|
// Reposition controls on window resize
|
||||||
|
|||||||
+6
-1
@@ -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';
|
||||||
@@ -33,7 +34,11 @@ export class AppCore {
|
|||||||
if (this.initialized) return;
|
if (this.initialized) return;
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -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,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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,77 +45,147 @@ 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) => {
|
||||||
// Remove active class from all buttons and panes
|
this.activateHelpTab(event.currentTarget.getAttribute('data-tab'));
|
||||||
document.querySelectorAll('.help-tabs .tab-btn').forEach(btn => {
|
|
||||||
btn.classList.remove('active');
|
|
||||||
});
|
|
||||||
document.querySelectorAll('.help-content .tab-pane').forEach(pane => {
|
|
||||||
pane.classList.remove('active');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add active class to clicked button
|
|
||||||
event.currentTarget.classList.add('active');
|
|
||||||
|
|
||||||
// Show corresponding tab content
|
|
||||||
const tabId = event.currentTarget.getAttribute('data-tab');
|
|
||||||
document.getElementById(tabId).classList.add('active');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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
|
||||||
|
document.querySelectorAll('.help-tabs .tab-btn').forEach(btn => {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.help-content .tab-pane').forEach(pane => {
|
||||||
|
pane.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activate the requested tab
|
||||||
|
tabButton.classList.add('active');
|
||||||
|
tabPane.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;
|
||||||
window.modalManager.toggleModal('helpModal');
|
|
||||||
|
const hadNewContent = this.hasNewContent();
|
||||||
// Add visual indicator to Documentation tab if there's new content
|
|
||||||
this.updateDocumentationTabIndicator();
|
window.modalManager.toggleModal('helpModal');
|
||||||
|
|
||||||
// Update the last viewed timestamp
|
if (tabId) {
|
||||||
this.markContentAsViewed();
|
this.activateHelpTab(tabId);
|
||||||
|
|
||||||
// Hide the badge
|
|
||||||
this.hideHelpBadge();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide the badge
|
||||||
|
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()) {
|
||||||
@@ -134,13 +194,13 @@ export class HelpManager {
|
|||||||
this.hideHelpBadge();
|
this.hideHelpBadge();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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%' }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -9,8 +9,13 @@
|
|||||||
export const OptimizationMode = {
|
export const OptimizationMode = {
|
||||||
/** Full quality for showcase/display - uses /optimized=true only */
|
/** Full quality for showcase/display - uses /optimized=true only */
|
||||||
SHOWCASE: 'showcase',
|
SHOWCASE: 'showcase',
|
||||||
|
/** In-modal display - caps image width at 2400 (covers the ~1200 CSS px
|
||||||
|
* main viewer at DPR 2); videos stay full quality */
|
||||||
|
DISPLAY: 'display',
|
||||||
/** Thumbnail size for cards - uses /width=450,optimized=true */
|
/** Thumbnail size for cards - uses /width=450,optimized=true */
|
||||||
THUMBNAIL: 'thumbnail',
|
THUMBNAIL: 'thumbnail',
|
||||||
|
/** Small thumbnails for the showcase gallery strip (72px display) - uses /width=160,optimized=true */
|
||||||
|
GALLERY_THUMBNAIL: 'gallery-thumbnail',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_CIVITAI_PAGE_HOST = 'civitai.com';
|
export const DEFAULT_CIVITAI_PAGE_HOST = 'civitai.com';
|
||||||
@@ -95,15 +100,21 @@ export function rewriteCivitaiUrl(sourceUrl, mediaType = null, mode = Optimizati
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Determine replacement based on mode and media type
|
// Determine replacement based on mode and media type
|
||||||
|
const isVideo = Boolean(mediaType && mediaType.toLowerCase() === 'video');
|
||||||
let replacement;
|
let replacement;
|
||||||
if (mode === OptimizationMode.SHOWCASE) {
|
if (mode === OptimizationMode.SHOWCASE) {
|
||||||
// Full quality for showcase - no width restriction
|
// Full quality for showcase - no width restriction
|
||||||
replacement = '/optimized=true';
|
replacement = '/optimized=true';
|
||||||
|
} else if (mode === OptimizationMode.DISPLAY) {
|
||||||
|
// Display mode caps image width for in-modal viewing; videos stay
|
||||||
|
// full quality (CDN transcoding costs more than it saves here)
|
||||||
|
replacement = isVideo ? '/optimized=true' : '/width=2400,optimized=true';
|
||||||
} else {
|
} else {
|
||||||
// Thumbnail mode with width restriction
|
// Thumbnail modes with width restriction
|
||||||
replacement = '/width=450,optimized=true';
|
const width = mode === OptimizationMode.GALLERY_THUMBNAIL ? 160 : 450;
|
||||||
if (mediaType && mediaType.toLowerCase() === 'video') {
|
replacement = `/width=${width},optimized=true`;
|
||||||
replacement = '/transcode=true,width=450,optimized=true';
|
if (isVideo) {
|
||||||
|
replacement = `/transcode=true,width=${width},optimized=true`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +161,19 @@ export function getShowcaseUrl(url, type = 'image') {
|
|||||||
return getOptimizedUrl(url, type, OptimizationMode.SHOWCASE);
|
return getOptimizedUrl(url, type, OptimizationMode.SHOWCASE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get display-optimized URL for the in-modal main viewer (images capped at
|
||||||
|
* width=2400; videos full quality). Use getShowcaseUrl for full-size viewing
|
||||||
|
* (e.g. the media viewer overlay)
|
||||||
|
*
|
||||||
|
* @param {string} url - Original URL
|
||||||
|
* @param {string} type - Media type ("image" or "video")
|
||||||
|
* @returns {string} - Optimized URL for in-modal display
|
||||||
|
*/
|
||||||
|
export function getDisplayUrl(url, type = 'image') {
|
||||||
|
return getOptimizedUrl(url, type, OptimizationMode.DISPLAY);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get thumbnail-optimized URL (width=450)
|
* Get thumbnail-optimized URL (width=450)
|
||||||
*
|
*
|
||||||
@@ -161,6 +185,17 @@ export function getThumbnailUrl(url, type = 'image') {
|
|||||||
return getOptimizedUrl(url, type, OptimizationMode.THUMBNAIL);
|
return getOptimizedUrl(url, type, OptimizationMode.THUMBNAIL);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get gallery-strip-thumbnail-optimized URL (width=160, for the 72px strip)
|
||||||
|
*
|
||||||
|
* @param {string} url - Original URL
|
||||||
|
* @param {string} type - Media type ("image" or "video")
|
||||||
|
* @returns {string} - Optimized URL for gallery strip thumbnail display
|
||||||
|
*/
|
||||||
|
export function getGalleryThumbnailUrl(url, type = 'image') {
|
||||||
|
return getOptimizedUrl(url, type, OptimizationMode.GALLERY_THUMBNAIL);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a URL is from CivitAI
|
* Check if a URL is from CivitAI
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -51,13 +76,15 @@ export function getStorageItem(key, defaultValue = null) {
|
|||||||
*/
|
*/
|
||||||
export function setStorageItem(key, value) {
|
export function setStorageItem(key, value) {
|
||||||
const prefixedKey = STORAGE_PREFIX + key;
|
const prefixedKey = STORAGE_PREFIX + key;
|
||||||
|
|
||||||
// Convert objects and arrays to JSON strings
|
// Convert objects and arrays to JSON strings
|
||||||
if (typeof value === 'object' && value !== null) {
|
if (typeof value === 'object' && value !== null) {
|
||||||
localStorage.setItem(prefixedKey, JSON.stringify(value));
|
localStorage.setItem(prefixedKey, JSON.stringify(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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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}"]`);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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')">×</button>
|
<button class="close" onclick="modalManager.closeModal('helpModal')">×</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>←</kbd><span class="shortcut-sep">/</span><kbd>→</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>←</kbd><span class="shortcut-sep">/</span><kbd>→</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>
|
||||||
|
|||||||
@@ -144,5 +144,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<!-- Meta footer: file location + recipe ID, populated by RecipeModal.syncMetaFooter() -->
|
||||||
|
<footer class="recipe-meta-footer" id="recipeMetaFooter" hidden></footer>
|
||||||
|
</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', () => ({
|
||||||
@@ -1200,4 +1206,87 @@ describe('PageControls favorites, sorting, and duplicates scenarios', () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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', () => ({
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
const showToastMock = vi.fn();
|
||||||
|
const copyToClipboardMock = vi.fn();
|
||||||
|
const translateMock = vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key));
|
||||||
|
|
||||||
|
const loadingManagerStub = {
|
||||||
|
showSimpleLoading: vi.fn(),
|
||||||
|
hide: vi.fn(),
|
||||||
|
show: vi.fn(),
|
||||||
|
restoreProgressBar: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const recipeItem = {
|
||||||
|
id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||||
|
file_path: '/recipes/a1b2c3d4-e5f6-7890-abcd-ef1234567890.png',
|
||||||
|
title: 'Demo Recipe',
|
||||||
|
tags: [],
|
||||||
|
loras: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const virtualScrollerStub = {
|
||||||
|
updateSingleItem: vi.fn(),
|
||||||
|
getNavigationState: vi.fn(() => ({
|
||||||
|
index: 0,
|
||||||
|
hasPrev: false,
|
||||||
|
hasNext: false,
|
||||||
|
loadedItems: 1,
|
||||||
|
totalItems: 1,
|
||||||
|
})),
|
||||||
|
getAdjacentItemByFilePath: vi.fn(async () => null),
|
||||||
|
};
|
||||||
|
|
||||||
|
const stateStub = {
|
||||||
|
global: { settings: {}, loadingManager: loadingManagerStub },
|
||||||
|
loadingManager: loadingManagerStub,
|
||||||
|
virtualScroller: virtualScrollerStub,
|
||||||
|
};
|
||||||
|
|
||||||
|
const modalManagerMock = {
|
||||||
|
showModal: vi.fn(),
|
||||||
|
closeModal: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchRecipeDetailsMock = vi.fn(async () => ({}));
|
||||||
|
const updateRecipeMetadataMock = vi.fn(() => Promise.resolve({ success: true }));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||||
|
showToast: showToastMock,
|
||||||
|
copyToClipboard: copyToClipboardMock,
|
||||||
|
sendLoraToWorkflow: vi.fn(),
|
||||||
|
sendModelPathToWorkflow: vi.fn(),
|
||||||
|
openCivitaiByMetadata: vi.fn(),
|
||||||
|
stripLoraTags: vi.fn((text) => text),
|
||||||
|
sendPromptToWorkflow: vi.fn(),
|
||||||
|
sendGenParamsToWorkflow: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||||
|
translate: translateMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/state/index.js', () => ({
|
||||||
|
state: stateStub,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
|
||||||
|
setSessionItem: vi.fn(),
|
||||||
|
removeSessionItem: vi.fn(),
|
||||||
|
getStorageItem: vi.fn(() => null),
|
||||||
|
setStorageItem: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
||||||
|
fetchRecipeDetails: fetchRecipeDetailsMock,
|
||||||
|
updateRecipeMetadata: updateRecipeMetadataMock,
|
||||||
|
sendRecipeWorkflow: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
||||||
|
MODEL_TYPES: {
|
||||||
|
LORA: 'loras',
|
||||||
|
CHECKPOINT: 'checkpoints',
|
||||||
|
EMBEDDING: 'embeddings',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function recipeModalFixture() {
|
||||||
|
return `
|
||||||
|
<div id="recipeModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<header class="recipe-modal-header">
|
||||||
|
<div class="recipe-modal-header-row">
|
||||||
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div class="modal-nav-controls">
|
||||||
|
<button class="modal-nav-btn" id="recipeNavPrevBtn" disabled></button>
|
||||||
|
<button class="modal-nav-btn" id="recipeNavNextBtn" disabled></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn"><i class="fas fa-paper-plane"></i></button>
|
||||||
|
<button class="modal-copy-btn" id="copyRecipeSyntaxBtn"><i class="fas fa-copy"></i></button>
|
||||||
|
</div>
|
||||||
|
<div id="recipeTagsContainer"></div>
|
||||||
|
</header>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="recipe-media-column">
|
||||||
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-section recipe-gen-params">
|
||||||
|
<div class="gen-params-container">
|
||||||
|
<div class="param-group info-item">
|
||||||
|
<div class="param-content" id="recipePrompt"></div>
|
||||||
|
<div class="param-editor" id="recipePromptEditor">
|
||||||
|
<textarea class="param-textarea" id="recipePromptInput"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="param-group info-item">
|
||||||
|
<div class="param-content" id="recipeNegativePrompt"></div>
|
||||||
|
<div class="param-editor" id="recipeNegativePromptEditor">
|
||||||
|
<textarea class="param-textarea" id="recipeNegativePromptInput"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-section recipe-bottom-section">
|
||||||
|
<div class="recipe-section-actions">
|
||||||
|
<span id="recipeLorasCount"></span>
|
||||||
|
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn"></button>
|
||||||
|
</div>
|
||||||
|
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer class="recipe-meta-footer" id="recipeMetaFooter" hidden></footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushAsyncTasks() {
|
||||||
|
await Promise.resolve();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdModals = [];
|
||||||
|
|
||||||
|
async function createRecipeModal() {
|
||||||
|
const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js');
|
||||||
|
const recipeModal = new RecipeModal();
|
||||||
|
createdModals.push(recipeModal);
|
||||||
|
return recipeModal;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLocationFetchCalls() {
|
||||||
|
return global.fetch.mock.calls.filter(([url]) => url === '/api/lm/open-file-location');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RecipeModal meta footer', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
document.body.innerHTML = recipeModalFixture();
|
||||||
|
global.modalManager = modalManagerMock;
|
||||||
|
global.fetch = vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({}),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
createdModals.forEach(recipeModal => recipeModal.cleanupNavigationShortcuts());
|
||||||
|
createdModals.length = 0;
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
delete global.modalManager;
|
||||||
|
delete global.fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the folder path and a middle-truncated recipe ID', async () => {
|
||||||
|
const recipeModal = await createRecipeModal();
|
||||||
|
recipeModal.showRecipeDetails(recipeItem);
|
||||||
|
|
||||||
|
const footer = document.getElementById('recipeMetaFooter');
|
||||||
|
expect(footer.hidden).toBe(false);
|
||||||
|
|
||||||
|
const location = footer.querySelector('.recipe-meta-location');
|
||||||
|
expect(location.querySelector('.recipe-meta-location-path').textContent).toBe('/recipes/');
|
||||||
|
expect(location.dataset.filepath).toBe(recipeItem.file_path);
|
||||||
|
|
||||||
|
const idValue = footer.querySelector('.recipe-meta-id-value');
|
||||||
|
expect(idValue.textContent).toBe('a1b2c3d4…7890');
|
||||||
|
expect(idValue.getAttribute('title')).toBe(recipeItem.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies the full recipe ID when the copy button is clicked', async () => {
|
||||||
|
const recipeModal = await createRecipeModal();
|
||||||
|
recipeModal.showRecipeDetails(recipeItem);
|
||||||
|
|
||||||
|
document.querySelector('.recipe-meta-copy-btn').click();
|
||||||
|
|
||||||
|
expect(copyToClipboardMock).toHaveBeenCalledWith(recipeItem.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the recipe file location when the path is clicked', async () => {
|
||||||
|
const recipeModal = await createRecipeModal();
|
||||||
|
recipeModal.showRecipeDetails(recipeItem);
|
||||||
|
|
||||||
|
document.querySelector('.recipe-meta-location').click();
|
||||||
|
await flushAsyncTasks();
|
||||||
|
|
||||||
|
const calls = openLocationFetchCalls();
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(JSON.parse(calls[0][1].body)).toEqual({ file_path: recipeItem.file_path });
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith('recipes.modal.openFileLocation.success', {}, 'success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the recipe JSON path once hydration provides it', async () => {
|
||||||
|
const jsonPath = '/recipes/a1b2c3d4-e5f6-7890-abcd-ef1234567890.recipe.json';
|
||||||
|
fetchRecipeDetailsMock.mockResolvedValueOnce({
|
||||||
|
id: recipeItem.id,
|
||||||
|
file_path: recipeItem.file_path,
|
||||||
|
recipe_json_path: jsonPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipeModal = await createRecipeModal();
|
||||||
|
recipeModal.showRecipeDetails(recipeItem);
|
||||||
|
await flushAsyncTasks();
|
||||||
|
|
||||||
|
const location = document.querySelector('.recipe-meta-location');
|
||||||
|
expect(location.dataset.filepath).toBe(jsonPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies the path to clipboard when the backend reports clipboard mode', async () => {
|
||||||
|
const writeTextMock = vi.fn(async () => {});
|
||||||
|
Object.defineProperty(window.navigator, 'clipboard', {
|
||||||
|
value: { writeText: writeTextMock },
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
global.fetch = vi.fn(async (url) => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => (url === '/api/lm/open-file-location'
|
||||||
|
? { mode: 'clipboard', path: '/recipes' }
|
||||||
|
: {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const recipeModal = await createRecipeModal();
|
||||||
|
recipeModal.showRecipeDetails(recipeItem);
|
||||||
|
|
||||||
|
document.querySelector('.recipe-meta-location').click();
|
||||||
|
await flushAsyncTasks();
|
||||||
|
|
||||||
|
expect(writeTextMock).toHaveBeenCalledWith('/recipes');
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith(
|
||||||
|
'recipes.modal.openFileLocation.copied',
|
||||||
|
{ path: '/recipes' },
|
||||||
|
'success',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the footer when neither ID nor file path is available', async () => {
|
||||||
|
const recipeModal = await createRecipeModal();
|
||||||
|
recipeModal.showRecipeDetails({ title: 'Orphan', tags: [], loras: [] });
|
||||||
|
|
||||||
|
const footer = document.getElementById('recipeMetaFooter');
|
||||||
|
expect(footer.hidden).toBe(true);
|
||||||
|
expect(footer.innerHTML).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
@@ -197,4 +198,361 @@ describe('Showcase gallery', () => {
|
|||||||
expect(document.querySelector('.gallery-indicator-bar')).toBeTruthy();
|
expect(document.querySelector('.gallery-indicator-bar')).toBeTruthy();
|
||||||
expect(document.querySelectorAll('.gallery-thumb')).toHaveLength(0);
|
expect(document.querySelectorAll('.gallery-thumb')).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prefetches adjacent example images (skipping videos) while expanded', async () => {
|
||||||
|
const { renderShowcaseContent, initShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
|
||||||
|
|
||||||
|
const prefetched = [];
|
||||||
|
class MockImage {
|
||||||
|
set src(value) { prefetched.push(value); }
|
||||||
|
set fetchPriority(_value) { /* jsdom lacks fetchPriority */ }
|
||||||
|
}
|
||||||
|
vi.stubGlobal('Image', MockImage);
|
||||||
|
|
||||||
|
// Unique URLs: the module-level prefetch dedup set persists across tests
|
||||||
|
const images = [
|
||||||
|
{ url: 'https://image.civitai.com/pf/aaa.jpeg', width: 100, height: 100, nsfwLevel: 0 },
|
||||||
|
{ url: 'https://image.civitai.com/pf/bbb.jpeg', width: 100, height: 100, nsfwLevel: 0 },
|
||||||
|
{ url: 'https://image.civitai.com/pf/ccc.mp4', width: 100, height: 100, nsfwLevel: 0 },
|
||||||
|
];
|
||||||
|
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(images, [], PREVIEW_URL, true)}</div>`;
|
||||||
|
initShowcaseContent(document.querySelector('.showcase-gallery'));
|
||||||
|
|
||||||
|
// galleryState.activeIndex persists across tests → pin it to 0
|
||||||
|
updateMainDisplay(0);
|
||||||
|
|
||||||
|
// Active index 0 → prefetches index 1; index 2 is a video and is skipped
|
||||||
|
expect(prefetched).toContain('https://image.civitai.com/pf/bbb.jpeg');
|
||||||
|
expect(prefetched).not.toContain('https://image.civitai.com/pf/ccc.mp4');
|
||||||
|
|
||||||
|
// Navigating to 1 prefetches the new neighbor (index 0)
|
||||||
|
updateMainDisplay(1);
|
||||||
|
expect(prefetched).toContain('https://image.civitai.com/pf/aaa.jpeg');
|
||||||
|
|
||||||
|
// Navigating back does not duplicate prefetch requests
|
||||||
|
const count = prefetched.length;
|
||||||
|
updateMainDisplay(0);
|
||||||
|
expect(prefetched).toHaveLength(count);
|
||||||
|
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not prefetch while collapsed', async () => {
|
||||||
|
const { renderShowcaseContent, initShowcaseContent } = await import(SHOWCASE_MODULE);
|
||||||
|
|
||||||
|
const prefetched = [];
|
||||||
|
class MockImage {
|
||||||
|
set src(value) { prefetched.push(value); }
|
||||||
|
set fetchPriority(_value) { /* jsdom lacks fetchPriority */ }
|
||||||
|
}
|
||||||
|
vi.stubGlobal('Image', MockImage);
|
||||||
|
|
||||||
|
const images = [
|
||||||
|
{ url: 'https://image.civitai.com/pc/ddd.jpeg', width: 100, height: 100, nsfwLevel: 0 },
|
||||||
|
{ url: 'https://image.civitai.com/pc/eee.jpeg', width: 100, height: 100, nsfwLevel: 0 },
|
||||||
|
];
|
||||||
|
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(images, [], PREVIEW_URL)}</div>`;
|
||||||
|
initShowcaseContent(document.querySelector('.showcase-gallery'));
|
||||||
|
|
||||||
|
expect(prefetched).toHaveLength(0);
|
||||||
|
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefetches one extra example ahead along the navigation direction', async () => {
|
||||||
|
const { renderShowcaseContent, initShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
|
||||||
|
|
||||||
|
const prefetched = [];
|
||||||
|
class MockImage {
|
||||||
|
set src(value) { prefetched.push(value); }
|
||||||
|
set fetchPriority(_value) { /* jsdom lacks fetchPriority */ }
|
||||||
|
}
|
||||||
|
vi.stubGlobal('Image', MockImage);
|
||||||
|
|
||||||
|
// Unique URLs: the module-level prefetch dedup set persists across tests
|
||||||
|
const images = [0, 1, 2, 3, 4].map(i => ({
|
||||||
|
url: `https://image.civitai.com/pd/${i}.jpeg`, width: 100, height: 100, nsfwLevel: 0,
|
||||||
|
}));
|
||||||
|
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(images, [], PREVIEW_URL, true)}</div>`;
|
||||||
|
initShowcaseContent(document.querySelector('.showcase-gallery'));
|
||||||
|
|
||||||
|
// Pin position, then step forward: prefetch reaches +2 ahead (index 3)
|
||||||
|
updateMainDisplay(0);
|
||||||
|
updateMainDisplay(1);
|
||||||
|
expect(prefetched).toContain('https://image.civitai.com/pd/2.jpeg');
|
||||||
|
expect(prefetched).toContain('https://image.civitai.com/pd/3.jpeg');
|
||||||
|
|
||||||
|
// Step backward: prefetch reaches -2 ahead (index 4 wrapping around)
|
||||||
|
updateMainDisplay(0);
|
||||||
|
expect(prefetched).toContain('https://image.civitai.com/pd/4.jpeg');
|
||||||
|
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets the gallery position when a new model is loaded', async () => {
|
||||||
|
const { renderShowcaseContent, loadExampleImages, updateMainDisplay } = await import(SHOWCASE_MODULE);
|
||||||
|
|
||||||
|
// Model A: expand and navigate to the third example
|
||||||
|
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>`;
|
||||||
|
updateMainDisplay(2);
|
||||||
|
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('2');
|
||||||
|
|
||||||
|
// Model B opens: loadExampleImages is the per-model entry point
|
||||||
|
const modelBImages = [0, 1, 2, 3].map(i => ({
|
||||||
|
url: `https://image.civitai.com/reset/${i}.jpeg`, width: 100, height: 100, nsfwLevel: 0,
|
||||||
|
}));
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({ success: true, files: [] }),
|
||||||
|
}));
|
||||||
|
await loadExampleImages(modelBImages, 'model-b-hash', '');
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
|
||||||
|
// The leaked index (2) must not carry over: model B starts at example 1
|
||||||
|
const gallery = document.querySelector('.showcase-gallery');
|
||||||
|
expect(gallery).toBeTruthy();
|
||||||
|
expect(document.querySelector('.gallery-indicator-bar')).toBeTruthy();
|
||||||
|
// Expand model B's gallery: it renders from index 0, not the leaked 2
|
||||||
|
// (loadExampleImages already bound the controls via initShowcaseContent)
|
||||||
|
document.querySelector('#galleryShowBtn').click();
|
||||||
|
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
|
||||||
|
expect(document.querySelector('#galleryPosition')?.textContent).toBe('1 / 4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defers video thumbnail metadata fetches until the strip shows them', async () => {
|
||||||
|
const { renderShowcaseContent, initShowcaseContent } = await import(SHOWCASE_MODULE);
|
||||||
|
|
||||||
|
const images = [
|
||||||
|
{ url: 'https://image.civitai.com/lv/fff.jpeg', width: 100, height: 100, nsfwLevel: 0 },
|
||||||
|
{ url: 'https://image.civitai.com/lv/ggg.mp4', width: 100, height: 100, nsfwLevel: 0 },
|
||||||
|
];
|
||||||
|
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(images, [], PREVIEW_URL, true)}</div>`;
|
||||||
|
|
||||||
|
const video = document.querySelector('.gallery-strip video');
|
||||||
|
expect(video?.getAttribute('preload')).toBe('none');
|
||||||
|
expect(video?.hasAttribute('data-lazy-video')).toBe(true);
|
||||||
|
|
||||||
|
// jsdom's HTMLMediaElement.load() is a not-implemented stub that logs
|
||||||
|
const loadSpy = vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => {});
|
||||||
|
|
||||||
|
// jsdom has no IntersectionObserver → fallback enables everything at once
|
||||||
|
initShowcaseContent(document.querySelector('.showcase-gallery'));
|
||||||
|
expect(video.preload).toBe('metadata');
|
||||||
|
expect(video.hasAttribute('data-lazy-video')).toBe(false);
|
||||||
|
expect(loadSpy).toHaveBeenCalled();
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
rewriteCivitaiUrl,
|
rewriteCivitaiUrl,
|
||||||
getOptimizedUrl,
|
getOptimizedUrl,
|
||||||
getShowcaseUrl,
|
getShowcaseUrl,
|
||||||
|
getDisplayUrl,
|
||||||
getThumbnailUrl,
|
getThumbnailUrl,
|
||||||
|
getGalleryThumbnailUrl,
|
||||||
extractCivitaiImageId,
|
extractCivitaiImageId,
|
||||||
extractCivitaiModelUrlParts,
|
extractCivitaiModelUrlParts,
|
||||||
classifyModelRelinkUrl,
|
classifyModelRelinkUrl,
|
||||||
@@ -21,7 +23,9 @@ describe('civitaiUtils', () => {
|
|||||||
describe('OptimizationMode', () => {
|
describe('OptimizationMode', () => {
|
||||||
it('should have correct mode values', () => {
|
it('should have correct mode values', () => {
|
||||||
expect(OptimizationMode.SHOWCASE).toBe('showcase');
|
expect(OptimizationMode.SHOWCASE).toBe('showcase');
|
||||||
|
expect(OptimizationMode.DISPLAY).toBe('display');
|
||||||
expect(OptimizationMode.THUMBNAIL).toBe('thumbnail');
|
expect(OptimizationMode.THUMBNAIL).toBe('thumbnail');
|
||||||
|
expect(OptimizationMode.GALLERY_THUMBNAIL).toBe('gallery-thumbnail');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -107,6 +111,38 @@ describe('civitaiUtils', () => {
|
|||||||
expect(rewritten).toBe('https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/width=450,optimized=true/12345.jpeg');
|
expect(rewritten).toBe('https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/width=450,optimized=true/12345.jpeg');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should rewrite image URLs with /original=true for gallery-thumbnail mode (width=160)', () => {
|
||||||
|
const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/original=true/12345.jpeg';
|
||||||
|
const [rewritten, wasRewritten] = rewriteCivitaiUrl(originalUrl, 'image', OptimizationMode.GALLERY_THUMBNAIL);
|
||||||
|
|
||||||
|
expect(wasRewritten).toBe(true);
|
||||||
|
expect(rewritten).toBe('https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/width=160,optimized=true/12345.jpeg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should rewrite video URLs with /original=true for gallery-thumbnail mode', () => {
|
||||||
|
const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/original=true/12345.mp4';
|
||||||
|
const [rewritten, wasRewritten] = rewriteCivitaiUrl(originalUrl, 'video', OptimizationMode.GALLERY_THUMBNAIL);
|
||||||
|
|
||||||
|
expect(wasRewritten).toBe(true);
|
||||||
|
expect(rewritten).toBe('https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/transcode=true,width=160,optimized=true/12345.mp4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should rewrite image URLs with /original=true for display mode (width=2400)', () => {
|
||||||
|
const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/original=true/12345.jpeg';
|
||||||
|
const [rewritten, wasRewritten] = rewriteCivitaiUrl(originalUrl, 'image', OptimizationMode.DISPLAY);
|
||||||
|
|
||||||
|
expect(wasRewritten).toBe(true);
|
||||||
|
expect(rewritten).toBe('https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/width=2400,optimized=true/12345.jpeg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep videos full quality in display mode (no transcode/width)', () => {
|
||||||
|
const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/original=true/12345.mp4';
|
||||||
|
const [rewritten, wasRewritten] = rewriteCivitaiUrl(originalUrl, 'video', OptimizationMode.DISPLAY);
|
||||||
|
|
||||||
|
expect(wasRewritten).toBe(true);
|
||||||
|
expect(rewritten).toBe('https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/optimized=true/12345.mp4');
|
||||||
|
});
|
||||||
|
|
||||||
it('should not rewrite URLs without /original=true', () => {
|
it('should not rewrite URLs without /original=true', () => {
|
||||||
const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/width=450/12345.jpeg';
|
const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/width=450/12345.jpeg';
|
||||||
const [rewritten, wasRewritten] = rewriteCivitaiUrl(originalUrl, 'image', OptimizationMode.THUMBNAIL);
|
const [rewritten, wasRewritten] = rewriteCivitaiUrl(originalUrl, 'image', OptimizationMode.THUMBNAIL);
|
||||||
@@ -232,6 +268,38 @@ describe('civitaiUtils', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getDisplayUrl', () => {
|
||||||
|
it('should return display-optimized URL (width=2400) for images', () => {
|
||||||
|
const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/original=true/12345.jpeg';
|
||||||
|
const displayUrl = getDisplayUrl(originalUrl, 'image');
|
||||||
|
|
||||||
|
expect(displayUrl).toBe('https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/width=2400,optimized=true/12345.jpeg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep videos full quality', () => {
|
||||||
|
const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/original=true/12345.mp4';
|
||||||
|
const displayUrl = getDisplayUrl(originalUrl, 'video');
|
||||||
|
|
||||||
|
expect(displayUrl).toBe('https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/optimized=true/12345.mp4');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getGalleryThumbnailUrl', () => {
|
||||||
|
it('should return gallery-thumbnail-optimized URL (width=160)', () => {
|
||||||
|
const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/original=true/12345.jpeg';
|
||||||
|
const thumbnailUrl = getGalleryThumbnailUrl(originalUrl, 'image');
|
||||||
|
|
||||||
|
expect(thumbnailUrl).toBe('https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/width=160,optimized=true/12345.jpeg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle videos for gallery thumbnails', () => {
|
||||||
|
const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/original=true/12345.mp4';
|
||||||
|
const thumbnailUrl = getGalleryThumbnailUrl(originalUrl, 'video');
|
||||||
|
|
||||||
|
expect(thumbnailUrl).toBe('https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/transcode=true,width=160,optimized=true/12345.mp4');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('isCivitaiUrl', () => {
|
describe('isCivitaiUrl', () => {
|
||||||
it('should return true for CivitAI URLs', () => {
|
it('should return true for CivitAI URLs', () => {
|
||||||
expect(isCivitaiUrl('https://image.civitai.com/something')).toBe(true);
|
expect(isCivitaiUrl('https://image.civitai.com/something')).toBe(true);
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -197,6 +197,7 @@ class StubAnalysisService:
|
|||||||
self.upload_calls: List[bytes] = []
|
self.upload_calls: List[bytes] = []
|
||||||
self.remote_calls: List[Optional[str]] = []
|
self.remote_calls: List[Optional[str]] = []
|
||||||
self.local_calls: List[Optional[str]] = []
|
self.local_calls: List[Optional[str]] = []
|
||||||
|
self.local_ignore_recipe_metadata_calls: List[bool] = []
|
||||||
self.result = SimpleNamespace(payload={"loras": []}, status=200)
|
self.result = SimpleNamespace(payload={"loras": []}, status=200)
|
||||||
self._recipe_parser_factory: Any = None
|
self._recipe_parser_factory: Any = None
|
||||||
StubAnalysisService.instances.append(self)
|
StubAnalysisService.instances.append(self)
|
||||||
@@ -218,11 +219,16 @@ class StubAnalysisService:
|
|||||||
return self.result
|
return self.result
|
||||||
|
|
||||||
async def analyze_local_image(
|
async def analyze_local_image(
|
||||||
self, *, file_path: Optional[str], recipe_scanner
|
self,
|
||||||
|
*,
|
||||||
|
file_path: Optional[str],
|
||||||
|
recipe_scanner,
|
||||||
|
ignore_recipe_metadata: bool = False,
|
||||||
) -> SimpleNamespace: # noqa: D401
|
) -> SimpleNamespace: # noqa: D401
|
||||||
if self.raise_for_local:
|
if self.raise_for_local:
|
||||||
raise self.raise_for_local
|
raise self.raise_for_local
|
||||||
self.local_calls.append(file_path)
|
self.local_calls.append(file_path)
|
||||||
|
self.local_ignore_recipe_metadata_calls.append(ignore_recipe_metadata)
|
||||||
return self.result
|
return self.result
|
||||||
|
|
||||||
async def analyze_widget_metadata(self, *, recipe_scanner) -> SimpleNamespace:
|
async def analyze_widget_metadata(self, *, recipe_scanner) -> SimpleNamespace:
|
||||||
@@ -257,6 +263,7 @@ class StubPersistenceService:
|
|||||||
extension=None,
|
extension=None,
|
||||||
recipe_id=None,
|
recipe_id=None,
|
||||||
target_dir=None,
|
target_dir=None,
|
||||||
|
skip_optimize=False,
|
||||||
) -> SimpleNamespace: # noqa: D401
|
) -> SimpleNamespace: # noqa: D401
|
||||||
self.save_calls.append(
|
self.save_calls.append(
|
||||||
{
|
{
|
||||||
@@ -269,6 +276,7 @@ class StubPersistenceService:
|
|||||||
"extension": extension,
|
"extension": extension,
|
||||||
"recipe_id": recipe_id,
|
"recipe_id": recipe_id,
|
||||||
"target_dir": target_dir,
|
"target_dir": target_dir,
|
||||||
|
"skip_optimize": skip_optimize,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return self.save_result
|
return self.save_result
|
||||||
@@ -2168,3 +2176,183 @@ async def test_checkpoint_mark_hash_invalid_route_requires_recipe_id(
|
|||||||
json={},
|
json={},
|
||||||
)
|
)
|
||||||
assert response.status == 400
|
assert response.status == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reimport_without_source_path_falls_back_to_recipe_file(
|
||||||
|
monkeypatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Drag & drop imports record no source_path; re-import must fall back to
|
||||||
|
the recipe's own saved image and re-parse ignoring the recipe metadata."""
|
||||||
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||||
|
recipe_file = harness.tmp_dir / "recipes" / "rec1.webp"
|
||||||
|
recipe_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
recipe_file.write_bytes(b"fake-image")
|
||||||
|
|
||||||
|
harness.scanner.recipes["rec1"] = {
|
||||||
|
"id": "rec1",
|
||||||
|
"title": "Old title",
|
||||||
|
"file_path": str(recipe_file),
|
||||||
|
"tags": ["tag1"],
|
||||||
|
# no source_path on purpose
|
||||||
|
}
|
||||||
|
harness.analysis.result = SimpleNamespace(
|
||||||
|
payload={
|
||||||
|
"success": True,
|
||||||
|
"recipe_id": "new-rec",
|
||||||
|
"loras": [],
|
||||||
|
},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
harness.persistence.save_result = SimpleNamespace(
|
||||||
|
payload={"success": True, "recipe_id": "new-rec"}, status=200
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await harness.client.post("/api/lm/recipe/rec1/reimport")
|
||||||
|
payload = await response.json()
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["old_recipe_id"] == "rec1"
|
||||||
|
assert payload["recipe_id"] == "new-rec"
|
||||||
|
# Local analysis is used on the saved image, ignoring recipe metadata.
|
||||||
|
assert harness.analysis.local_calls == [str(recipe_file)]
|
||||||
|
assert harness.analysis.local_ignore_recipe_metadata_calls == [True]
|
||||||
|
# The old recipe is deleted after the fresh save.
|
||||||
|
assert harness.persistence.delete_calls == ["rec1"]
|
||||||
|
# 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]["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.
|
||||||
|
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"]["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(
|
||||||
|
monkeypatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Recipes with neither source_path nor an accessible image cannot re-import."""
|
||||||
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||||
|
harness.scanner.recipes["rec2"] = {
|
||||||
|
"id": "rec2",
|
||||||
|
"title": "No source",
|
||||||
|
"file_path": str(harness.tmp_dir / "recipes" / "missing.webp"),
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await harness.client.post("/api/lm/recipe/rec2/reimport")
|
||||||
|
payload = await response.json()
|
||||||
|
|
||||||
|
assert response.status == 400
|
||||||
|
assert payload["success"] is False
|
||||||
|
assert harness.analysis.local_calls == []
|
||||||
|
assert harness.persistence.delete_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_recipe_detail_includes_recipe_json_path(
|
||||||
|
monkeypatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""The detail response exposes the recipe JSON path for open-location UI."""
|
||||||
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||||
|
recipes_dir = Path(harness.scanner.recipes_dir)
|
||||||
|
recipes_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
harness.scanner.recipes["recipe-1"] = {
|
||||||
|
"id": "recipe-1",
|
||||||
|
"title": "Demo",
|
||||||
|
"file_path": str(recipes_dir / "recipe-1.png"),
|
||||||
|
}
|
||||||
|
json_file = recipes_dir / "recipe-1.recipe.json"
|
||||||
|
json_file.write_text("{}", encoding="utf-8")
|
||||||
|
|
||||||
|
response = await harness.client.get("/api/lm/recipe/recipe-1")
|
||||||
|
assert response.status == 200
|
||||||
|
payload = await response.json()
|
||||||
|
assert payload["recipe_json_path"] == str(json_file)
|
||||||
|
|
||||||
|
# Without the JSON file on disk the key is omitted entirely.
|
||||||
|
json_file.unlink()
|
||||||
|
response = await harness.client.get("/api/lm/recipe/recipe-1")
|
||||||
|
assert response.status == 200
|
||||||
|
payload = await response.json()
|
||||||
|
assert "recipe_json_path" not in payload
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -503,3 +503,64 @@ async def test_parse_metadata_extracts_checkpoint_from_model_hash(monkeypatch):
|
|||||||
assert result["model"] == checkpoint
|
assert result["model"] == checkpoint
|
||||||
assert result["base_model"] == "flux"
|
assert result["base_model"] == "flux"
|
||||||
assert result["loras"] == []
|
assert result["loras"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_metadata_keeps_empty_placeholder_hash_lora_unresolved(monkeypatch):
|
||||||
|
"""A LoRA hash equal to the SHA256("") placeholder must never be resolved
|
||||||
|
against CivitAI or the local hash index, but the LoRA item itself must be
|
||||||
|
kept: matched by filename locally when present, otherwise kept as an
|
||||||
|
unresolved entry (no hash) instead of being dropped."""
|
||||||
|
queried_hashes = []
|
||||||
|
|
||||||
|
async def fake_metadata_provider():
|
||||||
|
class Provider:
|
||||||
|
async def get_model_by_hash(self, model_hash):
|
||||||
|
queried_hashes.append(model_hash)
|
||||||
|
return None, "Model not found"
|
||||||
|
|
||||||
|
async def get_model_version_info(self, version_id):
|
||||||
|
raise AssertionError("get_model_version_info should not be called")
|
||||||
|
|
||||||
|
return Provider()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.recipes.parsers.automatic.get_default_metadata_provider",
|
||||||
|
fake_metadata_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
parser = AutomaticMetadataParser()
|
||||||
|
metadata_text = (
|
||||||
|
"photo of a DeLorean DMC12, <lora:dmc12bttf:1.2>, at night\n"
|
||||||
|
"Steps: 20, Sampler: Euler, CFG scale: 1, Seed: 2242760352, Size: 1280x720, "
|
||||||
|
"Model: flux1-dev, Model hash: 3f97fdc57a, "
|
||||||
|
'Lora hashes: "dmc12bttf: e3b0c44298fc"'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Local file with the same name: the item is matched by filename.
|
||||||
|
scanner_with_local = LocalRecipeScanner({"dmc12bttf": local_lora("dmc12bttf")})
|
||||||
|
result = await parser.parse_metadata(metadata_text, recipe_scanner=scanner_with_local)
|
||||||
|
|
||||||
|
assert "e3b0c44298fc" not in queried_hashes
|
||||||
|
assert "e3b0c44298" not in queried_hashes
|
||||||
|
assert scanner_with_local.hash_queries == []
|
||||||
|
assert scanner_with_local.queries == ["dmc12bttf"]
|
||||||
|
assert len(result["loras"]) == 1
|
||||||
|
assert result["loras"][0]["file_name"] == "dmc12bttf"
|
||||||
|
assert result["loras"][0]["weight"] == 1.2
|
||||||
|
assert result["loras"][0]["existsLocally"] is True
|
||||||
|
assert result["loras"][0]["isDeleted"] is False
|
||||||
|
|
||||||
|
# No local file: the item is kept as unresolved (empty hash, flagged
|
||||||
|
# hashInvalid so the UI renders the unresolvable-hash badge).
|
||||||
|
scanner_without_local = LocalRecipeScanner({})
|
||||||
|
result = await parser.parse_metadata(metadata_text, recipe_scanner=scanner_without_local)
|
||||||
|
|
||||||
|
assert len(result["loras"]) == 1
|
||||||
|
lora = result["loras"][0]
|
||||||
|
assert lora["file_name"] == "dmc12bttf"
|
||||||
|
assert lora["weight"] == 1.2
|
||||||
|
assert lora["hash"] == ""
|
||||||
|
assert lora["hashInvalid"] is True
|
||||||
|
assert lora["existsLocally"] is False
|
||||||
|
assert lora["isDeleted"] is False
|
||||||
|
|||||||
@@ -789,3 +789,32 @@ async def test_get_creator_model_count_never_raises(downloader):
|
|||||||
|
|
||||||
client = await CivitaiClient.get_instance()
|
client = await CivitaiClient.get_instance()
|
||||||
assert await client.get_creator_model_count("pixel") is None
|
assert await client.get_creator_model_count("pixel") is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"placeholder_hash",
|
||||||
|
[
|
||||||
|
"e3b0c44298", # AutoV2 (10 chars)
|
||||||
|
"e3b0c44298fc", # AutoV3 (12 chars)
|
||||||
|
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", # full SHA256
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_get_model_by_hash_rejects_empty_placeholder_without_request(downloader, placeholder_hash):
|
||||||
|
"""The empty-hash placeholder must never be resolved via the by-hash API:
|
||||||
|
CivitAI's index can contain polluted entries for it (e.g. a broken SD 1.5
|
||||||
|
LoRA whose AutoV3 equals the placeholder)."""
|
||||||
|
requested = []
|
||||||
|
|
||||||
|
async def fake_make_request(method, url, use_auth=True, **kwargs):
|
||||||
|
requested.append(url)
|
||||||
|
return True, {}
|
||||||
|
|
||||||
|
downloader.make_request = fake_make_request
|
||||||
|
|
||||||
|
client = await CivitaiClient.get_instance()
|
||||||
|
|
||||||
|
result, error = await client.get_model_by_hash(placeholder_hash)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert error == "Model not found"
|
||||||
|
assert requested == []
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from py.services.model_hash_index import ModelHashIndex
|
from py.services.model_hash_index import ModelHashIndex
|
||||||
|
from py.utils.constants import EMPTY_HASH_SHA256
|
||||||
|
|
||||||
|
|
||||||
class TestModelHashIndexRemoveByPath:
|
class TestModelHashIndexRemoveByPath:
|
||||||
@@ -253,3 +254,38 @@ class TestModelHashIndexAutov3:
|
|||||||
assert index.has_hash("abcdef123456") is False
|
assert index.has_hash("abcdef123456") is False
|
||||||
assert index.get_path("fedcba654321") == "/models/ckpt.safetensors"
|
assert index.get_path("fedcba654321") == "/models/ckpt.safetensors"
|
||||||
assert index.get_all_autov3() == {"fedcba654321": "/models/ckpt.safetensors"}
|
assert index.get_all_autov3() == {"fedcba654321": "/models/ckpt.safetensors"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestModelHashIndexEmptyPlaceholder:
|
||||||
|
def test_add_autov3_rejects_empty_placeholder(self):
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_autov3("e3b0c44298fc", "/models/lora.safetensors")
|
||||||
|
assert "e3b0c44298fc" not in index.get_all_autov3()
|
||||||
|
|
||||||
|
def test_add_entry_rejects_empty_placeholder_autov3(self):
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_entry("abc123", "/models/lora.safetensors", autov3="e3b0c44298fc")
|
||||||
|
assert "e3b0c44298fc" not in index.get_all_autov3()
|
||||||
|
|
||||||
|
def test_has_hash_false_for_placeholder(self):
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_entry("abc123", "/models/lora.safetensors")
|
||||||
|
assert not index.has_hash("e3b0c44298")
|
||||||
|
assert not index.has_hash("e3b0c44298fc")
|
||||||
|
assert not index.has_hash(EMPTY_HASH_SHA256)
|
||||||
|
|
||||||
|
def test_get_path_none_for_placeholder(self):
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_entry("abc123", "/models/lora.safetensors")
|
||||||
|
assert index.get_path("e3b0c44298") is None
|
||||||
|
assert index.get_path("e3b0c44298fc") is None
|
||||||
|
assert index.get_path(EMPTY_HASH_SHA256) is None
|
||||||
|
|
||||||
|
def test_placeholder_autov3_does_not_clobber_existing_mapping(self):
|
||||||
|
# Registering a path with a placeholder autov3 must not replace or
|
||||||
|
# clear autov3 mappings already registered for other paths.
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_entry("a" * 64, "/models/real.safetensors", autov3="abcdef123456")
|
||||||
|
index.add_entry("b" * 64, "/models/other.safetensors", autov3="e3b0c44298fc")
|
||||||
|
assert index.get_path("abcdef123456") == "/models/real.safetensors"
|
||||||
|
assert index.get_all_autov3() == {"abcdef123456": "/models/real.safetensors"}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from typing import Any, Dict
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from py.recipes.parsers.recipe_format import RecipeFormatParser
|
from py.recipes.parsers.recipe_format import RecipeFormatParser, strip_recipe_metadata
|
||||||
from py.config import config
|
from py.config import config
|
||||||
|
|
||||||
|
|
||||||
@@ -425,3 +425,38 @@ async def test_recipe_format_parser_sha256_less_cache_item_no_keyerror(monkeypat
|
|||||||
lora_entry = result["loras"][0]
|
lora_entry = result["loras"][0]
|
||||||
assert lora_entry["existsLocally"] is False
|
assert lora_entry["existsLocally"] is False
|
||||||
assert lora_entry["localPath"] is None
|
assert lora_entry["localPath"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_strip_recipe_metadata_removes_appended_marker():
|
||||||
|
original = (
|
||||||
|
"masterpiece, best quality\n"
|
||||||
|
"Negative prompt: lowres\n"
|
||||||
|
"Steps: 20, Sampler: DPM++ 2M Karras, CFG scale: 7, Seed: 123, "
|
||||||
|
"Size: 512x768, Model hash: abc123, Model: foo_v1, Clip skip: 2\n"
|
||||||
|
' Recipe metadata: {"title": "Saved", "loras": []}'
|
||||||
|
)
|
||||||
|
stripped = strip_recipe_metadata(original)
|
||||||
|
|
||||||
|
assert "Recipe metadata:" not in stripped
|
||||||
|
assert stripped.startswith("masterpiece, best quality")
|
||||||
|
assert "Steps: 20" in stripped
|
||||||
|
assert '{"title": "Saved"}' not in stripped
|
||||||
|
|
||||||
|
|
||||||
|
def test_strip_recipe_metadata_returns_input_without_marker():
|
||||||
|
text = "Steps: 20, Sampler: DPM++ 2M Karras, Seed: 1"
|
||||||
|
assert strip_recipe_metadata(text) == text
|
||||||
|
|
||||||
|
|
||||||
|
def test_strip_recipe_metadata_empty_when_only_marker():
|
||||||
|
text = ' Recipe metadata: {"title": "Saved"}'
|
||||||
|
assert strip_recipe_metadata(text) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_strip_recipe_metadata_handles_multiline_json_marker():
|
||||||
|
original = (
|
||||||
|
"Steps: 20, Sampler: DPM++ 2M Karras, Seed: 1\n"
|
||||||
|
' Recipe metadata: {"title": "Saved", "loras": [{"name": "a", "hash": "h"}]}'
|
||||||
|
)
|
||||||
|
stripped = strip_recipe_metadata(original)
|
||||||
|
assert stripped == "Steps: 20, Sampler: DPM++ 2M Karras, Seed: 1"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ class DummyExifUtils:
|
|||||||
self.optimized_calls += 1
|
self.optimized_calls += 1
|
||||||
return image_data, ".webp"
|
return image_data, ".webp"
|
||||||
|
|
||||||
def append_recipe_metadata(self, image_path, recipe_data):
|
def append_recipe_metadata(self, image_path, recipe_data, pixel_preserving=False):
|
||||||
self.appended = (image_path, recipe_data)
|
self.appended = (image_path, recipe_data, pixel_preserving)
|
||||||
|
|
||||||
def extract_image_metadata(self, path):
|
def extract_image_metadata(self, path):
|
||||||
return {}
|
return {}
|
||||||
@@ -87,6 +87,87 @@ async def test_save_recipe_video_bypasses_optimization(tmp_path):
|
|||||||
assert exif_utils.appended is None, "Metadata embedding should be bypassed for video"
|
assert exif_utils.appended is None, "Metadata embedding should be bypassed for video"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_recipe_skip_optimize_preserves_image_bytes(tmp_path):
|
||||||
|
"""Local re-import sources are already-optimized recipe images; saving them
|
||||||
|
must keep the bytes verbatim instead of re-compressing, while the recipe
|
||||||
|
metadata block is still embedded via a pixel-preserving EXIF update."""
|
||||||
|
exif_utils = DummyExifUtils()
|
||||||
|
|
||||||
|
class DummyScanner:
|
||||||
|
def __init__(self, root):
|
||||||
|
self.recipes_dir = str(root / "recipes")
|
||||||
|
|
||||||
|
async def add_recipe(self, recipe_data):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def find_recipes_by_fingerprint(self, fingerprint):
|
||||||
|
return []
|
||||||
|
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
service = RecipePersistenceService(
|
||||||
|
exif_utils=exif_utils,
|
||||||
|
card_preview_width=512,
|
||||||
|
logger=logging.getLogger("test"),
|
||||||
|
)
|
||||||
|
|
||||||
|
image_bytes = b"\x89PNG-not-optimized-again"
|
||||||
|
result = await service.save_recipe(
|
||||||
|
recipe_scanner=scanner,
|
||||||
|
image_bytes=image_bytes,
|
||||||
|
image_base64=None,
|
||||||
|
name="Re-imported",
|
||||||
|
tags=[],
|
||||||
|
metadata={"gen_params": {"steps": 20}, "base_model": "SDXL", "loras": []},
|
||||||
|
extension=".webp",
|
||||||
|
skip_optimize=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.payload["image_path"].endswith(".webp")
|
||||||
|
assert Path(result.payload["image_path"]).read_bytes() == image_bytes
|
||||||
|
assert exif_utils.optimized_calls == 0, "Optimization should be bypassed"
|
||||||
|
# Metadata is still embedded, but through the pixel-preserving path.
|
||||||
|
assert exif_utils.appended is not None
|
||||||
|
assert exif_utils.appended[2] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_recipe_skip_optimize_default_optimizes(tmp_path):
|
||||||
|
"""Normal saves must keep optimizing; only re-import opts out."""
|
||||||
|
exif_utils = DummyExifUtils()
|
||||||
|
|
||||||
|
class DummyScanner:
|
||||||
|
def __init__(self, root):
|
||||||
|
self.recipes_dir = str(root / "recipes")
|
||||||
|
|
||||||
|
async def add_recipe(self, recipe_data):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def find_recipes_by_fingerprint(self, fingerprint):
|
||||||
|
return []
|
||||||
|
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
service = RecipePersistenceService(
|
||||||
|
exif_utils=exif_utils,
|
||||||
|
card_preview_width=512,
|
||||||
|
logger=logging.getLogger("test"),
|
||||||
|
)
|
||||||
|
|
||||||
|
await service.save_recipe(
|
||||||
|
recipe_scanner=scanner,
|
||||||
|
image_bytes=b"raw-image",
|
||||||
|
image_base64=None,
|
||||||
|
name="Normal",
|
||||||
|
tags=[],
|
||||||
|
metadata={"gen_params": {"steps": 20}, "base_model": "SDXL", "loras": []},
|
||||||
|
extension=".webp",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exif_utils.optimized_calls == 1
|
||||||
|
assert exif_utils.appended is not None
|
||||||
|
assert exif_utils.appended[2] is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_analyze_remote_image_download_failure_cleans_temp(tmp_path, monkeypatch):
|
async def test_analyze_remote_image_download_failure_cleans_temp(tmp_path, monkeypatch):
|
||||||
exif_utils = DummyExifUtils()
|
exif_utils = DummyExifUtils()
|
||||||
@@ -1979,3 +2060,116 @@ async def test_mark_checkpoint_hash_invalid_can_clear_flag(tmp_path):
|
|||||||
|
|
||||||
assert result.payload["hash_invalid"] is False
|
assert result.payload["hash_invalid"] is False
|
||||||
assert result.payload["updated_checkpoint"]["hashInvalid"] is False
|
assert result.payload["updated_checkpoint"]["hashInvalid"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_analyze_local_image_ignore_recipe_metadata_strips_marker(tmp_path):
|
||||||
|
"""Re-import must re-parse the original embedded metadata, not the
|
||||||
|
recipe JSON block appended on save."""
|
||||||
|
original = (
|
||||||
|
"masterpiece, best quality\n"
|
||||||
|
"Negative prompt: lowres\n"
|
||||||
|
"Steps: 20, Sampler: DPM++ 2M Karras, CFG scale: 7, Seed: 1, "
|
||||||
|
"Size: 512x768, Model hash: abc123, Model: foo_v1, Clip skip: 2\n"
|
||||||
|
' Recipe metadata: {"title": "Saved", "loras": [], "gen_params": {}}'
|
||||||
|
)
|
||||||
|
|
||||||
|
class SpyFactory:
|
||||||
|
def __init__(self):
|
||||||
|
self.received = None
|
||||||
|
|
||||||
|
def create_parser(self, metadata):
|
||||||
|
self.received = metadata
|
||||||
|
return _AutomaticMetadataSpyParser()
|
||||||
|
|
||||||
|
class _AutomaticMetadataSpyParser:
|
||||||
|
async def parse_metadata(self, user_comment, recipe_scanner=None, civitai_client=None):
|
||||||
|
return {"loras": [], "base_model": "Illustrious", "gen_params": {"seed": 1}}
|
||||||
|
|
||||||
|
class DummyScanner:
|
||||||
|
async def find_recipes_by_fingerprint(self, fingerprint):
|
||||||
|
return []
|
||||||
|
|
||||||
|
image_path = tmp_path / "rec.webp"
|
||||||
|
image_path.write_bytes(b"fake-image")
|
||||||
|
|
||||||
|
factory = SpyFactory()
|
||||||
|
service = _make_analysis_service(factory, _exif_utils_returning(original))
|
||||||
|
|
||||||
|
result = await service.analyze_local_image(
|
||||||
|
file_path=str(image_path),
|
||||||
|
recipe_scanner=DummyScanner(),
|
||||||
|
ignore_recipe_metadata=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# The parser must receive the original A1111 text without the appended
|
||||||
|
# recipe metadata block, so it re-parses rather than reusing the snapshot.
|
||||||
|
assert factory.received is not None
|
||||||
|
assert "Recipe metadata:" not in factory.received
|
||||||
|
assert factory.received.startswith("masterpiece, best quality")
|
||||||
|
assert '{"title": "Saved"}' not in factory.received
|
||||||
|
assert result.payload["parser"] == "_AutomaticMetadataSpyParser"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_analyze_local_image_ignore_recipe_metadata_only_marker(tmp_path):
|
||||||
|
"""An image carrying only the recipe metadata block (no original embedded
|
||||||
|
metadata) cannot be re-imported; report it instead of reusing the block."""
|
||||||
|
original = 'Recipe metadata: {"title": "Saved", "loras": []}'
|
||||||
|
|
||||||
|
class NeverFactory:
|
||||||
|
def create_parser(self, metadata):
|
||||||
|
raise AssertionError("Parser must not run on stripped metadata")
|
||||||
|
|
||||||
|
image_path = tmp_path / "rec.webp"
|
||||||
|
image_path.write_bytes(b"fake-image")
|
||||||
|
|
||||||
|
service = _make_analysis_service(NeverFactory(), _exif_utils_returning(original))
|
||||||
|
|
||||||
|
result = await service.analyze_local_image(
|
||||||
|
file_path=str(image_path),
|
||||||
|
recipe_scanner=SimpleNamespace(),
|
||||||
|
ignore_recipe_metadata=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "error" in result.payload
|
||||||
|
assert result.payload["diagnostics"]["reason"] == "only_recipe_metadata"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_analyze_local_image_default_keeps_recipe_metadata_behavior(tmp_path):
|
||||||
|
"""Normal import path keeps preferring the recipe metadata block."""
|
||||||
|
original = (
|
||||||
|
"Steps: 20, Sampler: DPM++ 2M Karras, Seed: 1\n"
|
||||||
|
' Recipe metadata: {"title": "Saved", "loras": [], "gen_params": {}}'
|
||||||
|
)
|
||||||
|
|
||||||
|
class SpyFactory:
|
||||||
|
def __init__(self):
|
||||||
|
self.received = None
|
||||||
|
|
||||||
|
def create_parser(self, metadata):
|
||||||
|
self.received = metadata
|
||||||
|
return _AutomaticMetadataSpyParser()
|
||||||
|
|
||||||
|
class _AutomaticMetadataSpyParser:
|
||||||
|
async def parse_metadata(self, user_comment, recipe_scanner=None, civitai_client=None):
|
||||||
|
return {"loras": [], "base_model": "Illustrious", "gen_params": {"seed": 1}}
|
||||||
|
|
||||||
|
class DummyScanner:
|
||||||
|
async def find_recipes_by_fingerprint(self, fingerprint):
|
||||||
|
return []
|
||||||
|
|
||||||
|
image_path = tmp_path / "rec.webp"
|
||||||
|
image_path.write_bytes(b"fake-image")
|
||||||
|
|
||||||
|
factory = SpyFactory()
|
||||||
|
service = _make_analysis_service(factory, _exif_utils_returning(original))
|
||||||
|
|
||||||
|
result = await service.analyze_local_image(
|
||||||
|
file_path=str(image_path),
|
||||||
|
recipe_scanner=DummyScanner(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert factory.received == original
|
||||||
|
assert "Recipe metadata:" in factory.received
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Tests for the empty-hash placeholder predicate in constants."""
|
||||||
|
|
||||||
|
from py.utils.constants import (
|
||||||
|
EMPTY_HASH_SHA256,
|
||||||
|
INVALID_AUTOV2_EMPTY_HASH,
|
||||||
|
INVALID_AUTOV3_EMPTY_HASH,
|
||||||
|
is_empty_placeholder_hash,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsEmptyPlaceholderHash:
|
||||||
|
def test_full_length_sha256(self):
|
||||||
|
assert is_empty_placeholder_hash(EMPTY_HASH_SHA256)
|
||||||
|
|
||||||
|
def test_autov3_length(self):
|
||||||
|
assert is_empty_placeholder_hash("e3b0c44298fc")
|
||||||
|
|
||||||
|
def test_autov2_length(self):
|
||||||
|
assert is_empty_placeholder_hash("e3b0c44298")
|
||||||
|
|
||||||
|
def test_case_insensitive(self):
|
||||||
|
assert is_empty_placeholder_hash("E3B0C44298FC")
|
||||||
|
assert is_empty_placeholder_hash(EMPTY_HASH_SHA256.upper())
|
||||||
|
|
||||||
|
def test_derived_constants_are_prefixes(self):
|
||||||
|
assert INVALID_AUTOV2_EMPTY_HASH == EMPTY_HASH_SHA256[:10]
|
||||||
|
assert INVALID_AUTOV3_EMPTY_HASH == EMPTY_HASH_SHA256[:12]
|
||||||
|
|
||||||
|
def test_rejects_other_lengths(self):
|
||||||
|
# 8-char AutoV1-style prefix and non-placeholder lengths are not it
|
||||||
|
assert not is_empty_placeholder_hash("e3b0c442")
|
||||||
|
assert not is_empty_placeholder_hash("e3b0c44298fc1c")
|
||||||
|
assert not is_empty_placeholder_hash("")
|
||||||
|
|
||||||
|
def test_rejects_real_hashes_that_share_the_prefix(self):
|
||||||
|
# A real hash whose first characters coincide must not be rejected
|
||||||
|
assert not is_empty_placeholder_hash("e3b0c44298aa")
|
||||||
|
assert not is_empty_placeholder_hash("e3b0c44298fc" + "a" * 52)
|
||||||
|
assert not is_empty_placeholder_hash("915a9a1f5f")
|
||||||
|
assert not is_empty_placeholder_hash("915a9a1f5f58")
|
||||||
|
assert not is_empty_placeholder_hash("a" * 64)
|
||||||
|
|
||||||
|
def test_rejects_non_strings(self):
|
||||||
|
assert not is_empty_placeholder_hash(None)
|
||||||
|
assert not is_empty_placeholder_hash(123)
|
||||||
@@ -65,6 +65,69 @@ def test_append_recipe_metadata_includes_checkpoint(monkeypatch, tmp_path):
|
|||||||
assert payload["base_model"] == "Illustrious"
|
assert payload["base_model"] == "Illustrious"
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_recipe_metadata_pixel_preserving_webp(tmp_path):
|
||||||
|
"""pixel_preserving=True must rewrite only the EXIF chunk of a WebP,
|
||||||
|
leaving the pixel chunks byte-identical and the old block replaced."""
|
||||||
|
img = Image.new("RGB", (64, 48), (120, 30, 200))
|
||||||
|
original_params = (
|
||||||
|
"masterpiece, best quality\n"
|
||||||
|
"Negative prompt: lowres\n"
|
||||||
|
"Steps: 20, Sampler: DPM++ 2M Karras, CFG scale: 7, Seed: 1, "
|
||||||
|
"Size: 512x768, Model hash: abc123, Model: foo_v1, Clip skip: 2\n"
|
||||||
|
' Recipe metadata: {"title": "Old", "loras": []}'
|
||||||
|
)
|
||||||
|
exif = piexif.dump(
|
||||||
|
{
|
||||||
|
"0th": {},
|
||||||
|
"Exif": {
|
||||||
|
piexif.ExifIFD.UserComment: b"UNICODE\x00"
|
||||||
|
+ original_params.encode("utf-16be")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
image_path = tmp_path / "recipe.webp"
|
||||||
|
img.save(str(image_path), format="WEBP", exif=exif, quality=85)
|
||||||
|
|
||||||
|
with open(image_path, "rb") as fh:
|
||||||
|
before = fh.read()
|
||||||
|
|
||||||
|
new_recipe = {
|
||||||
|
"title": "New",
|
||||||
|
"base_model": "SDXL",
|
||||||
|
"loras": [],
|
||||||
|
"gen_params": {"steps": 25},
|
||||||
|
}
|
||||||
|
ExifUtils.append_recipe_metadata(
|
||||||
|
str(image_path), new_recipe, pixel_preserving=True
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(image_path, "rb") as fh:
|
||||||
|
after = fh.read()
|
||||||
|
|
||||||
|
def chunks(data: bytes) -> Dict[bytes, bytes]:
|
||||||
|
pos, result = 12, {}
|
||||||
|
while pos + 8 <= len(data):
|
||||||
|
fourcc = data[pos : pos + 4]
|
||||||
|
size = int.from_bytes(data[pos + 4 : pos + 8], "little")
|
||||||
|
result[fourcc] = data[pos + 8 : pos + 8 + size]
|
||||||
|
pos += 8 + size + (size % 2)
|
||||||
|
return result
|
||||||
|
|
||||||
|
before_chunks = chunks(before)
|
||||||
|
after_chunks = chunks(after)
|
||||||
|
for fourcc, payload in before_chunks.items():
|
||||||
|
if fourcc == b"EXIF":
|
||||||
|
assert after_chunks[fourcc] != payload, "EXIF must be replaced"
|
||||||
|
else:
|
||||||
|
assert after_chunks[fourcc] == payload, f"{fourcc} was re-encoded"
|
||||||
|
|
||||||
|
# The appended block is updated; the original parameters stay in front.
|
||||||
|
metadata = ExifUtils.extract_image_metadata(str(image_path))
|
||||||
|
assert "Steps: 20" in metadata
|
||||||
|
assert 'Recipe metadata: {"title": "New"' in metadata
|
||||||
|
assert '"title": "Old"' not in metadata
|
||||||
|
|
||||||
|
|
||||||
def test_optimize_image_preserves_workflow_when_converting_png_to_webp(tmp_path):
|
def test_optimize_image_preserves_workflow_when_converting_png_to_webp(tmp_path):
|
||||||
image_path = tmp_path / "source.png"
|
image_path = tmp_path / "source.png"
|
||||||
png_info = PngImagePlugin.PngInfo()
|
png_info = PngImagePlugin.PngInfo()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
+210
-96
@@ -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.searchType !== 'custom_words'
|
this._renderCommandListFooter();
|
||||||
|| this.activeCommand) {
|
} 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.activeCommand) {
|
||||||
|
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;
|
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;
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user