Compare commits

...

11 Commits

Author SHA1 Message Date
Will Miao cc9d3bff42 fix(download): accept HuggingFace blob URLs in download dialog
detectUrlType only matched /resolve/ links, so pasting a HF web preview
(/blob/) URL fell through to direct-http and surfaced a misleading
'Invalid CivitAI URL format' error. Treat blob URLs as hf-resolve.
2026-09-09 11:47:17 +08:00
Will Miao 2672b3331b i18n: translate rematch summary modal strings into 9 locales 2026-09-09 11:07:42 +08:00
Will Miao 4963bf2b2e feat(recipes): show a summary modal after rematch runs
Replace the post-run toast cascade and the standalone L4 results modal
with a summary modal modeled on the batch download summary: 3-state
header, stat cards (matched / needs review / unresolved / errors),
an L4 review table with per-entry undo, and a copyable report. Wired
into the global, bulk and single-recipe rematch entries; complete
no-op runs keep the lightweight toast. Obsolete results-modal code,
styles and i18n keys are removed.
2026-09-09 10:38:10 +08:00
Will Miao 51cad6f852 i18n: translate recipe rematch options/results strings into 9 locales 2026-09-09 07:05:25 +08:00
Will Miao 1b5cbbbaa0 feat(recipes): add reconnect remediation paths for missing recipe LoRAs
- Snapshot pre-rematch entry state (reconnectSnapshot) so rematched
  entries can be undone via the existing restore flow
- Bulk missing-LoRA downloads mark unresolvable failures hash-invalid,
  flipping those entries from download to reconnect candidacy
- Recipe modal always offers a reconnect action next to download for
  missing LoRA entries
- Rematch runs collect an opt-in relaxed-matching choice (also reconnect
  missing models by file name) via a pre-run options dialog on the
  global, bulk and single-recipe entries
- L4 (filename-level) matches are listed in a results dialog with
  per-entry undo
2026-09-09 06:59:54 +08:00
Will Miao e747946f7a fix(onboarding): keep folder sidebar fixed-positioned during tutorial highlight
The .onboarding-target-highlight class sets position: relative, which
overrode .folder-sidebar's position: fixed (equal specificity, later
stylesheet). The sidebar left fixed positioning and moved in-flow, while
the spotlight/mask cutout stayed at the pre-highlight rect, leaving an
empty highlighted region during the folder sidebar step.
2026-09-07 19:32:58 +08:00
Will Miao 53fa22f39c fix(lora-loader): preserve repeated spaces inside lora names
Whitespace cleanup in cleanupLoraSyntax() and the autocomplete blur
formatter collapsed all whitespace runs, including inside <lora:...>
tags. A file named 'test -  0021.safetensors' was rewritten to
'test - 0021' in the node text, so runtime file resolution failed.

