mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 11:11:26 -03:00
Compare commits
31 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 | |||
| 2a3c632dc5 | |||
| 8d46d26abe | |||
| d761ac77f7 | |||
| c8b9db5bf4 | |||
| bce7d1d30c | |||
| bccd494a56 |
@@ -72,6 +72,11 @@ python scripts/sync_translation_keys.py
|
||||
|
||||
Locale files are in `locales/` (en, zh-CN, zh-TW, ja, ko, fr, de, es, ru, he).
|
||||
|
||||
After adding keys to `en.json` and syncing, **stop**: the `[TODO: Translate]` placeholders in
|
||||
the other locales are the expected end state during feature development. Do NOT translate
|
||||
proactively — translate only when the feature owner explicitly asks (see
|
||||
`docs/i18n-translation-guidelines.md` §7).
|
||||
|
||||
**Before translating anything, read `docs/i18n-translation-guidelines.md`** — it defines the
|
||||
term conventions (e.g. "Recipe" stays untranslated in French, 配方 in Chinese; model-type and
|
||||
brand names are never translated), per-locale preferred renderings, placeholder rules, and
|
||||
|
||||
@@ -23,7 +23,9 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
|
||||
same nested key set. `tests/i18n/test_i18n.py` enforces this.
|
||||
- When a new UI string is added to `en.json`, run
|
||||
`python scripts/sync_translation_keys.py` (adds the missing keys to all locales with
|
||||
placeholder copies), then translate the newly added keys in every locale.
|
||||
`[TODO: Translate]` placeholder copies) — **then stop**. Do NOT translate proactively:
|
||||
placeholders are the expected end state during feature development, and translations are
|
||||
filled in only when the feature owner explicitly asks (workflow details in §7).
|
||||
- Never reorder, re-indent, or reformat a locale file "for tidiness". The sync script
|
||||
preserves formatting; manual reformatting creates noisy diffs.
|
||||
|
||||
|
||||
+150
-10
@@ -50,6 +50,27 @@
|
||||
"mb": "MB",
|
||||
"gb": "GB",
|
||||
"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": {
|
||||
@@ -75,7 +96,7 @@
|
||||
},
|
||||
"bulk": {
|
||||
"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": {
|
||||
"title": "Suchoptionen",
|
||||
@@ -95,7 +116,19 @@
|
||||
},
|
||||
"contextMenu": {
|
||||
"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 (←)",
|
||||
"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": {
|
||||
"sendWorkflow": "Workflow an ComfyUI senden",
|
||||
"sent": "Workflow an ComfyUI gesendet",
|
||||
@@ -898,6 +946,37 @@
|
||||
"notInLibraryTooltip": "Dieses Modell ist nicht in Ihrer Bibliothek",
|
||||
"deletedTooltip": "Dieses LoRA wurde an der Quelle gelöscht und kann nicht mehr heruntergeladen werden",
|
||||
"hashInvalidTooltip": "Dieser LoRA-Hash kann auf CivitAI nicht aufgelöst werden - das Modell wurde möglicherweise aktualisiert",
|
||||
"noLorasAssociated": "Keine LoRAs mit diesem Rezept verknüpft",
|
||||
"noLorasWhyToggle": "Warum keine LoRAs?",
|
||||
"noLorasImportMethod": "Importmethode",
|
||||
"noLorasInferredNote": "Mögliche Ursache (abgeleitet) — dieses Rezept wurde importiert, bevor Importdiagnosen aufgezeichnet wurden.",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "Massenimport (Bild-URL)",
|
||||
"batch_import_local": "Massenimport (lokale Datei)",
|
||||
"url": "Bild-URL-Import",
|
||||
"local": "Import lokaler Datei",
|
||||
"upload": "Bild-Upload",
|
||||
"widget": "Aus Workflow gespeichert",
|
||||
"reimport_url": "Neuimport (Bild-URL)",
|
||||
"reimport_local": "Neuimport (lokale Datei)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "Die Generierungsmetadaten sind vollständig und verweisen auf keine LoRAs.",
|
||||
"api_meta_no_lora_resources": "Die Quell-API hat für dieses Bild keine LoRA-Ressourcendaten zurückgegeben. Auf der CivitAI-Seite angezeigte LoRAs stammen möglicherweise aus internen Daten, die die öffentliche API nicht bereitstellt.",
|
||||
"api_meta_missing": "Die Quell-API hat für dieses Bild keine Generierungsmetadaten zurückgegeben.",
|
||||
"no_embedded_metadata": "Das Bild enthält keine eingebetteten Generierungsmetadaten, sodass LoRA-Informationen nicht wiederhergestellt werden konnten.",
|
||||
"workflow_metadata_limited": "Die eingebetteten Metadaten des Bildes sind ein ComfyUI-Workflow; das Extrahieren von LoRA-Informationen aus Workflows ist eingeschränkt.",
|
||||
"video_no_metadata": "Videodateien enthalten keine eingebetteten Generierungsmetadaten.",
|
||||
"metadata_unsupported": "Das Bild enthält Metadaten in einem Format, das nicht analysiert werden konnte.",
|
||||
"unknown": "Die Ursache konnte aus den gespeicherten Rezeptdaten nicht ermittelt werden."
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "API-Metadatenfelder",
|
||||
"modelVersionIds": "Gemeldete Modellversions-IDs",
|
||||
"embeddedMetadata": "Eingebettete Metadaten",
|
||||
"present": "gefunden",
|
||||
"absent": "keine"
|
||||
},
|
||||
"download": "Herunterladen",
|
||||
"downloadLoraTooltip": "Dieses LoRA herunterladen",
|
||||
"preparingDownload": "Download wird vorbereitet...",
|
||||
@@ -917,7 +996,14 @@
|
||||
"undoReconnectTooltipNamed": "Stellt {name} wieder her (die Verknüpfung vor dem Neuverknüpfen)",
|
||||
"viewOnCivitai": "Auf CivitAI anzeigen",
|
||||
"openLoraDetails": "{name} in der LoRA-Bibliothek anzeigen",
|
||||
"openCheckpointDetails": "{name} in der Modellbibliothek anzeigen"
|
||||
"openCheckpointDetails": "{name} in der Modellbibliothek anzeigen",
|
||||
"checkpointDeletedTooltip": "Dieser Checkpoint wurde aus der Quelle gelöscht und kann nicht mehr heruntergeladen werden - verknüpfen Sie ihn mit einem lokalen Modell neu",
|
||||
"checkpointHashInvalidTooltip": "Dieser Checkpoint-Hash kann auf CivitAI nicht aufgelöst werden - das Modell wurde möglicherweise aktualisiert",
|
||||
"reconnectCheckpoint": "Neu verknüpfen",
|
||||
"reconnectCheckpointTooltip": "Mit einem lokalen Checkpoint neu verknüpfen",
|
||||
"checkpointReconnectInstructions": "Geben Sie den Namen des Checkpoints zum Neuverknüpfen ein:",
|
||||
"checkpointReconnectPlaceholder": "Name des Checkpoints eingeben",
|
||||
"checkpointReconnectSuggestionsEmpty": "Keine passenden Checkpoints in Ihrer lokalen Bibliothek"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -1540,7 +1626,11 @@
|
||||
"clipSkip": "Clip Skip",
|
||||
"valuePlaceholder": "Wert",
|
||||
"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": {
|
||||
"label": "Trigger Words",
|
||||
@@ -1551,7 +1641,7 @@
|
||||
"addPlaceholder": "Tippen zum Hinzufügen oder klicken Sie auf Vorschläge unten",
|
||||
"editWord": "Trigger Word bearbeiten",
|
||||
"editPlaceholder": "Trigger Word bearbeiten",
|
||||
"copyWord": "Trigger Word kopieren",
|
||||
"copyOrEditWord": "Klicken zum Kopieren, Doppelklick zum Bearbeiten",
|
||||
"deleteWord": "Trigger Word löschen",
|
||||
"suggestions": {
|
||||
"noSuggestions": "Keine Vorschläge verfügbar",
|
||||
@@ -1611,8 +1701,8 @@
|
||||
"showCount": "Beispiele anzeigen ({count})",
|
||||
"hideExamples": "Beispiele ausblenden",
|
||||
"addExamples": "Beispiele hinzufügen",
|
||||
"previousExample": "Vorheriges Beispiel",
|
||||
"nextExample": "Nächstes Beispiel",
|
||||
"previousExample": "Vorheriges Beispiel ([)",
|
||||
"nextExample": "Nächstes Beispiel (])",
|
||||
"noExamples": "Keine Beispielbilder verfügbar",
|
||||
"addMoreExamples": "Weitere Beispiele hinzufügen",
|
||||
"dragDrop": "Bilder oder Videos hierher ziehen & ablegen",
|
||||
@@ -1876,10 +1966,52 @@
|
||||
"tabs": {
|
||||
"gettingStarted": "Erste Schritte",
|
||||
"updateVlogs": "Update-Vlogs",
|
||||
"documentation": "Dokumentation"
|
||||
"documentation": "Dokumentation",
|
||||
"shortcuts": "Tastenkürzel"
|
||||
},
|
||||
"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": {
|
||||
"title": "Neueste Updates",
|
||||
@@ -1896,7 +2028,8 @@
|
||||
"settings": "Einstellungen & Konfiguration",
|
||||
"extensions": "Erweiterungen",
|
||||
"newBadge": "NEU"
|
||||
}
|
||||
},
|
||||
"newContentBadge": "NEU"
|
||||
},
|
||||
"update": {
|
||||
"title": "Nach Updates suchen",
|
||||
@@ -2066,6 +2199,13 @@
|
||||
"missingCheckpointPath": "Checkpoint-Pfad nicht verfügbar",
|
||||
"missingCheckpointInfo": "Checkpoint-Informationen fehlen",
|
||||
"downloadCheckpointFailed": "Checkpoint-Download fehlgeschlagen: {message}",
|
||||
"enterCheckpointName": "Bitte geben Sie einen Checkpoint-Namen ein",
|
||||
"checkpointReconnectedSuccessfully": "Checkpoint erfolgreich neu verbunden",
|
||||
"reconnectCheckpointBaseModelMismatch": "Neuverbindung erfolgreich, aber die Basismodelle unterscheiden sich (Rezept: {recipe}, Checkpoint: {checkpoint}) — sie sind architekturkompatibel",
|
||||
"checkpointReconnectFailed": "Fehler beim Neuverbinden des Checkpoints: {message}",
|
||||
"checkpointRestored": "Checkpoint auf die vorherige Verknüpfung zurückgesetzt",
|
||||
"checkpointRestoreFailed": "Fehler beim Wiederherstellen des Checkpoints: {message}",
|
||||
"checkpointDownloadUnavailable": "Dieser Checkpoint kann ohne CivitAI-Kennungen nicht heruntergeladen werden - versuchen Sie, ihn mit einem lokalen Checkpoint neu zu verknüpfen",
|
||||
"missingLoraDownloadInfo": "Download-Informationen für dieses LoRA fehlen",
|
||||
"hashNotFoundOnCivitai": "Dieser LoRA-Hash kann auf CivitAI nicht aufgelöst werden - das Modell wurde möglicherweise aktualisiert oder der Hash ist ungültig",
|
||||
"downloadLoraFailed": "LoRA-Download fehlgeschlagen: {message}",
|
||||
|
||||
+150
-10
@@ -50,6 +50,27 @@
|
||||
"mb": "MB",
|
||||
"gb": "GB",
|
||||
"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": {
|
||||
@@ -75,7 +96,7 @@
|
||||
},
|
||||
"bulk": {
|
||||
"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": {
|
||||
"title": "Search Options",
|
||||
@@ -95,7 +116,19 @@
|
||||
},
|
||||
"contextMenu": {
|
||||
"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 (←)",
|
||||
"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": {
|
||||
"sendWorkflow": "Send Workflow to ComfyUI",
|
||||
"sent": "Workflow sent to ComfyUI",
|
||||
@@ -898,6 +946,37 @@
|
||||
"notInLibraryTooltip": "This model is not in your library",
|
||||
"deletedTooltip": "This LoRA was deleted from the source and is no longer available for download",
|
||||
"hashInvalidTooltip": "This LoRA hash cannot be resolved on CivitAI - the model may have been updated",
|
||||
"noLorasAssociated": "No LoRAs associated with this recipe",
|
||||
"noLorasWhyToggle": "Why no LoRAs?",
|
||||
"noLorasImportMethod": "Import method",
|
||||
"noLorasInferredNote": "Possible reason (inferred) — this recipe was imported before import diagnostics were recorded.",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "Batch import (image URL)",
|
||||
"batch_import_local": "Batch import (local file)",
|
||||
"url": "Image URL import",
|
||||
"local": "Local file import",
|
||||
"upload": "Image upload",
|
||||
"widget": "Saved from workflow",
|
||||
"reimport_url": "Re-import (image URL)",
|
||||
"reimport_local": "Re-import (local file)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "The generation metadata is complete and does not reference any LoRAs.",
|
||||
"api_meta_no_lora_resources": "The source API returned no LoRA resource data for this image. LoRAs shown on the CivitAI page may come from internal data that the public API does not expose.",
|
||||
"api_meta_missing": "The source API returned no generation metadata for this image.",
|
||||
"no_embedded_metadata": "The image has no embedded generation metadata, so LoRA information could not be recovered.",
|
||||
"workflow_metadata_limited": "The image's embedded metadata is a ComfyUI workflow; extracting LoRA information from workflows is limited.",
|
||||
"video_no_metadata": "Video files do not carry embedded generation metadata.",
|
||||
"metadata_unsupported": "The image contains metadata in a format that could not be parsed.",
|
||||
"unknown": "The reason could not be determined from the stored recipe data."
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "API metadata fields",
|
||||
"modelVersionIds": "Model version IDs reported",
|
||||
"embeddedMetadata": "Embedded metadata",
|
||||
"present": "found",
|
||||
"absent": "none"
|
||||
},
|
||||
"download": "Download",
|
||||
"downloadLoraTooltip": "Download this LoRA",
|
||||
"preparingDownload": "Preparing download...",
|
||||
@@ -917,7 +996,14 @@
|
||||
"undoReconnectTooltipNamed": "Restore to {name} (the association before reconnecting)",
|
||||
"viewOnCivitai": "View on CivitAI",
|
||||
"openLoraDetails": "View {name} in the LoRA library",
|
||||
"openCheckpointDetails": "View {name} in the model library"
|
||||
"openCheckpointDetails": "View {name} in the model library",
|
||||
"checkpointDeletedTooltip": "This checkpoint was deleted from the source and can no longer be downloaded - reconnect it with a local model",
|
||||
"checkpointHashInvalidTooltip": "This checkpoint hash cannot be resolved on CivitAI - the model may have been updated",
|
||||
"reconnectCheckpoint": "Reconnect",
|
||||
"reconnectCheckpointTooltip": "Reconnect with a local checkpoint",
|
||||
"checkpointReconnectInstructions": "Enter checkpoint name to reconnect:",
|
||||
"checkpointReconnectPlaceholder": "Enter checkpoint name",
|
||||
"checkpointReconnectSuggestionsEmpty": "No matching checkpoints in your local library"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -1540,7 +1626,11 @@
|
||||
"clipSkip": "Clip Skip",
|
||||
"valuePlaceholder": "Value",
|
||||
"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": {
|
||||
"label": "Trigger Words",
|
||||
@@ -1551,7 +1641,7 @@
|
||||
"addPlaceholder": "Type to add or click suggestions below",
|
||||
"editWord": "Edit trigger word",
|
||||
"editPlaceholder": "Edit trigger word",
|
||||
"copyWord": "Copy trigger word",
|
||||
"copyOrEditWord": "Click to copy, double-click to edit",
|
||||
"deleteWord": "Delete trigger word",
|
||||
"suggestions": {
|
||||
"noSuggestions": "No suggestions available",
|
||||
@@ -1611,8 +1701,8 @@
|
||||
"showCount": "Show examples ({count})",
|
||||
"hideExamples": "Hide examples",
|
||||
"addExamples": "Add examples",
|
||||
"previousExample": "Previous example",
|
||||
"nextExample": "Next example",
|
||||
"previousExample": "Previous example ([)",
|
||||
"nextExample": "Next example (])",
|
||||
"noExamples": "No example images available",
|
||||
"addMoreExamples": "Add more examples",
|
||||
"dragDrop": "Drag & drop images or videos here",
|
||||
@@ -1876,10 +1966,52 @@
|
||||
"tabs": {
|
||||
"gettingStarted": "Getting Started",
|
||||
"updateVlogs": "Update Vlogs",
|
||||
"documentation": "Documentation"
|
||||
"documentation": "Documentation",
|
||||
"shortcuts": "Shortcuts"
|
||||
},
|
||||
"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": {
|
||||
"title": "Latest Updates",
|
||||
@@ -1896,7 +2028,8 @@
|
||||
"settings": "Settings & Configuration",
|
||||
"extensions": "Extensions",
|
||||
"newBadge": "NEW"
|
||||
}
|
||||
},
|
||||
"newContentBadge": "New"
|
||||
},
|
||||
"update": {
|
||||
"title": "Check for Updates",
|
||||
@@ -2066,6 +2199,13 @@
|
||||
"missingCheckpointPath": "Checkpoint path not available",
|
||||
"missingCheckpointInfo": "Missing checkpoint information",
|
||||
"downloadCheckpointFailed": "Failed to download checkpoint: {message}",
|
||||
"enterCheckpointName": "Please enter a checkpoint name",
|
||||
"checkpointReconnectedSuccessfully": "Checkpoint reconnected successfully",
|
||||
"reconnectCheckpointBaseModelMismatch": "Reconnected, but base models differ (recipe: {recipe}, checkpoint: {checkpoint}) — they are architecture-compatible",
|
||||
"checkpointReconnectFailed": "Error reconnecting checkpoint: {message}",
|
||||
"checkpointRestored": "Checkpoint restored to its previous association",
|
||||
"checkpointRestoreFailed": "Error restoring checkpoint: {message}",
|
||||
"checkpointDownloadUnavailable": "This checkpoint cannot be downloaded without CivitAI identifiers - try reconnecting it with a local checkpoint",
|
||||
"missingLoraDownloadInfo": "Missing download information for this LoRA",
|
||||
"hashNotFoundOnCivitai": "This LoRA hash cannot be resolved on CivitAI - the model may have been updated or the hash is invalid",
|
||||
"downloadLoraFailed": "Failed to download LoRA: {message}",
|
||||
|
||||
+150
-10
@@ -50,6 +50,27 @@
|
||||
"mb": "MB",
|
||||
"gb": "GB",
|
||||
"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": {
|
||||
@@ -75,7 +96,7 @@
|
||||
},
|
||||
"bulk": {
|
||||
"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": {
|
||||
"title": "Opciones de búsqueda",
|
||||
@@ -95,7 +116,19 @@
|
||||
},
|
||||
"contextMenu": {
|
||||
"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 (←)",
|
||||
"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": {
|
||||
"sendWorkflow": "Enviar workflow a ComfyUI",
|
||||
"sent": "Workflow enviado a ComfyUI",
|
||||
@@ -898,6 +946,37 @@
|
||||
"notInLibraryTooltip": "Este modelo no está en tu biblioteca",
|
||||
"deletedTooltip": "Este LoRA fue eliminado de la fuente y ya no se puede descargar",
|
||||
"hashInvalidTooltip": "Este hash de LoRA no se puede resolver en CivitAI - el modelo puede haber sido actualizado",
|
||||
"noLorasAssociated": "No hay LoRAs asociados con esta receta",
|
||||
"noLorasWhyToggle": "¿Por qué no hay LoRAs?",
|
||||
"noLorasImportMethod": "Método de importación",
|
||||
"noLorasInferredNote": "Posible motivo (inferido): esta receta se importó antes de que se registraran los diagnósticos de importación.",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "Importación por lotes (URL de imagen)",
|
||||
"batch_import_local": "Importación por lotes (archivo local)",
|
||||
"url": "Importación desde URL de imagen",
|
||||
"local": "Importación de archivo local",
|
||||
"upload": "Carga de imagen",
|
||||
"widget": "Guardada desde el workflow",
|
||||
"reimport_url": "Reimportación (URL de imagen)",
|
||||
"reimport_local": "Reimportación (archivo local)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "Los metadatos de generación están completos y no hacen referencia a ningún LoRA.",
|
||||
"api_meta_no_lora_resources": "La API de origen no devolvió datos de recursos LoRA para esta imagen. Los LoRAs que se muestran en la página de CivitAI pueden proceder de datos internos que la API pública no expone.",
|
||||
"api_meta_missing": "La API de origen no devolvió metadatos de generación para esta imagen.",
|
||||
"no_embedded_metadata": "La imagen no tiene metadatos de generación incrustados, por lo que no se pudo recuperar la información de LoRAs.",
|
||||
"workflow_metadata_limited": "Los metadatos incrustados en la imagen son un workflow de ComfyUI; la extracción de información de LoRAs a partir de workflows es limitada.",
|
||||
"video_no_metadata": "Los archivos de vídeo no contienen metadatos de generación incrustados.",
|
||||
"metadata_unsupported": "La imagen contiene metadatos en un formato que no se pudo analizar.",
|
||||
"unknown": "No se pudo determinar el motivo a partir de los datos de la receta almacenados."
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "Campos de metadatos de la API",
|
||||
"modelVersionIds": "IDs de versión de modelo informados",
|
||||
"embeddedMetadata": "Metadatos incrustados",
|
||||
"present": "encontrados",
|
||||
"absent": "ninguno"
|
||||
},
|
||||
"download": "Descargar",
|
||||
"downloadLoraTooltip": "Descargar este LoRA",
|
||||
"preparingDownload": "Preparando descarga...",
|
||||
@@ -917,7 +996,14 @@
|
||||
"undoReconnectTooltipNamed": "Restaurar a {name} (la asociación antes de reconectar)",
|
||||
"viewOnCivitai": "Ver en CivitAI",
|
||||
"openLoraDetails": "Ver {name} en la biblioteca de LoRAs",
|
||||
"openCheckpointDetails": "Ver {name} en la biblioteca de modelos"
|
||||
"openCheckpointDetails": "Ver {name} en la biblioteca de modelos",
|
||||
"checkpointDeletedTooltip": "Este checkpoint fue eliminado de la fuente y ya no se puede descargar - reconéctalo con un modelo local",
|
||||
"checkpointHashInvalidTooltip": "El hash de este checkpoint no se puede resolver en CivitAI - el modelo puede haber sido actualizado",
|
||||
"reconnectCheckpoint": "Reconectar",
|
||||
"reconnectCheckpointTooltip": "Reconectar con un checkpoint local",
|
||||
"checkpointReconnectInstructions": "Introduce el nombre del checkpoint para reconectar:",
|
||||
"checkpointReconnectPlaceholder": "Introduce el nombre del checkpoint",
|
||||
"checkpointReconnectSuggestionsEmpty": "No hay checkpoints coincidentes en tu biblioteca local"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -1540,7 +1626,11 @@
|
||||
"clipSkip": "Clip Skip",
|
||||
"valuePlaceholder": "Valor",
|
||||
"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": {
|
||||
"label": "Palabras clave",
|
||||
@@ -1551,7 +1641,7 @@
|
||||
"addPlaceholder": "Escribe para añadir o haz clic en sugerencias de abajo",
|
||||
"editWord": "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",
|
||||
"suggestions": {
|
||||
"noSuggestions": "No hay sugerencias disponibles",
|
||||
@@ -1611,8 +1701,8 @@
|
||||
"showCount": "Mostrar ejemplos ({count})",
|
||||
"hideExamples": "Ocultar ejemplos",
|
||||
"addExamples": "Añadir ejemplos",
|
||||
"previousExample": "Ejemplo anterior",
|
||||
"nextExample": "Ejemplo siguiente",
|
||||
"previousExample": "Ejemplo anterior ([)",
|
||||
"nextExample": "Ejemplo siguiente (])",
|
||||
"noExamples": "No hay imágenes de ejemplo disponibles",
|
||||
"addMoreExamples": "Añadir más ejemplos",
|
||||
"dragDrop": "Arrastra y suelta imágenes o videos aquí",
|
||||
@@ -1876,10 +1966,52 @@
|
||||
"tabs": {
|
||||
"gettingStarted": "Comenzando",
|
||||
"updateVlogs": "Vlogs de actualización",
|
||||
"documentation": "Documentación"
|
||||
"documentation": "Documentación",
|
||||
"shortcuts": "Atajos"
|
||||
},
|
||||
"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": {
|
||||
"title": "Últimas actualizaciones",
|
||||
@@ -1896,7 +2028,8 @@
|
||||
"settings": "Configuración",
|
||||
"extensions": "Extensiones",
|
||||
"newBadge": "NUEVO"
|
||||
}
|
||||
},
|
||||
"newContentBadge": "NUEVO"
|
||||
},
|
||||
"update": {
|
||||
"title": "Comprobar actualizaciones",
|
||||
@@ -2066,6 +2199,13 @@
|
||||
"missingCheckpointPath": "Ruta del checkpoint no disponible",
|
||||
"missingCheckpointInfo": "Falta información del checkpoint",
|
||||
"downloadCheckpointFailed": "Error al descargar el checkpoint: {message}",
|
||||
"enterCheckpointName": "Introduce un nombre de checkpoint",
|
||||
"checkpointReconnectedSuccessfully": "Checkpoint reconectado exitosamente",
|
||||
"reconnectCheckpointBaseModelMismatch": "Reconectado, pero los modelos base difieren (receta: {recipe}, checkpoint: {checkpoint}) — son compatibles a nivel de arquitectura",
|
||||
"checkpointReconnectFailed": "Error reconectando checkpoint: {message}",
|
||||
"checkpointRestored": "Checkpoint restaurado a su asociación anterior",
|
||||
"checkpointRestoreFailed": "Error restaurando checkpoint: {message}",
|
||||
"checkpointDownloadUnavailable": "Este checkpoint no se puede descargar sin identificadores de CivitAI - intenta reconectarlo con un checkpoint local",
|
||||
"missingLoraDownloadInfo": "Falta la información de descarga de este LoRA",
|
||||
"hashNotFoundOnCivitai": "Este hash de LoRA no se puede resolver en CivitAI - el modelo puede haber sido actualizado o el hash no es válido",
|
||||
"downloadLoraFailed": "Error al descargar el LoRA: {message}",
|
||||
|
||||
+150
-10
@@ -50,6 +50,27 @@
|
||||
"mb": "Mo",
|
||||
"gb": "Go",
|
||||
"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": {
|
||||
@@ -75,7 +96,7 @@
|
||||
},
|
||||
"bulk": {
|
||||
"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": {
|
||||
"title": "Options de recherche",
|
||||
@@ -95,7 +116,19 @@
|
||||
},
|
||||
"contextMenu": {
|
||||
"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 (←)",
|
||||
"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": {
|
||||
"sendWorkflow": "Envoyer le workflow vers ComfyUI",
|
||||
"sent": "Workflow envoyé vers ComfyUI",
|
||||
@@ -898,6 +946,37 @@
|
||||
"notInLibraryTooltip": "Ce modèle n'est pas dans votre bibliothèque",
|
||||
"deletedTooltip": "Ce LoRA a été supprimé de la source et ne peut plus être téléchargé",
|
||||
"hashInvalidTooltip": "Ce hash de LoRA ne peut pas être résolu sur CivitAI - le modèle a peut-être été mis à jour",
|
||||
"noLorasAssociated": "Aucune LoRA associée à cette Recipe",
|
||||
"noLorasWhyToggle": "Pourquoi aucune LoRA ?",
|
||||
"noLorasImportMethod": "Méthode d'import",
|
||||
"noLorasInferredNote": "Raison possible (déduite) — cette Recipe a été importée avant l'enregistrement des diagnostics d'import.",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "Import groupé (URL d'image)",
|
||||
"batch_import_local": "Import groupé (fichier local)",
|
||||
"url": "Import d'une URL d'image",
|
||||
"local": "Import d'un fichier local",
|
||||
"upload": "Téléversement d'image",
|
||||
"widget": "Enregistrée depuis le Workflow",
|
||||
"reimport_url": "Réimport (URL d'image)",
|
||||
"reimport_local": "Réimport (fichier local)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "Les métadonnées de génération sont complètes et ne référencent aucune LoRA.",
|
||||
"api_meta_no_lora_resources": "L'API source n'a renvoyé aucune donnée de ressource LoRA pour cette image. Les LoRAs affichées sur la page CivitAI peuvent provenir de données internes que l'API publique n'expose pas.",
|
||||
"api_meta_missing": "L'API source n'a renvoyé aucune métadonnée de génération pour cette image.",
|
||||
"no_embedded_metadata": "L'image ne contient aucune métadonnée de génération intégrée ; les informations LoRA n'ont donc pas pu être récupérées.",
|
||||
"workflow_metadata_limited": "Les métadonnées intégrées à l'image sont un Workflow ComfyUI ; l'extraction des informations LoRA à partir des Workflows est limitée.",
|
||||
"video_no_metadata": "Les fichiers vidéo ne contiennent pas de métadonnées de génération intégrées.",
|
||||
"metadata_unsupported": "L'image contient des métadonnées dans un format non analysable.",
|
||||
"unknown": "La raison n'a pas pu être déterminée à partir des données de la Recipe enregistrée."
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "Champs de métadonnées de l'API",
|
||||
"modelVersionIds": "IDs de version de modèle signalés",
|
||||
"embeddedMetadata": "Métadonnées intégrées",
|
||||
"present": "trouvées",
|
||||
"absent": "aucune"
|
||||
},
|
||||
"download": "Télécharger",
|
||||
"downloadLoraTooltip": "Télécharger ce LoRA",
|
||||
"preparingDownload": "Préparation du téléchargement...",
|
||||
@@ -917,7 +996,14 @@
|
||||
"undoReconnectTooltipNamed": "Restaurer vers {name} (l'association avant la reconnexion)",
|
||||
"viewOnCivitai": "Voir sur CivitAI",
|
||||
"openLoraDetails": "Voir {name} dans la bibliothèque LoRA",
|
||||
"openCheckpointDetails": "Voir {name} dans la bibliothèque de modèles"
|
||||
"openCheckpointDetails": "Voir {name} dans la bibliothèque de modèles",
|
||||
"checkpointDeletedTooltip": "Ce checkpoint a été supprimé de la source et ne peut plus être téléchargé - reconnectez-le avec un modèle local",
|
||||
"checkpointHashInvalidTooltip": "Le hash de ce checkpoint ne peut pas être résolu sur CivitAI - le modèle a peut-être été mis à jour",
|
||||
"reconnectCheckpoint": "Reconnecter",
|
||||
"reconnectCheckpointTooltip": "Reconnecter avec un checkpoint local",
|
||||
"checkpointReconnectInstructions": "Entrez le nom du checkpoint à reconnecter:",
|
||||
"checkpointReconnectPlaceholder": "Entrez le nom du checkpoint",
|
||||
"checkpointReconnectSuggestionsEmpty": "Aucun checkpoint correspondant dans votre bibliothèque locale"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -1540,7 +1626,11 @@
|
||||
"clipSkip": "Clip Skip",
|
||||
"valuePlaceholder": "Valeur",
|
||||
"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": {
|
||||
"label": "Mots-clés",
|
||||
@@ -1551,7 +1641,7 @@
|
||||
"addPlaceholder": "Tapez pour ajouter ou cliquez sur les suggestions ci-dessous",
|
||||
"editWord": "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é",
|
||||
"suggestions": {
|
||||
"noSuggestions": "Aucune suggestion disponible",
|
||||
@@ -1611,8 +1701,8 @@
|
||||
"showCount": "Afficher les exemples ({count})",
|
||||
"hideExamples": "Masquer les exemples",
|
||||
"addExamples": "Ajouter des exemples",
|
||||
"previousExample": "Exemple précédent",
|
||||
"nextExample": "Exemple suivant",
|
||||
"previousExample": "Exemple précédent ([)",
|
||||
"nextExample": "Exemple suivant (])",
|
||||
"noExamples": "Aucune image d'exemple disponible",
|
||||
"addMoreExamples": "Ajouter d'autres exemples",
|
||||
"dragDrop": "Glissez-déposez des images ou des vidéos ici",
|
||||
@@ -1876,10 +1966,52 @@
|
||||
"tabs": {
|
||||
"gettingStarted": "Commencer",
|
||||
"updateVlogs": "Vlogs de mise à jour",
|
||||
"documentation": "Documentation"
|
||||
"documentation": "Documentation",
|
||||
"shortcuts": "Raccourcis"
|
||||
},
|
||||
"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": {
|
||||
"title": "Dernières mises à jour",
|
||||
@@ -1896,7 +2028,8 @@
|
||||
"settings": "Paramètres & Configuration",
|
||||
"extensions": "Extensions",
|
||||
"newBadge": "NOUVEAU"
|
||||
}
|
||||
},
|
||||
"newContentBadge": "NOUVEAU"
|
||||
},
|
||||
"update": {
|
||||
"title": "Vérifier les mises à jour",
|
||||
@@ -2066,6 +2199,13 @@
|
||||
"missingCheckpointPath": "Chemin du checkpoint indisponible",
|
||||
"missingCheckpointInfo": "Informations sur le checkpoint manquantes",
|
||||
"downloadCheckpointFailed": "Échec du téléchargement du checkpoint : {message}",
|
||||
"enterCheckpointName": "Veuillez saisir un nom de checkpoint",
|
||||
"checkpointReconnectedSuccessfully": "Checkpoint reconnecté avec succès",
|
||||
"reconnectCheckpointBaseModelMismatch": "Reconnexion effectuée, mais les modèles de base diffèrent (Recipe : {recipe}, checkpoint : {checkpoint}) — ils sont compatibles au niveau architectural",
|
||||
"checkpointReconnectFailed": "Erreur lors de la reconnexion du checkpoint : {message}",
|
||||
"checkpointRestored": "Checkpoint restauré à son association précédente",
|
||||
"checkpointRestoreFailed": "Erreur lors de la restauration du checkpoint : {message}",
|
||||
"checkpointDownloadUnavailable": "Ce checkpoint ne peut pas être téléchargé sans identifiants CivitAI - essayez de le reconnecter avec un checkpoint local",
|
||||
"missingLoraDownloadInfo": "Informations de téléchargement manquantes pour ce LoRA",
|
||||
"hashNotFoundOnCivitai": "Ce hash de LoRA ne peut pas être résolu sur CivitAI - le modèle a peut-être été mis à jour ou le hash est invalide",
|
||||
"downloadLoraFailed": "Échec du téléchargement du LoRA : {message}",
|
||||
|
||||
+150
-10
@@ -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": {
|
||||
@@ -75,7 +96,7 @@
|
||||
},
|
||||
"bulk": {
|
||||
"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": {
|
||||
"title": "אפשרויות חיפוש",
|
||||
@@ -95,7 +116,19 @@
|
||||
},
|
||||
"contextMenu": {
|
||||
"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": "המתכון הקודם (←)",
|
||||
"nextWithShortcut": "המתכון הבא (→)"
|
||||
},
|
||||
"modal": {
|
||||
"metadata": {
|
||||
"id": "ID"
|
||||
},
|
||||
"actions": {
|
||||
"openFileLocation": "פתח מיקום קובץ",
|
||||
"copyId": "העתק מזהה מתכון"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "מיקום הקובץ נפתח בהצלחה",
|
||||
"failed": "פתיחת מיקום הקובץ נכשלה",
|
||||
"copied": "הנתיב הועתק ללוח העריכה: {{path}}",
|
||||
"clipboardFallback": "נתיב: {{path}}"
|
||||
}
|
||||
},
|
||||
"workflow": {
|
||||
"sendWorkflow": "שלח workflow ל-ComfyUI",
|
||||
"sent": "ה-workflow נשלח ל-ComfyUI",
|
||||
@@ -898,6 +946,37 @@
|
||||
"notInLibraryTooltip": "מודל זה לא נמצא בספרייה שלך",
|
||||
"deletedTooltip": "LoRA זה נמחק מהמקור ואינו זמין יותר להורדה",
|
||||
"hashInvalidTooltip": "לא ניתן לפתור את ה-hash של ה-LoRA ב-CivitAI - ייתכן שהמודל עודכן",
|
||||
"noLorasAssociated": "אין LoRAs המשויכים למתכון זה",
|
||||
"noLorasWhyToggle": "למה אין LoRAs?",
|
||||
"noLorasImportMethod": "שיטת ייבוא",
|
||||
"noLorasInferredNote": "סיבה אפשרית (משוערת) — מתכון זה יובא לפני שנרשמו אבחוני ייבוא.",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "ייבוא בכמות גדולה (URL של תמונה)",
|
||||
"batch_import_local": "ייבוא בכמות גדולה (קובץ מקומי)",
|
||||
"url": "ייבוא מ-URL של תמונה",
|
||||
"local": "ייבוא קובץ מקומי",
|
||||
"upload": "העלאת תמונה",
|
||||
"widget": "נשמר מה-workflow",
|
||||
"reimport_url": "ייבוא מחדש (URL של תמונה)",
|
||||
"reimport_local": "ייבוא מחדש (קובץ מקומי)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "מטא-הנתונים של היצירה שלמים ואינם מפנים ל-LoRAs כלשהם.",
|
||||
"api_meta_no_lora_resources": "ה-API של המקור לא החזיר נתוני משאבי LoRA עבור תמונה זו. LoRAs המוצגים בעמוד CivitAI עשויים להגיע מנתונים פנימיים שה-API הציבורי אינו חושף.",
|
||||
"api_meta_missing": "ה-API של המקור לא החזיר מטא-נתוני יצירה עבור תמונה זו.",
|
||||
"no_embedded_metadata": "לתמונה אין מטא-נתוני יצירה מוטבעים, ולכן לא ניתן היה לשחזר את מידע ה-LoRA.",
|
||||
"workflow_metadata_limited": "המטא-נתונים המוטבעים של התמונה הם workflow של ComfyUI; חילוץ מידע LoRA מתוך workflows מוגבל.",
|
||||
"video_no_metadata": "קבצי וידאו אינם נושאים מטא-נתוני יצירה מוטבעים.",
|
||||
"metadata_unsupported": "התמונה מכילה מטא-נתונים בפורמט שלא ניתן לנתח.",
|
||||
"unknown": "לא ניתן היה לקבוע את הסיבה מנתוני המתכון השמורים."
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "שדות מטא-נתונים של API",
|
||||
"modelVersionIds": "מספר מזהי גרסת מודל שדווחו",
|
||||
"embeddedMetadata": "מטא-נתונים מוטבעים",
|
||||
"present": "נמצאו",
|
||||
"absent": "אין"
|
||||
},
|
||||
"download": "הורדה",
|
||||
"downloadLoraTooltip": "הורד את ה-LoRA הזה",
|
||||
"preparingDownload": "מכין את ההורדה...",
|
||||
@@ -917,7 +996,14 @@
|
||||
"undoReconnectTooltipNamed": "שחזר ל-{name} (השיוך לפני החיבור מחדש)",
|
||||
"viewOnCivitai": "הצג ב-CivitAI",
|
||||
"openLoraDetails": "הצג את {name} בספריית ה-LoRA",
|
||||
"openCheckpointDetails": "הצג את {name} בספריית המודלים"
|
||||
"openCheckpointDetails": "הצג את {name} בספריית המודלים",
|
||||
"checkpointDeletedTooltip": "Checkpoint זה נמחק מהמקור ואינו זמין עוד להורדה - חבר אותו מחדש עם מודל מקומי",
|
||||
"checkpointHashInvalidTooltip": "לא ניתן לפתור את ה-hash של Checkpoint זה ב-CivitAI - ייתכן שהמודל עודכן",
|
||||
"reconnectCheckpoint": "חבר מחדש",
|
||||
"reconnectCheckpointTooltip": "חבר מחדש עם Checkpoint מקומי",
|
||||
"checkpointReconnectInstructions": "הזן שם של Checkpoint לחיבור מחדש:",
|
||||
"checkpointReconnectPlaceholder": "הזן שם של Checkpoint",
|
||||
"checkpointReconnectSuggestionsEmpty": "לא נמצאו Checkpoints תואמים בספרייה המקומית שלך"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -1540,7 +1626,11 @@
|
||||
"clipSkip": "Clip Skip",
|
||||
"valuePlaceholder": "ערך",
|
||||
"add": "הוסף",
|
||||
"invalidRange": "פורמט טווח לא תקין. השתמש ב-x.x-y.y"
|
||||
"invalidRange": "פורמט טווח לא תקין. השתמש ב-x.x-y.y",
|
||||
"invalidValue": "נא להזין מספר תקין",
|
||||
"saveFailed": "שמירת הפרמטר הקבוע מראש נכשלה",
|
||||
"added": "הפרמטר הקבוע מראש נוסף",
|
||||
"updated": "הפרמטר הקבוע מראש עודכן"
|
||||
},
|
||||
"triggerWords": {
|
||||
"label": "מילות טריגר",
|
||||
@@ -1551,7 +1641,7 @@
|
||||
"addPlaceholder": "הקלד להוספה או לחץ על הצעות למטה",
|
||||
"editWord": "עריכת מילת טריגר",
|
||||
"editPlaceholder": "עריכת מילת טריגר",
|
||||
"copyWord": "העתק מילת טריגר",
|
||||
"copyOrEditWord": "לחץ כדי להעתיק, לחץ פעמיים כדי לערוך",
|
||||
"deleteWord": "מחק מילת טריגר",
|
||||
"suggestions": {
|
||||
"noSuggestions": "אין הצעות זמינות",
|
||||
@@ -1611,8 +1701,8 @@
|
||||
"showCount": "הצג דוגמאות ({count})",
|
||||
"hideExamples": "הסתר דוגמאות",
|
||||
"addExamples": "הוסף דוגמאות",
|
||||
"previousExample": "דוגמה קודמת",
|
||||
"nextExample": "דוגמה הבאה",
|
||||
"previousExample": "דוגמה קודמת ([)",
|
||||
"nextExample": "דוגמה הבאה (])",
|
||||
"noExamples": "אין תמונות דוגמה זמינות",
|
||||
"addMoreExamples": "הוסף עוד דוגמאות",
|
||||
"dragDrop": "גרור ושחרר תמונות או סרטונים כאן",
|
||||
@@ -1876,10 +1966,52 @@
|
||||
"tabs": {
|
||||
"gettingStarted": "תחילת עבודה",
|
||||
"updateVlogs": "בלוגי וידאו של עדכונים",
|
||||
"documentation": "תיעוד"
|
||||
"documentation": "תיעוד",
|
||||
"shortcuts": "קיצורי דרך"
|
||||
},
|
||||
"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": {
|
||||
"title": "עדכונים אחרונים",
|
||||
@@ -1896,7 +2028,8 @@
|
||||
"settings": "הגדרות ותצורה",
|
||||
"extensions": "הרחבות",
|
||||
"newBadge": "חדש"
|
||||
}
|
||||
},
|
||||
"newContentBadge": "חדש"
|
||||
},
|
||||
"update": {
|
||||
"title": "בדוק עדכונים",
|
||||
@@ -2066,6 +2199,13 @@
|
||||
"missingCheckpointPath": "נתיב ה-checkpoint אינו זמין",
|
||||
"missingCheckpointInfo": "חסרים פרטי checkpoint",
|
||||
"downloadCheckpointFailed": "הורדת checkpoint נכשלה: {message}",
|
||||
"enterCheckpointName": "הזן שם של Checkpoint",
|
||||
"checkpointReconnectedSuccessfully": "Checkpoint קושר מחדש בהצלחה",
|
||||
"reconnectCheckpointBaseModelMismatch": "הקישור מחדש הצליח, אך מודלי הבסיס שונים (מתכון: {recipe}, Checkpoint: {checkpoint}) — הם תואמים מבחינת הארכיטקטורה",
|
||||
"checkpointReconnectFailed": "שגיאה בקישור מחדש של Checkpoint: {message}",
|
||||
"checkpointRestored": "Checkpoint שוחזר לשיוך הקודם",
|
||||
"checkpointRestoreFailed": "שגיאה בשחזור Checkpoint: {message}",
|
||||
"checkpointDownloadUnavailable": "לא ניתן להוריד Checkpoint זה ללא מזהי CivitAI - נסה לחבר אותו מחדש עם Checkpoint מקומי",
|
||||
"missingLoraDownloadInfo": "חסר מידע הורדה עבור LoRA זה",
|
||||
"hashNotFoundOnCivitai": "לא ניתן לפתור את ה-hash של ה-LoRA ב-CivitAI - ייתכן שהמודל עודכן או שה-hash אינו תקין",
|
||||
"downloadLoraFailed": "הורדת ה-LoRA נכשלה: {message}",
|
||||
|
||||
+150
-10
@@ -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": "残り1分未満",
|
||||
"minutes": "残り約 {minutes} 分",
|
||||
"hours": "残り約 {hours} 時間 {minutes} 分"
|
||||
}
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
@@ -75,7 +96,7 @@
|
||||
},
|
||||
"bulk": {
|
||||
"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": {
|
||||
"title": "検索オプション",
|
||||
@@ -95,7 +116,19 @@
|
||||
},
|
||||
"contextMenu": {
|
||||
"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": "前のレシピ(←)",
|
||||
"nextWithShortcut": "次のレシピ(→)"
|
||||
},
|
||||
"modal": {
|
||||
"metadata": {
|
||||
"id": "ID"
|
||||
},
|
||||
"actions": {
|
||||
"openFileLocation": "ファイルの場所を開く",
|
||||
"copyId": "レシピIDをコピー"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "ファイルの場所を正常に開きました",
|
||||
"failed": "ファイルの場所を開くのに失敗しました",
|
||||
"copied": "パスをクリップボードにコピーしました: {{path}}",
|
||||
"clipboardFallback": "パス: {{path}}"
|
||||
}
|
||||
},
|
||||
"workflow": {
|
||||
"sendWorkflow": "ワークフローをComfyUIへ送信",
|
||||
"sent": "ワークフローをComfyUIへ送信しました",
|
||||
@@ -898,6 +946,37 @@
|
||||
"notInLibraryTooltip": "このモデルはライブラリにありません",
|
||||
"deletedTooltip": "この LoRA は配信元から削除されたため、ダウンロードできません",
|
||||
"hashInvalidTooltip": "このLoRAハッシュはCivitAIで解決できません - モデルが更新された可能性があります",
|
||||
"noLorasAssociated": "このレシピに関連付けられた LoRA はありません",
|
||||
"noLorasWhyToggle": "LoRA がない理由",
|
||||
"noLorasImportMethod": "インポート方法",
|
||||
"noLorasInferredNote": "考えられる理由(推定)— このレシピはインポート診断が記録される前にインポートされました。",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "一括インポート(画像 URL)",
|
||||
"batch_import_local": "一括インポート(ローカルファイル)",
|
||||
"url": "画像 URL からのインポート",
|
||||
"local": "ローカルファイルのインポート",
|
||||
"upload": "画像のアップロード",
|
||||
"widget": "ワークフローから保存",
|
||||
"reimport_url": "再インポート(画像 URL)",
|
||||
"reimport_local": "再インポート(ローカルファイル)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "生成メタデータは完全で、LoRA への参照は含まれていません。",
|
||||
"api_meta_no_lora_resources": "ソース API がこの画像の LoRA リソースデータを返しませんでした。CivitAI ページに表示される LoRA は、公開 API では公開されない内部データに由来する場合があります。",
|
||||
"api_meta_missing": "ソース API がこの画像の生成メタデータを返しませんでした。",
|
||||
"no_embedded_metadata": "画像に埋め込まれた生成メタデータがないため、LoRA 情報を復元できませんでした。",
|
||||
"workflow_metadata_limited": "画像に埋め込まれたメタデータは ComfyUI ワークフローです。ワークフローからの LoRA 情報の抽出には限界があります。",
|
||||
"video_no_metadata": "動画ファイルには埋め込み生成メタデータがありません。",
|
||||
"metadata_unsupported": "画像に解析できない形式のメタデータが含まれています。",
|
||||
"unknown": "保存されたレシピデータから理由を特定できませんでした。"
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "API メタデータフィールド",
|
||||
"modelVersionIds": "報告されたモデルバージョン ID 数",
|
||||
"embeddedMetadata": "埋め込みメタデータ",
|
||||
"present": "あり",
|
||||
"absent": "なし"
|
||||
},
|
||||
"download": "ダウンロード",
|
||||
"downloadLoraTooltip": "この LoRA をダウンロード",
|
||||
"preparingDownload": "ダウンロードを準備中...",
|
||||
@@ -917,7 +996,14 @@
|
||||
"undoReconnectTooltipNamed": "{name} に戻す(再接続前の関連付け)",
|
||||
"viewOnCivitai": "CivitAI で表示",
|
||||
"openLoraDetails": "LoRA ライブラリで {name} を表示",
|
||||
"openCheckpointDetails": "モデルライブラリで {name} を表示"
|
||||
"openCheckpointDetails": "モデルライブラリで {name} を表示",
|
||||
"checkpointDeletedTooltip": "この Checkpoint はソースから削除されたため、ダウンロードできません - ローカルモデルで再接続してください",
|
||||
"checkpointHashInvalidTooltip": "この Checkpoint のハッシュは CivitAI で解決できません - モデルが更新された可能性があります",
|
||||
"reconnectCheckpoint": "再接続",
|
||||
"reconnectCheckpointTooltip": "ローカルの Checkpoint と再接続",
|
||||
"checkpointReconnectInstructions": "再接続する Checkpoint の名前を入力してください:",
|
||||
"checkpointReconnectPlaceholder": "Checkpoint 名を入力",
|
||||
"checkpointReconnectSuggestionsEmpty": "ローカルライブラリに一致するCheckpointがありません"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -1540,7 +1626,11 @@
|
||||
"clipSkip": "Clip Skip",
|
||||
"valuePlaceholder": "値",
|
||||
"add": "追加",
|
||||
"invalidRange": "無効な範囲形式です。x.x-y.y を使用してください"
|
||||
"invalidRange": "無効な範囲形式です。x.x-y.y を使用してください",
|
||||
"invalidValue": "有効な数値を入力してください",
|
||||
"saveFailed": "プリセットパラメータの保存に失敗しました",
|
||||
"added": "プリセットパラメータを追加しました",
|
||||
"updated": "プリセットパラメータを更新しました"
|
||||
},
|
||||
"triggerWords": {
|
||||
"label": "トリガーワード",
|
||||
@@ -1551,7 +1641,7 @@
|
||||
"addPlaceholder": "入力して追加するか、下の提案をクリック",
|
||||
"editWord": "トリガーワードを編集",
|
||||
"editPlaceholder": "トリガーワードを編集",
|
||||
"copyWord": "トリガーワードをコピー",
|
||||
"copyOrEditWord": "クリックでコピー、ダブルクリックで編集",
|
||||
"deleteWord": "トリガーワードを削除",
|
||||
"suggestions": {
|
||||
"noSuggestions": "提案はありません",
|
||||
@@ -1611,8 +1701,8 @@
|
||||
"showCount": "例を表示({count})",
|
||||
"hideExamples": "例を非表示",
|
||||
"addExamples": "例を追加",
|
||||
"previousExample": "前の例",
|
||||
"nextExample": "次の例",
|
||||
"previousExample": "前の例([)",
|
||||
"nextExample": "次の例(])",
|
||||
"noExamples": "利用可能な例画像がありません",
|
||||
"addMoreExamples": "さらに例を追加",
|
||||
"dragDrop": "画像または動画をここにドラッグ&ドロップ",
|
||||
@@ -1876,10 +1966,52 @@
|
||||
"tabs": {
|
||||
"gettingStarted": "はじめに",
|
||||
"updateVlogs": "更新Vlog",
|
||||
"documentation": "ドキュメント"
|
||||
"documentation": "ドキュメント",
|
||||
"shortcuts": "ショートカット"
|
||||
},
|
||||
"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": {
|
||||
"title": "最新の更新",
|
||||
@@ -1896,7 +2028,8 @@
|
||||
"settings": "設定&構成",
|
||||
"extensions": "拡張機能",
|
||||
"newBadge": "新着"
|
||||
}
|
||||
},
|
||||
"newContentBadge": "新着"
|
||||
},
|
||||
"update": {
|
||||
"title": "更新確認",
|
||||
@@ -2066,6 +2199,13 @@
|
||||
"missingCheckpointPath": "Checkpointのパスがありません",
|
||||
"missingCheckpointInfo": "Checkpoint情報が不足しています",
|
||||
"downloadCheckpointFailed": "Checkpointのダウンロードに失敗しました: {message}",
|
||||
"enterCheckpointName": "Checkpoint 名を入力してください",
|
||||
"checkpointReconnectedSuccessfully": "Checkpointが正常に再接続されました",
|
||||
"reconnectCheckpointBaseModelMismatch": "再接続しましたが、ベースモデルが異なります(レシピ:{recipe}、Checkpoint:{checkpoint})— アーキテクチャ互換です",
|
||||
"checkpointReconnectFailed": "Checkpoint再接続エラー:{message}",
|
||||
"checkpointRestored": "Checkpoint が以前の関連付けに復元されました",
|
||||
"checkpointRestoreFailed": "Checkpoint復元エラー:{message}",
|
||||
"checkpointDownloadUnavailable": "CivitAI の識別子がないため、この Checkpoint をダウンロードできません - ローカルの Checkpoint と再接続してみてください",
|
||||
"missingLoraDownloadInfo": "この LoRA のダウンロード情報がありません",
|
||||
"hashNotFoundOnCivitai": "このLoRAハッシュはCivitAIで解決できません - モデルが更新されたか、ハッシュが無効な可能性があります",
|
||||
"downloadLoraFailed": "LoRA のダウンロードに失敗しました: {message}",
|
||||
|
||||
+150
-10
@@ -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": "남은 시간 1분 미만",
|
||||
"minutes": "약 {minutes}분 남음",
|
||||
"hours": "약 {hours}시간 {minutes}분 남음"
|
||||
}
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
@@ -75,7 +96,7 @@
|
||||
},
|
||||
"bulk": {
|
||||
"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": {
|
||||
"title": "검색 옵션",
|
||||
@@ -95,7 +116,19 @@
|
||||
},
|
||||
"contextMenu": {
|
||||
"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": "이전 레시피(←)",
|
||||
"nextWithShortcut": "다음 레시피(→)"
|
||||
},
|
||||
"modal": {
|
||||
"metadata": {
|
||||
"id": "ID"
|
||||
},
|
||||
"actions": {
|
||||
"openFileLocation": "파일 위치 열기",
|
||||
"copyId": "레시피 ID 복사"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "파일 위치가 성공적으로 열렸습니다",
|
||||
"failed": "파일 위치 열기에 실패했습니다",
|
||||
"copied": "경로가 클립보드에 복사되었습니다: {{path}}",
|
||||
"clipboardFallback": "경로: {{path}}"
|
||||
}
|
||||
},
|
||||
"workflow": {
|
||||
"sendWorkflow": "워크플로를 ComfyUI로 보내기",
|
||||
"sent": "워크플로를 ComfyUI로 보냈습니다",
|
||||
@@ -898,6 +946,37 @@
|
||||
"notInLibraryTooltip": "이 모델은 라이브러리에 없습니다",
|
||||
"deletedTooltip": "이 LoRA는 소스에서 삭제되어 더 이상 다운로드할 수 없습니다",
|
||||
"hashInvalidTooltip": "이 LoRA 해시는 CivitAI에서 해석할 수 없습니다 - 모델이 업데이트되었을 수 있습니다",
|
||||
"noLorasAssociated": "이 레시피에 연결된 LoRA가 없습니다",
|
||||
"noLorasWhyToggle": "LoRA가 없는 이유",
|
||||
"noLorasImportMethod": "가져오기 방법",
|
||||
"noLorasInferredNote": "가능한 이유(추정) — 이 레시피는 가져오기 진단이 기록되기 전에 가져온 것입니다.",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "일괄 가져오기(이미지 URL)",
|
||||
"batch_import_local": "일괄 가져오기(로컬 파일)",
|
||||
"url": "이미지 URL 가져오기",
|
||||
"local": "로컬 파일 가져오기",
|
||||
"upload": "이미지 업로드",
|
||||
"widget": "워크플로에서 저장",
|
||||
"reimport_url": "다시 가져오기(이미지 URL)",
|
||||
"reimport_local": "다시 가져오기(로컬 파일)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "생성 메타데이터가 완전하며 LoRA를 참조하지 않습니다.",
|
||||
"api_meta_no_lora_resources": "소스 API가 이 이미지에 대한 LoRA 리소스 데이터를 반환하지 않았습니다. CivitAI 페이지에 표시되는 LoRA는 공개 API가 노출하지 않는 내부 데이터에서 비롯될 수 있습니다.",
|
||||
"api_meta_missing": "소스 API가 이 이미지에 대한 생성 메타데이터를 반환하지 않았습니다.",
|
||||
"no_embedded_metadata": "이미지에 내장된 생성 메타데이터가 없어 LoRA 정보를 복구할 수 없습니다.",
|
||||
"workflow_metadata_limited": "이미지에 내장된 메타데이터는 ComfyUI 워크플로입니다. 워크플로에서 LoRA 정보를 추출하는 것은 제한적입니다.",
|
||||
"video_no_metadata": "동영상 파일에는 내장 생성 메타데이터가 없습니다.",
|
||||
"metadata_unsupported": "이미지에 파싱할 수 없는 형식의 메타데이터가 포함되어 있습니다.",
|
||||
"unknown": "저장된 레시피 데이터에서 이유를 확인할 수 없습니다."
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "API 메타데이터 필드",
|
||||
"modelVersionIds": "보고된 모델 버전 ID 수",
|
||||
"embeddedMetadata": "내장 메타데이터",
|
||||
"present": "있음",
|
||||
"absent": "없음"
|
||||
},
|
||||
"download": "다운로드",
|
||||
"downloadLoraTooltip": "이 LoRA 다운로드",
|
||||
"preparingDownload": "다운로드 준비 중...",
|
||||
@@ -917,7 +996,14 @@
|
||||
"undoReconnectTooltipNamed": "이전 연결 상태로 복원: {name}",
|
||||
"viewOnCivitai": "CivitAI에서 보기",
|
||||
"openLoraDetails": "LoRA 라이브러리에서 {name} 보기",
|
||||
"openCheckpointDetails": "모델 라이브러리에서 {name} 보기"
|
||||
"openCheckpointDetails": "모델 라이브러리에서 {name} 보기",
|
||||
"checkpointDeletedTooltip": "이 Checkpoint는 소스에서 삭제되어 더 이상 다운로드할 수 없습니다 - 로컬 모델로 다시 연결하세요",
|
||||
"checkpointHashInvalidTooltip": "이 Checkpoint의 해시를 CivitAI에서 확인할 수 없습니다 - 모델이 업데이트되었을 수 있습니다",
|
||||
"reconnectCheckpoint": "다시 연결",
|
||||
"reconnectCheckpointTooltip": "로컬 Checkpoint와 다시 연결",
|
||||
"checkpointReconnectInstructions": "다시 연결할 Checkpoint 이름을 입력하세요:",
|
||||
"checkpointReconnectPlaceholder": "Checkpoint 이름 입력",
|
||||
"checkpointReconnectSuggestionsEmpty": "로컬 라이브러리에 일치하는 Checkpoint가 없습니다"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -1540,7 +1626,11 @@
|
||||
"clipSkip": "클립 스킵",
|
||||
"valuePlaceholder": "값",
|
||||
"add": "추가",
|
||||
"invalidRange": "잘못된 범위 형식입니다. x.x-y.y를 사용하세요"
|
||||
"invalidRange": "잘못된 범위 형식입니다. x.x-y.y를 사용하세요",
|
||||
"invalidValue": "유효한 숫자를 입력하세요",
|
||||
"saveFailed": "프리셋 매개변수 저장에 실패했습니다",
|
||||
"added": "프리셋 매개변수가 추가되었습니다",
|
||||
"updated": "프리셋 매개변수가 업데이트되었습니다"
|
||||
},
|
||||
"triggerWords": {
|
||||
"label": "트리거 단어",
|
||||
@@ -1551,7 +1641,7 @@
|
||||
"addPlaceholder": "입력하거나 아래 제안을 클릭하세요",
|
||||
"editWord": "트리거 단어 편집",
|
||||
"editPlaceholder": "트리거 단어 편집",
|
||||
"copyWord": "트리거 단어 복사",
|
||||
"copyOrEditWord": "클릭하여 복사, 더블 클릭하여 편집",
|
||||
"deleteWord": "트리거 단어 삭제",
|
||||
"suggestions": {
|
||||
"noSuggestions": "사용 가능한 제안이 없습니다",
|
||||
@@ -1611,8 +1701,8 @@
|
||||
"showCount": "예시 보기 ({count})",
|
||||
"hideExamples": "예시 숨기기",
|
||||
"addExamples": "예시 추가",
|
||||
"previousExample": "이전 예시",
|
||||
"nextExample": "다음 예시",
|
||||
"previousExample": "이전 예시([)",
|
||||
"nextExample": "다음 예시(])",
|
||||
"noExamples": "사용 가능한 예시 이미지가 없습니다",
|
||||
"addMoreExamples": "예시 더 추가",
|
||||
"dragDrop": "이미지 또는 비디오를 여기로 끌어다 놓으세요",
|
||||
@@ -1876,10 +1966,52 @@
|
||||
"tabs": {
|
||||
"gettingStarted": "시작하기",
|
||||
"updateVlogs": "업데이트 영상",
|
||||
"documentation": "문서"
|
||||
"documentation": "문서",
|
||||
"shortcuts": "단축키"
|
||||
},
|
||||
"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": {
|
||||
"title": "최신 업데이트",
|
||||
@@ -1896,7 +2028,8 @@
|
||||
"settings": "설정 & 구성",
|
||||
"extensions": "확장",
|
||||
"newBadge": "신규"
|
||||
}
|
||||
},
|
||||
"newContentBadge": "신규"
|
||||
},
|
||||
"update": {
|
||||
"title": "업데이트 확인",
|
||||
@@ -2066,6 +2199,13 @@
|
||||
"missingCheckpointPath": "Checkpoint 경로를 사용할 수 없습니다",
|
||||
"missingCheckpointInfo": "Checkpoint 정보가 부족합니다",
|
||||
"downloadCheckpointFailed": "Checkpoint 다운로드 실패: {message}",
|
||||
"enterCheckpointName": "Checkpoint 이름을 입력하세요",
|
||||
"checkpointReconnectedSuccessfully": "Checkpoint가 성공적으로 다시 연결되었습니다",
|
||||
"reconnectCheckpointBaseModelMismatch": "다시 연결했지만 베이스 모델이 다릅니다(레시피: {recipe}, Checkpoint: {checkpoint}) — 아키텍처 호환입니다",
|
||||
"checkpointReconnectFailed": "Checkpoint 다시 연결 오류: {message}",
|
||||
"checkpointRestored": "Checkpoint가 이전 연결 상태로 복원되었습니다",
|
||||
"checkpointRestoreFailed": "Checkpoint 복원 오류: {message}",
|
||||
"checkpointDownloadUnavailable": "CivitAI 식별자가 없어 이 Checkpoint를 다운로드할 수 없습니다 - 로컬 Checkpoint로 다시 연결해 보세요",
|
||||
"missingLoraDownloadInfo": "이 LoRA의 다운로드 정보가 없습니다",
|
||||
"hashNotFoundOnCivitai": "이 LoRA 해시는 CivitAI에서 해석할 수 없습니다 - 모델이 업데이트되었거나 해시가 유효하지 않을 수 있습니다",
|
||||
"downloadLoraFailed": "LoRA 다운로드 실패: {message}",
|
||||
|
||||
+150
-10
@@ -50,6 +50,27 @@
|
||||
"mb": "МБ",
|
||||
"gb": "ГБ",
|
||||
"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": {
|
||||
@@ -75,7 +96,7 @@
|
||||
},
|
||||
"bulk": {
|
||||
"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": {
|
||||
"title": "Опции поиска",
|
||||
@@ -95,7 +116,19 @@
|
||||
},
|
||||
"contextMenu": {
|
||||
"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": "Предыдущий рецепт (←)",
|
||||
"nextWithShortcut": "Следующий рецепт (→)"
|
||||
},
|
||||
"modal": {
|
||||
"metadata": {
|
||||
"id": "ID"
|
||||
},
|
||||
"actions": {
|
||||
"openFileLocation": "Открыть расположение файла",
|
||||
"copyId": "Копировать ID рецепта"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "Расположение файла успешно открыто",
|
||||
"failed": "Не удалось открыть расположение файла",
|
||||
"copied": "Путь скопирован в буфер обмена: {{path}}",
|
||||
"clipboardFallback": "Путь: {{path}}"
|
||||
}
|
||||
},
|
||||
"workflow": {
|
||||
"sendWorkflow": "Отправить workflow в ComfyUI",
|
||||
"sent": "Workflow отправлен в ComfyUI",
|
||||
@@ -898,6 +946,37 @@
|
||||
"notInLibraryTooltip": "Этой модели нет в вашей библиотеке",
|
||||
"deletedTooltip": "Этот LoRA был удалён из источника и больше недоступен для скачивания",
|
||||
"hashInvalidTooltip": "Этот хеш LoRA не удаётся распознать на CivitAI - возможно, модель была обновлена",
|
||||
"noLorasAssociated": "С этим рецептом не связаны LoRA",
|
||||
"noLorasWhyToggle": "Почему нет LoRA?",
|
||||
"noLorasImportMethod": "Способ импорта",
|
||||
"noLorasInferredNote": "Возможная причина (выведена) — этот рецепт был импортирован до того, как стала записываться диагностика импорта.",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "Пакетный импорт (URL изображения)",
|
||||
"batch_import_local": "Пакетный импорт (локальный файл)",
|
||||
"url": "Импорт по URL изображения",
|
||||
"local": "Импорт локального файла",
|
||||
"upload": "Загрузка изображения",
|
||||
"widget": "Сохранён из Workflow",
|
||||
"reimport_url": "Повторный импорт (URL изображения)",
|
||||
"reimport_local": "Повторный импорт (локальный файл)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "Метаданные генерации полны и не содержат ссылок на LoRA.",
|
||||
"api_meta_no_lora_resources": "Исходный API не вернул данные о ресурсах LoRA для этого изображения. LoRA, отображаемые на странице CivitAI, могут поступать из внутренних данных, которые публичный API не раскрывает.",
|
||||
"api_meta_missing": "Исходный API не вернул метаданные генерации для этого изображения.",
|
||||
"no_embedded_metadata": "Изображение не содержит встроенных метаданных генерации, поэтому восстановить информацию о LoRA невозможно.",
|
||||
"workflow_metadata_limited": "Встроенные метаданные изображения представляют собой Workflow ComfyUI; извлечение информации о LoRA из Workflow ограничено.",
|
||||
"video_no_metadata": "Видеофайлы не содержат встроенных метаданных генерации.",
|
||||
"metadata_unsupported": "Изображение содержит метаданные в формате, который не удалось разобрать.",
|
||||
"unknown": "Причину не удалось определить по сохранённым данным рецепта."
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "Поля метаданных API",
|
||||
"modelVersionIds": "Сообщено ID версий моделей",
|
||||
"embeddedMetadata": "Встроенные метаданные",
|
||||
"present": "найдены",
|
||||
"absent": "нет"
|
||||
},
|
||||
"download": "Скачать",
|
||||
"downloadLoraTooltip": "Скачать этот LoRA",
|
||||
"preparingDownload": "Подготовка к скачиванию...",
|
||||
@@ -917,7 +996,14 @@
|
||||
"undoReconnectTooltipNamed": "Восстановить {name} (привязка до переподключения)",
|
||||
"viewOnCivitai": "Открыть на CivitAI",
|
||||
"openLoraDetails": "Открыть {name} в библиотеке LoRA",
|
||||
"openCheckpointDetails": "Открыть {name} в библиотеке моделей"
|
||||
"openCheckpointDetails": "Открыть {name} в библиотеке моделей",
|
||||
"checkpointDeletedTooltip": "Этот чекпойнт был удалён из источника и больше не может быть скачан - переподключите его к локальной модели",
|
||||
"checkpointHashInvalidTooltip": "Хеш этого чекпойнта не удаётся разрешить на CivitAI - возможно, модель была обновлена",
|
||||
"reconnectCheckpoint": "Переподключить",
|
||||
"reconnectCheckpointTooltip": "Переподключить к локальному чекпойнту",
|
||||
"checkpointReconnectInstructions": "Введите имя чекпойнта для переподключения:",
|
||||
"checkpointReconnectPlaceholder": "Введите имя чекпойнта",
|
||||
"checkpointReconnectSuggestionsEmpty": "В локальной библиотеке нет подходящих чекпойнтов"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -1540,7 +1626,11 @@
|
||||
"clipSkip": "Clip Skip",
|
||||
"valuePlaceholder": "Значение",
|
||||
"add": "Добавить",
|
||||
"invalidRange": "Неверный формат диапазона. Используйте x.x-y.y"
|
||||
"invalidRange": "Неверный формат диапазона. Используйте x.x-y.y",
|
||||
"invalidValue": "Введите корректное число",
|
||||
"saveFailed": "Не удалось сохранить предустановленный параметр",
|
||||
"added": "Предустановленный параметр добавлен",
|
||||
"updated": "Предустановленный параметр обновлён"
|
||||
},
|
||||
"triggerWords": {
|
||||
"label": "Триггерные слова",
|
||||
@@ -1551,7 +1641,7 @@
|
||||
"addPlaceholder": "Введите для добавления или нажмите на предложения ниже",
|
||||
"editWord": "Редактировать триггерное слово",
|
||||
"editPlaceholder": "Редактировать триггерное слово",
|
||||
"copyWord": "Копировать триггерное слово",
|
||||
"copyOrEditWord": "Клик — скопировать, двойной клик — редактировать",
|
||||
"deleteWord": "Удалить триггерное слово",
|
||||
"suggestions": {
|
||||
"noSuggestions": "Предложения недоступны",
|
||||
@@ -1611,8 +1701,8 @@
|
||||
"showCount": "Показать примеры ({count})",
|
||||
"hideExamples": "Скрыть примеры",
|
||||
"addExamples": "Добавить примеры",
|
||||
"previousExample": "Предыдущий пример",
|
||||
"nextExample": "Следующий пример",
|
||||
"previousExample": "Предыдущий пример ([)",
|
||||
"nextExample": "Следующий пример (])",
|
||||
"noExamples": "Примеры изображений недоступны",
|
||||
"addMoreExamples": "Добавить ещё примеры",
|
||||
"dragDrop": "Перетащите изображения или видео сюда",
|
||||
@@ -1876,10 +1966,52 @@
|
||||
"tabs": {
|
||||
"gettingStarted": "Начало работы",
|
||||
"updateVlogs": "Видео обновлений",
|
||||
"documentation": "Документация"
|
||||
"documentation": "Документация",
|
||||
"shortcuts": "Горячие клавиши"
|
||||
},
|
||||
"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": {
|
||||
"title": "Последние обновления",
|
||||
@@ -1896,7 +2028,8 @@
|
||||
"settings": "Настройки и конфигурация",
|
||||
"extensions": "Расширения",
|
||||
"newBadge": "НОВОЕ"
|
||||
}
|
||||
},
|
||||
"newContentBadge": "НОВОЕ"
|
||||
},
|
||||
"update": {
|
||||
"title": "Проверить обновления",
|
||||
@@ -2066,6 +2199,13 @@
|
||||
"missingCheckpointPath": "Путь к чекпойнту недоступен",
|
||||
"missingCheckpointInfo": "Отсутствуют данные о чекпойнте",
|
||||
"downloadCheckpointFailed": "Не удалось скачать чекпойнт: {message}",
|
||||
"enterCheckpointName": "Введите имя чекпойнта",
|
||||
"checkpointReconnectedSuccessfully": "Чекпойнт успешно переподключён",
|
||||
"reconnectCheckpointBaseModelMismatch": "Переподключение выполнено, но базовые модели различаются (рецепт: {recipe}, чекпойнт: {checkpoint}) — они совместимы по архитектуре",
|
||||
"checkpointReconnectFailed": "Ошибка переподключения чекпойнта: {message}",
|
||||
"checkpointRestored": "Чекпойнт восстановлен к прежней привязке",
|
||||
"checkpointRestoreFailed": "Ошибка восстановления чекпойнта: {message}",
|
||||
"checkpointDownloadUnavailable": "Этот чекпойнт нельзя скачать без идентификаторов CivitAI - попробуйте переподключить его к локальному чекпойнту",
|
||||
"missingLoraDownloadInfo": "Нет информации для скачивания этого LoRA",
|
||||
"hashNotFoundOnCivitai": "Этот хеш LoRA не удаётся распознать на CivitAI - возможно, модель была обновлена или хеш недействителен",
|
||||
"downloadLoraFailed": "Не удалось скачать LoRA: {message}",
|
||||
|
||||
+150
-10
@@ -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": {
|
||||
@@ -75,7 +96,7 @@
|
||||
},
|
||||
"bulk": {
|
||||
"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": {
|
||||
"title": "搜索选项",
|
||||
@@ -95,7 +116,19 @@
|
||||
},
|
||||
"contextMenu": {
|
||||
"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": "上一个配方(←)",
|
||||
"nextWithShortcut": "下一个配方(→)"
|
||||
},
|
||||
"modal": {
|
||||
"metadata": {
|
||||
"id": "ID"
|
||||
},
|
||||
"actions": {
|
||||
"openFileLocation": "打开文件位置",
|
||||
"copyId": "复制配方 ID"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "文件位置已成功打开",
|
||||
"failed": "打开文件位置失败",
|
||||
"copied": "路径已复制到剪贴板:{{path}}",
|
||||
"clipboardFallback": "路径:{{path}}"
|
||||
}
|
||||
},
|
||||
"workflow": {
|
||||
"sendWorkflow": "发送工作流到 ComfyUI",
|
||||
"sent": "工作流已发送到 ComfyUI",
|
||||
@@ -898,6 +946,37 @@
|
||||
"notInLibraryTooltip": "该模型不在你的本地库中",
|
||||
"deletedTooltip": "该 LoRA 已从来源站删除,无法下载",
|
||||
"hashInvalidTooltip": "此 LoRA 哈希无法在 CivitAI 上解析——模型可能已更新",
|
||||
"noLorasAssociated": "此配方没有关联任何 LoRA",
|
||||
"noLorasWhyToggle": "为什么没有 LoRA?",
|
||||
"noLorasImportMethod": "导入方式",
|
||||
"noLorasInferredNote": "可能的原因(推断)——该配方是在记录导入诊断信息之前导入的。",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "批量导入(图片 URL)",
|
||||
"batch_import_local": "批量导入(本地文件)",
|
||||
"url": "图片 URL 导入",
|
||||
"local": "本地文件导入",
|
||||
"upload": "图片上传",
|
||||
"widget": "从工作流保存",
|
||||
"reimport_url": "重新导入(图片 URL)",
|
||||
"reimport_local": "重新导入(本地文件)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "生成元数据完整,且未引用任何 LoRA。",
|
||||
"api_meta_no_lora_resources": "来源 API 未返回此图片的 LoRA 资源数据。CivitAI 页面上显示的 LoRA 可能来自公开 API 未开放的内部数据。",
|
||||
"api_meta_missing": "来源 API 未返回此图片的生成元数据。",
|
||||
"no_embedded_metadata": "图片没有内嵌生成元数据,因此无法恢复 LoRA 信息。",
|
||||
"workflow_metadata_limited": "图片内嵌的元数据是 ComfyUI 工作流;从工作流中提取 LoRA 信息的能力有限。",
|
||||
"video_no_metadata": "视频文件不携带内嵌生成元数据。",
|
||||
"metadata_unsupported": "图片包含的元数据格式无法解析。",
|
||||
"unknown": "无法从存储的配方数据中确定原因。"
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "API 元数据字段",
|
||||
"modelVersionIds": "报告的模型版本 ID 数",
|
||||
"embeddedMetadata": "内嵌元数据",
|
||||
"present": "已找到",
|
||||
"absent": "无"
|
||||
},
|
||||
"download": "下载",
|
||||
"downloadLoraTooltip": "下载此 LoRA",
|
||||
"preparingDownload": "正在准备下载...",
|
||||
@@ -917,7 +996,14 @@
|
||||
"undoReconnectTooltipNamed": "恢复为 {name}(重新关联前的关联)",
|
||||
"viewOnCivitai": "在 CivitAI 上查看",
|
||||
"openLoraDetails": "在 LoRA 库中查看 {name}",
|
||||
"openCheckpointDetails": "在模型库中查看 {name}"
|
||||
"openCheckpointDetails": "在模型库中查看 {name}",
|
||||
"checkpointDeletedTooltip": "此 Checkpoint 已从来源删除,无法再下载 - 请使用本地模型重新关联",
|
||||
"checkpointHashInvalidTooltip": "此 Checkpoint 的哈希无法在 CivitAI 上解析 - 模型可能已更新",
|
||||
"reconnectCheckpoint": "重新关联",
|
||||
"reconnectCheckpointTooltip": "与本地 Checkpoint 重新关联",
|
||||
"checkpointReconnectInstructions": "输入 Checkpoint 名称以重新关联:",
|
||||
"checkpointReconnectPlaceholder": "输入 Checkpoint 名称",
|
||||
"checkpointReconnectSuggestionsEmpty": "本地库中没有匹配的 Checkpoint"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -1540,7 +1626,11 @@
|
||||
"clipSkip": "Clip Skip",
|
||||
"valuePlaceholder": "数值",
|
||||
"add": "添加",
|
||||
"invalidRange": "无效的范围格式。请使用 x.x-y.y"
|
||||
"invalidRange": "无效的范围格式。请使用 x.x-y.y",
|
||||
"invalidValue": "请输入有效的数值",
|
||||
"saveFailed": "保存预设参数失败",
|
||||
"added": "已添加预设参数",
|
||||
"updated": "已更新预设参数"
|
||||
},
|
||||
"triggerWords": {
|
||||
"label": "触发词",
|
||||
@@ -1551,7 +1641,7 @@
|
||||
"addPlaceholder": "输入或点击下方建议添加",
|
||||
"editWord": "编辑触发词",
|
||||
"editPlaceholder": "编辑触发词",
|
||||
"copyWord": "复制触发词",
|
||||
"copyOrEditWord": "单击复制,双击编辑",
|
||||
"deleteWord": "删除触发词",
|
||||
"suggestions": {
|
||||
"noSuggestions": "暂无建议",
|
||||
@@ -1611,8 +1701,8 @@
|
||||
"showCount": "显示示例({count})",
|
||||
"hideExamples": "隐藏示例",
|
||||
"addExamples": "添加示例",
|
||||
"previousExample": "上一个示例",
|
||||
"nextExample": "下一个示例",
|
||||
"previousExample": "上一个示例([)",
|
||||
"nextExample": "下一个示例(])",
|
||||
"noExamples": "暂无示例图片",
|
||||
"addMoreExamples": "添加更多示例",
|
||||
"dragDrop": "将图片或视频拖放到此处",
|
||||
@@ -1876,10 +1966,52 @@
|
||||
"tabs": {
|
||||
"gettingStarted": "新手入门",
|
||||
"updateVlogs": "更新日志",
|
||||
"documentation": "文档"
|
||||
"documentation": "文档",
|
||||
"shortcuts": "快捷键"
|
||||
},
|
||||
"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": {
|
||||
"title": "最新更新",
|
||||
@@ -1896,7 +2028,8 @@
|
||||
"settings": "设置与配置",
|
||||
"extensions": "扩展",
|
||||
"newBadge": "新"
|
||||
}
|
||||
},
|
||||
"newContentBadge": "新"
|
||||
},
|
||||
"update": {
|
||||
"title": "检查更新",
|
||||
@@ -2066,6 +2199,13 @@
|
||||
"missingCheckpointPath": "缺少Checkpoint路径",
|
||||
"missingCheckpointInfo": "缺少Checkpoint信息",
|
||||
"downloadCheckpointFailed": "下载Checkpoint失败:{message}",
|
||||
"enterCheckpointName": "请输入 Checkpoint 名称",
|
||||
"checkpointReconnectedSuccessfully": "Checkpoint 重新连接成功",
|
||||
"reconnectCheckpointBaseModelMismatch": "已重新关联,但基础模型不同(配方:{recipe},Checkpoint:{checkpoint})——两者架构兼容",
|
||||
"checkpointReconnectFailed": "Checkpoint 重新连接出错:{message}",
|
||||
"checkpointRestored": "Checkpoint 已恢复为重新关联前的关联",
|
||||
"checkpointRestoreFailed": "Checkpoint 恢复出错:{message}",
|
||||
"checkpointDownloadUnavailable": "缺少 CivitAI 标识,无法下载此 Checkpoint - 请尝试使用本地 Checkpoint 重新关联",
|
||||
"missingLoraDownloadInfo": "缺少此 LoRA 的下载信息",
|
||||
"hashNotFoundOnCivitai": "此 LoRA 哈希无法在 CivitAI 上解析——模型可能已更新或哈希无效",
|
||||
"downloadLoraFailed": "下载 LoRA 失败:{message}",
|
||||
|
||||
+150
-10
@@ -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": {
|
||||
@@ -75,7 +96,7 @@
|
||||
},
|
||||
"bulk": {
|
||||
"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": {
|
||||
"title": "搜尋選項",
|
||||
@@ -95,7 +116,19 @@
|
||||
},
|
||||
"contextMenu": {
|
||||
"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": "上一個配方(←)",
|
||||
"nextWithShortcut": "下一個配方(→)"
|
||||
},
|
||||
"modal": {
|
||||
"metadata": {
|
||||
"id": "ID"
|
||||
},
|
||||
"actions": {
|
||||
"openFileLocation": "開啟檔案位置",
|
||||
"copyId": "複製配方 ID"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "檔案位置已成功開啟",
|
||||
"failed": "開啟檔案位置失敗",
|
||||
"copied": "路徑已複製到剪貼簿:{{path}}",
|
||||
"clipboardFallback": "路徑:{{path}}"
|
||||
}
|
||||
},
|
||||
"workflow": {
|
||||
"sendWorkflow": "傳送工作流到 ComfyUI",
|
||||
"sent": "工作流已傳送到 ComfyUI",
|
||||
@@ -898,6 +946,37 @@
|
||||
"notInLibraryTooltip": "此模型不在您的本地庫中",
|
||||
"deletedTooltip": "此 LoRA 已從來源站刪除,無法下載",
|
||||
"hashInvalidTooltip": "此 LoRA 雜湊無法在 CivitAI 上解析——模型可能已更新",
|
||||
"noLorasAssociated": "此配方未關聯任何 LoRA",
|
||||
"noLorasWhyToggle": "為什麼沒有 LoRA?",
|
||||
"noLorasImportMethod": "匯入方式",
|
||||
"noLorasInferredNote": "可能的原因(推斷)——此配方是在記錄匯入診斷資訊之前匯入的。",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "批量匯入(圖片 URL)",
|
||||
"batch_import_local": "批量匯入(本機檔案)",
|
||||
"url": "圖片 URL 匯入",
|
||||
"local": "本機檔案匯入",
|
||||
"upload": "圖片上傳",
|
||||
"widget": "從工作流儲存",
|
||||
"reimport_url": "重新匯入(圖片 URL)",
|
||||
"reimport_local": "重新匯入(本機檔案)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "生成中繼資料完整,且未引用任何 LoRA。",
|
||||
"api_meta_no_lora_resources": "來源 API 未回傳此圖片的 LoRA 資源資料。CivitAI 頁面上顯示的 LoRA 可能來自公開 API 未開放的內部資料。",
|
||||
"api_meta_missing": "來源 API 未回傳此圖片的生成中繼資料。",
|
||||
"no_embedded_metadata": "圖片沒有內嵌生成中繼資料,因此無法復原 LoRA 資訊。",
|
||||
"workflow_metadata_limited": "圖片內嵌的中繼資料是 ComfyUI 工作流;從工作流中提取 LoRA 資訊的能力有限。",
|
||||
"video_no_metadata": "影片檔案不攜帶內嵌生成中繼資料。",
|
||||
"metadata_unsupported": "圖片包含的中繼資料格式無法解析。",
|
||||
"unknown": "無法從儲存的配方資料中確定原因。"
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "API 中繼資料欄位",
|
||||
"modelVersionIds": "回報的模型版本 ID 數",
|
||||
"embeddedMetadata": "內嵌中繼資料",
|
||||
"present": "已找到",
|
||||
"absent": "無"
|
||||
},
|
||||
"download": "下載",
|
||||
"downloadLoraTooltip": "下載此 LoRA",
|
||||
"preparingDownload": "正在準備下載...",
|
||||
@@ -917,7 +996,14 @@
|
||||
"undoReconnectTooltipNamed": "恢復為 {name}(重新關聯前的關聯)",
|
||||
"viewOnCivitai": "在 CivitAI 上檢視",
|
||||
"openLoraDetails": "在 LoRA 庫中檢視 {name}",
|
||||
"openCheckpointDetails": "在模型庫中檢視 {name}"
|
||||
"openCheckpointDetails": "在模型庫中檢視 {name}",
|
||||
"checkpointDeletedTooltip": "此 Checkpoint 已從來源刪除,無法再下載 - 請使用本地模型重新關聯",
|
||||
"checkpointHashInvalidTooltip": "此 Checkpoint 的雜湊無法在 CivitAI 上解析 - 模型可能已更新",
|
||||
"reconnectCheckpoint": "重新關聯",
|
||||
"reconnectCheckpointTooltip": "與本地 Checkpoint 重新關聯",
|
||||
"checkpointReconnectInstructions": "輸入 Checkpoint 名稱以重新關聯:",
|
||||
"checkpointReconnectPlaceholder": "輸入 Checkpoint 名稱",
|
||||
"checkpointReconnectSuggestionsEmpty": "本地庫中沒有符合的 Checkpoint"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -1540,7 +1626,11 @@
|
||||
"clipSkip": "Clip Skip",
|
||||
"valuePlaceholder": "數值",
|
||||
"add": "新增",
|
||||
"invalidRange": "無效的範圍格式。請使用 x.x-y.y"
|
||||
"invalidRange": "無效的範圍格式。請使用 x.x-y.y",
|
||||
"invalidValue": "請輸入有效的數值",
|
||||
"saveFailed": "儲存預設參數失敗",
|
||||
"added": "已新增預設參數",
|
||||
"updated": "已更新預設參數"
|
||||
},
|
||||
"triggerWords": {
|
||||
"label": "觸發詞",
|
||||
@@ -1551,7 +1641,7 @@
|
||||
"addPlaceholder": "輸入或點擊下方建議",
|
||||
"editWord": "編輯觸發詞",
|
||||
"editPlaceholder": "編輯觸發詞",
|
||||
"copyWord": "複製觸發詞",
|
||||
"copyOrEditWord": "點擊複製,雙擊編輯",
|
||||
"deleteWord": "刪除觸發詞",
|
||||
"suggestions": {
|
||||
"noSuggestions": "無可用建議",
|
||||
@@ -1611,8 +1701,8 @@
|
||||
"showCount": "顯示範例({count})",
|
||||
"hideExamples": "隱藏範例",
|
||||
"addExamples": "新增範例",
|
||||
"previousExample": "上一個範例",
|
||||
"nextExample": "下一個範例",
|
||||
"previousExample": "上一個範例([)",
|
||||
"nextExample": "下一個範例(])",
|
||||
"noExamples": "沒有可用的範例圖片",
|
||||
"addMoreExamples": "新增更多範例",
|
||||
"dragDrop": "拖放圖片或影片到此處",
|
||||
@@ -1876,10 +1966,52 @@
|
||||
"tabs": {
|
||||
"gettingStarted": "快速開始",
|
||||
"updateVlogs": "更新影片",
|
||||
"documentation": "文件"
|
||||
"documentation": "文件",
|
||||
"shortcuts": "快捷鍵"
|
||||
},
|
||||
"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": {
|
||||
"title": "最新更新",
|
||||
@@ -1896,7 +2028,8 @@
|
||||
"settings": "設定與配置",
|
||||
"extensions": "擴充功能",
|
||||
"newBadge": "新"
|
||||
}
|
||||
},
|
||||
"newContentBadge": "新"
|
||||
},
|
||||
"update": {
|
||||
"title": "檢查更新",
|
||||
@@ -2066,6 +2199,13 @@
|
||||
"missingCheckpointPath": "缺少Checkpoint路徑",
|
||||
"missingCheckpointInfo": "缺少Checkpoint資訊",
|
||||
"downloadCheckpointFailed": "下載Checkpoint失敗:{message}",
|
||||
"enterCheckpointName": "請輸入 Checkpoint 名稱",
|
||||
"checkpointReconnectedSuccessfully": "Checkpoint 重新連結成功",
|
||||
"reconnectCheckpointBaseModelMismatch": "已重新關聯,但基礎模型不同(配方:{recipe},Checkpoint:{checkpoint})——兩者架構相容",
|
||||
"checkpointReconnectFailed": "Checkpoint 重新連結錯誤:{message}",
|
||||
"checkpointRestored": "Checkpoint 已恢復為重新關聯前的關聯",
|
||||
"checkpointRestoreFailed": "Checkpoint 恢復錯誤:{message}",
|
||||
"checkpointDownloadUnavailable": "缺少 CivitAI 標識,無法下載此 Checkpoint - 請嘗試使用本地 Checkpoint 重新關聯",
|
||||
"missingLoraDownloadInfo": "缺少此 LoRA 的下載資訊",
|
||||
"hashNotFoundOnCivitai": "此 LoRA 雜湊無法在 CivitAI 上解析——模型可能已更新或雜湊無效",
|
||||
"downloadLoraFailed": "下載 LoRA 失敗:{message}",
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Dict, Any
|
||||
from ..base import RecipeMetadataParser
|
||||
from ..constants import GEN_PARAM_KEYS
|
||||
from ...services.metadata_service import get_default_metadata_provider
|
||||
from ...utils.constants import is_empty_placeholder_hash
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -524,6 +525,26 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
||||
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)
|
||||
|
||||
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':
|
||||
local_lora = await recipe_scanner.get_local_lora_by_hash(lora_hash)
|
||||
if local_lora:
|
||||
|
||||
@@ -196,7 +196,7 @@ class RecipeFormatParser(RecipeMetadataParser):
|
||||
filtered_gen_params[key] = value
|
||||
|
||||
return {
|
||||
'base_model': checkpoint['baseModel'] if checkpoint and checkpoint.get('baseModel') else recipe_metadata.get('base_model', ''),
|
||||
'base_model': checkpoint['baseModel'] if checkpoint and checkpoint.get('baseModel') else (recipe_metadata.get('base_model') or None),
|
||||
'loras': loras,
|
||||
'gen_params': filtered_gen_params,
|
||||
'tags': recipe_metadata.get('tags', []),
|
||||
@@ -208,3 +208,24 @@ class RecipeFormatParser(RecipeMetadataParser):
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing recipe format metadata: {e}", exc_info=True)
|
||||
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
|
||||
|
||||
from ...config import config
|
||||
from ...services.active_filters_store import (
|
||||
ActiveFiltersStore,
|
||||
active_filters_to_query_kwargs,
|
||||
)
|
||||
from ...services.download_coordinator import DownloadCoordinator
|
||||
from ...services.connectivity_guard import (
|
||||
OFFLINE_FRIENDLY_MESSAGE,
|
||||
@@ -1595,12 +1599,50 @@ class ModelQueryHandler:
|
||||
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
|
||||
# widget when filter mode is on) signals that the filter pipeline
|
||||
# must run even when no concrete filter is set, so global settings
|
||||
# like show_only_sfw stay consistent with the list endpoint.
|
||||
apply_filters = (
|
||||
"recursive" in request.query
|
||||
use_active_filters
|
||||
or "recursive" in request.query
|
||||
or folder is not None
|
||||
or bool(base_models)
|
||||
or bool(model_types)
|
||||
@@ -1634,6 +1676,50 @@ class ModelQueryHandler:
|
||||
)
|
||||
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:
|
||||
"""Coordinate downloads and progress reporting."""
|
||||
@@ -3339,6 +3425,8 @@ class ModelHandlerSet:
|
||||
"get_model_metadata": self.query.get_model_metadata,
|
||||
"get_model_description": self.query.get_model_description,
|
||||
"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,
|
||||
"fetch_missing_civitai_license_data": self.updates.fetch_missing_civitai_license_data,
|
||||
"set_model_update_ignore": self.updates.set_model_update_ignore,
|
||||
|
||||
@@ -26,6 +26,7 @@ from ...services.recipes import (
|
||||
RecipeValidationError,
|
||||
)
|
||||
from ...services.metadata_service import get_default_metadata_provider
|
||||
from ...services.recipe_scanner import UNKNOWN_BASE_MODEL_FILTER
|
||||
from ...utils.civitai_utils import (
|
||||
build_civitai_image_page_url,
|
||||
extract_civitai_image_id,
|
||||
@@ -116,6 +117,10 @@ class RecipeHandlerSet:
|
||||
"restore_lora": self.management.restore_lora,
|
||||
"get_reconnect_suggestions": self.management.get_reconnect_suggestions,
|
||||
"mark_lora_hash_invalid": self.management.mark_lora_hash_invalid,
|
||||
"reconnect_checkpoint": self.management.reconnect_checkpoint,
|
||||
"restore_checkpoint": self.management.restore_checkpoint,
|
||||
"get_checkpoint_reconnect_suggestions": self.management.get_checkpoint_reconnect_suggestions,
|
||||
"mark_checkpoint_hash_invalid": self.management.mark_checkpoint_hash_invalid,
|
||||
"find_duplicates": self.query.find_duplicates,
|
||||
"move_recipes_bulk": self.management.move_recipes_bulk,
|
||||
"bulk_delete": self.management.bulk_delete,
|
||||
@@ -348,6 +353,17 @@ class RecipeListingHandler:
|
||||
|
||||
if not recipe:
|
||||
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)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
@@ -469,17 +485,32 @@ class RecipeQueryHandler:
|
||||
cache = await recipe_scanner.get_cached_data()
|
||||
|
||||
base_model_counts: Dict[str, int] = {}
|
||||
unknown_count = 0
|
||||
for recipe in getattr(cache, "raw_data", []):
|
||||
base_model = recipe.get("base_model")
|
||||
if base_model:
|
||||
base_model_counts[base_model] = (
|
||||
base_model_counts.get(base_model, 0) + 1
|
||||
)
|
||||
else:
|
||||
unknown_count += 1
|
||||
|
||||
sorted_models = [
|
||||
{"name": model, "count": count}
|
||||
for model, count in base_model_counts.items()
|
||||
]
|
||||
if unknown_count:
|
||||
# Synthetic "Unknown" bucket for recipes whose base model could
|
||||
# not be determined. `value` carries the filter marker so the
|
||||
# UI can display "Unknown" without colliding with real base
|
||||
# model strings.
|
||||
sorted_models.append(
|
||||
{
|
||||
"name": "Unknown",
|
||||
"value": UNKNOWN_BASE_MODEL_FILTER,
|
||||
"count": unknown_count,
|
||||
}
|
||||
)
|
||||
sorted_models.sort(key=lambda entry: entry["count"], reverse=True)
|
||||
if limit > 0:
|
||||
sorted_models = sorted_models[:limit]
|
||||
@@ -1070,12 +1101,14 @@ class RecipeManagementHandler:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
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
|
||||
CivitAI, re-parses EXIF metadata with the current parser, and
|
||||
re-resolves LoRAs / checkpoint. User edits (title, tags, favorite)
|
||||
are carried over from the old recipe.
|
||||
Gives the recipe a fresh start: URL-sourced recipes re-download the
|
||||
image from CivitAI; local ones re-parse the saved recipe image. Both
|
||||
use the original embedded generation metadata (the appended 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:
|
||||
await self._ensure_dependencies_ready()
|
||||
@@ -1088,13 +1121,40 @@ class RecipeManagementHandler:
|
||||
if not old_recipe:
|
||||
raise RecipeNotFoundError(f"Recipe {recipe_id} not found")
|
||||
|
||||
source_path = old_recipe.get("source_path")
|
||||
if not source_path:
|
||||
old_file_path = old_recipe.get("file_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(
|
||||
{
|
||||
"success": False,
|
||||
"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."
|
||||
),
|
||||
},
|
||||
@@ -1108,33 +1168,15 @@ class RecipeManagementHandler:
|
||||
if "tags" in user_edits and not isinstance(user_edits["tags"], list):
|
||||
del user_edits["tags"]
|
||||
|
||||
old_file_path = old_recipe.get("file_path", "")
|
||||
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:
|
||||
if local_source:
|
||||
return await self._do_reimport_from_local(
|
||||
source_path,
|
||||
local_source,
|
||||
recipe_scanner,
|
||||
recipe_id=recipe_id,
|
||||
target_dir=old_folder,
|
||||
user_edits=user_edits,
|
||||
old_title=old_recipe.get("title", ""),
|
||||
persisted_source_path=persisted_source_path,
|
||||
)
|
||||
|
||||
async with self._import_semaphore:
|
||||
@@ -1683,6 +1725,116 @@ class RecipeManagementHandler:
|
||||
)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
async def reconnect_checkpoint(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
data = await request.json()
|
||||
for field in ("recipe_id", "target_name"):
|
||||
if field not in data:
|
||||
raise RecipeValidationError(f"Missing required field: {field}")
|
||||
|
||||
result = await self._persistence_service.reconnect_checkpoint(
|
||||
recipe_scanner=recipe_scanner,
|
||||
recipe_id=data["recipe_id"],
|
||||
target_name=data["target_name"],
|
||||
)
|
||||
return web.json_response(result.payload, status=result.status)
|
||||
except RecipeValidationError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
except RecipeNotFoundError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=404)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error reconnecting checkpoint: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
async def restore_checkpoint(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
data = await request.json()
|
||||
if "recipe_id" not in data:
|
||||
raise RecipeValidationError("Missing required field: recipe_id")
|
||||
|
||||
result = await self._persistence_service.restore_checkpoint(
|
||||
recipe_scanner=recipe_scanner,
|
||||
recipe_id=data["recipe_id"],
|
||||
)
|
||||
return web.json_response(result.payload, status=result.status)
|
||||
except RecipeValidationError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
except RecipeNotFoundError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=404)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error restoring checkpoint: %s", exc, exc_info=True)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
async def get_checkpoint_reconnect_suggestions(
|
||||
self, request: web.Request
|
||||
) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
recipe_id = request.match_info.get("recipe_id")
|
||||
if not recipe_id:
|
||||
raise RecipeValidationError("recipe_id is required")
|
||||
|
||||
result = await self._persistence_service.get_checkpoint_reconnect_suggestions(
|
||||
recipe_scanner=recipe_scanner,
|
||||
recipe_id=recipe_id,
|
||||
query=request.query.get("query") or None,
|
||||
)
|
||||
return web.json_response(result.payload, status=result.status)
|
||||
except RecipeValidationError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
except RecipeNotFoundError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=404)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error suggesting checkpoint reconnect candidates: %s",
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
async def mark_checkpoint_hash_invalid(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
data = await request.json()
|
||||
if "recipe_id" not in data:
|
||||
raise RecipeValidationError("Missing required field: recipe_id")
|
||||
|
||||
result = await self._persistence_service.mark_checkpoint_hash_invalid(
|
||||
recipe_scanner=recipe_scanner,
|
||||
recipe_id=data["recipe_id"],
|
||||
hash_invalid=bool(data.get("hash_invalid", True)),
|
||||
)
|
||||
return web.json_response(result.payload, status=result.status)
|
||||
except RecipeValidationError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
except RecipeNotFoundError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=404)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error marking checkpoint hash invalid: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
async def bulk_delete(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
@@ -2115,6 +2267,23 @@ class RecipeManagementHandler:
|
||||
await self._download_remote_media(image_url)
|
||||
)
|
||||
|
||||
# Diagnostics for the recipe modal's "Why no LoRAs?" panel. This path
|
||||
# always comes from a CivitAI image URL (import_from_url validates the
|
||||
# image id), so civitai_image is True.
|
||||
diagnostics: Dict[str, Any] = {
|
||||
"civitai_image": True,
|
||||
"is_video": extension in (".mp4", ".webm"),
|
||||
}
|
||||
if isinstance(civitai_meta_raw, dict):
|
||||
raw_mvids = civitai_meta_raw.get("modelVersionIds")
|
||||
diagnostics["api_model_version_ids"] = (
|
||||
len(raw_mvids) if isinstance(raw_mvids, list) else 0
|
||||
)
|
||||
inner_meta_for_diag = civitai_meta_raw.get("meta")
|
||||
if isinstance(inner_meta_for_diag, dict):
|
||||
diagnostics["api_meta_present"] = True
|
||||
diagnostics["api_meta_keys"] = sorted(inner_meta_for_diag.keys())
|
||||
|
||||
# Build a version-cached map of local model hashes to cache items so
|
||||
# CivitaiApiMetadataParser can skip CivitAI API calls for models that
|
||||
# exist on disk. Built once and shared by every parse pass below.
|
||||
@@ -2135,6 +2304,7 @@ class RecipeManagementHandler:
|
||||
raw_embedded = await asyncio.to_thread(
|
||||
ExifUtils.extract_image_metadata, temp_img_path
|
||||
)
|
||||
diagnostics["exif_present"] = bool(raw_embedded)
|
||||
if raw_embedded:
|
||||
parser = (
|
||||
self._analysis_service._recipe_parser_factory.create_parser(
|
||||
@@ -2142,6 +2312,7 @@ class RecipeManagementHandler:
|
||||
)
|
||||
)
|
||||
if parser:
|
||||
diagnostics["exif_parser"] = parser.__class__.__name__
|
||||
if isinstance(parser, CivitaiApiMetadataParser):
|
||||
parsed_embedded = await parser.parse_metadata(
|
||||
raw_embedded,
|
||||
@@ -2182,6 +2353,7 @@ class RecipeManagementHandler:
|
||||
raw_orig = await asyncio.to_thread(
|
||||
ExifUtils.extract_image_metadata, orig_tmp_path
|
||||
)
|
||||
diagnostics["exif_present"] = bool(raw_orig)
|
||||
if raw_orig:
|
||||
parser = (
|
||||
self._analysis_service._recipe_parser_factory.create_parser(
|
||||
@@ -2189,6 +2361,7 @@ class RecipeManagementHandler:
|
||||
)
|
||||
)
|
||||
if parser:
|
||||
diagnostics["exif_parser"] = parser.__class__.__name__
|
||||
if isinstance(parser, CivitaiApiMetadataParser):
|
||||
parsed_embedded = await parser.parse_metadata(
|
||||
raw_orig,
|
||||
@@ -2310,6 +2483,20 @@ class RecipeManagementHandler:
|
||||
else:
|
||||
name = f"Civitai Image {image_id}"
|
||||
|
||||
# Record why this import ended up with no LoRAs so the recipe modal
|
||||
# can explain it (collapsed by default).
|
||||
from ...services.recipes.import_info import (
|
||||
CHANNEL_REIMPORT_URL,
|
||||
CHANNEL_URL,
|
||||
build_import_info,
|
||||
)
|
||||
|
||||
metadata["import_info"] = build_import_info(
|
||||
CHANNEL_REIMPORT_URL if recipe_id else CHANNEL_URL,
|
||||
diagnostics,
|
||||
metadata.get("loras"),
|
||||
)
|
||||
|
||||
result = await self._persistence_service.save_recipe(
|
||||
recipe_scanner=recipe_scanner,
|
||||
image_bytes=image_bytes,
|
||||
@@ -2332,11 +2519,20 @@ class RecipeManagementHandler:
|
||||
target_dir: str | None,
|
||||
user_edits: dict[str, Any],
|
||||
old_title: str,
|
||||
persisted_source_path: str,
|
||||
) -> web.Response:
|
||||
"""Re-import a recipe from a local image file.
|
||||
|
||||
Reads the original source file, re-parses its EXIF metadata, saves a
|
||||
fresh recipe, then deletes the old one.
|
||||
Reads the original source file, re-parses its original embedded
|
||||
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)
|
||||
if not os.path.isfile(normalized):
|
||||
@@ -2352,6 +2548,7 @@ class RecipeManagementHandler:
|
||||
analysis_result = await self._analysis_service.analyze_local_image(
|
||||
file_path=normalized,
|
||||
recipe_scanner=recipe_scanner,
|
||||
ignore_recipe_metadata=True,
|
||||
)
|
||||
analysis_payload: dict[str, Any] = analysis_result.payload
|
||||
|
||||
@@ -2364,11 +2561,22 @@ class RecipeManagementHandler:
|
||||
"base_model": base_model,
|
||||
"loras": loras,
|
||||
"gen_params": gen_params,
|
||||
"source_path": normalized,
|
||||
"source_path": persisted_source_path,
|
||||
}
|
||||
if checkpoint:
|
||||
metadata["checkpoint"] = checkpoint
|
||||
|
||||
from ...services.recipes.import_info import (
|
||||
CHANNEL_REIMPORT_LOCAL,
|
||||
build_import_info,
|
||||
)
|
||||
|
||||
metadata["import_info"] = build_import_info(
|
||||
CHANNEL_REIMPORT_LOCAL,
|
||||
analysis_payload.get("diagnostics"),
|
||||
loras,
|
||||
)
|
||||
|
||||
prompt = (
|
||||
gen_params.get("prompt")
|
||||
or gen_params.get("positivePrompt")
|
||||
@@ -2385,6 +2593,10 @@ class RecipeManagementHandler:
|
||||
metadata=metadata,
|
||||
extension=extension,
|
||||
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(
|
||||
@@ -2412,7 +2624,7 @@ class RecipeManagementHandler:
|
||||
"success": True,
|
||||
"old_recipe_id": 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"
|
||||
),
|
||||
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(
|
||||
"GET", "/api/lm/{prefix}/civitai/versions/{model_id}", "get_civitai_versions"
|
||||
),
|
||||
|
||||
@@ -58,6 +58,22 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/recipe/lora/mark-hash-invalid", "mark_lora_hash_invalid"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/recipe/checkpoint/reconnect", "reconnect_checkpoint"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/recipe/checkpoint/restore", "restore_checkpoint"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET",
|
||||
"/api/lm/recipe/{recipe_id}/checkpoint/reconnect-suggestions",
|
||||
"get_checkpoint_reconnect_suggestions",
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST",
|
||||
"/api/lm/recipe/checkpoint/mark-hash-invalid",
|
||||
"mark_checkpoint_hash_invalid",
|
||||
),
|
||||
RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"),
|
||||
RouteDefinition(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@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(
|
||||
self,
|
||||
search_term: str,
|
||||
@@ -1404,9 +1425,13 @@ class BaseModelService(ABC):
|
||||
):
|
||||
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(
|
||||
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
|
||||
|
||||
@@ -20,6 +20,11 @@ from .recipes import (
|
||||
RecipeDownloadError,
|
||||
RecipeNotFoundError,
|
||||
)
|
||||
from .recipes.import_info import (
|
||||
CHANNEL_BATCH_IMPORT_LOCAL,
|
||||
CHANNEL_BATCH_IMPORT_URL,
|
||||
build_import_info,
|
||||
)
|
||||
|
||||
|
||||
class ImportItemType(Enum):
|
||||
@@ -624,6 +629,17 @@ class BatchImportService:
|
||||
"loras": loras,
|
||||
"gen_params": payload.get("gen_params", {}),
|
||||
"source_path": item.source,
|
||||
# Record why this import ended up with no LoRAs so the
|
||||
# recipe modal can explain it (collapsed by default).
|
||||
"import_info": build_import_info(
|
||||
(
|
||||
CHANNEL_BATCH_IMPORT_URL
|
||||
if item.item_type == ImportItemType.URL
|
||||
else CHANNEL_BATCH_IMPORT_LOCAL
|
||||
),
|
||||
payload.get("diagnostics"),
|
||||
loras,
|
||||
),
|
||||
}
|
||||
|
||||
if payload.get("checkpoint"):
|
||||
|
||||
@@ -21,7 +21,7 @@ from .model_metadata_provider import (
|
||||
from .downloader import get_downloader
|
||||
from .errors import RateLimitError, ResourceNotFoundError
|
||||
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__)
|
||||
|
||||
@@ -180,6 +180,11 @@ class CivitaiClient:
|
||||
async def get_model_by_hash(
|
||||
self, model_hash: 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:
|
||||
success, version = await self._make_request(
|
||||
"GET",
|
||||
@@ -503,6 +508,8 @@ class CivitaiClient:
|
||||
async def _fetch_version_by_hash(self, model_hash: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
if not model_hash:
|
||||
return None
|
||||
if is_empty_placeholder_hash(model_hash):
|
||||
return None
|
||||
|
||||
success, version = await self._make_request(
|
||||
"GET",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from typing import Dict, Optional, Set, List
|
||||
import os
|
||||
|
||||
from ..utils.constants import is_empty_placeholder_hash
|
||||
|
||||
class ModelHashIndex:
|
||||
"""Index for looking up models by hash or filename"""
|
||||
|
||||
@@ -81,6 +83,8 @@ class ModelHashIndex:
|
||||
# mapping. First-time registrations stay O(1).
|
||||
if autov3:
|
||||
autov3 = autov3.lower()
|
||||
if is_empty_placeholder_hash(autov3):
|
||||
autov3 = None
|
||||
if is_re_registration and (existing_hash != sha256 or autov3):
|
||||
stale_autov3_keys = [
|
||||
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:
|
||||
"""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
|
||||
autov3 = autov3.lower()
|
||||
self._autov3_to_path[autov3] = file_path
|
||||
@@ -250,6 +254,8 @@ class ModelHashIndex:
|
||||
|
||||
def has_hash(self, hash_value: str) -> bool:
|
||||
"""Check if hash exists in index (SHA256, AutoV2, or AutoV3)"""
|
||||
if is_empty_placeholder_hash(hash_value):
|
||||
return False
|
||||
normalized = hash_value.lower()
|
||||
if normalized in self._hash_to_path:
|
||||
return True
|
||||
@@ -261,6 +267,8 @@ class ModelHashIndex:
|
||||
|
||||
def get_path(self, hash_value: str) -> Optional[str]:
|
||||
"""Get file path for a hash (SHA256, AutoV2, or AutoV3)"""
|
||||
if is_empty_placeholder_hash(hash_value):
|
||||
return None
|
||||
normalized = hash_value.lower()
|
||||
path = self._hash_to_path.get(normalized)
|
||||
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.
|
||||
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:
|
||||
"""Return True when any path component is the pending-delete staging dir."""
|
||||
@@ -149,6 +157,38 @@ class ModelScanner:
|
||||
# Register this 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
|
||||
def cache_version(self) -> int:
|
||||
"""Monotonic version counter for the in-memory cache.
|
||||
@@ -434,12 +474,7 @@ class ModelScanner:
|
||||
self._is_initializing = True
|
||||
|
||||
# Determine the page type based on model type
|
||||
page_type_map = {
|
||||
'lora': 'loras',
|
||||
'checkpoint': 'checkpoints',
|
||||
'embedding': 'embeddings'
|
||||
}
|
||||
page_type = page_type_map.get(self.model_type, self.model_type)
|
||||
page_type = self.page_type
|
||||
|
||||
# First, try to load from cache
|
||||
await ws_manager.broadcast_init_progress({
|
||||
@@ -804,7 +839,7 @@ class ModelScanner:
|
||||
last_progress_time = time.time()
|
||||
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
|
||||
|
||||
if expected_total <= 0:
|
||||
@@ -871,32 +906,84 @@ class ModelScanner:
|
||||
async def _initialize_cache(self) -> None:
|
||||
"""Initialize or refresh the cache"""
|
||||
self._is_initializing = True # Set flag
|
||||
last_progress_percent = 0
|
||||
try:
|
||||
start_time = time.time()
|
||||
|
||||
|
||||
await self._broadcast_scan_progress('started', 'scan_folders', 0, True)
|
||||
|
||||
# Manually trigger a symlink rescan during a full rebuild.
|
||||
# This ensures that any new symlink mappings are correctly picked up.
|
||||
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_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():
|
||||
await self._broadcast_scan_progress('finalizing', 'finalizing', 99, True)
|
||||
await self._apply_scan_result(scan_result)
|
||||
await self._save_persistent_cache(scan_result)
|
||||
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(
|
||||
f"{self.model_type.capitalize()} Scanner: Cache initialization completed in {time.time() - start_time:.2f} seconds, "
|
||||
f"found {len(scan_result.raw_data)} models"
|
||||
)
|
||||
else:
|
||||
await self._broadcast_scan_progress(
|
||||
'cancelled', 'process_models', last_progress_percent, True,
|
||||
elapsed_seconds=time.time() - start_time,
|
||||
)
|
||||
logger.info(
|
||||
f"{self.model_type.capitalize()} Scanner: Cache initialization cancelled "
|
||||
f"after {time.time() - start_time:.2f} seconds"
|
||||
)
|
||||
except Exception as 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
|
||||
if self._cache is None:
|
||||
self._cache = ModelCache(
|
||||
@@ -914,6 +1001,8 @@ class ModelScanner:
|
||||
try:
|
||||
start_time = time.time()
|
||||
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
|
||||
cached_paths = {item['file_path'] for item in self._cache.raw_data}
|
||||
@@ -987,6 +1076,10 @@ class ModelScanner:
|
||||
await asyncio.sleep(0)
|
||||
if self.is_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
|
||||
|
||||
# Process new files in batches
|
||||
@@ -994,10 +1087,14 @@ class ModelScanner:
|
||||
if new_files:
|
||||
logger.info(f"{self.model_type.capitalize()} Scanner: Found {len(new_files)} new files to process")
|
||||
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]
|
||||
for path in batch:
|
||||
logger.info(f"{self.model_type.capitalize()} Scanner: Processing {path}")
|
||||
processed_new += 1
|
||||
try:
|
||||
# Find the appropriate root path for this file
|
||||
root_path = None
|
||||
@@ -1053,9 +1150,24 @@ class ModelScanner:
|
||||
logger.error(f"Could not determine root path for {path}")
|
||||
except Exception as 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():
|
||||
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
|
||||
|
||||
# Find missing files (in cache but not in filesystem)
|
||||
@@ -1121,8 +1233,17 @@ class ModelScanner:
|
||||
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.")
|
||||
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:
|
||||
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:
|
||||
self._is_initializing = False # Unset flag
|
||||
self.bump_cache_version()
|
||||
@@ -1498,7 +1619,7 @@ class ModelScanner:
|
||||
self,
|
||||
*,
|
||||
total_files: int = 0,
|
||||
progress_callback: Optional[Callable[[int, int], Awaitable[None]]] = None
|
||||
progress_callback: Optional[Callable[[int, int, str], Awaitable[None]]] = None
|
||||
) -> CacheBuildResult:
|
||||
"""Collect metadata for all model files."""
|
||||
|
||||
@@ -1510,11 +1631,11 @@ class ModelScanner:
|
||||
processed_real_files: 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:
|
||||
return
|
||||
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
|
||||
logger.error(f"Error reporting progress for {self.model_type}: {exc}")
|
||||
|
||||
@@ -1580,7 +1701,7 @@ class ModelScanner:
|
||||
for tag in result.get('tags') or []:
|
||||
tags_count[tag] = tags_count.get(tag, 0) + 1
|
||||
|
||||
await handle_progress()
|
||||
await handle_progress(entry.name)
|
||||
await asyncio.sleep(0)
|
||||
if self.is_cancelled():
|
||||
return
|
||||
|
||||
@@ -59,6 +59,7 @@ class PersistentRecipeCache:
|
||||
"gen_params_json",
|
||||
"tags_json",
|
||||
"has_workflow",
|
||||
"import_info_json",
|
||||
)
|
||||
_instances: Dict[str, "PersistentRecipeCache"] = {}
|
||||
_instance_lock = threading.Lock()
|
||||
@@ -447,7 +448,8 @@ class PersistentRecipeCache:
|
||||
checkpoint_json TEXT,
|
||||
gen_params_json TEXT,
|
||||
tags_json TEXT,
|
||||
has_workflow INTEGER DEFAULT 0
|
||||
has_workflow INTEGER DEFAULT 0,
|
||||
import_info_json TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recipes_json_path ON recipes(json_path);
|
||||
@@ -473,6 +475,13 @@ class PersistentRecipeCache:
|
||||
)
|
||||
except Exception:
|
||||
pass # column already exists
|
||||
# Migration: add import_info_json column to existing databases
|
||||
try:
|
||||
conn.execute(
|
||||
"ALTER TABLE recipes ADD COLUMN import_info_json TEXT"
|
||||
)
|
||||
except Exception:
|
||||
pass # column already exists
|
||||
conn.commit()
|
||||
self._schema_initialized = True
|
||||
except Exception as exc:
|
||||
@@ -504,6 +513,9 @@ class PersistentRecipeCache:
|
||||
tags = recipe.get("tags")
|
||||
tags_json = json.dumps(tags) if tags else None
|
||||
|
||||
import_info = recipe.get("import_info")
|
||||
import_info_json = json.dumps(import_info) if import_info else None
|
||||
|
||||
# Get file stats if json_path exists
|
||||
file_mtime = 0.0
|
||||
file_size = 0
|
||||
@@ -536,6 +548,7 @@ class PersistentRecipeCache:
|
||||
gen_params_json,
|
||||
tags_json,
|
||||
1 if recipe.get("has_workflow") else 0,
|
||||
import_info_json,
|
||||
)
|
||||
|
||||
def _row_to_recipe(self, row: sqlite3.Row) -> Dict[str, Any]:
|
||||
@@ -568,6 +581,13 @@ class PersistentRecipeCache:
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
import_info = None
|
||||
if row["import_info_json"]:
|
||||
try:
|
||||
import_info = json.loads(row["import_info_json"])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
recipe = {
|
||||
"id": row["recipe_id"],
|
||||
"file_path": row["file_path"] or "",
|
||||
@@ -592,6 +612,9 @@ class PersistentRecipeCache:
|
||||
if checkpoint:
|
||||
recipe["checkpoint"] = checkpoint
|
||||
|
||||
if import_info:
|
||||
recipe["import_info"] = import_info
|
||||
|
||||
return recipe
|
||||
|
||||
|
||||
|
||||
+405
-16
@@ -48,6 +48,12 @@ _CHECKPOINT_MODEL_TYPE_ALIASES = {"diffusionmodel": "diffusion_model"}
|
||||
# Valid LoRA availability statuses for the recipe listing filter.
|
||||
_VALID_LORA_AVAILABILITY_STATUSES = frozenset({"ready", "missing", "deleted"})
|
||||
|
||||
# Filter marker for recipes whose base model could not be determined
|
||||
# (base_model is None or empty). The UI displays "Unknown" for this bucket;
|
||||
# the marker keeps the semantics explicit and disjoint from any real base
|
||||
# model string.
|
||||
UNKNOWN_BASE_MODEL_FILTER = "__unknown__"
|
||||
|
||||
|
||||
class RecipeScanner:
|
||||
"""Service for scanning and managing recipe images"""
|
||||
@@ -265,6 +271,49 @@ class RecipeScanner:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Rank local LoRAs as reconnect candidates for a broken recipe entry.
|
||||
|
||||
Thin wrapper over ``_suggest_reconnect_candidates`` scoped to the
|
||||
LoRA library (see it for the ranking contract).
|
||||
"""
|
||||
return await self._suggest_reconnect_candidates(
|
||||
entry=entry,
|
||||
recipe_base_model=recipe_base_model,
|
||||
query=query,
|
||||
limit=limit,
|
||||
is_checkpoint=False,
|
||||
)
|
||||
|
||||
async def suggest_checkpoint_reconnect_candidates(
|
||||
self,
|
||||
*,
|
||||
entry: dict[str, Any],
|
||||
recipe_base_model: Optional[str],
|
||||
query: Optional[str] = None,
|
||||
limit: int = 5,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Rank local checkpoints as reconnect candidates for a broken entry.
|
||||
|
||||
Thin wrapper over ``_suggest_reconnect_candidates`` scoped to the
|
||||
checkpoint library (see it for the ranking contract).
|
||||
"""
|
||||
return await self._suggest_reconnect_candidates(
|
||||
entry=entry,
|
||||
recipe_base_model=recipe_base_model,
|
||||
query=query,
|
||||
limit=limit,
|
||||
is_checkpoint=True,
|
||||
)
|
||||
|
||||
async def _suggest_reconnect_candidates(
|
||||
self,
|
||||
*,
|
||||
entry: dict[str, Any],
|
||||
recipe_base_model: Optional[str],
|
||||
query: Optional[str] = None,
|
||||
limit: int = 5,
|
||||
is_checkpoint: bool,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Rank local models as reconnect candidates for a broken recipe entry.
|
||||
|
||||
Identity signals (same hash / same CivitAI model version) outrank
|
||||
similarity signals (filename / model name fuzzy match). A confident
|
||||
base-model mismatch (both sides known and different) is a hard
|
||||
@@ -286,11 +335,11 @@ class RecipeScanner:
|
||||
if limit <= 0 or not isinstance(entry, dict):
|
||||
return []
|
||||
|
||||
lora_scanner = self._lora_scanner
|
||||
if lora_scanner is None:
|
||||
scanner = self._checkpoint_scanner if is_checkpoint else self._lora_scanner
|
||||
if scanner is None:
|
||||
return []
|
||||
|
||||
data = await lora_scanner.get_cached_data()
|
||||
data = await scanner.get_cached_data()
|
||||
recipe_bm = (recipe_base_model or "").strip().casefold()
|
||||
|
||||
def _base_model_known_mismatch(item: dict[str, Any]) -> bool:
|
||||
@@ -315,7 +364,7 @@ class RecipeScanner:
|
||||
# entry without a usable hash — same rule as the filename cache.
|
||||
if not (item.get("sha256") or "").strip():
|
||||
continue
|
||||
if not self._is_type_compatible(item, is_checkpoint=False):
|
||||
if not self._is_type_compatible(item, is_checkpoint=is_checkpoint):
|
||||
continue
|
||||
if _base_model_known_mismatch(item):
|
||||
continue
|
||||
@@ -351,14 +400,17 @@ class RecipeScanner:
|
||||
if (
|
||||
isinstance(hit, dict)
|
||||
and (hit.get("sha256") or "").strip()
|
||||
and self._is_type_compatible(hit, is_checkpoint=False)
|
||||
and self._is_type_compatible(hit, is_checkpoint=is_checkpoint)
|
||||
and not _base_model_known_mismatch(hit)
|
||||
):
|
||||
_consider(hit, 1.0 + _base_model_adjustment(hit), "same_hash")
|
||||
|
||||
version_id = entry.get("modelVersionId") or entry.get("id")
|
||||
if version_id is not None:
|
||||
hit = self._get_lora_from_version_index(str(version_id))
|
||||
if is_checkpoint:
|
||||
hit = self._get_checkpoint_from_version_index(str(version_id))
|
||||
else:
|
||||
hit = self._get_lora_from_version_index(str(version_id))
|
||||
if (
|
||||
isinstance(hit, dict)
|
||||
and (hit.get("sha256") or "").strip()
|
||||
@@ -367,7 +419,12 @@ class RecipeScanner:
|
||||
_consider(hit, 0.95 + _base_model_adjustment(hit), "same_version")
|
||||
|
||||
filename_source = query_text or (entry.get("file_name") or "")
|
||||
name_source = query_text or (entry.get("modelName") or "")
|
||||
# Parser-style checkpoint entries carry the model name under ``name``,
|
||||
# widget-style ones under ``modelName`` — try both for checkpoints.
|
||||
if is_checkpoint:
|
||||
name_source = query_text or (entry.get("name") or entry.get("modelName") or "")
|
||||
else:
|
||||
name_source = query_text or (entry.get("modelName") or "")
|
||||
norm_filename_source = self._normalize_filename_key(filename_source)
|
||||
name_source_cf = name_source.casefold()
|
||||
# Substring hits floor the similarity ratio, but only for meaningful
|
||||
@@ -1496,6 +1553,7 @@ class RecipeScanner:
|
||||
identifier key when neither identifier form exists).
|
||||
"""
|
||||
entry["isDeleted"] = False
|
||||
entry["hashInvalid"] = False
|
||||
|
||||
new_hash = (item.get("sha256") or "").lower()
|
||||
if new_hash:
|
||||
@@ -1695,7 +1753,36 @@ class RecipeScanner:
|
||||
# Mark initialization as complete regardless of outcome
|
||||
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.
|
||||
|
||||
Uses persistent cache for fast startup when available:
|
||||
@@ -1703,8 +1790,14 @@ class RecipeScanner:
|
||||
2. Reconcile with filesystem (check mtime/size for changes)
|
||||
3. Fall back to full directory scan if cache miss or reconciliation fails
|
||||
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
|
||||
scan_start_time: Optional[float] = None
|
||||
try:
|
||||
# Ensure cache exists to avoid None reference errors
|
||||
if self._cache is None:
|
||||
@@ -1786,7 +1879,17 @@ class RecipeScanner:
|
||||
|
||||
# Fall back to 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
|
||||
|
||||
# Update cache with the collected data
|
||||
@@ -1800,12 +1903,30 @@ class RecipeScanner:
|
||||
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
|
||||
except Exception as e:
|
||||
logger.error(f"Error in thread-based recipe cache initialization: {e}")
|
||||
import traceback
|
||||
|
||||
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
|
||||
finally:
|
||||
# Clean up the event loop
|
||||
@@ -1959,12 +2080,16 @@ class RecipeScanner:
|
||||
return updated
|
||||
|
||||
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]]:
|
||||
"""Perform a full synchronous directory scan for recipes.
|
||||
|
||||
Args:
|
||||
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:
|
||||
Tuple of (recipes list, json_paths dict).
|
||||
@@ -1979,6 +2104,17 @@ class RecipeScanner:
|
||||
if file.lower().endswith(".recipe.json"):
|
||||
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
|
||||
for i, recipe_path in enumerate(recipe_files):
|
||||
recipe_data = self._load_recipe_file_sync(recipe_path)
|
||||
@@ -1986,6 +2122,23 @@ class RecipeScanner:
|
||||
recipe_id = str(recipe_data.get("id", ""))
|
||||
recipes.append(recipe_data)
|
||||
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
|
||||
if i % 100 == 0:
|
||||
time.sleep(0)
|
||||
@@ -2555,11 +2708,14 @@ class RecipeScanner:
|
||||
start_time = time.time()
|
||||
|
||||
# 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()
|
||||
cache = await loop.run_in_executor(
|
||||
None,
|
||||
self._initialize_recipe_cache_sync,
|
||||
True,
|
||||
)
|
||||
if cache is not None:
|
||||
self._cache = cache
|
||||
@@ -3290,6 +3446,19 @@ class RecipeScanner:
|
||||
|
||||
return await self._lora_scanner.find_models_by_name(name, base_model=base_model)
|
||||
|
||||
async def find_local_checkpoints_by_name(
|
||||
self, name: str, base_model: Optional[str] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Return every local checkpoint matching ``name`` (used to explain lookup misses)."""
|
||||
|
||||
checkpoint_scanner = getattr(self, "_checkpoint_scanner", None)
|
||||
if not checkpoint_scanner or not name:
|
||||
return []
|
||||
|
||||
return await checkpoint_scanner.find_models_by_name(
|
||||
name, base_model=base_model
|
||||
)
|
||||
|
||||
async def get_local_lora_by_hash(self, hash_value: str) -> Optional[Dict[str, Any]]:
|
||||
"""Lookup a local LoRA through the scanner's hash index."""
|
||||
|
||||
@@ -3465,11 +3634,23 @@ class RecipeScanner:
|
||||
if filters:
|
||||
# Filter by base model
|
||||
if "base_model" in filters and filters["base_model"]:
|
||||
filtered_data = [
|
||||
item
|
||||
for item in filtered_data
|
||||
if item.get("base_model", "") in filters["base_model"]
|
||||
]
|
||||
base_model_filter = filters["base_model"]
|
||||
if UNKNOWN_BASE_MODEL_FILTER in base_model_filter:
|
||||
# The unknown bucket matches recipes whose base model
|
||||
# could not be determined (None/empty); real base
|
||||
# models in the list still match by exact name.
|
||||
filtered_data = [
|
||||
item
|
||||
for item in filtered_data
|
||||
if not item.get("base_model")
|
||||
or item.get("base_model") in base_model_filter
|
||||
]
|
||||
else:
|
||||
filtered_data = [
|
||||
item
|
||||
for item in filtered_data
|
||||
if item.get("base_model", "") in base_model_filter
|
||||
]
|
||||
|
||||
# Filter by favorite
|
||||
if "favorite" in filters and filters["favorite"]:
|
||||
@@ -4036,6 +4217,214 @@ class RecipeScanner:
|
||||
updated_lora = self._enrich_lora_entry(dict(lora_entry))
|
||||
return recipe_data, updated_lora
|
||||
|
||||
async def update_checkpoint_entry(
|
||||
self,
|
||||
recipe_id: str,
|
||||
*,
|
||||
target_name: str,
|
||||
target_checkpoint: Optional[Dict[str, Any]] = None,
|
||||
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
||||
"""Update the checkpoint entry within a recipe (manual reconnect).
|
||||
|
||||
Mirrors :meth:`update_lora_entry`: the pre-update entry is snapshotted
|
||||
under ``reconnectSnapshot`` so the association can be restored later,
|
||||
then the matched local checkpoint is written back following the same
|
||||
pinned key set as ``_write_rematch_checkpoint_entry``. ``file_name``
|
||||
keeps the user-entered ``target_name`` (the same convention as the
|
||||
LoRA reconnect), while hash/name/version/baseModel/identifier are
|
||||
refreshed from the local item. The fingerprint is untouched — it is
|
||||
computed over LoRAs only.
|
||||
|
||||
Returns:
|
||||
The updated recipe data and the refreshed checkpoint metadata.
|
||||
"""
|
||||
if target_name is None:
|
||||
raise ValueError("target_name must be provided")
|
||||
|
||||
recipe_json_path = await self.get_recipe_json_path(recipe_id)
|
||||
if not recipe_json_path or not os.path.exists(recipe_json_path):
|
||||
raise RecipeNotFoundError("Recipe not found")
|
||||
|
||||
async with self._mutation_lock:
|
||||
with open(recipe_json_path, "r", encoding="utf-8") as file_obj:
|
||||
recipe_data = json.load(file_obj)
|
||||
|
||||
checkpoint = recipe_data.get("checkpoint")
|
||||
if not isinstance(checkpoint, dict):
|
||||
raise RecipeValidationError(
|
||||
"Recipe has no checkpoint entry to reconnect"
|
||||
)
|
||||
|
||||
# Snapshot the pre-update state so the association can be restored
|
||||
# later (undo reconnect). Never nest snapshots.
|
||||
snapshot = {
|
||||
key: copy.deepcopy(value)
|
||||
for key, value in checkpoint.items()
|
||||
if key != "reconnectSnapshot"
|
||||
}
|
||||
checkpoint["isDeleted"] = False
|
||||
checkpoint["hashInvalid"] = False
|
||||
checkpoint["file_name"] = target_name
|
||||
|
||||
if target_checkpoint is not None:
|
||||
sha_value = target_checkpoint.get("sha256") or target_checkpoint.get(
|
||||
"sha"
|
||||
)
|
||||
if sha_value:
|
||||
checkpoint["hash"] = sha_value.lower()
|
||||
|
||||
self._write_rematch_checkpoint_entry(checkpoint, target_checkpoint)
|
||||
|
||||
# The write-back only refreshes keys the entry already has;
|
||||
# a manual reconnect must also backfill the display keys so a
|
||||
# sparse parser-style entry renders properly after the swap.
|
||||
if not checkpoint.get("name") and target_checkpoint.get("model_name"):
|
||||
checkpoint["name"] = target_checkpoint["model_name"]
|
||||
civitai = target_checkpoint.get("civitai") or {}
|
||||
civ_name = civitai.get("name")
|
||||
if not checkpoint.get("version") and civ_name:
|
||||
checkpoint["version"] = civ_name
|
||||
if (
|
||||
not checkpoint.get("baseModel")
|
||||
and target_checkpoint.get("base_model")
|
||||
):
|
||||
checkpoint["baseModel"] = target_checkpoint["base_model"]
|
||||
|
||||
checkpoint["reconnectSnapshot"] = snapshot
|
||||
recipe_data["modified"] = time.time()
|
||||
|
||||
with open(recipe_json_path, "w", encoding="utf-8") as file_obj:
|
||||
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
|
||||
|
||||
cache = await self.get_cached_data()
|
||||
replaced = await cache.replace_recipe(recipe_id, recipe_data, resort=False)
|
||||
if not replaced:
|
||||
await cache.add_recipe(recipe_data, resort=False)
|
||||
self._schedule_resort()
|
||||
|
||||
# Update FTS index
|
||||
self._update_fts_index_for_recipe(recipe_data, "update")
|
||||
|
||||
# Update persistent SQLite cache
|
||||
if self._persistent_cache:
|
||||
self._persistent_cache.update_recipe(recipe_data, recipe_json_path)
|
||||
self._json_path_map[recipe_id] = recipe_json_path
|
||||
|
||||
updated_checkpoint = dict(checkpoint)
|
||||
if target_checkpoint is not None:
|
||||
preview_url = target_checkpoint.get("preview_url")
|
||||
if preview_url:
|
||||
updated_checkpoint["preview_url"] = config.get_preview_static_url(
|
||||
preview_url
|
||||
)
|
||||
if target_checkpoint.get("file_path"):
|
||||
updated_checkpoint["localPath"] = target_checkpoint["file_path"]
|
||||
|
||||
updated_checkpoint = self._enrich_checkpoint_entry(updated_checkpoint)
|
||||
return recipe_data, updated_checkpoint
|
||||
|
||||
async def restore_checkpoint_entry(
|
||||
self,
|
||||
recipe_id: str,
|
||||
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
||||
"""Restore the checkpoint entry to its pre-reconnect snapshot.
|
||||
|
||||
Reverses :meth:`update_checkpoint_entry`: the entry saved under
|
||||
``reconnectSnapshot`` becomes the checkpoint again and the snapshot is
|
||||
dropped. Returns the updated recipe data and the restored checkpoint
|
||||
metadata.
|
||||
"""
|
||||
recipe_json_path = await self.get_recipe_json_path(recipe_id)
|
||||
if not recipe_json_path or not os.path.exists(recipe_json_path):
|
||||
raise RecipeNotFoundError("Recipe not found")
|
||||
|
||||
async with self._mutation_lock:
|
||||
with open(recipe_json_path, "r", encoding="utf-8") as file_obj:
|
||||
recipe_data = json.load(file_obj)
|
||||
|
||||
checkpoint = recipe_data.get("checkpoint")
|
||||
if not isinstance(checkpoint, dict):
|
||||
raise RecipeValidationError(
|
||||
"Recipe has no checkpoint entry to restore"
|
||||
)
|
||||
|
||||
snapshot = checkpoint.get("reconnectSnapshot")
|
||||
if not isinstance(snapshot, dict):
|
||||
raise RecipeValidationError(
|
||||
"Checkpoint entry has no reconnect snapshot to restore"
|
||||
)
|
||||
|
||||
restored_entry = copy.deepcopy(snapshot)
|
||||
restored_entry.pop("reconnectSnapshot", None)
|
||||
recipe_data["checkpoint"] = restored_entry
|
||||
recipe_data["modified"] = time.time()
|
||||
|
||||
with open(recipe_json_path, "w", encoding="utf-8") as file_obj:
|
||||
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
|
||||
|
||||
cache = await self.get_cached_data()
|
||||
replaced = await cache.replace_recipe(recipe_id, recipe_data, resort=False)
|
||||
if not replaced:
|
||||
await cache.add_recipe(recipe_data, resort=False)
|
||||
self._schedule_resort()
|
||||
|
||||
# Update FTS index
|
||||
self._update_fts_index_for_recipe(recipe_data, "update")
|
||||
|
||||
# Update persistent SQLite cache
|
||||
if self._persistent_cache:
|
||||
self._persistent_cache.update_recipe(recipe_data, recipe_json_path)
|
||||
self._json_path_map[recipe_id] = recipe_json_path
|
||||
|
||||
restored_checkpoint = self._enrich_checkpoint_entry(dict(restored_entry))
|
||||
return recipe_data, restored_checkpoint
|
||||
|
||||
async def set_checkpoint_entry_hash_invalid(
|
||||
self,
|
||||
recipe_id: str,
|
||||
hash_invalid: bool,
|
||||
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
||||
"""Set the ``hashInvalid`` flag on the recipe's checkpoint entry.
|
||||
|
||||
``hashInvalid`` records that the entry's hash could not be resolved
|
||||
on CivitAI (e.g. a download attempt returned "Model not found").
|
||||
Marking it makes the entry an unresolved rematch candidate without
|
||||
touching its stored hash/file_name.
|
||||
|
||||
Returns:
|
||||
The updated recipe data and the refreshed checkpoint metadata.
|
||||
"""
|
||||
recipe_json_path = await self.get_recipe_json_path(recipe_id)
|
||||
if not recipe_json_path or not os.path.exists(recipe_json_path):
|
||||
raise RecipeNotFoundError("Recipe not found")
|
||||
|
||||
async with self._mutation_lock:
|
||||
with open(recipe_json_path, "r", encoding="utf-8") as file_obj:
|
||||
recipe_data = json.load(file_obj)
|
||||
|
||||
checkpoint = recipe_data.get("checkpoint")
|
||||
if not isinstance(checkpoint, dict):
|
||||
raise RecipeValidationError("Checkpoint entry is not a dict")
|
||||
|
||||
checkpoint["hashInvalid"] = bool(hash_invalid)
|
||||
recipe_data["modified"] = time.time()
|
||||
|
||||
with open(recipe_json_path, "w", encoding="utf-8") as file_obj:
|
||||
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
|
||||
|
||||
cache = await self.get_cached_data()
|
||||
replaced = await cache.replace_recipe(recipe_id, recipe_data, resort=False)
|
||||
if not replaced:
|
||||
await cache.add_recipe(recipe_data, resort=False)
|
||||
self._schedule_resort()
|
||||
|
||||
if self._persistent_cache:
|
||||
self._persistent_cache.update_recipe(recipe_data, recipe_json_path)
|
||||
self._json_path_map[recipe_id] = recipe_json_path
|
||||
|
||||
updated_checkpoint = self._enrich_checkpoint_entry(dict(checkpoint))
|
||||
return recipe_data, updated_checkpoint
|
||||
|
||||
async def get_recipes_for_lora(self, lora_hash: str) -> List[Dict[str, Any]]:
|
||||
"""Return recipes that reference a given LoRA hash."""
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Recipe service layer implementations."""
|
||||
|
||||
from .analysis_service import RecipeAnalysisService
|
||||
from .import_info import build_import_info, compute_no_loras_reason
|
||||
from .persistence_service import RecipePersistenceService
|
||||
from .sharing_service import RecipeSharingService
|
||||
from .errors import (
|
||||
@@ -15,6 +16,8 @@ __all__ = [
|
||||
"RecipeAnalysisService",
|
||||
"RecipePersistenceService",
|
||||
"RecipeSharingService",
|
||||
"build_import_info",
|
||||
"compute_no_loras_reason",
|
||||
"RecipeServiceError",
|
||||
"RecipeValidationError",
|
||||
"RecipeNotFoundError",
|
||||
|
||||
@@ -72,15 +72,28 @@ class RecipeAnalysisService:
|
||||
metadata = self._exif_utils.extract_image_metadata(temp_path)
|
||||
if not metadata:
|
||||
return AnalysisResult(
|
||||
{"error": "No metadata found in this image", "loras": []}
|
||||
{
|
||||
"error": "No metadata found in this image",
|
||||
"loras": [],
|
||||
"diagnostics": {
|
||||
"channel": "upload",
|
||||
"exif_present": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return await self._parse_metadata(
|
||||
result = await self._parse_metadata(
|
||||
metadata,
|
||||
recipe_scanner=recipe_scanner,
|
||||
image_path=None,
|
||||
include_image_base64=False,
|
||||
)
|
||||
result.payload["diagnostics"] = {
|
||||
"channel": "upload",
|
||||
"exif_present": True,
|
||||
"exif_parser": result.payload.get("parser"),
|
||||
}
|
||||
return result
|
||||
finally:
|
||||
self._safe_cleanup(temp_path)
|
||||
|
||||
@@ -104,9 +117,13 @@ class RecipeAnalysisService:
|
||||
image_info: Optional[dict[str, Any]] = None
|
||||
is_video = False
|
||||
extension = ".jpg" # Default
|
||||
# Diagnostics collected during analysis; surfaced in the payload so
|
||||
# callers can persist an import_info block explaining empty LoRA lists.
|
||||
diagnostics: dict[str, Any] = {"channel": "url"}
|
||||
|
||||
try:
|
||||
civitai_image_id = extract_civitai_image_id(url)
|
||||
diagnostics["civitai_image"] = bool(civitai_image_id)
|
||||
if civitai_image_id:
|
||||
image_info = await civitai_client.get_image_info(
|
||||
civitai_image_id, source_url=url
|
||||
@@ -147,11 +164,23 @@ class RecipeAnalysisService:
|
||||
):
|
||||
metadata = metadata["meta"]
|
||||
|
||||
# Diagnostics: capture the API meta shape before injecting
|
||||
# modelVersionIds / browsingLevel so the recipe modal can
|
||||
# explain why an import ended up without LoRAs.
|
||||
diagnostics["api_meta_present"] = isinstance(metadata, dict)
|
||||
if isinstance(metadata, dict):
|
||||
diagnostics["api_meta_keys"] = sorted(metadata.keys())
|
||||
|
||||
# Include modelVersionIds from root level if available.
|
||||
# CivitAI API returns modelVersionIds at root level, not in meta.
|
||||
# When meta is null (None), create a minimal dict so downstream
|
||||
# parsers can still discover LoRAs and checkpoints.
|
||||
model_version_ids = image_info.get("modelVersionIds")
|
||||
diagnostics["api_model_version_ids"] = (
|
||||
len(model_version_ids)
|
||||
if isinstance(model_version_ids, list)
|
||||
else 0
|
||||
)
|
||||
if model_version_ids:
|
||||
if isinstance(metadata, dict):
|
||||
metadata["modelVersionIds"] = model_version_ids
|
||||
@@ -229,6 +258,8 @@ class RecipeAnalysisService:
|
||||
finally:
|
||||
self._safe_cleanup(orig_temp_path)
|
||||
|
||||
diagnostics["exif_present"] = bool(exif_metadata)
|
||||
|
||||
# Parse EXIF data (typically a string like parameters/prompt/workflow)
|
||||
# and API metadata (dict with modelVersionIds, browsingLevel) separately,
|
||||
# then merge: API loras/checkpoint override, EXIF gen_params fill in gaps.
|
||||
@@ -237,6 +268,7 @@ class RecipeAnalysisService:
|
||||
if isinstance(exif_metadata, str):
|
||||
exif_parser = self._recipe_parser_factory.create_parser(exif_metadata)
|
||||
if exif_parser:
|
||||
diagnostics["exif_parser"] = exif_parser.__class__.__name__
|
||||
exif_data = await exif_parser.parse_metadata(
|
||||
exif_metadata, recipe_scanner=recipe_scanner,
|
||||
)
|
||||
@@ -324,6 +356,8 @@ class RecipeAnalysisService:
|
||||
if isinstance(bl, int) and bl > 0:
|
||||
result.payload["preview_nsfw_level"] = bl
|
||||
|
||||
diagnostics["is_video"] = is_video
|
||||
result.payload["diagnostics"] = diagnostics
|
||||
return result
|
||||
finally:
|
||||
if temp_path:
|
||||
@@ -334,6 +368,7 @@ class RecipeAnalysisService:
|
||||
*,
|
||||
file_path: str | None,
|
||||
recipe_scanner,
|
||||
ignore_recipe_metadata: bool = False,
|
||||
) -> AnalysisResult:
|
||||
"""Analyze a file already present on disk."""
|
||||
|
||||
@@ -348,14 +383,41 @@ class RecipeAnalysisService:
|
||||
self._exif_utils.extract_image_metadata, normalized_path
|
||||
)
|
||||
if not metadata:
|
||||
return self._metadata_not_found_response(normalized_path)
|
||||
result = self._metadata_not_found_response(normalized_path)
|
||||
result.payload["diagnostics"] = {
|
||||
"channel": "local",
|
||||
"exif_present": False,
|
||||
}
|
||||
return result
|
||||
|
||||
return await self._parse_metadata(
|
||||
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(
|
||||
metadata,
|
||||
recipe_scanner=recipe_scanner,
|
||||
image_path=normalized_path,
|
||||
include_image_base64=True,
|
||||
)
|
||||
result.payload["diagnostics"] = {
|
||||
"channel": "local",
|
||||
"exif_present": True,
|
||||
"exif_parser": result.payload.get("parser"),
|
||||
}
|
||||
return result
|
||||
|
||||
async def analyze_widget_metadata(self, *, recipe_scanner) -> AnalysisResult:
|
||||
"""Analyse the most recent generation metadata for widget saves."""
|
||||
@@ -452,6 +514,10 @@ class RecipeAnalysisService:
|
||||
metadata, recipe_scanner=recipe_scanner
|
||||
)
|
||||
|
||||
# Record which parser handled the metadata so import diagnostics
|
||||
# can distinguish e.g. ComfyUI workflow sources.
|
||||
result["parser"] = parser.__class__.__name__
|
||||
|
||||
if include_image_base64 and image_path:
|
||||
result["image_base64"] = self._encode_file(image_path)
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Import provenance helpers for recipes.
|
||||
|
||||
Builds the ``import_info`` block persisted on a recipe: the import channel
|
||||
(batch import / single URL / local file / upload / widget) and, when the
|
||||
recipe ended up with no LoRAs, a machine-readable reason plus the diagnostic
|
||||
details that led to it. The recipe modal renders this block in a collapsed
|
||||
"Why no LoRAs?" panel; legacy recipes without ``import_info`` fall back to a
|
||||
frontend heuristic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# Import channels (how the recipe entered the library).
|
||||
CHANNEL_BATCH_IMPORT_URL = "batch_import_url"
|
||||
CHANNEL_BATCH_IMPORT_LOCAL = "batch_import_local"
|
||||
CHANNEL_URL = "url"
|
||||
CHANNEL_LOCAL = "local"
|
||||
CHANNEL_UPLOAD = "upload"
|
||||
CHANNEL_WIDGET = "widget"
|
||||
CHANNEL_REIMPORT_URL = "reimport_url"
|
||||
CHANNEL_REIMPORT_LOCAL = "reimport_local"
|
||||
|
||||
_URL_CHANNELS = frozenset(
|
||||
{CHANNEL_BATCH_IMPORT_URL, CHANNEL_URL, CHANNEL_REIMPORT_URL}
|
||||
)
|
||||
|
||||
# No-LoRA reason codes (persisted, consumed by the recipe modal).
|
||||
REASON_NO_LORAS_USED = "no_loras_used"
|
||||
REASON_API_NO_LORA_RESOURCES = "api_meta_no_lora_resources"
|
||||
REASON_API_META_MISSING = "api_meta_missing"
|
||||
REASON_NO_EMBEDDED_METADATA = "no_embedded_metadata"
|
||||
REASON_WORKFLOW_METADATA_LIMITED = "workflow_metadata_limited"
|
||||
REASON_VIDEO_NO_METADATA = "video_no_metadata"
|
||||
REASON_METADATA_UNSUPPORTED = "metadata_unsupported"
|
||||
REASON_UNKNOWN = "unknown"
|
||||
|
||||
_COMFY_PARSER_NAME = "ComfyMetadataParser"
|
||||
|
||||
# Cap for api_meta_keys kept in details — enough for the UI bullet without
|
||||
# bloating the recipe JSON.
|
||||
_MAX_DETAIL_KEYS = 12
|
||||
|
||||
|
||||
def compute_no_loras_reason(
|
||||
channel: str, diagnostics: Optional[Dict[str, Any]]
|
||||
) -> str:
|
||||
"""Classify why an import produced no LoRA entries.
|
||||
|
||||
Args:
|
||||
channel: One of the CHANNEL_* constants.
|
||||
diagnostics: Signals collected during analysis (see
|
||||
``RecipeAnalysisService``), or None for channels without analysis
|
||||
(e.g. widget saves).
|
||||
"""
|
||||
diag = diagnostics or {}
|
||||
|
||||
if diag.get("is_video"):
|
||||
return REASON_VIDEO_NO_METADATA
|
||||
|
||||
# Embedded metadata that is a ComfyUI workflow: LoRA extraction from
|
||||
# workflows is limited, so report that specifically.
|
||||
parser = diag.get("exif_parser") or diag.get("parser")
|
||||
if parser == _COMFY_PARSER_NAME:
|
||||
return REASON_WORKFLOW_METADATA_LIMITED
|
||||
|
||||
if channel in _URL_CHANNELS:
|
||||
if not diag.get("civitai_image"):
|
||||
# Generic (non-CivitAI) URL: only embedded metadata is available.
|
||||
if not diag.get("exif_present"):
|
||||
return REASON_NO_EMBEDDED_METADATA
|
||||
return (
|
||||
REASON_NO_LORAS_USED if parser else REASON_METADATA_UNSUPPORTED
|
||||
)
|
||||
# NOTE: no "parsed EXIF means no LoRAs were used" shortcut here.
|
||||
# CivitAI's onsite generator writes A1111-style EXIF (prompt, seed,
|
||||
# steps, ...) WITHOUT LoRA references — LoRA usage lives only in
|
||||
# CivitAI-internal data — so cleanly parsed EXIF cannot prove the
|
||||
# generation used no LoRAs. Report the API meta shape instead.
|
||||
api_keys = diag.get("api_meta_keys") or []
|
||||
api_mvids = diag.get("api_model_version_ids") or 0
|
||||
if api_keys or api_mvids:
|
||||
return REASON_API_NO_LORA_RESOURCES
|
||||
return REASON_API_META_MISSING
|
||||
|
||||
if channel == CHANNEL_WIDGET:
|
||||
return REASON_NO_LORAS_USED
|
||||
|
||||
# Local file / upload / local re-import: embedded metadata only.
|
||||
if not diag.get("exif_present"):
|
||||
return REASON_NO_EMBEDDED_METADATA
|
||||
return REASON_NO_LORAS_USED if parser else REASON_METADATA_UNSUPPORTED
|
||||
|
||||
|
||||
def build_import_info(
|
||||
channel: str,
|
||||
diagnostics: Optional[Dict[str, Any]],
|
||||
loras: Optional[List[Dict[str, Any]]],
|
||||
) -> Dict[str, Any]:
|
||||
"""Build the ``import_info`` block persisted on a recipe.
|
||||
|
||||
Always records the import channel; adds ``reason`` and ``details`` only
|
||||
when the recipe has no LoRAs.
|
||||
"""
|
||||
info: Dict[str, Any] = {"channel": channel}
|
||||
if loras:
|
||||
return info
|
||||
|
||||
info["reason"] = compute_no_loras_reason(channel, diagnostics)
|
||||
|
||||
diag = diagnostics or {}
|
||||
details: Dict[str, Any] = {}
|
||||
api_keys = diag.get("api_meta_keys")
|
||||
if api_keys:
|
||||
details["api_meta_keys"] = list(api_keys)[:_MAX_DETAIL_KEYS]
|
||||
api_mvids = diag.get("api_model_version_ids")
|
||||
if api_mvids is not None:
|
||||
details["api_model_version_ids"] = api_mvids
|
||||
if "exif_present" in diag:
|
||||
details["exif_present"] = bool(diag.get("exif_present"))
|
||||
if diag.get("exif_parser"):
|
||||
details["exif_parser"] = diag["exif_parser"]
|
||||
if diag.get("is_video"):
|
||||
details["is_video"] = True
|
||||
if details:
|
||||
info["details"] = details
|
||||
|
||||
return info
|
||||
@@ -21,6 +21,7 @@ from ...utils.base_model import (
|
||||
from ...utils.utils import calculate_recipe_fingerprint
|
||||
from ..pending_delete_service import get_pending_delete_service
|
||||
from .errors import RecipeNotFoundError, RecipeValidationError
|
||||
from .import_info import CHANNEL_UPLOAD, CHANNEL_WIDGET, build_import_info
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -57,6 +58,7 @@ class RecipePersistenceService:
|
||||
extension: str | None = None,
|
||||
recipe_id: str | None = None,
|
||||
target_dir: str | None = None,
|
||||
skip_optimize: bool = False,
|
||||
) -> PersistenceResult:
|
||||
"""Persist a user uploaded recipe.
|
||||
|
||||
@@ -66,6 +68,11 @@ class RecipePersistenceService:
|
||||
target_dir: If provided, save recipe files to this directory instead
|
||||
of the default recipes_dir. Used by re-import to preserve the
|
||||
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 = []
|
||||
@@ -86,9 +93,12 @@ class RecipePersistenceService:
|
||||
|
||||
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"]
|
||||
if is_video:
|
||||
if is_video or skip_optimize:
|
||||
optimized_image = resolved_image_bytes
|
||||
# extension is already set
|
||||
else:
|
||||
@@ -134,6 +144,22 @@ class RecipePersistenceService:
|
||||
if metadata.get("source_path"):
|
||||
recipe_data["source_path"] = metadata.get("source_path")
|
||||
|
||||
# Persist import provenance. Batch import / re-import paths pass a
|
||||
# prebuilt import_info; frontend-driven saves (upload, single URL,
|
||||
# local path) carry the analysis payload's diagnostics, from which
|
||||
# import_info is derived here.
|
||||
import_info = metadata.get("import_info")
|
||||
if not isinstance(import_info, dict):
|
||||
diagnostics = metadata.get("diagnostics")
|
||||
if isinstance(diagnostics, dict):
|
||||
import_info = build_import_info(
|
||||
diagnostics.get("channel") or CHANNEL_UPLOAD,
|
||||
diagnostics,
|
||||
loras_data,
|
||||
)
|
||||
if isinstance(import_info, dict) and import_info:
|
||||
recipe_data["import_info"] = import_info
|
||||
|
||||
nsfw_level = metadata.get("preview_nsfw_level")
|
||||
if nsfw_level is not None and isinstance(nsfw_level, int):
|
||||
recipe_data["preview_nsfw_level"] = nsfw_level
|
||||
@@ -158,7 +184,11 @@ class RecipePersistenceService:
|
||||
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
|
||||
|
||||
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)
|
||||
await recipe_scanner.add_recipe(recipe_data)
|
||||
@@ -582,6 +612,172 @@ class RecipePersistenceService:
|
||||
}
|
||||
)
|
||||
|
||||
async def reconnect_checkpoint(
|
||||
self,
|
||||
*,
|
||||
recipe_scanner,
|
||||
recipe_id: str,
|
||||
target_name: str,
|
||||
) -> PersistenceResult:
|
||||
"""Reconnect the checkpoint entry within an existing recipe."""
|
||||
|
||||
recipe_path = await recipe_scanner.get_recipe_json_path(recipe_id)
|
||||
if not recipe_path or not os.path.exists(recipe_path):
|
||||
raise RecipeNotFoundError("Recipe not found")
|
||||
|
||||
with open(recipe_path, "r", encoding="utf-8") as file_obj:
|
||||
recipe_base_model = json.load(file_obj).get("base_model", "")
|
||||
|
||||
matches = await recipe_scanner.find_local_checkpoints_by_name(target_name)
|
||||
if not matches:
|
||||
raise RecipeNotFoundError(
|
||||
f"Local checkpoint not found with name: {target_name}"
|
||||
)
|
||||
|
||||
# Same three-tier base-model guard as reconnect_lora: exact/unknown
|
||||
# labels pass silently; same-architecture-family labels pass but are
|
||||
# reported so the UI can warn; confident mismatches stay hard-rejected.
|
||||
eligible: list[tuple[dict, str]] = []
|
||||
for match in matches:
|
||||
relation = base_model_relation(recipe_base_model, match.get("base_model"))
|
||||
if relation != RELATION_INCOMPATIBLE:
|
||||
eligible.append((match, relation))
|
||||
|
||||
if not eligible:
|
||||
raise RecipeValidationError(
|
||||
f"Local checkpoint '{target_name}' has a different base model "
|
||||
"than the recipe"
|
||||
)
|
||||
if len(eligible) > 1:
|
||||
raise RecipeValidationError(
|
||||
f"Multiple local checkpoints match '{target_name}'; "
|
||||
"include the folder path to disambiguate"
|
||||
)
|
||||
target_checkpoint, target_relation = eligible[0]
|
||||
|
||||
recipe_data, updated_checkpoint = await recipe_scanner.update_checkpoint_entry(
|
||||
recipe_id,
|
||||
target_name=target_name,
|
||||
target_checkpoint=target_checkpoint,
|
||||
)
|
||||
|
||||
image_path = recipe_data.get("file_path")
|
||||
if image_path and os.path.exists(image_path):
|
||||
self._exif_utils.append_recipe_metadata(image_path, recipe_data)
|
||||
|
||||
matching_recipes = []
|
||||
if "fingerprint" in recipe_data:
|
||||
matching_recipes = await recipe_scanner.find_recipes_by_fingerprint(
|
||||
recipe_data["fingerprint"]
|
||||
)
|
||||
if recipe_id in matching_recipes:
|
||||
matching_recipes.remove(recipe_id)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"success": True,
|
||||
"recipe_id": recipe_id,
|
||||
"updated_checkpoint": updated_checkpoint,
|
||||
"matching_recipes": matching_recipes,
|
||||
}
|
||||
if target_relation == RELATION_COMPATIBLE:
|
||||
# Structured data, not prose — the frontend localizes the warning.
|
||||
payload["base_model_mismatch"] = {
|
||||
"recipe_base_model": recipe_base_model,
|
||||
"checkpoint_base_model": target_checkpoint.get("base_model") or "",
|
||||
}
|
||||
return PersistenceResult(payload)
|
||||
|
||||
async def restore_checkpoint(
|
||||
self,
|
||||
*,
|
||||
recipe_scanner,
|
||||
recipe_id: str,
|
||||
) -> PersistenceResult:
|
||||
"""Restore the checkpoint entry to the state captured before its reconnect."""
|
||||
|
||||
recipe_data, updated_checkpoint = await recipe_scanner.restore_checkpoint_entry(
|
||||
recipe_id
|
||||
)
|
||||
|
||||
image_path = recipe_data.get("file_path")
|
||||
if image_path and os.path.exists(image_path):
|
||||
self._exif_utils.append_recipe_metadata(image_path, recipe_data)
|
||||
|
||||
matching_recipes = []
|
||||
if "fingerprint" in recipe_data:
|
||||
matching_recipes = await recipe_scanner.find_recipes_by_fingerprint(
|
||||
recipe_data["fingerprint"]
|
||||
)
|
||||
if recipe_id in matching_recipes:
|
||||
matching_recipes.remove(recipe_id)
|
||||
|
||||
return PersistenceResult(
|
||||
{
|
||||
"success": True,
|
||||
"recipe_id": recipe_id,
|
||||
"updated_checkpoint": updated_checkpoint,
|
||||
"matching_recipes": matching_recipes,
|
||||
}
|
||||
)
|
||||
|
||||
async def get_checkpoint_reconnect_suggestions(
|
||||
self,
|
||||
*,
|
||||
recipe_scanner,
|
||||
recipe_id: str,
|
||||
query: str | None = None,
|
||||
) -> PersistenceResult:
|
||||
"""Return ranked local checkpoint candidates for reconnecting a recipe entry."""
|
||||
|
||||
recipe_path = await recipe_scanner.get_recipe_json_path(recipe_id)
|
||||
if not recipe_path or not os.path.exists(recipe_path):
|
||||
raise RecipeNotFoundError("Recipe not found")
|
||||
|
||||
with open(recipe_path, "r", encoding="utf-8") as file_obj:
|
||||
recipe_data = json.load(file_obj)
|
||||
|
||||
checkpoint = recipe_data.get("checkpoint")
|
||||
if not isinstance(checkpoint, dict):
|
||||
raise RecipeValidationError("Recipe has no checkpoint entry")
|
||||
|
||||
suggestions = await recipe_scanner.suggest_checkpoint_reconnect_candidates(
|
||||
entry=checkpoint,
|
||||
recipe_base_model=recipe_data.get("base_model"),
|
||||
query=query,
|
||||
)
|
||||
|
||||
return PersistenceResult({"success": True, "suggestions": suggestions})
|
||||
|
||||
async def mark_checkpoint_hash_invalid(
|
||||
self,
|
||||
*,
|
||||
recipe_scanner,
|
||||
recipe_id: str,
|
||||
hash_invalid: bool = True,
|
||||
) -> PersistenceResult:
|
||||
"""Mark the recipe checkpoint entry's hash as unresolvable on CivitAI.
|
||||
|
||||
Called when a download attempt by hash returned "Model not found".
|
||||
The flag makes the entry an unresolved rematch candidate without
|
||||
altering its stored hash/file_name.
|
||||
"""
|
||||
|
||||
recipe_data, updated_checkpoint = (
|
||||
await recipe_scanner.set_checkpoint_entry_hash_invalid(
|
||||
recipe_id,
|
||||
hash_invalid=hash_invalid,
|
||||
)
|
||||
)
|
||||
|
||||
return PersistenceResult(
|
||||
{
|
||||
"success": True,
|
||||
"recipe_id": recipe_id,
|
||||
"hash_invalid": bool(hash_invalid),
|
||||
"updated_checkpoint": updated_checkpoint,
|
||||
}
|
||||
)
|
||||
|
||||
async def bulk_delete(
|
||||
self,
|
||||
*,
|
||||
@@ -731,6 +927,9 @@ class RecipePersistenceService:
|
||||
# Widget saves re-encode an in-memory tensor to PNG/WebP with no
|
||||
# embedded metadata chunks, so a workflow can never be present.
|
||||
"has_workflow": False,
|
||||
# Widget saves read LoRAs straight from the current workflow; an
|
||||
# empty list means the workflow used no LoRAs.
|
||||
"import_info": build_import_info(CHANNEL_WIDGET, None, loras_data),
|
||||
}
|
||||
if checkpoint_entry:
|
||||
recipe_data["checkpoint"] = checkpoint_entry
|
||||
|
||||
+26
-5
@@ -1,3 +1,5 @@
|
||||
from typing import Any
|
||||
|
||||
NSFW_LEVELS = {
|
||||
"PG": 1,
|
||||
"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.
|
||||
MAX_SAFETENSORS_HEADER_BYTES = 64 * 1024 * 1024
|
||||
|
||||
# First 12 chars of the SHA256 of an empty byte string. Some (re-packaging)
|
||||
# training tools write this placeholder into safetensors metadata instead of a
|
||||
# real hash; it must never be treated as a valid AutoV3 — several broken
|
||||
# models sharing it would collide in the hash index and falsely match recipes.
|
||||
INVALID_AUTOV3_EMPTY_HASH = "e3b0c44298fc"
|
||||
# SHA256 of an empty byte string. Some (re-packaging) training tools write a
|
||||
# truncated form of this placeholder into safetensors metadata (as
|
||||
# ``modelspec.hash_sha256`` / ``sshs_model_hash``), and hashing an empty or
|
||||
# unreadable file produces it directly. It must never be treated as a valid
|
||||
# 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_BATCH_SIZE = (
|
||||
|
||||
+67
-3
@@ -348,8 +348,14 @@ class ExifUtils:
|
||||
return image_path
|
||||
|
||||
@staticmethod
|
||||
def append_recipe_metadata(image_path, recipe_data) -> str:
|
||||
"""Append recipe metadata to an image's EXIF data"""
|
||||
def append_recipe_metadata(image_path, recipe_data, pixel_preserving=False) -> str:
|
||||
"""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:
|
||||
if image_path:
|
||||
ext = os.path.splitext(image_path)[1].lower()
|
||||
@@ -417,13 +423,71 @@ class ExifUtils:
|
||||
|
||||
# Append to existing metadata or create new one
|
||||
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
|
||||
return ExifUtils.update_image_metadata(image_path, new_metadata)
|
||||
except Exception as e:
|
||||
logger.error(f"Error appending recipe metadata: {e}", exc_info=True)
|
||||
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
|
||||
def remove_recipe_metadata(user_comment):
|
||||
"""Remove recipe metadata from user comment"""
|
||||
|
||||
+8
-3
@@ -31,9 +31,14 @@ body {
|
||||
--header-height: 48px;
|
||||
--scrollbar-width: 8px;
|
||||
|
||||
--shortcut-bg: var(--color-accent-subtle);
|
||||
--shortcut-border: var(--color-accent-border);
|
||||
--shortcut-text: var(--text-primary);
|
||||
/* Neutral "keycap" style for keyboard shortcut hints (GitHub/Linear-like).
|
||||
Derived from --text-muted so it adapts to every theme/preset. */
|
||||
--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);
|
||||
|
||||
|
||||
@@ -249,10 +249,10 @@
|
||||
font-family: inherit;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
/* Subtle tint derived from text color so it adapts to both light & dark themes */
|
||||
background: color-mix(in oklch, var(--text-muted) 12%, transparent);
|
||||
border: 1px solid color-mix(in oklch, var(--text-muted) 25%, transparent);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -65,4 +65,13 @@
|
||||
|
||||
.add-preset-btn:hover {
|
||||
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;
|
||||
overflow: hidden;
|
||||
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 {
|
||||
@@ -134,6 +137,32 @@
|
||||
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 video {
|
||||
position: absolute;
|
||||
|
||||
@@ -13,6 +13,16 @@
|
||||
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 */
|
||||
body.modal-open {
|
||||
position: fixed;
|
||||
|
||||
@@ -921,8 +921,8 @@
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(var(--modal-backdrop-blur, 6px));
|
||||
-webkit-backdrop-filter: blur(var(--modal-backdrop-blur, 6px));
|
||||
}
|
||||
|
||||
.batch-preview-select-all input[type="checkbox"] {
|
||||
|
||||
@@ -167,6 +167,29 @@
|
||||
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 */
|
||||
.video-list {
|
||||
display: flex;
|
||||
@@ -304,4 +327,87 @@
|
||||
/* Dark theme adjustments */
|
||||
[data-theme="dark"] .video-container {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -715,6 +715,72 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Empty LoRA list + collapsible "Why no LoRAs?" explanation */
|
||||
.no-loras {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9em;
|
||||
padding: var(--space-2) 0;
|
||||
}
|
||||
|
||||
.no-loras-reason {
|
||||
margin: var(--space-1) 0 var(--space-2);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--lora-surface);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.no-loras-reason summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* Hide the native disclosure triangle; rotate the icon instead. */
|
||||
.no-loras-reason summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-loras-reason summary i {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.no-loras-reason[open] summary i {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.no-loras-reason summary:hover {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.no-loras-reason-body {
|
||||
padding: 0 var(--space-3) var(--space-3);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.no-loras-reason-body ul {
|
||||
margin: 0;
|
||||
padding-left: var(--space-5);
|
||||
}
|
||||
|
||||
.no-loras-reason-body li {
|
||||
margin: var(--space-1) 0;
|
||||
}
|
||||
|
||||
.no-loras-bullet-label {
|
||||
color: var(--text-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.no-loras-inferred-note {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.recipe-checkpoint-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -891,8 +957,10 @@
|
||||
}
|
||||
|
||||
/* Restore icon for manually reconnected entries: its presence on the info
|
||||
row doubles as the "was reconnected" marker. */
|
||||
.lora-undo-reconnect {
|
||||
row doubles as the "was reconnected" marker. Shared by LoRA and
|
||||
checkpoint entries, which use the same info-row flex layout. */
|
||||
.lora-undo-reconnect,
|
||||
.checkpoint-undo-reconnect {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -907,7 +975,9 @@
|
||||
}
|
||||
|
||||
.lora-undo-reconnect:hover,
|
||||
.lora-undo-reconnect:focus-visible {
|
||||
.lora-undo-reconnect:focus-visible,
|
||||
.checkpoint-undo-reconnect:hover,
|
||||
.checkpoint-undo-reconnect:focus-visible {
|
||||
opacity: 1;
|
||||
color: var(--lora-accent);
|
||||
background: var(--lora-surface);
|
||||
@@ -1477,3 +1547,93 @@
|
||||
width: 20px;
|
||||
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;
|
||||
justify-content: center;
|
||||
margin-left: 6px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 3px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
border-radius: var(--border-radius-xs);
|
||||
background-color: var(--shortcut-bg);
|
||||
border: 1px solid var(--shortcut-border);
|
||||
box-shadow: var(--shortcut-shadow);
|
||||
color: var(--shortcut-text);
|
||||
vertical-align: middle;
|
||||
opacity: 0.8;
|
||||
@@ -219,12 +221,8 @@
|
||||
|
||||
.control-group button:hover .shortcut-key {
|
||||
opacity: 1;
|
||||
background-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.2);
|
||||
}
|
||||
|
||||
[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);
|
||||
background-color: var(--shortcut-bg-hover);
|
||||
border-color: var(--shortcut-border-hover);
|
||||
}
|
||||
|
||||
/* Ensure correct vertical alignment for text+shortcut */
|
||||
|
||||
@@ -205,6 +205,7 @@
|
||||
display: inline-block;
|
||||
background: var(--shortcut-bg);
|
||||
border: 1px solid var(--shortcut-border);
|
||||
box-shadow: var(--shortcut-shadow);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 2px 6px;
|
||||
font-size: 0.8em;
|
||||
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
} from './apiConfig.js';
|
||||
import { resetAndReload } from './modelApiFactory.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
|
||||
@@ -507,23 +512,67 @@ export class BaseModelApiClient {
|
||||
|
||||
async refreshModels(fullRebuild = false) {
|
||||
const abortController = new AbortController();
|
||||
try {
|
||||
state.loadingManager.show(
|
||||
`${fullRebuild ? 'Full rebuild' : 'Refreshing'} ${this.apiConfig.config.displayName}s...`,
|
||||
0
|
||||
const displayName = this.apiConfig.config.displayName;
|
||||
const singularName = this.apiConfig.config.singularName;
|
||||
const actionText = translate(
|
||||
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(() => {
|
||||
this.cancelTask();
|
||||
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);
|
||||
url.searchParams.append('full_rebuild', fullRebuild);
|
||||
|
||||
const response = await fetch(url, { signal: abortController.signal });
|
||||
|
||||
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();
|
||||
@@ -534,20 +583,69 @@ export class BaseModelApiClient {
|
||||
|
||||
resetAndReload(true);
|
||||
|
||||
showToast('toast.api.refreshComplete', { action: fullRebuild ? 'Full rebuild' : 'Refresh' }, 'success');
|
||||
showToast('toast.api.refreshComplete', { action: actionText }, 'success');
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
showToast('toast.api.operationCancelled', {}, 'info');
|
||||
return;
|
||||
}
|
||||
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 {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
state.loadingManager.hide();
|
||||
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) {
|
||||
try {
|
||||
state.loadingManager.showSimpleLoading('Refreshing metadata...');
|
||||
@@ -605,6 +703,9 @@ export class BaseModelApiClient {
|
||||
ws.onmessage = (event) => {
|
||||
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) {
|
||||
case 'started':
|
||||
loading.setStatus('Starting metadata fetch...');
|
||||
|
||||
+100
-5
@@ -1,7 +1,12 @@
|
||||
import { RecipeCard } from '../components/RecipeCard.js';
|
||||
import { state, getCurrentPageState } from '../state/index.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { translate } from '../utils/i18nHelpers.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 = {
|
||||
list: '/api/lm/recipes',
|
||||
@@ -333,11 +338,53 @@ export async function syncChanges() {
|
||||
}
|
||||
|
||||
export async function refreshRecipes(fullRebuild = true) {
|
||||
const actionLabel = fullRebuild ? 'Rebuilding recipe cache' : 'Refreshing recipes';
|
||||
const actionToast = fullRebuild ? 'Full rebuild' : 'Refresh';
|
||||
const actionText = translate(
|
||||
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 {
|
||||
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);
|
||||
url.searchParams.append('full_rebuild', fullRebuild);
|
||||
@@ -356,16 +403,64 @@ export async function refreshRecipes(fullRebuild = true) {
|
||||
|
||||
await resetAndReload(false);
|
||||
|
||||
showToast('toast.api.refreshComplete', { action: actionToast }, 'success');
|
||||
showToast('toast.api.refreshComplete', { action: actionText }, 'success');
|
||||
} catch (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 {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
state.loadingManager.hide();
|
||||
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
|
||||
* @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 { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
|
||||
import { MODEL_TYPES } from './api/apiConfig.js';
|
||||
import { initActiveFiltersSync } from './utils/activeFiltersSync.js';
|
||||
|
||||
// Initialize the Checkpoints page
|
||||
export class CheckpointsPageManager {
|
||||
@@ -32,6 +33,9 @@ export class CheckpointsPageManager {
|
||||
// Initialize common page features (including context menus)
|
||||
appCore.initializePageFeatures();
|
||||
|
||||
// Mirror active filters to the backend for the ComfyUI-side autocomplete
|
||||
initActiveFiltersSync(MODEL_TYPES.CHECKPOINT);
|
||||
|
||||
console.log('Checkpoints Manager initialized');
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
||||
// PageControls.js - Manages controls for both LoRAs and Checkpoints pages
|
||||
import { state, getCurrentPageState, setCurrentPageType } from '../../state/index.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 { sidebarManager } from '../SidebarManager.js';
|
||||
import { initSortDropdown, applySortToSelect, randomizeSortValue } from './SortDropdown.js';
|
||||
@@ -146,6 +147,62 @@ export class PageControls {
|
||||
|
||||
// Page-specific event listeners
|
||||
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() {
|
||||
|
||||
@@ -607,9 +607,11 @@ export function createModelCard(model, modelType) {
|
||||
sendTitle = translate('modelCard.actions.sendToWorkflow', {}, 'Send to ComfyUI (Click: Append, Shift+Click: Replace)');
|
||||
copyTitle = translate('modelCard.actions.copyLoRASyntax', {}, 'Copy LoRA Syntax');
|
||||
} 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');
|
||||
copyTitle = translate('modelCard.actions.copyCheckpointName', {}, 'Copy checkpoint name');
|
||||
} else if (modelType === MODEL_TYPES.EMBEDDING) {
|
||||
// Embedding send always appends to the prompt; no replace mode.
|
||||
sendTitle = translate('modelCard.actions.sendEmbeddingToWorkflow', {}, 'Send to ComfyUI');
|
||||
copyTitle = translate('modelCard.actions.copyEmbeddingName', {}, 'Copy embedding name');
|
||||
} else {
|
||||
|
||||
@@ -877,8 +877,9 @@ function renderLoraSpecificContent(lora, escapedWords) {
|
||||
<option value="clip_strength">${translate('modals.model.usageTips.clipStrength', {}, 'Clip Strength')}</option>
|
||||
<option value="clip_skip">${translate('modals.model.usageTips.clipSkip', {}, 'Clip Skip')}</option>
|
||||
</select>
|
||||
<input type="number" id="preset-value" step="0.01" placeholder="${translate('modals.model.usageTips.valuePlaceholder', {}, 'Value')}" style="display:none;">
|
||||
<button class="add-preset-btn">${translate('modals.model.usageTips.add', {}, 'Add')}</button>
|
||||
<!-- autofill opt-out attrs prevent password managers / email-alias extensions from attaching popups -->
|
||||
<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 class="preset-tags">
|
||||
${renderPresetTags(parsePresets(lora.usage_tips))}
|
||||
@@ -1086,6 +1087,11 @@ function setupLoraSpecificFields(filePath) {
|
||||
|
||||
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 () {
|
||||
const selected = this.value;
|
||||
if (selected) {
|
||||
@@ -1111,12 +1117,16 @@ function setupLoraSpecificFields(filePath) {
|
||||
} else {
|
||||
presetValue.style.display = 'none';
|
||||
}
|
||||
updateAddPresetButtonState();
|
||||
});
|
||||
|
||||
presetValue.addEventListener('input', updateAddPresetButtonState);
|
||||
|
||||
addPresetBtn.addEventListener('click', async function () {
|
||||
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;
|
||||
|
||||
const currentPath = resolveFilePath();
|
||||
@@ -1131,9 +1141,11 @@ function setupLoraSpecificFields(filePath) {
|
||||
document.querySelector(`.model-card[data-filepath="${escapedFilePath}"]`);
|
||||
const currentPresets = parsePresets(loraCard?.dataset.usage_tips);
|
||||
|
||||
let isUpdate;
|
||||
if (key === 'strength_range') {
|
||||
const rangeMatch = value.match(/^(-?\d*\.?\d+)\s*[-~]\s*(-?\d*\.?\d+)$/);
|
||||
if (rangeMatch) {
|
||||
isUpdate = 'strength_min' in currentPresets || 'strength_max' in currentPresets;
|
||||
currentPresets['strength_min'] = parseFloat(rangeMatch[1]);
|
||||
currentPresets['strength_max'] = parseFloat(rangeMatch[2]);
|
||||
} else {
|
||||
@@ -1141,17 +1153,36 @@ function setupLoraSpecificFields(filePath) {
|
||||
return;
|
||||
}
|
||||
} 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);
|
||||
|
||||
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);
|
||||
showToast(
|
||||
isUpdate ? 'modals.model.usageTips.updated' : 'modals.model.usageTips.added',
|
||||
{},
|
||||
'success',
|
||||
isUpdate ? 'Preset parameter updated' : 'Preset parameter added'
|
||||
);
|
||||
|
||||
presetSelector.value = '';
|
||||
presetValue.value = '';
|
||||
presetValue.style.display = 'none';
|
||||
addPresetBtn.disabled = true;
|
||||
});
|
||||
|
||||
// Add keydown event for preset value
|
||||
|
||||
@@ -227,7 +227,7 @@ export function renderTriggerWords(words, filePath) {
|
||||
const escapedWord = escapeHtml(word);
|
||||
const escapedAttr = escapeAttribute(word);
|
||||
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-copy">
|
||||
<i class="fas fa-copy"></i>
|
||||
@@ -455,7 +455,7 @@ function resetTriggerWordsUIState(section) {
|
||||
// Restore click-to-copy functionality
|
||||
tag.removeEventListener('click', startEditTriggerWord);
|
||||
setupDisplayTriggerWordTag(tag);
|
||||
tag.title = translate('modals.model.triggerWords.copyWord');
|
||||
tag.title = translate('modals.model.triggerWords.copyOrEditWord');
|
||||
|
||||
// Show copy icon, hide delete button
|
||||
if (copyIcon) copyIcon.style.display = '';
|
||||
@@ -503,7 +503,7 @@ function createTriggerWordTag(word, isEditMode = false) {
|
||||
const tag = document.createElement('div');
|
||||
tag.className = 'trigger-word-tag';
|
||||
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);
|
||||
tag.innerHTML = `
|
||||
@@ -537,7 +537,7 @@ function setupDisplayTriggerWordTag(tag) {
|
||||
|
||||
tag.addEventListener('click', handleDisplayTriggerWordClick);
|
||||
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"
|
||||
width="${media.width}"
|
||||
height="${media.height}"
|
||||
fetchpriority="high"
|
||||
class="lazy ${shouldBlur ? 'blurred' : ''}">
|
||||
${shouldBlur ? `
|
||||
<div class="nsfw-overlay">
|
||||
|
||||
@@ -22,8 +22,8 @@ import {
|
||||
} from './MediaUtils.js';
|
||||
import { generateMetadataPanel } from './MetadataPanel.js';
|
||||
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
|
||||
import { getShowcaseUrl, getThumbnailUrl } from '../../../utils/civitaiUtils.js';
|
||||
import { openMediaViewer } from '../MediaViewer.js';
|
||||
import { getShowcaseUrl, getDisplayUrl, getGalleryThumbnailUrl } from '../../../utils/civitaiUtils.js';
|
||||
import { openMediaViewer, isMediaViewerOpen } from '../MediaViewer.js';
|
||||
import { escapeAttribute } from '../utils.js';
|
||||
|
||||
/**
|
||||
@@ -54,6 +54,13 @@ export async function loadExampleImages(images, modelHash, previewUrl = '') {
|
||||
const showcaseTab = document.getElementById('showcase-tab');
|
||||
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
|
||||
let localFiles = [];
|
||||
|
||||
@@ -224,10 +231,10 @@ export function renderShowcaseContent(images, exampleFiles = [], previewUrl = ''
|
||||
${renderMediaItem(activeImg, galleryState.activeIndex, exampleFiles)}
|
||||
${renderPositionBadge(positionText)}
|
||||
</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>
|
||||
</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>
|
||||
</button>` : ''}
|
||||
</div>
|
||||
@@ -275,7 +282,7 @@ function renderThumbnail(img, index, exampleFiles) {
|
||||
originalRemoteUrl.endsWith('.mp4') || originalRemoteUrl.endsWith('.webm');
|
||||
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 matureBlurThreshold = getMatureBlurThreshold(state.settings);
|
||||
@@ -284,9 +291,9 @@ function renderThumbnail(img, index, exampleFiles) {
|
||||
const activeClass = index === galleryState.activeIndex ? ' active' : '';
|
||||
const blurClass = shouldBlur ? ' blurred' : '';
|
||||
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>` :
|
||||
`<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>' : '';
|
||||
|
||||
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');
|
||||
const mediaType = isVideo ? 'video' : 'image';
|
||||
|
||||
// Optimize CivitAI URLs for showcase display (full quality)
|
||||
const remoteUrl = getShowcaseUrl(originalRemoteUrl, mediaType);
|
||||
// Optimize CivitAI URLs for in-modal display (images capped at width=2400;
|
||||
// the full-size media viewer uses getShowcaseUrl separately)
|
||||
const remoteUrl = getDisplayUrl(originalRemoteUrl, mediaType);
|
||||
|
||||
const localUrl = localFile ? localFile.path : '';
|
||||
|
||||
@@ -438,6 +446,48 @@ function findLocalFile(img, index, exampleFiles) {
|
||||
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)
|
||||
* @param {number} index - Target index in galleryState.images
|
||||
@@ -446,6 +496,11 @@ export function updateMainDisplay(index) {
|
||||
const count = galleryState.images.length;
|
||||
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;
|
||||
|
||||
const container = document.getElementById('mainMediaContainer');
|
||||
@@ -453,6 +508,13 @@ export function updateMainDisplay(index) {
|
||||
|
||||
const activeImg = galleryState.images[galleryState.activeIndex];
|
||||
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
|
||||
container.innerHTML = renderMediaItem(
|
||||
activeImg,
|
||||
@@ -470,6 +532,7 @@ export function updateMainDisplay(index) {
|
||||
});
|
||||
|
||||
initMainMediaInteractions(container);
|
||||
prefetchAdjacentMedia();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -624,6 +687,211 @@ function setupScrollToExpand(gallery) {
|
||||
}, { 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
|
||||
* @param {HTMLElement} gallery - The .showcase-gallery element
|
||||
@@ -683,6 +951,13 @@ export function initShowcaseContent(gallery) {
|
||||
const container = gallery.querySelector('.main-media-container');
|
||||
if (container && galleryState.expanded) {
|
||||
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
|
||||
|
||||
+6
-1
@@ -12,6 +12,7 @@ import { helpManager } from './managers/HelpManager.js';
|
||||
import { doctorManager } from './managers/DoctorManager.js';
|
||||
import { bannerService } from './managers/BannerService.js';
|
||||
import { initTheme, initBackToTop } from './utils/uiHelpers.js';
|
||||
import { applyModalBackdropBlurPolicy } from './utils/renderingCapability.js';
|
||||
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
|
||||
import { i18n } from './i18n/index.js';
|
||||
import { onboardingManager } from './managers/OnboardingManager.js';
|
||||
@@ -33,7 +34,11 @@ export class AppCore {
|
||||
if (this.initialized) return;
|
||||
|
||||
console.log('AppCore: Initializing...');
|
||||
|
||||
|
||||
// Disable full-viewport backdrop blur under software rendering before
|
||||
// anything can open a modal (issue #1092)
|
||||
applyModalBackdropBlurPolicy();
|
||||
|
||||
// Initialize i18n first
|
||||
window.i18n = i18n;
|
||||
// Wait for i18n to be ready
|
||||
|
||||
@@ -3,6 +3,7 @@ import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } fr
|
||||
import { createPageControls } from './components/controls/index.js';
|
||||
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
|
||||
import { MODEL_TYPES } from './api/apiConfig.js';
|
||||
import { initActiveFiltersSync } from './utils/activeFiltersSync.js';
|
||||
|
||||
// Initialize the Embeddings page
|
||||
class EmbeddingsPageManager {
|
||||
@@ -32,6 +33,9 @@ class EmbeddingsPageManager {
|
||||
// Initialize common page features (including context menus)
|
||||
appCore.initializePageFeatures();
|
||||
|
||||
// Mirror active filters to the backend for the ComfyUI-side autocomplete
|
||||
initActiveFiltersSync(MODEL_TYPES.EMBEDDING);
|
||||
|
||||
console.log('Embeddings Manager initialized');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { updateCardsForBulkMode } from './components/shared/ModelCard.js';
|
||||
import { createPageControls } from './components/controls/index.js';
|
||||
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
|
||||
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
|
||||
import { initActiveFiltersSync } from './utils/activeFiltersSync.js';
|
||||
|
||||
// Initialize the LoRA page
|
||||
export class LoraPageManager {
|
||||
@@ -41,6 +42,9 @@ export class LoraPageManager {
|
||||
|
||||
// Initialize common page features (including context menus and virtual scroll)
|
||||
appCore.initializePageFeatures();
|
||||
|
||||
// Mirror active filters to the backend for the ComfyUI-side autocomplete
|
||||
initActiveFiltersSync('loras');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1182,10 +1182,13 @@ export class DownloadManager {
|
||||
if (!response?.success) {
|
||||
this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
|
||||
const errorMessage = response?.error || 'Unknown error';
|
||||
// Always record the latest failure so callers can distinguish
|
||||
// an unresolvable model (not found / deleted) from a transient
|
||||
// transport failure; the summary flow below may or may not run.
|
||||
this._lastDownloadError = errorMessage;
|
||||
// When the caller aggregates failures itself (multi-file
|
||||
// loop), just record the error and return (#1058).
|
||||
if (suppressFailureSummary) {
|
||||
this._lastDownloadError = errorMessage;
|
||||
return false;
|
||||
}
|
||||
// A file-level "already in library" rejection is an expected
|
||||
|
||||
@@ -511,18 +511,21 @@ export class FilterManager {
|
||||
filteredModels.forEach(model => {
|
||||
const tag = document.createElement('div');
|
||||
tag.className = 'filter-tag base-model-tag';
|
||||
tag.dataset.baseModel = model.name;
|
||||
// Display name may differ from the filter value (e.g. the "Unknown"
|
||||
// bucket shows "Unknown" but filters via a dedicated marker).
|
||||
const filterValue = model.value ?? model.name;
|
||||
tag.dataset.baseModel = filterValue;
|
||||
tag.innerHTML = `${model.name} <span class="tag-count">${model.count}</span>`;
|
||||
|
||||
tag.addEventListener('click', async () => {
|
||||
tag.classList.toggle('active');
|
||||
|
||||
if (tag.classList.contains('active')) {
|
||||
if (!this.filters.baseModel.includes(model.name)) {
|
||||
this.filters.baseModel.push(model.name);
|
||||
if (!this.filters.baseModel.includes(filterValue)) {
|
||||
this.filters.baseModel.push(filterValue);
|
||||
}
|
||||
} else {
|
||||
this.filters.baseModel = this.filters.baseModel.filter(m => m !== model.name);
|
||||
this.filters.baseModel = this.filters.baseModel.filter(m => m !== filterValue);
|
||||
}
|
||||
|
||||
this.updateActiveFiltersCount();
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||
import { onboardingManager } from './OnboardingManager.js';
|
||||
|
||||
/**
|
||||
* Manages help modal functionality and tutorial update notifications
|
||||
*/
|
||||
export class HelpManager {
|
||||
constructor() {
|
||||
this.lastViewedTimestamp = getStorageItem('help_last_viewed', 0);
|
||||
this.latestContentTimestamp = new Date('2025-10-11').getTime(); // Will be updated from server or config
|
||||
// Version of the help content the user has seen. Compared against the
|
||||
// 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;
|
||||
|
||||
// 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
|
||||
this.updateHelpBadge();
|
||||
|
||||
// Fetch latest video data (could be implemented to fetch from remote source)
|
||||
this.fetchLatestVideoData();
|
||||
|
||||
this.isInitialized = true;
|
||||
return this;
|
||||
}
|
||||
@@ -55,77 +45,147 @@ export class HelpManager {
|
||||
const tabButtons = document.querySelectorAll('.help-tabs .tab-btn');
|
||||
tabButtons.forEach(button => {
|
||||
button.addEventListener('click', (event) => {
|
||||
// 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');
|
||||
});
|
||||
|
||||
// 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');
|
||||
this.activateHelpTab(event.currentTarget.getAttribute('data-tab'));
|
||||
});
|
||||
});
|
||||
|
||||
// Replay tutorial button in the Getting Started tab
|
||||
const replayTutorialBtn = document.getElementById('replayTutorialBtn');
|
||||
if (replayTutorialBtn) {
|
||||
replayTutorialBtn.addEventListener('click', () => {
|
||||
// Close the help modal, then restart the onboarding tutorial
|
||||
if (window.modalManager) {
|
||||
window.modalManager.closeModal('helpModal');
|
||||
}
|
||||
onboardingManager.reset();
|
||||
onboardingManager.startTutorial();
|
||||
});
|
||||
}
|
||||
|
||||
// Global "?" shortcut opens the help modal on the Shortcuts tab
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key !== '?') return;
|
||||
if (this.isTypingContext(event.target)) return;
|
||||
if (window.modalManager?.isAnyModalOpen()) return;
|
||||
|
||||
event.preventDefault();
|
||||
this.openHelpModal('shortcuts');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the event target is a text entry context where "?" is literal input
|
||||
*/
|
||||
isTypingContext(target) {
|
||||
if (!(target instanceof Element)) return false;
|
||||
|
||||
const tagName = target.tagName?.toLowerCase();
|
||||
return target.isContentEditable || tagName === 'input' || tagName === 'textarea' || tagName === 'select';
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a specific help modal tab by its data-tab id
|
||||
* @param {string} tabId - The tab id (matches data-tab and pane element id)
|
||||
*/
|
||||
activateHelpTab(tabId) {
|
||||
const tabButton = document.querySelector(`.help-tabs .tab-btn[data-tab="${tabId}"]`);
|
||||
const tabPane = document.getElementById(tabId);
|
||||
if (!tabButton || !tabPane) return;
|
||||
|
||||
// Remove active class from all buttons and panes
|
||||
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
|
||||
* @param {string} [tabId] - Optional tab id to activate after opening
|
||||
*/
|
||||
openHelpModal() {
|
||||
openHelpModal(tabId) {
|
||||
// Use modalManager to open the help modal
|
||||
if (window.modalManager) {
|
||||
window.modalManager.toggleModal('helpModal');
|
||||
|
||||
// Add visual indicator to Documentation tab if there's new content
|
||||
this.updateDocumentationTabIndicator();
|
||||
|
||||
// Update the last viewed timestamp
|
||||
this.markContentAsViewed();
|
||||
|
||||
// Hide the badge
|
||||
this.hideHelpBadge();
|
||||
if (!window.modalManager) return;
|
||||
|
||||
const hadNewContent = this.hasNewContent();
|
||||
|
||||
window.modalManager.toggleModal('helpModal');
|
||||
|
||||
if (tabId) {
|
||||
this.activateHelpTab(tabId);
|
||||
}
|
||||
|
||||
// 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() {
|
||||
const docTab = document.querySelector('.tab-btn[data-tab="documentation"]');
|
||||
if (docTab && this.hasNewContent()) {
|
||||
docTab.classList.add('has-new-content');
|
||||
updateNewContentTabIndicators() {
|
||||
if (!this.hasNewContent()) return;
|
||||
|
||||
// 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() {
|
||||
this.lastViewedTimestamp = Date.now();
|
||||
setStorageItem('help_last_viewed', this.lastViewedTimestamp);
|
||||
const currentVersion = this.getCurrentContentVersion();
|
||||
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() {
|
||||
// In a real implementation, you'd fetch this from your server
|
||||
// For now, we'll just use the hardcoded data from constructor
|
||||
|
||||
// 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();
|
||||
getCurrentContentVersion() {
|
||||
const marker = document.querySelector('[data-help-content-version]');
|
||||
return marker ? marker.getAttribute('data-help-content-version') : null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update help badge visibility based on timestamps
|
||||
* Update help badge visibility based on viewed vs. served content version
|
||||
*/
|
||||
updateHelpBadge() {
|
||||
if (this.hasNewContent()) {
|
||||
@@ -134,13 +194,13 @@ export class HelpManager {
|
||||
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() {
|
||||
// If user has never viewed the help, or the content is newer than last viewed
|
||||
return this.lastViewedTimestamp === 0 || this.latestContentTimestamp > this.lastViewedTimestamp;
|
||||
const currentVersion = this.getCurrentContentVersion();
|
||||
return Boolean(currentVersion) && currentVersion !== this.viewedContentVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -43,7 +43,7 @@ export class OnboardingManager {
|
||||
{
|
||||
target: '.controls .action-buttons [data-action="bulk"]',
|
||||
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'
|
||||
},
|
||||
{
|
||||
@@ -71,10 +71,30 @@ export class OnboardingManager {
|
||||
position: 'top',
|
||||
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',
|
||||
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',
|
||||
customPosition: { top: '20%', left: '50%' }
|
||||
}
|
||||
|
||||
@@ -65,6 +65,13 @@ export class DownloadManager {
|
||||
raw_metadata: this.importManager.recipeData.raw_metadata || {},
|
||||
};
|
||||
|
||||
// Pass analysis diagnostics through so the backend can record
|
||||
// why the recipe ended up with no LoRAs (recipe modal panel).
|
||||
const diagnostics = this.importManager.recipeData.diagnostics;
|
||||
if (diagnostics && typeof diagnostics === 'object') {
|
||||
completeMetadata.diagnostics = diagnostics;
|
||||
}
|
||||
|
||||
// Preserve preview_nsfw_level from analysis so the saved
|
||||
// recipe applies the correct NSFW blur on the preview image.
|
||||
const nsfwLevel = this.importManager.recipeData.preview_nsfw_level;
|
||||
|
||||
@@ -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 = {
|
||||
/** Full quality for showcase/display - uses /optimized=true only */
|
||||
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: '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';
|
||||
@@ -95,15 +100,21 @@ export function rewriteCivitaiUrl(sourceUrl, mediaType = null, mode = Optimizati
|
||||
}
|
||||
|
||||
// Determine replacement based on mode and media type
|
||||
const isVideo = Boolean(mediaType && mediaType.toLowerCase() === 'video');
|
||||
let replacement;
|
||||
if (mode === OptimizationMode.SHOWCASE) {
|
||||
// Full quality for showcase - no width restriction
|
||||
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 {
|
||||
// Thumbnail mode with width restriction
|
||||
replacement = '/width=450,optimized=true';
|
||||
if (mediaType && mediaType.toLowerCase() === 'video') {
|
||||
replacement = '/transcode=true,width=450,optimized=true';
|
||||
// Thumbnail modes with width restriction
|
||||
const width = mode === OptimizationMode.GALLERY_THUMBNAIL ? 160 : 450;
|
||||
replacement = `/width=${width},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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*
|
||||
@@ -161,6 +185,17 @@ export function getThumbnailUrl(url, type = 'image') {
|
||||
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
|
||||
*
|
||||
|
||||
@@ -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
|
||||
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
|
||||
* @param {string} key - The key without prefix
|
||||
@@ -51,13 +76,15 @@ export function getStorageItem(key, defaultValue = null) {
|
||||
*/
|
||||
export function setStorageItem(key, value) {
|
||||
const prefixedKey = STORAGE_PREFIX + key;
|
||||
|
||||
|
||||
// Convert objects and arrays to JSON strings
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
localStorage.setItem(prefixedKey, JSON.stringify(value));
|
||||
} else {
|
||||
localStorage.setItem(prefixedKey, value);
|
||||
}
|
||||
|
||||
notifyActiveFiltersChanged(key);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,6 +94,8 @@ export function setStorageItem(key, value) {
|
||||
export function removeStorageItem(key) {
|
||||
localStorage.removeItem(STORAGE_PREFIX + 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
const activeFolder = getStorageItem('activeFolder');
|
||||
const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`);
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
</select>
|
||||
</div>
|
||||
<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">
|
||||
<i class="fas fa-caret-down"></i>
|
||||
</button>
|
||||
@@ -78,11 +78,11 @@
|
||||
|
||||
{% if page_id != 'recipes' %}
|
||||
<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 class="control-group">
|
||||
<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>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -96,7 +96,7 @@
|
||||
{% endif %}
|
||||
<div class="control-group">
|
||||
<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>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- 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">
|
||||
<button class="close" onclick="modalManager.closeModal('helpModal')">×</button>
|
||||
<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" 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="shortcuts">{{ t('help.tabs.shortcuts') }}</button>
|
||||
</div>
|
||||
|
||||
<div class="help-content">
|
||||
@@ -39,6 +40,13 @@
|
||||
<li><strong>Recipe System:</strong> Create, save and share your perfect combinations</li>
|
||||
</ul>
|
||||
</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>
|
||||
|
||||
<!-- Update Vlogs Tab -->
|
||||
@@ -136,6 +144,126 @@
|
||||
</ul>
|
||||
</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>
|
||||
|
||||
@@ -144,5 +144,8 @@
|
||||
</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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
settingGetMock.mockImplementation((key) => {
|
||||
@@ -1799,12 +1799,11 @@ describe('AutoComplete widget interactions', () => {
|
||||
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({
|
||||
baseModel: ['SD 1.5'],
|
||||
tags: { anime: 'include', nsfw: 'exclude', __no_tags__: 'exclude' },
|
||||
autoTags: { I2V: 'include' },
|
||||
modelTypes: ['standard'],
|
||||
tagLogic: 'all',
|
||||
tags: { anime: 'include', nsfw: 'exclude' },
|
||||
license: { noCredit: 'include', allowSelling: 'exclude' },
|
||||
}));
|
||||
localStorage.setItem('lora_manager_loras_activeFolder', 'MyLoras');
|
||||
@@ -1830,19 +1829,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
await Promise.resolve();
|
||||
|
||||
const calledUrl = fetchApiMock.mock.calls[0][0];
|
||||
expect(calledUrl).toContain('/lm/loras/relative-paths?search=example&limit=100');
|
||||
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');
|
||||
expect(calledUrl).toBe('/lm/loras/relative-paths?search=example&limit=100&use_active_filters=true');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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
|
||||
// carried no params, so the backend skipped the filter pipeline and global
|
||||
// settings like show_only_sfw diverged from the list endpoint.
|
||||
// carried no signal, so the backend skipped the filter pipeline and global
|
||||
// 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();
|
||||
|
||||
settingGetMock.mockImplementation((key) => {
|
||||
@@ -1907,10 +1896,13 @@ describe('AutoComplete widget interactions', () => {
|
||||
await Promise.resolve();
|
||||
|
||||
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();
|
||||
|
||||
settingGetMock.mockImplementation((key) => {
|
||||
@@ -1948,10 +1940,12 @@ describe('AutoComplete widget interactions', () => {
|
||||
|
||||
const calledUrl = fetchApiMock.mock.calls[0][0];
|
||||
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();
|
||||
|
||||
settingGetMock.mockImplementation((key) => {
|
||||
@@ -1988,15 +1982,14 @@ describe('AutoComplete widget interactions', () => {
|
||||
await Promise.resolve();
|
||||
|
||||
const calledUrl = fetchApiMock.mock.calls[0][0];
|
||||
expect(calledUrl).toContain('folder=');
|
||||
expect(calledUrl).toContain('recursive=false');
|
||||
const parsed = new URL(calledUrl, 'https://example.com');
|
||||
expect(parsed.searchParams.get('folder')).toBe('');
|
||||
expect(calledUrl).not.toContain('folder=');
|
||||
expect(calledUrl).toContain('use_active_filters=true');
|
||||
});
|
||||
|
||||
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
|
||||
// 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();
|
||||
|
||||
settingGetMock.mockImplementation((key) => {
|
||||
@@ -2029,8 +2022,8 @@ describe('AutoComplete widget interactions', () => {
|
||||
await Promise.resolve();
|
||||
|
||||
const calledUrl = fetchApiMock.mock.calls[0][0];
|
||||
expect(calledUrl).toContain('folder=Flux.1+D%2Fstyle');
|
||||
expect(calledUrl).toContain('recursive=true');
|
||||
expect(calledUrl).toContain('use_active_filters=true');
|
||||
expect(calledUrl).not.toContain('folder=');
|
||||
});
|
||||
|
||||
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,
|
||||
openCivitaiByMetadata: openCivitaiByMetadataMock,
|
||||
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', () => ({
|
||||
@@ -350,6 +356,49 @@ describe('FilterManager tag and base model filters', () => {
|
||||
expect(baseModelChip.classList.contains('active')).toBe(false);
|
||||
});
|
||||
|
||||
it('filters recipes by the unknown base model bucket via its marker value', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
base_models: [
|
||||
{ name: 'Unknown', value: '__unknown__', count: 3 },
|
||||
{ name: 'SDXL', count: 2 },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
renderControlsDom('recipes');
|
||||
const stateModule = await import('../../../static/js/state/index.js');
|
||||
stateModule.initPageState('recipes');
|
||||
const { getCurrentPageState } = stateModule;
|
||||
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
|
||||
|
||||
const loadRecipesMock = vi.fn().mockResolvedValue(undefined);
|
||||
window.recipeManager = { loadRecipes: loadRecipesMock };
|
||||
|
||||
new FilterManager({ page: 'recipes' });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const chip = document.querySelector('[data-base-model="__unknown__"]');
|
||||
expect(chip).not.toBeNull();
|
||||
});
|
||||
|
||||
const unknownChip = document.querySelector('[data-base-model="__unknown__"]');
|
||||
// Display label is "Unknown" even though the filter value is the marker
|
||||
expect(unknownChip.textContent).toContain('Unknown');
|
||||
|
||||
unknownChip.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
await vi.waitFor(() => expect(loadRecipesMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
expect(getCurrentPageState().filters.baseModel).toEqual(['__unknown__']);
|
||||
expect(unknownChip.classList.contains('active')).toBe(true);
|
||||
|
||||
const storageKey = 'lora_manager_recipes_filters';
|
||||
const storedFilters = JSON.parse(localStorage.getItem(storageKey));
|
||||
expect(storedFilters.baseModel).toEqual(['__unknown__']);
|
||||
});
|
||||
|
||||
it('filters base model chips locally without changing selected state', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -1157,4 +1206,87 @@ describe('PageControls favorites, sorting, and duplicates scenarios', () => {
|
||||
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(),
|
||||
openCivitaiByMetadata: 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', () => ({
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import { describe, it, beforeEach, expect, vi } from 'vitest';
|
||||
|
||||
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 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,
|
||||
};
|
||||
|
||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||
showToast: vi.fn(),
|
||||
copyToClipboard: vi.fn(),
|
||||
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: vi.fn(),
|
||||
updateRecipeMetadata: vi.fn(() => Promise.resolve({ success: true })),
|
||||
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">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<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-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>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
describe('RecipeModal no-LoRA reason panel', () => {
|
||||
let recipeModal;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
document.body.innerHTML = recipeModalFixture();
|
||||
const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js');
|
||||
recipeModal = new RecipeModal();
|
||||
});
|
||||
|
||||
function sync(recipe) {
|
||||
recipeModal.syncResourcesSection(recipe);
|
||||
return document.getElementById('recipeLorasList');
|
||||
}
|
||||
|
||||
it('shows the base message only when generation genuinely used no LoRAs', () => {
|
||||
const list = sync({
|
||||
id: 'r1',
|
||||
loras: [],
|
||||
import_info: { channel: 'url', reason: 'no_loras_used' },
|
||||
});
|
||||
|
||||
expect(list.querySelector('.no-loras')).not.toBeNull();
|
||||
expect(list.textContent).toContain('No LoRAs associated with this recipe');
|
||||
expect(list.querySelector('details.no-loras-reason')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders a collapsed reason panel from recorded import_info', () => {
|
||||
const list = sync({
|
||||
id: 'r2',
|
||||
loras: [],
|
||||
import_info: {
|
||||
channel: 'batch_import_url',
|
||||
reason: 'api_meta_no_lora_resources',
|
||||
details: {
|
||||
api_meta_keys: ['prompt'],
|
||||
api_model_version_ids: 0,
|
||||
exif_present: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const details = list.querySelector('details.no-loras-reason');
|
||||
expect(details).not.toBeNull();
|
||||
// Collapsed by default (no `open` attribute).
|
||||
expect(details.hasAttribute('open')).toBe(false);
|
||||
expect(details.querySelector('summary').textContent).toContain('Why no LoRAs?');
|
||||
|
||||
const body = details.querySelector('.no-loras-reason-body');
|
||||
expect(body.textContent).toContain('Batch import (image URL)');
|
||||
expect(body.textContent).toContain('The source API returned no LoRA resource data');
|
||||
expect(body.textContent).toContain('API metadata fields');
|
||||
expect(body.textContent).toContain('prompt');
|
||||
expect(body.textContent).toContain('Model version IDs reported');
|
||||
expect(body.textContent).toContain('Embedded metadata');
|
||||
// Recorded diagnostics are not labeled as inferred.
|
||||
expect(body.querySelector('.no-loras-inferred-note')).toBeNull();
|
||||
});
|
||||
|
||||
it('infers a possible reason for legacy URL recipes without import_info', () => {
|
||||
const list = sync({
|
||||
id: 'r3',
|
||||
loras: [],
|
||||
source_path: 'https://civitai.red/images/139995974',
|
||||
gen_params: { prompt: 'a castle' },
|
||||
});
|
||||
|
||||
const details = list.querySelector('details.no-loras-reason');
|
||||
expect(details).not.toBeNull();
|
||||
const body = details.querySelector('.no-loras-reason-body');
|
||||
expect(body.textContent).toContain('The source API returned no LoRA resource data');
|
||||
// Heuristic results must be labeled as inferred.
|
||||
expect(body.querySelector('.no-loras-inferred-note')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('reports missing embedded metadata for legacy local recipes with no params', () => {
|
||||
const list = sync({
|
||||
id: 'r4',
|
||||
loras: [],
|
||||
source_path: '/data/images/photo.png',
|
||||
gen_params: {},
|
||||
});
|
||||
|
||||
const details = list.querySelector('details.no-loras-reason');
|
||||
expect(details).not.toBeNull();
|
||||
expect(details.querySelector('.no-loras-reason-body').textContent).toContain(
|
||||
'The image has no embedded generation metadata'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not show the panel for legacy local recipes with complete params', () => {
|
||||
const list = sync({
|
||||
id: 'r5',
|
||||
loras: [],
|
||||
source_path: '/data/images/photo.png',
|
||||
gen_params: { prompt: 'a castle', steps: 20, seed: 42 },
|
||||
});
|
||||
|
||||
expect(list.querySelector('details.no-loras-reason')).toBeNull();
|
||||
});
|
||||
|
||||
it('flags ComfyUI workflow sources via has_workflow', () => {
|
||||
const list = sync({
|
||||
id: 'r6',
|
||||
loras: [],
|
||||
has_workflow: true,
|
||||
});
|
||||
|
||||
const details = list.querySelector('details.no-loras-reason');
|
||||
expect(details).not.toBeNull();
|
||||
expect(details.querySelector('.no-loras-reason-body').textContent).toContain(
|
||||
'ComfyUI workflow'
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes HTML in recorded diagnostic values', () => {
|
||||
const list = sync({
|
||||
id: 'r7',
|
||||
loras: [],
|
||||
import_info: {
|
||||
channel: 'url',
|
||||
reason: 'api_meta_no_lora_resources',
|
||||
details: { api_meta_keys: ['<img src=x onerror=alert(1)>'] },
|
||||
},
|
||||
});
|
||||
|
||||
const details = list.querySelector('details.no-loras-reason');
|
||||
expect(details).not.toBeNull();
|
||||
expect(details.innerHTML).not.toContain('<img src=x');
|
||||
expect(details.textContent).toContain('<img src=x onerror=alert(1)>');
|
||||
});
|
||||
});
|
||||
@@ -92,10 +92,13 @@ vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const downloadManagerMock = {
|
||||
downloadVersionWithDefaults: downloadVersionWithDefaultsMock,
|
||||
_lastDownloadError: '',
|
||||
};
|
||||
|
||||
vi.mock('../../../static/js/managers/DownloadManager.js', () => ({
|
||||
downloadManager: {
|
||||
downloadVersionWithDefaults: downloadVersionWithDefaultsMock,
|
||||
},
|
||||
downloadManager: downloadManagerMock,
|
||||
}));
|
||||
|
||||
function recipeModalFixture() {
|
||||
@@ -192,6 +195,7 @@ describe('RecipeModal resource item interactions', () => {
|
||||
// the shared mocks to their defaults explicitly.
|
||||
downloadVersionWithDefaultsMock.mockReset();
|
||||
downloadVersionWithDefaultsMock.mockResolvedValue(undefined);
|
||||
downloadManagerMock._lastDownloadError = '';
|
||||
fetchRecipeDetailsMock.mockReset();
|
||||
// Hydration re-fetches the recipe right after render; resolving an empty
|
||||
// object would delete currentRecipe.loras and wipe the list, so resolve
|
||||
@@ -206,7 +210,11 @@ describe('RecipeModal resource item interactions', () => {
|
||||
});
|
||||
|
||||
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;
|
||||
document.body.innerHTML = '';
|
||||
delete global.modalManager;
|
||||
@@ -428,14 +436,20 @@ describe('RecipeModal resource item interactions', () => {
|
||||
expect(downloadVersionWithDefaultsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders no action row when neither identifiers nor hash are available', async () => {
|
||||
it('offers reconnect for name-only LoRAs with no CivitAI identifiers', async () => {
|
||||
const recipeModal = await createRecipeModal();
|
||||
recipeModal.showRecipeDetails(recipeWithResources);
|
||||
await flushWiring();
|
||||
|
||||
const mysteryItem = document.querySelector('[data-lora-index="4"]');
|
||||
expect(mysteryItem.querySelector('.lora-download')).toBeNull();
|
||||
// No actions at all -> no empty action row taking vertical space
|
||||
expect(mysteryItem.querySelector('.recipe-lora-actions')).toBeNull();
|
||||
const reconnectButton = mysteryItem.querySelector('.lora-reconnect');
|
||||
expect(reconnectButton).not.toBeNull();
|
||||
|
||||
reconnectButton.click();
|
||||
const container = mysteryItem.querySelector('.lora-reconnect-container');
|
||||
expect(container).not.toBeNull();
|
||||
expect(container.classList.contains('active')).toBe(true);
|
||||
|
||||
// The name-fallback search link still sits inline in the title
|
||||
const link = mysteryItem.querySelector('.recipe-lora-title a.recipe-civitai-link');
|
||||
@@ -443,6 +457,81 @@ describe('RecipeModal resource item interactions', () => {
|
||||
expect(link.href).toContain('query=Mystery%20LoRA');
|
||||
});
|
||||
|
||||
it('marks the entry hash-invalid when a direct download fails with an unresolvable error', async () => {
|
||||
const recipeModal = await createRecipeModal();
|
||||
const requests = [];
|
||||
// Deep copy so the mark step mutating loras[1].hashInvalid does not
|
||||
// leak into the shared fixture used by later tests.
|
||||
const isolatedRecipe = JSON.parse(JSON.stringify(recipeWithResources));
|
||||
fetchRecipeDetailsMock.mockResolvedValue(isolatedRecipe);
|
||||
downloadManagerMock._lastDownloadError = 'Model not found';
|
||||
downloadVersionWithDefaultsMock.mockResolvedValue(false);
|
||||
global.fetch = vi.fn(async (url, options) => {
|
||||
requests.push({ url: String(url), options });
|
||||
return { ok: true, json: async () => ({}) };
|
||||
});
|
||||
recipeModal.showRecipeDetails(isolatedRecipe);
|
||||
await flushWiring();
|
||||
|
||||
// missingLora carries direct identifiers, so no hash-resolution round
|
||||
// trip happens before the download attempt.
|
||||
const missingItem = document.querySelector('[data-lora-index="1"]');
|
||||
missingItem.querySelector('.lora-download').click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(downloadVersionWithDefaultsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
requests.some(r => r.url.includes('/recipe/lora/mark-hash-invalid'))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
const markRequest = requests.find(r => r.url.includes('/mark-hash-invalid'));
|
||||
expect(JSON.parse(markRequest.options.body)).toEqual({
|
||||
recipe_id: 'recipe-resources',
|
||||
lora_index: 1,
|
||||
});
|
||||
|
||||
// The re-rendered entry swaps the download action for the reconnect one
|
||||
await vi.waitFor(() => {
|
||||
const item = document.querySelector('[data-lora-index="1"]');
|
||||
expect(item.querySelector('.lora-reconnect')).not.toBeNull();
|
||||
expect(item.querySelector('.lora-download')).toBeNull();
|
||||
expect(item.querySelector('.invalid-hash-badge')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves the entry untouched when a direct download fails transiently', async () => {
|
||||
const recipeModal = await createRecipeModal();
|
||||
const requests = [];
|
||||
const isolatedRecipe = JSON.parse(JSON.stringify(recipeWithResources));
|
||||
fetchRecipeDetailsMock.mockResolvedValue(isolatedRecipe);
|
||||
downloadManagerMock._lastDownloadError = 'Connection timed out';
|
||||
downloadVersionWithDefaultsMock.mockResolvedValue(false);
|
||||
global.fetch = vi.fn(async (url, options) => {
|
||||
requests.push({ url: String(url), options });
|
||||
return { ok: true, json: async () => ({}) };
|
||||
});
|
||||
recipeModal.showRecipeDetails(isolatedRecipe);
|
||||
await flushWiring();
|
||||
|
||||
const missingItem = document.querySelector('[data-lora-index="1"]');
|
||||
missingItem.querySelector('.lora-download').click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(downloadVersionWithDefaultsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
// Give any (unexpected) mark request a chance to fire
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
expect(requests.some(r => r.url.includes('mark-hash-invalid'))).toBe(false);
|
||||
|
||||
// The entry keeps the download action and never flips to reconnect
|
||||
const item = document.querySelector('[data-lora-index="1"]');
|
||||
expect(item.querySelector('.lora-download')).not.toBeNull();
|
||||
expect(item.querySelector('.lora-reconnect')).toBeNull();
|
||||
});
|
||||
|
||||
it('offers download for hash-only LoRAs and resolves identifiers on demand', async () => {
|
||||
const recipeModal = await createRecipeModal();
|
||||
global.fetch = vi.fn(async (url) => ({
|
||||
@@ -776,4 +865,321 @@ describe('RecipeModal resource item interactions', () => {
|
||||
lora_index: '0',
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkpoint reconnect', () => {
|
||||
const brokenCheckpoint = {
|
||||
name: 'gone-checkpoint',
|
||||
file_name: 'gone',
|
||||
inLibrary: false,
|
||||
isDeleted: true,
|
||||
hash: 'a2a12bfa01',
|
||||
};
|
||||
const hashInvalidCheckpoint = {
|
||||
name: 'invalid-checkpoint',
|
||||
file_name: 'invalid',
|
||||
inLibrary: false,
|
||||
hashInvalid: true,
|
||||
hash: 'deadbeefcafe',
|
||||
};
|
||||
|
||||
function recipeWithCheckpoint(checkpoint) {
|
||||
return {
|
||||
...JSON.parse(JSON.stringify(recipeWithResources)),
|
||||
checkpoint: { ...checkpoint },
|
||||
};
|
||||
}
|
||||
|
||||
// Hydration re-fetches the recipe right after render and re-renders the
|
||||
// modal, so the mock must resolve the SAME broken-checkpoint recipe —
|
||||
// otherwise the fetch wipes isDeleted/hashInvalid back to the fixture.
|
||||
async function renderBrokenCheckpoint(recipeModal, checkpoint) {
|
||||
const isolated = recipeWithCheckpoint(checkpoint);
|
||||
fetchRecipeDetailsMock.mockResolvedValue(isolated);
|
||||
recipeModal.showRecipeDetails(isolated);
|
||||
await flushWiring();
|
||||
}
|
||||
|
||||
function mockCheckpointSuggestionsFetch(payload) {
|
||||
const requests = [];
|
||||
global.fetch = vi.fn(async (url, options) => {
|
||||
requests.push({ url: String(url), options });
|
||||
if (String(url).includes('/checkpoint/reconnect-suggestions')) {
|
||||
return { ok: true, json: async () => payload };
|
||||
}
|
||||
if (String(url).includes('/recipe/checkpoint/reconnect')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
updated_checkpoint: {
|
||||
name: 'main-checkpoint',
|
||||
file_name: 'main',
|
||||
inLibrary: true,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { ok: true, json: async () => ({}) };
|
||||
});
|
||||
return requests;
|
||||
}
|
||||
|
||||
it('renders a deleted checkpoint with a badge and reconnect affordance', async () => {
|
||||
const recipeModal = await createRecipeModal();
|
||||
await renderBrokenCheckpoint(recipeModal, brokenCheckpoint);
|
||||
|
||||
const item = document.querySelector('.checkpoint-item');
|
||||
expect(item.classList.contains('is-deleted')).toBe(true);
|
||||
expect(item.querySelector('.deleted-badge')).not.toBeNull();
|
||||
const reconnectButton = item.querySelector('.checkpoint-reconnect');
|
||||
expect(reconnectButton).not.toBeNull();
|
||||
// Deleted checkpoints lose the civitai link (their source page is gone)
|
||||
expect(item.querySelector('.recipe-lora-title a.recipe-civitai-link')).toBeNull();
|
||||
// The inline form is present but hidden until the button is pressed
|
||||
const container = item.querySelector('.lora-reconnect-container[data-lora-index="checkpoint"]');
|
||||
expect(container).not.toBeNull();
|
||||
expect(container.classList.contains('active')).toBe(false);
|
||||
});
|
||||
|
||||
it('renders a hash-invalid checkpoint with the unresolvable hash badge', async () => {
|
||||
const recipeModal = await createRecipeModal();
|
||||
await renderBrokenCheckpoint(recipeModal, hashInvalidCheckpoint);
|
||||
|
||||
const item = document.querySelector('.checkpoint-item');
|
||||
expect(item.querySelector('.invalid-hash-badge')).not.toBeNull();
|
||||
expect(item.querySelector('.checkpoint-reconnect')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders reconnect for a name-only checkpoint with no download identifiers', async () => {
|
||||
// Importers can leave a checkpoint entry with nothing but a model name
|
||||
// (no hash / version id, so nothing was ever queryable on CivitAI).
|
||||
// It cannot be downloaded and is not marked deleted — reconnect is the
|
||||
// only remediation, so it must still surface.
|
||||
const recipeModal = await createRecipeModal();
|
||||
await renderBrokenCheckpoint(recipeModal, {
|
||||
type: 'checkpoint',
|
||||
modelName: 'meichidarkMix_meichidarkanimxlV1',
|
||||
inLibrary: false,
|
||||
});
|
||||
|
||||
const item = document.querySelector('.checkpoint-item');
|
||||
expect(item.querySelector('.checkpoint-download')).toBeNull();
|
||||
const reconnectButton = item.querySelector('.checkpoint-reconnect');
|
||||
expect(reconnectButton).not.toBeNull();
|
||||
const container = item.querySelector('.lora-reconnect-container[data-lora-index="checkpoint"]');
|
||||
expect(container).not.toBeNull();
|
||||
|
||||
reconnectButton.click();
|
||||
expect(container.classList.contains('active')).toBe(true);
|
||||
});
|
||||
|
||||
it('fetches checkpoint suggestions against the checkpoint endpoint', async () => {
|
||||
const recipeModal = await createRecipeModal();
|
||||
const suggestionsPayload = {
|
||||
success: true,
|
||||
suggestions: [
|
||||
{
|
||||
file_name: 'main-checkpoint.safetensors',
|
||||
base_model: 'SD 1.5',
|
||||
preview_url: '/preview/main.png',
|
||||
score: 0.95,
|
||||
match_reason: 'same_version',
|
||||
target_name: 'main-checkpoint',
|
||||
},
|
||||
],
|
||||
};
|
||||
mockCheckpointSuggestionsFetch(suggestionsPayload);
|
||||
|
||||
await renderBrokenCheckpoint(recipeModal, brokenCheckpoint);
|
||||
document.querySelector('.checkpoint-reconnect').click();
|
||||
const container = document.querySelector('.lora-reconnect-container[data-lora-index="checkpoint"]');
|
||||
|
||||
expect(container.classList.contains('active')).toBe(true);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/lm/recipe/recipe-resources/checkpoint/reconnect-suggestions'
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(container.querySelectorAll('.reconnect-suggestion').length).toBe(1);
|
||||
});
|
||||
expect(container.querySelector('.reconnect-suggestion-name').textContent)
|
||||
.toBe('main-checkpoint');
|
||||
});
|
||||
|
||||
it('reconnects the checkpoint via its own endpoint when a suggestion is clicked', async () => {
|
||||
const recipeModal = await createRecipeModal();
|
||||
const requests = mockCheckpointSuggestionsFetch({
|
||||
success: true,
|
||||
suggestions: [
|
||||
{
|
||||
file_name: 'main-checkpoint.safetensors',
|
||||
target_name: 'main-checkpoint',
|
||||
match_reason: 'same_version',
|
||||
score: 0.95,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await renderBrokenCheckpoint(recipeModal, brokenCheckpoint);
|
||||
document.querySelector('.checkpoint-reconnect').click();
|
||||
const container = document.querySelector('.lora-reconnect-container[data-lora-index="checkpoint"]');
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(container.querySelectorAll('.reconnect-suggestion').length).toBe(1);
|
||||
});
|
||||
container.querySelector('.reconnect-suggestion').click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(requests.some(r => r.url === '/api/lm/recipe/checkpoint/reconnect')).toBe(true);
|
||||
});
|
||||
const reconnectRequest = requests.find(r => r.url === '/api/lm/recipe/checkpoint/reconnect');
|
||||
expect(reconnectRequest.options.method).toBe('POST');
|
||||
expect(JSON.parse(reconnectRequest.options.body)).toEqual({
|
||||
recipe_id: 'recipe-resources',
|
||||
target_name: 'main-checkpoint',
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.checkpointReconnectedSuccessfully',
|
||||
{},
|
||||
'success'
|
||||
);
|
||||
});
|
||||
expect(recipeModal.currentRecipe.checkpoint.inLibrary).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when the checkpoint reconnect crossed base-model families', async () => {
|
||||
const recipeModal = await createRecipeModal();
|
||||
global.fetch = vi.fn(async (url) => {
|
||||
if (String(url).includes('/checkpoint/reconnect-suggestions')) {
|
||||
return { ok: true, json: async () => ({ success: true, suggestions: [] }) };
|
||||
}
|
||||
if (String(url).includes('/recipe/checkpoint/reconnect')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
updated_checkpoint: { name: 'main', inLibrary: true },
|
||||
base_model_mismatch: { recipe_base_model: 'Illustrious', checkpoint_base_model: 'Pony' },
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { ok: true, json: async () => ({}) };
|
||||
});
|
||||
|
||||
await renderBrokenCheckpoint(recipeModal, brokenCheckpoint);
|
||||
document.querySelector('.checkpoint-reconnect').click();
|
||||
const container = document.querySelector('.lora-reconnect-container[data-lora-index="checkpoint"]');
|
||||
const input = container.querySelector('.reconnect-input');
|
||||
input.value = 'main';
|
||||
container.querySelector('.reconnect-confirm-btn').click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.reconnectCheckpointBaseModelMismatch',
|
||||
{ recipe: 'Illustrious', checkpoint: 'Pony' },
|
||||
'warning'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('offers undo for a reconnected checkpoint and restores via the API', async () => {
|
||||
const recipeModal = await createRecipeModal();
|
||||
const isolatedRecipe = recipeWithCheckpoint(brokenCheckpoint);
|
||||
isolatedRecipe.checkpoint = {
|
||||
name: 'main-checkpoint',
|
||||
file_name: 'main',
|
||||
inLibrary: true,
|
||||
reconnectSnapshot: { name: 'gone-checkpoint', file_name: 'gone', isDeleted: true },
|
||||
};
|
||||
fetchRecipeDetailsMock.mockResolvedValue(isolatedRecipe);
|
||||
const requests = [];
|
||||
global.fetch = vi.fn(async (url, options) => {
|
||||
requests.push({ url: String(url), options });
|
||||
if (String(url).includes('/recipe/checkpoint/restore')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
updated_checkpoint: { name: 'gone', inLibrary: false, isDeleted: true },
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { ok: true, json: async () => ({}) };
|
||||
});
|
||||
recipeModal.showRecipeDetails(isolatedRecipe);
|
||||
await flushWiring();
|
||||
|
||||
const item = document.querySelector('.checkpoint-item');
|
||||
const undoButton = item.querySelector('.checkpoint-undo-reconnect');
|
||||
expect(undoButton).not.toBeNull();
|
||||
|
||||
undoButton.click();
|
||||
await vi.waitFor(() => {
|
||||
expect(showToastMock).toHaveBeenCalledWith('toast.recipes.checkpointRestored', {}, 'success');
|
||||
});
|
||||
const restoreRequest = requests.find(r => r.url === '/api/lm/recipe/checkpoint/restore');
|
||||
expect(restoreRequest.options.method).toBe('POST');
|
||||
expect(JSON.parse(restoreRequest.options.body)).toEqual({
|
||||
recipe_id: 'recipe-resources',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks the checkpoint hash invalid only when the failure is unresolvable', async () => {
|
||||
const recipeModal = await createRecipeModal();
|
||||
const { downloadManager } = await import('../../../static/js/managers/DownloadManager.js');
|
||||
const requests = [];
|
||||
global.fetch = vi.fn(async (url, options) => {
|
||||
requests.push({ url: String(url), options });
|
||||
return { ok: true, json: async () => ({ success: true }) };
|
||||
});
|
||||
|
||||
// Explicit "model removed" signal: the entry becomes a rematch/
|
||||
// reconnect candidate (same rule as the LoRA resolve "not found").
|
||||
// Use an isolated copy so the hashInvalid mutation does not leak into
|
||||
// the shared recipeWithResources fixture used by later tests.
|
||||
const isolatedRecipe = JSON.parse(JSON.stringify(recipeWithResources));
|
||||
fetchRecipeDetailsMock.mockResolvedValue(isolatedRecipe);
|
||||
downloadVersionWithDefaultsMock.mockResolvedValue(false);
|
||||
downloadManager._lastDownloadError = 'Model not found';
|
||||
recipeModal.showRecipeDetails(isolatedRecipe);
|
||||
await flushWiring();
|
||||
document.querySelector('.checkpoint-download').click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(requests.some(r => r.url === '/api/lm/recipe/checkpoint/mark-hash-invalid')).toBe(true);
|
||||
});
|
||||
const markRequest = requests.find(r => r.url === '/api/lm/recipe/checkpoint/mark-hash-invalid');
|
||||
expect(markRequest.options.method).toBe('POST');
|
||||
expect(JSON.parse(markRequest.options.body)).toEqual({ recipe_id: 'recipe-resources' });
|
||||
expect(recipeModal.currentRecipe.checkpoint.hashInvalid).toBe(true);
|
||||
});
|
||||
|
||||
it('does not mark the checkpoint hash invalid on transient download failures', async () => {
|
||||
const recipeModal = await createRecipeModal();
|
||||
const { downloadManager } = await import('../../../static/js/managers/DownloadManager.js');
|
||||
const requests = [];
|
||||
global.fetch = vi.fn(async (url, options) => {
|
||||
requests.push({ url: String(url), options });
|
||||
return { ok: true, json: async () => ({ success: true }) };
|
||||
});
|
||||
|
||||
// Transport/API exceptions must NOT enroll the entry in the
|
||||
// remediation flow — transient failures are not evidence the model is
|
||||
// unrecoverable (mirrors the LoRA path).
|
||||
downloadVersionWithDefaultsMock.mockRejectedValue(new Error('Network timeout'));
|
||||
recipeModal.showRecipeDetails(recipeWithResources);
|
||||
await flushWiring();
|
||||
document.querySelector('.checkpoint-download').click();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
expect(requests.some(r => r.url === '/api/lm/recipe/checkpoint/mark-hash-invalid')).toBe(false);
|
||||
|
||||
// Business failure without an unresolvable signal also stays untouched.
|
||||
downloadVersionWithDefaultsMock.mockResolvedValue(false);
|
||||
downloadManager._lastDownloadError = 'Connection refused';
|
||||
await recipeModal.downloadCheckpoint(recipeModal.currentRecipe.checkpoint);
|
||||
expect(requests.some(r => r.url === '/api/lm/recipe/checkpoint/mark-hash-invalid')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ vi.mock(MEDIA_UTILS_MODULE, () => ({
|
||||
|
||||
vi.mock(MEDIA_VIEWER_MODULE, () => ({
|
||||
openMediaViewer: vi.fn(),
|
||||
isMediaViewerOpen: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
const PREVIEW_URL = '/loras_static/preview/abc.png';
|
||||
@@ -197,4 +198,361 @@ describe('Showcase gallery', () => {
|
||||
expect(document.querySelector('.gallery-indicator-bar')).toBeTruthy();
|
||||
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,
|
||||
getOptimizedUrl,
|
||||
getShowcaseUrl,
|
||||
getDisplayUrl,
|
||||
getThumbnailUrl,
|
||||
getGalleryThumbnailUrl,
|
||||
extractCivitaiImageId,
|
||||
extractCivitaiModelUrlParts,
|
||||
classifyModelRelinkUrl,
|
||||
@@ -21,7 +23,9 @@ describe('civitaiUtils', () => {
|
||||
describe('OptimizationMode', () => {
|
||||
it('should have correct mode values', () => {
|
||||
expect(OptimizationMode.SHOWCASE).toBe('showcase');
|
||||
expect(OptimizationMode.DISPLAY).toBe('display');
|
||||
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');
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/width=450/12345.jpeg';
|
||||
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', () => {
|
||||
it('should return true for CivitAI URLs', () => {
|
||||
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
|
||||
@@ -5,6 +5,7 @@ from types import SimpleNamespace
|
||||
import pytest
|
||||
|
||||
from py.routes.handlers.recipe_handlers import RecipeQueryHandler
|
||||
from py.services.recipe_scanner import UNKNOWN_BASE_MODEL_FILTER
|
||||
|
||||
|
||||
async def _noop():
|
||||
@@ -46,3 +47,42 @@ async def test_recipe_query_handler_base_models_limit_zero_returns_all():
|
||||
{"name": "SDXL", "count": 2},
|
||||
{"name": "LTXV 2.3", "count": 1},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recipe_query_handler_base_models_includes_unknown_bucket():
|
||||
cache = SimpleNamespace(
|
||||
raw_data=[
|
||||
{"base_model": "SDXL"},
|
||||
{"base_model": None},
|
||||
{"base_model": ""},
|
||||
]
|
||||
)
|
||||
scanner = SimpleNamespace(get_cached_data=lambda: None)
|
||||
|
||||
async def get_cached_data():
|
||||
return cache
|
||||
|
||||
scanner.get_cached_data = get_cached_data
|
||||
|
||||
handler = RecipeQueryHandler(
|
||||
ensure_dependencies_ready=_noop,
|
||||
recipe_scanner_getter=lambda: scanner,
|
||||
format_recipe_file_url=lambda value: value,
|
||||
logger=logging.getLogger(__name__),
|
||||
)
|
||||
|
||||
response = await handler.get_base_models(
|
||||
SimpleNamespace(query={"limit": "0"}) # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
text = response.text
|
||||
assert text is not None
|
||||
payload = json.loads(text)
|
||||
|
||||
assert payload["success"] is True
|
||||
# Unknown bucket carries a dedicated marker so the UI can show "Unknown"
|
||||
# without colliding with real base model strings.
|
||||
assert payload["base_models"] == [
|
||||
{"name": "Unknown", "value": UNKNOWN_BASE_MODEL_FILTER, "count": 2},
|
||||
{"name": "SDXL", "count": 1},
|
||||
]
|
||||
|
||||
@@ -197,6 +197,7 @@ class StubAnalysisService:
|
||||
self.upload_calls: List[bytes] = []
|
||||
self.remote_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._recipe_parser_factory: Any = None
|
||||
StubAnalysisService.instances.append(self)
|
||||
@@ -218,11 +219,16 @@ class StubAnalysisService:
|
||||
return self.result
|
||||
|
||||
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
|
||||
if self.raise_for_local:
|
||||
raise self.raise_for_local
|
||||
self.local_calls.append(file_path)
|
||||
self.local_ignore_recipe_metadata_calls.append(ignore_recipe_metadata)
|
||||
return self.result
|
||||
|
||||
async def analyze_widget_metadata(self, *, recipe_scanner) -> SimpleNamespace:
|
||||
@@ -257,6 +263,7 @@ class StubPersistenceService:
|
||||
extension=None,
|
||||
recipe_id=None,
|
||||
target_dir=None,
|
||||
skip_optimize=False,
|
||||
) -> SimpleNamespace: # noqa: D401
|
||||
self.save_calls.append(
|
||||
{
|
||||
@@ -269,6 +276,7 @@ class StubPersistenceService:
|
||||
"extension": extension,
|
||||
"recipe_id": recipe_id,
|
||||
"target_dir": target_dir,
|
||||
"skip_optimize": skip_optimize,
|
||||
}
|
||||
)
|
||||
return self.save_result
|
||||
@@ -311,6 +319,28 @@ class StubPersistenceService:
|
||||
) -> SimpleNamespace: # pragma: no cover
|
||||
return SimpleNamespace(payload={"success": True}, status=200)
|
||||
|
||||
async def reconnect_checkpoint(
|
||||
self, *, recipe_scanner, recipe_id: str, target_name: str
|
||||
) -> SimpleNamespace: # pragma: no cover
|
||||
return SimpleNamespace(payload={"success": True}, status=200)
|
||||
|
||||
async def restore_checkpoint(
|
||||
self, *, recipe_scanner, recipe_id: str
|
||||
) -> SimpleNamespace: # pragma: no cover
|
||||
return SimpleNamespace(payload={"success": True}, status=200)
|
||||
|
||||
async def get_checkpoint_reconnect_suggestions(
|
||||
self, *, recipe_scanner, recipe_id: str, query: str | None = None
|
||||
) -> SimpleNamespace: # pragma: no cover
|
||||
return SimpleNamespace(
|
||||
payload={"success": True, "suggestions": []}, status=200
|
||||
)
|
||||
|
||||
async def mark_checkpoint_hash_invalid(
|
||||
self, *, recipe_scanner, recipe_id: str, hash_invalid: bool = True
|
||||
) -> SimpleNamespace: # pragma: no cover
|
||||
return SimpleNamespace(payload={"success": True}, status=200)
|
||||
|
||||
async def bulk_delete(
|
||||
self, *, recipe_scanner, recipe_ids: List[str]
|
||||
) -> SimpleNamespace: # pragma: no cover
|
||||
@@ -2050,3 +2080,279 @@ async def test_find_duplicates_forwards_include_prompt_and_assigns_unique_keys(
|
||||
assert len(groups) == 2
|
||||
assert {g["type"] for g in groups} == {"fingerprint", "source_path"}
|
||||
assert len({g["key"] for g in groups}) == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Checkpoint reconnect routes (manual remediation for recipe.checkpoint)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_checkpoint_reconnect_route(monkeypatch, tmp_path: Path) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
response = await harness.client.post(
|
||||
"/api/lm/recipe/checkpoint/reconnect",
|
||||
json={"recipe_id": "r1", "target_name": "main"},
|
||||
)
|
||||
payload = await response.json()
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
|
||||
|
||||
async def test_checkpoint_reconnect_route_requires_target_name(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
response = await harness.client.post(
|
||||
"/api/lm/recipe/checkpoint/reconnect",
|
||||
json={"recipe_id": "r1"},
|
||||
)
|
||||
assert response.status == 400
|
||||
|
||||
|
||||
async def test_checkpoint_restore_route(monkeypatch, tmp_path: Path) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
response = await harness.client.post(
|
||||
"/api/lm/recipe/checkpoint/restore",
|
||||
json={"recipe_id": "r1"},
|
||||
)
|
||||
payload = await response.json()
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
|
||||
|
||||
async def test_checkpoint_restore_route_requires_recipe_id(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
response = await harness.client.post(
|
||||
"/api/lm/recipe/checkpoint/restore",
|
||||
json={},
|
||||
)
|
||||
assert response.status == 400
|
||||
|
||||
|
||||
async def test_checkpoint_reconnect_suggestions_route(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
response = await harness.client.get(
|
||||
"/api/lm/recipe/r1/checkpoint/reconnect-suggestions?query=main"
|
||||
)
|
||||
payload = await response.json()
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
assert payload["suggestions"] == []
|
||||
|
||||
|
||||
async def test_checkpoint_reconnect_suggestions_route_without_query(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
response = await harness.client.get(
|
||||
"/api/lm/recipe/r1/checkpoint/reconnect-suggestions"
|
||||
)
|
||||
payload = await response.json()
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
|
||||
|
||||
async def test_checkpoint_mark_hash_invalid_route(monkeypatch, tmp_path: Path) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
response = await harness.client.post(
|
||||
"/api/lm/recipe/checkpoint/mark-hash-invalid",
|
||||
json={"recipe_id": "r1"},
|
||||
)
|
||||
payload = await response.json()
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
|
||||
|
||||
async def test_checkpoint_mark_hash_invalid_route_requires_recipe_id(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
response = await harness.client.post(
|
||||
"/api/lm/recipe/checkpoint/mark-hash-invalid",
|
||||
json={},
|
||||
)
|
||||
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["base_model"] == "flux"
|
||||
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()
|
||||
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
|
||||
from py.services.model_hash_index import ModelHashIndex
|
||||
from py.utils.constants import EMPTY_HASH_SHA256
|
||||
|
||||
|
||||
class TestModelHashIndexRemoveByPath:
|
||||
@@ -253,3 +254,38 @@ class TestModelHashIndexAutov3:
|
||||
assert index.has_hash("abcdef123456") is False
|
||||
assert index.get_path("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:
|
||||
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 _normalize_path(path: Path) -> str:
|
||||
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/deep" in 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
|
||||
|
||||
from py.recipes.parsers.recipe_format import RecipeFormatParser
|
||||
from py.recipes.parsers.recipe_format import RecipeFormatParser, strip_recipe_metadata
|
||||
from py.config import config
|
||||
|
||||
|
||||
@@ -111,6 +111,36 @@ async def test_recipe_format_parser_populates_checkpoint(monkeypatch):
|
||||
assert result["model"] == checkpoint
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recipe_format_parser_base_model_defaults_to_none_when_unknown(monkeypatch):
|
||||
class _FakeScanner:
|
||||
pass
|
||||
|
||||
# No checkpoint and empty base_model -> unknown renders as None (not "")
|
||||
result = await _parse(
|
||||
monkeypatch,
|
||||
{"title": "T", "base_model": "", "loras": [], "gen_params": {}},
|
||||
_FakeScanner(),
|
||||
)
|
||||
assert result["base_model"] is None
|
||||
|
||||
# Missing base_model key behaves the same
|
||||
result = await _parse(
|
||||
monkeypatch,
|
||||
{"title": "T", "loras": [], "gen_params": {}},
|
||||
_FakeScanner(),
|
||||
)
|
||||
assert result["base_model"] is None
|
||||
|
||||
# A real base_model in recipe metadata is kept
|
||||
result = await _parse(
|
||||
monkeypatch,
|
||||
{"title": "T", "base_model": "Illustrious", "loras": [], "gen_params": {}},
|
||||
_FakeScanner(),
|
||||
)
|
||||
assert result["base_model"] == "Illustrious"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recipe_format_parser_marks_lora_in_library_by_version(monkeypatch):
|
||||
async def fake_metadata_provider():
|
||||
@@ -395,3 +425,38 @@ async def test_recipe_format_parser_sha256_less_cache_item_no_keyerror(monkeypat
|
||||
lora_entry = result["loras"][0]
|
||||
assert lora_entry["existsLocally"] is False
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Tests for the recipe import_info helpers (no-LoRA reason computation)."""
|
||||
|
||||
from py.services.recipes.import_info import (
|
||||
CHANNEL_BATCH_IMPORT_LOCAL,
|
||||
CHANNEL_BATCH_IMPORT_URL,
|
||||
CHANNEL_LOCAL,
|
||||
CHANNEL_REIMPORT_URL,
|
||||
CHANNEL_UPLOAD,
|
||||
CHANNEL_URL,
|
||||
CHANNEL_WIDGET,
|
||||
REASON_API_META_MISSING,
|
||||
REASON_API_NO_LORA_RESOURCES,
|
||||
REASON_METADATA_UNSUPPORTED,
|
||||
REASON_NO_EMBEDDED_METADATA,
|
||||
REASON_NO_LORAS_USED,
|
||||
REASON_VIDEO_NO_METADATA,
|
||||
REASON_WORKFLOW_METADATA_LIMITED,
|
||||
build_import_info,
|
||||
compute_no_loras_reason,
|
||||
)
|
||||
|
||||
|
||||
class TestComputeNoLorasReason:
|
||||
def test_video_takes_priority(self):
|
||||
diag = {"is_video": True, "exif_parser": "ComfyMetadataParser"}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_BATCH_IMPORT_URL, diag)
|
||||
== REASON_VIDEO_NO_METADATA
|
||||
)
|
||||
|
||||
def test_comfy_workflow_parser(self):
|
||||
diag = {"exif_parser": "ComfyMetadataParser", "exif_present": True}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_UPLOAD, diag)
|
||||
== REASON_WORKFLOW_METADATA_LIMITED
|
||||
)
|
||||
|
||||
def test_merged_comfy_parser(self):
|
||||
# ComfyUI workflow parsed from the merged dict (no string EXIF).
|
||||
diag = {"parser": "ComfyMetadataParser"}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_LOCAL, diag)
|
||||
== REASON_WORKFLOW_METADATA_LIMITED
|
||||
)
|
||||
|
||||
def test_civitai_url_with_api_meta_but_no_lora_resources(self):
|
||||
diag = {
|
||||
"civitai_image": True,
|
||||
"api_meta_keys": ["prompt"],
|
||||
"api_model_version_ids": 0,
|
||||
"exif_present": False,
|
||||
}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_BATCH_IMPORT_URL, diag)
|
||||
== REASON_API_NO_LORA_RESOURCES
|
||||
)
|
||||
|
||||
def test_civitai_url_with_model_version_ids_only(self):
|
||||
diag = {
|
||||
"civitai_image": True,
|
||||
"api_meta_keys": [],
|
||||
"api_model_version_ids": 2,
|
||||
"exif_present": False,
|
||||
}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_URL, diag)
|
||||
== REASON_API_NO_LORA_RESOURCES
|
||||
)
|
||||
|
||||
def test_civitai_url_with_no_meta_at_all(self):
|
||||
diag = {
|
||||
"civitai_image": True,
|
||||
"api_meta_keys": [],
|
||||
"api_model_version_ids": 0,
|
||||
"exif_present": False,
|
||||
}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_REIMPORT_URL, diag)
|
||||
== REASON_API_META_MISSING
|
||||
)
|
||||
|
||||
def test_civitai_url_with_parsed_exif_still_reports_api_gap(self):
|
||||
# CivitAI's onsite generator writes A1111-style EXIF WITHOUT LoRA
|
||||
# references (LoRA usage lives in CivitAI-internal data), so cleanly
|
||||
# parsed EXIF must NOT be read as "no LoRAs were used".
|
||||
diag = {
|
||||
"civitai_image": True,
|
||||
"api_meta_keys": ["prompt", "steps", "seed", "resources"],
|
||||
"api_model_version_ids": 1,
|
||||
"exif_present": True,
|
||||
"exif_parser": "AutomaticMetadataParser",
|
||||
"parser": "CivitaiApiMetadataParser",
|
||||
}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_BATCH_IMPORT_URL, diag)
|
||||
== REASON_API_NO_LORA_RESOURCES
|
||||
)
|
||||
|
||||
def test_generic_url_without_embedded_metadata(self):
|
||||
diag = {"civitai_image": False, "exif_present": False}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_URL, diag) == REASON_NO_EMBEDDED_METADATA
|
||||
)
|
||||
|
||||
def test_generic_url_with_unsupported_metadata(self):
|
||||
diag = {"civitai_image": False, "exif_present": True}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_URL, diag) == REASON_METADATA_UNSUPPORTED
|
||||
)
|
||||
|
||||
def test_generic_url_with_parsed_metadata_means_no_loras(self):
|
||||
diag = {
|
||||
"civitai_image": False,
|
||||
"exif_present": True,
|
||||
"exif_parser": "AutomaticMetadataParser",
|
||||
}
|
||||
assert compute_no_loras_reason(CHANNEL_URL, diag) == REASON_NO_LORAS_USED
|
||||
|
||||
def test_widget(self):
|
||||
assert compute_no_loras_reason(CHANNEL_WIDGET, None) == REASON_NO_LORAS_USED
|
||||
|
||||
def test_local_without_embedded_metadata(self):
|
||||
diag = {"exif_present": False}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_LOCAL, diag)
|
||||
== REASON_NO_EMBEDDED_METADATA
|
||||
)
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_BATCH_IMPORT_LOCAL, diag)
|
||||
== REASON_NO_EMBEDDED_METADATA
|
||||
)
|
||||
|
||||
def test_local_with_unsupported_metadata(self):
|
||||
diag = {"exif_present": True}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_UPLOAD, diag)
|
||||
== REASON_METADATA_UNSUPPORTED
|
||||
)
|
||||
|
||||
def test_missing_diagnostics_falls_back_safely(self):
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_LOCAL, None)
|
||||
== REASON_NO_EMBEDDED_METADATA
|
||||
)
|
||||
# URL channel without diagnostics is treated as a generic URL (the
|
||||
# civitai_image flag defaults to False).
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_URL, None)
|
||||
== REASON_NO_EMBEDDED_METADATA
|
||||
)
|
||||
|
||||
|
||||
class TestBuildImportInfo:
|
||||
def test_channel_always_recorded(self):
|
||||
info = build_import_info(
|
||||
CHANNEL_URL, None, loras=[{"file_name": "x", "hash": "abc"}]
|
||||
)
|
||||
assert info == {"channel": CHANNEL_URL}
|
||||
|
||||
def test_reason_and_details_when_no_loras(self):
|
||||
diag = {
|
||||
"civitai_image": True,
|
||||
"api_meta_keys": ["prompt"],
|
||||
"api_model_version_ids": 0,
|
||||
"exif_present": False,
|
||||
"exif_parser": None,
|
||||
}
|
||||
info = build_import_info(CHANNEL_BATCH_IMPORT_URL, diag, loras=[])
|
||||
assert info["channel"] == CHANNEL_BATCH_IMPORT_URL
|
||||
assert info["reason"] == REASON_API_NO_LORA_RESOURCES
|
||||
assert info["details"]["api_meta_keys"] == ["prompt"]
|
||||
assert info["details"]["api_model_version_ids"] == 0
|
||||
assert info["details"]["exif_present"] is False
|
||||
# Empty exif_parser must not leak into details.
|
||||
assert "exif_parser" not in info["details"]
|
||||
|
||||
def test_details_omitted_when_nothing_to_report(self):
|
||||
info = build_import_info(CHANNEL_WIDGET, None, loras=[])
|
||||
assert info == {"channel": CHANNEL_WIDGET, "reason": REASON_NO_LORAS_USED}
|
||||
|
||||
def test_api_meta_keys_capped(self):
|
||||
diag = {
|
||||
"civitai_image": True,
|
||||
"api_meta_keys": [f"k{i}" for i in range(50)],
|
||||
"api_model_version_ids": 1,
|
||||
}
|
||||
info = build_import_info(CHANNEL_URL, diag, loras=[])
|
||||
assert len(info["details"]["api_meta_keys"]) == 12
|
||||
@@ -9,10 +9,11 @@ import pytest
|
||||
|
||||
from py.config import config
|
||||
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_hash_index import ModelHashIndex
|
||||
from py.services.model_scanner import CacheBuildResult, ModelScanner
|
||||
from py.services.recipe_scanner import RecipeScanner
|
||||
from py.services.recipe_scanner import RecipeScanner, UNKNOWN_BASE_MODEL_FILTER
|
||||
from py.services import settings_manager as settings_manager_module
|
||||
from py.utils.models import BaseModelMetadata
|
||||
from py.utils.utils import calculate_recipe_fingerprint
|
||||
@@ -768,6 +769,251 @@ async def test_set_lora_entry_hash_invalid_persists_flag(tmp_path: Path, recipe_
|
||||
assert cleared_lora["hashInvalid"] is False
|
||||
|
||||
|
||||
async def test_update_checkpoint_entry_updates_cache_and_file(
|
||||
tmp_path: Path, recipe_scanner
|
||||
):
|
||||
scanner, _ = recipe_scanner
|
||||
recipes_dir = Path(config.loras_roots[0]) / "recipes"
|
||||
recipes_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
recipe_id = "recipe-ckpt-1"
|
||||
recipe_path = recipes_dir / f"{recipe_id}.recipe.json"
|
||||
original_checkpoint = {
|
||||
"name": "Old Model",
|
||||
"version": "v1",
|
||||
"id": 1,
|
||||
"type": "Checkpoint",
|
||||
"baseModel": "SDXL 1.0",
|
||||
"file_name": "old",
|
||||
"hash": "aaa",
|
||||
"isDeleted": True,
|
||||
}
|
||||
recipe_data = {
|
||||
"id": recipe_id,
|
||||
"file_path": str(tmp_path / "image.png"),
|
||||
"title": "Original",
|
||||
"modified": 0.0,
|
||||
"created_date": 0.0,
|
||||
"base_model": "SDXL 1.0",
|
||||
"checkpoint": dict(original_checkpoint),
|
||||
}
|
||||
recipe_path.write_text(json.dumps(recipe_data))
|
||||
await scanner.add_recipe(dict(recipe_data))
|
||||
|
||||
target_info = {
|
||||
"sha256": "abc123",
|
||||
"file_path": str(tmp_path / "checkpoints" / "main.safetensors"),
|
||||
"preview_url": "preview.png",
|
||||
"model_name": "Main Model",
|
||||
"base_model": "SDXL 1.0",
|
||||
"civitai": {"id": 42, "name": "v2"},
|
||||
}
|
||||
|
||||
updated_recipe, updated_checkpoint = await scanner.update_checkpoint_entry(
|
||||
recipe_id,
|
||||
target_name="main",
|
||||
target_checkpoint=target_info,
|
||||
)
|
||||
|
||||
# Write-back follows the pinned checkpoint key set, keeping the
|
||||
# user-entered file_name.
|
||||
assert updated_checkpoint["file_name"] == "main"
|
||||
assert updated_checkpoint["hash"] == "abc123"
|
||||
assert updated_checkpoint["isDeleted"] is False
|
||||
assert updated_checkpoint["hashInvalid"] is False
|
||||
assert updated_checkpoint["name"] == "Main Model"
|
||||
assert updated_checkpoint["version"] == "v2"
|
||||
assert updated_checkpoint["baseModel"] == "SDXL 1.0"
|
||||
assert updated_checkpoint["id"] == 42
|
||||
# The pre-reconnect state is snapshotted for undo
|
||||
assert updated_checkpoint["reconnectSnapshot"] == original_checkpoint
|
||||
assert "reconnectSnapshot" not in updated_checkpoint["reconnectSnapshot"]
|
||||
|
||||
with recipe_path.open("r", encoding="utf-8") as file_obj:
|
||||
persisted = json.load(file_obj)
|
||||
assert persisted["checkpoint"]["hash"] == "abc123"
|
||||
assert persisted["checkpoint"]["reconnectSnapshot"] == original_checkpoint
|
||||
|
||||
cache = await scanner.get_cached_data()
|
||||
cached_recipe = next(item for item in cache.raw_data if item["id"] == recipe_id)
|
||||
assert cached_recipe["checkpoint"]["hash"] == "abc123"
|
||||
|
||||
|
||||
async def test_update_checkpoint_entry_backfills_missing_display_keys(
|
||||
tmp_path: Path, recipe_scanner
|
||||
):
|
||||
scanner, _ = recipe_scanner
|
||||
recipes_dir = Path(config.loras_roots[0]) / "recipes"
|
||||
recipes_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
recipe_id = "recipe-ckpt-sparse"
|
||||
recipe_path = recipes_dir / f"{recipe_id}.recipe.json"
|
||||
# Parser-style sparse entry without name/version/baseModel keys
|
||||
recipe_data = {
|
||||
"id": recipe_id,
|
||||
"file_path": str(tmp_path / "image.png"),
|
||||
"title": "Sparse",
|
||||
"modified": 0.0,
|
||||
"created_date": 0.0,
|
||||
"checkpoint": {"file_name": "old", "hash": "aaa", "isDeleted": True},
|
||||
}
|
||||
recipe_path.write_text(json.dumps(recipe_data))
|
||||
await scanner.add_recipe(dict(recipe_data))
|
||||
|
||||
target_info = {
|
||||
"sha256": "abc123",
|
||||
"file_path": "/models/checkpoints/main.safetensors",
|
||||
"model_name": "Main Model",
|
||||
"base_model": "SDXL 1.0",
|
||||
"civitai": {"id": 42, "name": "v2"},
|
||||
}
|
||||
|
||||
_, updated_checkpoint = await scanner.update_checkpoint_entry(
|
||||
recipe_id,
|
||||
target_name="main",
|
||||
target_checkpoint=target_info,
|
||||
)
|
||||
|
||||
assert updated_checkpoint["name"] == "Main Model"
|
||||
assert updated_checkpoint["version"] == "v2"
|
||||
assert updated_checkpoint["baseModel"] == "SDXL 1.0"
|
||||
assert updated_checkpoint["modelVersionId"] == 42
|
||||
|
||||
|
||||
async def test_restore_checkpoint_entry_round_trip(tmp_path: Path, recipe_scanner):
|
||||
scanner, _ = recipe_scanner
|
||||
recipes_dir = Path(config.loras_roots[0]) / "recipes"
|
||||
recipes_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
recipe_id = "recipe-ckpt-restore"
|
||||
recipe_path = recipes_dir / f"{recipe_id}.recipe.json"
|
||||
original_checkpoint = {
|
||||
"name": "Old Model",
|
||||
"file_name": "old",
|
||||
"hash": "aaa",
|
||||
"isDeleted": True,
|
||||
}
|
||||
recipe_data = {
|
||||
"id": recipe_id,
|
||||
"file_path": str(tmp_path / "image.png"),
|
||||
"title": "Original",
|
||||
"modified": 0.0,
|
||||
"created_date": 0.0,
|
||||
"checkpoint": dict(original_checkpoint),
|
||||
}
|
||||
recipe_path.write_text(json.dumps(recipe_data))
|
||||
await scanner.add_recipe(dict(recipe_data))
|
||||
|
||||
target_info = {
|
||||
"sha256": "abc123",
|
||||
"file_path": "/models/checkpoints/main.safetensors",
|
||||
"model_name": "Main Model",
|
||||
"civitai": {"id": 42, "name": "v2"},
|
||||
}
|
||||
await scanner.update_checkpoint_entry(
|
||||
recipe_id, target_name="main", target_checkpoint=target_info
|
||||
)
|
||||
|
||||
restored_recipe, restored_checkpoint = await scanner.restore_checkpoint_entry(
|
||||
recipe_id
|
||||
)
|
||||
|
||||
assert restored_recipe["checkpoint"] == original_checkpoint
|
||||
assert "reconnectSnapshot" not in restored_recipe["checkpoint"]
|
||||
assert restored_checkpoint["file_name"] == "old"
|
||||
|
||||
with recipe_path.open("r", encoding="utf-8") as file_obj:
|
||||
persisted = json.load(file_obj)
|
||||
assert persisted["checkpoint"] == original_checkpoint
|
||||
|
||||
|
||||
async def test_restore_checkpoint_entry_without_snapshot_rejected(
|
||||
tmp_path: Path, recipe_scanner
|
||||
):
|
||||
scanner, _ = recipe_scanner
|
||||
recipes_dir = Path(config.loras_roots[0]) / "recipes"
|
||||
recipes_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
recipe_id = "recipe-ckpt-no-snapshot"
|
||||
recipe_path = recipes_dir / f"{recipe_id}.recipe.json"
|
||||
recipe_path.write_text(
|
||||
json.dumps({"id": recipe_id, "checkpoint": {"file_name": "plain"}})
|
||||
)
|
||||
|
||||
with pytest.raises(RecipeValidationError):
|
||||
await scanner.restore_checkpoint_entry(recipe_id)
|
||||
|
||||
|
||||
async def test_set_checkpoint_entry_hash_invalid_persists_flag(
|
||||
tmp_path: Path, recipe_scanner
|
||||
):
|
||||
scanner, _ = recipe_scanner
|
||||
recipes_dir = Path(config.loras_roots[0]) / "recipes"
|
||||
recipes_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
recipe_id = "hash-invalid-ckpt"
|
||||
recipe_path = recipes_dir / f"{recipe_id}.recipe.json"
|
||||
recipe_data = {
|
||||
"id": recipe_id,
|
||||
"file_path": str(tmp_path / "image.png"),
|
||||
"title": "Hash invalid",
|
||||
"modified": 0.0,
|
||||
"created_date": 0.0,
|
||||
"checkpoint": {"name": "Old", "file_name": "old", "hash": "a2a12bfa01"},
|
||||
}
|
||||
recipe_path.write_text(json.dumps(recipe_data))
|
||||
await scanner.add_recipe(dict(recipe_data))
|
||||
|
||||
updated_recipe, updated_checkpoint = await scanner.set_checkpoint_entry_hash_invalid(
|
||||
recipe_id, True
|
||||
)
|
||||
|
||||
assert updated_checkpoint["hashInvalid"] is True
|
||||
assert updated_recipe["checkpoint"]["hashInvalid"] is True
|
||||
with recipe_path.open("r", encoding="utf-8") as file_obj:
|
||||
persisted = json.load(file_obj)
|
||||
assert persisted["checkpoint"]["hashInvalid"] is True
|
||||
assert persisted["checkpoint"]["hash"] == "a2a12bfa01"
|
||||
|
||||
cache = await scanner.get_cached_data()
|
||||
cached_recipe = next(item for item in cache.raw_data if item["id"] == recipe_id)
|
||||
assert cached_recipe["checkpoint"]["hashInvalid"] is True
|
||||
|
||||
_, cleared_checkpoint = await scanner.set_checkpoint_entry_hash_invalid(
|
||||
recipe_id, False
|
||||
)
|
||||
assert cleared_checkpoint["hashInvalid"] is False
|
||||
|
||||
|
||||
async def test_find_local_checkpoints_by_name_uses_checkpoint_scanner(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
from py.services.recipe_scanner import RecipeScanner as RecipeScannerCls
|
||||
|
||||
class StubCheckpointScanner:
|
||||
async def find_models_by_name(self, name, *, base_model=None):
|
||||
return [
|
||||
{"file_name": f"{name}.safetensors", "base_model": base_model or ""}
|
||||
]
|
||||
|
||||
class StubLoraScannerForCkpt:
|
||||
async def get_cached_data(self):
|
||||
return SimpleNamespace(raw_data=[], version_index={})
|
||||
|
||||
RecipeScannerCls._instance = None
|
||||
scanner = RecipeScannerCls(
|
||||
lora_scanner=StubLoraScannerForCkpt(),
|
||||
checkpoint_scanner=StubCheckpointScanner(), # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
|
||||
matches = await scanner.find_local_checkpoints_by_name("main")
|
||||
assert matches == [{"file_name": "main.safetensors", "base_model": ""}]
|
||||
|
||||
assert await scanner.find_local_checkpoints_by_name("") == []
|
||||
scanner._checkpoint_scanner = None
|
||||
assert await scanner.find_local_checkpoints_by_name("main") == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_recipe_syntax_tokens_skips_unobtainable_loras(tmp_path: Path, recipe_scanner):
|
||||
scanner, _ = recipe_scanner
|
||||
@@ -1709,6 +1955,59 @@ async def test_get_paginated_data_filters_by_favorite(recipe_scanner):
|
||||
assert len(result_fav_false["items"]) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_paginated_data_filters_by_base_model_unknown_bucket(recipe_scanner):
|
||||
scanner, _ = recipe_scanner
|
||||
|
||||
await scanner.add_recipe(
|
||||
{
|
||||
"id": "known",
|
||||
"file_path": "path/known.png",
|
||||
"title": "Known Base Model",
|
||||
"modified": 1.0,
|
||||
"created_date": 1.0,
|
||||
"base_model": "SDXL 1.0",
|
||||
"loras": [],
|
||||
}
|
||||
)
|
||||
await scanner.add_recipe(
|
||||
{
|
||||
"id": "unknown",
|
||||
"file_path": "path/unknown.png",
|
||||
"title": "Unknown Base Model",
|
||||
"modified": 2.0,
|
||||
"created_date": 2.0,
|
||||
"base_model": None,
|
||||
"loras": [],
|
||||
}
|
||||
)
|
||||
|
||||
await asyncio.sleep(0)
|
||||
await _wait_for_resort(scanner)
|
||||
|
||||
# Exact-name filter matches only the recipe with that base model
|
||||
result_known = await scanner.get_paginated_data(
|
||||
page=1, page_size=10, filters={"base_model": ["SDXL 1.0"]}
|
||||
)
|
||||
assert [item["id"] for item in result_known["items"]] == ["known"]
|
||||
|
||||
# Unknown bucket matches recipes whose base model could not be determined
|
||||
result_unknown = await scanner.get_paginated_data(
|
||||
page=1,
|
||||
page_size=10,
|
||||
filters={"base_model": [UNKNOWN_BASE_MODEL_FILTER]},
|
||||
)
|
||||
assert [item["id"] for item in result_unknown["items"]] == ["unknown"]
|
||||
|
||||
# Mixing known values with the unknown bucket matches both groups
|
||||
result_both = await scanner.get_paginated_data(
|
||||
page=1,
|
||||
page_size=10,
|
||||
filters={"base_model": ["SDXL 1.0", UNKNOWN_BASE_MODEL_FILTER]},
|
||||
)
|
||||
assert {item["id"] for item in result_both["items"]} == {"known", "unknown"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_paginated_data_filters_by_prompt(recipe_scanner):
|
||||
scanner, _ = recipe_scanner
|
||||
@@ -4667,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)
|
||||
# Recipes without gen_params/prompt normalize to empty prompt and match
|
||||
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
|
||||
return image_data, ".webp"
|
||||
|
||||
def append_recipe_metadata(self, image_path, recipe_data):
|
||||
self.appended = (image_path, recipe_data)
|
||||
def append_recipe_metadata(self, image_path, recipe_data, pixel_preserving=False):
|
||||
self.appended = (image_path, recipe_data, pixel_preserving)
|
||||
|
||||
def extract_image_metadata(self, path):
|
||||
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"
|
||||
|
||||
|
||||
@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
|
||||
async def test_analyze_remote_image_download_failure_cleans_temp(tmp_path, monkeypatch):
|
||||
exif_utils = DummyExifUtils()
|
||||
@@ -1676,3 +1757,419 @@ async def test_analyze_remote_image_meta_null_keeps_exif_loras(tmp_path, monkeyp
|
||||
assert loras[0]["hash"] == LORA_SHA256
|
||||
assert loras[0].get("isDeleted") in (None, False)
|
||||
assert "Daphne" in str(payload.get("gen_params", {}).get("prompt"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Checkpoint reconnect chain (manual remediation for recipe.checkpoint)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_persistence_service():
|
||||
return RecipePersistenceService(
|
||||
exif_utils=DummyExifUtils(),
|
||||
card_preview_width=512,
|
||||
logger=logging.getLogger("test"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconnect_checkpoint_distinguishes_ambiguous_mismatched_and_missing(tmp_path):
|
||||
service = _make_persistence_service()
|
||||
|
||||
models = [
|
||||
{
|
||||
"file_name": "realistic.safetensors",
|
||||
"folder": "sdxl",
|
||||
"file_path": "/models/checkpoints/sdxl/realistic.safetensors",
|
||||
"base_model": "SDXL 1.0",
|
||||
},
|
||||
{
|
||||
"file_name": "realistic.safetensors",
|
||||
"folder": "sd15",
|
||||
"file_path": "/models/checkpoints/sd15/realistic.safetensors",
|
||||
"base_model": "SD 1.5",
|
||||
},
|
||||
]
|
||||
|
||||
class DummyScanner:
|
||||
def __init__(self, recipe_path):
|
||||
self._recipe_path = recipe_path
|
||||
|
||||
async def get_recipe_json_path(self, recipe_id):
|
||||
return str(self._recipe_path)
|
||||
|
||||
async def find_local_checkpoints_by_name(self, name, base_model=None):
|
||||
return ModelScanner.find_matching_models(models, name, base_model=base_model)
|
||||
|
||||
def write_recipe(base_model):
|
||||
recipe_path = tmp_path / "recipe.json"
|
||||
recipe_path.write_text(
|
||||
json.dumps({"id": "r1", "base_model": base_model, "checkpoint": {}})
|
||||
)
|
||||
return DummyScanner(recipe_path)
|
||||
|
||||
# Ambiguous bare name: two candidates survive (recipe base model unknown)
|
||||
scanner = write_recipe("")
|
||||
with pytest.raises(RecipeValidationError, match="include the folder path"):
|
||||
await service.reconnect_checkpoint(
|
||||
recipe_scanner=scanner, recipe_id="r1", target_name="realistic"
|
||||
)
|
||||
|
||||
# Confident base-model mismatch: the only candidate belongs to another family
|
||||
scanner = write_recipe("SD 1.5")
|
||||
with pytest.raises(RecipeValidationError, match="different base model"):
|
||||
await service.reconnect_checkpoint(
|
||||
recipe_scanner=scanner, recipe_id="r1", target_name="sdxl/realistic"
|
||||
)
|
||||
|
||||
# No candidate at all
|
||||
scanner = write_recipe("SDXL 1.0")
|
||||
with pytest.raises(RecipeNotFoundError, match="not found"):
|
||||
await service.reconnect_checkpoint(
|
||||
recipe_scanner=scanner, recipe_id="r1", target_name="missing"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconnect_checkpoint_family_compatible_succeeds_with_warning(tmp_path):
|
||||
service = _make_persistence_service()
|
||||
|
||||
pony_item = {
|
||||
"file_name": "main.safetensors",
|
||||
"folder": "",
|
||||
"file_path": "/models/checkpoints/main.safetensors",
|
||||
"base_model": "Pony",
|
||||
"sha256": "ab" * 32,
|
||||
}
|
||||
|
||||
recipe_path = tmp_path / "recipe.json"
|
||||
recipe_path.write_text(
|
||||
json.dumps({"id": "r1", "base_model": "Illustrious", "checkpoint": {}})
|
||||
)
|
||||
|
||||
class DummyScanner:
|
||||
async def get_recipe_json_path(self, recipe_id):
|
||||
return str(recipe_path)
|
||||
|
||||
async def find_local_checkpoints_by_name(self, name, base_model=None):
|
||||
return [pony_item]
|
||||
|
||||
async def update_checkpoint_entry(self, recipe_id, *, target_name, target_checkpoint):
|
||||
assert target_checkpoint is pony_item
|
||||
return ({"id": "r1"}, {"file_name": target_checkpoint["file_name"]})
|
||||
|
||||
async def find_recipes_by_fingerprint(self, fingerprint):
|
||||
return []
|
||||
|
||||
result = await service.reconnect_checkpoint(
|
||||
recipe_scanner=DummyScanner(), recipe_id="r1", target_name="main"
|
||||
)
|
||||
|
||||
assert result.payload["success"] is True
|
||||
assert result.payload["base_model_mismatch"] == {
|
||||
"recipe_base_model": "Illustrious",
|
||||
"checkpoint_base_model": "Pony",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconnect_checkpoint_exact_base_model_has_no_warning(tmp_path):
|
||||
service = _make_persistence_service()
|
||||
|
||||
item = {
|
||||
"file_name": "main.safetensors",
|
||||
"folder": "",
|
||||
"file_path": "/models/checkpoints/main.safetensors",
|
||||
"base_model": "SDXL 1.0",
|
||||
"sha256": "ab" * 32,
|
||||
}
|
||||
|
||||
recipe_path = tmp_path / "recipe.json"
|
||||
recipe_path.write_text(
|
||||
json.dumps({"id": "r1", "base_model": "SDXL 1.0", "checkpoint": {}})
|
||||
)
|
||||
|
||||
class DummyScanner:
|
||||
async def get_recipe_json_path(self, recipe_id):
|
||||
return str(recipe_path)
|
||||
|
||||
async def find_local_checkpoints_by_name(self, name, base_model=None):
|
||||
return [item]
|
||||
|
||||
async def update_checkpoint_entry(self, recipe_id, *, target_name, target_checkpoint):
|
||||
return ({"id": "r1"}, {"file_name": target_checkpoint["file_name"]})
|
||||
|
||||
async def find_recipes_by_fingerprint(self, fingerprint):
|
||||
return []
|
||||
|
||||
result = await service.reconnect_checkpoint(
|
||||
recipe_scanner=DummyScanner(), recipe_id="r1", target_name="main"
|
||||
)
|
||||
|
||||
assert result.payload["success"] is True
|
||||
assert "base_model_mismatch" not in result.payload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_checkpoint_delegates_and_reports(tmp_path):
|
||||
service = _make_persistence_service()
|
||||
|
||||
class DummyScanner:
|
||||
async def restore_checkpoint_entry(self, recipe_id):
|
||||
assert recipe_id == "r1"
|
||||
return (
|
||||
{"id": "r1", "checkpoint": {"file_name": "old.safetensors"}},
|
||||
{"file_name": "old.safetensors"},
|
||||
)
|
||||
|
||||
async def find_recipes_by_fingerprint(self, fingerprint):
|
||||
return []
|
||||
|
||||
result = await service.restore_checkpoint(
|
||||
recipe_scanner=DummyScanner(), recipe_id="r1"
|
||||
)
|
||||
|
||||
assert result.payload["success"] is True
|
||||
assert result.payload["updated_checkpoint"]["file_name"] == "old.safetensors"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_checkpoint_reconnect_suggestions_loads_entry_and_delegates(tmp_path):
|
||||
service = _make_persistence_service()
|
||||
|
||||
recipe_path = tmp_path / "recipe.json"
|
||||
recipe_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"id": "r1",
|
||||
"base_model": "SD 1.5",
|
||||
"checkpoint": {"file_name": "old.safetensors", "hash": "aaa"},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
class DummyScanner:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def get_recipe_json_path(self, recipe_id):
|
||||
assert recipe_id == "r1"
|
||||
return str(recipe_path)
|
||||
|
||||
async def suggest_checkpoint_reconnect_candidates(
|
||||
self, *, entry, recipe_base_model, query=None, limit=5
|
||||
):
|
||||
self.calls.append(
|
||||
{
|
||||
"entry": entry,
|
||||
"recipe_base_model": recipe_base_model,
|
||||
"query": query,
|
||||
}
|
||||
)
|
||||
return [
|
||||
{
|
||||
"file_name": "new.safetensors",
|
||||
"score": 1.0,
|
||||
"match_reason": "same_hash",
|
||||
"target_name": "new",
|
||||
}
|
||||
]
|
||||
|
||||
scanner = DummyScanner()
|
||||
result = await service.get_checkpoint_reconnect_suggestions(
|
||||
recipe_scanner=scanner, recipe_id="r1", query="new"
|
||||
)
|
||||
|
||||
assert result.payload["success"] is True
|
||||
assert result.payload["suggestions"][0]["target_name"] == "new"
|
||||
assert scanner.calls == [
|
||||
{
|
||||
"entry": {"file_name": "old.safetensors", "hash": "aaa"},
|
||||
"recipe_base_model": "SD 1.5",
|
||||
"query": "new",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_checkpoint_reconnect_suggestions_validates_recipe(tmp_path):
|
||||
service = _make_persistence_service()
|
||||
|
||||
class MissingScanner:
|
||||
async def get_recipe_json_path(self, recipe_id):
|
||||
return str(tmp_path / "missing.json")
|
||||
|
||||
with pytest.raises(RecipeNotFoundError):
|
||||
await service.get_checkpoint_reconnect_suggestions(
|
||||
recipe_scanner=MissingScanner(), recipe_id="nope"
|
||||
)
|
||||
|
||||
recipe_path = tmp_path / "recipe.json"
|
||||
recipe_path.write_text(json.dumps({"id": "r1"}))
|
||||
|
||||
class EmptyScanner:
|
||||
async def get_recipe_json_path(self, recipe_id):
|
||||
return str(recipe_path)
|
||||
|
||||
with pytest.raises(RecipeValidationError, match="checkpoint"):
|
||||
await service.get_checkpoint_reconnect_suggestions(
|
||||
recipe_scanner=EmptyScanner(), recipe_id="r1"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_checkpoint_hash_invalid_delegates_and_reports(tmp_path):
|
||||
service = _make_persistence_service()
|
||||
|
||||
class DummyScanner:
|
||||
async def set_checkpoint_entry_hash_invalid(self, recipe_id, hash_invalid):
|
||||
assert recipe_id == "r1"
|
||||
assert hash_invalid is True
|
||||
return (
|
||||
{"id": "r1", "checkpoint": {"file_name": "m", "hashInvalid": True}},
|
||||
{"file_name": "m", "hashInvalid": True},
|
||||
)
|
||||
|
||||
result = await service.mark_checkpoint_hash_invalid(
|
||||
recipe_scanner=DummyScanner(), recipe_id="r1"
|
||||
)
|
||||
|
||||
assert result.payload["success"] is True
|
||||
assert result.payload["recipe_id"] == "r1"
|
||||
assert result.payload["hash_invalid"] is True
|
||||
assert result.payload["updated_checkpoint"]["hashInvalid"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_checkpoint_hash_invalid_can_clear_flag(tmp_path):
|
||||
service = _make_persistence_service()
|
||||
|
||||
class DummyScanner:
|
||||
async def set_checkpoint_entry_hash_invalid(self, recipe_id, hash_invalid):
|
||||
assert hash_invalid is False
|
||||
return (
|
||||
{"id": "r1", "checkpoint": {"file_name": "m", "hashInvalid": False}},
|
||||
{"file_name": "m", "hashInvalid": False},
|
||||
)
|
||||
|
||||
result = await service.mark_checkpoint_hash_invalid(
|
||||
recipe_scanner=DummyScanner(),
|
||||
recipe_id="r1",
|
||||
hash_invalid=False,
|
||||
)
|
||||
|
||||
assert result.payload["hash_invalid"] 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")
|
||||
|
||||
# Folder grouping takes precedence over cross-folder relevance:
|
||||
# the "detail" folder sorts before "flux" alphabetically.
|
||||
assert matching == [
|
||||
f"flux{os.sep}detail-model.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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -120,6 +120,76 @@ class TestPersistentRecipeCache:
|
||||
loaded = cache.load_cache()
|
||||
assert loaded is None
|
||||
|
||||
def test_import_info_roundtrip(self, temp_db_path, sample_recipes):
|
||||
"""import_info (import provenance + no-LoRA reason) survives the cache."""
|
||||
cache = PersistentRecipeCache(db_path=temp_db_path)
|
||||
|
||||
sample_recipes[0]["import_info"] = {
|
||||
"channel": "batch_import_url",
|
||||
"reason": "api_meta_no_lora_resources",
|
||||
"details": {"api_meta_keys": ["prompt"], "api_model_version_ids": 0},
|
||||
}
|
||||
cache.save_cache(sample_recipes)
|
||||
|
||||
loaded = cache.load_cache()
|
||||
assert loaded is not None
|
||||
r1 = next(r for r in loaded.raw_data if r["id"] == "recipe-001")
|
||||
assert r1["import_info"]["channel"] == "batch_import_url"
|
||||
assert r1["import_info"]["reason"] == "api_meta_no_lora_resources"
|
||||
assert r1["import_info"]["details"]["api_meta_keys"] == ["prompt"]
|
||||
|
||||
# Recipes without import_info simply omit the key.
|
||||
r2 = next(r for r in loaded.raw_data if r["id"] == "recipe-002")
|
||||
assert "import_info" not in r2
|
||||
|
||||
def test_import_info_column_migration(self, temp_db_path, sample_recipes):
|
||||
"""Existing databases gain the import_info_json column via ALTER TABLE."""
|
||||
import sqlite3
|
||||
|
||||
# Simulate a legacy database without the new column.
|
||||
conn = sqlite3.connect(temp_db_path)
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE recipes (
|
||||
recipe_id TEXT PRIMARY KEY,
|
||||
file_path TEXT,
|
||||
json_path TEXT,
|
||||
title TEXT,
|
||||
folder TEXT,
|
||||
source_path TEXT,
|
||||
base_model TEXT,
|
||||
fingerprint TEXT,
|
||||
created_date REAL,
|
||||
modified REAL,
|
||||
file_mtime REAL,
|
||||
file_size INTEGER,
|
||||
favorite INTEGER DEFAULT 0,
|
||||
repair_version INTEGER DEFAULT 0,
|
||||
preview_nsfw_level INTEGER DEFAULT 0,
|
||||
loras_json TEXT,
|
||||
checkpoint_json TEXT,
|
||||
gen_params_json TEXT,
|
||||
tags_json TEXT,
|
||||
has_workflow INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE TABLE cache_metadata (key TEXT PRIMARY KEY, value TEXT);
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
cache = PersistentRecipeCache(db_path=temp_db_path)
|
||||
cache.save_cache(sample_recipes)
|
||||
|
||||
conn = sqlite3.connect(temp_db_path)
|
||||
columns = {row[1] for row in conn.execute("PRAGMA table_info(recipes)")}
|
||||
conn.close()
|
||||
assert "import_info_json" in columns
|
||||
|
||||
loaded = cache.load_cache()
|
||||
assert loaded is not None
|
||||
assert len(loaded.raw_data) == 2
|
||||
|
||||
def test_update_single_recipe(self, temp_db_path, sample_recipes):
|
||||
"""Test updating a single recipe."""
|
||||
cache = PersistentRecipeCache(db_path=temp_db_path)
|
||||
@@ -595,15 +665,19 @@ class TestHasWorkflowColumn:
|
||||
assert by_id["wf-3"]["has_workflow"] is False
|
||||
|
||||
def test_prepare_recipe_row_matches_column_order(self, temp_db_path):
|
||||
"""The prepared row must append has_workflow in column order."""
|
||||
"""The prepared row must append has_workflow/import_info in column order."""
|
||||
cache = PersistentRecipeCache(db_path=temp_db_path)
|
||||
row_true = cache._prepare_recipe_row({"id": "r1", "has_workflow": True}, "")
|
||||
row_false = cache._prepare_recipe_row({"id": "r2", "has_workflow": False}, "")
|
||||
|
||||
assert row_true[-1] == 1
|
||||
assert row_false[-1] == 0
|
||||
assert row_true[-2] == 1
|
||||
assert row_false[-2] == 0
|
||||
# import_info_json is the trailing column, unset by default.
|
||||
assert row_true[-1] is None
|
||||
assert row_false[-1] is None
|
||||
assert len(row_true) == len(cache._RECIPE_COLUMNS)
|
||||
assert cache._RECIPE_COLUMNS[-1] == "has_workflow"
|
||||
assert cache._RECIPE_COLUMNS[-2] == "has_workflow"
|
||||
assert cache._RECIPE_COLUMNS[-1] == "import_info_json"
|
||||
|
||||
def test_update_recipe_preserves_has_workflow(self, temp_db_path):
|
||||
"""update_recipe() must write the has_workflow column correctly."""
|
||||
|
||||
@@ -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)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user