Compare commits

..

27 Commits

Author SHA1 Message Date
Will Miao
68bf8442eb chore(release): bump version to v1.0.4 and add release notes 2026-04-16 14:26:28 +08:00
Will Miao
605fbf4117 feat(civitai): add host preference for view links 2026-04-16 13:28:51 +08:00
Will Miao
406d5fea6a fix(civitai): use red-only api host (#897) 2026-04-16 12:08:07 +08:00
Will Miao
af2146f96c fix(civitai): fallback image info hosts on request failure 2026-04-16 09:29:03 +08:00
Will Miao
bdc8dec860 fix(civitai): support civitai.red URLs (#897) 2026-04-16 08:54:12 +08:00
Will Miao
c4fa1631ee chore: bump version to v1.0.3 2026-04-15 23:10:43 +08:00
Will Miao
506d763dc2 chore: add pyyaml dependency 2026-04-15 23:07:36 +08:00
Will Miao
a2cd09b619 docs: add v1.0.3 release notes 2026-04-15 22:52:04 +08:00
Will Miao
cdd77029b6 fix(autocomplete): improve wildcard onboarding UX 2026-04-15 22:25:25 +08:00
Will Miao
439679e15f fix(autocomplete): preserve manual accept-key selection 2026-04-15 21:19:00 +08:00
Will Miao
2640258902 fix(prompt): invalidate dynamic wildcard cache without seed (#895) 2026-04-15 20:43:21 +08:00
Will Miao
b910388d54 fix(autocomplete): remove short prompt command aliases (#895) 2026-04-15 20:43:03 +08:00
Will Miao
083de395b1 chore(logging): remove autocomplete debug logs (#895) 2026-04-15 20:42:55 +08:00
Will Miao
4514ca94b7 fix(autocomplete): reduce tag search overhead (#895) 2026-04-15 20:42:33 +08:00
Will Miao
62247bdd87 feat(prompt): expand wildcards at runtime (#895) 2026-04-15 20:42:27 +08:00
Will Miao
6d0d9600a7 fix(versions): clarify tab hover states and copy 2026-04-13 21:12:13 +08:00
Will Miao
70cd3f4e1b fix(download-history): use title for downloaded tooltip 2026-04-13 20:26:40 +08:00
Will Miao
a95c518b30 feat(download-history): add downloaded status UX 2026-04-13 19:51:04 +08:00
Will Miao
ba1800095e fix(recipes): preserve scroll on in-place reloads 2026-04-13 10:30:50 +08:00
Will Miao
39c083db79 fix(recipes): preserve legacy gen params in modal flows 2026-04-12 21:25:54 +08:00
Will Miao
55e9e4bb6f fix(recipes): sanitize remote import gen params 2026-04-12 20:29:01 +08:00
Will Miao
0253d001e6 fix(recipe): hydrate stale modal data from recipe json 2026-04-12 19:22:58 +08:00
Will Miao
9998da3241 fix(ui): refresh stale model page versions 2026-04-11 20:11:21 +08:00
Will Miao
6666a72775 fix(doctor): center status badge 2026-04-11 16:28:14 +08:00
Will Miao
5f1bd894b9 fix(settings): prevent library modal focus jump 2026-04-11 16:20:37 +08:00
Will Miao
1817142a7b feat(doctor): add system diagnostics feature 2026-04-11 16:03:38 +08:00
Will Miao
25fa175aa2 fix(usage): resolve checkpoint hashes from disk 2026-04-10 22:28:04 +08:00
108 changed files with 9377 additions and 742 deletions

View File

@@ -56,6 +56,22 @@ Insomnia Art Designs, megakirbs, Brennok, 2018cfh, W+K+White, wackop, Takkan, Ca
## Release Notes
### v1.0.4
* **Civitai Domain Split Support** - Added support for `civitai.com` and `civitai.red` model URLs and recipe/image URLs across import, analysis, and download flows.
* **Civitai API Host Migration** - Updated core Civitai API requests to use `civitai.red` for compatibility with Civitai's current API host.
* **Configurable Civitai View Host** - Added a setting to choose which Civitai site opens by default for model, search, and view links.
* **401 Unauthorized Reminder** - Some users have reported `401 Unauthorized` errors. If you run into this, try generating a new API key on `civitai.red` and updating it in LoRA Manager settings.
### v1.0.3
* **Custom Recipe Storage Path** - Added support for configuring a custom storage path for recipes, with migration support to move existing recipe data when changing locations.
* **Wildcard Support for LM Text/Prompt Nodes** - The LM `Text` node and `Prompt` node now support the new `/wildcard` command, with runtime wildcard expansion and support for dynamic prompt syntax for more flexible prompt construction.
* **System Diagnostics ("Doctor")** - Added a new diagnostics feature to help surface environment and setup issues more clearly.
* **User-State Backup Support** - Added backup support for user state, with accompanying UI and clearer backup scope messaging in Settings.
* **Downloaded Status Visibility** - Added clearer downloaded-status UX so previously downloaded model versions are easier to recognize.
* **Autocomplete Performance Improvements** - Fixed autocomplete performance issues to reduce tag-search overhead and improve responsiveness.
### v1.0.2
* **Model Download History Tracking** - LoRA Manager now keeps a history of downloaded model versions, allowing it to recognize whether a version has been downloaded before, even if it is no longer currently present in your library.
@@ -101,7 +117,7 @@ Insomnia Art Designs, megakirbs, Brennok, 2018cfh, W+K+White, wackop, Takkan, Ca
### v0.9.14
* **LoRA Cycler Node** - Introduced a new LoRA Cycler node that enables iteration through specified LoRAs with support for repeat count and pause iteration functionality. Refer to the new "Lora Cycler" template workflow for concrete example.
* **Enhanced Prompt Node with Tag Autocomplete** - Enhanced the Prompt node with comprehensive tag autocomplete based on merged Danbooru + e621 tags. Supports tag search and autocomplete functionality. Implemented a command system with shortcuts like `/char` or `/artist` for category-specific tag searching. Added `/ac` or `/noac` commands to quickly enable or disable autocomplete. Refer to the "Lora Manager Basic" template workflow in ComfyUI -> Templates -> ComfyUI-Lora-Manager for detailed tips.
* **Enhanced Prompt Node with Tag Autocomplete** - Enhanced the Prompt node with comprehensive tag autocomplete based on merged Danbooru + e621 tags. Supports tag search and autocomplete functionality. Implemented a command system with shortcuts like `/character` or `/artist` for category-specific tag searching. Added `/ac` or `/noac` commands to quickly enable or disable autocomplete. Refer to the "Lora Manager Basic" template workflow in ComfyUI -> Templates -> ComfyUI-Lora-Manager for detailed tips.
* **Bug Fixes & Stability** - Addressed multiple bugs and improved overall stability.
### v0.9.12
@@ -253,6 +269,41 @@ pip install -r requirements.txt
- Paste into the Lora Loader node's text input
- The node will automatically apply preset strength and trigger words
### Wildcards for TextLM / PromptLM
`Text (LoraManager)` and `Prompt (LoraManager)` support `/wildcard` autocomplete plus runtime wildcard expansion.
- Wildcard files live in `{settings folder}/wildcards/`
- When you type `/wildcard` and no wildcard files exist yet, the autocomplete dropdown shows the exact folder path and lets you open it
- Supported formats: `.txt`, `.yaml`, `.yml`, `.json`
Format rules:
- `wildcards/animals/cat.txt` becomes `__animals/cat__`
- `.txt` files use one option per line
- YAML / JSON files use nested keys that end in string arrays
Examples:
```txt
# wildcards/color.txt
red
blue
green
```
Use it as `__color__`.
```yaml
# wildcards/colors.yaml
palette:
warm:
- red
- orange
```
Use it as `__palette/warm__`.
### Filename Format Patterns for Save Image Node
The Save Image Node supports dynamic filename generation using pattern codes. You can customize how your images are named using the following format patterns:

File diff suppressed because one or more lines are too long

View File

@@ -250,6 +250,19 @@
"civitaiApiKey": "Civitai API Key",
"civitaiApiKeyPlaceholder": "Geben Sie Ihren Civitai API Key ein",
"civitaiApiKeyHelp": "Wird für die Authentifizierung beim Herunterladen von Modellen von Civitai verwendet",
"civitaiHost": {
"label": "Civitai-Host",
"help": "Wählen Sie aus, welche Civitai-Seite geöffnet wird, wenn Sie „View on Civitai“-Links verwenden.",
"options": {
"com": "civitai.com (nur SFW)",
"red": "civitai.red (uneingeschränkt)"
}
},
"civitaiHostBanner": {
"title": "Civitai-Host-Einstellung verfügbar",
"content": "Civitai verwendet jetzt civitai.com für SFW-Inhalte und civitai.red für uneingeschränkte Inhalte. In den Einstellungen können Sie ändern, welche Seite standardmäßig geöffnet wird.",
"openSettings": "Einstellungen öffnen"
},
"openSettingsFileLocation": {
"label": "Einstellungsordner öffnen",
"tooltip": "Den Ordner mit der settings.json öffnen",
@@ -957,6 +970,8 @@
"earlyAccess": "Early Access",
"earlyAccessTooltip": "Early Access erforderlich",
"inLibrary": "In Bibliothek",
"downloaded": "Heruntergeladen",
"downloadedTooltip": "Zuvor heruntergeladen, aber derzeit nicht in Ihrer Bibliothek.",
"alreadyInLibrary": "Bereits in Bibliothek",
"autoOrganizedPath": "[Automatisch organisiert durch Pfadvorlage]",
"errors": {
@@ -1226,17 +1241,30 @@
"days": "in {count}d"
},
"badges": {
"current": "Aktuelle Version",
"current": "Geöffnete Version",
"currentTooltip": "Das ist die Version, mit der dieses Modal geöffnet wurde",
"inLibrary": "In der Bibliothek",
"inLibraryTooltip": "Diese Version befindet sich in Ihrer lokalen Bibliothek",
"downloaded": "Heruntergeladen",
"downloadedTooltip": "Diese Version wurde bereits heruntergeladen, befindet sich aber derzeit nicht in Ihrer Bibliothek",
"newer": "Neuere Version",
"newerTooltip": "Diese Version ist neuer als Ihre neueste lokale Version",
"earlyAccess": "Früher Zugriff",
"ignored": "Ignoriert"
"earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich",
"ignored": "Ignoriert",
"ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert"
},
"actions": {
"download": "Herunterladen",
"downloadTooltip": "Diese Version herunterladen",
"downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai herunterladen",
"delete": "Löschen",
"deleteTooltip": "Diese lokale Version löschen",
"ignore": "Ignorieren",
"unignore": "Ignorierung aufheben",
"ignoreTooltip": "Update-Benachrichtigungen für diese Version ignorieren",
"unignoreTooltip": "Update-Benachrichtigungen für diese Version fortsetzen",
"viewVersionOnCivitai": "Version auf Civitai anzeigen",
"earlyAccessTooltip": "Erfordert Early-Access-Kauf",
"resumeModelUpdates": "Aktualisierungen für dieses Modell fortsetzen",
"ignoreModelUpdates": "Aktualisierungen für dieses Modell ignorieren",
@@ -1806,6 +1834,35 @@
"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": {
"versionMismatch": {
"title": "Anwendungs-Update erkannt",

View File

@@ -250,6 +250,19 @@
"civitaiApiKey": "Civitai API Key",
"civitaiApiKeyPlaceholder": "Enter your Civitai API key",
"civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai",
"civitaiHost": {
"label": "Civitai host",
"help": "Choose which Civitai site opens when using View on Civitai links.",
"options": {
"com": "civitai.com (SFW)",
"red": "civitai.red (unrestricted)"
}
},
"civitaiHostBanner": {
"title": "Civitai host preference available",
"content": "Civitai now uses civitai.com for SFW content and civitai.red for unrestricted content. You can change which site opens by default in Settings.",
"openSettings": "Open Settings"
},
"openSettingsFileLocation": {
"label": "Open settings folder",
"tooltip": "Open folder containing settings.json",
@@ -685,9 +698,9 @@
"title": "Import a recipe from image or URL",
"urlLocalPath": "URL / Local Path",
"uploadImage": "Upload Image",
"urlSectionDescription": "Input a Civitai image URL or local file path to import as a recipe.",
"urlSectionDescription": "Input a Civitai image URL from civitai.com or civitai.red, or a local file path, to import as a recipe.",
"imageUrlOrPath": "Image URL or File Path:",
"urlPlaceholder": "https://civitai.com/images/... or C:/path/to/image.png",
"urlPlaceholder": "https://civitai.com/images/... or https://civitai.red/images/... or C:/path/to/image.png",
"fetchImage": "Fetch Image",
"uploadSectionDescription": "Upload an image with LoRA metadata to import as a recipe.",
"selectImage": "Select Image",
@@ -957,6 +970,8 @@
"earlyAccess": "Early Access",
"earlyAccessTooltip": "Early access required",
"inLibrary": "In Library",
"downloaded": "Downloaded",
"downloadedTooltip": "Previously downloaded, but it is not currently in your library.",
"alreadyInLibrary": "Already in Library",
"autoOrganizedPath": "[Auto-organized by path template]",
"errors": {
@@ -1088,9 +1103,9 @@
},
"proceedText": "Only proceed if you're sure this is what you want.",
"urlLabel": "Civitai Model URL:",
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676 or https://civitai.red/models/649516/model-name?modelVersionId=726676",
"helpText": {
"title": "Paste any Civitai model URL. Supported formats:",
"title": "Paste any Civitai model URL from civitai.com or civitai.red. Supported formats:",
"format1": "https://civitai.com/models/649516",
"format2": "https://civitai.com/models/649516?modelVersionId=726676",
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
@@ -1226,17 +1241,30 @@
"days": "in {count}d"
},
"badges": {
"current": "Current Version",
"current": "Opened Version",
"currentTooltip": "This is the version you opened this modal from",
"inLibrary": "In Library",
"inLibraryTooltip": "This version exists in your local library",
"downloaded": "Downloaded",
"downloadedTooltip": "This version was downloaded before, but is not currently in your library",
"newer": "Newer Version",
"newerTooltip": "This version is newer than your latest local version",
"earlyAccess": "Early Access",
"ignored": "Ignored"
"earlyAccessTooltip": "This version currently requires Civitai early access",
"ignored": "Ignored",
"ignoredTooltip": "Update notifications are disabled for this version"
},
"actions": {
"download": "Download",
"downloadTooltip": "Download this version",
"downloadEarlyAccessTooltip": "Download this early access version from Civitai",
"delete": "Delete",
"deleteTooltip": "Delete this local version",
"ignore": "Ignore",
"unignore": "Unignore",
"ignoreTooltip": "Ignore update notifications for this version",
"unignoreTooltip": "Resume update notifications for this version",
"viewVersionOnCivitai": "View version on Civitai",
"earlyAccessTooltip": "Requires early access purchase",
"resumeModelUpdates": "Resume updates for this model",
"ignoreModelUpdates": "Ignore updates for this model",
@@ -1806,6 +1834,35 @@
"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": {
"versionMismatch": {
"title": "Application Update Detected",

View File

@@ -250,6 +250,19 @@
"civitaiApiKey": "Clave API de Civitai",
"civitaiApiKeyPlaceholder": "Introduce tu clave API de Civitai",
"civitaiApiKeyHelp": "Utilizada para autenticación al descargar modelos de Civitai",
"civitaiHost": {
"label": "Host de Civitai",
"help": "Elige qué sitio de Civitai se abre al usar los enlaces de \"View on Civitai\".",
"options": {
"com": "civitai.com (solo SFW)",
"red": "civitai.red (sin restricciones)"
}
},
"civitaiHostBanner": {
"title": "Preferencia de host de Civitai disponible",
"content": "Civitai ahora usa civitai.com para contenido SFW y civitai.red para contenido sin restricciones. Puedes cambiar en Ajustes qué sitio se abre por defecto.",
"openSettings": "Abrir ajustes"
},
"openSettingsFileLocation": {
"label": "Abrir carpeta de ajustes",
"tooltip": "Abrir la carpeta que contiene settings.json",
@@ -957,6 +970,8 @@
"earlyAccess": "Acceso temprano",
"earlyAccessTooltip": "Acceso temprano requerido",
"inLibrary": "En la biblioteca",
"downloaded": "Descargado",
"downloadedTooltip": "Descargado anteriormente, pero actualmente no está en tu biblioteca.",
"alreadyInLibrary": "Ya en la biblioteca",
"autoOrganizedPath": "[Auto-organizado por plantilla de ruta]",
"errors": {
@@ -1226,17 +1241,30 @@
"days": "en {count}d"
},
"badges": {
"current": "Versión actual",
"current": "Versión abierta",
"currentTooltip": "Es la versión con la que abriste este modal",
"inLibrary": "En la biblioteca",
"inLibraryTooltip": "Esta versión existe en tu biblioteca local",
"downloaded": "Descargado",
"downloadedTooltip": "Esta versión se descargó antes, pero ahora no está en tu biblioteca",
"newer": "Versión más reciente",
"newerTooltip": "Esta versión es más reciente que tu última versión local",
"earlyAccess": "Acceso temprano",
"ignored": "Ignorada"
"earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai",
"ignored": "Ignorada",
"ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión"
},
"actions": {
"download": "Descargar",
"downloadTooltip": "Descargar esta versión",
"downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai",
"delete": "Eliminar",
"deleteTooltip": "Eliminar esta versión local",
"ignore": "Ignorar",
"unignore": "Dejar de ignorar",
"ignoreTooltip": "Ignorar las notificaciones de actualización de esta versión",
"unignoreTooltip": "Reanudar las notificaciones de actualización de esta versión",
"viewVersionOnCivitai": "Ver versión en Civitai",
"earlyAccessTooltip": "Requiere compra de acceso temprano",
"resumeModelUpdates": "Reanudar actualizaciones para este modelo",
"ignoreModelUpdates": "Ignorar actualizaciones para este modelo",
@@ -1806,6 +1834,35 @@
"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": {
"versionMismatch": {
"title": "Actualización de la aplicación detectada",

View File

@@ -250,6 +250,19 @@
"civitaiApiKey": "Clé API Civitai",
"civitaiApiKeyPlaceholder": "Entrez votre clé API Civitai",
"civitaiApiKeyHelp": "Utilisée pour l'authentification lors du téléchargement de modèles depuis Civitai",
"civitaiHost": {
"label": "Hôte Civitai",
"help": "Choisissez quel site Civitai s'ouvre lorsque vous utilisez les liens « View on Civitai ».",
"options": {
"com": "civitai.com (SFW uniquement)",
"red": "civitai.red (sans restriction)"
}
},
"civitaiHostBanner": {
"title": "Préférence dhôte Civitai disponible",
"content": "Civitai utilise désormais civitai.com pour le contenu SFW et civitai.red pour le contenu sans restriction. Vous pouvez modifier dans les paramètres le site ouvert par défaut.",
"openSettings": "Ouvrir les paramètres"
},
"openSettingsFileLocation": {
"label": "Ouvrir le dossier des paramètres",
"tooltip": "Ouvrir le dossier contenant settings.json",
@@ -957,6 +970,8 @@
"earlyAccess": "Accès anticipé",
"earlyAccessTooltip": "Accès anticipé requis",
"inLibrary": "Dans la bibliothèque",
"downloaded": "Téléchargé",
"downloadedTooltip": "Déjà téléchargé, mais il n'est actuellement pas dans votre bibliothèque.",
"alreadyInLibrary": "Déjà dans la bibliothèque",
"autoOrganizedPath": "[Auto-organisé par modèle de chemin]",
"errors": {
@@ -1226,17 +1241,30 @@
"days": "dans {count}j"
},
"badges": {
"current": "Version actuelle",
"current": "Version ouverte",
"currentTooltip": "C'est la version à partir de laquelle cette fenêtre a été ouverte",
"inLibrary": "Dans la bibliothèque",
"inLibraryTooltip": "Cette version existe dans votre bibliothèque locale",
"downloaded": "Téléchargé",
"downloadedTooltip": "Cette version a déjà été téléchargée, mais n'est pas actuellement dans votre bibliothèque",
"newer": "Version plus récente",
"newerTooltip": "Cette version est plus récente que votre dernière version locale",
"earlyAccess": "Accès anticipé",
"ignored": "Ignorée"
"earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai",
"ignored": "Ignorée",
"ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version"
},
"actions": {
"download": "Télécharger",
"downloadTooltip": "Télécharger cette version",
"downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai",
"delete": "Supprimer",
"deleteTooltip": "Supprimer cette version locale",
"ignore": "Ignorer",
"unignore": "Ne plus ignorer",
"ignoreTooltip": "Ignorer les notifications de mise à jour pour cette version",
"unignoreTooltip": "Reprendre les notifications de mise à jour pour cette version",
"viewVersionOnCivitai": "Voir la version sur Civitai",
"earlyAccessTooltip": "Nécessite l'achat de l'accès anticipé",
"resumeModelUpdates": "Reprendre les mises à jour pour ce modèle",
"ignoreModelUpdates": "Ignorer les mises à jour pour ce modèle",
@@ -1806,6 +1834,35 @@
"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": {
"versionMismatch": {
"title": "Mise à jour de l'application détectée",

View File

@@ -250,6 +250,19 @@
"civitaiApiKey": "מפתח API של Civitai",
"civitaiApiKeyPlaceholder": "הזן את מפתח ה-API שלך מ-Civitai",
"civitaiApiKeyHelp": "משמש לאימות בעת הורדת מודלים מ-Civitai",
"civitaiHost": {
"label": "מארח Civitai",
"help": "בחר איזה אתר של Civitai ייפתח בעת שימוש בקישורי \"View on Civitai\".",
"options": {
"com": "civitai.com (SFW בלבד)",
"red": "civitai.red (ללא הגבלות)"
}
},
"civitaiHostBanner": {
"title": "העדפת מארח Civitai זמינה",
"content": "Civitai משתמש כעת ב-civitai.com עבור תוכן SFW וב-civitai.red עבור תוכן ללא הגבלות. ניתן לשנות בהגדרות איזה אתר ייפתח כברירת מחדל.",
"openSettings": "פתח הגדרות"
},
"openSettingsFileLocation": {
"label": "פתח תיקיית הגדרות",
"tooltip": "פתח את התיקייה שמכילה את settings.json",
@@ -957,6 +970,8 @@
"earlyAccess": "גישה מוקדמת",
"earlyAccessTooltip": "נדרשת גישה מוקדמת",
"inLibrary": "בספרייה",
"downloaded": "הורד",
"downloadedTooltip": "הורד בעבר, אך הוא אינו נמצא כרגע בספרייה שלך.",
"alreadyInLibrary": "כבר בספרייה",
"autoOrganizedPath": "[מאורגן אוטומטית לפי תבנית נתיב]",
"errors": {
@@ -1226,17 +1241,30 @@
"days": "בעוד {count} ימים"
},
"badges": {
"current": "גרסה נוכחית",
"current": "גרסה שנפתחה",
"currentTooltip": "זוהי הגרסה שממנה נפתח החלון הזה",
"inLibrary": "בספרייה",
"inLibraryTooltip": "גרסה זו קיימת בספרייה המקומית שלך",
"downloaded": "הורד",
"downloadedTooltip": "גרסה זו הורדה בעבר, אך אינה נמצאת כרגע בספרייה שלך",
"newer": "גרסה חדשה יותר",
"newerTooltip": "גרסה זו חדשה יותר מהגרסה המקומית האחרונה שלך",
"earlyAccess": "גישה מוקדמת",
"ignored": "התעלם"
"earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai",
"ignored": "התעלם",
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו"
},
"actions": {
"download": "הורדה",
"downloadTooltip": "הורד את הגרסה הזו",
"downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai",
"delete": "מחיקה",
"deleteTooltip": "מחק את הגרסה המקומית הזו",
"ignore": "התעלם",
"unignore": "בטל התעלמות",
"ignoreTooltip": "התעלם מהתראות העדכון עבור גרסה זו",
"unignoreTooltip": "חזור לקבל התראות עדכון עבור גרסה זו",
"viewVersionOnCivitai": "הצג את הגרסה ב-Civitai",
"earlyAccessTooltip": "נדרש רכישת גישה מוקדמת",
"resumeModelUpdates": "המשך עדכונים עבור מודל זה",
"ignoreModelUpdates": "התעלם מעדכונים עבור מודל זה",
@@ -1806,6 +1834,35 @@
"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": {
"versionMismatch": {
"title": "זוהה עדכון יישום",

View File

@@ -250,6 +250,19 @@
"civitaiApiKey": "Civitai APIキー",
"civitaiApiKeyPlaceholder": "Civitai APIキーを入力してください",
"civitaiApiKeyHelp": "Civitaiからモデルをダウンロードするときの認証に使用されます",
"civitaiHost": {
"label": "Civitai ホスト",
"help": "「View on Civitai」リンクを使うときに開く Civitai サイトを選択します。",
"options": {
"com": "civitai.comSFW のみ)",
"red": "civitai.red制限なし"
}
},
"civitaiHostBanner": {
"title": "Civitai ホスト設定を利用できます",
"content": "Civitai は現在、SFW コンテンツには civitai.com、制限なしコンテンツには civitai.red を使用しています。設定で既定で開くサイトを変更できます。",
"openSettings": "設定を開く"
},
"openSettingsFileLocation": {
"label": "設定フォルダーを開く",
"tooltip": "settings.json を含むフォルダーを開きます",
@@ -957,6 +970,8 @@
"earlyAccess": "アーリーアクセス",
"earlyAccessTooltip": "アーリーアクセスが必要",
"inLibrary": "ライブラリ内",
"downloaded": "ダウンロード済み",
"downloadedTooltip": "以前にダウンロード済みですが、現在はライブラリにありません。",
"alreadyInLibrary": "既にライブラリ内",
"autoOrganizedPath": "[パステンプレートによる自動整理]",
"errors": {
@@ -1226,17 +1241,30 @@
"days": "{count}日後"
},
"badges": {
"current": "現在のバージョン",
"current": "開いたバージョン",
"currentTooltip": "このモーダルを開くために選択したバージョンです",
"inLibrary": "ライブラリにあります",
"inLibraryTooltip": "このバージョンはローカルライブラリに存在します",
"downloaded": "ダウンロード済み",
"downloadedTooltip": "このバージョンは以前ダウンロードされましたが、現在はライブラリにありません",
"newer": "新しいバージョン",
"newerTooltip": "このバージョンはローカルの最新バージョンより新しいです",
"earlyAccess": "早期アクセス",
"ignored": "無視中"
"earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です",
"ignored": "無視中",
"ignoredTooltip": "このバージョンの更新通知は無効です"
},
"actions": {
"download": "ダウンロード",
"downloadTooltip": "このバージョンをダウンロード",
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
"delete": "削除",
"deleteTooltip": "このローカルバージョンを削除",
"ignore": "無視",
"unignore": "無視を解除",
"ignoreTooltip": "このバージョンの更新通知を無視",
"unignoreTooltip": "このバージョンの更新通知を再開",
"viewVersionOnCivitai": "Civitai でバージョンを表示",
"earlyAccessTooltip": "早期アクセス購入が必要",
"resumeModelUpdates": "このモデルの更新を再開",
"ignoreModelUpdates": "このモデルの更新を無視",
@@ -1806,6 +1834,35 @@
"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": {
"versionMismatch": {
"title": "アプリケーション更新が検出されました",

View File

@@ -250,6 +250,19 @@
"civitaiApiKey": "Civitai API 키",
"civitaiApiKeyPlaceholder": "Civitai API 키를 입력하세요",
"civitaiApiKeyHelp": "Civitai에서 모델을 다운로드할 때 인증에 사용됩니다",
"civitaiHost": {
"label": "Civitai 호스트",
"help": "\"View on Civitai\" 링크를 사용할 때 어떤 Civitai 사이트를 열지 선택합니다.",
"options": {
"com": "civitai.com(SFW 전용)",
"red": "civitai.red(무제한)"
}
},
"civitaiHostBanner": {
"title": "Civitai 호스트 기본 설정 사용 가능",
"content": "이제 Civitai는 SFW 콘텐츠에 civitai.com을, 무제한 콘텐츠에 civitai.red를 사용합니다. 설정에서 기본으로 열 사이트를 변경할 수 있습니다.",
"openSettings": "설정 열기"
},
"openSettingsFileLocation": {
"label": "설정 폴더 열기",
"tooltip": "settings.json이 있는 폴더를 엽니다",
@@ -957,6 +970,8 @@
"earlyAccess": "얼리 액세스",
"earlyAccessTooltip": "얼리 액세스 필요",
"inLibrary": "라이브러리에 있음",
"downloaded": "다운로드됨",
"downloadedTooltip": "이전에 다운로드했지만 현재 라이브러리에 없습니다.",
"alreadyInLibrary": "이미 라이브러리에 있음",
"autoOrganizedPath": "[경로 템플릿으로 자동 정리됨]",
"errors": {
@@ -1226,17 +1241,30 @@
"days": "{count}일 후"
},
"badges": {
"current": "현재 버전",
"current": "열린 버전",
"currentTooltip": "이 모달을 열 때 사용한 버전입니다",
"inLibrary": "라이브러리에 있음",
"inLibraryTooltip": "이 버전은 로컬 라이브러리에 있습니다",
"downloaded": "다운로드됨",
"downloadedTooltip": "이 버전은 이전에 다운로드되었지만 현재는 라이브러리에 없습니다",
"newer": "최신 버전",
"newerTooltip": "이 버전은 로컬의 최신 버전보다 더 새롭습니다",
"earlyAccess": "얼리 액세스",
"ignored": "무시됨"
"earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다",
"ignored": "무시됨",
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다"
},
"actions": {
"download": "다운로드",
"downloadTooltip": "이 버전 다운로드",
"downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드",
"delete": "삭제",
"deleteTooltip": "이 로컬 버전 삭제",
"ignore": "무시",
"unignore": "무시 해제",
"ignoreTooltip": "이 버전의 업데이트 알림 무시",
"unignoreTooltip": "이 버전의 업데이트 알림 다시 받기",
"viewVersionOnCivitai": "Civitai에서 버전 보기",
"earlyAccessTooltip": "얼리 액세스 구매 필요",
"resumeModelUpdates": "이 모델 업데이트 재개",
"ignoreModelUpdates": "이 모델 업데이트 무시",
@@ -1806,6 +1834,35 @@
"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": {
"versionMismatch": {
"title": "애플리케이션 업데이트 감지",

View File

@@ -250,6 +250,19 @@
"civitaiApiKey": "Ключ API Civitai",
"civitaiApiKeyPlaceholder": "Введите ваш ключ API Civitai",
"civitaiApiKeyHelp": "Используется для аутентификации при загрузке моделей с Civitai",
"civitaiHost": {
"label": "Хост Civitai",
"help": "Выберите, какой сайт Civitai будет открываться при использовании ссылок «View on Civitai».",
"options": {
"com": "civitai.com (только SFW)",
"red": "civitai.red (без ограничений)"
}
},
"civitaiHostBanner": {
"title": "Доступна настройка хоста Civitai",
"content": "Теперь Civitai использует civitai.com для контента SFW и civitai.red для контента без ограничений. В настройках можно изменить, какой сайт открывать по умолчанию.",
"openSettings": "Открыть настройки"
},
"openSettingsFileLocation": {
"label": "Открыть папку настроек",
"tooltip": "Открыть папку, содержащую settings.json",
@@ -957,6 +970,8 @@
"earlyAccess": "Ранний доступ",
"earlyAccessTooltip": "Требуется ранний доступ",
"inLibrary": "В библиотеке",
"downloaded": "Загружено",
"downloadedTooltip": "Ранее загружено, но сейчас этого нет в вашей библиотеке.",
"alreadyInLibrary": "Уже в библиотеке",
"autoOrganizedPath": "[Автоматически организовано по шаблону пути]",
"errors": {
@@ -1226,17 +1241,30 @@
"days": "через {count}д"
},
"badges": {
"current": "Текущая версия",
"current": "Открытая версия",
"currentTooltip": "Это версия, с которой было открыто это окно",
"inLibrary": "В библиотеке",
"inLibraryTooltip": "Эта версия есть в вашей локальной библиотеке",
"downloaded": "Загружено",
"downloadedTooltip": "Эта версия уже загружалась, но сейчас отсутствует в вашей библиотеке",
"newer": "Более новая версия",
"newerTooltip": "Эта версия новее вашей последней локальной версии",
"earlyAccess": "Ранний доступ",
"ignored": "Игнорируется"
"earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai",
"ignored": "Игнорируется",
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены"
},
"actions": {
"download": "Скачать",
"downloadTooltip": "Скачать эту версию",
"downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai",
"delete": "Удалить",
"deleteTooltip": "Удалить эту локальную версию",
"ignore": "Игнорировать",
"unignore": "Перестать игнорировать",
"ignoreTooltip": "Игнорировать уведомления об обновлениях для этой версии",
"unignoreTooltip": "Возобновить уведомления об обновлениях для этой версии",
"viewVersionOnCivitai": "Посмотреть версию на Civitai",
"earlyAccessTooltip": "Требуется покупка раннего доступа",
"resumeModelUpdates": "Возобновить обновления для этой модели",
"ignoreModelUpdates": "Игнорировать обновления для этой модели",
@@ -1806,6 +1834,35 @@
"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": {
"versionMismatch": {
"title": "Обнаружено обновление приложения",

View File

@@ -250,6 +250,19 @@
"civitaiApiKey": "Civitai API 密钥",
"civitaiApiKeyPlaceholder": "请输入你的 Civitai API 密钥",
"civitaiApiKeyHelp": "用于从 Civitai 下载模型时的身份验证",
"civitaiHost": {
"label": "Civitai 站点",
"help": "选择使用“在 Civitai 中查看”时默认打开的 Civitai 站点。",
"options": {
"com": "civitai.com仅 SFW",
"red": "civitai.red无限制"
}
},
"civitaiHostBanner": {
"title": "已提供 Civitai 站点偏好设置",
"content": "Civitai 现在使用 civitai.com 提供 SFW 内容,使用 civitai.red 提供无限制内容。你可以在设置中更改默认打开的站点。",
"openSettings": "打开设置"
},
"openSettingsFileLocation": {
"label": "打开设置文件夹",
"tooltip": "打开包含 settings.json 的文件夹",
@@ -685,9 +698,9 @@
"title": "从图片或 URL 导入配方",
"urlLocalPath": "URL / 本地路径",
"uploadImage": "上传图片",
"urlSectionDescription": "输入 Civitai 图片 URL 或本地文件路径以导入为配方。",
"urlSectionDescription": "输入来自 civitai.com 或 civitai.red 的 Civitai 图片 URL或本地文件路径以导入为配方。",
"imageUrlOrPath": "图片 URL 或文件路径:",
"urlPlaceholder": "https://civitai.com/images/... 或 C:/path/to/image.png",
"urlPlaceholder": "https://civitai.com/images/... 或 https://civitai.red/images/... 或 C:/path/to/image.png",
"fetchImage": "获取图片",
"uploadSectionDescription": "上传带有 LoRA 元数据的图片以导入为配方。",
"selectImage": "选择图片",
@@ -957,6 +970,8 @@
"earlyAccess": "早期访问",
"earlyAccessTooltip": "需要早期访问权限",
"inLibrary": "已在库中",
"downloaded": "已下载",
"downloadedTooltip": "之前已下载,但当前不在你的库中。",
"alreadyInLibrary": "已存在于库中",
"autoOrganizedPath": "【已按路径模板自动整理】",
"errors": {
@@ -1088,9 +1103,9 @@
},
"proceedText": "仅在你确定需要此操作时继续。",
"urlLabel": "Civitai 模型 URL",
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676 或 https://civitai.red/models/649516/model-name?modelVersionId=726676",
"helpText": {
"title": "粘贴任意 Civitai 模型 URL。支持格式",
"title": "粘贴任意来自 civitai.com 或 civitai.red 的 Civitai 模型 URL。支持格式",
"format1": "https://civitai.com/models/649516",
"format2": "https://civitai.com/models/649516?modelVersionId=726676",
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
@@ -1226,17 +1241,30 @@
"days": "{count}天后"
},
"badges": {
"current": "当前版本",
"current": "已打开版本",
"currentTooltip": "这是你用来打开此弹窗的版本",
"inLibrary": "已在库中",
"inLibraryTooltip": "此版本已存在于你的本地库中",
"downloaded": "已下载",
"downloadedTooltip": "此版本之前下载过,但当前不在你的本地库中",
"newer": "较新的版本",
"newerTooltip": "此版本比你本地的最新版本更新",
"earlyAccess": "抢先体验",
"ignored": "已忽略"
"earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限",
"ignored": "已忽略",
"ignoredTooltip": "此版本已关闭更新通知"
},
"actions": {
"download": "下载",
"downloadTooltip": "下载此版本",
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
"delete": "删除",
"deleteTooltip": "删除此本地版本",
"ignore": "忽略",
"unignore": "取消忽略",
"ignoreTooltip": "忽略此版本的更新通知",
"unignoreTooltip": "恢复此版本的更新通知",
"viewVersionOnCivitai": "在 Civitai 上查看版本",
"earlyAccessTooltip": "需要购买抢先体验",
"resumeModelUpdates": "继续跟踪该模型的更新",
"ignoreModelUpdates": "忽略该模型的更新",
@@ -1806,6 +1834,35 @@
"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": {
"versionMismatch": {
"title": "检测到应用更新",

View File

@@ -250,6 +250,19 @@
"civitaiApiKey": "Civitai API 金鑰",
"civitaiApiKeyPlaceholder": "請輸入您的 Civitai API 金鑰",
"civitaiApiKeyHelp": "用於從 Civitai 下載模型時的身份驗證",
"civitaiHost": {
"label": "Civitai 站點",
"help": "選擇使用「在 Civitai 中查看」時預設開啟的 Civitai 站點。",
"options": {
"com": "civitai.com僅 SFW",
"red": "civitai.red無限制"
}
},
"civitaiHostBanner": {
"title": "已提供 Civitai 站點偏好設定",
"content": "Civitai 現在使用 civitai.com 提供 SFW 內容,使用 civitai.red 提供無限制內容。你可以在設定中變更預設開啟的站點。",
"openSettings": "開啟設定"
},
"openSettingsFileLocation": {
"label": "開啟設定資料夾",
"tooltip": "開啟包含 settings.json 的資料夾",
@@ -957,6 +970,8 @@
"earlyAccess": "早期存取",
"earlyAccessTooltip": "需要早期存取",
"inLibrary": "已在庫存",
"downloaded": "已下載",
"downloadedTooltip": "先前已下載,但目前不在你的庫中。",
"alreadyInLibrary": "已在庫存",
"autoOrganizedPath": "[依路徑範本自動整理]",
"errors": {
@@ -1226,17 +1241,30 @@
"days": "{count}天後"
},
"badges": {
"current": "目前版本",
"current": "已開啟版本",
"currentTooltip": "這是你用來開啟此彈窗的版本",
"inLibrary": "已在庫中",
"inLibraryTooltip": "此版本已存在於你的本地庫中",
"downloaded": "已下載",
"downloadedTooltip": "此版本之前下載過,但目前不在你的本地庫中",
"newer": "較新版本",
"newerTooltip": "此版本比你本地的最新版本更新",
"earlyAccess": "搶先體驗",
"ignored": "已忽略"
"earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限",
"ignored": "已忽略",
"ignoredTooltip": "此版本已關閉更新通知"
},
"actions": {
"download": "下載",
"downloadTooltip": "下載此版本",
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
"delete": "刪除",
"deleteTooltip": "刪除此本地版本",
"ignore": "忽略",
"unignore": "取消忽略",
"ignoreTooltip": "忽略此版本的更新通知",
"unignoreTooltip": "恢復此版本的更新通知",
"viewVersionOnCivitai": "在 Civitai 上查看版本",
"earlyAccessTooltip": "需要購買搶先體驗",
"resumeModelUpdates": "恢復追蹤此模型的更新",
"ignoreModelUpdates": "忽略此模型的更新",
@@ -1806,6 +1834,35 @@
"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": {
"versionMismatch": {
"title": "偵測到應用程式更新",

View File

@@ -1,15 +1,38 @@
from __future__ import annotations
from typing import Any
import inspect
from ..services.wildcard_service import (
contains_dynamic_syntax,
get_wildcard_service,
is_trigger_words_input,
)
class _AllContainer:
"""Container that accepts any key for dynamic input validation."""
def __contains__(self, item):
return True
class _PromptOptionalInputs:
"""Lookup that preserves explicit optional inputs and dynamic trigger slots."""
def __getitem__(self, key):
return ("STRING", {"forceInput": True})
def __init__(self, explicit_inputs: dict[str, tuple[str, dict[str, Any]]]) -> None:
self._explicit_inputs = explicit_inputs
def __contains__(self, item: object) -> bool:
if not isinstance(item, str):
return False
return item in self._explicit_inputs or is_trigger_words_input(item)
def __getitem__(self, key: str) -> tuple[str, dict[str, Any]]:
if key in self._explicit_inputs:
return self._explicit_inputs[key]
if is_trigger_words_input(key):
return (
"STRING",
{
"forceInput": True,
"tooltip": "Trigger words to prepend. Connect to add more inputs.",
},
)
raise KeyError(key)
class PromptLM:
@@ -20,12 +43,19 @@ class PromptLM:
DESCRIPTION = (
"Encodes a text prompt using a CLIP model into an embedding that can be used "
"to guide the diffusion model towards generating specific images. "
"Supports dynamic trigger words inputs."
"Supports dynamic trigger words inputs and runtime wildcard expansion."
)
@classmethod
def INPUT_TYPES(cls):
dyn_inputs = {
optional_inputs: dict[str, tuple[str, dict[str, Any]]] = {
"seed": (
"INT",
{
"forceInput": True,
"tooltip": "Optional seed for wildcard generation. Leave unconnected for non-deterministic wildcard expansion.",
},
),
"trigger_words1": (
"STRING",
{
@@ -35,10 +65,9 @@ class PromptLM:
),
}
# Bypass validation for dynamic inputs during graph execution
stack = inspect.stack()
if len(stack) > 2 and stack[2].function == "get_input_info":
dyn_inputs = _AllContainer()
optional_inputs = _PromptOptionalInputs(optional_inputs) # type: ignore[assignment]
return {
"required": {
@@ -46,8 +75,8 @@ class PromptLM:
"AUTOCOMPLETE_TEXT_PROMPT,STRING",
{
"widgetType": "AUTOCOMPLETE_TEXT_PROMPT",
"placeholder": "Enter prompt... /char, /artist for quick tag search",
"tooltip": "The text to be encoded.",
"placeholder": "Enter prompt... /character, /artist, /wildcard for quick search",
"tooltip": "The text to be encoded. Wildcard references inserted with /wildcard are expanded at runtime.",
},
),
"clip": (
@@ -55,7 +84,7 @@ class PromptLM:
{"tooltip": "The CLIP model used for encoding the text."},
),
},
"optional": dyn_inputs,
"optional": optional_inputs,
}
RETURN_TYPES = ("CONDITIONING", "STRING")
@@ -65,20 +94,39 @@ class PromptLM:
)
FUNCTION = "encode"
def encode(self, text: str, clip: Any, **kwargs):
# Collect all trigger words from dynamic inputs
@classmethod
def IS_CHANGED(
cls,
text: str,
clip: Any | None = None,
seed: int | None = None,
**kwargs: Any,
):
del clip, kwargs
if contains_dynamic_syntax(text) and seed is None:
return float("NaN")
return False
def encode(
self,
text: str,
clip: Any,
seed: int | None = None,
**kwargs: Any,
):
expanded_text = get_wildcard_service().expand_text(text, seed=seed)
trigger_words = []
for key, value in kwargs.items():
if key.startswith("trigger_words") and value:
if is_trigger_words_input(key) and value:
trigger_words.append(value)
# Build final prompt
if trigger_words:
prompt = ", ".join(trigger_words + [text])
prompt = ", ".join(trigger_words + [expanded_text])
else:
prompt = text
prompt = expanded_text
from nodes import CLIPTextEncode # type: ignore
conditioning = CLIPTextEncode().encode(clip, prompt)[0]
return (conditioning, prompt)
return (conditioning, prompt)

View File

@@ -1,10 +1,15 @@
from __future__ import annotations
from ..services.wildcard_service import contains_dynamic_syntax, get_wildcard_service
class TextLM:
"""A simple text node with autocomplete support."""
NAME = "Text (LoraManager)"
CATEGORY = "Lora Manager/utils"
DESCRIPTION = (
"A simple text input node with autocomplete support for tags and styles."
"A simple text input node with autocomplete support for tags, styles, and wildcard expansion."
)
@classmethod
@@ -15,8 +20,17 @@ class TextLM:
"AUTOCOMPLETE_TEXT_PROMPT,STRING",
{
"widgetType": "AUTOCOMPLETE_TEXT_PROMPT",
"placeholder": "Enter text... /char, /artist for quick tag search",
"tooltip": "The text output.",
"placeholder": "Enter text... /character, /artist, /wildcard for quick search",
"tooltip": "The text output. Wildcard references inserted with /wildcard are expanded at runtime.",
},
),
},
"optional": {
"seed": (
"INT",
{
"forceInput": True,
"tooltip": "Optional seed for wildcard generation. Leave unconnected for non-deterministic wildcard expansion.",
},
),
},
@@ -24,10 +38,14 @@ class TextLM:
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("STRING",)
OUTPUT_TOOLTIPS = (
"The text output.",
)
OUTPUT_TOOLTIPS = ("The text output.",)
FUNCTION = "process"
def process(self, text: str):
return (text,)
@classmethod
def IS_CHANGED(cls, text: str, seed: int | None = None):
if contains_dynamic_syntax(text) and seed is None:
return float("NaN")
return False
def process(self, text: str, seed: int | None = None):
return (get_wildcard_service().expand_text(text, seed=seed),)

View File

@@ -13,4 +13,5 @@ GEN_PARAM_KEYS = [
'seed',
'size',
'clip_skip',
'denoising_strength',
]

View File

@@ -1,11 +1,11 @@
import logging
import json
import re
import os
from typing import Any, Dict, Optional
from .merger import GenParamsMerger
from .base import RecipeMetadataParser
from ..services.metadata_service import get_default_metadata_provider
from ..utils.civitai_utils import extract_civitai_image_id
logger = logging.getLogger(__name__)
@@ -39,11 +39,12 @@ class RecipeEnricher:
source_url = recipe.get("source_url") or recipe.get("source_path", "")
# Check if it's a Civitai image URL
image_id_match = re.search(r'civitai\.com/images/(\d+)', str(source_url))
if image_id_match:
image_id = image_id_match.group(1)
image_id = extract_civitai_image_id(str(source_url))
if image_id:
try:
image_info = await civitai_client.get_image_info(image_id)
image_info = await civitai_client.get_image_info(
image_id, source_url=str(source_url)
)
if image_info:
# Handle nested meta often found in Civitai API responses
raw_meta = image_info.get("meta")

View File

@@ -1,27 +1,33 @@
from typing import Any, Dict, Optional
import logging
from .constants import GEN_PARAM_KEYS
logger = logging.getLogger(__name__)
class GenParamsMerger:
"""Utility to merge generation parameters from multiple sources with priority."""
ALLOWED_KEYS = set(GEN_PARAM_KEYS)
BLACKLISTED_KEYS = {
"id", "url", "userId", "username", "createdAt", "updatedAt", "hash", "meta",
"draft", "extra", "width", "height", "process", "quantity", "workflow",
"baseModel", "resources", "disablePoi", "aspectRatio", "Created Date",
"experimental", "civitaiResources", "civitai_resources", "Civitai resources",
"modelVersionId", "modelId", "hashes", "Model", "Model hash", "checkpoint_hash",
"checkpoint", "checksum", "model_checksum"
"checkpoint", "checksum", "model_checksum", "raw_metadata",
}
NORMALIZATION_MAPPING = {
# Civitai specific
"cfg": "cfg_scale",
"cfgScale": "cfg_scale",
"clipSkip": "clip_skip",
"negativePrompt": "negative_prompt",
# Case variations
"Sampler": "sampler",
"sampler_name": "sampler",
"scheduler": "sampler",
"Steps": "steps",
"Seed": "seed",
"Size": "size",
@@ -36,63 +42,40 @@ class GenParamsMerger:
def merge(
request_params: Optional[Dict[str, Any]] = None,
civitai_meta: Optional[Dict[str, Any]] = None,
embedded_metadata: Optional[Dict[str, Any]] = None
embedded_metadata: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
Merge generation parameters from three sources.
Priority: request_params > civitai_meta > embedded_metadata
Args:
request_params: Params provided directly in the import request
civitai_meta: Params from Civitai Image API 'meta' field
embedded_metadata: Params extracted from image EXIF/embedded metadata
Returns:
Merged parameters dictionary
"""
result = {}
# 1. Start with embedded metadata (lowest priority)
Priority: request_params > civitai_meta > embedded_metadata
"""
result: Dict[str, Any] = {}
if embedded_metadata:
# If it's a full recipe metadata, we use its gen_params
if "gen_params" in embedded_metadata and isinstance(embedded_metadata["gen_params"], dict):
if "gen_params" in embedded_metadata and isinstance(
embedded_metadata["gen_params"], dict
):
GenParamsMerger._update_normalized(result, embedded_metadata["gen_params"])
else:
# Otherwise assume the dict itself contains gen_params
GenParamsMerger._update_normalized(result, embedded_metadata)
# 2. Layer Civitai meta (medium priority)
if civitai_meta:
GenParamsMerger._update_normalized(result, civitai_meta)
# 3. Layer request params (highest priority)
if request_params:
GenParamsMerger._update_normalized(result, request_params)
# Filter out blacklisted keys and also the original camelCase keys if they were normalized
final_result = {}
for k, v in result.items():
if k in GenParamsMerger.BLACKLISTED_KEYS:
continue
if k in GenParamsMerger.NORMALIZATION_MAPPING:
continue
final_result[k] = v
return final_result
return result
@staticmethod
def _update_normalized(target: Dict[str, Any], source: Dict[str, Any]) -> None:
"""Update target dict with normalized keys from source."""
for k, v in source.items():
normalized_key = GenParamsMerger.NORMALIZATION_MAPPING.get(k, k)
target[normalized_key] = v
# Also keep the original key for now if it's not the same,
# so we can filter at the end or avoid losing it if it wasn't supposed to be renamed?
# Actually, if we rename it, we should probably NOT keep both in 'target'
# because we want to filter them out at the end anyway.
if normalized_key != k:
# If we are overwriting an existing snake_case key with a camelCase one's value,
# that's fine because of the priority order of calls to _update_normalized.
pass
target[k] = v
"""Update target dict with normalized, persistence-safe keys from source."""
for key, value in source.items():
if key in GenParamsMerger.BLACKLISTED_KEYS:
continue
normalized_key = GenParamsMerger.NORMALIZATION_MAPPING.get(key, key)
if normalized_key not in GenParamsMerger.ALLOWED_KEYS:
continue
target[normalized_key] = value

View File

@@ -10,15 +10,20 @@ from __future__ import annotations
import asyncio
import contextlib
import io
import json
import logging
import time
import os
import platform
import re
import subprocess
import sys
import tempfile
import zipfile
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
@@ -32,6 +37,7 @@ from ...services.settings_manager import get_settings_manager
from ...services.websocket_manager import ws_manager
from ...services.downloader import get_downloader
from ...services.errors import ResourceNotFoundError
from ...services.cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
from ...utils.constants import (
CIVITAI_USER_MODEL_TYPES,
DEFAULT_NODE_COLOR,
@@ -42,12 +48,321 @@ from ...utils.constants import (
from ...utils.civitai_utils import rewrite_preview_url
from ...utils.example_images_paths import is_valid_example_images_root
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 .base_model_handlers import BaseModelHandlerSet
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:
"""Check if running in WSL environment."""
try:
@@ -272,6 +587,388 @@ class SupportersHandler:
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:
"""Handler for example workflow templates."""
@@ -1714,6 +2411,16 @@ class FileSystemHandler:
logger.error("Failed to open backup location: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def open_wildcards_location(self, request: web.Request) -> web.Response:
try:
from ...services.wildcard_service import get_wildcards_dir
wildcards_dir = get_wildcards_dir(create=True)
return await self._open_path(wildcards_dir)
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to open wildcards location: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
class CustomWordsHandler:
"""Handler for autocomplete via TagFTSIndex."""
@@ -1793,6 +2500,41 @@ class CustomWordsHandler:
return None
class WildcardsHandler:
"""Handler for wildcard autocomplete search."""
def __init__(self, *, service=None) -> None:
if service is None:
from ...services.wildcard_service import get_wildcard_service
service = get_wildcard_service()
self._service = service
async def search_wildcards(self, request: web.Request) -> web.Response:
"""Search managed wildcard keys for autocomplete."""
try:
search_term = request.query.get("search", "")
limit = min(int(request.query.get("limit", "20")), 100)
offset = max(0, int(request.query.get("offset", "0")))
metadata = self._service.get_metadata(create_dir=True)
results = self._service.search_keys(search_term, limit=limit, offset=offset)
return web.json_response(
{
"success": True,
"words": results,
"meta": {
"has_wildcards": metadata.has_wildcards,
"wildcards_dir": metadata.wildcards_dir,
"supported_formats": list(metadata.supported_formats),
},
}
)
except Exception as exc:
logger.error("Error searching wildcards: %s", exc, exc_info=True)
return web.json_response({"error": str(exc)}, status=500)
class NodeRegistryHandler:
def __init__(
self,
@@ -2021,7 +2763,9 @@ class MiscHandlerSet:
backup: BackupHandler,
filesystem: FileSystemHandler,
custom_words: CustomWordsHandler,
wildcards: WildcardsHandler,
supporters: SupportersHandler,
doctor: DoctorHandler,
example_workflows: ExampleWorkflowsHandler,
base_model: BaseModelHandlerSet,
) -> None:
@@ -2037,7 +2781,9 @@ class MiscHandlerSet:
self.backup = backup
self.filesystem = filesystem
self.custom_words = custom_words
self.wildcards = wildcards
self.supporters = supporters
self.doctor = doctor
self.example_workflows = example_workflows
self.base_model = base_model
@@ -2048,6 +2794,9 @@ class MiscHandlerSet:
"health_check": self.health.health_check,
"get_settings": self.settings.get_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_settings_libraries": self.settings.get_libraries,
"activate_library": self.settings.activate_library,
@@ -2073,7 +2822,9 @@ class MiscHandlerSet:
"open_file_location": self.filesystem.open_file_location,
"open_settings_location": self.filesystem.open_settings_location,
"open_backup_location": self.filesystem.open_backup_location,
"open_wildcards_location": self.filesystem.open_wildcards_location,
"search_custom_words": self.custom_words.search_custom_words,
"search_wildcards": self.wildcards.search_wildcards,
"get_supporters": self.supporters.get_supporters,
"get_example_workflows": self.example_workflows.get_example_workflows,
"get_example_workflow": self.example_workflows.get_example_workflow,

View File

@@ -19,6 +19,7 @@ from ...services.download_coordinator import DownloadCoordinator
from ...services.metadata_sync_service import MetadataSyncService
from ...services.model_file_service import ModelMoveService
from ...services.preview_asset_service import PreviewAssetService
from ...services.service_registry import ServiceRegistry
from ...services.settings_manager import SettingsManager, get_settings_manager
from ...services.tag_update_service import TagUpdateService
from ...services.use_cases import (
@@ -64,7 +65,6 @@ class ModelPageView:
self._settings = settings_service
self._server_i18n = server_i18n
self._logger = logger
self._app_version = self._get_app_version()
def _load_supporters(self) -> dict:
"""Load supporters data from JSON file."""
@@ -155,7 +155,7 @@ class ModelPageView:
"request": request,
"folders": [],
"t": self._server_i18n.get_translation,
"version": self._app_version,
"version": self._get_app_version(),
}
if not is_initializing:
@@ -1532,6 +1532,20 @@ class ModelCivitaiHandler:
cache = await self._service.scanner.get_cached_data()
version_index = cache.version_index
downloaded_version_ids: set[int] = set()
try:
history_service = await ServiceRegistry.get_downloaded_version_history_service()
downloaded_version_ids = set(
await history_service.get_downloaded_version_ids(
self._service.model_type,
model_id,
)
)
except Exception as exc: # pragma: no cover - defensive logging
self._logger.debug(
"Failed to load download history for CivitAI versions: %s",
exc,
)
for version in versions:
version_id = None
@@ -1548,6 +1562,9 @@ class ModelCivitaiHandler:
else None
)
version["existsLocally"] = cache_entry is not None
version["hasBeenDownloaded"] = (
version_id in downloaded_version_ids if version_id is not None else False
)
if cache_entry and isinstance(cache_entry, Mapping):
local_path = cache_entry.get("file_path")
if local_path:
@@ -2266,7 +2283,7 @@ class ModelUpdateHandler:
self,
record,
*,
version_context: Optional[Dict[int, Dict[str, Optional[str]]]] = None,
version_context: Optional[Dict[int, Dict[str, Any]]] = None,
) -> Dict:
context = version_context or {}
# Check user setting for hiding early access versions
@@ -2295,7 +2312,7 @@ class ModelUpdateHandler:
@staticmethod
def _serialize_version(
version, context: Optional[Dict[str, Optional[str]]]
version, context: Optional[Dict[str, Any]]
) -> Dict:
context = context or {}
preview_override = context.get("preview_override")
@@ -2329,6 +2346,7 @@ class ModelUpdateHandler:
"sizeBytes": version.size_bytes,
"previewUrl": preview_url,
"isInLibrary": version.is_in_library,
"hasBeenDownloaded": bool(context.get("has_been_downloaded", False)),
"shouldIgnore": version.should_ignore,
"earlyAccessEndsAt": version.early_access_ends_at,
"isEarlyAccess": is_early_access,
@@ -2338,8 +2356,31 @@ class ModelUpdateHandler:
async def _build_version_context(
self, record
) -> Dict[int, Dict[str, Optional[str]]]:
context: Dict[int, Dict[str, Optional[str]]] = {}
) -> Dict[int, Dict[str, Any]]:
context: Dict[int, Dict[str, Any]] = {}
downloaded_version_ids: set[int] = set()
try:
history_service = await ServiceRegistry.get_downloaded_version_history_service()
downloaded_version_ids = set(
await history_service.get_downloaded_version_ids(
record.model_type,
record.model_id,
)
)
except Exception as exc: # pragma: no cover - defensive logging
self._logger.debug(
"Failed to load download history while building version context: %s",
exc,
)
for version in record.versions:
context[version.version_id] = {
"file_path": None,
"file_name": None,
"preview_override": None,
"has_been_downloaded": version.version_id in downloaded_version_ids,
}
try:
cache = await self._service.scanner.get_cached_data()
except Exception as exc: # pragma: no cover - defensive logging
@@ -2358,16 +2399,21 @@ class ModelUpdateHandler:
cache_entry = version_index.get(version.version_id)
if isinstance(cache_entry, Mapping):
preview = cache_entry.get("preview_url")
context_entry: Dict[str, Optional[str]] = {
"file_path": cache_entry.get("file_path"),
"file_name": cache_entry.get("file_name"),
"preview_override": None,
}
context_entry = context.setdefault(
version.version_id,
{
"file_path": None,
"file_name": None,
"preview_override": None,
"has_been_downloaded": version.version_id in downloaded_version_ids,
},
)
context_entry["file_path"] = cache_entry.get("file_path")
context_entry["file_name"] = cache_entry.get("file_name")
if isinstance(preview, str) and preview:
context_entry["preview_override"] = config.get_preview_static_url(
preview
)
context[version.version_id] = context_entry
return context

View File

@@ -26,7 +26,7 @@ from ...services.recipes import (
RecipeValidationError,
)
from ...services.metadata_service import get_default_metadata_provider
from ...utils.civitai_utils import rewrite_preview_url
from ...utils.civitai_utils import extract_civitai_image_id, rewrite_preview_url
from ...utils.exif_utils import ExifUtils
from ...recipes.merger import GenParamsMerger
from ...recipes.enrichment import RecipeEnricher
@@ -756,6 +756,14 @@ class RecipeManagementHandler:
)
gen_params_request = self._parse_gen_params(params.get("gen_params"))
self._logger.info(
"Remote recipe import received: url=%s, request_gen_params_keys=%s, lora_count=%d, checkpoint_keys=%s",
image_url,
sorted(gen_params_request.keys()) if gen_params_request else [],
len(lora_entries),
sorted(checkpoint_entry.keys()) if isinstance(checkpoint_entry, dict) else [],
)
# 2. Initial Metadata Construction
metadata: Dict[str, Any] = {
"base_model": params.get("base_model", "") or "",
@@ -1188,13 +1196,15 @@ class RecipeManagementHandler:
temp_path = temp_file.name
download_url = image_url
image_info = None
civitai_match = re.match(r"https://civitai\.com/images/(\d+)", image_url)
if civitai_match:
civitai_image_id = extract_civitai_image_id(image_url)
if civitai_image_id:
if civitai_client is None:
raise RecipeDownloadError(
"Civitai client unavailable for image download"
)
image_info = await civitai_client.get_image_info(civitai_match.group(1))
image_info = await civitai_client.get_image_info(
civitai_image_id, source_url=image_url
)
if not image_info:
raise RecipeDownloadError(
"Failed to fetch image information from Civitai"
@@ -1228,7 +1238,7 @@ class RecipeManagementHandler:
return (
file_obj.read(),
extension,
image_info.get("meta") if civitai_match and image_info else None,
image_info.get("meta") if civitai_image_id and image_info else None,
)
except RecipeDownloadError:
raise

View File

@@ -22,11 +22,16 @@ class RouteDefinition:
MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/settings", "get_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/settings/libraries", "get_settings_libraries"),
RouteDefinition("POST", "/api/lm/settings/libraries/activate", "activate_library"),
RouteDefinition("GET", "/api/lm/health-check", "health_check"),
RouteDefinition("GET", "/api/lm/supporters", "get_supporters"),
RouteDefinition("GET", "/api/lm/wildcards/search", "search_wildcards"),
RouteDefinition("POST", "/api/lm/wildcards/open-location", "open_wildcards_location"),
RouteDefinition("POST", "/api/lm/open-file-location", "open_file_location"),
RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"),
RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"),

View File

@@ -19,6 +19,7 @@ from ..services.downloader import get_downloader
from ..utils.usage_stats import UsageStats
from .handlers.misc_handlers import (
CustomWordsHandler,
DoctorHandler,
ExampleWorkflowsHandler,
FileSystemHandler,
HealthCheckHandler,
@@ -34,6 +35,7 @@ from .handlers.misc_handlers import (
SupportersHandler,
TrainedWordsHandler,
UsageStatsHandler,
WildcardsHandler,
build_service_registry_adapter,
)
from .handlers.base_model_handlers import BaseModelHandlerSet
@@ -129,7 +131,9 @@ class MiscRoutes:
metadata_provider_factory=self._metadata_provider_factory,
)
custom_words = CustomWordsHandler()
wildcards = WildcardsHandler()
supporters = SupportersHandler()
doctor = DoctorHandler(settings_service=self._settings)
example_workflows = ExampleWorkflowsHandler()
base_model = BaseModelHandlerSet()
@@ -146,7 +150,9 @@ class MiscRoutes:
backup=backup,
filesystem=filesystem,
custom_words=custom_words,
wildcards=wildcards,
supporters=supporters,
doctor=doctor,
example_workflows=example_workflows,
base_model=base_model,
)

View File

@@ -20,6 +20,7 @@ from .model_query import (
resolve_sub_type,
)
from .settings_manager import get_settings_manager
from ..utils.civitai_utils import build_civitai_model_page_url
logger = logging.getLogger(__name__)
@@ -774,9 +775,12 @@ class BaseModelService(ABC):
version_id = civitai_data.get("id")
if model_id:
civitai_url = f"https://civitai.com/models/{model_id}"
if version_id:
civitai_url += f"?modelVersionId={version_id}"
civitai_host = self.settings.get("civitai_host", "civitai.com")
civitai_url = build_civitai_model_page_url(
model_id,
version_id,
host=civitai_host,
)
return {
"civitai_url": civitai_url,

View File

@@ -105,12 +105,18 @@ class CheckpointScanner(ModelScanner):
return None
# Load current metadata
metadata, _ = await MetadataManager.load_metadata(
metadata, should_skip = await MetadataManager.load_metadata(
file_path, self.model_class
)
if metadata is None:
logger.error(f"No metadata found for {file_path}")
return 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}")
return None
metadata = created_metadata
# Check if hash is already calculated
if metadata.hash_status == "completed" and metadata.sha256:

View File

@@ -30,7 +30,7 @@ class CivitaiBaseModelService:
DEFAULT_CACHE_TTL = 7 * 24 * 60 * 60
# Civitai API endpoint for enums
CIVITAI_ENUMS_URL = "https://civitai.com/api/v1/enums"
CIVITAI_ENUMS_URL = "https://civitai.red/api/v1/enums"
@classmethod
async def get_instance(cls) -> CivitaiBaseModelService:

View File

@@ -39,7 +39,10 @@ class CivitaiClient:
return
self._initialized = True
self.base_url = "https://civitai.com/api/v1"
self.base_url = "https://civitai.red/api/v1"
def _build_image_info_url(self, image_id: str) -> str:
return f"{self.base_url}/images?imageId={image_id}&nsfw=X"
async def _make_request(
self,
@@ -190,7 +193,9 @@ class CivitaiClient:
"""Get all versions of a model with local availability info"""
try:
success, result = await self._make_request(
"GET", f"{self.base_url}/models/{model_id}", use_auth=True
"GET",
f"{self.base_url}/models/{model_id}",
use_auth=True,
)
if success:
# Also return model type along with versions
@@ -346,7 +351,9 @@ class CivitaiClient:
async def _fetch_model_data(self, model_id: int) -> Optional[Dict]:
success, data = await self._make_request(
"GET", f"{self.base_url}/models/{model_id}", use_auth=True
"GET",
f"{self.base_url}/models/{model_id}",
use_auth=True,
)
if success:
return data
@@ -358,7 +365,9 @@ class CivitaiClient:
return None
success, version = await self._make_request(
"GET", f"{self.base_url}/model-versions/{version_id}", use_auth=True
"GET",
f"{self.base_url}/model-versions/{version_id}",
use_auth=True,
)
if success:
return version
@@ -371,7 +380,9 @@ class CivitaiClient:
return None
success, version = await self._make_request(
"GET", f"{self.base_url}/model-versions/by-hash/{model_hash}", use_auth=True
"GET",
f"{self.base_url}/model-versions/by-hash/{model_hash}",
use_auth=True,
)
if success:
return version
@@ -453,13 +464,11 @@ class CivitaiClient:
try:
url = f"{self.base_url}/model-versions/{version_id}"
logger.debug(f"Resolving DNS for model version info: {url}")
logger.debug("Resolving Civitai model version info: %s", url)
success, result = await self._make_request("GET", url, use_auth=True)
if success:
logger.debug(
f"Successfully fetched model version info for: {version_id}"
)
logger.debug("Successfully fetched model version info for: %s", version_id)
self._remove_comfy_metadata(result)
return result, None
@@ -479,48 +488,58 @@ class CivitaiClient:
logger.error(error_msg)
return None, error_msg
async def get_image_info(self, image_id: str) -> Optional[Dict]:
async def get_image_info(
self, image_id: str, source_url: str | None = None
) -> Optional[Dict]:
"""Fetch image information from Civitai API
Args:
image_id: The Civitai image ID
source_url: Original image page URL. Accepted for caller compatibility;
API requests always target ``civitai.red``.
Returns:
Optional[Dict]: The image data or None if not found
"""
try:
url = f"{self.base_url}/images?imageId={image_id}&nsfw=X"
requested_id = int(image_id)
logger.debug(f"Fetching image info for ID: {image_id}")
url = self._build_image_info_url(image_id)
success, result = await self._make_request("GET", url, use_auth=True)
if success:
if result and "items" in result and isinstance(result["items"], list):
items = result["items"]
# First, try to find the item with matching ID
for item in items:
if isinstance(item, dict) and item.get("id") == requested_id:
logger.debug(f"Successfully fetched image info for ID: {image_id}")
return item
# No matching ID found - log warning with details about returned items
returned_ids = [
item.get("id") for item in items
if isinstance(item, dict) and "id" in item
]
logger.warning(
f"CivitAI API returned no matching image for requested ID {image_id}. "
f"Returned {len(items)} item(s) with IDs: {returned_ids}. "
f"This may indicate the image was deleted, hidden, or there is a database lag."
)
return None
logger.warning(f"No image found with ID: {image_id}")
if not success:
logger.error(
"Failed to fetch image info for ID %s from civitai.red: %s",
image_id,
result,
)
return None
logger.error(f"Failed to fetch image info for ID: {image_id}: {result}")
if result and "items" in result and isinstance(result["items"], list):
items = result["items"]
for item in items:
if isinstance(item, dict) and item.get("id") == requested_id:
logger.debug(
"Successfully fetched image info for ID %s from civitai.red",
image_id,
)
return item
returned_ids = [
item.get("id")
for item in items
if isinstance(item, dict) and "id" in item
]
logger.warning(
"CivitAI API returned no matching image for requested ID %s from civitai.red. Returned %d item(s) with IDs: %s. This may indicate the image was deleted, hidden, or there is a database lag.",
image_id,
len(items),
returned_ids,
)
return None
logger.warning("No image found with ID: %s", image_id)
return None
except RateLimitError:
raise
@@ -539,8 +558,12 @@ class CivitaiClient:
return None
try:
url = f"{self.base_url}/models?username={username}"
success, result = await self._make_request("GET", url, use_auth=True)
success, result = await self._make_request(
"GET",
f"{self.base_url}/models",
use_auth=True,
params={"username": username},
)
if not success:
logger.error("Failed to fetch models for %s: %s", username, result)

View File

@@ -7,11 +7,13 @@ with category filtering and enriched results including post counts.
from __future__ import annotations
import logging
import re
from typing import List, Dict, Any, Optional
logger = logging.getLogger(__name__)
_EMBEDDED_COMMAND_PATTERN = re.compile(r"\s/\w")
class CustomWordsService:
"""Service for autocomplete via TagFTSIndex.
@@ -77,12 +79,28 @@ class CustomWordsService:
Returns:
List of dicts with tag_name, category, and post_count.
"""
normalized_search = search_term.strip()
if not normalized_search:
return []
# Prompt widgets should only send the active token, but guard against
# accidental full-prompt queries reaching the FTS path.
if (
"__" in normalized_search
or "," in normalized_search
or ">" in normalized_search
or "\n" in normalized_search
or "\r" in normalized_search
or _EMBEDDED_COMMAND_PATTERN.search(normalized_search)
):
logger.debug("Skipping prompt-like custom words query: %s", normalized_search)
return []
tag_index = self._get_tag_index()
if tag_index is not None:
results = tag_index.search(
search_term, categories=categories, limit=limit, offset=offset
return tag_index.search(
normalized_search, categories=categories, limit=limit, offset=offset
)
return results
logger.debug("TagFTSIndex not available, returning empty results")
return []

View File

@@ -31,6 +31,11 @@ import tempfile
logger = logging.getLogger(__name__)
CIVITAI_DOWNLOAD_URL_PREFIXES = (
"https://civitai.com/api/download/",
"https://civitai.red/api/download/",
)
class DownloadManager:
_instance = None
@@ -647,12 +652,12 @@ class DownloadManager:
civitai_urls = [
u
for u in download_urls
if u.startswith("https://civitai.com/api/download/")
if u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
]
non_civitai_urls = [
u
for u in download_urls
if not u.startswith("https://civitai.com/api/download/")
if not u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
]
download_urls = non_civitai_urls + civitai_urls
else:
@@ -1133,7 +1138,7 @@ class DownloadManager:
pause_control.update_stall_timeout(downloader.stall_timeout)
last_error = None
for download_url in download_urls:
use_auth = download_url.startswith("https://civitai.com/api/download/")
use_auth = download_url.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
download_kwargs = {
"progress_callback": lambda progress, snapshot=None: (
self._handle_download_progress(

View File

@@ -952,6 +952,30 @@ class RecipeScanner:
except Exception as exc:
logger.debug("Failed to update FTS index for recipe: %s", exc)
@staticmethod
def _normalize_recipe_gen_params(recipe_data: Dict[str, Any]) -> Dict[str, Any]:
"""Return a recipe copy with normalized generation parameter aliases added."""
normalized_recipe = dict(recipe_data)
gen_params = recipe_data.get("gen_params")
if not isinstance(gen_params, dict):
return normalized_recipe
normalized_gen_params = dict(gen_params)
for key, value in gen_params.items():
if value in (None, ""):
continue
normalized_key = GenParamsMerger.NORMALIZATION_MAPPING.get(key, key)
if normalized_key not in GenParamsMerger.ALLOWED_KEYS:
continue
if normalized_gen_params.get(normalized_key) in (None, ""):
normalized_gen_params[normalized_key] = value
normalized_recipe["gen_params"] = normalized_gen_params
return normalized_recipe
async def _enrich_cache_metadata(self) -> None:
"""Perform remote metadata enrichment after the initial scan."""
@@ -1345,6 +1369,7 @@ class RecipeScanner:
# Ensure gen_params exists
if "gen_params" not in recipe_data:
recipe_data["gen_params"] = {}
recipe_data = self._normalize_recipe_gen_params(recipe_data)
# Update lora information with local paths and availability
lora_metadata_updated = await self._update_lora_information(recipe_data)
@@ -2055,7 +2080,10 @@ class RecipeScanner:
end_idx = min(start_idx + page_size, total_items)
# Get paginated items
paginated_items = filtered_data[start_idx:end_idx]
paginated_items = [
self._normalize_recipe_gen_params(item)
for item in filtered_data[start_idx:end_idx]
]
# Add inLibrary information and URLs for each recipe
for item in paginated_items:
@@ -2114,8 +2142,18 @@ class RecipeScanner:
if not recipe:
return None
# Prefer the on-disk recipe JSON for fields that are not persisted in the
# SQLite cache yet, such as source_path.
merged_recipe = self._normalize_recipe_gen_params({**recipe})
recipe_json = await self._load_recipe_json(recipe_id)
if recipe_json:
for field in ("source_path", "checkpoint", "loras", "gen_params"):
if field not in recipe_json:
merged_recipe.pop(field, None)
merged_recipe.update(recipe_json)
# Format the recipe with all needed information
formatted_recipe = {**recipe} # Copy all fields
formatted_recipe = {**merged_recipe}
# Format file path to URL
if "file_path" in formatted_recipe:
@@ -2149,6 +2187,30 @@ class RecipeScanner:
return formatted_recipe
async def _load_recipe_json(self, recipe_id: str) -> Optional[Dict[str, Any]]:
"""Load the raw recipe JSON payload for a recipe ID if it exists."""
recipe_json_path = await self.get_recipe_json_path(recipe_id)
if not recipe_json_path or not os.path.exists(recipe_json_path):
return None
try:
with open(recipe_json_path, "r", encoding="utf-8") as f:
recipe_data = json.load(f)
except Exception as exc:
logger.debug(
"Failed to load recipe JSON for %s from %s: %s",
recipe_id,
recipe_json_path,
exc,
)
return None
if not isinstance(recipe_data, dict):
return None
return self._normalize_recipe_gen_params(recipe_data)
def _format_file_url(self, file_path: str) -> str:
"""Format file path as URL for serving in web UI"""
if not file_path:

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
import base64
import io
import os
import re
import tempfile
from dataclasses import dataclass
from typing import Any, Callable, Optional
@@ -14,7 +13,7 @@ import numpy as np
from PIL import Image
from ...utils.utils import calculate_recipe_fingerprint
from ...utils.civitai_utils import rewrite_preview_url
from ...utils.civitai_utils import extract_civitai_image_id, rewrite_preview_url
from .errors import (
RecipeDownloadError,
RecipeNotFoundError,
@@ -104,9 +103,11 @@ class RecipeAnalysisService:
extension = ".jpg" # Default
try:
civitai_match = re.match(r"https://civitai\.com/images/(\d+)", url)
if civitai_match:
image_info = await civitai_client.get_image_info(civitai_match.group(1))
civitai_image_id = extract_civitai_image_id(url)
if civitai_image_id:
image_info = await civitai_client.get_image_info(
civitai_image_id, source_url=url
)
if not image_info:
raise RecipeDownloadError(
"Failed to fetch image information from Civitai"

View File

@@ -12,6 +12,7 @@ from dataclasses import dataclass
from typing import Any, Dict, Iterable, Optional
from ...config import config
from ...recipes.constants import GEN_PARAM_KEYS
from ...utils.utils import calculate_recipe_fingerprint
from .errors import RecipeNotFoundError, RecipeValidationError
@@ -90,23 +91,7 @@ class RecipePersistenceService:
current_time = time.time()
loras_data = [self._normalise_lora_entry(lora) for lora in (metadata.get("loras") or [])]
checkpoint_entry = self._sanitize_checkpoint_entry(self._extract_checkpoint_entry(metadata))
gen_params = metadata.get("gen_params") or {}
if not gen_params and "raw_metadata" in metadata:
raw_metadata = metadata.get("raw_metadata", {})
gen_params = {
"prompt": raw_metadata.get("prompt", ""),
"negative_prompt": raw_metadata.get("negative_prompt", ""),
"steps": raw_metadata.get("steps", ""),
"sampler": raw_metadata.get("sampler", ""),
"cfg_scale": raw_metadata.get("cfg_scale", ""),
"seed": raw_metadata.get("seed", ""),
"size": raw_metadata.get("size", ""),
"clip_skip": raw_metadata.get("clip_skip", ""),
}
# Drop checkpoint duplication from generation parameters to store it only at top level
gen_params.pop("checkpoint", None)
gen_params = self._sanitize_gen_params_for_storage(metadata)
fingerprint = calculate_recipe_fingerprint(loras_data)
recipe_data: Dict[str, Any] = {
@@ -133,6 +118,7 @@ class RecipePersistenceService:
json_filename = f"{recipe_id}.recipe.json"
json_path = os.path.join(recipes_dir, json_filename)
json_path = os.path.normpath(json_path)
with open(json_path, "w", encoding="utf-8") as file_obj:
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
@@ -152,6 +138,30 @@ class RecipePersistenceService:
}
)
@staticmethod
def _sanitize_gen_params_for_storage(metadata: dict[str, Any]) -> dict[str, Any]:
gen_params = metadata.get("gen_params")
if isinstance(gen_params, dict) and gen_params:
source = gen_params
else:
source = metadata.get("raw_metadata")
if not isinstance(source, dict):
return {}
allowed_keys = set(GEN_PARAM_KEYS)
sanitized: dict[str, Any] = {}
for key in allowed_keys:
if key not in source:
continue
value = source.get(key)
if value in (None, ""):
continue
sanitized[key] = value
sanitized.pop("checkpoint", None)
return sanitized
async def delete_recipe(self, *, recipe_scanner, recipe_id: str) -> PersistenceResult:
"""Delete an existing recipe."""

View File

@@ -54,6 +54,7 @@ DEFAULT_KEYS_CLEANUP_THRESHOLD = 10
DEFAULT_SETTINGS: Dict[str, Any] = {
"civitai_api_key": "",
"civitai_host": "civitai.com",
"use_portable_settings": False,
"hash_chunk_size_mb": DEFAULT_HASH_CHUNK_SIZE_MB,
"language": "en",

View File

@@ -450,9 +450,9 @@ class TagFTSIndex:
the tag_name, the result will include a "matched_alias" field.
Ranking is based on a combination of:
1. FTS5 bm25 relevance score (how well the text matches)
2. Post count (popularity)
3. Exact prefix match boost (tag_name starts with query)
1. Exact prefix match boost (tag_name starts with query)
2. Post count to preserve expected autocomplete ordering
3. FTS5 bm25 relevance score as a deterministic tie-breaker
Args:
query: The search query string.
@@ -484,65 +484,17 @@ class TagFTSIndex:
with self._lock:
conn = self._connect(readonly=True)
try:
# Build the SQL query with bm25 ranking
# FTS5 bm25() returns negative scores, lower is better
# We use -bm25() to get higher=better scores
# Weights: -100.0 for exact matches, 1.0 for others
# Add LOG10(post_count) weighting to boost popular tags
# Use CASE to boost tag_name prefix matches above alias matches
if categories:
placeholders = ",".join("?" * len(categories))
sql = f"""
SELECT t.tag_name, t.category, t.post_count, t.aliases,
CASE
WHEN t.tag_name LIKE ? ESCAPE '\\' THEN 1
ELSE 0
END AS is_tag_name_match,
bm25(tag_fts, -100.0, 1.0, 1.0) + LOG10(t.post_count + 1) * 10.0 AS rank_score
FROM tag_fts
JOIN tags t ON tag_fts.rowid = t.rowid
WHERE tag_fts.searchable_text MATCH ?
AND t.category IN ({placeholders})
ORDER BY is_tag_name_match DESC, rank_score DESC
LIMIT ? OFFSET ?
"""
# Escape special LIKE characters and add wildcard
query_escaped = (
query_lower.lstrip("/")
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
)
params = (
[query_escaped + "%", fts_query]
+ categories
+ [limit, offset]
)
else:
sql = """
SELECT t.tag_name, t.category, t.post_count, t.aliases,
CASE
WHEN t.tag_name LIKE ? ESCAPE '\\' THEN 1
ELSE 0
END AS is_tag_name_match,
bm25(tag_fts, -100.0, 1.0, 1.0) + LOG10(t.post_count + 1) * 10.0 AS rank_score
FROM tag_fts
JOIN tags t ON tag_fts.rowid = t.rowid
WHERE tag_fts.searchable_text MATCH ?
ORDER BY is_tag_name_match DESC, rank_score DESC
LIMIT ? OFFSET ?
"""
query_escaped = (
query_lower.lstrip("/")
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
)
params = [query_escaped + "%", fts_query, limit, offset]
sql, params = self._build_search_statement(
query_lower=query_lower,
fts_query=fts_query,
categories=categories,
limit=limit,
offset=offset,
)
cursor = conn.execute(sql, params)
rows = cursor.fetchall()
results = []
for row in cursor.fetchall():
for row in rows:
result = {
"tag_name": row[0],
"category": row[1],
@@ -571,6 +523,62 @@ class TagFTSIndex:
logger.debug("Tag FTS search error for query '%s': %s", query, exc)
return []
def _build_search_statement(
self,
query_lower: str,
fts_query: str,
categories: Optional[List[int]],
limit: int,
offset: int,
) -> tuple[str, list[object]]:
"""Build the SQL statement and params for a tag search."""
# Escape special LIKE characters and add wildcard
query_escaped = (
query_lower.lstrip("/")
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
)
# FTS5 bm25() returns negative scores, lower is better.
# We use -bm25() to get higher=better scores, but keep post_count as the
# primary sort within tag-name prefix matches so autocomplete ordering
# remains aligned with the existing popularity-first behavior.
if categories:
placeholders = ",".join("?" * len(categories))
sql = f"""
SELECT t.tag_name, t.category, t.post_count, t.aliases,
CASE
WHEN t.tag_name LIKE ? ESCAPE '\\' THEN 1
ELSE 0
END AS is_tag_name_match,
bm25(tag_fts, -100.0, 1.0, 1.0) AS rank_score
FROM tag_fts
CROSS JOIN tags t ON t.rowid = tag_fts.rowid
WHERE tag_fts.searchable_text MATCH ?
AND t.category IN ({placeholders})
ORDER BY is_tag_name_match DESC, t.post_count DESC, rank_score DESC
LIMIT ? OFFSET ?
"""
params = [query_escaped + "%", fts_query] + categories + [limit, offset]
else:
sql = """
SELECT t.tag_name, t.category, t.post_count, t.aliases,
CASE
WHEN t.tag_name LIKE ? ESCAPE '\\' THEN 1
ELSE 0
END AS is_tag_name_match,
bm25(tag_fts, -100.0, 1.0, 1.0) AS rank_score
FROM tag_fts
JOIN tags t ON tag_fts.rowid = t.rowid
WHERE tag_fts.searchable_text MATCH ?
ORDER BY is_tag_name_match DESC, t.post_count DESC, rank_score DESC
LIMIT ? OFFSET ?
"""
params = [query_escaped + "%", fts_query, limit, offset]
return sql, params
def _find_matched_alias(
self, query: str, tag_name: str, aliases_str: str
) -> Optional[str]:

View File

@@ -0,0 +1,428 @@
"""Managed wildcard loading, search, and text expansion."""
from __future__ import annotations
import json
import logging
import os
import random
import re
from dataclasses import dataclass
from typing import Any, Optional
import yaml
from ..utils.settings_paths import get_settings_dir
logger = logging.getLogger(__name__)
_WILDCARD_PATTERN = re.compile(r"__([\w\s.\-+/*\\]+?)__")
_OPTION_PATTERN = re.compile(r"{([^{}]*?)}")
_TRIGGER_WORD_PATTERN = re.compile(r"^trigger_words\d+$")
_WEIGHTED_OPTION_PATTERN = re.compile(r"^\s*([0-9.]+)::")
_NUMERIC_PATTERN = re.compile(r"^-?\d+(\.\d+)?$")
def _normalize_wildcard_key(value: str) -> str:
return value.replace("\\", "/").strip("/").lower()
def _is_numeric_string(value: str) -> bool:
return bool(_NUMERIC_PATTERN.match(value))
def contains_dynamic_syntax(text: str) -> bool:
"""Return True when text contains supported wildcard or option syntax."""
return isinstance(text, str) and bool(
_WILDCARD_PATTERN.search(text) or _OPTION_PATTERN.search(text)
)
def get_wildcards_dir(create: bool = False) -> str:
"""Return the managed wildcard directory inside the settings folder."""
settings_dir = get_settings_dir(create=create)
wildcards_dir = os.path.join(settings_dir, "wildcards")
if create:
os.makedirs(wildcards_dir, exist_ok=True)
return wildcards_dir
@dataclass(frozen=True)
class WildcardEntry:
key: str
values_count: int
@dataclass(frozen=True)
class WildcardMetadata:
has_wildcards: bool
wildcards_dir: str
supported_formats: tuple[str, ...]
class WildcardService:
"""Discover wildcard keys and expand wildcard syntax."""
_instance: Optional["WildcardService"] = None
def __new__(cls) -> "WildcardService":
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self) -> None:
if getattr(self, "_initialized", False):
return
self._initialized = True
self._cached_signature: tuple[tuple[str, int, int], ...] | None = None
self._wildcard_dict: dict[str, list[str]] = {}
@classmethod
def get_instance(cls) -> "WildcardService":
return cls()
def search_keys(
self, search_term: str, limit: int = 20, offset: int = 0
) -> list[str]:
"""Search wildcard keys for autocomplete."""
normalized_term = _normalize_wildcard_key(search_term).strip()
if not normalized_term:
return []
ranked: list[tuple[int, str]] = []
compact_term = normalized_term.replace("/", "")
for key in self.get_wildcard_dict().keys():
score = self._score_entry(key, normalized_term, compact_term)
if score is not None:
ranked.append((score, key))
ranked.sort(key=lambda item: (-item[0], item[1]))
keys = [key for _, key in ranked]
return keys[offset : offset + limit]
def expand_text(self, text: str, seed: int | None = None) -> str:
"""Expand wildcard and dynamic prompt syntax for a text value."""
if not isinstance(text, str) or not text:
return text
rng = random.Random(seed) if seed is not None else random.Random()
wildcard_dict = self.get_wildcard_dict()
if not wildcard_dict:
return self._expand_options_only(text, rng)
current = text
remaining_depth = 100
while remaining_depth > 0:
remaining_depth -= 1
after_options, options_replaced = self._replace_options(current, rng)
current, wildcards_replaced = self._replace_wildcards(
after_options, rng, wildcard_dict
)
if not options_replaced and not wildcards_replaced:
break
return current
def get_wildcard_dict(self) -> dict[str, list[str]]:
signature = self._build_signature()
if signature != self._cached_signature:
self._wildcard_dict = self._scan_wildcard_dict()
self._cached_signature = signature
return self._wildcard_dict
def get_entries(self) -> list[WildcardEntry]:
return [
WildcardEntry(key=key, values_count=len(values))
for key, values in sorted(self.get_wildcard_dict().items())
]
def get_metadata(self, *, create_dir: bool = False) -> WildcardMetadata:
wildcards_dir = get_wildcards_dir(create=create_dir)
return WildcardMetadata(
has_wildcards=bool(self.get_wildcard_dict()),
wildcards_dir=wildcards_dir,
supported_formats=(".txt", ".yaml", ".yml", ".json"),
)
def _build_signature(self) -> tuple[tuple[str, int, int], ...]:
root = get_wildcards_dir(create=False)
if not os.path.isdir(root):
return ()
signature: list[tuple[str, int, int]] = []
for current_root, _dirs, files in os.walk(root, followlinks=True):
for file_name in sorted(files):
if not file_name.lower().endswith((".txt", ".yaml", ".yml", ".json")):
continue
file_path = os.path.join(current_root, file_name)
try:
stat = os.stat(file_path)
except OSError:
continue
rel_path = os.path.relpath(file_path, root).replace("\\", "/")
signature.append((rel_path, int(stat.st_mtime_ns), int(stat.st_size)))
signature.sort()
return tuple(signature)
def _scan_wildcard_dict(self) -> dict[str, list[str]]:
root = get_wildcards_dir(create=False)
if not os.path.isdir(root):
return {}
collected: dict[str, list[str]] = {}
for current_root, _dirs, files in os.walk(root, followlinks=True):
for file_name in sorted(files):
file_path = os.path.join(current_root, file_name)
lower_name = file_name.lower()
try:
if lower_name.endswith(".txt"):
rel_path = os.path.relpath(file_path, root)
key = _normalize_wildcard_key(os.path.splitext(rel_path)[0])
values = self._read_txt(file_path)
if values:
collected[key] = values
elif lower_name.endswith((".yaml", ".yml")):
payload = self._read_yaml(file_path)
self._merge_nested_entries(collected, payload)
elif lower_name.endswith(".json"):
payload = self._read_json(file_path)
self._merge_nested_entries(collected, payload)
except Exception as exc: # pragma: no cover - defensive logging
logger.warning("Failed to load wildcard file %s: %s", file_path, exc)
return collected
def _read_txt(self, file_path: str) -> list[str]:
try:
with open(file_path, "r", encoding="utf-8", errors="ignore") as handle:
return [line.strip() for line in handle.read().splitlines() if line.strip()]
except OSError as exc:
logger.warning("Failed to read wildcard txt file %s: %s", file_path, exc)
return []
def _read_yaml(self, file_path: str) -> Any:
with open(file_path, "r", encoding="utf-8") as handle:
return yaml.safe_load(handle) or {}
def _read_json(self, file_path: str) -> Any:
with open(file_path, "r", encoding="utf-8") as handle:
return json.load(handle)
def _merge_nested_entries(
self, collected: dict[str, list[str]], payload: Any
) -> None:
for key, values in self._flatten_payload(payload):
collected[key] = values
def _flatten_payload(
self, payload: Any, prefix: str = ""
) -> list[tuple[str, list[str]]]:
entries: list[tuple[str, list[str]]] = []
if isinstance(payload, dict):
for key, value in payload.items():
next_prefix = f"{prefix}/{key}" if prefix else str(key)
entries.extend(self._flatten_payload(value, next_prefix))
return entries
if isinstance(payload, list):
normalized_prefix = _normalize_wildcard_key(prefix)
values = [value.strip() for value in payload if isinstance(value, str) and value.strip()]
if normalized_prefix and values:
entries.append((normalized_prefix, values))
return entries
return entries
def _score_entry(
self, key: str, normalized_term: str, compact_term: str
) -> int | None:
key_compact = key.replace("/", "")
if key == normalized_term:
return 5000
if key.startswith(normalized_term):
return 4000
if f"/{normalized_term}" in key:
return 3500
if normalized_term in key:
return 3000
if compact_term and key_compact.startswith(compact_term):
return 2500
if compact_term and compact_term in key_compact:
return 2000
return None
def _expand_options_only(self, text: str, rng: random.Random) -> str:
current = text
remaining_depth = 100
while remaining_depth > 0:
remaining_depth -= 1
current, replaced = self._replace_options(current, rng)
if not replaced:
break
return current
def _replace_options(
self, text: str, rng: random.Random
) -> tuple[str, bool]:
replaced_any = False
def replace_option(match: re.Match[str]) -> str:
nonlocal replaced_any
replacement = self._resolve_option_group(match.group(1), rng)
replaced_any = True
return replacement
return _OPTION_PATTERN.sub(replace_option, text), replaced_any
def _resolve_option_group(self, group_text: str, rng: random.Random) -> str:
options = group_text.split("|")
multi_select_pattern = options[0].split("$$")
select_range: tuple[int, int] | None = None
select_separator = " "
if len(multi_select_pattern) > 1:
count_spec = multi_select_pattern[0]
range_match = re.match(r"(\d+)(-(\d+))?$", count_spec)
shorthand_match = re.match(r"-(\d+)$", count_spec)
if range_match:
start_text = range_match.group(1)
end_text = range_match.group(3)
if end_text is not None and _is_numeric_string(start_text) and _is_numeric_string(end_text):
select_range = (int(start_text), int(end_text))
elif _is_numeric_string(start_text):
value = int(start_text)
select_range = (value, value)
elif shorthand_match:
end_text = shorthand_match.group(1)
if _is_numeric_string(end_text):
select_range = (1, int(end_text))
if select_range is not None and len(multi_select_pattern) == 2:
options[0] = multi_select_pattern[1]
elif select_range is not None and len(multi_select_pattern) >= 3:
select_separator = multi_select_pattern[1]
options[0] = multi_select_pattern[2]
weighted_options: list[tuple[float, str]] = []
for option in options:
weight = 1.0
parts = option.split("::", 1)
if len(parts) == 2 and _is_numeric_string(parts[0].strip()):
weight = float(parts[0].strip())
weighted_options.append((weight, option))
if select_range is None:
selection_count = 1
else:
selection_count = rng.randint(select_range[0], select_range[1])
if selection_count <= 1:
return self._strip_weight_prefix(self._weighted_choice(weighted_options, rng))
selection_count = min(selection_count, len(weighted_options))
selected: list[str] = []
used_indexes: set[int] = set()
while len(selected) < selection_count:
picked_index = self._weighted_choice_index(weighted_options, rng)
if picked_index in used_indexes:
if len(used_indexes) == len(weighted_options):
break
continue
used_indexes.add(picked_index)
selected.append(
self._strip_weight_prefix(weighted_options[picked_index][1])
)
return select_separator.join(selected)
def _weighted_choice(
self, weighted_options: list[tuple[float, str]], rng: random.Random
) -> str:
return weighted_options[self._weighted_choice_index(weighted_options, rng)][1]
def _weighted_choice_index(
self, weighted_options: list[tuple[float, str]], rng: random.Random
) -> int:
total_weight = sum(max(weight, 0.0) for weight, _value in weighted_options)
if total_weight <= 0:
return rng.randrange(len(weighted_options))
threshold = rng.uniform(0, total_weight)
cumulative = 0.0
for index, (weight, _value) in enumerate(weighted_options):
cumulative += max(weight, 0.0)
if threshold <= cumulative:
return index
return len(weighted_options) - 1
def _strip_weight_prefix(self, value: str) -> str:
return _WEIGHTED_OPTION_PATTERN.sub("", value, count=1)
def _replace_wildcards(
self,
text: str,
rng: random.Random,
wildcard_dict: dict[str, list[str]],
) -> tuple[str, bool]:
replaced_any = False
def replace_match(match: re.Match[str]) -> str:
nonlocal replaced_any
replacement = self._resolve_wildcard_match(match.group(1), rng, wildcard_dict)
if replacement is None:
return match.group(0)
replaced_any = True
return replacement
return _WILDCARD_PATTERN.sub(replace_match, text), replaced_any
def _resolve_wildcard_match(
self,
raw_key: str,
rng: random.Random,
wildcard_dict: dict[str, list[str]],
) -> str | None:
keyword = _normalize_wildcard_key(raw_key)
if keyword in wildcard_dict:
return rng.choice(wildcard_dict[keyword])
if "*" in keyword:
regex_pattern = keyword.replace("*", ".*").replace("+", r"\+")
compiled = re.compile(f"^{regex_pattern}$")
aggregated: list[str] = []
for key, values in wildcard_dict.items():
if compiled.match(key):
aggregated.extend(values)
if aggregated:
return rng.choice(aggregated)
if "/" not in keyword:
fallback_keyword = _normalize_wildcard_key(f"*/{keyword}")
if fallback_keyword != keyword:
return self._resolve_wildcard_match(fallback_keyword, rng, wildcard_dict)
return None
def is_trigger_words_input(name: str) -> bool:
return bool(_TRIGGER_WORD_PATTERN.match(name))
def get_wildcard_service() -> WildcardService:
return WildcardService.get_instance()
__all__ = [
"WildcardService",
"WildcardMetadata",
"contains_dynamic_syntax",
"get_wildcard_service",
"get_wildcards_dir",
"is_trigger_words_input",
]

View File

@@ -2,10 +2,13 @@
from __future__ import annotations
import re
from typing import Any, Dict, Iterable, Mapping, Sequence
from urllib.parse import urlparse, urlunparse
from urllib.parse import parse_qs, urlparse, urlunparse
_SUPPORTED_CIVITAI_PAGE_HOSTS = frozenset({"civitai.com", "civitai.red"})
DEFAULT_CIVITAI_PAGE_HOST = "civitai.com"
_DEFAULT_ALLOW_COMMERCIAL_USE: Sequence[str] = ("Sell",)
_LICENSE_DEFAULTS: Dict[str, Any] = {
"allowNoCredit": True,
@@ -17,6 +20,115 @@ _COMMERCIAL_ALLOWED_VALUES = {"sell", "rent", "rentcivit", "image"}
_COMMERCIAL_SHIFT = 1
def is_supported_civitai_page_host(hostname: str | None) -> bool:
"""Return whether the hostname is a supported Civitai page domain."""
if not hostname:
return False
return hostname.lower() in _SUPPORTED_CIVITAI_PAGE_HOSTS
def normalize_civitai_page_host(hostname: str | None) -> str:
"""Return a supported Civitai page host or the default host."""
if not isinstance(hostname, str):
return DEFAULT_CIVITAI_PAGE_HOST
normalized = hostname.strip().lower()
if is_supported_civitai_page_host(normalized):
return normalized
return DEFAULT_CIVITAI_PAGE_HOST
def build_civitai_model_page_url(
model_id: str | int | None,
version_id: str | int | None = None,
*,
host: str | None = None,
) -> str | None:
"""Build a Civitai model or model-version page URL."""
normalized_host = normalize_civitai_page_host(host)
normalized_model_id = str(model_id).strip() if model_id is not None else ""
normalized_version_id = str(version_id).strip() if version_id is not None else ""
if normalized_model_id:
path = f"/models/{normalized_model_id}"
query = f"modelVersionId={normalized_version_id}" if normalized_version_id else ""
return urlunparse(("https", normalized_host, path, "", query, ""))
if normalized_version_id:
return urlunparse(
("https", normalized_host, f"/model-versions/{normalized_version_id}", "", "", "")
)
return None
def _parse_supported_civitai_page_url(url: str | None):
if not url:
return None
try:
parsed = urlparse(url)
except ValueError:
return None
if parsed.scheme not in {"http", "https"}:
return None
if not is_supported_civitai_page_host(parsed.hostname):
return None
return parsed
def extract_civitai_model_url_parts(
url: str | None,
) -> tuple[str | None, str | None]:
"""Extract model and version identifiers from a supported Civitai model URL."""
parsed = _parse_supported_civitai_page_url(url)
if parsed is None:
return None, None
path_match = re.search(r"/models/(\d+)", parsed.path)
if not path_match:
return None, None
model_id = path_match.group(1)
query_params = parse_qs(parsed.query)
version_values = query_params.get("modelVersionId") or []
version_id = version_values[0] if version_values else None
return model_id, version_id
def extract_civitai_image_id(url: str | None) -> str | None:
"""Extract the image identifier from a supported Civitai image page URL."""
parsed = _parse_supported_civitai_page_url(url)
if parsed is None:
return None
path_match = re.search(r"/images/(\d+)", parsed.path)
if not path_match:
return None
return path_match.group(1)
def extract_civitai_page_host(url: str | None) -> str | None:
"""Extract the supported Civitai page host from a URL."""
parsed = _parse_supported_civitai_page_url(url)
if parsed is None:
return None
return parsed.hostname.lower() if parsed.hostname else None
def _normalize_commercial_values(value: Any) -> Sequence[str]:
"""Return a normalized list of commercial permissions preserving source values."""
@@ -199,6 +311,10 @@ def rewrite_preview_url(
__all__ = [
"build_license_flags",
"extract_civitai_image_id",
"extract_civitai_page_host",
"extract_civitai_model_url_parts",
"is_supported_civitai_page_host",
"resolve_license_payload",
"resolve_license_info",
"rewrite_preview_url",

136
py/utils/session_logging.py Normal file
View 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

View File

@@ -29,6 +29,18 @@ if not standalone_mode:
logger = logging.getLogger(__name__)
_DEFAULT_CHECKPOINT_EXTENSIONS = {
".ckpt",
".pt",
".pt2",
".bin",
".pth",
".safetensors",
".pkl",
".sft",
".gguf",
}
class UsageStats:
"""Track usage statistics for models and save to JSON"""
@@ -335,6 +347,55 @@ class UsageStats:
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)
@@ -343,27 +404,49 @@ class UsageStats:
return model_hash
cached_entry = await self._find_cached_checkpoint_entry(checkpoint_scanner, model_name)
if not cached_entry:
logger.warning(f"No hash found for checkpoint '{model_filename}', skipping usage tracking")
return None
if cached_entry:
cached_hash = cached_entry.get("sha256")
if cached_hash:
return cached_hash
cached_hash = cached_entry.get("sha256")
if cached_hash:
return cached_hash
if cached_entry.get("hash_status") == "pending":
calculate_hash = getattr(checkpoint_scanner, "calculate_hash_for_model", None)
file_path = cached_entry.get("file_path")
if callable(calculate_hash) and file_path:
calculated_hash = await calculate_hash(file_path)
if calculated_hash:
return calculated_hash
hash_status = cached_entry.get("hash_status")
if hash_status and hash_status != "pending":
logger.warning(
f"Failed to calculate pending hash for checkpoint '{model_filename}', skipping usage tracking"
"Checkpoint '%s' has hash_status=%s; skipping usage tracking",
model_filename,
hash_status,
)
return None
logger.warning(f"No hash found for checkpoint '{model_filename}', skipping usage tracking")
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):

View File

@@ -1,7 +1,7 @@
[project]
name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "1.0.2"
version = "1.0.4"
license = {file = "LICENSE"}
dependencies = [
"aiohttp",
@@ -14,7 +14,8 @@ dependencies = [
"natsort",
"GitPython",
"aiosqlite",
"platformdirs"
"platformdirs",
"pyyaml"
]
[project.urls]

View File

@@ -11,3 +11,4 @@ GitPython
aiosqlite
beautifulsoup4
platformdirs
pyyaml

View File

@@ -113,6 +113,8 @@ import asyncio
import logging
from aiohttp import web
from py.utils.session_logging import setup_standalone_session_logging
# Increase allowable header size to align with in-ComfyUI configuration.
HEADER_SIZE_LIMIT = 16384
@@ -125,6 +127,8 @@ logger = logging.getLogger("lora-manager-standalone")
# Configure aiohttp access logger to be less verbose
logging.getLogger("aiohttp.access").setLevel(logging.WARNING)
setup_standalone_session_logging(ensure_settings_file(logger))
# Add specific suppression for connection reset errors
class ConnectionResetFilter(logging.Filter):

View File

@@ -163,6 +163,18 @@
cursor: pointer;
}
.model-version-row.is-clickable .version-actions,
.model-version-row.is-clickable .version-badges,
.model-version-row.is-clickable .version-action,
.model-version-row.is-clickable .version-civitai-link {
cursor: default;
}
.model-version-row.is-clickable .version-action,
.model-version-row.is-clickable .version-civitai-link {
cursor: pointer;
}
.model-version-row.is-current {
border-color: var(--lora-accent);
box-shadow: 0 0 0 1px color-mix(in oklch, var(--lora-accent) 65%, transparent),
@@ -217,6 +229,7 @@
gap: 8px;
font-weight: 600;
font-size: 0.95rem;
min-width: 0;
}
.versions-tab-version-name {
@@ -226,6 +239,27 @@
max-width: 100%;
}
.version-civitai-link {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 999px;
color: var(--text-muted);
text-decoration: none;
flex: 0 0 auto;
transition: color 0.2s ease, background-color 0.2s ease, transform 0.2s ease;
}
.version-civitai-link:hover,
.version-civitai-link:focus-visible {
color: var(--lora-accent);
background: color-mix(in oklch, var(--lora-accent) 12%, transparent);
transform: translateY(-1px);
outline: none;
}
.version-badges {
display: flex;
flex-wrap: wrap;

View File

@@ -0,0 +1,281 @@
.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 {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
background: var(--lora-error);
color: #fff;
font-size: 11px;
line-height: 1;
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;
}
}

View File

@@ -21,6 +21,27 @@
font-size: 0.9em;
}
.downloaded-badge {
display: inline-flex;
align-items: center;
background: color-mix(in oklch, var(--badge-update-bg, #4a90e2) 22%, transparent);
color: var(--badge-update-bg, #4a90e2);
border: 1px solid color-mix(in oklch, var(--badge-update-bg, #4a90e2) 50%, transparent);
padding: 4px 8px;
border-radius: var(--border-radius-xs);
font-size: 0.8em;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
transform: translateZ(0);
will-change: transform;
}
.downloaded-badge i {
margin-right: 4px;
font-size: 0.9em;
}
/* Early Access Badge */
.early-access-badge {
display: inline-flex;
@@ -108,4 +129,4 @@
color: var(--lora-error);
font-size: 0.9em;
margin-top: 4px;
}
}

View File

@@ -12,6 +12,7 @@
@import 'components/modal/delete-modal.css';
@import 'components/modal/update-modal.css';
@import 'components/modal/settings-modal.css';
@import 'components/modal/doctor-modal.css';
@import 'components/modal/help-modal.css';
@import 'components/modal/relink-civitai-modal.css';
@import 'components/modal/example-access-modal.css';

View File

@@ -1,6 +1,7 @@
import { RecipeCard } from '../components/RecipeCard.js';
import { state, getCurrentPageState } from '../state/index.js';
import { showToast } from '../utils/uiHelpers.js';
import { captureScrollPosition, restoreScrollPosition } from '../utils/infiniteScroll.js';
const RECIPE_ENDPOINTS = {
list: '/api/lm/recipes',
@@ -31,6 +32,20 @@ export function extractRecipeId(filePath) {
return dotIndex > 0 ? basename.substring(0, dotIndex) : basename;
}
export async function fetchRecipeDetails(recipeId) {
if (!recipeId) {
throw new Error('Unable to determine recipe ID');
}
const encodedRecipeId = encodeURIComponent(recipeId);
const response = await fetch(`${RECIPE_ENDPOINTS.detail}/${encodedRecipeId}`);
if (!response.ok) {
throw new Error(`Failed to load recipe: ${response.statusText}`);
}
return response.json();
}
/**
* Fetch recipes with pagination for virtual scrolling
* @param {number} page - Page number to fetch
@@ -61,7 +76,9 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
// If we have a specific recipe ID to load
if (pageState.customFilter?.active && pageState.customFilter?.recipeId) {
// Special case: load specific recipe
const response = await fetch(`${RECIPE_ENDPOINTS.detail}/${pageState.customFilter.recipeId}`);
const response = await fetch(
`${RECIPE_ENDPOINTS.detail}/${encodeURIComponent(pageState.customFilter.recipeId)}`
);
if (!response.ok) {
throw new Error(`Failed to load recipe: ${response.statusText}`);
@@ -166,10 +183,12 @@ export async function resetAndReloadWithVirtualScroll(options = {}) {
const {
modelType = 'lora',
updateFolders = false,
fetchPageFunction
fetchPageFunction,
preserveScroll = false
} = options;
const pageState = getCurrentPageState();
const scrollSnapshot = preserveScroll ? captureScrollPosition() : null;
try {
pageState.isLoading = true;
@@ -191,6 +210,10 @@ export async function resetAndReloadWithVirtualScroll(options = {}) {
pageState.hasMore = result.hasMore;
pageState.currentPage = 2; // Next page will be 2
if (scrollSnapshot) {
await restoreScrollPosition(scrollSnapshot);
}
return result;
} catch (error) {
console.error(`Error reloading ${modelType}s:`, error);
@@ -211,10 +234,12 @@ export async function loadMoreWithVirtualScroll(options = {}) {
modelType = 'lora',
resetPage = false,
updateFolders = false,
fetchPageFunction
fetchPageFunction,
preserveScroll = false
} = options;
const pageState = getCurrentPageState();
const scrollSnapshot = preserveScroll ? captureScrollPosition() : null;
try {
// Start loading state
@@ -239,6 +264,10 @@ export async function loadMoreWithVirtualScroll(options = {}) {
pageState.hasMore = result.hasMore;
pageState.currentPage = 2; // Next page to load would be 2
if (scrollSnapshot) {
await restoreScrollPosition(scrollSnapshot);
}
return result;
} catch (error) {
console.error(`Error loading ${modelType}s:`, error);
@@ -254,11 +283,12 @@ export async function loadMoreWithVirtualScroll(options = {}) {
* @param {boolean} updateFolders - Whether to update folder tags
* @returns {Promise<Object>} The fetch result
*/
export async function resetAndReload(updateFolders = false) {
export async function resetAndReload(updateFolders = false, options = {}) {
return resetAndReloadWithVirtualScroll({
modelType: 'recipe',
updateFolders,
fetchPageFunction: fetchRecipesPage
fetchPageFunction: fetchRecipesPage,
preserveScroll: options.preserveScroll === true
});
}
@@ -270,7 +300,7 @@ export async function syncChanges() {
state.loadingManager.showSimpleLoading('Syncing changes...');
// Simply reload the recipes without rebuilding cache
await resetAndReload();
await resetAndReload(false, { preserveScroll: true });
showToast('toast.recipes.syncComplete', {}, 'success');
} catch (error) {
@@ -298,7 +328,7 @@ export async function refreshRecipes() {
}
// After successful cache rebuild, reload the recipes
await resetAndReload();
await resetAndReload(false, { preserveScroll: true });
showToast('toast.recipes.refreshComplete', {}, 'success');
} catch (error) {
@@ -349,9 +379,10 @@ export function createRecipeCard(recipe) {
* @param {Object} updates - The metadata updates to apply
* @returns {Promise<Object>} The updated recipe data
*/
export async function updateRecipeMetadata(filePath, updates) {
export async function updateRecipeMetadata(filePath, updates, options = {}) {
try {
state.loadingManager.showSimpleLoading('Saving metadata...');
const listFilePath = options.listFilePath || filePath;
// Extract recipeId from filePath (basename without extension)
const recipeId = extractRecipeId(filePath);
@@ -359,7 +390,7 @@ export async function updateRecipeMetadata(filePath, updates) {
throw new Error('Unable to determine recipe ID');
}
const response = await fetch(`${RECIPE_ENDPOINTS.update}/${recipeId}/update`, {
const response = await fetch(`${RECIPE_ENDPOINTS.update}/${encodeURIComponent(recipeId)}/update`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
@@ -374,7 +405,7 @@ export async function updateRecipeMetadata(filePath, updates) {
throw new Error(data.error || 'Failed to update recipe');
}
state.virtualScroller.updateSingleItem(filePath, updates);
state.virtualScroller.updateSingleItem(listFilePath, updates);
return data;
} catch (error) {

View File

@@ -6,6 +6,7 @@ import { bulkManager } from '../../managers/BulkManager.js';
import { MODEL_CONFIG } from '../../api/apiConfig.js';
import { translate } from '../../utils/i18nHelpers.js';
import { getNsfwLevelSelector } from '../shared/NsfwLevelSelector.js';
import { extractCivitaiModelUrlParts } from '../../utils/civitaiUtils.js';
// Mixin with shared functionality for LoraContextMenu and CheckpointContextMenu
export const ModelContextMenuMixin = {
@@ -154,25 +155,7 @@ export const ModelContextMenuMixin = {
},
extractModelVersionId(url) {
try {
// Handle all three URL formats:
// 1. https://civitai.com/models/649516
// 2. https://civitai.com/models/649516?modelVersionId=726676
// 3. https://civitai.com/models/649516/cynthia-pokemon-diamond-and-pearl-pdxl-lora?modelVersionId=726676
const parsedUrl = new URL(url);
// Extract model ID from path
const pathMatch = parsedUrl.pathname.match(/\/models\/(\d+)/);
const modelId = pathMatch ? pathMatch[1] : null;
// Extract model version ID from query parameters
const modelVersionId = parsedUrl.searchParams.get('modelVersionId');
return { modelId, modelVersionId };
} catch (e) {
return { modelId: null, modelVersionId: null };
}
return extractCivitaiModelUrlParts(url);
},
parseModelId(value) {

View File

@@ -23,7 +23,7 @@ export class RecipeContextMenu extends BaseContextMenu {
// Override resetAndReload for recipe context
async resetAndReload() {
const { resetAndReload } = await import('../../api/recipeApi.js');
return resetAndReload();
return resetAndReload(false, { preserveScroll: true });
}
showMenu(x, y, card) {

View File

@@ -3,16 +3,105 @@ import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow
import { translate } from '../utils/i18nHelpers.js';
import { state } from '../state/index.js';
import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js';
import { updateRecipeMetadata } from '../api/recipeApi.js';
import { fetchRecipeDetails, updateRecipeMetadata } from '../api/recipeApi.js';
import { downloadManager } from '../managers/DownloadManager.js';
import { MODEL_TYPES } from '../api/apiConfig.js';
const ALLOWED_GEN_PARAM_KEYS = new Set([
'prompt',
'negative_prompt',
'steps',
'sampler',
'cfg_scale',
'seed',
'size',
'clip_skip',
'denoising_strength',
]);
const GEN_PARAM_NORMALIZATION = {
cfg: 'cfg_scale',
cfgScale: 'cfg_scale',
clipSkip: 'clip_skip',
negativePrompt: 'negative_prompt',
Sampler: 'sampler',
sampler_name: 'sampler',
scheduler: 'sampler',
Steps: 'steps',
Seed: 'seed',
Size: 'size',
Prompt: 'prompt',
'Negative prompt': 'negative_prompt',
'Cfg scale': 'cfg_scale',
'Clip skip': 'clip_skip',
'Denoising strength': 'denoising_strength',
};
class RecipeModal {
constructor() {
this.promptEditorState = {};
this.recipeHydrationRequestId = 0;
this.resetLocalEditState();
this.init();
}
createLocalEditState() {
return {
title: { commitVersion: 0, isDirty: false },
tags: { commitVersion: 0, isDirty: false },
prompt: { commitVersion: 0, isDirty: false },
negative_prompt: { commitVersion: 0, isDirty: false },
source_path: { commitVersion: 0, isDirty: false },
};
}
resetLocalEditState() {
this.localEditState = this.createLocalEditState();
this.sourceUrlEditState = this.localEditState.source_path;
}
getLocalEditState(field) {
if (!this.localEditState[field]) {
this.localEditState[field] = { commitVersion: 0, isDirty: false };
}
return this.localEditState[field];
}
markFieldDirty(field) {
this.getLocalEditState(field).isDirty = true;
}
clearFieldDirty(field) {
this.getLocalEditState(field).isDirty = false;
}
commitField(field) {
const fieldState = this.getLocalEditState(field);
fieldState.isDirty = false;
fieldState.commitVersion += 1;
}
captureLocalEditVersions() {
return Object.fromEntries(
Object.entries(this.localEditState).map(([field, state]) => [
field,
state.commitVersion,
])
);
}
shouldPreserveField(field, requestVersions) {
const fieldState = this.getLocalEditState(field);
const requestVersion = requestVersions?.[field] ?? fieldState.commitVersion;
return fieldState.isDirty || fieldState.commitVersion !== requestVersion;
}
hasFieldCommittedSinceRequest(field, requestVersions) {
const fieldState = this.getLocalEditState(field);
const requestVersion = requestVersions?.[field] ?? fieldState.commitVersion;
return fieldState.commitVersion !== requestVersion;
}
init() {
this.setupCopyButtons();
this.setupPromptEditors();
@@ -87,8 +176,10 @@ class RecipeModal {
}
showRecipeDetails(recipe) {
const hydratedRecipe = recipe || {};
this.resetLocalEditState();
// Store the full recipe for editing
this.currentRecipe = recipe;
this.currentRecipe = hydratedRecipe;
this.resetPromptEditors();
// Set modal title with edit icon
@@ -96,11 +187,11 @@ class RecipeModal {
if (modalTitle) {
modalTitle.innerHTML = `
<div class="editable-content">
<span class="content-text">${recipe.title || 'Recipe Details'}</span>
<span class="content-text">${hydratedRecipe.title || 'Recipe Details'}</span>
<button class="edit-icon" title="Edit recipe name"><i class="fas fa-pencil-alt"></i></button>
</div>
<div id="recipeTitleEditor" class="content-editor">
<input type="text" class="title-input" value="${recipe.title || ''}">
<input type="text" class="title-input" value="${hydratedRecipe.title || ''}">
</div>
`;
@@ -122,8 +213,9 @@ class RecipeModal {
}
// Store the recipe ID for copy syntax API call
this.recipeId = recipe.id;
this.filePath = recipe.file_path;
this.recipeId = hydratedRecipe.id;
this.filePath = hydratedRecipe.file_path;
this.listFilePath = hydratedRecipe.file_path;
// Set recipe tags if they exist
const tagsCompactElement = document.getElementById('recipeTagsCompact');
@@ -143,11 +235,11 @@ class RecipeModal {
const tagsDisplay = tagsCompactElement.querySelector('.tags-display');
if (recipe.tags && recipe.tags.length > 0) {
if (hydratedRecipe.tags && hydratedRecipe.tags.length > 0) {
// Limit displayed tags to 5, show a "+X more" button if needed
const maxVisibleTags = 5;
const visibleTags = recipe.tags.slice(0, maxVisibleTags);
const remainingTags = recipe.tags.length > maxVisibleTags ? recipe.tags.slice(maxVisibleTags) : [];
const visibleTags = hydratedRecipe.tags.slice(0, maxVisibleTags);
const remainingTags = hydratedRecipe.tags.length > maxVisibleTags ? hydratedRecipe.tags.slice(maxVisibleTags) : [];
// Add visible tags
visibleTags.forEach(tag => {
@@ -184,7 +276,7 @@ class RecipeModal {
// Add all tags to tooltip
if (tagsTooltipContent) {
tagsTooltipContent.innerHTML = '';
recipe.tags.forEach(tag => {
hydratedRecipe.tags.forEach(tag => {
const tooltipTag = document.createElement('div');
tooltipTag.className = 'tooltip-tag';
tooltipTag.textContent = tag;
@@ -201,8 +293,8 @@ class RecipeModal {
const tagsInput = tagsCompactElement.querySelector('.tags-input');
// Set current tags in the input
if (recipe.tags && recipe.tags.length > 0) {
tagsInput.value = recipe.tags.join(', ');
if (hydratedRecipe.tags && hydratedRecipe.tags.length > 0) {
tagsInput.value = hydratedRecipe.tags.join(', ');
}
editTagsIcon.addEventListener('click', () => this.showTagsEditor());
@@ -222,49 +314,15 @@ class RecipeModal {
// Set recipe image
const mediaContainer = document.getElementById('recipePreviewContainer');
if (mediaContainer) {
// Stop any playing video before replacing content
const existingVideo = mediaContainer.querySelector('video');
if (existingVideo) {
existingVideo.pause();
existingVideo.currentTime = 0;
}
// Clear the container
mediaContainer.innerHTML = '';
// Ensure file_url exists, fallback to file_path if needed
const imageUrl = recipe.file_url ||
(recipe.file_path ? `/loras_static/root1/preview/${recipe.file_path.split('/').pop()}` :
'/loras_static/images/no-preview.png');
// Check if the file is a video (mp4)
const isVideo = imageUrl.toLowerCase().endsWith('.mp4');
if (isVideo) {
const videoElement = document.createElement('video');
videoElement.id = 'recipeModalVideo';
videoElement.src = imageUrl;
videoElement.controls = true;
videoElement.autoplay = false;
videoElement.loop = true;
videoElement.muted = true;
videoElement.className = 'recipe-preview-media';
videoElement.alt = recipe.title || 'Recipe Preview';
mediaContainer.appendChild(videoElement);
} else {
const imgElement = document.createElement('img');
imgElement.id = 'recipeModalImage';
imgElement.src = imageUrl;
imgElement.className = 'recipe-preview-media';
imgElement.alt = recipe.title || 'Recipe Preview';
mediaContainer.appendChild(imgElement);
}
this.syncPreviewMedia(hydratedRecipe);
mediaContainer.querySelector('.source-url-container')?.remove();
mediaContainer.querySelector('.source-url-editor')?.remove();
// Add source URL container if the recipe has a source_path
const sourceUrlContainer = document.createElement('div');
sourceUrlContainer.className = 'source-url-container';
const hasSourceUrl = recipe.source_path && recipe.source_path.trim().length > 0;
const sourceUrl = hasSourceUrl ? recipe.source_path : '';
const hasSourceUrl = hydratedRecipe.source_path && hydratedRecipe.source_path.trim().length > 0;
const sourceUrl = hasSourceUrl ? hydratedRecipe.source_path : '';
const isValidUrl = hasSourceUrl && (sourceUrl.startsWith('http://') || sourceUrl.startsWith('https://'));
sourceUrlContainer.innerHTML = `
@@ -293,40 +351,273 @@ class RecipeModal {
mediaContainer.appendChild(sourceUrlContainer);
mediaContainer.appendChild(sourceUrlEditor);
// Set up event listeners for source URL functionality
// Delay binding slightly so modal layout is stable, but skip if this render was torn down.
const sourceUrlContainerRef = sourceUrlContainer;
const sourceUrlEditorRef = sourceUrlEditor;
setTimeout(() => {
if (!document.body.contains(sourceUrlContainerRef) || !document.body.contains(sourceUrlEditorRef)) {
return;
}
this.setupSourceUrlHandlers();
}, 50);
}
// Set generation parameters
this.syncGenerationParams(hydratedRecipe.gen_params);
this.syncResourcesSection(hydratedRecipe);
// Show the modal
modalManager.showModal('recipeModal');
if (this.recipeId) {
const hydrationRequestId = ++this.recipeHydrationRequestId;
const requestEditVersions = this.captureLocalEditVersions();
this.hydrateRecipeDetails(
this.recipeId,
hydrationRequestId,
requestEditVersions
);
}
}
async hydrateRecipeDetails(recipeId, requestId, requestEditVersions = {}) {
try {
const fullRecipe = await fetchRecipeDetails(recipeId);
if (requestId !== this.recipeHydrationRequestId || !fullRecipe) {
return;
}
const nextRecipe = { ...this.currentRecipe };
if (!this.hasFieldCommittedSinceRequest('title', requestEditVersions) && fullRecipe.title !== undefined) {
nextRecipe.title = fullRecipe.title;
}
if (!this.hasFieldCommittedSinceRequest('tags', requestEditVersions) && fullRecipe.tags !== undefined) {
nextRecipe.tags = Array.isArray(fullRecipe.tags) ? [...fullRecipe.tags] : fullRecipe.tags;
}
if (!this.hasFieldCommittedSinceRequest('source_path', requestEditVersions)) {
nextRecipe.source_path = fullRecipe.source_path || '';
}
const previousFilePath = nextRecipe.file_path;
if (fullRecipe.file_path !== undefined) {
nextRecipe.file_path = fullRecipe.file_path;
}
if (fullRecipe.file_url !== undefined) {
nextRecipe.file_url = fullRecipe.file_url;
}
if (fullRecipe.preview_url !== undefined) {
nextRecipe.preview_url = fullRecipe.preview_url;
}
if (
fullRecipe.file_path !== undefined &&
fullRecipe.file_path !== previousFilePath &&
fullRecipe.file_url === undefined &&
fullRecipe.preview_url === undefined
) {
delete nextRecipe.file_url;
delete nextRecipe.preview_url;
}
if (fullRecipe.gen_params !== undefined) {
const previousGenParams = nextRecipe.gen_params || {};
const incomingGenParams = { ...(fullRecipe.gen_params || {}) };
for (const [key, value] of Object.entries(previousGenParams)) {
if (this.hasFieldCommittedSinceRequest(key, requestEditVersions)) {
incomingGenParams[key] = value;
}
}
nextRecipe.gen_params = incomingGenParams;
} else {
const previousGenParams = nextRecipe.gen_params || {};
const preservedGenParams = {};
for (const [key, value] of Object.entries(previousGenParams)) {
if (this.hasFieldCommittedSinceRequest(key, requestEditVersions)) {
preservedGenParams[key] = value;
}
}
nextRecipe.gen_params = preservedGenParams;
}
if (fullRecipe.checkpoint !== undefined) {
nextRecipe.checkpoint = fullRecipe.checkpoint;
} else {
delete nextRecipe.checkpoint;
}
if (fullRecipe.loras !== undefined) {
nextRecipe.loras = Array.isArray(fullRecipe.loras) ? [...fullRecipe.loras] : fullRecipe.loras;
} else {
delete nextRecipe.loras;
}
this.currentRecipe = nextRecipe;
this.filePath = this.currentRecipe.file_path || this.filePath;
this.syncHydratedRecipeFields(requestEditVersions);
} catch (error) {
// Keep the cached recipe visible if hydration fails.
console.warn('Failed to hydrate recipe details:', error);
}
}
syncHydratedRecipeFields(requestEditVersions = {}) {
this.syncPreviewMedia(this.currentRecipe);
if (!this.shouldPreserveField('title', requestEditVersions)) {
this.syncTitleDisplay(this.currentRecipe?.title || '');
}
if (!this.shouldPreserveField('tags', requestEditVersions)) {
this.syncTagsDisplay(this.currentRecipe?.tags || []);
}
if (!this.shouldPreserveField('prompt', requestEditVersions)) {
this.syncPromptField(
'prompt',
this.currentRecipe?.gen_params?.prompt || '',
'No prompt information available'
);
}
if (!this.shouldPreserveField('negative_prompt', requestEditVersions)) {
this.syncPromptField(
'negative_prompt',
this.currentRecipe?.gen_params?.negative_prompt || '',
'No negative prompt information available'
);
}
this.syncGenerationParams(this.currentRecipe?.gen_params, { promptFieldsOnly: true });
this.syncResourcesSection(this.currentRecipe);
if (!this.shouldPreserveField('source_path', requestEditVersions)) {
this.updateSourceUrlDisplay(this.currentRecipe.source_path || '', { forceInputSync: true });
} else {
this.updateSourceUrlDisplay(this.currentRecipe.source_path || '');
}
}
getPreviewMediaUrl(recipe = {}) {
return recipe.file_url ||
recipe.preview_url ||
(recipe.file_path ? `/loras_static/root1/preview/${recipe.file_path.split('/').pop()}` :
'/loras_static/images/no-preview.png');
}
syncPreviewMedia(recipe = {}) {
const mediaContainer = document.getElementById('recipePreviewContainer');
if (!mediaContainer) {
return;
}
const previewUrl = this.getPreviewMediaUrl(recipe);
const isVideo = previewUrl.toLowerCase().endsWith('.mp4');
const expectedElementId = isVideo ? 'recipeModalVideo' : 'recipeModalImage';
let previewElement = mediaContainer.querySelector(`#${expectedElementId}`);
const existingPreviewElement = mediaContainer.querySelector('.recipe-preview-media');
if (!previewElement || (existingPreviewElement && existingPreviewElement !== previewElement)) {
if (existingPreviewElement?.tagName === 'VIDEO') {
const existingVideo = existingPreviewElement;
existingVideo.pause();
existingVideo.currentTime = 0;
}
existingPreviewElement?.remove();
previewElement = document.createElement(isVideo ? 'video' : 'img');
previewElement.id = expectedElementId;
previewElement.className = 'recipe-preview-media';
mediaContainer.prepend(previewElement);
}
previewElement.src = previewUrl;
previewElement.alt = recipe.title || 'Recipe Preview';
if (isVideo) {
previewElement.controls = true;
previewElement.autoplay = false;
previewElement.loop = true;
previewElement.muted = true;
}
}
getMetadataUpdateOptions() {
return this.listFilePath ? { listFilePath: this.listFilePath } : {};
}
syncTitleDisplay(title) {
const titleContainer = document.getElementById('recipeModalTitle');
if (!titleContainer) {
return;
}
const contentText = titleContainer.querySelector('.content-text');
if (contentText) {
contentText.textContent = title || 'Recipe Details';
}
const titleInput = titleContainer.querySelector('.title-input');
if (titleInput) {
titleInput.value = title || '';
}
}
syncTagsDisplay(tags) {
const tagsContainer = document.getElementById('recipeTagsCompact');
if (!tagsContainer) {
return;
}
this.updateTagsDisplay(tagsContainer, tags || []);
const tagsInput = tagsContainer.querySelector('.tags-input');
if (tagsInput) {
tagsInput.value = tags && tags.length > 0 ? tags.join(', ') : '';
}
}
syncPromptField(field, value, placeholder) {
const contentId = field === 'prompt' ? 'recipePrompt' : 'recipeNegativePrompt';
const editorId = field === 'prompt' ? 'recipePromptEditor' : 'recipeNegativePromptEditor';
const inputId = field === 'prompt' ? 'recipePromptInput' : 'recipeNegativePromptInput';
this.renderPromptContent(document.getElementById(contentId), value, placeholder);
const input = document.getElementById(inputId);
if (input) {
input.value = value || '';
}
}
syncGenerationParams(genParams, options = {}) {
const promptElement = document.getElementById('recipePrompt');
const negativePromptElement = document.getElementById('recipeNegativePrompt');
const otherParamsElement = document.getElementById('recipeOtherParams');
const promptInput = document.getElementById('recipePromptInput');
const negativePromptInput = document.getElementById('recipeNegativePromptInput');
const promptFieldsOnly = options.promptFieldsOnly === true;
const sanitizedGenParams = this.sanitizeGenParams(genParams);
if (recipe.gen_params) {
this.renderPromptContent(promptElement, recipe.gen_params.prompt, 'No prompt information available');
this.renderPromptContent(negativePromptElement, recipe.gen_params.negative_prompt, 'No negative prompt information available');
if (sanitizedGenParams) {
if (!promptFieldsOnly) {
this.renderPromptContent(promptElement, sanitizedGenParams.prompt, 'No prompt information available');
this.renderPromptContent(negativePromptElement, sanitizedGenParams.negative_prompt, 'No negative prompt information available');
if (promptInput) {
promptInput.value = recipe.gen_params.prompt || '';
if (promptInput) {
promptInput.value = sanitizedGenParams.prompt || '';
}
if (negativePromptInput) {
negativePromptInput.value = sanitizedGenParams.negative_prompt || '';
}
}
if (negativePromptInput) {
negativePromptInput.value = recipe.gen_params.negative_prompt || '';
}
// Set other parameters
if (otherParamsElement) {
// Clear previous params
otherParamsElement.innerHTML = '';
// Add all other parameters except prompt and negative_prompt
const excludedParams = ['prompt', 'negative_prompt'];
for (const [key, value] of Object.entries(recipe.gen_params)) {
for (const [key, value] of Object.entries(sanitizedGenParams)) {
if (!excludedParams.includes(key) && value !== undefined && value !== null) {
const paramTag = document.createElement('div');
paramTag.className = 'param-tag';
@@ -338,22 +629,68 @@ class RecipeModal {
}
}
// If no other params, show a message
if (otherParamsElement.children.length === 0) {
otherParamsElement.innerHTML = '<div class="no-params">No additional parameters available</div>';
}
}
} else {
// No generation parameters available
return;
}
if (!promptFieldsOnly) {
this.renderPromptContent(promptElement, '', 'No prompt information available');
this.renderPromptContent(negativePromptElement, '', 'No negative prompt information available');
if (promptInput) promptInput.value = '';
if (negativePromptInput) negativePromptInput.value = '';
if (otherParamsElement) otherParamsElement.innerHTML = '<div class="no-params">No parameters available</div>';
}
if (otherParamsElement) {
otherParamsElement.innerHTML = '<div class="no-params">No parameters available</div>';
}
}
sanitizeGenParams(genParams) {
if (!genParams || typeof genParams !== 'object') {
return null;
}
const sanitized = {};
for (const [key, value] of Object.entries(genParams)) {
if (value === undefined || value === null || value === '') {
continue;
}
if (!ALLOWED_GEN_PARAM_KEYS.has(key)) {
continue;
}
sanitized[key] = value;
}
for (const [key, value] of Object.entries(genParams)) {
if (value === undefined || value === null || value === '') {
continue;
}
const normalizedKey = GEN_PARAM_NORMALIZATION[key] || key;
if (!ALLOWED_GEN_PARAM_KEYS.has(normalizedKey)) {
continue;
}
if (sanitized[normalizedKey] === undefined || sanitized[normalizedKey] === null || sanitized[normalizedKey] === '') {
sanitized[normalizedKey] = value;
}
}
return sanitized;
}
syncResourcesSection(recipe = {}) {
const checkpointContainer = document.getElementById('recipeCheckpoint');
const resourceDivider = document.getElementById('recipeResourceDivider');
const lorasListElement = document.getElementById('recipeLorasList');
const lorasCountElement = document.getElementById('recipeLorasCount');
const loras = Array.isArray(recipe.loras) ? recipe.loras : [];
if (checkpointContainer) {
checkpointContainer.innerHTML = '';
@@ -364,59 +701,43 @@ class RecipeModal {
}
}
// Set LoRAs list and count
const lorasListElement = document.getElementById('recipeLorasList');
const lorasCountElement = document.getElementById('recipeLorasCount');
// Check all LoRAs status
let allLorasAvailable = true;
let missingLorasCount = 0;
let deletedLorasCount = 0;
if (recipe.loras && recipe.loras.length > 0) {
recipe.loras.forEach(lora => {
if (lora.isDeleted) {
deletedLorasCount++;
} else if (!lora.inLibrary) {
allLorasAvailable = false;
missingLorasCount++;
}
});
}
loras.forEach(lora => {
if (lora.isDeleted) {
deletedLorasCount++;
} else if (!lora.inLibrary) {
allLorasAvailable = false;
missingLorasCount++;
}
});
// Set LoRAs count and status
if (lorasCountElement && recipe.loras) {
const totalCount = recipe.loras.length;
// Create status indicator based on LoRA states
if (lorasCountElement) {
const totalCount = loras.length;
let statusHTML = '';
if (totalCount > 0) {
if (allLorasAvailable && deletedLorasCount === 0) {
// All LoRAs are available
statusHTML = `<div class="recipe-status ready"><i class="fas fa-check-circle"></i> Ready to use</div>`;
} else if (missingLorasCount > 0) {
// Some LoRAs are missing (prioritize showing missing over deleted)
statusHTML = `<div class="recipe-status missing">
<i class="fas fa-exclamation-triangle"></i> ${missingLorasCount} missing
<div class="missing-tooltip">Click to download missing LoRAs</div>
</div>`;
} else if (deletedLorasCount > 0 && missingLorasCount === 0) {
// Some LoRAs are deleted but none are missing
statusHTML = `<div class="recipe-status partial"><i class="fas fa-info-circle"></i> ${deletedLorasCount} deleted</div>`;
}
}
lorasCountElement.innerHTML = `<i class="fas fa-layer-group"></i> ${totalCount} LoRAs ${statusHTML}`;
// Add event listeners for buttons and status indicators
setTimeout(() => {
// Set up click handler for View LoRAs button
const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn');
if (viewRecipeLorasBtn) {
viewRecipeLorasBtn.addEventListener('click', () => this.navigateToLorasPage());
}
// Add click handler for missing LoRAs status
const missingStatus = document.querySelector('.recipe-status.missing');
if (missingStatus && missingLorasCount > 0) {
missingStatus.classList.add('clickable');
@@ -425,13 +746,12 @@ class RecipeModal {
}, 100);
}
if (lorasListElement && recipe.loras && recipe.loras.length > 0) {
lorasListElement.innerHTML = recipe.loras.map(lora => {
if (lorasListElement && loras.length > 0) {
lorasListElement.innerHTML = loras.map(lora => {
const existsLocally = lora.inLibrary;
const isDeleted = lora.isDeleted;
const localPath = lora.localPath || '';
// Create status badge based on LoRA state
let localStatus;
if (existsLocally) {
localStatus = `
@@ -441,7 +761,7 @@ class RecipeModal {
</div>`;
} else if (isDeleted) {
localStatus = `
<div class="deleted-badge reconnectable" data-lora-index="${recipe.loras.indexOf(lora)}">
<div class="deleted-badge reconnectable" data-lora-index="${loras.indexOf(lora)}">
<span class="badge-text"><i class="fas fa-trash-alt"></i> Deleted</span>
<div class="reconnect-tooltip">Click to reconnect with a local LoRA</div>
</div>`;
@@ -452,7 +772,6 @@ class RecipeModal {
</div>`;
}
// Check if preview is a video
const isPreviewVideo = lora.preview_url && lora.preview_url.toLowerCase().endsWith('.mp4');
const previewMedia = isPreviewVideo ?
`<video class="thumbnail-video" autoplay loop muted playsinline>
@@ -460,7 +779,6 @@ class RecipeModal {
</video>` :
`<img src="${lora.preview_url || '/loras_static/images/no-preview.png'}" alt="LoRA preview">`;
// Determine CSS class based on LoRA state
let loraItemClass = 'recipe-lora-item';
if (existsLocally) {
loraItemClass += ' exists-locally';
@@ -471,7 +789,7 @@ class RecipeModal {
}
return `
<div class="${loraItemClass}" data-lora-index="${recipe.loras.indexOf(lora)}">
<div class="${loraItemClass}" data-lora-index="${loras.indexOf(lora)}">
<div class="recipe-lora-thumbnail">
${previewMedia}
</div>
@@ -485,7 +803,7 @@ class RecipeModal {
<div class="recipe-lora-weight">Weight: ${lora.strength || 1.0}</div>
${lora.baseModel ? `<div class="base-model">${lora.baseModel}</div>` : ''}
</div>
<div class="lora-reconnect-container" data-lora-index="${recipe.loras.indexOf(lora)}">
<div class="lora-reconnect-container" data-lora-index="${loras.indexOf(lora)}">
<div class="reconnect-instructions">
<p>Enter LoRA Syntax or Name to Reconnect:</p>
<small>Example: <code>&lt;lora:Boris_Vallejo_BV_flux_D:1&gt;</code> or just <code>Boris_Vallejo_BV_flux_D</code></small>
@@ -503,15 +821,12 @@ class RecipeModal {
`;
}).join('');
// Add event listeners for reconnect functionality
setTimeout(() => {
this.setupReconnectButtons();
this.setupLoraItemsClickable();
}, 100);
// Generate recipe syntax for copy button (this is now a placeholder, actual syntax will be fetched from the API)
this.recipeLorasSyntax = '';
} else if (lorasListElement) {
lorasListElement.innerHTML = '<div class="no-loras">No LoRAs associated with this recipe</div>';
this.recipeLorasSyntax = '';
@@ -522,9 +837,31 @@ class RecipeModal {
const hasLoraItems = lorasListElement && lorasListElement.querySelector('.recipe-lora-item');
resourceDivider.style.display = hasCheckpoint && hasLoraItems ? 'block' : 'none';
}
}
// Show the modal
modalManager.showModal('recipeModal');
updateSourceUrlDisplay(sourcePath, options = {}) {
const sourceUrlContainer = document.querySelector('.source-url-container');
const sourceUrlEditor = document.querySelector('.source-url-editor');
if (!sourceUrlContainer || !sourceUrlEditor) {
return;
}
const sourceUrlText = sourceUrlContainer.querySelector('.source-url-text');
const sourceUrlInput = sourceUrlEditor.querySelector('.source-url-input');
if (!sourceUrlText || !sourceUrlInput) {
return;
}
const normalizedSourcePath = typeof sourcePath === 'string' ? sourcePath.trim() : '';
const isValidUrl = normalizedSourcePath.startsWith('http://') || normalizedSourcePath.startsWith('https://');
sourceUrlText.textContent = normalizedSourcePath || 'No source URL';
sourceUrlText.title = normalizedSourcePath
? (isValidUrl ? 'Click to open source URL' : 'No valid URL')
: 'No valid URL';
if (options.forceInputSync || !sourceUrlEditor.classList.contains('active') || !this.sourceUrlEditState.isDirty) {
sourceUrlInput.value = normalizedSourcePath;
}
}
// Title editing methods
@@ -535,6 +872,7 @@ class RecipeModal {
const editor = titleContainer.querySelector('#recipeTitleEditor');
editor.classList.add('active');
const input = editor.querySelector('input');
input.oninput = () => this.markFieldDirty('title');
input.focus();
input.select();
}
@@ -553,19 +891,23 @@ class RecipeModal {
titleContainer.querySelector('.content-text').textContent = newTitle;
// Update the recipe on the server
updateRecipeMetadata(this.filePath, { title: newTitle })
updateRecipeMetadata(this.filePath, { title: newTitle }, this.getMetadataUpdateOptions())
.then(data => {
// Show success toast
showToast('toast.recipes.nameUpdated', {}, 'success');
// Update the current recipe object
this.currentRecipe.title = newTitle;
this.commitField('title');
})
.catch(error => {
// Error is handled in the API function
// Reset the UI if needed
titleContainer.querySelector('.content-text').textContent = this.currentRecipe.title || '';
this.clearFieldDirty('title');
});
} else {
this.clearFieldDirty('title');
}
// Hide editor
@@ -581,6 +923,7 @@ class RecipeModal {
const editor = titleContainer.querySelector('#recipeTitleEditor');
const input = editor.querySelector('input');
input.value = this.currentRecipe.title || '';
this.clearFieldDirty('title');
// Hide editor
editor.classList.remove('active');
@@ -596,6 +939,7 @@ class RecipeModal {
const editor = tagsContainer.querySelector('#recipeTagsEditor');
editor.classList.add('active');
const input = editor.querySelector('input');
input.oninput = () => this.markFieldDirty('tags');
input.focus();
}
}
@@ -623,20 +967,24 @@ class RecipeModal {
if (tagsChanged) {
// Update the recipe on the server
updateRecipeMetadata(this.filePath, { tags: newTags })
updateRecipeMetadata(this.filePath, { tags: newTags }, this.getMetadataUpdateOptions())
.then(data => {
// Show success toast
showToast('toast.recipes.tagsUpdated', {}, 'success');
// Update the current recipe object
this.currentRecipe.tags = newTags;
this.commitField('tags');
// Update tags in the UI
this.updateTagsDisplay(tagsContainer, newTags);
})
.catch(error => {
// Error is handled in the API function
this.clearFieldDirty('tags');
});
} else {
this.clearFieldDirty('tags');
}
// Hide editor
@@ -708,6 +1056,7 @@ class RecipeModal {
const editor = tagsContainer.querySelector('#recipeTagsEditor');
const input = editor.querySelector('input');
input.value = this.currentRecipe.tags ? this.currentRecipe.tags.join(', ') : '';
this.clearFieldDirty('tags');
// Hide editor
editor.classList.remove('active');
@@ -748,6 +1097,7 @@ class RecipeModal {
}
if (input) {
input.addEventListener('input', () => this.markFieldDirty(config.field));
input.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
event.preventDefault();
@@ -840,9 +1190,10 @@ class RecipeModal {
const currentGenParams = this.currentRecipe.gen_params || {};
const nextValue = input.value.trim() === '' ? '' : input.value;
const currentValue = currentGenParams[config.field] || '';
const currentValue = this.sanitizeGenParams(currentGenParams)?.[config.field] || '';
if (nextValue === currentValue) {
this.clearFieldDirty(config.field);
this.hidePromptEditor(config);
return;
}
@@ -857,14 +1208,17 @@ class RecipeModal {
...promptState,
isSaving: true,
};
await updateRecipeMetadata(this.filePath, { gen_params: nextGenParams });
await updateRecipeMetadata(this.filePath, { gen_params: nextGenParams }, this.getMetadataUpdateOptions());
this.currentRecipe.gen_params = nextGenParams;
this.renderPromptContent(content, nextValue, config.placeholder);
showToast(config.successKey, {}, 'success', config.successFallback);
this.commitField(config.field);
} catch (error) {
this.renderPromptContent(content, currentValue, config.placeholder);
input.value = currentValue;
this.clearFieldDirty(config.field);
} finally {
this.clearFieldDirty(config.field);
this.hidePromptEditor(config);
}
}
@@ -872,10 +1226,10 @@ class RecipeModal {
cancelPromptEdit(config) {
const input = document.getElementById(config.inputId);
if (input) {
const initialValue = this.promptEditorState[config.field]?.initialValue;
input.value = initialValue ?? (this.currentRecipe?.gen_params?.[config.field] || '');
input.value = this.currentRecipe?.gen_params?.[config.field] || '';
}
this.clearFieldDirty(config.field);
this.hidePromptEditor(config);
}
@@ -918,11 +1272,16 @@ class RecipeModal {
sourceUrlInput.focus();
});
sourceUrlInput.addEventListener('input', () => {
this.sourceUrlEditState.isDirty = true;
});
// Cancel editing
sourceUrlCancelBtn.addEventListener('click', () => {
sourceUrlEditor.classList.remove('active');
sourceUrlContainer.classList.remove('hide');
sourceUrlInput.value = this.currentRecipe.source_path || '';
this.updateSourceUrlDisplay(this.currentRecipe.source_path || '', { forceInputSync: true });
this.clearFieldDirty('source_path');
});
// Save new source URL
@@ -930,23 +1289,24 @@ class RecipeModal {
const newSourceUrl = sourceUrlInput.value.trim();
if (newSourceUrl !== this.currentRecipe.source_path) {
// Update the recipe on the server
updateRecipeMetadata(this.filePath, { source_path: newSourceUrl })
updateRecipeMetadata(this.filePath, { source_path: newSourceUrl }, this.getMetadataUpdateOptions())
.then(data => {
// Show success toast
showToast('toast.recipes.sourceUrlUpdated', {}, 'success');
// Update source URL in the UI
sourceUrlText.textContent = newSourceUrl || 'No source URL';
sourceUrlText.title = newSourceUrl && (newSourceUrl.startsWith('http://') ||
newSourceUrl.startsWith('https://')) ?
'Click to open source URL' : 'No valid URL';
this.commitField('source_path');
this.updateSourceUrlDisplay(newSourceUrl, { forceInputSync: true });
// Update the current recipe object
this.currentRecipe.source_path = newSourceUrl;
})
.catch(error => {
// Error is handled in the API function
this.clearFieldDirty('source_path');
});
} else {
this.clearFieldDirty('source_path');
}
// Hide editor
@@ -1286,7 +1646,7 @@ class RecipeModal {
this.showRecipeDetails(this.currentRecipe);
}, 500);
state.virtualScroller.updateSingleItem(this.currentRecipe.file_path, {
state.virtualScroller.updateSingleItem(this.listFilePath || this.currentRecipe.file_path, {
loras: this.currentRecipe.loras
});
} else {

View File

@@ -1,7 +1,7 @@
// PageControls.js - Manages controls for both LoRAs and Checkpoints pages
import { state, getCurrentPageState, setCurrentPageType } from '../../state/index.js';
import { getStorageItem, setStorageItem, getSessionItem, setSessionItem } from '../../utils/storageHelpers.js';
import { showToast } from '../../utils/uiHelpers.js';
import { showToast, openCivitaiByMetadata } from '../../utils/uiHelpers.js';
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
import { sidebarManager } from '../SidebarManager.js';
@@ -353,18 +353,8 @@ export class PageControls {
const metaData = JSON.parse(card.dataset.meta);
const civitaiId = metaData.modelId;
const versionId = metaData.id;
// Build URL
if (civitaiId) {
let url = `https://civitai.com/models/${civitaiId}`;
if (versionId) {
url += `?modelVersionId=${versionId}`;
}
window.open(url, '_blank');
} else {
// If no ID, try searching by name
window.open(`https://civitai.com/models?query=${encodeURIComponent(modelName)}`, '_blank');
}
openCivitaiByMetadata(civitaiId, versionId, modelName);
}
/**

View File

@@ -1,26 +1,21 @@
import { getModelApiClient } from '../../api/modelApiFactory.js';
import { downloadManager } from '../../managers/DownloadManager.js';
import { modalManager } from '../../managers/ModalManager.js';
import { showToast } from '../../utils/uiHelpers.js';
import { openCivitaiUrl, showToast } from '../../utils/uiHelpers.js';
import { translate } from '../../utils/i18nHelpers.js';
import { state } from '../../state/index.js';
import { buildCivitaiModelUrl } from '../../utils/civitaiUtils.js';
import { formatFileSize } from './utils.js';
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.mkv'];
const PREVIEW_PLACEHOLDER_URL = '/loras_static/images/no-preview.png';
function buildCivitaiVersionUrl(modelId, versionId) {
if (modelId == null || versionId == null) {
return null;
}
const normalizedModelId = String(modelId).trim();
const normalizedVersionId = String(versionId).trim();
if (!normalizedModelId || !normalizedVersionId) {
return null;
}
const encodedModelId = encodeURIComponent(normalizedModelId);
const encodedVersionId = encodeURIComponent(normalizedVersionId);
return `https://civitai.com/models/${encodedModelId}?modelVersionId=${encodedVersionId}`;
return buildCivitaiModelUrl(
modelId,
versionId,
state?.global?.settings?.civitai_host
);
}
function escapeHtml(value) {
@@ -220,8 +215,31 @@ function buildMetaMarkup(version, options = {}) {
.join('<span class="version-meta-separator">•</span>');
}
function buildBadge(label, tone) {
return `<span class="version-badge version-badge-${tone}">${escapeHtml(label)}</span>`;
function buildBadge(label, tone, options = {}) {
const attributes = [];
if (options.title) {
attributes.push(`title="${escapeHtml(options.title)}"`);
}
if (options.ariaLabel) {
attributes.push(`aria-label="${escapeHtml(options.ariaLabel)}"`);
}
const suffix = attributes.length ? ` ${attributes.join(' ')}` : '';
return `<span class="version-badge version-badge-${tone}"${suffix}>${escapeHtml(label)}</span>`;
}
function buildActionButton(label, variant, action, options = {}) {
const attributes = [
`class="version-action ${variant}"`,
`data-version-action="${escapeHtml(action)}"`,
];
if (options.title) {
attributes.push(`title="${escapeHtml(options.title)}"`);
attributes.push(`aria-label="${escapeHtml(options.title)}"`);
}
if (options.extraAttributes) {
attributes.push(options.extraAttributes);
}
return `<button ${attributes.join(' ')}>${options.iconMarkup || ''}${escapeHtml(label)}</button>`;
}
const DISPLAY_FILTER_MODES = Object.freeze({
@@ -426,23 +444,72 @@ function renderRow(version, options) {
version.versionId > latestLibraryVersionId;
const isEarlyAccess = isEarlyAccessActive(version);
const badges = [];
const openedBadgeLabel = translate('modals.model.versions.badges.current', {}, 'Opened Version');
const inLibraryBadgeLabel = translate('modals.model.versions.badges.inLibrary', {}, 'In Library');
const downloadedBadgeLabel = translate('modals.model.versions.badges.downloaded', {}, 'Downloaded');
const newerBadgeLabel = translate('modals.model.versions.badges.newer', {}, 'Newer Version');
const earlyAccessBadgeLabel = translate('modals.model.versions.badges.earlyAccess', {}, 'Early Access');
const ignoredBadgeLabel = translate('modals.model.versions.badges.ignored', {}, 'Ignored');
const versionName = version.name || translate('modals.model.versions.labels.unnamed', {}, 'Untitled Version');
if (isCurrent) {
badges.push(buildBadge(translate('modals.model.versions.badges.current', {}, 'Current Version'), 'current'));
badges.push(buildBadge(openedBadgeLabel, 'current', {
title: translate(
'modals.model.versions.badges.currentTooltip',
{},
'This is the version you opened this modal from'
),
}));
}
if (version.isInLibrary) {
badges.push(buildBadge(translate('modals.model.versions.badges.inLibrary', {}, 'In Library'), 'success'));
} else if (isNewer && !version.shouldIgnore) {
badges.push(buildBadge(translate('modals.model.versions.badges.newer', {}, 'Newer Version'), 'info'));
badges.push(buildBadge(inLibraryBadgeLabel, 'success', {
title: translate(
'modals.model.versions.badges.inLibraryTooltip',
{},
'This version exists in your local library'
),
}));
}
if (!version.isInLibrary && version.hasBeenDownloaded) {
badges.push(buildBadge(downloadedBadgeLabel, 'info', {
title: translate(
'modals.model.versions.badges.downloadedTooltip',
{},
'This version was downloaded before, but is not currently in your library'
),
}));
}
if (!version.isInLibrary && isNewer && !version.shouldIgnore) {
badges.push(buildBadge(newerBadgeLabel, 'info', {
title: translate(
'modals.model.versions.badges.newerTooltip',
{},
'This version is newer than your latest local version'
),
}));
}
if (isEarlyAccess) {
badges.push(buildBadge(translate('modals.model.versions.badges.earlyAccess', {}, 'Early Access'), 'early-access'));
badges.push(buildBadge(earlyAccessBadgeLabel, 'early-access', {
title: translate(
'modals.model.versions.badges.earlyAccessTooltip',
{},
'This version currently requires Civitai early access'
),
}));
}
if (version.shouldIgnore) {
badges.push(buildBadge(translate('modals.model.versions.badges.ignored', {}, 'Ignored'), 'muted'));
badges.push(buildBadge(ignoredBadgeLabel, 'muted', {
title: translate(
'modals.model.versions.badges.ignoredTooltip',
{},
'Update notifications are disabled for this version'
),
}));
}
const downloadLabel = translate('modals.model.versions.actions.download', {}, 'Download');
@@ -459,29 +526,82 @@ function renderRow(version, options) {
if (!version.isInLibrary) {
// Download button with optional EA bolt icon
const downloadIcon = isEarlyAccess ? '<i class="fas fa-bolt"></i> ' : '';
actions.push(
`<button class="version-action version-action-primary" data-version-action="download">${downloadIcon}${escapeHtml(downloadLabel)}</button>`
);
actions.push(buildActionButton(
downloadLabel,
'version-action-primary',
'download',
{
title: isEarlyAccess
? translate(
'modals.model.versions.actions.downloadEarlyAccessTooltip',
{},
'Download this early access version from Civitai'
)
: translate(
'modals.model.versions.actions.downloadTooltip',
{},
'Download this version'
),
iconMarkup: downloadIcon,
}
));
} else if (version.filePath) {
actions.push(
`<button class="version-action version-action-danger" data-version-action="delete">${escapeHtml(deleteLabel)}</button>`
);
actions.push(buildActionButton(
deleteLabel,
'version-action-danger',
'delete',
{
title: translate(
'modals.model.versions.actions.deleteTooltip',
{},
'Delete this local version'
),
}
));
}
actions.push(
`<button class="version-action version-action-ghost" data-version-action="toggle-ignore" data-ignore-state="${
version.shouldIgnore ? 'ignored' : 'active'
}">${escapeHtml(ignoreLabel)}</button>`
);
actions.push(buildActionButton(
ignoreLabel,
'version-action-ghost',
'toggle-ignore',
{
title: version.shouldIgnore
? translate(
'modals.model.versions.actions.unignoreTooltip',
{},
'Resume update notifications for this version'
)
: translate(
'modals.model.versions.actions.ignoreTooltip',
{},
'Ignore update notifications for this version'
),
extraAttributes: `data-ignore-state="${version.shouldIgnore ? 'ignored' : 'active'}"`,
}
));
const linkTarget = buildCivitaiVersionUrl(
version.modelId || parentModelId,
version.versionId
);
const civitaiTooltip = translate(
'modals.model.actions.viewOnCivitai',
'modals.model.versions.actions.viewVersionOnCivitai',
{},
'View on Civitai'
'View version on Civitai'
);
const civitaiLinkMarkup = linkTarget
? `
<a
class="version-civitai-link"
href="${escapeHtml(linkTarget)}"
target="_blank"
rel="noopener noreferrer"
title="${escapeHtml(civitaiTooltip)}"
aria-label="${escapeHtml(civitaiTooltip)}"
>
<i class="fas fa-arrow-up-right-from-square" aria-hidden="true"></i>
</a>
`
: '';
const rowAttributes = [
`class="model-version-row${isCurrent ? ' is-current' : ''}${linkTarget ? ' is-clickable' : ''}${isEarlyAccess ? ' is-early-access' : ''}"`,
@@ -489,7 +609,6 @@ function renderRow(version, options) {
];
if (linkTarget) {
rowAttributes.push(`data-civitai-url="${escapeHtml(linkTarget)}"`);
rowAttributes.push(`title="${escapeHtml(civitaiTooltip)}"`);
}
return `
@@ -497,7 +616,8 @@ function renderRow(version, options) {
${renderMediaMarkup(version)}
<div class="version-details">
<div class="version-title">
<span class="versions-tab-version-name">${escapeHtml(version.name || translate('modals.model.versions.labels.unnamed', {}, 'Untitled Version'))}</span>
<span class="versions-tab-version-name">${escapeHtml(versionName)}</span>
${civitaiLinkMarkup}
</div>
<div class="version-badges">${badges.join('')}</div>
<div class="version-meta">
@@ -1227,9 +1347,17 @@ export function initVersionsTab({
}
const row = event.target.closest('.model-version-row.is-clickable');
const civitaiLink = event.target.closest('.version-civitai-link');
if (civitaiLink) {
event.preventDefault();
openCivitaiUrl(civitaiLink.href);
return;
}
if (!row) {
return;
}
if (event.target.closest('button')) {
return;
}
@@ -1245,7 +1373,7 @@ export function initVersionsTab({
return;
}
event.preventDefault();
window.open(targetUrl, '_blank', 'noopener,noreferrer');
openCivitaiUrl(targetUrl);
});
// Listen for extension-triggered refresh requests

View File

@@ -9,6 +9,7 @@ import { moveManager } from './managers/MoveManager.js';
import { bulkManager } from './managers/BulkManager.js';
import { ExampleImagesManager } from './managers/ExampleImagesManager.js';
import { helpManager } from './managers/HelpManager.js';
import { doctorManager } from './managers/DoctorManager.js';
import { bannerService } from './managers/BannerService.js';
import { initTheme, initBackToTop } from './utils/uiHelpers.js';
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
@@ -58,6 +59,7 @@ export class AppCore {
const exampleImagesManager = new ExampleImagesManager();
window.exampleImagesManager = exampleImagesManager;
window.helpManager = helpManager;
window.doctorManager = doctorManager;
window.moveManager = moveManager;
window.bulkManager = bulkManager;
@@ -77,6 +79,7 @@ export class AppCore {
exampleImagesManager.initialize();
// Initialize the help manager
helpManager.initialize();
doctorManager.initialize();
const cardInfoDisplay = state.global.settings.card_info_display || 'always';
document.body.classList.toggle('hover-reveal', cardInfoDisplay === 'hover');

View File

@@ -432,7 +432,7 @@ export class BatchImportManager {
// Refresh recipes list to show newly imported recipes
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
window.recipeManager.loadRecipes();
window.recipeManager.loadRecipes({ preserveScroll: true });
}
// Show results step

View File

@@ -311,7 +311,7 @@ export class BulkMissingLoraDownloadManager {
// Refresh the recipes list to update LoRA status
if (window.recipeManager) {
window.recipeManager.loadRecipes();
window.recipeManager.loadRecipes({ preserveScroll: true });
}
}

View File

@@ -0,0 +1,397 @@
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 || '';
}
buildReloadUrl() {
const url = new URL(window.location.href);
url.searchParams.set('_lm_reload', Date.now().toString());
return url.toString();
}
reloadUi() {
window.location.replace(this.buildReloadUrl());
}
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':
this.reloadUi();
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();

View File

@@ -6,6 +6,7 @@ import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { FolderTreeManager } from '../components/FolderTreeManager.js';
import { translate } from '../utils/i18nHelpers.js';
import { extractCivitaiModelUrlParts } from '../utils/civitaiUtils.js';
export class DownloadManager {
constructor() {
@@ -197,21 +198,22 @@ export class DownloadManager {
}
extractModelId(url) {
const versionMatch = url.match(/modelVersionId=(\d+)/i);
this.modelVersionId = versionMatch ? versionMatch[1] : null;
const civarchiveMatch = url.match(/https?:\/\/(?:www\.)?(?:civitaiarchive|civarchive)\.com\/models\/(\d+)/i);
if (civarchiveMatch) {
const versionMatch = url.match(/modelVersionId=(\d+)/i);
this.modelVersionId = versionMatch ? versionMatch[1] : null;
this.source = 'civarchive';
return civarchiveMatch[1];
}
const civitaiMatch = url.match(/https?:\/\/(?:www\.)?civitai\.com\/models\/(\d+)/i);
if (civitaiMatch) {
const { modelId, modelVersionId } = extractCivitaiModelUrlParts(url);
if (modelId) {
this.modelVersionId = modelVersionId;
this.source = null;
return civitaiMatch[1];
return modelId;
}
this.modelVersionId = null;
this.source = null;
return null;
}
@@ -250,6 +252,7 @@ export class DownloadManager {
(version.files[0]?.sizeKB / 1024).toFixed(2);
const existsLocally = version.existsLocally;
const hasBeenDownloaded = version.hasBeenDownloaded && !existsLocally;
const localPath = version.localPath;
const isEarlyAccess = version.availability === 'EarlyAccess';
@@ -262,11 +265,22 @@ export class DownloadManager {
`;
}
const localStatus = existsLocally ?
`<div class="local-badge">
let localStatus = '';
if (existsLocally) {
localStatus = `<div class="local-badge">
<i class="fas fa-check"></i> ${translate('modals.download.inLibrary')}
<div class="local-path">${localPath || ''}</div>
</div>` : '';
</div>`;
} else if (hasBeenDownloaded) {
const downloadedTooltip = translate(
'modals.download.downloadedTooltip',
{},
'Previously downloaded, but it is not currently in your library.'
);
localStatus = `<div class="downloaded-badge" title="${downloadedTooltip.replace(/"/g, '&quot;')}">
<i class="fas fa-history"></i> ${translate('modals.download.downloaded', {}, 'Downloaded')}
</div>`;
}
return `
<div class="version-item ${this.currentVersion?.id === version.id ? 'selected' : ''}

View File

@@ -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
const moveModal = document.getElementById('moveModal');
if (moveModal) {
@@ -451,4 +463,4 @@ export class ModalManager {
}
// Create and export a singleton instance
export const modalManager = new ModalManager();
export const modalManager = new ModalManager();

View File

@@ -802,6 +802,11 @@ export class SettingsManager {
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings;
}
const civitaiHostSelect = document.getElementById('civitaiHost');
if (civitaiHostSelect) {
civitaiHostSelect.value = state.global.settings.civitai_host || 'civitai.com';
}
const recipesPathInput = document.getElementById('recipesPath');
if (recipesPathInput) {
recipesPathInput.value = state.global.settings.recipes_path || '';
@@ -1443,12 +1448,12 @@ export class SettingsManager {
// Add empty row for new path if no paths exist
if (paths.length === 0) {
this.addExtraFolderPathRow(modelType, '');
this.addExtraFolderPathRow(modelType, '', false);
}
});
}
addExtraFolderPathRow(modelType, path = '') {
addExtraFolderPathRow(modelType, path = '', shouldFocus = true) {
const container = document.getElementById(`extraFolderPaths-${modelType}`);
if (!container) return;
@@ -1472,7 +1477,7 @@ export class SettingsManager {
container.appendChild(row);
// Focus the input if it's empty (new row)
if (!path) {
if (!path && shouldFocus) {
const input = row.querySelector('.extra-folder-path-input');
if (input) {
setTimeout(() => input.focus(), 0);

View File

@@ -122,7 +122,7 @@ export class DownloadManager {
modalManager.closeModal('importModal');
// Refresh the recipe
window.recipeManager.loadRecipes();
window.recipeManager.loadRecipes({ preserveScroll: true });
} catch (error) {
console.error('Error:', error);

View File

@@ -328,16 +328,32 @@ class RecipeManager {
});
}
normalizeLoadRecipesOptions(options = true) {
if (typeof options === 'boolean') {
return {
resetPage: options,
preserveScroll: false
};
}
return {
resetPage: options?.resetPage !== false,
preserveScroll: options?.preserveScroll === true
};
}
// This method is kept for compatibility but now uses virtual scrolling
async loadRecipes(resetPage = true) {
async loadRecipes(options = true) {
// Skip loading if in duplicates mode
const pageState = getCurrentPageState();
if (pageState.duplicatesMode) {
return;
}
const { resetPage, preserveScroll } = this.normalizeLoadRecipesOptions(options);
if (resetPage) {
refreshVirtualScroll();
await refreshVirtualScroll({ preserveScroll });
}
}

View File

@@ -5,6 +5,7 @@ import { DEFAULT_PATH_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/co
const DEFAULT_SETTINGS_BASE = Object.freeze({
civitai_api_key: '',
civitai_host: 'civitai.com',
use_portable_settings: false,
language: 'en',
show_only_sfw: false,

View File

@@ -13,6 +13,64 @@ export const OptimizationMode = {
THUMBNAIL: 'thumbnail',
};
export const DEFAULT_CIVITAI_PAGE_HOST = 'civitai.com';
const SUPPORTED_CIVITAI_PAGE_HOSTS = new Set([
'civitai.com',
'civitai.red',
]);
export function normalizeCivitaiPageHost(hostname) {
if (!hostname || typeof hostname !== 'string') {
return DEFAULT_CIVITAI_PAGE_HOST;
}
const normalized = hostname.trim().toLowerCase();
if (SUPPORTED_CIVITAI_PAGE_HOSTS.has(normalized)) {
return normalized;
}
return DEFAULT_CIVITAI_PAGE_HOST;
}
export function buildCivitaiModelUrl(modelId, versionId = null, host = DEFAULT_CIVITAI_PAGE_HOST) {
const normalizedHost = normalizeCivitaiPageHost(host);
const normalizedModelId = modelId == null ? '' : String(modelId).trim();
const normalizedVersionId = versionId == null ? '' : String(versionId).trim();
if (normalizedModelId) {
const encodedModelId = encodeURIComponent(normalizedModelId);
let url = `https://${normalizedHost}/models/${encodedModelId}`;
if (normalizedVersionId) {
url += `?modelVersionId=${encodeURIComponent(normalizedVersionId)}`;
}
return url;
}
if (normalizedVersionId) {
return `https://${normalizedHost}/model-versions/${encodeURIComponent(normalizedVersionId)}`;
}
return null;
}
export function buildCivitaiSearchUrl(query, host = DEFAULT_CIVITAI_PAGE_HOST) {
const normalizedQuery = query == null ? '' : String(query).trim();
if (!normalizedQuery) {
return null;
}
const normalizedHost = normalizeCivitaiPageHost(host);
return `https://${normalizedHost}/models?query=${encodeURIComponent(normalizedQuery)}`;
}
export function buildCivitaiUrl({ modelId = null, versionId = null, modelName = null, host = DEFAULT_CIVITAI_PAGE_HOST } = {}) {
return (
buildCivitaiModelUrl(modelId, versionId, host)
|| buildCivitaiSearchUrl(modelName, host)
);
}
/**
* Rewrite Civitai preview URLs to use optimized renditions.
* Mirrors the backend's rewrite_preview_url() function from py/utils/civitai_utils.py
@@ -119,3 +177,50 @@ export function isCivitaiUrl(url) {
return false;
}
}
export function isSupportedCivitaiPageHost(hostname) {
if (!hostname) {
return false;
}
return SUPPORTED_CIVITAI_PAGE_HOSTS.has(hostname.toLowerCase());
}
export function extractCivitaiModelUrlParts(url) {
if (!url) {
return { modelId: null, modelVersionId: null };
}
try {
const parsedUrl = new URL(url);
if (!isSupportedCivitaiPageHost(parsedUrl.hostname)) {
return { modelId: null, modelVersionId: null };
}
const pathMatch = parsedUrl.pathname.match(/\/models\/(\d+)/);
const modelId = pathMatch ? pathMatch[1] : null;
const modelVersionId = parsedUrl.searchParams.get('modelVersionId');
return { modelId, modelVersionId };
} catch (e) {
return { modelId: null, modelVersionId: null };
}
}
export function extractCivitaiImageId(url) {
if (!url) {
return null;
}
try {
const parsedUrl = new URL(url);
if (!isSupportedCivitaiPageHost(parsedUrl.hostname)) {
return null;
}
const pathMatch = parsedUrl.pathname.match(/\/images\/(\d+)/);
return pathMatch ? pathMatch[1] : null;
} catch (e) {
return null;
}
}

View File

@@ -4,6 +4,43 @@ import { createModelCard, setupModelCardEventDelegation } from '../components/sh
import { getModelApiClient } from '../api/modelApiFactory.js';
import { showToast } from './uiHelpers.js';
function getScrollContainer() {
return document.querySelector('.page-content');
}
function getClampedScrollTop(scrollContainer, scrollTop) {
const maxScrollTop = Math.max(0, scrollContainer.scrollHeight - scrollContainer.clientHeight);
return Math.min(Math.max(scrollTop, 0), maxScrollTop);
}
function waitForAnimationFrame() {
return new Promise(resolve => requestAnimationFrame(resolve));
}
export function captureScrollPosition() {
const scrollContainer = getScrollContainer();
if (!scrollContainer) {
return null;
}
return {
scrollContainer,
scrollTop: scrollContainer.scrollTop
};
}
export async function restoreScrollPosition(snapshot) {
if (!snapshot?.scrollContainer) {
return;
}
// Wait for layout and the scheduled virtual-scroll render to settle.
await waitForAnimationFrame();
await waitForAnimationFrame();
snapshot.scrollContainer.scrollTop = getClampedScrollTop(snapshot.scrollContainer, snapshot.scrollTop);
}
// Function to dynamically import the appropriate card creator based on page type
async function getCardCreator(pageType) {
if (pageType === 'recipes') {
@@ -87,7 +124,7 @@ async function initializeVirtualScroll(pageType) {
}
// Change this line to get the actual scrolling container
const scrollContainer = document.querySelector('.page-content');
const scrollContainer = getScrollContainer();
const gridContainer = scrollContainer.querySelector('.container');
if (!gridContainer) {
@@ -200,9 +237,16 @@ export function cleanupKeyboardNavigation() {
}
// Export a method to refresh the virtual scroller when filters change
export function refreshVirtualScroll() {
export async function refreshVirtualScroll(options = {}) {
const { preserveScroll = false } = options;
if (state.virtualScroller) {
const scrollSnapshot = preserveScroll ? captureScrollPosition() : null;
state.virtualScroller.reset();
state.virtualScroller.initialize();
await state.virtualScroller.initialize();
if (scrollSnapshot) {
await restoreScrollPosition(scrollSnapshot);
}
}
}
}

View File

@@ -3,6 +3,66 @@ import { state, getCurrentPageState } from '../state/index.js';
import { getStorageItem, setStorageItem } from './storageHelpers.js';
import { NODE_TYPE_ICONS, DEFAULT_NODE_COLOR } from './constants.js';
import { eventManager } from './EventManager.js';
import { bannerService } from '../managers/BannerService.js';
import { modalManager } from '../managers/ModalManager.js';
import { buildCivitaiUrl, normalizeCivitaiPageHost } from './civitaiUtils.js';
const CIVITAI_HOST_INFO_BANNER_ID = 'civitai-host-preference';
const CIVITAI_HOST_INFO_BANNER_SEEN_KEY = 'civitai_host_info_banner_seen';
function getPreferredCivitaiHost() {
return normalizeCivitaiPageHost(state?.global?.settings?.civitai_host);
}
function maybeRegisterCivitaiHostInfoBanner() {
if (getStorageItem(CIVITAI_HOST_INFO_BANNER_SEEN_KEY, false)) {
return;
}
setStorageItem(CIVITAI_HOST_INFO_BANNER_SEEN_KEY, true);
bannerService.registerBanner(CIVITAI_HOST_INFO_BANNER_ID, {
id: CIVITAI_HOST_INFO_BANNER_ID,
title: translate(
'settings.civitaiHostBanner.title',
{},
'Civitai host preference available'
),
content: translate(
'settings.civitaiHostBanner.content',
{},
'Civitai now uses civitai.com for SFW content and civitai.red for unrestricted content. You can change which site opens by default in Settings.'
),
actions: [
{
text: translate('settings.civitaiHostBanner.openSettings', {}, 'Open Settings'),
icon: 'fas fa-cog',
action: 'open-settings-modal',
type: 'primary',
},
],
dismissible: true,
priority: 70,
onRegister: (bannerElement) => {
const button = bannerElement.querySelector('.banner-action[data-action="open-settings-modal"]');
if (button) {
button.addEventListener('click', (event) => {
event.preventDefault();
modalManager.showModal('settingsModal');
});
}
},
});
}
export function openCivitaiUrl(url) {
if (!url) {
return null;
}
maybeRegisterCivitaiHostInfoBanner();
return window.open(url, '_blank', 'noopener,noreferrer');
}
/**
* Utility function to copy text to clipboard with fallback for older browsers
@@ -184,14 +244,15 @@ function filterByFolder(folderPath) {
}
export function openCivitaiByMetadata(civitaiId, versionId, modelName = null) {
if (versionId) {
// Use model-versions endpoint which auto-redirects to correct model page
window.open(`https://civitai.com/model-versions/${versionId}`, '_blank');
} else if (civitaiId) {
window.open(`https://civitai.com/models/${civitaiId}`, '_blank');
} else if (modelName) {
// Fallback: search by name
window.open(`https://civitai.com/models?query=${encodeURIComponent(modelName)}`, '_blank');
const url = buildCivitaiUrl({
modelId: civitaiId,
versionId,
modelName,
host: getPreferredCivitaiHost(),
});
if (url) {
openCivitaiUrl(url);
}
}

View File

@@ -62,7 +62,7 @@
</head>
{% 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 -->
{% include 'components/header.html' %}

View File

@@ -84,6 +84,13 @@
</div>
<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">
<i class="fas fa-keyboard"></i>
<span class="tooltiptext">
@@ -117,4 +124,4 @@
<!-- Breadcrumbs will be populated by JavaScript -->
</nav>
</div>
</div>
</div>

View File

@@ -3,6 +3,7 @@
{% include 'components/modals/confirm_modals.html' %}
{% include 'components/modals/settings_modal.html' %}
{% include 'components/modals/doctor_modal.html' %}
{% include 'components/modals/support_modal.html' %}
{% include 'components/modals/update_modal.html' %}
{% include 'components/modals/help_modal.html' %}
@@ -11,4 +12,4 @@
{% include 'components/modals/download_modal.html' %}
{% include 'components/modals/move_modal.html' %}
{% include 'components/modals/bulk_add_tags_modal.html' %}
{% include 'components/modals/bulk_base_model_modal.html' %}
{% include 'components/modals/bulk_base_model_modal.html' %}

View File

@@ -0,0 +1,40 @@
<div id="doctorModal" class="modal">
<div class="modal-content doctor-modal">
<button class="close" onclick="modalManager.closeModal('doctorModal')">&times;</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>

View File

@@ -114,6 +114,21 @@
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="civitaiHost">{{ t('settings.civitaiHost.label') }}</label>
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.civitaiHost.help') }}"></i>
</div>
<div class="setting-control select-control">
<select id="civitaiHost" onchange="settingsManager.saveSelectSetting('civitaiHost', 'civitai_host')">
<option value="civitai.com">{{ t('settings.civitaiHost.options.com') }}</option>
<option value="civitai.red">{{ t('settings.civitaiHost.options.red') }}</option>
</select>
</div>
</div>
</div>
<!-- Backup -->
<div class="settings-subsection">
<div class="settings-subsection-header">

View File

@@ -120,6 +120,13 @@
</div>
<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">
<i class="fas fa-keyboard"></i>
<span class="tooltiptext">
@@ -189,4 +196,4 @@
{% block main_script %}
<script type="module" src="/loras_static/js/recipes.js?v={{ version }}"></script>
{% endblock %}
{% endblock %}

View File

@@ -4,7 +4,15 @@ const showToastMock = vi.hoisted(() => vi.fn());
const loadingManagerMock = vi.hoisted(() => ({
showSimpleLoading: vi.fn(),
hide: vi.fn(),
restoreProgressBar: vi.fn(),
}));
const virtualScrollerMock = vi.hoisted(() => ({
updateSingleItem: vi.fn(),
refreshWithData: vi.fn(),
}));
const getCurrentPageStateMock = vi.hoisted(() => vi.fn());
const captureScrollPositionMock = vi.hoisted(() => vi.fn());
const restoreScrollPositionMock = vi.hoisted(() => vi.fn());
vi.mock('../../../static/js/utils/uiHelpers.js', () => {
return {
@@ -20,17 +28,41 @@ vi.mock('../../../static/js/state/index.js', () => {
return {
state: {
loadingManager: loadingManagerMock,
virtualScroller: virtualScrollerMock,
},
getCurrentPageState: vi.fn(),
getCurrentPageState: getCurrentPageStateMock,
};
});
import { RecipeSidebarApiClient } from '../../../static/js/api/recipeApi.js';
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
captureScrollPosition: captureScrollPositionMock,
restoreScrollPosition: restoreScrollPositionMock,
}));
import {
RecipeSidebarApiClient,
fetchRecipeDetails,
resetAndReload,
syncChanges,
updateRecipeMetadata
} from '../../../static/js/api/recipeApi.js';
describe('RecipeSidebarApiClient bulk operations', () => {
beforeEach(() => {
vi.clearAllMocks();
global.fetch = vi.fn();
getCurrentPageStateMock.mockReturnValue({
pageSize: 50,
currentPage: 1,
hasMore: true,
isLoading: false,
sortBy: 'date:desc',
showFavoritesOnly: false,
activeFolder: null,
searchOptions: { recursive: true },
customFilter: { active: false },
filters: {},
});
});
afterEach(() => {
@@ -111,4 +143,77 @@ describe('RecipeSidebarApiClient bulk operations', () => {
});
expect(loadingManagerMock.hide).toHaveBeenCalled();
});
it('encodes recipe IDs when fetching recipe details', async () => {
global.fetch.mockResolvedValue({
ok: true,
json: async () => ({ id: 'abc' }),
});
await fetchRecipeDetails('recipe#1?name=foo%bar');
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe%231%3Fname%3Dfoo%25bar');
});
it('updates the virtual scroller using the original list path when provided', async () => {
global.fetch.mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
await updateRecipeMetadata(
'/recipes/new-folder/recipe#1.webp',
{ title: 'Updated Title' },
{ listFilePath: '/recipes/old-folder/recipe#1.webp' }
);
expect(global.fetch).toHaveBeenCalledWith(
'/api/lm/recipe/recipe%231/update',
expect.objectContaining({ method: 'PUT' })
);
expect(virtualScrollerMock.updateSingleItem).toHaveBeenCalledWith(
'/recipes/old-folder/recipe#1.webp',
{ title: 'Updated Title' }
);
});
it('preserves scroll position for recipe reloads when requested', async () => {
const scrollSnapshot = { scrollContainer: { scrollTop: 480 }, scrollTop: 480 };
captureScrollPositionMock.mockReturnValue(scrollSnapshot);
global.fetch.mockResolvedValue({
ok: true,
json: async () => ({
items: [{ id: 'recipe-1' }],
total: 1,
total_pages: 1,
}),
});
await resetAndReload(false, { preserveScroll: true });
expect(captureScrollPositionMock).toHaveBeenCalledTimes(1);
expect(virtualScrollerMock.refreshWithData).toHaveBeenCalledWith(
[{ id: 'recipe-1' }],
1,
false
);
expect(restoreScrollPositionMock).toHaveBeenCalledWith(scrollSnapshot);
});
it('uses scroll-preserving reloads for syncChanges', async () => {
global.fetch.mockResolvedValue({
ok: true,
json: async () => ({
items: [],
total: 0,
total_pages: 0,
}),
});
await syncChanges();
expect(captureScrollPositionMock).toHaveBeenCalledTimes(1);
expect(restoreScrollPositionMock).toHaveBeenCalledTimes(1);
expect(loadingManagerMock.restoreProgressBar).toHaveBeenCalledTimes(1);
});
});

View File

@@ -126,6 +126,31 @@ describe('AutoComplete widget interactions', () => {
expect(caretHelperInstance.getCursorOffset).toHaveBeenCalled();
});
it('deduplicates duplicate-equivalent query variations before issuing requests', async () => {
vi.useFakeTimers();
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, words: [] }),
});
caretHelperInstance.getBeforeCursor.mockReturnValue('Example');
const input = document.createElement('textarea');
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
new AutoComplete(input, 'prompt', { debounceDelay: 0, showPreview: false, minChars: 1 });
input.value = 'Example';
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
expect(fetchApiMock).toHaveBeenCalledTimes(1);
expect(fetchApiMock).toHaveBeenCalledWith('/lm/custom-words/search?enriched=true&search=Example&limit=100');
});
it('inserts the selected LoRA with usage tip strengths and restores focus', async () => {
fetchApiMock.mockImplementation((url) => {
if (url.includes('usage-tips-by-path')) {
@@ -244,6 +269,53 @@ describe('AutoComplete widget interactions', () => {
expect(inputListener).not.toHaveBeenCalled();
});
it('shows the full command list when typing a single slash', async () => {
const input = document.createElement('textarea');
input.value = '/';
input.selectionStart = input.value.length;
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'prompt', { showPreview: false, minChars: 1 });
input.dispatchEvent(new Event('input', { bubbles: true }));
const commandNames = autoComplete.items.map((item) => item.command);
expect(commandNames).toContain('/character');
expect(commandNames).toContain('/artist');
expect(commandNames).toContain('/general');
expect(commandNames).toContain('/copyright');
expect(commandNames).toContain('/meta');
expect(commandNames).toContain('/species');
expect(commandNames).toContain('/lore');
expect(commandNames).toContain('/emb');
expect(commandNames).toContain('/embedding');
expect(commandNames).toContain('/wildcard');
});
it('renders every command item when slash opens the command list', async () => {
const input = document.createElement('textarea');
input.value = '/';
input.selectionStart = input.value.length;
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'prompt', { showPreview: false, minChars: 1 });
input.dispatchEvent(new Event('input', { bubbles: true }));
const renderedCommands = autoComplete.contentContainer.querySelectorAll('.lm-autocomplete-command-name');
expect(renderedCommands).toHaveLength(autoComplete.items.length);
});
it('accepts the selected suggestion with Enter', async () => {
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
@@ -300,6 +372,66 @@ describe('AutoComplete widget interactions', () => {
expect(insertSelectionSpy).toHaveBeenCalledWith('loop');
});
it('preserves manual ArrowDown selection when Tab accepts a suggestion', async () => {
caretHelperInstance.getBeforeCursor.mockReturnValue('loop');
const input = document.createElement('textarea');
input.value = 'loop';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'prompt', { showPreview: false, minChars: 1 });
autoComplete.searchType = 'custom_words';
autoComplete.items = [
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1000 },
{ tag_name: 'loop', category: 0, post_count: 500 },
];
autoComplete.currentSearchTerm = 'loo';
autoComplete.selectedIndex = 0;
autoComplete.isVisible = true;
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
expect(autoComplete.selectedIndex).toBe(1);
expect(insertSelectionSpy).toHaveBeenCalledWith('loop');
});
it('preserves manual ArrowDown selection when Enter accepts a suggestion', async () => {
caretHelperInstance.getBeforeCursor.mockReturnValue('loop');
const input = document.createElement('textarea');
input.value = 'loop';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'prompt', { showPreview: false, minChars: 1 });
autoComplete.searchType = 'custom_words';
autoComplete.items = [
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1000 },
{ tag_name: 'loop', category: 0, post_count: 500 },
];
autoComplete.currentSearchTerm = 'loo';
autoComplete.selectedIndex = 0;
autoComplete.isVisible = true;
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
expect(autoComplete.selectedIndex).toBe(1);
expect(insertSelectionSpy).toHaveBeenCalledWith('loop');
});
it('accepts the first available suggestion with Tab even if delayed auto-selection has not happened yet', async () => {
caretHelperInstance.getBeforeCursor.mockReturnValue('loop');
@@ -743,12 +875,12 @@ describe('AutoComplete widget interactions', () => {
json: () => Promise.resolve({ success: true, words: mockTags }),
});
// Simulate "/char looking to the side" input
caretHelperInstance.getBeforeCursor.mockReturnValue('/char looking to the side');
// Simulate "/character looking to the side" input
caretHelperInstance.getBeforeCursor.mockReturnValue('/character looking to the side');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
input.value = '/char looking to the side';
input.value = '/character looking to the side';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
@@ -766,7 +898,7 @@ describe('AutoComplete widget interactions', () => {
autoComplete.activeCommand = { categories: [4, 11], label: 'Character' };
autoComplete.items = mockTags;
autoComplete.selectedIndex = 0;
autoComplete.currentSearchTerm = '/char looking to the side';
autoComplete.currentSearchTerm = '/character looking to the side';
await autoComplete.insertSelection('looking_to_the_side');
@@ -1073,6 +1205,253 @@ describe('AutoComplete widget interactions', () => {
expect(fetchApiMock).toHaveBeenCalledWith('/lm/custom-words/search?enriched=true&search=cat&limit=100');
});
it('searches wildcard keys when using the /wildcard command', async () => {
vi.useFakeTimers();
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({
success: true,
words: ['animals/cat'],
meta: {
has_wildcards: true,
wildcards_dir: '/tmp/settings/wildcards',
supported_formats: ['.txt', '.yaml', '.yml', '.json'],
},
}),
});
caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard cat');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
input.value = '/wildcard cat';
input.selectionStart = input.value.length;
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'prompt', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
fetchApiMock.mockClear();
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
expect(fetchApiMock).toHaveBeenCalledWith('/lm/wildcards/search?search=cat&limit=100');
expect(autoComplete.searchType).toBe('wildcards');
expect(autoComplete.items).toEqual(['animals/cat']);
});
it('shows wildcard onboarding when /wildcard is used before any files exist', async () => {
vi.useFakeTimers();
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({
success: true,
words: [],
meta: {
has_wildcards: false,
wildcards_dir: '/tmp/settings/wildcards',
supported_formats: ['.txt', '.yaml', '.yml', '.json'],
},
}),
});
caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard cat');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
input.value = '/wildcard cat';
input.selectionStart = input.value.length;
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'prompt', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
expect(autoComplete.isVisible).toBe(true);
expect(autoComplete.items).toHaveLength(1);
expect(autoComplete.items[0].type).toBe('wildcard_empty_state');
expect(autoComplete.dropdown.textContent).toContain('No wildcards found yet');
expect(autoComplete.dropdown.textContent).toContain('/tmp/settings/wildcards');
expect(autoComplete.dropdown.textContent).toContain('.txt, .yaml, .yml, .json');
});
it('shows wildcard onboarding when only the /wildcard command is entered', async () => {
vi.useFakeTimers();
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({
success: true,
words: [],
meta: {
has_wildcards: false,
wildcards_dir: '/tmp/settings/wildcards',
supported_formats: ['.txt', '.yaml', '.yml', '.json'],
},
}),
});
caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard ');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
input.value = '/wildcard ';
input.selectionStart = input.value.length;
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'prompt', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
expect(fetchApiMock).toHaveBeenCalledWith('/lm/wildcards/search?search=&limit=100');
expect(autoComplete.isVisible).toBe(true);
expect(autoComplete.items).toHaveLength(1);
expect(autoComplete.items[0].type).toBe('wildcard_empty_state');
expect(autoComplete.dropdown.textContent).toContain('No wildcards found yet');
});
it('shows a lightweight no-match state when wildcard files exist but search misses', async () => {
vi.useFakeTimers();
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({
success: true,
words: [],
meta: {
has_wildcards: true,
wildcards_dir: '/tmp/settings/wildcards',
supported_formats: ['.txt', '.yaml', '.yml', '.json'],
},
}),
});
caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard dragon');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
input.value = '/wildcard dragon';
input.selectionStart = input.value.length;
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'prompt', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
expect(autoComplete.items).toHaveLength(1);
expect(autoComplete.items[0].type).toBe('wildcard_no_matches');
expect(autoComplete.dropdown.textContent).toContain('No wildcard matches');
expect(autoComplete.dropdown.textContent).not.toContain('Open wildcards folder');
});
it('inserts wildcard references when accepting a /wildcard result', async () => {
caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard animals/cat');
const input = document.createElement('textarea');
input.value = '/wildcard animals/cat';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'prompt', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
autoComplete.searchType = 'wildcards';
autoComplete.activeCommand = { type: 'wildcard', label: 'Wildcards' };
autoComplete.items = ['animals/cat'];
autoComplete.selectedIndex = 0;
await autoComplete.insertSelection('animals/cat');
expect(input.value).toBe('__animals/cat__,');
expect(input.focus).toHaveBeenCalled();
expect(input.setSelectionRange).toHaveBeenCalled();
});
it('does not reopen autocomplete on blur after inserting a wildcard literal', async () => {
const input = document.createElement('textarea');
input.value = '__flower__,';
input.selectionStart = input.value.length;
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('__flower__,');
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'prompt', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
const hideSpy = vi.spyOn(autoComplete, 'hide');
input.dispatchEvent(new Event('blur', { bubbles: true }));
expect(fetchApiMock).not.toHaveBeenCalled();
expect(hideSpy).toHaveBeenCalled();
expect(autoComplete.isVisible).toBe(false);
});
it('treats a command after a wildcard literal as the active token', async () => {
vi.useFakeTimers();
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({
success: true,
words: [{ tag_name: 'flower_field', category: 4, post_count: 1234 }],
}),
});
const input = document.createElement('textarea');
input.value = '__flower__ /character f';
input.selectionStart = input.value.length;
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('__flower__ /character f');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'prompt', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
expect(autoComplete.getSearchTerm(input.value)).toBe('/character f');
});
it('invalidates stale autocomplete metadata and falls back to delimiter-based matching', async () => {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.autocomplete_append_comma') {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { ModelContextMenuMixin } from '../../../static/js/components/ContextMenu/ModelContextMenuMixin.js';
describe('ModelContextMenuMixin.extractModelVersionId', () => {
it('accepts civitai.red model URLs', () => {
expect(
ModelContextMenuMixin.extractModelVersionId(
'https://civitai.red/models/65423/nijimecha-artstyle?modelVersionId=777'
)
).toEqual({ modelId: '65423', modelVersionId: '777' });
});
it('rejects model-like URLs from unsupported hosts', () => {
expect(
ModelContextMenuMixin.extractModelVersionId(
'https://example.com/models/65423?modelVersionId=777'
)
).toEqual({ modelId: null, modelVersionId: null });
});
});

View File

@@ -26,6 +26,7 @@ vi.mock(DOWNLOAD_MANAGER_MODULE, () => ({
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: vi.fn(),
openCivitaiUrl: vi.fn(),
}));
const stateMock = {
@@ -349,4 +350,59 @@ describe('ModelVersionsTab media rendering', () => {
);
expect(firstBadges).toContain('Newer Version');
});
it('shows downloaded badge only for previously downloaded versions that are not in library', async () => {
fetchModelUpdateVersions.mockResolvedValue({
success: true,
record: {
shouldIgnore: false,
inLibraryVersionIds: [8],
versions: [
{
versionId: 9,
name: 'History only',
baseModel: 'SDXL',
previewUrl: '/api/lm/previews/v9.png',
sizeBytes: 1024,
isInLibrary: false,
hasBeenDownloaded: true,
shouldIgnore: false,
},
{
versionId: 8,
name: 'Local copy',
baseModel: 'SDXL',
previewUrl: '/api/lm/previews/v8.png',
sizeBytes: 2048,
isInLibrary: true,
hasBeenDownloaded: true,
shouldIgnore: false,
},
],
},
});
const { initVersionsTab } = await import(MODEL_VERSIONS_MODULE);
const controller = initVersionsTab({
modalId: 'model-versions-modal',
modelType: 'loras',
modelId: 654,
currentVersionId: 8,
});
await controller.load();
const rows = document.querySelectorAll('.model-version-row');
const historyBadges = Array.from(rows[0].querySelectorAll('.version-badge')).map(
badge => badge.textContent?.trim()
);
const localBadges = Array.from(rows[1].querySelectorAll('.version-badge')).map(
badge => badge.textContent?.trim()
);
expect(historyBadges).toContain('Downloaded');
expect(historyBadges).not.toContain('In Library');
expect(localBadges).toContain('In Library');
expect(localBadges).not.toContain('Downloaded');
});
});

View File

@@ -12,6 +12,7 @@ const apiClientMock = {
};
const showToastMock = vi.fn();
const openCivitaiByMetadataMock = vi.fn();
const updatePanelPositionsMock = vi.fn();
const downloadManagerMock = {
showDownloadModal: vi.fn(),
@@ -40,6 +41,7 @@ vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
openCivitaiByMetadata: openCivitaiByMetadataMock,
updatePanelPositions: updatePanelPositionsMock,
}));

View File

@@ -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', () => ({
bannerService: {
initialize: vi.fn(),
@@ -103,6 +109,7 @@ import { moveManager } from '../../../static/js/managers/MoveManager.js';
import { bulkManager } from '../../../static/js/managers/BulkManager.js';
import { ExampleImagesManager } from '../../../static/js/managers/ExampleImagesManager.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 { initTheme, initBackToTop } from '../../../static/js/utils/uiHelpers.js';
import { onboardingManager } from '../../../static/js/managers/OnboardingManager.js';
@@ -187,6 +194,7 @@ describe('AppCore initialization flow', () => {
delete window.helpManager;
delete window.moveManager;
delete window.bulkManager;
delete window.doctorManager;
delete window.headerManager;
delete window.i18n;
delete window.pageContextMenu;
@@ -214,6 +222,7 @@ describe('AppCore initialization flow', () => {
expect(bannerService.initialize).toHaveBeenCalledTimes(1);
expect(window.modalManager).toBe(modalManager);
expect(window.settingsManager).toBe(settingsManager);
expect(window.doctorManager).toBe(doctorManager);
expect(window.moveManager).toBe(moveManager);
expect(window.bulkManager).toBe(bulkManager);
expect(HeaderManager).toHaveBeenCalledTimes(1);
@@ -227,6 +236,7 @@ describe('AppCore initialization flow', () => {
expect(window.exampleImagesManager).toBe(exampleImagesManagerInstance);
expect(exampleImagesManagerInitialize).toHaveBeenCalledTimes(1);
expect(helpManager.initialize).toHaveBeenCalledTimes(1);
expect(doctorManager.initialize).toHaveBeenCalledTimes(1);
expect(document.body.classList.contains('hover-reveal')).toBe(true);
expect(initializeEventManagement).toHaveBeenCalledTimes(1);
expect(onboardingManager.start).not.toHaveBeenCalled();

View File

@@ -0,0 +1,78 @@
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();
});
it('builds a cache-busted reload URL that preserves the current location', () => {
renderDoctorFixture();
window.history.replaceState({}, '', '/loras?filter=active#details');
vi.spyOn(Date, 'now').mockReturnValue(1234567890);
const manager = new DoctorManager();
const url = manager.buildReloadUrl();
expect(url).toBe('http://localhost:3000/loras?filter=active&_lm_reload=1234567890#details');
});
it('delegates reload-page actions to reloadUi', async () => {
renderDoctorFixture();
const manager = new DoctorManager();
const reloadSpy = vi.spyOn(manager, 'reloadUi').mockImplementation(() => undefined);
await manager.handleAction('reload-page');
expect(reloadSpy).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,149 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const {
DOWNLOAD_MANAGER_MODULE,
MODAL_MANAGER_MODULE,
UI_HELPERS_MODULE,
STATE_MODULE,
LOADING_MANAGER_MODULE,
API_FACTORY_MODULE,
STORAGE_HELPERS_MODULE,
FOLDER_TREE_MANAGER_MODULE,
I18N_HELPERS_MODULE,
} = vi.hoisted(() => ({
DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname,
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
LOADING_MANAGER_MODULE: new URL('../../../static/js/managers/LoadingManager.js', import.meta.url).pathname,
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
FOLDER_TREE_MANAGER_MODULE: new URL('../../../static/js/components/FolderTreeManager.js', import.meta.url).pathname,
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
}));
vi.mock(MODAL_MANAGER_MODULE, () => ({
modalManager: {
showModal: vi.fn(),
closeModal: vi.fn(),
},
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: vi.fn(),
}));
vi.mock(STATE_MODULE, () => ({
state: {
global: {
settings: {},
},
},
}));
vi.mock(LOADING_MANAGER_MODULE, () => ({
LoadingManager: vi.fn(() => ({
showSimpleLoading: vi.fn(),
hide: vi.fn(),
restoreProgressBar: vi.fn(),
showDownloadProgress: vi.fn(() => vi.fn()),
setStatus: vi.fn(),
})),
}));
vi.mock(API_FACTORY_MODULE, () => ({
getModelApiClient: vi.fn(() => ({
apiConfig: {
config: {
displayName: 'LoRA',
singularName: 'lora',
},
},
})),
resetAndReload: vi.fn(),
}));
vi.mock(STORAGE_HELPERS_MODULE, () => ({
getStorageItem: vi.fn((_key, defaultValue) => defaultValue),
setStorageItem: vi.fn(),
}));
vi.mock(FOLDER_TREE_MANAGER_MODULE, () => ({
FolderTreeManager: vi.fn(() => ({
clearSelection: vi.fn(),
init: vi.fn(),
})),
}));
vi.mock(I18N_HELPERS_MODULE, () => ({
translate: vi.fn((_, __, fallback) => fallback ?? ''),
}));
describe('DownloadManager version history badges', () => {
let DownloadManager;
beforeEach(async () => {
vi.resetModules();
document.body.innerHTML = `
<div id="urlStep"></div>
<div id="versionStep"></div>
<div id="versionList"></div>
<button id="nextFromVersion"></button>
`;
({ DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE));
});
afterEach(() => {
document.body.innerHTML = '';
});
it('shows downloaded badge only for versions missing locally', () => {
const manager = new DownloadManager();
manager.versions = [
{
id: 101,
name: 'History only',
images: [],
files: [{ sizeKB: 2048 }],
createdAt: '2026-01-01T00:00:00Z',
existsLocally: false,
hasBeenDownloaded: true,
},
{
id: 102,
name: 'Still local',
images: [],
files: [{ sizeKB: 2048 }],
createdAt: '2026-01-01T00:00:00Z',
existsLocally: true,
hasBeenDownloaded: true,
localPath: '/models/still-local.safetensors',
},
];
manager.showVersionStep();
const items = document.querySelectorAll('.version-item');
expect(items).toHaveLength(2);
expect(items[0].querySelector('.downloaded-badge')?.textContent).toContain('Downloaded');
expect(items[0].querySelector('.downloaded-badge')?.getAttribute('title')).toContain(
'Previously downloaded, but it is not currently in your library.'
);
expect(items[0].querySelector('.local-badge')).toBeNull();
expect(items[1].querySelector('.local-badge')).not.toBeNull();
expect(items[1].querySelector('.local-path')?.textContent).toContain('/models/still-local.safetensors');
expect(items[1].querySelector('.downloaded-badge')).toBeNull();
});
it('extracts model and version ids from civitai.red URLs', () => {
const manager = new DownloadManager();
expect(
manager.extractModelId('https://civitai.red/models/65423/nijimecha-artstyle?modelVersionId=777')
).toBe('65423');
expect(manager.modelVersionId).toBe('777');
expect(manager.source).toBeNull();
});
});

View File

@@ -96,6 +96,7 @@ beforeEach(() => {
});
afterEach(() => {
vi.useRealTimers();
delete global.fetch;
delete document.hidden;
Object.defineProperty(window, 'location', {
@@ -231,6 +232,51 @@ describe('SettingsManager library controls', () => {
expect(input.value).toBe('/custom/recipes');
});
it('does not autofocus empty extra folder path rows during initial settings load', async () => {
vi.useFakeTimers();
const manager = createManager();
document.body.innerHTML = `
<div id="extraFolderPaths-loras"></div>
<div id="extraFolderPaths-checkpoints"></div>
<div id="extraFolderPaths-unet"></div>
<div id="extraFolderPaths-embeddings"></div>
`;
vi.spyOn(manager, 'loadMetadataArchiveSettings').mockResolvedValue();
vi.spyOn(manager, 'loadBackupSettings').mockResolvedValue();
vi.spyOn(manager, 'loadLibraries').mockResolvedValue();
vi.spyOn(manager, 'loadLoraRoots').mockResolvedValue();
vi.spyOn(manager, 'loadCheckpointRoots').mockResolvedValue();
vi.spyOn(manager, 'loadUnetRoots').mockResolvedValue();
vi.spyOn(manager, 'loadEmbeddingRoots').mockResolvedValue();
const focusSpy = vi.spyOn(HTMLElement.prototype, 'focus').mockImplementation(() => {});
state.global.settings = {
extra_folder_paths: {},
};
await manager.loadSettingsToUI();
await vi.runAllTimersAsync();
expect(focusSpy).not.toHaveBeenCalled();
});
it('still focuses an extra folder path row when it is added explicitly', async () => {
vi.useFakeTimers();
const manager = createManager();
document.body.innerHTML = '<div id="extraFolderPaths-embeddings"></div>';
const focusSpy = vi.spyOn(HTMLElement.prototype, 'focus').mockImplementation(() => {});
manager.addExtraFolderPathRow('embeddings', '');
await vi.runAllTimersAsync();
expect(focusSpy).toHaveBeenCalledTimes(1);
});
it('shows loading while saving recipes_path', async () => {
const manager = createManager();
const input = document.createElement('input');

View File

@@ -7,6 +7,8 @@ const getCurrentPageStateMock = vi.fn();
const getSessionItemMock = vi.fn();
const removeSessionItemMock = vi.fn();
const getStorageItemMock = vi.fn();
const setStorageItemMock = vi.fn();
const removeStorageItemMock = vi.fn();
const RecipeContextMenuMock = vi.fn();
const refreshVirtualScrollMock = vi.fn();
const refreshRecipesMock = vi.fn();
@@ -53,6 +55,8 @@ vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
getSessionItem: getSessionItemMock,
removeSessionItem: removeSessionItemMock,
getStorageItem: getStorageItemMock,
setStorageItem: setStorageItemMock,
removeStorageItem: removeStorageItemMock,
}));
vi.mock('../../../static/js/components/ContextMenu/index.js', () => ({
@@ -212,6 +216,19 @@ describe('RecipeManager', () => {
expect(refreshVirtualScrollMock).toHaveBeenCalledTimes(1);
});
it('supports preserve-scroll options while keeping boolean compatibility', async () => {
const manager = new RecipeManager();
await manager.loadRecipes({ preserveScroll: true });
expect(refreshVirtualScrollMock).toHaveBeenNthCalledWith(1, { preserveScroll: true });
await manager.loadRecipes(false);
expect(refreshVirtualScrollMock).toHaveBeenCalledTimes(1);
await manager.loadRecipes({ resetPage: true, preserveScroll: false });
expect(refreshVirtualScrollMock).toHaveBeenNthCalledWith(2, { preserveScroll: false });
});
it('proxies duplicate management and refresh helpers', async () => {
const manager = new RecipeManager();

View File

@@ -14,6 +14,7 @@ describe('state module', () => {
expect(defaultSettings).toMatchObject({
civitai_api_key: '',
civitai_host: 'civitai.com',
language: 'en',
blur_mature_content: true,
mature_blur_level: 'R'

View File

@@ -1,10 +1,18 @@
import { describe, it, expect } from 'vitest';
import {
DEFAULT_CIVITAI_PAGE_HOST,
normalizeCivitaiPageHost,
buildCivitaiModelUrl,
buildCivitaiSearchUrl,
buildCivitaiUrl,
rewriteCivitaiUrl,
getOptimizedUrl,
getShowcaseUrl,
getThumbnailUrl,
extractCivitaiImageId,
extractCivitaiModelUrlParts,
isCivitaiUrl,
isSupportedCivitaiPageHost,
OptimizationMode
} from '../../../static/js/utils/civitaiUtils.js';
@@ -16,6 +24,47 @@ describe('civitaiUtils', () => {
});
});
describe('Civitai page URL helpers', () => {
it('normalizes invalid hosts to the default page host', () => {
expect(DEFAULT_CIVITAI_PAGE_HOST).toBe('civitai.com');
expect(normalizeCivitaiPageHost('civitai.red')).toBe('civitai.red');
expect(normalizeCivitaiPageHost(' CIVITAI.COM ')).toBe('civitai.com');
expect(normalizeCivitaiPageHost('example.com')).toBe('civitai.com');
expect(normalizeCivitaiPageHost(null)).toBe('civitai.com');
});
it('builds model URLs using the configured host', () => {
expect(buildCivitaiModelUrl(123, 456, 'civitai.red')).toBe(
'https://civitai.red/models/123?modelVersionId=456'
);
expect(buildCivitaiModelUrl(123, null, 'civitai.com')).toBe(
'https://civitai.com/models/123'
);
});
it('falls back to the model-versions endpoint when only a version id is available', () => {
expect(buildCivitaiModelUrl(null, 456, 'civitai.red')).toBe(
'https://civitai.red/model-versions/456'
);
});
it('builds search URLs using the configured host', () => {
expect(buildCivitaiSearchUrl('demo model', 'civitai.red')).toBe(
'https://civitai.red/models?query=demo%20model'
);
expect(buildCivitaiSearchUrl('', 'civitai.red')).toBe(null);
});
it('prefers model/version URLs and falls back to search URLs', () => {
expect(buildCivitaiUrl({ modelId: 321, versionId: 654, host: 'civitai.red' })).toBe(
'https://civitai.red/models/321?modelVersionId=654'
);
expect(buildCivitaiUrl({ modelName: 'search me', host: 'civitai.red' })).toBe(
'https://civitai.red/models?query=search%20me'
);
});
});
describe('rewriteCivitaiUrl', () => {
it('should rewrite image URLs with /original=true for thumbnail mode', () => {
const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/original=true/12345.jpeg';
@@ -217,4 +266,43 @@ describe('civitaiUtils', () => {
expect(isCivitaiUrl('not-a-url')).toBe(false);
});
});
describe('isSupportedCivitaiPageHost', () => {
it('accepts civitai.com and civitai.red page hosts', () => {
expect(isSupportedCivitaiPageHost('civitai.com')).toBe(true);
expect(isSupportedCivitaiPageHost('civitai.red')).toBe(true);
});
it('rejects unrelated hosts', () => {
expect(isSupportedCivitaiPageHost('www.civitai.com')).toBe(false);
expect(isSupportedCivitaiPageHost('www.civitai.red')).toBe(false);
expect(isSupportedCivitaiPageHost('example.com')).toBe(false);
expect(isSupportedCivitaiPageHost('')).toBe(false);
expect(isSupportedCivitaiPageHost(null)).toBe(false);
});
});
describe('extractCivitaiModelUrlParts', () => {
it('extracts model and version ids from civitai.red model URLs', () => {
expect(
extractCivitaiModelUrlParts('https://civitai.red/models/65423/name?modelVersionId=98765')
).toEqual({ modelId: '65423', modelVersionId: '98765' });
});
it('rejects model-like URLs from unsupported hosts', () => {
expect(
extractCivitaiModelUrlParts('https://example.com/models/65423?modelVersionId=98765')
).toEqual({ modelId: null, modelVersionId: null });
});
});
describe('extractCivitaiImageId', () => {
it('extracts image ids from civitai.red image URLs', () => {
expect(extractCivitaiImageId('https://civitai.red/images/126920345')).toBe('126920345');
});
it('rejects image-like URLs from unsupported hosts', () => {
expect(extractCivitaiImageId('https://example.com/images/126920345')).toBe(null);
});
});
});

View File

@@ -6,6 +6,8 @@ const {
STORAGE_MODULE,
CONSTANTS_MODULE,
EVENT_MANAGER_MODULE,
BANNER_SERVICE_MODULE,
MODAL_MANAGER_MODULE,
UI_HELPERS_MODULE,
} = vi.hoisted(() => ({
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
@@ -13,12 +15,16 @@ const {
STORAGE_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
CONSTANTS_MODULE: new URL('../../../static/js/utils/constants.js', import.meta.url).pathname,
EVENT_MANAGER_MODULE: new URL('../../../static/js/utils/EventManager.js', import.meta.url).pathname,
BANNER_SERVICE_MODULE: new URL('../../../static/js/managers/BannerService.js', import.meta.url).pathname,
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
}));
const translateMock = vi.fn((key, _params, fallback) => fallback || key);
const getStorageItemMock = vi.fn();
const setStorageItemMock = vi.fn();
const registerBannerMock = vi.fn();
const showModalMock = vi.fn();
vi.mock(I18N_MODULE, () => ({
translate: translateMock,
@@ -50,6 +56,18 @@ vi.mock(EVENT_MANAGER_MODULE, () => ({
},
}));
vi.mock(BANNER_SERVICE_MODULE, () => ({
bannerService: {
registerBanner: registerBannerMock,
},
}));
vi.mock(MODAL_MANAGER_MODULE, () => ({
modalManager: {
showModal: showModalMock,
},
}));
describe('UI helper DOM utilities', () => {
beforeEach(() => {
document.body.innerHTML = '';
@@ -57,6 +75,8 @@ describe('UI helper DOM utilities', () => {
document.documentElement.removeAttribute('data-theme');
getStorageItemMock.mockReset();
setStorageItemMock.mockReset();
registerBannerMock.mockReset();
showModalMock.mockReset();
translateMock.mockReset();
globalThis.requestAnimationFrame = (cb) => cb();
});
@@ -156,4 +176,58 @@ describe('UI helper DOM utilities', () => {
'#2 (Character Subgraph) Nested Loader',
]);
});
it('opens Civitai links using the preferred host and registers the first-use banner once', async () => {
const openSpy = vi.fn();
globalThis.window.open = openSpy;
getStorageItemMock.mockImplementation((key, defaultValue) => {
if (key === 'civitai_host_info_banner_seen') {
return false;
}
return defaultValue;
});
const { openCivitaiByMetadata } = await import(UI_HELPERS_MODULE);
openCivitaiByMetadata(123, 456, 'Demo Model');
expect(setStorageItemMock).toHaveBeenCalledWith('civitai_host_info_banner_seen', true);
expect(registerBannerMock).toHaveBeenCalledTimes(1);
expect(openSpy).toHaveBeenCalledWith(
'https://civitai.com/models/123?modelVersionId=456',
'_blank',
'noopener,noreferrer'
);
});
it('uses the configured red host for fallback searches', async () => {
const openSpy = vi.fn();
globalThis.window.open = openSpy;
getStorageItemMock.mockImplementation((key, defaultValue) => {
if (key === 'civitai_host_info_banner_seen') {
return true;
}
return defaultValue;
});
const stateModule = await import(STATE_MODULE);
stateModule.state.global = {
settings: {
civitai_host: 'civitai.red',
},
};
const { openCivitaiByMetadata } = await import(UI_HELPERS_MODULE);
openCivitaiByMetadata(null, null, 'Demo Model');
expect(registerBannerMock).not.toHaveBeenCalled();
expect(openSpy).toHaveBeenCalledWith(
'https://civitai.red/models?query=Demo%20Model',
'_blank',
'noopener,noreferrer'
);
});
});

View File

@@ -0,0 +1,84 @@
from __future__ import annotations
from py.nodes.prompt import PromptLM
from py.nodes.text import TextLM
def test_text_lm_expands_wildcards_before_output(monkeypatch):
node = TextLM()
expand_calls = []
class StubService:
def expand_text(self, text, seed=None):
expand_calls.append((text, seed))
return "expanded text"
monkeypatch.setattr("py.nodes.text.get_wildcard_service", lambda: StubService())
assert node.process("__flower__", seed=9) == ("expanded text",)
assert expand_calls == [("__flower__", 9)]
def test_prompt_lm_expands_before_appending_trigger_words(monkeypatch):
node = PromptLM()
class StubService:
def expand_text(self, text, seed=None):
assert text == "__flower__"
assert seed == 42
return "rose"
class StubEncoder:
def encode(self, clip, prompt):
assert clip == "clip"
assert prompt == "artist style, rose"
return ("conditioning",)
monkeypatch.setattr("py.nodes.prompt.get_wildcard_service", lambda: StubService())
monkeypatch.setattr("nodes.CLIPTextEncode", lambda: StubEncoder(), raising=False)
result = node.encode("__flower__", "clip", seed=42, trigger_words1="artist style")
assert result == ("conditioning", "artist style, rose")
def test_prompt_lm_input_types_expose_input_only_seed():
input_types = PromptLM.INPUT_TYPES()
seed_type, seed_options = input_types["optional"]["seed"]
assert seed_type == "INT"
assert seed_options["forceInput"] is True
assert "wildcard generation" in seed_options["tooltip"]
def test_text_lm_input_types_expose_input_only_seed():
input_types = TextLM.INPUT_TYPES()
seed_type, seed_options = input_types["optional"]["seed"]
assert seed_type == "INT"
assert seed_options["forceInput"] is True
assert "wildcard generation" in seed_options["tooltip"]
def test_text_lm_is_changed_forces_rerun_without_seed_when_text_is_dynamic():
result = TextLM.IS_CHANGED("__flower__", seed=None)
assert result != result
def test_text_lm_is_changed_keeps_cache_for_seeded_or_static_text():
assert TextLM.IS_CHANGED("__flower__", seed=7) is False
assert TextLM.IS_CHANGED("plain text", seed=None) is False
assert TextLM.IS_CHANGED("{red|blue}", seed=7) is False
def test_prompt_lm_is_changed_forces_rerun_without_seed_when_text_is_dynamic():
result = PromptLM.IS_CHANGED("{red|blue}", clip="clip", seed=None)
assert result != result
def test_prompt_lm_is_changed_keeps_cache_for_seeded_or_static_text():
assert PromptLM.IS_CHANGED("__flower__", clip="clip", seed=11) is False
assert PromptLM.IS_CHANGED("plain text", clip="clip", seed=None) is False

View File

@@ -1,6 +1,9 @@
import io
import json
import logging
import os
import subprocess
import zipfile
from types import SimpleNamespace
from unittest.mock import patch, MagicMock
@@ -9,6 +12,7 @@ from aiohttp import web
from py.routes.handlers.misc_handlers import (
BackupHandler,
DoctorHandler,
FileSystemHandler,
LoraCodeHandler,
ModelLibraryHandler,
@@ -16,10 +20,15 @@ from py.routes.handlers.misc_handlers import (
NodeRegistryHandler,
ServiceRegistryAdapter,
SettingsHandler,
_collect_comfyui_session_logs,
_is_wsl,
_wsl_to_windows_path,
_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_routes import MiscRoutes
@@ -37,6 +46,7 @@ class FakeRequest:
class DummySettings:
def __init__(self, data=None):
self.data = data or {}
self.settings = self.data
def get(self, key, default=None):
return self.data.get(key, default)
@@ -67,6 +77,31 @@ async def dummy_downloader_factory():
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.red/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
async def test_get_settings_excludes_no_sync_keys():
"""Verify that settings in _NO_SYNC_KEYS are not synced, but others are."""
@@ -113,6 +148,257 @@ async def test_update_settings_rejects_missing_example_path(tmp_path):
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 = []
@@ -213,6 +499,38 @@ async def test_open_backup_location_uses_settings_directory(tmp_path, monkeypatc
assert calls == [["xdg-open", str(backup_dir)]]
@pytest.mark.asyncio
async def test_open_wildcards_location_creates_and_opens_directory(tmp_path, monkeypatch):
wildcards_dir = tmp_path / "settings" / "wildcards"
handler = FileSystemHandler(settings_service=SimpleNamespace(settings_file=str(tmp_path / "settings.json")))
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)
monkeypatch.setattr(
"py.services.wildcard_service.get_wildcards_dir",
lambda create=False: str(wildcards_dir.mkdir(parents=True, exist_ok=True) or wildcards_dir)
if create
else str(wildcards_dir),
)
response = await handler.open_wildcards_location(FakeRequest())
payload = json.loads(response.text)
assert response.status == 200
assert payload["success"] is True
assert payload["path"] == str(wildcards_dir)
assert wildcards_dir.is_dir()
assert calls == [["xdg-open", str(wildcards_dir)]]
class RecordingRouter:
def __init__(self):
self.calls = []

View File

@@ -0,0 +1,66 @@
from __future__ import annotations
from types import SimpleNamespace
import jinja2
from py.routes.handlers.model_handlers import ModelPageView
class DummySettings:
def get(self, key, default=None):
return default
class DummyI18n:
def __init__(self):
self.locale = None
def set_locale(self, locale):
self.locale = locale
def get_translation(self, key, default=None, **_kwargs):
return default or key
def create_template_filter(self):
return lambda key, *_args, **_kwargs: key
class DummyScanner:
def __init__(self):
self._cache = SimpleNamespace()
async def get_cached_data(self, *_args, **_kwargs):
return SimpleNamespace(folders=[])
class DummyService:
def __init__(self):
self.scanner = DummyScanner()
async def test_model_page_view_reads_version_per_request():
template_env = jinja2.Environment(
loader=jinja2.DictLoader({"dummy.html": "{{ version }}"}),
autoescape=True,
)
view = ModelPageView(
template_env=template_env,
template_name="dummy.html",
service=DummyService(),
settings_service=DummySettings(),
server_i18n=DummyI18n(),
logger=SimpleNamespace(
debug=lambda *_args, **_kwargs: None,
error=lambda *_args, **_kwargs: None,
),
)
view._get_app_version = lambda: "1.0.2-old"
first = await view.handle(SimpleNamespace())
view._get_app_version = lambda: "1.0.2-new"
second = await view.handle(SimpleNamespace())
assert first.text == "1.0.2-old"
assert second.text == "1.0.2-new"

View File

@@ -6,7 +6,8 @@ from types import SimpleNamespace
import pytest
from py.config import config
from py.routes.handlers.model_handlers import ModelUpdateHandler
from py.routes.handlers.model_handlers import ModelCivitaiHandler, ModelUpdateHandler
from py.services.service_registry import ServiceRegistry
from py.utils.metadata_manager import MetadataManager
from py.services.model_update_service import ModelUpdateRecord, ModelVersionRecord
@@ -91,7 +92,131 @@ async def test_build_version_context_includes_static_urls():
overrides = await handler._build_version_context(record)
expected = config.get_preview_static_url("/tmp/previews/example.png")
assert overrides == {123: {"file_path": None, "file_name": None, "preview_override": expected}}
assert overrides == {
123: {
"file_path": None,
"file_name": None,
"preview_override": expected,
"has_been_downloaded": False,
}
}
@pytest.mark.asyncio
async def test_build_version_context_includes_download_history(monkeypatch):
cache = SimpleNamespace(version_index={})
service = DummyService(cache)
handler = ModelUpdateHandler(
service=service,
update_service=SimpleNamespace(),
metadata_provider_selector=lambda *_: None,
settings_service=SimpleNamespace(get=lambda *_: False),
logger=logging.getLogger(__name__),
)
class DummyHistoryService:
async def get_downloaded_version_ids(self, model_type, model_id):
assert model_type == "lora"
assert model_id == 42
return [123]
async def fake_history_service_factory():
return DummyHistoryService()
monkeypatch.setattr(
ServiceRegistry,
"get_downloaded_version_history_service",
staticmethod(fake_history_service_factory),
)
record = ModelUpdateRecord(
model_type="lora",
model_id=42,
versions=[
ModelVersionRecord(
version_id=123,
name="Downloaded",
base_model=None,
released_at=None,
size_bytes=None,
preview_url=None,
is_in_library=False,
should_ignore=False,
),
ModelVersionRecord(
version_id=124,
name="Fresh",
base_model=None,
released_at=None,
size_bytes=None,
preview_url=None,
is_in_library=False,
should_ignore=False,
),
],
last_checked_at=None,
should_ignore_model=False,
)
overrides = await handler._build_version_context(record)
assert overrides[123]["has_been_downloaded"] is True
assert overrides[124]["has_been_downloaded"] is False
@pytest.mark.asyncio
async def test_get_civitai_versions_degrades_when_download_history_unavailable(monkeypatch):
cache = SimpleNamespace(version_index={})
service = DummyService(cache)
class DummyProvider:
async def get_model_versions(self, model_id):
assert model_id == "42"
return {
"type": "lora",
"modelVersions": [
{
"id": 7,
"name": "Version 7",
"files": [],
}
],
}
async def fake_history_service_factory():
raise RuntimeError("download history unavailable")
monkeypatch.setattr(
ServiceRegistry,
"get_downloaded_version_history_service",
staticmethod(fake_history_service_factory),
)
async def metadata_provider_factory():
return DummyProvider()
handler = ModelCivitaiHandler(
service=service,
settings_service=SimpleNamespace(get=lambda *_: False),
ws_manager=SimpleNamespace(),
logger=logging.getLogger(__name__),
metadata_provider_factory=metadata_provider_factory,
validate_model_type=lambda *_: True,
expected_model_types=lambda: "LoRA",
find_model_file=lambda *_: None,
metadata_sync=SimpleNamespace(),
metadata_refresh_use_case=SimpleNamespace(),
metadata_progress_callback=lambda *_args, **_kwargs: None,
)
response = await handler.get_civitai_versions(
SimpleNamespace(match_info={"model_id": "42"})
)
payload = json.loads(response.text)
assert response.status == 200
assert payload[0]["id"] == 7
assert payload[0]["existsLocally"] is False
assert payload[0]["hasBeenDownloaded"] is False
@pytest.mark.asyncio

View File

@@ -274,7 +274,9 @@ class StubCivitaiClient:
def __init__(self) -> None:
self.image_info: Dict[str, Any] = {}
async def get_image_info(self, image_id: str) -> Optional[Dict[str, Any]]:
async def get_image_info(
self, image_id: str, source_url: str | None = None
) -> Optional[Dict[str, Any]]:
return self.image_info.get(image_id)
@@ -635,6 +637,58 @@ async def test_import_remote_video_recipe(monkeypatch, tmp_path: Path) -> None:
assert call["extension"] == ".mp4"
async def test_import_remote_recipe_supports_civitai_red(monkeypatch, tmp_path: Path) -> None:
async def fake_get_default_metadata_provider():
return SimpleNamespace(get_model_version_info=lambda id: ({}, None))
monkeypatch.setattr(
"py.recipes.enrichment.get_default_metadata_provider",
fake_get_default_metadata_provider,
)
async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.civitai.image_info["126920345"] = {
"id": 126920345,
"url": "https://image.civitai.com/x/y/original=true/sample.jpeg",
"type": "image",
}
response = await harness.client.get(
"/api/lm/recipes/import-remote",
params={
"image_url": "https://civitai.red/images/126920345",
"name": "Red Recipe",
"resources": json.dumps([]),
"base_model": "Flux",
},
)
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
assert harness.downloader.urls
assert "width=450,optimized=true" in harness.downloader.urls[0]
async def test_analyze_remote_image_supports_civitai_red(
monkeypatch, tmp_path: Path
) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.analysis.result = SimpleNamespace(payload={"loras": []}, status=200)
response = await harness.client.post(
"/api/lm/recipes/analyze-image",
json={"url": "https://civitai.red/images/126920345"},
)
payload = await response.json()
assert response.status == 200
assert payload == {"loras": []}
assert harness.analysis.remote_calls == [
"https://civitai.red/images/126920345"
]
async def test_analyze_uploaded_image_error_path(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.analysis.raise_for_uploaded = RecipeValidationError(

View File

@@ -0,0 +1,69 @@
from __future__ import annotations
import json
from types import SimpleNamespace
import pytest
from py.routes.handlers.misc_handlers import WildcardsHandler
class FakeRequest:
def __init__(self, query=None):
self.query = query or {}
@pytest.mark.asyncio
async def test_search_wildcards_returns_results():
class StubService:
def get_metadata(self, create_dir=False):
assert create_dir is True
return SimpleNamespace(
has_wildcards=True,
wildcards_dir="/tmp/settings/wildcards",
supported_formats=(".txt", ".yaml", ".yml", ".json"),
)
def search_keys(self, search_term, limit, offset):
assert search_term == "cat"
assert limit == 25
assert offset == 2
return ["animals/cat"]
handler = WildcardsHandler(service=StubService())
response = await handler.search_wildcards(
FakeRequest(query={"search": "cat", "limit": "25", "offset": "2"})
)
payload = json.loads(response.text)
assert response.status == 200
assert payload == {
"success": True,
"words": ["animals/cat"],
"meta": {
"has_wildcards": True,
"wildcards_dir": "/tmp/settings/wildcards",
"supported_formats": [".txt", ".yaml", ".yml", ".json"],
},
}
@pytest.mark.asyncio
async def test_search_wildcards_handles_errors():
class StubService:
def get_metadata(self, create_dir=False):
return SimpleNamespace(
has_wildcards=False,
wildcards_dir="/tmp/settings/wildcards",
supported_formats=(".txt",),
)
def search_keys(self, search_term, limit, offset):
raise RuntimeError("boom")
handler = WildcardsHandler(service=StubService())
response = await handler.search_wildcards(FakeRequest(query={"search": "cat"}))
payload = json.loads(response.text)
assert response.status == 500
assert payload["error"] == "boom"

View File

@@ -886,3 +886,111 @@ async def test_format_response_defaults_update_flag_false(service_cls, extra_fie
assert "update_available" in formatted
assert formatted["update_available"] is False
@pytest.mark.asyncio
async def test_get_model_civitai_url_uses_default_host():
raw_data = [
{
"file_name": "demo.safetensors",
"civitai": {"modelId": 123, "id": 456},
}
]
class CacheStub:
def __init__(self, raw_data):
self.raw_data = raw_data
class ScannerStub:
def __init__(self, cache):
self._cache = cache
async def get_cached_data(self, *_, **__):
return self._cache
service = DummyService(
model_type="stub",
scanner=ScannerStub(CacheStub(raw_data)),
metadata_class=BaseModelMetadata,
settings_provider=StubSettings({}),
)
result = await service.get_model_civitai_url("demo.safetensors")
assert result == {
"civitai_url": "https://civitai.com/models/123?modelVersionId=456",
"model_id": "123",
"version_id": "456",
}
@pytest.mark.asyncio
async def test_get_model_civitai_url_uses_configured_host():
raw_data = [
{
"file_name": "demo.safetensors",
"civitai": {"modelId": 123, "id": 456},
}
]
class CacheStub:
def __init__(self, raw_data):
self.raw_data = raw_data
class ScannerStub:
def __init__(self, cache):
self._cache = cache
async def get_cached_data(self, *_, **__):
return self._cache
service = DummyService(
model_type="stub",
scanner=ScannerStub(CacheStub(raw_data)),
metadata_class=BaseModelMetadata,
settings_provider=StubSettings({"civitai_host": "civitai.red"}),
)
result = await service.get_model_civitai_url("demo.safetensors")
assert result == {
"civitai_url": "https://civitai.red/models/123?modelVersionId=456",
"model_id": "123",
"version_id": "456",
}
@pytest.mark.asyncio
async def test_get_model_civitai_url_falls_back_when_host_setting_is_not_a_string():
raw_data = [
{
"file_name": "demo.safetensors",
"civitai": {"modelId": 123, "id": 456},
}
]
class CacheStub:
def __init__(self, raw_data):
self.raw_data = raw_data
class ScannerStub:
def __init__(self, cache):
self._cache = cache
async def get_cached_data(self, *_, **__):
return self._cache
service = DummyService(
model_type="stub",
scanner=ScannerStub(CacheStub(raw_data)),
metadata_class=BaseModelMetadata,
settings_provider=StubSettings({"civitai_host": True}),
)
result = await service.get_model_civitai_url("demo.safetensors")
assert result == {
"civitai_url": "https://civitai.com/models/123?modelVersionId=456",
"model_id": "123",
"version_id": "456",
}

View File

@@ -581,6 +581,7 @@ class TestInputValidation:
assert service._validate_url("https://example.com/image.png") is True
assert service._validate_url("http://example.com/image.png") is True
assert service._validate_url("https://civitai.com/images/123") is True
assert service._validate_url("https://civitai.red/images/123") is True
def test_validate_invalid_url(self, service):
assert service._validate_url("not-a-url") is False

View File

@@ -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"
@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
async def test_calculate_all_pending_hashes(tmp_path: Path, monkeypatch):
"""Test bulk hash calculation for all pending checkpoints."""

View File

@@ -62,6 +62,12 @@ async def test_download_file_uses_downloader(tmp_path, downloader):
assert downloader.download_calls[0]["use_auth"] is True
async def test_client_defaults_to_red_api_host(downloader):
client = await CivitaiClient.get_instance()
assert client.base_url == "https://civitai.red/api/v1"
async def test_get_model_by_hash_enriches_metadata(monkeypatch, downloader):
version_payload = {
"modelId": 123,
@@ -530,6 +536,69 @@ async def test_get_image_info_handles_missing(monkeypatch, downloader):
assert result is None
async def test_get_image_info_prefers_red_host_for_red_source(monkeypatch, downloader):
requested_urls = []
async def fake_make_request(method, url, use_auth=True, **kwargs):
requested_urls.append(url)
return True, {"items": [{"id": 124950237, "name": "target"}]}
downloader.make_request = fake_make_request
client = await CivitaiClient.get_instance()
result = await client.get_image_info(
"124950237", source_url="https://civitai.red/images/124950237"
)
assert result == {"id": 124950237, "name": "target"}
assert requested_urls == [
"https://civitai.red/api/v1/images?imageId=124950237&nsfw=X"
]
async def test_get_image_info_uses_red_host_even_for_red_source(monkeypatch, downloader):
requested_urls = []
async def fake_make_request(method, url, use_auth=True, **kwargs):
requested_urls.append(url)
return True, {"items": [{"id": 124950237, "name": "target"}]}
downloader.make_request = fake_make_request
client = await CivitaiClient.get_instance()
result = await client.get_image_info(
"124950237", source_url="https://civitai.red/images/124950237"
)
assert result == {"id": 124950237, "name": "target"}
assert requested_urls == [
"https://civitai.red/api/v1/images?imageId=124950237&nsfw=X",
]
async def test_get_image_info_does_not_fall_back_after_request_failure(monkeypatch, downloader):
requested_urls = []
async def fake_make_request(method, url, use_auth=True, **kwargs):
requested_urls.append(url)
return False, "403 forbidden"
downloader.make_request = fake_make_request
client = await CivitaiClient.get_instance()
result = await client.get_image_info(
"124950237", source_url="https://civitai.red/images/124950237"
)
assert result is None
assert requested_urls == [
"https://civitai.red/api/v1/images?imageId=124950237&nsfw=X",
]
async def test_get_image_info_handles_invalid_id(monkeypatch, downloader, caplog):
"""When given a non-numeric image ID, return None and log error."""
client = await CivitaiClient.get_instance()

View File

@@ -7,7 +7,10 @@ from unittest.mock import AsyncMock
import pytest
from py.services.download_manager import DownloadManager
from py.services.download_manager import (
CIVITAI_DOWNLOAD_URL_PREFIXES,
DownloadManager,
)
from py.services import download_manager
from py.services.service_registry import ServiceRegistry
from py.services.settings_manager import SettingsManager, get_settings_manager
@@ -309,6 +312,67 @@ async def test_execute_download_respects_blur_setting(monkeypatch, tmp_path):
assert stored_preview and stored_preview.endswith(".jpeg")
@pytest.mark.asyncio
async def test_execute_download_uses_auth_for_red_civitai_downloads(monkeypatch, tmp_path):
manager = DownloadManager()
save_dir = tmp_path / "downloads"
save_dir.mkdir()
target_path = save_dir / "file.safetensors"
class DummyMetadata:
def __init__(self, path: Path):
self.file_path = str(path)
self.sha256 = "sha256"
self.file_name = path.stem
self.preview_url = None
self.preview_nsfw_level = None
def generate_unique_filename(self, *_args, **_kwargs):
return os.path.basename(self.file_path)
def update_file_info(self, _path):
return None
def to_dict(self):
return {"file_path": self.file_path}
metadata = DummyMetadata(target_path)
recorded_use_auth = []
class DummyDownloader:
stall_timeout = None
async def download_file(self, url, path, progress_callback=None, use_auth=None, **_kwargs):
recorded_use_auth.append((url, use_auth))
Path(path).write_bytes(b"model")
return True, None
monkeypatch.setattr(
download_manager, "get_downloader", AsyncMock(return_value=DummyDownloader())
)
monkeypatch.setattr(MetadataManager, "save_metadata", AsyncMock(return_value=True))
dummy_scanner = SimpleNamespace(add_model_to_cache=AsyncMock(return_value=None))
monkeypatch.setattr(
DownloadManager, "_get_lora_scanner", AsyncMock(return_value=dummy_scanner)
)
result = await manager._execute_download(
download_urls=["https://civitai.red/api/download/models/119514"],
save_dir=str(save_dir),
metadata=metadata,
version_info={"images": []},
relative_path="",
progress_callback=None,
model_type="lora",
download_id=None,
)
assert result == {"success": True}
assert recorded_use_auth == [("https://civitai.red/api/download/models/119514", True)]
assert "https://civitai.red/api/download/".startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
@pytest.mark.asyncio
async def test_civarchive_source_uses_civarchive_provider(
monkeypatch, scanners, tmp_path

View File

@@ -1,95 +1,86 @@
import pytest
from py.recipes.merger import GenParamsMerger
def test_merge_priority():
request_params = {"prompt": "from request", "steps": 20}
civitai_meta = {"prompt": "from civitai", "cfg": 7.0}
def test_merge_priority_and_normalization():
request_params = {"prompt": "from request", "Steps": 20, "cfg": 7.5}
civitai_meta = {"prompt": "from civitai", "cfgScale": 6.5, "negativePrompt": "bad"}
embedded_metadata = {"gen_params": {"prompt": "from embedded", "seed": 123}}
merged = GenParamsMerger.merge(request_params, civitai_meta, embedded_metadata)
assert merged["prompt"] == "from request"
assert merged["steps"] == 20
assert merged["cfg"] == 7.0
assert merged["seed"] == 123
def test_merge_no_request_params():
civitai_meta = {"prompt": "from civitai", "cfg": 7.0}
embedded_metadata = {"gen_params": {"prompt": "from embedded", "seed": 123}}
merged = GenParamsMerger.merge(None, civitai_meta, embedded_metadata)
assert merged["prompt"] == "from civitai"
assert merged["cfg"] == 7.0
assert merged["seed"] == 123
assert merged == {
"prompt": "from request",
"steps": 20,
"cfg_scale": 7.5,
"negative_prompt": "bad",
"seed": 123,
}
def test_merge_accepts_raw_embedded_metadata():
embedded_metadata = {"prompt": "from raw embedded", "seed": 456, "scheduler": "karras"}
def test_merge_only_embedded():
embedded_metadata = {"gen_params": {"prompt": "from embedded", "seed": 123}}
merged = GenParamsMerger.merge(None, None, embedded_metadata)
assert merged["prompt"] == "from embedded"
assert merged["seed"] == 123
def test_merge_raw_embedded():
# Test when embedded metadata is just the gen_params themselves
embedded_metadata = {"prompt": "from raw embedded", "seed": 456}
merged = GenParamsMerger.merge(None, None, embedded_metadata)
assert merged["prompt"] == "from raw embedded"
assert merged["seed"] == 456
assert merged == {
"prompt": "from raw embedded",
"seed": 456,
"sampler": "karras",
}
def test_merge_none_values():
merged = GenParamsMerger.merge(None, None, None)
assert merged == {}
def test_merge_filters_blacklisted_keys():
request_params = {"prompt": "test", "id": "should-be-removed", "checkpoint": "should-not-be-here"}
civitai_meta = {"cfg": 7, "url": "remove-me"}
embedded_metadata = {"seed": 123, "hash": "remove-also"}
merged = GenParamsMerger.merge(request_params, civitai_meta, embedded_metadata)
assert "prompt" in merged
assert "cfg" in merged
assert "seed" in merged
assert "id" not in merged
assert "url" not in merged
assert "hash" not in merged
assert "checkpoint" not in merged
def test_merge_filters_meta_and_normalizes_keys():
def test_merge_filters_unknown_and_blacklisted_keys():
request_params = {
"prompt": "test",
"id": "should-be-removed",
"checkpoint": "should-not-be-here",
"raw_metadata": {"prompt": "remove"},
}
civitai_meta = {
"Version": "ComfyUI",
"RNG": "cpu",
"cfgScale": 7,
"url": "remove-me",
}
embedded_metadata = {
"seed": 123,
"hash": "remove-also",
"Discard penultimate sigma": True,
"eps_scaling_factor": 0.1,
}
merged = GenParamsMerger.merge(request_params, civitai_meta, embedded_metadata)
assert merged == {
"prompt": "test",
"cfg_scale": 7,
"seed": 123,
}
def test_merge_does_not_keep_original_key_variants():
civitai_meta = {
"prompt": "masterpiece",
"cfgScale": 5,
"clipSkip": 2,
"negativePrompt": "low quality",
"meta": {"irrelevant": "data"},
"Size": "1024x1024",
"draft": False,
"workflow": "txt2img",
"civitaiResources": [{"type": "checkpoint"}]
"Denoising strength": 0.35,
}
request_params = {
"cfg_scale": 5.0,
"clip_skip": "2",
"Steps": 30
}
merged = GenParamsMerger.merge(request_params, civitai_meta)
assert "meta" not in merged
assert "cfgScale" not in merged
assert "clipSkip" not in merged
assert "negativePrompt" not in merged
assert "Size" not in merged
assert "draft" not in merged
assert "workflow" not in merged
assert "civitaiResources" not in merged
assert merged["cfg_scale"] == 5.0 # From request_params
assert merged["clip_skip"] == "2" # From request_params
assert merged["negative_prompt"] == "low quality" # Normalized from civitai_meta
assert merged["size"] == "1024x1024" # Normalized from civitai_meta
assert merged["steps"] == 30 # Normalized from request_params
assert merged == {
"cfg_scale": 5.0,
"clip_skip": "2",
"negative_prompt": "low quality",
"size": "1024x1024",
"denoising_strength": 0.35,
}
def test_merge_none_values():
assert GenParamsMerger.merge(None, None, None) == {}

View File

@@ -6,7 +6,7 @@ from types import SimpleNamespace
# We define these here to help with spec= if needed
class MockCivitaiClient:
async def get_image_info(self, image_id):
async def get_image_info(self, image_id, source_url=None):
pass
class MockPersistenceService:
@@ -119,6 +119,50 @@ async def test_repair_all_recipes_with_enriched_checkpoint_id(setup_scanner):
assert "hash" not in checkpoint
assert "file_name" not in checkpoint
@pytest.mark.asyncio
async def test_repair_all_recipes_supports_civitai_red_source_url(setup_scanner):
recipe_scanner, mock_civitai_client, mock_metadata_provider = setup_scanner
recipe = {
"id": "r1",
"title": "Red Recipe",
"source_url": "https://civitai.red/images/12345",
"checkpoint": None,
"gen_params": {"prompt": ""},
}
recipe_scanner._cache = SimpleNamespace(raw_data=[recipe])
mock_civitai_client.get_image_info.return_value = {
"modelVersionId": 5678,
"meta": {"prompt": "from red"},
}
mock_metadata_provider.get_model_version_info.return_value = (
{
"id": 5678,
"modelId": 1234,
"name": "v1.0",
"model": {"name": "Full Model Name"},
"baseModel": "SDXL 1.0",
"images": [{"url": "https://image.url/thumb.jpg"}],
"files": [
{
"type": "Model",
"hashes": {"SHA256": "ABCDEF"},
"name": "full_filename.safetensors",
}
],
},
None,
)
results = await recipe_scanner.repair_all_recipes()
assert results["repaired"] == 1
mock_civitai_client.get_image_info.assert_called_with(
"12345", source_url="https://civitai.red/images/12345"
)
@pytest.mark.asyncio
async def test_repair_all_recipes_with_enriched_checkpoint_hash(setup_scanner):
recipe_scanner, mock_civitai_client, mock_metadata_provider = setup_scanner

View File

@@ -358,6 +358,188 @@ async def test_get_recipe_by_id_handles_non_dict_checkpoint(recipe_scanner):
assert recipe["checkpoint"]["file_name"] == "by-id"
@pytest.mark.asyncio
async def test_get_recipe_by_id_merges_recipe_json_details(recipe_scanner):
scanner, _ = recipe_scanner
recipes_dir = Path(scanner.recipes_dir)
recipe_id = "hydrate-me"
recipe_json_path = recipes_dir / f"{recipe_id}.recipe.json"
recipe_json_path.write_text(
json.dumps(
{
"id": recipe_id,
"file_path": "/tmp/hydrate-me.png",
"title": "Hydrated Recipe",
"source_path": "https://example.com/source",
"gen_params": {
"prompt": "prompt from json",
"negative_prompt": "negative from json",
},
"loras": [],
}
),
encoding="utf-8",
)
scanner._cache.raw_data = [
{
"id": recipe_id,
"file_path": "/tmp/hydrate-me.png",
"title": "Cached Recipe",
"folder": "",
"modified": 0.0,
"created_date": 0.0,
"loras": [],
"gen_params": {},
}
]
recipe = await scanner.get_recipe_by_id(recipe_id)
assert recipe is not None
assert recipe["title"] == "Hydrated Recipe"
assert recipe["source_path"] == "https://example.com/source"
assert recipe["gen_params"]["prompt"] == "prompt from json"
@pytest.mark.asyncio
async def test_get_recipe_by_id_normalizes_gen_params_aliases_without_dropping_metadata(
recipe_scanner,
):
scanner, _ = recipe_scanner
recipes_dir = Path(scanner.recipes_dir)
recipe_id = "dirty-json-gen-params"
recipe_json_path = recipes_dir / f"{recipe_id}.recipe.json"
recipe_json_path.write_text(
json.dumps(
{
"id": recipe_id,
"file_path": "/tmp/dirty-json-gen-params.png",
"title": "Dirty Recipe",
"gen_params": {
"Prompt": "prompt from json",
"negativePrompt": "negative from json",
"cfgScale": 7,
"raw_metadata": {"prompt": "nested"},
"Version": "ComfyUI",
"RNG": "cpu",
},
"loras": [],
}
),
encoding="utf-8",
)
scanner._cache.raw_data = [
{
"id": recipe_id,
"file_path": "/tmp/dirty-json-gen-params.png",
"title": "Cached Recipe",
"folder": "",
"modified": 0.0,
"created_date": 0.0,
"loras": [],
"gen_params": {"prompt": "cached prompt", "raw_metadata": {"bad": True}},
}
]
recipe = await scanner.get_recipe_by_id(recipe_id)
assert recipe is not None
assert recipe["gen_params"]["Prompt"] == "prompt from json"
assert recipe["gen_params"]["negativePrompt"] == "negative from json"
assert recipe["gen_params"]["cfgScale"] == 7
assert recipe["gen_params"]["raw_metadata"] == {"prompt": "nested"}
assert recipe["gen_params"]["Version"] == "ComfyUI"
assert recipe["gen_params"]["RNG"] == "cpu"
assert recipe["gen_params"]["prompt"] == "prompt from json"
assert recipe["gen_params"]["negative_prompt"] == "negative from json"
assert recipe["gen_params"]["cfg_scale"] == 7
@pytest.mark.asyncio
async def test_get_recipe_by_id_prefers_json_file_path(recipe_scanner):
scanner, _ = recipe_scanner
recipes_dir = Path(scanner.recipes_dir)
recipe_id = "move-me"
recipe_json_path = recipes_dir / f"{recipe_id}.recipe.json"
recipe_json_path.write_text(
json.dumps(
{
"id": recipe_id,
"file_path": "/tmp/new-location.png",
"title": "Moved Recipe",
"source_path": "https://example.com/moved",
"gen_params": {},
"loras": [],
}
),
encoding="utf-8",
)
scanner._cache.raw_data = [
{
"id": recipe_id,
"file_path": "/tmp/old-location.png",
"title": "Cached Title",
"folder": "",
"modified": 0.0,
"created_date": 0.0,
"loras": [],
"gen_params": {},
}
]
recipe = await scanner.get_recipe_by_id(recipe_id)
assert recipe is not None
assert recipe["file_path"] == "/tmp/new-location.png"
assert recipe["title"] == "Moved Recipe"
assert recipe["source_path"] == "https://example.com/moved"
@pytest.mark.asyncio
async def test_get_recipe_by_id_drops_deleted_optional_json_fields(recipe_scanner):
scanner, _ = recipe_scanner
recipes_dir = Path(scanner.recipes_dir)
recipe_id = "drop-optional-fields"
recipe_json_path = recipes_dir / f"{recipe_id}.recipe.json"
recipe_json_path.write_text(
json.dumps(
{
"id": recipe_id,
"file_path": "/tmp/drop-optional-fields.png",
"title": "Trimmed Recipe",
}
),
encoding="utf-8",
)
scanner._cache.raw_data = [
{
"id": recipe_id,
"file_path": "/tmp/drop-optional-fields.png",
"title": "Cached Recipe",
"folder": "",
"modified": 0.0,
"created_date": 0.0,
"source_path": "https://example.com/stale-source",
"checkpoint": {"name": "stale-checkpoint.safetensors"},
"loras": [{"modelName": "stale-lora"}],
"gen_params": {"prompt": "stale prompt"},
}
]
recipe = await scanner.get_recipe_by_id(recipe_id)
assert recipe is not None
assert recipe["title"] == "Trimmed Recipe"
assert "source_path" not in recipe
assert "checkpoint" not in recipe
assert "gen_params" not in recipe
assert "loras" not in recipe
@pytest.mark.asyncio
async def test_get_paginated_data_filters_by_checkpoint_hash(recipe_scanner):
scanner, _ = recipe_scanner
@@ -401,6 +583,40 @@ async def test_get_paginated_data_filters_by_checkpoint_hash(recipe_scanner):
assert [item["id"] for item in result["items"]] == ["checkpoint-match"]
@pytest.mark.asyncio
async def test_get_paginated_data_normalizes_gen_params_aliases_without_dropping_metadata(
recipe_scanner,
):
scanner, _ = recipe_scanner
await scanner.add_recipe(
{
"id": "dirty-listing",
"file_path": str(Path(config.loras_roots[0]) / "dirty-listing.webp"),
"title": "Dirty Listing",
"modified": 0.0,
"created_date": 0.0,
"loras": [],
"gen_params": {
"Prompt": "a beautiful forest landscape",
"cfgScale": 7,
"Version": "ComfyUI",
"raw_metadata": {"bad": True},
},
}
)
await asyncio.sleep(0)
result = await scanner.get_paginated_data(page=1, page_size=10)
item = next(entry for entry in result["items"] if entry["id"] == "dirty-listing")
assert item["gen_params"]["Prompt"] == "a beautiful forest landscape"
assert item["gen_params"]["cfgScale"] == 7
assert item["gen_params"]["Version"] == "ComfyUI"
assert item["gen_params"]["raw_metadata"] == {"bad": True}
assert item["gen_params"]["prompt"] == "a beautiful forest landscape"
assert item["gen_params"]["cfg_scale"] == 7
@pytest.mark.asyncio
async def test_get_recipes_for_checkpoint_matches_hash_case_insensitively(recipe_scanner):
scanner, _ = recipe_scanner

View File

@@ -306,6 +306,120 @@ async def test_save_recipe_promotes_checkpoint_from_gen_params(tmp_path):
assert "checkpoint" not in stored["gen_params"]
@pytest.mark.asyncio
async def test_save_recipe_strips_non_persistable_gen_params(tmp_path):
exif_utils = DummyExifUtils()
class DummyScanner:
def __init__(self, root):
self.recipes_dir = str(root)
async def find_recipes_by_fingerprint(self, fingerprint):
return []
async def add_recipe(self, recipe_data):
return None
scanner = DummyScanner(tmp_path)
service = RecipePersistenceService(
exif_utils=exif_utils,
card_preview_width=512,
logger=logging.getLogger("test"),
)
metadata = {
"base_model": "Flux",
"loras": [],
"gen_params": {
"prompt": "hello world",
"negative_prompt": "bad hands",
"cfg_scale": 7,
"raw_metadata": {"prompt": "should not persist"},
"Version": "ComfyUI",
"RNG": "cpu",
"Schedule type": "karras",
"Discard penultimate sigma": True,
"eps_scaling_factor": 0.1,
},
}
result = await service.save_recipe(
recipe_scanner=scanner,
image_bytes=b"img",
image_base64=None,
name="Sanitized",
tags=[],
metadata=metadata,
)
stored = json.loads(Path(result.payload["json_path"]).read_text())
assert stored["gen_params"] == {
"prompt": "hello world",
"negative_prompt": "bad hands",
"cfg_scale": 7,
}
@pytest.mark.asyncio
async def test_save_recipe_derives_allowed_fields_from_raw_metadata(tmp_path):
exif_utils = DummyExifUtils()
class DummyScanner:
def __init__(self, root):
self.recipes_dir = str(root)
async def find_recipes_by_fingerprint(self, fingerprint):
return []
async def add_recipe(self, recipe_data):
return None
scanner = DummyScanner(tmp_path)
service = RecipePersistenceService(
exif_utils=exif_utils,
card_preview_width=512,
logger=logging.getLogger("test"),
)
metadata = {
"base_model": "Flux",
"loras": [],
"raw_metadata": {
"prompt": "hello world",
"negative_prompt": "bad hands",
"steps": 30,
"sampler": "Euler",
"cfg_scale": 7,
"seed": 123,
"size": "1024x1024",
"clip_skip": 2,
"Version": "ComfyUI",
"raw_metadata": {"nested": True},
},
}
result = await service.save_recipe(
recipe_scanner=scanner,
image_bytes=b"img",
image_base64=None,
name="Derived",
tags=[],
metadata=metadata,
)
stored = json.loads(Path(result.payload["json_path"]).read_text())
assert stored["gen_params"] == {
"prompt": "hello world",
"negative_prompt": "bad hands",
"steps": 30,
"sampler": "Euler",
"cfg_scale": 7,
"seed": 123,
"size": "1024x1024",
"clip_skip": 2,
}
@pytest.mark.asyncio
async def test_save_recipe_strips_checkpoint_local_fields(tmp_path):
exif_utils = DummyExifUtils()
@@ -564,7 +678,7 @@ async def test_analyze_remote_video(tmp_path):
)
class DummyClient:
async def get_image_info(self, image_id):
async def get_image_info(self, image_id, source_url=None):
return {
"url": "https://civitai.com/video.mp4",
"type": "video",
@@ -584,3 +698,60 @@ async def test_analyze_remote_video(tmp_path):
assert result.payload["is_video"] is True
assert result.payload["extension"] == ".mp4"
assert result.payload["image_base64"] is not None
@pytest.mark.asyncio
async def test_analyze_remote_image_supports_civitai_red():
exif_utils = DummyExifUtils()
class DummyFactory:
def create_parser(self, metadata):
async def parse_metadata(m, recipe_scanner=None, civitai_client=None):
return {"loras": [], "gen_params": {"prompt": "red prompt"}}
return SimpleNamespace(parse_metadata=parse_metadata)
async def downloader_factory():
class Downloader:
async def download_file(self, url, path, use_auth=False):
Path(path).write_bytes(b"fake-image")
return True, "success"
return Downloader()
service = RecipeAnalysisService(
exif_utils=exif_utils,
recipe_parser_factory=DummyFactory(),
downloader_factory=downloader_factory,
metadata_collector=None,
metadata_processor_cls=None,
metadata_registry_cls=None,
standalone_mode=False,
logger=logging.getLogger("test"),
)
class DummyClient:
def __init__(self):
self.calls = []
async def get_image_info(self, image_id, source_url=None):
self.calls.append((image_id, source_url))
return {
"url": "https://image.civitai.com/x/y/original=true/sample.jpeg",
"type": "image",
"meta": {"prompt": "red prompt"},
}
class DummyScanner:
async def find_recipes_by_fingerprint(self, fingerprint):
return []
client = DummyClient()
result = await service.analyze_remote_image(
url="https://civitai.red/images/123",
recipe_scanner=DummyScanner(),
civitai_client=client,
)
assert client.calls == [("123", "https://civitai.red/images/123")]
assert result.payload["loras"] == []

View File

@@ -0,0 +1,141 @@
from __future__ import annotations
import json
from py.services.wildcard_service import WildcardService, contains_dynamic_syntax
def _make_service(monkeypatch, tmp_path):
settings_dir = tmp_path / "settings"
settings_dir.mkdir()
monkeypatch.setattr(
"py.services.wildcard_service.get_settings_dir",
lambda create=True: str(settings_dir),
)
service = WildcardService()
service._cached_signature = None
service._wildcard_dict = {}
return service, settings_dir / "wildcards"
def test_search_keys_returns_empty_when_directory_missing(monkeypatch, tmp_path):
service, _wildcards_dir = _make_service(monkeypatch, tmp_path)
assert service.search_keys("cat") == []
def test_search_keys_loads_txt_yaml_and_json(monkeypatch, tmp_path):
service, wildcards_dir = _make_service(monkeypatch, tmp_path)
wildcards_dir.mkdir()
(wildcards_dir / "animals").mkdir()
(wildcards_dir / "animals" / "cat.txt").write_text("tabby\nblack cat\n", encoding="utf-8")
(wildcards_dir / "colors.yaml").write_text(
"palette:\n warm:\n - red\n - orange\n",
encoding="utf-8",
)
(wildcards_dir / "artists.json").write_text(
json.dumps({"illustrators/digital": ["alice", "bob"]}),
encoding="utf-8",
)
assert service.search_keys("cat") == ["animals/cat"]
assert service.search_keys("warm") == ["palette/warm"]
assert service.search_keys("digital") == ["illustrators/digital"]
def test_search_keys_prefers_exact_and_prefix_matches(monkeypatch, tmp_path):
service, wildcards_dir = _make_service(monkeypatch, tmp_path)
wildcards_dir.mkdir()
(wildcards_dir / "animals").mkdir()
(wildcards_dir / "animals" / "cat.txt").write_text("tabby\n", encoding="utf-8")
(wildcards_dir / "animals" / "catgirl.txt").write_text("heroine\n", encoding="utf-8")
(wildcards_dir / "fantasy_cat.txt").write_text("beast\n", encoding="utf-8")
results = service.search_keys("cat")
assert results == ["animals/cat", "animals/catgirl", "fantasy_cat"]
def test_search_keys_supports_offset_and_limit(monkeypatch, tmp_path):
service, wildcards_dir = _make_service(monkeypatch, tmp_path)
wildcards_dir.mkdir()
for name in ("cat", "catgirl", "catmaid"):
(wildcards_dir / f"{name}.txt").write_text("x\n", encoding="utf-8")
assert service.search_keys("cat", limit=1, offset=1) == ["catgirl"]
def test_get_metadata_creates_directory_and_reports_formats(monkeypatch, tmp_path):
service, wildcards_dir = _make_service(monkeypatch, tmp_path)
metadata = service.get_metadata(create_dir=True)
assert metadata.has_wildcards is False
assert metadata.wildcards_dir == str(wildcards_dir)
assert metadata.supported_formats == (".txt", ".yaml", ".yml", ".json")
assert wildcards_dir.is_dir()
def test_expand_text_resolves_nested_wildcards(monkeypatch, tmp_path):
service, wildcards_dir = _make_service(monkeypatch, tmp_path)
wildcards_dir.mkdir()
(wildcards_dir / "flower.txt").write_text("rose\n__color__ lily\n", encoding="utf-8")
(wildcards_dir / "color.txt").write_text("red\nblue\n", encoding="utf-8")
expanded = service.expand_text("__flower__", seed=7)
assert expanded in {"rose", "red lily", "blue lily"}
assert "__" not in expanded
def test_expand_text_resolves_dynamic_prompt_and_multi_select(monkeypatch, tmp_path):
service, wildcards_dir = _make_service(monkeypatch, tmp_path)
wildcards_dir.mkdir()
expanded = service.expand_text("{2$$, $$red|blue|green}", seed=3)
assert expanded.count(", ") == 1
assert set(expanded.split(", ")).issubset({"red", "blue", "green"})
def test_expand_text_resolves_wildcard_glob(monkeypatch, tmp_path):
service, wildcards_dir = _make_service(monkeypatch, tmp_path)
wildcards_dir.mkdir()
(wildcards_dir / "animals").mkdir()
(wildcards_dir / "animals" / "cat.txt").write_text("tabby\n", encoding="utf-8")
(wildcards_dir / "animals" / "dog.txt").write_text("retriever\n", encoding="utf-8")
expanded = service.expand_text("__animals/*__", seed=1)
assert expanded in {"tabby", "retriever"}
def test_expand_text_is_deterministic_with_seed(monkeypatch, tmp_path):
service, wildcards_dir = _make_service(monkeypatch, tmp_path)
wildcards_dir.mkdir()
(wildcards_dir / "color.txt").write_text("red\nblue\ngreen\n", encoding="utf-8")
first = service.expand_text("__color__", seed=123)
second = service.expand_text("__color__", seed=123)
assert first == second
def test_expand_text_leaves_unresolved_reference_visible(monkeypatch, tmp_path):
service, wildcards_dir = _make_service(monkeypatch, tmp_path)
wildcards_dir.mkdir()
assert service.expand_text("__missing__", seed=1) == "__missing__"
def test_contains_dynamic_syntax_detects_wildcards_and_options():
assert contains_dynamic_syntax("plain text") is False
assert contains_dynamic_syntax("__flower__") is True
assert contains_dynamic_syntax("{red|blue}") is True
assert contains_dynamic_syntax("{2$$, $$red|blue|green}") is True

View File

@@ -94,6 +94,19 @@ class TestCustomWordsService:
results = service.search_words("test")
assert mock_tag_index.called
def test_search_words_skips_prompt_like_queries(self):
service = CustomWordsService.__new__(CustomWordsService)
mock_tag_index = MockTagFTSIndex()
def mock_get_index():
return mock_tag_index
service._get_tag_index = mock_get_index
results = service.search_words("__flower__ /character f")
assert results == []
assert mock_tag_index.called is False
class MockTagFTSIndex:
"""Mock TagFTSIndex for testing."""

Some files were not shown because too many files have changed in this diff Show More