Compare commits

..

18 Commits

Author SHA1 Message Date
Will Miao
89fd2b43d6 chore(release): bump version to v1.0.5 and add release notes 2026-04-16 21:52:34 +08:00
Will Miao
c53f44e7ef feat(excluded-models): add excluded management view 2026-04-16 21:40:59 +08:00
Will Miao
ae7bfdb517 fix(download): normalize civitai.red download URLs (#898) 2026-04-16 18:25:16 +08:00
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
87 changed files with 4212 additions and 381 deletions

View File

@@ -56,6 +56,27 @@ Insomnia Art Designs, megakirbs, Brennok, 2018cfh, W+K+White, wackop, Takkan, Ca
## Release Notes ## Release Notes
### v1.0.5
* **Excluded Models Management View** - Added a new global-menu view for excluded models, with actions to restore them or delete them permanently.
* **Fix for `401 Unauthorized` Downloads** - Fixed an issue where some `civitai.red` downloads could lose authentication during redirect and fail with `401 Unauthorized`.
### 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 ### 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. * **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 +122,7 @@ Insomnia Art Designs, megakirbs, Brennok, 2018cfh, W+K+White, wackop, Takkan, Ca
### v0.9.14 ### 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. * **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. * **Bug Fixes & Stability** - Addressed multiple bugs and improved overall stability.
### v0.9.12 ### v0.9.12
@@ -253,6 +274,41 @@ pip install -r requirements.txt
- Paste into the Lora Loader node's text input - Paste into the Lora Loader node's text input
- The node will automatically apply preset strength and trigger words - 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 ### 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: 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

@@ -175,6 +175,9 @@
"success": "{count} Rezepte erfolgreich repariert.", "success": "{count} Rezepte erfolgreich repariert.",
"cancelled": "Reparatur abgebrochen. {count} Rezepte wurden repariert.", "cancelled": "Reparatur abgebrochen. {count} Rezepte wurden repariert.",
"error": "Recipe-Reparatur fehlgeschlagen: {message}" "error": "Recipe-Reparatur fehlgeschlagen: {message}"
},
"manageExcludedModels": {
"label": "Ausgeschlossene Modelle verwalten"
} }
}, },
"header": { "header": {
@@ -250,6 +253,19 @@
"civitaiApiKey": "Civitai API Key", "civitaiApiKey": "Civitai API Key",
"civitaiApiKeyPlaceholder": "Geben Sie Ihren Civitai API Key ein", "civitaiApiKeyPlaceholder": "Geben Sie Ihren Civitai API Key ein",
"civitaiApiKeyHelp": "Wird für die Authentifizierung beim Herunterladen von Modellen von Civitai verwendet", "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": { "openSettingsFileLocation": {
"label": "Einstellungsordner öffnen", "label": "Einstellungsordner öffnen",
"tooltip": "Den Ordner mit der settings.json öffnen", "tooltip": "Den Ordner mit der settings.json öffnen",
@@ -667,6 +683,7 @@
"moveToFolder": "In Ordner verschieben", "moveToFolder": "In Ordner verschieben",
"repairMetadata": "Metadaten reparieren", "repairMetadata": "Metadaten reparieren",
"excludeModel": "Modell ausschließen", "excludeModel": "Modell ausschließen",
"restoreModel": "Modell wiederherstellen",
"deleteModel": "Modell löschen", "deleteModel": "Modell löschen",
"shareRecipe": "Rezept teilen", "shareRecipe": "Rezept teilen",
"viewAllLoras": "Alle LoRAs anzeigen", "viewAllLoras": "Alle LoRAs anzeigen",
@@ -1790,6 +1807,8 @@
"deleteFailed": "Fehler beim Löschen von {type}: {message}", "deleteFailed": "Fehler beim Löschen von {type}: {message}",
"excludeSuccess": "{type} erfolgreich ausgeschlossen", "excludeSuccess": "{type} erfolgreich ausgeschlossen",
"excludeFailed": "Fehler beim Ausschließen von {type}: {message}", "excludeFailed": "Fehler beim Ausschließen von {type}: {message}",
"restoreSuccess": "{type} erfolgreich wiederhergestellt",
"restoreFailed": "{type} konnte nicht wiederhergestellt werden: {message}",
"fileNameUpdated": "Dateiname erfolgreich aktualisiert", "fileNameUpdated": "Dateiname erfolgreich aktualisiert",
"fileRenameFailed": "Fehler beim Umbenennen der Datei: {error}", "fileRenameFailed": "Fehler beim Umbenennen der Datei: {error}",
"previewUpdated": "Vorschau erfolgreich aktualisiert", "previewUpdated": "Vorschau erfolgreich aktualisiert",

View File

@@ -175,6 +175,9 @@
"success": "Successfully repaired {count} recipes.", "success": "Successfully repaired {count} recipes.",
"cancelled": "Repair cancelled. {count} recipes were repaired.", "cancelled": "Repair cancelled. {count} recipes were repaired.",
"error": "Recipe repair failed: {message}" "error": "Recipe repair failed: {message}"
},
"manageExcludedModels": {
"label": "Manage Excluded Models"
} }
}, },
"header": { "header": {
@@ -250,6 +253,19 @@
"civitaiApiKey": "Civitai API Key", "civitaiApiKey": "Civitai API Key",
"civitaiApiKeyPlaceholder": "Enter your Civitai API key", "civitaiApiKeyPlaceholder": "Enter your Civitai API key",
"civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai", "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": { "openSettingsFileLocation": {
"label": "Open settings folder", "label": "Open settings folder",
"tooltip": "Open folder containing settings.json", "tooltip": "Open folder containing settings.json",
@@ -667,6 +683,7 @@
"moveToFolder": "Move to Folder", "moveToFolder": "Move to Folder",
"repairMetadata": "Repair metadata", "repairMetadata": "Repair metadata",
"excludeModel": "Exclude Model", "excludeModel": "Exclude Model",
"restoreModel": "Restore Model",
"deleteModel": "Delete Model", "deleteModel": "Delete Model",
"shareRecipe": "Share Recipe", "shareRecipe": "Share Recipe",
"viewAllLoras": "View All LoRAs", "viewAllLoras": "View All LoRAs",
@@ -685,9 +702,9 @@
"title": "Import a recipe from image or URL", "title": "Import a recipe from image or URL",
"urlLocalPath": "URL / Local Path", "urlLocalPath": "URL / Local Path",
"uploadImage": "Upload Image", "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:", "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", "fetchImage": "Fetch Image",
"uploadSectionDescription": "Upload an image with LoRA metadata to import as a recipe.", "uploadSectionDescription": "Upload an image with LoRA metadata to import as a recipe.",
"selectImage": "Select Image", "selectImage": "Select Image",
@@ -1090,9 +1107,9 @@
}, },
"proceedText": "Only proceed if you're sure this is what you want.", "proceedText": "Only proceed if you're sure this is what you want.",
"urlLabel": "Civitai Model URL:", "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": { "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", "format1": "https://civitai.com/models/649516",
"format2": "https://civitai.com/models/649516?modelVersionId=726676", "format2": "https://civitai.com/models/649516?modelVersionId=726676",
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676", "format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
@@ -1790,6 +1807,8 @@
"deleteFailed": "Failed to delete {type}: {message}", "deleteFailed": "Failed to delete {type}: {message}",
"excludeSuccess": "{type} excluded successfully", "excludeSuccess": "{type} excluded successfully",
"excludeFailed": "Failed to exclude {type}: {message}", "excludeFailed": "Failed to exclude {type}: {message}",
"restoreSuccess": "{type} restored successfully",
"restoreFailed": "Failed to restore {type}: {message}",
"fileNameUpdated": "File name updated successfully", "fileNameUpdated": "File name updated successfully",
"fileRenameFailed": "Failed to rename file: {error}", "fileRenameFailed": "Failed to rename file: {error}",
"previewUpdated": "Preview updated successfully", "previewUpdated": "Preview updated successfully",

View File

@@ -175,6 +175,9 @@
"success": "Se repararon con éxito {count} recetas.", "success": "Se repararon con éxito {count} recetas.",
"cancelled": "Reparación cancelada. {count} recetas fueron reparadas.", "cancelled": "Reparación cancelada. {count} recetas fueron reparadas.",
"error": "Error al reparar recetas: {message}" "error": "Error al reparar recetas: {message}"
},
"manageExcludedModels": {
"label": "Gestionar modelos excluidos"
} }
}, },
"header": { "header": {
@@ -250,6 +253,19 @@
"civitaiApiKey": "Clave API de Civitai", "civitaiApiKey": "Clave API de Civitai",
"civitaiApiKeyPlaceholder": "Introduce tu clave API de Civitai", "civitaiApiKeyPlaceholder": "Introduce tu clave API de Civitai",
"civitaiApiKeyHelp": "Utilizada para autenticación al descargar modelos 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": { "openSettingsFileLocation": {
"label": "Abrir carpeta de ajustes", "label": "Abrir carpeta de ajustes",
"tooltip": "Abrir la carpeta que contiene settings.json", "tooltip": "Abrir la carpeta que contiene settings.json",
@@ -667,6 +683,7 @@
"moveToFolder": "Mover a carpeta", "moveToFolder": "Mover a carpeta",
"repairMetadata": "Reparar metadatos", "repairMetadata": "Reparar metadatos",
"excludeModel": "Excluir modelo", "excludeModel": "Excluir modelo",
"restoreModel": "Restaurar modelo",
"deleteModel": "Eliminar modelo", "deleteModel": "Eliminar modelo",
"shareRecipe": "Compartir receta", "shareRecipe": "Compartir receta",
"viewAllLoras": "Ver todos los LoRAs", "viewAllLoras": "Ver todos los LoRAs",
@@ -1790,6 +1807,8 @@
"deleteFailed": "Error al eliminar {type}: {message}", "deleteFailed": "Error al eliminar {type}: {message}",
"excludeSuccess": "{type} excluido exitosamente", "excludeSuccess": "{type} excluido exitosamente",
"excludeFailed": "Error al excluir {type}: {message}", "excludeFailed": "Error al excluir {type}: {message}",
"restoreSuccess": "{type} restaurado correctamente",
"restoreFailed": "No se pudo restaurar {type}: {message}",
"fileNameUpdated": "Nombre de archivo actualizado exitosamente", "fileNameUpdated": "Nombre de archivo actualizado exitosamente",
"fileRenameFailed": "Error al renombrar archivo: {error}", "fileRenameFailed": "Error al renombrar archivo: {error}",
"previewUpdated": "Vista previa actualizada exitosamente", "previewUpdated": "Vista previa actualizada exitosamente",

View File

@@ -175,6 +175,9 @@
"success": "{count} recettes réparées avec succès.", "success": "{count} recettes réparées avec succès.",
"cancelled": "Réparation annulée. {count} recettes ont été réparées.", "cancelled": "Réparation annulée. {count} recettes ont été réparées.",
"error": "Échec de la réparation des recettes : {message}" "error": "Échec de la réparation des recettes : {message}"
},
"manageExcludedModels": {
"label": "Gérer les modèles exclus"
} }
}, },
"header": { "header": {
@@ -250,6 +253,19 @@
"civitaiApiKey": "Clé API Civitai", "civitaiApiKey": "Clé API Civitai",
"civitaiApiKeyPlaceholder": "Entrez votre 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", "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": { "openSettingsFileLocation": {
"label": "Ouvrir le dossier des paramètres", "label": "Ouvrir le dossier des paramètres",
"tooltip": "Ouvrir le dossier contenant settings.json", "tooltip": "Ouvrir le dossier contenant settings.json",
@@ -667,6 +683,7 @@
"moveToFolder": "Déplacer vers un dossier", "moveToFolder": "Déplacer vers un dossier",
"repairMetadata": "Réparer les métadonnées", "repairMetadata": "Réparer les métadonnées",
"excludeModel": "Exclure le modèle", "excludeModel": "Exclure le modèle",
"restoreModel": "Restaurer le modèle",
"deleteModel": "Supprimer le modèle", "deleteModel": "Supprimer le modèle",
"shareRecipe": "Partager la recipe", "shareRecipe": "Partager la recipe",
"viewAllLoras": "Voir tous les LoRAs", "viewAllLoras": "Voir tous les LoRAs",
@@ -1790,6 +1807,8 @@
"deleteFailed": "Échec de la suppression de {type} : {message}", "deleteFailed": "Échec de la suppression de {type} : {message}",
"excludeSuccess": "{type} exclu avec succès", "excludeSuccess": "{type} exclu avec succès",
"excludeFailed": "Échec de l'exclusion de {type} : {message}", "excludeFailed": "Échec de l'exclusion de {type} : {message}",
"restoreSuccess": "{type} restauré avec succès",
"restoreFailed": "Échec de la restauration de {type} : {message}",
"fileNameUpdated": "Nom de fichier mis à jour avec succès", "fileNameUpdated": "Nom de fichier mis à jour avec succès",
"fileRenameFailed": "Échec du renommage du fichier : {error}", "fileRenameFailed": "Échec du renommage du fichier : {error}",
"previewUpdated": "Aperçu mis à jour avec succès", "previewUpdated": "Aperçu mis à jour avec succès",

View File

@@ -175,6 +175,9 @@
"success": "תוקנו בהצלחה {count} מתכונים.", "success": "תוקנו בהצלחה {count} מתכונים.",
"cancelled": "תיקון בוטל. {count} מתכונים תוקנו.", "cancelled": "תיקון בוטל. {count} מתכונים תוקנו.",
"error": "תיקון המתכונים נכשל: {message}" "error": "תיקון המתכונים נכשל: {message}"
},
"manageExcludedModels": {
"label": "ניהול מודלים מוחרגים"
} }
}, },
"header": { "header": {
@@ -250,6 +253,19 @@
"civitaiApiKey": "מפתח API של Civitai", "civitaiApiKey": "מפתח API של Civitai",
"civitaiApiKeyPlaceholder": "הזן את מפתח ה-API שלך מ-Civitai", "civitaiApiKeyPlaceholder": "הזן את מפתח ה-API שלך מ-Civitai",
"civitaiApiKeyHelp": "משמש לאימות בעת הורדת מודלים מ-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": { "openSettingsFileLocation": {
"label": "פתח תיקיית הגדרות", "label": "פתח תיקיית הגדרות",
"tooltip": "פתח את התיקייה שמכילה את settings.json", "tooltip": "פתח את התיקייה שמכילה את settings.json",
@@ -667,6 +683,7 @@
"moveToFolder": "העבר לתיקייה", "moveToFolder": "העבר לתיקייה",
"repairMetadata": "תיקון מטא-דאטה", "repairMetadata": "תיקון מטא-דאטה",
"excludeModel": "החרג מודל", "excludeModel": "החרג מודל",
"restoreModel": "שחזור מודל",
"deleteModel": "מחק מודל", "deleteModel": "מחק מודל",
"shareRecipe": "שתף מתכון", "shareRecipe": "שתף מתכון",
"viewAllLoras": "הצג את כל ה-LoRAs", "viewAllLoras": "הצג את כל ה-LoRAs",
@@ -1790,6 +1807,8 @@
"deleteFailed": "מחיקת {type} נכשלה: {message}", "deleteFailed": "מחיקת {type} נכשלה: {message}",
"excludeSuccess": "{type} הוחרג בהצלחה", "excludeSuccess": "{type} הוחרג בהצלחה",
"excludeFailed": "החרגת {type} נכשלה: {message}", "excludeFailed": "החרגת {type} נכשלה: {message}",
"restoreSuccess": "{type} שוחזר בהצלחה",
"restoreFailed": "שחזור {type} נכשל: {message}",
"fileNameUpdated": "שם הקובץ עודכן בהצלחה", "fileNameUpdated": "שם הקובץ עודכן בהצלחה",
"fileRenameFailed": "שינוי שם הקובץ נכשל: {error}", "fileRenameFailed": "שינוי שם הקובץ נכשל: {error}",
"previewUpdated": "התצוגה המקדימה עודכנה בהצלחה", "previewUpdated": "התצוגה המקדימה עודכנה בהצלחה",

View File

@@ -175,6 +175,9 @@
"success": "{count} 件のレシピを正常に修復しました。", "success": "{count} 件のレシピを正常に修復しました。",
"cancelled": "修復がキャンセルされました。{count}個のレシピが修復されました。", "cancelled": "修復がキャンセルされました。{count}個のレシピが修復されました。",
"error": "レシピの修復に失敗しました: {message}" "error": "レシピの修復に失敗しました: {message}"
},
"manageExcludedModels": {
"label": "除外モデルを管理"
} }
}, },
"header": { "header": {
@@ -250,6 +253,19 @@
"civitaiApiKey": "Civitai APIキー", "civitaiApiKey": "Civitai APIキー",
"civitaiApiKeyPlaceholder": "Civitai APIキーを入力してください", "civitaiApiKeyPlaceholder": "Civitai APIキーを入力してください",
"civitaiApiKeyHelp": "Civitaiからモデルをダウンロードするときの認証に使用されます", "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": { "openSettingsFileLocation": {
"label": "設定フォルダーを開く", "label": "設定フォルダーを開く",
"tooltip": "settings.json を含むフォルダーを開きます", "tooltip": "settings.json を含むフォルダーを開きます",
@@ -667,6 +683,7 @@
"moveToFolder": "フォルダに移動", "moveToFolder": "フォルダに移動",
"repairMetadata": "メタデータを修復", "repairMetadata": "メタデータを修復",
"excludeModel": "モデルを除外", "excludeModel": "モデルを除外",
"restoreModel": "モデルを復元",
"deleteModel": "モデルを削除", "deleteModel": "モデルを削除",
"shareRecipe": "レシピを共有", "shareRecipe": "レシピを共有",
"viewAllLoras": "すべてのLoRAを表示", "viewAllLoras": "すべてのLoRAを表示",
@@ -1790,6 +1807,8 @@
"deleteFailed": "{type}の削除に失敗しました:{message}", "deleteFailed": "{type}の削除に失敗しました:{message}",
"excludeSuccess": "{type}が正常に除外されました", "excludeSuccess": "{type}が正常に除外されました",
"excludeFailed": "{type}の除外に失敗しました:{message}", "excludeFailed": "{type}の除外に失敗しました:{message}",
"restoreSuccess": "{type}を復元しました",
"restoreFailed": "{type}の復元に失敗しました: {message}",
"fileNameUpdated": "ファイル名が正常に更新されました", "fileNameUpdated": "ファイル名が正常に更新されました",
"fileRenameFailed": "ファイル名の変更に失敗しました:{error}", "fileRenameFailed": "ファイル名の変更に失敗しました:{error}",
"previewUpdated": "プレビューが正常に更新されました", "previewUpdated": "プレビューが正常に更新されました",

View File

@@ -175,6 +175,9 @@
"success": "{count}개의 레시피가 성공적으로 복구되었습니다.", "success": "{count}개의 레시피가 성공적으로 복구되었습니다.",
"cancelled": "수리가 취소되었습니다. {count}개의 레시피가 수리되었습니다.", "cancelled": "수리가 취소되었습니다. {count}개의 레시피가 수리되었습니다.",
"error": "레시피 복구 실패: {message}" "error": "레시피 복구 실패: {message}"
},
"manageExcludedModels": {
"label": "제외된 모델 관리"
} }
}, },
"header": { "header": {
@@ -250,6 +253,19 @@
"civitaiApiKey": "Civitai API 키", "civitaiApiKey": "Civitai API 키",
"civitaiApiKeyPlaceholder": "Civitai API 키를 입력하세요", "civitaiApiKeyPlaceholder": "Civitai API 키를 입력하세요",
"civitaiApiKeyHelp": "Civitai에서 모델을 다운로드할 때 인증에 사용됩니다", "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": { "openSettingsFileLocation": {
"label": "설정 폴더 열기", "label": "설정 폴더 열기",
"tooltip": "settings.json이 있는 폴더를 엽니다", "tooltip": "settings.json이 있는 폴더를 엽니다",
@@ -667,6 +683,7 @@
"moveToFolder": "폴더로 이동", "moveToFolder": "폴더로 이동",
"repairMetadata": "메타데이터 복구", "repairMetadata": "메타데이터 복구",
"excludeModel": "모델 제외", "excludeModel": "모델 제외",
"restoreModel": "모델 복원",
"deleteModel": "모델 삭제", "deleteModel": "모델 삭제",
"shareRecipe": "레시피 공유", "shareRecipe": "레시피 공유",
"viewAllLoras": "모든 LoRA 보기", "viewAllLoras": "모든 LoRA 보기",
@@ -1790,6 +1807,8 @@
"deleteFailed": "{type} 삭제 실패: {message}", "deleteFailed": "{type} 삭제 실패: {message}",
"excludeSuccess": "{type}이(가) 성공적으로 제외되었습니다", "excludeSuccess": "{type}이(가) 성공적으로 제외되었습니다",
"excludeFailed": "{type} 제외 실패: {message}", "excludeFailed": "{type} 제외 실패: {message}",
"restoreSuccess": "{type} 복원 완료",
"restoreFailed": "{type} 복원 실패: {message}",
"fileNameUpdated": "파일명이 성공적으로 업데이트되었습니다", "fileNameUpdated": "파일명이 성공적으로 업데이트되었습니다",
"fileRenameFailed": "파일 이름 변경 실패: {error}", "fileRenameFailed": "파일 이름 변경 실패: {error}",
"previewUpdated": "미리보기가 성공적으로 업데이트되었습니다", "previewUpdated": "미리보기가 성공적으로 업데이트되었습니다",

View File

@@ -175,6 +175,9 @@
"success": "Успешно восстановлено {count} рецептов.", "success": "Успешно восстановлено {count} рецептов.",
"cancelled": "Восстановление отменено. {count} рецептов было восстановлено.", "cancelled": "Восстановление отменено. {count} рецептов было восстановлено.",
"error": "Ошибка восстановления рецептов: {message}" "error": "Ошибка восстановления рецептов: {message}"
},
"manageExcludedModels": {
"label": "Управление исключёнными моделями"
} }
}, },
"header": { "header": {
@@ -250,6 +253,19 @@
"civitaiApiKey": "Ключ API Civitai", "civitaiApiKey": "Ключ API Civitai",
"civitaiApiKeyPlaceholder": "Введите ваш ключ API Civitai", "civitaiApiKeyPlaceholder": "Введите ваш ключ API Civitai",
"civitaiApiKeyHelp": "Используется для аутентификации при загрузке моделей с 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": { "openSettingsFileLocation": {
"label": "Открыть папку настроек", "label": "Открыть папку настроек",
"tooltip": "Открыть папку, содержащую settings.json", "tooltip": "Открыть папку, содержащую settings.json",
@@ -667,6 +683,7 @@
"moveToFolder": "Переместить в папку", "moveToFolder": "Переместить в папку",
"repairMetadata": "Восстановить метаданные", "repairMetadata": "Восстановить метаданные",
"excludeModel": "Исключить модель", "excludeModel": "Исключить модель",
"restoreModel": "Восстановить модель",
"deleteModel": "Удалить модель", "deleteModel": "Удалить модель",
"shareRecipe": "Поделиться рецептом", "shareRecipe": "Поделиться рецептом",
"viewAllLoras": "Посмотреть все LoRAs", "viewAllLoras": "Посмотреть все LoRAs",
@@ -1790,6 +1807,8 @@
"deleteFailed": "Не удалось удалить {type}: {message}", "deleteFailed": "Не удалось удалить {type}: {message}",
"excludeSuccess": "{type} успешно исключен", "excludeSuccess": "{type} успешно исключен",
"excludeFailed": "Не удалось исключить {type}: {message}", "excludeFailed": "Не удалось исключить {type}: {message}",
"restoreSuccess": "{type} успешно восстановлен",
"restoreFailed": "Не удалось восстановить {type}: {message}",
"fileNameUpdated": "Имя файла успешно обновлено", "fileNameUpdated": "Имя файла успешно обновлено",
"fileRenameFailed": "Не удалось переименовать файл: {error}", "fileRenameFailed": "Не удалось переименовать файл: {error}",
"previewUpdated": "Превью успешно обновлено", "previewUpdated": "Превью успешно обновлено",

View File

@@ -175,6 +175,9 @@
"success": "成功修复了 {count} 个配方。", "success": "成功修复了 {count} 个配方。",
"cancelled": "修复已取消。已修复 {count} 个配方。", "cancelled": "修复已取消。已修复 {count} 个配方。",
"error": "配方修复失败:{message}" "error": "配方修复失败:{message}"
},
"manageExcludedModels": {
"label": "管理已排除的模型"
} }
}, },
"header": { "header": {
@@ -250,6 +253,19 @@
"civitaiApiKey": "Civitai API 密钥", "civitaiApiKey": "Civitai API 密钥",
"civitaiApiKeyPlaceholder": "请输入你的 Civitai API 密钥", "civitaiApiKeyPlaceholder": "请输入你的 Civitai API 密钥",
"civitaiApiKeyHelp": "用于从 Civitai 下载模型时的身份验证", "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": { "openSettingsFileLocation": {
"label": "打开设置文件夹", "label": "打开设置文件夹",
"tooltip": "打开包含 settings.json 的文件夹", "tooltip": "打开包含 settings.json 的文件夹",
@@ -667,6 +683,7 @@
"moveToFolder": "移动到文件夹", "moveToFolder": "移动到文件夹",
"repairMetadata": "修复元数据", "repairMetadata": "修复元数据",
"excludeModel": "排除模型", "excludeModel": "排除模型",
"restoreModel": "恢复模型",
"deleteModel": "删除模型", "deleteModel": "删除模型",
"shareRecipe": "分享配方", "shareRecipe": "分享配方",
"viewAllLoras": "查看所有 LoRA", "viewAllLoras": "查看所有 LoRA",
@@ -685,9 +702,9 @@
"title": "从图片或 URL 导入配方", "title": "从图片或 URL 导入配方",
"urlLocalPath": "URL / 本地路径", "urlLocalPath": "URL / 本地路径",
"uploadImage": "上传图片", "uploadImage": "上传图片",
"urlSectionDescription": "输入 Civitai 图片 URL 或本地文件路径以导入为配方。", "urlSectionDescription": "输入来自 civitai.com 或 civitai.red 的 Civitai 图片 URL或本地文件路径以导入为配方。",
"imageUrlOrPath": "图片 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": "获取图片", "fetchImage": "获取图片",
"uploadSectionDescription": "上传带有 LoRA 元数据的图片以导入为配方。", "uploadSectionDescription": "上传带有 LoRA 元数据的图片以导入为配方。",
"selectImage": "选择图片", "selectImage": "选择图片",
@@ -1090,9 +1107,9 @@
}, },
"proceedText": "仅在你确定需要此操作时继续。", "proceedText": "仅在你确定需要此操作时继续。",
"urlLabel": "Civitai 模型 URL", "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": { "helpText": {
"title": "粘贴任意 Civitai 模型 URL。支持格式", "title": "粘贴任意来自 civitai.com 或 civitai.red 的 Civitai 模型 URL。支持格式",
"format1": "https://civitai.com/models/649516", "format1": "https://civitai.com/models/649516",
"format2": "https://civitai.com/models/649516?modelVersionId=726676", "format2": "https://civitai.com/models/649516?modelVersionId=726676",
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676", "format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
@@ -1790,6 +1807,8 @@
"deleteFailed": "删除 {type} 失败:{message}", "deleteFailed": "删除 {type} 失败:{message}",
"excludeSuccess": "{type} 排除成功", "excludeSuccess": "{type} 排除成功",
"excludeFailed": "排除 {type} 失败:{message}", "excludeFailed": "排除 {type} 失败:{message}",
"restoreSuccess": "{type} 已成功恢复",
"restoreFailed": "恢复 {type} 失败:{message}",
"fileNameUpdated": "文件名更新成功", "fileNameUpdated": "文件名更新成功",
"fileRenameFailed": "重命名文件失败:{error}", "fileRenameFailed": "重命名文件失败:{error}",
"previewUpdated": "预览图片更新成功", "previewUpdated": "预览图片更新成功",

View File

@@ -175,6 +175,9 @@
"success": "成功修復 {count} 個配方。", "success": "成功修復 {count} 個配方。",
"cancelled": "修復已取消。已修復 {count} 個配方。", "cancelled": "修復已取消。已修復 {count} 個配方。",
"error": "配方修復失敗:{message}" "error": "配方修復失敗:{message}"
},
"manageExcludedModels": {
"label": "管理已排除的模型"
} }
}, },
"header": { "header": {
@@ -250,6 +253,19 @@
"civitaiApiKey": "Civitai API 金鑰", "civitaiApiKey": "Civitai API 金鑰",
"civitaiApiKeyPlaceholder": "請輸入您的 Civitai API 金鑰", "civitaiApiKeyPlaceholder": "請輸入您的 Civitai API 金鑰",
"civitaiApiKeyHelp": "用於從 Civitai 下載模型時的身份驗證", "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": { "openSettingsFileLocation": {
"label": "開啟設定資料夾", "label": "開啟設定資料夾",
"tooltip": "開啟包含 settings.json 的資料夾", "tooltip": "開啟包含 settings.json 的資料夾",
@@ -667,6 +683,7 @@
"moveToFolder": "移動到資料夾", "moveToFolder": "移動到資料夾",
"repairMetadata": "修復元數據", "repairMetadata": "修復元數據",
"excludeModel": "排除模型", "excludeModel": "排除模型",
"restoreModel": "還原模型",
"deleteModel": "刪除模型", "deleteModel": "刪除模型",
"shareRecipe": "分享配方", "shareRecipe": "分享配方",
"viewAllLoras": "檢視全部 LoRA", "viewAllLoras": "檢視全部 LoRA",
@@ -1790,6 +1807,8 @@
"deleteFailed": "刪除 {type} 失敗:{message}", "deleteFailed": "刪除 {type} 失敗:{message}",
"excludeSuccess": "{type} 已成功排除", "excludeSuccess": "{type} 已成功排除",
"excludeFailed": "排除 {type} 失敗:{message}", "excludeFailed": "排除 {type} 失敗:{message}",
"restoreSuccess": "{type} 已成功還原",
"restoreFailed": "還原 {type} 失敗:{message}",
"fileNameUpdated": "檔案名稱已成功更新", "fileNameUpdated": "檔案名稱已成功更新",
"fileRenameFailed": "重新命名檔案失敗:{error}", "fileRenameFailed": "重新命名檔案失敗:{error}",
"previewUpdated": "預覽圖片已成功更新", "previewUpdated": "預覽圖片已成功更新",

View File

@@ -1,15 +1,38 @@
from __future__ import annotations
from typing import Any from typing import Any
import inspect 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): class _PromptOptionalInputs:
return True """Lookup that preserves explicit optional inputs and dynamic trigger slots."""
def __getitem__(self, key): def __init__(self, explicit_inputs: dict[str, tuple[str, dict[str, Any]]]) -> None:
return ("STRING", {"forceInput": True}) 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: class PromptLM:
@@ -20,12 +43,19 @@ class PromptLM:
DESCRIPTION = ( DESCRIPTION = (
"Encodes a text prompt using a CLIP model into an embedding that can be used " "Encodes a text prompt using a CLIP model into an embedding that can be used "
"to guide the diffusion model towards generating specific images. " "to guide the diffusion model towards generating specific images. "
"Supports dynamic trigger words inputs." "Supports dynamic trigger words inputs and runtime wildcard expansion."
) )
@classmethod @classmethod
def INPUT_TYPES(cls): 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": ( "trigger_words1": (
"STRING", "STRING",
{ {
@@ -35,10 +65,9 @@ class PromptLM:
), ),
} }
# Bypass validation for dynamic inputs during graph execution
stack = inspect.stack() stack = inspect.stack()
if len(stack) > 2 and stack[2].function == "get_input_info": if len(stack) > 2 and stack[2].function == "get_input_info":
dyn_inputs = _AllContainer() optional_inputs = _PromptOptionalInputs(optional_inputs) # type: ignore[assignment]
return { return {
"required": { "required": {
@@ -46,8 +75,8 @@ class PromptLM:
"AUTOCOMPLETE_TEXT_PROMPT,STRING", "AUTOCOMPLETE_TEXT_PROMPT,STRING",
{ {
"widgetType": "AUTOCOMPLETE_TEXT_PROMPT", "widgetType": "AUTOCOMPLETE_TEXT_PROMPT",
"placeholder": "Enter prompt... /char, /artist for quick tag search", "placeholder": "Enter prompt... /character, /artist, /wildcard for quick search",
"tooltip": "The text to be encoded.", "tooltip": "The text to be encoded. Wildcard references inserted with /wildcard are expanded at runtime.",
}, },
), ),
"clip": ( "clip": (
@@ -55,7 +84,7 @@ class PromptLM:
{"tooltip": "The CLIP model used for encoding the text."}, {"tooltip": "The CLIP model used for encoding the text."},
), ),
}, },
"optional": dyn_inputs, "optional": optional_inputs,
} }
RETURN_TYPES = ("CONDITIONING", "STRING") RETURN_TYPES = ("CONDITIONING", "STRING")
@@ -65,20 +94,39 @@ class PromptLM:
) )
FUNCTION = "encode" FUNCTION = "encode"
def encode(self, text: str, clip: Any, **kwargs): @classmethod
# Collect all trigger words from dynamic inputs 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 = [] trigger_words = []
for key, value in kwargs.items(): 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) trigger_words.append(value)
# Build final prompt
if trigger_words: if trigger_words:
prompt = ", ".join(trigger_words + [text]) prompt = ", ".join(trigger_words + [expanded_text])
else: else:
prompt = text prompt = expanded_text
from nodes import CLIPTextEncode # type: ignore from nodes import CLIPTextEncode # type: ignore
conditioning = CLIPTextEncode().encode(clip, prompt)[0] 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: class TextLM:
"""A simple text node with autocomplete support.""" """A simple text node with autocomplete support."""
NAME = "Text (LoraManager)" NAME = "Text (LoraManager)"
CATEGORY = "Lora Manager/utils" CATEGORY = "Lora Manager/utils"
DESCRIPTION = ( 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 @classmethod
@@ -15,8 +20,17 @@ class TextLM:
"AUTOCOMPLETE_TEXT_PROMPT,STRING", "AUTOCOMPLETE_TEXT_PROMPT,STRING",
{ {
"widgetType": "AUTOCOMPLETE_TEXT_PROMPT", "widgetType": "AUTOCOMPLETE_TEXT_PROMPT",
"placeholder": "Enter text... /char, /artist for quick tag search", "placeholder": "Enter text... /character, /artist, /wildcard for quick search",
"tooltip": "The text output.", "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_TYPES = ("STRING",)
RETURN_NAMES = ("STRING",) RETURN_NAMES = ("STRING",)
OUTPUT_TOOLTIPS = ( OUTPUT_TOOLTIPS = ("The text output.",)
"The text output.",
)
FUNCTION = "process" FUNCTION = "process"
def process(self, text: str): @classmethod
return (text,) 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

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

View File

@@ -13,6 +13,7 @@ import contextlib
import io import io
import json import json
import logging import logging
import time
import os import os
import platform import platform
import re import re
@@ -2410,6 +2411,16 @@ class FileSystemHandler:
logger.error("Failed to open backup location: %s", exc, exc_info=True) logger.error("Failed to open backup location: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def open_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: class CustomWordsHandler:
"""Handler for autocomplete via TagFTSIndex.""" """Handler for autocomplete via TagFTSIndex."""
@@ -2489,6 +2500,41 @@ class CustomWordsHandler:
return None 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: class NodeRegistryHandler:
def __init__( def __init__(
self, self,
@@ -2717,6 +2763,7 @@ class MiscHandlerSet:
backup: BackupHandler, backup: BackupHandler,
filesystem: FileSystemHandler, filesystem: FileSystemHandler,
custom_words: CustomWordsHandler, custom_words: CustomWordsHandler,
wildcards: WildcardsHandler,
supporters: SupportersHandler, supporters: SupportersHandler,
doctor: DoctorHandler, doctor: DoctorHandler,
example_workflows: ExampleWorkflowsHandler, example_workflows: ExampleWorkflowsHandler,
@@ -2734,6 +2781,7 @@ class MiscHandlerSet:
self.backup = backup self.backup = backup
self.filesystem = filesystem self.filesystem = filesystem
self.custom_words = custom_words self.custom_words = custom_words
self.wildcards = wildcards
self.supporters = supporters self.supporters = supporters
self.doctor = doctor self.doctor = doctor
self.example_workflows = example_workflows self.example_workflows = example_workflows
@@ -2774,7 +2822,9 @@ class MiscHandlerSet:
"open_file_location": self.filesystem.open_file_location, "open_file_location": self.filesystem.open_file_location,
"open_settings_location": self.filesystem.open_settings_location, "open_settings_location": self.filesystem.open_settings_location,
"open_backup_location": self.filesystem.open_backup_location, "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_custom_words": self.custom_words.search_custom_words,
"search_wildcards": self.wildcards.search_wildcards,
"get_supporters": self.supporters.get_supporters, "get_supporters": self.supporters.get_supporters,
"get_example_workflows": self.example_workflows.get_example_workflows, "get_example_workflows": self.example_workflows.get_example_workflows,
"get_example_workflow": self.example_workflows.get_example_workflow, "get_example_workflow": self.example_workflows.get_example_workflow,

View File

@@ -224,6 +224,42 @@ class ModelListingHandler:
) )
return web.json_response({"error": str(exc)}, status=500) return web.json_response({"error": str(exc)}, status=500)
async def get_excluded_models(self, request: web.Request) -> web.Response:
start_time = time.perf_counter()
try:
params = self._parse_common_params(request)
result = await self._service.get_excluded_paginated_data(**params)
format_start = time.perf_counter()
formatted_result = {
"items": [
await self._service.format_response(item)
for item in result["items"]
],
"total": result["total"],
"page": result["page"],
"page_size": result["page_size"],
"total_pages": result["total_pages"],
}
format_duration = time.perf_counter() - format_start
duration = time.perf_counter() - start_time
self._logger.debug(
"Request for %s/excluded took %.3fs (formatting: %.3fs)",
self._service.model_type,
duration,
format_duration,
)
return web.json_response(formatted_result)
except Exception as exc:
self._logger.error(
"Error retrieving excluded %ss: %s",
self._service.model_type,
exc,
exc_info=True,
)
return web.json_response({"error": str(exc)}, status=500)
def _parse_common_params(self, request: web.Request) -> Dict: def _parse_common_params(self, request: web.Request) -> Dict:
page = int(request.query.get("page", "1")) page = int(request.query.get("page", "1"))
page_size = min(int(request.query.get("page_size", "20")), 100) page_size = min(int(request.query.get("page_size", "20")), 100)
@@ -392,6 +428,21 @@ class ModelManagementHandler:
self._logger.error("Error excluding model: %s", exc, exc_info=True) self._logger.error("Error excluding model: %s", exc, exc_info=True)
return web.Response(text=str(exc), status=500) return web.Response(text=str(exc), status=500)
async def unexclude_model(self, request: web.Request) -> web.Response:
try:
data = await request.json()
file_path = data.get("file_path")
if not file_path:
return web.Response(text="Model path is required", status=400)
result = await self._lifecycle_service.unexclude_model(file_path)
return web.json_response(result)
except ValueError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
except Exception as exc:
self._logger.error("Error restoring model: %s", exc, exc_info=True)
return web.Response(text=str(exc), status=500)
async def fetch_civitai(self, request: web.Request) -> web.Response: async def fetch_civitai(self, request: web.Request) -> web.Response:
try: try:
data = await request.json() data = await request.json()
@@ -2437,8 +2488,10 @@ class ModelHandlerSet:
return { return {
"handle_models_page": self.page_view.handle, "handle_models_page": self.page_view.handle,
"get_models": self.listing.get_models, "get_models": self.listing.get_models,
"get_excluded_models": self.listing.get_excluded_models,
"delete_model": self.management.delete_model, "delete_model": self.management.delete_model,
"exclude_model": self.management.exclude_model, "exclude_model": self.management.exclude_model,
"unexclude_model": self.management.unexclude_model,
"fetch_civitai": self.management.fetch_civitai, "fetch_civitai": self.management.fetch_civitai,
"fetch_all_civitai": self.civitai.fetch_all_civitai, "fetch_all_civitai": self.civitai.fetch_all_civitai,
"relink_civitai": self.management.relink_civitai, "relink_civitai": self.management.relink_civitai,

View File

@@ -26,7 +26,7 @@ from ...services.recipes import (
RecipeValidationError, RecipeValidationError,
) )
from ...services.metadata_service import get_default_metadata_provider 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 ...utils.exif_utils import ExifUtils
from ...recipes.merger import GenParamsMerger from ...recipes.merger import GenParamsMerger
from ...recipes.enrichment import RecipeEnricher from ...recipes.enrichment import RecipeEnricher
@@ -1196,13 +1196,15 @@ class RecipeManagementHandler:
temp_path = temp_file.name temp_path = temp_file.name
download_url = image_url download_url = image_url
image_info = None image_info = None
civitai_match = re.match(r"https://civitai\.com/images/(\d+)", image_url) civitai_image_id = extract_civitai_image_id(image_url)
if civitai_match: if civitai_image_id:
if civitai_client is None: if civitai_client is None:
raise RecipeDownloadError( raise RecipeDownloadError(
"Civitai client unavailable for image download" "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: if not image_info:
raise RecipeDownloadError( raise RecipeDownloadError(
"Failed to fetch image information from Civitai" "Failed to fetch image information from Civitai"
@@ -1236,7 +1238,7 @@ class RecipeManagementHandler:
return ( return (
file_obj.read(), file_obj.read(),
extension, 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: except RecipeDownloadError:
raise raise

View File

@@ -30,6 +30,8 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/settings/libraries/activate", "activate_library"), RouteDefinition("POST", "/api/lm/settings/libraries/activate", "activate_library"),
RouteDefinition("GET", "/api/lm/health-check", "health_check"), RouteDefinition("GET", "/api/lm/health-check", "health_check"),
RouteDefinition("GET", "/api/lm/supporters", "get_supporters"), 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/open-file-location", "open_file_location"),
RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"), RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"),
RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"), RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"),

View File

@@ -35,6 +35,7 @@ from .handlers.misc_handlers import (
SupportersHandler, SupportersHandler,
TrainedWordsHandler, TrainedWordsHandler,
UsageStatsHandler, UsageStatsHandler,
WildcardsHandler,
build_service_registry_adapter, build_service_registry_adapter,
) )
from .handlers.base_model_handlers import BaseModelHandlerSet from .handlers.base_model_handlers import BaseModelHandlerSet
@@ -130,6 +131,7 @@ class MiscRoutes:
metadata_provider_factory=self._metadata_provider_factory, metadata_provider_factory=self._metadata_provider_factory,
) )
custom_words = CustomWordsHandler() custom_words = CustomWordsHandler()
wildcards = WildcardsHandler()
supporters = SupportersHandler() supporters = SupportersHandler()
doctor = DoctorHandler(settings_service=self._settings) doctor = DoctorHandler(settings_service=self._settings)
example_workflows = ExampleWorkflowsHandler() example_workflows = ExampleWorkflowsHandler()
@@ -148,6 +150,7 @@ class MiscRoutes:
backup=backup, backup=backup,
filesystem=filesystem, filesystem=filesystem,
custom_words=custom_words, custom_words=custom_words,
wildcards=wildcards,
supporters=supporters, supporters=supporters,
doctor=doctor, doctor=doctor,
example_workflows=example_workflows, example_workflows=example_workflows,

View File

@@ -22,8 +22,10 @@ class RouteDefinition:
COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/{prefix}/list", "get_models"), RouteDefinition("GET", "/api/lm/{prefix}/list", "get_models"),
RouteDefinition("GET", "/api/lm/{prefix}/excluded", "get_excluded_models"),
RouteDefinition("POST", "/api/lm/{prefix}/delete", "delete_model"), RouteDefinition("POST", "/api/lm/{prefix}/delete", "delete_model"),
RouteDefinition("POST", "/api/lm/{prefix}/exclude", "exclude_model"), RouteDefinition("POST", "/api/lm/{prefix}/exclude", "exclude_model"),
RouteDefinition("POST", "/api/lm/{prefix}/unexclude", "unexclude_model"),
RouteDefinition("POST", "/api/lm/{prefix}/fetch-civitai", "fetch_civitai"), RouteDefinition("POST", "/api/lm/{prefix}/fetch-civitai", "fetch_civitai"),
RouteDefinition("POST", "/api/lm/{prefix}/fetch-all-civitai", "fetch_all_civitai"), RouteDefinition("POST", "/api/lm/{prefix}/fetch-all-civitai", "fetch_all_civitai"),
RouteDefinition("POST", "/api/lm/{prefix}/relink-civitai", "relink_civitai"), RouteDefinition("POST", "/api/lm/{prefix}/relink-civitai", "relink_civitai"),

View File

@@ -20,6 +20,7 @@ from .model_query import (
resolve_sub_type, resolve_sub_type,
) )
from .settings_manager import get_settings_manager from .settings_manager import get_settings_manager
from ..utils.civitai_utils import build_civitai_model_page_url
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -178,6 +179,57 @@ class BaseModelService(ABC):
) )
return paginated return paginated
async def get_excluded_paginated_data(
self,
page: int,
page_size: int,
sort_by: str = "name",
search: str = None,
fuzzy_search: bool = False,
search_options: dict = None,
**kwargs,
) -> Dict:
"""Get paginated excluded model data."""
excluded_paths = list(self.scanner.get_excluded_models())
excluded_entries: List[Dict[str, Any]] = []
stale_paths: List[str] = []
for file_path in excluded_paths:
if not file_path or not os.path.exists(file_path):
stale_paths.append(file_path)
continue
entry = await self._build_excluded_entry(file_path)
if entry:
excluded_entries.append(entry)
else:
stale_paths.append(file_path)
if stale_paths:
current_excluded = getattr(self.scanner, "_excluded_models", None)
if isinstance(current_excluded, list):
stale_set = set(stale_paths)
self.scanner._excluded_models = [
path for path in current_excluded if path not in stale_set
]
persist_current_cache = getattr(self.scanner, "_persist_current_cache", None)
if callable(persist_current_cache):
await persist_current_cache()
excluded_entries = self._sort_entries(excluded_entries, sort_by)
if search:
excluded_entries = await self._apply_search_filters(
excluded_entries,
search,
fuzzy_search,
search_options,
)
paginated = self._paginate(excluded_entries, page, page_size)
paginated["items"] = await self._annotate_update_flags(paginated["items"])
return paginated
async def _fetch_with_usage_sort(self, sort_params): async def _fetch_with_usage_sort(self, sort_params):
"""Fetch data sorted by usage count (desc/asc).""" """Fetch data sorted by usage count (desc/asc)."""
cache = await self.cache_repository.get_cache() cache = await self.cache_repository.get_cache()
@@ -217,6 +269,62 @@ class BaseModelService(ABC):
) )
return annotated return annotated
def _sort_entries(self, data: List[Dict[str, Any]], sort_by: str) -> List[Dict[str, Any]]:
sort_params = self.cache_repository.parse_sort(sort_by)
key_name = sort_params.key
if key_name == "date":
key_fn = lambda item: (
float(item.get("modified", 0.0) or 0.0),
(item.get("model_name") or item.get("file_name") or "").lower(),
item.get("file_path", "").lower(),
)
elif key_name == "size":
key_fn = lambda item: (
int(item.get("size", 0) or 0),
(item.get("model_name") or item.get("file_name") or "").lower(),
item.get("file_path", "").lower(),
)
elif key_name == "usage":
key_fn = lambda item: (
int(item.get("usage_count", 0) or 0),
(item.get("model_name") or item.get("file_name") or "").lower(),
item.get("file_path", "").lower(),
)
else:
key_fn = lambda item: (
(item.get("model_name") or item.get("file_name") or "").lower(),
item.get("file_path", "").lower(),
)
return sorted(data, key=key_fn, reverse=sort_params.order == "desc")
async def _build_excluded_entry(self, file_path: str) -> Optional[Dict[str, Any]]:
root_path = self.scanner._find_root_for_file(file_path)
if not root_path:
return None
metadata, should_skip = await MetadataManager.load_metadata(
file_path,
self.metadata_class,
)
if should_skip:
return None
if metadata is None:
metadata = await self.scanner._create_default_metadata(file_path)
if metadata is None:
return None
metadata = self.scanner.adjust_metadata(metadata, file_path, root_path)
folder = os.path.dirname(os.path.relpath(file_path, root_path)).replace(
os.path.sep, "/"
)
entry = self.scanner._build_cache_entry(metadata, folder=folder)
entry = self.scanner.adjust_cached_entry(entry)
entry["exclude"] = True
return entry
async def _apply_hash_filters( async def _apply_hash_filters(
self, data: List[Dict], hash_filters: Dict self, data: List[Dict], hash_filters: Dict
) -> List[Dict]: ) -> List[Dict]:
@@ -774,9 +882,12 @@ class BaseModelService(ABC):
version_id = civitai_data.get("id") version_id = civitai_data.get("id")
if model_id: if model_id:
civitai_url = f"https://civitai.com/models/{model_id}" civitai_host = self.settings.get("civitai_host", "civitai.com")
if version_id: civitai_url = build_civitai_model_page_url(
civitai_url += f"?modelVersionId={version_id}" model_id,
version_id,
host=civitai_host,
)
return { return {
"civitai_url": civitai_url, "civitai_url": civitai_url,

View File

@@ -42,6 +42,7 @@ class CheckpointService(BaseModelService):
"notes": checkpoint_data.get("notes", ""), "notes": checkpoint_data.get("notes", ""),
"sub_type": sub_type, "sub_type": sub_type,
"favorite": checkpoint_data.get("favorite", False), "favorite": checkpoint_data.get("favorite", False),
"exclude": bool(checkpoint_data.get("exclude", False)),
"update_available": bool(checkpoint_data.get("update_available", False)), "update_available": bool(checkpoint_data.get("update_available", False)),
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)), "skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)),
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True) "civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True)