Protect lora tags with placeholders (or segment splitting) so only
whitespace between entries is normalized; names inside tags are kept
byte-for-byte.
2026-09-07 19:20:15 +08:00
Will Miao 82b34097fb refactor(metadata): remove vestigial top-level trainedWords field
The field dates back to a development-stage bug in the enrich-metadata
(agent) pipeline, which briefly wrote trigger words at the top level of
model metadata instead of the established civitai.trainedWords location.
The write path was fixed before the feature merged to main (PR #1013)
and never shipped in any release, so no writer has existed since.

Remove the leftover pieces:

- BaseModelMetadata.trainedWords field (py/utils/models.py); sidecars
  from that dev window now pass the key through _unknown_fields instead
- HF download handler's strip-empty-trainedWords special case, reverting
  to saving the metadata object directly (py/routes/handlers/hf_handlers.py)
- trainedWords in the LLM enrichment context (agent_service.py)
- matching fallbacks/fixtures in the enrich_hf_validation harness and
  post-processor test

Trigger words continue to live in civitai.trainedWords for all model
sources, which is what the UI, agent post-processor, and metadata sync
all read and write.
2026-09-07 16:24:15 +08:00
Will Miao a7995db009 fix(llm): add failure cooldown and lock for model catalog fetch 2026-09-07 10:17:48 +08:00
Will Miao 5ae4aef30e fix(llm): disable brotli for catalog fetch to prevent native crash (#1099, #1101)
models.dev is served by Cloudflare with brotli compression when the
client advertises it, and brotli is a required dependency here, so
aiohttp always negotiates br. A corrupted br stream can crash the
native decoder with a Windows access violation (a Python-level
exception handler cannot catch it), or produce garbage bytes.

Send an explicit "Accept-Encoding: gzip, deflate" header on the model
catalog and Ollama model-list requests so the server never returns
brotli. zlib handles corrupt gzip data by raising ContentEncodingError
(an aiohttp.ClientError subclass), which the existing handlers already
catch and degrade to a warning with an empty-catalog fallback.
2026-09-07 09:54:28 +08:00
willmiao 08023f0cd9 docs: auto-update supporters list in README 2026-09-06 14:29:39 +00:00
51 changed files with 3060 additions and 367 deletions
+2 -2
View File
File diff suppressed because one or more lines are too long
+36 -7
View File
@@ -216,9 +216,6 @@
"label": "Rezepte lokalen Modellen neu zuordnen", "label": "Rezepte lokalen Modellen neu zuordnen",
"loading": "Rezepte werden lokalen Modellen neu zugeordnet...", "loading": "Rezepte werden lokalen Modellen neu zugeordnet...",
"success": "{entries} Einträge in {recipes} Rezepten zugeordnet", "success": "{entries} Einträge in {recipes} Rezepten zugeordnet",
"successErrors": "{entries} Einträge in {recipes} Rezepten zugeordnet, {failures} fehlgeschlagen",
"allFailed": "Zuordnung fehlgeschlagen für {failures} von {total} Rezepten",
"noMatch": "Keine lokale Übereinstimmung für {entries} Einträge in {recipes} Rezepten gefunden",
"cancelled": "Zuordnung abgebrochen. {recipes} Rezepte aktualisiert ({entries} Einträge)", "cancelled": "Zuordnung abgebrochen. {recipes} Rezepte aktualisiert ({entries} Einträge)",
"error": "Zuordnung der Rezepte fehlgeschlagen: {message}" "error": "Zuordnung der Rezepte fehlgeschlagen: {message}"
}, },
@@ -1501,6 +1498,41 @@
"note": "Dateien werden mit Standard-Pfad-Vorlagen heruntergeladen. Dies kann je nach Anzahl der LoRAs eine Weile dauern.", "note": "Dateien werden mit Standard-Pfad-Vorlagen heruntergeladen. Dies kann je nach Anzahl der LoRAs eine Weile dauern.",
"downloadButton": "{count} LoRA(s) herunterladen" "downloadButton": "{count} LoRA(s) herunterladen"
}, },
"rematchOptions": {
"title": "Rezepte neu zuordnen",
"messageGlobal": "Alle Rezepte werden mit Ihrer lokalen Modellbibliothek abgeglichen.",
"messageSingle": "Dieses Rezept wird mit Ihrer lokalen Modellbibliothek abgeglichen.",
"messageBulk": "{count} ausgewählte Rezepte werden mit Ihrer lokalen Modellbibliothek abgeglichen.",
"relaxedLabel": "Fehlende Modelle auch per Dateiname neu verbinden",
"relaxedDescription": "Diese Modelle könnten auch per Download behoben werden — der Download ist genauer. Übereinstimmungen verknüpfen möglicherweise eine andere Version; sie werden zur Überprüfung aufgelistet und können rückgängig gemacht werden.",
"confirmButton": "Neu zuordnen"
},
"rematchResults": {
"undo": "Rückgängig",
"undone": "Rückgängig gemacht",
"undoFailed": "Rückgängigmachen der Neuordnung fehlgeschlagen: {message}"
},
"rematchSummary": {
"title": "Zusammenfassung der Neuordnung",
"successMessage": "{entries} Einträge zugeordnet",
"failed": "Neuordnung fehlgeschlagen",
"completedWithWarnings": "Neuordnung abgeschlossen — Überprüfung empfohlen",
"cancelledNote": "Der Vorgang wurde vorzeitig abgebrochen — die Zahlen sind unvollständig.",
"statMatched": "Zugeordnete Einträge",
"statReview": "Zu überprüfen",
"statUnresolved": "Nicht zugeordnet",
"statErrors": "Fehler",
"reviewSection": "Dateinamen-Übereinstimmungen zur Überprüfung ({count})",
"columnRecipe": "Rezept",
"columnEntry": "Eintrag",
"columnFile": "Zugeordnete Datei",
"columnUndo": "Rückgängig",
"copyReport": "Bericht kopieren",
"close": "Schließen",
"scope_global": "Alle Rezepte",
"scope_bulk": "Ausgewählte Rezepte",
"scope_single": "Einzelnes Rezept"
},
"exampleAccess": { "exampleAccess": {
"title": "Lokale Beispielbilder", "title": "Lokale Beispielbilder",
"message": "Keine lokalen Beispielbilder für dieses Modell gefunden. Ansichtsoptionen:", "message": "Keine lokalen Beispielbilder für dieses Modell gefunden. Ansichtsoptionen:",
@@ -2168,6 +2200,7 @@
"createMissingData": "Erforderliche Daten zum Erstellen des Rezepts fehlen", "createMissingData": "Erforderliche Daten zum Erstellen des Rezepts fehlen",
"created": "Rezept erfolgreich erstellt", "created": "Rezept erfolgreich erstellt",
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen", "noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen",
"unresolvableMarkedForReconnect": "{count} nicht auflösbare Einträge markiert — sie können jetzt mit einem lokalen LoRA neu verbunden werden.",
"noPreviousRecipe": "Kein vorheriges Rezept verfügbar", "noPreviousRecipe": "Kein vorheriges Rezept verfügbar",
"noNextRecipe": "Kein weiteres Rezept verfügbar", "noNextRecipe": "Kein weiteres Rezept verfügbar",
"missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs", "missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
@@ -2222,10 +2255,6 @@
"batchImportBrowseFailed": "Ordner konnte nicht durchsucht werden: {message}", "batchImportBrowseFailed": "Ordner konnte nicht durchsucht werden: {message}",
"batchImportDirectorySelected": "Verzeichnis ausgewählt: {path}", "batchImportDirectorySelected": "Verzeichnis ausgewählt: {path}",
"noRecipesSelected": "Keine Rezepte ausgewählt", "noRecipesSelected": "Keine Rezepte ausgewählt",
"rematchComplete": "{entries} Einträge in {recipes} Rezepten zugeordnet",
"rematchCompleteErrors": "{entries} Einträge in {recipes} Rezepten zugeordnet, {failures} fehlgeschlagen",
"rematchAllFailed": "Zuordnung fehlgeschlagen für {failures} von {total} ausgewählten Rezepten",
"rematchUnmatched": "Keine lokale Übereinstimmung für {entries} Einträge in {recipes} Rezepten gefunden",
"rematchSkipped": "Keine Zuordnung für die {total} ausgewählten Rezepte erforderlich", "rematchSkipped": "Keine Zuordnung für die {total} ausgewählten Rezepte erforderlich",
"rematchFailed": "Zuordnung der ausgewählten Rezepte fehlgeschlagen: {message}", "rematchFailed": "Zuordnung der ausgewählten Rezepte fehlgeschlagen: {message}",
"reimporting": "Rezept wird aus Quelle neu importiert...", "reimporting": "Rezept wird aus Quelle neu importiert...",
+36 -7
View File
@@ -216,9 +216,6 @@
"label": "Rematch recipes to local models", "label": "Rematch recipes to local models",
"loading": "Rematching recipes to local models...", "loading": "Rematching recipes to local models...",
"success": "Matched {entries} entries across {recipes} recipes", "success": "Matched {entries} entries across {recipes} recipes",
"successErrors": "Matched {entries} entries across {recipes} recipes, {failures} failed",
"allFailed": "Rematch failed for {failures} of {total} recipes",
"noMatch": "No local match found for {entries} entries in {recipes} recipes",
"cancelled": "Rematch cancelled. {recipes} recipes updated ({entries} entries).", "cancelled": "Rematch cancelled. {recipes} recipes updated ({entries} entries).",
"error": "Recipe rematch failed: {message}" "error": "Recipe rematch failed: {message}"
}, },
@@ -1501,6 +1498,41 @@
"note": "Files will be downloaded using default path templates. This may take a while depending on the number of LoRAs.", "note": "Files will be downloaded using default path templates. This may take a while depending on the number of LoRAs.",
"downloadButton": "Download {count} LoRA(s)" "downloadButton": "Download {count} LoRA(s)"
}, },
"rematchOptions": {
"title": "Rematch Recipes",
"messageGlobal": "All recipes will be scanned against your local model library.",
"messageSingle": "This recipe will be scanned against your local model library.",
"messageBulk": "{count} selected recipe(s) will be scanned against your local model library.",
"relaxedLabel": "Also reconnect missing models by file name",
"relaxedDescription": "These models could also be fixed by downloading — download is more accurate. Matches may link a different version; they'll be listed for review and can be undone.",
"confirmButton": "Rematch"
},
"rematchResults": {
"undo": "Undo",
"undone": "Undone",
"undoFailed": "Failed to undo rematch: {message}"
},
"rematchSummary": {
"title": "Rematch Summary",
"successMessage": "Matched {entries} entries",
"failed": "Rematch failed",
"completedWithWarnings": "Rematch completed — review recommended",
"cancelledNote": "Run cancelled before completion — counts are partial.",
"statMatched": "Matched entries",
"statReview": "Needs review",
"statUnresolved": "Unresolved",
"statErrors": "Errors",
"reviewSection": "Filename matches to review ({count})",
"columnRecipe": "Recipe",
"columnEntry": "Entry",
"columnFile": "Matched file",
"columnUndo": "Undo",
"copyReport": "Copy Report",
"close": "Close",
"scope_global": "All recipes",
"scope_bulk": "Selected recipes",
"scope_single": "Single recipe"
},
"exampleAccess": { "exampleAccess": {
"title": "Local Example Images", "title": "Local Example Images",
"message": "No local example images found for this model. View options:", "message": "No local example images found for this model. View options:",
@@ -2168,6 +2200,7 @@
"createMissingData": "Missing required data to create recipe", "createMissingData": "Missing required data to create recipe",
"created": "Recipe created successfully", "created": "Recipe created successfully",
"noMissingLoras": "No missing LoRAs to download", "noMissingLoras": "No missing LoRAs to download",
"unresolvableMarkedForReconnect": "{count} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.",
"noPreviousRecipe": "No previous recipe available", "noPreviousRecipe": "No previous recipe available",
"noNextRecipe": "No next recipe available", "noNextRecipe": "No next recipe available",
"missingLorasInfoFailed": "Failed to get information for missing LoRAs", "missingLorasInfoFailed": "Failed to get information for missing LoRAs",
@@ -2222,10 +2255,6 @@
"batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}", "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "No recipes selected", "noRecipesSelected": "No recipes selected",
"rematchComplete": "Matched {entries} entries across {recipes} recipes",
"rematchCompleteErrors": "Matched {entries} entries across {recipes} recipes, {failures} failed",
"rematchAllFailed": "Rematch failed for {failures} of {total} selected recipes",
"rematchUnmatched": "No local match found for {entries} entries in {recipes} recipes",
"rematchSkipped": "No rematch needed for any of the {total} selected recipes", "rematchSkipped": "No rematch needed for any of the {total} selected recipes",
"rematchFailed": "Failed to rematch selected recipes: {message}", "rematchFailed": "Failed to rematch selected recipes: {message}",
"reimporting": "Re-importing recipe from source...", "reimporting": "Re-importing recipe from source...",
+36 -7
View File
@@ -216,9 +216,6 @@
"label": "Reasociar recetas con modelos locales", "label": "Reasociar recetas con modelos locales",
"loading": "Reasociando recetas con modelos locales...", "loading": "Reasociando recetas con modelos locales...",
"success": "{entries} entradas asociadas en {recipes} recetas", "success": "{entries} entradas asociadas en {recipes} recetas",
"successErrors": "{entries} entradas asociadas en {recipes} recetas, {failures} fallidas",
"allFailed": "Falló la reasociación de {failures} de {total} recetas",
"noMatch": "No se encontró coincidencia local para {entries} entradas en {recipes} recetas",
"cancelled": "Reasociación cancelada. {recipes} recetas actualizadas ({entries} entradas)", "cancelled": "Reasociación cancelada. {recipes} recetas actualizadas ({entries} entradas)",
"error": "Falló la reasociación de recetas: {message}" "error": "Falló la reasociación de recetas: {message}"
}, },
@@ -1501,6 +1498,41 @@
"note": "Los archivos se descargarán usando las plantillas de ruta predeterminadas. Esto puede tomar un tiempo dependiendo del número de LoRAs.", "note": "Los archivos se descargarán usando las plantillas de ruta predeterminadas. Esto puede tomar un tiempo dependiendo del número de LoRAs.",
"downloadButton": "Descargar {count} LoRA(s)" "downloadButton": "Descargar {count} LoRA(s)"
}, },
"rematchOptions": {
"title": "Reasociar recetas",
"messageGlobal": "Se escanearán todas las recetas contra tu biblioteca local de modelos.",
"messageSingle": "Se escaneará esta receta contra tu biblioteca local de modelos.",
"messageBulk": "Se escanearán {count} receta(s) seleccionada(s) contra tu biblioteca local de modelos.",
"relaxedLabel": "Reconectar también los modelos faltantes por nombre de archivo",
"relaxedDescription": "Estos modelos también se pueden corregir descargándolos; la descarga es más precisa. Las coincidencias pueden enlazar una versión diferente; se listarán para su revisión y se pueden deshacer.",
"confirmButton": "Reasociar"
},
"rematchResults": {
"undo": "Deshacer",
"undone": "Deshecho",
"undoFailed": "No se pudo deshacer la reasociación: {message}"
},
"rematchSummary": {
"title": "Resumen de la reasociación",
"successMessage": "{entries} entradas asociadas",
"failed": "Falló la reasociación",
"completedWithWarnings": "Reasociación completada — se recomienda revisar",
"cancelledNote": "Ejecución cancelada antes de completarse — los recuentos son parciales.",
"statMatched": "Entradas asociadas",
"statReview": "Por revisar",
"statUnresolved": "Sin coincidencia",
"statErrors": "Errores",
"reviewSection": "Coincidencias por nombre de archivo para revisar ({count})",
"columnRecipe": "Receta",
"columnEntry": "Entrada",
"columnFile": "Archivo coincidente",
"columnUndo": "Deshacer",
"copyReport": "Copiar informe",
"close": "Cerrar",
"scope_global": "Todas las recetas",
"scope_bulk": "Recetas seleccionadas",
"scope_single": "Receta individual"
},
"exampleAccess": { "exampleAccess": {
"title": "Imágenes de ejemplo locales", "title": "Imágenes de ejemplo locales",
"message": "No se encontraron imágenes de ejemplo locales para este modelo. Opciones de visualización:", "message": "No se encontraron imágenes de ejemplo locales para este modelo. Opciones de visualización:",
@@ -2168,6 +2200,7 @@
"createMissingData": "Faltan datos necesarios para crear la receta", "createMissingData": "Faltan datos necesarios para crear la receta",
"created": "Receta creada exitosamente", "created": "Receta creada exitosamente",
"noMissingLoras": "No hay LoRAs faltantes para descargar", "noMissingLoras": "No hay LoRAs faltantes para descargar",
"unresolvableMarkedForReconnect": "Se marcaron {count} entrada(s) no resoluble(s) — ahora se pueden reconectar a un LoRA local.",
"noPreviousRecipe": "No hay receta anterior disponible", "noPreviousRecipe": "No hay receta anterior disponible",
"noNextRecipe": "No hay siguiente receta disponible", "noNextRecipe": "No hay siguiente receta disponible",
"missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes", "missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes",
@@ -2222,10 +2255,6 @@
"batchImportBrowseFailed": "No se pudo examinar el directorio: {message}", "batchImportBrowseFailed": "No se pudo examinar el directorio: {message}",
"batchImportDirectorySelected": "Directorio seleccionado: {path}", "batchImportDirectorySelected": "Directorio seleccionado: {path}",
"noRecipesSelected": "No se han seleccionado recetas", "noRecipesSelected": "No se han seleccionado recetas",
"rematchComplete": "{entries} entradas asociadas en {recipes} recetas",
"rematchCompleteErrors": "{entries} entradas asociadas en {recipes} recetas, {failures} fallidas",
"rematchAllFailed": "Falló la reasociación de {failures} de {total} recetas seleccionadas",
"rematchUnmatched": "No se encontró coincidencia local para {entries} entradas en {recipes} recetas",
"rematchSkipped": "Ninguna de las {total} recetas seleccionadas necesita reasociación", "rematchSkipped": "Ninguna de las {total} recetas seleccionadas necesita reasociación",
"rematchFailed": "Falló la reasociación de las recetas seleccionadas: {message}", "rematchFailed": "Falló la reasociación de las recetas seleccionadas: {message}",
"reimporting": "Reimportando receta desde origen...", "reimporting": "Reimportando receta desde origen...",
+36 -7
View File
@@ -216,9 +216,6 @@
"label": "Réassocier les Recipes aux modèles locaux", "label": "Réassocier les Recipes aux modèles locaux",
"loading": "Réassociation des Recipes aux modèles locaux...", "loading": "Réassociation des Recipes aux modèles locaux...",
"success": "{entries} entrées associées dans {recipes} Recipes", "success": "{entries} entrées associées dans {recipes} Recipes",
"successErrors": "{entries} entrées associées dans {recipes} Recipes, {failures} échecs",
"allFailed": "Échec de la réassociation de {failures} Recipes sur {total}",
"noMatch": "Aucune correspondance locale trouvée pour {entries} entrées dans {recipes} Recipes",
"cancelled": "Réassociation annulée. {recipes} Recipes mises à jour ({entries} entrées)", "cancelled": "Réassociation annulée. {recipes} Recipes mises à jour ({entries} entrées)",
"error": "Échec de la réassociation des Recipes : {message}" "error": "Échec de la réassociation des Recipes : {message}"
}, },
@@ -1501,6 +1498,41 @@
"note": "Les fichiers seront téléchargés en utilisant les modèles de chemins par défaut. Cela peut prendre un certain temps selon le nombre de LoRAs.", "note": "Les fichiers seront téléchargés en utilisant les modèles de chemins par défaut. Cela peut prendre un certain temps selon le nombre de LoRAs.",
"downloadButton": "Télécharger {count} LoRA(s)" "downloadButton": "Télécharger {count} LoRA(s)"
}, },
"rematchOptions": {
"title": "Réassocier les Recipes",
"messageGlobal": "Toutes les Recipes seront analysées par rapport à votre bibliothèque de modèles locale.",
"messageSingle": "Cette Recipe sera analysée par rapport à votre bibliothèque de modèles locale.",
"messageBulk": "{count} Recipes sélectionnées seront analysées par rapport à votre bibliothèque de modèles locale.",
"relaxedLabel": "Reconnecter aussi les modèles manquants par nom de fichier",
"relaxedDescription": "Ces modèles peuvent aussi être corrigés par téléchargement — le téléchargement est plus précis. Les correspondances peuvent associer une version différente ; elles seront listées pour vérification et peuvent être annulées.",
"confirmButton": "Réassocier"
},
"rematchResults": {
"undo": "Annuler",
"undone": "Annulé",
"undoFailed": "Échec de l'annulation de la réassociation : {message}"
},
"rematchSummary": {
"title": "Résumé de la réassociation",
"successMessage": "{entries} entrées associées",
"failed": "Échec de la réassociation",
"completedWithWarnings": "Réassociation terminée — vérification recommandée",
"cancelledNote": "Exécution annulée avant la fin — les décomptes sont partiels.",
"statMatched": "Entrées associées",
"statReview": "À vérifier",
"statUnresolved": "Sans correspondance",
"statErrors": "Erreurs",
"reviewSection": "Correspondances par nom de fichier à vérifier ({count})",
"columnRecipe": "Recipe",
"columnEntry": "Entrée",
"columnFile": "Fichier correspondant",
"columnUndo": "Annuler",
"copyReport": "Copier le rapport",
"close": "Fermer",
"scope_global": "Toutes les Recipes",
"scope_bulk": "Recipes sélectionnées",
"scope_single": "Une seule Recipe"
},
"exampleAccess": { "exampleAccess": {
"title": "Images d'exemple locales", "title": "Images d'exemple locales",
"message": "Aucune image d'exemple locale trouvée pour ce modèle. Options d'affichage :", "message": "Aucune image d'exemple locale trouvée pour ce modèle. Options d'affichage :",
@@ -2168,6 +2200,7 @@
"createMissingData": "Données requises manquantes pour créer le Recipe", "createMissingData": "Données requises manquantes pour créer le Recipe",
"created": "Recipe créé avec succès", "created": "Recipe créé avec succès",
"noMissingLoras": "Aucun LoRA manquant à télécharger", "noMissingLoras": "Aucun LoRA manquant à télécharger",
"unresolvableMarkedForReconnect": "{count} entrées irrésolubles marquées — elles peuvent maintenant être reconnectées à un LoRA local.",
"noPreviousRecipe": "Aucune Recipe précédente", "noPreviousRecipe": "Aucune Recipe précédente",
"noNextRecipe": "Aucune Recipe suivante", "noNextRecipe": "Aucune Recipe suivante",
"missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants", "missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
@@ -2222,10 +2255,6 @@
"batchImportBrowseFailed": "Échec de la navigation dans le dossier : {message}", "batchImportBrowseFailed": "Échec de la navigation dans le dossier : {message}",
"batchImportDirectorySelected": "Dossier sélectionné : {path}", "batchImportDirectorySelected": "Dossier sélectionné : {path}",
"noRecipesSelected": "Aucune Recipe sélectionnée", "noRecipesSelected": "Aucune Recipe sélectionnée",
"rematchComplete": "{entries} entrées associées dans {recipes} Recipes",
"rematchCompleteErrors": "{entries} entrées associées dans {recipes} Recipes, {failures} échecs",
"rematchAllFailed": "Échec de la réassociation de {failures} Recipes sélectionnées sur {total}",
"rematchUnmatched": "Aucune correspondance locale trouvée pour {entries} entrées dans {recipes} Recipes",
"rematchSkipped": "Aucune des {total} Recipes sélectionnées ne nécessite de réassociation", "rematchSkipped": "Aucune des {total} Recipes sélectionnées ne nécessite de réassociation",
"rematchFailed": "Échec de la réassociation des Recipes sélectionnées : {message}", "rematchFailed": "Échec de la réassociation des Recipes sélectionnées : {message}",
"reimporting": "Ré-import de la Recipe depuis la source...", "reimporting": "Ré-import de la Recipe depuis la source...",
+36 -7
View File
@@ -216,9 +216,6 @@
"label": "התאמה מחדש של מתכונים למודלים מקומיים", "label": "התאמה מחדש של מתכונים למודלים מקומיים",
"loading": "מתבצעת התאמה מחדש של מתכונים למודלים מקומיים...", "loading": "מתבצעת התאמה מחדש של מתכונים למודלים מקומיים...",
"success": "הותאמו {entries} פריטים ב־{recipes} מתכונים", "success": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
"successErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו",
"allFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים",
"noMatch": "לא נמצאה התאמה מקומית עבור {entries} פריטים ב־{recipes} מתכונים",
"cancelled": "ההתאמה בוטלה. עודכנו {recipes} מתכונים ({entries} פריטים)", "cancelled": "ההתאמה בוטלה. עודכנו {recipes} מתכונים ({entries} פריטים)",
"error": "ההתאמה מחדש של המתכונים נכשלה: {message}" "error": "ההתאמה מחדש של המתכונים נכשלה: {message}"
}, },
@@ -1501,6 +1498,41 @@
"note": "הקבצים יורדו באמצעות תבניות נתיב ברירת מחדל. זה עשוי לקחת זמן בהתאם למספר ה-LoRAs.", "note": "הקבצים יורדו באמצעות תבניות נתיב ברירת מחדל. זה עשוי לקחת זמן בהתאם למספר ה-LoRAs.",
"downloadButton": "הורד {count} LoRA(s)" "downloadButton": "הורד {count} LoRA(s)"
}, },
"rematchOptions": {
"title": "התאמה מחדש של מתכונים",
"messageGlobal": "כל המתכונים ייסרקו מול ספריית המודלים המקומית שלך.",
"messageSingle": "מתכון זה ייסרק מול ספריית המודלים המקומית שלך.",
"messageBulk": "{count} מתכונים שנבחרו ייסרקו מול ספריית המודלים המקומית שלך.",
"relaxedLabel": "חבר מחדש גם מודלים חסרים לפי שם קובץ",
"relaxedDescription": "אפשר לתקן את המודלים האלה גם על ידי הורדה — ההורדה מדויקת יותר. ההתאמות עשויות לקשר לגרסה אחרת; הן יוצגו לסקירה וניתן לבטל אותן.",
"confirmButton": "התאם מחדש"
},
"rematchResults": {
"undo": "בטל",
"undone": "בוטל",
"undoFailed": "ביטול ההתאמה מחדש נכשל: {message}"
},
"rematchSummary": {
"title": "סיכום התאמה מחדש",
"successMessage": "הותאמו {entries} פריטים",
"failed": "ההתאמה מחדש נכשלה",
"completedWithWarnings": "ההתאמה מחדש הושלמה — מומלץ לסקור",
"cancelledNote": "ההתאמה בוטלה לפני שהסתיימה — המספרים חלקיים.",
"statMatched": "פריטים שהותאמו",
"statReview": "טעוני סקירה",
"statUnresolved": "ללא התאמה",
"statErrors": "שגיאות",
"reviewSection": "התאמות לפי שם קובץ לסקירה ({count})",
"columnRecipe": "מתכון",
"columnEntry": "פריט",
"columnFile": "הקובץ שהותאם",
"columnUndo": "בטל",
"copyReport": "העתק דוח",
"close": "סגור",
"scope_global": "כל המתכונים",
"scope_bulk": "מתכונים שנבחרו",
"scope_single": "מתכון בודד"
},
"exampleAccess": { "exampleAccess": {
"title": "תמונות דוגמה מקומיות", "title": "תמונות דוגמה מקומיות",
"message": "לא נמצאו תמונות דוגמה מקומיות למודל זה. אפשרויות צפייה:", "message": "לא נמצאו תמונות דוגמה מקומיות למודל זה. אפשרויות צפייה:",
@@ -2168,6 +2200,7 @@
"createMissingData": "חסרים נתונים נדרשים ליצירת המתכון", "createMissingData": "חסרים נתונים נדרשים ליצירת המתכון",
"created": "המתכון נוצר בהצלחה", "created": "המתכון נוצר בהצלחה",
"noMissingLoras": "אין LoRAs חסרים להורדה", "noMissingLoras": "אין LoRAs חסרים להורדה",
"unresolvableMarkedForReconnect": "{count} פריטים שלא ניתן לפתור סומנו — עכשיו ניתן לחבר אותם מחדש ל-LoRA מקומי.",
"noPreviousRecipe": "אין מתכון קודם זמין", "noPreviousRecipe": "אין מתכון קודם זמין",
"noNextRecipe": "אין מתכון נוסף זמין", "noNextRecipe": "אין מתכון נוסף זמין",
"missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה", "missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
@@ -2222,10 +2255,6 @@
"batchImportBrowseFailed": "לא ניתן היה לעיין בתיקייה: {message}", "batchImportBrowseFailed": "לא ניתן היה לעיין בתיקייה: {message}",
"batchImportDirectorySelected": "נבחרה תיקייה: {path}", "batchImportDirectorySelected": "נבחרה תיקייה: {path}",
"noRecipesSelected": "לא נבחרו מתכונים", "noRecipesSelected": "לא נבחרו מתכונים",
"rematchComplete": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
"rematchCompleteErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו",
"rematchAllFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים שנבחרו",
"rematchUnmatched": "לא נמצאה התאמה מקומית עבור {entries} פריטים ב־{recipes} מתכונים",
"rematchSkipped": "אין צורך בהתאמה עבור {total} המתכונים שנבחרו", "rematchSkipped": "אין צורך בהתאמה עבור {total} המתכונים שנבחרו",
"rematchFailed": "ההתאמה מחדש של המתכונים שנבחרו נכשלה: {message}", "rematchFailed": "ההתאמה מחדש של המתכונים שנבחרו נכשלה: {message}",
"reimporting": "מייבא מתכון מחדש מהמקור...", "reimporting": "מייבא מתכון מחדש מהמקור...",
+36 -7
View File
@@ -216,9 +216,6 @@
"label": "レシピをローカルモデルに再マッチング", "label": "レシピをローカルモデルに再マッチング",
"loading": "レシピをローカルモデルに再マッチングしています...", "loading": "レシピをローカルモデルに再マッチングしています...",
"success": "{recipes} 件のレシピで {entries} エントリをマッチングしました", "success": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
"successErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)",
"allFailed": "{total} 件中 {failures} 件のレシピの再マッチングに失敗しました",
"noMatch": "{recipes} 件のレシピで {entries} エントリのローカルマッチが見つかりませんでした",
"cancelled": "再マッチングをキャンセルしました。{recipes} 件のレシピを更新({entries} エントリ)", "cancelled": "再マッチングをキャンセルしました。{recipes} 件のレシピを更新({entries} エントリ)",
"error": "レシピの再マッチングに失敗しました:{message}" "error": "レシピの再マッチングに失敗しました:{message}"
}, },
@@ -1501,6 +1498,41 @@
"note": "ファイルはデフォルトのパステンプレートを使用してダウンロードされます。LoRA の数によっては時間がかかる場合があります。", "note": "ファイルはデフォルトのパステンプレートを使用してダウンロードされます。LoRA の数によっては時間がかかる場合があります。",
"downloadButton": "{count} 個の LoRA をダウンロード" "downloadButton": "{count} 個の LoRA をダウンロード"
}, },
"rematchOptions": {
"title": "レシピの再マッチング",
"messageGlobal": "すべてのレシピをローカルのモデルライブラリと照合します。",
"messageSingle": "このレシピをローカルのモデルライブラリと照合します。",
"messageBulk": "選択した {count} 件のレシピをローカルのモデルライブラリと照合します。",
"relaxedLabel": "見つからないモデルもファイル名で再接続する",
"relaxedDescription": "これらのモデルはダウンロードでも修正できます(ダウンロードの方が正確です)。マッチにより別バージョンが関連付けられる場合があります。マッチした項目は確認用に一覧表示され、元に戻すことができます。",
"confirmButton": "再マッチング"
},
"rematchResults": {
"undo": "元に戻す",
"undone": "元に戻しました",
"undoFailed": "再マッチングを元に戻せませんでした:{message}"
},
"rematchSummary": {
"title": "再マッチングの概要",
"successMessage": "{entries} エントリをマッチングしました",
"failed": "再マッチングに失敗しました",
"completedWithWarnings": "再マッチングは完了しましたが、要確認の項目があります",
"cancelledNote": "完了前に実行がキャンセルされたため、件数は一部のみです。",
"statMatched": "マッチしたエントリ",
"statReview": "要確認",
"statUnresolved": "マッチなし",
"statErrors": "エラー",
"reviewSection": "確認が必要なファイル名マッチ({count})",
"columnRecipe": "レシピ",
"columnEntry": "エントリ",
"columnFile": "マッチしたファイル",
"columnUndo": "元に戻す",
"copyReport": "レポートをコピー",
"close": "閉じる",
"scope_global": "すべてのレシピ",
"scope_bulk": "選択したレシピ",
"scope_single": "単一のレシピ"
},
"exampleAccess": { "exampleAccess": {
"title": "ローカル例画像", "title": "ローカル例画像",
"message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:", "message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:",
@@ -2168,6 +2200,7 @@
"createMissingData": "レシピ作成に必要なデータが不足しています", "createMissingData": "レシピ作成に必要なデータが不足しています",
"created": "レシピを作成しました", "created": "レシピを作成しました",
"noMissingLoras": "ダウンロードする不足LoRAがありません", "noMissingLoras": "ダウンロードする不足LoRAがありません",
"unresolvableMarkedForReconnect": "解決できないエントリを {count} 件マークしました — ローカルの LoRA に再接続できるようになりました。",
"noPreviousRecipe": "前のレシピがありません", "noPreviousRecipe": "前のレシピがありません",
"noNextRecipe": "次のレシピがありません", "noNextRecipe": "次のレシピがありません",
"missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました", "missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました",
@@ -2222,10 +2255,6 @@
"batchImportBrowseFailed": "フォルダを参照できませんでした: {message}", "batchImportBrowseFailed": "フォルダを参照できませんでした: {message}",
"batchImportDirectorySelected": "選択されたフォルダ: {path}", "batchImportDirectorySelected": "選択されたフォルダ: {path}",
"noRecipesSelected": "レシピが選択されていません", "noRecipesSelected": "レシピが選択されていません",
"rematchComplete": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
"rematchCompleteErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)",
"rematchAllFailed": "選択した {total} 件中 {failures} 件のレシピの再マッチングに失敗しました",
"rematchUnmatched": "{recipes} 件のレシピで {entries} エントリのローカルマッチが見つかりませんでした",
"rematchSkipped": "選択した {total} 件のレシピは再マッチングの必要がありませんでした", "rematchSkipped": "選択した {total} 件のレシピは再マッチングの必要がありませんでした",
"rematchFailed": "選択したレシピの再マッチングに失敗しました:{message}", "rematchFailed": "選択したレシピの再マッチングに失敗しました:{message}",
"reimporting": "ソースからレシピを再インポート中...", "reimporting": "ソースからレシピを再インポート中...",
+36 -7
View File
@@ -216,9 +216,6 @@
"label": "레시피를 로컬 모델에 다시 매칭", "label": "레시피를 로컬 모델에 다시 매칭",
"loading": "레시피를 로컬 모델에 다시 매칭하는 중...", "loading": "레시피를 로컬 모델에 다시 매칭하는 중...",
"success": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다", "success": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
"successErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패",
"allFailed": "{total}개 레시피 중 {failures}개 재매칭 실패",
"noMatch": "{recipes}개 레시피에서 {entries}개 항목의 로컬 매칭을 찾지 못했습니다",
"cancelled": "재매칭이 취소되었습니다. {recipes}개 레시피 업데이트됨({entries}개 항목)", "cancelled": "재매칭이 취소되었습니다. {recipes}개 레시피 업데이트됨({entries}개 항목)",
"error": "레시피 재매칭 실패: {message}" "error": "레시피 재매칭 실패: {message}"
}, },
@@ -1501,6 +1498,41 @@
"note": "파일은 기본 경로 템플릿을 사용하여 다운로드됩니다. LoRA의 수에 따라 다소 시간이 걸릴 수 있습니다.", "note": "파일은 기본 경로 템플릿을 사용하여 다운로드됩니다. LoRA의 수에 따라 다소 시간이 걸릴 수 있습니다.",
"downloadButton": "{count}개 LoRA 다운로드" "downloadButton": "{count}개 LoRA 다운로드"
}, },
"rematchOptions": {
"title": "레시피 재매칭",
"messageGlobal": "모든 레시피를 로컬 모델 라이브러리와 대조하여 검사합니다.",
"messageSingle": "이 레시피를 로컬 모델 라이브러리와 대조하여 검사합니다.",
"messageBulk": "선택한 레시피 {count}개를 로컬 모델 라이브러리와 대조하여 검사합니다.",
"relaxedLabel": "누락된 모델도 파일 이름으로 다시 연결",
"relaxedDescription": "이 모델들은 다운로드로도 해결할 수 있으며 다운로드가 더 정확합니다. 매칭 시 모델의 다른 버전이 연결될 수 있으며, 검토용으로 목록에 표시되고 실행 취소할 수 있습니다.",
"confirmButton": "재매칭"
},
"rematchResults": {
"undo": "실행 취소",
"undone": "실행 취소됨",
"undoFailed": "재매칭 실행 취소 실패: {message}"
},
"rematchSummary": {
"title": "재매칭 요약",
"successMessage": "{entries}개 항목이 매칭되었습니다",
"failed": "재매칭 실패",
"completedWithWarnings": "재매칭이 완료되었습니다 — 검토가 권장됩니다",
"cancelledNote": "완료 전에 실행이 취소되었습니다 — 집계는 부분적입니다.",
"statMatched": "매칭된 항목",
"statReview": "검토 필요",
"statUnresolved": "매칭 없음",
"statErrors": "오류",
"reviewSection": "검토할 파일 이름 매칭 ({count})",
"columnRecipe": "레시피",
"columnEntry": "항목",
"columnFile": "매칭된 파일",
"columnUndo": "실행 취소",
"copyReport": "보고서 복사",
"close": "닫기",
"scope_global": "모든 레시피",
"scope_bulk": "선택한 레시피",
"scope_single": "단일 레시피"
},
"exampleAccess": { "exampleAccess": {
"title": "로컬 예시 이미지", "title": "로컬 예시 이미지",
"message": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:", "message": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:",
@@ -2168,6 +2200,7 @@
"createMissingData": "레시피 생성에 필요한 데이터가 없습니다", "createMissingData": "레시피 생성에 필요한 데이터가 없습니다",
"created": "레시피가 생성되었습니다", "created": "레시피가 생성되었습니다",
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다", "noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
"unresolvableMarkedForReconnect": "해석할 수 없는 항목 {count}개가 표시되었습니다 — 이제 로컬 LoRA에 다시 연결할 수 있습니다.",
"noPreviousRecipe": "이전 레시피가 없습니다", "noPreviousRecipe": "이전 레시피가 없습니다",
"noNextRecipe": "다음 레시피가 없습니다", "noNextRecipe": "다음 레시피가 없습니다",
"missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다", "missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
@@ -2222,10 +2255,6 @@
"batchImportBrowseFailed": "폴더를 찾아보지 못했습니다: {message}", "batchImportBrowseFailed": "폴더를 찾아보지 못했습니다: {message}",
"batchImportDirectorySelected": "선택한 폴더: {path}", "batchImportDirectorySelected": "선택한 폴더: {path}",
"noRecipesSelected": "선택한 레시피가 없습니다", "noRecipesSelected": "선택한 레시피가 없습니다",
"rematchComplete": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
"rematchCompleteErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패",
"rematchAllFailed": "선택한 {total}개 레시피 중 {failures}개 재매칭 실패",
"rematchUnmatched": "{recipes}개 레시피에서 {entries}개 항목의 로컬 매칭을 찾지 못했습니다",
"rematchSkipped": "선택한 {total}개 레시피는 재매칭이 필요하지 않습니다", "rematchSkipped": "선택한 {total}개 레시피는 재매칭이 필요하지 않습니다",
"rematchFailed": "선택한 레시피 재매칭 실패: {message}", "rematchFailed": "선택한 레시피 재매칭 실패: {message}",
"reimporting": "소스에서 레시피를 다시 가져오는 중...", "reimporting": "소스에서 레시피를 다시 가져오는 중...",
+36 -7
View File
@@ -216,9 +216,6 @@
"label": "Повторное сопоставление рецептов с локальными моделями", "label": "Повторное сопоставление рецептов с локальными моделями",
"loading": "Повторное сопоставление рецептов с локальными моделями...", "loading": "Повторное сопоставление рецептов с локальными моделями...",
"success": "Сопоставлено записей: {entries} в рецептах: {recipes}", "success": "Сопоставлено записей: {entries} в рецептах: {recipes}",
"successErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}",
"allFailed": "Не удалось сопоставить: {failures} из {total} рецептов",
"noMatch": "Не найдено локального сопоставления для {entries} записей в {recipes} рецептах",
"cancelled": "Сопоставление отменено. Обновлено рецептов: {recipes} (записей: {entries})", "cancelled": "Сопоставление отменено. Обновлено рецептов: {recipes} (записей: {entries})",
"error": "Не удалось выполнить сопоставление рецептов: {message}" "error": "Не удалось выполнить сопоставление рецептов: {message}"
}, },
@@ -1501,6 +1498,41 @@
"note": "Файлы будут скачаны с использованием шаблонов путей по умолчанию. Это может занять некоторое время в зависимости от количества LoRAs.", "note": "Файлы будут скачаны с использованием шаблонов путей по умолчанию. Это может занять некоторое время в зависимости от количества LoRAs.",
"downloadButton": "Скачать {count} LoRA(s)" "downloadButton": "Скачать {count} LoRA(s)"
}, },
"rematchOptions": {
"title": "Повторное сопоставление рецептов",
"messageGlobal": "Все рецепты будут проверены по вашей локальной библиотеке моделей.",
"messageSingle": "Этот рецепт будет проверен по вашей локальной библиотеке моделей.",
"messageBulk": "Выбранные рецепты ({count}) будут проверены по вашей локальной библиотеке моделей.",
"relaxedLabel": "Также переподключать отсутствующие модели по имени файла",
"relaxedDescription": "Эти модели также можно исправить загрузкой — загрузка точнее. Совпадения могут привязать другую версию; они будут перечислены для проверки, и их можно будет отменить.",
"confirmButton": "Сопоставить"
},
"rematchResults": {
"undo": "Отменить",
"undone": "Отменено",
"undoFailed": "Не удалось отменить сопоставление: {message}"
},
"rematchSummary": {
"title": "Сводка повторного сопоставления",
"successMessage": "Сопоставлено записей: {entries}",
"failed": "Не удалось выполнить сопоставление",
"completedWithWarnings": "Сопоставление завершено — рекомендуется проверка",
"cancelledNote": "Запуск отменён до завершения — подсчёты неполные.",
"statMatched": "Сопоставленные записи",
"statReview": "Требуют проверки",
"statUnresolved": "Не сопоставлено",
"statErrors": "Ошибки",
"reviewSection": "Совпадения по имени файла для проверки ({count})",
"columnRecipe": "Рецепт",
"columnEntry": "Запись",
"columnFile": "Совпавший файл",
"columnUndo": "Отменить",
"copyReport": "Скопировать отчёт",
"close": "Закрыть",
"scope_global": "Все рецепты",
"scope_bulk": "Выбранные рецепты",
"scope_single": "Один рецепт"
},
"exampleAccess": { "exampleAccess": {
"title": "Локальные примеры изображений", "title": "Локальные примеры изображений",
"message": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:", "message": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:",
@@ -2168,6 +2200,7 @@
"createMissingData": "Отсутствуют необходимые данные для создания рецепта", "createMissingData": "Отсутствуют необходимые данные для создания рецепта",
"created": "Рецепт успешно создан", "created": "Рецепт успешно создан",
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки", "noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
"unresolvableMarkedForReconnect": "Помечено неразрешимых записей: {count} — теперь их можно переподключить к локальному LoRA.",
"noPreviousRecipe": "Предыдущий рецепт отсутствует", "noPreviousRecipe": "Предыдущий рецепт отсутствует",
"noNextRecipe": "Следующий рецепт отсутствует", "noNextRecipe": "Следующий рецепт отсутствует",
"missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs", "missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
@@ -2222,10 +2255,6 @@
"batchImportBrowseFailed": "Не удалось открыть папку: {message}", "batchImportBrowseFailed": "Не удалось открыть папку: {message}",
"batchImportDirectorySelected": "Выбрана папка: {path}", "batchImportDirectorySelected": "Выбрана папка: {path}",
"noRecipesSelected": "Рецепты не выбраны", "noRecipesSelected": "Рецепты не выбраны",
"rematchComplete": "Сопоставлено записей: {entries} в рецептах: {recipes}",
"rematchCompleteErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}",
"rematchAllFailed": "Не удалось сопоставить: {failures} из {total} выбранных рецептов",
"rematchUnmatched": "Не найдено локального сопоставления для {entries} записей в {recipes} рецептах",
"rematchSkipped": "Ни один из {total} выбранных рецептов не требует сопоставления", "rematchSkipped": "Ни один из {total} выбранных рецептов не требует сопоставления",
"rematchFailed": "Не удалось сопоставить выбранные рецепты: {message}", "rematchFailed": "Не удалось сопоставить выбранные рецепты: {message}",
"reimporting": "Переимпорт рецепта из источника...", "reimporting": "Переимпорт рецепта из источника...",
+36 -7
View File
@@ -216,9 +216,6 @@
"label": "将配方重新匹配到本地模型", "label": "将配方重新匹配到本地模型",
"loading": "正在将配方重新匹配到本地模型...", "loading": "正在将配方重新匹配到本地模型...",
"success": "已匹配 {entries} 个条目,涉及 {recipes} 个配方", "success": "已匹配 {entries} 个条目,涉及 {recipes} 个配方",
"successErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个配方,{failures} 个失败",
"allFailed": "{failures}/{total} 个配方重新匹配失败",
"noMatch": "在 {recipes} 个配方中未找到 {entries} 个条目的本地匹配",
"cancelled": "已取消重新匹配。{recipes} 个配方已更新({entries} 个条目)。", "cancelled": "已取消重新匹配。{recipes} 个配方已更新({entries} 个条目)。",
"error": "配方重新匹配失败:{message}" "error": "配方重新匹配失败:{message}"
}, },
@@ -1501,6 +1498,41 @@
"note": "文件将使用默认路径模板下载。根据 LoRAs 的数量,这可能需要一些时间。", "note": "文件将使用默认路径模板下载。根据 LoRAs 的数量,这可能需要一些时间。",
"downloadButton": "下载 {count} 个 LoRA(s)" "downloadButton": "下载 {count} 个 LoRA(s)"
}, },
"rematchOptions": {
"title": "重新匹配配方",
"messageGlobal": "将对照你的本地模型库扫描所有配方。",
"messageSingle": "将对照你的本地模型库扫描此配方。",
"messageBulk": "将对照你的本地模型库扫描 {count} 个所选配方。",
"relaxedLabel": "同时按文件名重新关联缺失的模型",
"relaxedDescription": "这些模型也可以通过下载来修复——下载更为准确。匹配结果可能链接到模型的其他版本;它们会被列出供检查,且可以撤销。",
"confirmButton": "重新匹配"
},
"rematchResults": {
"undo": "撤销",
"undone": "已撤销",
"undoFailed": "撤销重新匹配失败:{message}"
},
"rematchSummary": {
"title": "重新匹配摘要",
"successMessage": "已匹配 {entries} 个条目",
"failed": "重新匹配失败",
"completedWithWarnings": "重新匹配已完成——建议检查",
"cancelledNote": "运行在完成前已取消——统计不完整。",
"statMatched": "已匹配条目",
"statReview": "需要检查",
"statUnresolved": "未匹配",
"statErrors": "错误",
"reviewSection": "需要检查的文件名匹配({count}",
"columnRecipe": "配方",
"columnEntry": "条目",
"columnFile": "匹配到的文件",
"columnUndo": "撤销",
"copyReport": "复制报告",
"close": "关闭",
"scope_global": "所有配方",
"scope_bulk": "所选配方",
"scope_single": "单个配方"
},
"exampleAccess": { "exampleAccess": {
"title": "本地示例图片", "title": "本地示例图片",
"message": "未找到此模型的本地示例图片。可选操作:", "message": "未找到此模型的本地示例图片。可选操作:",
@@ -2168,6 +2200,7 @@
"createMissingData": "缺少创建配方所需的数据", "createMissingData": "缺少创建配方所需的数据",
"created": "配方创建成功", "created": "配方创建成功",
"noMissingLoras": "没有缺失的 LoRA 可下载", "noMissingLoras": "没有缺失的 LoRA 可下载",
"unresolvableMarkedForReconnect": "已标记 {count} 个无法解析的条目——现在可以将它们重新关联到本地 LoRA。",
"noPreviousRecipe": "没有上一个配方", "noPreviousRecipe": "没有上一个配方",
"noNextRecipe": "没有下一个配方", "noNextRecipe": "没有下一个配方",
"missingLorasInfoFailed": "获取缺失 LoRA 信息失败", "missingLorasInfoFailed": "获取缺失 LoRA 信息失败",
@@ -2222,10 +2255,6 @@
"batchImportBrowseFailed": "浏览目录失败:{message}", "batchImportBrowseFailed": "浏览目录失败:{message}",
"batchImportDirectorySelected": "已选择目录:{path}", "batchImportDirectorySelected": "已选择目录:{path}",
"noRecipesSelected": "未选择任何配方", "noRecipesSelected": "未选择任何配方",
"rematchComplete": "已匹配 {entries} 个条目,涉及 {recipes} 个配方",
"rematchCompleteErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个配方,{failures} 个失败",
"rematchAllFailed": "{failures}/{total} 个所选配方重新匹配失败",
"rematchUnmatched": "在 {recipes} 个配方中未找到 {entries} 个条目的本地匹配",
"rematchSkipped": "{total} 个所选配方均无需重新匹配", "rematchSkipped": "{total} 个所选配方均无需重新匹配",
"rematchFailed": "重新匹配所选配方失败:{message}", "rematchFailed": "重新匹配所选配方失败:{message}",
"reimporting": "正在从源重新导入配方...", "reimporting": "正在从源重新导入配方...",
+36 -7
View File
@@ -216,9 +216,6 @@
"label": "將配方重新匹配到本地模型", "label": "將配方重新匹配到本地模型",
"loading": "正在將配方重新匹配到本地模型...", "loading": "正在將配方重新匹配到本地模型...",
"success": "已匹配 {entries} 個條目,涉及 {recipes} 個配方", "success": "已匹配 {entries} 個條目,涉及 {recipes} 個配方",
"successErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個配方,{failures} 個失敗",
"allFailed": "{failures}/{total} 個配方重新匹配失敗",
"noMatch": "在 {recipes} 個配方中找不到 {entries} 個條目的本地匹配",
"cancelled": "已取消重新匹配。{recipes} 個配方已更新({entries} 個條目)。", "cancelled": "已取消重新匹配。{recipes} 個配方已更新({entries} 個條目)。",
"error": "配方重新匹配失敗:{message}" "error": "配方重新匹配失敗:{message}"
}, },
@@ -1501,6 +1498,41 @@
"note": "檔案將使用預設路徑模板下載。根據 LoRAs 的數量,這可能需要一些時間。", "note": "檔案將使用預設路徑模板下載。根據 LoRAs 的數量,這可能需要一些時間。",
"downloadButton": "下載 {count} 個 LoRA(s)" "downloadButton": "下載 {count} 個 LoRA(s)"
}, },
"rematchOptions": {
"title": "重新匹配配方",
"messageGlobal": "所有配方將對照您的本地模型庫進行掃描。",
"messageSingle": "此配方將對照您的本地模型庫進行掃描。",
"messageBulk": "將對照您的本地模型庫掃描 {count} 個所選配方。",
"relaxedLabel": "同時依檔案名稱重新關聯缺少的模型",
"relaxedDescription": "這些模型也可以透過下載修復——下載更為準確。比對可能會連結到模型的不同版本;比對結果將列出供您檢閱,且可以撤銷。",
"confirmButton": "重新匹配"
},
"rematchResults": {
"undo": "撤銷",
"undone": "已撤銷",
"undoFailed": "撤銷重新匹配失敗:{message}"
},
"rematchSummary": {
"title": "重新匹配摘要",
"successMessage": "已匹配 {entries} 個條目",
"failed": "重新匹配失敗",
"completedWithWarnings": "重新匹配已完成——建議檢查",
"cancelledNote": "執行在完成前已取消——統計不完整。",
"statMatched": "已匹配條目",
"statReview": "需要檢查",
"statUnresolved": "未匹配",
"statErrors": "錯誤",
"reviewSection": "需要檢查的檔案名稱匹配({count})",
"columnRecipe": "配方",
"columnEntry": "條目",
"columnFile": "匹配到的檔案",
"columnUndo": "撤銷",
"copyReport": "複製報告",
"close": "關閉",
"scope_global": "所有配方",
"scope_bulk": "所選配方",
"scope_single": "單個配方"
},
"exampleAccess": { "exampleAccess": {
"title": "本機範例圖片", "title": "本機範例圖片",
"message": "此模型未找到本機範例圖片。可選擇:", "message": "此模型未找到本機範例圖片。可選擇:",
@@ -2168,6 +2200,7 @@
"createMissingData": "缺少建立配方所需的資料", "createMissingData": "缺少建立配方所需的資料",
"created": "配方建立成功", "created": "配方建立成功",
"noMissingLoras": "無缺少的 LoRA 可下載", "noMissingLoras": "無缺少的 LoRA 可下載",
"unresolvableMarkedForReconnect": "已標記 {count} 個無法解析的條目——現在可以將它們重新關聯到本地 LoRA。",
"noPreviousRecipe": "沒有上一個配方", "noPreviousRecipe": "沒有上一個配方",
"noNextRecipe": "沒有下一個配方", "noNextRecipe": "沒有下一個配方",
"missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗", "missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗",
@@ -2222,10 +2255,6 @@
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}", "batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
"batchImportDirectorySelected": "已選擇目錄:{path}", "batchImportDirectorySelected": "已選擇目錄:{path}",
"noRecipesSelected": "未選取任何配方", "noRecipesSelected": "未選取任何配方",
"rematchComplete": "已匹配 {entries} 個條目,涉及 {recipes} 個配方",
"rematchCompleteErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個配方,{failures} 個失敗",
"rematchAllFailed": "{failures}/{total} 個所選配方重新匹配失敗",
"rematchUnmatched": "在 {recipes} 個配方中找不到 {entries} 個條目的本地匹配",
"rematchSkipped": "{total} 個所選配方均無需重新匹配", "rematchSkipped": "{total} 個所選配方均無需重新匹配",
"rematchFailed": "重新匹配所選配方失敗:{message}", "rematchFailed": "重新匹配所選配方失敗:{message}",
"reimporting": "正在從來源重新匯入配方...", "reimporting": "正在從來源重新匯入配方...",
+1 -5
View File
@@ -122,12 +122,8 @@ async def _save_hf_metadata(dest_path: str, repo: str, model_root: str) -> None:
metadata._unknown_fields["hf_url"] = hf_url metadata._unknown_fields["hf_url"] = hf_url
metadata.from_civitai = False # HF models are not from CivitAI metadata.from_civitai = False # HF models are not from CivitAI
metadata_dict = metadata.to_dict()
if "trainedWords" in metadata_dict and not metadata_dict["trainedWords"]:
del metadata_dict["trainedWords"]
# 3. Save metadata atomically # 3. Save metadata atomically
await MetadataManager.save_metadata(dest_path, metadata_dict) await MetadataManager.save_metadata(dest_path, metadata)
logger.info("Saved HF metadata (with hf_url) for %s", dest_path) logger.info("Saved HF metadata (with hf_url) for %s", dest_path)
# 4. Determine relative folder path for cache # 4. Determine relative folder path for cache
+35 -3
View File
@@ -74,6 +74,26 @@ async def _read_preview_dims(path: str) -> Optional[Tuple[int, int]]:
return await asyncio.to_thread(ExifUtils.get_image_dimensions, path) return await asyncio.to_thread(ExifUtils.get_image_dimensions, path)
async def _parse_relaxed_flag(request: web.Request) -> bool:
"""Read the relaxed-rematch flag from the JSON body or query string.
The flag defaults to False (strict candidacy). A JSON body value wins;
``?relaxed=true`` is honored as a fallback so GET-only clients can opt
in. Body parse failures (empty/invalid JSON) are treated as "no flag".
"""
relaxed = False
if request.can_read_body:
try:
data = await request.json()
except Exception: # noqa: BLE001 - any parse failure means no flag
data = None
if isinstance(data, dict):
relaxed = bool(data.get("relaxed"))
if not relaxed:
relaxed = request.query.get("relaxed", "").lower() == "true"
return relaxed
@dataclass(frozen=True) @dataclass(frozen=True)
class RecipeHandlerSet: class RecipeHandlerSet:
"""Group of handlers providing recipe route implementations.""" """Group of handlers providing recipe route implementations."""
@@ -812,6 +832,8 @@ class RecipeManagementHandler:
recipe_scanner.reset_cancellation() recipe_scanner.reset_cancellation()
relaxed = await _parse_relaxed_flag(request)
async def progress_callback(data): async def progress_callback(data):
await self._ws_manager.broadcast_recipe_rematch_progress(data) await self._ws_manager.broadcast_recipe_rematch_progress(data)
@@ -819,7 +841,8 @@ class RecipeManagementHandler:
async def run_rematch(): async def run_rematch():
try: try:
await recipe_scanner.rematch_all_recipes( await recipe_scanner.rematch_all_recipes(
progress_callback=progress_callback progress_callback=progress_callback,
relaxed=relaxed,
) )
except Exception as e: except Exception as e:
self._logger.error( self._logger.error(
@@ -892,7 +915,13 @@ class RecipeManagementHandler:
status=400, status=400,
) )
result = await recipe_scanner.rematch_recipes_bulk(recipe_ids) relaxed = bool(data.get("relaxed")) or (
request.query.get("relaxed", "").lower() == "true"
)
result = await recipe_scanner.rematch_recipes_bulk(
recipe_ids, relaxed=relaxed
)
return web.json_response(result) return web.json_response(result)
except Exception as exc: except Exception as exc:
self._logger.error( self._logger.error(
@@ -921,7 +950,10 @@ class RecipeManagementHandler:
) )
recipe_id = request.match_info["recipe_id"] recipe_id = request.match_info["recipe_id"]
result = await recipe_scanner.rematch_recipe_by_id(recipe_id) relaxed = await _parse_relaxed_flag(request)
result = await recipe_scanner.rematch_recipe_by_id(
recipe_id, relaxed=relaxed
)
return web.json_response(result) return web.json_response(result)
except RecipeNotFoundError as exc: except RecipeNotFoundError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=404) return web.json_response({"success": False, "error": str(exc)}, status=404)
-1
View File
@@ -407,7 +407,6 @@ class AgentService:
"base_model": metadata.get("base_model", ""), "base_model": metadata.get("base_model", ""),
"tags": metadata.get("tags", []), "tags": metadata.get("tags", []),
"modelDescription": metadata.get("modelDescription", ""), "modelDescription": metadata.get("modelDescription", ""),
"trainedWords": metadata.get("trainedWords", []),
"sha256": (metadata.get("sha256") or "")[:16] + "..." if metadata.get("sha256") else "", "sha256": (metadata.get("sha256") or "")[:16] + "..." if metadata.get("sha256") else "",
"size": metadata.get("size", 0), "size": metadata.get("size", 0),
} }
+91 -48
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
import time
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import aiohttp import aiohttp
@@ -32,8 +33,26 @@ _catalog_cache: Optional[Dict[str, List[str]]] = None
# ``{provider_id: {model_id: max_output_tokens}}``. # ``{provider_id: {model_id: max_output_tokens}}``.
_model_output_limits: Dict[str, Dict[str, int]] = {} _model_output_limits: Dict[str, Dict[str, int]] = {}
# Monotonic timestamp of the last failed catalog fetch (None = no failure
# yet). Failed fetches are negatively cached: further calls return the
# empty fallback without hitting the network until the cooldown elapses,
# so users on broken networks don't stall on every settings-modal open.
_catalog_last_failure: Optional[float] = None
_CATALOG_FAILURE_COOLDOWN = 600.0 # seconds
# Serializes catalog fetches so concurrent callers don't duplicate requests.
_catalog_lock = asyncio.Lock()
_CATALOG_TIMEOUT = aiohttp.ClientTimeout(total=30) _CATALOG_TIMEOUT = aiohttp.ClientTimeout(total=30)
# Cloudflare serves brotli when the client advertises it, and brotli is a
# required dependency here — a corrupted br stream can crash the native
# decoder with a Windows access violation (issue #1099). Request gzip
# instead; zlib decompression is not affected and corrupt gzip data only
# raises ContentEncodingError (an aiohttp.ClientError subclass), which the
# exception handlers below already catch.
_NO_BROTLI_HEADERS = {"Accept-Encoding": "gzip, deflate"}
async def _load_model_catalog() -> Dict[str, List[str]]: async def _load_model_catalog() -> Dict[str, List[str]]:
"""Fetch and parse the model catalog. """Fetch and parse the model catalog.
@@ -46,61 +65,85 @@ async def _load_model_catalog() -> Dict[str, List[str]]:
value has a ``models`` sub-dict keyed by model ID. The result is cached value has a ``models`` sub-dict keyed by model ID. The result is cached
in memory after the first successful fetch. in memory after the first successful fetch.
Subsequent calls return the cached data immediately. Subsequent calls return the cached data immediately.
Failed fetches are negatively cached: further calls return an empty
dict without hitting the network until ``_CATALOG_FAILURE_COOLDOWN``
has elapsed, so a broken network does not stall every settings-modal
open. Concurrent callers are serialized behind :data:`_catalog_lock`
so only one request is ever in flight.
""" """
global _catalog_cache, _model_output_limits global _catalog_cache, _model_output_limits, _catalog_last_failure
if _catalog_cache is not None: if _catalog_cache is not None:
return _catalog_cache return _catalog_cache
try: async with _catalog_lock:
async with aiohttp.ClientSession(timeout=_CATALOG_TIMEOUT) as session: # Re-check under the lock: another caller may have fetched (or
async with session.get(_MODEL_CATALOG_URL) as resp: # failed) while we were waiting.
if resp.status != 200: if _catalog_cache is not None:
logger.warning("Model catalog returned HTTP %s", resp.status) return _catalog_cache
return _catalog_cache or {} if (
data = await resp.json() _catalog_last_failure is not None
except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError, UnicodeDecodeError) as exc: and time.monotonic() - _catalog_last_failure < _CATALOG_FAILURE_COOLDOWN
logger.warning("Failed to fetch model catalog: %s", exc) ):
return _catalog_cache or {} logger.debug(
"Skipping model catalog fetch: last attempt failed %.0fs ago",
time.monotonic() - _catalog_last_failure,
)
return {}
if not isinstance(data, dict): try:
logger.warning("Model catalog is not a dict, got %s", type(data).__name__) async with aiohttp.ClientSession(timeout=_CATALOG_TIMEOUT) as session:
return _catalog_cache or {} async with session.get(_MODEL_CATALOG_URL, headers=_NO_BROTLI_HEADERS) as resp:
if resp.status != 200:
logger.warning("Model catalog returned HTTP %s", resp.status)
_catalog_last_failure = time.monotonic()
return {}
data = await resp.json()
except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError, UnicodeDecodeError) as exc:
logger.warning("Failed to fetch model catalog: %s", exc)
_catalog_last_failure = time.monotonic()
return {}
result: Dict[str, List[str]] = {} if not isinstance(data, dict):
output_limits: Dict[str, Dict[str, int]] = {} logger.warning("Model catalog is not a dict, got %s", type(data).__name__)
for provider_id, provider_info in data.items(): _catalog_last_failure = time.monotonic()
if not isinstance(provider_info, dict): return {}
continue
models_dict = provider_info.get("models") result: Dict[str, List[str]] = {}
if not isinstance(models_dict, dict): output_limits: Dict[str, Dict[str, int]] = {}
continue for provider_id, provider_info in data.items():
model_ids: List[str] = [] if not isinstance(provider_info, dict):
provider_limits: Dict[str, int] = {}
for mid, model_info in models_dict.items():
if not isinstance(mid, str):
continue continue
model_ids.append(mid) models_dict = provider_info.get("models")
if isinstance(model_info, dict): if not isinstance(models_dict, dict):
limit = model_info.get("limit") continue
if isinstance(limit, dict): model_ids: List[str] = []
output = limit.get("output") provider_limits: Dict[str, int] = {}
if isinstance(output, (int, float)) and output > 0: for mid, model_info in models_dict.items():
provider_limits[mid] = int(output) if not isinstance(mid, str):
if model_ids: continue
result[provider_id] = model_ids model_ids.append(mid)
if provider_limits: if isinstance(model_info, dict):
output_limits[provider_id] = provider_limits limit = model_info.get("limit")
if isinstance(limit, dict):
output = limit.get("output")
if isinstance(output, (int, float)) and output > 0:
provider_limits[mid] = int(output)
if model_ids:
result[provider_id] = model_ids
if provider_limits:
output_limits[provider_id] = provider_limits
_catalog_cache = result _catalog_cache = result
_model_output_limits = output_limits _model_output_limits = output_limits
logger.debug( logger.debug(
"Loaded model catalog: %d providers, %d total models " "Loaded model catalog: %d providers, %d total models "
"(%d providers have output limits)", "(%d providers have output limits)",
len(result), len(result),
sum(len(m) for m in result.values()), sum(len(m) for m in result.values()),
len(output_limits), len(output_limits),
) )
return result return result
def _get_model_max_output(provider: str, model: str) -> Optional[int]: def _get_model_max_output(provider: str, model: str) -> Optional[int]:
@@ -126,7 +169,7 @@ async def fetch_ollama_models(api_base: str) -> List[str]:
url = f"{api_base.rstrip('/')}/models" url = f"{api_base.rstrip('/')}/models"
try: try:
async with aiohttp.ClientSession(timeout=_OLLAMA_API_TIMEOUT) as session: async with aiohttp.ClientSession(timeout=_OLLAMA_API_TIMEOUT) as session:
async with session.get(url) as resp: async with session.get(url, headers=_NO_BROTLI_HEADERS) as resp:
if resp.status != 200: if resp.status != 200:
logger.debug("Ollama API returned HTTP %s from %s", resp.status, api_base) logger.debug("Ollama API returned HTTP %s from %s", resp.status, api_base)
return [] return []
+159 -31
View File
@@ -483,32 +483,43 @@ class RecipeScanner:
suggestions.sort(key=lambda s: (-s["score"], s["file_name"].lower())) suggestions.sort(key=lambda s: (-s["score"], s["file_name"].lower()))
return suggestions[:limit] return suggestions[:limit]
def _is_rematch_candidate(self, entry: dict[str, Any]) -> bool: def _is_rematch_candidate(
self, entry: dict[str, Any], relaxed: bool = False
) -> bool:
"""Return True when a recipe entry is eligible for local re-matching. """Return True when a recipe entry is eligible for local re-matching.
An entry counts as unresolved when its identity is known to be An entry counts as unresolved when its identity is known to be
broken (``isDeleted`` or ``hashInvalid``) or when it is missing broken (``isDeleted`` or ``hashInvalid``) or when it is missing
identity fields (``hash``/``file_name``). A healthy entry whose identity fields (``hash``/``file_name``). A healthy entry whose
hash is simply not present in the local library is NOT a candidate: hash is simply not present in the local library is NOT a candidate
it may be a recipe imported without downloading the model yet, and in the default strict mode: it may be a recipe imported without
its CivitAI-valid hash must never be overwritten by the imprecise downloading the model yet, and its CivitAI-valid hash must never be
filename fallback. overwritten by the imprecise filename fallback.
With ``relaxed=True`` any entry carrying an identifier is a
candidate, including healthy ones the caller opted into trying to
reconnect "Not in Library" entries by file name. Entries without
any identifier are never candidates in either mode.
""" """
if not isinstance(entry, dict): if not isinstance(entry, dict):
return False return False
unresolved = (
entry.get("isDeleted")
or entry.get("hashInvalid")
or not entry.get("hash")
or not entry.get("file_name")
)
has_identifier = ( has_identifier = (
entry.get("hash") entry.get("hash")
or entry.get("modelVersionId") or entry.get("modelVersionId")
or entry.get("id") or entry.get("id")
or entry.get("file_name") or entry.get("file_name")
) )
return bool(unresolved and has_identifier) if not has_identifier:
return False
if relaxed:
return True
unresolved = (
entry.get("isDeleted")
or entry.get("hashInvalid")
or not entry.get("hash")
or not entry.get("file_name")
)
return bool(unresolved)
async def _build_rematch_autov3_cache(self) -> dict[str, dict[str, Any]]: async def _build_rematch_autov3_cache(self) -> dict[str, dict[str, Any]]:
"""Build a version-cached map of computed AutoV3 hashes to local items. """Build a version-cached map of computed AutoV3 hashes to local items.
@@ -809,7 +820,9 @@ class RecipeScanner:
"""Check if cancellation has been requested.""" """Check if cancellation has been requested."""
return self._cancel_requested return self._cancel_requested
async def rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]: async def rematch_recipe_by_id(
self, recipe_id: str, *, relaxed: bool = False
) -> Dict[str, Any]:
"""Rematch a single recipe's deleted lora/checkpoint entries locally. """Rematch a single recipe's deleted lora/checkpoint entries locally.
Logs one INFO summary line for this run and delegates the per-recipe Logs one INFO summary line for this run and delegates the per-recipe
@@ -817,12 +830,14 @@ class RecipeScanner:
Args: Args:
recipe_id: ID of the recipe to rematch recipe_id: ID of the recipe to rematch
relaxed: When True, healthy entries are rematch candidates too
(see ``_rematch_single_recipe``).
Returns: Returns:
Dict summary of the rematch result (see ``_rematch_recipe_by_id``). Dict summary of the rematch result (see ``_rematch_recipe_by_id``).
Raises RecipeNotFoundError when the recipe is missing. Raises RecipeNotFoundError when the recipe is missing.
""" """
result = await self._rematch_recipe_by_id(recipe_id) result = await self._rematch_recipe_by_id(recipe_id, relaxed=relaxed)
recipe_name = (result.get("recipe") or {}).get("name") or recipe_id recipe_name = (result.get("recipe") or {}).get("name") or recipe_id
logger.info( logger.info(
"Recipe rematch %s (%s): success=%s, %d entries matched, %d unresolved, %d errors", "Recipe rematch %s (%s): success=%s, %d entries matched, %d unresolved, %d errors",
@@ -835,7 +850,9 @@ class RecipeScanner:
) )
return result return result
async def _rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]: async def _rematch_recipe_by_id(
self, recipe_id: str, *, relaxed: bool = False
) -> Dict[str, Any]:
"""Rematch a single recipe's deleted lora/checkpoint entries locally. """Rematch a single recipe's deleted lora/checkpoint entries locally.
Match snapshots (local hash cache, computed autov3 cache, filename Match snapshots (local hash cache, computed autov3 cache, filename
@@ -846,12 +863,16 @@ class RecipeScanner:
Args: Args:
recipe_id: ID of the recipe to rematch recipe_id: ID of the recipe to rematch
relaxed: When True, healthy entries are rematch candidates too
(see ``_rematch_single_recipe``).
Returns: Returns:
Dict summary of the rematch result with unified counters Dict summary of the rematch result with unified counters
(matched_recipes, matched_entries, unresolved_recipes, (matched_recipes, matched_entries, unresolved_recipes,
unresolved_entries plus the legacy rematched/skipped/errors unresolved_entries plus the legacy rematched/skipped/errors
fields) and a per-entry ``details`` report. The legacy ``skipped`` fields) and a per-entry ``details`` report plus a flattened
``l4_matches`` list (filename-level matches for review/undo,
consistent with the bulk/global paths). The legacy ``skipped``
field means "recipe not updated" and overlaps field means "recipe not updated" and overlaps
``unresolved_recipes`` (a recipe with unmatched candidates counts ``unresolved_recipes`` (a recipe with unmatched candidates counts
as both). Raises RecipeNotFoundError when the recipe is missing. as both). Raises RecipeNotFoundError when the recipe is missing.
@@ -872,7 +893,8 @@ class RecipeScanner:
try: try:
rematched, _errors, details = await self._rematch_single_recipe( rematched, _errors, details = await self._rematch_single_recipe(
recipe, local_cache, autov3_cache, filename_cache recipe, local_cache, autov3_cache, filename_cache,
relaxed=relaxed,
) )
except RecipePersistenceError as exc: except RecipePersistenceError as exc:
logger.error( logger.error(
@@ -891,12 +913,16 @@ class RecipeScanner:
"unresolved_recipes": 0, "unresolved_recipes": 0,
"unresolved_entries": 0, "unresolved_entries": 0,
"details": {"matched": [], "unresolved": []}, "details": {"matched": [], "unresolved": []},
"l4_matches": [],
"recipe": recipe, "recipe": recipe,
"error": str(exc), "error": str(exc),
} }
unresolved_entries = len(details["unresolved"]) unresolved_entries = len(details["unresolved"])
unresolved_recipes = 1 if unresolved_entries > 0 else 0 unresolved_recipes = 1 if unresolved_entries > 0 else 0
# Flattened L4 matches for the results modal, consistent with
# the bulk/global paths.
l4_matches = self._collect_l4_matches(recipe_id, details)
if rematched == 0: if rematched == 0:
return { return {
@@ -908,6 +934,7 @@ class RecipeScanner:
"unresolved_recipes": unresolved_recipes, "unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries, "unresolved_entries": unresolved_entries,
"details": details, "details": details,
"l4_matches": l4_matches,
"recipe": recipe, "recipe": recipe,
} }
@@ -921,6 +948,7 @@ class RecipeScanner:
"unresolved_recipes": unresolved_recipes, "unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries, "unresolved_entries": unresolved_entries,
"details": details, "details": details,
"l4_matches": l4_matches,
"recipe": await self.get_recipe_by_id(recipe_id), "recipe": await self.get_recipe_by_id(recipe_id),
} }
@@ -930,6 +958,8 @@ class RecipeScanner:
local_cache: dict[str, dict[str, Any]], local_cache: dict[str, dict[str, Any]],
autov3_cache: dict[str, dict[str, Any]], autov3_cache: dict[str, dict[str, Any]],
filename_cache: Optional[dict[str, list[dict[str, Any]]]] = None, filename_cache: Optional[dict[str, list[dict[str, Any]]]] = None,
*,
relaxed: bool = False,
) -> Tuple[int, int, Dict[str, Any]]: ) -> Tuple[int, int, Dict[str, Any]]:
"""Rematch a single recipe's lora/checkpoint entries against local models. """Rematch a single recipe's lora/checkpoint entries against local models.
@@ -945,16 +975,24 @@ class RecipeScanner:
autov3_cache: L3 computed-autov3 cache snapshot autov3_cache: L3 computed-autov3 cache snapshot
filename_cache: L4 filename cache snapshot, or None to disable filename_cache: L4 filename cache snapshot, or None to disable
the filename fallback the filename fallback
relaxed: When True, healthy entries ("Not in Library") are also
rematch candidates. Anti-churn rule: an entry that is a
candidate ONLY because of relaxed mode is skipped when its
hash already resolves in the L1 ``local_cache`` it is
already correctly linked and rematching would only add noise
and a pointless snapshot.
Returns: Returns:
Tuple of (rematched_entries, errors, details). The errors element Tuple of (rematched_entries, errors, details). The errors element
is always 0 on a normal return a persistence failure RAISES is always 0 on a normal return a persistence failure RAISES
``RecipePersistenceError`` so callers can count it. ``details`` ``RecipePersistenceError`` so callers can count it. ``details``
carries the per-entry outcome: carries the per-entry outcome:
``{"matched": [{type, entry, file_name, match_level}], ``{"matched": [{type, entry, file_name, match_level, lora_index?}],
"unresolved": [{type, entry}]}`` where an unresolved entry is a "unresolved": [{type, entry}]}`` where an unresolved entry is a
rematch candidate that found no local match an expected outcome rematch candidate that found no local match an expected outcome
(the model may simply not exist locally), not an error. (the model may simply not exist locally), not an error.
``lora_index`` is only present for lora entries (the checkpoint
restore endpoint needs no index).
Raises: Raises:
RecipePersistenceError: when the recipe changed but RecipePersistenceError: when the recipe changed but
@@ -963,11 +1001,23 @@ class RecipeScanner:
rematched = 0 rematched = 0
details: Dict[str, Any] = {"matched": [], "unresolved": []} details: Dict[str, Any] = {"matched": [], "unresolved": []}
def is_actionable_candidate(entry: Dict[str, Any]) -> bool:
"""Apply candidacy plus the relaxed-mode anti-churn rule."""
if self._is_rematch_candidate(entry):
return True
if not relaxed or not self._is_rematch_candidate(entry, relaxed=True):
return False
# Relaxed-only candidate: skip when the stored hash already
# resolves in the L1 local cache — the entry is already correctly
# linked and rematching would just add noise and a snapshot.
entry_hash = (entry.get("hash") or "").lower()
return local_cache.get(entry_hash) is None
# Lora entries # Lora entries
loras = recipe.get("loras", []) loras = recipe.get("loras", [])
if isinstance(loras, list): if isinstance(loras, list):
for entry in loras: for lora_index, entry in enumerate(loras):
if not self._is_rematch_candidate(entry): if not is_actionable_candidate(entry):
continue continue
item, level = await self._match_rematch_entry_with_level( item, level = await self._match_rematch_entry_with_level(
entry, entry,
@@ -991,6 +1041,7 @@ class RecipeScanner:
"entry": self._entry_identifier(entry), "entry": self._entry_identifier(entry),
"file_name": item.get("file_name") or "", "file_name": item.get("file_name") or "",
"match_level": level, "match_level": level,
"lora_index": lora_index,
} }
) )
self._write_rematch_lora_entry(entry, item) self._write_rematch_lora_entry(entry, item)
@@ -1000,7 +1051,7 @@ class RecipeScanner:
# silently since ``entry.get`` on a str would raise AttributeError). # silently since ``entry.get`` on a str would raise AttributeError).
checkpoint = recipe.get("checkpoint") checkpoint = recipe.get("checkpoint")
if isinstance(checkpoint, dict): if isinstance(checkpoint, dict):
if self._is_rematch_candidate(checkpoint): if is_actionable_candidate(checkpoint):
item, level = await self._match_rematch_entry_with_level( item, level = await self._match_rematch_entry_with_level(
checkpoint, checkpoint,
local_cache, local_cache,
@@ -1065,8 +1116,36 @@ class RecipeScanner:
self._update_fts_index_for_recipe(recipe, "update") self._update_fts_index_for_recipe(recipe, "update")
return (rematched, 0, details) return (rematched, 0, details)
@staticmethod
def _collect_l4_matches(
recipe_id: Any, details: Dict[str, Any]
) -> List[Dict[str, Any]]:
"""Flatten a recipe's L4 (filename-level) matches for review.
Returns ``[{recipe_id, type, entry, file_name, lora_index?}]`` rows
one per matched detail at level L4. ``lora_index`` is only present
for lora entries (checkpoint restore needs no index).
"""
rows: List[Dict[str, Any]] = []
for match in details.get("matched", []):
if match.get("match_level") != "L4":
continue
row: Dict[str, Any] = {
"recipe_id": recipe_id,
"type": match.get("type"),
"entry": match.get("entry"),
"file_name": match.get("file_name"),
}
if "lora_index" in match:
row["lora_index"] = match["lora_index"]
rows.append(row)
return rows
async def rematch_all_recipes( async def rematch_all_recipes(
self, progress_callback: Optional[Callable[[Dict[str, Any]], Any]] = None self,
progress_callback: Optional[Callable[[Dict[str, Any]], Any]] = None,
*,
relaxed: bool = False,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Rematch every recipe's deleted lora/checkpoint entries locally. """Rematch every recipe's deleted lora/checkpoint entries locally.
@@ -1080,14 +1159,19 @@ class RecipeScanner:
Args: Args:
progress_callback: Optional callback for progress updates progress_callback: Optional callback for progress updates
(started/processing/cancelled/completed events). (started/processing/cancelled/completed events). The
completed/cancelled payloads carry ``l4_matches``, a
flattened list of filename-level matches for review/undo.
relaxed: When True, healthy entries are rematch candidates too
(see ``_rematch_single_recipe``).
Returns: Returns:
Dict summary of the rematch run with unified counters Dict summary of the rematch run with unified counters
(matched_recipes/matched_entries/unresolved_recipes/unresolved_ (matched_recipes/matched_entries/unresolved_recipes/unresolved_
entries plus the legacy success/status/rematched/skipped/errors/ entries plus the legacy success/status/rematched/skipped/errors/
total fields). ``rematched`` (legacy) counts updated recipes total fields) and ``l4_matches``. ``rematched`` (legacy) counts
use ``matched_entries`` for the entry-level total. updated recipes use ``matched_entries`` for the entry-level
total.
""" """
start_time = time.perf_counter() start_time = time.perf_counter()
@@ -1109,6 +1193,7 @@ class RecipeScanner:
unresolved_entries = 0 unresolved_entries = 0
skipped_count = 0 skipped_count = 0
errors_count = 0 errors_count = 0
l4_matches: List[Dict[str, Any]] = []
for i, recipe in enumerate(all_recipes): for i, recipe in enumerate(all_recipes):
if self.is_cancelled(): if self.is_cancelled():
@@ -1137,6 +1222,7 @@ class RecipeScanner:
"matched_entries": matched_entries, "matched_entries": matched_entries,
"unresolved_recipes": unresolved_recipes, "unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries, "unresolved_entries": unresolved_entries,
"l4_matches": l4_matches,
} }
) )
return { return {
@@ -1150,6 +1236,7 @@ class RecipeScanner:
"matched_entries": matched_entries, "matched_entries": matched_entries,
"unresolved_recipes": unresolved_recipes, "unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries, "unresolved_entries": unresolved_entries,
"l4_matches": l4_matches,
} }
try: try:
@@ -1165,11 +1252,15 @@ class RecipeScanner:
) )
rematched, _errors, details = await self._rematch_single_recipe( rematched, _errors, details = await self._rematch_single_recipe(
recipe, local_cache, autov3_cache, filename_cache recipe, local_cache, autov3_cache, filename_cache,
relaxed=relaxed,
) )
if rematched > 0: if rematched > 0:
matched_recipes += 1 matched_recipes += 1
matched_entries += rematched matched_entries += rematched
l4_matches.extend(
self._collect_l4_matches(recipe.get("id"), details)
)
else: else:
skipped_count += 1 skipped_count += 1
@@ -1215,6 +1306,7 @@ class RecipeScanner:
"matched_entries": matched_entries, "matched_entries": matched_entries,
"unresolved_recipes": unresolved_recipes, "unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries, "unresolved_entries": unresolved_entries,
"l4_matches": l4_matches,
} }
) )
@@ -1228,9 +1320,12 @@ class RecipeScanner:
"matched_entries": matched_entries, "matched_entries": matched_entries,
"unresolved_recipes": unresolved_recipes, "unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries, "unresolved_entries": unresolved_entries,
"l4_matches": l4_matches,
} }
async def rematch_recipes_bulk(self, recipe_ids: List[str]) -> Dict[str, Any]: async def rematch_recipes_bulk(
self, recipe_ids: List[str], *, relaxed: bool = False
) -> Dict[str, Any]:
"""Rematch a set of recipes by their IDs. """Rematch a set of recipes by their IDs.
Iterates ``_rematch_recipe_by_id`` over each id: not-found ids are Iterates ``_rematch_recipe_by_id`` over each id: not-found ids are
@@ -1241,14 +1336,18 @@ class RecipeScanner:
Args: Args:
recipe_ids: List of recipe ids to rematch. recipe_ids: List of recipe ids to rematch.
relaxed: When True, healthy entries are rematch candidates too
(see ``_rematch_single_recipe``).
Returns: Returns:
Dict summary of the bulk run with unified counters Dict summary of the bulk run with unified counters
(matched_recipes, matched_entries, unresolved_recipes, (matched_recipes, matched_entries, unresolved_recipes,
unresolved_entries plus the legacy total/rematched/skipped/errors unresolved_entries plus the legacy total/rematched/skipped/errors
fields) and a per-recipe ``details`` list. The legacy ``rematched`` fields), a per-recipe ``details`` list, and ``l4_matches`` a
field is the total entry count (same as ``matched_entries``) flattened list of filename-level matches for review/undo. The
unlike ``rematch_all_recipes`` where it counts updated recipes. legacy ``rematched`` field is the total entry count (same as
``matched_entries``) unlike ``rematch_all_recipes`` where it
counts updated recipes.
""" """
total = len(recipe_ids) total = len(recipe_ids)
matched_recipes = 0 matched_recipes = 0
@@ -1259,10 +1358,13 @@ class RecipeScanner:
errors = 0 errors = 0
recipes: List[Dict[str, Any]] = [] recipes: List[Dict[str, Any]] = []
details_list: List[Dict[str, Any]] = [] details_list: List[Dict[str, Any]] = []
l4_matches: List[Dict[str, Any]] = []
for recipe_id in recipe_ids: for recipe_id in recipe_ids:
try: try:
result = await self._rematch_recipe_by_id(recipe_id) result = await self._rematch_recipe_by_id(
recipe_id, relaxed=relaxed
)
if result.get("success"): if result.get("success"):
matched_recipes += result.get("matched_recipes", 0) matched_recipes += result.get("matched_recipes", 0)
matched_entries += result.get("matched_entries", 0) matched_entries += result.get("matched_entries", 0)
@@ -1275,6 +1377,9 @@ class RecipeScanner:
details_list.append( details_list.append(
{"recipe_id": recipe_id, **result["details"]} {"recipe_id": recipe_id, **result["details"]}
) )
l4_matches.extend(
self._collect_l4_matches(recipe_id, result["details"])
)
else: else:
errors += result.get("errors", 0) errors += result.get("errors", 0)
except RecipeNotFoundError: except RecipeNotFoundError:
@@ -1309,12 +1414,22 @@ class RecipeScanner:
"unresolved_entries": unresolved_entries, "unresolved_entries": unresolved_entries,
"recipes": recipes, "recipes": recipes,
"details": details_list, "details": details_list,
"l4_matches": l4_matches,
} }
def _write_rematch_lora_entry( def _write_rematch_lora_entry(
self, entry: Dict[str, Any], item: Dict[str, Any] self, entry: Dict[str, Any], item: Dict[str, Any]
) -> None: ) -> None:
"""Write back a matched local model to a lora recipe entry.""" """Write back a matched local model to a lora recipe entry."""
# Snapshot the pre-rematch state so the association can be restored
# later (undo), mirroring the manual reconnect flow in
# ``update_lora_entry``. Never nest snapshots.
snapshot = {
key: copy.deepcopy(value)
for key, value in entry.items()
if key != "reconnectSnapshot"
}
entry["isDeleted"] = False entry["isDeleted"] = False
entry["hashInvalid"] = False entry["hashInvalid"] = False
@@ -1338,6 +1453,8 @@ class RecipeScanner:
if civitai.get("name"): if civitai.get("name"):
entry["modelVersionName"] = civitai["name"] entry["modelVersionName"] = civitai["name"]
entry["reconnectSnapshot"] = snapshot
def _write_rematch_checkpoint_entry( def _write_rematch_checkpoint_entry(
self, entry: Dict[str, Any], item: Dict[str, Any] self, entry: Dict[str, Any], item: Dict[str, Any]
) -> None: ) -> None:
@@ -1349,6 +1466,15 @@ class RecipeScanner:
when they already exist on the entry (or written fresh for the when they already exist on the entry (or written fresh for the
identifier key when neither identifier form exists). identifier key when neither identifier form exists).
""" """
# Snapshot the pre-rematch state so the association can be restored
# later (undo), mirroring the manual reconnect flow. Never nest
# snapshots.
snapshot = {
key: copy.deepcopy(value)
for key, value in entry.items()
if key != "reconnectSnapshot"
}
entry["isDeleted"] = False entry["isDeleted"] = False
entry["hashInvalid"] = False entry["hashInvalid"] = False
@@ -1389,6 +1515,8 @@ class RecipeScanner:
else: else:
entry["modelVersionId"] = civ_id entry["modelVersionId"] = civ_id
entry["reconnectSnapshot"] = snapshot
async def _save_recipe_persistently(self, recipe: Dict[str, Any]) -> bool: async def _save_recipe_persistently(self, recipe: Dict[str, Any]) -> bool:
"""Helper to save a recipe to both JSON and EXIF metadata.""" """Helper to save a recipe to both JSON and EXIF metadata."""
recipe_id = recipe.get("id") recipe_id = recipe.get("id")
-6
View File
@@ -77,9 +77,6 @@ class BaseModelMetadata:
last_checked_at: float = 0 # Last checked timestamp last_checked_at: float = 0 # Last checked timestamp
hash_status: str = "completed" # Hash calculation status: pending | calculating | completed | failed hash_status: str = "completed" # Hash calculation status: pending | calculating | completed | failed
autov3: Optional[str] = None # CivitAI AutoV3 hash (12-char lowercase hex); "" = checked but unavailable, None = not checked autov3: Optional[str] = None # CivitAI AutoV3 hash (12-char lowercase hex); "" = checked but unavailable, None = not checked
trainedWords: List[str] = field(
default_factory=list
) # Trigger words / activation prompts (source-agnostic)
_unknown_fields: Dict[str, Any] = field( _unknown_fields: Dict[str, Any] = field(
default_factory=dict, repr=False, compare=False default_factory=dict, repr=False, compare=False
) # Store unknown fields ) # Store unknown fields
@@ -92,9 +89,6 @@ class BaseModelMetadata:
if self.tags is None: if self.tags is None:
self.tags = [] self.tags = []
if self.trainedWords is None:
self.trainedWords = []
@classmethod @classmethod
def from_dict(cls, data: Dict[str, Any]) -> "BaseModelMetadata": def from_dict(cls, data: Dict[str, Any]) -> "BaseModelMetadata":
"""Create instance from dictionary""" """Create instance from dictionary"""
+142
View File
@@ -592,3 +592,145 @@ button:disabled,
margin-top: 2px; margin-top: 2px;
flex-shrink: 0; flex-shrink: 0;
} }
/* Recipe Rematch Options Modal */
#rematchOptionsModal .modal-body {
padding: var(--space-3);
}
#rematchOptionsModal .confirmation-message {
color: var(--text-color);
margin-bottom: var(--space-3);
font-size: 1em;
line-height: 1.5;
}
/* Selectable option card click anywhere toggles the checkbox (label wrap).
Checkmark follows the batch-import modal's custom checkbox pattern. */
#rematchOptionsModal .rematch-option-card {
position: relative;
display: flex;
align-items: flex-start;
gap: var(--space-2);
padding: var(--space-3);
background: var(--surface-subtle);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
cursor: pointer;
user-select: none;
transition: var(--transition-base);
}
#rematchOptionsModal .rematch-option-card:hover {
border-color: var(--lora-accent);
}
#rematchOptionsModal .rematch-option-card:has(input[type="checkbox"]:checked) {
border-color: var(--lora-accent);
background: oklch(from var(--lora-accent) l c h / 0.08);
}
/* Visually hidden but keyboard-focusable (focus ring lands on the card). */
#rematchOptionsModal .rematch-option-card input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
#rematchOptionsModal .rematch-option-card:has(input[type="checkbox"]:focus-visible) {
box-shadow: 0 0 0 2px oklch(from var(--lora-accent) l c h / 0.2);
}
#rematchOptionsModal .rematch-option-checkmark {
width: 18px;
height: 18px;
margin-top: 1px;
flex-shrink: 0;
border: 2px solid var(--border-color);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition-base);
background: var(--bg-color);
}
#rematchOptionsModal .rematch-option-card input[type="checkbox"]:checked + .rematch-option-checkmark {
background: var(--lora-accent);
border-color: var(--lora-accent);
}
#rematchOptionsModal .rematch-option-card input[type="checkbox"]:checked + .rematch-option-checkmark::after {
content: '\f00c';
font-family: 'Font Awesome 6 Free', sans-serif;
font-weight: 900;
color: var(--lora-text);
font-size: 12px;
}
#rematchOptionsModal .rematch-option-text {
display: flex;
flex-direction: column;
gap: var(--space-1);
color: var(--text-color);
min-width: 0;
}
#rematchOptionsModal .rematch-option-title {
font-weight: 600;
font-size: 0.95em;
}
#rematchOptionsModal .rematch-option-caveat {
display: flex;
align-items: flex-start;
gap: var(--space-2);
font-size: 0.85em;
line-height: 1.4;
color: var(--text-muted);
}
#rematchOptionsModal .rematch-option-caveat i {
color: var(--lora-accent);
margin-top: 2px;
flex-shrink: 0;
}
/* Recipe Rematch Summary Modal (dynamically built by RematchSummaryModal.js;
stat cards / failure table / summary header come from
metadata-refresh-result.css and download-batch-summary.css). */
.rematch-summary-modal {
max-width: 700px;
}
.rematch-cancelled-note {
display: flex;
align-items: flex-start;
gap: var(--space-2);
margin: 0 0 var(--space-2) 0;
font-size: var(--text-sm);
color: var(--color-warning);
}
.rematch-cancelled-note i {
margin-top: 2px;
flex-shrink: 0;
}
/* Review section heading uses the accent (review, not failure) instead of
the failure-section error color. */
.rematch-review-section h4 {
color: var(--lora-accent);
}
#rematchSummaryModal .rematch-undo-btn {
padding: var(--space-1) var(--space-2);
font-size: var(--text-xs);
white-space: nowrap;
}
#rematchSummaryModal tr.undone td:not(.rematch-undo-cell) {
text-decoration: line-through;
opacity: 0.6;
}
+7
View File
@@ -44,6 +44,13 @@
pointer-events: auto !important; pointer-events: auto !important;
} }
/* Keep the fixed-position sidebar anchored when highlighted, otherwise
.onboarding-target-highlight's position: relative would pull it into
normal flow and it would move away from the spotlight cutout */
.folder-sidebar.onboarding-target-highlight {
position: fixed;
}
.onboarding-popup { .onboarding-popup {
position: absolute; position: absolute;
background: var(--lora-surface); background: var(--lora-surface);
+9 -4
View File
@@ -677,7 +677,7 @@ export class RecipeSidebarApiClient {
}; };
} }
async rematchBulkModels(filePaths) { async rematchBulkModels(filePaths, options = {}) {
if (!filePaths || filePaths.length === 0) { if (!filePaths || filePaths.length === 0) {
throw new Error('No file paths provided'); throw new Error('No file paths provided');
} }
@@ -690,14 +690,19 @@ export class RecipeSidebarApiClient {
throw new Error('No recipe IDs could be derived from file paths'); throw new Error('No recipe IDs could be derived from file paths');
} }
const body = { recipe_ids: recipeIds };
// Only sent when opted in — the strict body stays exactly
// {recipe_ids} for backward compatibility.
if (options.relaxed === true) {
body.relaxed = true;
}
const response = await fetch(this.apiConfig.endpoints.rematchBulk, { const response = await fetch(this.apiConfig.endpoints.rematchBulk, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify(body),
recipe_ids: recipeIds,
}),
}); });
const result = await response.json(); const result = await response.json();
@@ -4,6 +4,8 @@ import { translate } from '../../utils/i18nHelpers.js';
import { state } from '../../state/index.js'; import { state } from '../../state/index.js';
import { getCompleteApiConfig, getCurrentModelType } from '../../api/apiConfig.js'; import { getCompleteApiConfig, getCurrentModelType } from '../../api/apiConfig.js';
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js'; import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
import { rematchModalManager } from '../../managers/RematchModalManager.js';
import { showRematchSummary } from '../RematchSummaryModal.js';
export class GlobalContextMenu extends BaseContextMenu { export class GlobalContextMenu extends BaseContextMenu {
constructor() { constructor() {
@@ -368,6 +370,18 @@ export class GlobalContextMenu extends BaseContextMenu {
return; return;
} }
// Collect options (relaxed matching) before starting anything; the
// run only begins when the user confirms the dialog.
rematchModalManager.showOptionsModal({
onConfirm: ({ relaxed }) => this._startRematch(menuItem, relaxed),
});
}
async _startRematch(menuItem, relaxed = false) {
if (this._rematchInProgress) {
return;
}
this._rematchInProgress = true; this._rematchInProgress = true;
menuItem?.classList.add('disabled'); menuItem?.classList.add('disabled');
@@ -384,6 +398,7 @@ export class GlobalContextMenu extends BaseContextMenu {
const response = await fetch('/api/lm/recipes/rematch', { const response = await fetch('/api/lm/recipes/rematch', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ relaxed: !!relaxed }),
}); });
const result = await response.json(); const result = await response.json();
@@ -411,48 +426,32 @@ export class GlobalContextMenu extends BaseContextMenu {
const recipes = p.matched_recipes ?? p.rematched ?? 0; const recipes = p.matched_recipes ?? p.rematched ?? 0;
const failures = p.errors || 0; const failures = p.errors || 0;
const unresolved = p.unresolved_entries ?? 0; const unresolved = p.unresolved_entries ?? 0;
if (entries > 0) { const l4Matches = Array.isArray(p.l4_matches) ? p.l4_matches : [];
const successKey = failures > 0 // Complete no-op (nothing matched, nothing
? 'globalContextMenu.rematchRecipes.successErrors' // unresolved, no errors) keeps the lightweight
: 'globalContextMenu.rematchRecipes.success'; // toast; anything else opens the post-run summary
const successText = failures > 0 // modal.
? `Matched ${entries} entries across ${recipes} recipes, ${failures} failed.` const isNoop = entries === 0 && unresolved === 0 && failures === 0;
: `Matched ${entries} entries across ${recipes} recipes.`; if (isNoop) {
progressUI?.complete(translate(
successKey,
{ count: recipes, recipes, entries, failures },
successText
));
showToast(successKey, { count: recipes, recipes, entries, failures }, failures > 0 ? 'warning' : 'success');
} else if (failures > 0) {
// Nothing matched and at least one recipe
// errored — "no rematch needed" would be
// actively misleading here.
progressUI?.complete(translate(
'globalContextMenu.rematchRecipes.allFailed',
{ total: p.total, recipes, entries, failures },
`Rematch failed for ${failures} of ${p.total} recipes.`
));
showToast('globalContextMenu.rematchRecipes.allFailed', { total: p.total, recipes, entries, failures }, 'error');
} else if (unresolved > 0) {
// Entries existed but have no local model —
// expected for models deleted from Civitai;
// informational, not an error.
const unresolvedRecipes = p.unresolved_recipes ?? 0;
progressUI?.complete(translate(
'globalContextMenu.rematchRecipes.noMatch',
{ entries: unresolved, recipes: unresolvedRecipes, total: p.total, failures },
`No local match found for ${unresolved} entries in ${unresolvedRecipes} recipes.`
));
showToast('globalContextMenu.rematchRecipes.noMatch', { entries: unresolved, recipes: unresolvedRecipes, total: p.total, failures }, 'info');
} else {
// Everything was skipped (nothing to do).
progressUI?.complete(translate( progressUI?.complete(translate(
'globalContextMenu.rematchRecipes.success', 'globalContextMenu.rematchRecipes.success',
{ count: recipes, recipes, entries, failures }, { count: recipes, recipes, entries, failures },
`Matched ${entries} entries across ${recipes} recipes.` `Matched ${entries} entries across ${recipes} recipes.`
)); ));
showToast('globalContextMenu.rematchRecipes.success', { count: recipes, recipes, entries, failures }, 'success'); showToast('globalContextMenu.rematchRecipes.success', { count: recipes, recipes, entries, failures }, 'success');
} else {
progressUI?.complete();
showRematchSummary({
scope: 'global',
total: p.total || 0,
matchedRecipes: recipes,
matchedEntries: entries,
unresolvedRecipes: p.unresolved_recipes ?? 0,
unresolvedEntries: unresolved,
skipped: p.skipped || 0,
errors: failures,
l4Matches,
});
} }
// Refresh recipes page if active // Refresh recipes page if active
if (window.recipesPage) { if (window.recipesPage) {
@@ -469,7 +468,23 @@ export class GlobalContextMenu extends BaseContextMenu {
{ count: cancelledRecipes, recipes: cancelledRecipes, entries: cancelledEntries }, { count: cancelledRecipes, recipes: cancelledRecipes, entries: cancelledEntries },
`Rematch cancelled. ${cancelledRecipes} recipes updated (${cancelledEntries} entries).` `Rematch cancelled. ${cancelledRecipes} recipes updated (${cancelledEntries} entries).`
)); ));
showToast('globalContextMenu.rematchRecipes.cancelled', { count: cancelledRecipes, recipes: cancelledRecipes, entries: cancelledEntries }, 'info'); // A cancelled run still reports partial results
// via the summary modal (marked as cancelled).
showRematchSummary({
scope: 'global',
cancelled: true,
total: p.total || 0,
matchedRecipes: cancelledRecipes,
matchedEntries: cancelledEntries,
unresolvedRecipes: p.unresolved_recipes ?? 0,
unresolvedEntries: p.unresolved_entries ?? 0,
skipped: p.skipped || 0,
errors: p.errors || 0,
l4Matches: Array.isArray(p.l4_matches) ? p.l4_matches : [],
});
if (window.recipesPage) {
window.recipesPage.refresh();
}
} }
} else if (progressResponse.status === 404) { } else if (progressResponse.status === 404) {
// Progress might have finished quickly and been cleaned up // Progress might have finished quickly and been cleaned up
@@ -6,6 +6,8 @@ import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js
import { updateRecipeMetadata } from '../../api/recipeApi.js'; import { updateRecipeMetadata } from '../../api/recipeApi.js';
import { state } from '../../state/index.js'; import { state } from '../../state/index.js';
import { moveManager } from '../../managers/MoveManager.js'; import { moveManager } from '../../managers/MoveManager.js';
import { rematchModalManager } from '../../managers/RematchModalManager.js';
import { showRematchSummary } from '../RematchSummaryModal.js';
import { probeExtension, delegateReimport, getCivitaiImageInfo } from '../../utils/extensionReimportBridge.js'; import { probeExtension, delegateReimport, getCivitaiImageInfo } from '../../utils/extensionReimportBridge.js';
export class RecipeContextMenu extends BaseContextMenu { export class RecipeContextMenu extends BaseContextMenu {
@@ -303,26 +305,36 @@ export class RecipeContextMenu extends BaseContextMenu {
// Capture before any await: the menu's click handler nulls currentCard // Capture before any await: the menu's click handler nulls currentCard
const filePath = this.currentCard?.dataset?.filepath; const filePath = this.currentCard?.dataset?.filepath;
// Collect options (relaxed matching) before starting anything; the
// run only begins when the user confirms the dialog.
rematchModalManager.showOptionsModal({
scope: 'single',
onConfirm: ({ relaxed }) => this._startRematchRecipe(recipeId, filePath, relaxed),
});
}
async _startRematchRecipe(recipeId, filePath, relaxed = false) {
try { try {
showToast('Rematching recipe to local models...', {}, 'info'); showToast('Rematching recipe to local models...', {}, 'info');
const response = await fetch(`/api/lm/recipe/${recipeId}/rematch`, { const response = await fetch(`/api/lm/recipe/${recipeId}/rematch`, {
method: 'POST' method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ relaxed: !!relaxed }),
}); });
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
const matchedEntries = result.matched_entries || result.rematched || 0; const matchedEntries = result.matched_entries || result.rematched || 0;
const failures = result.errors || 0; const failures = result.errors || 0;
const unresolvedEntries = result.unresolved_entries || 0;
const l4Matches = Array.isArray(result.l4_matches) ? result.l4_matches : [];
// Complete no-op (nothing matched, nothing unresolved, no
// errors) keeps the lightweight toast; anything else opens
// the post-run summary modal.
const isNoop = matchedEntries === 0 && unresolvedEntries === 0 && failures === 0;
if (matchedEntries > 0) { if (matchedEntries > 0) {
const toastKey = failures > 0
? 'toast.recipes.rematchCompleteErrors'
: 'toast.recipes.rematchComplete';
showToast(
toastKey,
{ rematched: matchedEntries, skipped: result.skipped || 0, total: 1, entries: matchedEntries, recipes: 1, failures },
failures > 0 ? 'warning' : 'success'
);
const detailResponse = await fetch(`/api/lm/recipe/${recipeId}`); const detailResponse = await fetch(`/api/lm/recipe/${recipeId}`);
if (detailResponse.ok) { if (detailResponse.ok) {
const updatedRecipe = await detailResponse.json(); const updatedRecipe = await detailResponse.json();
@@ -330,16 +342,22 @@ export class RecipeContextMenu extends BaseContextMenu {
state.virtualScroller.updateSingleItem(filePath, updatedRecipe); state.virtualScroller.updateSingleItem(filePath, updatedRecipe);
} }
} }
} else if (result.unresolved_entries > 0) { }
// Entries existed but have no local model — expected for
// models deleted from Civitai; informational, not an error. if (isNoop) {
showToast(
'toast.recipes.rematchUnmatched',
{ entries: result.unresolved_entries, recipes: 1, total: 1 },
'info'
);
} else {
showToast('toast.recipes.rematchSkipped', { total: 1 }, 'info'); showToast('toast.recipes.rematchSkipped', { total: 1 }, 'info');
} else {
showRematchSummary({
scope: 'single',
total: 1,
matchedRecipes: result.matched_recipes || (matchedEntries > 0 ? 1 : 0),
matchedEntries,
unresolvedRecipes: result.unresolved_recipes || 0,
unresolvedEntries,
skipped: result.skipped || 0,
errors: failures,
l4Matches,
});
} }
} else { } else {
throw new Error(result.error || 'Rematch failed'); throw new Error(result.error || 'Rematch failed');
+20 -21
View File
@@ -1,5 +1,5 @@
// Recipe Modal Component // Recipe Modal Component
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, stripLoraTags, sendPromptToWorkflow, sendGenParamsToWorkflow } from '../utils/uiHelpers.js'; import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, stripLoraTags, sendPromptToWorkflow, sendGenParamsToWorkflow, isUnresolvableDownloadError } from '../utils/uiHelpers.js';
import { isModelWeightFile } from '../utils/modelFileTypes.js'; import { isModelWeightFile } from '../utils/modelFileTypes.js';
import { buildCivitaiUrl } from '../utils/civitaiUtils.js'; import { buildCivitaiUrl } from '../utils/civitaiUtils.js';
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
@@ -1078,8 +1078,9 @@ class RecipeModal {
// Mirror the checkpoint "broken" rule: deleted, an // Mirror the checkpoint "broken" rule: deleted, an
// unresolvable hash, or a name-only remnant with no CivitAI // unresolvable hash, or a name-only remnant with no CivitAI
// identifiers at all cannot be fixed by downloading // identifiers at all cannot be fixed by downloading, so no
// reconnecting a local LoRA is the only remediation. // download button is offered. Reconnect is always available
// for missing entries (see renderLoraItemActions).
const needsReconnect = !existsLocally const needsReconnect = !existsLocally
&& (isDeleted || lora.hashInvalid || !this.canDownloadLora(lora)); && (isDeleted || lora.hashInvalid || !this.canDownloadLora(lora));
@@ -1180,7 +1181,7 @@ class RecipeModal {
</div> </div>
${actionsRow} ${actionsRow}
</div> </div>
${needsReconnect ? ` ${!existsLocally ? `
<div class="lora-reconnect-container" data-lora-index="${loraIndex}"> <div class="lora-reconnect-container" data-lora-index="${loraIndex}">
<div class="reconnect-instructions"> <div class="reconnect-instructions">
<p>${escapeHtml(translate('recipes.resources.reconnectInstructions', {}, 'Enter LoRA syntax or name to reconnect:'))}</p> <p>${escapeHtml(translate('recipes.resources.reconnectInstructions', {}, 'Enter LoRA syntax or name to reconnect:'))}</p>
@@ -2853,11 +2854,7 @@ class RecipeModal {
* the model cannot be resolved never for transient transport errors. * the model cannot be resolved never for transient transport errors.
*/ */
_isUnresolvableDownloadError(message) { _isUnresolvableDownloadError(message) {
if (!message) { return isUnresolvableDownloadError(message);
return false;
}
const text = String(message).toLowerCase();
return /(not found|no longer available|deleted|removed|404|410|gone)/.test(text);
} }
getResourceCivitaiUrl(resource) { getResourceCivitaiUrl(resource) {
@@ -2915,19 +2912,9 @@ class RecipeModal {
} }
const controls = []; const controls = [];
if (needsReconnect) { if (!needsReconnect) {
const reconnectLabel = translate('recipes.resources.reconnect', {}, 'Reconnect');
const reconnectTooltip = translate('recipes.resources.reconnectTooltip', {}, 'Reconnect with a local LoRA');
controls.push(`
<button type="button" class="resource-action ghost compact lora-reconnect" data-lora-index="${loraIndex}"
title="${escapeHtml(reconnectTooltip)}" aria-label="${escapeHtml(reconnectTooltip)}">
<i class="fas fa-link" aria-hidden="true"></i>
<span>${escapeHtml(reconnectLabel)}</span>
</button>
`);
} else {
// needsReconnect already implies canDownloadLora() here, so the // needsReconnect already implies canDownloadLora() here, so the
// download action is unconditional. // download action is unconditional in this branch.
const downloadLabel = translate('recipes.resources.download', {}, 'Download'); const downloadLabel = translate('recipes.resources.download', {}, 'Download');
const downloadTooltip = translate('recipes.resources.downloadLoraTooltip', {}, 'Download this LoRA'); const downloadTooltip = translate('recipes.resources.downloadLoraTooltip', {}, 'Download this LoRA');
controls.push(` controls.push(`
@@ -2938,6 +2925,18 @@ class RecipeModal {
</button> </button>
`); `);
} }
// Reconnect is always offered for missing entries — when the LoRA
// already exists locally under a different hash, downloading first
// just to flip the button would be a waste.
const reconnectLabel = translate('recipes.resources.reconnect', {}, 'Reconnect');
const reconnectTooltip = translate('recipes.resources.reconnectTooltip', {}, 'Reconnect with a local LoRA');
controls.push(`
<button type="button" class="resource-action ghost compact lora-reconnect" data-lora-index="${loraIndex}"
title="${escapeHtml(reconnectTooltip)}" aria-label="${escapeHtml(reconnectTooltip)}">
<i class="fas fa-link" aria-hidden="true"></i>
<span>${escapeHtml(reconnectLabel)}</span>
</button>
`);
return `<div class="recipe-lora-actions">${controls.join('')}</div>`; return `<div class="recipe-lora-actions">${controls.join('')}</div>`;
} }
+338
View File
@@ -0,0 +1,338 @@
import { translate } from '../utils/i18nHelpers.js';
import { showToast } from '../utils/uiHelpers.js';
/**
* Escape HTML entities in a string to prevent injection when interpolating
* into innerHTML (same approach as DownloadBatchSummaryModal).
* @param {string} str - The string to escape
* @returns {string} - The escaped string
*/
function _escapeHtml(str) {
if (str === null || str === undefined) return '';
const div = document.createElement('div');
div.textContent = String(str);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
/**
* Resolve the 3-state summary header (mirrors the batch download/import
* summary semantics).
*
* - error: nothing matched and at least one recipe errored
* - warning: errors, unresolved entries, filename-level (L4) matches to
* review, or a cancelled run
* - success: otherwise
*/
function _resolveHeader({ matchedEntries, errors, unresolvedEntries, l4Count, cancelled }) {
if (matchedEntries === 0 && errors > 0) {
return {
state: 'error',
icon: 'fa-times-circle',
text: translate('modals.rematchSummary.failed', {}, 'Rematch failed'),
};
}
if (errors > 0 || unresolvedEntries > 0 || l4Count > 0 || cancelled) {
return {
state: 'warning',
icon: 'fa-exclamation-circle',
text: translate('modals.rematchSummary.completedWithWarnings', {}, 'Rematch completed — review recommended'),
};
}
return {
state: 'success',
icon: 'fa-check-circle',
text: translate('modals.rematchSummary.successMessage', { entries: matchedEntries }, `Matched ${matchedEntries} entries`),
};
}
/**
* Build a plain-text report of the rematch run. `undoneIndexes` carries the
* L4 rows undone so far, so the report reflects the undo status at copy time.
*/
function _buildReportText({ scope, cancelled, total, matchedRecipes, matchedEntries, unresolvedRecipes, unresolvedEntries, skipped, errors, l4Matches, undoneIndexes }) {
const scopeFallbacks = {
global: 'All recipes',
bulk: 'Selected recipes',
single: 'Single recipe',
};
const scopeLabel = translate(
`modals.rematchSummary.scope_${scope}`,
{},
scopeFallbacks[scope] || scope
);
const lines = [
'=== Recipe Rematch Report ===',
`Date: ${new Date().toLocaleString()}`,
`Scope: ${scopeLabel}`,
`Cancelled: ${cancelled ? 'yes' : 'no'}`,
`Total recipes: ${total}`,
`Matched recipes: ${matchedRecipes}`,
`Matched entries: ${matchedEntries}`,
`Needs review (filename matches): ${l4Matches.length}`,
`Unresolved entries: ${unresolvedEntries} (in ${unresolvedRecipes} recipes)`,
`Skipped: ${skipped}`,
`Errors: ${errors}`,
'',
];
if (l4Matches.length > 0) {
lines.push('--- Filename matches (L4) ---');
l4Matches.forEach((match, i) => {
const undone = undoneIndexes.has(i) ? ' [undone]' : '';
lines.push(`${i + 1}. [${match.recipe_id}] ${match.entry} -> ${match.file_name}${undone}`);
});
lines.push('');
}
lines.push('====================');
return lines.join('\n');
}
/**
* Handle a successful clipboard write: confirm via toast and briefly swap the
* trigger button to a "Copied!" state (mirrors the batch summary modal).
*/
function _onCopyReportSuccess(btn) {
showToast('toast.api.copiedToClipboard', {}, 'success');
if (btn) {
const origHTML = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
setTimeout(() => { btn.innerHTML = origHTML; }, 2000);
}
}
/**
* Fallback for environments without the async Clipboard API (e.g. insecure
* contexts over LAN http): copy via a hidden textarea and execCommand.
*/
function _copyReportWithExecCommand(text) {
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
showToast('toast.api.copiedToClipboard', {}, 'success');
}
function _copyReport(btn, reportArgs) {
const text = _buildReportText(reportArgs);
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
navigator.clipboard.writeText(text)
.then(() => _onCopyReportSuccess(btn))
.catch(() => _copyReportWithExecCommand(text));
} else {
_copyReportWithExecCommand(text);
}
}
/**
* Undo a single L4 match via the existing restore endpoints (moved from
* RematchModalManager). Checkpoint restore needs only recipe_id; lora
* restore additionally takes lora_index.
*/
async function _undoMatch(match) {
const isCheckpoint = match.type === 'checkpoint';
const body = isCheckpoint
? { recipe_id: match.recipe_id }
: { recipe_id: match.recipe_id, lora_index: match.lora_index };
const response = await fetch(
isCheckpoint
? '/api/lm/recipe/checkpoint/restore'
: '/api/lm/recipe/lora/restore',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}
);
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || 'Restore failed');
}
}
/**
* Show the post-run rematch summary modal. Mirrors the batch download
* summary lifecycle: the modal element is appended directly to
* document.body and removed on close; it is not registered with
* ModalManager.
*
* @param {Object} options
* @param {'global'|'bulk'|'single'} options.scope - Which entry point ran
* @param {boolean} options.cancelled - Whether the run was cancelled
* @param {number} options.total - Recipes scanned
* @param {number} options.matchedRecipes - Recipes updated
* @param {number} options.matchedEntries - Entries reconnected
* @param {number} options.unresolvedRecipes - Recipes with unresolved entries
* @param {number} options.unresolvedEntries - Candidate entries with no local match
* @param {number} options.skipped - Recipes left untouched
* @param {number} options.errors - Per-recipe errors
* @param {Array} options.l4Matches - Filename-level matches for review/undo
* ({ recipe_id, type, entry, file_name, lora_index? })
*/
export function showRematchSummary({
scope = 'global',
cancelled = false,
total = 0,
matchedRecipes = 0,
matchedEntries = 0,
unresolvedRecipes = 0,
unresolvedEntries = 0,
skipped = 0,
errors = 0,
l4Matches = [],
} = {}) {
const matches = Array.isArray(l4Matches) ? l4Matches : [];
const undoneIndexes = new Set();
const header = _resolveHeader({
matchedEntries,
errors,
unresolvedEntries,
l4Count: matches.length,
cancelled,
});
const matchRows = matches.map((match, i) => `
<tr data-l4-index="${i}">
<td class="failure-index">${i + 1}</td>
<td class="failure-name" title="${_escapeHtml(match.recipe_id)}">${_escapeHtml(match.recipe_id)}</td>
<td class="failure-name" title="${_escapeHtml(match.entry)}">${_escapeHtml(match.entry)}</td>
<td class="failure-name" title="${_escapeHtml(match.file_name)}">${_escapeHtml(match.file_name)}</td>
<td class="rematch-undo-cell">
<button class="secondary-btn rematch-undo-btn" data-action="undo-match" data-index="${i}">
${translate('modals.rematchResults.undo', {}, 'Undo')}
</button>
</td>
</tr>`).join('');
const modalHtml = `
<div id="rematchSummaryModal" class="modal" style="display: block;">
<div class="modal-content rematch-summary-modal">
<button class="close" data-action="close-modal">&times;</button>
<h2>${translate('modals.rematchSummary.title', {}, 'Rematch Summary')}</h2>
<div class="summary-header ${header.state}">
<i class="fas ${header.icon}"></i>
<span class="summary-title">${header.text}</span>
<span class="summary-hint">${matchedRecipes}/${total}</span>
</div>
${cancelled ? `
<p class="rematch-cancelled-note">
<i class="fas fa-info-circle"></i>
${translate('modals.rematchSummary.cancelledNote', {}, 'Run cancelled before completion — counts are partial.')}
</p>` : ''}
<div class="refresh-summary-stats">
<div class="stat-card stat-card-success">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.rematchSummary.statMatched', {}, 'Matched entries')}</span>
<span class="stat-card-value">${matchedEntries}</span>
</div>
</div>
<div class="stat-card stat-card-skipped">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.rematchSummary.statReview', {}, 'Needs review')}</span>
<span class="stat-card-value">${matches.length}</span>
</div>
</div>
<div class="stat-card stat-card-total">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.rematchSummary.statUnresolved', {}, 'Unresolved')}</span>
<span class="stat-card-value">${unresolvedEntries}</span>
</div>
</div>
<div class="stat-card stat-card-failure">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.rematchSummary.statErrors', {}, 'Errors')}</span>
<span class="stat-card-value">${errors}</span>
</div>
</div>
</div>
${matches.length > 0 ? `
<div class="refresh-failures-section rematch-review-section">
<h4><i class="fas fa-exclamation-triangle"></i> ${translate('modals.rematchSummary.reviewSection', { count: matches.length }, `Filename matches to review (${matches.length})`)}</h4>
<div class="failure-table-wrapper">
<table class="failure-table">
<thead>
<tr>
<th>#</th>
<th>${translate('modals.rematchSummary.columnRecipe', {}, 'Recipe')}</th>
<th>${translate('modals.rematchSummary.columnEntry', {}, 'Entry')}</th>
<th>${translate('modals.rematchSummary.columnFile', {}, 'Matched file')}</th>
<th>${translate('modals.rematchSummary.columnUndo', {}, 'Undo')}</th>
</tr>
</thead>
<tbody>${matchRows}</tbody>
</table>
</div>
</div>
` : ''}
<div class="modal-actions">
<button class="secondary-btn" data-action="copy-report"><i class="fas fa-copy"></i> ${translate('modals.rematchSummary.copyReport', {}, 'Copy Report')}</button>
<button class="cancel-btn" data-action="close-modal">${translate('modals.rematchSummary.close', {}, 'Close')}</button>
</div>
</div>
</div>
`;
const existing = document.getElementById('rematchSummaryModal');
if (existing) existing.remove();
const container = document.createElement('div');
container.innerHTML = modalHtml;
const modal = container.firstElementChild;
document.body.appendChild(modal);
const reportArgs = {
scope,
cancelled,
total,
matchedRecipes,
matchedEntries,
unresolvedRecipes,
unresolvedEntries,
skipped,
errors,
l4Matches: matches,
undoneIndexes,
};
modal.addEventListener('click', async (e) => {
const actionEl = e.target.closest('[data-action]');
const action = actionEl?.dataset.action;
if (!action) return;
e.preventDefault();
switch (action) {
case 'close-modal':
modal.remove();
break;
case 'copy-report':
_copyReport(actionEl, reportArgs);
break;
case 'undo-match': {
const index = Number(actionEl.dataset.index);
const match = matches[index];
if (!match || actionEl.disabled) break;
const row = modal.querySelector(`tr[data-l4-index="${index}"]`);
try {
await _undoMatch(match);
undoneIndexes.add(index);
row?.classList.add('undone');
actionEl.disabled = true;
actionEl.textContent = translate('modals.rematchResults.undone', {}, 'Undone');
} catch (error) {
console.error('Failed to undo rematch match:', error);
showToast(
'modals.rematchResults.undoFailed',
{ message: error.message },
'error'
);
}
break;
}
}
});
}
+2
View File
@@ -7,6 +7,7 @@ import { HeaderManager } from './components/Header.js';
import { settingsManager } from './managers/SettingsManager.js'; import { settingsManager } from './managers/SettingsManager.js';
import { moveManager } from './managers/MoveManager.js'; import { moveManager } from './managers/MoveManager.js';
import { bulkManager } from './managers/BulkManager.js'; import { bulkManager } from './managers/BulkManager.js';
import { rematchModalManager } from './managers/RematchModalManager.js';
import { ExampleImagesManager } from './managers/ExampleImagesManager.js'; import { ExampleImagesManager } from './managers/ExampleImagesManager.js';
import { helpManager } from './managers/HelpManager.js'; import { helpManager } from './managers/HelpManager.js';
import { doctorManager } from './managers/DoctorManager.js'; import { doctorManager } from './managers/DoctorManager.js';
@@ -68,6 +69,7 @@ export class AppCore {
window.doctorManager = doctorManager; window.doctorManager = doctorManager;
window.moveManager = moveManager; window.moveManager = moveManager;
window.bulkManager = bulkManager; window.bulkManager = bulkManager;
window.rematchModalManager = rematchModalManager;
// Initialize UI components // Initialize UI components
window.headerManager = new HeaderManager(); window.headerManager = new HeaderManager();
+30 -28
View File
@@ -3,6 +3,8 @@ import { showToast, showActionToast, copyToClipboard, sendLoraToWorkflow, sendEm
import { handleUndoDelete } from '../utils/undoHelpers.js'; import { handleUndoDelete } from '../utils/undoHelpers.js';
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js'; import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
import { modalManager } from './ModalManager.js'; import { modalManager } from './ModalManager.js';
import { rematchModalManager } from './RematchModalManager.js';
import { showRematchSummary } from '../components/RematchSummaryModal.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js'; import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
import { RecipeSidebarApiClient, updateRecipeMetadata, extractRecipeId } from '../api/recipeApi.js'; import { RecipeSidebarApiClient, updateRecipeMetadata, extractRecipeId } from '../api/recipeApi.js';
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js'; import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
@@ -978,6 +980,15 @@ export class BulkManager {
return; return;
} }
// Collect options (relaxed matching) before starting anything; the
// run only begins when the user confirms the dialog.
rematchModalManager.showOptionsModal({
recipeCount: state.selectedModels.size,
onConfirm: ({ relaxed }) => this._startRematchSelectedRecipes(relaxed),
});
}
async _startRematchSelectedRecipes(relaxed = false) {
try { try {
const apiClient = this.getActiveApiClient(); const apiClient = this.getActiveApiClient();
const filePaths = Array.from(state.selectedModels); const filePaths = Array.from(state.selectedModels);
@@ -989,7 +1000,7 @@ export class BulkManager {
state.loadingManager.showSimpleLoading('Rematching recipes to local models...'); state.loadingManager.showSimpleLoading('Rematching recipes to local models...');
const result = await apiClient.rematchBulkModels(filePaths); const result = await apiClient.rematchBulkModels(filePaths, { relaxed: !!relaxed });
if (result.success) { if (result.success) {
const total = result.total || filePaths.length; const total = result.total || filePaths.length;
@@ -1015,38 +1026,29 @@ export class BulkManager {
} }
} }
if (matchedEntries > 0) { // Complete no-op (nothing matched, nothing unresolved, no
const hasFailures = failures > 0; // errors) keeps the lightweight toast; anything else opens
const toastKey = hasFailures // the post-run summary modal.
? 'toast.recipes.rematchCompleteErrors' const l4Matches = Array.isArray(result.l4_matches) ? result.l4_matches : [];
: 'toast.recipes.rematchComplete'; const isNoop = matchedEntries === 0 && unresolvedEntries === 0 && failures === 0;
showToast( if (isNoop) {
toastKey,
{ rematched, skipped, total, entries: matchedEntries, recipes: matchedRecipes, failures },
hasFailures ? 'warning' : 'success'
);
} else if (failures > 0) {
// Nothing matched and at least one recipe errored —
// "no rematch needed" would be actively misleading here.
showToast(
'toast.recipes.rematchAllFailed',
{ total, failures },
'error'
);
} else if (unresolvedEntries > 0) {
// Entries existed but have no local model — expected for
// models deleted from Civitai; informational, not an error.
showToast(
'toast.recipes.rematchUnmatched',
{ entries: unresolvedEntries, recipes: unresolvedRecipes, total },
'info'
);
} else {
showToast( showToast(
'toast.recipes.rematchSkipped', 'toast.recipes.rematchSkipped',
{ total }, { total },
'info' 'info'
); );
} else {
showRematchSummary({
scope: 'bulk',
total,
matchedRecipes,
matchedEntries,
unresolvedRecipes,
unresolvedEntries,
skipped,
errors: failures,
l4Matches,
});
} }
if (state.bulkMode) this.toggleBulkMode(); if (state.bulkMode) this.toggleBulkMode();
@@ -1,7 +1,9 @@
import { showToast } from '../utils/uiHelpers.js'; import { showToast } from '../utils/uiHelpers.js';
import { isUnresolvableDownloadError } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
import { getModelApiClient } from '../api/modelApiFactory.js'; import { getModelApiClient } from '../api/modelApiFactory.js';
import { MODEL_TYPES } from '../api/apiConfig.js'; import { MODEL_TYPES } from '../api/apiConfig.js';
import { extractRecipeId } from '../api/recipeApi.js';
import { state } from '../state/index.js'; import { state } from '../state/index.js';
import { modalManager } from './ModalManager.js'; import { modalManager } from './ModalManager.js';
@@ -13,6 +15,7 @@ export class BulkMissingLoraDownloadManager {
this.loraApiClient = getModelApiClient(MODEL_TYPES.LORA); this.loraApiClient = getModelApiClient(MODEL_TYPES.LORA);
this.pendingLoras = []; this.pendingLoras = [];
this.pendingRecipes = []; this.pendingRecipes = [];
this.pendingMissingByRecipe = null;
} }
/** /**
@@ -136,6 +139,7 @@ export class BulkMissingLoraDownloadManager {
// Execute download // Execute download
await this.executeDownload(this.pendingLoras); await this.executeDownload(this.pendingLoras);
this.pendingLoras = []; this.pendingLoras = [];
this.pendingMissingByRecipe = null;
} }
/** /**
@@ -153,6 +157,9 @@ export class BulkMissingLoraDownloadManager {
// Collect missing LoRAs with deduplication // Collect missing LoRAs with deduplication
const stats = this.collectMissingLoras(selectedRecipes); const stats = this.collectMissingLoras(selectedRecipes);
// Kept so executeDownload can mark unresolvable failures back onto
// every recipe occurrence (hashInvalid → reconnect candidacy).
this.pendingMissingByRecipe = stats.missingLorasByRecipe;
if (stats.uniqueCount === 0) { if (stats.uniqueCount === 0) {
showToast('toast.recipes.noMissingLorasInSelection', {}, 'info'); showToast('toast.recipes.noMissingLorasInSelection', {}, 'info');
@@ -196,6 +203,7 @@ export class BulkMissingLoraDownloadManager {
let completedDownloads = 0; let completedDownloads = 0;
let failedDownloads = 0; let failedDownloads = 0;
let markedInvalidCount = 0;
let currentLoraProgress = 0; let currentLoraProgress = 0;
let cancelled = false; let cancelled = false;
@@ -304,6 +312,12 @@ export class BulkMissingLoraDownloadManager {
if (!response.success) { if (!response.success) {
console.error(`Failed to download LoRA ${lora.name || lora.file_name}: ${response.error}`); console.error(`Failed to download LoRA ${lora.name || lora.file_name}: ${response.error}`);
failedDownloads++; failedDownloads++;
// An unresolvable failure (model gone on CivitAI) flips
// every recipe occurrence to reconnect candidacy — same
// rule as the single-LoRA download in RecipeModal.
if (isUnresolvableDownloadError(response.error)) {
markedInvalidCount += await this.markLoraHashInvalidInRecipes(lora);
}
} else { } else {
completedDownloads++; completedDownloads++;
updateProgress(100, completedDownloads, ''); updateProgress(100, completedDownloads, '');
@@ -312,6 +326,9 @@ export class BulkMissingLoraDownloadManager {
if (!cancelled) { if (!cancelled) {
console.error(`Error downloading LoRA ${lora.name || lora.file_name}:`, error); console.error(`Error downloading LoRA ${lora.name || lora.file_name}:`, error);
failedDownloads++; failedDownloads++;
if (isUnresolvableDownloadError(error?.message)) {
markedInvalidCount += await this.markLoraHashInvalidInRecipes(lora);
}
} }
} }
} }
@@ -335,9 +352,16 @@ export class BulkMissingLoraDownloadManager {
}, 'warning'); }, 'warning');
} }
// Unresolvable failures were marked hash-invalid during the loop;
// tell the user those entries now offer reconnect instead of download.
if (markedInvalidCount > 0) {
showToast('toast.recipes.unresolvableMarkedForReconnect', {
count: markedInvalidCount
}, 'info', `${markedInvalidCount} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.`);
}
// Update each affected recipe card with fresh data (LoRA inLibrary flags changed) // Update each affected recipe card with fresh data (LoRA inLibrary flags changed)
if (state.virtualScroller) { if (state.virtualScroller) {
const { extractRecipeId } = await import('../api/recipeApi.js');
for (const recipe of this.pendingRecipes) { for (const recipe of this.pendingRecipes) {
const recipeId = extractRecipeId(recipe.file_path); const recipeId = extractRecipeId(recipe.file_path);
if (!recipeId) continue; if (!recipeId) continue;
@@ -354,6 +378,59 @@ export class BulkMissingLoraDownloadManager {
} }
} }
/**
* Mark every recipe occurrence of a failed LoRA as hash-invalid.
*
* Mirrors RecipeModal.markLoraHashInvalid for the bulk flow: the flag
* makes each occurrence an unresolved rematch candidate and swaps its
* action from download to reconnect. Only called for unresolvable
* failures transient errors leave entries untouched.
*
* @param {Object} failedLora - The deduplicated LoRA that failed
* @returns {Promise<number>} - How many recipe entries were marked
*/
async markLoraHashInvalidInRecipes(failedLora) {
const failedKey = failedLora.hash || failedLora.id || failedLora.modelVersionId;
if (!failedKey || !this.pendingMissingByRecipe) {
return 0;
}
let marked = 0;
for (const { recipe, missingLoras } of this.pendingMissingByRecipe.values()) {
const recipeId = extractRecipeId(recipe.file_path) || recipe.id;
if (!recipeId || !Array.isArray(recipe.loras)) {
continue;
}
for (const entry of missingLoras) {
const entryKey = entry.hash || entry.id || entry.modelVersionId;
if (entryKey !== failedKey) {
continue;
}
const loraIndex = recipe.loras.indexOf(entry);
if (loraIndex < 0) {
continue;
}
try {
const response = await fetch('/api/lm/recipe/lora/mark-hash-invalid', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recipe_id: recipeId,
lora_index: loraIndex,
}),
});
if (response.ok) {
entry.hashInvalid = true;
marked++;
}
} catch (error) {
console.warn('Failed to mark LoRA hash invalid:', error);
}
}
}
return marked;
}
/** /**
* Get LoRA root directory from API * Get LoRA root directory from API
* @returns {Promise<string|null>} - LoRA root directory or null * @returns {Promise<string|null>} - LoRA root directory or null
+3 -2
View File
@@ -489,8 +489,9 @@ export class DownloadManager {
return { type: 'civitai' }; return { type: 'civitai' };
} }
// Hugging Face resolve URL → direct file // Hugging Face resolve/blob URL → direct file
const hfResolveMatch = trimmed.match(/huggingface\.co\/([^/\s]+\/[^/\s]+)\/resolve\/([^/\s]+)\/(.+)/i); // "blob" is the web preview page; it maps 1:1 to the "resolve" download URL
const hfResolveMatch = trimmed.match(/huggingface\.co\/([^/\s]+\/[^/\s]+)\/(?:resolve|blob)\/([^/\s]+)\/(.+)/i);
if (hfResolveMatch) { if (hfResolveMatch) {
return { return {
type: 'hf-resolve', type: 'hf-resolve',
+13
View File
@@ -347,6 +347,19 @@ export class ModalManager {
}); });
} }
// Register rematchOptionsModal
const rematchOptionsModal = document.getElementById('rematchOptionsModal');
if (rematchOptionsModal) {
this.registerModal('rematchOptionsModal', {
element: rematchOptionsModal,
onClose: () => {
this.getModal('rematchOptionsModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
document.addEventListener('keydown', this.boundHandleEscape); document.addEventListener('keydown', this.boundHandleEscape);
this.initialized = true; this.initialized = true;
} }
+72
View File
@@ -0,0 +1,72 @@
import { modalManager } from './ModalManager.js';
import { translate } from '../utils/i18nHelpers.js';
/**
* Owns the recipe-rematch options modal (rematchOptionsModal), shown BEFORE
* a global/bulk/single rematch run; collects the "relaxed matching" opt-in
* and only then invokes the run callback. Post-run reporting lives in
* static/js/components/RematchSummaryModal.js.
*/
export class RematchModalManager {
constructor() {
this._optionsConfirmCallback = null;
}
/**
* Open the options modal. `onConfirm({ relaxed })` fires only when the
* user clicks Rematch Cancel/X runs nothing.
*
* @param {{ scope?: 'global'|'bulk'|'single', recipeCount?: number|null, onConfirm?: function }} options
*/
showOptionsModal({ scope = null, recipeCount = null, onConfirm } = {}) {
const resolvedScope = scope || (recipeCount != null ? 'bulk' : 'global');
const message = document.getElementById('rematchOptionsMessage');
if (message) {
if (resolvedScope === 'bulk') {
message.textContent = translate(
'modals.rematchOptions.messageBulk',
{ count: recipeCount },
`${recipeCount} selected recipe(s) will be scanned against your local model library.`
);
} else if (resolvedScope === 'single') {
message.textContent = translate(
'modals.rematchOptions.messageSingle',
{},
'This recipe will be scanned against your local model library.'
);
} else {
message.textContent = translate(
'modals.rematchOptions.messageGlobal',
{},
'All recipes will be scanned against your local model library.'
);
}
}
const checkbox = document.getElementById('rematchOptionsRelaxed');
if (checkbox) {
checkbox.checked = false;
}
this._optionsConfirmCallback = typeof onConfirm === 'function' ? onConfirm : null;
modalManager.showModal('rematchOptionsModal');
}
confirmOptions() {
const checkbox = document.getElementById('rematchOptionsRelaxed');
const relaxed = checkbox ? !!checkbox.checked : false;
const callback = this._optionsConfirmCallback;
this._optionsConfirmCallback = null;
modalManager.closeModal('rematchOptionsModal');
if (callback) {
// Returned so callers (and tests) can await the started run.
return callback({ relaxed });
}
return undefined;
}
cancelOptions() {
this._optionsConfirmCallback = null;
modalManager.closeModal('rematchOptionsModal');
}
}
export const rematchModalManager = new RematchModalManager();
+17
View File
@@ -325,6 +325,23 @@ export function isTypingContext(target) {
return target.isContentEditable || tagName === 'input' || tagName === 'textarea' || tagName === 'select'; return target.isContentEditable || tagName === 'input' || tagName === 'textarea' || tagName === 'select';
} }
/**
* Decide whether a download failure means the model is unrecoverable.
*
* The hash-invalid flag (and the resulting rematch/reconnect candidacy) is
* only set when CivitAI explicitly says the model cannot be resolved never
* for transient transport errors (network, 5xx).
* @param {*} message - The error message carried by the failed download
* @returns {boolean}
*/
export function isUnresolvableDownloadError(message) {
if (!message) {
return false;
}
const text = String(message).toLowerCase();
return /(not found|no longer available|deleted|removed|404|410|gone)/.test(text);
}
export function restoreFolderFilter() { export function restoreFolderFilter() {
const activeFolder = getStorageItem('activeFolder'); const activeFolder = getStorageItem('activeFolder');
const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`); const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`);
@@ -125,4 +125,35 @@
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<!-- Recipe Rematch Options Modal -->
<div id="rematchOptionsModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>{{ t('modals.rematchOptions.title') }}</h2>
<span class="close" onclick="rematchModalManager.cancelOptions()">&times;</span>
</div>
<div class="modal-body">
<p class="confirmation-message" id="rematchOptionsMessage"></p>
<label class="rematch-option-card" for="rematchOptionsRelaxed">
<input type="checkbox" id="rematchOptionsRelaxed">
<span class="rematch-option-checkmark" aria-hidden="true"></span>
<span class="rematch-option-text">
<span class="rematch-option-title">{{ t('modals.rematchOptions.relaxedLabel') }}</span>
<span class="rematch-option-caveat">
<i class="fas fa-info-circle" aria-hidden="true"></i>
{{ t('modals.rematchOptions.relaxedDescription') }}
</span>
</span>
</label>
</div>
<div class="modal-actions">
<button class="secondary-btn" onclick="rematchModalManager.cancelOptions()">{{ t('common.actions.cancel') }}</button>
<button class="primary-btn" id="rematchOptionsConfirmBtn" onclick="rematchModalManager.confirmOptions()">
<i class="fas fa-sync-alt"></i>
{{ t('modals.rematchOptions.confirmButton') }}
</button>
</div>
</div>
</div>
@@ -100,7 +100,7 @@ def evaluate_model(
flagged issues. flagged issues.
""" """
civitai = metadata.get("civitai") or {} civitai = metadata.get("civitai") or {}
trained_words: List[str] = civitai.get("trainedWords") or metadata.get("trainedWords") or [] trained_words: List[str] = civitai.get("trainedWords") or []
short_desc: str = civitai.get("description") or "" short_desc: str = civitai.get("description") or ""
tags: List[str] = metadata.get("tags") or [] tags: List[str] = metadata.get("tags") or []
notes: str = metadata.get("notes") or "" notes: str = metadata.get("notes") or ""
@@ -149,7 +149,6 @@ def create_initial_metadata(
"metadata_source": "", "metadata_source": "",
"last_checked_at": 0, "last_checked_at": 0,
"hash_status": "completed", "hash_status": "completed",
"trainedWords": [],
"hf_url": hf_url, "hf_url": hf_url,
"usage_tips": "{}", "usage_tips": "{}",
} }
+15
View File
@@ -309,6 +309,21 @@ describe('RecipeSidebarApiClient bulk operations', () => {
expect(global.fetch).not.toHaveBeenCalled(); expect(global.fetch).not.toHaveBeenCalled();
}); });
it('includes relaxed in the bulk rematch body only when opted in', async () => {
const api = new RecipeSidebarApiClient();
global.fetch.mockResolvedValue({
ok: true,
json: async () => ({ success: true, total: 1, rematched: 1, skipped: 0, errors: 0, recipes: [] }),
});
await api.rematchBulkModels(['/recipes/a.webp'], { relaxed: true });
expect(JSON.parse(global.fetch.mock.calls[0][1].body)).toEqual({
recipe_ids: ['a'],
relaxed: true,
});
});
it('throws the backend error when bulk rematch fails', async () => { it('throws the backend error when bulk rematch fails', async () => {
const api = new RecipeSidebarApiClient(); const api = new RecipeSidebarApiClient();
global.fetch.mockResolvedValue({ global.fetch.mockResolvedValue({
@@ -0,0 +1,233 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const showToastMock = vi.fn();
const translateMock = vi.fn((key, params, fallback) => {
if (typeof fallback === 'string') {
// Apply {param} interpolation so counts remain assertable.
return Object.entries(params || {}).reduce(
(text, [name, value]) => text.replaceAll(`{${name}}`, String(value)),
fallback
);
}
return key;
});
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: translateMock,
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
}));
async function getShowRematchSummary() {
const { showRematchSummary } = await import(
'../../../static/js/components/RematchSummaryModal.js'
);
return showRematchSummary;
}
const L4_LORA = { recipe_id: 'r1', type: 'lora', entry: 'old.safetensors', file_name: 'new.safetensors', lora_index: 2 };
const L4_CHECKPOINT = { recipe_id: 'r2', type: 'checkpoint', entry: 'cp-old', file_name: 'cp-new.safetensors' };
describe('RematchSummaryModal', () => {
beforeEach(() => {
vi.clearAllMocks();
document.body.innerHTML = '';
global.fetch = vi.fn();
});
afterEach(() => {
document.body.innerHTML = '';
delete global.fetch;
delete navigator.clipboard;
vi.restoreAllMocks();
});
it('renders a success header when everything matched cleanly', async () => {
const showRematchSummary = await getShowRematchSummary();
showRematchSummary({ scope: 'global', total: 10, matchedRecipes: 2, matchedEntries: 3 });
const modal = document.getElementById('rematchSummaryModal');
expect(modal).not.toBeNull();
expect(modal.querySelector('.summary-header').classList.contains('success')).toBe(true);
expect(modal.querySelector('.summary-title').textContent).toBe('Matched 3 entries');
expect(modal.querySelector('.failure-table')).toBeNull();
});
it('renders an error header when nothing matched and errors occurred', async () => {
const showRematchSummary = await getShowRematchSummary();
showRematchSummary({ scope: 'global', total: 3, errors: 3 });
const modal = document.getElementById('rematchSummaryModal');
expect(modal.querySelector('.summary-header').classList.contains('error')).toBe(true);
expect(modal.querySelector('.summary-title').textContent).toBe('Rematch failed');
});
it('renders a warning header for unresolved entries, L4 matches, or cancellations', async () => {
const showRematchSummary = await getShowRematchSummary();
showRematchSummary({ scope: 'bulk', total: 2, matchedEntries: 1, unresolvedEntries: 1, unresolvedRecipes: 1 });
expect(document.querySelector('#rematchSummaryModal .summary-header').classList.contains('warning')).toBe(true);
showRematchSummary({ scope: 'bulk', total: 2, matchedEntries: 2, l4Matches: [L4_LORA] });
expect(document.querySelector('#rematchSummaryModal .summary-header').classList.contains('warning')).toBe(true);
showRematchSummary({ scope: 'global', total: 5, matchedEntries: 2, cancelled: true });
const modal = document.getElementById('rematchSummaryModal');
expect(modal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
expect(modal.querySelector('.rematch-cancelled-note')).not.toBeNull();
});
it('renders the four stat cards in order', async () => {
const showRematchSummary = await getShowRematchSummary();
showRematchSummary({
scope: 'bulk',
total: 4,
matchedRecipes: 1,
matchedEntries: 2,
unresolvedEntries: 3,
errors: 1,
l4Matches: [L4_LORA],
});
const modal = document.getElementById('rematchSummaryModal');
const values = Array.from(modal.querySelectorAll('.stat-card-value')).map(el => el.textContent);
expect(values).toEqual(['2', '1', '3', '1']);
const labels = Array.from(modal.querySelectorAll('.stat-card-label')).map(el => el.textContent);
expect(labels).toEqual(['Matched entries', 'Needs review', 'Unresolved', 'Errors']);
});
it('renders the L4 review table only when matches exist', async () => {
const showRematchSummary = await getShowRematchSummary();
showRematchSummary({ scope: 'bulk', total: 2, matchedEntries: 2, l4Matches: [L4_LORA, L4_CHECKPOINT] });
const modal = document.getElementById('rematchSummaryModal');
const rows = modal.querySelectorAll('.failure-table tbody tr');
expect(rows).toHaveLength(2);
expect(rows[0].textContent).toContain('r1');
expect(rows[0].textContent).toContain('old.safetensors');
expect(rows[0].textContent).toContain('new.safetensors');
expect(rows[1].textContent).toContain('cp-new.safetensors');
expect(modal.querySelectorAll('.rematch-undo-btn')).toHaveLength(2);
});
it('undo posts to the lora restore endpoint, then strikes and disables the row', async () => {
const showRematchSummary = await getShowRematchSummary();
global.fetch.mockResolvedValue({ ok: true, json: async () => ({ success: true }) });
showRematchSummary({ scope: 'bulk', total: 1, matchedEntries: 1, l4Matches: [L4_LORA] });
const modal = document.getElementById('rematchSummaryModal');
const row = modal.querySelector('tr[data-l4-index="0"]');
const button = row.querySelector('.rematch-undo-btn');
button.click();
await vi.waitFor(() => expect(button.disabled).toBe(true));
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/lora/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ recipe_id: 'r1', lora_index: 2 }),
});
expect(row.classList.contains('undone')).toBe(true);
expect(button.textContent).toBe('Undone');
});
it('undo posts to the checkpoint restore endpoint with recipe_id only', async () => {
const showRematchSummary = await getShowRematchSummary();
global.fetch.mockResolvedValue({ ok: true, json: async () => ({ success: true }) });
showRematchSummary({ scope: 'bulk', total: 1, matchedEntries: 1, l4Matches: [L4_CHECKPOINT] });
const button = document.querySelector('.rematch-undo-btn');
button.click();
await vi.waitFor(() => expect(button.disabled).toBe(true));
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/checkpoint/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ recipe_id: 'r2' }),
});
});
it('keeps the row actionable and toasts when undo fails', async () => {
const showRematchSummary = await getShowRematchSummary();
global.fetch.mockResolvedValue({ ok: true, json: async () => ({ success: false, error: 'no snapshot' }) });
showRematchSummary({ scope: 'bulk', total: 1, matchedEntries: 1, l4Matches: [L4_LORA] });
const row = document.querySelector('tr[data-l4-index="0"]');
const button = row.querySelector('.rematch-undo-btn');
button.click();
await vi.waitFor(() => expect(showToastMock).toHaveBeenCalled());
expect(button.disabled).toBe(false);
expect(row.classList.contains('undone')).toBe(false);
expect(showToastMock).toHaveBeenCalledWith(
'modals.rematchResults.undoFailed',
{ message: 'no snapshot' },
'error'
);
});
it('copy report includes scope, counts and the L4 list with undo status', async () => {
const showRematchSummary = await getShowRematchSummary();
global.fetch.mockResolvedValue({ ok: true, json: async () => ({ success: true }) });
const writeText = vi.fn().mockResolvedValue(undefined);
navigator.clipboard = { writeText };
showRematchSummary({
scope: 'bulk',
total: 2,
matchedRecipes: 1,
matchedEntries: 2,
unresolvedEntries: 1,
unresolvedRecipes: 1,
skipped: 0,
errors: 0,
l4Matches: [L4_LORA, L4_CHECKPOINT],
});
// Undo the first row before copying so the report carries its status.
const undoButton = document.querySelector('tr[data-l4-index="0"] .rematch-undo-btn');
undoButton.click();
await vi.waitFor(() => expect(undoButton.disabled).toBe(true));
document.querySelector('[data-action="copy-report"]').click();
await vi.waitFor(() => expect(writeText).toHaveBeenCalled());
const report = writeText.mock.calls[0][0];
expect(report).toContain('Scope: Selected recipes');
expect(report).toContain('Total recipes: 2');
expect(report).toContain('Matched entries: 2');
expect(report).toContain('Needs review (filename matches): 2');
expect(report).toContain('Unresolved entries: 1 (in 1 recipes)');
expect(report).toContain('[r1] old.safetensors -> new.safetensors [undone]');
expect(report).toContain('[r2] cp-old -> cp-new.safetensors');
// The success toast fires in the writeText .then() microtask.
await vi.waitFor(() => expect(showToastMock).toHaveBeenCalledWith('toast.api.copiedToClipboard', {}, 'success'));
});
it('close removes the modal from the DOM', async () => {
const showRematchSummary = await getShowRematchSummary();
showRematchSummary({ scope: 'single', total: 1, matchedEntries: 1 });
expect(document.getElementById('rematchSummaryModal')).not.toBeNull();
document.querySelector('[data-action="close-modal"].cancel-btn').click();
expect(document.getElementById('rematchSummaryModal')).toBeNull();
});
it('escapes HTML in L4 row fields', async () => {
const showRematchSummary = await getShowRematchSummary();
showRematchSummary({
scope: 'bulk',
total: 1,
matchedEntries: 1,
l4Matches: [{ recipe_id: 'r<x>', type: 'lora', entry: '<img src=x>', file_name: 'f.safetensors', lora_index: 0 }],
});
const modal = document.getElementById('rematchSummaryModal');
expect(modal.querySelector('.failure-table img')).toBeNull();
expect(modal.querySelector('.failure-table tbody tr').textContent).toContain('<img src=x>');
});
});
@@ -0,0 +1,58 @@
import { describe, it, expect, vi } from 'vitest';
const {
API_MODULE,
APP_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,
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(),
},
}));
describe('formatAutocompleteTextOnBlur', () => {
it('preserves repeated spaces inside LoRA names', async () => {
const { formatAutocompleteTextOnBlur } = await import(AUTOCOMPLETE_MODULE);
expect(formatAutocompleteTextOnBlur('<lora:test - 0021:1.00>')).toBe(
'<lora:test - 0021:1.00>'
);
});
it('preserves repeated spaces across multiple LoRA entries', async () => {
const { formatAutocompleteTextOnBlur } = await import(AUTOCOMPLETE_MODULE);
expect(
formatAutocompleteTextOnBlur('<lora:test - 0021:1.00>,<lora:a b:0.50>')
).toBe('<lora:test - 0021:1.00>, <lora:a b:0.50>');
});
it('still normalizes whitespace outside LoRA tags', async () => {
const { formatAutocompleteTextOnBlur } = await import(AUTOCOMPLETE_MODULE);
expect(formatAutocompleteTextOnBlur('masterpiece, best quality')).toBe(
'masterpiece, best quality'
);
});
});
@@ -143,6 +143,16 @@ async function flushAsyncTasks() {
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
} }
// The real RematchModalManager runs against the mocked modalManager; the
// global rematch menu action now opens the options dialog first and only
// starts once confirmOptions() is invoked (the user clicking Rematch).
async function getRematchModalManager() {
const { rematchModalManager } = await import(
'../../../static/js/managers/RematchModalManager.js'
);
return rematchModalManager;
}
function createDeferred() { function createDeferred() {
let resolve; let resolve;
let reject; let reject;
@@ -2223,7 +2233,7 @@ describe('Interaction-level regression coverage', () => {
expect(downloadExampleImagesApiMock).toHaveBeenCalledWith(['abc123hash'], null, { force: true }); expect(downloadExampleImagesApiMock).toHaveBeenCalledWith(['abc123hash'], null, { force: true });
}); });
it('runs global recipe rematch with polling and toasts the rematched count', async () => { it('runs global recipe rematch with polling and opens the summary modal', async () => {
document.body.innerHTML = ` document.body.innerHTML = `
<div id="globalContextMenu" class="context-menu"> <div id="globalContextMenu" class="context-menu">
<div class="context-menu-item" data-action="rematch-recipes"></div> <div class="context-menu-item" data-action="rematch-recipes"></div>
@@ -2266,27 +2276,44 @@ describe('Interaction-level regression coverage', () => {
}); });
rematchItem.dispatchEvent(new Event('click', { bubbles: true })); rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
// The click only opens the options dialog — nothing starts yet.
expect(global.fetch).not.toHaveBeenCalled();
expect(rematchItem.classList.contains('disabled')).toBe(false);
const rematchModalManager = await getRematchModalManager();
const runPromise = rematchModalManager.confirmOptions();
expect(rematchItem.classList.contains('disabled')).toBe(true); expect(rematchItem.classList.contains('disabled')).toBe(true);
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
await flushAsyncTasks(); await flushAsyncTasks();
} }
await runPromise;
expect(global.fetch).toHaveBeenNthCalledWith(1, '/api/lm/recipes/rematch', { expect(global.fetch).toHaveBeenNthCalledWith(1, '/api/lm/recipes/rematch', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ relaxed: false }),
}); });
expect(global.fetch).toHaveBeenNthCalledWith(2, '/api/lm/recipes/rematch-progress'); expect(global.fetch).toHaveBeenNthCalledWith(2, '/api/lm/recipes/rematch-progress');
expect(global.fetch).toHaveBeenCalledTimes(2); expect(global.fetch).toHaveBeenCalledTimes(2);
expect(progressUI.showCancelButton).toHaveBeenCalledTimes(1); expect(progressUI.showCancelButton).toHaveBeenCalledTimes(1);
expect(progressUI.complete).toHaveBeenCalledWith('Matched 5 entries across 2 recipes.'); // A non-noop run opens the summary modal instead of toasting; the
// Oracle R4-F1 pin: count comes from `rematched`, a blind `repaired` mirror renders undefined // progress overlay completes without a message.
expect(showToastMock).toHaveBeenCalledWith( expect(progressUI.complete).toHaveBeenCalledWith();
expect(showToastMock).not.toHaveBeenCalledWith(
'globalContextMenu.rematchRecipes.success', 'globalContextMenu.rematchRecipes.success',
{ count: 2, recipes: 2, entries: 5, failures: 0 }, expect.anything(),
'success' expect.anything()
); );
const summaryModal = document.getElementById('rematchSummaryModal');
expect(summaryModal).not.toBeNull();
// unresolved_entries > 0 forces the warning header
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('5');
expect(summaryModal.querySelector('.stat-card-skipped .stat-card-value').textContent).toBe('0');
expect(summaryModal.querySelector('.stat-card-total .stat-card-value').textContent).toBe('1');
expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('0');
expect(window.recipesPage.refresh).toHaveBeenCalledTimes(1); expect(window.recipesPage.refresh).toHaveBeenCalledTimes(1);
expect(rematchItem.classList.contains('disabled')).toBe(false); expect(rematchItem.classList.contains('disabled')).toBe(false);
expect(menu._rematchInProgress).toBe(false); expect(menu._rematchInProgress).toBe(false);
@@ -2295,7 +2322,7 @@ describe('Interaction-level regression coverage', () => {
delete stateStub.currentPageType; delete stateStub.currentPageType;
}); });
it('uses the warning toast variant when a global rematch completes with failures', async () => { it('opens the summary modal with a warning header when a global rematch completes with failures', async () => {
document.body.innerHTML = ` document.body.innerHTML = `
<div id="globalContextMenu" class="context-menu"> <div id="globalContextMenu" class="context-menu">
<div class="context-menu-item" data-action="rematch-recipes"></div> <div class="context-menu-item" data-action="rematch-recipes"></div>
@@ -2331,24 +2358,28 @@ describe('Interaction-level regression coverage', () => {
}); });
rematchItem.dispatchEvent(new Event('click', { bubbles: true })); rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
expect(global.fetch).not.toHaveBeenCalled();
const rematchModalManager = await getRematchModalManager();
const runPromise = rematchModalManager.confirmOptions();
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
await flushAsyncTasks(); await flushAsyncTasks();
} }
await runPromise;
expect(progressUI.complete).toHaveBeenCalledWith('Matched 5 entries across 2 recipes, 2 failed.'); expect(progressUI.complete).toHaveBeenCalledWith();
expect(showToastMock).toHaveBeenCalledWith( const summaryModal = document.getElementById('rematchSummaryModal');
'globalContextMenu.rematchRecipes.successErrors', expect(summaryModal).not.toBeNull();
{ count: 2, recipes: 2, entries: 5, failures: 2 }, expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
'warning' expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('2');
);
expect(menu._rematchInProgress).toBe(false); expect(menu._rematchInProgress).toBe(false);
delete window.recipesPage; delete window.recipesPage;
delete stateStub.currentPageType; delete stateStub.currentPageType;
}); });
it('toasts an error when every recipe in a global rematch failed', async () => { it('opens the summary modal with an error header when every recipe in a global rematch failed', async () => {
document.body.innerHTML = ` document.body.innerHTML = `
<div id="globalContextMenu" class="context-menu"> <div id="globalContextMenu" class="context-menu">
<div class="context-menu-item" data-action="rematch-recipes"></div> <div class="context-menu-item" data-action="rematch-recipes"></div>
@@ -2384,24 +2415,28 @@ describe('Interaction-level regression coverage', () => {
}); });
rematchItem.dispatchEvent(new Event('click', { bubbles: true })); rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
expect(global.fetch).not.toHaveBeenCalled();
const rematchModalManager = await getRematchModalManager();
const runPromise = rematchModalManager.confirmOptions();
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
await flushAsyncTasks(); await flushAsyncTasks();
} }
await runPromise;
expect(progressUI.complete).toHaveBeenCalledWith('Rematch failed for 3 of 3 recipes.'); expect(progressUI.complete).toHaveBeenCalledWith();
expect(showToastMock).toHaveBeenCalledWith( const summaryModal = document.getElementById('rematchSummaryModal');
'globalContextMenu.rematchRecipes.allFailed', expect(summaryModal).not.toBeNull();
{ total: 3, recipes: 0, entries: 0, failures: 3 }, expect(summaryModal.querySelector('.summary-header').classList.contains('error')).toBe(true);
'error' expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('3');
);
expect(menu._rematchInProgress).toBe(false); expect(menu._rematchInProgress).toBe(false);
delete window.recipesPage; delete window.recipesPage;
delete stateStub.currentPageType; delete stateStub.currentPageType;
}); });
it('toasts an info message when a global rematch found no local matches', async () => { it('opens the summary modal listing unresolved entries when a global rematch found no local matches', async () => {
document.body.innerHTML = ` document.body.innerHTML = `
<div id="globalContextMenu" class="context-menu"> <div id="globalContextMenu" class="context-menu">
<div class="context-menu-item" data-action="rematch-recipes"></div> <div class="context-menu-item" data-action="rematch-recipes"></div>
@@ -2437,24 +2472,28 @@ describe('Interaction-level regression coverage', () => {
}); });
rematchItem.dispatchEvent(new Event('click', { bubbles: true })); rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
expect(global.fetch).not.toHaveBeenCalled();
const rematchModalManager = await getRematchModalManager();
const runPromise = rematchModalManager.confirmOptions();
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
await flushAsyncTasks(); await flushAsyncTasks();
} }
await runPromise;
expect(progressUI.complete).toHaveBeenCalledWith('No local match found for 2 entries in 1 recipes.'); expect(progressUI.complete).toHaveBeenCalledWith();
expect(showToastMock).toHaveBeenCalledWith( const summaryModal = document.getElementById('rematchSummaryModal');
'globalContextMenu.rematchRecipes.noMatch', expect(summaryModal).not.toBeNull();
{ entries: 2, recipes: 1, total: 3, failures: 0 }, expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
'info' expect(summaryModal.querySelector('.stat-card-total .stat-card-value').textContent).toBe('2');
);
expect(menu._rematchInProgress).toBe(false); expect(menu._rematchInProgress).toBe(false);
delete window.recipesPage; delete window.recipesPage;
delete stateStub.currentPageType; delete stateStub.currentPageType;
}); });
it('toasts the rematched count when a global rematch is cancelled', async () => { it('opens the summary modal marked as cancelled when a global rematch is cancelled', async () => {
document.body.innerHTML = ` document.body.innerHTML = `
<div id="globalContextMenu" class="context-menu"> <div id="globalContextMenu" class="context-menu">
<div class="context-menu-item" data-action="rematch-recipes"></div> <div class="context-menu-item" data-action="rematch-recipes"></div>
@@ -2489,17 +2528,22 @@ describe('Interaction-level regression coverage', () => {
}); });
rematchItem.dispatchEvent(new Event('click', { bubbles: true })); rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
expect(global.fetch).not.toHaveBeenCalled();
const rematchModalManager = await getRematchModalManager();
const runPromise = rematchModalManager.confirmOptions();
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
await flushAsyncTasks(); await flushAsyncTasks();
} }
await runPromise;
expect(progressUI.complete).toHaveBeenCalledWith('Rematch cancelled. 1 recipes updated (2 entries).'); expect(progressUI.complete).toHaveBeenCalledWith('Rematch cancelled. 1 recipes updated (2 entries).');
expect(showToastMock).toHaveBeenCalledWith( const summaryModal = document.getElementById('rematchSummaryModal');
'globalContextMenu.rematchRecipes.cancelled', expect(summaryModal).not.toBeNull();
{ count: 1, recipes: 1, entries: 2 }, expect(summaryModal.querySelector('.rematch-cancelled-note')).not.toBeNull();
'info' expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
); expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('2');
expect(menu._rematchInProgress).toBe(false); expect(menu._rematchInProgress).toBe(false);
delete stateStub.currentPageType; delete stateStub.currentPageType;
@@ -44,6 +44,22 @@ const flushAsyncTasks = async (rounds = 5) => {
} }
}; };
// The single-recipe rematch now opens the options dialog first and only
// starts once confirmOptions() is invoked (the user clicking Rematch).
async function confirmRematchOptions() {
const { rematchModalManager } = await import(
'../../../static/js/managers/RematchModalManager.js'
);
return rematchModalManager.confirmOptions();
}
async function cancelRematchOptions() {
const { rematchModalManager } = await import(
'../../../static/js/managers/RematchModalManager.js'
);
rematchModalManager.cancelOptions();
}
describe('RecipeContextMenu.rematchRecipe', () => { describe('RecipeContextMenu.rematchRecipe', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -69,8 +85,8 @@ describe('RecipeContextMenu.rematchRecipe', () => {
} }
// Oracle R4-F1 pin: branches on `result.rematched > 0` — a blind `repaired` // Oracle R4-F1 pin: branches on `result.rematched > 0` — a blind `repaired`
// mirror would fire the skipped toast here. // mirror would render 0 matched entries in the summary modal here.
it('posts to the per-recipe rematch endpoint and toasts the rematched count', async () => { it('posts to the per-recipe rematch endpoint and opens the summary modal', async () => {
const menu = await createMenu(); const menu = await createMenu();
const card = document.getElementById('card'); const card = document.getElementById('card');
menu.showMenu(100, 100, card); menu.showMenu(100, 100, card);
@@ -91,19 +107,33 @@ describe('RecipeContextMenu.rematchRecipe', () => {
await flushAsyncTasks(); await flushAsyncTasks();
// The click only opened the options dialog — nothing started yet.
expect(global.fetch).not.toHaveBeenCalled();
await confirmRematchOptions();
await flushAsyncTasks();
expect(global.fetch).toHaveBeenNthCalledWith(1, '/api/lm/recipe/recipe-1/rematch', { expect(global.fetch).toHaveBeenNthCalledWith(1, '/api/lm/recipe/recipe-1/rematch', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ relaxed: false }),
}); });
expect(showToastMock).toHaveBeenCalledWith( // Non-noop runs open the summary modal instead of toasting.
expect(showToastMock).not.toHaveBeenCalledWith(
'toast.recipes.rematchComplete', 'toast.recipes.rematchComplete',
{ rematched: 2, skipped: 0, total: 1, entries: 2, recipes: 1, failures: 0 }, expect.anything(),
'success' expect.anything()
); );
expect(showToastMock).not.toHaveBeenCalledWith( expect(showToastMock).not.toHaveBeenCalledWith(
'toast.recipes.rematchSkipped', 'toast.recipes.rematchSkipped',
expect.anything(), expect.anything(),
expect.anything() expect.anything()
); );
const summaryModal = document.getElementById('rematchSummaryModal');
expect(summaryModal).not.toBeNull();
expect(summaryModal.querySelector('.summary-header').classList.contains('success')).toBe(true);
expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('2');
expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('0');
expect(global.fetch).toHaveBeenNthCalledWith(2, '/api/lm/recipe/recipe-1'); expect(global.fetch).toHaveBeenNthCalledWith(2, '/api/lm/recipe/recipe-1');
expect(updateSingleItemMock).toHaveBeenCalledWith('/recipes/recipe-1.webp', { expect(updateSingleItemMock).toHaveBeenCalledWith('/recipes/recipe-1.webp', {
id: 'recipe-1', id: 'recipe-1',
@@ -111,7 +141,7 @@ describe('RecipeContextMenu.rematchRecipe', () => {
}); });
}); });
it('toasts an info message when the entries had no local match', async () => { it('opens the summary modal when the entries had no local match', async () => {
const menu = await createMenu(); const menu = await createMenu();
const card = document.getElementById('card'); const card = document.getElementById('card');
menu.showMenu(100, 100, card); menu.showMenu(100, 100, card);
@@ -126,12 +156,14 @@ describe('RecipeContextMenu.rematchRecipe', () => {
.dispatchEvent(new Event('click', { bubbles: true })); .dispatchEvent(new Event('click', { bubbles: true }));
await flushAsyncTasks(); await flushAsyncTasks();
await confirmRematchOptions();
await flushAsyncTasks();
expect(showToastMock).toHaveBeenCalledWith( const summaryModal = document.getElementById('rematchSummaryModal');
'toast.recipes.rematchUnmatched', expect(summaryModal).not.toBeNull();
{ entries: 2, recipes: 1, total: 1 }, expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
'info' expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('0');
); expect(summaryModal.querySelector('.stat-card-total .stat-card-value').textContent).toBe('2');
expect(showToastMock).not.toHaveBeenCalledWith( expect(showToastMock).not.toHaveBeenCalledWith(
'toast.recipes.rematchSkipped', 'toast.recipes.rematchSkipped',
expect.anything(), expect.anything(),
@@ -155,6 +187,8 @@ describe('RecipeContextMenu.rematchRecipe', () => {
.dispatchEvent(new Event('click', { bubbles: true })); .dispatchEvent(new Event('click', { bubbles: true }));
await flushAsyncTasks(); await flushAsyncTasks();
await confirmRematchOptions();
await flushAsyncTasks();
expect(showToastMock).toHaveBeenCalledWith( expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchSkipped', 'toast.recipes.rematchSkipped',
@@ -186,6 +220,8 @@ describe('RecipeContextMenu.rematchRecipe', () => {
.dispatchEvent(new Event('click', { bubbles: true })); .dispatchEvent(new Event('click', { bubbles: true }));
await flushAsyncTasks(); await flushAsyncTasks();
await confirmRematchOptions();
await flushAsyncTasks();
expect(showToastMock).toHaveBeenCalledWith( expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchFailed', 'toast.recipes.rematchFailed',
@@ -207,6 +243,8 @@ describe('RecipeContextMenu.rematchRecipe', () => {
.dispatchEvent(new Event('click', { bubbles: true })); .dispatchEvent(new Event('click', { bubbles: true }));
await flushAsyncTasks(); await flushAsyncTasks();
await confirmRematchOptions();
await flushAsyncTasks();
expect(showToastMock).toHaveBeenCalledWith( expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchFailed', 'toast.recipes.rematchFailed',
@@ -214,4 +252,91 @@ describe('RecipeContextMenu.rematchRecipe', () => {
'error' 'error'
); );
}); });
it('sends relaxed: true when the relaxed checkbox is checked', async () => {
const menu = await createMenu();
const card = document.getElementById('card');
menu.showMenu(100, 100, card);
document.body.insertAdjacentHTML(
'beforeend',
'<input type="checkbox" id="rematchOptionsRelaxed">'
);
global.fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true, rematched: 0, skipped: 1 }),
});
document
.querySelector('[data-action="rematch"]')
.dispatchEvent(new Event('click', { bubbles: true }));
await flushAsyncTasks();
// The dialog resets the checkbox to unchecked on open; the user opts in.
document.getElementById('rematchOptionsRelaxed').checked = true;
await confirmRematchOptions();
await flushAsyncTasks();
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-1/rematch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ relaxed: true }),
});
});
it('starts nothing when the options dialog is cancelled', async () => {
const menu = await createMenu();
const card = document.getElementById('card');
menu.showMenu(100, 100, card);
document
.querySelector('[data-action="rematch"]')
.dispatchEvent(new Event('click', { bubbles: true }));
await flushAsyncTasks();
await cancelRematchOptions();
await flushAsyncTasks();
expect(global.fetch).not.toHaveBeenCalled();
});
it('lists L4 filename matches in the summary modal with undo buttons', async () => {
const menu = await createMenu();
const card = document.getElementById('card');
menu.showMenu(100, 100, card);
const l4Matches = [
{ recipe_id: 'recipe-1', type: 'lora', entry: 'old.safetensors', file_name: 'new.safetensors', lora_index: 0 },
];
global.fetch
.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true, rematched: 1, matched_entries: 1, l4_matches: l4Matches }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'recipe-1', title: 'Updated Recipe' }),
});
document
.querySelector('[data-action="rematch"]')
.dispatchEvent(new Event('click', { bubbles: true }));
await flushAsyncTasks();
await confirmRematchOptions();
await flushAsyncTasks();
const summaryModal = document.getElementById('rematchSummaryModal');
expect(summaryModal).not.toBeNull();
// L4 matches to review force the warning header
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
expect(summaryModal.querySelector('.stat-card-skipped .stat-card-value').textContent).toBe('1');
const rows = summaryModal.querySelectorAll('tr[data-l4-index]');
expect(rows).toHaveLength(1);
expect(rows[0].textContent).toContain('old.safetensors');
expect(rows[0].textContent).toContain('new.safetensors');
expect(rows[0].querySelector('.rematch-undo-btn[data-action="undo-match"][data-index="0"]')).not.toBeNull();
});
}); });
@@ -51,6 +51,10 @@ vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
stripLoraTags: vi.fn((text) => text), stripLoraTags: vi.fn((text) => text),
sendPromptToWorkflow: vi.fn(), sendPromptToWorkflow: vi.fn(),
sendGenParamsToWorkflow: vi.fn(), sendGenParamsToWorkflow: vi.fn(),
// Keep the real predicate: the download-failure tests assert on its
// unresolvable-error classification.
isUnresolvableDownloadError: (message) =>
!!message && /(not found|no longer available|deleted|removed|404|410|gone)/.test(String(message).toLowerCase()),
})); }));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({ vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
@@ -292,7 +296,7 @@ describe('RecipeModal resource item interactions', () => {
); );
}); });
it('renders a download action (not reconnect) for a version-only LoRA', async () => { it('renders a download action alongside reconnect for a version-only LoRA', async () => {
const recipeModal = await createRecipeModal(); const recipeModal = await createRecipeModal();
recipeModal.showRecipeDetails(recipeWithResources); recipeModal.showRecipeDetails(recipeWithResources);
await flushWiring(); await flushWiring();
@@ -301,10 +305,12 @@ describe('RecipeModal resource item interactions', () => {
expect(item).not.toBeNull(); expect(item).not.toBeNull();
expect(item.classList.contains('missing-locally')).toBe(true); expect(item.classList.contains('missing-locally')).toBe(true);
// Missing from the local library (badge) but still downloadable by its // Missing from the local library (badge) but still downloadable by its
// exact CivitAI version id, so the row offers Download, not Reconnect. // exact CivitAI version id, so the row offers Download as the primary
// action; Reconnect stays available for entries the user already has
// locally under a different hash.
expect(item.querySelector('.missing-badge')).not.toBeNull(); expect(item.querySelector('.missing-badge')).not.toBeNull();
expect(item.querySelector('.lora-download')).not.toBeNull(); expect(item.querySelector('.lora-download')).not.toBeNull();
expect(item.querySelector('.lora-reconnect')).toBeNull(); expect(item.querySelector('.lora-reconnect')).not.toBeNull();
}); });
it('downloads a version-only LoRA by resolving the model id from the version endpoint', async () => { it('downloads a version-only LoRA by resolving the model id from the version endpoint', async () => {
@@ -588,10 +594,12 @@ describe('RecipeModal resource item interactions', () => {
await new Promise(resolve => setTimeout(resolve, 50)); await new Promise(resolve => setTimeout(resolve, 50));
expect(requests.some(r => r.url.includes('mark-hash-invalid'))).toBe(false); expect(requests.some(r => r.url.includes('mark-hash-invalid'))).toBe(false);
// The entry keeps the download action and never flips to reconnect // The entry keeps the download action and never flips to hash-invalid
// (reconnect is always present for missing entries now; the signal here
// is that the download action survives and no invalid badge appears)
const item = document.querySelector('[data-lora-index="1"]'); const item = document.querySelector('[data-lora-index="1"]');
expect(item.querySelector('.lora-download')).not.toBeNull(); expect(item.querySelector('.lora-download')).not.toBeNull();
expect(item.querySelector('.lora-reconnect')).toBeNull(); expect(item.querySelector('.invalid-hash-badge')).toBeNull();
}); });
it('offers download for hash-only LoRAs and resolves identifiers on demand', async () => { it('offers download for hash-only LoRAs and resolves identifiers on demand', async () => {
@@ -73,12 +73,29 @@ vi.mock('../../../static/js/components/shared/NsfwLevelSelector.js', () => ({
getNsfwLevelSelector: vi.fn(), getNsfwLevelSelector: vi.fn(),
})); }));
// The real RematchModalManager runs against the mocked modalManager; confirm
// is invoked explicitly, mirroring the user clicking Rematch in the dialog.
async function confirmRematchOptions() {
const { rematchModalManager } = await import(
'../../../static/js/managers/RematchModalManager.js'
);
return rematchModalManager.confirmOptions();
}
async function cancelRematchOptions() {
const { rematchModalManager } = await import(
'../../../static/js/managers/RematchModalManager.js'
);
rematchModalManager.cancelOptions();
}
describe('BulkManager.rematchSelectedRecipes', () => { describe('BulkManager.rematchSelectedRecipes', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
stateStub.currentPageType = 'recipes'; stateStub.currentPageType = 'recipes';
stateStub.bulkMode = false; stateStub.bulkMode = false;
stateStub.selectedModels.clear(); stateStub.selectedModels.clear();
document.body.innerHTML = '';
}); });
async function createBulkManager() { async function createBulkManager() {
@@ -91,9 +108,9 @@ describe('BulkManager.rematchSelectedRecipes', () => {
expect(bulk.actionConfig.recipes.rematchMetadata).toBe(true); expect(bulk.actionConfig.recipes.rematchMetadata).toBe(true);
}); });
// Oracle R4-F1 pin: the complete toast must branch on `rematched` — a blind // Oracle R4-F1 pin: the summary modal must branch on `matched_entries` — a
// `repaired` mirror would fire the skipped toast with count 0 here. // blind `repaired` mirror would render 0 matched entries here.
it('toasts the rematched count when the bulk rematch succeeds', async () => { it('opens the summary modal when the bulk rematch succeeds', async () => {
const bulk = await createBulkManager(); const bulk = await createBulkManager();
stateStub.selectedModels.add('/recipes/a.webp'); stateStub.selectedModels.add('/recipes/a.webp');
stateStub.selectedModels.add('/recipes/b.webp'); stateStub.selectedModels.add('/recipes/b.webp');
@@ -114,29 +131,41 @@ describe('BulkManager.rematchSelectedRecipes', () => {
}); });
await bulk.rematchSelectedRecipes(); await bulk.rematchSelectedRecipes();
await confirmRematchOptions();
expect(rematchBulkModelsMock).toHaveBeenCalledWith([ expect(rematchBulkModelsMock).toHaveBeenCalledWith(
'/recipes/a.webp', [
'/recipes/b.webp', '/recipes/a.webp',
'/recipes/c.webp', '/recipes/b.webp',
]); '/recipes/c.webp',
expect(showToastMock).toHaveBeenCalledWith( ],
{ relaxed: false }
);
// Non-noop runs open the summary modal instead of toasting.
expect(showToastMock).not.toHaveBeenCalledWith(
'toast.recipes.rematchComplete', 'toast.recipes.rematchComplete',
{ rematched: 4, skipped: 1, total: 3, entries: 4, recipes: 2, failures: 0 }, expect.anything(),
'success' expect.anything()
); );
expect(showToastMock).not.toHaveBeenCalledWith( expect(showToastMock).not.toHaveBeenCalledWith(
'toast.recipes.rematchSkipped', 'toast.recipes.rematchSkipped',
expect.anything(), expect.anything(),
expect.anything() expect.anything()
); );
const summaryModal = document.getElementById('rematchSummaryModal');
expect(summaryModal).not.toBeNull();
// unresolved_entries > 0 forces the warning header
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('4');
expect(summaryModal.querySelector('.stat-card-total .stat-card-value').textContent).toBe('1');
expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('0');
expect(updateSingleItemMock).toHaveBeenCalledWith('/recipes/a.webp', rematchedRecipe); expect(updateSingleItemMock).toHaveBeenCalledWith('/recipes/a.webp', rematchedRecipe);
expect(loadingManagerStub.showSimpleLoading).toHaveBeenCalled(); expect(loadingManagerStub.showSimpleLoading).toHaveBeenCalled();
expect(loadingManagerStub.hide).toHaveBeenCalled(); expect(loadingManagerStub.hide).toHaveBeenCalled();
expect(loadingManagerStub.restoreProgressBar).toHaveBeenCalled(); expect(loadingManagerStub.restoreProgressBar).toHaveBeenCalled();
}); });
it('uses the errors toast variant when the bulk rematch has failures', async () => { it('opens the summary modal with a warning header when the bulk rematch has failures', async () => {
const bulk = await createBulkManager(); const bulk = await createBulkManager();
stateStub.selectedModels.add('/recipes/a.webp'); stateStub.selectedModels.add('/recipes/a.webp');
stateStub.selectedModels.add('/recipes/b.webp'); stateStub.selectedModels.add('/recipes/b.webp');
@@ -155,15 +184,16 @@ describe('BulkManager.rematchSelectedRecipes', () => {
}); });
await bulk.rematchSelectedRecipes(); await bulk.rematchSelectedRecipes();
await confirmRematchOptions();
expect(showToastMock).toHaveBeenCalledWith( const summaryModal = document.getElementById('rematchSummaryModal');
'toast.recipes.rematchCompleteErrors', expect(summaryModal).not.toBeNull();
{ rematched: 3, skipped: 0, total: 2, entries: 3, recipes: 1, failures: 2 }, expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
'warning' expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('3');
); expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('2');
}); });
it('toasts an error when every selected recipe failed to rematch', async () => { it('opens the summary modal with an error header when every selected recipe failed to rematch', async () => {
const bulk = await createBulkManager(); const bulk = await createBulkManager();
stateStub.selectedModels.add('/recipes/a.webp'); stateStub.selectedModels.add('/recipes/a.webp');
stateStub.selectedModels.add('/recipes/b.webp'); stateStub.selectedModels.add('/recipes/b.webp');
@@ -182,12 +212,12 @@ describe('BulkManager.rematchSelectedRecipes', () => {
}); });
await bulk.rematchSelectedRecipes(); await bulk.rematchSelectedRecipes();
await confirmRematchOptions();
expect(showToastMock).toHaveBeenCalledWith( const summaryModal = document.getElementById('rematchSummaryModal');
'toast.recipes.rematchAllFailed', expect(summaryModal).not.toBeNull();
{ total: 2, failures: 2 }, expect(summaryModal.querySelector('.summary-header').classList.contains('error')).toBe(true);
'error' expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('2');
);
expect(showToastMock).not.toHaveBeenCalledWith( expect(showToastMock).not.toHaveBeenCalledWith(
'toast.recipes.rematchSkipped', 'toast.recipes.rematchSkipped',
expect.anything(), expect.anything(),
@@ -195,7 +225,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
); );
}); });
it('toasts an info message when entries had no local match', async () => { it('opens the summary modal when entries had no local match', async () => {
const bulk = await createBulkManager(); const bulk = await createBulkManager();
stateStub.selectedModels.add('/recipes/a.webp'); stateStub.selectedModels.add('/recipes/a.webp');
stateStub.selectedModels.add('/recipes/b.webp'); stateStub.selectedModels.add('/recipes/b.webp');
@@ -215,12 +245,13 @@ describe('BulkManager.rematchSelectedRecipes', () => {
}); });
await bulk.rematchSelectedRecipes(); await bulk.rematchSelectedRecipes();
await confirmRematchOptions();
expect(showToastMock).toHaveBeenCalledWith( const summaryModal = document.getElementById('rematchSummaryModal');
'toast.recipes.rematchUnmatched', expect(summaryModal).not.toBeNull();
{ entries: 2, recipes: 1, total: 3 }, expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
'info' expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('0');
); expect(summaryModal.querySelector('.stat-card-total .stat-card-value').textContent).toBe('2');
expect(showToastMock).not.toHaveBeenCalledWith( expect(showToastMock).not.toHaveBeenCalledWith(
'toast.recipes.rematchSkipped', 'toast.recipes.rematchSkipped',
expect.anything(), expect.anything(),
@@ -243,6 +274,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
}); });
await bulk.rematchSelectedRecipes(); await bulk.rematchSelectedRecipes();
await confirmRematchOptions();
expect(showToastMock).toHaveBeenCalledWith( expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchSkipped', 'toast.recipes.rematchSkipped',
@@ -268,6 +300,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
}); });
await bulk.rematchSelectedRecipes(); await bulk.rematchSelectedRecipes();
await confirmRematchOptions();
expect(showToastMock).toHaveBeenCalledWith( expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchFailed', 'toast.recipes.rematchFailed',
@@ -284,6 +317,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
rematchBulkModelsMock.mockRejectedValue(new Error('network down')); rematchBulkModelsMock.mockRejectedValue(new Error('network down'));
await bulk.rematchSelectedRecipes(); await bulk.rematchSelectedRecipes();
await confirmRematchOptions();
expect(showToastMock).toHaveBeenCalledWith( expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchFailed', 'toast.recipes.rematchFailed',
@@ -319,4 +353,101 @@ describe('BulkManager.rematchSelectedRecipes', () => {
); );
expect(rematchBulkModelsMock).not.toHaveBeenCalled(); expect(rematchBulkModelsMock).not.toHaveBeenCalled();
}); });
it('does not start the rematch until the options dialog is confirmed', async () => {
const bulk = await createBulkManager();
stateStub.selectedModels.add('/recipes/a.webp');
await bulk.rematchSelectedRecipes();
expect(rematchBulkModelsMock).not.toHaveBeenCalled();
rematchBulkModelsMock.mockResolvedValue({
success: true,
total: 1,
rematched: 1,
skipped: 0,
errors: 0,
matched_recipes: 1,
matched_entries: 1,
recipes: [],
});
await confirmRematchOptions();
expect(rematchBulkModelsMock).toHaveBeenCalledWith(['/recipes/a.webp'], { relaxed: false });
});
it('sends relaxed: true when the relaxed checkbox is checked', async () => {
const bulk = await createBulkManager();
stateStub.selectedModels.add('/recipes/a.webp');
document.body.innerHTML = '<input type="checkbox" id="rematchOptionsRelaxed">';
rematchBulkModelsMock.mockResolvedValue({
success: true,
total: 1,
rematched: 0,
skipped: 1,
errors: 0,
recipes: [],
});
await bulk.rematchSelectedRecipes();
// The dialog resets the checkbox to unchecked on open; the user opts in.
document.getElementById('rematchOptionsRelaxed').checked = true;
await confirmRematchOptions();
expect(rematchBulkModelsMock).toHaveBeenCalledWith(['/recipes/a.webp'], { relaxed: true });
document.body.innerHTML = '';
});
it('starts nothing when the options dialog is cancelled', async () => {
const bulk = await createBulkManager();
stateStub.selectedModels.add('/recipes/a.webp');
await bulk.rematchSelectedRecipes();
await cancelRematchOptions();
expect(rematchBulkModelsMock).not.toHaveBeenCalled();
expect(showToastMock).not.toHaveBeenCalledWith(
'toast.recipes.rematchComplete',
expect.anything(),
expect.anything()
);
});
it('lists L4 filename matches in the summary modal with undo buttons', async () => {
const bulk = await createBulkManager();
stateStub.selectedModels.add('/recipes/a.webp');
const l4Matches = [
{ recipe_id: 'a', type: 'lora', entry: 'old.safetensors', file_name: 'new.safetensors', lora_index: 0 },
];
rematchBulkModelsMock.mockResolvedValue({
success: true,
total: 1,
rematched: 1,
skipped: 0,
errors: 0,
matched_recipes: 1,
matched_entries: 1,
recipes: [],
l4_matches: l4Matches,
});
await bulk.rematchSelectedRecipes();
await confirmRematchOptions();
const summaryModal = document.getElementById('rematchSummaryModal');
expect(summaryModal).not.toBeNull();
// L4 matches to review force the warning header
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
expect(summaryModal.querySelector('.stat-card-skipped .stat-card-value').textContent).toBe('1');
const rows = summaryModal.querySelectorAll('tr[data-l4-index]');
expect(rows).toHaveLength(1);
expect(rows[0].textContent).toContain('old.safetensors');
expect(rows[0].textContent).toContain('new.safetensors');
expect(rows[0].querySelector('.rematch-undo-btn[data-action="undo-match"][data-index="0"]')).not.toBeNull();
});
}); });
@@ -0,0 +1,192 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const MODULE = '../../../static/js/managers/BulkMissingLoraDownloadManager.js';
const showToastMock = vi.fn();
const updateProgressMock = vi.fn();
const updateSingleItemMock = vi.fn();
const mockApiClient = {
downloadModel: vi.fn(),
cancelDownload: vi.fn(),
fetchModelRoots: vi.fn(() => Promise.resolve({ roots: ['/models/loras'] })),
};
const loadingManagerStub = {
showDownloadProgress: vi.fn(() => updateProgressMock),
setStatus: vi.fn(),
showCancelButton: vi.fn(),
hide: vi.fn(),
};
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
// Keep the real predicate: these tests assert on its classification.
isUnresolvableDownloadError: (message) =>
!!message && /(not found|no longer available|deleted|removed|404|410|gone)/.test(String(message).toLowerCase()),
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: vi.fn((_, __, fallback) => fallback ?? ''),
}));
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
getModelApiClient: vi.fn(() => mockApiClient),
}));
vi.mock('../../../static/js/api/apiConfig.js', () => ({
MODEL_TYPES: { LORA: 'loras', CHECKPOINT: 'checkpoints', EMBEDDING: 'embeddings' },
}));
vi.mock('../../../static/js/api/recipeApi.js', () => ({
extractRecipeId: (filePath) => {
if (!filePath) return null;
const basename = filePath.split('/').pop().split('\\').pop();
const dotIndex = basename.lastIndexOf('.');
return dotIndex > 0 ? basename.substring(0, dotIndex) : basename;
},
}));
vi.mock('../../../static/js/state/index.js', () => ({
state: {
loadingManager: loadingManagerStub,
virtualScroller: { updateSingleItem: updateSingleItemMock },
global: { settings: {} },
},
}));
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
modalManager: { showModal: vi.fn(), closeModal: vi.fn() },
}));
/** Mirrors the FakeWebSocket pattern from downloadManager.batchSummary.test.js. */
class FakeWebSocket {
static instances = [];
constructor(url) {
this.url = url;
this.onopen = null;
this.onmessage = null;
this.onerror = null;
this.close = vi.fn();
FakeWebSocket.instances.push(this);
queueMicrotask(() => {
if (this.onopen) this.onopen();
});
}
}
const makeRecipe = (filePath, loras) => ({ file_path: filePath, loras });
describe('BulkMissingLoraDownloadManager unresolvable-failure write-back', () => {
let manager;
let fetchMock;
let requests;
beforeEach(async () => {
FakeWebSocket.instances = [];
vi.clearAllMocks();
loadingManagerStub.showDownloadProgress.mockReturnValue(updateProgressMock);
requests = [];
fetchMock = vi.fn((url, options) => {
requests.push({ url, options });
if (url === '/api/lm/recipe/lora/mark-hash-invalid') {
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
}
// Recipe detail refresh after the download loop
return Promise.resolve({ ok: true, json: () => Promise.resolve({ id: 'refreshed' }) });
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('WebSocket', FakeWebSocket);
vi.resetModules();
({ bulkMissingLoraDownloadManager: manager } = await import(MODULE));
manager.pendingLoras = [];
manager.pendingRecipes = [];
manager.pendingMissingByRecipe = null;
});
afterEach(() => {
vi.unstubAllGlobals();
});
const primePending = (recipes) => {
const stats = manager.collectMissingLoras(recipes);
manager.pendingRecipes = recipes;
manager.pendingMissingByRecipe = stats.missingLorasByRecipe;
return stats.uniqueLoras;
};
it('marks every recipe occurrence hash-invalid when the failure is unresolvable', async () => {
const entryA = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 };
const entryB = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 };
const recipe1 = makeRecipe('/recipes/r1.json', [entryA]);
const recipe2 = makeRecipe('/recipes/r2.json', [{ hash: 'x', file_name: 'keep.safetensors', inLibrary: true }, entryB]);
const uniqueLoras = primePending([recipe1, recipe2]);
mockApiClient.downloadModel.mockResolvedValue({ success: false, error: 'Model not found' });
await manager.executeDownload(uniqueLoras);
const markCalls = requests.filter(r => r.url === '/api/lm/recipe/lora/mark-hash-invalid');
expect(markCalls).toHaveLength(2);
const payloads = markCalls.map(r => JSON.parse(r.options.body));
expect(payloads).toContainEqual({ recipe_id: 'r1', lora_index: 0 });
expect(payloads).toContainEqual({ recipe_id: 'r2', lora_index: 1 });
expect(entryA.hashInvalid).toBe(true);
expect(entryB.hashInvalid).toBe(true);
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.unresolvableMarkedForReconnect',
{ count: 2 },
'info',
expect.any(String),
);
});
it('leaves entries untouched when the failure is transient', async () => {
const entry = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 };
const recipe = makeRecipe('/recipes/r1.json', [entry]);
const uniqueLoras = primePending([recipe]);
mockApiClient.downloadModel.mockResolvedValue({ success: false, error: 'Connection timed out' });
await manager.executeDownload(uniqueLoras);
expect(requests.some(r => r.url === '/api/lm/recipe/lora/mark-hash-invalid')).toBe(false);
expect(entry.hashInvalid).toBeUndefined();
expect(showToastMock).not.toHaveBeenCalledWith(
'toast.recipes.unresolvableMarkedForReconnect',
expect.anything(),
expect.anything(),
expect.anything(),
);
});
it('marks hash-invalid when the download request itself throws an unresolvable error', async () => {
const entry = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 };
const recipe = makeRecipe('/recipes/r1.json', [entry]);
const uniqueLoras = primePending([recipe]);
mockApiClient.downloadModel.mockRejectedValue(new Error('410 Gone'));
await manager.executeDownload(uniqueLoras);
const markCalls = requests.filter(r => r.url === '/api/lm/recipe/lora/mark-hash-invalid');
expect(markCalls).toHaveLength(1);
expect(entry.hashInvalid).toBe(true);
});
it('does not mark entries whose download succeeds', async () => {
const entry = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 };
const recipe = makeRecipe('/recipes/r1.json', [entry]);
const uniqueLoras = primePending([recipe]);
mockApiClient.downloadModel.mockResolvedValue({ success: true });
await manager.executeDownload(uniqueLoras);
expect(requests.some(r => r.url === '/api/lm/recipe/lora/mark-hash-invalid')).toBe(false);
});
});
@@ -0,0 +1,118 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const showToastMock = vi.fn();
const translateMock = vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key));
const modalManagerMock = {
showModal: vi.fn(),
closeModal: vi.fn(),
};
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
modalManager: modalManagerMock,
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: translateMock,
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
}));
async function getManager() {
const { rematchModalManager } = await import(
'../../../static/js/managers/RematchModalManager.js'
);
return rematchModalManager;
}
describe('RematchModalManager options dialog', () => {
beforeEach(() => {
vi.clearAllMocks();
document.body.innerHTML = `
<p id="rematchOptionsMessage"></p>
<input type="checkbox" id="rematchOptionsRelaxed">
`;
});
afterEach(() => {
document.body.innerHTML = '';
});
it('does not invoke the callback until confirmOptions is called', async () => {
const manager = await getManager();
const onConfirm = vi.fn();
manager.showOptionsModal({ recipeCount: 3, onConfirm });
expect(modalManagerMock.showModal).toHaveBeenCalledWith('rematchOptionsModal');
expect(onConfirm).not.toHaveBeenCalled();
// Bulk message mentions the selection size.
expect(document.getElementById('rematchOptionsMessage').textContent).toContain('3');
// The checkbox always starts unchecked.
expect(document.getElementById('rematchOptionsRelaxed').checked).toBe(false);
manager.confirmOptions();
expect(onConfirm).toHaveBeenCalledWith({ relaxed: false });
expect(modalManagerMock.closeModal).toHaveBeenCalledWith('rematchOptionsModal');
});
it('uses the generic message when no recipe count is given', async () => {
const manager = await getManager();
manager.showOptionsModal({ onConfirm: vi.fn() });
expect(translateMock).toHaveBeenCalledWith(
'modals.rematchOptions.messageGlobal',
{},
'All recipes will be scanned against your local model library.'
);
});
it('uses the single-recipe message for scope: single', async () => {
const manager = await getManager();
manager.showOptionsModal({ scope: 'single', onConfirm: vi.fn() });
expect(translateMock).toHaveBeenCalledWith(
'modals.rematchOptions.messageSingle',
{},
'This recipe will be scanned against your local model library.'
);
});
it('passes relaxed: true when the checkbox is checked', async () => {
const manager = await getManager();
const onConfirm = vi.fn();
manager.showOptionsModal({ onConfirm });
document.getElementById('rematchOptionsRelaxed').checked = true;
manager.confirmOptions();
expect(onConfirm).toHaveBeenCalledWith({ relaxed: true });
});
it('resets the checkbox to unchecked each time the dialog opens', async () => {
const manager = await getManager();
const checkbox = document.getElementById('rematchOptionsRelaxed');
checkbox.checked = true;
manager.showOptionsModal({ onConfirm: vi.fn() });
expect(checkbox.checked).toBe(false);
});
it('cancelOptions runs nothing and clears the callback', async () => {
const manager = await getManager();
const onConfirm = vi.fn();
manager.showOptionsModal({ onConfirm });
manager.cancelOptions();
expect(modalManagerMock.closeModal).toHaveBeenCalledWith('rematchOptionsModal');
// A later confirm must not fire the cancelled callback.
manager.confirmOptions();
expect(onConfirm).not.toHaveBeenCalled();
});
});
@@ -40,6 +40,15 @@ describe("applyLoraValuesToText", () => {
expect(result).toBe("<lora:Expanded:1.00:1.00>"); expect(result).toBe("<lora:Expanded:1.00:1.00>");
}); });
it("preserves repeated spaces inside LoRA names", () => {
const original = "<lora:test - 0021:1.00>";
const result = applyLoraValuesToText(original, [
{ name: "test - 0021", strength: 0.5 }
]);
expect(result).toBe("<lora:test - 0021:0.50>");
});
}); });
describe("normalizeStrengthValue", () => { describe("normalizeStrengthValue", () => {
@@ -74,6 +83,18 @@ describe("cleanupLoraSyntax", () => {
it("collapses whitespace and stray commas", () => { it("collapses whitespace and stray commas", () => {
expect(cleanupLoraSyntax(" <lora:A:1.00> , ," )).toBe("<lora:A:1.00>"); expect(cleanupLoraSyntax(" <lora:A:1.00> , ," )).toBe("<lora:A:1.00>");
}); });
it("preserves repeated spaces inside LoRA names", () => {
expect(cleanupLoraSyntax("<lora:test - 0021:1.00> , ,")).toBe(
"<lora:test - 0021:1.00>"
);
});
it("still normalizes whitespace between entries", () => {
expect(
cleanupLoraSyntax(" <lora:A:1.00> <lora:test - 0021:0.50> ")
).toBe("<lora:A:1.00> <lora:test - 0021:0.50>");
});
}); });
describe("debounce", () => { describe("debounce", () => {
@@ -55,6 +55,18 @@ describe('DownloadManager.detectUrlType — HF URL detection', () => {
}); });
}); });
it('detects HF blob (web preview) URL as resolve', () => {
const result = DownloadManager.detectUrlType(
'https://huggingface.co/Comfy-Org/z_image_turbo/blob/main/split_files/diffusion_models/z_image_turbo_bf16.safetensors'
);
expect(result).toEqual({
type: 'hf-resolve',
repo: 'Comfy-Org/z_image_turbo',
revision: 'main',
filename: 'split_files/diffusion_models/z_image_turbo_bf16.safetensors',
});
});
it('detects CivitAI URL', () => { it('detects CivitAI URL', () => {
const result = DownloadManager.detectUrlType( const result = DownloadManager.detectUrlType(
'https://civitai.com/models/123/some-model' 'https://civitai.com/models/123/some-model'
+89 -3
View File
@@ -60,6 +60,9 @@ class StubRecipeScanner:
self.rematch_all_calls: List[Any] = [] self.rematch_all_calls: List[Any] = []
self.rematch_by_id_calls: List[str] = [] self.rematch_by_id_calls: List[str] = []
self.rematch_bulk_calls: List[List[str]] = [] self.rematch_bulk_calls: List[List[str]] = []
self.rematch_all_relaxed: List[bool] = []
self.rematch_by_id_relaxed: List[bool] = []
self.rematch_bulk_relaxed: List[bool] = []
self.rematch_results: Dict[str, Dict[str, Any]] = {} self.rematch_results: Dict[str, Dict[str, Any]] = {}
async def _noop_get_cached_data(force_refresh: bool = False) -> None: # noqa: ARG001 - signature mirrors real scanner async def _noop_get_cached_data(force_refresh: bool = False) -> None: # noqa: ARG001 - signature mirrors real scanner
@@ -131,7 +134,7 @@ class StubRecipeScanner:
def reset_cancellation(self) -> None: def reset_cancellation(self) -> None:
self.reset_calls += 1 self.reset_calls += 1
async def rematch_all_recipes(self, progress_callback=None): async def rematch_all_recipes(self, progress_callback=None, *, relaxed: bool = False):
"""Run a canned rematch-all run, mirroring the real progress events.""" """Run a canned rematch-all run, mirroring the real progress events."""
if progress_callback: if progress_callback:
await progress_callback({"status": "started"}) await progress_callback({"status": "started"})
@@ -142,6 +145,7 @@ class StubRecipeScanner:
{"status": "completed", "rematched": 1, "skipped": 0, "errors": 0, "total": 1} {"status": "completed", "rematched": 1, "skipped": 0, "errors": 0, "total": 1}
) )
self.rematch_all_calls.append(progress_callback) self.rematch_all_calls.append(progress_callback)
self.rematch_all_relaxed.append(relaxed)
return { return {
"success": True, "success": True,
"status": "completed", "status": "completed",
@@ -151,14 +155,20 @@ class StubRecipeScanner:
"total": 1, "total": 1,
} }
async def rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]: async def rematch_recipe_by_id(
self, recipe_id: str, *, relaxed: bool = False
) -> Dict[str, Any]:
self.rematch_by_id_calls.append(recipe_id) self.rematch_by_id_calls.append(recipe_id)
self.rematch_by_id_relaxed.append(relaxed)
if recipe_id not in self.rematch_results: if recipe_id not in self.rematch_results:
raise RecipeNotFoundError(f"Recipe not found: {recipe_id}") raise RecipeNotFoundError(f"Recipe not found: {recipe_id}")
return self.rematch_results[recipe_id] return self.rematch_results[recipe_id]
async def rematch_recipes_bulk(self, recipe_ids: List[str]) -> Dict[str, Any]: async def rematch_recipes_bulk(
self, recipe_ids: List[str], *, relaxed: bool = False
) -> Dict[str, Any]:
self.rematch_bulk_calls.append(list(recipe_ids)) self.rematch_bulk_calls.append(list(recipe_ids))
self.rematch_bulk_relaxed.append(relaxed)
total = len(recipe_ids) total = len(recipe_ids)
rematched = 0 rematched = 0
skipped = 0 skipped = 0
@@ -1992,6 +2002,82 @@ async def test_rematch_recipe_maps_not_found_to_404(monkeypatch, tmp_path: Path)
assert harness.scanner.rematch_by_id_calls == ["ghost"] assert harness.scanner.rematch_by_id_calls == ["ghost"]
async def test_rematch_recipes_passes_relaxed_flag_from_body(
monkeypatch, tmp_path: Path
) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.post(
"/api/lm/recipes/rematch", json={"relaxed": True}
)
payload = await response.json()
assert response.status == 200, payload
await asyncio.sleep(0.1)
assert harness.scanner.rematch_all_relaxed == [True]
async def test_rematch_recipes_relaxed_defaults_to_false(
monkeypatch, tmp_path: Path
) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.post("/api/lm/recipes/rematch")
payload = await response.json()
assert response.status == 200, payload
await asyncio.sleep(0.1)
assert harness.scanner.rematch_all_relaxed == [False]
async def test_rematch_recipes_relaxed_query_param_fallback(
monkeypatch, tmp_path: Path
) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.post("/api/lm/recipes/rematch?relaxed=true")
payload = await response.json()
assert response.status == 200, payload
await asyncio.sleep(0.1)
assert harness.scanner.rematch_all_relaxed == [True]
async def test_rematch_recipes_bulk_passes_relaxed_flag_from_body(
monkeypatch, tmp_path: Path
) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.post(
"/api/lm/recipes/rematch-bulk",
json={"recipe_ids": ["r1"], "relaxed": True},
)
payload = await response.json()
assert response.status == 200, payload
assert harness.scanner.rematch_bulk_relaxed == [True]
async def test_rematch_recipes_bulk_relaxed_query_param_fallback(
monkeypatch, tmp_path: Path
) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.post(
"/api/lm/recipes/rematch-bulk?relaxed=true",
json={"recipe_ids": ["r1"]},
)
payload = await response.json()
assert response.status == 200, payload
assert harness.scanner.rematch_bulk_relaxed == [True]
async def test_rematch_recipe_passes_relaxed_flag_from_query(
monkeypatch, tmp_path: Path
) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.scanner.rematch_results = {
"abc123": {"success": True, "rematched": 1},
}
response = await harness.client.post(
"/api/lm/recipe/abc123/rematch?relaxed=true"
)
payload = await response.json()
assert response.status == 200, payload
assert harness.scanner.rematch_by_id_relaxed == [True]
async def test_get_rematch_progress_404_when_no_progress( async def test_get_rematch_progress_404_when_no_progress(
monkeypatch, tmp_path: Path monkeypatch, tmp_path: Path
) -> None: ) -> None:
+99 -1
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import time
from unittest import mock from unittest import mock
import pytest import pytest
@@ -322,8 +323,12 @@ class MockGetSession:
def __init__(self, response): def __init__(self, response):
self._response = response self._response = response
self.last_url = None
self.last_headers = None
def get(self, url): def get(self, url, headers=None):
self.last_url = url
self.last_headers = headers
return self._response return self._response
async def __aenter__(self): async def __aenter__(self):
@@ -340,6 +345,14 @@ class CorruptJsonResponse(MockResponse):
raise UnicodeDecodeError("utf-8", b"\x9a", 0, 1, "invalid start byte") raise UnicodeDecodeError("utf-8", b"\x9a", 0, 1, "invalid start byte")
class SlowResponse(MockResponse):
"""Response whose body takes a moment to read, to force contention."""
async def json(self):
await asyncio.sleep(0.05)
return self._json_data
class TestModelCatalog: class TestModelCatalog:
"""Tests for _load_model_catalog / fetch_ollama_models error handling.""" """Tests for _load_model_catalog / fetch_ollama_models error handling."""
@@ -348,9 +361,11 @@ class TestModelCatalog:
"""Reset the module-level catalog cache around each test.""" """Reset the module-level catalog cache around each test."""
llm_module._catalog_cache = None llm_module._catalog_cache = None
llm_module._model_output_limits = {} llm_module._model_output_limits = {}
llm_module._catalog_last_failure = None
yield yield
llm_module._catalog_cache = None llm_module._catalog_cache = None
llm_module._model_output_limits = {} llm_module._model_output_limits = {}
llm_module._catalog_last_failure = None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_load_model_catalog_falls_back_on_unicode_decode_error(self): async def test_load_model_catalog_falls_back_on_unicode_decode_error(self):
@@ -373,3 +388,86 @@ class TestModelCatalog:
models = await fetch_ollama_models("http://localhost:11434/v1") models = await fetch_ollama_models("http://localhost:11434/v1")
assert models == [] assert models == []
@pytest.mark.asyncio
async def test_catalog_request_disables_brotli_encoding(self):
"""The catalog request must not advertise br — a corrupt brotli stream
can crash the native decoder (Windows access violation, issue #1099)."""
response = MockResponse(200, json_data={})
session = MockGetSession(response)
with mock.patch("aiohttp.ClientSession", return_value=session):
await llm_module._load_model_catalog()
assert session.last_headers == {"Accept-Encoding": "gzip, deflate"}
@pytest.mark.asyncio
async def test_ollama_request_disables_brotli_encoding(self):
"""The Ollama models request must not advertise br either."""
response = MockResponse(200, json_data={"data": [{"id": "llama3"}]})
session = MockGetSession(response)
with mock.patch("aiohttp.ClientSession", return_value=session):
models = await fetch_ollama_models("http://localhost:11434/v1")
assert models == ["llama3"]
assert session.last_headers == {"Accept-Encoding": "gzip, deflate"}
@pytest.mark.asyncio
async def test_failed_fetch_is_negatively_cached(self):
"""A failed fetch is not retried until the cooldown elapses."""
created = []
def factory(*args, **kwargs):
session = MockGetSession(MockResponse(500, text_data="error"))
created.append(session)
return session
with mock.patch("aiohttp.ClientSession", side_effect=factory):
first = await llm_module._load_model_catalog()
second = await llm_module._load_model_catalog()
assert first == {}
assert second == {}
assert len(created) == 1
assert llm_module._catalog_last_failure is not None
@pytest.mark.asyncio
async def test_fetch_retries_after_cooldown(self):
"""Once the cooldown elapses, the next call fetches again."""
bad = MockGetSession(MockResponse(500, text_data="error"))
with mock.patch("aiohttp.ClientSession", return_value=bad):
assert await llm_module._load_model_catalog() == {}
# Simulate the cooldown having elapsed.
llm_module._catalog_last_failure = (
time.monotonic() - llm_module._CATALOG_FAILURE_COOLDOWN - 1
)
good = MockGetSession(
MockResponse(200, json_data={"openai": {"models": {"gpt-4o": {}}}})
)
with mock.patch("aiohttp.ClientSession", return_value=good):
catalog = await llm_module._load_model_catalog()
assert catalog == {"openai": ["gpt-4o"]}
@pytest.mark.asyncio
async def test_concurrent_fetches_are_deduplicated(self):
"""Concurrent callers share a single in-flight fetch."""
created = []
def factory(*args, **kwargs):
session = MockGetSession(
SlowResponse(200, json_data={"openai": {"models": {"gpt-4o": {}}}})
)
created.append(session)
return session
with mock.patch("aiohttp.ClientSession", side_effect=factory):
results = await asyncio.gather(
*(llm_module._load_model_catalog() for _ in range(3))
)
assert len(created) == 1
assert all(r == {"openai": ["gpt-4o"]} for r in results)
+1 -1
View File
@@ -164,7 +164,7 @@ class TestEnrichHfMetadata:
skill_name="enrich_hf_metadata", skill_name="enrich_hf_metadata",
model_path="/p.safetensors", model_path="/p.safetensors",
llm_output=llm, llm_output=llm,
metadata={"trainedWords": []}, metadata={},
) )
applied = mock_apply.call_args[0][1] applied = mock_apply.call_args[0][1]
assert applied["civitai"]["trainedWords"] == ["trigger1", "trigger2"] assert applied["civitai"]["trainedWords"] == ["trigger1", "trigger2"]
+309 -1
View File
@@ -4012,6 +4012,7 @@ async def test_rematch_recipe_by_id_lora_l1_write_back(tmp_path: Path, monkeypat
"entry": "old.safetensors", "entry": "old.safetensors",
"file_name": "m.safetensors", "file_name": "m.safetensors",
"match_level": "L1", "match_level": "L1",
"lora_index": 0,
} }
] ]
assert result["recipe"] is enriched assert result["recipe"] is enriched
@@ -4032,6 +4033,79 @@ async def test_rematch_recipe_by_id_lora_l1_write_back(tmp_path: Path, monkeypat
assert resort_calls == [] # Metis F1 — hoisted to public entry points assert resort_calls == [] # Metis F1 — hoisted to public entry points
# Rematch write-back must snapshot the pre-match state (undo affordance)
async def test_write_rematch_lora_entry_snapshots_pre_match_state(tmp_path: Path):
scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
original_entry = {
"isDeleted": True,
"hashInvalid": False,
"hash": "oldhash",
"file_name": "old.safetensors",
"modelVersionId": 0,
"modelName": "Old Name",
}
entry = dict(original_entry)
item = _civitai_lora_item(
sha256="b" * 64,
version_id=222,
name="v2.0",
model_name="New Model",
file_name="new.safetensors",
)
scanner._write_rematch_lora_entry(entry, item)
assert entry["hash"] == "b" * 64
assert entry["file_name"] == "new.safetensors"
assert entry["reconnectSnapshot"] == original_entry
async def test_write_rematch_lora_entry_snapshot_never_nests(tmp_path: Path):
scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
entry = {
"isDeleted": True,
"hash": "oldhash",
"file_name": "old.safetensors",
"reconnectSnapshot": {"file_name": "even-older.safetensors"},
}
item = _civitai_lora_item(sha256="c" * 64, file_name="new.safetensors")
scanner._write_rematch_lora_entry(entry, item)
snapshot = entry["reconnectSnapshot"]
assert snapshot["file_name"] == "old.safetensors"
assert "reconnectSnapshot" not in snapshot
async def test_write_rematch_checkpoint_entry_snapshots_pre_match_state(tmp_path: Path):
scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
original_entry = {
"isDeleted": True,
"hashInvalid": True,
"hash": "oldhash",
"file_name": "old.safetensors",
"name": "Old CP",
"modelVersionId": 0,
}
entry = dict(original_entry)
item = _civitai_checkpoint_item(
sha256="d" * 64,
version_id=333,
name="cp-v1",
model_name="New CP",
file_name="new-cp.safetensors",
)
scanner._write_rematch_checkpoint_entry(entry, item)
assert entry["hash"] == "d" * 64
assert entry["file_name"] == "new-cp.safetensors"
assert entry["reconnectSnapshot"] == original_entry
assert "reconnectSnapshot" not in entry["reconnectSnapshot"]
# Acceptance criterion (2): checkpoint entry rematched via L2 — parser style # Acceptance criterion (2): checkpoint entry rematched via L2 — parser style
@@ -4659,6 +4733,7 @@ async def test_rematch_all_recipes_per_recipe_error_continues_loop(
local_cache: dict[str, Any], local_cache: dict[str, Any],
autov3_cache: dict[str, Any], autov3_cache: dict[str, Any],
filename_cache=None, filename_cache=None,
**_kwargs: Any,
) -> tuple[int, int, dict[str, Any]]: ) -> tuple[int, int, dict[str, Any]]:
if recipe.get("id") == "boom": if recipe.get("id") == "boom":
raise RuntimeError("kaboom") raise RuntimeError("kaboom")
@@ -4717,12 +4792,15 @@ async def test_rematch_all_recipes_holds_mutation_lock(tmp_path: Path, monkeypat
local_cache: dict[str, Any], local_cache: dict[str, Any],
autov3_cache: dict[str, Any], autov3_cache: dict[str, Any],
filename_cache=None, filename_cache=None,
**kwargs: Any,
) -> tuple[int, int, dict[str, Any]]: ) -> tuple[int, int, dict[str, Any]]:
nonlocal entered nonlocal entered
if recipe.get("id") == "r0": if recipe.get("id") == "r0":
entered = True entered = True
await release.wait() await release.wait()
return await original(recipe, local_cache, autov3_cache, filename_cache) return await original(
recipe, local_cache, autov3_cache, filename_cache, **kwargs
)
monkeypatch.setattr(scanner, "_rematch_single_recipe", blocking_single) monkeypatch.setattr(scanner, "_rematch_single_recipe", blocking_single)
@@ -4899,6 +4977,236 @@ async def test_rematch_all_autov3_cache_reuse_across_calls(
assert len(called) == 1 assert len(called) == 1
# ---------------------------------------------------------------------------
# Relaxed rematch candidacy (Feature 3)
# ---------------------------------------------------------------------------
async def test_is_rematch_candidate_relaxed_accepts_healthy_entry(tmp_path: Path):
scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
healthy = {"hash": "abc", "file_name": "m.safetensors"}
assert scanner._is_rematch_candidate(healthy, relaxed=True)
# Default strict behavior is unchanged.
assert not scanner._is_rematch_candidate(healthy)
assert not scanner._is_rematch_candidate(healthy, relaxed=False)
async def test_is_rematch_candidate_relaxed_still_requires_identifier(tmp_path: Path):
scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
assert not scanner._is_rematch_candidate({}, relaxed=True)
assert not scanner._is_rematch_candidate({"isDeleted": True}, relaxed=True)
assert not scanner._is_rematch_candidate("garbage", relaxed=True)
async def test_rematch_relaxed_skips_healthy_entry_with_local_hash(
tmp_path: Path, monkeypatch
):
# Anti-churn: a relaxed-only candidate whose hash already resolves in the
# L1 local cache is already correctly linked — no write-back, no
# snapshot, and it counts as neither matched nor unresolved.
sha256 = ("A1" * 32).lower()
item = _civitai_lora_item(sha256=sha256, file_name="m.safetensors")
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
recipe: Dict[str, Any] = {
"id": "r1",
"loras": [{"hash": sha256, "file_name": "m.safetensors"}],
}
_set_recipe_cache(scanner, [recipe])
saved, _ = await _spy_rematch_persistence(scanner, monkeypatch)
result = await scanner.rematch_recipe_by_id("r1", relaxed=True)
assert result["success"] is True
assert result["matched_entries"] == 0
assert result["unresolved_entries"] == 0
assert result["details"] == {"matched": [], "unresolved": []}
assert saved == []
assert "reconnectSnapshot" not in recipe["loras"][0]
async def test_rematch_relaxed_matches_healthy_missing_entry_via_l4(
tmp_path: Path, monkeypatch
):
# A healthy entry whose hash is NOT in the local library becomes an L4
# filename match under relaxed mode when the base models agree.
sha256 = ("B2" * 32).lower()
item = _rematch_item(
sha256=sha256,
sub_type="lora",
base_model="SD 1.5",
file_name="detail.safetensors",
)
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
recipe: Dict[str, Any] = {
"id": "r1",
"base_model": "SD 1.5",
"loras": [
{
"hash": "f" * 64, # not present locally
"file_name": "detail.safetensors",
}
],
}
_set_recipe_cache(scanner, [recipe])
saved, _ = await _spy_rematch_persistence(scanner, monkeypatch)
# Strict mode never touches the healthy entry.
strict = await scanner.rematch_recipe_by_id("r1")
assert strict["matched_entries"] == 0
assert saved == []
result = await scanner.rematch_recipe_by_id("r1", relaxed=True)
assert result["matched_entries"] == 1
assert result["details"]["matched"] == [
{
"type": "lora",
"entry": "detail.safetensors",
"file_name": "detail.safetensors",
"match_level": "L4",
"lora_index": 0,
}
]
entry = recipe["loras"][0]
assert entry["hash"] == sha256
assert entry["reconnectSnapshot"]["hash"] == "f" * 64
assert saved == [recipe]
async def test_rematch_matched_details_carry_lora_index_and_bulk_flattens_l4(
tmp_path: Path, monkeypatch
):
sha256_l1 = ("C3" * 32).lower()
l1_item = _civitai_lora_item(sha256=sha256_l1, file_name="l1.safetensors")
l4_item = _rematch_item(
sha256=("D4" * 32).lower(),
sub_type="lora",
base_model="SD 1.5",
file_name="detail.safetensors",
)
scanner, _, _ = _make_rematch_scanner([l1_item, l4_item], [], tmp_path)
recipes: list[Dict[str, Any]] = [
{
"id": "r0",
"base_model": "SD 1.5",
"loras": [
# index 0: not a candidate at all (healthy, strict run)
{"hash": "zzz", "file_name": "other.safetensors"},
# index 1: L4 filename match
{"isDeleted": True, "file_name": "detail.safetensors"},
# index 2: L1 hash match
{
"isDeleted": True,
"hash": sha256_l1,
"file_name": "old.safetensors",
},
],
},
{"id": "r1", "loras": []},
]
_set_recipe_cache(scanner, recipes)
await _spy_rematch_persistence(scanner, monkeypatch)
await _spy_resort(scanner, monkeypatch)
result = await scanner.rematch_recipes_bulk(["r0", "r1"])
assert result["matched_entries"] == 2
matched = result["details"][0]["matched"]
assert matched[0]["lora_index"] == 1
assert matched[0]["match_level"] == "L4"
assert matched[1]["lora_index"] == 2
assert matched[1]["match_level"] == "L1"
# Only the L4 match is flattened for review; L1 matches need none.
assert result["l4_matches"] == [
{
"recipe_id": "r0",
"type": "lora",
"entry": "detail.safetensors",
"file_name": "detail.safetensors",
"lora_index": 1,
}
]
async def test_rematch_recipe_by_id_returns_flattened_l4_matches(
tmp_path: Path, monkeypatch
):
# The single-recipe return carries the same flattened l4_matches shape
# as the bulk/global paths so the frontend results modal works for all
# three entry points.
l4_item = _rematch_item(
sha256=("F6" * 32).lower(),
sub_type="lora",
base_model="SD 1.5",
file_name="detail.safetensors",
)
scanner, _, _ = _make_rematch_scanner([l4_item], [], tmp_path)
recipe: Dict[str, Any] = {
"id": "r1",
"base_model": "SD 1.5",
"loras": [{"isDeleted": True, "file_name": "detail.safetensors"}],
}
_set_recipe_cache(scanner, [recipe])
await _spy_rematch_persistence(scanner, monkeypatch)
result = await scanner.rematch_recipe_by_id("r1")
assert result["l4_matches"] == [
{
"recipe_id": "r1",
"type": "lora",
"entry": "detail.safetensors",
"file_name": "detail.safetensors",
"lora_index": 0,
}
]
async def test_rematch_all_recipes_reports_l4_matches_in_completed_payload(
tmp_path: Path, monkeypatch
):
l4_item = _rematch_item(
sha256=("E5" * 32).lower(),
sub_type="checkpoint",
base_model="SDXL",
file_name="realistic.safetensors",
)
scanner, _, _ = _make_rematch_scanner([], [l4_item], tmp_path)
recipe: Dict[str, Any] = {
"id": "r1",
"loras": [],
"checkpoint": {
"isDeleted": True,
"file_name": "realistic.safetensors",
"baseModel": "SDXL",
},
}
_set_recipe_cache(scanner, [recipe])
await _spy_rematch_persistence(scanner, monkeypatch)
await _spy_resort(scanner, monkeypatch)
events: list[Dict[str, Any]] = []
async def cb(ev: Dict[str, Any]) -> None:
events.append(ev)
result = await scanner.rematch_all_recipes(progress_callback=cb)
expected_l4 = [
{
"recipe_id": "r1",
"type": "checkpoint",
"entry": "realistic.safetensors",
"file_name": "realistic.safetensors",
}
]
# Checkpoint matches carry no lora_index (the checkpoint restore
# endpoint only needs recipe_id).
assert result["l4_matches"] == expected_l4
completed = [e for e in events if e["status"] == "completed"]
assert completed and completed[0]["l4_matches"] == expected_l4
async def test_find_all_duplicate_recipes_groups_by_fingerprint(recipe_scanner, monkeypatch): async def test_find_all_duplicate_recipes_groups_by_fingerprint(recipe_scanner, monkeypatch):
scanner, _ = recipe_scanner scanner, _ = recipe_scanner
+13 -1
View File
@@ -227,8 +227,20 @@ function formatAutocompleteInsertion(text = '') {
return getAutocompleteAppendCommaPreference() ? `${trimmed},` : `${trimmed} `; return getAutocompleteAppendCommaPreference() ? `${trimmed},` : `${trimmed} `;
} }
// Matches a complete <lora:name:strength[:clip_strength]> tag. Kept
// permissive on the strength fields (mirrors the backend parser) so tags
// are still protected while the user is mid-edit.
const LORA_TAG_PATTERN = /(<lora:[^:>]+:[^:>]+(?::[^:>]+)?>)/gi;
function normalizeAutocompleteSegment(segment = '') { function normalizeAutocompleteSegment(segment = '') {
return segment.replace(/\s+/g, ' ').trim(); // Collapse whitespace only outside <lora:...> tags: names inside the tags
// may legitimately contain repeated spaces (e.g. "test - 0021"), and
// collapsing them breaks file resolution at runtime.
return segment
.split(LORA_TAG_PATTERN)
.map((part, index) => (index % 2 === 1 ? part : part.replace(/\s+/g, ' ')))
.join('')
.trim();
} }
export function formatAutocompleteTextOnBlur(text = '') { export function formatAutocompleteTextOnBlur(text = '') {
+14 -2
View File
@@ -38,7 +38,17 @@ function cleanupLoraSyntax(text) {
return ""; return "";
} }
let cleaned = text // Protect <lora:...> tags with placeholders before cleanup: names inside
// the tags may legitimately contain repeated spaces or commas (e.g.
// "test - 0021"), and collapsing them breaks file resolution at runtime.
const protectedTags = [];
LORA_PATTERN.lastIndex = 0;
const masked = text.replace(LORA_PATTERN, (match) => {
protectedTags.push(match);
return `\u0000${protectedTags.length - 1}\u0000`;
});
let cleaned = masked
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
.replace(/,\s*,+/g, ",") .replace(/,\s*,+/g, ",")
.replace(/\s*,\s*/g, ",") .replace(/\s*,\s*/g, ",")
@@ -51,7 +61,9 @@ function cleanupLoraSyntax(text) {
cleaned = cleaned.replace(/(^,)|(,$)/g, ""); cleaned = cleaned.replace(/(^,)|(,$)/g, "");
cleaned = cleaned.replace(/,\s*/g, ", "); cleaned = cleaned.replace(/,\s*/g, ", ");
return cleaned.trim(); return cleaned
.trim()
.replace(/\u0000(\d+)\u0000/g, (_, index) => protectedTags[Number(index)]);
} }
export function applyLoraValuesToText(originalText, loras) { export function applyLoraValuesToText(originalText, loras) {