mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 11:11:26 -03:00
Compare commits
15 Commits
v1.2.2
...
e0052cd237
| Author | SHA1 | Date | |
|---|---|---|---|
| e0052cd237 | |||
| 3cdc5ba7a2 | |||
| 04485e384f | |||
| a03dc4002f | |||
| cc9d3bff42 | |||
| 2672b3331b | |||
| 4963bf2b2e | |||
| 51cad6f852 | |||
| 1b5cbbbaa0 | |||
| e747946f7a | |||
| 53fa22f39c | |||
| 82b34097fb | |||
| a7995db009 | |||
| 5ae4aef30e | |||
| 08023f0cd9 |
+36
-7
@@ -216,9 +216,6 @@
|
||||
"label": "Rezepte lokalen Modellen neu zuordnen",
|
||||
"loading": "Rezepte werden lokalen Modellen neu 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)",
|
||||
"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.",
|
||||
"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": {
|
||||
"title": "Lokale Beispielbilder",
|
||||
"message": "Keine lokalen Beispielbilder für dieses Modell gefunden. Ansichtsoptionen:",
|
||||
@@ -2168,6 +2200,7 @@
|
||||
"createMissingData": "Erforderliche Daten zum Erstellen des Rezepts fehlen",
|
||||
"created": "Rezept erfolgreich erstellt",
|
||||
"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",
|
||||
"noNextRecipe": "Kein weiteres Rezept verfügbar",
|
||||
"missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
|
||||
@@ -2222,10 +2255,6 @@
|
||||
"batchImportBrowseFailed": "Ordner konnte nicht durchsucht werden: {message}",
|
||||
"batchImportDirectorySelected": "Verzeichnis ausgewählt: {path}",
|
||||
"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",
|
||||
"rematchFailed": "Zuordnung der ausgewählten Rezepte fehlgeschlagen: {message}",
|
||||
"reimporting": "Rezept wird aus Quelle neu importiert...",
|
||||
|
||||
+36
-7
@@ -216,9 +216,6 @@
|
||||
"label": "Rematch recipes to local models",
|
||||
"loading": "Rematching recipes to local models...",
|
||||
"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).",
|
||||
"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.",
|
||||
"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": {
|
||||
"title": "Local Example Images",
|
||||
"message": "No local example images found for this model. View options:",
|
||||
@@ -2168,6 +2200,7 @@
|
||||
"createMissingData": "Missing required data to create recipe",
|
||||
"created": "Recipe created successfully",
|
||||
"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",
|
||||
"noNextRecipe": "No next recipe available",
|
||||
"missingLorasInfoFailed": "Failed to get information for missing LoRAs",
|
||||
@@ -2222,10 +2255,6 @@
|
||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||
"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",
|
||||
"rematchFailed": "Failed to rematch selected recipes: {message}",
|
||||
"reimporting": "Re-importing recipe from source...",
|
||||
|
||||
+36
-7
@@ -216,9 +216,6 @@
|
||||
"label": "Reasociar recetas con modelos locales",
|
||||
"loading": "Reasociando recetas con modelos locales...",
|
||||
"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)",
|
||||
"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.",
|
||||
"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": {
|
||||
"title": "Imágenes de ejemplo locales",
|
||||
"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",
|
||||
"created": "Receta creada exitosamente",
|
||||
"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",
|
||||
"noNextRecipe": "No hay siguiente receta disponible",
|
||||
"missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes",
|
||||
@@ -2222,10 +2255,6 @@
|
||||
"batchImportBrowseFailed": "No se pudo examinar el directorio: {message}",
|
||||
"batchImportDirectorySelected": "Directorio seleccionado: {path}",
|
||||
"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",
|
||||
"rematchFailed": "Falló la reasociación de las recetas seleccionadas: {message}",
|
||||
"reimporting": "Reimportando receta desde origen...",
|
||||
|
||||
+36
-7
@@ -216,9 +216,6 @@
|
||||
"label": "Réassocier les Recipes aux modèles locaux",
|
||||
"loading": "Réassociation des Recipes aux modèles locaux...",
|
||||
"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)",
|
||||
"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.",
|
||||
"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": {
|
||||
"title": "Images d'exemple locales",
|
||||
"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",
|
||||
"created": "Recipe créé avec succès",
|
||||
"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",
|
||||
"noNextRecipe": "Aucune Recipe suivante",
|
||||
"missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
|
||||
@@ -2222,10 +2255,6 @@
|
||||
"batchImportBrowseFailed": "Échec de la navigation dans le dossier : {message}",
|
||||
"batchImportDirectorySelected": "Dossier sélectionné : {path}",
|
||||
"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",
|
||||
"rematchFailed": "Échec de la réassociation des Recipes sélectionnées : {message}",
|
||||
"reimporting": "Ré-import de la Recipe depuis la source...",
|
||||
|
||||
+36
-7
@@ -216,9 +216,6 @@
|
||||
"label": "התאמה מחדש של מתכונים למודלים מקומיים",
|
||||
"loading": "מתבצעת התאמה מחדש של מתכונים למודלים מקומיים...",
|
||||
"success": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
|
||||
"successErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו",
|
||||
"allFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים",
|
||||
"noMatch": "לא נמצאה התאמה מקומית עבור {entries} פריטים ב־{recipes} מתכונים",
|
||||
"cancelled": "ההתאמה בוטלה. עודכנו {recipes} מתכונים ({entries} פריטים)",
|
||||
"error": "ההתאמה מחדש של המתכונים נכשלה: {message}"
|
||||
},
|
||||
@@ -1501,6 +1498,41 @@
|
||||
"note": "הקבצים יורדו באמצעות תבניות נתיב ברירת מחדל. זה עשוי לקחת זמן בהתאם למספר ה-LoRAs.",
|
||||
"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": {
|
||||
"title": "תמונות דוגמה מקומיות",
|
||||
"message": "לא נמצאו תמונות דוגמה מקומיות למודל זה. אפשרויות צפייה:",
|
||||
@@ -2168,6 +2200,7 @@
|
||||
"createMissingData": "חסרים נתונים נדרשים ליצירת המתכון",
|
||||
"created": "המתכון נוצר בהצלחה",
|
||||
"noMissingLoras": "אין LoRAs חסרים להורדה",
|
||||
"unresolvableMarkedForReconnect": "{count} פריטים שלא ניתן לפתור סומנו — עכשיו ניתן לחבר אותם מחדש ל-LoRA מקומי.",
|
||||
"noPreviousRecipe": "אין מתכון קודם זמין",
|
||||
"noNextRecipe": "אין מתכון נוסף זמין",
|
||||
"missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
|
||||
@@ -2222,10 +2255,6 @@
|
||||
"batchImportBrowseFailed": "לא ניתן היה לעיין בתיקייה: {message}",
|
||||
"batchImportDirectorySelected": "נבחרה תיקייה: {path}",
|
||||
"noRecipesSelected": "לא נבחרו מתכונים",
|
||||
"rematchComplete": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
|
||||
"rematchCompleteErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו",
|
||||
"rematchAllFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים שנבחרו",
|
||||
"rematchUnmatched": "לא נמצאה התאמה מקומית עבור {entries} פריטים ב־{recipes} מתכונים",
|
||||
"rematchSkipped": "אין צורך בהתאמה עבור {total} המתכונים שנבחרו",
|
||||
"rematchFailed": "ההתאמה מחדש של המתכונים שנבחרו נכשלה: {message}",
|
||||
"reimporting": "מייבא מתכון מחדש מהמקור...",
|
||||
|
||||
+36
-7
@@ -216,9 +216,6 @@
|
||||
"label": "レシピをローカルモデルに再マッチング",
|
||||
"loading": "レシピをローカルモデルに再マッチングしています...",
|
||||
"success": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
|
||||
"successErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)",
|
||||
"allFailed": "{total} 件中 {failures} 件のレシピの再マッチングに失敗しました",
|
||||
"noMatch": "{recipes} 件のレシピで {entries} エントリのローカルマッチが見つかりませんでした",
|
||||
"cancelled": "再マッチングをキャンセルしました。{recipes} 件のレシピを更新({entries} エントリ)",
|
||||
"error": "レシピの再マッチングに失敗しました:{message}"
|
||||
},
|
||||
@@ -1501,6 +1498,41 @@
|
||||
"note": "ファイルはデフォルトのパステンプレートを使用してダウンロードされます。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": {
|
||||
"title": "ローカル例画像",
|
||||
"message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:",
|
||||
@@ -2168,6 +2200,7 @@
|
||||
"createMissingData": "レシピ作成に必要なデータが不足しています",
|
||||
"created": "レシピを作成しました",
|
||||
"noMissingLoras": "ダウンロードする不足LoRAがありません",
|
||||
"unresolvableMarkedForReconnect": "解決できないエントリを {count} 件マークしました — ローカルの LoRA に再接続できるようになりました。",
|
||||
"noPreviousRecipe": "前のレシピがありません",
|
||||
"noNextRecipe": "次のレシピがありません",
|
||||
"missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました",
|
||||
@@ -2222,10 +2255,6 @@
|
||||
"batchImportBrowseFailed": "フォルダを参照できませんでした: {message}",
|
||||
"batchImportDirectorySelected": "選択されたフォルダ: {path}",
|
||||
"noRecipesSelected": "レシピが選択されていません",
|
||||
"rematchComplete": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
|
||||
"rematchCompleteErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)",
|
||||
"rematchAllFailed": "選択した {total} 件中 {failures} 件のレシピの再マッチングに失敗しました",
|
||||
"rematchUnmatched": "{recipes} 件のレシピで {entries} エントリのローカルマッチが見つかりませんでした",
|
||||
"rematchSkipped": "選択した {total} 件のレシピは再マッチングの必要がありませんでした",
|
||||
"rematchFailed": "選択したレシピの再マッチングに失敗しました:{message}",
|
||||
"reimporting": "ソースからレシピを再インポート中...",
|
||||
|
||||
+36
-7
@@ -216,9 +216,6 @@
|
||||
"label": "레시피를 로컬 모델에 다시 매칭",
|
||||
"loading": "레시피를 로컬 모델에 다시 매칭하는 중...",
|
||||
"success": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
|
||||
"successErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패",
|
||||
"allFailed": "{total}개 레시피 중 {failures}개 재매칭 실패",
|
||||
"noMatch": "{recipes}개 레시피에서 {entries}개 항목의 로컬 매칭을 찾지 못했습니다",
|
||||
"cancelled": "재매칭이 취소되었습니다. {recipes}개 레시피 업데이트됨({entries}개 항목)",
|
||||
"error": "레시피 재매칭 실패: {message}"
|
||||
},
|
||||
@@ -1501,6 +1498,41 @@
|
||||
"note": "파일은 기본 경로 템플릿을 사용하여 다운로드됩니다. 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": {
|
||||
"title": "로컬 예시 이미지",
|
||||
"message": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:",
|
||||
@@ -2168,6 +2200,7 @@
|
||||
"createMissingData": "레시피 생성에 필요한 데이터가 없습니다",
|
||||
"created": "레시피가 생성되었습니다",
|
||||
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
|
||||
"unresolvableMarkedForReconnect": "해석할 수 없는 항목 {count}개가 표시되었습니다 — 이제 로컬 LoRA에 다시 연결할 수 있습니다.",
|
||||
"noPreviousRecipe": "이전 레시피가 없습니다",
|
||||
"noNextRecipe": "다음 레시피가 없습니다",
|
||||
"missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
|
||||
@@ -2222,10 +2255,6 @@
|
||||
"batchImportBrowseFailed": "폴더를 찾아보지 못했습니다: {message}",
|
||||
"batchImportDirectorySelected": "선택한 폴더: {path}",
|
||||
"noRecipesSelected": "선택한 레시피가 없습니다",
|
||||
"rematchComplete": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
|
||||
"rematchCompleteErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패",
|
||||
"rematchAllFailed": "선택한 {total}개 레시피 중 {failures}개 재매칭 실패",
|
||||
"rematchUnmatched": "{recipes}개 레시피에서 {entries}개 항목의 로컬 매칭을 찾지 못했습니다",
|
||||
"rematchSkipped": "선택한 {total}개 레시피는 재매칭이 필요하지 않습니다",
|
||||
"rematchFailed": "선택한 레시피 재매칭 실패: {message}",
|
||||
"reimporting": "소스에서 레시피를 다시 가져오는 중...",
|
||||
|
||||
+36
-7
@@ -216,9 +216,6 @@
|
||||
"label": "Повторное сопоставление рецептов с локальными моделями",
|
||||
"loading": "Повторное сопоставление рецептов с локальными моделями...",
|
||||
"success": "Сопоставлено записей: {entries} в рецептах: {recipes}",
|
||||
"successErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}",
|
||||
"allFailed": "Не удалось сопоставить: {failures} из {total} рецептов",
|
||||
"noMatch": "Не найдено локального сопоставления для {entries} записей в {recipes} рецептах",
|
||||
"cancelled": "Сопоставление отменено. Обновлено рецептов: {recipes} (записей: {entries})",
|
||||
"error": "Не удалось выполнить сопоставление рецептов: {message}"
|
||||
},
|
||||
@@ -1501,6 +1498,41 @@
|
||||
"note": "Файлы будут скачаны с использованием шаблонов путей по умолчанию. Это может занять некоторое время в зависимости от количества LoRAs.",
|
||||
"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": {
|
||||
"title": "Локальные примеры изображений",
|
||||
"message": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:",
|
||||
@@ -2168,6 +2200,7 @@
|
||||
"createMissingData": "Отсутствуют необходимые данные для создания рецепта",
|
||||
"created": "Рецепт успешно создан",
|
||||
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
|
||||
"unresolvableMarkedForReconnect": "Помечено неразрешимых записей: {count} — теперь их можно переподключить к локальному LoRA.",
|
||||
"noPreviousRecipe": "Предыдущий рецепт отсутствует",
|
||||
"noNextRecipe": "Следующий рецепт отсутствует",
|
||||
"missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
|
||||
@@ -2222,10 +2255,6 @@
|
||||
"batchImportBrowseFailed": "Не удалось открыть папку: {message}",
|
||||
"batchImportDirectorySelected": "Выбрана папка: {path}",
|
||||
"noRecipesSelected": "Рецепты не выбраны",
|
||||
"rematchComplete": "Сопоставлено записей: {entries} в рецептах: {recipes}",
|
||||
"rematchCompleteErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}",
|
||||
"rematchAllFailed": "Не удалось сопоставить: {failures} из {total} выбранных рецептов",
|
||||
"rematchUnmatched": "Не найдено локального сопоставления для {entries} записей в {recipes} рецептах",
|
||||
"rematchSkipped": "Ни один из {total} выбранных рецептов не требует сопоставления",
|
||||
"rematchFailed": "Не удалось сопоставить выбранные рецепты: {message}",
|
||||
"reimporting": "Переимпорт рецепта из источника...",
|
||||
|
||||
+36
-7
@@ -216,9 +216,6 @@
|
||||
"label": "将配方重新匹配到本地模型",
|
||||
"loading": "正在将配方重新匹配到本地模型...",
|
||||
"success": "已匹配 {entries} 个条目,涉及 {recipes} 个配方",
|
||||
"successErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个配方,{failures} 个失败",
|
||||
"allFailed": "{failures}/{total} 个配方重新匹配失败",
|
||||
"noMatch": "在 {recipes} 个配方中未找到 {entries} 个条目的本地匹配",
|
||||
"cancelled": "已取消重新匹配。{recipes} 个配方已更新({entries} 个条目)。",
|
||||
"error": "配方重新匹配失败:{message}"
|
||||
},
|
||||
@@ -1501,6 +1498,41 @@
|
||||
"note": "文件将使用默认路径模板下载。根据 LoRAs 的数量,这可能需要一些时间。",
|
||||
"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": {
|
||||
"title": "本地示例图片",
|
||||
"message": "未找到此模型的本地示例图片。可选操作:",
|
||||
@@ -2168,6 +2200,7 @@
|
||||
"createMissingData": "缺少创建配方所需的数据",
|
||||
"created": "配方创建成功",
|
||||
"noMissingLoras": "没有缺失的 LoRA 可下载",
|
||||
"unresolvableMarkedForReconnect": "已标记 {count} 个无法解析的条目——现在可以将它们重新关联到本地 LoRA。",
|
||||
"noPreviousRecipe": "没有上一个配方",
|
||||
"noNextRecipe": "没有下一个配方",
|
||||
"missingLorasInfoFailed": "获取缺失 LoRA 信息失败",
|
||||
@@ -2222,10 +2255,6 @@
|
||||
"batchImportBrowseFailed": "浏览目录失败:{message}",
|
||||
"batchImportDirectorySelected": "已选择目录:{path}",
|
||||
"noRecipesSelected": "未选择任何配方",
|
||||
"rematchComplete": "已匹配 {entries} 个条目,涉及 {recipes} 个配方",
|
||||
"rematchCompleteErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个配方,{failures} 个失败",
|
||||
"rematchAllFailed": "{failures}/{total} 个所选配方重新匹配失败",
|
||||
"rematchUnmatched": "在 {recipes} 个配方中未找到 {entries} 个条目的本地匹配",
|
||||
"rematchSkipped": "{total} 个所选配方均无需重新匹配",
|
||||
"rematchFailed": "重新匹配所选配方失败:{message}",
|
||||
"reimporting": "正在从源重新导入配方...",
|
||||
|
||||
+36
-7
@@ -216,9 +216,6 @@
|
||||
"label": "將配方重新匹配到本地模型",
|
||||
"loading": "正在將配方重新匹配到本地模型...",
|
||||
"success": "已匹配 {entries} 個條目,涉及 {recipes} 個配方",
|
||||
"successErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個配方,{failures} 個失敗",
|
||||
"allFailed": "{failures}/{total} 個配方重新匹配失敗",
|
||||
"noMatch": "在 {recipes} 個配方中找不到 {entries} 個條目的本地匹配",
|
||||
"cancelled": "已取消重新匹配。{recipes} 個配方已更新({entries} 個條目)。",
|
||||
"error": "配方重新匹配失敗:{message}"
|
||||
},
|
||||
@@ -1501,6 +1498,41 @@
|
||||
"note": "檔案將使用預設路徑模板下載。根據 LoRAs 的數量,這可能需要一些時間。",
|
||||
"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": {
|
||||
"title": "本機範例圖片",
|
||||
"message": "此模型未找到本機範例圖片。可選擇:",
|
||||
@@ -2168,6 +2200,7 @@
|
||||
"createMissingData": "缺少建立配方所需的資料",
|
||||
"created": "配方建立成功",
|
||||
"noMissingLoras": "無缺少的 LoRA 可下載",
|
||||
"unresolvableMarkedForReconnect": "已標記 {count} 個無法解析的條目——現在可以將它們重新關聯到本地 LoRA。",
|
||||
"noPreviousRecipe": "沒有上一個配方",
|
||||
"noNextRecipe": "沒有下一個配方",
|
||||
"missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗",
|
||||
@@ -2222,10 +2255,6 @@
|
||||
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
|
||||
"batchImportDirectorySelected": "已選擇目錄:{path}",
|
||||
"noRecipesSelected": "未選取任何配方",
|
||||
"rematchComplete": "已匹配 {entries} 個條目,涉及 {recipes} 個配方",
|
||||
"rematchCompleteErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個配方,{failures} 個失敗",
|
||||
"rematchAllFailed": "{failures}/{total} 個所選配方重新匹配失敗",
|
||||
"rematchUnmatched": "在 {recipes} 個配方中找不到 {entries} 個條目的本地匹配",
|
||||
"rematchSkipped": "{total} 個所選配方均無需重新匹配",
|
||||
"rematchFailed": "重新匹配所選配方失敗:{message}",
|
||||
"reimporting": "正在從來源重新匯入配方...",
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""HTTP handler for download target routing decisions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ...services.download_routing import is_diffusion_model_download
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DownloadRoutingHandler:
|
||||
"""Expose the download-time checkpoint/diffusion-model routing decision.
|
||||
|
||||
The web UI calls this when the user reaches the download location step
|
||||
so the root dropdown offers the same root set (checkpoint vs unet) that
|
||||
the download manager would pick for ``use_default_paths``.
|
||||
"""
|
||||
|
||||
async def get_download_routing(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
payload = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Invalid JSON payload"}, status=400
|
||||
)
|
||||
|
||||
model_type = payload.get("model_type", "")
|
||||
base_model = payload.get("base_model") or ""
|
||||
file_types = payload.get("file_types") or []
|
||||
|
||||
if not isinstance(model_type, str) or not model_type:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "model_type is required"}, status=400
|
||||
)
|
||||
if not isinstance(base_model, str) or not isinstance(file_types, list):
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "base_model must be a string and file_types a list",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
is_diffusion = is_diffusion_model_download(
|
||||
model_type,
|
||||
file_types=(str(t) for t in file_types),
|
||||
base_model=base_model,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"is_diffusion_model": is_diffusion,
|
||||
"root_kind": "unet" if is_diffusion else model_type,
|
||||
}
|
||||
)
|
||||
@@ -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.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
|
||||
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)
|
||||
|
||||
# 4. Determine relative folder path for cache
|
||||
|
||||
@@ -56,6 +56,7 @@ from ...utils.constants import (
|
||||
)
|
||||
from .hf_handlers import HfHandler
|
||||
from .agent_handlers import AgentHandler
|
||||
from .download_routing_handlers import DownloadRoutingHandler
|
||||
from .model_handlers import ModelCivitaiHandler
|
||||
from ...utils.civitai_utils import rewrite_preview_url
|
||||
from ...utils.example_images_paths import (
|
||||
@@ -3884,6 +3885,7 @@ class MiscHandlerSet:
|
||||
base_model: BaseModelHandlerSet,
|
||||
hf_handler: Any = None,
|
||||
agent_handler: Any = None,
|
||||
download_routing: Any = None,
|
||||
) -> None:
|
||||
self.health = health
|
||||
self.settings = settings
|
||||
@@ -3904,6 +3906,7 @@ class MiscHandlerSet:
|
||||
self.base_model = base_model
|
||||
self.hf_handler = hf_handler
|
||||
self.agent_handler = agent_handler
|
||||
self.download_routing = download_routing
|
||||
|
||||
def to_route_mapping(
|
||||
self,
|
||||
@@ -3962,6 +3965,8 @@ class MiscHandlerSet:
|
||||
"get_agent_skills": self.agent_handler.get_agent_skills,
|
||||
"execute_agent_skill": self.agent_handler.execute_agent_skill,
|
||||
"cancel_agent_skill": self.agent_handler.cancel_agent_skill,
|
||||
# Download routing handler
|
||||
"get_download_routing": self.download_routing.get_download_routing,
|
||||
# Base model handlers
|
||||
"get_base_models": self.base_model.get_base_models,
|
||||
"refresh_base_models": self.base_model.refresh_base_models,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
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)
|
||||
class RecipeHandlerSet:
|
||||
"""Group of handlers providing recipe route implementations."""
|
||||
@@ -812,6 +832,8 @@ class RecipeManagementHandler:
|
||||
|
||||
recipe_scanner.reset_cancellation()
|
||||
|
||||
relaxed = await _parse_relaxed_flag(request)
|
||||
|
||||
async def progress_callback(data):
|
||||
await self._ws_manager.broadcast_recipe_rematch_progress(data)
|
||||
|
||||
@@ -819,7 +841,8 @@ class RecipeManagementHandler:
|
||||
async def run_rematch():
|
||||
try:
|
||||
await recipe_scanner.rematch_all_recipes(
|
||||
progress_callback=progress_callback
|
||||
progress_callback=progress_callback,
|
||||
relaxed=relaxed,
|
||||
)
|
||||
except Exception as e:
|
||||
self._logger.error(
|
||||
@@ -892,7 +915,13 @@ class RecipeManagementHandler:
|
||||
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)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
@@ -921,7 +950,10 @@ class RecipeManagementHandler:
|
||||
)
|
||||
|
||||
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)
|
||||
except RecipeNotFoundError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
||||
|
||||
@@ -103,6 +103,10 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/hf-repo-files", "get_hf_repo_files"
|
||||
),
|
||||
# Download target routing decision (checkpoint vs diffusion model roots)
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/download/routing", "get_download_routing"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/download-hf-model", "download_hf_model"
|
||||
),
|
||||
|
||||
@@ -41,6 +41,7 @@ from .handlers.misc_handlers import (
|
||||
from .handlers.base_model_handlers import BaseModelHandlerSet
|
||||
from .handlers.hf_handlers import HfHandler
|
||||
from .handlers.agent_handlers import AgentHandler
|
||||
from .handlers.download_routing_handlers import DownloadRoutingHandler
|
||||
from .misc_route_registrar import MiscRouteRegistrar
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -140,6 +141,7 @@ class MiscRoutes:
|
||||
base_model = BaseModelHandlerSet()
|
||||
hf_handler = HfHandler()
|
||||
agent_handler = AgentHandler()
|
||||
download_routing = DownloadRoutingHandler()
|
||||
|
||||
return self._handler_set_factory(
|
||||
health=health,
|
||||
@@ -161,6 +163,7 @@ class MiscRoutes:
|
||||
base_model=base_model,
|
||||
hf_handler=hf_handler,
|
||||
agent_handler=agent_handler,
|
||||
download_routing=download_routing,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -407,7 +407,6 @@ class AgentService:
|
||||
"base_model": metadata.get("base_model", ""),
|
||||
"tags": metadata.get("tags", []),
|
||||
"modelDescription": metadata.get("modelDescription", ""),
|
||||
"trainedWords": metadata.get("trainedWords", []),
|
||||
"sha256": (metadata.get("sha256") or "")[:16] + "..." if metadata.get("sha256") else "",
|
||||
"size": metadata.get("size", 0),
|
||||
}
|
||||
|
||||
@@ -161,6 +161,11 @@ class Aria2Downloader:
|
||||
(typically an expired CivitAI signed URL): a fresh URL is resolved
|
||||
and the partial download continues. Recovery is bounded by
|
||||
``MAX_TRANSFER_RECOVERY_ATTEMPTS``.
|
||||
|
||||
Cancellation never leaks daemon transfers: the gid is tracked in
|
||||
``_transfers`` before any post-``addUri`` await, and a gid accepted
|
||||
by the daemon while the caller is being cancelled is removed again
|
||||
before the ``CancelledError`` propagates.
|
||||
"""
|
||||
|
||||
await self._ensure_process()
|
||||
@@ -251,7 +256,11 @@ class Aria2Downloader:
|
||||
await asyncio.sleep(self._poll_interval)
|
||||
finally:
|
||||
current = self._transfers.get(download_id)
|
||||
if current is not None and current.gid == transfer.gid:
|
||||
if (
|
||||
transfer is not None
|
||||
and current is not None
|
||||
and current.gid == transfer.gid
|
||||
):
|
||||
self._transfers.pop(download_id, None)
|
||||
|
||||
async def _get_status_with_retry(
|
||||
@@ -339,21 +348,43 @@ class Aria2Downloader:
|
||||
resolved_url != url,
|
||||
)
|
||||
|
||||
# Shield the addUri RPC from cancellation: the daemon may accept the
|
||||
# download even when the caller is cancelled while the request is in
|
||||
# flight. On cancellation, wait for the RPC result so the freshly
|
||||
# created gid can be removed instead of leaking an untracked
|
||||
# download that keeps running in the daemon.
|
||||
add_task = asyncio.ensure_future(
|
||||
self._rpc_call("aria2.addUri", [[resolved_url], options])
|
||||
)
|
||||
try:
|
||||
gid = await self._rpc_call("aria2.addUri", [[resolved_url], options])
|
||||
gid = await asyncio.shield(add_task)
|
||||
except asyncio.CancelledError:
|
||||
leaked_gid: Any = None
|
||||
try:
|
||||
leaked_gid = await add_task
|
||||
except Exception:
|
||||
leaked_gid = None
|
||||
if isinstance(leaked_gid, str) and leaked_gid:
|
||||
logger.info(
|
||||
"Removing aria2 gid %s accepted while download %s was "
|
||||
"being cancelled",
|
||||
leaked_gid,
|
||||
download_id,
|
||||
)
|
||||
try:
|
||||
await self._rpc_call("aria2.forceRemove", [leaked_gid])
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to remove leaked aria2 gid %s for download %s: %s",
|
||||
leaked_gid,
|
||||
download_id,
|
||||
exc,
|
||||
)
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise Aria2Error(f"Failed to schedule aria2 download: {exc}") from exc
|
||||
|
||||
logger.debug("aria2 accepted download %s with gid %s", download_id, gid)
|
||||
await self._state_store.upsert(
|
||||
download_id,
|
||||
{
|
||||
"gid": gid,
|
||||
"save_path": save_path,
|
||||
"status": "downloading",
|
||||
"url": url,
|
||||
},
|
||||
)
|
||||
return gid
|
||||
|
||||
async def _register_transfer(
|
||||
@@ -372,7 +403,46 @@ class Aria2Downloader:
|
||||
headers=headers,
|
||||
)
|
||||
transfer = Aria2Transfer(gid=gid, save_path=os.path.abspath(save_path))
|
||||
# Register the transfer before any further await: once the daemon
|
||||
# holds the gid, cancel_download() must be able to find it. An await
|
||||
# in between would open a window where a concurrent cancel reports
|
||||
# "Download task not found" and the daemon keeps downloading
|
||||
# untracked.
|
||||
self._transfers[download_id] = transfer
|
||||
try:
|
||||
await self._state_store.upsert(
|
||||
download_id,
|
||||
{
|
||||
"gid": gid,
|
||||
"save_path": transfer.save_path,
|
||||
"status": "downloading",
|
||||
"url": url,
|
||||
},
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
# The task was cancelled while persisting state and the
|
||||
# coordinator's cancel ran before the transfer was registered
|
||||
# above. Remove the daemon transfer unless it was deliberately
|
||||
# paused (skip_download preserves paused transfers for resume).
|
||||
status = None
|
||||
try:
|
||||
status = await self.get_status(download_id)
|
||||
except Exception:
|
||||
status = None
|
||||
if status is not None and status.get("status") != "paused":
|
||||
try:
|
||||
await self._rpc_call("aria2.forceRemove", [gid])
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to remove aria2 gid %s for cancelled download %s: %s",
|
||||
gid,
|
||||
download_id,
|
||||
exc,
|
||||
)
|
||||
current = self._transfers.get(download_id)
|
||||
if current is not None and current.gid == gid:
|
||||
self._transfers.pop(download_id, None)
|
||||
raise
|
||||
return transfer
|
||||
|
||||
async def get_status(self, download_id: str) -> Optional[Dict[str, Any]]:
|
||||
|
||||
@@ -410,6 +410,10 @@ class CheckpointScanner(ModelScanner):
|
||||
|
||||
return None
|
||||
|
||||
def resolve_sub_type_for_path(self, file_path: Optional[str]) -> Optional[str]:
|
||||
"""Resolve sub_type from the configured root that contains the file."""
|
||||
return self._resolve_sub_type(self._find_root_for_file(file_path))
|
||||
|
||||
def adjust_metadata(self, metadata, file_path, root_path):
|
||||
"""Adjust metadata during scanning to set sub_type."""
|
||||
sub_type = self._resolve_sub_type(root_path)
|
||||
@@ -419,9 +423,7 @@ class CheckpointScanner(ModelScanner):
|
||||
|
||||
def adjust_cached_entry(self, entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Adjust entries loaded from the persisted cache to ensure sub_type is set."""
|
||||
sub_type = self._resolve_sub_type(
|
||||
self._find_root_for_file(entry.get("file_path"))
|
||||
)
|
||||
sub_type = self.resolve_sub_type_for_path(entry.get("file_path"))
|
||||
if sub_type:
|
||||
entry["sub_type"] = sub_type
|
||||
return entry
|
||||
|
||||
@@ -20,7 +20,6 @@ from urllib.parse import urlparse
|
||||
from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
|
||||
from ..utils.constants import (
|
||||
CARD_PREVIEW_WIDTH,
|
||||
DIFFUSION_MODEL_BASE_MODELS,
|
||||
MODEL_WEIGHT_FILE_TYPES,
|
||||
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
|
||||
VALID_LORA_TYPES,
|
||||
@@ -32,6 +31,7 @@ from ..utils.utils import sanitize_folder_name
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from .service_registry import ServiceRegistry
|
||||
from .download_routing import is_diffusion_model_download
|
||||
from .settings_manager import get_settings_manager
|
||||
from .metadata_service import get_default_metadata_provider, get_metadata_provider
|
||||
from .downloader import get_downloader, DownloadProgress, DownloadStreamControl
|
||||
@@ -1621,27 +1621,13 @@ class DownloadManager:
|
||||
}
|
||||
|
||||
# Check if this checkpoint should be treated as a diffusion model
|
||||
# Priority: (1) any file has type "UNet" or "Diffusion Model",
|
||||
# (2) baseModel is in DIFFUSION_MODEL_BASE_MODELS
|
||||
is_diffusion_model = False
|
||||
if model_type == "checkpoint":
|
||||
# Check file types first (more direct signal from CivitAI)
|
||||
version_files = version_info.get("files", [])
|
||||
for f in version_files:
|
||||
f_type = f.get("type", "")
|
||||
if f_type in ("UNet", "Diffusion Model"):
|
||||
is_diffusion_model = True
|
||||
logger.info(
|
||||
f"File type '{f_type}' detected, routing checkpoint to unet folder"
|
||||
)
|
||||
break
|
||||
|
||||
# Fallback to baseModel name check
|
||||
if not is_diffusion_model and base_model_value in DIFFUSION_MODEL_BASE_MODELS:
|
||||
is_diffusion_model = True
|
||||
logger.info(
|
||||
f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder"
|
||||
)
|
||||
# (shared with the download routing endpoint so the UI location
|
||||
# step and the actual download agree on the target roots).
|
||||
is_diffusion_model = is_diffusion_model_download(
|
||||
model_type,
|
||||
file_types=(f.get("type", "") for f in version_info.get("files", [])),
|
||||
base_model=base_model_value,
|
||||
)
|
||||
|
||||
# Existence check after the metadata fetch (#1058):
|
||||
# - An explicit file selection only blocks when THIS file is
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Shared download routing logic.
|
||||
|
||||
Decides whether a download initiated from the checkpoint library should be
|
||||
routed to the unet/diffusion-model roots instead of the checkpoint roots.
|
||||
Used by both the download manager (at download time) and the download
|
||||
routing HTTP endpoint (when the user picks a location in the UI), so the
|
||||
two can never disagree.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Iterable
|
||||
|
||||
from ..utils.constants import DIFFUSION_MODEL_BASE_MODELS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# File types reported by the CivitAI API that indicate a raw diffusion
|
||||
# model (loaded via UNETLoader in ComfyUI) rather than a full checkpoint.
|
||||
DIFFUSION_FILE_TYPES = frozenset({"UNet", "Diffusion Model"})
|
||||
|
||||
|
||||
def is_diffusion_model_download(
|
||||
model_type: str,
|
||||
file_types: Iterable[str] = (),
|
||||
base_model: str = "",
|
||||
) -> bool:
|
||||
"""Return True when a download should be routed to the unet roots.
|
||||
|
||||
Only applies to downloads initiated from the checkpoint library.
|
||||
Priority: (1) any file has type "UNet" or "Diffusion Model" (the more
|
||||
direct signal from CivitAI), (2) baseModel is a known diffusion model.
|
||||
"""
|
||||
if model_type != "checkpoint":
|
||||
return False
|
||||
|
||||
for file_type in file_types:
|
||||
if file_type in DIFFUSION_FILE_TYPES:
|
||||
logger.info(
|
||||
"File type '%s' detected, routing checkpoint to unet folder",
|
||||
file_type,
|
||||
)
|
||||
return True
|
||||
|
||||
if base_model in DIFFUSION_MODEL_BASE_MODELS:
|
||||
logger.info(
|
||||
"baseModel '%s' is a known diffusion model, routing to unet folder",
|
||||
base_model,
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
+91
-48
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import aiohttp
|
||||
@@ -32,8 +33,26 @@ _catalog_cache: Optional[Dict[str, List[str]]] = None
|
||||
# ``{provider_id: {model_id: max_output_tokens}}``.
|
||||
_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)
|
||||
|
||||
# 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]]:
|
||||
"""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
|
||||
in memory after the first successful fetch.
|
||||
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:
|
||||
return _catalog_cache
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=_CATALOG_TIMEOUT) as session:
|
||||
async with session.get(_MODEL_CATALOG_URL) as resp:
|
||||
if resp.status != 200:
|
||||
logger.warning("Model catalog returned HTTP %s", resp.status)
|
||||
return _catalog_cache or {}
|
||||
data = await resp.json()
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||
logger.warning("Failed to fetch model catalog: %s", exc)
|
||||
return _catalog_cache or {}
|
||||
async with _catalog_lock:
|
||||
# Re-check under the lock: another caller may have fetched (or
|
||||
# failed) while we were waiting.
|
||||
if _catalog_cache is not None:
|
||||
return _catalog_cache
|
||||
if (
|
||||
_catalog_last_failure is not None
|
||||
and time.monotonic() - _catalog_last_failure < _CATALOG_FAILURE_COOLDOWN
|
||||
):
|
||||
logger.debug(
|
||||
"Skipping model catalog fetch: last attempt failed %.0fs ago",
|
||||
time.monotonic() - _catalog_last_failure,
|
||||
)
|
||||
return {}
|
||||
|
||||
if not isinstance(data, dict):
|
||||
logger.warning("Model catalog is not a dict, got %s", type(data).__name__)
|
||||
return _catalog_cache or {}
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=_CATALOG_TIMEOUT) as session:
|
||||
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]] = {}
|
||||
output_limits: Dict[str, Dict[str, int]] = {}
|
||||
for provider_id, provider_info in data.items():
|
||||
if not isinstance(provider_info, dict):
|
||||
continue
|
||||
models_dict = provider_info.get("models")
|
||||
if not isinstance(models_dict, dict):
|
||||
continue
|
||||
model_ids: List[str] = []
|
||||
provider_limits: Dict[str, int] = {}
|
||||
for mid, model_info in models_dict.items():
|
||||
if not isinstance(mid, str):
|
||||
if not isinstance(data, dict):
|
||||
logger.warning("Model catalog is not a dict, got %s", type(data).__name__)
|
||||
_catalog_last_failure = time.monotonic()
|
||||
return {}
|
||||
|
||||
result: Dict[str, List[str]] = {}
|
||||
output_limits: Dict[str, Dict[str, int]] = {}
|
||||
for provider_id, provider_info in data.items():
|
||||
if not isinstance(provider_info, dict):
|
||||
continue
|
||||
model_ids.append(mid)
|
||||
if isinstance(model_info, dict):
|
||||
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
|
||||
models_dict = provider_info.get("models")
|
||||
if not isinstance(models_dict, dict):
|
||||
continue
|
||||
model_ids: List[str] = []
|
||||
provider_limits: Dict[str, int] = {}
|
||||
for mid, model_info in models_dict.items():
|
||||
if not isinstance(mid, str):
|
||||
continue
|
||||
model_ids.append(mid)
|
||||
if isinstance(model_info, dict):
|
||||
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
|
||||
_model_output_limits = output_limits
|
||||
logger.debug(
|
||||
"Loaded model catalog: %d providers, %d total models "
|
||||
"(%d providers have output limits)",
|
||||
len(result),
|
||||
sum(len(m) for m in result.values()),
|
||||
len(output_limits),
|
||||
)
|
||||
return result
|
||||
_catalog_cache = result
|
||||
_model_output_limits = output_limits
|
||||
logger.debug(
|
||||
"Loaded model catalog: %d providers, %d total models "
|
||||
"(%d providers have output limits)",
|
||||
len(result),
|
||||
sum(len(m) for m in result.values()),
|
||||
len(output_limits),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
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"
|
||||
try:
|
||||
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:
|
||||
logger.debug("Ollama API returned HTTP %s from %s", resp.status, api_base)
|
||||
return []
|
||||
|
||||
@@ -1339,6 +1339,14 @@ class ModelScanner:
|
||||
"""Hook for subclasses: adjust entries loaded from the persisted cache."""
|
||||
return entry
|
||||
|
||||
def resolve_sub_type_for_path(self, file_path: Optional[str]) -> Optional[str]:
|
||||
"""Hook for subclasses: resolve the location-derived sub_type for a file.
|
||||
|
||||
Returns ``None`` when the model type has no location-derived sub-types
|
||||
(the default), in which case any stored value is left untouched.
|
||||
"""
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_path_value(path: Optional[str]) -> str:
|
||||
if not path:
|
||||
@@ -1869,6 +1877,20 @@ class ModelScanner:
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving metadata file: {e}")
|
||||
|
||||
if metadata is not None:
|
||||
# sub_type is derived from the model's location (e.g. a file
|
||||
# moved from a checkpoints root into a unet root becomes a
|
||||
# diffusion_model). Persist the recalculated value into the
|
||||
# moved metadata file so later metadata-driven cache syncs
|
||||
# do not revert the cache entry to the stale sub_type.
|
||||
new_sub_type = self.resolve_sub_type_for_path(target_file)
|
||||
if new_sub_type and metadata.get('sub_type') != new_sub_type:
|
||||
metadata['sub_type'] = new_sub_type
|
||||
try:
|
||||
await MetadataManager.save_metadata(moved_metadata_path, metadata)
|
||||
except Exception as e:
|
||||
logger.error(f"Error persisting sub_type for moved model: {e}")
|
||||
|
||||
update_result = await self.update_single_model_cache(source_path, target_file, metadata, recalculate_type=True)
|
||||
|
||||
return {
|
||||
@@ -2064,6 +2086,11 @@ class ModelScanner:
|
||||
file_path_override=file_path,
|
||||
)
|
||||
|
||||
# Location-derived fields (e.g. the checkpoint sub_type) must be
|
||||
# re-resolved from the file path rather than trusting the on-disk
|
||||
# metadata snapshot, which may predate a cross-root move.
|
||||
desired_entry = self.adjust_cached_entry(desired_entry)
|
||||
|
||||
# Ensure sha256 is populated (defensive — metadata should have it)
|
||||
if (
|
||||
not desired_entry.get("sha256")
|
||||
|
||||
+159
-31
@@ -483,32 +483,43 @@ class RecipeScanner:
|
||||
suggestions.sort(key=lambda s: (-s["score"], s["file_name"].lower()))
|
||||
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.
|
||||
|
||||
An entry counts as unresolved when its identity is known to be
|
||||
broken (``isDeleted`` or ``hashInvalid``) or when it is missing
|
||||
identity fields (``hash``/``file_name``). A healthy entry whose
|
||||
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
|
||||
its CivitAI-valid hash must never be overwritten by the imprecise
|
||||
filename fallback.
|
||||
hash is simply not present in the local library is NOT a candidate
|
||||
in the default strict mode: it may be a recipe imported without
|
||||
downloading the model yet, and its CivitAI-valid hash must never be
|
||||
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):
|
||||
return False
|
||||
unresolved = (
|
||||
entry.get("isDeleted")
|
||||
or entry.get("hashInvalid")
|
||||
or not entry.get("hash")
|
||||
or not entry.get("file_name")
|
||||
)
|
||||
has_identifier = (
|
||||
entry.get("hash")
|
||||
or entry.get("modelVersionId")
|
||||
or entry.get("id")
|
||||
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]]:
|
||||
"""Build a version-cached map of computed AutoV3 hashes to local items.
|
||||
@@ -809,7 +820,9 @@ class RecipeScanner:
|
||||
"""Check if cancellation has been 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.
|
||||
|
||||
Logs one INFO summary line for this run and delegates the per-recipe
|
||||
@@ -817,12 +830,14 @@ class RecipeScanner:
|
||||
|
||||
Args:
|
||||
recipe_id: ID of the recipe to rematch
|
||||
relaxed: When True, healthy entries are rematch candidates too
|
||||
(see ``_rematch_single_recipe``).
|
||||
|
||||
Returns:
|
||||
Dict summary of the rematch result (see ``_rematch_recipe_by_id``).
|
||||
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
|
||||
logger.info(
|
||||
"Recipe rematch %s (%s): success=%s, %d entries matched, %d unresolved, %d errors",
|
||||
@@ -835,7 +850,9 @@ class RecipeScanner:
|
||||
)
|
||||
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.
|
||||
|
||||
Match snapshots (local hash cache, computed autov3 cache, filename
|
||||
@@ -846,12 +863,16 @@ class RecipeScanner:
|
||||
|
||||
Args:
|
||||
recipe_id: ID of the recipe to rematch
|
||||
relaxed: When True, healthy entries are rematch candidates too
|
||||
(see ``_rematch_single_recipe``).
|
||||
|
||||
Returns:
|
||||
Dict summary of the rematch result with unified counters
|
||||
(matched_recipes, matched_entries, unresolved_recipes,
|
||||
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
|
||||
``unresolved_recipes`` (a recipe with unmatched candidates counts
|
||||
as both). Raises RecipeNotFoundError when the recipe is missing.
|
||||
@@ -872,7 +893,8 @@ class RecipeScanner:
|
||||
|
||||
try:
|
||||
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:
|
||||
logger.error(
|
||||
@@ -891,12 +913,16 @@ class RecipeScanner:
|
||||
"unresolved_recipes": 0,
|
||||
"unresolved_entries": 0,
|
||||
"details": {"matched": [], "unresolved": []},
|
||||
"l4_matches": [],
|
||||
"recipe": recipe,
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
unresolved_entries = len(details["unresolved"])
|
||||
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:
|
||||
return {
|
||||
@@ -908,6 +934,7 @@ class RecipeScanner:
|
||||
"unresolved_recipes": unresolved_recipes,
|
||||
"unresolved_entries": unresolved_entries,
|
||||
"details": details,
|
||||
"l4_matches": l4_matches,
|
||||
"recipe": recipe,
|
||||
}
|
||||
|
||||
@@ -921,6 +948,7 @@ class RecipeScanner:
|
||||
"unresolved_recipes": unresolved_recipes,
|
||||
"unresolved_entries": unresolved_entries,
|
||||
"details": details,
|
||||
"l4_matches": l4_matches,
|
||||
"recipe": await self.get_recipe_by_id(recipe_id),
|
||||
}
|
||||
|
||||
@@ -930,6 +958,8 @@ class RecipeScanner:
|
||||
local_cache: dict[str, dict[str, Any]],
|
||||
autov3_cache: dict[str, dict[str, Any]],
|
||||
filename_cache: Optional[dict[str, list[dict[str, Any]]]] = None,
|
||||
*,
|
||||
relaxed: bool = False,
|
||||
) -> Tuple[int, int, Dict[str, Any]]:
|
||||
"""Rematch a single recipe's lora/checkpoint entries against local models.
|
||||
|
||||
@@ -945,16 +975,24 @@ class RecipeScanner:
|
||||
autov3_cache: L3 computed-autov3 cache snapshot
|
||||
filename_cache: L4 filename cache snapshot, or None to disable
|
||||
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:
|
||||
Tuple of (rematched_entries, errors, details). The errors element
|
||||
is always 0 on a normal return — a persistence failure RAISES
|
||||
``RecipePersistenceError`` so callers can count it. ``details``
|
||||
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
|
||||
rematch candidate that found no local match — an expected outcome
|
||||
(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:
|
||||
RecipePersistenceError: when the recipe changed but
|
||||
@@ -963,11 +1001,23 @@ class RecipeScanner:
|
||||
rematched = 0
|
||||
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
|
||||
loras = recipe.get("loras", [])
|
||||
if isinstance(loras, list):
|
||||
for entry in loras:
|
||||
if not self._is_rematch_candidate(entry):
|
||||
for lora_index, entry in enumerate(loras):
|
||||
if not is_actionable_candidate(entry):
|
||||
continue
|
||||
item, level = await self._match_rematch_entry_with_level(
|
||||
entry,
|
||||
@@ -991,6 +1041,7 @@ class RecipeScanner:
|
||||
"entry": self._entry_identifier(entry),
|
||||
"file_name": item.get("file_name") or "",
|
||||
"match_level": level,
|
||||
"lora_index": lora_index,
|
||||
}
|
||||
)
|
||||
self._write_rematch_lora_entry(entry, item)
|
||||
@@ -1000,7 +1051,7 @@ class RecipeScanner:
|
||||
# silently since ``entry.get`` on a str would raise AttributeError).
|
||||
checkpoint = recipe.get("checkpoint")
|
||||
if isinstance(checkpoint, dict):
|
||||
if self._is_rematch_candidate(checkpoint):
|
||||
if is_actionable_candidate(checkpoint):
|
||||
item, level = await self._match_rematch_entry_with_level(
|
||||
checkpoint,
|
||||
local_cache,
|
||||
@@ -1065,8 +1116,36 @@ class RecipeScanner:
|
||||
self._update_fts_index_for_recipe(recipe, "update")
|
||||
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(
|
||||
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]:
|
||||
"""Rematch every recipe's deleted lora/checkpoint entries locally.
|
||||
|
||||
@@ -1080,14 +1159,19 @@ class RecipeScanner:
|
||||
|
||||
Args:
|
||||
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:
|
||||
Dict summary of the rematch run with unified counters
|
||||
(matched_recipes/matched_entries/unresolved_recipes/unresolved_
|
||||
entries plus the legacy success/status/rematched/skipped/errors/
|
||||
total fields). ``rematched`` (legacy) counts updated recipes —
|
||||
use ``matched_entries`` for the entry-level total.
|
||||
total fields) and ``l4_matches``. ``rematched`` (legacy) counts
|
||||
updated recipes — use ``matched_entries`` for the entry-level
|
||||
total.
|
||||
"""
|
||||
start_time = time.perf_counter()
|
||||
|
||||
@@ -1109,6 +1193,7 @@ class RecipeScanner:
|
||||
unresolved_entries = 0
|
||||
skipped_count = 0
|
||||
errors_count = 0
|
||||
l4_matches: List[Dict[str, Any]] = []
|
||||
|
||||
for i, recipe in enumerate(all_recipes):
|
||||
if self.is_cancelled():
|
||||
@@ -1137,6 +1222,7 @@ class RecipeScanner:
|
||||
"matched_entries": matched_entries,
|
||||
"unresolved_recipes": unresolved_recipes,
|
||||
"unresolved_entries": unresolved_entries,
|
||||
"l4_matches": l4_matches,
|
||||
}
|
||||
)
|
||||
return {
|
||||
@@ -1150,6 +1236,7 @@ class RecipeScanner:
|
||||
"matched_entries": matched_entries,
|
||||
"unresolved_recipes": unresolved_recipes,
|
||||
"unresolved_entries": unresolved_entries,
|
||||
"l4_matches": l4_matches,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -1165,11 +1252,15 @@ class RecipeScanner:
|
||||
)
|
||||
|
||||
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:
|
||||
matched_recipes += 1
|
||||
matched_entries += rematched
|
||||
l4_matches.extend(
|
||||
self._collect_l4_matches(recipe.get("id"), details)
|
||||
)
|
||||
else:
|
||||
skipped_count += 1
|
||||
|
||||
@@ -1215,6 +1306,7 @@ class RecipeScanner:
|
||||
"matched_entries": matched_entries,
|
||||
"unresolved_recipes": unresolved_recipes,
|
||||
"unresolved_entries": unresolved_entries,
|
||||
"l4_matches": l4_matches,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1228,9 +1320,12 @@ class RecipeScanner:
|
||||
"matched_entries": matched_entries,
|
||||
"unresolved_recipes": unresolved_recipes,
|
||||
"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.
|
||||
|
||||
Iterates ``_rematch_recipe_by_id`` over each id: not-found ids are
|
||||
@@ -1241,14 +1336,18 @@ class RecipeScanner:
|
||||
|
||||
Args:
|
||||
recipe_ids: List of recipe ids to rematch.
|
||||
relaxed: When True, healthy entries are rematch candidates too
|
||||
(see ``_rematch_single_recipe``).
|
||||
|
||||
Returns:
|
||||
Dict summary of the bulk run with unified counters
|
||||
(matched_recipes, matched_entries, unresolved_recipes,
|
||||
unresolved_entries plus the legacy total/rematched/skipped/errors
|
||||
fields) and a per-recipe ``details`` list. The legacy ``rematched``
|
||||
field is the total entry count (same as ``matched_entries``) —
|
||||
unlike ``rematch_all_recipes`` where it counts updated recipes.
|
||||
fields), a per-recipe ``details`` list, and ``l4_matches`` — a
|
||||
flattened list of filename-level matches for review/undo. The
|
||||
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)
|
||||
matched_recipes = 0
|
||||
@@ -1259,10 +1358,13 @@ class RecipeScanner:
|
||||
errors = 0
|
||||
recipes: List[Dict[str, Any]] = []
|
||||
details_list: List[Dict[str, Any]] = []
|
||||
l4_matches: List[Dict[str, Any]] = []
|
||||
|
||||
for recipe_id in recipe_ids:
|
||||
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"):
|
||||
matched_recipes += result.get("matched_recipes", 0)
|
||||
matched_entries += result.get("matched_entries", 0)
|
||||
@@ -1275,6 +1377,9 @@ class RecipeScanner:
|
||||
details_list.append(
|
||||
{"recipe_id": recipe_id, **result["details"]}
|
||||
)
|
||||
l4_matches.extend(
|
||||
self._collect_l4_matches(recipe_id, result["details"])
|
||||
)
|
||||
else:
|
||||
errors += result.get("errors", 0)
|
||||
except RecipeNotFoundError:
|
||||
@@ -1309,12 +1414,22 @@ class RecipeScanner:
|
||||
"unresolved_entries": unresolved_entries,
|
||||
"recipes": recipes,
|
||||
"details": details_list,
|
||||
"l4_matches": l4_matches,
|
||||
}
|
||||
|
||||
def _write_rematch_lora_entry(
|
||||
self, entry: Dict[str, Any], item: Dict[str, Any]
|
||||
) -> None:
|
||||
"""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["hashInvalid"] = False
|
||||
|
||||
@@ -1338,6 +1453,8 @@ class RecipeScanner:
|
||||
if civitai.get("name"):
|
||||
entry["modelVersionName"] = civitai["name"]
|
||||
|
||||
entry["reconnectSnapshot"] = snapshot
|
||||
|
||||
def _write_rematch_checkpoint_entry(
|
||||
self, entry: Dict[str, Any], item: Dict[str, Any]
|
||||
) -> None:
|
||||
@@ -1349,6 +1466,15 @@ class RecipeScanner:
|
||||
when they already exist on the entry (or written fresh for the
|
||||
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["hashInvalid"] = False
|
||||
|
||||
@@ -1389,6 +1515,8 @@ class RecipeScanner:
|
||||
else:
|
||||
entry["modelVersionId"] = civ_id
|
||||
|
||||
entry["reconnectSnapshot"] = snapshot
|
||||
|
||||
async def _save_recipe_persistently(self, recipe: Dict[str, Any]) -> bool:
|
||||
"""Helper to save a recipe to both JSON and EXIF metadata."""
|
||||
recipe_id = recipe.get("id")
|
||||
|
||||
@@ -77,9 +77,6 @@ class BaseModelMetadata:
|
||||
last_checked_at: float = 0 # Last checked timestamp
|
||||
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
|
||||
trainedWords: List[str] = field(
|
||||
default_factory=list
|
||||
) # Trigger words / activation prompts (source-agnostic)
|
||||
_unknown_fields: Dict[str, Any] = field(
|
||||
default_factory=dict, repr=False, compare=False
|
||||
) # Store unknown fields
|
||||
@@ -92,9 +89,6 @@ class BaseModelMetadata:
|
||||
if self.tags is None:
|
||||
self.tags = []
|
||||
|
||||
if self.trainedWords is None:
|
||||
self.trainedWords = []
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "BaseModelMetadata":
|
||||
"""Create instance from dictionary"""
|
||||
|
||||
@@ -592,3 +592,145 @@ button:disabled,
|
||||
margin-top: 2px;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,13 @@
|
||||
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 {
|
||||
position: absolute;
|
||||
background: var(--lora-surface);
|
||||
|
||||
@@ -184,6 +184,7 @@ export const DOWNLOAD_ENDPOINTS = {
|
||||
downloadGet: '/api/lm/download-model-get',
|
||||
cancelGet: '/api/lm/cancel-download-get',
|
||||
progress: '/api/lm/download-progress',
|
||||
routing: '/api/lm/download/routing',
|
||||
exampleImages: '/api/lm/force-download-example-images', // Re-process example images ignoring previous status
|
||||
exampleImagesMissing: '/api/lm/download-example-images' // Download only missing example images
|
||||
};
|
||||
|
||||
@@ -677,7 +677,7 @@ export class RecipeSidebarApiClient {
|
||||
};
|
||||
}
|
||||
|
||||
async rematchBulkModels(filePaths) {
|
||||
async rematchBulkModels(filePaths, options = {}) {
|
||||
if (!filePaths || filePaths.length === 0) {
|
||||
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');
|
||||
}
|
||||
|
||||
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, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recipe_ids: recipeIds,
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
@@ -25,6 +25,7 @@ export class CheckpointContextMenu extends BaseContextMenu {
|
||||
showMenu(x, y, card) {
|
||||
super.showMenu(x, y, card);
|
||||
this.updateExcludeMenuItem();
|
||||
this.updateEnrichMenuItem(card);
|
||||
|
||||
// Update the "Move to other root" label based on current model type
|
||||
const moveOtherItem = this.menu.querySelector('[data-action="move-other"]');
|
||||
|
||||
@@ -4,6 +4,8 @@ import { translate } from '../../utils/i18nHelpers.js';
|
||||
import { state } from '../../state/index.js';
|
||||
import { getCompleteApiConfig, getCurrentModelType } from '../../api/apiConfig.js';
|
||||
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
|
||||
import { rematchModalManager } from '../../managers/RematchModalManager.js';
|
||||
import { showRematchSummary } from '../RematchSummaryModal.js';
|
||||
|
||||
export class GlobalContextMenu extends BaseContextMenu {
|
||||
constructor() {
|
||||
@@ -368,6 +370,18 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
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;
|
||||
menuItem?.classList.add('disabled');
|
||||
|
||||
@@ -384,6 +398,7 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
const response = await fetch('/api/lm/recipes/rematch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ relaxed: !!relaxed }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
@@ -411,48 +426,32 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
const recipes = p.matched_recipes ?? p.rematched ?? 0;
|
||||
const failures = p.errors || 0;
|
||||
const unresolved = p.unresolved_entries ?? 0;
|
||||
if (entries > 0) {
|
||||
const successKey = failures > 0
|
||||
? 'globalContextMenu.rematchRecipes.successErrors'
|
||||
: 'globalContextMenu.rematchRecipes.success';
|
||||
const successText = failures > 0
|
||||
? `Matched ${entries} entries across ${recipes} recipes, ${failures} failed.`
|
||||
: `Matched ${entries} entries across ${recipes} recipes.`;
|
||||
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).
|
||||
const l4Matches = Array.isArray(p.l4_matches) ? p.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 = entries === 0 && unresolved === 0 && failures === 0;
|
||||
if (isNoop) {
|
||||
progressUI?.complete(translate(
|
||||
'globalContextMenu.rematchRecipes.success',
|
||||
{ count: recipes, recipes, entries, failures },
|
||||
`Matched ${entries} entries across ${recipes} recipes.`
|
||||
));
|
||||
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
|
||||
if (window.recipesPage) {
|
||||
@@ -469,7 +468,23 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
{ count: cancelledRecipes, recipes: cancelledRecipes, entries: cancelledEntries },
|
||||
`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) {
|
||||
// Progress might have finished quickly and been cleaned up
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { BaseContextMenu } from './BaseContextMenu.js';
|
||||
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
||||
import { state } from '../../state/index.js';
|
||||
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
|
||||
import { copyLoraSyntax, sendLoraToWorkflow, buildLoraSyntax, showToast } from '../../utils/uiHelpers.js';
|
||||
import { copyLoraSyntax, sendLoraToWorkflow, buildLoraSyntax } from '../../utils/uiHelpers.js';
|
||||
import { showExcludeModal, showDeleteModal } from '../../utils/modalUtils.js';
|
||||
import { moveManager } from '../../managers/MoveManager.js';
|
||||
|
||||
@@ -27,16 +26,6 @@ export class LoraContextMenu extends BaseContextMenu {
|
||||
this.updateEnrichMenuItem(card);
|
||||
}
|
||||
|
||||
updateEnrichMenuItem(card) {
|
||||
const enrichItem = this.menu?.querySelector('[data-action="enrich-hf-llm"]');
|
||||
if (!enrichItem) return;
|
||||
const hasHfUrl = !!card.dataset.hf_url;
|
||||
enrichItem.classList.toggle('disabled', !hasHfUrl);
|
||||
enrichItem.title = hasHfUrl
|
||||
? ''
|
||||
: 'Link this model to a HuggingFace repo first (Link Model \u2192 Link to HuggingFace)';
|
||||
}
|
||||
|
||||
handleMenuAction(action, menuItem) {
|
||||
// First try to handle with common actions
|
||||
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
|
||||
@@ -75,9 +64,6 @@ export class LoraContextMenu extends BaseContextMenu {
|
||||
case 'refresh-metadata':
|
||||
getModelApiClient().refreshSingleModelMetadata(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
case 'enrich-hf-llm':
|
||||
this.enrichWithAgent(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
case 'exclude':
|
||||
showExcludeModal(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
@@ -87,68 +73,6 @@ export class LoraContextMenu extends BaseContextMenu {
|
||||
}
|
||||
}
|
||||
|
||||
async enrichWithAgent(filePath) {
|
||||
const { agentManager } = await import('../../managers/AgentManager.js');
|
||||
|
||||
const configured = await agentManager.isLlmConfigured();
|
||||
if (!configured) {
|
||||
showToast('toast.agent.llmNotConfigured', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
agentManager.connect();
|
||||
|
||||
const progressUI = state.loadingManager.showEnhancedProgress(
|
||||
'Enriching metadata with AI...'
|
||||
);
|
||||
|
||||
function cleanupCallbacks() {
|
||||
const pIdx = agentManager.progressCallbacks.indexOf(onProgress);
|
||||
if (pIdx >= 0) agentManager.progressCallbacks.splice(pIdx, 1);
|
||||
const cIdx = agentManager.completeCallbacks.indexOf(onComplete);
|
||||
if (cIdx >= 0) agentManager.completeCallbacks.splice(cIdx, 1);
|
||||
const eIdx = agentManager.errorCallbacks.indexOf(onError);
|
||||
if (eIdx >= 0) agentManager.errorCallbacks.splice(eIdx, 1);
|
||||
}
|
||||
|
||||
const onProgress = (data) => {
|
||||
if (data.status === 'processing' && data.current_path && data.updated_data && Object.keys(data.updated_data).length > 0) {
|
||||
if (state.virtualScroller?.updateSingleItem) {
|
||||
state.virtualScroller.updateSingleItem(data.current_path, data.updated_data);
|
||||
}
|
||||
const pct = data.total > 0 ? Math.floor((data.processed / data.total) * 100) : 0;
|
||||
const name = data.current_path.split('/').pop();
|
||||
progressUI.updateProgress(pct, name, `Processing ${name}`);
|
||||
}
|
||||
};
|
||||
agentManager.onProgress(onProgress);
|
||||
|
||||
const onComplete = (data) => {
|
||||
cleanupCallbacks();
|
||||
|
||||
if (data.status === 'completed') {
|
||||
progressUI.complete(data.summary || 'Enrich complete');
|
||||
showToast('toast.agent.enrichComplete', { summary: data.summary || 'Done' }, 'success');
|
||||
}
|
||||
};
|
||||
agentManager.onComplete(onComplete);
|
||||
|
||||
const onError = (data) => {
|
||||
cleanupCallbacks();
|
||||
state.loadingManager.hide();
|
||||
showToast('toast.agent.enrichFailed', { error: data.error || 'Unknown error' }, 'error');
|
||||
};
|
||||
agentManager.onError(onError);
|
||||
|
||||
try {
|
||||
await agentManager.executeSkill('enrich_hf_metadata', [filePath]);
|
||||
} catch (error) {
|
||||
cleanupCallbacks();
|
||||
state.loadingManager.hide();
|
||||
showToast('toast.agent.enrichFailed', { error: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
sendLoraToWorkflow(replaceMode) {
|
||||
const card = this.currentCard;
|
||||
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
||||
|
||||
@@ -278,6 +278,79 @@ export const ModelContextMenuMixin = {
|
||||
setTimeout(() => urlInput.focus(), 50);
|
||||
},
|
||||
|
||||
// HF metadata enrichment (AI agent) methods
|
||||
updateEnrichMenuItem(card) {
|
||||
const enrichItem = this.menu?.querySelector('[data-action="enrich-hf-llm"]');
|
||||
if (!enrichItem) return;
|
||||
const hasHfUrl = !!card.dataset.hf_url;
|
||||
enrichItem.classList.toggle('disabled', !hasHfUrl);
|
||||
enrichItem.title = hasHfUrl
|
||||
? ''
|
||||
: 'Link this model to a HuggingFace repo first (Link Model → Link to HuggingFace)';
|
||||
},
|
||||
|
||||
async enrichWithAgent(filePath) {
|
||||
const { agentManager } = await import('../../managers/AgentManager.js');
|
||||
|
||||
const configured = await agentManager.isLlmConfigured();
|
||||
if (!configured) {
|
||||
showToast('toast.agent.llmNotConfigured', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
agentManager.connect();
|
||||
|
||||
const progressUI = state.loadingManager.showEnhancedProgress(
|
||||
'Enriching metadata with AI...'
|
||||
);
|
||||
|
||||
function cleanupCallbacks() {
|
||||
const pIdx = agentManager.progressCallbacks.indexOf(onProgress);
|
||||
if (pIdx >= 0) agentManager.progressCallbacks.splice(pIdx, 1);
|
||||
const cIdx = agentManager.completeCallbacks.indexOf(onComplete);
|
||||
if (cIdx >= 0) agentManager.completeCallbacks.splice(cIdx, 1);
|
||||
const eIdx = agentManager.errorCallbacks.indexOf(onError);
|
||||
if (eIdx >= 0) agentManager.errorCallbacks.splice(eIdx, 1);
|
||||
}
|
||||
|
||||
const onProgress = (data) => {
|
||||
if (data.status === 'processing' && data.current_path && data.updated_data && Object.keys(data.updated_data).length > 0) {
|
||||
if (state.virtualScroller?.updateSingleItem) {
|
||||
state.virtualScroller.updateSingleItem(data.current_path, data.updated_data);
|
||||
}
|
||||
const pct = data.total > 0 ? Math.floor((data.processed / data.total) * 100) : 0;
|
||||
const name = data.current_path.split('/').pop();
|
||||
progressUI.updateProgress(pct, name, `Processing ${name}`);
|
||||
}
|
||||
};
|
||||
agentManager.onProgress(onProgress);
|
||||
|
||||
const onComplete = (data) => {
|
||||
cleanupCallbacks();
|
||||
|
||||
if (data.status === 'completed') {
|
||||
progressUI.complete(data.summary || 'Enrich complete');
|
||||
showToast('toast.agent.enrichComplete', { summary: data.summary || 'Done' }, 'success');
|
||||
}
|
||||
};
|
||||
agentManager.onComplete(onComplete);
|
||||
|
||||
const onError = (data) => {
|
||||
cleanupCallbacks();
|
||||
state.loadingManager.hide();
|
||||
showToast('toast.agent.enrichFailed', { error: data.error || 'Unknown error' }, 'error');
|
||||
};
|
||||
agentManager.onError(onError);
|
||||
|
||||
try {
|
||||
await agentManager.executeSkill('enrich_hf_metadata', [filePath]);
|
||||
} catch (error) {
|
||||
cleanupCallbacks();
|
||||
state.loadingManager.hide();
|
||||
showToast('toast.agent.enrichFailed', { error: error.message }, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
parseModelId(value) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return null;
|
||||
@@ -388,6 +461,9 @@ export const ModelContextMenuMixin = {
|
||||
case 'link-hf':
|
||||
this.showLinkHfModal();
|
||||
return true;
|
||||
case 'enrich-hf-llm':
|
||||
this.enrichWithAgent(this.currentCard.dataset.filepath);
|
||||
return true;
|
||||
case 'set-nsfw':
|
||||
this.showNSFWLevelSelector(null, null, this.currentCard);
|
||||
return true;
|
||||
|
||||
@@ -6,6 +6,8 @@ import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js
|
||||
import { updateRecipeMetadata } from '../../api/recipeApi.js';
|
||||
import { state } from '../../state/index.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';
|
||||
|
||||
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
|
||||
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 {
|
||||
showToast('Rematching recipe to local models...', {}, 'info');
|
||||
|
||||
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();
|
||||
|
||||
if (result.success) {
|
||||
const matchedEntries = result.matched_entries || result.rematched || 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) {
|
||||
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}`);
|
||||
if (detailResponse.ok) {
|
||||
const updatedRecipe = await detailResponse.json();
|
||||
@@ -330,16 +342,22 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
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.
|
||||
showToast(
|
||||
'toast.recipes.rematchUnmatched',
|
||||
{ entries: result.unresolved_entries, recipes: 1, total: 1 },
|
||||
'info'
|
||||
);
|
||||
} else {
|
||||
}
|
||||
|
||||
if (isNoop) {
|
||||
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 {
|
||||
throw new Error(result.error || 'Rematch failed');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// 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 { buildCivitaiUrl } from '../utils/civitaiUtils.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
@@ -1078,8 +1078,9 @@ class RecipeModal {
|
||||
|
||||
// Mirror the checkpoint "broken" rule: deleted, an
|
||||
// unresolvable hash, or a name-only remnant with no CivitAI
|
||||
// identifiers at all cannot be fixed by downloading —
|
||||
// reconnecting a local LoRA is the only remediation.
|
||||
// identifiers at all cannot be fixed by downloading, so no
|
||||
// download button is offered. Reconnect is always available
|
||||
// for missing entries (see renderLoraItemActions).
|
||||
const needsReconnect = !existsLocally
|
||||
&& (isDeleted || lora.hashInvalid || !this.canDownloadLora(lora));
|
||||
|
||||
@@ -1180,7 +1181,7 @@ class RecipeModal {
|
||||
</div>
|
||||
${actionsRow}
|
||||
</div>
|
||||
${needsReconnect ? `
|
||||
${!existsLocally ? `
|
||||
<div class="lora-reconnect-container" data-lora-index="${loraIndex}">
|
||||
<div class="reconnect-instructions">
|
||||
<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.
|
||||
*/
|
||||
_isUnresolvableDownloadError(message) {
|
||||
if (!message) {
|
||||
return false;
|
||||
}
|
||||
const text = String(message).toLowerCase();
|
||||
return /(not found|no longer available|deleted|removed|404|410|gone)/.test(text);
|
||||
return isUnresolvableDownloadError(message);
|
||||
}
|
||||
|
||||
getResourceCivitaiUrl(resource) {
|
||||
@@ -2915,19 +2912,9 @@ class RecipeModal {
|
||||
}
|
||||
|
||||
const controls = [];
|
||||
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 {
|
||||
if (!needsReconnect) {
|
||||
// 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 downloadTooltip = translate('recipes.resources.downloadLoraTooltip', {}, 'Download this LoRA');
|
||||
controls.push(`
|
||||
@@ -2938,6 +2925,18 @@ class RecipeModal {
|
||||
</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>`;
|
||||
}
|
||||
|
||||
@@ -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, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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">×</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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1390,8 +1390,22 @@ export function initVersionsTab({
|
||||
|
||||
try {
|
||||
const client = ensureClient();
|
||||
const rootsData = await client.fetchModelRoots();
|
||||
const roots = rootsData?.roots;
|
||||
// On the checkpoints page a diffusion model lives under the unet
|
||||
// roots, so both root sets are needed to locate the current file.
|
||||
let roots;
|
||||
if (modelType === 'checkpoints') {
|
||||
const [checkpointRoots, unetRoots] = await Promise.all([
|
||||
client.fetchModelRoots(),
|
||||
client.fetchModelRoots('diffusion_model'),
|
||||
]);
|
||||
roots = [
|
||||
...(checkpointRoots?.roots || []),
|
||||
...(unetRoots?.roots || []),
|
||||
];
|
||||
} else {
|
||||
const rootsData = await client.fetchModelRoots();
|
||||
roots = rootsData?.roots;
|
||||
}
|
||||
if (!Array.isArray(roots) || roots.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { HeaderManager } from './components/Header.js';
|
||||
import { settingsManager } from './managers/SettingsManager.js';
|
||||
import { moveManager } from './managers/MoveManager.js';
|
||||
import { bulkManager } from './managers/BulkManager.js';
|
||||
import { rematchModalManager } from './managers/RematchModalManager.js';
|
||||
import { ExampleImagesManager } from './managers/ExampleImagesManager.js';
|
||||
import { helpManager } from './managers/HelpManager.js';
|
||||
import { doctorManager } from './managers/DoctorManager.js';
|
||||
@@ -68,6 +69,7 @@ export class AppCore {
|
||||
window.doctorManager = doctorManager;
|
||||
window.moveManager = moveManager;
|
||||
window.bulkManager = bulkManager;
|
||||
window.rematchModalManager = rematchModalManager;
|
||||
|
||||
// Initialize UI components
|
||||
window.headerManager = new HeaderManager();
|
||||
|
||||
@@ -3,6 +3,8 @@ import { showToast, showActionToast, copyToClipboard, sendLoraToWorkflow, sendEm
|
||||
import { handleUndoDelete } from '../utils/undoHelpers.js';
|
||||
import { updateCardsForBulkMode } from '../components/shared/ModelCard.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 { RecipeSidebarApiClient, updateRecipeMetadata, extractRecipeId } from '../api/recipeApi.js';
|
||||
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
|
||||
@@ -978,6 +980,15 @@ export class BulkManager {
|
||||
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 {
|
||||
const apiClient = this.getActiveApiClient();
|
||||
const filePaths = Array.from(state.selectedModels);
|
||||
@@ -989,7 +1000,7 @@ export class BulkManager {
|
||||
|
||||
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) {
|
||||
const total = result.total || filePaths.length;
|
||||
@@ -1015,38 +1026,29 @@ export class BulkManager {
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedEntries > 0) {
|
||||
const hasFailures = failures > 0;
|
||||
const toastKey = hasFailures
|
||||
? 'toast.recipes.rematchCompleteErrors'
|
||||
: 'toast.recipes.rematchComplete';
|
||||
showToast(
|
||||
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 {
|
||||
// Complete no-op (nothing matched, nothing unresolved, no
|
||||
// errors) keeps the lightweight toast; anything else opens
|
||||
// the post-run summary modal.
|
||||
const l4Matches = Array.isArray(result.l4_matches) ? result.l4_matches : [];
|
||||
const isNoop = matchedEntries === 0 && unresolvedEntries === 0 && failures === 0;
|
||||
if (isNoop) {
|
||||
showToast(
|
||||
'toast.recipes.rematchSkipped',
|
||||
{ total },
|
||||
'info'
|
||||
);
|
||||
} else {
|
||||
showRematchSummary({
|
||||
scope: 'bulk',
|
||||
total,
|
||||
matchedRecipes,
|
||||
matchedEntries,
|
||||
unresolvedRecipes,
|
||||
unresolvedEntries,
|
||||
skipped,
|
||||
errors: failures,
|
||||
l4Matches,
|
||||
});
|
||||
}
|
||||
|
||||
if (state.bulkMode) this.toggleBulkMode();
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { isUnresolvableDownloadError } from '../utils/uiHelpers.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||
import { MODEL_TYPES } from '../api/apiConfig.js';
|
||||
import { extractRecipeId } from '../api/recipeApi.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { modalManager } from './ModalManager.js';
|
||||
|
||||
@@ -13,6 +15,7 @@ export class BulkMissingLoraDownloadManager {
|
||||
this.loraApiClient = getModelApiClient(MODEL_TYPES.LORA);
|
||||
this.pendingLoras = [];
|
||||
this.pendingRecipes = [];
|
||||
this.pendingMissingByRecipe = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,6 +139,7 @@ export class BulkMissingLoraDownloadManager {
|
||||
// Execute download
|
||||
await this.executeDownload(this.pendingLoras);
|
||||
this.pendingLoras = [];
|
||||
this.pendingMissingByRecipe = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,6 +157,9 @@ export class BulkMissingLoraDownloadManager {
|
||||
|
||||
// Collect missing LoRAs with deduplication
|
||||
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) {
|
||||
showToast('toast.recipes.noMissingLorasInSelection', {}, 'info');
|
||||
@@ -196,6 +203,7 @@ export class BulkMissingLoraDownloadManager {
|
||||
|
||||
let completedDownloads = 0;
|
||||
let failedDownloads = 0;
|
||||
let markedInvalidCount = 0;
|
||||
let currentLoraProgress = 0;
|
||||
let cancelled = false;
|
||||
|
||||
@@ -304,6 +312,12 @@ export class BulkMissingLoraDownloadManager {
|
||||
if (!response.success) {
|
||||
console.error(`Failed to download LoRA ${lora.name || lora.file_name}: ${response.error}`);
|
||||
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 {
|
||||
completedDownloads++;
|
||||
updateProgress(100, completedDownloads, '');
|
||||
@@ -312,6 +326,9 @@ export class BulkMissingLoraDownloadManager {
|
||||
if (!cancelled) {
|
||||
console.error(`Error downloading LoRA ${lora.name || lora.file_name}:`, error);
|
||||
failedDownloads++;
|
||||
if (isUnresolvableDownloadError(error?.message)) {
|
||||
markedInvalidCount += await this.markLoraHashInvalidInRecipes(lora);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -335,9 +352,16 @@ export class BulkMissingLoraDownloadManager {
|
||||
}, '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)
|
||||
if (state.virtualScroller) {
|
||||
const { extractRecipeId } = await import('../api/recipeApi.js');
|
||||
for (const recipe of this.pendingRecipes) {
|
||||
const recipeId = extractRecipeId(recipe.file_path);
|
||||
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
|
||||
* @returns {Promise<string|null>} - LoRA root directory or null
|
||||
|
||||
@@ -3,6 +3,7 @@ import { showToast, setupAutoNewlineOnPaste } from '../utils/uiHelpers.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { LoadingManager } from './LoadingManager.js';
|
||||
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||
import { DOWNLOAD_ENDPOINTS } from '../api/apiConfig.js';
|
||||
import { isModelWeightFile } from '../utils/modelFileTypes.js';
|
||||
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
||||
@@ -489,8 +490,9 @@ export class DownloadManager {
|
||||
return { type: 'civitai' };
|
||||
}
|
||||
|
||||
// Hugging Face resolve URL → direct file
|
||||
const hfResolveMatch = trimmed.match(/huggingface\.co\/([^/\s]+\/[^/\s]+)\/resolve\/([^/\s]+)\/(.+)/i);
|
||||
// Hugging Face resolve/blob URL → direct file
|
||||
// "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) {
|
||||
return {
|
||||
type: 'hf-resolve',
|
||||
@@ -953,12 +955,7 @@ export class DownloadManager {
|
||||
async proceedToLocationContent() {
|
||||
|
||||
try {
|
||||
const _isDiffusionModel = this.selectedFile
|
||||
? (this.selectedFile.type === 'UNet' || this.selectedFile.type === 'Diffusion Model')
|
||||
: (this.currentVersion?.files || []).some(
|
||||
f => f.type === 'UNet' || f.type === 'Diffusion Model'
|
||||
);
|
||||
this._isDiffusionModel = _isDiffusionModel;
|
||||
this._isDiffusionModel = await this._resolveIsDiffusionModel();
|
||||
|
||||
let rootsData;
|
||||
if (this._isDiffusionModel && this.apiClient.modelType === 'checkpoints') {
|
||||
@@ -1019,6 +1016,55 @@ export class DownloadManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether this download routes to the diffusion model (unet)
|
||||
* roots rather than the checkpoint roots. The backend owns the routing
|
||||
* rule (file type first, baseModel fallback), so the location step asks
|
||||
* it; if the endpoint is unavailable we degrade to the local file-type
|
||||
* signal, which matches the backend for well-annotated models.
|
||||
*/
|
||||
async _resolveIsDiffusionModel() {
|
||||
const localFileTypeCheck = this.selectedFile
|
||||
? (this.selectedFile.type === 'UNet' || this.selectedFile.type === 'Diffusion Model')
|
||||
: (this.currentVersion?.files || []).some(
|
||||
f => f.type === 'UNet' || f.type === 'Diffusion Model'
|
||||
);
|
||||
|
||||
// Only checkpoint downloads can route to the diffusion model roots;
|
||||
// without version metadata (e.g. Hugging Face downloads) the local
|
||||
// signal is all we have.
|
||||
if (this.apiClient.modelType !== 'checkpoints'
|
||||
|| (!this.selectedFile && !this.currentVersion)) {
|
||||
return localFileTypeCheck;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileTypes = this.selectedFile
|
||||
? [this.selectedFile.type]
|
||||
: (this.currentVersion?.files || []).map(f => f.type);
|
||||
const response = await fetch(DOWNLOAD_ENDPOINTS.routing, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model_type: 'checkpoint',
|
||||
base_model: this.currentVersion?.baseModel || '',
|
||||
file_types: fileTypes,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`routing endpoint returned ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
if (typeof data.is_diffusion_model === 'boolean') {
|
||||
return data.is_diffusion_model;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[download] routing endpoint unavailable, '
|
||||
+ 'falling back to local file-type check:', error);
|
||||
}
|
||||
return localFileTypeCheck;
|
||||
}
|
||||
|
||||
loadDefaultPathSetting() {
|
||||
const modelType = this.apiClient.modelType;
|
||||
const storageKey = `use_default_path_${modelType}`;
|
||||
|
||||
@@ -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);
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
@@ -329,7 +329,11 @@ class MoveManager {
|
||||
const results = await apiClient.moveBulkModels(this.bulkFilePaths, targetPath, this.useDefaultPath);
|
||||
movedFiles = (results || [])
|
||||
.filter(r => r.success)
|
||||
.map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path }));
|
||||
.map(r => ({
|
||||
original_file_path: r.original_file_path,
|
||||
new_file_path: r.new_file_path,
|
||||
sub_type: r.cache_entry?.sub_type
|
||||
}));
|
||||
|
||||
// Deselect moving items and exit bulk mode
|
||||
this.bulkFilePaths.forEach(path => bulkManager.deselectItem(path));
|
||||
@@ -340,7 +344,11 @@ class MoveManager {
|
||||
if (result) {
|
||||
movedFiles.push({
|
||||
original_file_path: result.original_file_path || this.currentFilePath,
|
||||
new_file_path: result.new_file_path
|
||||
new_file_path: result.new_file_path,
|
||||
// The backend recalculates location-derived fields
|
||||
// (e.g. checkpoint -> diffusion_model) during the move;
|
||||
// carry them so the card re-renders with the new type.
|
||||
sub_type: result.cache_entry?.sub_type
|
||||
});
|
||||
}
|
||||
|
||||
@@ -379,24 +387,28 @@ class MoveManager {
|
||||
}
|
||||
|
||||
if (stillVisible) {
|
||||
const newData = {
|
||||
file_path: moved.new_file_path,
|
||||
folder: newRelativeFolder
|
||||
};
|
||||
if (moved.sub_type) newData.sub_type = moved.sub_type;
|
||||
pathsToUpdate.push({
|
||||
originalPath: moved.original_file_path,
|
||||
newData: {
|
||||
file_path: moved.new_file_path,
|
||||
folder: newRelativeFolder
|
||||
}
|
||||
newData
|
||||
});
|
||||
} else {
|
||||
pathsToRemove.push(moved.original_file_path);
|
||||
}
|
||||
} else {
|
||||
// No folder filter active — items remain visible, just update path
|
||||
const newData = {
|
||||
file_path: moved.new_file_path,
|
||||
folder: this._getRelativeFolder(moved.new_file_path)
|
||||
};
|
||||
if (moved.sub_type) newData.sub_type = moved.sub_type;
|
||||
pathsToUpdate.push({
|
||||
originalPath: moved.original_file_path,
|
||||
newData: {
|
||||
file_path: moved.new_file_path,
|
||||
folder: this._getRelativeFolder(moved.new_file_path)
|
||||
}
|
||||
newData
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -325,6 +325,23 @@ export function isTypingContext(target) {
|
||||
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() {
|
||||
const activeFolder = getStorageItem('activeFolder');
|
||||
const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`);
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="enrich-hf-llm">
|
||||
<i class="fas fa-wand-magic-sparkles"></i> <span>{{ t('loras.contextMenu.enrichHfAgent') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-separator menu-section-break"></div>
|
||||
<!-- Workflow -->
|
||||
<div class="context-menu-item" data-action="copyname"><i class="fas fa-copy"></i> {{ t('loras.contextMenu.copyFilename') }}</div>
|
||||
|
||||
@@ -125,4 +125,35 @@
|
||||
</button>
|
||||
</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()">×</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.
|
||||
"""
|
||||
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 ""
|
||||
tags: List[str] = metadata.get("tags") or []
|
||||
notes: str = metadata.get("notes") or ""
|
||||
|
||||
@@ -149,7 +149,6 @@ def create_initial_metadata(
|
||||
"metadata_source": "",
|
||||
"last_checked_at": 0,
|
||||
"hash_status": "completed",
|
||||
"trainedWords": [],
|
||||
"hf_url": hf_url,
|
||||
"usage_tips": "{}",
|
||||
}
|
||||
|
||||
@@ -309,6 +309,21 @@ describe('RecipeSidebarApiClient bulk operations', () => {
|
||||
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 () => {
|
||||
const api = new RecipeSidebarApiClient();
|
||||
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));
|
||||
}
|
||||
|
||||
// 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() {
|
||||
let resolve;
|
||||
let reject;
|
||||
@@ -2223,7 +2233,7 @@ describe('Interaction-level regression coverage', () => {
|
||||
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 = `
|
||||
<div id="globalContextMenu" class="context-menu">
|
||||
<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 }));
|
||||
// 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);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await flushAsyncTasks();
|
||||
}
|
||||
await runPromise;
|
||||
|
||||
expect(global.fetch).toHaveBeenNthCalledWith(1, '/api/lm/recipes/rematch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ relaxed: false }),
|
||||
});
|
||||
expect(global.fetch).toHaveBeenNthCalledWith(2, '/api/lm/recipes/rematch-progress');
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(progressUI.showCancelButton).toHaveBeenCalledTimes(1);
|
||||
expect(progressUI.complete).toHaveBeenCalledWith('Matched 5 entries across 2 recipes.');
|
||||
// Oracle R4-F1 pin: count comes from `rematched`, a blind `repaired` mirror renders undefined
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
// A non-noop run opens the summary modal instead of toasting; the
|
||||
// progress overlay completes without a message.
|
||||
expect(progressUI.complete).toHaveBeenCalledWith();
|
||||
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||
'globalContextMenu.rematchRecipes.success',
|
||||
{ count: 2, recipes: 2, entries: 5, failures: 0 },
|
||||
'success'
|
||||
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('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(rematchItem.classList.contains('disabled')).toBe(false);
|
||||
expect(menu._rematchInProgress).toBe(false);
|
||||
@@ -2295,7 +2322,7 @@ describe('Interaction-level regression coverage', () => {
|
||||
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 = `
|
||||
<div id="globalContextMenu" class="context-menu">
|
||||
<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 }));
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
|
||||
const rematchModalManager = await getRematchModalManager();
|
||||
const runPromise = rematchModalManager.confirmOptions();
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await flushAsyncTasks();
|
||||
}
|
||||
await runPromise;
|
||||
|
||||
expect(progressUI.complete).toHaveBeenCalledWith('Matched 5 entries across 2 recipes, 2 failed.');
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'globalContextMenu.rematchRecipes.successErrors',
|
||||
{ count: 2, recipes: 2, entries: 5, failures: 2 },
|
||||
'warning'
|
||||
);
|
||||
expect(progressUI.complete).toHaveBeenCalledWith();
|
||||
const summaryModal = document.getElementById('rematchSummaryModal');
|
||||
expect(summaryModal).not.toBeNull();
|
||||
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
|
||||
expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('2');
|
||||
expect(menu._rematchInProgress).toBe(false);
|
||||
|
||||
delete window.recipesPage;
|
||||
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 = `
|
||||
<div id="globalContextMenu" class="context-menu">
|
||||
<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 }));
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
|
||||
const rematchModalManager = await getRematchModalManager();
|
||||
const runPromise = rematchModalManager.confirmOptions();
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await flushAsyncTasks();
|
||||
}
|
||||
await runPromise;
|
||||
|
||||
expect(progressUI.complete).toHaveBeenCalledWith('Rematch failed for 3 of 3 recipes.');
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'globalContextMenu.rematchRecipes.allFailed',
|
||||
{ total: 3, recipes: 0, entries: 0, failures: 3 },
|
||||
'error'
|
||||
);
|
||||
expect(progressUI.complete).toHaveBeenCalledWith();
|
||||
const summaryModal = document.getElementById('rematchSummaryModal');
|
||||
expect(summaryModal).not.toBeNull();
|
||||
expect(summaryModal.querySelector('.summary-header').classList.contains('error')).toBe(true);
|
||||
expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('3');
|
||||
expect(menu._rematchInProgress).toBe(false);
|
||||
|
||||
delete window.recipesPage;
|
||||
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 = `
|
||||
<div id="globalContextMenu" class="context-menu">
|
||||
<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 }));
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
|
||||
const rematchModalManager = await getRematchModalManager();
|
||||
const runPromise = rematchModalManager.confirmOptions();
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await flushAsyncTasks();
|
||||
}
|
||||
await runPromise;
|
||||
|
||||
expect(progressUI.complete).toHaveBeenCalledWith('No local match found for 2 entries in 1 recipes.');
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'globalContextMenu.rematchRecipes.noMatch',
|
||||
{ entries: 2, recipes: 1, total: 3, failures: 0 },
|
||||
'info'
|
||||
);
|
||||
expect(progressUI.complete).toHaveBeenCalledWith();
|
||||
const summaryModal = document.getElementById('rematchSummaryModal');
|
||||
expect(summaryModal).not.toBeNull();
|
||||
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
|
||||
expect(summaryModal.querySelector('.stat-card-total .stat-card-value').textContent).toBe('2');
|
||||
expect(menu._rematchInProgress).toBe(false);
|
||||
|
||||
delete window.recipesPage;
|
||||
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 = `
|
||||
<div id="globalContextMenu" class="context-menu">
|
||||
<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 }));
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
|
||||
const rematchModalManager = await getRematchModalManager();
|
||||
const runPromise = rematchModalManager.confirmOptions();
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await flushAsyncTasks();
|
||||
}
|
||||
await runPromise;
|
||||
|
||||
expect(progressUI.complete).toHaveBeenCalledWith('Rematch cancelled. 1 recipes updated (2 entries).');
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'globalContextMenu.rematchRecipes.cancelled',
|
||||
{ count: 1, recipes: 1, entries: 2 },
|
||||
'info'
|
||||
);
|
||||
const summaryModal = document.getElementById('rematchSummaryModal');
|
||||
expect(summaryModal).not.toBeNull();
|
||||
expect(summaryModal.querySelector('.rematch-cancelled-note')).not.toBeNull();
|
||||
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);
|
||||
|
||||
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', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -69,8 +85,8 @@ describe('RecipeContextMenu.rematchRecipe', () => {
|
||||
}
|
||||
|
||||
// Oracle R4-F1 pin: branches on `result.rematched > 0` — a blind `repaired`
|
||||
// mirror would fire the skipped toast here.
|
||||
it('posts to the per-recipe rematch endpoint and toasts the rematched count', async () => {
|
||||
// mirror would render 0 matched entries in the summary modal here.
|
||||
it('posts to the per-recipe rematch endpoint and opens the summary modal', async () => {
|
||||
const menu = await createMenu();
|
||||
const card = document.getElementById('card');
|
||||
menu.showMenu(100, 100, card);
|
||||
@@ -91,19 +107,33 @@ describe('RecipeContextMenu.rematchRecipe', () => {
|
||||
|
||||
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', {
|
||||
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',
|
||||
{ rematched: 2, skipped: 0, total: 1, entries: 2, recipes: 1, failures: 0 },
|
||||
'success'
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchSkipped',
|
||||
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(updateSingleItemMock).toHaveBeenCalledWith('/recipes/recipe-1.webp', {
|
||||
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 card = document.getElementById('card');
|
||||
menu.showMenu(100, 100, card);
|
||||
@@ -126,12 +156,14 @@ describe('RecipeContextMenu.rematchRecipe', () => {
|
||||
.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
|
||||
await flushAsyncTasks();
|
||||
await confirmRematchOptions();
|
||||
await flushAsyncTasks();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchUnmatched',
|
||||
{ entries: 2, recipes: 1, total: 1 },
|
||||
'info'
|
||||
);
|
||||
const summaryModal = document.getElementById('rematchSummaryModal');
|
||||
expect(summaryModal).not.toBeNull();
|
||||
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
|
||||
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(
|
||||
'toast.recipes.rematchSkipped',
|
||||
expect.anything(),
|
||||
@@ -155,6 +187,8 @@ describe('RecipeContextMenu.rematchRecipe', () => {
|
||||
.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
|
||||
await flushAsyncTasks();
|
||||
await confirmRematchOptions();
|
||||
await flushAsyncTasks();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchSkipped',
|
||||
@@ -186,6 +220,8 @@ describe('RecipeContextMenu.rematchRecipe', () => {
|
||||
.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
|
||||
await flushAsyncTasks();
|
||||
await confirmRematchOptions();
|
||||
await flushAsyncTasks();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchFailed',
|
||||
@@ -207,6 +243,8 @@ describe('RecipeContextMenu.rematchRecipe', () => {
|
||||
.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
|
||||
await flushAsyncTasks();
|
||||
await confirmRematchOptions();
|
||||
await flushAsyncTasks();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchFailed',
|
||||
@@ -214,4 +252,91 @@ describe('RecipeContextMenu.rematchRecipe', () => {
|
||||
'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),
|
||||
sendPromptToWorkflow: 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', () => ({
|
||||
@@ -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();
|
||||
recipeModal.showRecipeDetails(recipeWithResources);
|
||||
await flushWiring();
|
||||
@@ -301,10 +305,12 @@ describe('RecipeModal resource item interactions', () => {
|
||||
expect(item).not.toBeNull();
|
||||
expect(item.classList.contains('missing-locally')).toBe(true);
|
||||
// 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('.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 () => {
|
||||
@@ -588,10 +594,12 @@ describe('RecipeModal resource item interactions', () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
expect(requests.some(r => r.url.includes('mark-hash-invalid'))).toBe(false);
|
||||
|
||||
// The entry keeps the download action and never flips to reconnect
|
||||
// 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"]');
|
||||
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 () => {
|
||||
|
||||
@@ -73,12 +73,29 @@ vi.mock('../../../static/js/components/shared/NsfwLevelSelector.js', () => ({
|
||||
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', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
stateStub.currentPageType = 'recipes';
|
||||
stateStub.bulkMode = false;
|
||||
stateStub.selectedModels.clear();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
async function createBulkManager() {
|
||||
@@ -91,9 +108,9 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
||||
expect(bulk.actionConfig.recipes.rematchMetadata).toBe(true);
|
||||
});
|
||||
|
||||
// Oracle R4-F1 pin: the complete toast must branch on `rematched` — a blind
|
||||
// `repaired` mirror would fire the skipped toast with count 0 here.
|
||||
it('toasts the rematched count when the bulk rematch succeeds', async () => {
|
||||
// Oracle R4-F1 pin: the summary modal must branch on `matched_entries` — a
|
||||
// blind `repaired` mirror would render 0 matched entries here.
|
||||
it('opens the summary modal when the bulk rematch succeeds', async () => {
|
||||
const bulk = await createBulkManager();
|
||||
stateStub.selectedModels.add('/recipes/a.webp');
|
||||
stateStub.selectedModels.add('/recipes/b.webp');
|
||||
@@ -114,29 +131,41 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
||||
});
|
||||
|
||||
await bulk.rematchSelectedRecipes();
|
||||
await confirmRematchOptions();
|
||||
|
||||
expect(rematchBulkModelsMock).toHaveBeenCalledWith([
|
||||
'/recipes/a.webp',
|
||||
'/recipes/b.webp',
|
||||
'/recipes/c.webp',
|
||||
]);
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
expect(rematchBulkModelsMock).toHaveBeenCalledWith(
|
||||
[
|
||||
'/recipes/a.webp',
|
||||
'/recipes/b.webp',
|
||||
'/recipes/c.webp',
|
||||
],
|
||||
{ relaxed: false }
|
||||
);
|
||||
// Non-noop runs open the summary modal instead of toasting.
|
||||
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchComplete',
|
||||
{ rematched: 4, skipped: 1, total: 3, entries: 4, recipes: 2, failures: 0 },
|
||||
'success'
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchSkipped',
|
||||
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(loadingManagerStub.showSimpleLoading).toHaveBeenCalled();
|
||||
expect(loadingManagerStub.hide).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();
|
||||
stateStub.selectedModels.add('/recipes/a.webp');
|
||||
stateStub.selectedModels.add('/recipes/b.webp');
|
||||
@@ -155,15 +184,16 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
||||
});
|
||||
|
||||
await bulk.rematchSelectedRecipes();
|
||||
await confirmRematchOptions();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchCompleteErrors',
|
||||
{ rematched: 3, skipped: 0, total: 2, entries: 3, recipes: 1, failures: 2 },
|
||||
'warning'
|
||||
);
|
||||
const summaryModal = document.getElementById('rematchSummaryModal');
|
||||
expect(summaryModal).not.toBeNull();
|
||||
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
|
||||
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();
|
||||
stateStub.selectedModels.add('/recipes/a.webp');
|
||||
stateStub.selectedModels.add('/recipes/b.webp');
|
||||
@@ -182,12 +212,12 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
||||
});
|
||||
|
||||
await bulk.rematchSelectedRecipes();
|
||||
await confirmRematchOptions();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchAllFailed',
|
||||
{ total: 2, failures: 2 },
|
||||
'error'
|
||||
);
|
||||
const summaryModal = document.getElementById('rematchSummaryModal');
|
||||
expect(summaryModal).not.toBeNull();
|
||||
expect(summaryModal.querySelector('.summary-header').classList.contains('error')).toBe(true);
|
||||
expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('2');
|
||||
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchSkipped',
|
||||
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();
|
||||
stateStub.selectedModels.add('/recipes/a.webp');
|
||||
stateStub.selectedModels.add('/recipes/b.webp');
|
||||
@@ -215,12 +245,13 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
||||
});
|
||||
|
||||
await bulk.rematchSelectedRecipes();
|
||||
await confirmRematchOptions();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchUnmatched',
|
||||
{ entries: 2, recipes: 1, total: 3 },
|
||||
'info'
|
||||
);
|
||||
const summaryModal = document.getElementById('rematchSummaryModal');
|
||||
expect(summaryModal).not.toBeNull();
|
||||
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
|
||||
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(
|
||||
'toast.recipes.rematchSkipped',
|
||||
expect.anything(),
|
||||
@@ -243,6 +274,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
||||
});
|
||||
|
||||
await bulk.rematchSelectedRecipes();
|
||||
await confirmRematchOptions();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchSkipped',
|
||||
@@ -268,6 +300,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
||||
});
|
||||
|
||||
await bulk.rematchSelectedRecipes();
|
||||
await confirmRematchOptions();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchFailed',
|
||||
@@ -284,6 +317,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
||||
rematchBulkModelsMock.mockRejectedValue(new Error('network down'));
|
||||
|
||||
await bulk.rematchSelectedRecipes();
|
||||
await confirmRematchOptions();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchFailed',
|
||||
@@ -319,4 +353,101 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,8 @@ vi.mock('../../../static/js/state/index.js', () => ({
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
getCurrentPageState: vi.fn(() => ({ activeFolder: null, searchOptions: {} }))
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
|
||||
@@ -162,4 +163,75 @@ describe('MoveManager', () => {
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('should propagate the recalculated sub_type from the move response to the card', async () => {
|
||||
// Setup state: moving a checkpoint into the unet root
|
||||
moveManager.useDefaultPath = false;
|
||||
moveManager.bulkFilePaths = null;
|
||||
moveManager.currentFilePath = '/models/checkpoints/model.safetensors';
|
||||
moveManager.modelRoots = ['/models/checkpoints', '/models/unet'];
|
||||
document.getElementById('moveModelRoot').innerHTML = '<option value="/models/unet">/models/unet</option>';
|
||||
document.getElementById('moveModelRoot').value = '/models/unet';
|
||||
moveManager.folderTreeManager.selectedPath = '';
|
||||
|
||||
const updateSingleItem = vi.fn();
|
||||
state.virtualScroller = {
|
||||
updateSingleItem,
|
||||
removeMultipleItemsByFilePath: vi.fn()
|
||||
};
|
||||
|
||||
mockApiClient.moveSingleModel = vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
original_file_path: '/models/checkpoints/model.safetensors',
|
||||
new_file_path: '/models/unet/model.safetensors',
|
||||
cache_entry: { sub_type: 'diffusion_model' }
|
||||
});
|
||||
|
||||
try {
|
||||
await moveManager.moveModel();
|
||||
|
||||
expect(updateSingleItem).toHaveBeenCalledWith(
|
||||
'/models/checkpoints/model.safetensors',
|
||||
expect.objectContaining({
|
||||
file_path: '/models/unet/model.safetensors',
|
||||
sub_type: 'diffusion_model'
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
delete state.virtualScroller;
|
||||
}
|
||||
});
|
||||
|
||||
it('should omit sub_type from the card update when the response has no cache entry', async () => {
|
||||
moveManager.useDefaultPath = false;
|
||||
moveManager.bulkFilePaths = null;
|
||||
moveManager.currentFilePath = '/models/loras/a.safetensors';
|
||||
moveManager.modelRoots = ['/models/loras'];
|
||||
document.getElementById('moveModelRoot').innerHTML = '<option value="/models/loras">/models/loras</option>';
|
||||
document.getElementById('moveModelRoot').value = '/models/loras';
|
||||
moveManager.folderTreeManager.selectedPath = '';
|
||||
|
||||
const updateSingleItem = vi.fn();
|
||||
state.virtualScroller = {
|
||||
updateSingleItem,
|
||||
removeMultipleItemsByFilePath: vi.fn()
|
||||
};
|
||||
|
||||
mockApiClient.moveSingleModel = vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
original_file_path: '/models/loras/a.safetensors',
|
||||
new_file_path: '/models/loras/b/a.safetensors'
|
||||
});
|
||||
|
||||
try {
|
||||
await moveManager.moveModel();
|
||||
|
||||
expect(updateSingleItem).toHaveBeenCalledWith(
|
||||
'/models/loras/a.safetensors',
|
||||
expect.not.objectContaining({ sub_type: expect.anything() })
|
||||
);
|
||||
} finally {
|
||||
delete state.virtualScroller;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
DOWNLOAD_MANAGER_MODULE,
|
||||
MODAL_MANAGER_MODULE,
|
||||
UI_HELPERS_MODULE,
|
||||
STATE_MODULE,
|
||||
LOADING_MANAGER_MODULE,
|
||||
API_FACTORY_MODULE,
|
||||
STORAGE_HELPERS_MODULE,
|
||||
FOLDER_TREE_MANAGER_MODULE,
|
||||
I18N_HELPERS_MODULE,
|
||||
SUMMARY_MODULE,
|
||||
} = vi.hoisted(() => ({
|
||||
DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname,
|
||||
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
|
||||
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
|
||||
LOADING_MANAGER_MODULE: new URL('../../../static/js/managers/LoadingManager.js', import.meta.url).pathname,
|
||||
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
|
||||
STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
|
||||
FOLDER_TREE_MANAGER_MODULE: new URL('../../../static/js/components/FolderTreeManager.js', import.meta.url).pathname,
|
||||
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||
SUMMARY_MODULE: new URL('../../../static/js/components/DownloadBatchSummaryModal.js', import.meta.url).pathname,
|
||||
}));
|
||||
|
||||
vi.mock(MODAL_MANAGER_MODULE, () => ({
|
||||
modalManager: { showModal: vi.fn(), closeModal: vi.fn() },
|
||||
}));
|
||||
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||
showToast: vi.fn(),
|
||||
setupAutoNewlineOnPaste: vi.fn(),
|
||||
}));
|
||||
vi.mock(STATE_MODULE, () => ({
|
||||
state: { global: { settings: {} }, loadingManager: {} },
|
||||
}));
|
||||
vi.mock(LOADING_MANAGER_MODULE, () => ({
|
||||
LoadingManager: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock(API_FACTORY_MODULE, () => ({
|
||||
getModelApiClient: vi.fn(),
|
||||
resetAndReload: vi.fn(),
|
||||
}));
|
||||
vi.mock(STORAGE_HELPERS_MODULE, () => ({
|
||||
getStorageItem: vi.fn((_key, defaultValue) => defaultValue),
|
||||
setStorageItem: vi.fn(),
|
||||
}));
|
||||
vi.mock(FOLDER_TREE_MANAGER_MODULE, () => ({
|
||||
FolderTreeManager: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock(I18N_HELPERS_MODULE, () => ({
|
||||
translate: vi.fn((_key, _vars, fallback) => fallback ?? ''),
|
||||
}));
|
||||
vi.mock(SUMMARY_MODULE, () => ({
|
||||
showDownloadBatchSummary: vi.fn(),
|
||||
}));
|
||||
|
||||
const { DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE);
|
||||
|
||||
describe('DownloadManager._resolveIsDiffusionModel', () => {
|
||||
let manager;
|
||||
let fetchMock;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new DownloadManager();
|
||||
manager.apiClient = { modelType: 'checkpoints' };
|
||||
manager.selectedFile = null;
|
||||
manager.selectedFiles = [];
|
||||
manager.currentVersion = null;
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function mockRoutingResponse(data, ok = true) {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok,
|
||||
status: ok ? 200 : 500,
|
||||
json: async () => data,
|
||||
});
|
||||
}
|
||||
|
||||
it('asks the backend and routes baseModel-only diffusion models to unet roots', async () => {
|
||||
// The reported Anima case: file type is plain "Model".
|
||||
manager.currentVersion = { baseModel: 'Anima', files: [{ type: 'Model' }] };
|
||||
mockRoutingResponse({ success: true, is_diffusion_model: true, root_kind: 'unet' });
|
||||
|
||||
expect(await manager._resolveIsDiffusionModel()).toBe(true);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/lm/download/routing', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model_type: 'checkpoint',
|
||||
base_model: 'Anima',
|
||||
file_types: ['Model'],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the backend decision for regular checkpoints', async () => {
|
||||
manager.currentVersion = { baseModel: 'SDXL 1.0', files: [{ type: 'Model' }] };
|
||||
mockRoutingResponse({ success: true, is_diffusion_model: false, root_kind: 'checkpoint' });
|
||||
|
||||
expect(await manager._resolveIsDiffusionModel()).toBe(false);
|
||||
});
|
||||
|
||||
it('sends only the selected file type when a file is selected', async () => {
|
||||
manager.currentVersion = { baseModel: 'Flux.1 D', files: [{ type: 'Model' }, { type: 'UNet' }] };
|
||||
manager.selectedFile = { type: 'UNet' };
|
||||
mockRoutingResponse({ success: true, is_diffusion_model: true, root_kind: 'unet' });
|
||||
|
||||
expect(await manager._resolveIsDiffusionModel()).toBe(true);
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body).file_types).toEqual(['UNet']);
|
||||
});
|
||||
|
||||
it('falls back to the local file-type check when the endpoint fails', async () => {
|
||||
manager.currentVersion = { baseModel: 'SDXL 1.0', files: [{ type: 'UNet' }] };
|
||||
fetchMock.mockRejectedValue(new Error('network down'));
|
||||
|
||||
expect(await manager._resolveIsDiffusionModel()).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to false when the endpoint fails and no local signal exists', async () => {
|
||||
manager.currentVersion = { baseModel: 'Anima', files: [{ type: 'Model' }] };
|
||||
mockRoutingResponse({}, false);
|
||||
|
||||
expect(await manager._resolveIsDiffusionModel()).toBe(false);
|
||||
});
|
||||
|
||||
it('never calls the endpoint for non-checkpoint pages', async () => {
|
||||
manager.apiClient = { modelType: 'loras' };
|
||||
manager.currentVersion = { baseModel: 'Anima', files: [{ type: 'Model' }] };
|
||||
|
||||
expect(await manager._resolveIsDiffusionModel()).toBe(false);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never calls the endpoint without version metadata (e.g. Hugging Face)', async () => {
|
||||
expect(await manager._resolveIsDiffusionModel()).toBe(false);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,15 @@ describe("applyLoraValuesToText", () => {
|
||||
|
||||
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", () => {
|
||||
@@ -74,6 +83,18 @@ describe("cleanupLoraSyntax", () => {
|
||||
it("collapses whitespace and stray commas", () => {
|
||||
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", () => {
|
||||
|
||||
@@ -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', () => {
|
||||
const result = DownloadManager.detectUrlType(
|
||||
'https://civitai.com/models/123/some-model'
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Tests for the download routing HTTP handler."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from py.routes.handlers.download_routing_handlers import DownloadRoutingHandler
|
||||
|
||||
|
||||
class FakeRequest:
|
||||
def __init__(self, payload):
|
||||
self._payload = payload
|
||||
|
||||
async def json(self):
|
||||
if isinstance(self._payload, Exception):
|
||||
raise self._payload
|
||||
return self._payload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diffusion_base_model_routes_to_unet():
|
||||
"""The reported Anima case: file type "Model", baseModel "Anima"."""
|
||||
handler = DownloadRoutingHandler()
|
||||
response = await handler.get_download_routing(
|
||||
FakeRequest(
|
||||
{"model_type": "checkpoint", "base_model": "Anima", "file_types": ["Model"]}
|
||||
)
|
||||
)
|
||||
payload = json.loads(response.text)
|
||||
assert response.status == 200
|
||||
assert payload == {"success": True, "is_diffusion_model": True, "root_kind": "unet"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unet_file_type_routes_to_unet():
|
||||
handler = DownloadRoutingHandler()
|
||||
response = await handler.get_download_routing(
|
||||
FakeRequest(
|
||||
{"model_type": "checkpoint", "base_model": "SDXL 1.0", "file_types": ["UNet"]}
|
||||
)
|
||||
)
|
||||
payload = json.loads(response.text)
|
||||
assert payload["is_diffusion_model"] is True
|
||||
assert payload["root_kind"] == "unet"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_regular_checkpoint_stays_on_checkpoint_root():
|
||||
handler = DownloadRoutingHandler()
|
||||
response = await handler.get_download_routing(
|
||||
FakeRequest(
|
||||
{"model_type": "checkpoint", "base_model": "SDXL 1.0", "file_types": ["Model"]}
|
||||
)
|
||||
)
|
||||
payload = json.loads(response.text)
|
||||
assert payload["is_diffusion_model"] is False
|
||||
assert payload["root_kind"] == "checkpoint"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lora_is_never_diffusion():
|
||||
handler = DownloadRoutingHandler()
|
||||
response = await handler.get_download_routing(
|
||||
FakeRequest({"model_type": "lora", "base_model": "Anima", "file_types": []})
|
||||
)
|
||||
payload = json.loads(response.text)
|
||||
assert payload["is_diffusion_model"] is False
|
||||
assert payload["root_kind"] == "lora"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_model_type_rejected():
|
||||
handler = DownloadRoutingHandler()
|
||||
response = await handler.get_download_routing(FakeRequest({"base_model": "Anima"}))
|
||||
assert response.status == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_file_types_rejected():
|
||||
handler = DownloadRoutingHandler()
|
||||
response = await handler.get_download_routing(
|
||||
FakeRequest({"model_type": "checkpoint", "file_types": "Model"})
|
||||
)
|
||||
assert response.status == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_json_rejected():
|
||||
handler = DownloadRoutingHandler()
|
||||
response = await handler.get_download_routing(
|
||||
FakeRequest(json.JSONDecodeError("bad", "", 0))
|
||||
)
|
||||
assert response.status == 400
|
||||
@@ -60,6 +60,9 @@ class StubRecipeScanner:
|
||||
self.rematch_all_calls: List[Any] = []
|
||||
self.rematch_by_id_calls: 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]] = {}
|
||||
|
||||
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:
|
||||
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."""
|
||||
if progress_callback:
|
||||
await progress_callback({"status": "started"})
|
||||
@@ -142,6 +145,7 @@ class StubRecipeScanner:
|
||||
{"status": "completed", "rematched": 1, "skipped": 0, "errors": 0, "total": 1}
|
||||
)
|
||||
self.rematch_all_calls.append(progress_callback)
|
||||
self.rematch_all_relaxed.append(relaxed)
|
||||
return {
|
||||
"success": True,
|
||||
"status": "completed",
|
||||
@@ -151,14 +155,20 @@ class StubRecipeScanner:
|
||||
"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_relaxed.append(relaxed)
|
||||
if recipe_id not in self.rematch_results:
|
||||
raise RecipeNotFoundError(f"Recipe not found: {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_relaxed.append(relaxed)
|
||||
total = len(recipe_ids)
|
||||
rematched = 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"]
|
||||
|
||||
|
||||
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(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
|
||||
@@ -889,6 +889,215 @@ async def test_cancel_download_tolerates_missing_gid(monkeypatch):
|
||||
assert await downloader._state_store.get("download-1") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_transfer_tracks_gid_before_state_persist_completes(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""A cancel arriving while the state store write is still in flight must
|
||||
already find the transfer — otherwise the gid leaks and the daemon keeps
|
||||
downloading."""
|
||||
downloader = Aria2Downloader()
|
||||
downloader._rpc_url = "http://127.0.0.1/jsonrpc"
|
||||
downloader._rpc_secret = "secret"
|
||||
|
||||
save_path = tmp_path / "downloads" / "model.safetensors"
|
||||
rpc_calls = []
|
||||
|
||||
async def fake_rpc_call(method, params, **_kwargs):
|
||||
rpc_calls.append((method, params))
|
||||
if method == "aria2.addUri":
|
||||
return "gid-1"
|
||||
if method == "aria2.forceRemove":
|
||||
return "OK"
|
||||
raise AssertionError(f"Unexpected RPC method: {method}")
|
||||
|
||||
monkeypatch.setattr(downloader, "_ensure_process", AsyncMock())
|
||||
monkeypatch.setattr(downloader, "_rpc_call", fake_rpc_call)
|
||||
|
||||
persist_started = asyncio.Event()
|
||||
persist_release = asyncio.Event()
|
||||
|
||||
class BlockingStore:
|
||||
async def upsert(self, download_id, payload):
|
||||
persist_started.set()
|
||||
await persist_release.wait()
|
||||
|
||||
async def remove(self, download_id):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(downloader, "_state_store", BlockingStore())
|
||||
|
||||
register_task = asyncio.create_task(
|
||||
downloader._register_transfer(
|
||||
"https://example.com/model.safetensors",
|
||||
str(save_path),
|
||||
download_id="download-1",
|
||||
)
|
||||
)
|
||||
await asyncio.wait_for(persist_started.wait(), timeout=1.0)
|
||||
|
||||
transfer = downloader._transfers.get("download-1")
|
||||
assert transfer is not None and transfer.gid == "gid-1"
|
||||
|
||||
result = await downloader.cancel_download("download-1")
|
||||
assert result["success"] is True
|
||||
assert ("aria2.forceRemove", ["gid-1"]) in rpc_calls
|
||||
|
||||
persist_release.set()
|
||||
registered = await register_task
|
||||
assert registered.gid == "gid-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedule_download_removes_gid_accepted_while_cancelled(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""Cancelling while the addUri RPC is in flight must remove the gid the
|
||||
daemon accepted, instead of leaking an untracked download."""
|
||||
downloader = Aria2Downloader()
|
||||
downloader._rpc_url = "http://127.0.0.1/jsonrpc"
|
||||
downloader._rpc_secret = "secret"
|
||||
|
||||
save_path = tmp_path / "downloads" / "model.safetensors"
|
||||
add_uri_started = asyncio.Event()
|
||||
force_removed = []
|
||||
|
||||
async def fake_rpc_call(method, params, **_kwargs):
|
||||
if method == "aria2.addUri":
|
||||
add_uri_started.set()
|
||||
# The daemon processes the request while the client is cancelled.
|
||||
await asyncio.sleep(0.05)
|
||||
return "gid-leaked"
|
||||
if method == "aria2.forceRemove":
|
||||
force_removed.append(params[0])
|
||||
return "OK"
|
||||
raise AssertionError(f"Unexpected RPC method: {method}")
|
||||
|
||||
monkeypatch.setattr(downloader, "_rpc_call", fake_rpc_call)
|
||||
|
||||
schedule_task = asyncio.create_task(
|
||||
downloader._schedule_download(
|
||||
"https://example.com/model.safetensors",
|
||||
str(save_path),
|
||||
download_id="download-1",
|
||||
)
|
||||
)
|
||||
await asyncio.wait_for(add_uri_started.wait(), timeout=1.0)
|
||||
schedule_task.cancel()
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await schedule_task
|
||||
|
||||
assert force_removed == ["gid-leaked"]
|
||||
assert "download-1" not in downloader._transfers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_transfer_cancelled_during_persist_removes_active_gid(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""Cancellation landing after the gid is registered but before the state
|
||||
store write finishes must remove the still-active daemon transfer."""
|
||||
downloader = Aria2Downloader()
|
||||
downloader._rpc_url = "http://127.0.0.1/jsonrpc"
|
||||
downloader._rpc_secret = "secret"
|
||||
|
||||
save_path = tmp_path / "downloads" / "model.safetensors"
|
||||
persist_started = asyncio.Event()
|
||||
force_removed = []
|
||||
|
||||
async def fake_rpc_call(method, params, **_kwargs):
|
||||
if method == "aria2.addUri":
|
||||
return "gid-2"
|
||||
if method == "aria2.tellStatus":
|
||||
return {"gid": "gid-2", "status": "active"}
|
||||
if method == "aria2.forceRemove":
|
||||
force_removed.append(params[0])
|
||||
return "OK"
|
||||
raise AssertionError(f"Unexpected RPC method: {method}")
|
||||
|
||||
monkeypatch.setattr(downloader, "_rpc_call", fake_rpc_call)
|
||||
|
||||
class BlockingStore:
|
||||
async def upsert(self, download_id, payload):
|
||||
persist_started.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
async def remove(self, download_id):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(downloader, "_state_store", BlockingStore())
|
||||
|
||||
register_task = asyncio.create_task(
|
||||
downloader._register_transfer(
|
||||
"https://example.com/model.safetensors",
|
||||
str(save_path),
|
||||
download_id="download-1",
|
||||
)
|
||||
)
|
||||
await asyncio.wait_for(persist_started.wait(), timeout=1.0)
|
||||
register_task.cancel()
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await register_task
|
||||
|
||||
assert force_removed == ["gid-2"]
|
||||
assert "download-1" not in downloader._transfers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_transfer_cancelled_during_persist_preserves_paused_gid(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""skip_download pauses the daemon transfer before cancelling the task;
|
||||
the unwind cleanup must not remove a deliberately paused gid."""
|
||||
downloader = Aria2Downloader()
|
||||
downloader._rpc_url = "http://127.0.0.1/jsonrpc"
|
||||
downloader._rpc_secret = "secret"
|
||||
|
||||
save_path = tmp_path / "downloads" / "model.safetensors"
|
||||
persist_started = asyncio.Event()
|
||||
force_removed = []
|
||||
|
||||
async def fake_rpc_call(method, params, **_kwargs):
|
||||
if method == "aria2.addUri":
|
||||
return "gid-3"
|
||||
if method == "aria2.tellStatus":
|
||||
return {"gid": "gid-3", "status": "paused"}
|
||||
if method == "aria2.forceRemove":
|
||||
force_removed.append(params[0])
|
||||
return "OK"
|
||||
raise AssertionError(f"Unexpected RPC method: {method}")
|
||||
|
||||
monkeypatch.setattr(downloader, "_rpc_call", fake_rpc_call)
|
||||
|
||||
class BlockingStore:
|
||||
async def upsert(self, download_id, payload):
|
||||
persist_started.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
async def remove(self, download_id):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(downloader, "_state_store", BlockingStore())
|
||||
|
||||
register_task = asyncio.create_task(
|
||||
downloader._register_transfer(
|
||||
"https://example.com/model.safetensors",
|
||||
str(save_path),
|
||||
download_id="download-1",
|
||||
)
|
||||
)
|
||||
await asyncio.wait_for(persist_started.wait(), timeout=1.0)
|
||||
register_task.cancel()
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await register_task
|
||||
|
||||
assert force_removed == []
|
||||
assert downloader._transfers["download-1"].gid == "gid-3"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rpc_call_suppresses_error_log_when_log_errors_false(
|
||||
monkeypatch, caplog
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
"""Tests for CheckpointScanner sub_type resolution."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import pytest
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from py.services.checkpoint_scanner import CheckpointScanner
|
||||
from py.services.model_cache import ModelCache
|
||||
from py.services.model_hash_index import ModelHashIndex
|
||||
from py.utils.models import CheckpointMetadata
|
||||
|
||||
|
||||
@@ -142,3 +147,150 @@ class TestCheckpointScannerSubType:
|
||||
config_module.config.checkpoints_roots = original_checkpoints_roots
|
||||
if original_unet_roots is not None:
|
||||
config_module.config.unet_roots = original_unet_roots
|
||||
|
||||
|
||||
def _make_move_scanner(ckpt_root: Path, unet_root: Path) -> CheckpointScanner:
|
||||
"""Create a CheckpointScanner wired for move/sync tests without async init."""
|
||||
scanner = object.__new__(CheckpointScanner)
|
||||
scanner.model_type = "checkpoint"
|
||||
scanner.model_class = CheckpointMetadata
|
||||
scanner.file_extensions = {".safetensors"}
|
||||
scanner._cache = None
|
||||
scanner._cache_version = 0
|
||||
scanner._hash_index = ModelHashIndex()
|
||||
scanner._tags_count = {}
|
||||
scanner._excluded_models = []
|
||||
scanner._is_initializing = False
|
||||
scanner._persistent_cache = MagicMock()
|
||||
scanner._name_display_mode = "model_name"
|
||||
scanner._cancel_requested = False
|
||||
scanner._all_folders_ttl_cache = None
|
||||
roots = [str(ckpt_root), str(unet_root)]
|
||||
scanner.get_model_roots = lambda: roots
|
||||
return scanner
|
||||
|
||||
|
||||
def _set_config_roots(monkeypatch, ckpt_root: Path, unet_root: Path) -> None:
|
||||
from py import config as config_module
|
||||
|
||||
monkeypatch.setattr(
|
||||
config_module.config, "checkpoints_roots", [str(ckpt_root)]
|
||||
)
|
||||
monkeypatch.setattr(config_module.config, "unet_roots", [str(unet_root)])
|
||||
monkeypatch.setattr(config_module.config, "extra_checkpoints_roots", [])
|
||||
monkeypatch.setattr(config_module.config, "extra_unet_roots", [])
|
||||
|
||||
|
||||
def _write_model(root: Path, name: str, sub_type: str) -> str:
|
||||
model_path = root / f"{name}.safetensors"
|
||||
model_path.write_bytes(b"fake")
|
||||
(root / f"{name}.metadata.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"file_path": str(model_path).replace(os.sep, "/"),
|
||||
"file_name": name,
|
||||
"model_name": name,
|
||||
"sha256": "abc123",
|
||||
"sub_type": sub_type,
|
||||
"hash_status": "completed",
|
||||
"tags": [],
|
||||
}
|
||||
)
|
||||
)
|
||||
return str(model_path).replace(os.sep, "/")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_to_unet_root_updates_sub_type_in_cache_and_metadata(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""Moving a checkpoint into a unet root must recalculate sub_type and
|
||||
persist it into the moved .metadata.json, so later metadata-driven cache
|
||||
syncs cannot revert the cache entry to the stale sub_type."""
|
||||
ckpt_root = tmp_path / "checkpoints"
|
||||
unet_root = tmp_path / "unet"
|
||||
ckpt_root.mkdir()
|
||||
unet_root.mkdir()
|
||||
_set_config_roots(monkeypatch, ckpt_root, unet_root)
|
||||
|
||||
scanner = _make_move_scanner(ckpt_root, unet_root)
|
||||
source = _write_model(ckpt_root, "mymodel", "checkpoint")
|
||||
|
||||
scanner._cache = ModelCache(
|
||||
raw_data=[
|
||||
{
|
||||
"file_path": source,
|
||||
"file_name": "mymodel",
|
||||
"model_name": "mymodel",
|
||||
"folder": "",
|
||||
"sha256": "abc123",
|
||||
"sub_type": "checkpoint",
|
||||
"tags": [],
|
||||
}
|
||||
],
|
||||
folders=[""],
|
||||
)
|
||||
|
||||
result = await scanner.move_model(source, str(unet_root).replace(os.sep, "/"))
|
||||
assert result is not None
|
||||
|
||||
cache = await scanner.get_cached_data()
|
||||
entry = next(
|
||||
(e for e in cache.raw_data if e.get("file_name") == "mymodel"), None
|
||||
)
|
||||
assert entry is not None
|
||||
assert entry["sub_type"] == "diffusion_model"
|
||||
|
||||
moved_metadata = json.loads(
|
||||
(unet_root / "mymodel.metadata.json").read_text()
|
||||
)
|
||||
assert moved_metadata["sub_type"] == "diffusion_model"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_cache_from_metadata_does_not_revert_sub_type(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""An opportunistic sync from a stale .metadata.json (sub_type predating a
|
||||
cross-root move) must not overwrite the location-derived cache sub_type."""
|
||||
ckpt_root = tmp_path / "checkpoints"
|
||||
unet_root = tmp_path / "unet"
|
||||
ckpt_root.mkdir()
|
||||
unet_root.mkdir()
|
||||
_set_config_roots(monkeypatch, ckpt_root, unet_root)
|
||||
|
||||
scanner = _make_move_scanner(ckpt_root, unet_root)
|
||||
file_path = _write_model(unet_root, "mymodel", "diffusion_model")
|
||||
|
||||
scanner._cache = ModelCache(
|
||||
raw_data=[
|
||||
{
|
||||
"file_path": file_path,
|
||||
"file_name": "mymodel",
|
||||
"model_name": "mymodel",
|
||||
"folder": "",
|
||||
"sha256": "abc123",
|
||||
"sub_type": "diffusion_model",
|
||||
"tags": [],
|
||||
}
|
||||
],
|
||||
folders=[""],
|
||||
)
|
||||
|
||||
# Stale metadata snapshot: still says 'checkpoint' (as before a move).
|
||||
stale_metadata = {
|
||||
"file_path": file_path,
|
||||
"file_name": "mymodel",
|
||||
"model_name": "mymodel Renamed",
|
||||
"sha256": "abc123",
|
||||
"sub_type": "checkpoint",
|
||||
"hash_status": "completed",
|
||||
"tags": [],
|
||||
}
|
||||
|
||||
changed = await scanner.sync_cache_from_metadata(file_path, stale_metadata)
|
||||
assert changed is True # other fields (model_name) did change
|
||||
|
||||
entry = scanner._cache.raw_data[0]
|
||||
assert entry["sub_type"] == "diffusion_model"
|
||||
assert entry["model_name"] == "mymodel Renamed"
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Tests for the shared download routing decision."""
|
||||
|
||||
import pytest
|
||||
|
||||
from py.services.download_routing import is_diffusion_model_download
|
||||
|
||||
|
||||
@pytest.mark.parametrize("file_type", ["UNet", "Diffusion Model"])
|
||||
def test_file_type_signal_routes_to_unet(file_type):
|
||||
assert is_diffusion_model_download(
|
||||
"checkpoint", file_types=[file_type], base_model="SDXL 1.0"
|
||||
)
|
||||
|
||||
|
||||
def test_base_model_fallback_routes_to_unet():
|
||||
"""The reported Anima case: file type is plain "Model", but the
|
||||
baseModel is a known diffusion model."""
|
||||
assert is_diffusion_model_download(
|
||||
"checkpoint", file_types=["Model"], base_model="Anima"
|
||||
)
|
||||
|
||||
|
||||
def test_regular_checkpoint_stays_on_checkpoint_roots():
|
||||
assert not is_diffusion_model_download(
|
||||
"checkpoint", file_types=["Model"], base_model="SDXL 1.0"
|
||||
)
|
||||
|
||||
|
||||
def test_non_checkpoint_types_never_route_to_unet():
|
||||
assert not is_diffusion_model_download(
|
||||
"lora", file_types=["UNet"], base_model="Anima"
|
||||
)
|
||||
assert not is_diffusion_model_download(
|
||||
"embedding", file_types=["Diffusion Model"], base_model="Anima"
|
||||
)
|
||||
|
||||
|
||||
def test_empty_inputs_stay_on_checkpoint_roots():
|
||||
assert not is_diffusion_model_download("checkpoint")
|
||||
assert not is_diffusion_model_download("checkpoint", file_types=[], base_model="")
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
@@ -322,8 +323,12 @@ class MockGetSession:
|
||||
|
||||
def __init__(self, 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
|
||||
|
||||
async def __aenter__(self):
|
||||
@@ -340,6 +345,14 @@ class CorruptJsonResponse(MockResponse):
|
||||
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:
|
||||
"""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."""
|
||||
llm_module._catalog_cache = None
|
||||
llm_module._model_output_limits = {}
|
||||
llm_module._catalog_last_failure = None
|
||||
yield
|
||||
llm_module._catalog_cache = None
|
||||
llm_module._model_output_limits = {}
|
||||
llm_module._catalog_last_failure = None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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")
|
||||
|
||||
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)
|
||||
|
||||
@@ -164,7 +164,7 @@ class TestEnrichHfMetadata:
|
||||
skill_name="enrich_hf_metadata",
|
||||
model_path="/p.safetensors",
|
||||
llm_output=llm,
|
||||
metadata={"trainedWords": []},
|
||||
metadata={},
|
||||
)
|
||||
applied = mock_apply.call_args[0][1]
|
||||
assert applied["civitai"]["trainedWords"] == ["trigger1", "trigger2"]
|
||||
|
||||
@@ -4012,6 +4012,7 @@ async def test_rematch_recipe_by_id_lora_l1_write_back(tmp_path: Path, monkeypat
|
||||
"entry": "old.safetensors",
|
||||
"file_name": "m.safetensors",
|
||||
"match_level": "L1",
|
||||
"lora_index": 0,
|
||||
}
|
||||
]
|
||||
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
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
@@ -4659,6 +4733,7 @@ async def test_rematch_all_recipes_per_recipe_error_continues_loop(
|
||||
local_cache: dict[str, Any],
|
||||
autov3_cache: dict[str, Any],
|
||||
filename_cache=None,
|
||||
**_kwargs: Any,
|
||||
) -> tuple[int, int, dict[str, Any]]:
|
||||
if recipe.get("id") == "boom":
|
||||
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],
|
||||
autov3_cache: dict[str, Any],
|
||||
filename_cache=None,
|
||||
**kwargs: Any,
|
||||
) -> tuple[int, int, dict[str, Any]]:
|
||||
nonlocal entered
|
||||
if recipe.get("id") == "r0":
|
||||
entered = True
|
||||
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)
|
||||
|
||||
@@ -4899,6 +4977,236 @@ async def test_rematch_all_autov3_cache_reuse_across_calls(
|
||||
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):
|
||||
scanner, _ = recipe_scanner
|
||||
|
||||
@@ -227,8 +227,20 @@ function formatAutocompleteInsertion(text = '') {
|
||||
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 = '') {
|
||||
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 = '') {
|
||||
|
||||
@@ -38,7 +38,17 @@ function cleanupLoraSyntax(text) {
|
||||
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*,\s*/g, ",")
|
||||
@@ -51,7 +61,9 @@ function cleanupLoraSyntax(text) {
|
||||
cleaned = cleaned.replace(/(^,)|(,$)/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) {
|
||||
|
||||
Reference in New Issue
Block a user