View File

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

View File

@@ -39,7 +39,10 @@ class CivitaiClient:
return return
self._initialized = True 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( async def _make_request(
self, self,
@@ -190,7 +193,9 @@ class CivitaiClient:
"""Get all versions of a model with local availability info""" """Get all versions of a model with local availability info"""
try: try:
success, result = await self._make_request( 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: if success:
# Also return model type along with versions # Also return model type along with versions
@@ -346,7 +351,9 @@ class CivitaiClient:
async def _fetch_model_data(self, model_id: int) -> Optional[Dict]: async def _fetch_model_data(self, model_id: int) -> Optional[Dict]:
success, data = await self._make_request( 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: if success:
return data return data
@@ -358,7 +365,9 @@ class CivitaiClient:
return None return None
success, version = await self._make_request( 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: if success:
return version return version
@@ -371,7 +380,9 @@ class CivitaiClient:
return None return None
success, version = await self._make_request( 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: if success:
return version return version
@@ -453,13 +464,11 @@ class CivitaiClient:
try: try:
url = f"{self.base_url}/model-versions/{version_id}" 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) success, result = await self._make_request("GET", url, use_auth=True)
if success: if success:
logger.debug( logger.debug("Successfully fetched model version info for: %s", version_id)
f"Successfully fetched model version info for: {version_id}"
)
self._remove_comfy_metadata(result) self._remove_comfy_metadata(result)
return result, None return result, None
@@ -479,48 +488,58 @@ class CivitaiClient:
logger.error(error_msg) logger.error(error_msg)
return None, 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 """Fetch image information from Civitai API
Args: Args:
image_id: The Civitai image ID image_id: The Civitai image ID
source_url: Original image page URL. Accepted for caller compatibility;
API requests always target ``civitai.red``.
Returns: Returns:
Optional[Dict]: The image data or None if not found Optional[Dict]: The image data or None if not found
""" """
try: try:
url = f"{self.base_url}/images?imageId={image_id}&nsfw=X"
requested_id = int(image_id) requested_id = int(image_id)
url = self._build_image_info_url(image_id)
logger.debug(f"Fetching image info for ID: {image_id}")
success, result = await self._make_request("GET", url, use_auth=True) success, result = await self._make_request("GET", url, use_auth=True)
if success: if not success:
if result and "items" in result and isinstance(result["items"], list): logger.error(
items = result["items"] "Failed to fetch image info for ID %s from civitai.red: %s",
image_id,
# First, try to find the item with matching ID result,
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}")
return None 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 return None
except RateLimitError: except RateLimitError:
raise raise
@@ -539,8 +558,12 @@ class CivitaiClient:
return None return None
try: try:
url = f"{self.base_url}/models?username={username}" success, result = await self._make_request(
success, result = await self._make_request("GET", url, use_auth=True) "GET",
f"{self.base_url}/models",
use_auth=True,
params={"username": username},
)
if not success: if not success:
logger.error("Failed to fetch models for %s: %s", username, result) 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 from __future__ import annotations
import logging import logging
import re
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_EMBEDDED_COMMAND_PATTERN = re.compile(r"\s/\w")
class CustomWordsService: class CustomWordsService:
"""Service for autocomplete via TagFTSIndex. """Service for autocomplete via TagFTSIndex.
@@ -77,12 +79,28 @@ class CustomWordsService:
Returns: Returns:
List of dicts with tag_name, category, and post_count. 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() tag_index = self._get_tag_index()
if tag_index is not None: if tag_index is not None:
results = tag_index.search( return tag_index.search(
search_term, categories=categories, limit=limit, offset=offset normalized_search, categories=categories, limit=limit, offset=offset
) )
return results
logger.debug("TagFTSIndex not available, returning empty results") logger.debug("TagFTSIndex not available, returning empty results")
return [] return []

View File

@@ -16,7 +16,7 @@ from ..utils.constants import (
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS, SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
VALID_LORA_TYPES, VALID_LORA_TYPES,
) )
from ..utils.civitai_utils import rewrite_preview_url from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
from ..utils.utils import sanitize_folder_name from ..utils.utils import sanitize_folder_name
from ..utils.exif_utils import ExifUtils from ..utils.exif_utils import ExifUtils
@@ -31,6 +31,11 @@ import tempfile
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
CIVITAI_DOWNLOAD_URL_PREFIXES = (
"https://civitai.com/api/download/",
"https://civitai.red/api/download/",
)
class DownloadManager: class DownloadManager:
_instance = None _instance = None
@@ -639,7 +644,9 @@ class DownloadManager:
if mirrors: if mirrors:
for mirror in mirrors: for mirror in mirrors:
if mirror.get("deletedAt") is None and mirror.get("url"): if mirror.get("deletedAt") is None and mirror.get("url"):
download_urls.append(mirror["url"]) download_urls.append(
normalize_civitai_download_url(mirror["url"])
)
# When source is 'civarchive', prioritize non-Civitai URLs # When source is 'civarchive', prioritize non-Civitai URLs
# This avoids failed downloads from deleted Civitai models # This avoids failed downloads from deleted Civitai models
@@ -647,18 +654,20 @@ class DownloadManager:
civitai_urls = [ civitai_urls = [
u u
for u in download_urls for u in download_urls
if u.startswith("https://civitai.com/api/download/") if u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
] ]
non_civitai_urls = [ non_civitai_urls = [
u u
for u in download_urls 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 download_urls = non_civitai_urls + civitai_urls
else: else:
download_url = file_info.get("downloadUrl") download_url = file_info.get("downloadUrl")
if download_url: if download_url:
download_urls.append(download_url) download_urls.append(
normalize_civitai_download_url(download_url)
)
if not download_urls: if not download_urls:
return {"success": False, "error": "No mirror URL found"} return {"success": False, "error": "No mirror URL found"}
@@ -1133,7 +1142,8 @@ class DownloadManager:
pause_control.update_stall_timeout(downloader.stall_timeout) pause_control.update_stall_timeout(downloader.stall_timeout)
last_error = None last_error = None
for download_url in download_urls: for download_url in download_urls:
use_auth = download_url.startswith("https://civitai.com/api/download/") download_url = normalize_civitai_download_url(download_url)
use_auth = download_url.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
download_kwargs = { download_kwargs = {
"progress_callback": lambda progress, snapshot=None: ( "progress_callback": lambda progress, snapshot=None: (
self._handle_download_progress( self._handle_download_progress(

View File

@@ -42,6 +42,7 @@ class EmbeddingService(BaseModelService):
"notes": embedding_data.get("notes", ""), "notes": embedding_data.get("notes", ""),
"sub_type": sub_type, "sub_type": sub_type,
"favorite": embedding_data.get("favorite", False), "favorite": embedding_data.get("favorite", False),
"exclude": bool(embedding_data.get("exclude", False)),
"update_available": bool(embedding_data.get("update_available", False)), "update_available": bool(embedding_data.get("update_available", False)),
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)), "skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)),
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True) "civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True)

View File

@@ -48,6 +48,7 @@ class LoraService(BaseModelService):
"usage_tips": lora_data.get("usage_tips", ""), "usage_tips": lora_data.get("usage_tips", ""),
"notes": lora_data.get("notes", ""), "notes": lora_data.get("notes", ""),
"favorite": lora_data.get("favorite", False), "favorite": lora_data.get("favorite", False),
"exclude": bool(lora_data.get("exclude", False)),
"update_available": bool(lora_data.get("update_available", False)), "update_available": bool(lora_data.get("update_available", False)),
"skip_metadata_refresh": bool( "skip_metadata_refresh": bool(
lora_data.get("skip_metadata_refresh", False) lora_data.get("skip_metadata_refresh", False)

View File

@@ -8,6 +8,7 @@ from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Opti
from ..services.service_registry import ServiceRegistry from ..services.service_registry import ServiceRegistry
from ..utils.constants import PREVIEW_EXTENSIONS from ..utils.constants import PREVIEW_EXTENSIONS
from ..utils.metadata_manager import MetadataManager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -207,11 +208,56 @@ class ModelLifecycleService:
excluded = getattr(self._scanner, "_excluded_models", None) excluded = getattr(self._scanner, "_excluded_models", None)
if isinstance(excluded, list): if isinstance(excluded, list):
excluded.append(file_path) if file_path not in excluded:
excluded.append(file_path)
persist_current_cache = getattr(self._scanner, "_persist_current_cache", None)
if callable(persist_current_cache):
await persist_current_cache()
message = f"Model {os.path.basename(file_path)} excluded" message = f"Model {os.path.basename(file_path)} excluded"
return {"success": True, "message": message} return {"success": True, "message": message}
async def unexclude_model(self, file_path: str) -> Dict[str, object]:
"""Restore a previously excluded model to the active cache."""
if not file_path:
raise ValueError("Model path is required")
if not os.path.exists(file_path):
raise ValueError("Model file does not exist")
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
metadata_payload = await self._metadata_loader(metadata_path)
metadata_payload["exclude"] = False
await self._metadata_manager.save_metadata(file_path, metadata_payload)
metadata, should_skip = await MetadataManager.load_metadata(
file_path,
self._scanner.model_class,
)
if should_skip:
metadata = None
if metadata is None:
metadata = metadata_payload
excluded = getattr(self._scanner, "_excluded_models", None)
if isinstance(excluded, list):
self._scanner._excluded_models = [
path for path in excluded if path != file_path
]
await self._scanner.update_single_model_cache(
file_path,
file_path,
metadata,
recalculate_type=True,
)
message = f"Model {os.path.basename(file_path)} restored"
return {"success": True, "message": message}
async def bulk_delete_models(self, file_paths: Iterable[str]) -> Dict[str, object]: async def bulk_delete_models(self, file_paths: Iterable[str]) -> Dict[str, object]:
"""Delete a collection of models via the scanner bulk operation.""" """Delete a collection of models via the scanner bulk operation."""

View File

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

View File

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

View File

@@ -450,9 +450,9 @@ class TagFTSIndex:
the tag_name, the result will include a "matched_alias" field. the tag_name, the result will include a "matched_alias" field.
Ranking is based on a combination of: Ranking is based on a combination of:
1. FTS5 bm25 relevance score (how well the text matches) 1. Exact prefix match boost (tag_name starts with query)
2. Post count (popularity) 2. Post count to preserve expected autocomplete ordering
3. Exact prefix match boost (tag_name starts with query) 3. FTS5 bm25 relevance score as a deterministic tie-breaker
Args: Args:
query: The search query string. query: The search query string.
@@ -484,65 +484,17 @@ class TagFTSIndex:
with self._lock: with self._lock:
conn = self._connect(readonly=True) conn = self._connect(readonly=True)
try: try:
# Build the SQL query with bm25 ranking sql, params = self._build_search_statement(
# FTS5 bm25() returns negative scores, lower is better query_lower=query_lower,
# We use -bm25() to get higher=better scores fts_query=fts_query,
# Weights: -100.0 for exact matches, 1.0 for others categories=categories,
# Add LOG10(post_count) weighting to boost popular tags limit=limit,
# Use CASE to boost tag_name prefix matches above alias matches offset=offset,
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]
cursor = conn.execute(sql, params) cursor = conn.execute(sql, params)
rows = cursor.fetchall()
results = [] results = []
for row in cursor.fetchall(): for row in rows:
result = { result = {
"tag_name": row[0], "tag_name": row[0],
"category": row[1], "category": row[1],
@@ -571,6 +523,62 @@ class TagFTSIndex:
logger.debug("Tag FTS search error for query '%s': %s", query, exc) logger.debug("Tag FTS search error for query '%s': %s", query, exc)
return [] 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( def _find_matched_alias(
self, query: str, tag_name: str, aliases_str: str self, query: str, tag_name: str, aliases_str: str
) -> Optional[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 from __future__ import annotations
import re
from typing import Any, Dict, Iterable, Mapping, Sequence 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",) _DEFAULT_ALLOW_COMMERCIAL_USE: Sequence[str] = ("Sell",)
_LICENSE_DEFAULTS: Dict[str, Any] = { _LICENSE_DEFAULTS: Dict[str, Any] = {
"allowNoCredit": True, "allowNoCredit": True,
@@ -17,6 +20,133 @@ _COMMERCIAL_ALLOWED_VALUES = {"sell", "rent", "rentcivit", "image"}
_COMMERCIAL_SHIFT = 1 _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 normalize_civitai_download_url(url: str | None) -> str | None:
"""Rewrite Civitai download URLs to the canonical authenticated host."""
if not url:
return url
try:
parsed = urlparse(url)
except ValueError:
return url
hostname = parsed.hostname.lower() if parsed.hostname else None
if hostname != "civitai.red" or not parsed.path.startswith("/api/download/"):
return url
return urlunparse(parsed._replace(netloc="civitai.com"))
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]: def _normalize_commercial_values(value: Any) -> Sequence[str]:
"""Return a normalized list of commercial permissions preserving source values.""" """Return a normalized list of commercial permissions preserving source values."""
@@ -199,6 +329,10 @@ def rewrite_preview_url(
__all__ = [ __all__ = [
"build_license_flags", "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_payload",
"resolve_license_info", "resolve_license_info",
"rewrite_preview_url", "rewrite_preview_url",

View File

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

View File

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

View File

@@ -243,3 +243,58 @@
-ms-user-select: none; -ms-user-select: none;
user-select: none; user-select: none;
} }
.excluded-view-banner {
margin-bottom: var(--space-2);
padding: 12px 16px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
background: linear-gradient(
135deg,
oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.08),
var(--card-bg)
);
}
.excluded-view-banner__content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.excluded-view-banner__title {
display: inline-flex;
align-items: center;
gap: 10px;
font-weight: 600;
color: var(--text-color);
}
.excluded-view-banner__back {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid var(--border-color);
background: var(--card-bg);
color: var(--text-color);
border-radius: var(--border-radius-xs);
padding: 8px 12px;
cursor: pointer;
}
.excluded-view-banner__back:hover {
border-color: var(--lora-accent);
color: var(--lora-accent);
}
@media (max-width: 768px) {
.excluded-view-banner__content {
flex-direction: column;
align-items: stretch;
}
.excluded-view-banner__back {
justify-content: center;
}
}

View File

@@ -680,3 +680,22 @@
margin-left: 0; margin-left: 0;
line-height: 1; line-height: 1;
} }
.excluded-model {
border-style: dashed;
}
.model-excluded-badge {
width: 16px;
height: 16px;
padding: 0;
border-radius: 3px;
background: color-mix(in oklab, var(--warning-color, #d97706) 85%, white 15%);
color: white;
font-size: 0.65rem;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
opacity: 0.9;
}

View File

@@ -56,8 +56,10 @@ export function getApiEndpoints(modelType) {
return { return {
// Base CRUD operations // Base CRUD operations
list: `/api/lm/${modelType}/list`, list: `/api/lm/${modelType}/list`,
excluded: `/api/lm/${modelType}/excluded`,
delete: `/api/lm/${modelType}/delete`, delete: `/api/lm/${modelType}/delete`,
exclude: `/api/lm/${modelType}/exclude`, exclude: `/api/lm/${modelType}/exclude`,
unexclude: `/api/lm/${modelType}/unexclude`,
rename: `/api/lm/${modelType}/rename`, rename: `/api/lm/${modelType}/rename`,
save: `/api/lm/${modelType}/save-metadata`, save: `/api/lm/${modelType}/save-metadata`,
cancelTask: `/api/lm/${modelType}/cancel-task`, cancelTask: `/api/lm/${modelType}/cancel-task`,

View File

@@ -51,6 +51,7 @@ export class BaseModelApiClient {
async fetchModelsPage(page = 1, pageSize = null) { async fetchModelsPage(page = 1, pageSize = null) {
const pageState = this.getPageState(); const pageState = this.getPageState();
const actualPageSize = pageSize || pageState.pageSize || this.apiConfig.config.defaultPageSize; const actualPageSize = pageSize || pageState.pageSize || this.apiConfig.config.defaultPageSize;
const isExcludedView = pageState.viewMode === 'excluded';
try { try {
const params = this._buildQueryParams({ const params = this._buildQueryParams({
@@ -71,7 +72,10 @@ export class BaseModelApiClient {
}; };
} }
const response = await fetch(`${this.apiConfig.endpoints.list}?${params}`); const endpoint = isExcludedView
? this.apiConfig.endpoints.excluded
: this.apiConfig.endpoints.list;
const response = await fetch(`${endpoint}?${params}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch ${this.apiConfig.config.displayName}s: ${response.statusText}`); throw new Error(`Failed to fetch ${this.apiConfig.config.displayName}s: ${response.statusText}`);
} }
@@ -84,7 +88,7 @@ export class BaseModelApiClient {
totalPages: data.total_pages, totalPages: data.total_pages,
currentPage: page, currentPage: page,
hasMore: page < data.total_pages, hasMore: page < data.total_pages,
folders: data.folders folders: data.folders || []
}; };
} catch (error) { } catch (error) {
@@ -212,6 +216,50 @@ export class BaseModelApiClient {
} }
} }
async unexcludeModel(filePath) {
try {
state.loadingManager.showSimpleLoading(`Restoring ${this.apiConfig.config.singularName}...`);
const response = await fetch(this.apiConfig.endpoints.unexclude, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: filePath })
});
if (!response.ok) {
throw new Error(`Failed to restore ${this.apiConfig.config.singularName}: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
if (state.virtualScroller) {
state.virtualScroller.removeItemByFilePath(filePath);
}
showToast(
'toast.api.restoreSuccess',
{ type: this.apiConfig.config.displayName },
'success',
`Restored ${this.apiConfig.config.displayName}`
);
return true;
}
throw new Error(data.error || `Failed to restore ${this.apiConfig.config.singularName}`);
} catch (error) {
console.error(`Error restoring ${this.apiConfig.config.singularName}:`, error);
showToast(
'toast.api.restoreFailed',
{ type: this.apiConfig.config.singularName, message: error.message },
'error',
`Failed to restore ${this.apiConfig.config.singularName}: ${error.message}`
);
return false;
} finally {
state.loadingManager.hide();
}
}
async renameModelFile(filePath, newFileName) { async renameModelFile(filePath, newFileName) {
try { try {
state.loadingManager.showSimpleLoading(`Renaming ${this.apiConfig.config.singularName} file...`); state.loadingManager.showSimpleLoading(`Renaming ${this.apiConfig.config.singularName} file...`);
@@ -883,20 +931,21 @@ export class BaseModelApiClient {
_buildQueryParams(baseParams, pageState) { _buildQueryParams(baseParams, pageState) {
const params = new URLSearchParams(baseParams); const params = new URLSearchParams(baseParams);
const isExcludedView = pageState.viewMode === 'excluded';
if (pageState.activeFolder !== null) { if (!isExcludedView && pageState.activeFolder !== null) {
params.append('folder', pageState.activeFolder); params.append('folder', pageState.activeFolder);
} }
if (pageState.showFavoritesOnly) { if (!isExcludedView && pageState.showFavoritesOnly) {
params.append('favorites_only', 'true'); params.append('favorites_only', 'true');
} }
if (pageState.showUpdateAvailableOnly) { if (!isExcludedView && pageState.showUpdateAvailableOnly) {
params.append('update_available_only', 'true'); params.append('update_available_only', 'true');
} }
if (this.apiConfig.config.supportsLetterFilter && pageState.activeLetterFilter) { if (!isExcludedView && this.apiConfig.config.supportsLetterFilter && pageState.activeLetterFilter) {
params.append('first_letter', pageState.activeLetterFilter); params.append('first_letter', pageState.activeLetterFilter);
} }
@@ -918,7 +967,7 @@ export class BaseModelApiClient {
params.append('recursive', pageState.searchOptions.recursive ? 'true' : 'false'); params.append('recursive', pageState.searchOptions.recursive ? 'true' : 'false');
if (pageState.filters) { if (!isExcludedView && pageState.filters) {
if (pageState.filters.tags && Object.keys(pageState.filters.tags).length > 0) { if (pageState.filters.tags && Object.keys(pageState.filters.tags).length > 0) {
Object.entries(pageState.filters.tags).forEach(([tag, state]) => { Object.entries(pageState.filters.tags).forEach(([tag, state]) => {
if (state === 'include') { if (state === 'include') {
@@ -981,7 +1030,9 @@ export class BaseModelApiClient {
} }
} }
this._addModelSpecificParams(params, pageState); if (!isExcludedView) {
this._addModelSpecificParams(params, pageState);
}
return params; return params;
} }

View File

@@ -24,6 +24,7 @@ export class CheckpointContextMenu extends BaseContextMenu {
showMenu(x, y, card) { showMenu(x, y, card) {
super.showMenu(x, y, card); super.showMenu(x, y, card);
this.updateExcludeMenuItem();
// Update the "Move to other root" label based on current model type // Update the "Move to other root" label based on current model type
const moveOtherItem = this.menu.querySelector('[data-action="move-other"]'); const moveOtherItem = this.menu.querySelector('[data-action="move-other"]');
@@ -83,6 +84,9 @@ export class CheckpointContextMenu extends BaseContextMenu {
case 'exclude': case 'exclude':
showExcludeModal(this.currentCard.dataset.filepath); showExcludeModal(this.currentCard.dataset.filepath);
break; break;
case 'restore':
this.restoreExcludedModel(this.currentCard.dataset.filepath);
break;
} }
} }

View File

@@ -18,6 +18,11 @@ export class EmbeddingContextMenu extends BaseContextMenu {
async saveModelMetadata(filePath, data) { async saveModelMetadata(filePath, data) {
return getModelApiClient().saveModelMetadata(filePath, data); return getModelApiClient().saveModelMetadata(filePath, data);
} }
showMenu(x, y, card) {
super.showMenu(x, y, card);
this.updateExcludeMenuItem();
}
handleMenuAction(action) { handleMenuAction(action) {
// First try to handle with common actions // First try to handle with common actions
@@ -56,6 +61,9 @@ export class EmbeddingContextMenu extends BaseContextMenu {
case 'exclude': case 'exclude':
showExcludeModal(this.currentCard.dataset.filepath); showExcludeModal(this.currentCard.dataset.filepath);
break; break;
case 'restore':
this.restoreExcludedModel(this.currentCard.dataset.filepath);
break;
} }
} }
} }

View File

@@ -22,6 +22,7 @@ export class GlobalContextMenu extends BaseContextMenu {
const licenseRefreshItem = this.menu.querySelector('[data-action="fetch-missing-licenses"]'); const licenseRefreshItem = this.menu.querySelector('[data-action="fetch-missing-licenses"]');
const downloadExamplesItem = this.menu.querySelector('[data-action="download-example-images"]'); const downloadExamplesItem = this.menu.querySelector('[data-action="download-example-images"]');
const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]'); const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]');
const excludedModelsItem = this.menu.querySelector('[data-action="manage-excluded-models"]');
const repairRecipesItem = this.menu.querySelector('[data-action="repair-recipes"]'); const repairRecipesItem = this.menu.querySelector('[data-action="repair-recipes"]');
if (isRecipesPage) { if (isRecipesPage) {
@@ -29,12 +30,14 @@ export class GlobalContextMenu extends BaseContextMenu {
licenseRefreshItem?.classList.add('hidden'); licenseRefreshItem?.classList.add('hidden');
downloadExamplesItem?.classList.add('hidden'); downloadExamplesItem?.classList.add('hidden');
cleanupExamplesItem?.classList.add('hidden'); cleanupExamplesItem?.classList.add('hidden');
excludedModelsItem?.classList.add('hidden');
repairRecipesItem?.classList.remove('hidden'); repairRecipesItem?.classList.remove('hidden');
} else { } else {
modelUpdateItem?.classList.remove('hidden'); modelUpdateItem?.classList.remove('hidden');
licenseRefreshItem?.classList.remove('hidden'); licenseRefreshItem?.classList.remove('hidden');
downloadExamplesItem?.classList.remove('hidden'); downloadExamplesItem?.classList.remove('hidden');
cleanupExamplesItem?.classList.remove('hidden'); cleanupExamplesItem?.classList.remove('hidden');
excludedModelsItem?.classList.remove('hidden');
repairRecipesItem?.classList.add('hidden'); repairRecipesItem?.classList.add('hidden');
} }
@@ -68,12 +71,21 @@ export class GlobalContextMenu extends BaseContextMenu {
console.error('Failed to repair recipes:', error); console.error('Failed to repair recipes:', error);
}); });
break; break;
case 'manage-excluded-models':
this.manageExcludedModels();
break;
default: default:
console.warn(`Unhandled global context menu action: ${action}`); console.warn(`Unhandled global context menu action: ${action}`);
break; break;
} }
} }
manageExcludedModels() {
window.pageControls?.enterExcludedView?.().catch((error) => {
console.error('Failed to open excluded models view:', error);
});
}
async downloadExampleImages(menuItem) { async downloadExampleImages(menuItem) {
const downloadPath = state?.global?.settings?.example_images_path; const downloadPath = state?.global?.settings?.example_images_path;
if (!downloadPath) { if (!downloadPath) {

View File

@@ -20,6 +20,11 @@ export class LoraContextMenu extends BaseContextMenu {
return getModelApiClient().saveModelMetadata(filePath, data); return getModelApiClient().saveModelMetadata(filePath, data);
} }
showMenu(x, y, card) {
super.showMenu(x, y, card);
this.updateExcludeMenuItem();
}
handleMenuAction(action, menuItem) { handleMenuAction(action, menuItem) {
// First try to handle with common actions // First try to handle with common actions
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) { if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
@@ -61,6 +66,9 @@ export class LoraContextMenu extends BaseContextMenu {
case 'exclude': case 'exclude':
showExcludeModal(this.currentCard.dataset.filepath); showExcludeModal(this.currentCard.dataset.filepath);
break; break;
case 'restore':
this.restoreExcludedModel(this.currentCard.dataset.filepath);
break;
} }
} }

View File

@@ -6,9 +6,43 @@ import { bulkManager } from '../../managers/BulkManager.js';
import { MODEL_CONFIG } from '../../api/apiConfig.js'; import { MODEL_CONFIG } from '../../api/apiConfig.js';
import { translate } from '../../utils/i18nHelpers.js'; import { translate } from '../../utils/i18nHelpers.js';
import { getNsfwLevelSelector } from '../shared/NsfwLevelSelector.js'; import { getNsfwLevelSelector } from '../shared/NsfwLevelSelector.js';
import { extractCivitaiModelUrlParts } from '../../utils/civitaiUtils.js';
// Mixin with shared functionality for LoraContextMenu and CheckpointContextMenu // Mixin with shared functionality for LoraContextMenu and CheckpointContextMenu
export const ModelContextMenuMixin = { export const ModelContextMenuMixin = {
isExcludedView() {
return state?.pages?.[state.currentPageType]?.viewMode === 'excluded';
},
updateExcludeMenuItem() {
const excludeItem = this.menu?.querySelector('[data-action="exclude"], [data-action="restore"]');
if (!excludeItem) {
return;
}
const isExcludedView = this.isExcludedView();
excludeItem.dataset.action = isExcludedView ? 'restore' : 'exclude';
excludeItem.innerHTML = isExcludedView
? `<i class="fas fa-undo"></i> <span>${translate('loras.contextMenu.restoreModel', {}, 'Restore model')}</span>`
: `<i class="fas fa-eye-slash"></i> <span>${translate('loras.contextMenu.excludeModel', {}, 'Exclude model')}</span>`;
},
async restoreExcludedModel(filePath) {
const restored = await getModelApiClient().unexcludeModel(filePath);
if (!restored) {
return;
}
if (window.pageControls?.exitExcludedView) {
await window.pageControls.exitExcludedView();
} else {
const resetFn = this.resetAndReload || resetAndReload;
if (typeof resetFn === 'function') {
await resetFn(true);
}
}
},
// NSFW Selector methods // NSFW Selector methods
initNSFWSelector() { initNSFWSelector() {
if (this._nsfwSelectorInitialized) { if (this._nsfwSelectorInitialized) {
@@ -154,25 +188,7 @@ export const ModelContextMenuMixin = {
}, },
extractModelVersionId(url) { extractModelVersionId(url) {
try { return extractCivitaiModelUrlParts(url);
// 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 };
}
}, },
parseModelId(value) { parseModelId(value) {

View File

@@ -1,7 +1,7 @@
// PageControls.js - Manages controls for both LoRAs and Checkpoints pages // PageControls.js - Manages controls for both LoRAs and Checkpoints pages
import { state, getCurrentPageState, setCurrentPageType } from '../../state/index.js'; import { state, getCurrentPageState, setCurrentPageType } from '../../state/index.js';
import { getStorageItem, setStorageItem, getSessionItem, setSessionItem } from '../../utils/storageHelpers.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 { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
import { sidebarManager } from '../SidebarManager.js'; import { sidebarManager } from '../SidebarManager.js';
@@ -38,8 +38,12 @@ export class PageControls {
// Initialize favorites filter button state // Initialize favorites filter button state
this.initFavoritesFilter(); this.initFavoritesFilter();
this.initExcludedViewControls();
this.syncExcludedViewState();
console.log(`PageControls initialized for ${pageType} page`); console.log(`PageControls initialized for ${pageType} page`);
window.pageControls = this;
} }
/** /**
@@ -56,6 +60,19 @@ export class PageControls {
// Load sort preference // Load sort preference
this.loadSortPreference(); this.loadSortPreference();
if (!this.pageState.viewMode) {
this.pageState.viewMode = 'active';
}
if (!this.pageState.excludedViewState) {
this.pageState.excludedViewState = {
sortBy: 'name:asc',
search: '',
};
}
if (!this.pageState.filters?.search) {
this.pageState.filters.search = '';
}
} }
/** /**
@@ -116,6 +133,15 @@ export class PageControls {
// Page-specific event listeners // Page-specific event listeners
this.initPageSpecificListeners(); this.initPageSpecificListeners();
} }
initExcludedViewControls() {
const backButton = document.getElementById('excludedViewBackBtn');
if (backButton) {
backButton.addEventListener('click', async () => {
await this.exitExcludedView();
});
}
}
/** /**
* Initialize dropdown functionality * Initialize dropdown functionality
@@ -334,6 +360,13 @@ export class PageControls {
* @param {string} sortValue - The sort value to save * @param {string} sortValue - The sort value to save
*/ */
saveSortPreference(sortValue) { saveSortPreference(sortValue) {
if (this.pageState.viewMode === 'excluded') {
this.pageState.excludedViewState = {
...(this.pageState.excludedViewState || {}),
sortBy: sortValue,
};
return;
}
setStorageItem(`${this.pageType}_sort`, sortValue); setStorageItem(`${this.pageType}_sort`, sortValue);
} }
@@ -353,18 +386,8 @@ export class PageControls {
const metaData = JSON.parse(card.dataset.meta); const metaData = JSON.parse(card.dataset.meta);
const civitaiId = metaData.modelId; const civitaiId = metaData.modelId;
const versionId = metaData.id; const versionId = metaData.id;
// Build URL openCivitaiByMetadata(civitaiId, versionId, modelName);
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');
}
} }
/** /**
@@ -483,6 +506,8 @@ export class PageControls {
// Update app state // Update app state
this.pageState.showFavoritesOnly = showFavoritesOnly; this.pageState.showFavoritesOnly = showFavoritesOnly;
} }
this.updateActionButtonStates();
} }
/** /**
@@ -499,12 +524,17 @@ export class PageControls {
if (updateFilterBtn) { if (updateFilterBtn) {
updateFilterBtn.classList.toggle('active', showUpdatesOnly); updateFilterBtn.classList.toggle('active', showUpdatesOnly);
} }
this.updateActionButtonStates();
} }
/** /**
* Toggle favorites-only filter and reload models * Toggle favorites-only filter and reload models
*/ */
async toggleFavoritesOnly() { async toggleFavoritesOnly() {
if (this.pageState.viewMode === 'excluded') {
return;
}
const favoriteFilterBtn = document.getElementById('favoriteFilterBtn'); const favoriteFilterBtn = document.getElementById('favoriteFilterBtn');
// Toggle the filter state in storage // Toggle the filter state in storage
@@ -531,6 +561,9 @@ export class PageControls {
* Toggle update-available-only filter and reload models * Toggle update-available-only filter and reload models
*/ */
async toggleUpdateAvailableOnly() { async toggleUpdateAvailableOnly() {
if (this.pageState.viewMode === 'excluded') {
return;
}
const updateFilterBtn = document.getElementById('updateFilterBtn'); const updateFilterBtn = document.getElementById('updateFilterBtn');
const storageKey = `show_update_available_only_${this.pageType}`; const storageKey = `show_update_available_only_${this.pageType}`;
const newState = !this.pageState.showUpdateAvailableOnly; const newState = !this.pageState.showUpdateAvailableOnly;
@@ -545,6 +578,234 @@ export class PageControls {
await this.resetAndReload(true); await this.resetAndReload(true);
} }
cloneFilters(filters = this.pageState.filters) {
return JSON.parse(JSON.stringify(filters || {}));
}
buildExcludedFilters(search = '') {
return {
baseModel: [],
tags: {},
license: {},
modelTypes: [],
search,
tagLogic: 'any',
};
}
applyFilterState(filters) {
this.pageState.filters = filters;
if (window.filterManager) {
window.filterManager.filters = window.filterManager.initializeFilters(filters);
window.filterManager.updateActiveFiltersCount();
if (typeof window.filterManager.updateSelections === 'function') {
window.filterManager.updateSelections();
}
window.filterManager.closeFilterPanel();
}
}
updateActionButtonStates() {
const favoriteFilterBtn = document.getElementById('favoriteFilterBtn');
if (favoriteFilterBtn) {
favoriteFilterBtn.classList.toggle('active', Boolean(this.pageState.showFavoritesOnly));
}
const updateFilterBtn = document.getElementById('updateFilterBtn');
if (updateFilterBtn) {
updateFilterBtn.classList.toggle('active', Boolean(this.pageState.showUpdateAvailableOnly));
}
}
syncExcludedViewState() {
const isExcludedView = this.pageState.viewMode === 'excluded';
const sortSelect = document.getElementById('sortSelect');
const searchInput = document.getElementById('searchInput');
const excludedBanner = document.getElementById('excludedViewBanner');
const filterButton = document.getElementById('filterButton');
const breadcrumbContainer = document.getElementById('breadcrumbContainer');
const duplicatesBanner = document.getElementById('duplicatesBanner');
const alphabetBarContainer = document.querySelector('.alphabet-bar-container');
const hiddenSelectors = [
'[data-action="fetch"]',
'[data-action="download"]',
'[data-action="bulk"]',
'[data-action="find-duplicates"]',
'#favoriteFilterBtn',
'.update-filter-group',
];
const customFilterIndicator = document.getElementById('customFilterIndicator');
document.body.classList.toggle('excluded-view-active', isExcludedView);
excludedBanner?.classList.toggle('hidden', !isExcludedView);
breadcrumbContainer?.classList.toggle('hidden', isExcludedView);
alphabetBarContainer?.classList.toggle('hidden', isExcludedView);
if (duplicatesBanner && isExcludedView) {
duplicatesBanner.style.display = 'none';
}
hiddenSelectors.forEach((selector) => {
document.querySelectorAll(selector).forEach((element) => {
element.classList.toggle('hidden', isExcludedView);
});
});
if (customFilterIndicator && isExcludedView) {
customFilterIndicator.classList.add('hidden');
}
if (filterButton) {
filterButton.disabled = isExcludedView;
filterButton.classList.toggle('hidden', isExcludedView);
}
const activeFiltersCount = document.getElementById('activeFiltersCount');
if (activeFiltersCount && isExcludedView) {
activeFiltersCount.style.display = 'none';
}
if (sortSelect) {
sortSelect.value = this.pageState.sortBy;
}
if (searchInput) {
searchInput.value = this.pageState.filters?.search || '';
}
this.updateActionButtonStates();
if (this.sidebarManager) {
const shouldShowSidebar = !isExcludedView && state?.global?.settings?.show_folder_sidebar !== false;
this.sidebarManager.setSidebarEnabled(shouldShowSidebar).catch((error) => {
console.error('Failed to update sidebar visibility:', error);
});
}
}
suspendInteractiveModes() {
const snapshot = {
bulkMode: Boolean(state.bulkMode),
duplicatesMode: Boolean(this.pageState.duplicatesMode),
};
if (snapshot.bulkMode && window.bulkManager?.toggleBulkMode) {
window.bulkManager.toggleBulkMode();
}
if (snapshot.duplicatesMode && window.modelDuplicatesManager?.exitDuplicateMode) {
window.modelDuplicatesManager.exitDuplicateMode();
}
return snapshot;
}
async restoreInteractiveModes(snapshot = {}) {
if (snapshot.bulkMode && !state.bulkMode && window.bulkManager?.toggleBulkMode) {
window.bulkManager.toggleBulkMode();
}
if (!snapshot.duplicatesMode || this.pageState.duplicatesMode) {
return;
}
const duplicatesManager = window.modelDuplicatesManager;
if (!duplicatesManager) {
return;
}
if (typeof duplicatesManager.enterDuplicateMode === 'function' &&
Array.isArray(duplicatesManager.duplicateGroups) &&
duplicatesManager.duplicateGroups.length > 0) {
duplicatesManager.enterDuplicateMode();
return;
}
if (typeof duplicatesManager.findDuplicates === 'function') {
await duplicatesManager.findDuplicates();
}
}
syncCustomFilterIndicator() {
const indicator = document.getElementById('customFilterIndicator');
if (!indicator) {
return;
}
if (this.pageState.viewMode === 'excluded') {
indicator.classList.add('hidden');
return;
}
if (typeof this.checkCustomFilters === 'function') {
this.checkCustomFilters();
}
}
async enterExcludedView() {
if (this.pageState.viewMode === 'excluded') {
return;
}
const interactionSnapshot = this.suspendInteractiveModes();
this.pageState.activeViewSnapshot = {
sortBy: this.pageState.sortBy,
activeFolder: this.pageState.activeFolder,
activeLetterFilter: this.pageState.activeLetterFilter ?? null,
showFavoritesOnly: this.pageState.showFavoritesOnly,
showUpdateAvailableOnly: this.pageState.showUpdateAvailableOnly,
bulkMode: interactionSnapshot.bulkMode,
duplicatesMode: interactionSnapshot.duplicatesMode,
filters: this.cloneFilters(),
};
const excludedState = this.pageState.excludedViewState || {
sortBy: 'name:asc',
search: '',
};
this.pageState.viewMode = 'excluded';
this.pageState.sortBy = excludedState.sortBy || 'name:asc';
this.pageState.currentPage = 1;
this.pageState.activeFolder = null;
this.pageState.activeLetterFilter = null;
this.pageState.showFavoritesOnly = false;
this.pageState.showUpdateAvailableOnly = false;
this.applyFilterState(this.buildExcludedFilters(excludedState.search || ''));
this.syncExcludedViewState();
await this.resetAndReload(false);
}
async exitExcludedView() {
if (this.pageState.viewMode !== 'excluded') {
return;
}
this.pageState.excludedViewState = {
...(this.pageState.excludedViewState || {}),
sortBy: this.pageState.sortBy,
search: this.pageState.filters?.search || '',
};
const snapshot = this.pageState.activeViewSnapshot || {};
this.pageState.viewMode = 'active';
this.pageState.sortBy = snapshot.sortBy || this.convertLegacySortFormat(getStorageItem(`${this.pageType}_sort`) || 'name:asc');
this.pageState.currentPage = 1;
this.pageState.activeFolder = snapshot.activeFolder ?? getStorageItem(`${this.pageType}_activeFolder`);
this.pageState.activeLetterFilter = snapshot.activeLetterFilter ?? null;
this.pageState.showFavoritesOnly = Boolean(snapshot.showFavoritesOnly);
this.pageState.showUpdateAvailableOnly = Boolean(snapshot.showUpdateAvailableOnly);
this.applyFilterState(snapshot.filters || this.buildExcludedFilters(''));
this.pageState.activeViewSnapshot = null;
this.syncExcludedViewState();
await this.resetAndReload(true);
this.syncCustomFilterIndicator();
await this.restoreInteractiveModes(snapshot);
}
/** /**
* Find duplicate models * Find duplicate models

View File

@@ -433,10 +433,11 @@ export function createModelCard(model, modelType) {
card.dataset.usage_count = String(model.usage_count); card.dataset.usage_count = String(model.usage_count);
card.dataset.notes = model.notes || ''; card.dataset.notes = model.notes || '';
card.dataset.base_model = model.base_model || 'Unknown'; card.dataset.base_model = model.base_model || 'Unknown';
card.dataset.favorite = model.favorite ? 'true' : 'false'; card.dataset.favorite = model.favorite ? 'true' : 'false';
const hasUpdateAvailable = Boolean(model.update_available); card.dataset.exclude = model.exclude ? 'true' : 'false';
card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false'; const hasUpdateAvailable = Boolean(model.update_available);
card.dataset.skip_metadata_refresh = model.skip_metadata_refresh ? 'true' : 'false'; card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false';
card.dataset.skip_metadata_refresh = model.skip_metadata_refresh ? 'true' : 'false';
// To only show usage_count when sorting by usage. // To only show usage_count when sorting by usage.
const pageState = getCurrentPageState(); const pageState = getCurrentPageState();
@@ -487,6 +488,9 @@ export function createModelCard(model, modelType) {
if (model.skip_metadata_refresh) { if (model.skip_metadata_refresh) {
card.classList.add('skip-refresh'); card.classList.add('skip-refresh');
} }
if (model.exclude) {
card.classList.add('excluded-model');
}
// Apply selection state if in bulk mode and this card is in the selected set (LoRA only) // Apply selection state if in bulk mode and this card is in the selected set (LoRA only)
if (modelType === MODEL_TYPES.LORA && state.bulkMode && state.selectedLoras.has(model.file_path)) { if (modelType === MODEL_TYPES.LORA && state.bulkMode && state.selectedLoras.has(model.file_path)) {
@@ -619,6 +623,11 @@ export function createModelCard(model, modelType) {
<i class="fas fa-ban"></i> <i class="fas fa-ban"></i>
</span> </span>
` : ''} ` : ''}
${model.exclude ? `
<span class="model-excluded-badge" title="${translate('globalContextMenu.manageExcludedModels.label', {}, 'Excluded Models')}">
<i class="fas fa-eye-slash"></i>
</span>
` : ''}
</div> </div>
<div class="card-actions"> <div class="card-actions">
${actionIcons} ${actionIcons}

View File

@@ -1,26 +1,21 @@
import { getModelApiClient } from '../../api/modelApiFactory.js'; import { getModelApiClient } from '../../api/modelApiFactory.js';
import { downloadManager } from '../../managers/DownloadManager.js'; import { downloadManager } from '../../managers/DownloadManager.js';
import { modalManager } from '../../managers/ModalManager.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 { translate } from '../../utils/i18nHelpers.js';
import { state } from '../../state/index.js'; import { state } from '../../state/index.js';
import { buildCivitaiModelUrl } from '../../utils/civitaiUtils.js';
import { formatFileSize } from './utils.js'; import { formatFileSize } from './utils.js';
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.mkv']; const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.mkv'];
const PREVIEW_PLACEHOLDER_URL = '/loras_static/images/no-preview.png'; const PREVIEW_PLACEHOLDER_URL = '/loras_static/images/no-preview.png';
function buildCivitaiVersionUrl(modelId, versionId) { function buildCivitaiVersionUrl(modelId, versionId) {
if (modelId == null || versionId == null) { return buildCivitaiModelUrl(
return null; modelId,
} versionId,
const normalizedModelId = String(modelId).trim(); state?.global?.settings?.civitai_host
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}`;
} }
function escapeHtml(value) { function escapeHtml(value) {
@@ -1352,6 +1347,13 @@ export function initVersionsTab({
} }
const row = event.target.closest('.model-version-row.is-clickable'); 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) { if (!row) {
return; return;
} }
@@ -1371,7 +1373,7 @@ export function initVersionsTab({
return; return;
} }
event.preventDefault(); event.preventDefault();
window.open(targetUrl, '_blank', 'noopener,noreferrer'); openCivitaiUrl(targetUrl);
}); });
// Listen for extension-triggered refresh requests // Listen for extension-triggered refresh requests

View File

@@ -6,6 +6,7 @@ import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js'; import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { FolderTreeManager } from '../components/FolderTreeManager.js'; import { FolderTreeManager } from '../components/FolderTreeManager.js';
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
import { extractCivitaiModelUrlParts } from '../utils/civitaiUtils.js';
export class DownloadManager { export class DownloadManager {
constructor() { constructor() {
@@ -197,21 +198,22 @@ export class DownloadManager {
} }
extractModelId(url) { 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); const civarchiveMatch = url.match(/https?:\/\/(?:www\.)?(?:civitaiarchive|civarchive)\.com\/models\/(\d+)/i);
if (civarchiveMatch) { if (civarchiveMatch) {
const versionMatch = url.match(/modelVersionId=(\d+)/i);
this.modelVersionId = versionMatch ? versionMatch[1] : null;
this.source = 'civarchive'; this.source = 'civarchive';
return civarchiveMatch[1]; return civarchiveMatch[1];
} }
const civitaiMatch = url.match(/https?:\/\/(?:www\.)?civitai\.com\/models\/(\d+)/i); const { modelId, modelVersionId } = extractCivitaiModelUrlParts(url);
if (civitaiMatch) { if (modelId) {
this.modelVersionId = modelVersionId;
this.source = null; this.source = null;
return civitaiMatch[1]; return modelId;
} }
this.modelVersionId = null;
this.source = null; this.source = null;
return null; return null;
} }

View File

@@ -802,6 +802,11 @@ export class SettingsManager {
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings; 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'); const recipesPathInput = document.getElementById('recipesPath');
if (recipesPathInput) { if (recipesPathInput) {
recipesPathInput.value = state.global.settings.recipes_path || ''; recipesPathInput.value = state.global.settings.recipes_path || '';

View File

@@ -5,6 +5,7 @@ import { DEFAULT_PATH_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/co
const DEFAULT_SETTINGS_BASE = Object.freeze({ const DEFAULT_SETTINGS_BASE = Object.freeze({
civitai_api_key: '', civitai_api_key: '',
civitai_host: 'civitai.com',
use_portable_settings: false, use_portable_settings: false,
language: 'en', language: 'en',
show_only_sfw: false, show_only_sfw: false,
@@ -89,7 +90,9 @@ export const state = {
baseModel: [], baseModel: [],
tags: {}, tags: {},
license: {}, license: {},
modelTypes: [] modelTypes: [],
search: '',
tagLogic: 'any',
}, },
bulkMode: false, bulkMode: false,
selectedLoras: new Set(), selectedLoras: new Set(),
@@ -97,6 +100,12 @@ export const state = {
showFavoritesOnly: false, showFavoritesOnly: false,
showUpdateAvailableOnly: false, showUpdateAvailableOnly: false,
duplicatesMode: false, duplicatesMode: false,
viewMode: 'active',
excludedViewState: {
sortBy: 'name:asc',
search: '',
},
activeViewSnapshot: null,
}, },
recipes: { recipes: {
@@ -146,7 +155,9 @@ export const state = {
baseModel: [], baseModel: [],
tags: {}, tags: {},
license: {}, license: {},
modelTypes: [] modelTypes: [],
search: '',
tagLogic: 'any',
}, },
modelType: 'checkpoint', // 'checkpoint' or 'diffusion_model' modelType: 'checkpoint', // 'checkpoint' or 'diffusion_model'
bulkMode: false, bulkMode: false,
@@ -155,6 +166,12 @@ export const state = {
showFavoritesOnly: false, showFavoritesOnly: false,
showUpdateAvailableOnly: false, showUpdateAvailableOnly: false,
duplicatesMode: false, duplicatesMode: false,
viewMode: 'active',
excludedViewState: {
sortBy: 'name:asc',
search: '',
},
activeViewSnapshot: null,
}, },
[MODEL_TYPES.EMBEDDING]: { [MODEL_TYPES.EMBEDDING]: {
@@ -177,7 +194,9 @@ export const state = {
baseModel: [], baseModel: [],
tags: {}, tags: {},
license: {}, license: {},
modelTypes: [] modelTypes: [],
search: '',
tagLogic: 'any',
}, },
bulkMode: false, bulkMode: false,
selectedModels: new Set(), selectedModels: new Set(),
@@ -185,6 +204,12 @@ export const state = {
showFavoritesOnly: false, showFavoritesOnly: false,
showUpdateAvailableOnly: false, showUpdateAvailableOnly: false,
duplicatesMode: false, duplicatesMode: false,
viewMode: 'active',
excludedViewState: {
sortBy: 'name:asc',
search: '',
},
activeViewSnapshot: null,
} }
}, },

View File

@@ -13,6 +13,64 @@ export const OptimizationMode = {
THUMBNAIL: 'thumbnail', 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. * Rewrite Civitai preview URLs to use optimized renditions.
* Mirrors the backend's rewrite_preview_url() function from py/utils/civitai_utils.py * Mirrors the backend's rewrite_preview_url() function from py/utils/civitai_utils.py
@@ -119,3 +177,50 @@ export function isCivitaiUrl(url) {
return false; 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

@@ -3,6 +3,66 @@ import { state, getCurrentPageState } from '../state/index.js';
import { getStorageItem, setStorageItem } from './storageHelpers.js'; import { getStorageItem, setStorageItem } from './storageHelpers.js';
import { NODE_TYPE_ICONS, DEFAULT_NODE_COLOR } from './constants.js'; import { NODE_TYPE_ICONS, DEFAULT_NODE_COLOR } from './constants.js';
import { eventManager } from './EventManager.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 * 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) { export function openCivitaiByMetadata(civitaiId, versionId, modelName = null) {
if (versionId) { const url = buildCivitaiUrl({
// Use model-versions endpoint which auto-redirects to correct model page modelId: civitaiId,
window.open(`https://civitai.com/model-versions/${versionId}`, '_blank'); versionId,
} else if (civitaiId) { modelName,
window.open(`https://civitai.com/models/${civitaiId}`, '_blank'); host: getPreferredCivitaiHost(),
} else if (modelName) { });
// Fallback: search by name
window.open(`https://civitai.com/models?query=${encodeURIComponent(modelName)}`, '_blank'); if (url) {
openCivitaiUrl(url);
} }
} }

View File

@@ -111,6 +111,9 @@
<div class="context-menu-item" data-action="cleanup-example-images-folders"> <div class="context-menu-item" data-action="cleanup-example-images-folders">
<i class="fas fa-trash-restore"></i> <span>{{ t('globalContextMenu.cleanupExampleImages.label') }}</span> <i class="fas fa-trash-restore"></i> <span>{{ t('globalContextMenu.cleanupExampleImages.label') }}</span>
</div> </div>
<div class="context-menu-item" data-action="manage-excluded-models">
<i class="fas fa-eye-slash"></i> <span>{{ t('globalContextMenu.manageExcludedModels.label', default='Manage Excluded Models') }}</span>
</div>
<div class="context-menu-item" data-action="repair-recipes"> <div class="context-menu-item" data-action="repair-recipes">
<i class="fas fa-tools"></i> <span>{{ t('globalContextMenu.repairRecipes.label') }}</span> <i class="fas fa-tools"></i> <span>{{ t('globalContextMenu.repairRecipes.label') }}</span>
</div> </div>
@@ -136,4 +139,4 @@
<div id="nodeSelector" class="node-selector"> <div id="nodeSelector" class="node-selector">
<!-- Dynamic node list will be populated here --> <!-- Dynamic node list will be populated here -->
</div> </div>

View File

@@ -1,4 +1,16 @@
<div class="controls"> <div class="controls">
<div id="excludedViewBanner" class="excluded-view-banner hidden">
<div class="excluded-view-banner__content">
<div class="excluded-view-banner__title">
<i class="fas fa-eye-slash"></i>
<span>{{ t('globalContextMenu.manageExcludedModels.label', default='Excluded Models') }}</span>
</div>
<button id="excludedViewBackBtn" class="excluded-view-banner__back">
<i class="fas fa-arrow-left"></i>
<span>{{ t('common.actions.back', default='Back') }}</span>
</button>
</div>
</div>
<div class="actions"> <div class="actions">
<div class="action-buttons"> <div class="action-buttons">
<div title="{{ t('loras.controls.sort.title') }}" class="control-group"> <div title="{{ t('loras.controls.sort.title') }}" class="control-group">

View File

@@ -114,6 +114,21 @@
</div> </div>
</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 --> <!-- Backup -->
<div class="settings-subsection"> <div class="settings-subsection">
<div class="settings-subsection-header"> <div class="settings-subsection-header">

View File

@@ -126,6 +126,31 @@ describe('AutoComplete widget interactions', () => {
expect(caretHelperInstance.getCursorOffset).toHaveBeenCalled(); 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 () => { it('inserts the selected LoRA with usage tip strengths and restores focus', async () => {
fetchApiMock.mockImplementation((url) => { fetchApiMock.mockImplementation((url) => {
if (url.includes('usage-tips-by-path')) { if (url.includes('usage-tips-by-path')) {
@@ -244,6 +269,53 @@ describe('AutoComplete widget interactions', () => {
expect(inputListener).not.toHaveBeenCalled(); 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 () => { it('accepts the selected suggestion with Enter', async () => {
caretHelperInstance.getBeforeCursor.mockReturnValue('example'); caretHelperInstance.getBeforeCursor.mockReturnValue('example');
@@ -300,6 +372,66 @@ describe('AutoComplete widget interactions', () => {
expect(insertSelectionSpy).toHaveBeenCalledWith('loop'); 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 () => { it('accepts the first available suggestion with Tab even if delayed auto-selection has not happened yet', async () => {
caretHelperInstance.getBeforeCursor.mockReturnValue('loop'); caretHelperInstance.getBeforeCursor.mockReturnValue('loop');
@@ -743,12 +875,12 @@ describe('AutoComplete widget interactions', () => {
json: () => Promise.resolve({ success: true, words: mockTags }), json: () => Promise.resolve({ success: true, words: mockTags }),
}); });
// Simulate "/char looking to the side" input // Simulate "/character looking to the side" input
caretHelperInstance.getBeforeCursor.mockReturnValue('/char looking to the side'); caretHelperInstance.getBeforeCursor.mockReturnValue('/character looking to the side');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea'); 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.selectionStart = input.value.length;
input.focus = vi.fn(); input.focus = vi.fn();
input.setSelectionRange = vi.fn(); input.setSelectionRange = vi.fn();
@@ -766,7 +898,7 @@ describe('AutoComplete widget interactions', () => {
autoComplete.activeCommand = { categories: [4, 11], label: 'Character' }; autoComplete.activeCommand = { categories: [4, 11], label: 'Character' };
autoComplete.items = mockTags; autoComplete.items = mockTags;
autoComplete.selectedIndex = 0; autoComplete.selectedIndex = 0;
autoComplete.currentSearchTerm = '/char looking to the side'; autoComplete.currentSearchTerm = '/character looking to the side';
await autoComplete.insertSelection('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'); 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 () => { it('invalidates stale autocomplete metadata and falls back to delimiter-based matching', async () => {
settingGetMock.mockImplementation((key) => { settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.autocomplete_append_comma') { if (key === 'loramanager.autocomplete_append_comma') {

View File

@@ -2102,6 +2102,7 @@ describe('Interaction-level regression coverage', () => {
<div class="context-menu-item" data-action="check-model-updates"></div> <div class="context-menu-item" data-action="check-model-updates"></div>
<div class="context-menu-item" data-action="fetch-missing-licenses"></div> <div class="context-menu-item" data-action="fetch-missing-licenses"></div>
<div class="context-menu-item" data-action="cleanup-example-images-folders"></div> <div class="context-menu-item" data-action="cleanup-example-images-folders"></div>
<div class="context-menu-item" data-action="manage-excluded-models"></div>
</div> </div>
`; `;
@@ -2120,6 +2121,9 @@ describe('Interaction-level regression coverage', () => {
startProgressUpdates: vi.fn(), startProgressUpdates: vi.fn(),
updateDownloadButtonText: vi.fn(), updateDownloadButtonText: vi.fn(),
}; };
window.pageControls = {
enterExcludedView: vi.fn().mockResolvedValue(undefined),
};
global.fetch = vi.fn() global.fetch = vi.fn()
.mockResolvedValueOnce({ .mockResolvedValueOnce({
@@ -2224,5 +2228,10 @@ describe('Interaction-level regression coverage', () => {
); );
expect(loadingManagerStub.showSimpleLoading).toHaveBeenNthCalledWith(2, 'Refreshing license metadata for LoRAs...'); expect(loadingManagerStub.showSimpleLoading).toHaveBeenNthCalledWith(2, 'Refreshing license metadata for LoRAs...');
expect(fetchMissingItem.classList.contains('disabled')).toBe(false); expect(fetchMissingItem.classList.contains('disabled')).toBe(false);
menu.showMenu(560, 600);
const excludedItem = document.querySelector('[data-action="manage-excluded-models"]');
excludedItem.dispatchEvent(new Event('click', { bubbles: true }));
expect(window.pageControls.enterExcludedView).toHaveBeenCalledTimes(1);
}); });
}); });

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, () => ({ vi.mock(UI_HELPERS_MODULE, () => ({
showToast: vi.fn(), showToast: vi.fn(),
openCivitaiUrl: vi.fn(),
})); }));
const stateMock = { const stateMock = {

View File

@@ -12,6 +12,7 @@ const apiClientMock = {
}; };
const showToastMock = vi.fn(); const showToastMock = vi.fn();
const openCivitaiByMetadataMock = vi.fn();
const updatePanelPositionsMock = vi.fn(); const updatePanelPositionsMock = vi.fn();
const downloadManagerMock = { const downloadManagerMock = {
showDownloadModal: vi.fn(), showDownloadModal: vi.fn(),
@@ -40,6 +41,7 @@ vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock, showToast: showToastMock,
openCivitaiByMetadata: openCivitaiByMetadataMock,
updatePanelPositions: updatePanelPositionsMock, updatePanelPositions: updatePanelPositionsMock,
})); }));
@@ -84,6 +86,7 @@ beforeEach(() => {
}); });
afterEach(() => { afterEach(() => {
delete window.bulkManager;
delete window.modelDuplicatesManager; delete window.modelDuplicatesManager;
delete global.fetch; delete global.fetch;
vi.useRealTimers(); vi.useRealTimers();
@@ -112,6 +115,9 @@ function renderControlsDom(pageKey) {
<button class="clear-filter"></button> <button class="clear-filter"></button>
</div> </div>
<div class="controls"> <div class="controls">
<div id="excludedViewBanner" class="excluded-view-banner hidden">
<button id="excludedViewBackBtn">Back</button>
</div>
<div class="actions"> <div class="actions">
<div class="action-buttons"> <div class="action-buttons">
<div class="control-group"> <div class="control-group">
@@ -170,6 +176,9 @@ function renderControlsDom(pageKey) {
<i class="fas fa-times-circle clear-filter"></i> <i class="fas fa-times-circle clear-filter"></i>
</div> </div>
</div> </div>
<div id="breadcrumbContainer"></div>
<div id="duplicatesBanner" style="display: none;"></div>
<div class="alphabet-bar-container"></div>
`; `;
} }
@@ -574,4 +583,93 @@ describe('PageControls favorites, sorting, and duplicates scenarios', () => {
duplicateButton.click(); duplicateButton.click();
expect(toggleDuplicateMode).toHaveBeenCalledTimes(1); expect(toggleDuplicateMode).toHaveBeenCalledTimes(1);
}); });
it.each([
['loras', 'LorasControls'],
['checkpoints', 'CheckpointsControls'],
['embeddings', 'EmbeddingsControls'],
])('switches %s page into excluded mode and restores state', async (pageKey, exportName) => {
renderControlsDom(pageKey);
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState(pageKey);
const pageState = stateModule.getCurrentPageState();
pageState.filters.search = 'active-search';
pageState.showFavoritesOnly = true;
pageState.showUpdateAvailableOnly = true;
const controlsModule = await import('../../../static/js/components/controls/index.js');
const ControlsClass = controlsModule[exportName];
const controls = new ControlsClass();
await controls.enterExcludedView();
expect(pageState.viewMode).toBe('excluded');
expect(pageState.filters.search).toBe('');
expect(resetAndReloadMock).toHaveBeenLastCalledWith(false);
expect(document.getElementById('excludedViewBanner').classList.contains('hidden')).toBe(false);
expect(document.querySelector('[data-action="fetch"]').classList.contains('hidden')).toBe(true);
expect(document.getElementById('filterButton').disabled).toBe(true);
pageState.filters.search = 'excluded-search';
await controls.exitExcludedView();
expect(pageState.viewMode).toBe('active');
expect(pageState.filters.search).toBe('active-search');
expect(pageState.excludedViewState.search).toBe('excluded-search');
expect(resetAndReloadMock).toHaveBeenLastCalledWith(true);
expect(document.getElementById('excludedViewBanner').classList.contains('hidden')).toBe(true);
expect(document.querySelector('[data-action="fetch"]').classList.contains('hidden')).toBe(false);
expect(document.getElementById('filterButton').disabled).toBe(false);
});
it('suspends bulk and duplicate modes for excluded view and restores custom filter banner on exit', async () => {
renderControlsDom('loras');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('loras');
const pageState = stateModule.getCurrentPageState();
stateModule.state.bulkMode = true;
pageState.duplicatesMode = true;
sessionStorage.setItem('lora_manager_recipe_to_lora_filterLoraHash', 'hash-1');
sessionStorage.setItem('lora_manager_filterRecipeName', 'Recipe Filter');
const { LorasControls } = await import('../../../static/js/components/controls/LorasControls.js');
const toggleBulkMode = vi.fn(() => {
stateModule.state.bulkMode = !stateModule.state.bulkMode;
});
const exitDuplicateMode = vi.fn(() => {
pageState.duplicatesMode = false;
});
const enterDuplicateMode = vi.fn(() => {
pageState.duplicatesMode = true;
});
window.bulkManager = { toggleBulkMode };
window.modelDuplicatesManager = {
duplicateGroups: [{ hash: 'dup-1', models: [{ file_path: 'a' }, { file_path: 'b' }] }],
exitDuplicateMode,
enterDuplicateMode,
};
const controls = new LorasControls();
const indicator = document.getElementById('customFilterIndicator');
expect(indicator.classList.contains('hidden')).toBe(false);
await controls.enterExcludedView();
expect(toggleBulkMode).toHaveBeenCalledTimes(1);
expect(exitDuplicateMode).toHaveBeenCalledTimes(1);
expect(stateModule.state.bulkMode).toBe(false);
expect(pageState.duplicatesMode).toBe(false);
expect(indicator.classList.contains('hidden')).toBe(true);
await controls.exitExcludedView();
expect(indicator.classList.contains('hidden')).toBe(false);
expect(toggleBulkMode).toHaveBeenCalledTimes(2);
expect(enterDuplicateMode).toHaveBeenCalledTimes(1);
expect(stateModule.state.bulkMode).toBe(true);
expect(pageState.duplicatesMode).toBe(true);
});
}); });

View File

@@ -136,4 +136,14 @@ describe('DownloadManager version history badges', () => {
expect(items[1].querySelector('.local-path')?.textContent).toContain('/models/still-local.safetensors'); expect(items[1].querySelector('.local-path')?.textContent).toContain('/models/still-local.safetensors');
expect(items[1].querySelector('.downloaded-badge')).toBeNull(); 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

@@ -7,6 +7,8 @@ const getCurrentPageStateMock = vi.fn();
const getSessionItemMock = vi.fn(); const getSessionItemMock = vi.fn();
const removeSessionItemMock = vi.fn(); const removeSessionItemMock = vi.fn();
const getStorageItemMock = vi.fn(); const getStorageItemMock = vi.fn();
const setStorageItemMock = vi.fn();
const removeStorageItemMock = vi.fn();
const RecipeContextMenuMock = vi.fn(); const RecipeContextMenuMock = vi.fn();
const refreshVirtualScrollMock = vi.fn(); const refreshVirtualScrollMock = vi.fn();
const refreshRecipesMock = vi.fn(); const refreshRecipesMock = vi.fn();
@@ -53,6 +55,8 @@ vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
getSessionItem: getSessionItemMock, getSessionItem: getSessionItemMock,
removeSessionItem: removeSessionItemMock, removeSessionItem: removeSessionItemMock,
getStorageItem: getStorageItemMock, getStorageItem: getStorageItemMock,
setStorageItem: setStorageItemMock,
removeStorageItem: removeStorageItemMock,
})); }));
vi.mock('../../../static/js/components/ContextMenu/index.js', () => ({ vi.mock('../../../static/js/components/ContextMenu/index.js', () => ({

View File

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

View File

@@ -1,10 +1,18 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { import {
DEFAULT_CIVITAI_PAGE_HOST,
normalizeCivitaiPageHost,
buildCivitaiModelUrl,
buildCivitaiSearchUrl,
buildCivitaiUrl,
rewriteCivitaiUrl, rewriteCivitaiUrl,
getOptimizedUrl, getOptimizedUrl,
getShowcaseUrl, getShowcaseUrl,
getThumbnailUrl, getThumbnailUrl,
extractCivitaiImageId,
extractCivitaiModelUrlParts,
isCivitaiUrl, isCivitaiUrl,
isSupportedCivitaiPageHost,
OptimizationMode OptimizationMode
} from '../../../static/js/utils/civitaiUtils.js'; } 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', () => { describe('rewriteCivitaiUrl', () => {
it('should rewrite image URLs with /original=true for thumbnail mode', () => { it('should rewrite image URLs with /original=true for thumbnail mode', () => {
const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/original=true/12345.jpeg'; 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); 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, STORAGE_MODULE,
CONSTANTS_MODULE, CONSTANTS_MODULE,
EVENT_MANAGER_MODULE, EVENT_MANAGER_MODULE,
BANNER_SERVICE_MODULE,
MODAL_MANAGER_MODULE,
UI_HELPERS_MODULE, UI_HELPERS_MODULE,
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname, 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, 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, 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, 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, UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
})); }));
const translateMock = vi.fn((key, _params, fallback) => fallback || key); const translateMock = vi.fn((key, _params, fallback) => fallback || key);
const getStorageItemMock = vi.fn(); const getStorageItemMock = vi.fn();
const setStorageItemMock = vi.fn(); const setStorageItemMock = vi.fn();
const registerBannerMock = vi.fn();
const showModalMock = vi.fn();
vi.mock(I18N_MODULE, () => ({ vi.mock(I18N_MODULE, () => ({
translate: translateMock, 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', () => { describe('UI helper DOM utilities', () => {
beforeEach(() => { beforeEach(() => {
document.body.innerHTML = ''; document.body.innerHTML = '';
@@ -57,6 +75,8 @@ describe('UI helper DOM utilities', () => {
document.documentElement.removeAttribute('data-theme'); document.documentElement.removeAttribute('data-theme');
getStorageItemMock.mockReset(); getStorageItemMock.mockReset();
setStorageItemMock.mockReset(); setStorageItemMock.mockReset();
registerBannerMock.mockReset();
showModalMock.mockReset();
translateMock.mockReset(); translateMock.mockReset();
globalThis.requestAnimationFrame = (cb) => cb(); globalThis.requestAnimationFrame = (cb) => cb();
}); });
@@ -156,4 +176,58 @@ describe('UI helper DOM utilities', () => {
'#2 (Character Subgraph) Nested Loader', '#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

@@ -94,7 +94,7 @@ class DummyDoctorScanner:
class DummyCivitaiClient: class DummyCivitaiClient:
def __init__(self, *, success=True, result=None): def __init__(self, *, success=True, result=None):
self.base_url = 'https://civitai.com/api/v1' self.base_url = 'https://civitai.red/api/v1'
self._success = success self._success = success
self._result = result if result is not None else {'items': []} self._result = result if result is not None else {'items': []}
@@ -499,6 +499,38 @@ async def test_open_backup_location_uses_settings_directory(tmp_path, monkeypatc
assert calls == [["xdg-open", str(backup_dir)]] 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: class RecordingRouter:
def __init__(self): def __init__(self):
self.calls = [] self.calls = []

View File

@@ -274,7 +274,9 @@ class StubCivitaiClient:
def __init__(self) -> None: def __init__(self) -> None:
self.image_info: Dict[str, Any] = {} 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) 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" 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 def test_analyze_uploaded_image_error_path(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness: async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.analysis.raise_for_uploaded = RecipeValidationError( 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 "update_available" in formatted
assert formatted["update_available"] is False 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("https://example.com/image.png") is True
assert service._validate_url("http://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.com/images/123") is True
assert service._validate_url("https://civitai.red/images/123") is True
def test_validate_invalid_url(self, service): def test_validate_invalid_url(self, service):
assert service._validate_url("not-a-url") is False assert service._validate_url("not-a-url") is False

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 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): async def test_get_model_by_hash_enriches_metadata(monkeypatch, downloader):
version_payload = { version_payload = {
"modelId": 123, "modelId": 123,
@@ -530,6 +536,69 @@ async def test_get_image_info_handles_missing(monkeypatch, downloader):
assert result is None 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): async def test_get_image_info_handles_invalid_id(monkeypatch, downloader, caplog):
"""When given a non-numeric image ID, return None and log error.""" """When given a non-numeric image ID, return None and log error."""
client = await CivitaiClient.get_instance() client = await CivitaiClient.get_instance()

View File

@@ -7,7 +7,10 @@ from unittest.mock import AsyncMock
import pytest 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 import download_manager
from py.services.service_registry import ServiceRegistry from py.services.service_registry import ServiceRegistry
from py.services.settings_manager import SettingsManager, get_settings_manager 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") 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.com/api/download/models/119514", True)]
assert "https://civitai.com/api/download/".startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_civarchive_source_uses_civarchive_provider( async def test_civarchive_source_uses_civarchive_provider(
monkeypatch, scanners, tmp_path monkeypatch, scanners, tmp_path

View File

@@ -5,6 +5,7 @@ import pytest
from py.services.model_lifecycle_service import ModelLifecycleService from py.services.model_lifecycle_service import ModelLifecycleService
from py.utils.metadata_manager import MetadataManager from py.utils.metadata_manager import MetadataManager
from py.utils.models import LoraMetadata
class DummyCache: class DummyCache:
@@ -445,6 +446,63 @@ async def test_exclude_model_empty_path_raises_error():
await service.exclude_model("") await service.exclude_model("")
@pytest.mark.asyncio
async def test_unexclude_model_restores_cache_entry(tmp_path: Path):
"""Verify unexclude_model clears exclude metadata and restores cache entry."""
model_path = tmp_path / "restored_model.safetensors"
model_path.write_bytes(b"content")
metadata_payload = {
"file_name": "restored_model",
"model_name": "restored_model",
"file_path": str(model_path),
"sha256": "abc123",
"exclude": True,
"tags": ["tag1"],
}
metadata_path = tmp_path / "restored_model.metadata.json"
metadata_path.write_text(json.dumps(metadata_payload))
class RestoreScanner:
def __init__(self):
self.model_type = "lora"
self.model_class = LoraMetadata
self._excluded_models = [str(model_path)]
self.updated = []
async def update_single_model_cache(self, old_path, new_path, metadata, recalculate_type=False):
exclude_value = metadata.get("exclude") if isinstance(metadata, dict) else metadata.exclude
self.updated.append((old_path, new_path, exclude_value, recalculate_type))
saved_metadata = []
class SavingMetadataManager:
async def save_metadata(self, path: str, metadata: dict):
saved_metadata.append((path, metadata.copy()))
await MetadataManager.save_metadata(path, metadata)
async def metadata_loader(path: str):
with open(path, "r", encoding="utf-8") as handle:
return json.load(handle)
scanner = RestoreScanner()
service = ModelLifecycleService(
scanner=scanner,
metadata_manager=SavingMetadataManager(),
metadata_loader=metadata_loader,
)
result = await service.unexclude_model(str(model_path))
assert result["success"] is True
assert "restored" in result["message"].lower()
assert scanner._excluded_models == []
assert saved_metadata[0][1]["exclude"] is False
assert scanner.updated == [
(str(model_path), str(model_path), False, True)
]
# ============================================================================= # =============================================================================
# Tests for bulk_delete_models functionality # Tests for bulk_delete_models functionality
# ============================================================================= # =============================================================================

View File

@@ -6,7 +6,7 @@ from types import SimpleNamespace
# We define these here to help with spec= if needed # We define these here to help with spec= if needed
class MockCivitaiClient: class MockCivitaiClient:
async def get_image_info(self, image_id): async def get_image_info(self, image_id, source_url=None):
pass pass
class MockPersistenceService: class MockPersistenceService:
@@ -119,6 +119,50 @@ async def test_repair_all_recipes_with_enriched_checkpoint_id(setup_scanner):
assert "hash" not in checkpoint assert "hash" not in checkpoint
assert "file_name" 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 @pytest.mark.asyncio
async def test_repair_all_recipes_with_enriched_checkpoint_hash(setup_scanner): async def test_repair_all_recipes_with_enriched_checkpoint_hash(setup_scanner):
recipe_scanner, mock_civitai_client, mock_metadata_provider = setup_scanner recipe_scanner, mock_civitai_client, mock_metadata_provider = setup_scanner

View File

@@ -678,7 +678,7 @@ async def test_analyze_remote_video(tmp_path):
) )
class DummyClient: class DummyClient:
async def get_image_info(self, image_id): async def get_image_info(self, image_id, source_url=None):
return { return {
"url": "https://civitai.com/video.mp4", "url": "https://civitai.com/video.mp4",
"type": "video", "type": "video",
@@ -698,3 +698,60 @@ async def test_analyze_remote_video(tmp_path):
assert result.payload["is_video"] is True assert result.payload["is_video"] is True
assert result.payload["extension"] == ".mp4" assert result.payload["extension"] == ".mp4"
assert result.payload["image_base64"] is not None 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") results = service.search_words("test")
assert mock_tag_index.called 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: class MockTagFTSIndex:
"""Mock TagFTSIndex for testing.""" """Mock TagFTSIndex for testing."""

View File

@@ -1,6 +1,7 @@
"""Tests for TagFTSIndex functionality.""" """Tests for TagFTSIndex functionality."""
import os import os
import sqlite3
import tempfile import tempfile
from typing import List from typing import List
@@ -173,6 +174,40 @@ class TestTagFTSIndexSearch:
assert len(results) >= 1 assert len(results) >= 1
assert all(r["category"] in [4, 11] for r in results) assert all(r["category"] in [4, 11] for r in results)
def test_search_with_category_filter_uses_fts_first_plan(self, populated_fts):
"""Category-filtered searches should start from FTS hits, not category scans."""
sql, params = populated_fts._build_search_statement(
query_lower="f",
fts_query="f*",
categories=[4, 11],
limit=20,
offset=0,
)
conn = sqlite3.connect(f"file:{populated_fts.get_database_path()}?mode=ro", uri=True)
try:
plan_rows = conn.execute(f"EXPLAIN QUERY PLAN {sql}", params).fetchall()
finally:
conn.close()
plan_details = [row[3] for row in plan_rows]
assert any(detail.startswith("SCAN tag_fts VIRTUAL TABLE INDEX") for detail in plan_details)
assert any("SEARCH t USING INTEGER PRIMARY KEY" in detail for detail in plan_details)
assert not any("SEARCH t USING INDEX idx_tags_category" in detail for detail in plan_details)
def test_search_statement_uses_post_count_as_tie_breaker(self, populated_fts):
"""Search ranking should use popularity as a secondary sort key."""
sql, _ = populated_fts._build_search_statement(
query_lower="f",
fts_query="f*",
categories=[4, 11],
limit=20,
offset=0,
)
assert "ORDER BY is_tag_name_match DESC, t.post_count DESC, rank_score DESC" in sql
assert "LOG10" not in sql
def test_search_with_category_filter_excludes_others(self, populated_fts): def test_search_with_category_filter_excludes_others(self, populated_fts):
"""Test that category filter excludes other categories.""" """Test that category filter excludes other categories."""
# Search for "hi" but only in general category # Search for "hi" but only in general category

View File

@@ -1,4 +1,12 @@
from py.utils.civitai_utils import build_license_flags, resolve_license_info, resolve_license_payload from py.utils.civitai_utils import (
build_license_flags,
extract_civitai_image_id,
extract_civitai_model_url_parts,
is_supported_civitai_page_host,
normalize_civitai_download_url,
resolve_license_info,
resolve_license_payload,
)
def test_resolve_license_payload_defaults(): def test_resolve_license_payload_defaults():
@@ -78,3 +86,61 @@ def test_build_license_flags_parses_aggregate_inside_list():
flags = build_license_flags(source) flags = build_license_flags(source)
expected_flags = (1 << 0) | (7 << 1) | (1 << 5) expected_flags = (1 << 0) | (7 << 1) | (1 << 5)
assert flags == expected_flags assert flags == expected_flags
def test_supported_civitai_page_hosts_include_red():
assert is_supported_civitai_page_host("civitai.com") is True
assert is_supported_civitai_page_host("civitai.red") is True
assert is_supported_civitai_page_host("www.civitai.com") is False
assert is_supported_civitai_page_host("www.civitai.red") is False
assert is_supported_civitai_page_host("example.com") is False
def test_extract_civitai_model_url_parts_supports_red():
model_id, version_id = extract_civitai_model_url_parts(
"https://civitai.red/models/65423/nijimecha-artstyle?modelVersionId=777"
)
assert model_id == "65423"
assert version_id == "777"
def test_extract_civitai_model_url_parts_rejects_non_civitai_host():
model_id, version_id = extract_civitai_model_url_parts(
"https://example.com/models/65423?modelVersionId=777"
)
assert model_id is None
assert version_id is None
def test_extract_civitai_image_id_supports_red():
assert (
extract_civitai_image_id("https://civitai.red/images/126920345")
== "126920345"
)
def test_extract_civitai_image_id_rejects_non_civitai_host():
assert extract_civitai_image_id("https://example.com/images/126920345") is None
def test_normalize_civitai_download_url_rewrites_red_to_com():
url = "https://civitai.red/api/download/models/2786889?type=Model&format=SafeTensor"
assert (
normalize_civitai_download_url(url)
== "https://civitai.com/api/download/models/2786889?type=Model&format=SafeTensor"
)
def test_normalize_civitai_download_url_keeps_non_download_red_urls():
url = "https://civitai.red/models/65423/nijimecha-artstyle?modelVersionId=777"
assert normalize_civitai_download_url(url) == url
def test_normalize_civitai_download_url_keeps_existing_com_urls():
url = "https://civitai.com/api/download/models/2786889?type=Model&format=SafeTensor"
assert normalize_civitai_download_url(url) == url

View File

@@ -425,7 +425,7 @@ function shouldBypassAutocompleteWidgetMigration(
} }
const originalWidgetsInputs = Object.values(inputDefs).filter((input: any) => const originalWidgetsInputs = Object.values(inputDefs).filter((input: any) =>
widgetNames.has(input.name) || input.forceInput widgetNames.has(input.name)
) )
const widgetIndexHasForceInput = originalWidgetsInputs.flatMap((input: any) => const widgetIndexHasForceInput = originalWidgetsInputs.flatMap((input: any) =>

View File

@@ -1,6 +1,15 @@
import { api } from "../../scripts/api.js"; import { api } from "../../scripts/api.js";
import { app } from "../../scripts/app.js"; import { app } from "../../scripts/app.js";
import { TextAreaCaretHelper } from "./textarea_caret_helper.js"; import { TextAreaCaretHelper } from "./textarea_caret_helper.js";
import {
WILDCARD_COMMANDS,
createWildcardEmptyStateItem,
createWildcardNoMatchesItem,
getWildcardInsertText,
getWildcardSearchEndpoint,
isWildcardCommand,
isWildcardInfoItem,
} from "./autocomplete_wildcards.js";
import { import {
getAutocompleteAppendCommaPreference, getAutocompleteAppendCommaPreference,
getAutocompleteAutoFormatPreference, getAutocompleteAutoFormatPreference,
@@ -13,7 +22,6 @@ import { showToast } from "./utils.js";
// Command definitions for category filtering // Command definitions for category filtering
const TAG_COMMANDS = { const TAG_COMMANDS = {
'/character': { categories: [4, 11], label: 'Character' }, '/character': { categories: [4, 11], label: 'Character' },
'/char': { categories: [4, 11], label: 'Character' },
'/artist': { categories: [1, 8], label: 'Artist' }, '/artist': { categories: [1, 8], label: 'Artist' },
'/general': { categories: [0, 7], label: 'General' }, '/general': { categories: [0, 7], label: 'General' },
'/copyright': { categories: [3, 10], label: 'Copyright' }, '/copyright': { categories: [3, 10], label: 'Copyright' },
@@ -22,6 +30,7 @@ const TAG_COMMANDS = {
'/lore': { categories: [15], label: 'Lore' }, '/lore': { categories: [15], label: 'Lore' },
'/emb': { type: 'embedding', label: 'Embeddings' }, '/emb': { type: 'embedding', label: 'Embeddings' },
'/embedding': { type: 'embedding', label: 'Embeddings' }, '/embedding': { type: 'embedding', label: 'Embeddings' },
...WILDCARD_COMMANDS,
// Autocomplete toggle commands - only show one based on current state // Autocomplete toggle commands - only show one based on current state
'/ac': { '/ac': {
type: 'toggle_setting', type: 'toggle_setting',
@@ -314,6 +323,8 @@ const MODEL_BEHAVIORS = {
const trimmedName = removeGeneralExtension(fileName); const trimmedName = removeGeneralExtension(fileName);
const folder = directories.length ? `${directories.join('/')}/` : ''; const folder = directories.length ? `${directories.join('/')}/` : '';
return formatAutocompleteInsertion(`embedding:${folder}${trimmedName}`); return formatAutocompleteInsertion(`embedding:${folder}${trimmedName}`);
} else if (instance.searchType === 'wildcards' || isWildcardCommand(instance.activeCommand)) {
return formatAutocompleteInsertion(getWildcardInsertText(relativePath));
} else { } else {
let tagText = relativePath; let tagText = relativePath;
@@ -350,13 +361,16 @@ class AutoComplete {
this.dropdown = null; this.dropdown = null;
this.selectedIndex = -1; this.selectedIndex = -1;
this.hasManualSelection = false;
this.items = []; this.items = [];
this.debounceTimer = null; this.debounceTimer = null;
this.isVisible = false; this.isVisible = false;
this.currentSearchTerm = ''; this.currentSearchTerm = '';
this.wildcardMeta = null;
this.previewTooltip = null; this.previewTooltip = null;
this.previewTooltipPromise = null; this.previewTooltipPromise = null;
this.searchType = null; this.searchType = null;
this.suppressAutocompleteOnce = false;
// Virtual scrolling state // Virtual scrolling state
this.virtualScrollOffset = 0; this.virtualScrollOffset = 0;
@@ -496,6 +510,11 @@ class AutoComplete {
bindEvents() { bindEvents() {
// Handle input changes // Handle input changes
this.onInput = (e) => { this.onInput = (e) => {
if (this.suppressAutocompleteOnce) {
this.suppressAutocompleteOnce = false;
this.hide();
return;
}
this.handleInput(e.target.value); this.handleInput(e.target.value);
}; };
this.inputElement.addEventListener('input', this.onInput); this.inputElement.addEventListener('input', this.onInput);
@@ -512,6 +531,7 @@ class AutoComplete {
const formattedValue = formatAutocompleteTextOnBlur(this.inputElement.value); const formattedValue = formatAutocompleteTextOnBlur(this.inputElement.value);
if (formattedValue !== this.inputElement.value) { if (formattedValue !== this.inputElement.value) {
this.inputElement.value = formattedValue; this.inputElement.value = formattedValue;
this.suppressAutocompleteOnce = true;
this.inputElement.dispatchEvent(new Event('input', { bubbles: true })); this.inputElement.dispatchEvent(new Event('input', { bubbles: true }));
} }
} }
@@ -658,6 +678,9 @@ class AutoComplete {
// /emb or /embedding command // /emb or /embedding command
endpoint = '/lm/embeddings/relative-paths'; endpoint = '/lm/embeddings/relative-paths';
this.searchType = 'embeddings'; this.searchType = 'embeddings';
} else if (isWildcardCommand(commandResult.command)) {
endpoint = getWildcardSearchEndpoint();
this.searchType = 'wildcards';
} else { } else {
// Category filter command // Category filter command
const categories = commandResult.command.categories.join(','); const categories = commandResult.command.categories.join(',');
@@ -684,7 +707,12 @@ class AutoComplete {
} }
} }
if (searchTerm.length < this.options.minChars) { const allowEmptyWildcardSearch =
this.modelType === 'prompt' &&
this.searchType === 'wildcards' &&
searchTerm.length === 0;
if (!allowEmptyWildcardSearch && searchTerm.length < this.options.minChars) {
this.hide(); this.hide();
return; return;
} }
@@ -713,9 +741,24 @@ class AutoComplete {
} }
const rawText = beforeCursor.substring(start); const rawText = beforeCursor.substring(start);
const text = rawText.trim();
const leadingWhitespaceLength = rawText.length - rawText.trimStart().length; const leadingWhitespaceLength = rawText.length - rawText.trimStart().length;
const trimmedStart = start + leadingWhitespaceLength; const trimmedStart = start + leadingWhitespaceLength;
const text = rawText.trim();
if (this.modelType === 'prompt') {
const tokenRange = this._getPromptTokenRange(rawText, trimmedStart, caretPos);
if (tokenRange) {
return {
start: tokenRange.start,
trimmedStart: tokenRange.trimmedStart,
end: caretPos,
beforeCursor,
rawText: tokenRange.rawText,
text: tokenRange.text,
tokenType: tokenRange.tokenType,
};
}
}
return { return {
start, start,
@@ -727,6 +770,73 @@ class AutoComplete {
}; };
} }
_getPromptTokenRange(rawText = '', trimmedStart = 0, caretPos = 0) {
const trimmedText = rawText.trim();
if (!trimmedText) {
return {
start: trimmedStart,
trimmedStart,
rawText: '',
text: '',
tokenType: 'empty',
};
}
const commandOffset = trimmedText.startsWith('/')
? 0
: trimmedText.lastIndexOf(' /');
if (commandOffset !== -1) {
const normalizedCommandOffset = commandOffset === 0 ? 0 : commandOffset + 1;
const commandText = trimmedText.slice(normalizedCommandOffset);
const commandStart = trimmedStart + normalizedCommandOffset;
return {
start: commandStart,
trimmedStart: commandStart,
rawText: commandText,
text: commandText,
tokenType: commandText === '/' ? 'empty_command_trigger' : 'command',
};
}
const wildcardMatch = trimmedText.match(/(?:^|\s)(__[\w\s.\-+/*\\]+?__)$/);
if (wildcardMatch) {
const wildcardText = wildcardMatch[1];
const wildcardOffset = trimmedText.lastIndexOf(wildcardText);
const wildcardStart = trimmedStart + wildcardOffset;
return {
start: wildcardStart,
trimmedStart: wildcardStart,
rawText: wildcardText,
text: '',
tokenType: 'wildcard_literal',
};
}
const embeddingOffset = trimmedText.search(/(?:^|\s)emb:[^\s]*$/i);
if (embeddingOffset !== -1) {
const normalizedEmbeddingOffset = trimmedText.slice(embeddingOffset).startsWith(' ')
? embeddingOffset + 1
: embeddingOffset;
const embeddingText = trimmedText.slice(normalizedEmbeddingOffset);
const embeddingStart = trimmedStart + normalizedEmbeddingOffset;
return {
start: embeddingStart,
trimmedStart: embeddingStart,
rawText: embeddingText,
text: embeddingText,
tokenType: 'embedding_literal',
};
}
return {
start: trimmedStart,
trimmedStart,
rawText,
text: trimmedText,
tokenType: 'tag_text',
};
}
_getHardBoundaryStart(beforeCursor = '') { _getHardBoundaryStart(beforeCursor = '') {
const lastComma = beforeCursor.lastIndexOf(','); const lastComma = beforeCursor.lastIndexOf(',');
const lastAngle = beforeCursor.lastIndexOf('>'); const lastAngle = beforeCursor.lastIndexOf('>');
@@ -878,12 +988,50 @@ class AutoComplete {
return Array.from(variations).filter(v => v.length >= this.options.minChars); return Array.from(variations).filter(v => v.length >= this.options.minChars);
} }
_normalizeQueryForRequest(term = '') {
return term.trim().toLowerCase();
}
_getQueriesToExecute(term = '') {
const queryVariations = this._generateQueryVariations(term);
const uniqueQueries = [];
const seen = new Set();
for (const query of queryVariations) {
const normalized = this._normalizeQueryForRequest(query);
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
uniqueQueries.push(query);
if (uniqueQueries.length >= 4) {
break;
}
}
return uniqueQueries;
}
_containsInformationalItems() {
return this.items.some((item) => isWildcardInfoItem(item));
}
_isSelectableInfoItem(item) {
return isWildcardInfoItem(item);
}
/** /**
* Get display text for an item (without extension for models) * Get display text for an item (without extension for models)
* @param {string|Object} item - Item to get display text from * @param {string|Object} item - Item to get display text from
* @returns {string} - Display text without extension * @returns {string} - Display text without extension
*/ */
_getDisplayText(item) { _getDisplayText(item) {
if (isWildcardInfoItem(item)) {
return item.title || item.description || 'Wildcards';
}
const itemText = typeof item === 'object' && item.tag_name ? item.tag_name : String(item); const itemText = typeof item === 'object' && item.tag_name ? item.tag_name : String(item);
// Remove extension for models to avoid matching/displaying .safetensors etc. // Remove extension for models to avoid matching/displaying .safetensors etc.
if (this.modelType === 'loras' || this.searchType === 'embeddings') { if (this.modelType === 'loras' || this.searchType === 'embeddings') {
@@ -1013,6 +1161,14 @@ class AutoComplete {
return 0; return 0;
} }
_getAcceptSelectionIndex(searchTerm = '') {
if (this.hasManualSelection && this.selectedIndex >= 0 && this.selectedIndex < this.items.length) {
return this.selectedIndex;
}
return this._getPreferredSelectedIndex(searchTerm);
}
async search(term = '', endpoint = null) { async search(term = '', endpoint = null) {
try { try {
this.currentSearchTerm = term; this.currentSearchTerm = term;
@@ -1024,23 +1180,26 @@ class AutoComplete {
// This is critical for preventing command suggestions from persisting // This is critical for preventing command suggestions from persisting
// when switching from command mode to regular tag search // when switching from command mode to regular tag search
this.items = []; this.items = [];
this.wildcardMeta = null;
if (!endpoint) { if (!endpoint) {
endpoint = `/lm/${this.modelType}/relative-paths`; endpoint = `/lm/${this.modelType}/relative-paths`;
} }
// Generate multiple query variations for better matching // Generate multiple query variations for better matching, but avoid
const queryVariations = this._generateQueryVariations(term); // sending duplicate-equivalent requests that normalize to the same
// backend search term.
const queriesToExecute =
this.searchType === 'wildcards' && term.length === 0
? ['']
: this._getQueriesToExecute(term);
if (queryVariations.length === 0) { if (queriesToExecute.length === 0) {
this.items = []; this.items = [];
this.hide(); this.hide();
return; return;
} }
// Limit the number of parallel queries to avoid overwhelming the server
const queriesToExecute = queryVariations.slice(0, 4);
// Execute all queries in parallel // Execute all queries in parallel
const searchPromises = queriesToExecute.map(async (query) => { const searchPromises = queriesToExecute.map(async (query) => {
const url = endpoint.includes('?') const url = endpoint.includes('?')
@@ -1050,10 +1209,16 @@ class AutoComplete {
try { try {
const response = await api.fetchApi(url); const response = await api.fetchApi(url);
const data = await response.json(); const data = await response.json();
return data.success ? (data.relative_paths || data.words || []) : []; return {
items: data.success ? (data.relative_paths || data.words || []) : [],
meta: data?.meta || null,
};
} catch (error) { } catch (error) {
console.warn(`Search query failed for "${query}":`, error); console.warn(`Search query failed for "${query}":`, error);
return []; return {
items: [],
meta: null,
};
} }
}); });
@@ -1071,7 +1236,12 @@ class AutoComplete {
const seen = new Set(); const seen = new Set();
const mergedItems = []; const mergedItems = [];
for (const resultArray of resultsArrays) { for (const result of resultsArrays) {
if (!this.wildcardMeta && result?.meta) {
this.wildcardMeta = result.meta;
}
const resultArray = result?.items || [];
for (const item of resultArray) { for (const item of resultArray) {
const itemKey = typeof item === 'object' && item.tag_name const itemKey = typeof item === 'object' && item.tag_name
? item.tag_name.toLowerCase() ? item.tag_name.toLowerCase()
@@ -1084,6 +1254,17 @@ class AutoComplete {
} }
} }
if (this.searchType === 'wildcards' && mergedItems.length === 0) {
const meta = this.wildcardMeta || {};
this.items = meta.has_wildcards
? [createWildcardNoMatchesItem(term, meta)]
: [createWildcardEmptyStateItem(meta)];
this.hasMoreItems = false;
this.render();
this.show();
return;
}
// Use backend-sorted results directly without re-scoring // Use backend-sorted results directly without re-scoring
// Backend already ranks by: FTS5 bm25 score + post count + exact prefix boost // Backend already ranks by: FTS5 bm25 score + post count + exact prefix boost
if (mergedItems.length > 0) { if (mergedItems.length > 0) {
@@ -1144,7 +1325,7 @@ class AutoComplete {
}; };
} }
// Command with search term (e.g., "/char miku") // Command with search term (e.g., "/character miku")
const commandPart = trimmed.slice(0, spaceIndex).toLowerCase(); const commandPart = trimmed.slice(0, spaceIndex).toLowerCase();
const searchPart = trimmed.slice(spaceIndex + 1).trim(); const searchPart = trimmed.slice(spaceIndex + 1).trim();
@@ -1178,20 +1359,15 @@ class AutoComplete {
const filterLower = filter.toLowerCase(); const filterLower = filter.toLowerCase();
// Get unique commands (avoid duplicates like /char and /character)
const seenLabels = new Set();
const commands = []; const commands = [];
for (const [cmd, info] of Object.entries(TAG_COMMANDS)) { for (const [cmd, info] of Object.entries(TAG_COMMANDS)) {
if (seenLabels.has(info.label)) continue;
// Filter out toggle commands that don't meet their condition // Filter out toggle commands that don't meet their condition
if (info.type === 'toggle_setting' && info.condition) { if (info.type === 'toggle_setting' && info.condition) {
if (!info.condition()) continue; if (!info.condition()) continue;
} }
if (!filter || cmd.slice(1).startsWith(filterLower)) { if (!filter || cmd.slice(1).startsWith(filterLower)) {
seenLabels.add(info.label);
commands.push({ command: cmd, ...info }); commands.push({ command: cmd, ...info });
} }
} }
@@ -1219,6 +1395,7 @@ class AutoComplete {
this.dropdown.innerHTML = ''; this.dropdown.innerHTML = '';
} }
this.selectedIndex = -1; this.selectedIndex = -1;
this.hasManualSelection = false;
this.items.forEach((item, index) => { this.items.forEach((item, index) => {
const itemEl = document.createElement('div'); const itemEl = document.createElement('div');
@@ -1254,7 +1431,7 @@ class AutoComplete {
`; `;
itemEl.addEventListener('mouseenter', () => { itemEl.addEventListener('mouseenter', () => {
this.selectItem(index); this.selectItem(index, { manual: true });
}); });
itemEl.addEventListener('click', () => { itemEl.addEventListener('click', () => {
@@ -1276,8 +1453,17 @@ class AutoComplete {
} }
// Auto-select immediately so accept keys remain stable. // Auto-select immediately so accept keys remain stable.
// In virtual-scroll mode, calling selectItem() before the dropdown is
// visible can see a zero-height container and incorrectly replace the
// full command list with a partially virtualized slice.
if (this.items.length > 0) { if (this.items.length > 0) {
this.selectItem(0); this.selectedIndex = 0;
this.hasManualSelection = false;
if (this.contentContainer) {
this._applyItemSelection(0);
} else {
this.selectItem(0);
}
} }
// Update virtual scroll height for virtual scrolling mode // Update virtual scroll height for virtual scrolling mode
@@ -1288,7 +1474,7 @@ class AutoComplete {
/** /**
* Insert a command into the input * Insert a command into the input
* @param {string} command - The command to insert (e.g., "/char") * @param {string} command - The command to insert (e.g., "/character")
*/ */
_insertCommand(command) { _insertCommand(command) {
const currentValue = this.inputElement.value; const currentValue = this.inputElement.value;
@@ -1315,6 +1501,7 @@ class AutoComplete {
render() { render() {
this.selectedIndex = -1; this.selectedIndex = -1;
this.hasManualSelection = false;
// Reset virtual scroll state // Reset virtual scroll state
this.virtualScrollOffset = 0; this.virtualScrollOffset = 0;
@@ -1346,91 +1533,7 @@ class AutoComplete {
const isCommand = this.items[0] && typeof this.items[0] === 'object' && 'command' in this.items[0]; const isCommand = this.items[0] && typeof this.items[0] === 'object' && 'command' in this.items[0];
this.items.forEach((itemData, index) => { this.items.forEach((itemData, index) => {
const item = document.createElement('div'); const item = this.createItemElement(itemData, index, isEnriched, isCommand);
item.className = 'comfy-autocomplete-item';
if (isCommand) {
// Render command item
const cmdSpan = document.createElement('span');
cmdSpan.className = 'lm-autocomplete-command-name';
cmdSpan.textContent = itemData.command;
const labelSpan = document.createElement('span');
labelSpan.className = 'lm-autocomplete-command-label';
labelSpan.textContent = itemData.label;
item.appendChild(cmdSpan);
item.appendChild(labelSpan);
item.style.cssText = `
padding: 8px 12px;
cursor: pointer;
color: rgba(226, 232, 240, 0.8);
border-bottom: 1px solid rgba(226, 232, 240, 0.1);
transition: all 0.2s ease;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
`;
} else if (isEnriched) {
// Render enriched item with category badge and post count
this._renderEnrichedItem(item, itemData, this.currentSearchTerm);
} else {
// Create highlighted content for simple items, wrapped in a span
// to prevent flex layout from breaking up the text
const nameSpan = document.createElement('span');
nameSpan.className = 'lm-autocomplete-name';
// Use display text without extension for cleaner UI
const displayTextWithoutExt = this._getDisplayText(itemData);
nameSpan.innerHTML = this.highlightMatch(displayTextWithoutExt, this.currentSearchTerm);
nameSpan.style.cssText = `
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
`;
item.appendChild(nameSpan);
// Apply item styles with new color scheme
item.style.cssText = `
padding: 8px 12px;
cursor: pointer;
color: rgba(226, 232, 240, 0.8);
border-bottom: 1px solid rgba(226, 232, 240, 0.1);
transition: all 0.2s ease;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
`;
}
// Hover and selection handlers
item.addEventListener('mouseenter', () => {
this.selectItem(index);
});
item.addEventListener('mouseleave', () => {
this.hidePreview();
});
// Click handler
item.addEventListener('click', () => {
if (isCommand) {
this._insertCommand(itemData.command);
} else {
const insertPath = isEnriched ? itemData.tag_name : itemData;
this.insertSelection(insertPath);
}
});
this.dropdown.appendChild(item); this.dropdown.appendChild(item);
}); });
@@ -1524,6 +1627,124 @@ class AutoComplete {
itemEl.appendChild(nameSpan); itemEl.appendChild(nameSpan);
itemEl.appendChild(metaSpan); itemEl.appendChild(metaSpan);
} }
_renderInformationalItem(itemEl, itemData) {
itemEl.classList.add('comfy-autocomplete-info-item');
itemEl.style.cssText = `
padding: 12px;
color: rgba(226, 232, 240, 0.88);
border-bottom: none;
cursor: default;
display: block;
white-space: normal;
height: auto;
`;
const title = document.createElement('div');
title.className = 'lm-autocomplete-info-title';
title.textContent = itemData.title || 'Wildcards';
title.style.cssText = `
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
`;
itemEl.appendChild(title);
const description = document.createElement('div');
description.className = 'lm-autocomplete-info-description';
description.textContent = itemData.description || '';
description.style.cssText = `
font-size: 12px;
line-height: 1.45;
color: rgba(226, 232, 240, 0.72);
`;
itemEl.appendChild(description);
if (itemData.type === 'wildcard_no_matches') {
return;
}
const pathBlock = document.createElement('div');
pathBlock.style.cssText = `
margin-top: 10px;
padding: 8px 10px;
border-radius: 6px;
background: rgba(15, 23, 42, 0.6);
font-size: 11px;
line-height: 1.5;
`;
pathBlock.innerHTML = [
'<div style="font-weight: 600; margin-bottom: 4px;">Wildcards folder</div>',
`<code style="word-break: break-all; color: #dbeafe;">${itemData.wildcardsDir || '(unavailable)'}</code>`,
`<div style="margin-top: 6px; color: rgba(226, 232, 240, 0.68);">Supported formats: ${(itemData.supportedFormats || []).join(', ')}</div>`,
].join('');
itemEl.appendChild(pathBlock);
const examples = document.createElement('div');
examples.style.cssText = `
margin-top: 10px;
font-size: 11px;
line-height: 1.55;
color: rgba(226, 232, 240, 0.72);
`;
examples.innerHTML = [
'<div style="font-weight: 600; color: rgba(226, 232, 240, 0.88); margin-bottom: 4px;">Examples</div>',
'<div><code>animals/cat.txt</code> -> use <code>__animals/cat__</code></div>',
'<div><code>colors.yaml</code> with <code>palette: { warm: [red, orange] }</code> -> use <code>__palette/warm__</code></div>',
'<div style="margin-top: 6px;">Text files use one option per line. YAML/JSON use nested keys ending in string arrays.</div>',
].join('');
itemEl.appendChild(examples);
const actions = document.createElement('div');
actions.style.cssText = `
display: flex;
gap: 8px;
margin-top: 12px;
flex-wrap: wrap;
`;
const openButton = document.createElement('button');
openButton.type = 'button';
openButton.dataset.action = 'open-wildcards-folder';
openButton.textContent = 'Open wildcards folder';
openButton.style.cssText = `
border: 1px solid rgba(96, 165, 250, 0.45);
background: rgba(37, 99, 235, 0.18);
color: #dbeafe;
border-radius: 6px;
padding: 6px 10px;
font-size: 11px;
cursor: pointer;
`;
openButton.addEventListener('click', async (event) => {
event.preventDefault();
event.stopPropagation();
await this._openWildcardsFolder();
});
actions.appendChild(openButton);
const copyButton = document.createElement('button');
copyButton.type = 'button';
copyButton.dataset.action = 'copy-wildcards-path';
copyButton.textContent = 'Copy path';
copyButton.style.cssText = `
border: 1px solid rgba(226, 232, 240, 0.2);
background: rgba(148, 163, 184, 0.12);
color: rgba(226, 232, 240, 0.88);
border-radius: 6px;
padding: 6px 10px;
font-size: 11px;
cursor: pointer;
`;
copyButton.addEventListener('click', async (event) => {
event.preventDefault();
event.stopPropagation();
await this._copyWildcardPath(itemData.wildcardsDir || '');
});
actions.appendChild(copyButton);
itemEl.appendChild(actions);
}
highlightMatch(text, searchTerm) { highlightMatch(text, searchTerm) {
const { include } = parseSearchTokens(searchTerm); const { include } = parseSearchTokens(searchTerm);
@@ -1541,6 +1762,62 @@ class AutoComplete {
'<span style="background-color: rgba(66, 153, 225, 0.3); color: white; padding: 1px 2px; border-radius: 2px;">$1</span>', '<span style="background-color: rgba(66, 153, 225, 0.3); color: white; padding: 1px 2px; border-radius: 2px;">$1</span>',
); );
} }
async _openWildcardsFolder() {
try {
const response = await api.fetchApi('/lm/wildcards/open-location', { method: 'POST' });
const data = await response.json();
if (!response.ok || data?.success === false) {
throw new Error(data?.error || 'Failed to open wildcards folder');
}
if (data?.mode === 'clipboard' && data?.path) {
await this._copyWildcardPath(data.path);
return;
}
showToast({
severity: 'success',
summary: 'Wildcards folder',
detail: 'Opened wildcards folder.',
life: 2500,
});
} catch (error) {
console.error('[Lora Manager] Failed to open wildcards folder:', error);
showToast({
severity: 'error',
summary: 'Error',
detail: error?.message || 'Failed to open wildcards folder',
life: 3000,
});
}
}
async _copyWildcardPath(path) {
if (!path) {
return;
}
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(path);
}
showToast({
severity: 'info',
summary: 'Wildcards path',
detail: path,
life: 3000,
});
} catch (error) {
console.error('[Lora Manager] Failed to copy wildcards path:', error);
showToast({
severity: 'warn',
summary: 'Wildcards path',
detail: path,
life: 4000,
});
}
}
showPreviewForItem(relativePath, itemElement) { showPreviewForItem(relativePath, itemElement) {
if (!this.options.showPreview || !this.previewTooltip) return; if (!this.options.showPreview || !this.previewTooltip) return;
@@ -1611,6 +1888,8 @@ class AutoComplete {
if (this.modelType === 'prompt') { if (this.modelType === 'prompt') {
if (this.searchType === 'embeddings') { if (this.searchType === 'embeddings') {
endpoint = '/lm/embeddings/relative-paths'; endpoint = '/lm/embeddings/relative-paths';
} else if (this.searchType === 'wildcards') {
endpoint = getWildcardSearchEndpoint();
} else if (this.searchType === 'custom_words') { } else if (this.searchType === 'custom_words') {
if (this.activeCommand?.categories) { if (this.activeCommand?.categories) {
const categories = this.activeCommand.categories.join(','); const categories = this.activeCommand.categories.join(',');
@@ -1621,8 +1900,7 @@ class AutoComplete {
} }
} }
const queryVariations = this._generateQueryVariations(this.currentSearchTerm); const queriesToExecute = this._getQueriesToExecute(this.currentSearchTerm);
const queriesToExecute = queryVariations.slice(0, 4);
const offset = this.items.length; const offset = this.items.length;
// Execute all queries in parallel with offset // Execute all queries in parallel with offset
@@ -1733,6 +2011,14 @@ class AutoComplete {
updateVirtualScrollHeight() { updateVirtualScrollHeight() {
if (!this.contentContainer || !this.scrollContainer) return; if (!this.contentContainer || !this.scrollContainer) return;
if (this._containsInformationalItems()) {
this.totalHeight = 0;
this.contentContainer.style.height = 'auto';
this.scrollContainer.style.maxHeight = `${this.options.visibleItems * this.options.itemHeight}px`;
this.scrollContainer.style.overflowY = 'hidden';
return;
}
this.totalHeight = this.items.length * this.options.itemHeight; this.totalHeight = this.items.length * this.options.itemHeight;
this.contentContainer.style.height = `${this.totalHeight}px`; this.contentContainer.style.height = `${this.totalHeight}px`;
@@ -1751,6 +2037,16 @@ class AutoComplete {
updateVisibleItems() { updateVisibleItems() {
if (!this.scrollContainer || !this.contentContainer) return; if (!this.scrollContainer || !this.contentContainer) return;
if (this._containsInformationalItems()) {
this.contentContainer.innerHTML = '';
if (this.items[0]) {
this.contentContainer.appendChild(
this.createItemElement(this.items[0], 0, false, false)
);
}
return;
}
const scrollTop = this.scrollContainer.scrollTop; const scrollTop = this.scrollContainer.scrollTop;
const containerHeight = this.scrollContainer.clientHeight; const containerHeight = this.scrollContainer.clientHeight;
@@ -1830,7 +2126,9 @@ class AutoComplete {
isCommand = true; isCommand = true;
} }
if (isCommand) { if (isWildcardInfoItem(itemData)) {
this._renderInformationalItem(item, itemData);
} else if (isCommand) {
// Render command item // Render command item
const cmdSpan = document.createElement('span'); const cmdSpan = document.createElement('span');
cmdSpan.className = 'lm-autocomplete-command-name'; cmdSpan.className = 'lm-autocomplete-command-name';
@@ -1862,7 +2160,7 @@ class AutoComplete {
// Hover and selection handlers // Hover and selection handlers
item.addEventListener('mouseenter', () => { item.addEventListener('mouseenter', () => {
this.selectItem(index); this.selectItem(index, { manual: true });
}); });
item.addEventListener('mouseleave', () => { item.addEventListener('mouseleave', () => {
@@ -1871,6 +2169,10 @@ class AutoComplete {
// Click handler // Click handler
item.addEventListener('click', () => { item.addEventListener('click', () => {
if (isWildcardInfoItem(itemData)) {
return;
}
if (isCommand) { if (isCommand) {
this._insertCommand(itemData.command); this._insertCommand(itemData.command);
} else { } else {
@@ -1954,11 +2256,13 @@ class AutoComplete {
this.dropdown.style.display = 'none'; this.dropdown.style.display = 'none';
this.isVisible = false; this.isVisible = false;
this.selectedIndex = -1; this.selectedIndex = -1;
this.hasManualSelection = false;
this.showingCommands = false; this.showingCommands = false;
// Clear items to prevent stale data from being displayed // Clear items to prevent stale data from being displayed
// when autocomplete is shown again // when autocomplete is shown again
this.items = []; this.items = [];
this.wildcardMeta = null;
// Clear content container to prevent stale items from showing // Clear content container to prevent stale items from showing
if (this.contentContainer) { if (this.contentContainer) {
@@ -1992,7 +2296,7 @@ class AutoComplete {
}); });
} }
selectItem(index) { selectItem(index, { manual = false } = {}) {
// Remove previous selection // Remove previous selection
const container = this.options.enableVirtualScroll && this.contentContainer const container = this.options.enableVirtualScroll && this.contentContainer
? this.contentContainer ? this.contentContainer
@@ -2006,6 +2310,7 @@ class AutoComplete {
// Add new selection // Add new selection
if (index >= 0 && index < this.items.length) { if (index >= 0 && index < this.items.length) {
this.selectedIndex = index; this.selectedIndex = index;
this.hasManualSelection = manual;
// For virtual scrolling, we need to ensure the item is rendered // For virtual scrolling, we need to ensure the item is rendered
if (this.options.enableVirtualScroll && this.scrollContainer) { if (this.options.enableVirtualScroll && this.scrollContainer) {
@@ -2046,7 +2351,7 @@ class AutoComplete {
item.scrollIntoView({ block: 'nearest' }); item.scrollIntoView({ block: 'nearest' });
// Show preview for selected item // Show preview for selected item
if (this.options.showPreview) { if (this.options.showPreview && !this._isSelectableInfoItem(this.items[index])) {
if (typeof this.behavior.showPreview === 'function') { if (typeof this.behavior.showPreview === 'function') {
this.behavior.showPreview(this, this.items[index], item); this.behavior.showPreview(this, this.items[index], item);
} else if (this.previewTooltip) { } else if (this.previewTooltip) {
@@ -2073,7 +2378,7 @@ class AutoComplete {
selectedEl.style.backgroundColor = 'rgba(66, 153, 225, 0.2)'; selectedEl.style.backgroundColor = 'rgba(66, 153, 225, 0.2)';
// Show preview for selected item // Show preview for selected item
if (this.options.showPreview) { if (this.options.showPreview && !this._isSelectableInfoItem(this.items[index])) {
if (typeof this.behavior.showPreview === 'function') { if (typeof this.behavior.showPreview === 'function') {
this.behavior.showPreview(this, this.items[index], selectedEl); this.behavior.showPreview(this, this.items[index], selectedEl);
} else if (this.previewTooltip) { } else if (this.previewTooltip) {
@@ -2099,15 +2404,15 @@ class AutoComplete {
this.loadMoreItems().then(() => { this.loadMoreItems().then(() => {
// After loading more, select the next item // After loading more, select the next item
if (this.selectedIndex < this.items.length - 1) { if (this.selectedIndex < this.items.length - 1) {
this.selectItem(this.selectedIndex + 1); this.selectItem(this.selectedIndex + 1, { manual: true });
} }
}); });
} }
} else { } else {
this.selectItem(this.selectedIndex + 1); this.selectItem(this.selectedIndex + 1, { manual: true });
} }
} else { } else {
this.selectItem(Math.min(this.selectedIndex + 1, this.items.length - 1)); this.selectItem(Math.min(this.selectedIndex + 1, this.items.length - 1), { manual: true });
} }
break; break;
@@ -2117,12 +2422,12 @@ class AutoComplete {
// For virtual scrolling, handle top boundary // For virtual scrolling, handle top boundary
if (this.selectedIndex <= 0) { if (this.selectedIndex <= 0) {
// Already at first item, ensure it's selected // Already at first item, ensure it's selected
this.selectItem(0); this.selectItem(0, { manual: true });
} else { } else {
this.selectItem(this.selectedIndex - 1); this.selectItem(this.selectedIndex - 1, { manual: true });
} }
} else { } else {
this.selectItem(Math.max(this.selectedIndex - 1, 0)); this.selectItem(Math.max(this.selectedIndex - 1, 0), { manual: true });
} }
break; break;
@@ -2134,9 +2439,9 @@ class AutoComplete {
{ {
const liveSearchTerm = this._getLiveSearchTermForAcceptance(); const liveSearchTerm = this._getLiveSearchTermForAcceptance();
const preferredIndex = this._getPreferredSelectedIndex(liveSearchTerm); const acceptIndex = this._getAcceptSelectionIndex(liveSearchTerm);
if (preferredIndex !== -1 && preferredIndex !== this.selectedIndex) { if (acceptIndex !== -1 && acceptIndex !== this.selectedIndex) {
this.selectItem(preferredIndex); this.selectItem(acceptIndex);
} }
} }
@@ -2146,8 +2451,15 @@ class AutoComplete {
// Insert command // Insert command
this._insertCommand(this.items[this.selectedIndex].command); this._insertCommand(this.items[this.selectedIndex].command);
} else { } else {
// Insert selection (handle enriched items)
const selectedItem = this.items[this.selectedIndex]; const selectedItem = this.items[this.selectedIndex];
if (isWildcardInfoItem(selectedItem)) {
if (selectedItem.type === 'wildcard_empty_state') {
this._openWildcardsFolder();
}
break;
}
// Insert selection (handle enriched items)
const insertPath = typeof selectedItem === 'object' && 'tag_name' in selectedItem const insertPath = typeof selectedItem === 'object' && 'tag_name' in selectedItem
? selectedItem.tag_name ? selectedItem.tag_name
: selectedItem; : selectedItem;
@@ -2180,7 +2492,7 @@ class AutoComplete {
// This allows "hello 1gi" + selecting "1girl" to become "hello 1girl, " // This allows "hello 1gi" + selecting "1girl" to become "hello 1girl, "
// However, if the user typed a multi-word phrase that matches a tag (e.g., "looking to the side" // However, if the user typed a multi-word phrase that matches a tag (e.g., "looking to the side"
// matching "looking_to_the_side"), replace the entire phrase instead of just the last word. // matching "looking_to_the_side"), replace the entire phrase instead of just the last word.
// Command mode (e.g., "/char miku") should replace the entire command+search // Command mode (e.g., "/character miku") should replace the entire command+search
let searchTerm = fullSearchTerm; let searchTerm = fullSearchTerm;
if (this.modelType === 'prompt' && this.searchType === 'custom_words' && !this.activeCommand) { if (this.modelType === 'prompt' && this.searchType === 'custom_words' && !this.activeCommand) {
// Check if the selectedItem exists and its tag_name matches the full search term // Check if the selectedItem exists and its tag_name matches the full search term

View File

@@ -0,0 +1,54 @@
export const WILDCARD_COMMANDS = {
'/wildcard': { type: 'wildcard', label: 'Wildcards' },
};
export const WILDCARD_INFO_ITEM_TYPES = {
EMPTY_STATE: 'wildcard_empty_state',
NO_MATCHES: 'wildcard_no_matches',
};
export function isWildcardCommand(command) {
return command?.type === 'wildcard';
}
export function getWildcardSearchEndpoint() {
return '/lm/wildcards/search';
}
export function getWildcardInsertText(relativePath = '') {
const trimmed = typeof relativePath === 'string' ? relativePath.trim() : '';
if (!trimmed) {
return '';
}
return `__${trimmed}__`;
}
export function isWildcardInfoItem(item) {
return Boolean(
item &&
typeof item === 'object' &&
Object.values(WILDCARD_INFO_ITEM_TYPES).includes(item.type)
);
}
export function createWildcardEmptyStateItem(meta = {}) {
return {
type: WILDCARD_INFO_ITEM_TYPES.EMPTY_STATE,
title: 'No wildcards found yet',
description: 'Create wildcard files in your wildcards folder, then use /wildcard to search and insert keys.',
wildcardsDir: meta.wildcards_dir || '',
supportedFormats: Array.isArray(meta.supported_formats) ? meta.supported_formats : [],
};
}
export function createWildcardNoMatchesItem(searchTerm = '', meta = {}) {
return {
type: WILDCARD_INFO_ITEM_TYPES.NO_MATCHES,
title: 'No wildcard matches',
description: searchTerm
? `No wildcard keys matched "${searchTerm}".`
: 'No wildcard keys matched your search.',
wildcardsDir: meta.wildcards_dir || '',
supportedFormats: Array.isArray(meta.supported_formats) ? meta.supported_formats : [],
};
}

View File

@@ -15557,7 +15557,7 @@ function shouldBypassAutocompleteWidgetMigration(node, widgetValues) {
return false; return false;
} }
const originalWidgetsInputs = Object.values(inputDefs).filter( const originalWidgetsInputs = Object.values(inputDefs).filter(
(input) => widgetNames.has(input.name) || input.forceInput (input) => widgetNames.has(input.name)
); );
const widgetIndexHasForceInput = originalWidgetsInputs.flatMap( const widgetIndexHasForceInput = originalWidgetsInputs.flatMap(
(input) => input.control_after_generate ? [!!input.forceInput, false] : [!!input.forceInput] (input) => input.control_after_generate ? [!!input.forceInput, false] : [!!input.forceInput]

File diff suppressed because one or more lines are too long