mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-04-12 05:42:14 -03:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1817142a7b | ||
|
|
25fa175aa2 | ||
|
|
39643eb2bc | ||
|
|
4ac78f8aa8 | ||
|
|
0bcca0ba68 | ||
|
|
72f8e0d1be | ||
|
|
85b6c91192 | ||
|
|
908016cbd6 | ||
|
|
a5ac9cf81b | ||
|
|
32875042bd | ||
|
|
51fe7aa07e | ||
|
|
db4726a961 | ||
|
|
e13d70248a | ||
|
|
1c4919a3e8 | ||
|
|
18ddadc9ec | ||
|
|
b711ac468a |
@@ -263,7 +263,9 @@
|
|||||||
"videoSettings": "Video-Einstellungen",
|
"videoSettings": "Video-Einstellungen",
|
||||||
"layoutSettings": "Layout-Einstellungen",
|
"layoutSettings": "Layout-Einstellungen",
|
||||||
"misc": "Verschiedenes",
|
"misc": "Verschiedenes",
|
||||||
|
"backup": "Backups",
|
||||||
"folderSettings": "Standard-Roots",
|
"folderSettings": "Standard-Roots",
|
||||||
|
"recipeSettings": "Rezepte",
|
||||||
"extraFolderPaths": "Zusätzliche Ordnerpfade",
|
"extraFolderPaths": "Zusätzliche Ordnerpfade",
|
||||||
"downloadPathTemplates": "Download-Pfad-Vorlagen",
|
"downloadPathTemplates": "Download-Pfad-Vorlagen",
|
||||||
"priorityTags": "Prioritäts-Tags",
|
"priorityTags": "Prioritäts-Tags",
|
||||||
@@ -323,6 +325,32 @@
|
|||||||
"saveFailed": "Übersprungene Pfade konnten nicht gespeichert werden: {message}"
|
"saveFailed": "Übersprungene Pfade konnten nicht gespeichert werden: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"backup": {
|
||||||
|
"autoEnabled": "Automatische Backups",
|
||||||
|
"autoEnabledHelp": "Erstellt einmal täglich einen lokalen Schnappschuss und behält die neuesten Schnappschüsse gemäß der Aufbewahrungsrichtlinie.",
|
||||||
|
"retention": "Aufbewahrungsanzahl",
|
||||||
|
"retentionHelp": "Wie viele automatische Schnappschüsse behalten werden, bevor ältere entfernt werden.",
|
||||||
|
"management": "Backup-Verwaltung",
|
||||||
|
"managementHelp": "Exportiere deinen aktuellen Benutzerstatus oder stelle ihn aus einem Backup-Archiv wieder her.",
|
||||||
|
"scopeHelp": "Sichert deine Einstellungen, den Downloadverlauf und den Status der Modellaktualisierung. Modelldateien und neu erzeugbare Caches sind nicht enthalten.",
|
||||||
|
"locationSummary": "Aktueller Backup-Speicherort",
|
||||||
|
"openFolderButton": "Backup-Ordner öffnen",
|
||||||
|
"openFolderSuccess": "Backup-Ordner geöffnet",
|
||||||
|
"openFolderFailed": "Backup-Ordner konnte nicht geöffnet werden",
|
||||||
|
"locationCopied": "Backup-Pfad in die Zwischenablage kopiert: {{path}}",
|
||||||
|
"locationClipboardFallback": "Backup-Pfad: {{path}}",
|
||||||
|
"exportButton": "Backup exportieren",
|
||||||
|
"exportSuccess": "Backup erfolgreich exportiert.",
|
||||||
|
"exportFailed": "Backup konnte nicht exportiert werden: {message}",
|
||||||
|
"importButton": "Backup importieren",
|
||||||
|
"importConfirm": "Dieses Backup importieren und den lokalen Benutzerstatus überschreiben?",
|
||||||
|
"importSuccess": "Backup erfolgreich importiert.",
|
||||||
|
"importFailed": "Backup konnte nicht importiert werden: {message}",
|
||||||
|
"latestSnapshot": "Neuester Schnappschuss",
|
||||||
|
"latestAutoSnapshot": "Neuester automatischer Schnappschuss",
|
||||||
|
"snapshotCount": "Gespeicherte Schnappschüsse",
|
||||||
|
"noneAvailable": "Noch keine Schnappschüsse vorhanden"
|
||||||
|
},
|
||||||
"downloadSkipBaseModels": {
|
"downloadSkipBaseModels": {
|
||||||
"label": "Downloads für Basismodelle überspringen",
|
"label": "Downloads für Basismodelle überspringen",
|
||||||
"help": "Gilt für alle Download-Abläufe. Hier können nur unterstützte Basismodelle ausgewählt werden.",
|
"help": "Gilt für alle Download-Abläufe. Hier können nur unterstützte Basismodelle ausgewählt werden.",
|
||||||
@@ -393,6 +421,10 @@
|
|||||||
"defaultUnetRootHelp": "Legen Sie den Standard-Diffusion-Modell-(UNET)-Stammordner für Downloads, Importe und Verschiebungen fest",
|
"defaultUnetRootHelp": "Legen Sie den Standard-Diffusion-Modell-(UNET)-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||||
"defaultEmbeddingRoot": "Embedding-Stammordner",
|
"defaultEmbeddingRoot": "Embedding-Stammordner",
|
||||||
"defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest",
|
"defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||||
|
"recipesPath": "Rezepte-Speicherpfad",
|
||||||
|
"recipesPathHelp": "Optionales benutzerdefiniertes Verzeichnis für gespeicherte Rezepte. Leer lassen, um den recipes-Ordner im ersten LoRA-Stammverzeichnis zu verwenden.",
|
||||||
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
|
"recipesPathMigrating": "Rezepte-Speicher wird verschoben...",
|
||||||
"noDefault": "Kein Standard"
|
"noDefault": "Kein Standard"
|
||||||
},
|
},
|
||||||
"extraFolderPaths": {
|
"extraFolderPaths": {
|
||||||
@@ -1629,6 +1661,8 @@
|
|||||||
"mappingSaveFailed": "Fehler beim Speichern der Basis-Modell-Zuordnungen: {message}",
|
"mappingSaveFailed": "Fehler beim Speichern der Basis-Modell-Zuordnungen: {message}",
|
||||||
"downloadTemplatesUpdated": "Download-Pfad-Vorlagen aktualisiert",
|
"downloadTemplatesUpdated": "Download-Pfad-Vorlagen aktualisiert",
|
||||||
"downloadTemplatesFailed": "Fehler beim Speichern der Download-Pfad-Vorlagen: {message}",
|
"downloadTemplatesFailed": "Fehler beim Speichern der Download-Pfad-Vorlagen: {message}",
|
||||||
|
"recipesPathUpdated": "Rezepte-Speicherpfad aktualisiert",
|
||||||
|
"recipesPathSaveFailed": "Fehler beim Aktualisieren des Rezepte-Speicherpfads: {message}",
|
||||||
"settingsUpdated": "Einstellungen aktualisiert: {setting}",
|
"settingsUpdated": "Einstellungen aktualisiert: {setting}",
|
||||||
"compactModeToggled": "Kompakt-Modus {state}",
|
"compactModeToggled": "Kompakt-Modus {state}",
|
||||||
"settingSaveFailed": "Fehler beim Speichern der Einstellung: {message}",
|
"settingSaveFailed": "Fehler beim Speichern der Einstellung: {message}",
|
||||||
@@ -1772,6 +1806,35 @@
|
|||||||
"moveFailed": "Failed to move item: {message}"
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"doctor": {
|
||||||
|
"kicker": "Systemdiagnose",
|
||||||
|
"title": "Doktor",
|
||||||
|
"buttonTitle": "Diagnose und häufige Fehlerbehebungen ausführen",
|
||||||
|
"loading": "Umgebung wird geprüft...",
|
||||||
|
"footer": "Exportiere ein Diagnosepaket, falls das Problem nach der Reparatur weiterhin besteht.",
|
||||||
|
"summary": {
|
||||||
|
"idle": "Führe eine Überprüfung von Einstellungen, Cache-Integrität und UI-Konsistenz durch.",
|
||||||
|
"ok": "Keine aktiven Probleme wurden in der aktuellen Umgebung gefunden.",
|
||||||
|
"warning": "{count} Problem(e) wurden gefunden. Die meisten lassen sich direkt über dieses Panel beheben.",
|
||||||
|
"error": "Bevor die App vollständig fehlerfrei ist, müssen {count} Problem(e) behoben werden."
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"ok": "Gesund",
|
||||||
|
"warning": "Handlungsbedarf",
|
||||||
|
"error": "Aktion erforderlich"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"runAgain": "Erneut ausführen",
|
||||||
|
"exportBundle": "Paket exportieren"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"loadFailed": "Diagnose konnte nicht geladen werden: {message}",
|
||||||
|
"repairSuccess": "Cache-Neuaufbau abgeschlossen.",
|
||||||
|
"repairFailed": "Cache-Neuaufbau fehlgeschlagen: {message}",
|
||||||
|
"exportSuccess": "Diagnosepaket exportiert.",
|
||||||
|
"exportFailed": "Export des Diagnosepakets fehlgeschlagen: {message}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"versionMismatch": {
|
"versionMismatch": {
|
||||||
"title": "Anwendungs-Update erkannt",
|
"title": "Anwendungs-Update erkannt",
|
||||||
|
|||||||
@@ -263,7 +263,9 @@
|
|||||||
"videoSettings": "Video Settings",
|
"videoSettings": "Video Settings",
|
||||||
"layoutSettings": "Layout Settings",
|
"layoutSettings": "Layout Settings",
|
||||||
"misc": "Miscellaneous",
|
"misc": "Miscellaneous",
|
||||||
|
"backup": "Backups",
|
||||||
"folderSettings": "Default Roots",
|
"folderSettings": "Default Roots",
|
||||||
|
"recipeSettings": "Recipes",
|
||||||
"extraFolderPaths": "Extra Folder Paths",
|
"extraFolderPaths": "Extra Folder Paths",
|
||||||
"downloadPathTemplates": "Download Path Templates",
|
"downloadPathTemplates": "Download Path Templates",
|
||||||
"priorityTags": "Priority Tags",
|
"priorityTags": "Priority Tags",
|
||||||
@@ -323,6 +325,32 @@
|
|||||||
"saveFailed": "Unable to save skip paths: {message}"
|
"saveFailed": "Unable to save skip paths: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"backup": {
|
||||||
|
"autoEnabled": "Automatic backups",
|
||||||
|
"autoEnabledHelp": "Create a local snapshot once per day and keep the latest snapshots according to the retention policy.",
|
||||||
|
"retention": "Retention count",
|
||||||
|
"retentionHelp": "How many automatic snapshots to keep before older ones are pruned.",
|
||||||
|
"management": "Backup management",
|
||||||
|
"managementHelp": "Export your current user state or restore it from a backup archive.",
|
||||||
|
"scopeHelp": "Backs up your settings, download history, and model update state. It does not include model files or rebuildable caches.",
|
||||||
|
"locationSummary": "Current backup location",
|
||||||
|
"openFolderButton": "Open backup folder",
|
||||||
|
"openFolderSuccess": "Opened backup folder",
|
||||||
|
"openFolderFailed": "Failed to open backup folder",
|
||||||
|
"locationCopied": "Backup path copied to clipboard: {{path}}",
|
||||||
|
"locationClipboardFallback": "Backup path: {{path}}",
|
||||||
|
"exportButton": "Export backup",
|
||||||
|
"exportSuccess": "Backup exported successfully.",
|
||||||
|
"exportFailed": "Failed to export backup: {message}",
|
||||||
|
"importButton": "Import backup",
|
||||||
|
"importConfirm": "Import this backup and overwrite local user state?",
|
||||||
|
"importSuccess": "Backup imported successfully.",
|
||||||
|
"importFailed": "Failed to import backup: {message}",
|
||||||
|
"latestSnapshot": "Latest snapshot",
|
||||||
|
"latestAutoSnapshot": "Latest automatic snapshot",
|
||||||
|
"snapshotCount": "Saved snapshots",
|
||||||
|
"noneAvailable": "No snapshots yet"
|
||||||
|
},
|
||||||
"downloadSkipBaseModels": {
|
"downloadSkipBaseModels": {
|
||||||
"label": "Skip downloads for base models",
|
"label": "Skip downloads for base models",
|
||||||
"help": "When enabled, versions using the selected base models will be skipped.",
|
"help": "When enabled, versions using the selected base models will be skipped.",
|
||||||
@@ -393,6 +421,10 @@
|
|||||||
"defaultUnetRootHelp": "Set default diffusion model (UNET) root directory for downloads, imports and moves",
|
"defaultUnetRootHelp": "Set default diffusion model (UNET) root directory for downloads, imports and moves",
|
||||||
"defaultEmbeddingRoot": "Embedding Root",
|
"defaultEmbeddingRoot": "Embedding Root",
|
||||||
"defaultEmbeddingRootHelp": "Set default embedding root directory for downloads, imports and moves",
|
"defaultEmbeddingRootHelp": "Set default embedding root directory for downloads, imports and moves",
|
||||||
|
"recipesPath": "Recipes Storage Path",
|
||||||
|
"recipesPathHelp": "Optional custom directory for stored recipes. Leave empty to use the first LoRA root's recipes folder.",
|
||||||
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
|
"recipesPathMigrating": "Migrating recipes storage...",
|
||||||
"noDefault": "No Default"
|
"noDefault": "No Default"
|
||||||
},
|
},
|
||||||
"extraFolderPaths": {
|
"extraFolderPaths": {
|
||||||
@@ -1629,6 +1661,8 @@
|
|||||||
"mappingSaveFailed": "Failed to save base model mappings: {message}",
|
"mappingSaveFailed": "Failed to save base model mappings: {message}",
|
||||||
"downloadTemplatesUpdated": "Download path templates updated",
|
"downloadTemplatesUpdated": "Download path templates updated",
|
||||||
"downloadTemplatesFailed": "Failed to save download path templates: {message}",
|
"downloadTemplatesFailed": "Failed to save download path templates: {message}",
|
||||||
|
"recipesPathUpdated": "Recipes storage path updated",
|
||||||
|
"recipesPathSaveFailed": "Failed to update recipes storage path: {message}",
|
||||||
"settingsUpdated": "Settings updated: {setting}",
|
"settingsUpdated": "Settings updated: {setting}",
|
||||||
"compactModeToggled": "Compact Mode {state}",
|
"compactModeToggled": "Compact Mode {state}",
|
||||||
"settingSaveFailed": "Failed to save setting: {message}",
|
"settingSaveFailed": "Failed to save setting: {message}",
|
||||||
@@ -1772,6 +1806,35 @@
|
|||||||
"moveFailed": "Failed to move item: {message}"
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"doctor": {
|
||||||
|
"kicker": "System diagnostics",
|
||||||
|
"title": "Doctor",
|
||||||
|
"buttonTitle": "Run diagnostics and common fixes",
|
||||||
|
"loading": "Checking environment...",
|
||||||
|
"footer": "Export a diagnostics bundle if the issue still persists after repair.",
|
||||||
|
"summary": {
|
||||||
|
"idle": "Run a health check for settings, cache integrity, and UI consistency.",
|
||||||
|
"ok": "No active issues were found in the current environment.",
|
||||||
|
"warning": "{count} issue(s) were found. Most can be fixed directly from this panel.",
|
||||||
|
"error": "{count} issue(s) need attention before the app is fully healthy."
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"ok": "Healthy",
|
||||||
|
"warning": "Needs Attention",
|
||||||
|
"error": "Action Required"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"runAgain": "Run Again",
|
||||||
|
"exportBundle": "Export Bundle"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"loadFailed": "Failed to load diagnostics: {message}",
|
||||||
|
"repairSuccess": "Cache rebuild completed.",
|
||||||
|
"repairFailed": "Cache rebuild failed: {message}",
|
||||||
|
"exportSuccess": "Diagnostics bundle exported.",
|
||||||
|
"exportFailed": "Failed to export diagnostics bundle: {message}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"versionMismatch": {
|
"versionMismatch": {
|
||||||
"title": "Application Update Detected",
|
"title": "Application Update Detected",
|
||||||
|
|||||||
@@ -263,7 +263,9 @@
|
|||||||
"videoSettings": "Configuración de video",
|
"videoSettings": "Configuración de video",
|
||||||
"layoutSettings": "Configuración de diseño",
|
"layoutSettings": "Configuración de diseño",
|
||||||
"misc": "Varios",
|
"misc": "Varios",
|
||||||
|
"backup": "Copias de seguridad",
|
||||||
"folderSettings": "Raíces predeterminadas",
|
"folderSettings": "Raíces predeterminadas",
|
||||||
|
"recipeSettings": "Recetas",
|
||||||
"extraFolderPaths": "Rutas de carpetas adicionales",
|
"extraFolderPaths": "Rutas de carpetas adicionales",
|
||||||
"downloadPathTemplates": "Plantillas de rutas de descarga",
|
"downloadPathTemplates": "Plantillas de rutas de descarga",
|
||||||
"priorityTags": "Etiquetas prioritarias",
|
"priorityTags": "Etiquetas prioritarias",
|
||||||
@@ -323,6 +325,32 @@
|
|||||||
"saveFailed": "No se pudieron guardar las rutas a omitir: {message}"
|
"saveFailed": "No se pudieron guardar las rutas a omitir: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"backup": {
|
||||||
|
"autoEnabled": "Copias de seguridad automáticas",
|
||||||
|
"autoEnabledHelp": "Crea una instantánea local una vez al día y conserva las más recientes según la política de retención.",
|
||||||
|
"retention": "Cantidad de retención",
|
||||||
|
"retentionHelp": "Cuántas instantáneas automáticas conservar antes de eliminar las antiguas.",
|
||||||
|
"management": "Gestión de copias",
|
||||||
|
"managementHelp": "Exporta tu estado de usuario actual o restáuralo desde un archivo de copia de seguridad.",
|
||||||
|
"scopeHelp": "Incluye tu configuración, el historial de descargas y el estado de actualización de los modelos. No incluye los archivos de modelo ni las cachés que se pueden regenerar.",
|
||||||
|
"locationSummary": "Ubicación actual de la copia",
|
||||||
|
"openFolderButton": "Abrir carpeta de copias",
|
||||||
|
"openFolderSuccess": "Carpeta de copias abierta",
|
||||||
|
"openFolderFailed": "No se pudo abrir la carpeta de copias",
|
||||||
|
"locationCopied": "Ruta de la copia copiada al portapapeles: {{path}}",
|
||||||
|
"locationClipboardFallback": "Ruta de la copia: {{path}}",
|
||||||
|
"exportButton": "Exportar copia",
|
||||||
|
"exportSuccess": "Copia exportada correctamente.",
|
||||||
|
"exportFailed": "No se pudo exportar la copia: {message}",
|
||||||
|
"importButton": "Importar copia",
|
||||||
|
"importConfirm": "¿Importar esta copia y sobrescribir el estado local del usuario?",
|
||||||
|
"importSuccess": "Copia importada correctamente.",
|
||||||
|
"importFailed": "No se pudo importar la copia: {message}",
|
||||||
|
"latestSnapshot": "Última instantánea",
|
||||||
|
"latestAutoSnapshot": "Última instantánea automática",
|
||||||
|
"snapshotCount": "Instantáneas guardadas",
|
||||||
|
"noneAvailable": "Aún no hay instantáneas"
|
||||||
|
},
|
||||||
"downloadSkipBaseModels": {
|
"downloadSkipBaseModels": {
|
||||||
"label": "Omitir descargas para modelos base",
|
"label": "Omitir descargas para modelos base",
|
||||||
"help": "Se aplica a todos los flujos de descarga. Aquí solo se pueden seleccionar modelos base compatibles.",
|
"help": "Se aplica a todos los flujos de descarga. Aquí solo se pueden seleccionar modelos base compatibles.",
|
||||||
@@ -393,6 +421,10 @@
|
|||||||
"defaultUnetRootHelp": "Establecer el directorio raíz predeterminado de Diffusion Model (UNET) para descargas, importaciones y movimientos",
|
"defaultUnetRootHelp": "Establecer el directorio raíz predeterminado de Diffusion Model (UNET) para descargas, importaciones y movimientos",
|
||||||
"defaultEmbeddingRoot": "Raíz de embedding",
|
"defaultEmbeddingRoot": "Raíz de embedding",
|
||||||
"defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos",
|
"defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos",
|
||||||
|
"recipesPath": "Ruta de almacenamiento de recetas",
|
||||||
|
"recipesPathHelp": "Directorio personalizado opcional para las recetas guardadas. Déjalo vacío para usar la carpeta recipes del primer directorio raíz de LoRA.",
|
||||||
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
|
"recipesPathMigrating": "Migrando el almacenamiento de recetas...",
|
||||||
"noDefault": "Sin predeterminado"
|
"noDefault": "Sin predeterminado"
|
||||||
},
|
},
|
||||||
"extraFolderPaths": {
|
"extraFolderPaths": {
|
||||||
@@ -1629,6 +1661,8 @@
|
|||||||
"mappingSaveFailed": "Error al guardar mapeos de modelo base: {message}",
|
"mappingSaveFailed": "Error al guardar mapeos de modelo base: {message}",
|
||||||
"downloadTemplatesUpdated": "Plantillas de rutas de descarga actualizadas",
|
"downloadTemplatesUpdated": "Plantillas de rutas de descarga actualizadas",
|
||||||
"downloadTemplatesFailed": "Error al guardar plantillas de rutas de descarga: {message}",
|
"downloadTemplatesFailed": "Error al guardar plantillas de rutas de descarga: {message}",
|
||||||
|
"recipesPathUpdated": "Ruta de almacenamiento de recetas actualizada",
|
||||||
|
"recipesPathSaveFailed": "Error al actualizar la ruta de almacenamiento de recetas: {message}",
|
||||||
"settingsUpdated": "Configuración actualizada: {setting}",
|
"settingsUpdated": "Configuración actualizada: {setting}",
|
||||||
"compactModeToggled": "Modo compacto {state}",
|
"compactModeToggled": "Modo compacto {state}",
|
||||||
"settingSaveFailed": "Error al guardar configuración: {message}",
|
"settingSaveFailed": "Error al guardar configuración: {message}",
|
||||||
@@ -1772,6 +1806,35 @@
|
|||||||
"moveFailed": "Failed to move item: {message}"
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"doctor": {
|
||||||
|
"kicker": "Diagnósticos del sistema",
|
||||||
|
"title": "Doctor",
|
||||||
|
"buttonTitle": "Ejecutar diagnósticos y correcciones comunes",
|
||||||
|
"loading": "Comprobando el entorno...",
|
||||||
|
"footer": "Exporta un paquete de diagnóstico si el problema persiste después de la reparación.",
|
||||||
|
"summary": {
|
||||||
|
"idle": "Ejecuta una comprobación del estado de la configuración, la integridad de la caché y la coherencia de la interfaz.",
|
||||||
|
"ok": "No se encontraron problemas activos en el entorno actual.",
|
||||||
|
"warning": "Se encontraron {count} problema(s). La mayoría se puede solucionar directamente desde este panel.",
|
||||||
|
"error": "Se encontraron {count} problema(s). Deben atenderse antes de que la aplicación esté completamente saludable."
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"ok": "Saludable",
|
||||||
|
"warning": "Requiere atención",
|
||||||
|
"error": "Se requiere acción"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"runAgain": "Ejecutar de nuevo",
|
||||||
|
"exportBundle": "Exportar paquete"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"loadFailed": "Error al cargar los diagnósticos: {message}",
|
||||||
|
"repairSuccess": "Reconstrucción de caché completada.",
|
||||||
|
"repairFailed": "Error al reconstruir la caché: {message}",
|
||||||
|
"exportSuccess": "Paquete de diagnósticos exportado.",
|
||||||
|
"exportFailed": "Error al exportar el paquete de diagnósticos: {message}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"versionMismatch": {
|
"versionMismatch": {
|
||||||
"title": "Actualización de la aplicación detectada",
|
"title": "Actualización de la aplicación detectada",
|
||||||
|
|||||||
@@ -263,7 +263,9 @@
|
|||||||
"videoSettings": "Paramètres vidéo",
|
"videoSettings": "Paramètres vidéo",
|
||||||
"layoutSettings": "Paramètres d'affichage",
|
"layoutSettings": "Paramètres d'affichage",
|
||||||
"misc": "Divers",
|
"misc": "Divers",
|
||||||
|
"backup": "Sauvegardes",
|
||||||
"folderSettings": "Racines par défaut",
|
"folderSettings": "Racines par défaut",
|
||||||
|
"recipeSettings": "Recipes",
|
||||||
"extraFolderPaths": "Chemins de dossiers supplémentaires",
|
"extraFolderPaths": "Chemins de dossiers supplémentaires",
|
||||||
"downloadPathTemplates": "Modèles de chemin de téléchargement",
|
"downloadPathTemplates": "Modèles de chemin de téléchargement",
|
||||||
"priorityTags": "Étiquettes prioritaires",
|
"priorityTags": "Étiquettes prioritaires",
|
||||||
@@ -323,6 +325,32 @@
|
|||||||
"saveFailed": "Impossible d'enregistrer les chemins à ignorer : {message}"
|
"saveFailed": "Impossible d'enregistrer les chemins à ignorer : {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"backup": {
|
||||||
|
"autoEnabled": "Sauvegardes automatiques",
|
||||||
|
"autoEnabledHelp": "Crée un instantané local une fois par jour et conserve les plus récents selon la politique de rétention.",
|
||||||
|
"retention": "Nombre de rétention",
|
||||||
|
"retentionHelp": "Combien d'instantanés automatiques conserver avant de supprimer les plus anciens.",
|
||||||
|
"management": "Gestion des sauvegardes",
|
||||||
|
"managementHelp": "Exporte l'état actuel de l'utilisateur ou restaure-le depuis une archive de sauvegarde.",
|
||||||
|
"scopeHelp": "Inclut vos paramètres, l'historique des téléchargements et l'état des mises à jour des modèles. Les fichiers de modèle et les caches régénérables ne sont pas inclus.",
|
||||||
|
"locationSummary": "Emplacement actuel des sauvegardes",
|
||||||
|
"openFolderButton": "Ouvrir le dossier de sauvegarde",
|
||||||
|
"openFolderSuccess": "Dossier de sauvegarde ouvert",
|
||||||
|
"openFolderFailed": "Impossible d'ouvrir le dossier de sauvegarde",
|
||||||
|
"locationCopied": "Chemin de sauvegarde copié dans le presse-papiers : {{path}}",
|
||||||
|
"locationClipboardFallback": "Chemin de sauvegarde : {{path}}",
|
||||||
|
"exportButton": "Exporter la sauvegarde",
|
||||||
|
"exportSuccess": "Sauvegarde exportée avec succès.",
|
||||||
|
"exportFailed": "Échec de l'export de la sauvegarde : {message}",
|
||||||
|
"importButton": "Importer la sauvegarde",
|
||||||
|
"importConfirm": "Importer cette sauvegarde et écraser l'état local de l'utilisateur ?",
|
||||||
|
"importSuccess": "Sauvegarde importée avec succès.",
|
||||||
|
"importFailed": "Échec de l'import de la sauvegarde : {message}",
|
||||||
|
"latestSnapshot": "Dernier instantané",
|
||||||
|
"latestAutoSnapshot": "Dernier instantané automatique",
|
||||||
|
"snapshotCount": "Instantanés enregistrés",
|
||||||
|
"noneAvailable": "Aucun instantané pour le moment"
|
||||||
|
},
|
||||||
"downloadSkipBaseModels": {
|
"downloadSkipBaseModels": {
|
||||||
"label": "Ignorer les téléchargements pour certains modèles de base",
|
"label": "Ignorer les téléchargements pour certains modèles de base",
|
||||||
"help": "S’applique à tous les flux de téléchargement. Seuls les modèles de base pris en charge peuvent être sélectionnés ici.",
|
"help": "S’applique à tous les flux de téléchargement. Seuls les modèles de base pris en charge peuvent être sélectionnés ici.",
|
||||||
@@ -393,6 +421,10 @@
|
|||||||
"defaultUnetRootHelp": "Définir le répertoire racine Diffusion Model (UNET) par défaut pour les téléchargements, imports et déplacements",
|
"defaultUnetRootHelp": "Définir le répertoire racine Diffusion Model (UNET) par défaut pour les téléchargements, imports et déplacements",
|
||||||
"defaultEmbeddingRoot": "Racine Embedding",
|
"defaultEmbeddingRoot": "Racine Embedding",
|
||||||
"defaultEmbeddingRootHelp": "Définir le répertoire racine embedding par défaut pour les téléchargements, imports et déplacements",
|
"defaultEmbeddingRootHelp": "Définir le répertoire racine embedding par défaut pour les téléchargements, imports et déplacements",
|
||||||
|
"recipesPath": "Recipes Storage Path",
|
||||||
|
"recipesPathHelp": "Optional custom directory for stored recipes. Leave empty to use the first LoRA root's recipes folder.",
|
||||||
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
|
"recipesPathMigrating": "Migrating recipes storage...",
|
||||||
"noDefault": "Aucun par défaut"
|
"noDefault": "Aucun par défaut"
|
||||||
},
|
},
|
||||||
"extraFolderPaths": {
|
"extraFolderPaths": {
|
||||||
@@ -1629,6 +1661,8 @@
|
|||||||
"mappingSaveFailed": "Échec de la sauvegarde des mappages de modèle de base : {message}",
|
"mappingSaveFailed": "Échec de la sauvegarde des mappages de modèle de base : {message}",
|
||||||
"downloadTemplatesUpdated": "Modèles de chemin de téléchargement mis à jour",
|
"downloadTemplatesUpdated": "Modèles de chemin de téléchargement mis à jour",
|
||||||
"downloadTemplatesFailed": "Échec de la sauvegarde des modèles de chemin de téléchargement : {message}",
|
"downloadTemplatesFailed": "Échec de la sauvegarde des modèles de chemin de téléchargement : {message}",
|
||||||
|
"recipesPathUpdated": "Recipes storage path updated",
|
||||||
|
"recipesPathSaveFailed": "Failed to update recipes storage path: {message}",
|
||||||
"settingsUpdated": "Paramètres mis à jour : {setting}",
|
"settingsUpdated": "Paramètres mis à jour : {setting}",
|
||||||
"compactModeToggled": "Mode compact {state}",
|
"compactModeToggled": "Mode compact {state}",
|
||||||
"settingSaveFailed": "Échec de la sauvegarde du paramètre : {message}",
|
"settingSaveFailed": "Échec de la sauvegarde du paramètre : {message}",
|
||||||
@@ -1772,6 +1806,35 @@
|
|||||||
"moveFailed": "Failed to move item: {message}"
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"doctor": {
|
||||||
|
"kicker": "Diagnostics système",
|
||||||
|
"title": "Docteur",
|
||||||
|
"buttonTitle": "Lancer les diagnostics et les corrections courantes",
|
||||||
|
"loading": "Vérification de l'environnement...",
|
||||||
|
"footer": "Exportez un lot de diagnostic si le problème persiste après la réparation.",
|
||||||
|
"summary": {
|
||||||
|
"idle": "Lancez une vérification de l'état des paramètres, de l'intégrité du cache et de la cohérence de l'interface.",
|
||||||
|
"ok": "Aucun problème actif n'a été trouvé dans l'environnement actuel.",
|
||||||
|
"warning": "{count} problème(s) ont été trouvés. La plupart peuvent être corrigés directement depuis ce panneau.",
|
||||||
|
"error": "{count} problème(s) nécessitent une attention avant que l'application soit entièrement saine."
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"ok": "Sain",
|
||||||
|
"warning": "Nécessite une attention",
|
||||||
|
"error": "Action requise"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"runAgain": "Relancer",
|
||||||
|
"exportBundle": "Exporter le lot"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"loadFailed": "Échec du chargement des diagnostics : {message}",
|
||||||
|
"repairSuccess": "Reconstruction du cache terminée.",
|
||||||
|
"repairFailed": "Échec de la reconstruction du cache : {message}",
|
||||||
|
"exportSuccess": "Lot de diagnostics exporté.",
|
||||||
|
"exportFailed": "Échec de l'export du lot de diagnostics : {message}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"versionMismatch": {
|
"versionMismatch": {
|
||||||
"title": "Mise à jour de l'application détectée",
|
"title": "Mise à jour de l'application détectée",
|
||||||
|
|||||||
@@ -263,7 +263,9 @@
|
|||||||
"videoSettings": "הגדרות וידאו",
|
"videoSettings": "הגדרות וידאו",
|
||||||
"layoutSettings": "הגדרות פריסה",
|
"layoutSettings": "הגדרות פריסה",
|
||||||
"misc": "שונות",
|
"misc": "שונות",
|
||||||
|
"backup": "גיבויים",
|
||||||
"folderSettings": "תיקיות ברירת מחדל",
|
"folderSettings": "תיקיות ברירת מחדל",
|
||||||
|
"recipeSettings": "מתכונים",
|
||||||
"extraFolderPaths": "נתיבי תיקיות נוספים",
|
"extraFolderPaths": "נתיבי תיקיות נוספים",
|
||||||
"downloadPathTemplates": "תבניות נתיב הורדה",
|
"downloadPathTemplates": "תבניות נתיב הורדה",
|
||||||
"priorityTags": "תגיות עדיפות",
|
"priorityTags": "תגיות עדיפות",
|
||||||
@@ -323,6 +325,32 @@
|
|||||||
"saveFailed": "לא ניתן לשמור נתיבי דילוג: {message}"
|
"saveFailed": "לא ניתן לשמור נתיבי דילוג: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"backup": {
|
||||||
|
"autoEnabled": "גיבויים אוטומטיים",
|
||||||
|
"autoEnabledHelp": "יוצר צילום מצב מקומי פעם ביום ושומר את הצילומים האחרונים לפי מדיניות השמירה.",
|
||||||
|
"retention": "כמות שמירה",
|
||||||
|
"retentionHelp": "כמה צילומי מצב אוטומטיים לשמור לפני שמסירים ישנים.",
|
||||||
|
"management": "ניהול גיבויים",
|
||||||
|
"managementHelp": "ייצא את מצב המשתמש הנוכחי או שחזר אותו מארכיון גיבוי.",
|
||||||
|
"scopeHelp": "כולל את ההגדרות שלך, היסטוריית ההורדות ומצב עדכוני המודלים. אינו כולל קובצי מודל או מטמונים שניתן לשחזר.",
|
||||||
|
"locationSummary": "מיקום הגיבוי הנוכחי",
|
||||||
|
"openFolderButton": "פתח את תיקיית הגיבויים",
|
||||||
|
"openFolderSuccess": "תיקיית הגיבויים נפתחה",
|
||||||
|
"openFolderFailed": "לא ניתן היה לפתוח את תיקיית הגיבויים",
|
||||||
|
"locationCopied": "נתיב הגיבוי הועתק ללוח: {{path}}",
|
||||||
|
"locationClipboardFallback": "נתיב הגיבוי: {{path}}",
|
||||||
|
"exportButton": "ייצא גיבוי",
|
||||||
|
"exportSuccess": "הגיבוי יוצא בהצלחה.",
|
||||||
|
"exportFailed": "נכשל ייצוא הגיבוי: {message}",
|
||||||
|
"importButton": "ייבא גיבוי",
|
||||||
|
"importConfirm": "לייבא את הגיבוי הזה ולדרוס את מצב המשתמש המקומי?",
|
||||||
|
"importSuccess": "הגיבוי יובא בהצלחה.",
|
||||||
|
"importFailed": "נכשל ייבוא הגיבוי: {message}",
|
||||||
|
"latestSnapshot": "צילום המצב האחרון",
|
||||||
|
"latestAutoSnapshot": "צילום המצב האוטומטי האחרון",
|
||||||
|
"snapshotCount": "צילומי מצב שמורים",
|
||||||
|
"noneAvailable": "עדיין אין צילומי מצב"
|
||||||
|
},
|
||||||
"downloadSkipBaseModels": {
|
"downloadSkipBaseModels": {
|
||||||
"label": "דלג על הורדות עבור מודלי בסיס",
|
"label": "דלג על הורדות עבור מודלי בסיס",
|
||||||
"help": "חל על כל תהליכי ההורדה. ניתן לבחור כאן רק מודלי בסיס נתמכים.",
|
"help": "חל על כל תהליכי ההורדה. ניתן לבחור כאן רק מודלי בסיס נתמכים.",
|
||||||
@@ -393,6 +421,10 @@
|
|||||||
"defaultUnetRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Diffusion Model (UNET) להורדות, ייבוא והעברות",
|
"defaultUnetRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Diffusion Model (UNET) להורדות, ייבוא והעברות",
|
||||||
"defaultEmbeddingRoot": "תיקיית שורש Embedding",
|
"defaultEmbeddingRoot": "תיקיית שורש Embedding",
|
||||||
"defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות",
|
"defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות",
|
||||||
|
"recipesPath": "נתיב אחסון מתכונים",
|
||||||
|
"recipesPathHelp": "ספרייה מותאמת אישית אופציונלית למתכונים שנשמרו. השאר ריק כדי להשתמש בתיקיית recipes של שורש LoRA הראשון.",
|
||||||
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
|
"recipesPathMigrating": "מעביר את אחסון המתכונים...",
|
||||||
"noDefault": "אין ברירת מחדל"
|
"noDefault": "אין ברירת מחדל"
|
||||||
},
|
},
|
||||||
"extraFolderPaths": {
|
"extraFolderPaths": {
|
||||||
@@ -1629,6 +1661,8 @@
|
|||||||
"mappingSaveFailed": "שמירת מיפויי מודל בסיס נכשלה: {message}",
|
"mappingSaveFailed": "שמירת מיפויי מודל בסיס נכשלה: {message}",
|
||||||
"downloadTemplatesUpdated": "תבניות נתיב הורדה עודכנו",
|
"downloadTemplatesUpdated": "תבניות נתיב הורדה עודכנו",
|
||||||
"downloadTemplatesFailed": "שמירת תבניות נתיב הורדה נכשלה: {message}",
|
"downloadTemplatesFailed": "שמירת תבניות נתיב הורדה נכשלה: {message}",
|
||||||
|
"recipesPathUpdated": "נתיב אחסון המתכונים עודכן",
|
||||||
|
"recipesPathSaveFailed": "עדכון נתיב אחסון המתכונים נכשל: {message}",
|
||||||
"settingsUpdated": "הגדרות עודכנו: {setting}",
|
"settingsUpdated": "הגדרות עודכנו: {setting}",
|
||||||
"compactModeToggled": "מצב קומפקטי {state}",
|
"compactModeToggled": "מצב קומפקטי {state}",
|
||||||
"settingSaveFailed": "שמירת ההגדרה נכשלה: {message}",
|
"settingSaveFailed": "שמירת ההגדרה נכשלה: {message}",
|
||||||
@@ -1772,6 +1806,35 @@
|
|||||||
"moveFailed": "Failed to move item: {message}"
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"doctor": {
|
||||||
|
"kicker": "אבחון מערכת",
|
||||||
|
"title": "דוקטור",
|
||||||
|
"buttonTitle": "הפעלת אבחון ותיקונים נפוצים",
|
||||||
|
"loading": "בודק את הסביבה...",
|
||||||
|
"footer": "ייצא חבילת אבחון אם הבעיה עדיין נמשכת לאחר התיקון.",
|
||||||
|
"summary": {
|
||||||
|
"idle": "הרץ בדיקת תקינות עבור הגדרות, שלמות המטמון ועקביות הממשק.",
|
||||||
|
"ok": "לא נמצאו בעיות פעילות בסביבה הנוכחית.",
|
||||||
|
"warning": "נמצאה/נמצאו {count} בעיה/בעיות. את רובן אפשר לתקן ישירות מלוח זה.",
|
||||||
|
"error": "יש לטפל ב-{count} בעיה/בעיות לפני שהאפליקציה תהיה תקינה לחלוטין."
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"ok": "תקין",
|
||||||
|
"warning": "דורש תשומת לב",
|
||||||
|
"error": "נדרשת פעולה"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"runAgain": "הפעל שוב",
|
||||||
|
"exportBundle": "ייצוא חבילה"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"loadFailed": "טעינת האבחון נכשלה: {message}",
|
||||||
|
"repairSuccess": "בניית המטמון מחדש הושלמה.",
|
||||||
|
"repairFailed": "בניית המטמון מחדש נכשלה: {message}",
|
||||||
|
"exportSuccess": "חבילת האבחון יוצאה.",
|
||||||
|
"exportFailed": "ייצוא חבילת האבחון נכשל: {message}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"versionMismatch": {
|
"versionMismatch": {
|
||||||
"title": "זוהה עדכון יישום",
|
"title": "זוהה עדכון יישום",
|
||||||
|
|||||||
@@ -263,7 +263,9 @@
|
|||||||
"videoSettings": "動画設定",
|
"videoSettings": "動画設定",
|
||||||
"layoutSettings": "レイアウト設定",
|
"layoutSettings": "レイアウト設定",
|
||||||
"misc": "その他",
|
"misc": "その他",
|
||||||
|
"backup": "バックアップ",
|
||||||
"folderSettings": "デフォルトルート",
|
"folderSettings": "デフォルトルート",
|
||||||
|
"recipeSettings": "レシピ",
|
||||||
"extraFolderPaths": "追加フォルダーパス",
|
"extraFolderPaths": "追加フォルダーパス",
|
||||||
"downloadPathTemplates": "ダウンロードパステンプレート",
|
"downloadPathTemplates": "ダウンロードパステンプレート",
|
||||||
"priorityTags": "優先タグ",
|
"priorityTags": "優先タグ",
|
||||||
@@ -323,6 +325,32 @@
|
|||||||
"saveFailed": "スキップパスの保存に失敗しました:{message}"
|
"saveFailed": "スキップパスの保存に失敗しました:{message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"backup": {
|
||||||
|
"autoEnabled": "自動バックアップ",
|
||||||
|
"autoEnabledHelp": "1日1回ローカルのスナップショットを作成し、保持ポリシーに従って最新のものを残します。",
|
||||||
|
"retention": "保持数",
|
||||||
|
"retentionHelp": "古いものを削除する前に、何件の自動スナップショットを保持するかを指定します。",
|
||||||
|
"management": "バックアップ管理",
|
||||||
|
"managementHelp": "現在のユーザー状態をエクスポートするか、バックアップアーカイブから復元します。",
|
||||||
|
"scopeHelp": "設定、ダウンロード履歴、モデル更新の状態をバックアップします。モデルファイルや再生成できるキャッシュは含まれません。",
|
||||||
|
"locationSummary": "現在のバックアップ場所",
|
||||||
|
"openFolderButton": "バックアップフォルダを開く",
|
||||||
|
"openFolderSuccess": "バックアップフォルダを開きました",
|
||||||
|
"openFolderFailed": "バックアップフォルダを開けませんでした",
|
||||||
|
"locationCopied": "バックアップパスをクリップボードにコピーしました: {{path}}",
|
||||||
|
"locationClipboardFallback": "バックアップパス: {{path}}",
|
||||||
|
"exportButton": "バックアップをエクスポート",
|
||||||
|
"exportSuccess": "バックアップを正常にエクスポートしました。",
|
||||||
|
"exportFailed": "バックアップのエクスポートに失敗しました: {message}",
|
||||||
|
"importButton": "バックアップをインポート",
|
||||||
|
"importConfirm": "このバックアップをインポートして、ローカルのユーザー状態を上書きしますか?",
|
||||||
|
"importSuccess": "バックアップを正常にインポートしました。",
|
||||||
|
"importFailed": "バックアップのインポートに失敗しました: {message}",
|
||||||
|
"latestSnapshot": "最新のスナップショット",
|
||||||
|
"latestAutoSnapshot": "最新の自動スナップショット",
|
||||||
|
"snapshotCount": "保存済みスナップショット",
|
||||||
|
"noneAvailable": "まだスナップショットはありません"
|
||||||
|
},
|
||||||
"downloadSkipBaseModels": {
|
"downloadSkipBaseModels": {
|
||||||
"label": "ベースモデルのダウンロードをスキップ",
|
"label": "ベースモデルのダウンロードをスキップ",
|
||||||
"help": "すべてのダウンロードフローに適用されます。ここでは対応しているベースモデルのみ選択できます。",
|
"help": "すべてのダウンロードフローに適用されます。ここでは対応しているベースモデルのみ選択できます。",
|
||||||
@@ -393,6 +421,10 @@
|
|||||||
"defaultUnetRootHelp": "ダウンロード、インポート、移動用のデフォルトDiffusion Model (UNET)ルートディレクトリを設定",
|
"defaultUnetRootHelp": "ダウンロード、インポート、移動用のデフォルトDiffusion Model (UNET)ルートディレクトリを設定",
|
||||||
"defaultEmbeddingRoot": "Embeddingルート",
|
"defaultEmbeddingRoot": "Embeddingルート",
|
||||||
"defaultEmbeddingRootHelp": "ダウンロード、インポート、移動用のデフォルトembeddingルートディレクトリを設定",
|
"defaultEmbeddingRootHelp": "ダウンロード、インポート、移動用のデフォルトembeddingルートディレクトリを設定",
|
||||||
|
"recipesPath": "レシピ保存先",
|
||||||
|
"recipesPathHelp": "保存済みレシピ用の任意のカスタムディレクトリです。空欄にすると最初のLoRAルートのrecipesフォルダーを使用します。",
|
||||||
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
|
"recipesPathMigrating": "レシピ保存先を移動中...",
|
||||||
"noDefault": "デフォルトなし"
|
"noDefault": "デフォルトなし"
|
||||||
},
|
},
|
||||||
"extraFolderPaths": {
|
"extraFolderPaths": {
|
||||||
@@ -1629,6 +1661,8 @@
|
|||||||
"mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}",
|
"mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}",
|
||||||
"downloadTemplatesUpdated": "ダウンロードパステンプレートが更新されました",
|
"downloadTemplatesUpdated": "ダウンロードパステンプレートが更新されました",
|
||||||
"downloadTemplatesFailed": "ダウンロードパステンプレートの保存に失敗しました:{message}",
|
"downloadTemplatesFailed": "ダウンロードパステンプレートの保存に失敗しました:{message}",
|
||||||
|
"recipesPathUpdated": "レシピ保存先を更新しました",
|
||||||
|
"recipesPathSaveFailed": "レシピ保存先の更新に失敗しました: {message}",
|
||||||
"settingsUpdated": "設定が更新されました:{setting}",
|
"settingsUpdated": "設定が更新されました:{setting}",
|
||||||
"compactModeToggled": "コンパクトモード {state}",
|
"compactModeToggled": "コンパクトモード {state}",
|
||||||
"settingSaveFailed": "設定の保存に失敗しました:{message}",
|
"settingSaveFailed": "設定の保存に失敗しました:{message}",
|
||||||
@@ -1772,6 +1806,35 @@
|
|||||||
"moveFailed": "Failed to move item: {message}"
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"doctor": {
|
||||||
|
"kicker": "システム診断",
|
||||||
|
"title": "ドクター",
|
||||||
|
"buttonTitle": "診断と一般的な修復を実行",
|
||||||
|
"loading": "環境を確認中...",
|
||||||
|
"footer": "修復後も問題が続く場合は、診断パッケージをエクスポートしてください。",
|
||||||
|
"summary": {
|
||||||
|
"idle": "設定、キャッシュ整合性、UI の一貫性をヘルスチェックします。",
|
||||||
|
"ok": "現在の環境でアクティブな問題は見つかりませんでした。",
|
||||||
|
"warning": "{count} 件の問題が見つかりました。ほとんどはこのパネルから直接修復できます。",
|
||||||
|
"error": "アプリが完全に正常になる前に、{count} 件の問題に対処する必要があります。"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"ok": "正常",
|
||||||
|
"warning": "要注意",
|
||||||
|
"error": "対応が必要"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"runAgain": "再実行",
|
||||||
|
"exportBundle": "パッケージをエクスポート"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"loadFailed": "診断の読み込みに失敗しました: {message}",
|
||||||
|
"repairSuccess": "キャッシュの再構築が完了しました。",
|
||||||
|
"repairFailed": "キャッシュの再構築に失敗しました: {message}",
|
||||||
|
"exportSuccess": "診断パッケージをエクスポートしました。",
|
||||||
|
"exportFailed": "診断パッケージのエクスポートに失敗しました: {message}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"versionMismatch": {
|
"versionMismatch": {
|
||||||
"title": "アプリケーション更新が検出されました",
|
"title": "アプリケーション更新が検出されました",
|
||||||
|
|||||||
@@ -263,7 +263,9 @@
|
|||||||
"videoSettings": "비디오 설정",
|
"videoSettings": "비디오 설정",
|
||||||
"layoutSettings": "레이아웃 설정",
|
"layoutSettings": "레이아웃 설정",
|
||||||
"misc": "기타",
|
"misc": "기타",
|
||||||
|
"backup": "백업",
|
||||||
"folderSettings": "기본 루트",
|
"folderSettings": "기본 루트",
|
||||||
|
"recipeSettings": "레시피",
|
||||||
"extraFolderPaths": "추가 폴다 경로",
|
"extraFolderPaths": "추가 폴다 경로",
|
||||||
"downloadPathTemplates": "다운로드 경로 템플릿",
|
"downloadPathTemplates": "다운로드 경로 템플릿",
|
||||||
"priorityTags": "우선순위 태그",
|
"priorityTags": "우선순위 태그",
|
||||||
@@ -323,6 +325,32 @@
|
|||||||
"saveFailed": "건너뛰기 경로를 저장할 수 없습니다: {message}"
|
"saveFailed": "건너뛰기 경로를 저장할 수 없습니다: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"backup": {
|
||||||
|
"autoEnabled": "자동 백업",
|
||||||
|
"autoEnabledHelp": "하루에 한 번 로컬 스냅샷을 만들고 보존 정책에 따라 최신 스냅샷을 유지합니다.",
|
||||||
|
"retention": "보존 개수",
|
||||||
|
"retentionHelp": "오래된 자동 스냅샷을 삭제하기 전에 몇 개를 유지할지 지정합니다.",
|
||||||
|
"management": "백업 관리",
|
||||||
|
"managementHelp": "현재 사용자 상태를 내보내거나 백업 아카이브에서 복원합니다.",
|
||||||
|
"scopeHelp": "설정, 다운로드 기록, 모델 업데이트 상태를 백업합니다. 모델 파일과 다시 생성할 수 있는 캐시는 포함되지 않습니다.",
|
||||||
|
"locationSummary": "현재 백업 위치",
|
||||||
|
"openFolderButton": "백업 폴더 열기",
|
||||||
|
"openFolderSuccess": "백업 폴더를 열었습니다",
|
||||||
|
"openFolderFailed": "백업 폴더를 열지 못했습니다",
|
||||||
|
"locationCopied": "백업 경로를 클립보드에 복사했습니다: {{path}}",
|
||||||
|
"locationClipboardFallback": "백업 경로: {{path}}",
|
||||||
|
"exportButton": "백업 내보내기",
|
||||||
|
"exportSuccess": "백업을 성공적으로 내보냈습니다.",
|
||||||
|
"exportFailed": "백업 내보내기에 실패했습니다: {message}",
|
||||||
|
"importButton": "백업 가져오기",
|
||||||
|
"importConfirm": "이 백업을 가져와서 로컬 사용자 상태를 덮어쓰시겠습니까?",
|
||||||
|
"importSuccess": "백업을 성공적으로 가져왔습니다.",
|
||||||
|
"importFailed": "백업 가져오기에 실패했습니다: {message}",
|
||||||
|
"latestSnapshot": "최근 스냅샷",
|
||||||
|
"latestAutoSnapshot": "최근 자동 스냅샷",
|
||||||
|
"snapshotCount": "저장된 스냅샷",
|
||||||
|
"noneAvailable": "아직 스냅샷이 없습니다"
|
||||||
|
},
|
||||||
"downloadSkipBaseModels": {
|
"downloadSkipBaseModels": {
|
||||||
"label": "기본 모델 다운로드 건너뛰기",
|
"label": "기본 모델 다운로드 건너뛰기",
|
||||||
"help": "모든 다운로드 흐름에 적용됩니다. 여기서는 지원되는 기본 모델만 선택할 수 있습니다.",
|
"help": "모든 다운로드 흐름에 적용됩니다. 여기서는 지원되는 기본 모델만 선택할 수 있습니다.",
|
||||||
@@ -393,6 +421,10 @@
|
|||||||
"defaultUnetRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Diffusion Model (UNET) 루트 디렉토리를 설정합니다",
|
"defaultUnetRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Diffusion Model (UNET) 루트 디렉토리를 설정합니다",
|
||||||
"defaultEmbeddingRoot": "Embedding 루트",
|
"defaultEmbeddingRoot": "Embedding 루트",
|
||||||
"defaultEmbeddingRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Embedding 루트 디렉토리를 설정합니다",
|
"defaultEmbeddingRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Embedding 루트 디렉토리를 설정합니다",
|
||||||
|
"recipesPath": "레시피 저장 경로",
|
||||||
|
"recipesPathHelp": "저장된 레시피를 위한 선택적 사용자 지정 디렉터리입니다. 비워 두면 첫 번째 LoRA 루트의 recipes 폴더를 사용합니다.",
|
||||||
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
|
"recipesPathMigrating": "레시피 저장 경로를 이동 중...",
|
||||||
"noDefault": "기본값 없음"
|
"noDefault": "기본값 없음"
|
||||||
},
|
},
|
||||||
"extraFolderPaths": {
|
"extraFolderPaths": {
|
||||||
@@ -1629,6 +1661,8 @@
|
|||||||
"mappingSaveFailed": "베이스 모델 매핑 저장 실패: {message}",
|
"mappingSaveFailed": "베이스 모델 매핑 저장 실패: {message}",
|
||||||
"downloadTemplatesUpdated": "다운로드 경로 템플릿이 업데이트되었습니다",
|
"downloadTemplatesUpdated": "다운로드 경로 템플릿이 업데이트되었습니다",
|
||||||
"downloadTemplatesFailed": "다운로드 경로 템플릿 저장 실패: {message}",
|
"downloadTemplatesFailed": "다운로드 경로 템플릿 저장 실패: {message}",
|
||||||
|
"recipesPathUpdated": "레시피 저장 경로가 업데이트되었습니다",
|
||||||
|
"recipesPathSaveFailed": "레시피 저장 경로 업데이트 실패: {message}",
|
||||||
"settingsUpdated": "설정 업데이트됨: {setting}",
|
"settingsUpdated": "설정 업데이트됨: {setting}",
|
||||||
"compactModeToggled": "컴팩트 모드 {state}",
|
"compactModeToggled": "컴팩트 모드 {state}",
|
||||||
"settingSaveFailed": "설정 저장 실패: {message}",
|
"settingSaveFailed": "설정 저장 실패: {message}",
|
||||||
@@ -1772,6 +1806,35 @@
|
|||||||
"moveFailed": "Failed to move item: {message}"
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"doctor": {
|
||||||
|
"kicker": "시스템 진단",
|
||||||
|
"title": "닥터",
|
||||||
|
"buttonTitle": "진단 및 일반적인 수정 실행",
|
||||||
|
"loading": "환경을 확인하는 중...",
|
||||||
|
"footer": "수리 후에도 문제가 계속되면 진단 번들을 내보내세요.",
|
||||||
|
"summary": {
|
||||||
|
"idle": "설정, 캐시 무결성, UI 일관성에 대한 상태 검사를 실행합니다.",
|
||||||
|
"ok": "현재 환경에서 활성 문제를 찾지 못했습니다.",
|
||||||
|
"warning": "{count}개의 문제가 발견되었습니다. 대부분은 이 패널에서 바로 해결할 수 있습니다.",
|
||||||
|
"error": "앱이 완전히 정상 상태가 되기 전에 {count}개의 문제를 처리해야 합니다."
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"ok": "정상",
|
||||||
|
"warning": "주의 필요",
|
||||||
|
"error": "조치 필요"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"runAgain": "다시 실행",
|
||||||
|
"exportBundle": "번들 내보내기"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"loadFailed": "진단 로드 실패: {message}",
|
||||||
|
"repairSuccess": "캐시 재구성이 완료되었습니다.",
|
||||||
|
"repairFailed": "캐시 재구성 실패: {message}",
|
||||||
|
"exportSuccess": "진단 번들이 내보내졌습니다.",
|
||||||
|
"exportFailed": "진단 번들 내보내기 실패: {message}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"versionMismatch": {
|
"versionMismatch": {
|
||||||
"title": "애플리케이션 업데이트 감지",
|
"title": "애플리케이션 업데이트 감지",
|
||||||
|
|||||||
@@ -263,7 +263,9 @@
|
|||||||
"videoSettings": "Настройки видео",
|
"videoSettings": "Настройки видео",
|
||||||
"layoutSettings": "Настройки макета",
|
"layoutSettings": "Настройки макета",
|
||||||
"misc": "Разное",
|
"misc": "Разное",
|
||||||
|
"backup": "Резервные копии",
|
||||||
"folderSettings": "Корневые папки",
|
"folderSettings": "Корневые папки",
|
||||||
|
"recipeSettings": "Рецепты",
|
||||||
"extraFolderPaths": "Дополнительные пути к папкам",
|
"extraFolderPaths": "Дополнительные пути к папкам",
|
||||||
"downloadPathTemplates": "Шаблоны путей загрузки",
|
"downloadPathTemplates": "Шаблоны путей загрузки",
|
||||||
"priorityTags": "Приоритетные теги",
|
"priorityTags": "Приоритетные теги",
|
||||||
@@ -323,6 +325,32 @@
|
|||||||
"saveFailed": "Не удалось сохранить пути для пропуска: {message}"
|
"saveFailed": "Не удалось сохранить пути для пропуска: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"backup": {
|
||||||
|
"autoEnabled": "Автоматические резервные копии",
|
||||||
|
"autoEnabledHelp": "Создаёт локальный снимок раз в день и хранит последние снимки согласно политике хранения.",
|
||||||
|
"retention": "Количество хранения",
|
||||||
|
"retentionHelp": "Сколько автоматических снимков сохранять перед удалением старых.",
|
||||||
|
"management": "Управление резервными копиями",
|
||||||
|
"managementHelp": "Экспортируйте текущее состояние пользователя или восстановите его из архива резервной копии.",
|
||||||
|
"scopeHelp": "Резервная копия включает ваши настройки, историю загрузок и состояние обновлений моделей. Файлы моделей и пересоздаваемые кэши не входят.",
|
||||||
|
"locationSummary": "Текущее расположение резервных копий",
|
||||||
|
"openFolderButton": "Открыть папку резервных копий",
|
||||||
|
"openFolderSuccess": "Папка резервных копий открыта",
|
||||||
|
"openFolderFailed": "Не удалось открыть папку резервных копий",
|
||||||
|
"locationCopied": "Путь к резервной копии скопирован в буфер обмена: {{path}}",
|
||||||
|
"locationClipboardFallback": "Путь к резервной копии: {{path}}",
|
||||||
|
"exportButton": "Экспортировать резервную копию",
|
||||||
|
"exportSuccess": "Резервная копия успешно экспортирована.",
|
||||||
|
"exportFailed": "Не удалось экспортировать резервную копию: {message}",
|
||||||
|
"importButton": "Импортировать резервную копию",
|
||||||
|
"importConfirm": "Импортировать эту резервную копию и перезаписать локальное состояние пользователя?",
|
||||||
|
"importSuccess": "Резервная копия успешно импортирована.",
|
||||||
|
"importFailed": "Не удалось импортировать резервную копию: {message}",
|
||||||
|
"latestSnapshot": "Последний снимок",
|
||||||
|
"latestAutoSnapshot": "Последний автоматический снимок",
|
||||||
|
"snapshotCount": "Сохранённые снимки",
|
||||||
|
"noneAvailable": "Снимков пока нет"
|
||||||
|
},
|
||||||
"downloadSkipBaseModels": {
|
"downloadSkipBaseModels": {
|
||||||
"label": "Пропускать загрузки для базовых моделей",
|
"label": "Пропускать загрузки для базовых моделей",
|
||||||
"help": "Применяется ко всем сценариям загрузки. Здесь можно выбрать только поддерживаемые базовые модели.",
|
"help": "Применяется ко всем сценариям загрузки. Здесь можно выбрать только поддерживаемые базовые модели.",
|
||||||
@@ -393,6 +421,10 @@
|
|||||||
"defaultUnetRootHelp": "Установить корневую папку Diffusion Model (UNET) по умолчанию для загрузок, импорта и перемещений",
|
"defaultUnetRootHelp": "Установить корневую папку Diffusion Model (UNET) по умолчанию для загрузок, импорта и перемещений",
|
||||||
"defaultEmbeddingRoot": "Корневая папка Embedding",
|
"defaultEmbeddingRoot": "Корневая папка Embedding",
|
||||||
"defaultEmbeddingRootHelp": "Установить корневую папку embedding по умолчанию для загрузок, импорта и перемещений",
|
"defaultEmbeddingRootHelp": "Установить корневую папку embedding по умолчанию для загрузок, импорта и перемещений",
|
||||||
|
"recipesPath": "Путь хранения рецептов",
|
||||||
|
"recipesPathHelp": "Дополнительный пользовательский каталог для сохранённых рецептов. Оставьте пустым, чтобы использовать папку recipes в первом корне LoRA.",
|
||||||
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
|
"recipesPathMigrating": "Перенос хранилища рецептов...",
|
||||||
"noDefault": "Не задано"
|
"noDefault": "Не задано"
|
||||||
},
|
},
|
||||||
"extraFolderPaths": {
|
"extraFolderPaths": {
|
||||||
@@ -1629,6 +1661,8 @@
|
|||||||
"mappingSaveFailed": "Не удалось сохранить сопоставления базовых моделей: {message}",
|
"mappingSaveFailed": "Не удалось сохранить сопоставления базовых моделей: {message}",
|
||||||
"downloadTemplatesUpdated": "Шаблоны путей загрузки обновлены",
|
"downloadTemplatesUpdated": "Шаблоны путей загрузки обновлены",
|
||||||
"downloadTemplatesFailed": "Не удалось сохранить шаблоны путей загрузки: {message}",
|
"downloadTemplatesFailed": "Не удалось сохранить шаблоны путей загрузки: {message}",
|
||||||
|
"recipesPathUpdated": "Путь хранения рецептов обновлён",
|
||||||
|
"recipesPathSaveFailed": "Не удалось обновить путь хранения рецептов: {message}",
|
||||||
"settingsUpdated": "Настройки обновлены: {setting}",
|
"settingsUpdated": "Настройки обновлены: {setting}",
|
||||||
"compactModeToggled": "Компактный режим {state}",
|
"compactModeToggled": "Компактный режим {state}",
|
||||||
"settingSaveFailed": "Не удалось сохранить настройку: {message}",
|
"settingSaveFailed": "Не удалось сохранить настройку: {message}",
|
||||||
@@ -1772,6 +1806,35 @@
|
|||||||
"moveFailed": "Failed to move item: {message}"
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"doctor": {
|
||||||
|
"kicker": "Системная диагностика",
|
||||||
|
"title": "Доктор",
|
||||||
|
"buttonTitle": "Запустить диагностику и обычные исправления",
|
||||||
|
"loading": "Проверка окружения...",
|
||||||
|
"footer": "Экспортируйте диагностический пакет, если проблема сохраняется после исправления.",
|
||||||
|
"summary": {
|
||||||
|
"idle": "Выполнить проверку настроек, целостности кэша и согласованности интерфейса.",
|
||||||
|
"ok": "В текущем окружении активных проблем не обнаружено.",
|
||||||
|
"warning": "Обнаружено {count} проблем(ы). Большинство можно исправить прямо из этой панели.",
|
||||||
|
"error": "Перед тем как приложение станет полностью исправным, нужно устранить {count} проблем(ы)."
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"ok": "Исправно",
|
||||||
|
"warning": "Требует внимания",
|
||||||
|
"error": "Требуется действие"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"runAgain": "Запустить снова",
|
||||||
|
"exportBundle": "Экспортировать пакет"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"loadFailed": "Не удалось загрузить диагностику: {message}",
|
||||||
|
"repairSuccess": "Перестройка кэша завершена.",
|
||||||
|
"repairFailed": "Не удалось перестроить кэш: {message}",
|
||||||
|
"exportSuccess": "Диагностический пакет экспортирован.",
|
||||||
|
"exportFailed": "Не удалось экспортировать диагностический пакет: {message}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"versionMismatch": {
|
"versionMismatch": {
|
||||||
"title": "Обнаружено обновление приложения",
|
"title": "Обнаружено обновление приложения",
|
||||||
|
|||||||
@@ -263,7 +263,9 @@
|
|||||||
"videoSettings": "视频设置",
|
"videoSettings": "视频设置",
|
||||||
"layoutSettings": "布局设置",
|
"layoutSettings": "布局设置",
|
||||||
"misc": "其他",
|
"misc": "其他",
|
||||||
|
"backup": "备份",
|
||||||
"folderSettings": "默认根目录",
|
"folderSettings": "默认根目录",
|
||||||
|
"recipeSettings": "配方",
|
||||||
"extraFolderPaths": "额外文件夹路径",
|
"extraFolderPaths": "额外文件夹路径",
|
||||||
"downloadPathTemplates": "下载路径模板",
|
"downloadPathTemplates": "下载路径模板",
|
||||||
"priorityTags": "优先标签",
|
"priorityTags": "优先标签",
|
||||||
@@ -323,6 +325,32 @@
|
|||||||
"saveFailed": "无法保存跳过路径:{message}"
|
"saveFailed": "无法保存跳过路径:{message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"backup": {
|
||||||
|
"autoEnabled": "自动备份",
|
||||||
|
"autoEnabledHelp": "每天创建一次本地快照,并按保留策略保留最新快照。",
|
||||||
|
"retention": "保留数量",
|
||||||
|
"retentionHelp": "在删除旧快照之前,要保留多少个自动快照。",
|
||||||
|
"management": "备份管理",
|
||||||
|
"managementHelp": "导出当前用户状态,或从备份归档中恢复。",
|
||||||
|
"scopeHelp": "备份你的设置、下载历史和模型更新状态。不包含模型文件或可重建的缓存。",
|
||||||
|
"locationSummary": "当前备份位置",
|
||||||
|
"openFolderButton": "打开备份文件夹",
|
||||||
|
"openFolderSuccess": "已打开备份文件夹",
|
||||||
|
"openFolderFailed": "无法打开备份文件夹",
|
||||||
|
"locationCopied": "备份路径已复制到剪贴板:{{path}}",
|
||||||
|
"locationClipboardFallback": "备份路径:{{path}}",
|
||||||
|
"exportButton": "导出备份",
|
||||||
|
"exportSuccess": "备份导出成功。",
|
||||||
|
"exportFailed": "备份导出失败:{message}",
|
||||||
|
"importButton": "导入备份",
|
||||||
|
"importConfirm": "导入此备份并覆盖本地用户状态吗?",
|
||||||
|
"importSuccess": "备份导入成功。",
|
||||||
|
"importFailed": "备份导入失败:{message}",
|
||||||
|
"latestSnapshot": "最近快照",
|
||||||
|
"latestAutoSnapshot": "最近自动快照",
|
||||||
|
"snapshotCount": "已保存快照",
|
||||||
|
"noneAvailable": "还没有快照"
|
||||||
|
},
|
||||||
"downloadSkipBaseModels": {
|
"downloadSkipBaseModels": {
|
||||||
"label": "跳过这些基础模型的下载",
|
"label": "跳过这些基础模型的下载",
|
||||||
"help": "适用于所有下载流程。这里只能选择受支持的基础模型。",
|
"help": "适用于所有下载流程。这里只能选择受支持的基础模型。",
|
||||||
@@ -393,6 +421,10 @@
|
|||||||
"defaultUnetRootHelp": "设置下载、导入和移动时的默认 Diffusion Model (UNET) 根目录",
|
"defaultUnetRootHelp": "设置下载、导入和移动时的默认 Diffusion Model (UNET) 根目录",
|
||||||
"defaultEmbeddingRoot": "Embedding 根目录",
|
"defaultEmbeddingRoot": "Embedding 根目录",
|
||||||
"defaultEmbeddingRootHelp": "设置下载、导入和移动时的默认 Embedding 根目录",
|
"defaultEmbeddingRootHelp": "设置下载、导入和移动时的默认 Embedding 根目录",
|
||||||
|
"recipesPath": "配方存储路径",
|
||||||
|
"recipesPathHelp": "已保存配方的可选自定义目录。留空则使用第一个 LoRA 根目录下的 recipes 文件夹。",
|
||||||
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
|
"recipesPathMigrating": "正在迁移配方存储...",
|
||||||
"noDefault": "无默认"
|
"noDefault": "无默认"
|
||||||
},
|
},
|
||||||
"extraFolderPaths": {
|
"extraFolderPaths": {
|
||||||
@@ -1629,6 +1661,8 @@
|
|||||||
"mappingSaveFailed": "保存基础模型映射失败:{message}",
|
"mappingSaveFailed": "保存基础模型映射失败:{message}",
|
||||||
"downloadTemplatesUpdated": "下载路径模板已更新",
|
"downloadTemplatesUpdated": "下载路径模板已更新",
|
||||||
"downloadTemplatesFailed": "保存下载路径模板失败:{message}",
|
"downloadTemplatesFailed": "保存下载路径模板失败:{message}",
|
||||||
|
"recipesPathUpdated": "配方存储路径已更新",
|
||||||
|
"recipesPathSaveFailed": "更新配方存储路径失败:{message}",
|
||||||
"settingsUpdated": "设置已更新:{setting}",
|
"settingsUpdated": "设置已更新:{setting}",
|
||||||
"compactModeToggled": "紧凑模式 {state}",
|
"compactModeToggled": "紧凑模式 {state}",
|
||||||
"settingSaveFailed": "保存设置失败:{message}",
|
"settingSaveFailed": "保存设置失败:{message}",
|
||||||
@@ -1772,6 +1806,35 @@
|
|||||||
"moveFailed": "Failed to move item: {message}"
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"doctor": {
|
||||||
|
"kicker": "系统诊断",
|
||||||
|
"title": "医生",
|
||||||
|
"buttonTitle": "运行诊断并尝试修复常见问题",
|
||||||
|
"loading": "正在检查当前环境...",
|
||||||
|
"footer": "如果修复后问题仍然存在,可以导出诊断包进一步排查。",
|
||||||
|
"summary": {
|
||||||
|
"idle": "检查设置、缓存健康状况和前后端 UI 版本是否一致。",
|
||||||
|
"ok": "当前环境未发现活动问题。",
|
||||||
|
"warning": "发现 {count} 个问题,大多数可以直接在这里处理。",
|
||||||
|
"error": "发现 {count} 个需要尽快处理的问题。"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"ok": "健康",
|
||||||
|
"warning": "需要关注",
|
||||||
|
"error": "需要处理"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"runAgain": "重新检查",
|
||||||
|
"exportBundle": "导出诊断包"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"loadFailed": "加载诊断结果失败:{message}",
|
||||||
|
"repairSuccess": "缓存重建完成。",
|
||||||
|
"repairFailed": "缓存重建失败:{message}",
|
||||||
|
"exportSuccess": "诊断包已导出。",
|
||||||
|
"exportFailed": "导出诊断包失败:{message}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"versionMismatch": {
|
"versionMismatch": {
|
||||||
"title": "检测到应用更新",
|
"title": "检测到应用更新",
|
||||||
|
|||||||
@@ -263,7 +263,9 @@
|
|||||||
"videoSettings": "影片設定",
|
"videoSettings": "影片設定",
|
||||||
"layoutSettings": "版面設定",
|
"layoutSettings": "版面設定",
|
||||||
"misc": "其他",
|
"misc": "其他",
|
||||||
|
"backup": "備份",
|
||||||
"folderSettings": "預設根目錄",
|
"folderSettings": "預設根目錄",
|
||||||
|
"recipeSettings": "配方",
|
||||||
"extraFolderPaths": "額外資料夾路徑",
|
"extraFolderPaths": "額外資料夾路徑",
|
||||||
"downloadPathTemplates": "下載路徑範本",
|
"downloadPathTemplates": "下載路徑範本",
|
||||||
"priorityTags": "優先標籤",
|
"priorityTags": "優先標籤",
|
||||||
@@ -323,6 +325,32 @@
|
|||||||
"saveFailed": "無法儲存跳過路徑:{message}"
|
"saveFailed": "無法儲存跳過路徑:{message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"backup": {
|
||||||
|
"autoEnabled": "自動備份",
|
||||||
|
"autoEnabledHelp": "每天建立一次本地快照,並依保留政策保留最新快照。",
|
||||||
|
"retention": "保留數量",
|
||||||
|
"retentionHelp": "在刪除舊快照之前,要保留多少自動快照。",
|
||||||
|
"management": "備份管理",
|
||||||
|
"managementHelp": "匯出目前的使用者狀態,或從備份封存中還原。",
|
||||||
|
"scopeHelp": "備份你的設定、下載歷史與模型更新狀態。不包含模型檔案或可重建的快取。",
|
||||||
|
"locationSummary": "目前備份位置",
|
||||||
|
"openFolderButton": "開啟備份資料夾",
|
||||||
|
"openFolderSuccess": "已開啟備份資料夾",
|
||||||
|
"openFolderFailed": "無法開啟備份資料夾",
|
||||||
|
"locationCopied": "備份路徑已複製到剪貼簿:{{path}}",
|
||||||
|
"locationClipboardFallback": "備份路徑:{{path}}",
|
||||||
|
"exportButton": "匯出備份",
|
||||||
|
"exportSuccess": "備份匯出成功。",
|
||||||
|
"exportFailed": "備份匯出失敗:{message}",
|
||||||
|
"importButton": "匯入備份",
|
||||||
|
"importConfirm": "要匯入此備份並覆寫本機使用者狀態嗎?",
|
||||||
|
"importSuccess": "備份匯入成功。",
|
||||||
|
"importFailed": "備份匯入失敗:{message}",
|
||||||
|
"latestSnapshot": "最近快照",
|
||||||
|
"latestAutoSnapshot": "最近自動快照",
|
||||||
|
"snapshotCount": "已儲存快照",
|
||||||
|
"noneAvailable": "目前還沒有快照"
|
||||||
|
},
|
||||||
"downloadSkipBaseModels": {
|
"downloadSkipBaseModels": {
|
||||||
"label": "跳過這些基礎模型的下載",
|
"label": "跳過這些基礎模型的下載",
|
||||||
"help": "適用於所有下載流程。這裡只能選擇受支援的基礎模型。",
|
"help": "適用於所有下載流程。這裡只能選擇受支援的基礎模型。",
|
||||||
@@ -393,6 +421,10 @@
|
|||||||
"defaultUnetRootHelp": "設定下載、匯入和移動時的預設 Diffusion Model (UNET) 根目錄",
|
"defaultUnetRootHelp": "設定下載、匯入和移動時的預設 Diffusion Model (UNET) 根目錄",
|
||||||
"defaultEmbeddingRoot": "Embedding 根目錄",
|
"defaultEmbeddingRoot": "Embedding 根目錄",
|
||||||
"defaultEmbeddingRootHelp": "設定下載、匯入和移動時的預設 Embedding 根目錄",
|
"defaultEmbeddingRootHelp": "設定下載、匯入和移動時的預設 Embedding 根目錄",
|
||||||
|
"recipesPath": "配方儲存路徑",
|
||||||
|
"recipesPathHelp": "已儲存配方的可選自訂目錄。留空則使用第一個 LoRA 根目錄下的 recipes 資料夾。",
|
||||||
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
|
"recipesPathMigrating": "正在遷移配方儲存...",
|
||||||
"noDefault": "未設定預設"
|
"noDefault": "未設定預設"
|
||||||
},
|
},
|
||||||
"extraFolderPaths": {
|
"extraFolderPaths": {
|
||||||
@@ -1629,6 +1661,8 @@
|
|||||||
"mappingSaveFailed": "儲存基礎模型對應失敗:{message}",
|
"mappingSaveFailed": "儲存基礎模型對應失敗:{message}",
|
||||||
"downloadTemplatesUpdated": "下載路徑範本已更新",
|
"downloadTemplatesUpdated": "下載路徑範本已更新",
|
||||||
"downloadTemplatesFailed": "儲存下載路徑範本失敗:{message}",
|
"downloadTemplatesFailed": "儲存下載路徑範本失敗:{message}",
|
||||||
|
"recipesPathUpdated": "配方儲存路徑已更新",
|
||||||
|
"recipesPathSaveFailed": "更新配方儲存路徑失敗:{message}",
|
||||||
"settingsUpdated": "設定已更新:{setting}",
|
"settingsUpdated": "設定已更新:{setting}",
|
||||||
"compactModeToggled": "緊湊模式已{state}",
|
"compactModeToggled": "緊湊模式已{state}",
|
||||||
"settingSaveFailed": "儲存設定失敗:{message}",
|
"settingSaveFailed": "儲存設定失敗:{message}",
|
||||||
@@ -1772,6 +1806,35 @@
|
|||||||
"moveFailed": "Failed to move item: {message}"
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"doctor": {
|
||||||
|
"kicker": "系統診斷",
|
||||||
|
"title": "醫生",
|
||||||
|
"buttonTitle": "執行診斷與常見修復",
|
||||||
|
"loading": "正在檢查環境...",
|
||||||
|
"footer": "如果修復後問題仍然存在,請匯出診斷套件。",
|
||||||
|
"summary": {
|
||||||
|
"idle": "針對設定、快取完整性與 UI 一致性執行健康檢查。",
|
||||||
|
"ok": "目前環境中未發現任何活動中的問題。",
|
||||||
|
"warning": "找到 {count} 個問題。大多可以直接在此面板修復。",
|
||||||
|
"error": "應先處理 {count} 個問題,應用程式才能完全正常。"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"ok": "健康",
|
||||||
|
"warning": "需要注意",
|
||||||
|
"error": "需要處理"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"runAgain": "重新執行",
|
||||||
|
"exportBundle": "匯出套件"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"loadFailed": "載入診斷失敗:{message}",
|
||||||
|
"repairSuccess": "快取重建完成。",
|
||||||
|
"repairFailed": "快取重建失敗:{message}",
|
||||||
|
"exportSuccess": "診斷套件已匯出。",
|
||||||
|
"exportFailed": "匯出診斷套件失敗:{message}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"versionMismatch": {
|
"versionMismatch": {
|
||||||
"title": "偵測到應用程式更新",
|
"title": "偵測到應用程式更新",
|
||||||
|
|||||||
12
py/config.py
12
py/config.py
@@ -134,6 +134,7 @@ class Config:
|
|||||||
self.extra_checkpoints_roots: List[str] = []
|
self.extra_checkpoints_roots: List[str] = []
|
||||||
self.extra_unet_roots: List[str] = []
|
self.extra_unet_roots: List[str] = []
|
||||||
self.extra_embeddings_roots: List[str] = []
|
self.extra_embeddings_roots: List[str] = []
|
||||||
|
self.recipes_path: str = ""
|
||||||
# Scan symbolic links during initialization
|
# Scan symbolic links during initialization
|
||||||
self._initialize_symlink_mappings()
|
self._initialize_symlink_mappings()
|
||||||
|
|
||||||
@@ -652,6 +653,8 @@ class Config:
|
|||||||
preview_roots.update(self._expand_preview_root(root))
|
preview_roots.update(self._expand_preview_root(root))
|
||||||
for root in self.extra_embeddings_roots or []:
|
for root in self.extra_embeddings_roots or []:
|
||||||
preview_roots.update(self._expand_preview_root(root))
|
preview_roots.update(self._expand_preview_root(root))
|
||||||
|
if self.recipes_path:
|
||||||
|
preview_roots.update(self._expand_preview_root(self.recipes_path))
|
||||||
|
|
||||||
for target, link in self._path_mappings.items():
|
for target, link in self._path_mappings.items():
|
||||||
preview_roots.update(self._expand_preview_root(target))
|
preview_roots.update(self._expand_preview_root(target))
|
||||||
@@ -911,9 +914,11 @@ class Config:
|
|||||||
self,
|
self,
|
||||||
folder_paths: Mapping[str, Iterable[str]],
|
folder_paths: Mapping[str, Iterable[str]],
|
||||||
extra_folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
|
extra_folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
|
||||||
|
recipes_path: str = "",
|
||||||
) -> None:
|
) -> None:
|
||||||
self._path_mappings.clear()
|
self._path_mappings.clear()
|
||||||
self._preview_root_paths = set()
|
self._preview_root_paths = set()
|
||||||
|
self.recipes_path = recipes_path if isinstance(recipes_path, str) else ""
|
||||||
|
|
||||||
lora_paths = folder_paths.get("loras", []) or []
|
lora_paths = folder_paths.get("loras", []) or []
|
||||||
checkpoint_paths = folder_paths.get("checkpoints", []) or []
|
checkpoint_paths = folder_paths.get("checkpoints", []) or []
|
||||||
@@ -1169,7 +1174,12 @@ class Config:
|
|||||||
if not isinstance(extra_folder_paths, Mapping):
|
if not isinstance(extra_folder_paths, Mapping):
|
||||||
extra_folder_paths = None
|
extra_folder_paths = None
|
||||||
|
|
||||||
self._apply_library_paths(folder_paths, extra_folder_paths)
|
recipes_path = (
|
||||||
|
str(library_config.get("recipes_path", ""))
|
||||||
|
if isinstance(library_config, Mapping)
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
self._apply_library_paths(folder_paths, extra_folder_paths, recipes_path)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Applied library settings with %d lora roots (%d extra), %d checkpoint roots (%d extra), and %d embedding roots (%d extra)",
|
"Applied library settings with %d lora roots (%d extra), %d checkpoint roots (%d extra), and %d embedding roots (%d extra)",
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ class LoraManager:
|
|||||||
|
|
||||||
# Register DownloadManager with ServiceRegistry
|
# Register DownloadManager with ServiceRegistry
|
||||||
await ServiceRegistry.get_download_manager()
|
await ServiceRegistry.get_download_manager()
|
||||||
|
await ServiceRegistry.get_backup_service()
|
||||||
|
|
||||||
from .services.metadata_service import initialize_metadata_providers
|
from .services.metadata_service import initialize_metadata_providers
|
||||||
|
|
||||||
|
|||||||
@@ -595,6 +595,15 @@ class MetadataProcessor:
|
|||||||
if negative_node_id and negative_node_id in metadata.get(PROMPTS, {}):
|
if negative_node_id and negative_node_id in metadata.get(PROMPTS, {}):
|
||||||
params["negative_prompt"] = metadata[PROMPTS][negative_node_id].get("text", "")
|
params["negative_prompt"] = metadata[PROMPTS][negative_node_id].get("text", "")
|
||||||
else:
|
else:
|
||||||
|
# Generic guider nodes often expose separate positive/negative inputs.
|
||||||
|
positive_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "positive", max_depth=10)
|
||||||
|
if not positive_node_id:
|
||||||
positive_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "conditioning", max_depth=10)
|
positive_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "conditioning", max_depth=10)
|
||||||
if positive_node_id and positive_node_id in metadata.get(PROMPTS, {}):
|
if positive_node_id and positive_node_id in metadata.get(PROMPTS, {}):
|
||||||
params["prompt"] = metadata[PROMPTS][positive_node_id].get("text", "")
|
params["prompt"] = metadata[PROMPTS][positive_node_id].get("text", "")
|
||||||
|
|
||||||
|
negative_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "negative", max_depth=10)
|
||||||
|
if not negative_node_id:
|
||||||
|
negative_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "conditioning", max_depth=10)
|
||||||
|
if negative_node_id and negative_node_id in metadata.get(PROMPTS, {}):
|
||||||
|
params["negative_prompt"] = metadata[PROMPTS][negative_node_id].get("text", "")
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IMAGES, IS_SAMPLER
|
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IMAGES, IS_SAMPLER
|
||||||
|
|
||||||
@@ -427,6 +429,75 @@ class ImageSizeExtractor(NodeMetadataExtractor):
|
|||||||
"node_id": node_id
|
"node_id": node_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class RgthreePowerLoraLoaderExtractor(NodeMetadataExtractor):
|
||||||
|
"""Extract LoRA metadata from rgthree Power Lora Loader.
|
||||||
|
|
||||||
|
The node passes LoRAs as dynamic kwargs: LORA_1, LORA_2, ... each containing
|
||||||
|
{'on': bool, 'lora': filename, 'strength': float, 'strengthTwo': float}.
|
||||||
|
"""
|
||||||
|
@staticmethod
|
||||||
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
|
if not inputs:
|
||||||
|
return
|
||||||
|
|
||||||
|
active_loras = []
|
||||||
|
for key, value in inputs.items():
|
||||||
|
if not key.upper().startswith('LORA_'):
|
||||||
|
continue
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
continue
|
||||||
|
if not value.get('on') or not value.get('lora'):
|
||||||
|
continue
|
||||||
|
lora_name = os.path.splitext(os.path.basename(value['lora']))[0]
|
||||||
|
active_loras.append({
|
||||||
|
"name": lora_name,
|
||||||
|
"strength": round(float(value.get('strength', 1.0)), 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
if active_loras:
|
||||||
|
metadata[LORAS][node_id] = {
|
||||||
|
"lora_list": active_loras,
|
||||||
|
"node_id": node_id
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TensorRTLoaderExtractor(NodeMetadataExtractor):
|
||||||
|
"""Extract checkpoint metadata from TensorRT Loader.
|
||||||
|
|
||||||
|
extract() parses the engine filename from 'unet_name' as a best-effort
|
||||||
|
fallback (strips profile suffix after '_$' and counter suffix).
|
||||||
|
|
||||||
|
update() checks if the output MODEL has attachments["source_model"]
|
||||||
|
set by the node (NubeBuster fork) and overrides with the real name.
|
||||||
|
Vanilla TRT doesn't set this — the filename parse stands.
|
||||||
|
"""
|
||||||
|
@staticmethod
|
||||||
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
|
if not inputs or "unet_name" not in inputs:
|
||||||
|
return
|
||||||
|
unet_name = inputs.get("unet_name")
|
||||||
|
# Strip path and extension, then drop the $_profile suffix
|
||||||
|
model_name = os.path.splitext(os.path.basename(unet_name))[0]
|
||||||
|
if "_$" in model_name:
|
||||||
|
model_name = model_name[:model_name.index("_$")]
|
||||||
|
# Strip counter suffix (e.g. _00001_) left by ComfyUI's save path
|
||||||
|
model_name = re.sub(r'_\d+_?$', '', model_name)
|
||||||
|
_store_checkpoint_metadata(metadata, node_id, model_name)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update(node_id, outputs, metadata):
|
||||||
|
if not outputs or not isinstance(outputs, list) or len(outputs) == 0:
|
||||||
|
return
|
||||||
|
first_output = outputs[0]
|
||||||
|
if not isinstance(first_output, tuple) or len(first_output) < 1:
|
||||||
|
return
|
||||||
|
model = first_output[0]
|
||||||
|
# NubeBuster fork sets attachments["source_model"] on the ModelPatcher
|
||||||
|
source_model = getattr(model, 'attachments', {}).get("source_model")
|
||||||
|
if source_model:
|
||||||
|
_store_checkpoint_metadata(metadata, node_id, source_model)
|
||||||
|
|
||||||
|
|
||||||
class LoraLoaderManagerExtractor(NodeMetadataExtractor):
|
class LoraLoaderManagerExtractor(NodeMetadataExtractor):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def extract(node_id, inputs, outputs, metadata):
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
@@ -577,8 +648,6 @@ class SamplerCustomAdvancedExtractor(BaseSamplerExtractor):
|
|||||||
# Extract latent dimensions
|
# Extract latent dimensions
|
||||||
BaseSamplerExtractor.extract_latent_dimensions(node_id, inputs, metadata)
|
BaseSamplerExtractor.extract_latent_dimensions(node_id, inputs, metadata)
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
class CLIPTextEncodeFluxExtractor(NodeMetadataExtractor):
|
class CLIPTextEncodeFluxExtractor(NodeMetadataExtractor):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def extract(node_id, inputs, outputs, metadata):
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
@@ -715,8 +784,11 @@ NODE_EXTRACTORS = {
|
|||||||
"UnetLoaderGGUF": UNETLoaderExtractor, # Updated to use dedicated extractor
|
"UnetLoaderGGUF": UNETLoaderExtractor, # Updated to use dedicated extractor
|
||||||
"LoraLoader": LoraLoaderExtractor,
|
"LoraLoader": LoraLoaderExtractor,
|
||||||
"LoraLoaderLM": LoraLoaderManagerExtractor,
|
"LoraLoaderLM": LoraLoaderManagerExtractor,
|
||||||
|
"RgthreePowerLoraLoader": RgthreePowerLoraLoaderExtractor,
|
||||||
|
"TensorRTLoader": TensorRTLoaderExtractor,
|
||||||
# Conditioning
|
# Conditioning
|
||||||
"CLIPTextEncode": CLIPTextEncodeExtractor,
|
"CLIPTextEncode": CLIPTextEncodeExtractor,
|
||||||
|
"CLIPTextEncodeAttentionBias": CLIPTextEncodeExtractor, # From https://github.com/silveroxides/ComfyUI_PromptAttention
|
||||||
"PromptLM": CLIPTextEncodeExtractor,
|
"PromptLM": CLIPTextEncodeExtractor,
|
||||||
"CLIPTextEncodeFlux": CLIPTextEncodeFluxExtractor, # Add CLIPTextEncodeFlux
|
"CLIPTextEncodeFlux": CLIPTextEncodeFluxExtractor, # Add CLIPTextEncodeFlux
|
||||||
"WAS_Text_to_Conditioning": CLIPTextEncodeExtractor,
|
"WAS_Text_to_Conditioning": CLIPTextEncodeExtractor,
|
||||||
|
|||||||
@@ -9,13 +9,20 @@ objects that can be composed by the route controller.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import platform
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Awaitable, Callable, Dict, Mapping, Protocol
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Awaitable, Callable, Dict, Mapping, Protocol, Sequence
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
@@ -29,6 +36,7 @@ from ...services.settings_manager import get_settings_manager
|
|||||||
from ...services.websocket_manager import ws_manager
|
from ...services.websocket_manager import ws_manager
|
||||||
from ...services.downloader import get_downloader
|
from ...services.downloader import get_downloader
|
||||||
from ...services.errors import ResourceNotFoundError
|
from ...services.errors import ResourceNotFoundError
|
||||||
|
from ...services.cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
|
||||||
from ...utils.constants import (
|
from ...utils.constants import (
|
||||||
CIVITAI_USER_MODEL_TYPES,
|
CIVITAI_USER_MODEL_TYPES,
|
||||||
DEFAULT_NODE_COLOR,
|
DEFAULT_NODE_COLOR,
|
||||||
@@ -39,12 +47,321 @@ from ...utils.constants import (
|
|||||||
from ...utils.civitai_utils import rewrite_preview_url
|
from ...utils.civitai_utils import rewrite_preview_url
|
||||||
from ...utils.example_images_paths import is_valid_example_images_root
|
from ...utils.example_images_paths import is_valid_example_images_root
|
||||||
from ...utils.lora_metadata import extract_trained_words
|
from ...utils.lora_metadata import extract_trained_words
|
||||||
|
from ...utils.session_logging import get_standalone_session_log_snapshot
|
||||||
from ...utils.usage_stats import UsageStats
|
from ...utils.usage_stats import UsageStats
|
||||||
from .base_model_handlers import BaseModelHandlerSet
|
from .base_model_handlers import BaseModelHandlerSet
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_project_root() -> str:
|
||||||
|
current_file = os.path.abspath(__file__)
|
||||||
|
return os.path.dirname(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.dirname(current_file)))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_app_version_string() -> str:
|
||||||
|
version = "1.0.0"
|
||||||
|
short_hash = "stable"
|
||||||
|
try:
|
||||||
|
import toml
|
||||||
|
|
||||||
|
root_dir = _get_project_root()
|
||||||
|
pyproject_path = os.path.join(root_dir, "pyproject.toml")
|
||||||
|
|
||||||
|
if os.path.exists(pyproject_path):
|
||||||
|
with open(pyproject_path, "r", encoding="utf-8") as handle:
|
||||||
|
data = toml.load(handle)
|
||||||
|
version = (
|
||||||
|
data.get("project", {}).get("version", "1.0.0").replace("v", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
git_dir = os.path.join(root_dir, ".git")
|
||||||
|
if os.path.exists(git_dir):
|
||||||
|
try:
|
||||||
|
import git
|
||||||
|
|
||||||
|
repo = git.Repo(root_dir)
|
||||||
|
short_hash = repo.head.commit.hexsha[:7]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.debug("Failed to resolve app version for doctor diagnostics: %s", exc)
|
||||||
|
|
||||||
|
return f"{version}-{short_hash}"
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_sensitive_data(payload: Any) -> Any:
|
||||||
|
sensitive_markers = (
|
||||||
|
"api_key",
|
||||||
|
"apikey",
|
||||||
|
"token",
|
||||||
|
"password",
|
||||||
|
"secret",
|
||||||
|
"authorization",
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
sanitized: dict[str, Any] = {}
|
||||||
|
for key, value in payload.items():
|
||||||
|
normalized_key = str(key).lower()
|
||||||
|
if any(marker in normalized_key for marker in sensitive_markers):
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
sanitized[key] = f"{value[:4]}***{value[-2:]}" if len(value) > 6 else "***"
|
||||||
|
else:
|
||||||
|
sanitized[key] = "***"
|
||||||
|
else:
|
||||||
|
sanitized[key] = _sanitize_sensitive_data(value)
|
||||||
|
return sanitized
|
||||||
|
|
||||||
|
if isinstance(payload, list):
|
||||||
|
return [_sanitize_sensitive_data(item) for item in payload]
|
||||||
|
|
||||||
|
if isinstance(payload, str):
|
||||||
|
return _sanitize_sensitive_text(payload)
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_sensitive_text(value: str) -> str:
|
||||||
|
if not value:
|
||||||
|
return value
|
||||||
|
|
||||||
|
redacted = value
|
||||||
|
patterns = (
|
||||||
|
(
|
||||||
|
r'(?i)("authorization"\s*:\s*")Bearer\s+([^"]+)(")',
|
||||||
|
r'\1Bearer ***\3',
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r'(?i)("x[-_]?api[-_]?key"\s*:\s*")([^"]+)(")',
|
||||||
|
r'\1***\3',
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r'(?i)("api[_-]?key"\s*:\s*")([^"]+)(")',
|
||||||
|
r'\1***\3',
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r'(?i)("token"\s*:\s*")([^"]+)(")',
|
||||||
|
r'\1***\3',
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r'(?i)("password"\s*:\s*")([^"]+)(")',
|
||||||
|
r'\1***\3',
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r'(?i)("secret"\s*:\s*")([^"]+)(")',
|
||||||
|
r'\1***\3',
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"(?i)\b(authorization\s*[:=]\s*bearer\s+)([A-Za-z0-9._\-+/=]+)",
|
||||||
|
r"\1***",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"(?i)\b(x[-_]?api[-_]?key\s*[:=]\s*)([^\s,;]+)",
|
||||||
|
r"\1***",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"(?i)\b(api[_-]?key\s*[:=]\s*)([^\s,;]+)",
|
||||||
|
r"\1***",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"(?i)\b(token\s*[:=]\s*)([^\s,;]+)",
|
||||||
|
r"\1***",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"(?i)\b(password\s*[:=]\s*)([^\s,;]+)",
|
||||||
|
r"\1***",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"(?i)\b(secret\s*[:=]\s*)([^\s,;]+)",
|
||||||
|
r"\1***",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
for pattern, replacement in patterns:
|
||||||
|
redacted = re.sub(pattern, replacement, redacted)
|
||||||
|
|
||||||
|
return redacted
|
||||||
|
|
||||||
|
|
||||||
|
def _read_log_file_tail(path: str, max_bytes: int = 64 * 1024) -> str:
|
||||||
|
if not path or not os.path.isfile(path):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
with open(path, "rb") as handle:
|
||||||
|
handle.seek(0, os.SEEK_END)
|
||||||
|
file_size = handle.tell()
|
||||||
|
handle.seek(max(file_size - max_bytes, 0))
|
||||||
|
payload = handle.read()
|
||||||
|
|
||||||
|
return payload.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_text_file_head(path: str, max_bytes: int = 8 * 1024) -> str:
|
||||||
|
if not path or not os.path.isfile(path):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
with open(path, "rb") as handle:
|
||||||
|
payload = handle.read(max_bytes)
|
||||||
|
|
||||||
|
return payload.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_startup_marker(text: str, label: str) -> str | None:
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
|
||||||
|
pattern = re.compile(rf"{re.escape(label)}\s*:\s*([^\r\n]+)")
|
||||||
|
match = pattern.search(text)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return match.group(1).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _format_comfyui_log_entries(entries: Sequence[Mapping[str, Any]] | None) -> str:
|
||||||
|
if not entries:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
rendered: list[str] = []
|
||||||
|
for entry in entries:
|
||||||
|
timestamp = str(entry.get("t", "")).strip()
|
||||||
|
message = str(entry.get("m", ""))
|
||||||
|
if not message:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if timestamp:
|
||||||
|
rendered.append(f"{timestamp} - {message}")
|
||||||
|
else:
|
||||||
|
rendered.append(message)
|
||||||
|
|
||||||
|
if not rendered:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
text = "".join(rendered)
|
||||||
|
if text.endswith("\n"):
|
||||||
|
return text
|
||||||
|
return f"{text}\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_embedded_comfyui_log_path() -> str:
|
||||||
|
return os.path.abspath(
|
||||||
|
os.path.join(_get_project_root(), "..", "..", "user", "comfyui.log")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_comfyui_session_logs(
|
||||||
|
*,
|
||||||
|
log_entries: Sequence[Mapping[str, Any]] | None = None,
|
||||||
|
log_file_path: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if log_entries is None:
|
||||||
|
try:
|
||||||
|
import app.logger as comfy_logger
|
||||||
|
|
||||||
|
log_entries = list(comfy_logger.get_logs() or [])
|
||||||
|
except Exception as exc: # pragma: no cover - environment dependent
|
||||||
|
logger.debug("Failed to read ComfyUI in-memory logs: %s", exc)
|
||||||
|
log_entries = []
|
||||||
|
|
||||||
|
session_log_text = _format_comfyui_log_entries(log_entries)
|
||||||
|
session_started_at = _extract_startup_marker(
|
||||||
|
session_log_text, "** ComfyUI startup time"
|
||||||
|
)
|
||||||
|
if not session_started_at and log_entries:
|
||||||
|
session_started_at = str(log_entries[0].get("t", "")).strip() or None
|
||||||
|
|
||||||
|
resolved_log_path = os.path.abspath(log_file_path or _get_embedded_comfyui_log_path())
|
||||||
|
persisted_log_text = ""
|
||||||
|
notes: list[str] = []
|
||||||
|
|
||||||
|
if os.path.isfile(resolved_log_path):
|
||||||
|
head_text = _read_text_file_head(resolved_log_path)
|
||||||
|
file_started_at = _extract_startup_marker(head_text, "** ComfyUI startup time")
|
||||||
|
if session_started_at and file_started_at and file_started_at == session_started_at:
|
||||||
|
persisted_log_text = _read_log_file_tail(resolved_log_path)
|
||||||
|
elif session_started_at and file_started_at and file_started_at != session_started_at:
|
||||||
|
notes.append(
|
||||||
|
"Persistent ComfyUI log file does not match the current process session."
|
||||||
|
)
|
||||||
|
elif not session_started_at and file_started_at:
|
||||||
|
persisted_log_text = _read_log_file_tail(resolved_log_path)
|
||||||
|
session_started_at = file_started_at
|
||||||
|
else:
|
||||||
|
notes.append(
|
||||||
|
"Persistent ComfyUI log file is missing a startup marker and was not trusted as the current session log."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
notes.append("Persistent ComfyUI log file was not found.")
|
||||||
|
|
||||||
|
source_method = "comfyui_in_memory"
|
||||||
|
if persisted_log_text:
|
||||||
|
source_method = "comfyui_in_memory+current_log_file"
|
||||||
|
elif not session_log_text:
|
||||||
|
source_method = "unavailable"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mode": "comfyui",
|
||||||
|
"session_started_at": session_started_at,
|
||||||
|
"session_log_text": session_log_text,
|
||||||
|
"persistent_log_path": resolved_log_path,
|
||||||
|
"persistent_log_text": persisted_log_text,
|
||||||
|
"source_method": source_method,
|
||||||
|
"notes": notes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_standalone_session_logs(
|
||||||
|
*, snapshot: Mapping[str, Any] | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
snapshot = snapshot or get_standalone_session_log_snapshot()
|
||||||
|
|
||||||
|
if not snapshot:
|
||||||
|
return {
|
||||||
|
"mode": "standalone",
|
||||||
|
"session_started_at": None,
|
||||||
|
"session_log_text": "",
|
||||||
|
"persistent_log_path": None,
|
||||||
|
"persistent_log_text": "",
|
||||||
|
"source_method": "unavailable",
|
||||||
|
"session_id": None,
|
||||||
|
"notes": ["Standalone session logging was not initialized."],
|
||||||
|
}
|
||||||
|
|
||||||
|
log_file_path = snapshot.get("log_file_path")
|
||||||
|
persisted_log_text = _read_log_file_tail(log_file_path) if log_file_path else ""
|
||||||
|
session_log_text = str(snapshot.get("in_memory_text") or "")
|
||||||
|
source_method = "standalone_memory"
|
||||||
|
if persisted_log_text:
|
||||||
|
source_method = "standalone_session_file"
|
||||||
|
elif session_log_text:
|
||||||
|
source_method = "standalone_memory"
|
||||||
|
else:
|
||||||
|
source_method = "unavailable"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mode": "standalone",
|
||||||
|
"session_started_at": snapshot.get("started_at"),
|
||||||
|
"session_log_text": session_log_text,
|
||||||
|
"persistent_log_path": log_file_path,
|
||||||
|
"persistent_log_text": persisted_log_text,
|
||||||
|
"source_method": source_method,
|
||||||
|
"session_id": snapshot.get("session_id"),
|
||||||
|
"notes": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_backend_session_logs() -> dict[str, Any]:
|
||||||
|
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"
|
||||||
|
if standalone_mode:
|
||||||
|
return _collect_standalone_session_logs()
|
||||||
|
return _collect_comfyui_session_logs()
|
||||||
|
|
||||||
|
|
||||||
def _is_wsl() -> bool:
|
def _is_wsl() -> bool:
|
||||||
"""Check if running in WSL environment."""
|
"""Check if running in WSL environment."""
|
||||||
try:
|
try:
|
||||||
@@ -130,6 +447,22 @@ class MetadataArchiveManagerProtocol(Protocol):
|
|||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class BackupServiceProtocol(Protocol):
|
||||||
|
async def create_snapshot(
|
||||||
|
self, *, snapshot_type: str = "manual", persist: bool = False
|
||||||
|
) -> dict: # pragma: no cover - protocol
|
||||||
|
...
|
||||||
|
|
||||||
|
async def restore_snapshot(self, archive_path: str) -> dict: # pragma: no cover - protocol
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_status(self) -> dict: # pragma: no cover - protocol
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_available_snapshots(self) -> list[dict]: # pragma: no cover - protocol
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
class NodeRegistry:
|
class NodeRegistry:
|
||||||
"""Thread-safe registry for tracking LoRA nodes in active workflows."""
|
"""Thread-safe registry for tracking LoRA nodes in active workflows."""
|
||||||
|
|
||||||
@@ -253,6 +586,388 @@ class SupportersHandler:
|
|||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
class DoctorHandler:
|
||||||
|
"""Run environment diagnostics and export a support bundle."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
settings_service=None,
|
||||||
|
civitai_client_factory: Callable[[], Awaitable[Any]] = ServiceRegistry.get_civitai_client,
|
||||||
|
scanner_factories: Sequence[tuple[str, str, Callable[[], Awaitable[Any]]]] | None = None,
|
||||||
|
app_version_getter: Callable[[], str] = _get_app_version_string,
|
||||||
|
) -> None:
|
||||||
|
self._settings = settings_service or get_settings_manager()
|
||||||
|
self._civitai_client_factory = civitai_client_factory
|
||||||
|
self._scanner_factories = tuple(
|
||||||
|
scanner_factories
|
||||||
|
or (
|
||||||
|
("lora", "LoRAs", ServiceRegistry.get_lora_scanner),
|
||||||
|
("checkpoint", "Checkpoints", ServiceRegistry.get_checkpoint_scanner),
|
||||||
|
("embedding", "Embeddings", ServiceRegistry.get_embedding_scanner),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._app_version_getter = app_version_getter
|
||||||
|
|
||||||
|
async def get_doctor_diagnostics(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
client_version = (request.query.get("clientVersion") or "").strip()
|
||||||
|
app_version = self._app_version_getter()
|
||||||
|
diagnostics = [
|
||||||
|
await self._check_civitai_api_key(),
|
||||||
|
await self._check_cache_health(),
|
||||||
|
self._check_ui_version(client_version, app_version),
|
||||||
|
]
|
||||||
|
|
||||||
|
issue_count = sum(
|
||||||
|
1 for item in diagnostics if item.get("status") in {"warning", "error"}
|
||||||
|
)
|
||||||
|
error_count = sum(1 for item in diagnostics if item.get("status") == "error")
|
||||||
|
warning_count = sum(
|
||||||
|
1 for item in diagnostics if item.get("status") == "warning"
|
||||||
|
)
|
||||||
|
|
||||||
|
overall_status = "ok"
|
||||||
|
if error_count:
|
||||||
|
overall_status = "error"
|
||||||
|
elif warning_count:
|
||||||
|
overall_status = "warning"
|
||||||
|
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"app_version": app_version,
|
||||||
|
"summary": {
|
||||||
|
"status": overall_status,
|
||||||
|
"issue_count": issue_count,
|
||||||
|
"warning_count": warning_count,
|
||||||
|
"error_count": error_count,
|
||||||
|
"checked_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
},
|
||||||
|
"diagnostics": diagnostics,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error("Error building doctor diagnostics: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def repair_doctor_cache(self, request: web.Request) -> web.Response:
|
||||||
|
repaired: list[dict[str, Any]] = []
|
||||||
|
failures: list[dict[str, str]] = []
|
||||||
|
|
||||||
|
for model_type, label, factory in self._scanner_factories:
|
||||||
|
try:
|
||||||
|
scanner = await factory()
|
||||||
|
await scanner.get_cached_data(force_refresh=True, rebuild_cache=True)
|
||||||
|
repaired.append({"model_type": model_type, "label": label})
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error("Doctor cache rebuild failed for %s: %s", model_type, exc, exc_info=True)
|
||||||
|
failures.append(
|
||||||
|
{
|
||||||
|
"model_type": model_type,
|
||||||
|
"label": label,
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
status = 200 if not failures else 500
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": not failures,
|
||||||
|
"repaired": repaired,
|
||||||
|
"failures": failures,
|
||||||
|
},
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def export_doctor_bundle(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except Exception:
|
||||||
|
payload = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
archive_bytes = self._build_support_bundle(payload)
|
||||||
|
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/zip",
|
||||||
|
"Content-Disposition": f'attachment; filename="lora-manager-doctor-{timestamp}.zip"',
|
||||||
|
}
|
||||||
|
return web.Response(body=archive_bytes, headers=headers)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error("Error exporting doctor bundle: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def _check_civitai_api_key(self) -> dict[str, Any]:
|
||||||
|
api_key = (self._settings.get("civitai_api_key", "") or "").strip()
|
||||||
|
if not api_key:
|
||||||
|
return {
|
||||||
|
"id": "civitai_api_key",
|
||||||
|
"title": "Civitai API Key",
|
||||||
|
"status": "warning",
|
||||||
|
"summary": "Civitai API key is not configured.",
|
||||||
|
"details": [
|
||||||
|
"Downloads and authenticated Civitai requests may fail until a valid API key is saved."
|
||||||
|
],
|
||||||
|
"actions": [{"id": "open-settings", "label": "Open Settings"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
obvious_placeholders = {"your_api_key", "changeme", "placeholder", "none"}
|
||||||
|
if api_key.lower() in obvious_placeholders:
|
||||||
|
return {
|
||||||
|
"id": "civitai_api_key",
|
||||||
|
"title": "Civitai API Key",
|
||||||
|
"status": "error",
|
||||||
|
"summary": "Civitai API key looks like a placeholder value.",
|
||||||
|
"details": ["Replace the placeholder with a real key from your Civitai account settings."],
|
||||||
|
"actions": [{"id": "open-settings", "label": "Open Settings"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = await self._civitai_client_factory()
|
||||||
|
success, result = await client._make_request( # noqa: SLF001 - internal diagnostic probe
|
||||||
|
"GET",
|
||||||
|
f"{client.base_url}/models",
|
||||||
|
use_auth=True,
|
||||||
|
params={"limit": 1},
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
return {
|
||||||
|
"id": "civitai_api_key",
|
||||||
|
"title": "Civitai API Key",
|
||||||
|
"status": "ok",
|
||||||
|
"summary": "Civitai API key is configured and accepted.",
|
||||||
|
"details": [],
|
||||||
|
"actions": [{"id": "open-settings", "label": "Open Settings"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
error_text = str(result)
|
||||||
|
lowered = error_text.lower()
|
||||||
|
if any(token in lowered for token in ("401", "403", "unauthorized", "forbidden", "invalid")):
|
||||||
|
return {
|
||||||
|
"id": "civitai_api_key",
|
||||||
|
"title": "Civitai API Key",
|
||||||
|
"status": "error",
|
||||||
|
"summary": "Configured Civitai API key was rejected.",
|
||||||
|
"details": [error_text],
|
||||||
|
"actions": [{"id": "open-settings", "label": "Open Settings"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": "civitai_api_key",
|
||||||
|
"title": "Civitai API Key",
|
||||||
|
"status": "warning",
|
||||||
|
"summary": "Unable to confirm whether the Civitai API key is valid.",
|
||||||
|
"details": [error_text],
|
||||||
|
"actions": [{"id": "open-settings", "label": "Open Settings"}],
|
||||||
|
}
|
||||||
|
except Exception as exc: # pragma: no cover - network/path dependent
|
||||||
|
logger.warning("Doctor API key validation failed: %s", exc)
|
||||||
|
return {
|
||||||
|
"id": "civitai_api_key",
|
||||||
|
"title": "Civitai API Key",
|
||||||
|
"status": "warning",
|
||||||
|
"summary": "Could not validate the Civitai API key right now.",
|
||||||
|
"details": [str(exc)],
|
||||||
|
"actions": [{"id": "open-settings", "label": "Open Settings"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _check_cache_health(self) -> dict[str, Any]:
|
||||||
|
details: list[dict[str, Any]] = []
|
||||||
|
overall_status = "ok"
|
||||||
|
summary = "All model caches look healthy."
|
||||||
|
|
||||||
|
for model_type, label, factory in self._scanner_factories:
|
||||||
|
try:
|
||||||
|
scanner = await factory()
|
||||||
|
persisted = None
|
||||||
|
persistent_cache = getattr(scanner, "_persistent_cache", None)
|
||||||
|
if persistent_cache and hasattr(persistent_cache, "load_cache"):
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
persisted = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
persistent_cache.load_cache,
|
||||||
|
getattr(scanner, "model_type", model_type),
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_data = list(getattr(persisted, "raw_data", None) or [])
|
||||||
|
if not raw_data:
|
||||||
|
cache = await scanner.get_cached_data(force_refresh=False)
|
||||||
|
raw_data = list(getattr(cache, "raw_data", None) or [])
|
||||||
|
|
||||||
|
report = CacheHealthMonitor().check_health(raw_data, auto_repair=False)
|
||||||
|
report_status = "ok"
|
||||||
|
if report.status == CacheHealthStatus.CORRUPTED:
|
||||||
|
report_status = "error"
|
||||||
|
elif report.status != CacheHealthStatus.HEALTHY:
|
||||||
|
report_status = "warning"
|
||||||
|
|
||||||
|
details.append(
|
||||||
|
{
|
||||||
|
"model_type": model_type,
|
||||||
|
"label": label,
|
||||||
|
"status": report_status,
|
||||||
|
"message": report.message,
|
||||||
|
"total_entries": report.total_entries,
|
||||||
|
"invalid_entries": report.invalid_entries,
|
||||||
|
"repaired_entries": report.repaired_entries,
|
||||||
|
"corruption_rate": f"{report.corruption_rate:.1%}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if report_status == "error":
|
||||||
|
overall_status = "error"
|
||||||
|
elif report_status == "warning" and overall_status == "ok":
|
||||||
|
overall_status = "warning"
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error("Doctor cache health check failed for %s: %s", model_type, exc, exc_info=True)
|
||||||
|
details.append(
|
||||||
|
{
|
||||||
|
"model_type": model_type,
|
||||||
|
"label": label,
|
||||||
|
"status": "error",
|
||||||
|
"message": str(exc),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
overall_status = "error"
|
||||||
|
|
||||||
|
if overall_status == "warning":
|
||||||
|
summary = "One or more model caches contain invalid entries."
|
||||||
|
elif overall_status == "error":
|
||||||
|
summary = "One or more model caches are corrupted or unavailable."
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": "cache_health",
|
||||||
|
"title": "Model Cache Health",
|
||||||
|
"status": overall_status,
|
||||||
|
"summary": summary,
|
||||||
|
"details": details,
|
||||||
|
"actions": [{"id": "repair-cache", "label": "Rebuild Cache"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _check_ui_version(self, client_version: str, app_version: str) -> dict[str, Any]:
|
||||||
|
if client_version and client_version != app_version:
|
||||||
|
return {
|
||||||
|
"id": "ui_version",
|
||||||
|
"title": "UI Version",
|
||||||
|
"status": "warning",
|
||||||
|
"summary": "Browser is running an older UI bundle than the backend expects.",
|
||||||
|
"details": [
|
||||||
|
{
|
||||||
|
"client_version": client_version,
|
||||||
|
"server_version": app_version,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [{"id": "reload-page", "label": "Reload UI"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": "ui_version",
|
||||||
|
"title": "UI Version",
|
||||||
|
"status": "ok",
|
||||||
|
"summary": "Browser UI bundle matches the backend version.",
|
||||||
|
"details": [
|
||||||
|
{
|
||||||
|
"client_version": client_version or app_version,
|
||||||
|
"server_version": app_version,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [{"id": "reload-page", "label": "Reload UI"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _collect_backend_session_logs(self) -> dict[str, Any]:
|
||||||
|
return _collect_backend_session_logs()
|
||||||
|
|
||||||
|
def _build_support_bundle(self, payload: dict[str, Any]) -> bytes:
|
||||||
|
diagnostics = payload.get("diagnostics") or []
|
||||||
|
frontend_logs = payload.get("frontend_logs") or []
|
||||||
|
client_context = payload.get("client_context") or {}
|
||||||
|
|
||||||
|
app_version = self._app_version_getter()
|
||||||
|
settings_snapshot = _sanitize_sensitive_data(
|
||||||
|
getattr(self._settings, "settings", {}) or {}
|
||||||
|
)
|
||||||
|
startup_messages_getter = getattr(self._settings, "get_startup_messages", None)
|
||||||
|
startup_messages = (
|
||||||
|
list(startup_messages_getter()) if callable(startup_messages_getter) else []
|
||||||
|
)
|
||||||
|
|
||||||
|
environment = {
|
||||||
|
"app_version": app_version,
|
||||||
|
"python_version": sys.version,
|
||||||
|
"platform": platform.platform(),
|
||||||
|
"cwd": os.getcwd(),
|
||||||
|
"standalone_mode": os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1",
|
||||||
|
"settings_file": getattr(self._settings, "settings_file", None),
|
||||||
|
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"client_context": client_context,
|
||||||
|
}
|
||||||
|
backend_logs = self._collect_backend_session_logs()
|
||||||
|
backend_session_text = _sanitize_sensitive_text(
|
||||||
|
str(backend_logs.get("session_log_text") or "")
|
||||||
|
)
|
||||||
|
backend_persisted_text = _sanitize_sensitive_text(
|
||||||
|
str(backend_logs.get("persistent_log_text") or "")
|
||||||
|
)
|
||||||
|
if not backend_session_text and backend_persisted_text:
|
||||||
|
backend_session_text = backend_persisted_text
|
||||||
|
if not backend_session_text:
|
||||||
|
backend_session_text = "No current backend session logs were available.\n"
|
||||||
|
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
archive.writestr(
|
||||||
|
"doctor-report.json",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"app_version": app_version,
|
||||||
|
"diagnostics": diagnostics,
|
||||||
|
"summary": payload.get("summary"),
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
archive.writestr(
|
||||||
|
"settings-sanitized.json",
|
||||||
|
json.dumps(settings_snapshot, indent=2, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
archive.writestr(
|
||||||
|
"startup-messages.json",
|
||||||
|
json.dumps(startup_messages, indent=2, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
archive.writestr(
|
||||||
|
"environment.json",
|
||||||
|
json.dumps(environment, indent=2, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
archive.writestr(
|
||||||
|
"frontend-console.json",
|
||||||
|
json.dumps(_sanitize_sensitive_data(frontend_logs), indent=2, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
archive.writestr("backend-logs.txt", backend_session_text)
|
||||||
|
archive.writestr(
|
||||||
|
"backend-log-source.json",
|
||||||
|
json.dumps(
|
||||||
|
_sanitize_sensitive_data(
|
||||||
|
{
|
||||||
|
"mode": backend_logs.get("mode"),
|
||||||
|
"source_method": backend_logs.get("source_method"),
|
||||||
|
"session_started_at": backend_logs.get("session_started_at"),
|
||||||
|
"session_id": backend_logs.get("session_id"),
|
||||||
|
"persistent_log_path": backend_logs.get("persistent_log_path"),
|
||||||
|
"notes": backend_logs.get("notes") or [],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
indent=2,
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if backend_persisted_text:
|
||||||
|
archive.writestr("backend-session-file-tail.txt", backend_persisted_text)
|
||||||
|
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
class ExampleWorkflowsHandler:
|
class ExampleWorkflowsHandler:
|
||||||
"""Handler for example workflow templates."""
|
"""Handler for example workflow templates."""
|
||||||
|
|
||||||
@@ -746,12 +1461,17 @@ class ModelExampleFilesHandler:
|
|||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
async def _noop_backup_service() -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ServiceRegistryAdapter:
|
class ServiceRegistryAdapter:
|
||||||
get_lora_scanner: Callable[[], Awaitable]
|
get_lora_scanner: Callable[[], Awaitable]
|
||||||
get_checkpoint_scanner: Callable[[], Awaitable]
|
get_checkpoint_scanner: Callable[[], Awaitable]
|
||||||
get_embedding_scanner: Callable[[], Awaitable]
|
get_embedding_scanner: Callable[[], Awaitable]
|
||||||
get_downloaded_version_history_service: Callable[[], Awaitable]
|
get_downloaded_version_history_service: Callable[[], Awaitable]
|
||||||
|
get_backup_service: Callable[[], Awaitable] = _noop_backup_service
|
||||||
|
|
||||||
|
|
||||||
class ModelLibraryHandler:
|
class ModelLibraryHandler:
|
||||||
@@ -1418,10 +2138,150 @@ class MetadataArchiveHandler:
|
|||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
class BackupHandler:
|
||||||
|
"""Handler for user-state backup export/import."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
backup_service_factory: Callable[[], Awaitable[BackupServiceProtocol]] = ServiceRegistry.get_backup_service,
|
||||||
|
) -> None:
|
||||||
|
self._backup_service_factory = backup_service_factory
|
||||||
|
|
||||||
|
async def get_backup_status(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
service = await self._backup_service_factory()
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"status": service.get_status(),
|
||||||
|
"snapshots": service.get_available_snapshots(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error("Error getting backup status: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def export_backup(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
service = await self._backup_service_factory()
|
||||||
|
result = await service.create_snapshot(snapshot_type="manual", persist=False)
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/zip",
|
||||||
|
"Content-Disposition": f'attachment; filename="{result["archive_name"]}"',
|
||||||
|
}
|
||||||
|
return web.Response(body=result["archive_bytes"], headers=headers)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error("Error exporting backup: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def import_backup(self, request: web.Request) -> web.Response:
|
||||||
|
temp_path: str | None = None
|
||||||
|
try:
|
||||||
|
fd, temp_path = tempfile.mkstemp(
|
||||||
|
suffix=".zip", prefix="lora-manager-backup-"
|
||||||
|
)
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
if request.content_type.startswith("multipart/"):
|
||||||
|
reader = await request.multipart()
|
||||||
|
field = await reader.next()
|
||||||
|
uploaded = False
|
||||||
|
while field is not None:
|
||||||
|
if getattr(field, "filename", None):
|
||||||
|
with open(temp_path, "wb") as handle:
|
||||||
|
while True:
|
||||||
|
chunk = await field.read_chunk()
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
handle.write(chunk)
|
||||||
|
uploaded = True
|
||||||
|
break
|
||||||
|
field = await reader.next()
|
||||||
|
if not uploaded:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Missing backup archive"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
body = await request.read()
|
||||||
|
if not body:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Missing backup archive"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
with open(temp_path, "wb") as handle:
|
||||||
|
handle.write(body)
|
||||||
|
|
||||||
|
if not temp_path or not os.path.exists(temp_path) or os.path.getsize(temp_path) == 0:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Missing backup archive"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
service = await self._backup_service_factory()
|
||||||
|
result = await service.restore_snapshot(temp_path)
|
||||||
|
return web.json_response({"success": True, **result})
|
||||||
|
except (ValueError, zipfile.BadZipFile) as exc:
|
||||||
|
logger.error("Error importing backup: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error("Error importing backup: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
finally:
|
||||||
|
if temp_path and os.path.exists(temp_path):
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
os.remove(temp_path)
|
||||||
|
|
||||||
|
|
||||||
class FileSystemHandler:
|
class FileSystemHandler:
|
||||||
def __init__(self, settings_service=None) -> None:
|
def __init__(self, settings_service=None) -> None:
|
||||||
self._settings = settings_service or get_settings_manager()
|
self._settings = settings_service or get_settings_manager()
|
||||||
|
|
||||||
|
async def _open_path(self, path: str) -> web.Response:
|
||||||
|
path = os.path.abspath(path)
|
||||||
|
if not os.path.isdir(path):
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Folder does not exist"},
|
||||||
|
status=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
if os.name == "nt":
|
||||||
|
subprocess.Popen(["explorer", path])
|
||||||
|
elif os.name == "posix":
|
||||||
|
if _is_docker():
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"message": "Running in Docker: Path available for copying",
|
||||||
|
"path": path,
|
||||||
|
"mode": "clipboard",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if _is_wsl():
|
||||||
|
windows_path = _wsl_to_windows_path(path)
|
||||||
|
if windows_path:
|
||||||
|
subprocess.Popen(["explorer.exe", windows_path])
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
"Failed to convert WSL path to Windows path: %s", path
|
||||||
|
)
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": "Failed to open folder location: path conversion error",
|
||||||
|
},
|
||||||
|
status=500,
|
||||||
|
)
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
subprocess.Popen(["open", path])
|
||||||
|
else:
|
||||||
|
subprocess.Popen(["xdg-open", path])
|
||||||
|
|
||||||
|
return web.json_response(
|
||||||
|
{"success": True, "message": f"Opened folder: {path}", "path": path}
|
||||||
|
)
|
||||||
|
|
||||||
async def open_file_location(self, request: web.Request) -> web.Response:
|
async def open_file_location(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
@@ -1536,6 +2396,20 @@ class FileSystemHandler:
|
|||||||
logger.error("Failed to open settings location: %s", exc, exc_info=True)
|
logger.error("Failed to open settings location: %s", exc, exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def open_backup_location(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
settings_file = getattr(self._settings, "settings_file", None)
|
||||||
|
if not settings_file:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Settings file not found"}, status=404
|
||||||
|
)
|
||||||
|
|
||||||
|
backup_dir = os.path.join(os.path.dirname(os.path.abspath(settings_file)), "backups")
|
||||||
|
return await self._open_path(backup_dir)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error("Failed to open backup location: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
class CustomWordsHandler:
|
class CustomWordsHandler:
|
||||||
"""Handler for autocomplete via TagFTSIndex."""
|
"""Handler for autocomplete via TagFTSIndex."""
|
||||||
@@ -1840,9 +2714,11 @@ class MiscHandlerSet:
|
|||||||
node_registry: NodeRegistryHandler,
|
node_registry: NodeRegistryHandler,
|
||||||
model_library: ModelLibraryHandler,
|
model_library: ModelLibraryHandler,
|
||||||
metadata_archive: MetadataArchiveHandler,
|
metadata_archive: MetadataArchiveHandler,
|
||||||
|
backup: BackupHandler,
|
||||||
filesystem: FileSystemHandler,
|
filesystem: FileSystemHandler,
|
||||||
custom_words: CustomWordsHandler,
|
custom_words: CustomWordsHandler,
|
||||||
supporters: SupportersHandler,
|
supporters: SupportersHandler,
|
||||||
|
doctor: DoctorHandler,
|
||||||
example_workflows: ExampleWorkflowsHandler,
|
example_workflows: ExampleWorkflowsHandler,
|
||||||
base_model: BaseModelHandlerSet,
|
base_model: BaseModelHandlerSet,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1855,9 +2731,11 @@ class MiscHandlerSet:
|
|||||||
self.node_registry = node_registry
|
self.node_registry = node_registry
|
||||||
self.model_library = model_library
|
self.model_library = model_library
|
||||||
self.metadata_archive = metadata_archive
|
self.metadata_archive = metadata_archive
|
||||||
|
self.backup = backup
|
||||||
self.filesystem = filesystem
|
self.filesystem = filesystem
|
||||||
self.custom_words = custom_words
|
self.custom_words = custom_words
|
||||||
self.supporters = supporters
|
self.supporters = supporters
|
||||||
|
self.doctor = doctor
|
||||||
self.example_workflows = example_workflows
|
self.example_workflows = example_workflows
|
||||||
self.base_model = base_model
|
self.base_model = base_model
|
||||||
|
|
||||||
@@ -1868,6 +2746,9 @@ class MiscHandlerSet:
|
|||||||
"health_check": self.health.health_check,
|
"health_check": self.health.health_check,
|
||||||
"get_settings": self.settings.get_settings,
|
"get_settings": self.settings.get_settings,
|
||||||
"update_settings": self.settings.update_settings,
|
"update_settings": self.settings.update_settings,
|
||||||
|
"get_doctor_diagnostics": self.doctor.get_doctor_diagnostics,
|
||||||
|
"repair_doctor_cache": self.doctor.repair_doctor_cache,
|
||||||
|
"export_doctor_bundle": self.doctor.export_doctor_bundle,
|
||||||
"get_priority_tags": self.settings.get_priority_tags,
|
"get_priority_tags": self.settings.get_priority_tags,
|
||||||
"get_settings_libraries": self.settings.get_libraries,
|
"get_settings_libraries": self.settings.get_libraries,
|
||||||
"activate_library": self.settings.activate_library,
|
"activate_library": self.settings.activate_library,
|
||||||
@@ -1886,9 +2767,13 @@ class MiscHandlerSet:
|
|||||||
"download_metadata_archive": self.metadata_archive.download_metadata_archive,
|
"download_metadata_archive": self.metadata_archive.download_metadata_archive,
|
||||||
"remove_metadata_archive": self.metadata_archive.remove_metadata_archive,
|
"remove_metadata_archive": self.metadata_archive.remove_metadata_archive,
|
||||||
"get_metadata_archive_status": self.metadata_archive.get_metadata_archive_status,
|
"get_metadata_archive_status": self.metadata_archive.get_metadata_archive_status,
|
||||||
|
"get_backup_status": self.backup.get_backup_status,
|
||||||
|
"export_backup": self.backup.export_backup,
|
||||||
|
"import_backup": self.backup.import_backup,
|
||||||
"get_model_versions_status": self.model_library.get_model_versions_status,
|
"get_model_versions_status": self.model_library.get_model_versions_status,
|
||||||
"open_file_location": self.filesystem.open_file_location,
|
"open_file_location": self.filesystem.open_file_location,
|
||||||
"open_settings_location": self.filesystem.open_settings_location,
|
"open_settings_location": self.filesystem.open_settings_location,
|
||||||
|
"open_backup_location": self.filesystem.open_backup_location,
|
||||||
"search_custom_words": self.custom_words.search_custom_words,
|
"search_custom_words": self.custom_words.search_custom_words,
|
||||||
"get_supporters": self.supporters.get_supporters,
|
"get_supporters": self.supporters.get_supporters,
|
||||||
"get_example_workflows": self.example_workflows.get_example_workflows,
|
"get_example_workflows": self.example_workflows.get_example_workflows,
|
||||||
@@ -1907,4 +2792,5 @@ def build_service_registry_adapter() -> ServiceRegistryAdapter:
|
|||||||
get_checkpoint_scanner=ServiceRegistry.get_checkpoint_scanner,
|
get_checkpoint_scanner=ServiceRegistry.get_checkpoint_scanner,
|
||||||
get_embedding_scanner=ServiceRegistry.get_embedding_scanner,
|
get_embedding_scanner=ServiceRegistry.get_embedding_scanner,
|
||||||
get_downloaded_version_history_service=ServiceRegistry.get_downloaded_version_history_service,
|
get_downloaded_version_history_service=ServiceRegistry.get_downloaded_version_history_service,
|
||||||
|
get_backup_service=ServiceRegistry.get_backup_service,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ class RouteDefinition:
|
|||||||
MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||||
RouteDefinition("GET", "/api/lm/settings", "get_settings"),
|
RouteDefinition("GET", "/api/lm/settings", "get_settings"),
|
||||||
RouteDefinition("POST", "/api/lm/settings", "update_settings"),
|
RouteDefinition("POST", "/api/lm/settings", "update_settings"),
|
||||||
|
RouteDefinition("GET", "/api/lm/doctor/diagnostics", "get_doctor_diagnostics"),
|
||||||
|
RouteDefinition("POST", "/api/lm/doctor/repair-cache", "repair_doctor_cache"),
|
||||||
|
RouteDefinition("POST", "/api/lm/doctor/export-bundle", "export_doctor_bundle"),
|
||||||
RouteDefinition("GET", "/api/lm/priority-tags", "get_priority_tags"),
|
RouteDefinition("GET", "/api/lm/priority-tags", "get_priority_tags"),
|
||||||
RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"),
|
RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"),
|
||||||
RouteDefinition("POST", "/api/lm/settings/libraries/activate", "activate_library"),
|
RouteDefinition("POST", "/api/lm/settings/libraries/activate", "activate_library"),
|
||||||
@@ -62,6 +65,10 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition(
|
RouteDefinition(
|
||||||
"GET", "/api/lm/metadata-archive-status", "get_metadata_archive_status"
|
"GET", "/api/lm/metadata-archive-status", "get_metadata_archive_status"
|
||||||
),
|
),
|
||||||
|
RouteDefinition("GET", "/api/lm/backup/status", "get_backup_status"),
|
||||||
|
RouteDefinition("POST", "/api/lm/backup/export", "export_backup"),
|
||||||
|
RouteDefinition("POST", "/api/lm/backup/import", "import_backup"),
|
||||||
|
RouteDefinition("POST", "/api/lm/backup/open-location", "open_backup_location"),
|
||||||
RouteDefinition(
|
RouteDefinition(
|
||||||
"GET", "/api/lm/model-versions-status", "get_model_versions_status"
|
"GET", "/api/lm/model-versions-status", "get_model_versions_status"
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ from ..services.downloader import get_downloader
|
|||||||
from ..utils.usage_stats import UsageStats
|
from ..utils.usage_stats import UsageStats
|
||||||
from .handlers.misc_handlers import (
|
from .handlers.misc_handlers import (
|
||||||
CustomWordsHandler,
|
CustomWordsHandler,
|
||||||
|
DoctorHandler,
|
||||||
ExampleWorkflowsHandler,
|
ExampleWorkflowsHandler,
|
||||||
FileSystemHandler,
|
FileSystemHandler,
|
||||||
HealthCheckHandler,
|
HealthCheckHandler,
|
||||||
LoraCodeHandler,
|
LoraCodeHandler,
|
||||||
|
BackupHandler,
|
||||||
MetadataArchiveHandler,
|
MetadataArchiveHandler,
|
||||||
MiscHandlerSet,
|
MiscHandlerSet,
|
||||||
ModelExampleFilesHandler,
|
ModelExampleFilesHandler,
|
||||||
@@ -116,6 +118,7 @@ class MiscRoutes:
|
|||||||
settings_service=self._settings,
|
settings_service=self._settings,
|
||||||
metadata_provider_updater=self._metadata_provider_updater,
|
metadata_provider_updater=self._metadata_provider_updater,
|
||||||
)
|
)
|
||||||
|
backup = BackupHandler()
|
||||||
filesystem = FileSystemHandler(settings_service=self._settings)
|
filesystem = FileSystemHandler(settings_service=self._settings)
|
||||||
node_registry_handler = NodeRegistryHandler(
|
node_registry_handler = NodeRegistryHandler(
|
||||||
node_registry=self._node_registry,
|
node_registry=self._node_registry,
|
||||||
@@ -128,6 +131,7 @@ class MiscRoutes:
|
|||||||
)
|
)
|
||||||
custom_words = CustomWordsHandler()
|
custom_words = CustomWordsHandler()
|
||||||
supporters = SupportersHandler()
|
supporters = SupportersHandler()
|
||||||
|
doctor = DoctorHandler(settings_service=self._settings)
|
||||||
example_workflows = ExampleWorkflowsHandler()
|
example_workflows = ExampleWorkflowsHandler()
|
||||||
base_model = BaseModelHandlerSet()
|
base_model = BaseModelHandlerSet()
|
||||||
|
|
||||||
@@ -141,9 +145,11 @@ class MiscRoutes:
|
|||||||
node_registry=node_registry_handler,
|
node_registry=node_registry_handler,
|
||||||
model_library=model_library,
|
model_library=model_library,
|
||||||
metadata_archive=metadata_archive,
|
metadata_archive=metadata_archive,
|
||||||
|
backup=backup,
|
||||||
filesystem=filesystem,
|
filesystem=filesystem,
|
||||||
custom_words=custom_words,
|
custom_words=custom_words,
|
||||||
supporters=supporters,
|
supporters=supporters,
|
||||||
|
doctor=doctor,
|
||||||
example_workflows=example_workflows,
|
example_workflows=example_workflows,
|
||||||
base_model=base_model,
|
base_model=base_model,
|
||||||
)
|
)
|
||||||
|
|||||||
411
py/services/backup_service.py
Normal file
411
py/services/backup_service.py
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import zipfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterable, Optional
|
||||||
|
|
||||||
|
from ..utils.cache_paths import CacheType, get_cache_base_dir, get_cache_file_path
|
||||||
|
from ..utils.settings_paths import get_settings_dir
|
||||||
|
from .settings_manager import get_settings_manager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
BACKUP_MANIFEST_VERSION = 1
|
||||||
|
DEFAULT_BACKUP_RETENTION_COUNT = 5
|
||||||
|
DEFAULT_BACKUP_INTERVAL_SECONDS = 24 * 60 * 60
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BackupEntry:
|
||||||
|
kind: str
|
||||||
|
archive_path: str
|
||||||
|
target_path: str
|
||||||
|
sha256: str
|
||||||
|
size: int
|
||||||
|
mtime: float
|
||||||
|
|
||||||
|
|
||||||
|
class BackupService:
|
||||||
|
"""Create and restore user-state backup archives."""
|
||||||
|
|
||||||
|
_instance: "BackupService | None" = None
|
||||||
|
_instance_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
def __init__(self, *, settings_manager=None, backup_dir: str | None = None) -> None:
|
||||||
|
self._settings = settings_manager or get_settings_manager()
|
||||||
|
self._backup_dir = Path(backup_dir or self._resolve_backup_dir())
|
||||||
|
self._backup_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
self._auto_task: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_instance(cls) -> "BackupService":
|
||||||
|
async with cls._instance_lock:
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = cls()
|
||||||
|
cls._instance._ensure_auto_snapshot_task()
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_backup_dir() -> str:
|
||||||
|
return os.path.join(get_settings_dir(create=True), "backups")
|
||||||
|
|
||||||
|
def get_backup_dir(self) -> str:
|
||||||
|
return str(self._backup_dir)
|
||||||
|
|
||||||
|
def _ensure_auto_snapshot_task(self) -> None:
|
||||||
|
if self._auto_task is not None and not self._auto_task.done():
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._auto_task = loop.create_task(self._auto_backup_loop())
|
||||||
|
|
||||||
|
def _get_setting_bool(self, key: str, default: bool) -> bool:
|
||||||
|
try:
|
||||||
|
return bool(self._settings.get(key, default))
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
def _get_setting_int(self, key: str, default: int) -> int:
|
||||||
|
try:
|
||||||
|
value = self._settings.get(key, default)
|
||||||
|
return max(1, int(value))
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
def _settings_file_path(self) -> str:
|
||||||
|
settings_file = getattr(self._settings, "settings_file", None)
|
||||||
|
if settings_file:
|
||||||
|
return str(settings_file)
|
||||||
|
return os.path.join(get_settings_dir(create=True), "settings.json")
|
||||||
|
|
||||||
|
def _download_history_path(self) -> str:
|
||||||
|
base_dir = get_cache_base_dir(create=True)
|
||||||
|
history_dir = os.path.join(base_dir, "download_history")
|
||||||
|
os.makedirs(history_dir, exist_ok=True)
|
||||||
|
return os.path.join(history_dir, "downloaded_versions.sqlite")
|
||||||
|
|
||||||
|
def _model_update_dir(self) -> str:
|
||||||
|
return str(Path(get_cache_file_path(CacheType.MODEL_UPDATE, create_dir=True)).parent)
|
||||||
|
|
||||||
|
def _model_update_targets(self) -> list[tuple[str, str, str]]:
|
||||||
|
"""Return (kind, archive_path, target_path) tuples for backup."""
|
||||||
|
|
||||||
|
targets: list[tuple[str, str, str]] = []
|
||||||
|
|
||||||
|
settings_path = self._settings_file_path()
|
||||||
|
targets.append(("settings", "settings/settings.json", settings_path))
|
||||||
|
|
||||||
|
history_path = self._download_history_path()
|
||||||
|
targets.append(
|
||||||
|
(
|
||||||
|
"download_history",
|
||||||
|
"cache/download_history/downloaded_versions.sqlite",
|
||||||
|
history_path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
symlink_path = get_cache_file_path(CacheType.SYMLINK, create_dir=True)
|
||||||
|
targets.append(
|
||||||
|
(
|
||||||
|
"symlink_map",
|
||||||
|
"cache/symlink/symlink_map.json",
|
||||||
|
symlink_path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
model_update_dir = Path(self._model_update_dir())
|
||||||
|
if model_update_dir.exists():
|
||||||
|
for sqlite_file in sorted(model_update_dir.glob("*.sqlite")):
|
||||||
|
targets.append(
|
||||||
|
(
|
||||||
|
"model_update",
|
||||||
|
f"cache/model_update/{sqlite_file.name}",
|
||||||
|
str(sqlite_file),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return targets
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _hash_file(path: str) -> tuple[str, int, float]:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
total = 0
|
||||||
|
with open(path, "rb") as handle:
|
||||||
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||||
|
total += len(chunk)
|
||||||
|
digest.update(chunk)
|
||||||
|
mtime = os.path.getmtime(path)
|
||||||
|
return digest.hexdigest(), total, mtime
|
||||||
|
|
||||||
|
def _build_manifest(self, entries: Iterable[BackupEntry], *, snapshot_type: str) -> dict[str, Any]:
|
||||||
|
created_at = datetime.now(timezone.utc).isoformat()
|
||||||
|
active_library = None
|
||||||
|
try:
|
||||||
|
active_library = self._settings.get_active_library_name()
|
||||||
|
except Exception:
|
||||||
|
active_library = None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"manifest_version": BACKUP_MANIFEST_VERSION,
|
||||||
|
"created_at": created_at,
|
||||||
|
"snapshot_type": snapshot_type,
|
||||||
|
"active_library": active_library,
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"kind": entry.kind,
|
||||||
|
"archive_path": entry.archive_path,
|
||||||
|
"target_path": entry.target_path,
|
||||||
|
"sha256": entry.sha256,
|
||||||
|
"size": entry.size,
|
||||||
|
"mtime": entry.mtime,
|
||||||
|
}
|
||||||
|
for entry in entries
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _write_archive(self, archive_path: str, entries: list[BackupEntry], manifest: dict[str, Any]) -> None:
|
||||||
|
with zipfile.ZipFile(
|
||||||
|
archive_path,
|
||||||
|
mode="w",
|
||||||
|
compression=zipfile.ZIP_DEFLATED,
|
||||||
|
compresslevel=6,
|
||||||
|
) as zf:
|
||||||
|
zf.writestr(
|
||||||
|
"manifest.json",
|
||||||
|
json.dumps(manifest, indent=2, ensure_ascii=False).encode("utf-8"),
|
||||||
|
)
|
||||||
|
for entry in entries:
|
||||||
|
zf.write(entry.target_path, arcname=entry.archive_path)
|
||||||
|
|
||||||
|
async def create_snapshot(self, *, snapshot_type: str = "manual", persist: bool = False) -> dict[str, Any]:
|
||||||
|
"""Create a backup archive.
|
||||||
|
|
||||||
|
If ``persist`` is true, the archive is stored in the backup directory
|
||||||
|
and retained according to the configured retention policy.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
raw_targets = self._model_update_targets()
|
||||||
|
entries: list[BackupEntry] = []
|
||||||
|
for kind, archive_path, target_path in raw_targets:
|
||||||
|
if not os.path.exists(target_path):
|
||||||
|
continue
|
||||||
|
sha256, size, mtime = self._hash_file(target_path)
|
||||||
|
entries.append(
|
||||||
|
BackupEntry(
|
||||||
|
kind=kind,
|
||||||
|
archive_path=archive_path,
|
||||||
|
target_path=target_path,
|
||||||
|
sha256=sha256,
|
||||||
|
size=size,
|
||||||
|
mtime=mtime,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not entries:
|
||||||
|
raise FileNotFoundError("No backupable files were found")
|
||||||
|
|
||||||
|
manifest = self._build_manifest(entries, snapshot_type=snapshot_type)
|
||||||
|
archive_name = self._build_archive_name(snapshot_type=snapshot_type)
|
||||||
|
fd, temp_path = tempfile.mkstemp(suffix=".zip", dir=str(self._backup_dir))
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._write_archive(temp_path, entries, manifest)
|
||||||
|
if persist:
|
||||||
|
final_path = self._backup_dir / archive_name
|
||||||
|
os.replace(temp_path, final_path)
|
||||||
|
self._prune_snapshots()
|
||||||
|
return {
|
||||||
|
"archive_path": str(final_path),
|
||||||
|
"archive_name": final_path.name,
|
||||||
|
"manifest": manifest,
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(temp_path, "rb") as handle:
|
||||||
|
data = handle.read()
|
||||||
|
return {
|
||||||
|
"archive_name": archive_name,
|
||||||
|
"archive_bytes": data,
|
||||||
|
"manifest": manifest,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
with contextlib.suppress(FileNotFoundError):
|
||||||
|
os.remove(temp_path)
|
||||||
|
|
||||||
|
def _build_archive_name(self, *, snapshot_type: str) -> str:
|
||||||
|
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
return f"lora-manager-backup-{timestamp}-{snapshot_type}.zip"
|
||||||
|
|
||||||
|
def _prune_snapshots(self) -> None:
|
||||||
|
retention = self._get_setting_int(
|
||||||
|
"backup_retention_count", DEFAULT_BACKUP_RETENTION_COUNT
|
||||||
|
)
|
||||||
|
archives = sorted(
|
||||||
|
self._backup_dir.glob("lora-manager-backup-*-auto.zip"),
|
||||||
|
key=lambda path: path.stat().st_mtime,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
for path in archives[retention:]:
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
async def restore_snapshot(self, archive_path: str) -> dict[str, Any]:
|
||||||
|
"""Restore backup contents from a ZIP archive."""
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
try:
|
||||||
|
zf = zipfile.ZipFile(archive_path, mode="r")
|
||||||
|
except zipfile.BadZipFile as exc:
|
||||||
|
raise ValueError("Backup archive is not a valid ZIP file") from exc
|
||||||
|
|
||||||
|
with zf:
|
||||||
|
try:
|
||||||
|
manifest = json.loads(zf.read("manifest.json").decode("utf-8"))
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError("Backup archive is missing manifest.json") from exc
|
||||||
|
|
||||||
|
if not isinstance(manifest, dict):
|
||||||
|
raise ValueError("Backup manifest is invalid")
|
||||||
|
if manifest.get("manifest_version") != BACKUP_MANIFEST_VERSION:
|
||||||
|
raise ValueError("Backup manifest version is not supported")
|
||||||
|
|
||||||
|
files = manifest.get("files", [])
|
||||||
|
if not isinstance(files, list):
|
||||||
|
raise ValueError("Backup manifest file list is invalid")
|
||||||
|
|
||||||
|
extracted_paths: list[tuple[str, str]] = []
|
||||||
|
temp_dir = Path(tempfile.mkdtemp(prefix="lora-manager-restore-"))
|
||||||
|
try:
|
||||||
|
for item in files:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
archive_member = item.get("archive_path")
|
||||||
|
if not isinstance(archive_member, str) or not archive_member:
|
||||||
|
continue
|
||||||
|
archive_member_path = Path(archive_member)
|
||||||
|
if archive_member_path.is_absolute() or ".." in archive_member_path.parts:
|
||||||
|
raise ValueError(f"Invalid archive member path: {archive_member}")
|
||||||
|
|
||||||
|
kind = item.get("kind")
|
||||||
|
target_path = self._resolve_restore_target(kind, archive_member)
|
||||||
|
if target_path is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
extracted_path = temp_dir / archive_member_path
|
||||||
|
extracted_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with zf.open(archive_member) as source, open(
|
||||||
|
extracted_path, "wb"
|
||||||
|
) as destination:
|
||||||
|
shutil.copyfileobj(source, destination)
|
||||||
|
|
||||||
|
expected_hash = item.get("sha256")
|
||||||
|
if isinstance(expected_hash, str) and expected_hash:
|
||||||
|
actual_hash, _, _ = self._hash_file(str(extracted_path))
|
||||||
|
if actual_hash != expected_hash:
|
||||||
|
raise ValueError(
|
||||||
|
f"Checksum mismatch for {archive_member}"
|
||||||
|
)
|
||||||
|
|
||||||
|
extracted_paths.append((str(extracted_path), target_path))
|
||||||
|
|
||||||
|
for extracted_path, target_path in extracted_paths:
|
||||||
|
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
||||||
|
os.replace(extracted_path, target_path)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"restored_files": len(extracted_paths),
|
||||||
|
"snapshot_type": manifest.get("snapshot_type"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _resolve_restore_target(self, kind: Any, archive_member: str) -> str | None:
|
||||||
|
if kind == "settings":
|
||||||
|
return self._settings_file_path()
|
||||||
|
if kind == "download_history":
|
||||||
|
return self._download_history_path()
|
||||||
|
if kind == "symlink_map":
|
||||||
|
return get_cache_file_path(CacheType.SYMLINK, create_dir=True)
|
||||||
|
if kind == "model_update":
|
||||||
|
filename = os.path.basename(archive_member)
|
||||||
|
return str(Path(get_cache_file_path(CacheType.MODEL_UPDATE, create_dir=True)).parent / filename)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def create_auto_snapshot_if_due(self) -> Optional[dict[str, Any]]:
|
||||||
|
if not self._get_setting_bool("backup_auto_enabled", True):
|
||||||
|
return None
|
||||||
|
|
||||||
|
latest = self.get_latest_auto_snapshot()
|
||||||
|
now = time.time()
|
||||||
|
if latest and now - latest["mtime"] < DEFAULT_BACKUP_INTERVAL_SECONDS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return await self.create_snapshot(snapshot_type="auto", persist=True)
|
||||||
|
|
||||||
|
async def _auto_backup_loop(self) -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await self.create_auto_snapshot_if_due()
|
||||||
|
await asyncio.sleep(DEFAULT_BACKUP_INTERVAL_SECONDS)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # pragma: no cover - defensive guard
|
||||||
|
logger.warning("Automatic backup snapshot failed: %s", exc, exc_info=True)
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
|
||||||
|
def get_available_snapshots(self) -> list[dict[str, Any]]:
|
||||||
|
snapshots: list[dict[str, Any]] = []
|
||||||
|
for path in sorted(self._backup_dir.glob("lora-manager-backup-*.zip")):
|
||||||
|
try:
|
||||||
|
stat = path.stat()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
snapshots.append(
|
||||||
|
{
|
||||||
|
"name": path.name,
|
||||||
|
"path": str(path),
|
||||||
|
"size": stat.st_size,
|
||||||
|
"mtime": stat.st_mtime,
|
||||||
|
"is_auto": path.name.endswith("-auto.zip"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
snapshots.sort(key=lambda item: item["mtime"], reverse=True)
|
||||||
|
return snapshots
|
||||||
|
|
||||||
|
def get_latest_auto_snapshot(self) -> Optional[dict[str, Any]]:
|
||||||
|
autos = [snapshot for snapshot in self.get_available_snapshots() if snapshot["is_auto"]]
|
||||||
|
if not autos:
|
||||||
|
return None
|
||||||
|
return autos[0]
|
||||||
|
|
||||||
|
def get_status(self) -> dict[str, Any]:
|
||||||
|
snapshots = self.get_available_snapshots()
|
||||||
|
return {
|
||||||
|
"backupDir": self.get_backup_dir(),
|
||||||
|
"enabled": self._get_setting_bool("backup_auto_enabled", True),
|
||||||
|
"retentionCount": self._get_setting_int(
|
||||||
|
"backup_retention_count", DEFAULT_BACKUP_RETENTION_COUNT
|
||||||
|
),
|
||||||
|
"snapshotCount": len(snapshots),
|
||||||
|
"latestSnapshot": snapshots[0] if snapshots else None,
|
||||||
|
"latestAutoSnapshot": self.get_latest_auto_snapshot(),
|
||||||
|
}
|
||||||
@@ -105,12 +105,18 @@ class CheckpointScanner(ModelScanner):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# Load current metadata
|
# Load current metadata
|
||||||
metadata, _ = await MetadataManager.load_metadata(
|
metadata, should_skip = await MetadataManager.load_metadata(
|
||||||
file_path, self.model_class
|
file_path, self.model_class
|
||||||
)
|
)
|
||||||
if metadata is None:
|
if metadata is None:
|
||||||
|
if should_skip:
|
||||||
|
logger.error(f"Invalid metadata found for {file_path}")
|
||||||
|
return None
|
||||||
|
created_metadata = await self._create_default_metadata(file_path)
|
||||||
|
if created_metadata is None:
|
||||||
logger.error(f"No metadata found for {file_path}")
|
logger.error(f"No metadata found for {file_path}")
|
||||||
return None
|
return None
|
||||||
|
metadata = created_metadata
|
||||||
|
|
||||||
# Check if hash is already calculated
|
# Check if hash is already calculated
|
||||||
if metadata.hash_status == "completed" and metadata.sha256:
|
if metadata.hash_status == "completed" and metadata.sha256:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
|
|||||||
|
|
||||||
from .errors import RateLimitError, ResourceNotFoundError
|
from .errors import RateLimitError, ResourceNotFoundError
|
||||||
from .settings_manager import get_settings_manager
|
from .settings_manager import get_settings_manager
|
||||||
|
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
|
||||||
from ..utils.civitai_utils import rewrite_preview_url
|
from ..utils.civitai_utils import rewrite_preview_url
|
||||||
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
||||||
|
|
||||||
@@ -234,12 +235,52 @@ class ModelUpdateService:
|
|||||||
ON model_update_versions(model_id);
|
ON model_update_versions(model_id);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, db_path: str, *, ttl_seconds: int = 24 * 60 * 60, settings_manager=None) -> None:
|
def __init__(
|
||||||
self._db_path = db_path
|
self,
|
||||||
|
db_path: str | None = None,
|
||||||
|
*,
|
||||||
|
ttl_seconds: int = 24 * 60 * 60,
|
||||||
|
settings_manager=None,
|
||||||
|
) -> None:
|
||||||
|
self._settings = settings_manager or get_settings_manager()
|
||||||
|
self._library_name = self._get_active_library_name()
|
||||||
|
self._db_path = db_path or self._resolve_default_path(self._library_name)
|
||||||
self._ttl_seconds = ttl_seconds
|
self._ttl_seconds = ttl_seconds
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
self._schema_initialized = False
|
self._schema_initialized = False
|
||||||
self._settings = settings_manager or get_settings_manager()
|
self._custom_db_path = db_path is not None
|
||||||
|
self._ensure_directory()
|
||||||
|
self._initialize_schema()
|
||||||
|
|
||||||
|
def _get_active_library_name(self) -> str:
|
||||||
|
try:
|
||||||
|
value = self._settings.get_active_library_name()
|
||||||
|
except Exception:
|
||||||
|
value = None
|
||||||
|
return value or "default"
|
||||||
|
|
||||||
|
def _resolve_default_path(self, library_name: str) -> str:
|
||||||
|
env_override = os.environ.get("LORA_MANAGER_MODEL_UPDATE_DB")
|
||||||
|
return resolve_cache_path_with_migration(
|
||||||
|
CacheType.MODEL_UPDATE,
|
||||||
|
library_name=library_name,
|
||||||
|
env_override=env_override,
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_library_changed(self) -> None:
|
||||||
|
"""Switch to the database for the active library."""
|
||||||
|
|
||||||
|
if self._custom_db_path:
|
||||||
|
return
|
||||||
|
|
||||||
|
library_name = self._get_active_library_name()
|
||||||
|
new_path = self._resolve_default_path(library_name)
|
||||||
|
if new_path == self._db_path:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._library_name = library_name
|
||||||
|
self._db_path = new_path
|
||||||
|
self._schema_initialized = False
|
||||||
self._ensure_directory()
|
self._ensure_directory()
|
||||||
self._initialize_schema()
|
self._initialize_schema()
|
||||||
|
|
||||||
@@ -262,11 +303,114 @@ class ModelUpdateService:
|
|||||||
conn.execute("PRAGMA foreign_keys = ON")
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
conn.executescript(self._SCHEMA)
|
conn.executescript(self._SCHEMA)
|
||||||
self._apply_migrations(conn)
|
self._apply_migrations(conn)
|
||||||
|
self._migrate_from_legacy_snapshot(conn)
|
||||||
self._schema_initialized = True
|
self._schema_initialized = True
|
||||||
except Exception as exc: # pragma: no cover - defensive guard
|
except Exception as exc: # pragma: no cover - defensive guard
|
||||||
logger.error("Failed to initialize update schema: %s", exc, exc_info=True)
|
logger.error("Failed to initialize update schema: %s", exc, exc_info=True)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def _migrate_from_legacy_snapshot(self, conn: sqlite3.Connection) -> None:
|
||||||
|
"""Copy update tracking data out of the legacy model snapshot database."""
|
||||||
|
|
||||||
|
if self._custom_db_path:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .persistent_model_cache import get_persistent_cache
|
||||||
|
|
||||||
|
legacy_path = get_persistent_cache(self._library_name).get_database_path()
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not legacy_path or os.path.abspath(legacy_path) == os.path.abspath(self._db_path):
|
||||||
|
return
|
||||||
|
if not os.path.exists(legacy_path):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
existing_row = conn.execute(
|
||||||
|
"SELECT 1 FROM model_update_status LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
if existing_row:
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(legacy_path, check_same_thread=False) as legacy_conn:
|
||||||
|
legacy_conn.row_factory = sqlite3.Row
|
||||||
|
status_rows = legacy_conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT model_id, model_type, last_checked_at, should_ignore_model
|
||||||
|
FROM model_update_status
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
if not status_rows:
|
||||||
|
return
|
||||||
|
|
||||||
|
version_rows = legacy_conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT model_id, version_id, sort_index, name, base_model, released_at,
|
||||||
|
size_bytes, preview_url, is_in_library, should_ignore,
|
||||||
|
early_access_ends_at, is_early_access
|
||||||
|
FROM model_update_versions
|
||||||
|
ORDER BY model_id ASC, sort_index ASC, version_id ASC
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
conn.execute("BEGIN")
|
||||||
|
conn.executemany(
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO model_update_status (
|
||||||
|
model_id, model_type, last_checked_at, should_ignore_model
|
||||||
|
) VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
int(row["model_id"]),
|
||||||
|
row["model_type"],
|
||||||
|
row["last_checked_at"],
|
||||||
|
int(row["should_ignore_model"] or 0),
|
||||||
|
)
|
||||||
|
for row in status_rows
|
||||||
|
],
|
||||||
|
)
|
||||||
|
conn.executemany(
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO model_update_versions (
|
||||||
|
model_id, version_id, sort_index, name, base_model, released_at,
|
||||||
|
size_bytes, preview_url, is_in_library, should_ignore,
|
||||||
|
early_access_ends_at, is_early_access
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
int(row["model_id"]),
|
||||||
|
int(row["version_id"]),
|
||||||
|
int(row["sort_index"] or 0),
|
||||||
|
row["name"],
|
||||||
|
row["base_model"],
|
||||||
|
row["released_at"],
|
||||||
|
row["size_bytes"],
|
||||||
|
row["preview_url"],
|
||||||
|
int(row["is_in_library"] or 0),
|
||||||
|
int(row["should_ignore"] or 0),
|
||||||
|
row["early_access_ends_at"],
|
||||||
|
int(row["is_early_access"] or 0),
|
||||||
|
)
|
||||||
|
for row in version_rows
|
||||||
|
],
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
logger.info(
|
||||||
|
"Migrated model update tracking data from legacy snapshot DB for %s",
|
||||||
|
self._library_name,
|
||||||
|
)
|
||||||
|
except sqlite3.OperationalError as exc:
|
||||||
|
logger.debug("Legacy model update migration skipped: %s", exc)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive guard
|
||||||
|
logger.warning("Failed to migrate model update data: %s", exc, exc_info=True)
|
||||||
|
|
||||||
def _apply_migrations(self, conn: sqlite3.Connection) -> None:
|
def _apply_migrations(self, conn: sqlite3.Connection) -> None:
|
||||||
"""Ensure legacy databases match the current schema without dropping data."""
|
"""Ensure legacy databases match the current schema without dropping data."""
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from .service_registry import ServiceRegistry
|
|||||||
from .lora_scanner import LoraScanner
|
from .lora_scanner import LoraScanner
|
||||||
from .metadata_service import get_default_metadata_provider
|
from .metadata_service import get_default_metadata_provider
|
||||||
from .checkpoint_scanner import CheckpointScanner
|
from .checkpoint_scanner import CheckpointScanner
|
||||||
|
from .settings_manager import get_settings_manager
|
||||||
from .recipes.errors import RecipeNotFoundError
|
from .recipes.errors import RecipeNotFoundError
|
||||||
from ..utils.utils import calculate_recipe_fingerprint, fuzzy_match
|
from ..utils.utils import calculate_recipe_fingerprint, fuzzy_match
|
||||||
from natsort import natsorted
|
from natsort import natsorted
|
||||||
@@ -1090,6 +1091,14 @@ class RecipeScanner:
|
|||||||
@property
|
@property
|
||||||
def recipes_dir(self) -> str:
|
def recipes_dir(self) -> str:
|
||||||
"""Get path to recipes directory"""
|
"""Get path to recipes directory"""
|
||||||
|
custom_recipes_dir = get_settings_manager().get("recipes_path", "")
|
||||||
|
if isinstance(custom_recipes_dir, str) and custom_recipes_dir.strip():
|
||||||
|
recipes_dir = os.path.abspath(
|
||||||
|
os.path.normpath(os.path.expanduser(custom_recipes_dir.strip()))
|
||||||
|
)
|
||||||
|
os.makedirs(recipes_dir, exist_ok=True)
|
||||||
|
return recipes_dir
|
||||||
|
|
||||||
if not config.loras_roots:
|
if not config.loras_roots:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|||||||
@@ -159,10 +159,9 @@ class ServiceRegistry:
|
|||||||
return cls._services[service_name]
|
return cls._services[service_name]
|
||||||
|
|
||||||
from .model_update_service import ModelUpdateService
|
from .model_update_service import ModelUpdateService
|
||||||
from .persistent_model_cache import get_persistent_cache
|
from .settings_manager import get_settings_manager
|
||||||
|
|
||||||
cache = get_persistent_cache()
|
service = ModelUpdateService(settings_manager=get_settings_manager())
|
||||||
service = ModelUpdateService(cache.get_database_path())
|
|
||||||
cls._services[service_name] = service
|
cls._services[service_name] = service
|
||||||
logger.debug(f"Created and registered {service_name}")
|
logger.debug(f"Created and registered {service_name}")
|
||||||
return service
|
return service
|
||||||
@@ -189,6 +188,26 @@ class ServiceRegistry:
|
|||||||
logger.debug(f"Created and registered {service_name}")
|
logger.debug(f"Created and registered {service_name}")
|
||||||
return service
|
return service
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_backup_service(cls):
|
||||||
|
"""Get or create the backup service."""
|
||||||
|
|
||||||
|
service_name = "backup_service"
|
||||||
|
|
||||||
|
if service_name in cls._services:
|
||||||
|
return cls._services[service_name]
|
||||||
|
|
||||||
|
async with cls._get_lock(service_name):
|
||||||
|
if service_name in cls._services:
|
||||||
|
return cls._services[service_name]
|
||||||
|
|
||||||
|
from .backup_service import BackupService
|
||||||
|
|
||||||
|
service = await BackupService.get_instance()
|
||||||
|
cls._services[service_name] = service
|
||||||
|
logger.debug(f"Created and registered {service_name}")
|
||||||
|
return service
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_civarchive_client(cls):
|
async def get_civarchive_client(cls):
|
||||||
"""Get or create CivArchive client instance"""
|
"""Get or create CivArchive client instance"""
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import copy
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import tempfile
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -70,6 +71,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
|||||||
"default_checkpoint_root": "",
|
"default_checkpoint_root": "",
|
||||||
"default_unet_root": "",
|
"default_unet_root": "",
|
||||||
"default_embedding_root": "",
|
"default_embedding_root": "",
|
||||||
|
"recipes_path": "",
|
||||||
"base_model_path_mappings": {},
|
"base_model_path_mappings": {},
|
||||||
"download_path_templates": {},
|
"download_path_templates": {},
|
||||||
"folder_paths": {},
|
"folder_paths": {},
|
||||||
@@ -93,6 +95,8 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
|||||||
"metadata_refresh_skip_paths": [],
|
"metadata_refresh_skip_paths": [],
|
||||||
"skip_previously_downloaded_model_versions": False,
|
"skip_previously_downloaded_model_versions": False,
|
||||||
"download_skip_base_models": [],
|
"download_skip_base_models": [],
|
||||||
|
"backup_auto_enabled": True,
|
||||||
|
"backup_retention_count": 5,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -254,6 +258,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root=merged.get("default_checkpoint_root"),
|
default_checkpoint_root=merged.get("default_checkpoint_root"),
|
||||||
default_unet_root=merged.get("default_unet_root"),
|
default_unet_root=merged.get("default_unet_root"),
|
||||||
default_embedding_root=merged.get("default_embedding_root"),
|
default_embedding_root=merged.get("default_embedding_root"),
|
||||||
|
recipes_path=merged.get("recipes_path"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
merged["active_library"] = library_name
|
merged["active_library"] = library_name
|
||||||
@@ -382,6 +387,7 @@ class SettingsManager:
|
|||||||
),
|
),
|
||||||
default_unet_root=self.settings.get("default_unet_root", ""),
|
default_unet_root=self.settings.get("default_unet_root", ""),
|
||||||
default_embedding_root=self.settings.get("default_embedding_root", ""),
|
default_embedding_root=self.settings.get("default_embedding_root", ""),
|
||||||
|
recipes_path=self.settings.get("recipes_path", ""),
|
||||||
)
|
)
|
||||||
libraries = {library_name: library_payload}
|
libraries = {library_name: library_payload}
|
||||||
self.settings["libraries"] = libraries
|
self.settings["libraries"] = libraries
|
||||||
@@ -429,6 +435,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root=data.get("default_checkpoint_root"),
|
default_checkpoint_root=data.get("default_checkpoint_root"),
|
||||||
default_unet_root=data.get("default_unet_root"),
|
default_unet_root=data.get("default_unet_root"),
|
||||||
default_embedding_root=data.get("default_embedding_root"),
|
default_embedding_root=data.get("default_embedding_root"),
|
||||||
|
recipes_path=data.get("recipes_path"),
|
||||||
metadata=data.get("metadata"),
|
metadata=data.get("metadata"),
|
||||||
base=data,
|
base=data,
|
||||||
)
|
)
|
||||||
@@ -475,6 +482,7 @@ class SettingsManager:
|
|||||||
self.settings["default_embedding_root"] = active_library.get(
|
self.settings["default_embedding_root"] = active_library.get(
|
||||||
"default_embedding_root", ""
|
"default_embedding_root", ""
|
||||||
)
|
)
|
||||||
|
self.settings["recipes_path"] = active_library.get("recipes_path", "")
|
||||||
|
|
||||||
if save:
|
if save:
|
||||||
self._save_settings()
|
self._save_settings()
|
||||||
@@ -491,6 +499,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root: Optional[str] = None,
|
default_checkpoint_root: Optional[str] = None,
|
||||||
default_unet_root: Optional[str] = None,
|
default_unet_root: Optional[str] = None,
|
||||||
default_embedding_root: Optional[str] = None,
|
default_embedding_root: Optional[str] = None,
|
||||||
|
recipes_path: Optional[str] = None,
|
||||||
metadata: Optional[Mapping[str, Any]] = None,
|
metadata: Optional[Mapping[str, Any]] = None,
|
||||||
base: Optional[Mapping[str, Any]] = None,
|
base: Optional[Mapping[str, Any]] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
@@ -529,6 +538,11 @@ class SettingsManager:
|
|||||||
else:
|
else:
|
||||||
payload.setdefault("default_embedding_root", "")
|
payload.setdefault("default_embedding_root", "")
|
||||||
|
|
||||||
|
if recipes_path is not None:
|
||||||
|
payload["recipes_path"] = recipes_path
|
||||||
|
else:
|
||||||
|
payload.setdefault("recipes_path", "")
|
||||||
|
|
||||||
if metadata:
|
if metadata:
|
||||||
merged_meta = dict(payload.get("metadata", {}))
|
merged_meta = dict(payload.get("metadata", {}))
|
||||||
merged_meta.update(metadata)
|
merged_meta.update(metadata)
|
||||||
@@ -630,6 +644,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root: Optional[str] = None,
|
default_checkpoint_root: Optional[str] = None,
|
||||||
default_unet_root: Optional[str] = None,
|
default_unet_root: Optional[str] = None,
|
||||||
default_embedding_root: Optional[str] = None,
|
default_embedding_root: Optional[str] = None,
|
||||||
|
recipes_path: Optional[str] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
libraries = self.settings.get("libraries", {})
|
libraries = self.settings.get("libraries", {})
|
||||||
active_name = self.settings.get("active_library")
|
active_name = self.settings.get("active_library")
|
||||||
@@ -679,6 +694,10 @@ class SettingsManager:
|
|||||||
library["default_embedding_root"] = default_embedding_root
|
library["default_embedding_root"] = default_embedding_root
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
|
if recipes_path is not None and library.get("recipes_path") != recipes_path:
|
||||||
|
library["recipes_path"] = recipes_path
|
||||||
|
changed = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
library.setdefault("created_at", self._current_timestamp())
|
library.setdefault("created_at", self._current_timestamp())
|
||||||
library["updated_at"] = self._current_timestamp()
|
library["updated_at"] = self._current_timestamp()
|
||||||
@@ -942,7 +961,9 @@ class SettingsManager:
|
|||||||
extra_folder_paths=defaults.get("extra_folder_paths", {}),
|
extra_folder_paths=defaults.get("extra_folder_paths", {}),
|
||||||
default_lora_root=defaults.get("default_lora_root"),
|
default_lora_root=defaults.get("default_lora_root"),
|
||||||
default_checkpoint_root=defaults.get("default_checkpoint_root"),
|
default_checkpoint_root=defaults.get("default_checkpoint_root"),
|
||||||
|
default_unet_root=defaults.get("default_unet_root"),
|
||||||
default_embedding_root=defaults.get("default_embedding_root"),
|
default_embedding_root=defaults.get("default_embedding_root"),
|
||||||
|
recipes_path=defaults.get("recipes_path"),
|
||||||
)
|
)
|
||||||
defaults["libraries"] = {library_name: default_library}
|
defaults["libraries"] = {library_name: default_library}
|
||||||
defaults["active_library"] = library_name
|
defaults["active_library"] = library_name
|
||||||
@@ -1236,6 +1257,193 @@ class SettingsManager:
|
|||||||
"""Get setting value"""
|
"""Get setting value"""
|
||||||
return self.settings.get(key, default)
|
return self.settings.get(key, default)
|
||||||
|
|
||||||
|
def _normalize_recipes_path_value(self, value: Any) -> str:
|
||||||
|
"""Return a normalized absolute recipes path or an empty string."""
|
||||||
|
|
||||||
|
if not isinstance(value, str):
|
||||||
|
value = "" if value is None else str(value)
|
||||||
|
|
||||||
|
stripped = value.strip()
|
||||||
|
if not stripped:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return os.path.abspath(os.path.normpath(os.path.expanduser(stripped)))
|
||||||
|
|
||||||
|
def _get_effective_recipes_dir(self, recipes_path: Optional[str] = None) -> str:
|
||||||
|
"""Resolve the effective recipes directory for the active library."""
|
||||||
|
|
||||||
|
normalized_custom = self._normalize_recipes_path_value(
|
||||||
|
self.settings.get("recipes_path", "")
|
||||||
|
if recipes_path is None
|
||||||
|
else recipes_path
|
||||||
|
)
|
||||||
|
if normalized_custom:
|
||||||
|
return normalized_custom
|
||||||
|
|
||||||
|
folder_paths = self.settings.get("folder_paths", {})
|
||||||
|
configured_lora_roots = []
|
||||||
|
if isinstance(folder_paths, Mapping):
|
||||||
|
raw_lora_roots = folder_paths.get("loras", [])
|
||||||
|
if isinstance(raw_lora_roots, Sequence) and not isinstance(
|
||||||
|
raw_lora_roots, (str, bytes)
|
||||||
|
):
|
||||||
|
configured_lora_roots = [
|
||||||
|
path
|
||||||
|
for path in raw_lora_roots
|
||||||
|
if isinstance(path, str) and path.strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
if configured_lora_roots:
|
||||||
|
lora_root = sorted(configured_lora_roots, key=str.casefold)[0]
|
||||||
|
return os.path.abspath(os.path.join(lora_root, "recipes"))
|
||||||
|
|
||||||
|
config_lora_roots = [
|
||||||
|
path
|
||||||
|
for path in getattr(config, "loras_roots", []) or []
|
||||||
|
if isinstance(path, str) and path.strip()
|
||||||
|
]
|
||||||
|
if not config_lora_roots:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return os.path.abspath(
|
||||||
|
os.path.join(sorted(config_lora_roots, key=str.casefold)[0], "recipes")
|
||||||
|
)
|
||||||
|
|
||||||
|
def _validate_recipes_storage_path(self, normalized_path: str) -> None:
|
||||||
|
"""Ensure the recipes storage target is usable before saving it."""
|
||||||
|
|
||||||
|
if not normalized_path:
|
||||||
|
return
|
||||||
|
|
||||||
|
if os.path.exists(normalized_path) and not os.path.isdir(normalized_path):
|
||||||
|
raise ValueError("Recipes path must point to a directory")
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.makedirs(normalized_path, exist_ok=True)
|
||||||
|
except Exception as exc:
|
||||||
|
raise ValueError(f"Unable to create recipes directory: {exc}") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
fd, probe_path = tempfile.mkstemp(
|
||||||
|
prefix=".lora-manager-recipes-", dir=normalized_path
|
||||||
|
)
|
||||||
|
os.close(fd)
|
||||||
|
os.remove(probe_path)
|
||||||
|
except Exception as exc:
|
||||||
|
raise ValueError(f"Recipes path is not writable: {exc}") from exc
|
||||||
|
|
||||||
|
def _migrate_recipes_directory(self, source_dir: str, target_dir: str) -> None:
|
||||||
|
"""Move existing recipe files to a new recipes root and rewrite JSON paths."""
|
||||||
|
|
||||||
|
source = os.path.abspath(os.path.normpath(source_dir)) if source_dir else ""
|
||||||
|
target = os.path.abspath(os.path.normpath(target_dir)) if target_dir else ""
|
||||||
|
if not source or not target or source == target:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not os.path.exists(source):
|
||||||
|
os.makedirs(target, exist_ok=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
if os.path.exists(target) and not os.path.isdir(target):
|
||||||
|
raise ValueError("Recipes path must point to a directory")
|
||||||
|
|
||||||
|
try:
|
||||||
|
common_root = os.path.commonpath([source, target])
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("Invalid recipes path change") from exc
|
||||||
|
|
||||||
|
if common_root == source:
|
||||||
|
raise ValueError("Recipes path cannot be moved into a nested directory")
|
||||||
|
|
||||||
|
planned_recipe_updates: Dict[str, Dict[str, Any]] = {}
|
||||||
|
file_pairs: List[Tuple[str, str]] = []
|
||||||
|
|
||||||
|
for root, _, files in os.walk(source):
|
||||||
|
for filename in files:
|
||||||
|
source_path = os.path.normpath(os.path.join(root, filename))
|
||||||
|
relative_path = os.path.relpath(source_path, source)
|
||||||
|
target_path = os.path.normpath(os.path.join(target, relative_path))
|
||||||
|
file_pairs.append((source_path, target_path))
|
||||||
|
|
||||||
|
if not filename.endswith(".recipe.json"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(source_path, "r", encoding="utf-8") as handle:
|
||||||
|
payload = json.load(handle)
|
||||||
|
except Exception as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unable to read recipe metadata during migration: {source_path}: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_path = payload.get("file_path")
|
||||||
|
if isinstance(file_path, str) and file_path.strip():
|
||||||
|
normalized_file_path = os.path.abspath(
|
||||||
|
os.path.normpath(os.path.expanduser(file_path))
|
||||||
|
)
|
||||||
|
source_candidates = [source]
|
||||||
|
real_source = os.path.abspath(
|
||||||
|
os.path.normpath(os.path.realpath(source_dir))
|
||||||
|
)
|
||||||
|
if real_source not in source_candidates:
|
||||||
|
source_candidates.append(real_source)
|
||||||
|
|
||||||
|
rewritten = False
|
||||||
|
for source_candidate in source_candidates:
|
||||||
|
try:
|
||||||
|
file_common_root = os.path.commonpath(
|
||||||
|
[normalized_file_path, source_candidate]
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if file_common_root != source_candidate:
|
||||||
|
continue
|
||||||
|
|
||||||
|
image_relative_path = os.path.relpath(
|
||||||
|
normalized_file_path, source_candidate
|
||||||
|
)
|
||||||
|
payload["file_path"] = os.path.normpath(
|
||||||
|
os.path.join(target, image_relative_path)
|
||||||
|
)
|
||||||
|
rewritten = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not rewritten and source_candidates:
|
||||||
|
logger.debug(
|
||||||
|
"Skipping recipe file_path rewrite during migration for %s",
|
||||||
|
normalized_file_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
planned_recipe_updates[target_path] = payload
|
||||||
|
|
||||||
|
for _, target_path in file_pairs:
|
||||||
|
if os.path.exists(target_path):
|
||||||
|
raise ValueError(
|
||||||
|
f"Recipes path already contains conflicting file: {target_path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
os.makedirs(target, exist_ok=True)
|
||||||
|
|
||||||
|
for source_path, target_path in file_pairs:
|
||||||
|
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
||||||
|
shutil.move(source_path, target_path)
|
||||||
|
|
||||||
|
for target_path, payload in planned_recipe_updates.items():
|
||||||
|
with open(target_path, "w", encoding="utf-8") as handle:
|
||||||
|
json.dump(payload, handle, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
|
for root, dirs, files in os.walk(source, topdown=False):
|
||||||
|
if dirs or files:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
os.rmdir(root)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
def set(self, key: str, value: Any) -> None:
|
def set(self, key: str, value: Any) -> None:
|
||||||
"""Set setting value and save"""
|
"""Set setting value and save"""
|
||||||
if key == "auto_organize_exclusions":
|
if key == "auto_organize_exclusions":
|
||||||
@@ -1246,6 +1454,12 @@ class SettingsManager:
|
|||||||
value = self.normalize_download_skip_base_models(value)
|
value = self.normalize_download_skip_base_models(value)
|
||||||
elif key == "mature_blur_level":
|
elif key == "mature_blur_level":
|
||||||
value = self.normalize_mature_blur_level(value)
|
value = self.normalize_mature_blur_level(value)
|
||||||
|
elif key == "recipes_path":
|
||||||
|
current_recipes_dir = self._get_effective_recipes_dir()
|
||||||
|
value = self._normalize_recipes_path_value(value)
|
||||||
|
target_recipes_dir = self._get_effective_recipes_dir(value)
|
||||||
|
self._validate_recipes_storage_path(target_recipes_dir)
|
||||||
|
self._migrate_recipes_directory(current_recipes_dir, target_recipes_dir)
|
||||||
self.settings[key] = value
|
self.settings[key] = value
|
||||||
portable_switch_pending = False
|
portable_switch_pending = False
|
||||||
if key == "use_portable_settings" and isinstance(value, bool):
|
if key == "use_portable_settings" and isinstance(value, bool):
|
||||||
@@ -1263,9 +1477,13 @@ class SettingsManager:
|
|||||||
self._update_active_library_entry(default_unet_root=str(value))
|
self._update_active_library_entry(default_unet_root=str(value))
|
||||||
elif key == "default_embedding_root":
|
elif key == "default_embedding_root":
|
||||||
self._update_active_library_entry(default_embedding_root=str(value))
|
self._update_active_library_entry(default_embedding_root=str(value))
|
||||||
|
elif key == "recipes_path":
|
||||||
|
self._update_active_library_entry(recipes_path=str(value))
|
||||||
elif key == "model_name_display":
|
elif key == "model_name_display":
|
||||||
self._notify_model_name_display_change(value)
|
self._notify_model_name_display_change(value)
|
||||||
self._save_settings()
|
self._save_settings()
|
||||||
|
if key == "recipes_path":
|
||||||
|
self._notify_library_change(self.get_active_library_name())
|
||||||
if portable_switch_pending:
|
if portable_switch_pending:
|
||||||
self._finalize_portable_switch()
|
self._finalize_portable_switch()
|
||||||
|
|
||||||
@@ -1575,6 +1793,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root: Optional[str] = None,
|
default_checkpoint_root: Optional[str] = None,
|
||||||
default_unet_root: Optional[str] = None,
|
default_unet_root: Optional[str] = None,
|
||||||
default_embedding_root: Optional[str] = None,
|
default_embedding_root: Optional[str] = None,
|
||||||
|
recipes_path: Optional[str] = None,
|
||||||
metadata: Optional[Mapping[str, Any]] = None,
|
metadata: Optional[Mapping[str, Any]] = None,
|
||||||
activate: bool = False,
|
activate: bool = False,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
@@ -1618,6 +1837,11 @@ class SettingsManager:
|
|||||||
if default_embedding_root is not None
|
if default_embedding_root is not None
|
||||||
else existing.get("default_embedding_root")
|
else existing.get("default_embedding_root")
|
||||||
),
|
),
|
||||||
|
recipes_path=(
|
||||||
|
recipes_path
|
||||||
|
if recipes_path is not None
|
||||||
|
else existing.get("recipes_path")
|
||||||
|
),
|
||||||
metadata=metadata if metadata is not None else existing.get("metadata"),
|
metadata=metadata if metadata is not None else existing.get("metadata"),
|
||||||
base=existing,
|
base=existing,
|
||||||
)
|
)
|
||||||
@@ -1645,6 +1869,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root: str = "",
|
default_checkpoint_root: str = "",
|
||||||
default_unet_root: str = "",
|
default_unet_root: str = "",
|
||||||
default_embedding_root: str = "",
|
default_embedding_root: str = "",
|
||||||
|
recipes_path: str = "",
|
||||||
metadata: Optional[Mapping[str, Any]] = None,
|
metadata: Optional[Mapping[str, Any]] = None,
|
||||||
activate: bool = False,
|
activate: bool = False,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
@@ -1662,6 +1887,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root=default_checkpoint_root,
|
default_checkpoint_root=default_checkpoint_root,
|
||||||
default_unet_root=default_unet_root,
|
default_unet_root=default_unet_root,
|
||||||
default_embedding_root=default_embedding_root,
|
default_embedding_root=default_embedding_root,
|
||||||
|
recipes_path=recipes_path,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
activate=activate,
|
activate=activate,
|
||||||
)
|
)
|
||||||
@@ -1721,6 +1947,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root: Optional[str] = None,
|
default_checkpoint_root: Optional[str] = None,
|
||||||
default_unet_root: Optional[str] = None,
|
default_unet_root: Optional[str] = None,
|
||||||
default_embedding_root: Optional[str] = None,
|
default_embedding_root: Optional[str] = None,
|
||||||
|
recipes_path: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Update folder paths for the active library."""
|
"""Update folder paths for the active library."""
|
||||||
|
|
||||||
@@ -1733,6 +1960,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root=default_checkpoint_root,
|
default_checkpoint_root=default_checkpoint_root,
|
||||||
default_unet_root=default_unet_root,
|
default_unet_root=default_unet_root,
|
||||||
default_embedding_root=default_embedding_root,
|
default_embedding_root=default_embedding_root,
|
||||||
|
recipes_path=recipes_path,
|
||||||
activate=True,
|
activate=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1757,6 +1985,7 @@ class SettingsManager:
|
|||||||
"checkpoint_scanner",
|
"checkpoint_scanner",
|
||||||
"embedding_scanner",
|
"embedding_scanner",
|
||||||
"recipe_scanner",
|
"recipe_scanner",
|
||||||
|
"model_update_service",
|
||||||
):
|
):
|
||||||
service = ServiceRegistry.get_service_sync(service_name)
|
service = ServiceRegistry.get_service_sync(service_name)
|
||||||
if service and hasattr(service, "on_library_changed"):
|
if service and hasattr(service, "on_library_changed"):
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ Target structure:
|
|||||||
│ └── symlink_map.json
|
│ └── symlink_map.json
|
||||||
├── model/
|
├── model/
|
||||||
│ └── {library_name}.sqlite
|
│ └── {library_name}.sqlite
|
||||||
|
├── model_update/
|
||||||
|
│ └── {library_name}.sqlite
|
||||||
├── recipe/
|
├── recipe/
|
||||||
│ └── {library_name}.sqlite
|
│ └── {library_name}.sqlite
|
||||||
└── fts/
|
└── fts/
|
||||||
@@ -36,6 +38,7 @@ class CacheType(Enum):
|
|||||||
"""Types of cache files managed by the cache path resolver."""
|
"""Types of cache files managed by the cache path resolver."""
|
||||||
|
|
||||||
MODEL = "model"
|
MODEL = "model"
|
||||||
|
MODEL_UPDATE = "model_update"
|
||||||
RECIPE = "recipe"
|
RECIPE = "recipe"
|
||||||
RECIPE_FTS = "recipe_fts"
|
RECIPE_FTS = "recipe_fts"
|
||||||
TAG_FTS = "tag_fts"
|
TAG_FTS = "tag_fts"
|
||||||
@@ -45,6 +48,7 @@ class CacheType(Enum):
|
|||||||
# Subdirectory structure for each cache type
|
# Subdirectory structure for each cache type
|
||||||
_CACHE_SUBDIRS = {
|
_CACHE_SUBDIRS = {
|
||||||
CacheType.MODEL: "model",
|
CacheType.MODEL: "model",
|
||||||
|
CacheType.MODEL_UPDATE: "model_update",
|
||||||
CacheType.RECIPE: "recipe",
|
CacheType.RECIPE: "recipe",
|
||||||
CacheType.RECIPE_FTS: "fts",
|
CacheType.RECIPE_FTS: "fts",
|
||||||
CacheType.TAG_FTS: "fts",
|
CacheType.TAG_FTS: "fts",
|
||||||
@@ -54,6 +58,7 @@ _CACHE_SUBDIRS = {
|
|||||||
# Filename patterns for each cache type
|
# Filename patterns for each cache type
|
||||||
_CACHE_FILENAMES = {
|
_CACHE_FILENAMES = {
|
||||||
CacheType.MODEL: "{library_name}.sqlite",
|
CacheType.MODEL: "{library_name}.sqlite",
|
||||||
|
CacheType.MODEL_UPDATE: "{library_name}.sqlite",
|
||||||
CacheType.RECIPE: "{library_name}.sqlite",
|
CacheType.RECIPE: "{library_name}.sqlite",
|
||||||
CacheType.RECIPE_FTS: "recipe_fts.sqlite",
|
CacheType.RECIPE_FTS: "recipe_fts.sqlite",
|
||||||
CacheType.TAG_FTS: "tag_fts.sqlite",
|
CacheType.TAG_FTS: "tag_fts.sqlite",
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ DEFAULT_PRIORITY_TAG_CONFIG = {
|
|||||||
DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
||||||
[
|
[
|
||||||
"ZImageTurbo",
|
"ZImageTurbo",
|
||||||
|
"ZImageBase",
|
||||||
"Wan Video 1.3B t2v",
|
"Wan Video 1.3B t2v",
|
||||||
"Wan Video 14B t2v",
|
"Wan Video 14B t2v",
|
||||||
"Wan Video 14B i2v 480p",
|
"Wan Video 14B i2v 480p",
|
||||||
|
|||||||
136
py/utils/session_logging.py
Normal file
136
py/utils/session_logging.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||||
|
_SESSION_HANDLER_NAME = "lora_manager_standalone_session_memory"
|
||||||
|
_FILE_HANDLER_NAME = "lora_manager_standalone_session_file"
|
||||||
|
_session_state: "StandaloneSessionLogState | None" = None
|
||||||
|
_session_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StandaloneSessionLogState:
|
||||||
|
started_at: str
|
||||||
|
session_id: str
|
||||||
|
log_file_path: str | None
|
||||||
|
memory_handler: "StandaloneSessionMemoryHandler"
|
||||||
|
|
||||||
|
|
||||||
|
class StandaloneSessionMemoryHandler(logging.Handler):
|
||||||
|
def __init__(self, capacity: int = 4000) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._entries: deque[str] = deque(maxlen=capacity)
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
try:
|
||||||
|
rendered = self.format(record)
|
||||||
|
except Exception:
|
||||||
|
rendered = record.getMessage()
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._entries.append(rendered)
|
||||||
|
|
||||||
|
def render(self, max_lines: int | None = None) -> str:
|
||||||
|
with self._lock:
|
||||||
|
entries = list(self._entries)
|
||||||
|
|
||||||
|
if max_lines is not None and max_lines > 0:
|
||||||
|
entries = entries[-max_lines:]
|
||||||
|
|
||||||
|
if not entries:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return "\n".join(entries) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_log_file_path(settings_file: str | None, started_at: datetime) -> str | None:
|
||||||
|
if not settings_file:
|
||||||
|
return None
|
||||||
|
|
||||||
|
settings_dir = os.path.dirname(os.path.abspath(settings_file))
|
||||||
|
log_dir = os.path.join(settings_dir, "logs")
|
||||||
|
os.makedirs(log_dir, exist_ok=True)
|
||||||
|
timestamp = started_at.strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
return os.path.join(log_dir, f"standalone-session-{timestamp}.log")
|
||||||
|
|
||||||
|
|
||||||
|
def setup_standalone_session_logging(settings_file: str | None) -> StandaloneSessionLogState:
|
||||||
|
global _session_state
|
||||||
|
|
||||||
|
with _session_lock:
|
||||||
|
if _session_state is not None:
|
||||||
|
return _session_state
|
||||||
|
|
||||||
|
started_dt = datetime.now(timezone.utc)
|
||||||
|
started_at = started_dt.replace(microsecond=0).isoformat()
|
||||||
|
session_id = f"{started_dt.strftime('%Y%m%dT%H%M%SZ')}-{uuid.uuid4().hex[:8]}"
|
||||||
|
formatter = logging.Formatter(LOG_FORMAT)
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
if root_logger.level > logging.INFO:
|
||||||
|
root_logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
memory_handler = StandaloneSessionMemoryHandler()
|
||||||
|
memory_handler.set_name(_SESSION_HANDLER_NAME)
|
||||||
|
memory_handler.setFormatter(formatter)
|
||||||
|
root_logger.addHandler(memory_handler)
|
||||||
|
|
||||||
|
log_file_path = _build_log_file_path(settings_file, started_dt)
|
||||||
|
if log_file_path:
|
||||||
|
file_handler = logging.FileHandler(log_file_path, encoding="utf-8")
|
||||||
|
file_handler.set_name(_FILE_HANDLER_NAME)
|
||||||
|
file_handler.setFormatter(formatter)
|
||||||
|
root_logger.addHandler(file_handler)
|
||||||
|
|
||||||
|
_session_state = StandaloneSessionLogState(
|
||||||
|
started_at=started_at,
|
||||||
|
session_id=session_id,
|
||||||
|
log_file_path=log_file_path,
|
||||||
|
memory_handler=memory_handler,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("lora-manager-standalone")
|
||||||
|
logger.info("LoRA Manager standalone startup time: %s", started_at)
|
||||||
|
logger.info("LoRA Manager standalone session id: %s", session_id)
|
||||||
|
if log_file_path:
|
||||||
|
logger.info("LoRA Manager standalone session log path: %s", log_file_path)
|
||||||
|
|
||||||
|
return _session_state
|
||||||
|
|
||||||
|
|
||||||
|
def get_standalone_session_log_snapshot(max_lines: int = 2000) -> dict[str, Any] | None:
|
||||||
|
state = _session_state
|
||||||
|
if state is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"started_at": state.started_at,
|
||||||
|
"session_id": state.session_id,
|
||||||
|
"log_file_path": state.log_file_path,
|
||||||
|
"in_memory_text": state.memory_handler.render(max_lines=max_lines),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def reset_standalone_session_logging_for_tests() -> None:
|
||||||
|
global _session_state
|
||||||
|
|
||||||
|
with _session_lock:
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
handlers_to_remove = [
|
||||||
|
handler
|
||||||
|
for handler in root_logger.handlers
|
||||||
|
if handler.get_name() in {_SESSION_HANDLER_NAME, _FILE_HANDLER_NAME}
|
||||||
|
]
|
||||||
|
for handler in handlers_to_remove:
|
||||||
|
root_logger.removeHandler(handler)
|
||||||
|
handler.close()
|
||||||
|
_session_state = None
|
||||||
@@ -29,6 +29,18 @@ if not standalone_mode:
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_DEFAULT_CHECKPOINT_EXTENSIONS = {
|
||||||
|
".ckpt",
|
||||||
|
".pt",
|
||||||
|
".pt2",
|
||||||
|
".bin",
|
||||||
|
".pth",
|
||||||
|
".safetensors",
|
||||||
|
".pkl",
|
||||||
|
".sft",
|
||||||
|
".gguf",
|
||||||
|
}
|
||||||
|
|
||||||
class UsageStats:
|
class UsageStats:
|
||||||
"""Track usage statistics for models and save to JSON"""
|
"""Track usage statistics for models and save to JSON"""
|
||||||
|
|
||||||
@@ -292,6 +304,151 @@ class UsageStats:
|
|||||||
if LORAS in metadata and isinstance(metadata[LORAS], dict):
|
if LORAS in metadata and isinstance(metadata[LORAS], dict):
|
||||||
await self._process_loras(metadata[LORAS], today)
|
await self._process_loras(metadata[LORAS], today)
|
||||||
|
|
||||||
|
def _increment_usage_counter(self, category: str, stat_key: str, today_date: str) -> None:
|
||||||
|
"""Increment usage counters for a resolved stats key."""
|
||||||
|
if stat_key not in self.stats[category]:
|
||||||
|
self.stats[category][stat_key] = {
|
||||||
|
"total": 0,
|
||||||
|
"history": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.stats[category][stat_key]["total"] += 1
|
||||||
|
|
||||||
|
if today_date not in self.stats[category][stat_key]["history"]:
|
||||||
|
self.stats[category][stat_key]["history"][today_date] = 0
|
||||||
|
self.stats[category][stat_key]["history"][today_date] += 1
|
||||||
|
|
||||||
|
def _normalize_model_lookup_name(self, model_name: str) -> str:
|
||||||
|
"""Normalize a model reference to its base filename without extension."""
|
||||||
|
return os.path.splitext(os.path.basename(model_name))[0]
|
||||||
|
|
||||||
|
async def _find_cached_checkpoint_entry(self, checkpoint_scanner, model_name: str):
|
||||||
|
"""Best-effort lookup for a checkpoint cache entry by filename/model name."""
|
||||||
|
get_cached_data = getattr(checkpoint_scanner, "get_cached_data", None)
|
||||||
|
if not callable(get_cached_data):
|
||||||
|
return None
|
||||||
|
|
||||||
|
cache = await get_cached_data()
|
||||||
|
raw_data = getattr(cache, "raw_data", None)
|
||||||
|
if not isinstance(raw_data, list):
|
||||||
|
return None
|
||||||
|
|
||||||
|
normalized_name = self._normalize_model_lookup_name(model_name)
|
||||||
|
for entry in raw_data:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for candidate_key in ("file_name", "model_name", "file_path"):
|
||||||
|
candidate_value = entry.get(candidate_key)
|
||||||
|
if not candidate_value or not isinstance(candidate_value, str):
|
||||||
|
continue
|
||||||
|
if self._normalize_model_lookup_name(candidate_value) == normalized_name:
|
||||||
|
return entry
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _find_checkpoint_file_on_disk(self, checkpoint_scanner, model_name: str):
|
||||||
|
"""Search checkpoint roots directly for a matching file.
|
||||||
|
|
||||||
|
This is used when usage tracking sees a checkpoint name before the cache has
|
||||||
|
been refreshed. The lookup is intentionally exact: we only match the model
|
||||||
|
basename and supported checkpoint extensions.
|
||||||
|
"""
|
||||||
|
get_model_roots = getattr(checkpoint_scanner, "get_model_roots", None)
|
||||||
|
if not callable(get_model_roots):
|
||||||
|
return None
|
||||||
|
|
||||||
|
roots = [root for root in get_model_roots() if root]
|
||||||
|
if not roots:
|
||||||
|
return None
|
||||||
|
|
||||||
|
supported_extensions = getattr(
|
||||||
|
checkpoint_scanner, "file_extensions", _DEFAULT_CHECKPOINT_EXTENSIONS
|
||||||
|
)
|
||||||
|
if not isinstance(supported_extensions, (set, frozenset, list, tuple)):
|
||||||
|
supported_extensions = _DEFAULT_CHECKPOINT_EXTENSIONS
|
||||||
|
|
||||||
|
normalized_name = self._normalize_model_lookup_name(model_name)
|
||||||
|
matches: list[str] = []
|
||||||
|
|
||||||
|
for root_path in roots:
|
||||||
|
if not os.path.exists(root_path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for dirpath, _dirnames, filenames in os.walk(root_path):
|
||||||
|
for filename in filenames:
|
||||||
|
extension = os.path.splitext(filename)[1].lower()
|
||||||
|
if extension not in supported_extensions:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if os.path.splitext(filename)[0] != normalized_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
matches.append(os.path.join(dirpath, filename).replace(os.sep, "/"))
|
||||||
|
|
||||||
|
if len(matches) > 1:
|
||||||
|
logger.warning(
|
||||||
|
"Multiple checkpoint files matched '%s'; skipping usage tracking: %s",
|
||||||
|
normalized_name,
|
||||||
|
", ".join(matches),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return matches[0] if matches else None
|
||||||
|
|
||||||
|
async def _resolve_checkpoint_hash(self, checkpoint_scanner, model_name: str):
|
||||||
|
"""Resolve a checkpoint hash, calculating pending hashes on demand when needed."""
|
||||||
|
model_filename = self._normalize_model_lookup_name(model_name)
|
||||||
|
model_hash = checkpoint_scanner.get_hash_by_filename(model_filename)
|
||||||
|
if model_hash:
|
||||||
|
return model_hash
|
||||||
|
|
||||||
|
cached_entry = await self._find_cached_checkpoint_entry(checkpoint_scanner, model_name)
|
||||||
|
if cached_entry:
|
||||||
|
cached_hash = cached_entry.get("sha256")
|
||||||
|
if cached_hash:
|
||||||
|
return cached_hash
|
||||||
|
|
||||||
|
hash_status = cached_entry.get("hash_status")
|
||||||
|
if hash_status and hash_status != "pending":
|
||||||
|
logger.warning(
|
||||||
|
"Checkpoint '%s' has hash_status=%s; skipping usage tracking",
|
||||||
|
model_filename,
|
||||||
|
hash_status,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
file_path = cached_entry.get("file_path") if cached_entry else None
|
||||||
|
if not file_path:
|
||||||
|
file_path = await self._find_checkpoint_file_on_disk(
|
||||||
|
checkpoint_scanner, model_name
|
||||||
|
)
|
||||||
|
|
||||||
|
if not file_path:
|
||||||
|
logger.warning(
|
||||||
|
f"No hash found for checkpoint '{model_filename}', skipping usage tracking"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
calculate_hash = getattr(checkpoint_scanner, "calculate_hash_for_model", None)
|
||||||
|
if not callable(calculate_hash):
|
||||||
|
logger.warning("Checkpoint scanner not available for usage tracking")
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Calculating hash for checkpoint '%s' from %s",
|
||||||
|
model_filename,
|
||||||
|
file_path,
|
||||||
|
)
|
||||||
|
calculated_hash = await calculate_hash(file_path)
|
||||||
|
if calculated_hash:
|
||||||
|
return calculated_hash
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to calculate hash for checkpoint '{model_filename}', skipping usage tracking"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
async def _process_checkpoints(self, models_data, today_date):
|
async def _process_checkpoints(self, models_data, today_date):
|
||||||
"""Process checkpoint models from metadata"""
|
"""Process checkpoint models from metadata"""
|
||||||
try:
|
try:
|
||||||
@@ -312,26 +469,11 @@ class UsageStats:
|
|||||||
if not model_name:
|
if not model_name:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Clean up filename (remove extension if present)
|
model_hash = await self._resolve_checkpoint_hash(checkpoint_scanner, model_name)
|
||||||
model_filename = os.path.splitext(os.path.basename(model_name))[0]
|
if not model_hash:
|
||||||
|
continue
|
||||||
|
|
||||||
# Get hash for this checkpoint
|
self._increment_usage_counter("checkpoints", model_hash, today_date)
|
||||||
model_hash = checkpoint_scanner.get_hash_by_filename(model_filename)
|
|
||||||
if model_hash:
|
|
||||||
# Update stats for this checkpoint with date tracking
|
|
||||||
if model_hash not in self.stats["checkpoints"]:
|
|
||||||
self.stats["checkpoints"][model_hash] = {
|
|
||||||
"total": 0,
|
|
||||||
"history": {}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Increment total count
|
|
||||||
self.stats["checkpoints"][model_hash]["total"] += 1
|
|
||||||
|
|
||||||
# Increment today's count
|
|
||||||
if today_date not in self.stats["checkpoints"][model_hash]["history"]:
|
|
||||||
self.stats["checkpoints"][model_hash]["history"][today_date] = 0
|
|
||||||
self.stats["checkpoints"][model_hash]["history"][today_date] += 1
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error processing checkpoint usage: {e}", exc_info=True)
|
logger.error(f"Error processing checkpoint usage: {e}", exc_info=True)
|
||||||
|
|
||||||
@@ -360,21 +502,11 @@ class UsageStats:
|
|||||||
|
|
||||||
# Get hash for this LoRA
|
# Get hash for this LoRA
|
||||||
lora_hash = lora_scanner.get_hash_by_filename(lora_name)
|
lora_hash = lora_scanner.get_hash_by_filename(lora_name)
|
||||||
if lora_hash:
|
if not lora_hash:
|
||||||
# Update stats for this LoRA with date tracking
|
logger.warning(f"No hash found for LoRA '{lora_name}', skipping usage tracking")
|
||||||
if lora_hash not in self.stats["loras"]:
|
continue
|
||||||
self.stats["loras"][lora_hash] = {
|
|
||||||
"total": 0,
|
|
||||||
"history": {}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Increment total count
|
self._increment_usage_counter("loras", lora_hash, today_date)
|
||||||
self.stats["loras"][lora_hash]["total"] += 1
|
|
||||||
|
|
||||||
# Increment today's count
|
|
||||||
if today_date not in self.stats["loras"][lora_hash]["history"]:
|
|
||||||
self.stats["loras"][lora_hash]["history"][today_date] = 0
|
|
||||||
self.stats["loras"][lora_hash]["history"][today_date] += 1
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error processing LoRA usage: {e}", exc_info=True)
|
logger.error(f"Error processing LoRA usage: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
|
from py.utils.session_logging import setup_standalone_session_logging
|
||||||
|
|
||||||
# Increase allowable header size to align with in-ComfyUI configuration.
|
# Increase allowable header size to align with in-ComfyUI configuration.
|
||||||
HEADER_SIZE_LIMIT = 16384
|
HEADER_SIZE_LIMIT = 16384
|
||||||
|
|
||||||
@@ -125,6 +127,8 @@ logger = logging.getLogger("lora-manager-standalone")
|
|||||||
# Configure aiohttp access logger to be less verbose
|
# Configure aiohttp access logger to be less verbose
|
||||||
logging.getLogger("aiohttp.access").setLevel(logging.WARNING)
|
logging.getLogger("aiohttp.access").setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
setup_standalone_session_logging(ensure_settings_file(logger))
|
||||||
|
|
||||||
|
|
||||||
# Add specific suppression for connection reset errors
|
# Add specific suppression for connection reset errors
|
||||||
class ConnectionResetFilter(logging.Filter):
|
class ConnectionResetFilter(logging.Filter):
|
||||||
|
|||||||
@@ -311,6 +311,161 @@ button:disabled,
|
|||||||
color: var(--lora-error, #ef4444);
|
color: var(--lora-error, #ef4444);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.backup-status {
|
||||||
|
background: rgba(0, 0, 0, 0.03);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .backup-status {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-summary-card {
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
padding: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .backup-summary-card {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border-color: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-summary-label {
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-summary-value {
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-summary-value.status-enabled {
|
||||||
|
color: var(--lora-success, #10b981);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-summary-value.status-disabled {
|
||||||
|
color: var(--lora-error, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-status-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-status-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(140px, 180px) 1fr;
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-status-label {
|
||||||
|
color: var(--text-color);
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-status-content {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-status-primary {
|
||||||
|
color: var(--text-color);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-status-secondary {
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.72;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-word;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-location-details {
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .backup-location-details {
|
||||||
|
border-color: var(--lora-border);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-location-details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-location-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 var(--space-3) var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-location-panel .text-btn {
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-location-path {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
color: var(--text-color);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .backup-location-path {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.backup-status-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-location-panel {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-location-panel .text-btn {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Add styles for delete preview image */
|
/* Add styles for delete preview image */
|
||||||
.delete-preview {
|
.delete-preview {
|
||||||
max-width: 150px;
|
max-width: 150px;
|
||||||
|
|||||||
278
static/css/components/modal/doctor-modal.css
Normal file
278
static/css/components/modal/doctor-modal.css
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
.doctor-trigger {
|
||||||
|
min-width: 120px;
|
||||||
|
position: relative;
|
||||||
|
border-color: color-mix(in srgb, var(--lora-accent) 24%, var(--border-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-trigger i {
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-status-badge {
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--lora-error);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-status-badge.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-modal {
|
||||||
|
width: min(960px, 92vw);
|
||||||
|
max-width: 960px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-hero {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
|
background: rgba(0, 0, 0, 0.03);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-kicker {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--lora-accent);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-hero h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-hero p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-summary-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-status-ok {
|
||||||
|
background: color-mix(in oklch, var(--lora-success) 14%, transparent);
|
||||||
|
border-color: color-mix(in oklch, var(--lora-success) 28%, transparent);
|
||||||
|
color: color-mix(in oklch, var(--lora-success) 72%, var(--text-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-status-warning {
|
||||||
|
background: color-mix(in oklch, var(--lora-warning) 16%, transparent);
|
||||||
|
border-color: color-mix(in oklch, var(--lora-warning) 30%, transparent);
|
||||||
|
color: color-mix(in oklch, var(--lora-warning) 70%, var(--text-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-status-error {
|
||||||
|
background: color-mix(in oklch, var(--lora-error) 16%, transparent);
|
||||||
|
border-color: color-mix(in oklch, var(--lora-error) 30%, transparent);
|
||||||
|
color: color-mix(in oklch, var(--lora-error) 68%, var(--text-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-loading-state {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
min-height: 22px;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background: var(--lora-surface);
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.18s ease, visibility 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-loading-state.visible {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-issues-list {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-issue-card {
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
background: rgba(0, 0, 0, 0.03);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
padding: var(--space-3);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-issue-card[data-status="warning"] {
|
||||||
|
border-color: color-mix(in oklch, var(--lora-warning) 32%, var(--lora-border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-issue-card[data-status="error"] {
|
||||||
|
border-color: color-mix(in oklch, var(--lora-error) 28%, var(--lora-border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-issue-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-issue-header h3 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-issue-summary {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-issue-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-issue-card[data-status="ok"] .doctor-issue-tag {
|
||||||
|
background: color-mix(in oklch, var(--lora-success) 14%, transparent);
|
||||||
|
color: color-mix(in oklch, var(--lora-success) 72%, var(--text-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-issue-card[data-status="warning"] .doctor-issue-tag {
|
||||||
|
background: color-mix(in oklch, var(--lora-warning) 16%, transparent);
|
||||||
|
color: color-mix(in oklch, var(--lora-warning) 70%, var(--text-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-issue-card[data-status="error"] .doctor-issue-tag {
|
||||||
|
background: color-mix(in oklch, var(--lora-error) 16%, transparent);
|
||||||
|
color: color-mix(in oklch, var(--lora-error) 68%, var(--text-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-issue-details {
|
||||||
|
margin: 14px 0 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-issue-details li + li {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-issue-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-inline-detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-inline-detail {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
background: var(--lora-surface);
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-inline-detail strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: center;
|
||||||
|
padding-top: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-footer-note {
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.82;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-footer-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .doctor-hero,
|
||||||
|
[data-theme="dark"] .doctor-issue-card {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-color: var(--lora-border);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.doctor-trigger {
|
||||||
|
min-width: auto;
|
||||||
|
padding-inline: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-trigger span:not(.doctor-status-badge) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-hero,
|
||||||
|
.doctor-footer {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-hero {
|
||||||
|
padding-right: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-summary-badge {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-footer-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-footer-actions button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,6 +102,13 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: var(--space-3);
|
padding: var(--space-3);
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports not (scrollbar-gutter: stable) {
|
||||||
|
.settings-content {
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-content .settings-form {
|
.settings-content .settings-form {
|
||||||
|
|||||||
@@ -1036,6 +1036,73 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-height: 860px) {
|
||||||
|
#recipeModal .modal-content {
|
||||||
|
padding-top: var(--space-2);
|
||||||
|
padding-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-modal-header {
|
||||||
|
padding-bottom: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-modal-header h2 {
|
||||||
|
font-size: 1.25em;
|
||||||
|
max-height: 2.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-tags-container {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-top-section {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-preview-container {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-gen-params {
|
||||||
|
height: auto;
|
||||||
|
max-height: 210px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-gen-params h3 {
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
font-size: 1.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gen-params-container {
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-content {
|
||||||
|
max-height: 90px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-textarea {
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.other-params {
|
||||||
|
margin-top: 0;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-bottom-section {
|
||||||
|
padding-top: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-section-header {
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.badge-container {
|
.badge-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
@import 'components/modal/delete-modal.css';
|
@import 'components/modal/delete-modal.css';
|
||||||
@import 'components/modal/update-modal.css';
|
@import 'components/modal/update-modal.css';
|
||||||
@import 'components/modal/settings-modal.css';
|
@import 'components/modal/settings-modal.css';
|
||||||
|
@import 'components/modal/doctor-modal.css';
|
||||||
@import 'components/modal/help-modal.css';
|
@import 'components/modal/help-modal.css';
|
||||||
@import 'components/modal/relink-civitai-modal.css';
|
@import 'components/modal/relink-civitai-modal.css';
|
||||||
@import 'components/modal/example-access-modal.css';
|
@import 'components/modal/example-access-modal.css';
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { moveManager } from './managers/MoveManager.js';
|
|||||||
import { bulkManager } from './managers/BulkManager.js';
|
import { bulkManager } from './managers/BulkManager.js';
|
||||||
import { ExampleImagesManager } from './managers/ExampleImagesManager.js';
|
import { ExampleImagesManager } from './managers/ExampleImagesManager.js';
|
||||||
import { helpManager } from './managers/HelpManager.js';
|
import { helpManager } from './managers/HelpManager.js';
|
||||||
|
import { doctorManager } from './managers/DoctorManager.js';
|
||||||
import { bannerService } from './managers/BannerService.js';
|
import { bannerService } from './managers/BannerService.js';
|
||||||
import { initTheme, initBackToTop } from './utils/uiHelpers.js';
|
import { initTheme, initBackToTop } from './utils/uiHelpers.js';
|
||||||
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
|
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
|
||||||
@@ -58,6 +59,7 @@ export class AppCore {
|
|||||||
const exampleImagesManager = new ExampleImagesManager();
|
const exampleImagesManager = new ExampleImagesManager();
|
||||||
window.exampleImagesManager = exampleImagesManager;
|
window.exampleImagesManager = exampleImagesManager;
|
||||||
window.helpManager = helpManager;
|
window.helpManager = helpManager;
|
||||||
|
window.doctorManager = doctorManager;
|
||||||
window.moveManager = moveManager;
|
window.moveManager = moveManager;
|
||||||
window.bulkManager = bulkManager;
|
window.bulkManager = bulkManager;
|
||||||
|
|
||||||
@@ -77,6 +79,7 @@ export class AppCore {
|
|||||||
exampleImagesManager.initialize();
|
exampleImagesManager.initialize();
|
||||||
// Initialize the help manager
|
// Initialize the help manager
|
||||||
helpManager.initialize();
|
helpManager.initialize();
|
||||||
|
doctorManager.initialize();
|
||||||
|
|
||||||
const cardInfoDisplay = state.global.settings.card_info_display || 'always';
|
const cardInfoDisplay = state.global.settings.card_info_display || 'always';
|
||||||
document.body.classList.toggle('hover-reveal', cardInfoDisplay === 'hover');
|
document.body.classList.toggle('hover-reveal', cardInfoDisplay === 'hover');
|
||||||
|
|||||||
387
static/js/managers/DoctorManager.js
Normal file
387
static/js/managers/DoctorManager.js
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
import { modalManager } from './ModalManager.js';
|
||||||
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
|
import { escapeHtml } from '../components/shared/utils.js';
|
||||||
|
|
||||||
|
const MAX_CONSOLE_ENTRIES = 200;
|
||||||
|
|
||||||
|
function stringifyConsoleArg(value) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch (_error) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DoctorManager {
|
||||||
|
constructor() {
|
||||||
|
this.initialized = false;
|
||||||
|
this.lastDiagnostics = null;
|
||||||
|
this.consoleEntries = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
initialize() {
|
||||||
|
if (this.initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.triggerButton = document.getElementById('doctorTriggerBtn');
|
||||||
|
this.badge = document.getElementById('doctorStatusBadge');
|
||||||
|
this.modal = document.getElementById('doctorModal');
|
||||||
|
this.issuesList = document.getElementById('doctorIssuesList');
|
||||||
|
this.summaryText = document.getElementById('doctorSummaryText');
|
||||||
|
this.summaryBadge = document.getElementById('doctorSummaryBadge');
|
||||||
|
this.loadingState = document.getElementById('doctorLoadingState');
|
||||||
|
this.refreshButton = document.getElementById('doctorRefreshBtn');
|
||||||
|
this.exportButton = document.getElementById('doctorExportBtn');
|
||||||
|
|
||||||
|
this.installConsoleCapture();
|
||||||
|
this.bindEvents();
|
||||||
|
this.initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
if (this.triggerButton) {
|
||||||
|
this.triggerButton.addEventListener('click', async () => {
|
||||||
|
modalManager.showModal('doctorModal');
|
||||||
|
await this.refreshDiagnostics();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.refreshButton) {
|
||||||
|
this.refreshButton.addEventListener('click', async () => {
|
||||||
|
await this.refreshDiagnostics();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.exportButton) {
|
||||||
|
this.exportButton.addEventListener('click', async () => {
|
||||||
|
await this.exportBundle();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
installConsoleCapture() {
|
||||||
|
if (window.__lmDoctorConsolePatched) {
|
||||||
|
this.consoleEntries = window.__lmDoctorConsoleEntries || [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalConsole = {};
|
||||||
|
const levels = ['log', 'info', 'warn', 'error', 'debug'];
|
||||||
|
window.__lmDoctorConsoleEntries = this.consoleEntries;
|
||||||
|
|
||||||
|
levels.forEach((level) => {
|
||||||
|
const original = console[level]?.bind(console);
|
||||||
|
originalConsole[level] = original;
|
||||||
|
|
||||||
|
console[level] = (...args) => {
|
||||||
|
this.consoleEntries.push({
|
||||||
|
level,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
message: args.map(stringifyConsoleArg).join(' '),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.consoleEntries.length > MAX_CONSOLE_ENTRIES) {
|
||||||
|
this.consoleEntries.splice(0, this.consoleEntries.length - MAX_CONSOLE_ENTRIES);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (original) {
|
||||||
|
original(...args);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
window.__lmDoctorConsolePatched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
getClientVersion() {
|
||||||
|
return document.body?.dataset?.appVersion || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(isLoading) {
|
||||||
|
if (this.loadingState) {
|
||||||
|
this.loadingState.classList.toggle('visible', isLoading);
|
||||||
|
}
|
||||||
|
if (this.refreshButton) {
|
||||||
|
this.refreshButton.disabled = isLoading;
|
||||||
|
}
|
||||||
|
if (this.exportButton) {
|
||||||
|
this.exportButton.disabled = isLoading;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshDiagnostics({ silent = false } = {}) {
|
||||||
|
this.setLoading(true);
|
||||||
|
try {
|
||||||
|
const clientVersion = encodeURIComponent(this.getClientVersion());
|
||||||
|
const response = await fetch(`/api/lm/doctor/diagnostics?clientVersion=${clientVersion}`);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || payload.success === false) {
|
||||||
|
throw new Error(payload.error || 'Failed to load doctor diagnostics');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastDiagnostics = payload;
|
||||||
|
this.updateTriggerState(payload.summary);
|
||||||
|
this.renderDiagnostics(payload);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Doctor diagnostics failed:', error);
|
||||||
|
if (!silent) {
|
||||||
|
showToast('doctor.toast.loadFailed', { message: error.message }, 'error');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTriggerState(summary = {}) {
|
||||||
|
if (!this.badge || !this.triggerButton) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const issueCount = Number(summary.issue_count || 0);
|
||||||
|
this.badge.textContent = issueCount > 9 ? '9+' : String(issueCount);
|
||||||
|
this.badge.classList.toggle('hidden', issueCount === 0);
|
||||||
|
|
||||||
|
this.triggerButton.classList.remove('doctor-status-warning', 'doctor-status-error');
|
||||||
|
if (summary.status === 'error') {
|
||||||
|
this.triggerButton.classList.add('doctor-status-error');
|
||||||
|
} else if (summary.status === 'warning') {
|
||||||
|
this.triggerButton.classList.add('doctor-status-warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderDiagnostics(payload) {
|
||||||
|
if (!this.modal || !this.issuesList || !this.summaryText || !this.summaryBadge) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { summary = {}, diagnostics = [] } = payload;
|
||||||
|
this.summaryText.textContent = this.getSummaryText(summary);
|
||||||
|
this.summaryBadge.className = `doctor-summary-badge ${this.getStatusClass(summary.status)}`;
|
||||||
|
this.summaryBadge.innerHTML = `
|
||||||
|
<i class="fas ${summary.status === 'error' ? 'fa-triangle-exclamation' : summary.status === 'warning' ? 'fa-stethoscope' : 'fa-heartbeat'}"></i>
|
||||||
|
<span>${escapeHtml(this.getStatusLabel(summary.status))}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.issuesList.innerHTML = diagnostics.map((item) => this.renderIssueCard(item)).join('');
|
||||||
|
this.attachIssueActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
getSummaryText(summary) {
|
||||||
|
if (summary.status === 'error') {
|
||||||
|
return translate(
|
||||||
|
'doctor.summary.error',
|
||||||
|
{ count: summary.issue_count || 0 },
|
||||||
|
`${summary.issue_count || 0} issue(s) need attention before the app is fully healthy.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.status === 'warning') {
|
||||||
|
return translate(
|
||||||
|
'doctor.summary.warning',
|
||||||
|
{ count: summary.issue_count || 0 },
|
||||||
|
`${summary.issue_count || 0} issue(s) were found. Most can be fixed directly from this panel.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return translate(
|
||||||
|
'doctor.summary.ok',
|
||||||
|
{},
|
||||||
|
'No active issues were found in the current environment.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusClass(status) {
|
||||||
|
if (status === 'error') {
|
||||||
|
return 'doctor-status-error';
|
||||||
|
}
|
||||||
|
if (status === 'warning') {
|
||||||
|
return 'doctor-status-warning';
|
||||||
|
}
|
||||||
|
return 'doctor-status-ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusLabel(status) {
|
||||||
|
return translate(`doctor.status.${status || 'ok'}`, {}, status || 'ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
renderIssueCard(item) {
|
||||||
|
const status = item.status || 'ok';
|
||||||
|
const tagLabel = this.getStatusLabel(status);
|
||||||
|
const details = Array.isArray(item.details) ? item.details : [];
|
||||||
|
const listItems = details
|
||||||
|
.filter((detail) => typeof detail === 'string')
|
||||||
|
.map((detail) => `<li>${escapeHtml(detail)}</li>`)
|
||||||
|
.join('');
|
||||||
|
const inlineDetails = details
|
||||||
|
.filter((detail) => detail && typeof detail === 'object')
|
||||||
|
.map((detail) => this.renderInlineDetail(detail))
|
||||||
|
.join('');
|
||||||
|
const actions = (item.actions || [])
|
||||||
|
.map((action) => `
|
||||||
|
<button class="${action.id === 'repair-cache' || action.id === 'reload-page' ? 'primary-btn' : 'secondary-btn'}" data-doctor-action="${escapeHtml(action.id)}">
|
||||||
|
${escapeHtml(action.label)}
|
||||||
|
</button>
|
||||||
|
`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<section class="doctor-issue-card" data-status="${escapeHtml(status)}" data-issue-id="${escapeHtml(item.id || '')}">
|
||||||
|
<div class="doctor-issue-header">
|
||||||
|
<div>
|
||||||
|
<h3>${escapeHtml(item.title || '')}</h3>
|
||||||
|
<p class="doctor-issue-summary">${escapeHtml(item.summary || '')}</p>
|
||||||
|
</div>
|
||||||
|
<span class="doctor-issue-tag">${escapeHtml(tagLabel)}</span>
|
||||||
|
</div>
|
||||||
|
${inlineDetails ? `<div class="doctor-inline-detail-grid">${inlineDetails}</div>` : ''}
|
||||||
|
${listItems ? `<ul class="doctor-issue-details">${listItems}</ul>` : ''}
|
||||||
|
${actions ? `<div class="doctor-issue-actions">${actions}</div>` : ''}
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderInlineDetail(detail) {
|
||||||
|
if (detail.client_version || detail.server_version) {
|
||||||
|
return `
|
||||||
|
<div class="doctor-inline-detail">
|
||||||
|
<strong>${escapeHtml(translate('common.status.version', {}, 'Version'))}</strong>
|
||||||
|
<div>${escapeHtml(`Client: ${detail.client_version || 'unknown'}`)}</div>
|
||||||
|
<div>${escapeHtml(`Server: ${detail.server_version || 'unknown'}`)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = detail.label || detail.model_type || detail.client_version || detail.server_version || 'Detail';
|
||||||
|
const message = detail.message
|
||||||
|
|| detail.corruption_rate
|
||||||
|
|| detail.server_version
|
||||||
|
|| detail.client_version
|
||||||
|
|| '';
|
||||||
|
|
||||||
|
if (detail.model_type) {
|
||||||
|
return `
|
||||||
|
<div class="doctor-inline-detail">
|
||||||
|
<strong>${escapeHtml(detail.label || detail.model_type)}</strong>
|
||||||
|
<div>${escapeHtml(detail.message || '')}</div>
|
||||||
|
${detail.corruption_rate ? `<div>${escapeHtml(detail.corruption_rate)} invalid</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="doctor-inline-detail">
|
||||||
|
<strong>${escapeHtml(label)}</strong>
|
||||||
|
<div>${escapeHtml(message)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
attachIssueActions() {
|
||||||
|
this.issuesList.querySelectorAll('[data-doctor-action]').forEach((button) => {
|
||||||
|
button.addEventListener('click', async (event) => {
|
||||||
|
const action = event.currentTarget.dataset.doctorAction;
|
||||||
|
await this.handleAction(action);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleAction(action) {
|
||||||
|
switch (action) {
|
||||||
|
case 'open-settings':
|
||||||
|
modalManager.showModal('settingsModal');
|
||||||
|
window.setTimeout(() => {
|
||||||
|
const input = document.getElementById('civitaiApiKey');
|
||||||
|
if (input) {
|
||||||
|
input.focus();
|
||||||
|
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
break;
|
||||||
|
case 'repair-cache':
|
||||||
|
await this.repairCache();
|
||||||
|
break;
|
||||||
|
case 'reload-page':
|
||||||
|
window.location.reload();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async repairCache() {
|
||||||
|
try {
|
||||||
|
this.setLoading(true);
|
||||||
|
const response = await fetch('/api/lm/doctor/repair-cache', { method: 'POST' });
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || payload.success === false) {
|
||||||
|
throw new Error(payload.error || translate('doctor.toast.repairFailed', {}, 'Cache rebuild failed.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('doctor.toast.repairSuccess', {}, 'success');
|
||||||
|
await this.refreshDiagnostics({ silent: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Doctor cache repair failed:', error);
|
||||||
|
showToast('doctor.toast.repairFailed', { message: error.message }, 'error');
|
||||||
|
} finally {
|
||||||
|
this.setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportBundle() {
|
||||||
|
try {
|
||||||
|
this.setLoading(true);
|
||||||
|
const response = await fetch('/api/lm/doctor/export-bundle', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
summary: this.lastDiagnostics?.summary || null,
|
||||||
|
diagnostics: this.lastDiagnostics?.diagnostics || [],
|
||||||
|
frontend_logs: this.consoleEntries,
|
||||||
|
client_context: {
|
||||||
|
url: window.location.href,
|
||||||
|
user_agent: navigator.userAgent,
|
||||||
|
language: navigator.language,
|
||||||
|
app_version: this.getClientVersion(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(payload.error || 'Failed to export diagnostics bundle');
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const disposition = response.headers.get('Content-Disposition') || '';
|
||||||
|
const match = disposition.match(/filename=\"([^\"]+)\"/);
|
||||||
|
const filename = match?.[1] || 'lora-manager-doctor.zip';
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = filename;
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
showToast('doctor.toast.exportSuccess', {}, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Doctor export failed:', error);
|
||||||
|
showToast('doctor.toast.exportFailed', { message: error.message }, 'error');
|
||||||
|
} finally {
|
||||||
|
this.setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const doctorManager = new DoctorManager();
|
||||||
@@ -84,6 +84,18 @@ export class ModalManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const doctorModal = document.getElementById('doctorModal');
|
||||||
|
if (doctorModal) {
|
||||||
|
this.registerModal('doctorModal', {
|
||||||
|
element: doctorModal,
|
||||||
|
onClose: () => {
|
||||||
|
this.getModal('doctorModal').element.style.display = 'none';
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
},
|
||||||
|
closeOnOutsideClick: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Add moveModal registration
|
// Add moveModal registration
|
||||||
const moveModal = document.getElementById('moveModal');
|
const moveModal = document.getElementById('moveModal');
|
||||||
if (moveModal) {
|
if (moveModal) {
|
||||||
|
|||||||
@@ -361,6 +361,13 @@ export class SettingsManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openBackupLocationButton = document.getElementById('backupOpenLocationBtn');
|
||||||
|
if (openBackupLocationButton) {
|
||||||
|
openBackupLocationButton.addEventListener('click', () => {
|
||||||
|
this.openBackupLocation();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
['lora', 'checkpoint', 'embedding'].forEach(modelType => {
|
['lora', 'checkpoint', 'embedding'].forEach(modelType => {
|
||||||
const customInput = document.getElementById(`${modelType}CustomTemplate`);
|
const customInput = document.getElementById(`${modelType}CustomTemplate`);
|
||||||
if (customInput) {
|
if (customInput) {
|
||||||
@@ -742,6 +749,35 @@ export class SettingsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async openBackupLocation() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/lm/backup/open-location', {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Request failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.mode === 'clipboard' && data.path) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(data.path);
|
||||||
|
showToast('settings.backup.locationCopied', { path: data.path }, 'success');
|
||||||
|
} catch (clipboardErr) {
|
||||||
|
console.warn('Clipboard API not available:', clipboardErr);
|
||||||
|
showToast('settings.backup.locationClipboardFallback', { path: data.path }, 'info');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast('settings.backup.openFolderSuccess', {}, 'success');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to open backup folder:', error);
|
||||||
|
showToast('settings.backup.openFolderFailed', {}, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async loadSettingsToUI() {
|
async loadSettingsToUI() {
|
||||||
// Set frontend settings from state
|
// Set frontend settings from state
|
||||||
const blurMatureContentCheckbox = document.getElementById('blurMatureContent');
|
const blurMatureContentCheckbox = document.getElementById('blurMatureContent');
|
||||||
@@ -766,6 +802,11 @@ export class SettingsManager {
|
|||||||
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings;
|
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const recipesPathInput = document.getElementById('recipesPath');
|
||||||
|
if (recipesPathInput) {
|
||||||
|
recipesPathInput.value = state.global.settings.recipes_path || '';
|
||||||
|
}
|
||||||
|
|
||||||
const autoOrganizeExclusionsInput = document.getElementById('autoOrganizeExclusions');
|
const autoOrganizeExclusionsInput = document.getElementById('autoOrganizeExclusions');
|
||||||
if (autoOrganizeExclusionsInput) {
|
if (autoOrganizeExclusionsInput) {
|
||||||
const patterns = this.normalizePatternList(state.global.settings.auto_organize_exclusions);
|
const patterns = this.normalizePatternList(state.global.settings.auto_organize_exclusions);
|
||||||
@@ -873,6 +914,9 @@ export class SettingsManager {
|
|||||||
// Load metadata archive settings
|
// Load metadata archive settings
|
||||||
await this.loadMetadataArchiveSettings();
|
await this.loadMetadataArchiveSettings();
|
||||||
|
|
||||||
|
// Load backup settings
|
||||||
|
await this.loadBackupSettings();
|
||||||
|
|
||||||
// Load base model path mappings
|
// Load base model path mappings
|
||||||
this.loadBaseModelMappings();
|
this.loadBaseModelMappings();
|
||||||
|
|
||||||
@@ -1852,6 +1896,10 @@ export class SettingsManager {
|
|||||||
await this.updateMetadataArchiveStatus();
|
await this.updateMetadataArchiveStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (settingKey === 'backup_auto_enabled') {
|
||||||
|
await this.updateBackupStatus();
|
||||||
|
}
|
||||||
|
|
||||||
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
|
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
|
||||||
|
|
||||||
// Apply frontend settings immediately
|
// Apply frontend settings immediately
|
||||||
@@ -1940,6 +1988,163 @@ export class SettingsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async loadBackupSettings() {
|
||||||
|
const backupAutoEnabledCheckbox = document.getElementById('backupAutoEnabled');
|
||||||
|
if (backupAutoEnabledCheckbox) {
|
||||||
|
backupAutoEnabledCheckbox.checked = state.global.settings.backup_auto_enabled ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backupRetentionCountInput = document.getElementById('backupRetentionCount');
|
||||||
|
if (backupRetentionCountInput) {
|
||||||
|
backupRetentionCountInput.value = state.global.settings.backup_retention_count ?? 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.updateBackupStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateBackupStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/lm/backup/status');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const statusContainer = document.getElementById('backupStatus');
|
||||||
|
if (!statusContainer || !data.success) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = data.status || {};
|
||||||
|
const latestAutoSnapshot = status.latestAutoSnapshot;
|
||||||
|
const retentionCount = status.retentionCount ?? state.global.settings.backup_retention_count ?? 5;
|
||||||
|
const enabled = status.enabled ?? state.global.settings.backup_auto_enabled ?? true;
|
||||||
|
const backupDir = status.backupDir || '';
|
||||||
|
const backupLocationPath = document.getElementById('backupLocationPath');
|
||||||
|
if (backupLocationPath) {
|
||||||
|
backupLocationPath.textContent = backupDir;
|
||||||
|
backupLocationPath.title = backupDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTimestamp = (timestamp) => {
|
||||||
|
if (!timestamp) {
|
||||||
|
return translate('common.status.unknown', {}, 'Unknown');
|
||||||
|
}
|
||||||
|
return new Date(timestamp * 1000).toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSnapshotDetail = (snapshot) => {
|
||||||
|
if (!snapshot) {
|
||||||
|
return translate('settings.backup.noneAvailable', {}, 'No snapshots yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
const size = typeof snapshot.size === 'number' ? ` (${this.formatFileSize(snapshot.size)})` : '';
|
||||||
|
return `${snapshot.name}${size}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
statusContainer.innerHTML = `
|
||||||
|
<div class="backup-summary-grid">
|
||||||
|
<div class="backup-summary-card">
|
||||||
|
<div class="backup-summary-label">${translate('settings.backup.autoEnabled', {}, 'Automatic snapshots')}</div>
|
||||||
|
<div class="backup-summary-value status-${enabled ? 'enabled' : 'disabled'}">
|
||||||
|
${enabled ? translate('common.status.enabled') : translate('common.status.disabled')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="backup-summary-card">
|
||||||
|
<div class="backup-summary-label">${translate('settings.backup.retention', {}, 'Retention')}</div>
|
||||||
|
<div class="backup-summary-value">${retentionCount}</div>
|
||||||
|
</div>
|
||||||
|
<div class="backup-summary-card">
|
||||||
|
<div class="backup-summary-label">${translate('settings.backup.snapshotCount', {}, 'Saved snapshots')}</div>
|
||||||
|
<div class="backup-summary-value">${status.snapshotCount ?? 0}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="backup-status-list">
|
||||||
|
<div class="backup-status-row">
|
||||||
|
<div class="backup-status-label">${translate('settings.backup.latestAutoSnapshot', {}, 'Latest auto snapshot')}</div>
|
||||||
|
<div class="backup-status-content">
|
||||||
|
<div class="backup-status-primary">${formatTimestamp(latestAutoSnapshot?.mtime)}</div>
|
||||||
|
<div class="backup-status-secondary">${renderSnapshotDetail(latestAutoSnapshot)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating backup status:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportBackup() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/lm/backup/export', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Request failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const contentDisposition = response.headers.get('Content-Disposition') || '';
|
||||||
|
const match = contentDisposition.match(/filename="([^"]+)"/);
|
||||||
|
const filename = match?.[1] || `lora-manager-backup-${Date.now()}.zip`;
|
||||||
|
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
showToast('settings.backup.exportSuccess', {}, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to export backup:', error);
|
||||||
|
showToast('settings.backup.exportFailed', { message: error.message }, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerBackupImport() {
|
||||||
|
const input = document.getElementById('backupImportInput');
|
||||||
|
input?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleBackupImportFile(input) {
|
||||||
|
if (!(input instanceof HTMLInputElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = input.files?.[0];
|
||||||
|
input.value = '';
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(translate('settings.backup.importConfirm', {}, 'Import this backup and overwrite local user state?'))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('archive', file);
|
||||||
|
|
||||||
|
const response = await fetch('/api/lm/backup/import', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok || data.success === false) {
|
||||||
|
throw new Error(data.error || `Request failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('settings.backup.importSuccess', {}, 'success');
|
||||||
|
await this.updateBackupStatus();
|
||||||
|
window.location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to import backup:', error);
|
||||||
|
showToast('settings.backup.importFailed', { message: error.message }, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async updateMetadataArchiveStatus() {
|
async updateMetadataArchiveStatus() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/lm/metadata-archive-status');
|
const response = await fetch('/api/lm/metadata-archive-status');
|
||||||
@@ -2464,14 +2669,24 @@ export class SettingsManager {
|
|||||||
if (!element) return;
|
if (!element) return;
|
||||||
|
|
||||||
const value = element.value.trim(); // Trim whitespace
|
const value = element.value.trim(); // Trim whitespace
|
||||||
|
const shouldShowLoading = settingKey === 'recipes_path';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if value has changed from existing value
|
// Check if value has changed from existing value
|
||||||
const currentValue = state.global.settings[settingKey] || '';
|
const currentValue = state.global.settings[settingKey];
|
||||||
if (value === currentValue) {
|
const normalizedCurrentValue = currentValue === undefined || currentValue === null
|
||||||
|
? ''
|
||||||
|
: String(currentValue).trim();
|
||||||
|
if (value === normalizedCurrentValue) {
|
||||||
return; // No change, exit early
|
return; // No change, exit early
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldShowLoading) {
|
||||||
|
state.loadingManager?.showSimpleLoading(
|
||||||
|
translate('settings.folderSettings.recipesPathMigrating', {}, 'Migrating recipes...')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// For username and password, handle empty values specially
|
// For username and password, handle empty values specially
|
||||||
if ((settingKey === 'proxy_username' || settingKey === 'proxy_password') && value === '') {
|
if ((settingKey === 'proxy_username' || settingKey === 'proxy_password') && value === '') {
|
||||||
// Remove from state instead of setting to empty string
|
// Remove from state instead of setting to empty string
|
||||||
@@ -2497,12 +2712,30 @@ export class SettingsManager {
|
|||||||
await this.saveSetting(settingKey, value);
|
await this.saveSetting(settingKey, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldShowLoading) {
|
||||||
|
state.loadingManager?.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settingKey === 'recipes_path') {
|
||||||
|
showToast('toast.settings.recipesPathUpdated', {}, 'success');
|
||||||
|
} else if (settingKey === 'backup_retention_count') {
|
||||||
|
await this.updateBackupStatus();
|
||||||
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
|
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
|
||||||
|
} else {
|
||||||
|
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (shouldShowLoading) {
|
||||||
|
state.loadingManager?.hide();
|
||||||
|
}
|
||||||
|
if (settingKey === 'recipes_path') {
|
||||||
|
showToast('toast.settings.recipesPathSaveFailed', { message: error.message }, 'error');
|
||||||
|
} else {
|
||||||
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
|
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async saveLanguageSetting() {
|
async saveLanguageSetting() {
|
||||||
const element = document.getElementById('languageSelect');
|
const element = document.getElementById('languageSelect');
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
|||||||
default_lora_root: '',
|
default_lora_root: '',
|
||||||
default_checkpoint_root: '',
|
default_checkpoint_root: '',
|
||||||
default_embedding_root: '',
|
default_embedding_root: '',
|
||||||
|
recipes_path: '',
|
||||||
base_model_path_mappings: {},
|
base_model_path_mappings: {},
|
||||||
download_path_templates: {},
|
download_path_templates: {},
|
||||||
example_images_path: '',
|
example_images_path: '',
|
||||||
@@ -40,6 +41,8 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
|||||||
metadata_refresh_skip_paths: [],
|
metadata_refresh_skip_paths: [],
|
||||||
skip_previously_downloaded_model_versions: false,
|
skip_previously_downloaded_model_versions: false,
|
||||||
download_skip_base_models: [],
|
download_skip_base_models: [],
|
||||||
|
backup_auto_enabled: true,
|
||||||
|
backup_retention_count: 5,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function createDefaultSettings() {
|
export function createDefaultSettings() {
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
{% set page_id = self.page_id() %}
|
{% set page_id = self.page_id() %}
|
||||||
<body data-page="{{ page_id }}">
|
<body data-page="{{ page_id }}" data-app-version="{{ version }}">
|
||||||
<!-- Header is always visible, even during initialization -->
|
<!-- Header is always visible, even during initialization -->
|
||||||
{% include 'components/header.html' %}
|
{% include 'components/header.html' %}
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="controls-right">
|
<div class="controls-right">
|
||||||
|
<div class="control-group doctor-control-group">
|
||||||
|
<button id="doctorTriggerBtn" class="doctor-trigger" title="{{ t('doctor.buttonTitle', default='Run diagnostics and common fixes') }}">
|
||||||
|
<i class="fas fa-stethoscope"></i>
|
||||||
|
<span>{{ t('doctor.title', default='Doctor') }}</span>
|
||||||
|
<span id="doctorStatusBadge" class="doctor-status-badge hidden" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="keyboard-nav-hint tooltip">
|
<div class="keyboard-nav-hint tooltip">
|
||||||
<i class="fas fa-keyboard"></i>
|
<i class="fas fa-keyboard"></i>
|
||||||
<span class="tooltiptext">
|
<span class="tooltiptext">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
{% include 'components/modals/confirm_modals.html' %}
|
{% include 'components/modals/confirm_modals.html' %}
|
||||||
{% include 'components/modals/settings_modal.html' %}
|
{% include 'components/modals/settings_modal.html' %}
|
||||||
|
{% include 'components/modals/doctor_modal.html' %}
|
||||||
{% include 'components/modals/support_modal.html' %}
|
{% include 'components/modals/support_modal.html' %}
|
||||||
{% include 'components/modals/update_modal.html' %}
|
{% include 'components/modals/update_modal.html' %}
|
||||||
{% include 'components/modals/help_modal.html' %}
|
{% include 'components/modals/help_modal.html' %}
|
||||||
|
|||||||
40
templates/components/modals/doctor_modal.html
Normal file
40
templates/components/modals/doctor_modal.html
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<div id="doctorModal" class="modal">
|
||||||
|
<div class="modal-content doctor-modal">
|
||||||
|
<button class="close" onclick="modalManager.closeModal('doctorModal')">×</button>
|
||||||
|
|
||||||
|
<div class="doctor-shell">
|
||||||
|
<div class="doctor-hero">
|
||||||
|
<div class="doctor-hero-copy">
|
||||||
|
<span class="doctor-kicker">{{ t('doctor.kicker', default='System diagnostics') }}</span>
|
||||||
|
<h2>{{ t('doctor.title', default='Doctor') }}</h2>
|
||||||
|
<p id="doctorSummaryText">{{ t('doctor.summary.idle', default='Run a health check for settings, cache integrity, and UI consistency.') }}</p>
|
||||||
|
</div>
|
||||||
|
<div id="doctorSummaryBadge" class="doctor-summary-badge doctor-status-ok">
|
||||||
|
<i class="fas fa-heartbeat"></i>
|
||||||
|
<span>{{ t('doctor.status.ok', default='Healthy') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="doctorLoadingState" class="doctor-loading-state">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
|
<span>{{ t('doctor.loading', default='Checking environment...') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="doctorIssuesList" class="doctor-issues-list"></div>
|
||||||
|
|
||||||
|
<div class="doctor-footer">
|
||||||
|
<div class="doctor-footer-note">{{ t('doctor.footer', default='Export a diagnostics bundle if the issues remain after repair.') }}</div>
|
||||||
|
<div class="doctor-footer-actions">
|
||||||
|
<button id="doctorRefreshBtn" class="secondary-btn">
|
||||||
|
<i class="fas fa-sync-alt"></i>
|
||||||
|
<span>{{ t('doctor.actions.runAgain', default='Run Again') }}</span>
|
||||||
|
</button>
|
||||||
|
<button id="doctorExportBtn" class="primary-btn">
|
||||||
|
<i class="fas fa-file-export"></i>
|
||||||
|
<span>{{ t('doctor.actions.exportBundle', default='Export Bundle') }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -114,6 +114,96 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Backup -->
|
||||||
|
<div class="settings-subsection">
|
||||||
|
<div class="settings-subsection-header">
|
||||||
|
<h4>{{ t('settings.sections.backup') }}</h4>
|
||||||
|
</div>
|
||||||
|
<div class="settings-help-text subtle">
|
||||||
|
{{ t('settings.backup.scopeHelp') }}
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="backupAutoEnabled">
|
||||||
|
{{ t('settings.backup.autoEnabled') }}
|
||||||
|
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.backup.autoEnabledHelp') }}"></i>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="backupAutoEnabled"
|
||||||
|
onchange="settingsManager.saveToggleSetting('backupAutoEnabled', 'backup_auto_enabled')">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="backupRetentionCount">
|
||||||
|
{{ t('settings.backup.retention') }}
|
||||||
|
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.backup.retentionHelp') }}"></i>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<div class="text-input-wrapper">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="backupRetentionCount"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
onblur="settingsManager.saveInputSetting('backupRetentionCount', 'backup_retention_count')"
|
||||||
|
onkeydown="if(event.key === 'Enter') { this.blur(); }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label>
|
||||||
|
{{ t('settings.backup.management') }}
|
||||||
|
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.backup.managementHelp') }}"></i>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<button type="button" class="secondary-btn" onclick="settingsManager.exportBackup()">
|
||||||
|
{{ t('settings.backup.exportButton') }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="secondary-btn" onclick="settingsManager.triggerBackupImport()" style="margin-left: 10px;">
|
||||||
|
{{ t('settings.backup.importButton') }}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="backupImportInput"
|
||||||
|
accept=".zip,application/zip"
|
||||||
|
style="display: none;"
|
||||||
|
onchange="settingsManager.handleBackupImportFile(this)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<details class="backup-location-details">
|
||||||
|
<summary>{{ t('settings.backup.locationSummary') }}</summary>
|
||||||
|
<div class="backup-location-panel">
|
||||||
|
<code id="backupLocationPath" class="backup-location-path"></code>
|
||||||
|
<button type="button" class="secondary-btn" id="backupOpenLocationBtn">
|
||||||
|
{{ t('settings.backup.openFolderButton') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="backup-status" id="backupStatus">
|
||||||
|
<!-- Status will be populated by JavaScript -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Proxy Settings -->
|
<!-- Proxy Settings -->
|
||||||
<div class="settings-subsection">
|
<div class="settings-subsection">
|
||||||
<div class="settings-subsection-header">
|
<div class="settings-subsection-header">
|
||||||
@@ -450,6 +540,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Section 3: Library -->
|
<!-- Section 3: Library -->
|
||||||
@@ -530,6 +621,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recipe Settings -->
|
||||||
|
<div class="settings-subsection">
|
||||||
|
<div class="settings-subsection-header">
|
||||||
|
<h4>{{ t('settings.sections.recipeSettings') }}</h4>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="recipesPath">
|
||||||
|
{{ t('settings.folderSettings.recipesPath') }}
|
||||||
|
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.folderSettings.recipesPathHelp') }}"></i>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<div class="text-input-wrapper">
|
||||||
|
<input type="text" id="recipesPath"
|
||||||
|
placeholder="{{ t('settings.folderSettings.recipesPathPlaceholder') }}"
|
||||||
|
onblur="settingsManager.saveInputSetting('recipesPath', 'recipes_path')"
|
||||||
|
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Extra Folder Paths -->
|
<!-- Extra Folder Paths -->
|
||||||
|
|||||||
@@ -120,6 +120,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="controls-right">
|
<div class="controls-right">
|
||||||
|
<div class="control-group doctor-control-group">
|
||||||
|
<button id="doctorTriggerBtn" class="doctor-trigger" title="{{ t('doctor.buttonTitle', default='Run diagnostics and common fixes') }}">
|
||||||
|
<i class="fas fa-stethoscope"></i>
|
||||||
|
<span>{{ t('doctor.title', default='Doctor') }}</span>
|
||||||
|
<span id="doctorStatusBadge" class="doctor-status-badge hidden" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="keyboard-nav-hint tooltip">
|
<div class="keyboard-nav-hint tooltip">
|
||||||
<i class="fas fa-keyboard"></i>
|
<i class="fas fa-keyboard"></i>
|
||||||
<span class="tooltiptext">
|
<span class="tooltiptext">
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
if (key === 'loramanager.autocomplete_append_comma') {
|
if (key === 'loramanager.autocomplete_append_comma') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (key === 'loramanager.autocomplete_auto_format') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (key === 'loramanager.autocomplete_accept_key') {
|
if (key === 'loramanager.autocomplete_accept_key') {
|
||||||
return 'both';
|
return 'both';
|
||||||
}
|
}
|
||||||
@@ -188,6 +191,59 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
expect(insertSelectionSpy).toHaveBeenCalledWith('example_completion');
|
expect(insertSelectionSpy).toHaveBeenCalledWith('example_completion');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('formats duplicate commas and extra spaces when the textarea loses focus', async () => {
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = 'foo bar, , baz ,, qux';
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
const inputListener = vi.fn();
|
||||||
|
input.addEventListener('input', inputListener);
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
new AutoComplete(input,'prompt', { showPreview: false });
|
||||||
|
|
||||||
|
input.dispatchEvent(new Event('blur', { bubbles: true }));
|
||||||
|
|
||||||
|
expect(input.value).toBe('foo bar, baz, qux');
|
||||||
|
expect(inputListener).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips blur formatting when autocomplete auto format is disabled', async () => {
|
||||||
|
settingGetMock.mockImplementation((key) => {
|
||||||
|
if (key === 'loramanager.autocomplete_append_comma') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (key === 'loramanager.autocomplete_auto_format') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (key === 'loramanager.autocomplete_accept_key') {
|
||||||
|
return 'both';
|
||||||
|
}
|
||||||
|
if (key === 'loramanager.prompt_tag_autocomplete') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (key === 'loramanager.tag_space_replacement') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = 'foo bar, , baz ,, qux';
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
const inputListener = vi.fn();
|
||||||
|
input.addEventListener('input', inputListener);
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
new AutoComplete(input,'prompt', { showPreview: false });
|
||||||
|
|
||||||
|
input.dispatchEvent(new Event('blur', { bubbles: true }));
|
||||||
|
|
||||||
|
expect(input.value).toBe('foo bar, , baz ,, qux');
|
||||||
|
expect(inputListener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('accepts the selected suggestion with Enter', async () => {
|
it('accepts the selected suggestion with Enter', async () => {
|
||||||
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
||||||
|
|
||||||
@@ -275,6 +331,9 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
if (key === 'loramanager.autocomplete_append_comma') {
|
if (key === 'loramanager.autocomplete_append_comma') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (key === 'loramanager.autocomplete_auto_format') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (key === 'loramanager.autocomplete_accept_key') {
|
if (key === 'loramanager.autocomplete_accept_key') {
|
||||||
return 'tab_only';
|
return 'tab_only';
|
||||||
}
|
}
|
||||||
@@ -322,6 +381,9 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
if (key === 'loramanager.autocomplete_append_comma') {
|
if (key === 'loramanager.autocomplete_append_comma') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (key === 'loramanager.autocomplete_auto_format') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (key === 'loramanager.autocomplete_accept_key') {
|
if (key === 'loramanager.autocomplete_accept_key') {
|
||||||
return 'enter_only';
|
return 'enter_only';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ vi.mock('../../../static/js/managers/HelpManager.js', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/managers/DoctorManager.js', () => ({
|
||||||
|
doctorManager: {
|
||||||
|
initialize: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../../../static/js/managers/BannerService.js', () => ({
|
vi.mock('../../../static/js/managers/BannerService.js', () => ({
|
||||||
bannerService: {
|
bannerService: {
|
||||||
initialize: vi.fn(),
|
initialize: vi.fn(),
|
||||||
@@ -103,6 +109,7 @@ import { moveManager } from '../../../static/js/managers/MoveManager.js';
|
|||||||
import { bulkManager } from '../../../static/js/managers/BulkManager.js';
|
import { bulkManager } from '../../../static/js/managers/BulkManager.js';
|
||||||
import { ExampleImagesManager } from '../../../static/js/managers/ExampleImagesManager.js';
|
import { ExampleImagesManager } from '../../../static/js/managers/ExampleImagesManager.js';
|
||||||
import { helpManager } from '../../../static/js/managers/HelpManager.js';
|
import { helpManager } from '../../../static/js/managers/HelpManager.js';
|
||||||
|
import { doctorManager } from '../../../static/js/managers/DoctorManager.js';
|
||||||
import { bannerService } from '../../../static/js/managers/BannerService.js';
|
import { bannerService } from '../../../static/js/managers/BannerService.js';
|
||||||
import { initTheme, initBackToTop } from '../../../static/js/utils/uiHelpers.js';
|
import { initTheme, initBackToTop } from '../../../static/js/utils/uiHelpers.js';
|
||||||
import { onboardingManager } from '../../../static/js/managers/OnboardingManager.js';
|
import { onboardingManager } from '../../../static/js/managers/OnboardingManager.js';
|
||||||
@@ -187,6 +194,7 @@ describe('AppCore initialization flow', () => {
|
|||||||
delete window.helpManager;
|
delete window.helpManager;
|
||||||
delete window.moveManager;
|
delete window.moveManager;
|
||||||
delete window.bulkManager;
|
delete window.bulkManager;
|
||||||
|
delete window.doctorManager;
|
||||||
delete window.headerManager;
|
delete window.headerManager;
|
||||||
delete window.i18n;
|
delete window.i18n;
|
||||||
delete window.pageContextMenu;
|
delete window.pageContextMenu;
|
||||||
@@ -214,6 +222,7 @@ describe('AppCore initialization flow', () => {
|
|||||||
expect(bannerService.initialize).toHaveBeenCalledTimes(1);
|
expect(bannerService.initialize).toHaveBeenCalledTimes(1);
|
||||||
expect(window.modalManager).toBe(modalManager);
|
expect(window.modalManager).toBe(modalManager);
|
||||||
expect(window.settingsManager).toBe(settingsManager);
|
expect(window.settingsManager).toBe(settingsManager);
|
||||||
|
expect(window.doctorManager).toBe(doctorManager);
|
||||||
expect(window.moveManager).toBe(moveManager);
|
expect(window.moveManager).toBe(moveManager);
|
||||||
expect(window.bulkManager).toBe(bulkManager);
|
expect(window.bulkManager).toBe(bulkManager);
|
||||||
expect(HeaderManager).toHaveBeenCalledTimes(1);
|
expect(HeaderManager).toHaveBeenCalledTimes(1);
|
||||||
@@ -227,6 +236,7 @@ describe('AppCore initialization flow', () => {
|
|||||||
expect(window.exampleImagesManager).toBe(exampleImagesManagerInstance);
|
expect(window.exampleImagesManager).toBe(exampleImagesManagerInstance);
|
||||||
expect(exampleImagesManagerInitialize).toHaveBeenCalledTimes(1);
|
expect(exampleImagesManagerInitialize).toHaveBeenCalledTimes(1);
|
||||||
expect(helpManager.initialize).toHaveBeenCalledTimes(1);
|
expect(helpManager.initialize).toHaveBeenCalledTimes(1);
|
||||||
|
expect(doctorManager.initialize).toHaveBeenCalledTimes(1);
|
||||||
expect(document.body.classList.contains('hover-reveal')).toBe(true);
|
expect(document.body.classList.contains('hover-reveal')).toBe(true);
|
||||||
expect(initializeEventManagement).toHaveBeenCalledTimes(1);
|
expect(initializeEventManagement).toHaveBeenCalledTimes(1);
|
||||||
expect(onboardingManager.start).not.toHaveBeenCalled();
|
expect(onboardingManager.start).not.toHaveBeenCalled();
|
||||||
|
|||||||
56
tests/frontend/managers/DoctorManager.test.js
Normal file
56
tests/frontend/managers/DoctorManager.test.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { mountMarkup, resetDom } from '../utils/domFixtures.js';
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
|
||||||
|
modalManager: {
|
||||||
|
showModal: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||||
|
showToast: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||||
|
translate: vi.fn((key, _params, fallback) => fallback || key),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/components/shared/utils.js', () => ({
|
||||||
|
escapeHtml: vi.fn((value) => String(value)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { DoctorManager } from '../../../static/js/managers/DoctorManager.js';
|
||||||
|
|
||||||
|
function renderDoctorFixture() {
|
||||||
|
mountMarkup(`
|
||||||
|
<button id="doctorTriggerBtn"></button>
|
||||||
|
<span id="doctorStatusBadge" class="hidden"></span>
|
||||||
|
<div id="doctorModal"></div>
|
||||||
|
<div id="doctorIssuesList"></div>
|
||||||
|
<div id="doctorSummaryText"></div>
|
||||||
|
<div id="doctorSummaryBadge"></div>
|
||||||
|
<div id="doctorLoadingState"></div>
|
||||||
|
<button id="doctorRefreshBtn"></button>
|
||||||
|
<button id="doctorExportBtn"></button>
|
||||||
|
`);
|
||||||
|
document.body.dataset.appVersion = '1.2.3-test';
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DoctorManager', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetDom();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
delete window.__lmDoctorConsolePatched;
|
||||||
|
delete window.__lmDoctorConsoleEntries;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not run diagnostics during initialize', () => {
|
||||||
|
renderDoctorFixture();
|
||||||
|
const manager = new DoctorManager();
|
||||||
|
const refreshSpy = vi.spyOn(manager, 'refreshDiagnostics').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
manager.initialize();
|
||||||
|
|
||||||
|
expect(refreshSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -205,4 +205,58 @@ describe('SettingsManager library controls', () => {
|
|||||||
expect(select.value).toBe('alpha');
|
expect(select.value).toBe('alpha');
|
||||||
expect(activateSpy).not.toHaveBeenCalled();
|
expect(activateSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('loads recipes_path into the settings input', async () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.id = 'recipesPath';
|
||||||
|
document.body.appendChild(input);
|
||||||
|
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
isAvailable: false,
|
||||||
|
isEnabled: false,
|
||||||
|
databaseSize: 0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
state.global.settings = {
|
||||||
|
recipes_path: '/custom/recipes',
|
||||||
|
};
|
||||||
|
|
||||||
|
await manager.loadSettingsToUI();
|
||||||
|
|
||||||
|
expect(input.value).toBe('/custom/recipes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows loading while saving recipes_path', async () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.id = 'recipesPath';
|
||||||
|
input.value = '/custom/recipes';
|
||||||
|
document.body.appendChild(input);
|
||||||
|
|
||||||
|
state.global.settings = {
|
||||||
|
recipes_path: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.saveInputSetting('recipesPath', 'recipes_path');
|
||||||
|
|
||||||
|
expect(state.loadingManager.showSimpleLoading).toHaveBeenCalledWith(
|
||||||
|
'Migrating recipes...'
|
||||||
|
);
|
||||||
|
expect(state.loadingManager.hide).toHaveBeenCalledTimes(1);
|
||||||
|
expect(showToast).toHaveBeenCalledWith(
|
||||||
|
'toast.settings.recipesPathUpdated',
|
||||||
|
{},
|
||||||
|
'success',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -98,6 +98,199 @@ def test_metadata_processor_extracts_generation_params(populated_registry, monke
|
|||||||
assert isinstance(value, str)
|
assert isinstance(value, str)
|
||||||
|
|
||||||
|
|
||||||
|
def test_attention_bias_clip_text_encode_prompts_are_collected(metadata_registry, monkeypatch):
|
||||||
|
import types
|
||||||
|
|
||||||
|
prompt_graph = {
|
||||||
|
"encode_pos": {
|
||||||
|
"class_type": "CLIPTextEncodeAttentionBias",
|
||||||
|
"inputs": {"text": "A <big dog=1.25> on a hill", "clip": ["clip", 0]},
|
||||||
|
},
|
||||||
|
"encode_neg": {
|
||||||
|
"class_type": "CLIPTextEncodeAttentionBias",
|
||||||
|
"inputs": {"text": "low quality", "clip": ["clip", 0]},
|
||||||
|
},
|
||||||
|
"sampler": {
|
||||||
|
"class_type": "KSampler",
|
||||||
|
"inputs": {
|
||||||
|
"seed": types.SimpleNamespace(seed=123),
|
||||||
|
"steps": 20,
|
||||||
|
"cfg": 7.0,
|
||||||
|
"sampler_name": "Euler",
|
||||||
|
"scheduler": "karras",
|
||||||
|
"denoise": 1.0,
|
||||||
|
"positive": ["encode_pos", 0],
|
||||||
|
"negative": ["encode_neg", 0],
|
||||||
|
"latent_image": {"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
prompt = SimpleNamespace(original_prompt=prompt_graph)
|
||||||
|
|
||||||
|
pos_conditioning = object()
|
||||||
|
neg_conditioning = object()
|
||||||
|
|
||||||
|
monkeypatch.setattr(metadata_processor, "standalone_mode", False)
|
||||||
|
|
||||||
|
metadata_registry.start_collection("prompt-attention")
|
||||||
|
metadata_registry.set_current_prompt(prompt)
|
||||||
|
|
||||||
|
metadata_registry.record_node_execution(
|
||||||
|
"encode_pos",
|
||||||
|
"CLIPTextEncodeAttentionBias",
|
||||||
|
{"text": "A <big dog=1.25> on a hill"},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
metadata_registry.update_node_execution(
|
||||||
|
"encode_pos", "CLIPTextEncodeAttentionBias", [(pos_conditioning,)]
|
||||||
|
)
|
||||||
|
metadata_registry.record_node_execution(
|
||||||
|
"encode_neg",
|
||||||
|
"CLIPTextEncodeAttentionBias",
|
||||||
|
{"text": "low quality"},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
metadata_registry.update_node_execution(
|
||||||
|
"encode_neg", "CLIPTextEncodeAttentionBias", [(neg_conditioning,)]
|
||||||
|
)
|
||||||
|
metadata_registry.record_node_execution(
|
||||||
|
"sampler",
|
||||||
|
"KSampler",
|
||||||
|
{
|
||||||
|
"seed": types.SimpleNamespace(seed=123),
|
||||||
|
"positive": pos_conditioning,
|
||||||
|
"negative": neg_conditioning,
|
||||||
|
"latent_image": {"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))},
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata = metadata_registry.get_metadata("prompt-attention")
|
||||||
|
sampler_data = metadata[SAMPLING]["sampler"]
|
||||||
|
prompt_results = MetadataProcessor.match_conditioning_to_prompts(metadata, "sampler")
|
||||||
|
|
||||||
|
assert metadata[PROMPTS]["encode_pos"]["text"] == "A <big dog=1.25> on a hill"
|
||||||
|
assert metadata[PROMPTS]["encode_neg"]["text"] == "low quality"
|
||||||
|
assert sampler_data["node_id"] == "sampler"
|
||||||
|
assert sampler_data["is_sampler"] is True
|
||||||
|
assert prompt_results["prompt"] == "A <big dog=1.25> on a hill"
|
||||||
|
assert prompt_results["negative_prompt"] == "low quality"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sampler_custom_advanced_recovers_prompt_text_through_guidance_nodes(metadata_registry, monkeypatch):
|
||||||
|
import types
|
||||||
|
|
||||||
|
prompt_graph = {
|
||||||
|
"encode_pos": {
|
||||||
|
"class_type": "CLIPTextEncodeAttentionBias",
|
||||||
|
"inputs": {
|
||||||
|
"text": "A low-angle, medium close-up portrait of her.",
|
||||||
|
"clip": ["clip", 0],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"encode_neg": {
|
||||||
|
"class_type": "CLIPTextEncodeAttentionBias",
|
||||||
|
"inputs": {
|
||||||
|
"text": " This low quality greyscale unfinished sketch is inaccurate and flawed. The image is very blurred and lacks detail with excessive chromatic aberrations and artifacts. The image is overly saturated with excessive bloom. It has a toony aesthetic with bold outlines and flat colors. ",
|
||||||
|
"clip": ["clip", 0],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"scheduled_cfg_guidance": {
|
||||||
|
"class_type": "ScheduledCFGGuidance",
|
||||||
|
"inputs": {
|
||||||
|
"model": ["model", 0],
|
||||||
|
"positive": ["encode_pos", 0],
|
||||||
|
"negative": ["encode_neg", 0],
|
||||||
|
"cfg": 2.6,
|
||||||
|
"start_percent": 0.0,
|
||||||
|
"end_percent": 0.62,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"sampler": {
|
||||||
|
"class_type": "SamplerCustomAdvanced",
|
||||||
|
"inputs": {
|
||||||
|
"noise": types.SimpleNamespace(seed=174),
|
||||||
|
"guider": ["scheduled_cfg_guidance", 0],
|
||||||
|
"sampler": ["sampler_select", 0],
|
||||||
|
"sigmas": ["scheduler", 0],
|
||||||
|
"latent_image": {"samples": types.SimpleNamespace(shape=(1, 4, 128, 128))},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"sampler_select": {
|
||||||
|
"class_type": "KSamplerSelect",
|
||||||
|
"inputs": {"sampler_name": "multistep/deis_2m"},
|
||||||
|
},
|
||||||
|
"scheduler": {
|
||||||
|
"class_type": "BasicScheduler",
|
||||||
|
"inputs": {"steps": 20, "scheduler": "power_shift", "denoise": 1.0},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
prompt = SimpleNamespace(original_prompt=prompt_graph)
|
||||||
|
|
||||||
|
pos_conditioning = object()
|
||||||
|
neg_conditioning = object()
|
||||||
|
|
||||||
|
monkeypatch.setattr(metadata_processor, "standalone_mode", False)
|
||||||
|
|
||||||
|
metadata_registry.start_collection("prompt-guidance")
|
||||||
|
metadata_registry.set_current_prompt(prompt)
|
||||||
|
|
||||||
|
metadata_registry.record_node_execution(
|
||||||
|
"encode_pos",
|
||||||
|
"CLIPTextEncodeAttentionBias",
|
||||||
|
{"text": "A low-angle, medium close-up portrait of her."},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
metadata_registry.update_node_execution(
|
||||||
|
"encode_pos", "CLIPTextEncodeAttentionBias", [(pos_conditioning,)]
|
||||||
|
)
|
||||||
|
metadata_registry.record_node_execution(
|
||||||
|
"encode_neg",
|
||||||
|
"CLIPTextEncodeAttentionBias",
|
||||||
|
{
|
||||||
|
"text": " This low quality greyscale unfinished sketch is inaccurate and flawed. The image is very blurred and lacks detail with excessive chromatic aberrations and artifacts. The image is overly saturated with excessive bloom. It has a toony aesthetic with bold outlines and flat colors. ",
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
metadata_registry.update_node_execution(
|
||||||
|
"encode_neg", "CLIPTextEncodeAttentionBias", [(neg_conditioning,)]
|
||||||
|
)
|
||||||
|
metadata_registry.record_node_execution(
|
||||||
|
"scheduled_cfg_guidance",
|
||||||
|
"ScheduledCFGGuidance",
|
||||||
|
{
|
||||||
|
"positive": pos_conditioning,
|
||||||
|
"negative": neg_conditioning,
|
||||||
|
"cfg": 2.6,
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
metadata_registry.record_node_execution(
|
||||||
|
"sampler",
|
||||||
|
"SamplerCustomAdvanced",
|
||||||
|
{
|
||||||
|
"noise": types.SimpleNamespace(seed=174),
|
||||||
|
"guider": {
|
||||||
|
"positive": pos_conditioning,
|
||||||
|
"negative": neg_conditioning,
|
||||||
|
},
|
||||||
|
"sampler": ["sampler_select", 0],
|
||||||
|
"sigmas": ["scheduler", 0],
|
||||||
|
"latent_image": {"samples": types.SimpleNamespace(shape=(1, 4, 128, 128))},
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata = metadata_registry.get_metadata("prompt-guidance")
|
||||||
|
params = MetadataProcessor.extract_generation_params(metadata)
|
||||||
|
|
||||||
|
assert params["prompt"] == "A low-angle, medium close-up portrait of her."
|
||||||
|
assert (
|
||||||
|
params["negative_prompt"]
|
||||||
|
== " This low quality greyscale unfinished sketch is inaccurate and flawed. The image is very blurred and lacks detail with excessive chromatic aberrations and artifacts. The image is overly saturated with excessive bloom. It has a toony aesthetic with bold outlines and flat colors. "
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_metadata_registry_caches_and_rehydrates(populated_registry):
|
def test_metadata_registry_caches_and_rehydrates(populated_registry):
|
||||||
registry = populated_registry["registry"]
|
registry = populated_registry["registry"]
|
||||||
prompt = populated_registry["prompt"]
|
prompt = populated_registry["prompt"]
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import io
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import zipfile
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
@@ -8,16 +11,24 @@ import pytest
|
|||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
from py.routes.handlers.misc_handlers import (
|
from py.routes.handlers.misc_handlers import (
|
||||||
|
BackupHandler,
|
||||||
|
DoctorHandler,
|
||||||
|
FileSystemHandler,
|
||||||
LoraCodeHandler,
|
LoraCodeHandler,
|
||||||
ModelLibraryHandler,
|
ModelLibraryHandler,
|
||||||
NodeRegistry,
|
NodeRegistry,
|
||||||
NodeRegistryHandler,
|
NodeRegistryHandler,
|
||||||
ServiceRegistryAdapter,
|
ServiceRegistryAdapter,
|
||||||
SettingsHandler,
|
SettingsHandler,
|
||||||
|
_collect_comfyui_session_logs,
|
||||||
_is_wsl,
|
_is_wsl,
|
||||||
_wsl_to_windows_path,
|
_wsl_to_windows_path,
|
||||||
_is_docker,
|
_is_docker,
|
||||||
)
|
)
|
||||||
|
from py.utils.session_logging import (
|
||||||
|
reset_standalone_session_logging_for_tests,
|
||||||
|
setup_standalone_session_logging,
|
||||||
|
)
|
||||||
from py.routes.misc_route_registrar import MISC_ROUTE_DEFINITIONS, MiscRouteRegistrar
|
from py.routes.misc_route_registrar import MISC_ROUTE_DEFINITIONS, MiscRouteRegistrar
|
||||||
from py.routes.misc_routes import MiscRoutes
|
from py.routes.misc_routes import MiscRoutes
|
||||||
|
|
||||||
@@ -35,6 +46,7 @@ class FakeRequest:
|
|||||||
class DummySettings:
|
class DummySettings:
|
||||||
def __init__(self, data=None):
|
def __init__(self, data=None):
|
||||||
self.data = data or {}
|
self.data = data or {}
|
||||||
|
self.settings = self.data
|
||||||
|
|
||||||
def get(self, key, default=None):
|
def get(self, key, default=None):
|
||||||
return self.data.get(key, default)
|
return self.data.get(key, default)
|
||||||
@@ -65,6 +77,31 @@ async def dummy_downloader_factory():
|
|||||||
return DummyDownloader()
|
return DummyDownloader()
|
||||||
|
|
||||||
|
|
||||||
|
class DummyDoctorScanner:
|
||||||
|
def __init__(self, *, model_type='lora', raw_data=None, rebuild_error=None):
|
||||||
|
self.model_type = model_type
|
||||||
|
self._raw_data = list(raw_data or [])
|
||||||
|
self._rebuild_error = rebuild_error
|
||||||
|
self._persistent_cache = SimpleNamespace(
|
||||||
|
load_cache=lambda _model_type: SimpleNamespace(raw_data=list(self._raw_data))
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_cached_data(self, force_refresh=False, rebuild_cache=False):
|
||||||
|
if rebuild_cache and self._rebuild_error:
|
||||||
|
raise self._rebuild_error
|
||||||
|
return SimpleNamespace(raw_data=list(self._raw_data))
|
||||||
|
|
||||||
|
|
||||||
|
class DummyCivitaiClient:
|
||||||
|
def __init__(self, *, success=True, result=None):
|
||||||
|
self.base_url = 'https://civitai.com/api/v1'
|
||||||
|
self._success = success
|
||||||
|
self._result = result if result is not None else {'items': []}
|
||||||
|
|
||||||
|
async def _make_request(self, *_args, **_kwargs):
|
||||||
|
return self._success, self._result
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_settings_excludes_no_sync_keys():
|
async def test_get_settings_excludes_no_sync_keys():
|
||||||
"""Verify that settings in _NO_SYNC_KEYS are not synced, but others are."""
|
"""Verify that settings in _NO_SYNC_KEYS are not synced, but others are."""
|
||||||
@@ -111,6 +148,357 @@ async def test_update_settings_rejects_missing_example_path(tmp_path):
|
|||||||
assert "Path does not exist" in payload["error"]
|
assert "Path does not exist" in payload["error"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_doctor_handler_reports_key_cache_and_ui_issues():
|
||||||
|
settings_service = DummySettings({"civitai_api_key": ""})
|
||||||
|
invalid_entry = {"file_path": "/tmp/missing.safetensors"}
|
||||||
|
|
||||||
|
async def civitai_factory():
|
||||||
|
return DummyCivitaiClient()
|
||||||
|
|
||||||
|
async def scanner_factory():
|
||||||
|
return DummyDoctorScanner(model_type="lora", raw_data=[invalid_entry])
|
||||||
|
|
||||||
|
handler = DoctorHandler(
|
||||||
|
settings_service=settings_service,
|
||||||
|
civitai_client_factory=civitai_factory,
|
||||||
|
scanner_factories=(("lora", "LoRAs", scanner_factory),),
|
||||||
|
app_version_getter=lambda: "1.2.3-server",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.get_doctor_diagnostics(
|
||||||
|
FakeRequest(query={"clientVersion": "1.2.2-client"}, method="GET")
|
||||||
|
)
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["summary"]["status"] == "error"
|
||||||
|
diagnostic_map = {item["id"]: item for item in payload["diagnostics"]}
|
||||||
|
assert diagnostic_map["civitai_api_key"]["status"] == "warning"
|
||||||
|
assert diagnostic_map["cache_health"]["status"] == "error"
|
||||||
|
assert diagnostic_map["ui_version"]["status"] == "warning"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_doctor_handler_can_repair_cache():
|
||||||
|
scanner = DummyDoctorScanner(model_type="lora", raw_data=[])
|
||||||
|
|
||||||
|
async def civitai_factory():
|
||||||
|
return DummyCivitaiClient()
|
||||||
|
|
||||||
|
async def scanner_factory():
|
||||||
|
return scanner
|
||||||
|
|
||||||
|
handler = DoctorHandler(
|
||||||
|
settings_service=DummySettings({"civitai_api_key": "token"}),
|
||||||
|
civitai_client_factory=civitai_factory,
|
||||||
|
scanner_factories=(("lora", "LoRAs", scanner_factory),),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.repair_doctor_cache(FakeRequest())
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["repaired"] == [{"model_type": "lora", "label": "LoRAs"}]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_doctor_handler_exports_support_bundle():
|
||||||
|
async def civitai_factory():
|
||||||
|
return DummyCivitaiClient()
|
||||||
|
|
||||||
|
handler = DoctorHandler(
|
||||||
|
settings_service=DummySettings({"civitai_api_key": "secret-key"}),
|
||||||
|
civitai_client_factory=civitai_factory,
|
||||||
|
scanner_factories=(),
|
||||||
|
app_version_getter=lambda: "9.9.9-test",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.export_doctor_bundle(
|
||||||
|
FakeRequest(
|
||||||
|
json_data={
|
||||||
|
"summary": {"status": "warning"},
|
||||||
|
"diagnostics": [{"id": "cache_health", "status": "warning"}],
|
||||||
|
"frontend_logs": [{"level": "error", "message": "boom"}],
|
||||||
|
"client_context": {"app_version": "9.9.8-old"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
with zipfile.ZipFile(io.BytesIO(response.body), "r") as archive:
|
||||||
|
names = set(archive.namelist())
|
||||||
|
assert "doctor-report.json" in names
|
||||||
|
assert "settings-sanitized.json" in names
|
||||||
|
assert "backend-log-source.json" in names
|
||||||
|
settings_payload = json.loads(archive.read("settings-sanitized.json").decode("utf-8"))
|
||||||
|
assert settings_payload["civitai_api_key"].startswith("secr")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_doctor_handler_redacts_string_secrets_in_bundle():
|
||||||
|
async def civitai_factory():
|
||||||
|
return DummyCivitaiClient()
|
||||||
|
|
||||||
|
handler = DoctorHandler(
|
||||||
|
settings_service=DummySettings({"civitai_api_key": "secret-key"}),
|
||||||
|
civitai_client_factory=civitai_factory,
|
||||||
|
scanner_factories=(),
|
||||||
|
app_version_getter=lambda: "9.9.9-test",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.export_doctor_bundle(
|
||||||
|
FakeRequest(
|
||||||
|
json_data={
|
||||||
|
"frontend_logs": [
|
||||||
|
{
|
||||||
|
"level": "error",
|
||||||
|
"message": "Authorization: Bearer abcdef123456 token=xyz password=hunter2",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
with zipfile.ZipFile(io.BytesIO(response.body), "r") as archive:
|
||||||
|
frontend_logs = archive.read("frontend-console.json").decode("utf-8")
|
||||||
|
assert "abcdef123456" not in frontend_logs
|
||||||
|
assert "hunter2" not in frontend_logs
|
||||||
|
assert "Bearer ***" in frontend_logs
|
||||||
|
backend_logs = archive.read("backend-logs.txt").decode("utf-8")
|
||||||
|
assert "hunter2" not in backend_logs
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_doctor_handler_redacts_json_shaped_string_secrets_in_bundle():
|
||||||
|
async def civitai_factory():
|
||||||
|
return DummyCivitaiClient()
|
||||||
|
|
||||||
|
handler = DoctorHandler(
|
||||||
|
settings_service=DummySettings({"civitai_api_key": "secret-key"}),
|
||||||
|
civitai_client_factory=civitai_factory,
|
||||||
|
scanner_factories=(),
|
||||||
|
app_version_getter=lambda: "9.9.9-test",
|
||||||
|
)
|
||||||
|
handler._collect_backend_session_logs = lambda: {
|
||||||
|
"mode": "standalone",
|
||||||
|
"source_method": "standalone_memory",
|
||||||
|
"session_started_at": "2026-04-11T10:00:00+00:00",
|
||||||
|
"session_id": "session-123",
|
||||||
|
"persistent_log_path": None,
|
||||||
|
"persistent_log_text": "",
|
||||||
|
"session_log_text": '{"token":"abcd1234","authorization":"Bearer qwerty","password":"hunter2"}\n',
|
||||||
|
"notes": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await handler.export_doctor_bundle(
|
||||||
|
FakeRequest(
|
||||||
|
json_data={
|
||||||
|
"frontend_logs": [
|
||||||
|
{
|
||||||
|
"level": "error",
|
||||||
|
"message": '{"token":"abcd1234","authorization":"Bearer qwerty","password":"hunter2"}',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
with zipfile.ZipFile(io.BytesIO(response.body), "r") as archive:
|
||||||
|
frontend_logs = archive.read("frontend-console.json").decode("utf-8")
|
||||||
|
backend_logs = archive.read("backend-logs.txt").decode("utf-8")
|
||||||
|
|
||||||
|
assert '"token":"abcd1234"' not in frontend_logs
|
||||||
|
assert '"password":"hunter2"' not in frontend_logs
|
||||||
|
assert 'Bearer qwerty' not in frontend_logs
|
||||||
|
assert '\\"token\\":\\"***\\"' in frontend_logs
|
||||||
|
assert '\\"password\\":\\"***\\"' in frontend_logs
|
||||||
|
assert 'Bearer ***' in frontend_logs
|
||||||
|
|
||||||
|
assert '"token":"abcd1234"' not in backend_logs
|
||||||
|
assert '"password":"hunter2"' not in backend_logs
|
||||||
|
assert 'Bearer qwerty' not in backend_logs
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_doctor_handler_exports_backend_session_logs_from_helper():
|
||||||
|
async def civitai_factory():
|
||||||
|
return DummyCivitaiClient()
|
||||||
|
|
||||||
|
handler = DoctorHandler(
|
||||||
|
settings_service=DummySettings({"civitai_api_key": "secret-key"}),
|
||||||
|
civitai_client_factory=civitai_factory,
|
||||||
|
scanner_factories=(),
|
||||||
|
app_version_getter=lambda: "9.9.9-test",
|
||||||
|
)
|
||||||
|
handler._collect_backend_session_logs = lambda: {
|
||||||
|
"mode": "standalone",
|
||||||
|
"source_method": "standalone_session_file",
|
||||||
|
"session_started_at": "2026-04-11T10:00:00+00:00",
|
||||||
|
"session_id": "session-123",
|
||||||
|
"persistent_log_path": "/tmp/standalone.log",
|
||||||
|
"persistent_log_text": "token=abcd1234\n",
|
||||||
|
"session_log_text": "Authorization: Bearer supersecret\n",
|
||||||
|
"notes": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await handler.export_doctor_bundle(FakeRequest(json_data={}))
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
with zipfile.ZipFile(io.BytesIO(response.body), "r") as archive:
|
||||||
|
backend_logs = archive.read("backend-logs.txt").decode("utf-8")
|
||||||
|
backend_source = json.loads(
|
||||||
|
archive.read("backend-log-source.json").decode("utf-8")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "supersecret" not in backend_logs
|
||||||
|
assert backend_source["source_method"] == "standalone_session_file"
|
||||||
|
assert backend_source["session_id"] == "session-123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_comfyui_session_logs_only_uses_matching_current_session_file(tmp_path):
|
||||||
|
log_file = tmp_path / "comfyui.log"
|
||||||
|
log_file.write_text(
|
||||||
|
"** ComfyUI startup time: 2026-04-11 12:00:00.000\n"
|
||||||
|
"[2026-04-11 12:00:01.000] file log line\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _collect_comfyui_session_logs(
|
||||||
|
log_entries=[
|
||||||
|
{
|
||||||
|
"t": "2026-04-11 12:05:00.000",
|
||||||
|
"m": "** ComfyUI startup time: 2026-04-11 12:05:00.000\n",
|
||||||
|
},
|
||||||
|
{"t": "2026-04-11 12:05:01.000", "m": "current session line\n"},
|
||||||
|
],
|
||||||
|
log_file_path=str(log_file),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["persistent_log_text"] == ""
|
||||||
|
assert any("does not match" in note for note in result["notes"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_standalone_session_logging_creates_current_session_file(tmp_path):
|
||||||
|
reset_standalone_session_logging_for_tests()
|
||||||
|
settings_file = tmp_path / "settings.json"
|
||||||
|
settings_file.write_text("{}", encoding="utf-8")
|
||||||
|
|
||||||
|
state = setup_standalone_session_logging(str(settings_file))
|
||||||
|
logger = logging.getLogger("lora-manager-standalone-test")
|
||||||
|
logger.info("standalone current session line")
|
||||||
|
|
||||||
|
assert state.log_file_path is not None
|
||||||
|
assert os.path.isfile(state.log_file_path)
|
||||||
|
with open(state.log_file_path, "r", encoding="utf-8") as handle:
|
||||||
|
payload = handle.read()
|
||||||
|
|
||||||
|
assert "LoRA Manager standalone startup time:" in payload
|
||||||
|
|
||||||
|
|
||||||
|
class DummyBackupService:
|
||||||
|
def __init__(self):
|
||||||
|
self.restore_calls = []
|
||||||
|
|
||||||
|
async def create_snapshot(self, *, snapshot_type="manual", persist=False):
|
||||||
|
return {
|
||||||
|
"archive_name": "backup.zip",
|
||||||
|
"archive_bytes": b"zip-bytes",
|
||||||
|
"manifest": {"snapshot_type": snapshot_type},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def restore_snapshot(self, archive_path):
|
||||||
|
self.restore_calls.append(archive_path)
|
||||||
|
return {"success": True, "restored_files": 3, "snapshot_type": "manual"}
|
||||||
|
|
||||||
|
def get_status(self):
|
||||||
|
return {
|
||||||
|
"backupDir": "/tmp/backups",
|
||||||
|
"enabled": True,
|
||||||
|
"retentionCount": 5,
|
||||||
|
"snapshotCount": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_available_snapshots(self):
|
||||||
|
return [{"name": "backup.zip", "path": "/tmp/backup.zip", "size": 8, "mtime": 1.0, "is_auto": False}]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_backup_handler_returns_status_and_exports(monkeypatch):
|
||||||
|
service = DummyBackupService()
|
||||||
|
|
||||||
|
async def factory():
|
||||||
|
return service
|
||||||
|
|
||||||
|
handler = BackupHandler(backup_service_factory=factory)
|
||||||
|
|
||||||
|
status_response = await handler.get_backup_status(FakeRequest())
|
||||||
|
status_payload = json.loads(status_response.text)
|
||||||
|
assert status_payload["success"] is True
|
||||||
|
assert status_payload["status"]["backupDir"] == "/tmp/backups"
|
||||||
|
assert status_payload["status"]["enabled"] is True
|
||||||
|
assert status_payload["snapshots"][0]["name"] == "backup.zip"
|
||||||
|
|
||||||
|
export_response = await handler.export_backup(FakeRequest())
|
||||||
|
assert export_response.status == 200
|
||||||
|
assert export_response.body == b"zip-bytes"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_backup_handler_rejects_missing_import_archive():
|
||||||
|
service = DummyBackupService()
|
||||||
|
|
||||||
|
async def factory():
|
||||||
|
return service
|
||||||
|
|
||||||
|
handler = BackupHandler(backup_service_factory=factory)
|
||||||
|
|
||||||
|
class EmptyRequest:
|
||||||
|
content_type = "application/octet-stream"
|
||||||
|
|
||||||
|
async def read(self):
|
||||||
|
return b""
|
||||||
|
|
||||||
|
response = await handler.import_backup(EmptyRequest())
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
|
||||||
|
assert response.status == 400
|
||||||
|
assert payload["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_open_backup_location_uses_settings_directory(tmp_path, monkeypatch):
|
||||||
|
settings_dir = tmp_path / "settings"
|
||||||
|
settings_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
settings_file = settings_dir / "settings.json"
|
||||||
|
settings_file.write_text("{}", encoding="utf-8")
|
||||||
|
backup_dir = settings_dir / "backups"
|
||||||
|
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
handler = FileSystemHandler(settings_service=SimpleNamespace(settings_file=str(settings_file)))
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_popen(args):
|
||||||
|
calls.append(args)
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
||||||
|
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_docker", lambda: False)
|
||||||
|
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_wsl", lambda: False)
|
||||||
|
|
||||||
|
response = await handler.open_backup_location(FakeRequest())
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["path"] == str(backup_dir)
|
||||||
|
assert calls == [["xdg-open", str(backup_dir)]]
|
||||||
|
|
||||||
|
|
||||||
class RecordingRouter:
|
class RecordingRouter:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.calls = []
|
self.calls = []
|
||||||
|
|||||||
@@ -113,6 +113,78 @@ async def test_config_updates_preview_roots_after_switch(tmp_path):
|
|||||||
assert decoded.replace("\\", "/").endswith("model.webp")
|
assert decoded.replace("\\", "/").endswith("model.webp")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_preview_handler_allows_custom_recipes_path(tmp_path):
|
||||||
|
lora_root = tmp_path / "library"
|
||||||
|
lora_root.mkdir()
|
||||||
|
recipes_root = tmp_path / "recipes_storage"
|
||||||
|
recipes_root.mkdir()
|
||||||
|
preview_file = recipes_root / "recipe.webp"
|
||||||
|
preview_file.write_bytes(b"preview")
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
config.apply_library_settings(
|
||||||
|
{
|
||||||
|
"folder_paths": {
|
||||||
|
"loras": [str(lora_root)],
|
||||||
|
"checkpoints": [],
|
||||||
|
"unet": [],
|
||||||
|
"embeddings": [],
|
||||||
|
},
|
||||||
|
"recipes_path": str(recipes_root),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert config.is_preview_path_allowed(str(preview_file))
|
||||||
|
|
||||||
|
handler = PreviewHandler(config=config)
|
||||||
|
encoded_path = urllib.parse.quote(str(preview_file), safe="")
|
||||||
|
request = make_mocked_request("GET", f"/api/lm/previews?path={encoded_path}")
|
||||||
|
|
||||||
|
response = await handler.serve_preview(request)
|
||||||
|
|
||||||
|
assert isinstance(response, web.FileResponse)
|
||||||
|
assert response.status == 200
|
||||||
|
assert Path(response._path) == preview_file
|
||||||
|
|
||||||
|
|
||||||
|
async def test_preview_handler_allows_symlinked_recipes_path(tmp_path):
|
||||||
|
lora_root = tmp_path / "library"
|
||||||
|
lora_root.mkdir()
|
||||||
|
real_recipes_root = tmp_path / "real_recipes"
|
||||||
|
real_recipes_root.mkdir()
|
||||||
|
symlink_recipes_root = tmp_path / "linked_recipes"
|
||||||
|
symlink_recipes_root.symlink_to(real_recipes_root, target_is_directory=True)
|
||||||
|
|
||||||
|
preview_file = real_recipes_root / "recipe.webp"
|
||||||
|
preview_file.write_bytes(b"preview")
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
config.apply_library_settings(
|
||||||
|
{
|
||||||
|
"folder_paths": {
|
||||||
|
"loras": [str(lora_root)],
|
||||||
|
"checkpoints": [],
|
||||||
|
"unet": [],
|
||||||
|
"embeddings": [],
|
||||||
|
},
|
||||||
|
"recipes_path": str(symlink_recipes_root),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
symlink_preview_path = symlink_recipes_root / "recipe.webp"
|
||||||
|
assert config.is_preview_path_allowed(str(symlink_preview_path))
|
||||||
|
|
||||||
|
handler = PreviewHandler(config=config)
|
||||||
|
encoded_path = urllib.parse.quote(str(symlink_preview_path), safe="")
|
||||||
|
request = make_mocked_request("GET", f"/api/lm/previews?path={encoded_path}")
|
||||||
|
|
||||||
|
response = await handler.serve_preview(request)
|
||||||
|
|
||||||
|
assert isinstance(response, web.FileResponse)
|
||||||
|
assert response.status == 200
|
||||||
|
assert Path(response._path) == preview_file.resolve()
|
||||||
|
|
||||||
|
|
||||||
def test_is_preview_path_allowed_case_insensitive_on_windows(tmp_path):
|
def test_is_preview_path_allowed_case_insensitive_on_windows(tmp_path):
|
||||||
"""Test that preview path validation is case-insensitive on Windows.
|
"""Test that preview path validation is case-insensitive on Windows.
|
||||||
|
|
||||||
|
|||||||
228
tests/services/test_backup_service.py
Normal file
228
tests/services/test_backup_service.py
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import py.services.backup_service as backup_service
|
||||||
|
from py.services.model_update_service import ModelUpdateService
|
||||||
|
from py.utils.cache_paths import CacheType
|
||||||
|
|
||||||
|
|
||||||
|
class DummySettings:
|
||||||
|
def __init__(self, settings_file: Path, *, library_name: str = "main", values=None):
|
||||||
|
self.settings_file = str(settings_file)
|
||||||
|
self._library_name = library_name
|
||||||
|
self._values = values or {}
|
||||||
|
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return self._values.get(key, default)
|
||||||
|
|
||||||
|
def get_active_library_name(self):
|
||||||
|
return self._library_name
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_backup_paths(monkeypatch, root: Path):
|
||||||
|
settings_dir = root / "settings"
|
||||||
|
cache_dir = settings_dir / "cache"
|
||||||
|
|
||||||
|
def fake_get_settings_dir(create: bool = True):
|
||||||
|
if create:
|
||||||
|
settings_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return str(settings_dir)
|
||||||
|
|
||||||
|
def fake_get_cache_base_dir(create: bool = True):
|
||||||
|
if create:
|
||||||
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return str(cache_dir)
|
||||||
|
|
||||||
|
def fake_get_cache_file_path(cache_type, library_name=None, create_dir=True):
|
||||||
|
if cache_type == CacheType.SYMLINK:
|
||||||
|
path = cache_dir / "symlink" / "symlink_map.json"
|
||||||
|
elif cache_type == CacheType.MODEL_UPDATE:
|
||||||
|
name = library_name or "default"
|
||||||
|
path = cache_dir / "model_update" / f"{name}.sqlite"
|
||||||
|
else: # pragma: no cover - the test only covers the backup targets
|
||||||
|
raise AssertionError(f"Unexpected cache type: {cache_type}")
|
||||||
|
|
||||||
|
if create_dir:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
monkeypatch.setattr(backup_service, "get_settings_dir", fake_get_settings_dir)
|
||||||
|
monkeypatch.setattr(backup_service, "get_cache_base_dir", fake_get_cache_base_dir)
|
||||||
|
monkeypatch.setattr(backup_service, "get_cache_file_path", fake_get_cache_file_path)
|
||||||
|
|
||||||
|
return settings_dir, cache_dir
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_backup_round_trip_restores_user_state(tmp_path, monkeypatch):
|
||||||
|
settings_dir, cache_dir = _configure_backup_paths(monkeypatch, tmp_path)
|
||||||
|
|
||||||
|
settings_file = settings_dir / "settings.json"
|
||||||
|
download_history = cache_dir / "download_history" / "downloaded_versions.sqlite"
|
||||||
|
symlink_map = cache_dir / "symlink" / "symlink_map.json"
|
||||||
|
model_update_db = cache_dir / "model_update" / "main.sqlite"
|
||||||
|
|
||||||
|
settings_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
download_history.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
symlink_map.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
model_update_db.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
settings_file.write_text(json.dumps({"backup_auto_enabled": True}), encoding="utf-8")
|
||||||
|
download_history.write_bytes(b"download-history-v1")
|
||||||
|
symlink_map.write_text(json.dumps({"a": "/tmp/a"}), encoding="utf-8")
|
||||||
|
model_update_db.write_bytes(b"model-update-v1")
|
||||||
|
|
||||||
|
service = backup_service.BackupService(
|
||||||
|
settings_manager=DummySettings(settings_file),
|
||||||
|
backup_dir=str(tmp_path / "backups"),
|
||||||
|
)
|
||||||
|
|
||||||
|
snapshot = await service.create_snapshot(snapshot_type="manual", persist=False)
|
||||||
|
archive_path = tmp_path / snapshot["archive_name"]
|
||||||
|
archive_path.write_bytes(snapshot["archive_bytes"])
|
||||||
|
|
||||||
|
settings_file.write_text(json.dumps({"backup_auto_enabled": False}), encoding="utf-8")
|
||||||
|
download_history.write_bytes(b"download-history-v2")
|
||||||
|
symlink_map.write_text(json.dumps({"a": "/tmp/b"}), encoding="utf-8")
|
||||||
|
model_update_db.write_bytes(b"model-update-v2")
|
||||||
|
|
||||||
|
result = await service.restore_snapshot(str(archive_path))
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert settings_file.read_text(encoding="utf-8") == json.dumps({"backup_auto_enabled": True})
|
||||||
|
assert download_history.read_bytes() == b"download-history-v1"
|
||||||
|
assert symlink_map.read_text(encoding="utf-8") == json.dumps({"a": "/tmp/a"})
|
||||||
|
assert model_update_db.read_bytes() == b"model-update-v1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_snapshots_keeps_latest_auto_only(tmp_path, monkeypatch):
|
||||||
|
settings_dir, _ = _configure_backup_paths(monkeypatch, tmp_path)
|
||||||
|
settings_file = settings_dir / "settings.json"
|
||||||
|
settings_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
settings_file.write_text(json.dumps({"backup_retention_count": 2}), encoding="utf-8")
|
||||||
|
|
||||||
|
service = backup_service.BackupService(
|
||||||
|
settings_manager=DummySettings(settings_file, values={"backup_retention_count": 2}),
|
||||||
|
backup_dir=str(tmp_path / "backups"),
|
||||||
|
)
|
||||||
|
|
||||||
|
backup_dir = Path(service.get_backup_dir())
|
||||||
|
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
files = [
|
||||||
|
backup_dir / "lora-manager-backup-20240101T000000Z-auto.zip",
|
||||||
|
backup_dir / "lora-manager-backup-20240102T000000Z-auto.zip",
|
||||||
|
backup_dir / "lora-manager-backup-20240103T000000Z-auto.zip",
|
||||||
|
backup_dir / "lora-manager-backup-20240104T000000Z-manual.zip",
|
||||||
|
]
|
||||||
|
for index, path in enumerate(files):
|
||||||
|
path.write_bytes(b"zip")
|
||||||
|
os.utime(path, (1000 + index, 1000 + index))
|
||||||
|
|
||||||
|
service._prune_snapshots()
|
||||||
|
|
||||||
|
remaining = sorted(p.name for p in backup_dir.glob("*.zip"))
|
||||||
|
assert remaining == [
|
||||||
|
"lora-manager-backup-20240102T000000Z-auto.zip",
|
||||||
|
"lora-manager-backup-20240103T000000Z-auto.zip",
|
||||||
|
"lora-manager-backup-20240104T000000Z-manual.zip",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_status_includes_backup_dir(tmp_path, monkeypatch):
|
||||||
|
settings_dir, _ = _configure_backup_paths(monkeypatch, tmp_path)
|
||||||
|
settings_file = settings_dir / "settings.json"
|
||||||
|
settings_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
settings_file.write_text("{}", encoding="utf-8")
|
||||||
|
|
||||||
|
service = backup_service.BackupService(
|
||||||
|
settings_manager=DummySettings(settings_file),
|
||||||
|
backup_dir=str(tmp_path / "backups"),
|
||||||
|
)
|
||||||
|
|
||||||
|
status = service.get_status()
|
||||||
|
|
||||||
|
assert status["backupDir"] == str(tmp_path / "backups")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_model_update_service_migrates_legacy_snapshot_db(tmp_path, monkeypatch):
|
||||||
|
legacy_db = tmp_path / "legacy" / "main.sqlite"
|
||||||
|
new_db = tmp_path / "cache" / "model_update" / "main.sqlite"
|
||||||
|
legacy_db.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
new_db.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with sqlite3.connect(legacy_db) as conn:
|
||||||
|
conn.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE model_update_status (
|
||||||
|
model_id INTEGER PRIMARY KEY,
|
||||||
|
model_type TEXT NOT NULL,
|
||||||
|
last_checked_at REAL,
|
||||||
|
should_ignore_model INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE model_update_versions (
|
||||||
|
model_id INTEGER NOT NULL,
|
||||||
|
version_id INTEGER NOT NULL,
|
||||||
|
sort_index INTEGER NOT NULL DEFAULT 0,
|
||||||
|
name TEXT,
|
||||||
|
base_model TEXT,
|
||||||
|
released_at TEXT,
|
||||||
|
size_bytes INTEGER,
|
||||||
|
preview_url TEXT,
|
||||||
|
is_in_library INTEGER NOT NULL DEFAULT 0,
|
||||||
|
should_ignore INTEGER NOT NULL DEFAULT 0,
|
||||||
|
early_access_ends_at TEXT,
|
||||||
|
is_early_access INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (model_id, version_id)
|
||||||
|
);
|
||||||
|
INSERT INTO model_update_status (
|
||||||
|
model_id, model_type, last_checked_at, should_ignore_model
|
||||||
|
) VALUES (1, 'lora', 123.0, 1);
|
||||||
|
INSERT INTO model_update_versions (
|
||||||
|
model_id, version_id, sort_index, name, base_model, released_at,
|
||||||
|
size_bytes, preview_url, is_in_library, should_ignore,
|
||||||
|
early_access_ends_at, is_early_access
|
||||||
|
) VALUES (
|
||||||
|
1, 11, 0, 'v1', 'SD15', '2024-01-01T00:00:00Z',
|
||||||
|
1024, 'https://example.com/v1.png', 1, 0, NULL, 0
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
class DummySettingsManager:
|
||||||
|
def get_active_library_name(self):
|
||||||
|
return "main"
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.services.model_update_service.resolve_cache_path_with_migration",
|
||||||
|
lambda *args, **kwargs: str(new_db),
|
||||||
|
)
|
||||||
|
|
||||||
|
class LegacyCache:
|
||||||
|
def get_database_path(self):
|
||||||
|
return str(legacy_db)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.services.persistent_model_cache.get_persistent_cache",
|
||||||
|
lambda *_args, **_kwargs: LegacyCache(),
|
||||||
|
)
|
||||||
|
|
||||||
|
service = ModelUpdateService(settings_manager=DummySettingsManager())
|
||||||
|
|
||||||
|
with sqlite3.connect(new_db) as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT model_id, model_type, last_checked_at, should_ignore_model FROM model_update_status"
|
||||||
|
).fetchone()
|
||||||
|
version_row = conn.execute(
|
||||||
|
"SELECT model_id, version_id, name, base_model, is_in_library FROM model_update_versions"
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
assert row == (1, "lora", 123.0, 1)
|
||||||
|
assert version_row == (1, 11, "v1", "SD15", 1)
|
||||||
|
assert service._db_path == str(new_db)
|
||||||
@@ -199,6 +199,47 @@ async def test_calculate_hash_skips_if_already_completed(tmp_path: Path, monkeyp
|
|||||||
mock_calc.assert_not_called(), "Should not recalculate if already completed"
|
mock_calc.assert_not_called(), "Should not recalculate if already completed"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_calculate_hash_for_model_bootstraps_missing_metadata(tmp_path: Path, monkeypatch):
|
||||||
|
"""Test that calculate_hash_for_model creates pending metadata when it is missing."""
|
||||||
|
checkpoints_root = tmp_path / "checkpoints"
|
||||||
|
checkpoints_root.mkdir()
|
||||||
|
|
||||||
|
checkpoint_file = checkpoints_root / "bootstrap_model.gguf"
|
||||||
|
checkpoint_file.write_text("fake content for hashing", encoding="utf-8")
|
||||||
|
|
||||||
|
normalized_root = _normalize(checkpoints_root)
|
||||||
|
normalized_file = _normalize(checkpoint_file)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
model_scanner.config,
|
||||||
|
"base_models_roots",
|
||||||
|
[normalized_root],
|
||||||
|
raising=False,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
model_scanner.config,
|
||||||
|
"checkpoints_roots",
|
||||||
|
[normalized_root],
|
||||||
|
raising=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
scanner = CheckpointScanner()
|
||||||
|
|
||||||
|
hash_result = await scanner.calculate_hash_for_model(normalized_file)
|
||||||
|
|
||||||
|
assert hash_result is not None, "Hash calculation should succeed without existing metadata"
|
||||||
|
assert len(hash_result) == 64, "SHA256 should be 64 hex characters"
|
||||||
|
assert scanner.get_hash_by_filename("bootstrap_model") == hash_result
|
||||||
|
|
||||||
|
metadata_file = checkpoints_root / "bootstrap_model.metadata.json"
|
||||||
|
with open(metadata_file, "r", encoding="utf-8") as f:
|
||||||
|
saved_data = json.load(f)
|
||||||
|
|
||||||
|
assert saved_data.get("sha256") == hash_result, "sha256 should be updated"
|
||||||
|
assert saved_data.get("hash_status") == "completed", "hash_status should be 'completed'"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_calculate_all_pending_hashes(tmp_path: Path, monkeypatch):
|
async def test_calculate_all_pending_hashes(tmp_path: Path, monkeypatch):
|
||||||
"""Test bulk hash calculation for all pending checkpoints."""
|
"""Test bulk hash calculation for all pending checkpoints."""
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import pytest
|
|||||||
|
|
||||||
from py.config import config
|
from py.config import config
|
||||||
from py.services.recipe_scanner import RecipeScanner
|
from py.services.recipe_scanner import RecipeScanner
|
||||||
|
from py.services import settings_manager as settings_manager_module
|
||||||
from py.utils.utils import calculate_recipe_fingerprint
|
from py.utils.utils import calculate_recipe_fingerprint
|
||||||
|
|
||||||
|
|
||||||
@@ -72,12 +73,56 @@ class StubLoraScanner:
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def recipe_scanner(tmp_path: Path, monkeypatch):
|
def recipe_scanner(tmp_path: Path, monkeypatch):
|
||||||
RecipeScanner._instance = None
|
RecipeScanner._instance = None
|
||||||
|
settings_manager_module.reset_settings_manager()
|
||||||
monkeypatch.setattr(config, "loras_roots", [str(tmp_path)])
|
monkeypatch.setattr(config, "loras_roots", [str(tmp_path)])
|
||||||
stub = StubLoraScanner()
|
stub = StubLoraScanner()
|
||||||
scanner = RecipeScanner(lora_scanner=stub)
|
scanner = RecipeScanner(lora_scanner=stub)
|
||||||
asyncio.run(scanner.refresh_cache(force=True))
|
asyncio.run(scanner.refresh_cache(force=True))
|
||||||
yield scanner, stub
|
yield scanner, stub
|
||||||
RecipeScanner._instance = None
|
RecipeScanner._instance = None
|
||||||
|
settings_manager_module.reset_settings_manager()
|
||||||
|
|
||||||
|
|
||||||
|
def test_recipes_dir_uses_custom_settings_path(tmp_path: Path, monkeypatch):
|
||||||
|
RecipeScanner._instance = None
|
||||||
|
settings_manager_module.reset_settings_manager()
|
||||||
|
|
||||||
|
settings_path = tmp_path / "settings.json"
|
||||||
|
custom_recipes = tmp_path / "custom" / ".." / "custom_recipes"
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.services.settings_manager.ensure_settings_file",
|
||||||
|
lambda logger=None: str(settings_path),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(config, "loras_roots", [str(tmp_path / "loras-root")])
|
||||||
|
|
||||||
|
manager = settings_manager_module.get_settings_manager()
|
||||||
|
manager.set("recipes_path", str(custom_recipes))
|
||||||
|
|
||||||
|
scanner = RecipeScanner(lora_scanner=StubLoraScanner())
|
||||||
|
resolved = scanner.recipes_dir
|
||||||
|
|
||||||
|
assert resolved == str((tmp_path / "custom_recipes").resolve())
|
||||||
|
assert Path(resolved).is_dir()
|
||||||
|
|
||||||
|
RecipeScanner._instance = None
|
||||||
|
settings_manager_module.reset_settings_manager()
|
||||||
|
|
||||||
|
|
||||||
|
def test_recipes_dir_falls_back_to_first_lora_root(tmp_path: Path, monkeypatch):
|
||||||
|
RecipeScanner._instance = None
|
||||||
|
settings_manager_module.reset_settings_manager()
|
||||||
|
|
||||||
|
monkeypatch.setattr(config, "loras_roots", [str(tmp_path / "alpha")])
|
||||||
|
|
||||||
|
scanner = RecipeScanner(lora_scanner=StubLoraScanner())
|
||||||
|
resolved = scanner.recipes_dir
|
||||||
|
|
||||||
|
assert resolved == str(tmp_path / "alpha" / "recipes")
|
||||||
|
assert Path(resolved).is_dir()
|
||||||
|
|
||||||
|
RecipeScanner._instance = None
|
||||||
|
settings_manager_module.reset_settings_manager()
|
||||||
|
|
||||||
|
|
||||||
async def test_add_recipe_during_concurrent_reads(recipe_scanner):
|
async def test_add_recipe_during_concurrent_reads(recipe_scanner):
|
||||||
|
|||||||
@@ -496,6 +496,7 @@ def test_migrate_sanitizes_legacy_libraries(tmp_path, monkeypatch):
|
|||||||
assert payload["default_lora_root"] == ""
|
assert payload["default_lora_root"] == ""
|
||||||
assert payload["default_checkpoint_root"] == ""
|
assert payload["default_checkpoint_root"] == ""
|
||||||
assert payload["default_embedding_root"] == ""
|
assert payload["default_embedding_root"] == ""
|
||||||
|
assert payload["recipes_path"] == ""
|
||||||
assert manager.get_active_library_name() == "legacy"
|
assert manager.get_active_library_name() == "legacy"
|
||||||
|
|
||||||
|
|
||||||
@@ -507,12 +508,14 @@ def test_active_library_syncs_top_level_settings(tmp_path, monkeypatch):
|
|||||||
"default_lora_root": "/loras",
|
"default_lora_root": "/loras",
|
||||||
"default_checkpoint_root": "/ckpt",
|
"default_checkpoint_root": "/ckpt",
|
||||||
"default_embedding_root": "/embed",
|
"default_embedding_root": "/embed",
|
||||||
|
"recipes_path": "/loras/recipes",
|
||||||
},
|
},
|
||||||
"studio": {
|
"studio": {
|
||||||
"folder_paths": {"loras": ["/studio"]},
|
"folder_paths": {"loras": ["/studio"]},
|
||||||
"default_lora_root": "/studio",
|
"default_lora_root": "/studio",
|
||||||
"default_checkpoint_root": "/studio_ckpt",
|
"default_checkpoint_root": "/studio_ckpt",
|
||||||
"default_embedding_root": "/studio_embed",
|
"default_embedding_root": "/studio_embed",
|
||||||
|
"recipes_path": "/studio/custom-recipes",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"active_library": "studio",
|
"active_library": "studio",
|
||||||
@@ -521,6 +524,7 @@ def test_active_library_syncs_top_level_settings(tmp_path, monkeypatch):
|
|||||||
"default_lora_root": "/loras",
|
"default_lora_root": "/loras",
|
||||||
"default_checkpoint_root": "/ckpt",
|
"default_checkpoint_root": "/ckpt",
|
||||||
"default_embedding_root": "/embed",
|
"default_embedding_root": "/embed",
|
||||||
|
"recipes_path": "/loras/recipes",
|
||||||
}
|
}
|
||||||
|
|
||||||
manager = _create_manager_with_settings(tmp_path, monkeypatch, initial)
|
manager = _create_manager_with_settings(tmp_path, monkeypatch, initial)
|
||||||
@@ -530,14 +534,17 @@ def test_active_library_syncs_top_level_settings(tmp_path, monkeypatch):
|
|||||||
assert manager.get("default_lora_root") == "/studio"
|
assert manager.get("default_lora_root") == "/studio"
|
||||||
assert manager.get("default_checkpoint_root") == "/studio_ckpt"
|
assert manager.get("default_checkpoint_root") == "/studio_ckpt"
|
||||||
assert manager.get("default_embedding_root") == "/studio_embed"
|
assert manager.get("default_embedding_root") == "/studio_embed"
|
||||||
|
assert manager.get("recipes_path") == "/studio/custom-recipes"
|
||||||
|
|
||||||
# Drift the top-level values again and ensure activate_library repairs them
|
# Drift the top-level values again and ensure activate_library repairs them
|
||||||
manager.settings["folder_paths"] = {"loras": ["/loras"]}
|
manager.settings["folder_paths"] = {"loras": ["/loras"]}
|
||||||
manager.settings["default_lora_root"] = "/loras"
|
manager.settings["default_lora_root"] = "/loras"
|
||||||
|
manager.settings["recipes_path"] = "/loras/recipes"
|
||||||
manager.activate_library("studio")
|
manager.activate_library("studio")
|
||||||
|
|
||||||
assert manager.get("folder_paths")["loras"] == ["/studio"]
|
assert manager.get("folder_paths")["loras"] == ["/studio"]
|
||||||
assert manager.get("default_lora_root") == "/studio"
|
assert manager.get("default_lora_root") == "/studio"
|
||||||
|
assert manager.get("recipes_path") == "/studio/custom-recipes"
|
||||||
|
|
||||||
|
|
||||||
def test_refresh_environment_variables_updates_stored_value(tmp_path, monkeypatch):
|
def test_refresh_environment_variables_updates_stored_value(tmp_path, monkeypatch):
|
||||||
@@ -554,6 +561,7 @@ def test_refresh_environment_variables_updates_stored_value(tmp_path, monkeypatc
|
|||||||
"default_lora_root": "",
|
"default_lora_root": "",
|
||||||
"default_checkpoint_root": "",
|
"default_checkpoint_root": "",
|
||||||
"default_embedding_root": "",
|
"default_embedding_root": "",
|
||||||
|
"recipes_path": "",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"active_library": "default",
|
"active_library": "default",
|
||||||
@@ -589,6 +597,177 @@ def test_upsert_library_creates_entry_and_activates(manager, tmp_path):
|
|||||||
assert str(lora_dir).replace(os.sep, "/") in normalized_stored_paths
|
assert str(lora_dir).replace(os.sep, "/") in normalized_stored_paths
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_recipes_path_updates_active_library_entry(manager, tmp_path):
|
||||||
|
recipes_dir = tmp_path / "custom" / "recipes"
|
||||||
|
|
||||||
|
manager.set("recipes_path", str(recipes_dir))
|
||||||
|
|
||||||
|
assert manager.get("recipes_path") == str(recipes_dir.resolve())
|
||||||
|
assert (
|
||||||
|
manager.get_libraries()["default"]["recipes_path"]
|
||||||
|
== str(recipes_dir.resolve())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_recipes_path_migrates_existing_recipe_files(manager, tmp_path):
|
||||||
|
lora_root = tmp_path / "loras"
|
||||||
|
old_recipes_dir = lora_root / "recipes" / "nested"
|
||||||
|
old_recipes_dir.mkdir(parents=True)
|
||||||
|
manager.set("folder_paths", {"loras": [str(lora_root)]})
|
||||||
|
|
||||||
|
recipe_id = "recipe-1"
|
||||||
|
old_image_path = old_recipes_dir / f"{recipe_id}.webp"
|
||||||
|
old_json_path = old_recipes_dir / f"{recipe_id}.recipe.json"
|
||||||
|
old_image_path.write_bytes(b"image-bytes")
|
||||||
|
old_json_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": str(old_image_path),
|
||||||
|
"title": "Recipe 1",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
new_recipes_dir = tmp_path / "custom_recipes"
|
||||||
|
manager.set("recipes_path", str(new_recipes_dir))
|
||||||
|
|
||||||
|
migrated_image_path = new_recipes_dir / "nested" / f"{recipe_id}.webp"
|
||||||
|
migrated_json_path = new_recipes_dir / "nested" / f"{recipe_id}.recipe.json"
|
||||||
|
|
||||||
|
assert manager.get("recipes_path") == str(new_recipes_dir.resolve())
|
||||||
|
assert migrated_image_path.read_bytes() == b"image-bytes"
|
||||||
|
migrated_payload = json.loads(migrated_json_path.read_text(encoding="utf-8"))
|
||||||
|
assert migrated_payload["file_path"] == str(migrated_image_path)
|
||||||
|
assert not old_image_path.exists()
|
||||||
|
assert not old_json_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_clearing_recipes_path_migrates_files_to_default_location(manager, tmp_path):
|
||||||
|
lora_root = tmp_path / "loras"
|
||||||
|
custom_recipes_dir = tmp_path / "custom_recipes"
|
||||||
|
old_recipes_dir = custom_recipes_dir / "nested"
|
||||||
|
old_recipes_dir.mkdir(parents=True)
|
||||||
|
manager.set("folder_paths", {"loras": [str(lora_root)]})
|
||||||
|
manager.settings["recipes_path"] = str(custom_recipes_dir)
|
||||||
|
|
||||||
|
recipe_id = "recipe-2"
|
||||||
|
old_image_path = old_recipes_dir / f"{recipe_id}.webp"
|
||||||
|
old_json_path = old_recipes_dir / f"{recipe_id}.recipe.json"
|
||||||
|
old_image_path.write_bytes(b"image-bytes")
|
||||||
|
old_json_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": str(old_image_path),
|
||||||
|
"title": "Recipe 2",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.set("recipes_path", "")
|
||||||
|
|
||||||
|
fallback_recipes_dir = lora_root / "recipes"
|
||||||
|
migrated_image_path = fallback_recipes_dir / "nested" / f"{recipe_id}.webp"
|
||||||
|
migrated_json_path = fallback_recipes_dir / "nested" / f"{recipe_id}.recipe.json"
|
||||||
|
|
||||||
|
assert manager.get("recipes_path") == ""
|
||||||
|
assert migrated_image_path.read_bytes() == b"image-bytes"
|
||||||
|
migrated_payload = json.loads(migrated_json_path.read_text(encoding="utf-8"))
|
||||||
|
assert migrated_payload["file_path"] == str(migrated_image_path)
|
||||||
|
assert not old_image_path.exists()
|
||||||
|
assert not old_json_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_moving_recipes_path_back_to_parent_directory_is_allowed(manager, tmp_path):
|
||||||
|
lora_root = tmp_path / "loras"
|
||||||
|
manager.set("folder_paths", {"loras": [str(lora_root)]})
|
||||||
|
|
||||||
|
source_recipes_dir = lora_root / "recipes" / "custom"
|
||||||
|
source_recipes_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
recipe_id = "recipe-parent"
|
||||||
|
old_image_path = source_recipes_dir / f"{recipe_id}.webp"
|
||||||
|
old_json_path = source_recipes_dir / f"{recipe_id}.recipe.json"
|
||||||
|
old_image_path.write_bytes(b"parent-bytes")
|
||||||
|
old_json_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": str(old_image_path),
|
||||||
|
"title": "Recipe Parent",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.settings["recipes_path"] = str(source_recipes_dir)
|
||||||
|
manager.set("recipes_path", str(lora_root / "recipes"))
|
||||||
|
|
||||||
|
migrated_image_path = lora_root / "recipes" / f"{recipe_id}.webp"
|
||||||
|
migrated_json_path = lora_root / "recipes" / f"{recipe_id}.recipe.json"
|
||||||
|
|
||||||
|
assert manager.get("recipes_path") == str((lora_root / "recipes").resolve())
|
||||||
|
assert migrated_image_path.read_bytes() == b"parent-bytes"
|
||||||
|
migrated_payload = json.loads(migrated_json_path.read_text(encoding="utf-8"))
|
||||||
|
assert migrated_payload["file_path"] == str(migrated_image_path)
|
||||||
|
assert not old_image_path.exists()
|
||||||
|
assert not old_json_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_recipes_path_rewrites_symlinked_recipe_metadata(manager, tmp_path):
|
||||||
|
real_recipes_dir = tmp_path / "real_recipes"
|
||||||
|
real_recipes_dir.mkdir()
|
||||||
|
symlink_recipes_dir = tmp_path / "linked_recipes"
|
||||||
|
symlink_recipes_dir.symlink_to(real_recipes_dir, target_is_directory=True)
|
||||||
|
|
||||||
|
manager.settings["recipes_path"] = str(symlink_recipes_dir)
|
||||||
|
manager.set("folder_paths", {"loras": [str(tmp_path / "loras")]})
|
||||||
|
|
||||||
|
recipe_id = "recipe-symlink"
|
||||||
|
old_image_path = real_recipes_dir / f"{recipe_id}.webp"
|
||||||
|
old_json_path = real_recipes_dir / f"{recipe_id}.recipe.json"
|
||||||
|
old_image_path.write_bytes(b"symlink-bytes")
|
||||||
|
old_json_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": str(old_image_path),
|
||||||
|
"title": "Recipe Symlink",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
new_recipes_dir = tmp_path / "migrated_recipes"
|
||||||
|
manager.set("recipes_path", str(new_recipes_dir))
|
||||||
|
|
||||||
|
migrated_image_path = new_recipes_dir / f"{recipe_id}.webp"
|
||||||
|
migrated_json_path = new_recipes_dir / f"{recipe_id}.recipe.json"
|
||||||
|
|
||||||
|
assert migrated_image_path.read_bytes() == b"symlink-bytes"
|
||||||
|
migrated_payload = json.loads(migrated_json_path.read_text(encoding="utf-8"))
|
||||||
|
assert migrated_payload["file_path"] == str(migrated_image_path)
|
||||||
|
assert not old_image_path.exists()
|
||||||
|
assert not old_json_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_recipes_path_rejects_file_target(manager, tmp_path):
|
||||||
|
lora_root = tmp_path / "loras"
|
||||||
|
lora_root.mkdir()
|
||||||
|
manager.set("folder_paths", {"loras": [str(lora_root)]})
|
||||||
|
|
||||||
|
target_file = tmp_path / "not_a_directory"
|
||||||
|
target_file.write_text("blocked", encoding="utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="directory"):
|
||||||
|
manager.set("recipes_path", str(target_file))
|
||||||
|
|
||||||
|
assert manager.get("recipes_path") == ""
|
||||||
|
|
||||||
|
|
||||||
def test_extra_folder_paths_stored_separately(manager, tmp_path):
|
def test_extra_folder_paths_stored_separately(manager, tmp_path):
|
||||||
lora_dir = tmp_path / "loras"
|
lora_dir = tmp_path / "loras"
|
||||||
extra_dir = tmp_path / "extra_loras"
|
extra_dir = tmp_path / "extra_loras"
|
||||||
|
|||||||
@@ -152,3 +152,145 @@ async def test_usage_stats_background_processor_handles_pending_prompts(tmp_path
|
|||||||
assert stats.stats["loras"]["lora-hash"]["history"][today] == 1
|
assert stats.stats["loras"]["lora-hash"]["history"][today] == 1
|
||||||
|
|
||||||
await _finalize_usage_stats(tasks)
|
await _finalize_usage_stats(tasks)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_usage_stats_calculates_pending_checkpoint_hash_on_demand(tmp_path, monkeypatch):
|
||||||
|
stats, tasks, _ = _prepare_usage_stats(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
metadata_payload = {
|
||||||
|
"models": {
|
||||||
|
"1": {"type": "checkpoint", "name": "pending_model.safetensors"},
|
||||||
|
},
|
||||||
|
"loras": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
checkpoint_cache = SimpleNamespace(
|
||||||
|
raw_data=[
|
||||||
|
{
|
||||||
|
"file_name": "pending_model",
|
||||||
|
"model_name": "pending_model",
|
||||||
|
"file_path": "/models/pending_model.safetensors",
|
||||||
|
"sha256": "",
|
||||||
|
"hash_status": "pending",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
checkpoint_scanner = SimpleNamespace(
|
||||||
|
get_hash_by_filename=lambda name: None,
|
||||||
|
get_cached_data=AsyncMock(return_value=checkpoint_cache),
|
||||||
|
calculate_hash_for_model=AsyncMock(return_value="resolved-hash"),
|
||||||
|
)
|
||||||
|
lora_scanner = SimpleNamespace(get_hash_by_filename=lambda name: None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", AsyncMock(return_value=checkpoint_scanner))
|
||||||
|
monkeypatch.setattr(ServiceRegistry, "get_lora_scanner", AsyncMock(return_value=lora_scanner))
|
||||||
|
|
||||||
|
await stats._process_metadata(metadata_payload)
|
||||||
|
|
||||||
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
checkpoint_scanner.calculate_hash_for_model.assert_awaited_once_with("/models/pending_model.safetensors")
|
||||||
|
assert stats.stats["checkpoints"]["resolved-hash"]["history"][today] == 1
|
||||||
|
|
||||||
|
await _finalize_usage_stats(tasks)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_usage_stats_skips_failed_checkpoint_hash_retry(tmp_path, monkeypatch):
|
||||||
|
stats, tasks, _ = _prepare_usage_stats(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
metadata_payload = {
|
||||||
|
"models": {
|
||||||
|
"1": {"type": "checkpoint", "name": "failed_model.safetensors"},
|
||||||
|
},
|
||||||
|
"loras": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
checkpoint_cache = SimpleNamespace(
|
||||||
|
raw_data=[
|
||||||
|
{
|
||||||
|
"file_name": "failed_model",
|
||||||
|
"model_name": "failed_model",
|
||||||
|
"file_path": "/models/failed_model.safetensors",
|
||||||
|
"sha256": "",
|
||||||
|
"hash_status": "failed",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
checkpoint_scanner = SimpleNamespace(
|
||||||
|
get_hash_by_filename=lambda name: None,
|
||||||
|
get_cached_data=AsyncMock(return_value=checkpoint_cache),
|
||||||
|
calculate_hash_for_model=AsyncMock(return_value=None),
|
||||||
|
)
|
||||||
|
lora_scanner = SimpleNamespace(get_hash_by_filename=lambda name: None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", AsyncMock(return_value=checkpoint_scanner))
|
||||||
|
monkeypatch.setattr(ServiceRegistry, "get_lora_scanner", AsyncMock(return_value=lora_scanner))
|
||||||
|
|
||||||
|
await stats._process_metadata(metadata_payload)
|
||||||
|
|
||||||
|
checkpoint_scanner.calculate_hash_for_model.assert_not_awaited()
|
||||||
|
assert stats.stats["checkpoints"] == {}
|
||||||
|
|
||||||
|
await _finalize_usage_stats(tasks)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_usage_stats_resolves_manually_copied_checkpoint_from_disk(tmp_path, monkeypatch):
|
||||||
|
stats, tasks, _ = _prepare_usage_stats(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
checkpoints_root = tmp_path / "checkpoints"
|
||||||
|
checkpoints_root.mkdir()
|
||||||
|
checkpoint_file = checkpoints_root / "Chroma1-HD-Q8_0.gguf"
|
||||||
|
checkpoint_file.write_text("manual checkpoint content", encoding="utf-8")
|
||||||
|
|
||||||
|
metadata_payload = {
|
||||||
|
"models": {
|
||||||
|
"1": {"type": "checkpoint", "name": "Chroma1-HD-Q8_0"},
|
||||||
|
},
|
||||||
|
"loras": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
checkpoint_cache = SimpleNamespace(raw_data=[])
|
||||||
|
checkpoint_scanner = SimpleNamespace(
|
||||||
|
get_hash_by_filename=lambda name: None,
|
||||||
|
get_cached_data=AsyncMock(return_value=checkpoint_cache),
|
||||||
|
get_model_roots=lambda: [str(checkpoints_root)],
|
||||||
|
file_extensions={".ckpt", ".pt", ".pt2", ".bin", ".pth", ".safetensors", ".pkl", ".sft", ".gguf"},
|
||||||
|
calculate_hash_for_model=AsyncMock(return_value="resolved-hash"),
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", AsyncMock(return_value=checkpoint_scanner))
|
||||||
|
monkeypatch.setattr(ServiceRegistry, "get_lora_scanner", AsyncMock(return_value=None))
|
||||||
|
|
||||||
|
await stats._process_metadata(metadata_payload)
|
||||||
|
|
||||||
|
checkpoint_scanner.calculate_hash_for_model.assert_awaited_once_with(
|
||||||
|
str(checkpoint_file).replace(os.sep, "/")
|
||||||
|
)
|
||||||
|
|
||||||
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
assert stats.stats["checkpoints"]["resolved-hash"]["history"][today] == 1
|
||||||
|
|
||||||
|
await _finalize_usage_stats(tasks)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_usage_stats_skips_name_fallback_for_missing_lora_hash(tmp_path, monkeypatch):
|
||||||
|
stats, tasks, _ = _prepare_usage_stats(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
metadata_payload = {
|
||||||
|
"models": {},
|
||||||
|
"loras": {
|
||||||
|
"2": {"lora_list": [{"name": "missing_lora"}]},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
checkpoint_scanner = SimpleNamespace(get_hash_by_filename=lambda name: None)
|
||||||
|
lora_scanner = SimpleNamespace(get_hash_by_filename=lambda name: None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", AsyncMock(return_value=checkpoint_scanner))
|
||||||
|
monkeypatch.setattr(ServiceRegistry, "get_lora_scanner", AsyncMock(return_value=lora_scanner))
|
||||||
|
|
||||||
|
await stats._process_metadata(metadata_payload)
|
||||||
|
|
||||||
|
assert stats.stats["loras"] == {}
|
||||||
|
assert not any(key.startswith("name:") for key in stats.stats["loras"])
|
||||||
|
|
||||||
|
await _finalize_usage_stats(tasks)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { app } from "../../scripts/app.js";
|
|||||||
import { TextAreaCaretHelper } from "./textarea_caret_helper.js";
|
import { TextAreaCaretHelper } from "./textarea_caret_helper.js";
|
||||||
import {
|
import {
|
||||||
getAutocompleteAppendCommaPreference,
|
getAutocompleteAppendCommaPreference,
|
||||||
|
getAutocompleteAutoFormatPreference,
|
||||||
getAutocompleteAcceptKeyPreference,
|
getAutocompleteAcceptKeyPreference,
|
||||||
getPromptTagAutocompletePreference,
|
getPromptTagAutocompletePreference,
|
||||||
getTagSpaceReplacementPreference,
|
getTagSpaceReplacementPreference,
|
||||||
@@ -122,6 +123,32 @@ function formatAutocompleteInsertion(text = '') {
|
|||||||
return getAutocompleteAppendCommaPreference() ? `${trimmed},` : `${trimmed} `;
|
return getAutocompleteAppendCommaPreference() ? `${trimmed},` : `${trimmed} `;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAutocompleteSegment(segment = '') {
|
||||||
|
return segment.replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAutocompleteTextOnBlur(text = '') {
|
||||||
|
if (typeof text !== 'string') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => {
|
||||||
|
if (!line.trim()) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanedSegments = line
|
||||||
|
.split(',')
|
||||||
|
.map(normalizeAutocompleteSegment)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return cleanedSegments.join(', ');
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
function shouldAcceptAutocompleteKey(key) {
|
function shouldAcceptAutocompleteKey(key) {
|
||||||
const mode = getAutocompleteAcceptKeyPreference();
|
const mode = getAutocompleteAcceptKeyPreference();
|
||||||
|
|
||||||
@@ -481,6 +508,14 @@ class AutoComplete {
|
|||||||
|
|
||||||
// Handle focus out to hide dropdown
|
// Handle focus out to hide dropdown
|
||||||
this.onBlur = () => {
|
this.onBlur = () => {
|
||||||
|
if (getAutocompleteAutoFormatPreference()) {
|
||||||
|
const formattedValue = formatAutocompleteTextOnBlur(this.inputElement.value);
|
||||||
|
if (formattedValue !== this.inputElement.value) {
|
||||||
|
this.inputElement.value = formattedValue;
|
||||||
|
this.inputElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Delay hiding to allow for clicks on dropdown items
|
// Delay hiding to allow for clicks on dropdown items
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.hide();
|
this.hide();
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ const PROMPT_TAG_AUTOCOMPLETE_DEFAULT = true;
|
|||||||
const AUTOCOMPLETE_APPEND_COMMA_SETTING_ID = "loramanager.autocomplete_append_comma";
|
const AUTOCOMPLETE_APPEND_COMMA_SETTING_ID = "loramanager.autocomplete_append_comma";
|
||||||
const AUTOCOMPLETE_APPEND_COMMA_DEFAULT = true;
|
const AUTOCOMPLETE_APPEND_COMMA_DEFAULT = true;
|
||||||
|
|
||||||
|
const AUTOCOMPLETE_AUTO_FORMAT_SETTING_ID = "loramanager.autocomplete_auto_format";
|
||||||
|
const AUTOCOMPLETE_AUTO_FORMAT_DEFAULT = true;
|
||||||
|
|
||||||
const AUTOCOMPLETE_ACCEPT_KEY_SETTING_ID = "loramanager.autocomplete_accept_key";
|
const AUTOCOMPLETE_ACCEPT_KEY_SETTING_ID = "loramanager.autocomplete_accept_key";
|
||||||
const AUTOCOMPLETE_ACCEPT_KEY_DEFAULT = "both";
|
const AUTOCOMPLETE_ACCEPT_KEY_DEFAULT = "both";
|
||||||
const AUTOCOMPLETE_ACCEPT_KEY_OPTION_BOTH = "Tab or Enter";
|
const AUTOCOMPLETE_ACCEPT_KEY_OPTION_BOTH = "Tab or Enter";
|
||||||
@@ -192,6 +195,32 @@ const getAutocompleteAppendCommaPreference = (() => {
|
|||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
const getAutocompleteAutoFormatPreference = (() => {
|
||||||
|
let settingsUnavailableLogged = false;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const settingManager = app?.extensionManager?.setting;
|
||||||
|
if (!settingManager || typeof settingManager.get !== "function") {
|
||||||
|
if (!settingsUnavailableLogged) {
|
||||||
|
console.warn("LoRA Manager: settings API unavailable, using default autocomplete auto format setting.");
|
||||||
|
settingsUnavailableLogged = true;
|
||||||
|
}
|
||||||
|
return AUTOCOMPLETE_AUTO_FORMAT_DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const value = settingManager.get(AUTOCOMPLETE_AUTO_FORMAT_SETTING_ID);
|
||||||
|
return value ?? AUTOCOMPLETE_AUTO_FORMAT_DEFAULT;
|
||||||
|
} catch (error) {
|
||||||
|
if (!settingsUnavailableLogged) {
|
||||||
|
console.warn("LoRA Manager: unable to read autocomplete auto format setting, using default.", error);
|
||||||
|
settingsUnavailableLogged = true;
|
||||||
|
}
|
||||||
|
return AUTOCOMPLETE_AUTO_FORMAT_DEFAULT;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
const getAutocompleteAcceptKeyPreference = (() => {
|
const getAutocompleteAcceptKeyPreference = (() => {
|
||||||
let settingsUnavailableLogged = false;
|
let settingsUnavailableLogged = false;
|
||||||
|
|
||||||
@@ -375,6 +404,14 @@ app.registerExtension({
|
|||||||
tooltip: "When enabled, accepted autocomplete suggestions append ', ' to the inserted text.",
|
tooltip: "When enabled, accepted autocomplete suggestions append ', ' to the inserted text.",
|
||||||
category: ["LoRA Manager", "Autocomplete", "Append comma"],
|
category: ["LoRA Manager", "Autocomplete", "Append comma"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: AUTOCOMPLETE_AUTO_FORMAT_SETTING_ID,
|
||||||
|
name: "Auto format autocomplete text on blur",
|
||||||
|
type: "boolean",
|
||||||
|
defaultValue: AUTOCOMPLETE_AUTO_FORMAT_DEFAULT,
|
||||||
|
tooltip: "When enabled, leaving an autocomplete textarea removes duplicate commas and collapses unnecessary spaces.",
|
||||||
|
category: ["LoRA Manager", "Autocomplete", "Auto Format"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: AUTOCOMPLETE_ACCEPT_KEY_SETTING_ID,
|
id: AUTOCOMPLETE_ACCEPT_KEY_SETTING_ID,
|
||||||
name: "Autocomplete accept key",
|
name: "Autocomplete accept key",
|
||||||
@@ -505,6 +542,7 @@ export {
|
|||||||
getWheelSensitivity,
|
getWheelSensitivity,
|
||||||
getAutoPathCorrectionPreference,
|
getAutoPathCorrectionPreference,
|
||||||
getAutocompleteAppendCommaPreference,
|
getAutocompleteAppendCommaPreference,
|
||||||
|
getAutocompleteAutoFormatPreference,
|
||||||
getAutocompleteAcceptKeyPreference,
|
getAutocompleteAcceptKeyPreference,
|
||||||
getPromptTagAutocompletePreference,
|
getPromptTagAutocompletePreference,
|
||||||
getTagSpaceReplacementPreference,
|
getTagSpaceReplacementPreference,
|
||||||
|
|||||||
Reference in New Issue
Block a user