mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-05-06 16:36:45 -03:00
Compare commits
18 Commits
6d0d9600a7
...
v1.0.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89fd2b43d6 | ||
|
|
c53f44e7ef | ||
|
|
ae7bfdb517 | ||
|
|
68bf8442eb | ||
|
|
605fbf4117 | ||
|
|
406d5fea6a | ||
|
|
af2146f96c | ||
|
|
bdc8dec860 | ||
|
|
c4fa1631ee | ||
|
|
506d763dc2 | ||
|
|
a2cd09b619 | ||
|
|
cdd77029b6 | ||
|
|
439679e15f | ||
|
|
2640258902 | ||
|
|
b910388d54 | ||
|
|
083de395b1 | ||
|
|
4514ca94b7 | ||
|
|
62247bdd87 |
58
README.md
58
README.md
@@ -56,6 +56,27 @@ Insomnia Art Designs, megakirbs, Brennok, 2018cfh, W+K+White, wackop, Takkan, Ca
|
||||
|
||||
## 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
|
||||
|
||||
* **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
|
||||
* **LoRA Cycler Node** - Introduced a new LoRA Cycler node that enables iteration through specified LoRAs with support for repeat count and pause iteration functionality. Refer to the new "Lora Cycler" template workflow for concrete example.
|
||||
* **Enhanced Prompt Node with Tag Autocomplete** - Enhanced the Prompt node with comprehensive tag autocomplete based on merged Danbooru + e621 tags. Supports tag search and autocomplete functionality. Implemented a command system with shortcuts like `/char` or `/artist` for category-specific tag searching. Added `/ac` or `/noac` commands to quickly enable or disable autocomplete. Refer to the "Lora Manager Basic" template workflow in ComfyUI -> Templates -> ComfyUI-Lora-Manager for detailed tips.
|
||||
* **Enhanced Prompt Node with Tag Autocomplete** - Enhanced the Prompt node with comprehensive tag autocomplete based on merged Danbooru + e621 tags. Supports tag search and autocomplete functionality. Implemented a command system with shortcuts like `/character` or `/artist` for category-specific tag searching. Added `/ac` or `/noac` commands to quickly enable or disable autocomplete. Refer to the "Lora Manager Basic" template workflow in ComfyUI -> Templates -> ComfyUI-Lora-Manager for detailed tips.
|
||||
* **Bug Fixes & Stability** - Addressed multiple bugs and improved overall stability.
|
||||
|
||||
### v0.9.12
|
||||
@@ -253,6 +274,41 @@ pip install -r requirements.txt
|
||||
- Paste into the Lora Loader node's text input
|
||||
- The node will automatically apply preset strength and trigger words
|
||||
|
||||
### Wildcards for TextLM / PromptLM
|
||||
|
||||
`Text (LoraManager)` and `Prompt (LoraManager)` support `/wildcard` autocomplete plus runtime wildcard expansion.
|
||||
|
||||
- Wildcard files live in `{settings folder}/wildcards/`
|
||||
- When you type `/wildcard` and no wildcard files exist yet, the autocomplete dropdown shows the exact folder path and lets you open it
|
||||
- Supported formats: `.txt`, `.yaml`, `.yml`, `.json`
|
||||
|
||||
Format rules:
|
||||
|
||||
- `wildcards/animals/cat.txt` becomes `__animals/cat__`
|
||||
- `.txt` files use one option per line
|
||||
- YAML / JSON files use nested keys that end in string arrays
|
||||
|
||||
Examples:
|
||||
|
||||
```txt
|
||||
# wildcards/color.txt
|
||||
red
|
||||
blue
|
||||
green
|
||||
```
|
||||
|
||||
Use it as `__color__`.
|
||||
|
||||
```yaml
|
||||
# wildcards/colors.yaml
|
||||
palette:
|
||||
warm:
|
||||
- red
|
||||
- orange
|
||||
```
|
||||
|
||||
Use it as `__palette/warm__`.
|
||||
|
||||
### Filename Format Patterns for Save Image Node
|
||||
|
||||
The Save Image Node supports dynamic filename generation using pattern codes. You can customize how your images are named using the following format patterns:
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -175,6 +175,9 @@
|
||||
"success": "{count} Rezepte erfolgreich repariert.",
|
||||
"cancelled": "Reparatur abgebrochen. {count} Rezepte wurden repariert.",
|
||||
"error": "Recipe-Reparatur fehlgeschlagen: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Ausgeschlossene Modelle verwalten"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -250,6 +253,19 @@
|
||||
"civitaiApiKey": "Civitai API Key",
|
||||
"civitaiApiKeyPlaceholder": "Geben Sie Ihren Civitai API Key ein",
|
||||
"civitaiApiKeyHelp": "Wird für die Authentifizierung beim Herunterladen von Modellen von Civitai verwendet",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai-Host",
|
||||
"help": "Wählen Sie aus, welche Civitai-Seite geöffnet wird, wenn Sie „View on Civitai“-Links verwenden.",
|
||||
"options": {
|
||||
"com": "civitai.com (nur SFW)",
|
||||
"red": "civitai.red (uneingeschränkt)"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "Civitai-Host-Einstellung verfügbar",
|
||||
"content": "Civitai verwendet jetzt civitai.com für SFW-Inhalte und civitai.red für uneingeschränkte Inhalte. In den Einstellungen können Sie ändern, welche Seite standardmäßig geöffnet wird.",
|
||||
"openSettings": "Einstellungen öffnen"
|
||||
},
|
||||
"openSettingsFileLocation": {
|
||||
"label": "Einstellungsordner öffnen",
|
||||
"tooltip": "Den Ordner mit der settings.json öffnen",
|
||||
@@ -667,6 +683,7 @@
|
||||
"moveToFolder": "In Ordner verschieben",
|
||||
"repairMetadata": "Metadaten reparieren",
|
||||
"excludeModel": "Modell ausschließen",
|
||||
"restoreModel": "Modell wiederherstellen",
|
||||
"deleteModel": "Modell löschen",
|
||||
"shareRecipe": "Rezept teilen",
|
||||
"viewAllLoras": "Alle LoRAs anzeigen",
|
||||
@@ -1790,6 +1807,8 @@
|
||||
"deleteFailed": "Fehler beim Löschen von {type}: {message}",
|
||||
"excludeSuccess": "{type} erfolgreich ausgeschlossen",
|
||||
"excludeFailed": "Fehler beim Ausschließen von {type}: {message}",
|
||||
"restoreSuccess": "{type} erfolgreich wiederhergestellt",
|
||||
"restoreFailed": "{type} konnte nicht wiederhergestellt werden: {message}",
|
||||
"fileNameUpdated": "Dateiname erfolgreich aktualisiert",
|
||||
"fileRenameFailed": "Fehler beim Umbenennen der Datei: {error}",
|
||||
"previewUpdated": "Vorschau erfolgreich aktualisiert",
|
||||
|
||||
@@ -175,6 +175,9 @@
|
||||
"success": "Successfully repaired {count} recipes.",
|
||||
"cancelled": "Repair cancelled. {count} recipes were repaired.",
|
||||
"error": "Recipe repair failed: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Manage Excluded Models"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -250,6 +253,19 @@
|
||||
"civitaiApiKey": "Civitai API Key",
|
||||
"civitaiApiKeyPlaceholder": "Enter your Civitai API key",
|
||||
"civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai host",
|
||||
"help": "Choose which Civitai site opens when using View on Civitai links.",
|
||||
"options": {
|
||||
"com": "civitai.com (SFW)",
|
||||
"red": "civitai.red (unrestricted)"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "Civitai host preference available",
|
||||
"content": "Civitai now uses civitai.com for SFW content and civitai.red for unrestricted content. You can change which site opens by default in Settings.",
|
||||
"openSettings": "Open Settings"
|
||||
},
|
||||
"openSettingsFileLocation": {
|
||||
"label": "Open settings folder",
|
||||
"tooltip": "Open folder containing settings.json",
|
||||
@@ -667,6 +683,7 @@
|
||||
"moveToFolder": "Move to Folder",
|
||||
"repairMetadata": "Repair metadata",
|
||||
"excludeModel": "Exclude Model",
|
||||
"restoreModel": "Restore Model",
|
||||
"deleteModel": "Delete Model",
|
||||
"shareRecipe": "Share Recipe",
|
||||
"viewAllLoras": "View All LoRAs",
|
||||
@@ -685,9 +702,9 @@
|
||||
"title": "Import a recipe from image or URL",
|
||||
"urlLocalPath": "URL / Local Path",
|
||||
"uploadImage": "Upload Image",
|
||||
"urlSectionDescription": "Input a Civitai image URL or local file path to import as a recipe.",
|
||||
"urlSectionDescription": "Input a Civitai image URL from civitai.com or civitai.red, or a local file path, to import as a recipe.",
|
||||
"imageUrlOrPath": "Image URL or File Path:",
|
||||
"urlPlaceholder": "https://civitai.com/images/... or C:/path/to/image.png",
|
||||
"urlPlaceholder": "https://civitai.com/images/... or https://civitai.red/images/... or C:/path/to/image.png",
|
||||
"fetchImage": "Fetch Image",
|
||||
"uploadSectionDescription": "Upload an image with LoRA metadata to import as a recipe.",
|
||||
"selectImage": "Select Image",
|
||||
@@ -1090,9 +1107,9 @@
|
||||
},
|
||||
"proceedText": "Only proceed if you're sure this is what you want.",
|
||||
"urlLabel": "Civitai Model URL:",
|
||||
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676 or https://civitai.red/models/649516/model-name?modelVersionId=726676",
|
||||
"helpText": {
|
||||
"title": "Paste any Civitai model URL. Supported formats:",
|
||||
"title": "Paste any Civitai model URL from civitai.com or civitai.red. Supported formats:",
|
||||
"format1": "https://civitai.com/models/649516",
|
||||
"format2": "https://civitai.com/models/649516?modelVersionId=726676",
|
||||
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||
@@ -1790,6 +1807,8 @@
|
||||
"deleteFailed": "Failed to delete {type}: {message}",
|
||||
"excludeSuccess": "{type} excluded successfully",
|
||||
"excludeFailed": "Failed to exclude {type}: {message}",
|
||||
"restoreSuccess": "{type} restored successfully",
|
||||
"restoreFailed": "Failed to restore {type}: {message}",
|
||||
"fileNameUpdated": "File name updated successfully",
|
||||
"fileRenameFailed": "Failed to rename file: {error}",
|
||||
"previewUpdated": "Preview updated successfully",
|
||||
|
||||
@@ -175,6 +175,9 @@
|
||||
"success": "Se repararon con éxito {count} recetas.",
|
||||
"cancelled": "Reparación cancelada. {count} recetas fueron reparadas.",
|
||||
"error": "Error al reparar recetas: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Gestionar modelos excluidos"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -250,6 +253,19 @@
|
||||
"civitaiApiKey": "Clave API de Civitai",
|
||||
"civitaiApiKeyPlaceholder": "Introduce tu clave API de Civitai",
|
||||
"civitaiApiKeyHelp": "Utilizada para autenticación al descargar modelos de Civitai",
|
||||
"civitaiHost": {
|
||||
"label": "Host de Civitai",
|
||||
"help": "Elige qué sitio de Civitai se abre al usar los enlaces de \"View on Civitai\".",
|
||||
"options": {
|
||||
"com": "civitai.com (solo SFW)",
|
||||
"red": "civitai.red (sin restricciones)"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "Preferencia de host de Civitai disponible",
|
||||
"content": "Civitai ahora usa civitai.com para contenido SFW y civitai.red para contenido sin restricciones. Puedes cambiar en Ajustes qué sitio se abre por defecto.",
|
||||
"openSettings": "Abrir ajustes"
|
||||
},
|
||||
"openSettingsFileLocation": {
|
||||
"label": "Abrir carpeta de ajustes",
|
||||
"tooltip": "Abrir la carpeta que contiene settings.json",
|
||||
@@ -667,6 +683,7 @@
|
||||
"moveToFolder": "Mover a carpeta",
|
||||
"repairMetadata": "Reparar metadatos",
|
||||
"excludeModel": "Excluir modelo",
|
||||
"restoreModel": "Restaurar modelo",
|
||||
"deleteModel": "Eliminar modelo",
|
||||
"shareRecipe": "Compartir receta",
|
||||
"viewAllLoras": "Ver todos los LoRAs",
|
||||
@@ -1790,6 +1807,8 @@
|
||||
"deleteFailed": "Error al eliminar {type}: {message}",
|
||||
"excludeSuccess": "{type} excluido exitosamente",
|
||||
"excludeFailed": "Error al excluir {type}: {message}",
|
||||
"restoreSuccess": "{type} restaurado correctamente",
|
||||
"restoreFailed": "No se pudo restaurar {type}: {message}",
|
||||
"fileNameUpdated": "Nombre de archivo actualizado exitosamente",
|
||||
"fileRenameFailed": "Error al renombrar archivo: {error}",
|
||||
"previewUpdated": "Vista previa actualizada exitosamente",
|
||||
|
||||
@@ -175,6 +175,9 @@
|
||||
"success": "{count} recettes réparées avec succès.",
|
||||
"cancelled": "Réparation annulée. {count} recettes ont été réparées.",
|
||||
"error": "Échec de la réparation des recettes : {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Gérer les modèles exclus"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -250,6 +253,19 @@
|
||||
"civitaiApiKey": "Clé API Civitai",
|
||||
"civitaiApiKeyPlaceholder": "Entrez votre clé API Civitai",
|
||||
"civitaiApiKeyHelp": "Utilisée pour l'authentification lors du téléchargement de modèles depuis Civitai",
|
||||
"civitaiHost": {
|
||||
"label": "Hôte Civitai",
|
||||
"help": "Choisissez quel site Civitai s'ouvre lorsque vous utilisez les liens « View on Civitai ».",
|
||||
"options": {
|
||||
"com": "civitai.com (SFW uniquement)",
|
||||
"red": "civitai.red (sans restriction)"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "Préférence d’hôte Civitai disponible",
|
||||
"content": "Civitai utilise désormais civitai.com pour le contenu SFW et civitai.red pour le contenu sans restriction. Vous pouvez modifier dans les paramètres le site ouvert par défaut.",
|
||||
"openSettings": "Ouvrir les paramètres"
|
||||
},
|
||||
"openSettingsFileLocation": {
|
||||
"label": "Ouvrir le dossier des paramètres",
|
||||
"tooltip": "Ouvrir le dossier contenant settings.json",
|
||||
@@ -667,6 +683,7 @@
|
||||
"moveToFolder": "Déplacer vers un dossier",
|
||||
"repairMetadata": "Réparer les métadonnées",
|
||||
"excludeModel": "Exclure le modèle",
|
||||
"restoreModel": "Restaurer le modèle",
|
||||
"deleteModel": "Supprimer le modèle",
|
||||
"shareRecipe": "Partager la recipe",
|
||||
"viewAllLoras": "Voir tous les LoRAs",
|
||||
@@ -1790,6 +1807,8 @@
|
||||
"deleteFailed": "Échec de la suppression de {type} : {message}",
|
||||
"excludeSuccess": "{type} exclu avec succès",
|
||||
"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",
|
||||
"fileRenameFailed": "Échec du renommage du fichier : {error}",
|
||||
"previewUpdated": "Aperçu mis à jour avec succès",
|
||||
|
||||
@@ -175,6 +175,9 @@
|
||||
"success": "תוקנו בהצלחה {count} מתכונים.",
|
||||
"cancelled": "תיקון בוטל. {count} מתכונים תוקנו.",
|
||||
"error": "תיקון המתכונים נכשל: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "ניהול מודלים מוחרגים"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -250,6 +253,19 @@
|
||||
"civitaiApiKey": "מפתח API של Civitai",
|
||||
"civitaiApiKeyPlaceholder": "הזן את מפתח ה-API שלך מ-Civitai",
|
||||
"civitaiApiKeyHelp": "משמש לאימות בעת הורדת מודלים מ-Civitai",
|
||||
"civitaiHost": {
|
||||
"label": "מארח Civitai",
|
||||
"help": "בחר איזה אתר של Civitai ייפתח בעת שימוש בקישורי \"View on Civitai\".",
|
||||
"options": {
|
||||
"com": "civitai.com (SFW בלבד)",
|
||||
"red": "civitai.red (ללא הגבלות)"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "העדפת מארח Civitai זמינה",
|
||||
"content": "Civitai משתמש כעת ב-civitai.com עבור תוכן SFW וב-civitai.red עבור תוכן ללא הגבלות. ניתן לשנות בהגדרות איזה אתר ייפתח כברירת מחדל.",
|
||||
"openSettings": "פתח הגדרות"
|
||||
},
|
||||
"openSettingsFileLocation": {
|
||||
"label": "פתח תיקיית הגדרות",
|
||||
"tooltip": "פתח את התיקייה שמכילה את settings.json",
|
||||
@@ -667,6 +683,7 @@
|
||||
"moveToFolder": "העבר לתיקייה",
|
||||
"repairMetadata": "תיקון מטא-דאטה",
|
||||
"excludeModel": "החרג מודל",
|
||||
"restoreModel": "שחזור מודל",
|
||||
"deleteModel": "מחק מודל",
|
||||
"shareRecipe": "שתף מתכון",
|
||||
"viewAllLoras": "הצג את כל ה-LoRAs",
|
||||
@@ -1790,6 +1807,8 @@
|
||||
"deleteFailed": "מחיקת {type} נכשלה: {message}",
|
||||
"excludeSuccess": "{type} הוחרג בהצלחה",
|
||||
"excludeFailed": "החרגת {type} נכשלה: {message}",
|
||||
"restoreSuccess": "{type} שוחזר בהצלחה",
|
||||
"restoreFailed": "שחזור {type} נכשל: {message}",
|
||||
"fileNameUpdated": "שם הקובץ עודכן בהצלחה",
|
||||
"fileRenameFailed": "שינוי שם הקובץ נכשל: {error}",
|
||||
"previewUpdated": "התצוגה המקדימה עודכנה בהצלחה",
|
||||
|
||||
@@ -175,6 +175,9 @@
|
||||
"success": "{count} 件のレシピを正常に修復しました。",
|
||||
"cancelled": "修復がキャンセルされました。{count}個のレシピが修復されました。",
|
||||
"error": "レシピの修復に失敗しました: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "除外モデルを管理"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -250,6 +253,19 @@
|
||||
"civitaiApiKey": "Civitai APIキー",
|
||||
"civitaiApiKeyPlaceholder": "Civitai APIキーを入力してください",
|
||||
"civitaiApiKeyHelp": "Civitaiからモデルをダウンロードするときの認証に使用されます",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai ホスト",
|
||||
"help": "「View on Civitai」リンクを使うときに開く Civitai サイトを選択します。",
|
||||
"options": {
|
||||
"com": "civitai.com(SFW のみ)",
|
||||
"red": "civitai.red(制限なし)"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "Civitai ホスト設定を利用できます",
|
||||
"content": "Civitai は現在、SFW コンテンツには civitai.com、制限なしコンテンツには civitai.red を使用しています。設定で既定で開くサイトを変更できます。",
|
||||
"openSettings": "設定を開く"
|
||||
},
|
||||
"openSettingsFileLocation": {
|
||||
"label": "設定フォルダーを開く",
|
||||
"tooltip": "settings.json を含むフォルダーを開きます",
|
||||
@@ -667,6 +683,7 @@
|
||||
"moveToFolder": "フォルダに移動",
|
||||
"repairMetadata": "メタデータを修復",
|
||||
"excludeModel": "モデルを除外",
|
||||
"restoreModel": "モデルを復元",
|
||||
"deleteModel": "モデルを削除",
|
||||
"shareRecipe": "レシピを共有",
|
||||
"viewAllLoras": "すべてのLoRAを表示",
|
||||
@@ -1790,6 +1807,8 @@
|
||||
"deleteFailed": "{type}の削除に失敗しました:{message}",
|
||||
"excludeSuccess": "{type}が正常に除外されました",
|
||||
"excludeFailed": "{type}の除外に失敗しました:{message}",
|
||||
"restoreSuccess": "{type}を復元しました",
|
||||
"restoreFailed": "{type}の復元に失敗しました: {message}",
|
||||
"fileNameUpdated": "ファイル名が正常に更新されました",
|
||||
"fileRenameFailed": "ファイル名の変更に失敗しました:{error}",
|
||||
"previewUpdated": "プレビューが正常に更新されました",
|
||||
|
||||
@@ -175,6 +175,9 @@
|
||||
"success": "{count}개의 레시피가 성공적으로 복구되었습니다.",
|
||||
"cancelled": "수리가 취소되었습니다. {count}개의 레시피가 수리되었습니다.",
|
||||
"error": "레시피 복구 실패: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "제외된 모델 관리"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -250,6 +253,19 @@
|
||||
"civitaiApiKey": "Civitai API 키",
|
||||
"civitaiApiKeyPlaceholder": "Civitai API 키를 입력하세요",
|
||||
"civitaiApiKeyHelp": "Civitai에서 모델을 다운로드할 때 인증에 사용됩니다",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai 호스트",
|
||||
"help": "\"View on Civitai\" 링크를 사용할 때 어떤 Civitai 사이트를 열지 선택합니다.",
|
||||
"options": {
|
||||
"com": "civitai.com(SFW 전용)",
|
||||
"red": "civitai.red(무제한)"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "Civitai 호스트 기본 설정 사용 가능",
|
||||
"content": "이제 Civitai는 SFW 콘텐츠에 civitai.com을, 무제한 콘텐츠에 civitai.red를 사용합니다. 설정에서 기본으로 열 사이트를 변경할 수 있습니다.",
|
||||
"openSettings": "설정 열기"
|
||||
},
|
||||
"openSettingsFileLocation": {
|
||||
"label": "설정 폴더 열기",
|
||||
"tooltip": "settings.json이 있는 폴더를 엽니다",
|
||||
@@ -667,6 +683,7 @@
|
||||
"moveToFolder": "폴더로 이동",
|
||||
"repairMetadata": "메타데이터 복구",
|
||||
"excludeModel": "모델 제외",
|
||||
"restoreModel": "모델 복원",
|
||||
"deleteModel": "모델 삭제",
|
||||
"shareRecipe": "레시피 공유",
|
||||
"viewAllLoras": "모든 LoRA 보기",
|
||||
@@ -1790,6 +1807,8 @@
|
||||
"deleteFailed": "{type} 삭제 실패: {message}",
|
||||
"excludeSuccess": "{type}이(가) 성공적으로 제외되었습니다",
|
||||
"excludeFailed": "{type} 제외 실패: {message}",
|
||||
"restoreSuccess": "{type} 복원 완료",
|
||||
"restoreFailed": "{type} 복원 실패: {message}",
|
||||
"fileNameUpdated": "파일명이 성공적으로 업데이트되었습니다",
|
||||
"fileRenameFailed": "파일 이름 변경 실패: {error}",
|
||||
"previewUpdated": "미리보기가 성공적으로 업데이트되었습니다",
|
||||
|
||||
@@ -175,6 +175,9 @@
|
||||
"success": "Успешно восстановлено {count} рецептов.",
|
||||
"cancelled": "Восстановление отменено. {count} рецептов было восстановлено.",
|
||||
"error": "Ошибка восстановления рецептов: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Управление исключёнными моделями"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -250,6 +253,19 @@
|
||||
"civitaiApiKey": "Ключ API Civitai",
|
||||
"civitaiApiKeyPlaceholder": "Введите ваш ключ API Civitai",
|
||||
"civitaiApiKeyHelp": "Используется для аутентификации при загрузке моделей с Civitai",
|
||||
"civitaiHost": {
|
||||
"label": "Хост Civitai",
|
||||
"help": "Выберите, какой сайт Civitai будет открываться при использовании ссылок «View on Civitai».",
|
||||
"options": {
|
||||
"com": "civitai.com (только SFW)",
|
||||
"red": "civitai.red (без ограничений)"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "Доступна настройка хоста Civitai",
|
||||
"content": "Теперь Civitai использует civitai.com для контента SFW и civitai.red для контента без ограничений. В настройках можно изменить, какой сайт открывать по умолчанию.",
|
||||
"openSettings": "Открыть настройки"
|
||||
},
|
||||
"openSettingsFileLocation": {
|
||||
"label": "Открыть папку настроек",
|
||||
"tooltip": "Открыть папку, содержащую settings.json",
|
||||
@@ -667,6 +683,7 @@
|
||||
"moveToFolder": "Переместить в папку",
|
||||
"repairMetadata": "Восстановить метаданные",
|
||||
"excludeModel": "Исключить модель",
|
||||
"restoreModel": "Восстановить модель",
|
||||
"deleteModel": "Удалить модель",
|
||||
"shareRecipe": "Поделиться рецептом",
|
||||
"viewAllLoras": "Посмотреть все LoRAs",
|
||||
@@ -1790,6 +1807,8 @@
|
||||
"deleteFailed": "Не удалось удалить {type}: {message}",
|
||||
"excludeSuccess": "{type} успешно исключен",
|
||||
"excludeFailed": "Не удалось исключить {type}: {message}",
|
||||
"restoreSuccess": "{type} успешно восстановлен",
|
||||
"restoreFailed": "Не удалось восстановить {type}: {message}",
|
||||
"fileNameUpdated": "Имя файла успешно обновлено",
|
||||
"fileRenameFailed": "Не удалось переименовать файл: {error}",
|
||||
"previewUpdated": "Превью успешно обновлено",
|
||||
|
||||
@@ -175,6 +175,9 @@
|
||||
"success": "成功修复了 {count} 个配方。",
|
||||
"cancelled": "修复已取消。已修复 {count} 个配方。",
|
||||
"error": "配方修复失败:{message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "管理已排除的模型"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -250,6 +253,19 @@
|
||||
"civitaiApiKey": "Civitai API 密钥",
|
||||
"civitaiApiKeyPlaceholder": "请输入你的 Civitai API 密钥",
|
||||
"civitaiApiKeyHelp": "用于从 Civitai 下载模型时的身份验证",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai 站点",
|
||||
"help": "选择使用“在 Civitai 中查看”时默认打开的 Civitai 站点。",
|
||||
"options": {
|
||||
"com": "civitai.com(仅 SFW)",
|
||||
"red": "civitai.red(无限制)"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "已提供 Civitai 站点偏好设置",
|
||||
"content": "Civitai 现在使用 civitai.com 提供 SFW 内容,使用 civitai.red 提供无限制内容。你可以在设置中更改默认打开的站点。",
|
||||
"openSettings": "打开设置"
|
||||
},
|
||||
"openSettingsFileLocation": {
|
||||
"label": "打开设置文件夹",
|
||||
"tooltip": "打开包含 settings.json 的文件夹",
|
||||
@@ -667,6 +683,7 @@
|
||||
"moveToFolder": "移动到文件夹",
|
||||
"repairMetadata": "修复元数据",
|
||||
"excludeModel": "排除模型",
|
||||
"restoreModel": "恢复模型",
|
||||
"deleteModel": "删除模型",
|
||||
"shareRecipe": "分享配方",
|
||||
"viewAllLoras": "查看所有 LoRA",
|
||||
@@ -685,9 +702,9 @@
|
||||
"title": "从图片或 URL 导入配方",
|
||||
"urlLocalPath": "URL / 本地路径",
|
||||
"uploadImage": "上传图片",
|
||||
"urlSectionDescription": "输入 Civitai 图片 URL 或本地文件路径以导入为配方。",
|
||||
"urlSectionDescription": "输入来自 civitai.com 或 civitai.red 的 Civitai 图片 URL,或本地文件路径以导入为配方。",
|
||||
"imageUrlOrPath": "图片 URL 或文件路径:",
|
||||
"urlPlaceholder": "https://civitai.com/images/... 或 C:/path/to/image.png",
|
||||
"urlPlaceholder": "https://civitai.com/images/... 或 https://civitai.red/images/... 或 C:/path/to/image.png",
|
||||
"fetchImage": "获取图片",
|
||||
"uploadSectionDescription": "上传带有 LoRA 元数据的图片以导入为配方。",
|
||||
"selectImage": "选择图片",
|
||||
@@ -1090,9 +1107,9 @@
|
||||
},
|
||||
"proceedText": "仅在你确定需要此操作时继续。",
|
||||
"urlLabel": "Civitai 模型 URL:",
|
||||
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676 或 https://civitai.red/models/649516/model-name?modelVersionId=726676",
|
||||
"helpText": {
|
||||
"title": "粘贴任意 Civitai 模型 URL。支持格式:",
|
||||
"title": "粘贴任意来自 civitai.com 或 civitai.red 的 Civitai 模型 URL。支持格式:",
|
||||
"format1": "https://civitai.com/models/649516",
|
||||
"format2": "https://civitai.com/models/649516?modelVersionId=726676",
|
||||
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||
@@ -1790,6 +1807,8 @@
|
||||
"deleteFailed": "删除 {type} 失败:{message}",
|
||||
"excludeSuccess": "{type} 排除成功",
|
||||
"excludeFailed": "排除 {type} 失败:{message}",
|
||||
"restoreSuccess": "{type} 已成功恢复",
|
||||
"restoreFailed": "恢复 {type} 失败:{message}",
|
||||
"fileNameUpdated": "文件名更新成功",
|
||||
"fileRenameFailed": "重命名文件失败:{error}",
|
||||
"previewUpdated": "预览图片更新成功",
|
||||
|
||||
@@ -175,6 +175,9 @@
|
||||
"success": "成功修復 {count} 個配方。",
|
||||
"cancelled": "修復已取消。已修復 {count} 個配方。",
|
||||
"error": "配方修復失敗:{message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "管理已排除的模型"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -250,6 +253,19 @@
|
||||
"civitaiApiKey": "Civitai API 金鑰",
|
||||
"civitaiApiKeyPlaceholder": "請輸入您的 Civitai API 金鑰",
|
||||
"civitaiApiKeyHelp": "用於從 Civitai 下載模型時的身份驗證",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai 站點",
|
||||
"help": "選擇使用「在 Civitai 中查看」時預設開啟的 Civitai 站點。",
|
||||
"options": {
|
||||
"com": "civitai.com(僅 SFW)",
|
||||
"red": "civitai.red(無限制)"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "已提供 Civitai 站點偏好設定",
|
||||
"content": "Civitai 現在使用 civitai.com 提供 SFW 內容,使用 civitai.red 提供無限制內容。你可以在設定中變更預設開啟的站點。",
|
||||
"openSettings": "開啟設定"
|
||||
},
|
||||
"openSettingsFileLocation": {
|
||||
"label": "開啟設定資料夾",
|
||||
"tooltip": "開啟包含 settings.json 的資料夾",
|
||||
@@ -667,6 +683,7 @@
|
||||
"moveToFolder": "移動到資料夾",
|
||||
"repairMetadata": "修復元數據",
|
||||
"excludeModel": "排除模型",
|
||||
"restoreModel": "還原模型",
|
||||
"deleteModel": "刪除模型",
|
||||
"shareRecipe": "分享配方",
|
||||
"viewAllLoras": "檢視全部 LoRA",
|
||||
@@ -1790,6 +1807,8 @@
|
||||
"deleteFailed": "刪除 {type} 失敗:{message}",
|
||||
"excludeSuccess": "{type} 已成功排除",
|
||||
"excludeFailed": "排除 {type} 失敗:{message}",
|
||||
"restoreSuccess": "{type} 已成功還原",
|
||||
"restoreFailed": "還原 {type} 失敗:{message}",
|
||||
"fileNameUpdated": "檔案名稱已成功更新",
|
||||
"fileRenameFailed": "重新命名檔案失敗:{error}",
|
||||
"previewUpdated": "預覽圖片已成功更新",
|
||||
|
||||
@@ -1,15 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
import inspect
|
||||
|
||||
from ..services.wildcard_service import (
|
||||
contains_dynamic_syntax,
|
||||
get_wildcard_service,
|
||||
is_trigger_words_input,
|
||||
)
|
||||
|
||||
class _AllContainer:
|
||||
"""Container that accepts any key for dynamic input validation."""
|
||||
|
||||
def __contains__(self, item):
|
||||
return True
|
||||
class _PromptOptionalInputs:
|
||||
"""Lookup that preserves explicit optional inputs and dynamic trigger slots."""
|
||||
|
||||
def __getitem__(self, key):
|
||||
return ("STRING", {"forceInput": True})
|
||||
def __init__(self, explicit_inputs: dict[str, tuple[str, dict[str, Any]]]) -> None:
|
||||
self._explicit_inputs = explicit_inputs
|
||||
|
||||
def __contains__(self, item: object) -> bool:
|
||||
if not isinstance(item, str):
|
||||
return False
|
||||
return item in self._explicit_inputs or is_trigger_words_input(item)
|
||||
|
||||
def __getitem__(self, key: str) -> tuple[str, dict[str, Any]]:
|
||||
if key in self._explicit_inputs:
|
||||
return self._explicit_inputs[key]
|
||||
if is_trigger_words_input(key):
|
||||
return (
|
||||
"STRING",
|
||||
{
|
||||
"forceInput": True,
|
||||
"tooltip": "Trigger words to prepend. Connect to add more inputs.",
|
||||
},
|
||||
)
|
||||
raise KeyError(key)
|
||||
|
||||
|
||||
class PromptLM:
|
||||
@@ -20,12 +43,19 @@ class PromptLM:
|
||||
DESCRIPTION = (
|
||||
"Encodes a text prompt using a CLIP model into an embedding that can be used "
|
||||
"to guide the diffusion model towards generating specific images. "
|
||||
"Supports dynamic trigger words inputs."
|
||||
"Supports dynamic trigger words inputs and runtime wildcard expansion."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
dyn_inputs = {
|
||||
optional_inputs: dict[str, tuple[str, dict[str, Any]]] = {
|
||||
"seed": (
|
||||
"INT",
|
||||
{
|
||||
"forceInput": True,
|
||||
"tooltip": "Optional seed for wildcard generation. Leave unconnected for non-deterministic wildcard expansion.",
|
||||
},
|
||||
),
|
||||
"trigger_words1": (
|
||||
"STRING",
|
||||
{
|
||||
@@ -35,10 +65,9 @@ class PromptLM:
|
||||
),
|
||||
}
|
||||
|
||||
# Bypass validation for dynamic inputs during graph execution
|
||||
stack = inspect.stack()
|
||||
if len(stack) > 2 and stack[2].function == "get_input_info":
|
||||
dyn_inputs = _AllContainer()
|
||||
optional_inputs = _PromptOptionalInputs(optional_inputs) # type: ignore[assignment]
|
||||
|
||||
return {
|
||||
"required": {
|
||||
@@ -46,8 +75,8 @@ class PromptLM:
|
||||
"AUTOCOMPLETE_TEXT_PROMPT,STRING",
|
||||
{
|
||||
"widgetType": "AUTOCOMPLETE_TEXT_PROMPT",
|
||||
"placeholder": "Enter prompt... /char, /artist for quick tag search",
|
||||
"tooltip": "The text to be encoded.",
|
||||
"placeholder": "Enter prompt... /character, /artist, /wildcard for quick search",
|
||||
"tooltip": "The text to be encoded. Wildcard references inserted with /wildcard are expanded at runtime.",
|
||||
},
|
||||
),
|
||||
"clip": (
|
||||
@@ -55,7 +84,7 @@ class PromptLM:
|
||||
{"tooltip": "The CLIP model used for encoding the text."},
|
||||
),
|
||||
},
|
||||
"optional": dyn_inputs,
|
||||
"optional": optional_inputs,
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("CONDITIONING", "STRING")
|
||||
@@ -65,20 +94,39 @@ class PromptLM:
|
||||
)
|
||||
FUNCTION = "encode"
|
||||
|
||||
def encode(self, text: str, clip: Any, **kwargs):
|
||||
# Collect all trigger words from dynamic inputs
|
||||
@classmethod
|
||||
def IS_CHANGED(
|
||||
cls,
|
||||
text: str,
|
||||
clip: Any | None = None,
|
||||
seed: int | None = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
del clip, kwargs
|
||||
if contains_dynamic_syntax(text) and seed is None:
|
||||
return float("NaN")
|
||||
return False
|
||||
|
||||
def encode(
|
||||
self,
|
||||
text: str,
|
||||
clip: Any,
|
||||
seed: int | None = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
expanded_text = get_wildcard_service().expand_text(text, seed=seed)
|
||||
|
||||
trigger_words = []
|
||||
for key, value in kwargs.items():
|
||||
if key.startswith("trigger_words") and value:
|
||||
if is_trigger_words_input(key) and value:
|
||||
trigger_words.append(value)
|
||||
|
||||
# Build final prompt
|
||||
if trigger_words:
|
||||
prompt = ", ".join(trigger_words + [text])
|
||||
prompt = ", ".join(trigger_words + [expanded_text])
|
||||
else:
|
||||
prompt = text
|
||||
prompt = expanded_text
|
||||
|
||||
from nodes import CLIPTextEncode # type: ignore
|
||||
|
||||
conditioning = CLIPTextEncode().encode(clip, prompt)[0]
|
||||
return (conditioning, prompt)
|
||||
return (conditioning, prompt)
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..services.wildcard_service import contains_dynamic_syntax, get_wildcard_service
|
||||
|
||||
|
||||
class TextLM:
|
||||
"""A simple text node with autocomplete support."""
|
||||
|
||||
NAME = "Text (LoraManager)"
|
||||
CATEGORY = "Lora Manager/utils"
|
||||
DESCRIPTION = (
|
||||
"A simple text input node with autocomplete support for tags and styles."
|
||||
"A simple text input node with autocomplete support for tags, styles, and wildcard expansion."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -15,8 +20,17 @@ class TextLM:
|
||||
"AUTOCOMPLETE_TEXT_PROMPT,STRING",
|
||||
{
|
||||
"widgetType": "AUTOCOMPLETE_TEXT_PROMPT",
|
||||
"placeholder": "Enter text... /char, /artist for quick tag search",
|
||||
"tooltip": "The text output.",
|
||||
"placeholder": "Enter text... /character, /artist, /wildcard for quick search",
|
||||
"tooltip": "The text output. Wildcard references inserted with /wildcard are expanded at runtime.",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"seed": (
|
||||
"INT",
|
||||
{
|
||||
"forceInput": True,
|
||||
"tooltip": "Optional seed for wildcard generation. Leave unconnected for non-deterministic wildcard expansion.",
|
||||
},
|
||||
),
|
||||
},
|
||||
@@ -24,10 +38,14 @@ class TextLM:
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("STRING",)
|
||||
OUTPUT_TOOLTIPS = (
|
||||
"The text output.",
|
||||
)
|
||||
OUTPUT_TOOLTIPS = ("The text output.",)
|
||||
FUNCTION = "process"
|
||||
|
||||
def process(self, text: str):
|
||||
return (text,)
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, text: str, seed: int | None = None):
|
||||
if contains_dynamic_syntax(text) and seed is None:
|
||||
return float("NaN")
|
||||
return False
|
||||
|
||||
def process(self, text: str, seed: int | None = None):
|
||||
return (get_wildcard_service().expand_text(text, seed=seed),)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import logging
|
||||
import json
|
||||
import re
|
||||
import os
|
||||
from typing import Any, Dict, Optional
|
||||
from .merger import GenParamsMerger
|
||||
from .base import RecipeMetadataParser
|
||||
from ..services.metadata_service import get_default_metadata_provider
|
||||
from ..utils.civitai_utils import extract_civitai_image_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,11 +39,12 @@ class RecipeEnricher:
|
||||
source_url = recipe.get("source_url") or recipe.get("source_path", "")
|
||||
|
||||
# Check if it's a Civitai image URL
|
||||
image_id_match = re.search(r'civitai\.com/images/(\d+)', str(source_url))
|
||||
if image_id_match:
|
||||
image_id = image_id_match.group(1)
|
||||
image_id = extract_civitai_image_id(str(source_url))
|
||||
if image_id:
|
||||
try:
|
||||
image_info = await civitai_client.get_image_info(image_id)
|
||||
image_info = await civitai_client.get_image_info(
|
||||
image_id, source_url=str(source_url)
|
||||
)
|
||||
if image_info:
|
||||
# Handle nested meta often found in Civitai API responses
|
||||
raw_meta = image_info.get("meta")
|
||||
|
||||
@@ -13,6 +13,7 @@ import contextlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
@@ -2410,6 +2411,16 @@ class FileSystemHandler:
|
||||
logger.error("Failed to open backup location: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def open_wildcards_location(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
from ...services.wildcard_service import get_wildcards_dir
|
||||
|
||||
wildcards_dir = get_wildcards_dir(create=True)
|
||||
return await self._open_path(wildcards_dir)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Failed to open wildcards location: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
|
||||
class CustomWordsHandler:
|
||||
"""Handler for autocomplete via TagFTSIndex."""
|
||||
@@ -2489,6 +2500,41 @@ class CustomWordsHandler:
|
||||
return None
|
||||
|
||||
|
||||
class WildcardsHandler:
|
||||
"""Handler for wildcard autocomplete search."""
|
||||
|
||||
def __init__(self, *, service=None) -> None:
|
||||
if service is None:
|
||||
from ...services.wildcard_service import get_wildcard_service
|
||||
|
||||
service = get_wildcard_service()
|
||||
self._service = service
|
||||
|
||||
async def search_wildcards(self, request: web.Request) -> web.Response:
|
||||
"""Search managed wildcard keys for autocomplete."""
|
||||
|
||||
try:
|
||||
search_term = request.query.get("search", "")
|
||||
limit = min(int(request.query.get("limit", "20")), 100)
|
||||
offset = max(0, int(request.query.get("offset", "0")))
|
||||
metadata = self._service.get_metadata(create_dir=True)
|
||||
results = self._service.search_keys(search_term, limit=limit, offset=offset)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"words": results,
|
||||
"meta": {
|
||||
"has_wildcards": metadata.has_wildcards,
|
||||
"wildcards_dir": metadata.wildcards_dir,
|
||||
"supported_formats": list(metadata.supported_formats),
|
||||
},
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Error searching wildcards: %s", exc, exc_info=True)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
|
||||
class NodeRegistryHandler:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -2717,6 +2763,7 @@ class MiscHandlerSet:
|
||||
backup: BackupHandler,
|
||||
filesystem: FileSystemHandler,
|
||||
custom_words: CustomWordsHandler,
|
||||
wildcards: WildcardsHandler,
|
||||
supporters: SupportersHandler,
|
||||
doctor: DoctorHandler,
|
||||
example_workflows: ExampleWorkflowsHandler,
|
||||
@@ -2734,6 +2781,7 @@ class MiscHandlerSet:
|
||||
self.backup = backup
|
||||
self.filesystem = filesystem
|
||||
self.custom_words = custom_words
|
||||
self.wildcards = wildcards
|
||||
self.supporters = supporters
|
||||
self.doctor = doctor
|
||||
self.example_workflows = example_workflows
|
||||
@@ -2774,7 +2822,9 @@ class MiscHandlerSet:
|
||||
"open_file_location": self.filesystem.open_file_location,
|
||||
"open_settings_location": self.filesystem.open_settings_location,
|
||||
"open_backup_location": self.filesystem.open_backup_location,
|
||||
"open_wildcards_location": self.filesystem.open_wildcards_location,
|
||||
"search_custom_words": self.custom_words.search_custom_words,
|
||||
"search_wildcards": self.wildcards.search_wildcards,
|
||||
"get_supporters": self.supporters.get_supporters,
|
||||
"get_example_workflows": self.example_workflows.get_example_workflows,
|
||||
"get_example_workflow": self.example_workflows.get_example_workflow,
|
||||
|
||||
@@ -224,6 +224,42 @@ class ModelListingHandler:
|
||||
)
|
||||
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:
|
||||
page = int(request.query.get("page", "1"))
|
||||
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)
|
||||
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:
|
||||
try:
|
||||
data = await request.json()
|
||||
@@ -2437,8 +2488,10 @@ class ModelHandlerSet:
|
||||
return {
|
||||
"handle_models_page": self.page_view.handle,
|
||||
"get_models": self.listing.get_models,
|
||||
"get_excluded_models": self.listing.get_excluded_models,
|
||||
"delete_model": self.management.delete_model,
|
||||
"exclude_model": self.management.exclude_model,
|
||||
"unexclude_model": self.management.unexclude_model,
|
||||
"fetch_civitai": self.management.fetch_civitai,
|
||||
"fetch_all_civitai": self.civitai.fetch_all_civitai,
|
||||
"relink_civitai": self.management.relink_civitai,
|
||||
|
||||
@@ -26,7 +26,7 @@ from ...services.recipes import (
|
||||
RecipeValidationError,
|
||||
)
|
||||
from ...services.metadata_service import get_default_metadata_provider
|
||||
from ...utils.civitai_utils import rewrite_preview_url
|
||||
from ...utils.civitai_utils import extract_civitai_image_id, rewrite_preview_url
|
||||
from ...utils.exif_utils import ExifUtils
|
||||
from ...recipes.merger import GenParamsMerger
|
||||
from ...recipes.enrichment import RecipeEnricher
|
||||
@@ -1196,13 +1196,15 @@ class RecipeManagementHandler:
|
||||
temp_path = temp_file.name
|
||||
download_url = image_url
|
||||
image_info = None
|
||||
civitai_match = re.match(r"https://civitai\.com/images/(\d+)", image_url)
|
||||
if civitai_match:
|
||||
civitai_image_id = extract_civitai_image_id(image_url)
|
||||
if civitai_image_id:
|
||||
if civitai_client is None:
|
||||
raise RecipeDownloadError(
|
||||
"Civitai client unavailable for image download"
|
||||
)
|
||||
image_info = await civitai_client.get_image_info(civitai_match.group(1))
|
||||
image_info = await civitai_client.get_image_info(
|
||||
civitai_image_id, source_url=image_url
|
||||
)
|
||||
if not image_info:
|
||||
raise RecipeDownloadError(
|
||||
"Failed to fetch image information from Civitai"
|
||||
@@ -1236,7 +1238,7 @@ class RecipeManagementHandler:
|
||||
return (
|
||||
file_obj.read(),
|
||||
extension,
|
||||
image_info.get("meta") if civitai_match and image_info else None,
|
||||
image_info.get("meta") if civitai_image_id and image_info else None,
|
||||
)
|
||||
except RecipeDownloadError:
|
||||
raise
|
||||
|
||||
@@ -30,6 +30,8 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("POST", "/api/lm/settings/libraries/activate", "activate_library"),
|
||||
RouteDefinition("GET", "/api/lm/health-check", "health_check"),
|
||||
RouteDefinition("GET", "/api/lm/supporters", "get_supporters"),
|
||||
RouteDefinition("GET", "/api/lm/wildcards/search", "search_wildcards"),
|
||||
RouteDefinition("POST", "/api/lm/wildcards/open-location", "open_wildcards_location"),
|
||||
RouteDefinition("POST", "/api/lm/open-file-location", "open_file_location"),
|
||||
RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"),
|
||||
RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"),
|
||||
|
||||
@@ -35,6 +35,7 @@ from .handlers.misc_handlers import (
|
||||
SupportersHandler,
|
||||
TrainedWordsHandler,
|
||||
UsageStatsHandler,
|
||||
WildcardsHandler,
|
||||
build_service_registry_adapter,
|
||||
)
|
||||
from .handlers.base_model_handlers import BaseModelHandlerSet
|
||||
@@ -130,6 +131,7 @@ class MiscRoutes:
|
||||
metadata_provider_factory=self._metadata_provider_factory,
|
||||
)
|
||||
custom_words = CustomWordsHandler()
|
||||
wildcards = WildcardsHandler()
|
||||
supporters = SupportersHandler()
|
||||
doctor = DoctorHandler(settings_service=self._settings)
|
||||
example_workflows = ExampleWorkflowsHandler()
|
||||
@@ -148,6 +150,7 @@ class MiscRoutes:
|
||||
backup=backup,
|
||||
filesystem=filesystem,
|
||||
custom_words=custom_words,
|
||||
wildcards=wildcards,
|
||||
supporters=supporters,
|
||||
doctor=doctor,
|
||||
example_workflows=example_workflows,
|
||||
|
||||
@@ -22,8 +22,10 @@ class RouteDefinition:
|
||||
|
||||
COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
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}/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-all-civitai", "fetch_all_civitai"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/relink-civitai", "relink_civitai"),
|
||||
|
||||
@@ -20,6 +20,7 @@ from .model_query import (
|
||||
resolve_sub_type,
|
||||
)
|
||||
from .settings_manager import get_settings_manager
|
||||
from ..utils.civitai_utils import build_civitai_model_page_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -178,6 +179,57 @@ class BaseModelService(ABC):
|
||||
)
|
||||
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):
|
||||
"""Fetch data sorted by usage count (desc/asc)."""
|
||||
cache = await self.cache_repository.get_cache()
|
||||
@@ -217,6 +269,62 @@ class BaseModelService(ABC):
|
||||
)
|
||||
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(
|
||||
self, data: List[Dict], hash_filters: Dict
|
||||
) -> List[Dict]:
|
||||
@@ -774,9 +882,12 @@ class BaseModelService(ABC):
|
||||
version_id = civitai_data.get("id")
|
||||
|
||||
if model_id:
|
||||
civitai_url = f"https://civitai.com/models/{model_id}"
|
||||
if version_id:
|
||||
civitai_url += f"?modelVersionId={version_id}"
|
||||
civitai_host = self.settings.get("civitai_host", "civitai.com")
|
||||
civitai_url = build_civitai_model_page_url(
|
||||
model_id,
|
||||
version_id,
|
||||
host=civitai_host,
|
||||
)
|
||||
|
||||
return {
|
||||
"civitai_url": civitai_url,
|
||||
|
||||
@@ -42,6 +42,7 @@ class CheckpointService(BaseModelService):
|
||||
"notes": checkpoint_data.get("notes", ""),
|
||||
"sub_type": sub_type,
|
||||
"favorite": checkpoint_data.get("favorite", False),
|
||||
"exclude": bool(checkpoint_data.get("exclude", False)),
|
||||
"update_available": bool(checkpoint_data.get("update_available", False)),
|
||||
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)),
|
||||
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True)
|
||||
|
||||
@@ -30,7 +30,7 @@ class CivitaiBaseModelService:
|
||||
DEFAULT_CACHE_TTL = 7 * 24 * 60 * 60
|
||||
|
||||
# Civitai API endpoint for enums
|
||||
CIVITAI_ENUMS_URL = "https://civitai.com/api/v1/enums"
|
||||
CIVITAI_ENUMS_URL = "https://civitai.red/api/v1/enums"
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls) -> CivitaiBaseModelService:
|
||||
|
||||
@@ -39,7 +39,10 @@ class CivitaiClient:
|
||||
return
|
||||
self._initialized = True
|
||||
|
||||
self.base_url = "https://civitai.com/api/v1"
|
||||
self.base_url = "https://civitai.red/api/v1"
|
||||
|
||||
def _build_image_info_url(self, image_id: str) -> str:
|
||||
return f"{self.base_url}/images?imageId={image_id}&nsfw=X"
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
@@ -190,7 +193,9 @@ class CivitaiClient:
|
||||
"""Get all versions of a model with local availability info"""
|
||||
try:
|
||||
success, result = await self._make_request(
|
||||
"GET", f"{self.base_url}/models/{model_id}", use_auth=True
|
||||
"GET",
|
||||
f"{self.base_url}/models/{model_id}",
|
||||
use_auth=True,
|
||||
)
|
||||
if success:
|
||||
# Also return model type along with versions
|
||||
@@ -346,7 +351,9 @@ class CivitaiClient:
|
||||
|
||||
async def _fetch_model_data(self, model_id: int) -> Optional[Dict]:
|
||||
success, data = await self._make_request(
|
||||
"GET", f"{self.base_url}/models/{model_id}", use_auth=True
|
||||
"GET",
|
||||
f"{self.base_url}/models/{model_id}",
|
||||
use_auth=True,
|
||||
)
|
||||
if success:
|
||||
return data
|
||||
@@ -358,7 +365,9 @@ class CivitaiClient:
|
||||
return None
|
||||
|
||||
success, version = await self._make_request(
|
||||
"GET", f"{self.base_url}/model-versions/{version_id}", use_auth=True
|
||||
"GET",
|
||||
f"{self.base_url}/model-versions/{version_id}",
|
||||
use_auth=True,
|
||||
)
|
||||
if success:
|
||||
return version
|
||||
@@ -371,7 +380,9 @@ class CivitaiClient:
|
||||
return None
|
||||
|
||||
success, version = await self._make_request(
|
||||
"GET", f"{self.base_url}/model-versions/by-hash/{model_hash}", use_auth=True
|
||||
"GET",
|
||||
f"{self.base_url}/model-versions/by-hash/{model_hash}",
|
||||
use_auth=True,
|
||||
)
|
||||
if success:
|
||||
return version
|
||||
@@ -453,13 +464,11 @@ class CivitaiClient:
|
||||
try:
|
||||
url = f"{self.base_url}/model-versions/{version_id}"
|
||||
|
||||
logger.debug(f"Resolving DNS for model version info: {url}")
|
||||
logger.debug("Resolving Civitai model version info: %s", url)
|
||||
success, result = await self._make_request("GET", url, use_auth=True)
|
||||
|
||||
if success:
|
||||
logger.debug(
|
||||
f"Successfully fetched model version info for: {version_id}"
|
||||
)
|
||||
logger.debug("Successfully fetched model version info for: %s", version_id)
|
||||
self._remove_comfy_metadata(result)
|
||||
return result, None
|
||||
|
||||
@@ -479,48 +488,58 @@ class CivitaiClient:
|
||||
logger.error(error_msg)
|
||||
return None, error_msg
|
||||
|
||||
async def get_image_info(self, image_id: str) -> Optional[Dict]:
|
||||
async def get_image_info(
|
||||
self, image_id: str, source_url: str | None = None
|
||||
) -> Optional[Dict]:
|
||||
"""Fetch image information from Civitai API
|
||||
|
||||
Args:
|
||||
image_id: The Civitai image ID
|
||||
source_url: Original image page URL. Accepted for caller compatibility;
|
||||
API requests always target ``civitai.red``.
|
||||
|
||||
Returns:
|
||||
Optional[Dict]: The image data or None if not found
|
||||
"""
|
||||
try:
|
||||
url = f"{self.base_url}/images?imageId={image_id}&nsfw=X"
|
||||
requested_id = int(image_id)
|
||||
|
||||
logger.debug(f"Fetching image info for ID: {image_id}")
|
||||
url = self._build_image_info_url(image_id)
|
||||
success, result = await self._make_request("GET", url, use_auth=True)
|
||||
|
||||
if success:
|
||||
if result and "items" in result and isinstance(result["items"], list):
|
||||
items = result["items"]
|
||||
|
||||
# First, try to find the item with matching ID
|
||||
for item in items:
|
||||
if isinstance(item, dict) and item.get("id") == requested_id:
|
||||
logger.debug(f"Successfully fetched image info for ID: {image_id}")
|
||||
return item
|
||||
|
||||
# No matching ID found - log warning with details about returned items
|
||||
returned_ids = [
|
||||
item.get("id") for item in items
|
||||
if isinstance(item, dict) and "id" in item
|
||||
]
|
||||
logger.warning(
|
||||
f"CivitAI API returned no matching image for requested ID {image_id}. "
|
||||
f"Returned {len(items)} item(s) with IDs: {returned_ids}. "
|
||||
f"This may indicate the image was deleted, hidden, or there is a database lag."
|
||||
)
|
||||
return None
|
||||
|
||||
logger.warning(f"No image found with ID: {image_id}")
|
||||
if not success:
|
||||
logger.error(
|
||||
"Failed to fetch image info for ID %s from civitai.red: %s",
|
||||
image_id,
|
||||
result,
|
||||
)
|
||||
return None
|
||||
|
||||
logger.error(f"Failed to fetch image info for ID: {image_id}: {result}")
|
||||
if result and "items" in result and isinstance(result["items"], list):
|
||||
items = result["items"]
|
||||
|
||||
for item in items:
|
||||
if isinstance(item, dict) and item.get("id") == requested_id:
|
||||
logger.debug(
|
||||
"Successfully fetched image info for ID %s from civitai.red",
|
||||
image_id,
|
||||
)
|
||||
return item
|
||||
|
||||
returned_ids = [
|
||||
item.get("id")
|
||||
for item in items
|
||||
if isinstance(item, dict) and "id" in item
|
||||
]
|
||||
|
||||
logger.warning(
|
||||
"CivitAI API returned no matching image for requested ID %s from civitai.red. Returned %d item(s) with IDs: %s. This may indicate the image was deleted, hidden, or there is a database lag.",
|
||||
image_id,
|
||||
len(items),
|
||||
returned_ids,
|
||||
)
|
||||
return None
|
||||
|
||||
logger.warning("No image found with ID: %s", image_id)
|
||||
return None
|
||||
except RateLimitError:
|
||||
raise
|
||||
@@ -539,8 +558,12 @@ class CivitaiClient:
|
||||
return None
|
||||
|
||||
try:
|
||||
url = f"{self.base_url}/models?username={username}"
|
||||
success, result = await self._make_request("GET", url, use_auth=True)
|
||||
success, result = await self._make_request(
|
||||
"GET",
|
||||
f"{self.base_url}/models",
|
||||
use_auth=True,
|
||||
params={"username": username},
|
||||
)
|
||||
|
||||
if not success:
|
||||
logger.error("Failed to fetch models for %s: %s", username, result)
|
||||
|
||||
@@ -7,11 +7,13 @@ with category filtering and enriched results including post counts.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_EMBEDDED_COMMAND_PATTERN = re.compile(r"\s/\w")
|
||||
class CustomWordsService:
|
||||
"""Service for autocomplete via TagFTSIndex.
|
||||
|
||||
@@ -77,12 +79,28 @@ class CustomWordsService:
|
||||
Returns:
|
||||
List of dicts with tag_name, category, and post_count.
|
||||
"""
|
||||
normalized_search = search_term.strip()
|
||||
if not normalized_search:
|
||||
return []
|
||||
|
||||
# Prompt widgets should only send the active token, but guard against
|
||||
# accidental full-prompt queries reaching the FTS path.
|
||||
if (
|
||||
"__" in normalized_search
|
||||
or "," in normalized_search
|
||||
or ">" in normalized_search
|
||||
or "\n" in normalized_search
|
||||
or "\r" in normalized_search
|
||||
or _EMBEDDED_COMMAND_PATTERN.search(normalized_search)
|
||||
):
|
||||
logger.debug("Skipping prompt-like custom words query: %s", normalized_search)
|
||||
return []
|
||||
|
||||
tag_index = self._get_tag_index()
|
||||
if tag_index is not None:
|
||||
results = tag_index.search(
|
||||
search_term, categories=categories, limit=limit, offset=offset
|
||||
return tag_index.search(
|
||||
normalized_search, categories=categories, limit=limit, offset=offset
|
||||
)
|
||||
return results
|
||||
|
||||
logger.debug("TagFTSIndex not available, returning empty results")
|
||||
return []
|
||||
|
||||
@@ -16,7 +16,7 @@ from ..utils.constants import (
|
||||
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
|
||||
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.utils import sanitize_folder_name
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
@@ -31,6 +31,11 @@ import tempfile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CIVITAI_DOWNLOAD_URL_PREFIXES = (
|
||||
"https://civitai.com/api/download/",
|
||||
"https://civitai.red/api/download/",
|
||||
)
|
||||
|
||||
|
||||
class DownloadManager:
|
||||
_instance = None
|
||||
@@ -639,7 +644,9 @@ class DownloadManager:
|
||||
if mirrors:
|
||||
for mirror in mirrors:
|
||||
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
|
||||
# This avoids failed downloads from deleted Civitai models
|
||||
@@ -647,18 +654,20 @@ class DownloadManager:
|
||||
civitai_urls = [
|
||||
u
|
||||
for u in download_urls
|
||||
if u.startswith("https://civitai.com/api/download/")
|
||||
if u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
||||
]
|
||||
non_civitai_urls = [
|
||||
u
|
||||
for u in download_urls
|
||||
if not u.startswith("https://civitai.com/api/download/")
|
||||
if not u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
||||
]
|
||||
download_urls = non_civitai_urls + civitai_urls
|
||||
else:
|
||||
download_url = file_info.get("downloadUrl")
|
||||
if download_url:
|
||||
download_urls.append(download_url)
|
||||
download_urls.append(
|
||||
normalize_civitai_download_url(download_url)
|
||||
)
|
||||
|
||||
if not download_urls:
|
||||
return {"success": False, "error": "No mirror URL found"}
|
||||
@@ -1133,7 +1142,8 @@ class DownloadManager:
|
||||
pause_control.update_stall_timeout(downloader.stall_timeout)
|
||||
last_error = None
|
||||
for download_url in download_urls:
|
||||
use_auth = download_url.startswith("https://civitai.com/api/download/")
|
||||
download_url = normalize_civitai_download_url(download_url)
|
||||
use_auth = download_url.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
||||
download_kwargs = {
|
||||
"progress_callback": lambda progress, snapshot=None: (
|
||||
self._handle_download_progress(
|
||||
|
||||
@@ -42,6 +42,7 @@ class EmbeddingService(BaseModelService):
|
||||
"notes": embedding_data.get("notes", ""),
|
||||
"sub_type": sub_type,
|
||||
"favorite": embedding_data.get("favorite", False),
|
||||
"exclude": bool(embedding_data.get("exclude", False)),
|
||||
"update_available": bool(embedding_data.get("update_available", False)),
|
||||
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)),
|
||||
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True)
|
||||
|
||||
@@ -48,6 +48,7 @@ class LoraService(BaseModelService):
|
||||
"usage_tips": lora_data.get("usage_tips", ""),
|
||||
"notes": lora_data.get("notes", ""),
|
||||
"favorite": lora_data.get("favorite", False),
|
||||
"exclude": bool(lora_data.get("exclude", False)),
|
||||
"update_available": bool(lora_data.get("update_available", False)),
|
||||
"skip_metadata_refresh": bool(
|
||||
lora_data.get("skip_metadata_refresh", False)
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Opti
|
||||
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..utils.constants import PREVIEW_EXTENSIONS
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -207,11 +208,56 @@ class ModelLifecycleService:
|
||||
|
||||
excluded = getattr(self._scanner, "_excluded_models", None)
|
||||
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"
|
||||
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]:
|
||||
"""Delete a collection of models via the scanner bulk operation."""
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Optional
|
||||
@@ -14,7 +13,7 @@ import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from ...utils.utils import calculate_recipe_fingerprint
|
||||
from ...utils.civitai_utils import rewrite_preview_url
|
||||
from ...utils.civitai_utils import extract_civitai_image_id, rewrite_preview_url
|
||||
from .errors import (
|
||||
RecipeDownloadError,
|
||||
RecipeNotFoundError,
|
||||
@@ -104,9 +103,11 @@ class RecipeAnalysisService:
|
||||
extension = ".jpg" # Default
|
||||
|
||||
try:
|
||||
civitai_match = re.match(r"https://civitai\.com/images/(\d+)", url)
|
||||
if civitai_match:
|
||||
image_info = await civitai_client.get_image_info(civitai_match.group(1))
|
||||
civitai_image_id = extract_civitai_image_id(url)
|
||||
if civitai_image_id:
|
||||
image_info = await civitai_client.get_image_info(
|
||||
civitai_image_id, source_url=url
|
||||
)
|
||||
if not image_info:
|
||||
raise RecipeDownloadError(
|
||||
"Failed to fetch image information from Civitai"
|
||||
|
||||
@@ -54,6 +54,7 @@ DEFAULT_KEYS_CLEANUP_THRESHOLD = 10
|
||||
|
||||
DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"civitai_api_key": "",
|
||||
"civitai_host": "civitai.com",
|
||||
"use_portable_settings": False,
|
||||
"hash_chunk_size_mb": DEFAULT_HASH_CHUNK_SIZE_MB,
|
||||
"language": "en",
|
||||
|
||||
@@ -450,9 +450,9 @@ class TagFTSIndex:
|
||||
the tag_name, the result will include a "matched_alias" field.
|
||||
|
||||
Ranking is based on a combination of:
|
||||
1. FTS5 bm25 relevance score (how well the text matches)
|
||||
2. Post count (popularity)
|
||||
3. Exact prefix match boost (tag_name starts with query)
|
||||
1. Exact prefix match boost (tag_name starts with query)
|
||||
2. Post count to preserve expected autocomplete ordering
|
||||
3. FTS5 bm25 relevance score as a deterministic tie-breaker
|
||||
|
||||
Args:
|
||||
query: The search query string.
|
||||
@@ -484,65 +484,17 @@ class TagFTSIndex:
|
||||
with self._lock:
|
||||
conn = self._connect(readonly=True)
|
||||
try:
|
||||
# Build the SQL query with bm25 ranking
|
||||
# FTS5 bm25() returns negative scores, lower is better
|
||||
# We use -bm25() to get higher=better scores
|
||||
# Weights: -100.0 for exact matches, 1.0 for others
|
||||
# Add LOG10(post_count) weighting to boost popular tags
|
||||
# Use CASE to boost tag_name prefix matches above alias matches
|
||||
if categories:
|
||||
placeholders = ",".join("?" * len(categories))
|
||||
sql = f"""
|
||||
SELECT t.tag_name, t.category, t.post_count, t.aliases,
|
||||
CASE
|
||||
WHEN t.tag_name LIKE ? ESCAPE '\\' THEN 1
|
||||
ELSE 0
|
||||
END AS is_tag_name_match,
|
||||
bm25(tag_fts, -100.0, 1.0, 1.0) + LOG10(t.post_count + 1) * 10.0 AS rank_score
|
||||
FROM tag_fts
|
||||
JOIN tags t ON tag_fts.rowid = t.rowid
|
||||
WHERE tag_fts.searchable_text MATCH ?
|
||||
AND t.category IN ({placeholders})
|
||||
ORDER BY is_tag_name_match DESC, rank_score DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
# Escape special LIKE characters and add wildcard
|
||||
query_escaped = (
|
||||
query_lower.lstrip("/")
|
||||
.replace("\\", "\\\\")
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_")
|
||||
)
|
||||
params = (
|
||||
[query_escaped + "%", fts_query]
|
||||
+ categories
|
||||
+ [limit, offset]
|
||||
)
|
||||
else:
|
||||
sql = """
|
||||
SELECT t.tag_name, t.category, t.post_count, t.aliases,
|
||||
CASE
|
||||
WHEN t.tag_name LIKE ? ESCAPE '\\' THEN 1
|
||||
ELSE 0
|
||||
END AS is_tag_name_match,
|
||||
bm25(tag_fts, -100.0, 1.0, 1.0) + LOG10(t.post_count + 1) * 10.0 AS rank_score
|
||||
FROM tag_fts
|
||||
JOIN tags t ON tag_fts.rowid = t.rowid
|
||||
WHERE tag_fts.searchable_text MATCH ?
|
||||
ORDER BY is_tag_name_match DESC, rank_score DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
query_escaped = (
|
||||
query_lower.lstrip("/")
|
||||
.replace("\\", "\\\\")
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_")
|
||||
)
|
||||
params = [query_escaped + "%", fts_query, limit, offset]
|
||||
|
||||
sql, params = self._build_search_statement(
|
||||
query_lower=query_lower,
|
||||
fts_query=fts_query,
|
||||
categories=categories,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
cursor = conn.execute(sql, params)
|
||||
rows = cursor.fetchall()
|
||||
results = []
|
||||
for row in cursor.fetchall():
|
||||
for row in rows:
|
||||
result = {
|
||||
"tag_name": row[0],
|
||||
"category": row[1],
|
||||
@@ -571,6 +523,62 @@ class TagFTSIndex:
|
||||
logger.debug("Tag FTS search error for query '%s': %s", query, exc)
|
||||
return []
|
||||
|
||||
def _build_search_statement(
|
||||
self,
|
||||
query_lower: str,
|
||||
fts_query: str,
|
||||
categories: Optional[List[int]],
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> tuple[str, list[object]]:
|
||||
"""Build the SQL statement and params for a tag search."""
|
||||
# Escape special LIKE characters and add wildcard
|
||||
query_escaped = (
|
||||
query_lower.lstrip("/")
|
||||
.replace("\\", "\\\\")
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_")
|
||||
)
|
||||
|
||||
# FTS5 bm25() returns negative scores, lower is better.
|
||||
# We use -bm25() to get higher=better scores, but keep post_count as the
|
||||
# primary sort within tag-name prefix matches so autocomplete ordering
|
||||
# remains aligned with the existing popularity-first behavior.
|
||||
if categories:
|
||||
placeholders = ",".join("?" * len(categories))
|
||||
sql = f"""
|
||||
SELECT t.tag_name, t.category, t.post_count, t.aliases,
|
||||
CASE
|
||||
WHEN t.tag_name LIKE ? ESCAPE '\\' THEN 1
|
||||
ELSE 0
|
||||
END AS is_tag_name_match,
|
||||
bm25(tag_fts, -100.0, 1.0, 1.0) AS rank_score
|
||||
FROM tag_fts
|
||||
CROSS JOIN tags t ON t.rowid = tag_fts.rowid
|
||||
WHERE tag_fts.searchable_text MATCH ?
|
||||
AND t.category IN ({placeholders})
|
||||
ORDER BY is_tag_name_match DESC, t.post_count DESC, rank_score DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
params = [query_escaped + "%", fts_query] + categories + [limit, offset]
|
||||
else:
|
||||
sql = """
|
||||
SELECT t.tag_name, t.category, t.post_count, t.aliases,
|
||||
CASE
|
||||
WHEN t.tag_name LIKE ? ESCAPE '\\' THEN 1
|
||||
ELSE 0
|
||||
END AS is_tag_name_match,
|
||||
bm25(tag_fts, -100.0, 1.0, 1.0) AS rank_score
|
||||
FROM tag_fts
|
||||
JOIN tags t ON tag_fts.rowid = t.rowid
|
||||
WHERE tag_fts.searchable_text MATCH ?
|
||||
ORDER BY is_tag_name_match DESC, t.post_count DESC, rank_score DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
params = [query_escaped + "%", fts_query, limit, offset]
|
||||
|
||||
return sql, params
|
||||
|
||||
def _find_matched_alias(
|
||||
self, query: str, tag_name: str, aliases_str: str
|
||||
) -> Optional[str]:
|
||||
|
||||
428
py/services/wildcard_service.py
Normal file
428
py/services/wildcard_service.py
Normal 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",
|
||||
]
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, Iterable, Mapping, Sequence
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
from urllib.parse import parse_qs, urlparse, urlunparse
|
||||
|
||||
|
||||
_SUPPORTED_CIVITAI_PAGE_HOSTS = frozenset({"civitai.com", "civitai.red"})
|
||||
DEFAULT_CIVITAI_PAGE_HOST = "civitai.com"
|
||||
_DEFAULT_ALLOW_COMMERCIAL_USE: Sequence[str] = ("Sell",)
|
||||
_LICENSE_DEFAULTS: Dict[str, Any] = {
|
||||
"allowNoCredit": True,
|
||||
@@ -17,6 +20,133 @@ _COMMERCIAL_ALLOWED_VALUES = {"sell", "rent", "rentcivit", "image"}
|
||||
_COMMERCIAL_SHIFT = 1
|
||||
|
||||
|
||||
def is_supported_civitai_page_host(hostname: str | None) -> bool:
|
||||
"""Return whether the hostname is a supported Civitai page domain."""
|
||||
|
||||
if not hostname:
|
||||
return False
|
||||
return hostname.lower() in _SUPPORTED_CIVITAI_PAGE_HOSTS
|
||||
|
||||
|
||||
def normalize_civitai_page_host(hostname: str | None) -> str:
|
||||
"""Return a supported Civitai page host or the default host."""
|
||||
|
||||
if not isinstance(hostname, str):
|
||||
return DEFAULT_CIVITAI_PAGE_HOST
|
||||
|
||||
normalized = hostname.strip().lower()
|
||||
if is_supported_civitai_page_host(normalized):
|
||||
return normalized
|
||||
|
||||
return DEFAULT_CIVITAI_PAGE_HOST
|
||||
|
||||
|
||||
def build_civitai_model_page_url(
|
||||
model_id: str | int | None,
|
||||
version_id: str | int | None = None,
|
||||
*,
|
||||
host: str | None = None,
|
||||
) -> str | None:
|
||||
"""Build a Civitai model or model-version page URL."""
|
||||
|
||||
normalized_host = normalize_civitai_page_host(host)
|
||||
normalized_model_id = str(model_id).strip() if model_id is not None else ""
|
||||
normalized_version_id = str(version_id).strip() if version_id is not None else ""
|
||||
|
||||
if normalized_model_id:
|
||||
path = f"/models/{normalized_model_id}"
|
||||
query = f"modelVersionId={normalized_version_id}" if normalized_version_id else ""
|
||||
return urlunparse(("https", normalized_host, path, "", query, ""))
|
||||
|
||||
if normalized_version_id:
|
||||
return urlunparse(
|
||||
("https", normalized_host, f"/model-versions/{normalized_version_id}", "", "", "")
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _parse_supported_civitai_page_url(url: str | None):
|
||||
if not url:
|
||||
return None
|
||||
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
return None
|
||||
|
||||
if not is_supported_civitai_page_host(parsed.hostname):
|
||||
return None
|
||||
|
||||
return parsed
|
||||
|
||||
|
||||
def extract_civitai_model_url_parts(
|
||||
url: str | None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Extract model and version identifiers from a supported Civitai model URL."""
|
||||
|
||||
parsed = _parse_supported_civitai_page_url(url)
|
||||
if parsed is None:
|
||||
return None, None
|
||||
|
||||
path_match = re.search(r"/models/(\d+)", parsed.path)
|
||||
if not path_match:
|
||||
return None, None
|
||||
|
||||
model_id = path_match.group(1)
|
||||
|
||||
query_params = parse_qs(parsed.query)
|
||||
version_values = query_params.get("modelVersionId") or []
|
||||
version_id = version_values[0] if version_values else None
|
||||
return model_id, version_id
|
||||
|
||||
|
||||
def extract_civitai_image_id(url: str | None) -> str | None:
|
||||
"""Extract the image identifier from a supported Civitai image page URL."""
|
||||
|
||||
parsed = _parse_supported_civitai_page_url(url)
|
||||
if parsed is None:
|
||||
return None
|
||||
|
||||
path_match = re.search(r"/images/(\d+)", parsed.path)
|
||||
if not path_match:
|
||||
return None
|
||||
|
||||
return path_match.group(1)
|
||||
|
||||
|
||||
def 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]:
|
||||
"""Return a normalized list of commercial permissions preserving source values."""
|
||||
|
||||
@@ -199,6 +329,10 @@ def rewrite_preview_url(
|
||||
|
||||
__all__ = [
|
||||
"build_license_flags",
|
||||
"extract_civitai_image_id",
|
||||
"extract_civitai_page_host",
|
||||
"extract_civitai_model_url_parts",
|
||||
"is_supported_civitai_page_host",
|
||||
"resolve_license_payload",
|
||||
"resolve_license_info",
|
||||
"rewrite_preview_url",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||
version = "1.0.2"
|
||||
version = "1.0.5"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
@@ -14,7 +14,8 @@ dependencies = [
|
||||
"natsort",
|
||||
"GitPython",
|
||||
"aiosqlite",
|
||||
"platformdirs"
|
||||
"platformdirs",
|
||||
"pyyaml"
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -11,3 +11,4 @@ GitPython
|
||||
aiosqlite
|
||||
beautifulsoup4
|
||||
platformdirs
|
||||
pyyaml
|
||||
|
||||
@@ -243,3 +243,58 @@
|
||||
-ms-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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -680,3 +680,22 @@
|
||||
margin-left: 0;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -56,8 +56,10 @@ export function getApiEndpoints(modelType) {
|
||||
return {
|
||||
// Base CRUD operations
|
||||
list: `/api/lm/${modelType}/list`,
|
||||
excluded: `/api/lm/${modelType}/excluded`,
|
||||
delete: `/api/lm/${modelType}/delete`,
|
||||
exclude: `/api/lm/${modelType}/exclude`,
|
||||
unexclude: `/api/lm/${modelType}/unexclude`,
|
||||
rename: `/api/lm/${modelType}/rename`,
|
||||
save: `/api/lm/${modelType}/save-metadata`,
|
||||
cancelTask: `/api/lm/${modelType}/cancel-task`,
|
||||
|
||||
@@ -51,6 +51,7 @@ export class BaseModelApiClient {
|
||||
async fetchModelsPage(page = 1, pageSize = null) {
|
||||
const pageState = this.getPageState();
|
||||
const actualPageSize = pageSize || pageState.pageSize || this.apiConfig.config.defaultPageSize;
|
||||
const isExcludedView = pageState.viewMode === 'excluded';
|
||||
|
||||
try {
|
||||
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) {
|
||||
throw new Error(`Failed to fetch ${this.apiConfig.config.displayName}s: ${response.statusText}`);
|
||||
}
|
||||
@@ -84,7 +88,7 @@ export class BaseModelApiClient {
|
||||
totalPages: data.total_pages,
|
||||
currentPage: page,
|
||||
hasMore: page < data.total_pages,
|
||||
folders: data.folders
|
||||
folders: data.folders || []
|
||||
};
|
||||
|
||||
} 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) {
|
||||
try {
|
||||
state.loadingManager.showSimpleLoading(`Renaming ${this.apiConfig.config.singularName} file...`);
|
||||
@@ -883,20 +931,21 @@ export class BaseModelApiClient {
|
||||
|
||||
_buildQueryParams(baseParams, pageState) {
|
||||
const params = new URLSearchParams(baseParams);
|
||||
const isExcludedView = pageState.viewMode === 'excluded';
|
||||
|
||||
if (pageState.activeFolder !== null) {
|
||||
if (!isExcludedView && pageState.activeFolder !== null) {
|
||||
params.append('folder', pageState.activeFolder);
|
||||
}
|
||||
|
||||
if (pageState.showFavoritesOnly) {
|
||||
if (!isExcludedView && pageState.showFavoritesOnly) {
|
||||
params.append('favorites_only', 'true');
|
||||
}
|
||||
|
||||
if (pageState.showUpdateAvailableOnly) {
|
||||
if (!isExcludedView && pageState.showUpdateAvailableOnly) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -918,7 +967,7 @@ export class BaseModelApiClient {
|
||||
|
||||
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) {
|
||||
Object.entries(pageState.filters.tags).forEach(([tag, state]) => {
|
||||
if (state === 'include') {
|
||||
@@ -981,7 +1030,9 @@ export class BaseModelApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
this._addModelSpecificParams(params, pageState);
|
||||
if (!isExcludedView) {
|
||||
this._addModelSpecificParams(params, pageState);
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export class CheckpointContextMenu extends BaseContextMenu {
|
||||
|
||||
showMenu(x, y, card) {
|
||||
super.showMenu(x, y, card);
|
||||
this.updateExcludeMenuItem();
|
||||
|
||||
// Update the "Move to other root" label based on current model type
|
||||
const moveOtherItem = this.menu.querySelector('[data-action="move-other"]');
|
||||
@@ -83,6 +84,9 @@ export class CheckpointContextMenu extends BaseContextMenu {
|
||||
case 'exclude':
|
||||
showExcludeModal(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
case 'restore':
|
||||
this.restoreExcludedModel(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,11 @@ export class EmbeddingContextMenu extends BaseContextMenu {
|
||||
async saveModelMetadata(filePath, data) {
|
||||
return getModelApiClient().saveModelMetadata(filePath, data);
|
||||
}
|
||||
|
||||
showMenu(x, y, card) {
|
||||
super.showMenu(x, y, card);
|
||||
this.updateExcludeMenuItem();
|
||||
}
|
||||
|
||||
handleMenuAction(action) {
|
||||
// First try to handle with common actions
|
||||
@@ -56,6 +61,9 @@ export class EmbeddingContextMenu extends BaseContextMenu {
|
||||
case 'exclude':
|
||||
showExcludeModal(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
case 'restore':
|
||||
this.restoreExcludedModel(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
const licenseRefreshItem = this.menu.querySelector('[data-action="fetch-missing-licenses"]');
|
||||
const downloadExamplesItem = this.menu.querySelector('[data-action="download-example-images"]');
|
||||
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"]');
|
||||
|
||||
if (isRecipesPage) {
|
||||
@@ -29,12 +30,14 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
licenseRefreshItem?.classList.add('hidden');
|
||||
downloadExamplesItem?.classList.add('hidden');
|
||||
cleanupExamplesItem?.classList.add('hidden');
|
||||
excludedModelsItem?.classList.add('hidden');
|
||||
repairRecipesItem?.classList.remove('hidden');
|
||||
} else {
|
||||
modelUpdateItem?.classList.remove('hidden');
|
||||
licenseRefreshItem?.classList.remove('hidden');
|
||||
downloadExamplesItem?.classList.remove('hidden');
|
||||
cleanupExamplesItem?.classList.remove('hidden');
|
||||
excludedModelsItem?.classList.remove('hidden');
|
||||
repairRecipesItem?.classList.add('hidden');
|
||||
}
|
||||
|
||||
@@ -68,12 +71,21 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
console.error('Failed to repair recipes:', error);
|
||||
});
|
||||
break;
|
||||
case 'manage-excluded-models':
|
||||
this.manageExcludedModels();
|
||||
break;
|
||||
default:
|
||||
console.warn(`Unhandled global context menu action: ${action}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
manageExcludedModels() {
|
||||
window.pageControls?.enterExcludedView?.().catch((error) => {
|
||||
console.error('Failed to open excluded models view:', error);
|
||||
});
|
||||
}
|
||||
|
||||
async downloadExampleImages(menuItem) {
|
||||
const downloadPath = state?.global?.settings?.example_images_path;
|
||||
if (!downloadPath) {
|
||||
|
||||
@@ -20,6 +20,11 @@ export class LoraContextMenu extends BaseContextMenu {
|
||||
return getModelApiClient().saveModelMetadata(filePath, data);
|
||||
}
|
||||
|
||||
showMenu(x, y, card) {
|
||||
super.showMenu(x, y, card);
|
||||
this.updateExcludeMenuItem();
|
||||
}
|
||||
|
||||
handleMenuAction(action, menuItem) {
|
||||
// First try to handle with common actions
|
||||
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
|
||||
@@ -61,6 +66,9 @@ export class LoraContextMenu extends BaseContextMenu {
|
||||
case 'exclude':
|
||||
showExcludeModal(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
case 'restore':
|
||||
this.restoreExcludedModel(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,43 @@ import { bulkManager } from '../../managers/BulkManager.js';
|
||||
import { MODEL_CONFIG } from '../../api/apiConfig.js';
|
||||
import { translate } from '../../utils/i18nHelpers.js';
|
||||
import { getNsfwLevelSelector } from '../shared/NsfwLevelSelector.js';
|
||||
import { extractCivitaiModelUrlParts } from '../../utils/civitaiUtils.js';
|
||||
|
||||
// Mixin with shared functionality for LoraContextMenu and CheckpointContextMenu
|
||||
export const ModelContextMenuMixin = {
|
||||
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
|
||||
initNSFWSelector() {
|
||||
if (this._nsfwSelectorInitialized) {
|
||||
@@ -154,25 +188,7 @@ export const ModelContextMenuMixin = {
|
||||
},
|
||||
|
||||
extractModelVersionId(url) {
|
||||
try {
|
||||
// Handle all three URL formats:
|
||||
// 1. https://civitai.com/models/649516
|
||||
// 2. https://civitai.com/models/649516?modelVersionId=726676
|
||||
// 3. https://civitai.com/models/649516/cynthia-pokemon-diamond-and-pearl-pdxl-lora?modelVersionId=726676
|
||||
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
// Extract model ID from path
|
||||
const pathMatch = parsedUrl.pathname.match(/\/models\/(\d+)/);
|
||||
const modelId = pathMatch ? pathMatch[1] : null;
|
||||
|
||||
// Extract model version ID from query parameters
|
||||
const modelVersionId = parsedUrl.searchParams.get('modelVersionId');
|
||||
|
||||
return { modelId, modelVersionId };
|
||||
} catch (e) {
|
||||
return { modelId: null, modelVersionId: null };
|
||||
}
|
||||
return extractCivitaiModelUrlParts(url);
|
||||
},
|
||||
|
||||
parseModelId(value) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// PageControls.js - Manages controls for both LoRAs and Checkpoints pages
|
||||
import { state, getCurrentPageState, setCurrentPageType } from '../../state/index.js';
|
||||
import { getStorageItem, setStorageItem, getSessionItem, setSessionItem } from '../../utils/storageHelpers.js';
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { showToast, openCivitaiByMetadata } from '../../utils/uiHelpers.js';
|
||||
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
|
||||
import { sidebarManager } from '../SidebarManager.js';
|
||||
|
||||
@@ -38,8 +38,12 @@ export class PageControls {
|
||||
|
||||
// Initialize favorites filter button state
|
||||
this.initFavoritesFilter();
|
||||
|
||||
this.initExcludedViewControls();
|
||||
this.syncExcludedViewState();
|
||||
|
||||
console.log(`PageControls initialized for ${pageType} page`);
|
||||
window.pageControls = this;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,6 +60,19 @@ export class PageControls {
|
||||
|
||||
// Load sort preference
|
||||
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
|
||||
this.initPageSpecificListeners();
|
||||
}
|
||||
|
||||
initExcludedViewControls() {
|
||||
const backButton = document.getElementById('excludedViewBackBtn');
|
||||
if (backButton) {
|
||||
backButton.addEventListener('click', async () => {
|
||||
await this.exitExcludedView();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize dropdown functionality
|
||||
@@ -334,6 +360,13 @@ export class PageControls {
|
||||
* @param {string} sortValue - The sort value to save
|
||||
*/
|
||||
saveSortPreference(sortValue) {
|
||||
if (this.pageState.viewMode === 'excluded') {
|
||||
this.pageState.excludedViewState = {
|
||||
...(this.pageState.excludedViewState || {}),
|
||||
sortBy: sortValue,
|
||||
};
|
||||
return;
|
||||
}
|
||||
setStorageItem(`${this.pageType}_sort`, sortValue);
|
||||
}
|
||||
|
||||
@@ -353,18 +386,8 @@ export class PageControls {
|
||||
const metaData = JSON.parse(card.dataset.meta);
|
||||
const civitaiId = metaData.modelId;
|
||||
const versionId = metaData.id;
|
||||
|
||||
// Build URL
|
||||
if (civitaiId) {
|
||||
let url = `https://civitai.com/models/${civitaiId}`;
|
||||
if (versionId) {
|
||||
url += `?modelVersionId=${versionId}`;
|
||||
}
|
||||
window.open(url, '_blank');
|
||||
} else {
|
||||
// If no ID, try searching by name
|
||||
window.open(`https://civitai.com/models?query=${encodeURIComponent(modelName)}`, '_blank');
|
||||
}
|
||||
|
||||
openCivitaiByMetadata(civitaiId, versionId, modelName);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -483,6 +506,8 @@ export class PageControls {
|
||||
// Update app state
|
||||
this.pageState.showFavoritesOnly = showFavoritesOnly;
|
||||
}
|
||||
|
||||
this.updateActionButtonStates();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -499,12 +524,17 @@ export class PageControls {
|
||||
if (updateFilterBtn) {
|
||||
updateFilterBtn.classList.toggle('active', showUpdatesOnly);
|
||||
}
|
||||
|
||||
this.updateActionButtonStates();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle favorites-only filter and reload models
|
||||
*/
|
||||
async toggleFavoritesOnly() {
|
||||
if (this.pageState.viewMode === 'excluded') {
|
||||
return;
|
||||
}
|
||||
const favoriteFilterBtn = document.getElementById('favoriteFilterBtn');
|
||||
|
||||
// Toggle the filter state in storage
|
||||
@@ -531,6 +561,9 @@ export class PageControls {
|
||||
* Toggle update-available-only filter and reload models
|
||||
*/
|
||||
async toggleUpdateAvailableOnly() {
|
||||
if (this.pageState.viewMode === 'excluded') {
|
||||
return;
|
||||
}
|
||||
const updateFilterBtn = document.getElementById('updateFilterBtn');
|
||||
const storageKey = `show_update_available_only_${this.pageType}`;
|
||||
const newState = !this.pageState.showUpdateAvailableOnly;
|
||||
@@ -545,6 +578,234 @@ export class PageControls {
|
||||
|
||||
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
|
||||
|
||||
@@ -433,10 +433,11 @@ export function createModelCard(model, modelType) {
|
||||
card.dataset.usage_count = String(model.usage_count);
|
||||
card.dataset.notes = model.notes || '';
|
||||
card.dataset.base_model = model.base_model || 'Unknown';
|
||||
card.dataset.favorite = model.favorite ? 'true' : 'false';
|
||||
const hasUpdateAvailable = Boolean(model.update_available);
|
||||
card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false';
|
||||
card.dataset.skip_metadata_refresh = model.skip_metadata_refresh ? 'true' : 'false';
|
||||
card.dataset.favorite = model.favorite ? 'true' : 'false';
|
||||
card.dataset.exclude = model.exclude ? 'true' : 'false';
|
||||
const hasUpdateAvailable = Boolean(model.update_available);
|
||||
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.
|
||||
const pageState = getCurrentPageState();
|
||||
@@ -487,6 +488,9 @@ export function createModelCard(model, modelType) {
|
||||
if (model.skip_metadata_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)
|
||||
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>
|
||||
</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 class="card-actions">
|
||||
${actionIcons}
|
||||
|
||||
@@ -1,26 +1,21 @@
|
||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||
import { downloadManager } from '../../managers/DownloadManager.js';
|
||||
import { modalManager } from '../../managers/ModalManager.js';
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { openCivitaiUrl, showToast } from '../../utils/uiHelpers.js';
|
||||
import { translate } from '../../utils/i18nHelpers.js';
|
||||
import { state } from '../../state/index.js';
|
||||
import { buildCivitaiModelUrl } from '../../utils/civitaiUtils.js';
|
||||
import { formatFileSize } from './utils.js';
|
||||
|
||||
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.mkv'];
|
||||
const PREVIEW_PLACEHOLDER_URL = '/loras_static/images/no-preview.png';
|
||||
|
||||
function buildCivitaiVersionUrl(modelId, versionId) {
|
||||
if (modelId == null || versionId == null) {
|
||||
return null;
|
||||
}
|
||||
const normalizedModelId = String(modelId).trim();
|
||||
const normalizedVersionId = String(versionId).trim();
|
||||
if (!normalizedModelId || !normalizedVersionId) {
|
||||
return null;
|
||||
}
|
||||
const encodedModelId = encodeURIComponent(normalizedModelId);
|
||||
const encodedVersionId = encodeURIComponent(normalizedVersionId);
|
||||
return `https://civitai.com/models/${encodedModelId}?modelVersionId=${encodedVersionId}`;
|
||||
return buildCivitaiModelUrl(
|
||||
modelId,
|
||||
versionId,
|
||||
state?.global?.settings?.civitai_host
|
||||
);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
@@ -1352,6 +1347,13 @@ export function initVersionsTab({
|
||||
}
|
||||
|
||||
const row = event.target.closest('.model-version-row.is-clickable');
|
||||
const civitaiLink = event.target.closest('.version-civitai-link');
|
||||
if (civitaiLink) {
|
||||
event.preventDefault();
|
||||
openCivitaiUrl(civitaiLink.href);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
@@ -1371,7 +1373,7 @@ export function initVersionsTab({
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
window.open(targetUrl, '_blank', 'noopener,noreferrer');
|
||||
openCivitaiUrl(targetUrl);
|
||||
});
|
||||
|
||||
// Listen for extension-triggered refresh requests
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { extractCivitaiModelUrlParts } from '../utils/civitaiUtils.js';
|
||||
|
||||
export class DownloadManager {
|
||||
constructor() {
|
||||
@@ -197,21 +198,22 @@ export class DownloadManager {
|
||||
}
|
||||
|
||||
extractModelId(url) {
|
||||
const versionMatch = url.match(/modelVersionId=(\d+)/i);
|
||||
this.modelVersionId = versionMatch ? versionMatch[1] : null;
|
||||
|
||||
const civarchiveMatch = url.match(/https?:\/\/(?:www\.)?(?:civitaiarchive|civarchive)\.com\/models\/(\d+)/i);
|
||||
if (civarchiveMatch) {
|
||||
const versionMatch = url.match(/modelVersionId=(\d+)/i);
|
||||
this.modelVersionId = versionMatch ? versionMatch[1] : null;
|
||||
this.source = 'civarchive';
|
||||
return civarchiveMatch[1];
|
||||
}
|
||||
|
||||
const civitaiMatch = url.match(/https?:\/\/(?:www\.)?civitai\.com\/models\/(\d+)/i);
|
||||
if (civitaiMatch) {
|
||||
const { modelId, modelVersionId } = extractCivitaiModelUrlParts(url);
|
||||
if (modelId) {
|
||||
this.modelVersionId = modelVersionId;
|
||||
this.source = null;
|
||||
return civitaiMatch[1];
|
||||
return modelId;
|
||||
}
|
||||
|
||||
this.modelVersionId = null;
|
||||
this.source = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -802,6 +802,11 @@ export class SettingsManager {
|
||||
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings;
|
||||
}
|
||||
|
||||
const civitaiHostSelect = document.getElementById('civitaiHost');
|
||||
if (civitaiHostSelect) {
|
||||
civitaiHostSelect.value = state.global.settings.civitai_host || 'civitai.com';
|
||||
}
|
||||
|
||||
const recipesPathInput = document.getElementById('recipesPath');
|
||||
if (recipesPathInput) {
|
||||
recipesPathInput.value = state.global.settings.recipes_path || '';
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DEFAULT_PATH_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/co
|
||||
|
||||
const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
civitai_api_key: '',
|
||||
civitai_host: 'civitai.com',
|
||||
use_portable_settings: false,
|
||||
language: 'en',
|
||||
show_only_sfw: false,
|
||||
@@ -89,7 +90,9 @@ export const state = {
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
license: {},
|
||||
modelTypes: []
|
||||
modelTypes: [],
|
||||
search: '',
|
||||
tagLogic: 'any',
|
||||
},
|
||||
bulkMode: false,
|
||||
selectedLoras: new Set(),
|
||||
@@ -97,6 +100,12 @@ export const state = {
|
||||
showFavoritesOnly: false,
|
||||
showUpdateAvailableOnly: false,
|
||||
duplicatesMode: false,
|
||||
viewMode: 'active',
|
||||
excludedViewState: {
|
||||
sortBy: 'name:asc',
|
||||
search: '',
|
||||
},
|
||||
activeViewSnapshot: null,
|
||||
},
|
||||
|
||||
recipes: {
|
||||
@@ -146,7 +155,9 @@ export const state = {
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
license: {},
|
||||
modelTypes: []
|
||||
modelTypes: [],
|
||||
search: '',
|
||||
tagLogic: 'any',
|
||||
},
|
||||
modelType: 'checkpoint', // 'checkpoint' or 'diffusion_model'
|
||||
bulkMode: false,
|
||||
@@ -155,6 +166,12 @@ export const state = {
|
||||
showFavoritesOnly: false,
|
||||
showUpdateAvailableOnly: false,
|
||||
duplicatesMode: false,
|
||||
viewMode: 'active',
|
||||
excludedViewState: {
|
||||
sortBy: 'name:asc',
|
||||
search: '',
|
||||
},
|
||||
activeViewSnapshot: null,
|
||||
},
|
||||
|
||||
[MODEL_TYPES.EMBEDDING]: {
|
||||
@@ -177,7 +194,9 @@ export const state = {
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
license: {},
|
||||
modelTypes: []
|
||||
modelTypes: [],
|
||||
search: '',
|
||||
tagLogic: 'any',
|
||||
},
|
||||
bulkMode: false,
|
||||
selectedModels: new Set(),
|
||||
@@ -185,6 +204,12 @@ export const state = {
|
||||
showFavoritesOnly: false,
|
||||
showUpdateAvailableOnly: false,
|
||||
duplicatesMode: false,
|
||||
viewMode: 'active',
|
||||
excludedViewState: {
|
||||
sortBy: 'name:asc',
|
||||
search: '',
|
||||
},
|
||||
activeViewSnapshot: null,
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -13,6 +13,64 @@ export const OptimizationMode = {
|
||||
THUMBNAIL: 'thumbnail',
|
||||
};
|
||||
|
||||
export const DEFAULT_CIVITAI_PAGE_HOST = 'civitai.com';
|
||||
|
||||
const SUPPORTED_CIVITAI_PAGE_HOSTS = new Set([
|
||||
'civitai.com',
|
||||
'civitai.red',
|
||||
]);
|
||||
|
||||
export function normalizeCivitaiPageHost(hostname) {
|
||||
if (!hostname || typeof hostname !== 'string') {
|
||||
return DEFAULT_CIVITAI_PAGE_HOST;
|
||||
}
|
||||
|
||||
const normalized = hostname.trim().toLowerCase();
|
||||
if (SUPPORTED_CIVITAI_PAGE_HOSTS.has(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return DEFAULT_CIVITAI_PAGE_HOST;
|
||||
}
|
||||
|
||||
export function buildCivitaiModelUrl(modelId, versionId = null, host = DEFAULT_CIVITAI_PAGE_HOST) {
|
||||
const normalizedHost = normalizeCivitaiPageHost(host);
|
||||
const normalizedModelId = modelId == null ? '' : String(modelId).trim();
|
||||
const normalizedVersionId = versionId == null ? '' : String(versionId).trim();
|
||||
|
||||
if (normalizedModelId) {
|
||||
const encodedModelId = encodeURIComponent(normalizedModelId);
|
||||
let url = `https://${normalizedHost}/models/${encodedModelId}`;
|
||||
if (normalizedVersionId) {
|
||||
url += `?modelVersionId=${encodeURIComponent(normalizedVersionId)}`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
if (normalizedVersionId) {
|
||||
return `https://${normalizedHost}/model-versions/${encodeURIComponent(normalizedVersionId)}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildCivitaiSearchUrl(query, host = DEFAULT_CIVITAI_PAGE_HOST) {
|
||||
const normalizedQuery = query == null ? '' : String(query).trim();
|
||||
if (!normalizedQuery) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedHost = normalizeCivitaiPageHost(host);
|
||||
return `https://${normalizedHost}/models?query=${encodeURIComponent(normalizedQuery)}`;
|
||||
}
|
||||
|
||||
export function buildCivitaiUrl({ modelId = null, versionId = null, modelName = null, host = DEFAULT_CIVITAI_PAGE_HOST } = {}) {
|
||||
return (
|
||||
buildCivitaiModelUrl(modelId, versionId, host)
|
||||
|| buildCivitaiSearchUrl(modelName, host)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite Civitai preview URLs to use optimized renditions.
|
||||
* Mirrors the backend's rewrite_preview_url() function from py/utils/civitai_utils.py
|
||||
@@ -119,3 +177,50 @@ export function isCivitaiUrl(url) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isSupportedCivitaiPageHost(hostname) {
|
||||
if (!hostname) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return SUPPORTED_CIVITAI_PAGE_HOSTS.has(hostname.toLowerCase());
|
||||
}
|
||||
|
||||
export function extractCivitaiModelUrlParts(url) {
|
||||
if (!url) {
|
||||
return { modelId: null, modelVersionId: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
if (!isSupportedCivitaiPageHost(parsedUrl.hostname)) {
|
||||
return { modelId: null, modelVersionId: null };
|
||||
}
|
||||
|
||||
const pathMatch = parsedUrl.pathname.match(/\/models\/(\d+)/);
|
||||
const modelId = pathMatch ? pathMatch[1] : null;
|
||||
const modelVersionId = parsedUrl.searchParams.get('modelVersionId');
|
||||
|
||||
return { modelId, modelVersionId };
|
||||
} catch (e) {
|
||||
return { modelId: null, modelVersionId: null };
|
||||
}
|
||||
}
|
||||
|
||||
export function extractCivitaiImageId(url) {
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
if (!isSupportedCivitaiPageHost(parsedUrl.hostname)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pathMatch = parsedUrl.pathname.match(/\/images\/(\d+)/);
|
||||
return pathMatch ? pathMatch[1] : null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,66 @@ import { state, getCurrentPageState } from '../state/index.js';
|
||||
import { getStorageItem, setStorageItem } from './storageHelpers.js';
|
||||
import { NODE_TYPE_ICONS, DEFAULT_NODE_COLOR } from './constants.js';
|
||||
import { eventManager } from './EventManager.js';
|
||||
import { bannerService } from '../managers/BannerService.js';
|
||||
import { modalManager } from '../managers/ModalManager.js';
|
||||
import { buildCivitaiUrl, normalizeCivitaiPageHost } from './civitaiUtils.js';
|
||||
|
||||
const CIVITAI_HOST_INFO_BANNER_ID = 'civitai-host-preference';
|
||||
const CIVITAI_HOST_INFO_BANNER_SEEN_KEY = 'civitai_host_info_banner_seen';
|
||||
|
||||
function getPreferredCivitaiHost() {
|
||||
return normalizeCivitaiPageHost(state?.global?.settings?.civitai_host);
|
||||
}
|
||||
|
||||
function maybeRegisterCivitaiHostInfoBanner() {
|
||||
if (getStorageItem(CIVITAI_HOST_INFO_BANNER_SEEN_KEY, false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStorageItem(CIVITAI_HOST_INFO_BANNER_SEEN_KEY, true);
|
||||
|
||||
bannerService.registerBanner(CIVITAI_HOST_INFO_BANNER_ID, {
|
||||
id: CIVITAI_HOST_INFO_BANNER_ID,
|
||||
title: translate(
|
||||
'settings.civitaiHostBanner.title',
|
||||
{},
|
||||
'Civitai host preference available'
|
||||
),
|
||||
content: translate(
|
||||
'settings.civitaiHostBanner.content',
|
||||
{},
|
||||
'Civitai now uses civitai.com for SFW content and civitai.red for unrestricted content. You can change which site opens by default in Settings.'
|
||||
),
|
||||
actions: [
|
||||
{
|
||||
text: translate('settings.civitaiHostBanner.openSettings', {}, 'Open Settings'),
|
||||
icon: 'fas fa-cog',
|
||||
action: 'open-settings-modal',
|
||||
type: 'primary',
|
||||
},
|
||||
],
|
||||
dismissible: true,
|
||||
priority: 70,
|
||||
onRegister: (bannerElement) => {
|
||||
const button = bannerElement.querySelector('.banner-action[data-action="open-settings-modal"]');
|
||||
if (button) {
|
||||
button.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
modalManager.showModal('settingsModal');
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function openCivitaiUrl(url) {
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
maybeRegisterCivitaiHostInfoBanner();
|
||||
return window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to copy text to clipboard with fallback for older browsers
|
||||
@@ -184,14 +244,15 @@ function filterByFolder(folderPath) {
|
||||
}
|
||||
|
||||
export function openCivitaiByMetadata(civitaiId, versionId, modelName = null) {
|
||||
if (versionId) {
|
||||
// Use model-versions endpoint which auto-redirects to correct model page
|
||||
window.open(`https://civitai.com/model-versions/${versionId}`, '_blank');
|
||||
} else if (civitaiId) {
|
||||
window.open(`https://civitai.com/models/${civitaiId}`, '_blank');
|
||||
} else if (modelName) {
|
||||
// Fallback: search by name
|
||||
window.open(`https://civitai.com/models?query=${encodeURIComponent(modelName)}`, '_blank');
|
||||
const url = buildCivitaiUrl({
|
||||
modelId: civitaiId,
|
||||
versionId,
|
||||
modelName,
|
||||
host: getPreferredCivitaiHost(),
|
||||
});
|
||||
|
||||
if (url) {
|
||||
openCivitaiUrl(url);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -111,6 +111,9 @@
|
||||
<div class="context-menu-item" data-action="cleanup-example-images-folders">
|
||||
<i class="fas fa-trash-restore"></i> <span>{{ t('globalContextMenu.cleanupExampleImages.label') }}</span>
|
||||
</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">
|
||||
<i class="fas fa-tools"></i> <span>{{ t('globalContextMenu.repairRecipes.label') }}</span>
|
||||
</div>
|
||||
@@ -136,4 +139,4 @@
|
||||
|
||||
<div id="nodeSelector" class="node-selector">
|
||||
<!-- Dynamic node list will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
<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="action-buttons">
|
||||
<div title="{{ t('loras.controls.sort.title') }}" class="control-group">
|
||||
|
||||
@@ -114,6 +114,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="civitaiHost">{{ t('settings.civitaiHost.label') }}</label>
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.civitaiHost.help') }}"></i>
|
||||
</div>
|
||||
<div class="setting-control select-control">
|
||||
<select id="civitaiHost" onchange="settingsManager.saveSelectSetting('civitaiHost', 'civitai_host')">
|
||||
<option value="civitai.com">{{ t('settings.civitaiHost.options.com') }}</option>
|
||||
<option value="civitai.red">{{ t('settings.civitaiHost.options.red') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup -->
|
||||
<div class="settings-subsection">
|
||||
<div class="settings-subsection-header">
|
||||
|
||||
@@ -126,6 +126,31 @@ describe('AutoComplete widget interactions', () => {
|
||||
expect(caretHelperInstance.getCursorOffset).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deduplicates duplicate-equivalent query variations before issuing requests', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({ success: true, words: [] }),
|
||||
});
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('Example');
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
new AutoComplete(input, 'prompt', { debounceDelay: 0, showPreview: false, minChars: 1 });
|
||||
|
||||
input.value = 'Example';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchApiMock).toHaveBeenCalledWith('/lm/custom-words/search?enriched=true&search=Example&limit=100');
|
||||
});
|
||||
|
||||
it('inserts the selected LoRA with usage tip strengths and restores focus', async () => {
|
||||
fetchApiMock.mockImplementation((url) => {
|
||||
if (url.includes('usage-tips-by-path')) {
|
||||
@@ -244,6 +269,53 @@ describe('AutoComplete widget interactions', () => {
|
||||
expect(inputListener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the full command list when typing a single slash', async () => {
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '/';
|
||||
input.selectionStart = input.value.length;
|
||||
document.body.append(input);
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'prompt', { showPreview: false, minChars: 1 });
|
||||
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
const commandNames = autoComplete.items.map((item) => item.command);
|
||||
|
||||
expect(commandNames).toContain('/character');
|
||||
expect(commandNames).toContain('/artist');
|
||||
expect(commandNames).toContain('/general');
|
||||
expect(commandNames).toContain('/copyright');
|
||||
expect(commandNames).toContain('/meta');
|
||||
expect(commandNames).toContain('/species');
|
||||
expect(commandNames).toContain('/lore');
|
||||
expect(commandNames).toContain('/emb');
|
||||
expect(commandNames).toContain('/embedding');
|
||||
expect(commandNames).toContain('/wildcard');
|
||||
});
|
||||
|
||||
it('renders every command item when slash opens the command list', async () => {
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '/';
|
||||
input.selectionStart = input.value.length;
|
||||
document.body.append(input);
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', { showPreview: false, minChars: 1 });
|
||||
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
const renderedCommands = autoComplete.contentContainer.querySelectorAll('.lm-autocomplete-command-name');
|
||||
|
||||
expect(renderedCommands).toHaveLength(autoComplete.items.length);
|
||||
});
|
||||
|
||||
it('accepts the selected suggestion with Enter', async () => {
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
||||
|
||||
@@ -300,6 +372,66 @@ describe('AutoComplete widget interactions', () => {
|
||||
expect(insertSelectionSpy).toHaveBeenCalledWith('loop');
|
||||
});
|
||||
|
||||
it('preserves manual ArrowDown selection when Tab accepts a suggestion', async () => {
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('loop');
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = 'loop';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'prompt', { showPreview: false, minChars: 1 });
|
||||
|
||||
autoComplete.searchType = 'custom_words';
|
||||
autoComplete.items = [
|
||||
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1000 },
|
||||
{ tag_name: 'loop', category: 0, post_count: 500 },
|
||||
];
|
||||
autoComplete.currentSearchTerm = 'loo';
|
||||
autoComplete.selectedIndex = 0;
|
||||
autoComplete.isVisible = true;
|
||||
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
|
||||
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
|
||||
|
||||
expect(autoComplete.selectedIndex).toBe(1);
|
||||
expect(insertSelectionSpy).toHaveBeenCalledWith('loop');
|
||||
});
|
||||
|
||||
it('preserves manual ArrowDown selection when Enter accepts a suggestion', async () => {
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('loop');
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = 'loop';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'prompt', { showPreview: false, minChars: 1 });
|
||||
|
||||
autoComplete.searchType = 'custom_words';
|
||||
autoComplete.items = [
|
||||
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1000 },
|
||||
{ tag_name: 'loop', category: 0, post_count: 500 },
|
||||
];
|
||||
autoComplete.currentSearchTerm = 'loo';
|
||||
autoComplete.selectedIndex = 0;
|
||||
autoComplete.isVisible = true;
|
||||
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
|
||||
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
|
||||
|
||||
expect(autoComplete.selectedIndex).toBe(1);
|
||||
expect(insertSelectionSpy).toHaveBeenCalledWith('loop');
|
||||
});
|
||||
|
||||
it('accepts the first available suggestion with Tab even if delayed auto-selection has not happened yet', async () => {
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('loop');
|
||||
|
||||
@@ -743,12 +875,12 @@ describe('AutoComplete widget interactions', () => {
|
||||
json: () => Promise.resolve({ success: true, words: mockTags }),
|
||||
});
|
||||
|
||||
// Simulate "/char looking to the side" input
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('/char looking to the side');
|
||||
// Simulate "/character looking to the side" input
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('/character looking to the side');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '/char looking to the side';
|
||||
input.value = '/character looking to the side';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
@@ -766,7 +898,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
autoComplete.activeCommand = { categories: [4, 11], label: 'Character' };
|
||||
autoComplete.items = mockTags;
|
||||
autoComplete.selectedIndex = 0;
|
||||
autoComplete.currentSearchTerm = '/char looking to the side';
|
||||
autoComplete.currentSearchTerm = '/character looking to the side';
|
||||
|
||||
await autoComplete.insertSelection('looking_to_the_side');
|
||||
|
||||
@@ -1073,6 +1205,253 @@ describe('AutoComplete widget interactions', () => {
|
||||
expect(fetchApiMock).toHaveBeenCalledWith('/lm/custom-words/search?enriched=true&search=cat&limit=100');
|
||||
});
|
||||
|
||||
it('searches wildcard keys when using the /wildcard command', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({
|
||||
success: true,
|
||||
words: ['animals/cat'],
|
||||
meta: {
|
||||
has_wildcards: true,
|
||||
wildcards_dir: '/tmp/settings/wildcards',
|
||||
supported_formats: ['.txt', '.yaml', '.yml', '.json'],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard cat');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '/wildcard cat';
|
||||
input.selectionStart = input.value.length;
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
fetchApiMock.mockClear();
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fetchApiMock).toHaveBeenCalledWith('/lm/wildcards/search?search=cat&limit=100');
|
||||
expect(autoComplete.searchType).toBe('wildcards');
|
||||
expect(autoComplete.items).toEqual(['animals/cat']);
|
||||
});
|
||||
|
||||
it('shows wildcard onboarding when /wildcard is used before any files exist', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({
|
||||
success: true,
|
||||
words: [],
|
||||
meta: {
|
||||
has_wildcards: false,
|
||||
wildcards_dir: '/tmp/settings/wildcards',
|
||||
supported_formats: ['.txt', '.yaml', '.yml', '.json'],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard cat');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '/wildcard cat';
|
||||
input.selectionStart = input.value.length;
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(autoComplete.isVisible).toBe(true);
|
||||
expect(autoComplete.items).toHaveLength(1);
|
||||
expect(autoComplete.items[0].type).toBe('wildcard_empty_state');
|
||||
expect(autoComplete.dropdown.textContent).toContain('No wildcards found yet');
|
||||
expect(autoComplete.dropdown.textContent).toContain('/tmp/settings/wildcards');
|
||||
expect(autoComplete.dropdown.textContent).toContain('.txt, .yaml, .yml, .json');
|
||||
});
|
||||
|
||||
it('shows wildcard onboarding when only the /wildcard command is entered', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({
|
||||
success: true,
|
||||
words: [],
|
||||
meta: {
|
||||
has_wildcards: false,
|
||||
wildcards_dir: '/tmp/settings/wildcards',
|
||||
supported_formats: ['.txt', '.yaml', '.yml', '.json'],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard ');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '/wildcard ';
|
||||
input.selectionStart = input.value.length;
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fetchApiMock).toHaveBeenCalledWith('/lm/wildcards/search?search=&limit=100');
|
||||
expect(autoComplete.isVisible).toBe(true);
|
||||
expect(autoComplete.items).toHaveLength(1);
|
||||
expect(autoComplete.items[0].type).toBe('wildcard_empty_state');
|
||||
expect(autoComplete.dropdown.textContent).toContain('No wildcards found yet');
|
||||
});
|
||||
|
||||
it('shows a lightweight no-match state when wildcard files exist but search misses', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({
|
||||
success: true,
|
||||
words: [],
|
||||
meta: {
|
||||
has_wildcards: true,
|
||||
wildcards_dir: '/tmp/settings/wildcards',
|
||||
supported_formats: ['.txt', '.yaml', '.yml', '.json'],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard dragon');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '/wildcard dragon';
|
||||
input.selectionStart = input.value.length;
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(autoComplete.items).toHaveLength(1);
|
||||
expect(autoComplete.items[0].type).toBe('wildcard_no_matches');
|
||||
expect(autoComplete.dropdown.textContent).toContain('No wildcard matches');
|
||||
expect(autoComplete.dropdown.textContent).not.toContain('Open wildcards folder');
|
||||
});
|
||||
|
||||
it('inserts wildcard references when accepting a /wildcard result', async () => {
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard animals/cat');
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '/wildcard animals/cat';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
autoComplete.searchType = 'wildcards';
|
||||
autoComplete.activeCommand = { type: 'wildcard', label: 'Wildcards' };
|
||||
autoComplete.items = ['animals/cat'];
|
||||
autoComplete.selectedIndex = 0;
|
||||
|
||||
await autoComplete.insertSelection('animals/cat');
|
||||
|
||||
expect(input.value).toBe('__animals/cat__,');
|
||||
expect(input.focus).toHaveBeenCalled();
|
||||
expect(input.setSelectionRange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not reopen autocomplete on blur after inserting a wildcard literal', async () => {
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '__flower__,';
|
||||
input.selectionStart = input.value.length;
|
||||
document.body.append(input);
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('__flower__,');
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
const hideSpy = vi.spyOn(autoComplete, 'hide');
|
||||
input.dispatchEvent(new Event('blur', { bubbles: true }));
|
||||
|
||||
expect(fetchApiMock).not.toHaveBeenCalled();
|
||||
expect(hideSpy).toHaveBeenCalled();
|
||||
expect(autoComplete.isVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a command after a wildcard literal as the active token', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({
|
||||
success: true,
|
||||
words: [{ tag_name: 'flower_field', category: 4, post_count: 1234 }],
|
||||
}),
|
||||
});
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '__flower__ /character f';
|
||||
input.selectionStart = input.value.length;
|
||||
document.body.append(input);
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('__flower__ /character f');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(autoComplete.getSearchTerm(input.value)).toBe('/character f');
|
||||
});
|
||||
|
||||
it('invalidates stale autocomplete metadata and falls back to delimiter-based matching', async () => {
|
||||
settingGetMock.mockImplementation((key) => {
|
||||
if (key === 'loramanager.autocomplete_append_comma') {
|
||||
|
||||
@@ -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="fetch-missing-licenses"></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>
|
||||
`;
|
||||
|
||||
@@ -2120,6 +2121,9 @@ describe('Interaction-level regression coverage', () => {
|
||||
startProgressUpdates: vi.fn(),
|
||||
updateDownloadButtonText: vi.fn(),
|
||||
};
|
||||
window.pageControls = {
|
||||
enterExcludedView: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
global.fetch = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
@@ -2224,5 +2228,10 @@ describe('Interaction-level regression coverage', () => {
|
||||
);
|
||||
expect(loadingManagerStub.showSimpleLoading).toHaveBeenNthCalledWith(2, 'Refreshing license metadata for LoRAs...');
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
21
tests/frontend/components/modelContextMenuMixin.test.js
Normal file
21
tests/frontend/components/modelContextMenuMixin.test.js
Normal 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 });
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@ vi.mock(DOWNLOAD_MANAGER_MODULE, () => ({
|
||||
|
||||
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||
showToast: vi.fn(),
|
||||
openCivitaiUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
const stateMock = {
|
||||
|
||||
@@ -12,6 +12,7 @@ const apiClientMock = {
|
||||
};
|
||||
|
||||
const showToastMock = vi.fn();
|
||||
const openCivitaiByMetadataMock = vi.fn();
|
||||
const updatePanelPositionsMock = vi.fn();
|
||||
const downloadManagerMock = {
|
||||
showDownloadModal: vi.fn(),
|
||||
@@ -40,6 +41,7 @@ vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||
|
||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||
showToast: showToastMock,
|
||||
openCivitaiByMetadata: openCivitaiByMetadataMock,
|
||||
updatePanelPositions: updatePanelPositionsMock,
|
||||
}));
|
||||
|
||||
@@ -84,6 +86,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.bulkManager;
|
||||
delete window.modelDuplicatesManager;
|
||||
delete global.fetch;
|
||||
vi.useRealTimers();
|
||||
@@ -112,6 +115,9 @@ function renderControlsDom(pageKey) {
|
||||
<button class="clear-filter"></button>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<div id="excludedViewBanner" class="excluded-view-banner hidden">
|
||||
<button id="excludedViewBackBtn">Back</button>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="action-buttons">
|
||||
<div class="control-group">
|
||||
@@ -170,6 +176,9 @@ function renderControlsDom(pageKey) {
|
||||
<i class="fas fa-times-circle clear-filter"></i>
|
||||
</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();
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('.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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ const getCurrentPageStateMock = vi.fn();
|
||||
const getSessionItemMock = vi.fn();
|
||||
const removeSessionItemMock = vi.fn();
|
||||
const getStorageItemMock = vi.fn();
|
||||
const setStorageItemMock = vi.fn();
|
||||
const removeStorageItemMock = vi.fn();
|
||||
const RecipeContextMenuMock = vi.fn();
|
||||
const refreshVirtualScrollMock = vi.fn();
|
||||
const refreshRecipesMock = vi.fn();
|
||||
@@ -53,6 +55,8 @@ vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
|
||||
getSessionItem: getSessionItemMock,
|
||||
removeSessionItem: removeSessionItemMock,
|
||||
getStorageItem: getStorageItemMock,
|
||||
setStorageItem: setStorageItemMock,
|
||||
removeStorageItem: removeStorageItemMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/components/ContextMenu/index.js', () => ({
|
||||
|
||||
@@ -14,6 +14,7 @@ describe('state module', () => {
|
||||
|
||||
expect(defaultSettings).toMatchObject({
|
||||
civitai_api_key: '',
|
||||
civitai_host: 'civitai.com',
|
||||
language: 'en',
|
||||
blur_mature_content: true,
|
||||
mature_blur_level: 'R'
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
DEFAULT_CIVITAI_PAGE_HOST,
|
||||
normalizeCivitaiPageHost,
|
||||
buildCivitaiModelUrl,
|
||||
buildCivitaiSearchUrl,
|
||||
buildCivitaiUrl,
|
||||
rewriteCivitaiUrl,
|
||||
getOptimizedUrl,
|
||||
getShowcaseUrl,
|
||||
getThumbnailUrl,
|
||||
extractCivitaiImageId,
|
||||
extractCivitaiModelUrlParts,
|
||||
isCivitaiUrl,
|
||||
isSupportedCivitaiPageHost,
|
||||
OptimizationMode
|
||||
} from '../../../static/js/utils/civitaiUtils.js';
|
||||
|
||||
@@ -16,6 +24,47 @@ describe('civitaiUtils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Civitai page URL helpers', () => {
|
||||
it('normalizes invalid hosts to the default page host', () => {
|
||||
expect(DEFAULT_CIVITAI_PAGE_HOST).toBe('civitai.com');
|
||||
expect(normalizeCivitaiPageHost('civitai.red')).toBe('civitai.red');
|
||||
expect(normalizeCivitaiPageHost(' CIVITAI.COM ')).toBe('civitai.com');
|
||||
expect(normalizeCivitaiPageHost('example.com')).toBe('civitai.com');
|
||||
expect(normalizeCivitaiPageHost(null)).toBe('civitai.com');
|
||||
});
|
||||
|
||||
it('builds model URLs using the configured host', () => {
|
||||
expect(buildCivitaiModelUrl(123, 456, 'civitai.red')).toBe(
|
||||
'https://civitai.red/models/123?modelVersionId=456'
|
||||
);
|
||||
expect(buildCivitaiModelUrl(123, null, 'civitai.com')).toBe(
|
||||
'https://civitai.com/models/123'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the model-versions endpoint when only a version id is available', () => {
|
||||
expect(buildCivitaiModelUrl(null, 456, 'civitai.red')).toBe(
|
||||
'https://civitai.red/model-versions/456'
|
||||
);
|
||||
});
|
||||
|
||||
it('builds search URLs using the configured host', () => {
|
||||
expect(buildCivitaiSearchUrl('demo model', 'civitai.red')).toBe(
|
||||
'https://civitai.red/models?query=demo%20model'
|
||||
);
|
||||
expect(buildCivitaiSearchUrl('', 'civitai.red')).toBe(null);
|
||||
});
|
||||
|
||||
it('prefers model/version URLs and falls back to search URLs', () => {
|
||||
expect(buildCivitaiUrl({ modelId: 321, versionId: 654, host: 'civitai.red' })).toBe(
|
||||
'https://civitai.red/models/321?modelVersionId=654'
|
||||
);
|
||||
expect(buildCivitaiUrl({ modelName: 'search me', host: 'civitai.red' })).toBe(
|
||||
'https://civitai.red/models?query=search%20me'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rewriteCivitaiUrl', () => {
|
||||
it('should rewrite image URLs with /original=true for thumbnail mode', () => {
|
||||
const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/original=true/12345.jpeg';
|
||||
@@ -217,4 +266,43 @@ describe('civitaiUtils', () => {
|
||||
expect(isCivitaiUrl('not-a-url')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSupportedCivitaiPageHost', () => {
|
||||
it('accepts civitai.com and civitai.red page hosts', () => {
|
||||
expect(isSupportedCivitaiPageHost('civitai.com')).toBe(true);
|
||||
expect(isSupportedCivitaiPageHost('civitai.red')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unrelated hosts', () => {
|
||||
expect(isSupportedCivitaiPageHost('www.civitai.com')).toBe(false);
|
||||
expect(isSupportedCivitaiPageHost('www.civitai.red')).toBe(false);
|
||||
expect(isSupportedCivitaiPageHost('example.com')).toBe(false);
|
||||
expect(isSupportedCivitaiPageHost('')).toBe(false);
|
||||
expect(isSupportedCivitaiPageHost(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractCivitaiModelUrlParts', () => {
|
||||
it('extracts model and version ids from civitai.red model URLs', () => {
|
||||
expect(
|
||||
extractCivitaiModelUrlParts('https://civitai.red/models/65423/name?modelVersionId=98765')
|
||||
).toEqual({ modelId: '65423', modelVersionId: '98765' });
|
||||
});
|
||||
|
||||
it('rejects model-like URLs from unsupported hosts', () => {
|
||||
expect(
|
||||
extractCivitaiModelUrlParts('https://example.com/models/65423?modelVersionId=98765')
|
||||
).toEqual({ modelId: null, modelVersionId: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractCivitaiImageId', () => {
|
||||
it('extracts image ids from civitai.red image URLs', () => {
|
||||
expect(extractCivitaiImageId('https://civitai.red/images/126920345')).toBe('126920345');
|
||||
});
|
||||
|
||||
it('rejects image-like URLs from unsupported hosts', () => {
|
||||
expect(extractCivitaiImageId('https://example.com/images/126920345')).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,8 @@ const {
|
||||
STORAGE_MODULE,
|
||||
CONSTANTS_MODULE,
|
||||
EVENT_MANAGER_MODULE,
|
||||
BANNER_SERVICE_MODULE,
|
||||
MODAL_MANAGER_MODULE,
|
||||
UI_HELPERS_MODULE,
|
||||
} = vi.hoisted(() => ({
|
||||
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||
@@ -13,12 +15,16 @@ const {
|
||||
STORAGE_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
|
||||
CONSTANTS_MODULE: new URL('../../../static/js/utils/constants.js', import.meta.url).pathname,
|
||||
EVENT_MANAGER_MODULE: new URL('../../../static/js/utils/EventManager.js', import.meta.url).pathname,
|
||||
BANNER_SERVICE_MODULE: new URL('../../../static/js/managers/BannerService.js', import.meta.url).pathname,
|
||||
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
|
||||
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||
}));
|
||||
|
||||
const translateMock = vi.fn((key, _params, fallback) => fallback || key);
|
||||
const getStorageItemMock = vi.fn();
|
||||
const setStorageItemMock = vi.fn();
|
||||
const registerBannerMock = vi.fn();
|
||||
const showModalMock = vi.fn();
|
||||
|
||||
vi.mock(I18N_MODULE, () => ({
|
||||
translate: translateMock,
|
||||
@@ -50,6 +56,18 @@ vi.mock(EVENT_MANAGER_MODULE, () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock(BANNER_SERVICE_MODULE, () => ({
|
||||
bannerService: {
|
||||
registerBanner: registerBannerMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock(MODAL_MANAGER_MODULE, () => ({
|
||||
modalManager: {
|
||||
showModal: showModalMock,
|
||||
},
|
||||
}));
|
||||
|
||||
describe('UI helper DOM utilities', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
@@ -57,6 +75,8 @@ describe('UI helper DOM utilities', () => {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
getStorageItemMock.mockReset();
|
||||
setStorageItemMock.mockReset();
|
||||
registerBannerMock.mockReset();
|
||||
showModalMock.mockReset();
|
||||
translateMock.mockReset();
|
||||
globalThis.requestAnimationFrame = (cb) => cb();
|
||||
});
|
||||
@@ -156,4 +176,58 @@ describe('UI helper DOM utilities', () => {
|
||||
'#2 (Character Subgraph) Nested Loader',
|
||||
]);
|
||||
});
|
||||
|
||||
it('opens Civitai links using the preferred host and registers the first-use banner once', async () => {
|
||||
const openSpy = vi.fn();
|
||||
globalThis.window.open = openSpy;
|
||||
|
||||
getStorageItemMock.mockImplementation((key, defaultValue) => {
|
||||
if (key === 'civitai_host_info_banner_seen') {
|
||||
return false;
|
||||
}
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
const { openCivitaiByMetadata } = await import(UI_HELPERS_MODULE);
|
||||
|
||||
openCivitaiByMetadata(123, 456, 'Demo Model');
|
||||
|
||||
expect(setStorageItemMock).toHaveBeenCalledWith('civitai_host_info_banner_seen', true);
|
||||
expect(registerBannerMock).toHaveBeenCalledTimes(1);
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://civitai.com/models/123?modelVersionId=456',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the configured red host for fallback searches', async () => {
|
||||
const openSpy = vi.fn();
|
||||
globalThis.window.open = openSpy;
|
||||
|
||||
getStorageItemMock.mockImplementation((key, defaultValue) => {
|
||||
if (key === 'civitai_host_info_banner_seen') {
|
||||
return true;
|
||||
}
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
const stateModule = await import(STATE_MODULE);
|
||||
stateModule.state.global = {
|
||||
settings: {
|
||||
civitai_host: 'civitai.red',
|
||||
},
|
||||
};
|
||||
|
||||
const { openCivitaiByMetadata } = await import(UI_HELPERS_MODULE);
|
||||
|
||||
openCivitaiByMetadata(null, null, 'Demo Model');
|
||||
|
||||
expect(registerBannerMock).not.toHaveBeenCalled();
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://civitai.red/models?query=Demo%20Model',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
84
tests/nodes/test_prompt_text_wildcards.py
Normal file
84
tests/nodes/test_prompt_text_wildcards.py
Normal 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
|
||||
@@ -94,7 +94,7 @@ class DummyDoctorScanner:
|
||||
|
||||
class DummyCivitaiClient:
|
||||
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._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)]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_wildcards_location_creates_and_opens_directory(tmp_path, monkeypatch):
|
||||
wildcards_dir = tmp_path / "settings" / "wildcards"
|
||||
|
||||
handler = FileSystemHandler(settings_service=SimpleNamespace(settings_file=str(tmp_path / "settings.json")))
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_popen(args):
|
||||
calls.append(args)
|
||||
return MagicMock()
|
||||
|
||||
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
||||
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_docker", lambda: False)
|
||||
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_wsl", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
"py.services.wildcard_service.get_wildcards_dir",
|
||||
lambda create=False: str(wildcards_dir.mkdir(parents=True, exist_ok=True) or wildcards_dir)
|
||||
if create
|
||||
else str(wildcards_dir),
|
||||
)
|
||||
|
||||
response = await handler.open_wildcards_location(FakeRequest())
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
assert payload["path"] == str(wildcards_dir)
|
||||
assert wildcards_dir.is_dir()
|
||||
assert calls == [["xdg-open", str(wildcards_dir)]]
|
||||
|
||||
|
||||
class RecordingRouter:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
@@ -274,7 +274,9 @@ class StubCivitaiClient:
|
||||
def __init__(self) -> None:
|
||||
self.image_info: Dict[str, Any] = {}
|
||||
|
||||
async def get_image_info(self, image_id: str) -> Optional[Dict[str, Any]]:
|
||||
async def get_image_info(
|
||||
self, image_id: str, source_url: str | None = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
return self.image_info.get(image_id)
|
||||
|
||||
|
||||
@@ -635,6 +637,58 @@ async def test_import_remote_video_recipe(monkeypatch, tmp_path: Path) -> None:
|
||||
assert call["extension"] == ".mp4"
|
||||
|
||||
|
||||
async def test_import_remote_recipe_supports_civitai_red(monkeypatch, tmp_path: Path) -> None:
|
||||
async def fake_get_default_metadata_provider():
|
||||
return SimpleNamespace(get_model_version_info=lambda id: ({}, None))
|
||||
|
||||
monkeypatch.setattr(
|
||||
"py.recipes.enrichment.get_default_metadata_provider",
|
||||
fake_get_default_metadata_provider,
|
||||
)
|
||||
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
harness.civitai.image_info["126920345"] = {
|
||||
"id": 126920345,
|
||||
"url": "https://image.civitai.com/x/y/original=true/sample.jpeg",
|
||||
"type": "image",
|
||||
}
|
||||
|
||||
response = await harness.client.get(
|
||||
"/api/lm/recipes/import-remote",
|
||||
params={
|
||||
"image_url": "https://civitai.red/images/126920345",
|
||||
"name": "Red Recipe",
|
||||
"resources": json.dumps([]),
|
||||
"base_model": "Flux",
|
||||
},
|
||||
)
|
||||
|
||||
payload = await response.json()
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
assert harness.downloader.urls
|
||||
assert "width=450,optimized=true" in harness.downloader.urls[0]
|
||||
|
||||
|
||||
async def test_analyze_remote_image_supports_civitai_red(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
harness.analysis.result = SimpleNamespace(payload={"loras": []}, status=200)
|
||||
|
||||
response = await harness.client.post(
|
||||
"/api/lm/recipes/analyze-image",
|
||||
json={"url": "https://civitai.red/images/126920345"},
|
||||
)
|
||||
payload = await response.json()
|
||||
|
||||
assert response.status == 200
|
||||
assert payload == {"loras": []}
|
||||
assert harness.analysis.remote_calls == [
|
||||
"https://civitai.red/images/126920345"
|
||||
]
|
||||
|
||||
|
||||
async def test_analyze_uploaded_image_error_path(monkeypatch, tmp_path: Path) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
harness.analysis.raise_for_uploaded = RecipeValidationError(
|
||||
|
||||
69
tests/routes/test_wildcard_routes.py
Normal file
69
tests/routes/test_wildcard_routes.py
Normal 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"
|
||||
@@ -886,3 +886,111 @@ async def test_format_response_defaults_update_flag_false(service_cls, extra_fie
|
||||
|
||||
assert "update_available" in formatted
|
||||
assert formatted["update_available"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_model_civitai_url_uses_default_host():
|
||||
raw_data = [
|
||||
{
|
||||
"file_name": "demo.safetensors",
|
||||
"civitai": {"modelId": 123, "id": 456},
|
||||
}
|
||||
]
|
||||
|
||||
class CacheStub:
|
||||
def __init__(self, raw_data):
|
||||
self.raw_data = raw_data
|
||||
|
||||
class ScannerStub:
|
||||
def __init__(self, cache):
|
||||
self._cache = cache
|
||||
|
||||
async def get_cached_data(self, *_, **__):
|
||||
return self._cache
|
||||
|
||||
service = DummyService(
|
||||
model_type="stub",
|
||||
scanner=ScannerStub(CacheStub(raw_data)),
|
||||
metadata_class=BaseModelMetadata,
|
||||
settings_provider=StubSettings({}),
|
||||
)
|
||||
|
||||
result = await service.get_model_civitai_url("demo.safetensors")
|
||||
|
||||
assert result == {
|
||||
"civitai_url": "https://civitai.com/models/123?modelVersionId=456",
|
||||
"model_id": "123",
|
||||
"version_id": "456",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_model_civitai_url_uses_configured_host():
|
||||
raw_data = [
|
||||
{
|
||||
"file_name": "demo.safetensors",
|
||||
"civitai": {"modelId": 123, "id": 456},
|
||||
}
|
||||
]
|
||||
|
||||
class CacheStub:
|
||||
def __init__(self, raw_data):
|
||||
self.raw_data = raw_data
|
||||
|
||||
class ScannerStub:
|
||||
def __init__(self, cache):
|
||||
self._cache = cache
|
||||
|
||||
async def get_cached_data(self, *_, **__):
|
||||
return self._cache
|
||||
|
||||
service = DummyService(
|
||||
model_type="stub",
|
||||
scanner=ScannerStub(CacheStub(raw_data)),
|
||||
metadata_class=BaseModelMetadata,
|
||||
settings_provider=StubSettings({"civitai_host": "civitai.red"}),
|
||||
)
|
||||
|
||||
result = await service.get_model_civitai_url("demo.safetensors")
|
||||
|
||||
assert result == {
|
||||
"civitai_url": "https://civitai.red/models/123?modelVersionId=456",
|
||||
"model_id": "123",
|
||||
"version_id": "456",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_model_civitai_url_falls_back_when_host_setting_is_not_a_string():
|
||||
raw_data = [
|
||||
{
|
||||
"file_name": "demo.safetensors",
|
||||
"civitai": {"modelId": 123, "id": 456},
|
||||
}
|
||||
]
|
||||
|
||||
class CacheStub:
|
||||
def __init__(self, raw_data):
|
||||
self.raw_data = raw_data
|
||||
|
||||
class ScannerStub:
|
||||
def __init__(self, cache):
|
||||
self._cache = cache
|
||||
|
||||
async def get_cached_data(self, *_, **__):
|
||||
return self._cache
|
||||
|
||||
service = DummyService(
|
||||
model_type="stub",
|
||||
scanner=ScannerStub(CacheStub(raw_data)),
|
||||
metadata_class=BaseModelMetadata,
|
||||
settings_provider=StubSettings({"civitai_host": True}),
|
||||
)
|
||||
|
||||
result = await service.get_model_civitai_url("demo.safetensors")
|
||||
|
||||
assert result == {
|
||||
"civitai_url": "https://civitai.com/models/123?modelVersionId=456",
|
||||
"model_id": "123",
|
||||
"version_id": "456",
|
||||
}
|
||||
|
||||
@@ -581,6 +581,7 @@ class TestInputValidation:
|
||||
assert service._validate_url("https://example.com/image.png") is True
|
||||
assert service._validate_url("http://example.com/image.png") is True
|
||||
assert service._validate_url("https://civitai.com/images/123") is True
|
||||
assert service._validate_url("https://civitai.red/images/123") is True
|
||||
|
||||
def test_validate_invalid_url(self, service):
|
||||
assert service._validate_url("not-a-url") is False
|
||||
|
||||
@@ -62,6 +62,12 @@ async def test_download_file_uses_downloader(tmp_path, downloader):
|
||||
assert downloader.download_calls[0]["use_auth"] is True
|
||||
|
||||
|
||||
async def test_client_defaults_to_red_api_host(downloader):
|
||||
client = await CivitaiClient.get_instance()
|
||||
|
||||
assert client.base_url == "https://civitai.red/api/v1"
|
||||
|
||||
|
||||
async def test_get_model_by_hash_enriches_metadata(monkeypatch, downloader):
|
||||
version_payload = {
|
||||
"modelId": 123,
|
||||
@@ -530,6 +536,69 @@ async def test_get_image_info_handles_missing(monkeypatch, downloader):
|
||||
assert result is None
|
||||
|
||||
|
||||
async def test_get_image_info_prefers_red_host_for_red_source(monkeypatch, downloader):
|
||||
requested_urls = []
|
||||
|
||||
async def fake_make_request(method, url, use_auth=True, **kwargs):
|
||||
requested_urls.append(url)
|
||||
return True, {"items": [{"id": 124950237, "name": "target"}]}
|
||||
|
||||
downloader.make_request = fake_make_request
|
||||
|
||||
client = await CivitaiClient.get_instance()
|
||||
|
||||
result = await client.get_image_info(
|
||||
"124950237", source_url="https://civitai.red/images/124950237"
|
||||
)
|
||||
|
||||
assert result == {"id": 124950237, "name": "target"}
|
||||
assert requested_urls == [
|
||||
"https://civitai.red/api/v1/images?imageId=124950237&nsfw=X"
|
||||
]
|
||||
|
||||
|
||||
async def test_get_image_info_uses_red_host_even_for_red_source(monkeypatch, downloader):
|
||||
requested_urls = []
|
||||
|
||||
async def fake_make_request(method, url, use_auth=True, **kwargs):
|
||||
requested_urls.append(url)
|
||||
return True, {"items": [{"id": 124950237, "name": "target"}]}
|
||||
|
||||
downloader.make_request = fake_make_request
|
||||
|
||||
client = await CivitaiClient.get_instance()
|
||||
|
||||
result = await client.get_image_info(
|
||||
"124950237", source_url="https://civitai.red/images/124950237"
|
||||
)
|
||||
|
||||
assert result == {"id": 124950237, "name": "target"}
|
||||
assert requested_urls == [
|
||||
"https://civitai.red/api/v1/images?imageId=124950237&nsfw=X",
|
||||
]
|
||||
|
||||
|
||||
async def test_get_image_info_does_not_fall_back_after_request_failure(monkeypatch, downloader):
|
||||
requested_urls = []
|
||||
|
||||
async def fake_make_request(method, url, use_auth=True, **kwargs):
|
||||
requested_urls.append(url)
|
||||
return False, "403 forbidden"
|
||||
|
||||
downloader.make_request = fake_make_request
|
||||
|
||||
client = await CivitaiClient.get_instance()
|
||||
|
||||
result = await client.get_image_info(
|
||||
"124950237", source_url="https://civitai.red/images/124950237"
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert requested_urls == [
|
||||
"https://civitai.red/api/v1/images?imageId=124950237&nsfw=X",
|
||||
]
|
||||
|
||||
|
||||
async def test_get_image_info_handles_invalid_id(monkeypatch, downloader, caplog):
|
||||
"""When given a non-numeric image ID, return None and log error."""
|
||||
client = await CivitaiClient.get_instance()
|
||||
|
||||
@@ -7,7 +7,10 @@ from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from py.services.download_manager import DownloadManager
|
||||
from py.services.download_manager import (
|
||||
CIVITAI_DOWNLOAD_URL_PREFIXES,
|
||||
DownloadManager,
|
||||
)
|
||||
from py.services import download_manager
|
||||
from py.services.service_registry import ServiceRegistry
|
||||
from py.services.settings_manager import SettingsManager, get_settings_manager
|
||||
@@ -309,6 +312,67 @@ async def test_execute_download_respects_blur_setting(monkeypatch, tmp_path):
|
||||
assert stored_preview and stored_preview.endswith(".jpeg")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_download_uses_auth_for_red_civitai_downloads(monkeypatch, tmp_path):
|
||||
manager = DownloadManager()
|
||||
save_dir = tmp_path / "downloads"
|
||||
save_dir.mkdir()
|
||||
target_path = save_dir / "file.safetensors"
|
||||
|
||||
class DummyMetadata:
|
||||
def __init__(self, path: Path):
|
||||
self.file_path = str(path)
|
||||
self.sha256 = "sha256"
|
||||
self.file_name = path.stem
|
||||
self.preview_url = None
|
||||
self.preview_nsfw_level = None
|
||||
|
||||
def generate_unique_filename(self, *_args, **_kwargs):
|
||||
return os.path.basename(self.file_path)
|
||||
|
||||
def update_file_info(self, _path):
|
||||
return None
|
||||
|
||||
def to_dict(self):
|
||||
return {"file_path": self.file_path}
|
||||
|
||||
metadata = DummyMetadata(target_path)
|
||||
recorded_use_auth = []
|
||||
|
||||
class DummyDownloader:
|
||||
stall_timeout = None
|
||||
|
||||
async def download_file(self, url, path, progress_callback=None, use_auth=None, **_kwargs):
|
||||
recorded_use_auth.append((url, use_auth))
|
||||
Path(path).write_bytes(b"model")
|
||||
return True, None
|
||||
|
||||
monkeypatch.setattr(
|
||||
download_manager, "get_downloader", AsyncMock(return_value=DummyDownloader())
|
||||
)
|
||||
monkeypatch.setattr(MetadataManager, "save_metadata", AsyncMock(return_value=True))
|
||||
|
||||
dummy_scanner = SimpleNamespace(add_model_to_cache=AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(
|
||||
DownloadManager, "_get_lora_scanner", AsyncMock(return_value=dummy_scanner)
|
||||
)
|
||||
|
||||
result = await manager._execute_download(
|
||||
download_urls=["https://civitai.red/api/download/models/119514"],
|
||||
save_dir=str(save_dir),
|
||||
metadata=metadata,
|
||||
version_info={"images": []},
|
||||
relative_path="",
|
||||
progress_callback=None,
|
||||
model_type="lora",
|
||||
download_id=None,
|
||||
)
|
||||
|
||||
assert result == {"success": True}
|
||||
assert recorded_use_auth == [("https://civitai.com/api/download/models/119514", True)]
|
||||
assert "https://civitai.com/api/download/".startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_civarchive_source_uses_civarchive_provider(
|
||||
monkeypatch, scanners, tmp_path
|
||||
|
||||
@@ -5,6 +5,7 @@ import pytest
|
||||
|
||||
from py.services.model_lifecycle_service import ModelLifecycleService
|
||||
from py.utils.metadata_manager import MetadataManager
|
||||
from py.utils.models import LoraMetadata
|
||||
|
||||
|
||||
class DummyCache:
|
||||
@@ -445,6 +446,63 @@ async def test_exclude_model_empty_path_raises_error():
|
||||
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
|
||||
# =============================================================================
|
||||
|
||||
@@ -6,7 +6,7 @@ from types import SimpleNamespace
|
||||
|
||||
# We define these here to help with spec= if needed
|
||||
class MockCivitaiClient:
|
||||
async def get_image_info(self, image_id):
|
||||
async def get_image_info(self, image_id, source_url=None):
|
||||
pass
|
||||
|
||||
class MockPersistenceService:
|
||||
@@ -119,6 +119,50 @@ async def test_repair_all_recipes_with_enriched_checkpoint_id(setup_scanner):
|
||||
assert "hash" not in checkpoint
|
||||
assert "file_name" not in checkpoint
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repair_all_recipes_supports_civitai_red_source_url(setup_scanner):
|
||||
recipe_scanner, mock_civitai_client, mock_metadata_provider = setup_scanner
|
||||
|
||||
recipe = {
|
||||
"id": "r1",
|
||||
"title": "Red Recipe",
|
||||
"source_url": "https://civitai.red/images/12345",
|
||||
"checkpoint": None,
|
||||
"gen_params": {"prompt": ""},
|
||||
}
|
||||
recipe_scanner._cache = SimpleNamespace(raw_data=[recipe])
|
||||
|
||||
mock_civitai_client.get_image_info.return_value = {
|
||||
"modelVersionId": 5678,
|
||||
"meta": {"prompt": "from red"},
|
||||
}
|
||||
mock_metadata_provider.get_model_version_info.return_value = (
|
||||
{
|
||||
"id": 5678,
|
||||
"modelId": 1234,
|
||||
"name": "v1.0",
|
||||
"model": {"name": "Full Model Name"},
|
||||
"baseModel": "SDXL 1.0",
|
||||
"images": [{"url": "https://image.url/thumb.jpg"}],
|
||||
"files": [
|
||||
{
|
||||
"type": "Model",
|
||||
"hashes": {"SHA256": "ABCDEF"},
|
||||
"name": "full_filename.safetensors",
|
||||
}
|
||||
],
|
||||
},
|
||||
None,
|
||||
)
|
||||
|
||||
results = await recipe_scanner.repair_all_recipes()
|
||||
|
||||
assert results["repaired"] == 1
|
||||
mock_civitai_client.get_image_info.assert_called_with(
|
||||
"12345", source_url="https://civitai.red/images/12345"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repair_all_recipes_with_enriched_checkpoint_hash(setup_scanner):
|
||||
recipe_scanner, mock_civitai_client, mock_metadata_provider = setup_scanner
|
||||
|
||||
@@ -678,7 +678,7 @@ async def test_analyze_remote_video(tmp_path):
|
||||
)
|
||||
|
||||
class DummyClient:
|
||||
async def get_image_info(self, image_id):
|
||||
async def get_image_info(self, image_id, source_url=None):
|
||||
return {
|
||||
"url": "https://civitai.com/video.mp4",
|
||||
"type": "video",
|
||||
@@ -698,3 +698,60 @@ async def test_analyze_remote_video(tmp_path):
|
||||
assert result.payload["is_video"] is True
|
||||
assert result.payload["extension"] == ".mp4"
|
||||
assert result.payload["image_base64"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_remote_image_supports_civitai_red():
|
||||
exif_utils = DummyExifUtils()
|
||||
|
||||
class DummyFactory:
|
||||
def create_parser(self, metadata):
|
||||
async def parse_metadata(m, recipe_scanner=None, civitai_client=None):
|
||||
return {"loras": [], "gen_params": {"prompt": "red prompt"}}
|
||||
|
||||
return SimpleNamespace(parse_metadata=parse_metadata)
|
||||
|
||||
async def downloader_factory():
|
||||
class Downloader:
|
||||
async def download_file(self, url, path, use_auth=False):
|
||||
Path(path).write_bytes(b"fake-image")
|
||||
return True, "success"
|
||||
|
||||
return Downloader()
|
||||
|
||||
service = RecipeAnalysisService(
|
||||
exif_utils=exif_utils,
|
||||
recipe_parser_factory=DummyFactory(),
|
||||
downloader_factory=downloader_factory,
|
||||
metadata_collector=None,
|
||||
metadata_processor_cls=None,
|
||||
metadata_registry_cls=None,
|
||||
standalone_mode=False,
|
||||
logger=logging.getLogger("test"),
|
||||
)
|
||||
|
||||
class DummyClient:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def get_image_info(self, image_id, source_url=None):
|
||||
self.calls.append((image_id, source_url))
|
||||
return {
|
||||
"url": "https://image.civitai.com/x/y/original=true/sample.jpeg",
|
||||
"type": "image",
|
||||
"meta": {"prompt": "red prompt"},
|
||||
}
|
||||
|
||||
class DummyScanner:
|
||||
async def find_recipes_by_fingerprint(self, fingerprint):
|
||||
return []
|
||||
|
||||
client = DummyClient()
|
||||
result = await service.analyze_remote_image(
|
||||
url="https://civitai.red/images/123",
|
||||
recipe_scanner=DummyScanner(),
|
||||
civitai_client=client,
|
||||
)
|
||||
|
||||
assert client.calls == [("123", "https://civitai.red/images/123")]
|
||||
assert result.payload["loras"] == []
|
||||
|
||||
141
tests/services/test_wildcard_service.py
Normal file
141
tests/services/test_wildcard_service.py
Normal 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
|
||||
@@ -94,6 +94,19 @@ class TestCustomWordsService:
|
||||
results = service.search_words("test")
|
||||
assert mock_tag_index.called
|
||||
|
||||
def test_search_words_skips_prompt_like_queries(self):
|
||||
service = CustomWordsService.__new__(CustomWordsService)
|
||||
mock_tag_index = MockTagFTSIndex()
|
||||
|
||||
def mock_get_index():
|
||||
return mock_tag_index
|
||||
|
||||
service._get_tag_index = mock_get_index
|
||||
|
||||
results = service.search_words("__flower__ /character f")
|
||||
|
||||
assert results == []
|
||||
assert mock_tag_index.called is False
|
||||
|
||||
class MockTagFTSIndex:
|
||||
"""Mock TagFTSIndex for testing."""
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests for TagFTSIndex functionality."""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from typing import List
|
||||
|
||||
@@ -173,6 +174,40 @@ class TestTagFTSIndexSearch:
|
||||
assert len(results) >= 1
|
||||
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):
|
||||
"""Test that category filter excludes other categories."""
|
||||
# Search for "hi" but only in general category
|
||||
|
||||
@@ -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():
|
||||
@@ -78,3 +86,61 @@ def test_build_license_flags_parses_aggregate_inside_list():
|
||||
flags = build_license_flags(source)
|
||||
expected_flags = (1 << 0) | (7 << 1) | (1 << 5)
|
||||
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
|
||||
|
||||
@@ -425,7 +425,7 @@ function shouldBypassAutocompleteWidgetMigration(
|
||||
}
|
||||
|
||||
const originalWidgetsInputs = Object.values(inputDefs).filter((input: any) =>
|
||||
widgetNames.has(input.name) || input.forceInput
|
||||
widgetNames.has(input.name)
|
||||
)
|
||||
|
||||
const widgetIndexHasForceInput = originalWidgetsInputs.flatMap((input: any) =>
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { TextAreaCaretHelper } from "./textarea_caret_helper.js";
|
||||
import {
|
||||
WILDCARD_COMMANDS,
|
||||
createWildcardEmptyStateItem,
|
||||
createWildcardNoMatchesItem,
|
||||
getWildcardInsertText,
|
||||
getWildcardSearchEndpoint,
|
||||
isWildcardCommand,
|
||||
isWildcardInfoItem,
|
||||
} from "./autocomplete_wildcards.js";
|
||||
import {
|
||||
getAutocompleteAppendCommaPreference,
|
||||
getAutocompleteAutoFormatPreference,
|
||||
@@ -13,7 +22,6 @@ import { showToast } from "./utils.js";
|
||||
// Command definitions for category filtering
|
||||
const TAG_COMMANDS = {
|
||||
'/character': { categories: [4, 11], label: 'Character' },
|
||||
'/char': { categories: [4, 11], label: 'Character' },
|
||||
'/artist': { categories: [1, 8], label: 'Artist' },
|
||||
'/general': { categories: [0, 7], label: 'General' },
|
||||
'/copyright': { categories: [3, 10], label: 'Copyright' },
|
||||
@@ -22,6 +30,7 @@ const TAG_COMMANDS = {
|
||||
'/lore': { categories: [15], label: 'Lore' },
|
||||
'/emb': { type: 'embedding', label: 'Embeddings' },
|
||||
'/embedding': { type: 'embedding', label: 'Embeddings' },
|
||||
...WILDCARD_COMMANDS,
|
||||
// Autocomplete toggle commands - only show one based on current state
|
||||
'/ac': {
|
||||
type: 'toggle_setting',
|
||||
@@ -314,6 +323,8 @@ const MODEL_BEHAVIORS = {
|
||||
const trimmedName = removeGeneralExtension(fileName);
|
||||
const folder = directories.length ? `${directories.join('/')}/` : '';
|
||||
return formatAutocompleteInsertion(`embedding:${folder}${trimmedName}`);
|
||||
} else if (instance.searchType === 'wildcards' || isWildcardCommand(instance.activeCommand)) {
|
||||
return formatAutocompleteInsertion(getWildcardInsertText(relativePath));
|
||||
} else {
|
||||
let tagText = relativePath;
|
||||
|
||||
@@ -350,13 +361,16 @@ class AutoComplete {
|
||||
|
||||
this.dropdown = null;
|
||||
this.selectedIndex = -1;
|
||||
this.hasManualSelection = false;
|
||||
this.items = [];
|
||||
this.debounceTimer = null;
|
||||
this.isVisible = false;
|
||||
this.currentSearchTerm = '';
|
||||
this.wildcardMeta = null;
|
||||
this.previewTooltip = null;
|
||||
this.previewTooltipPromise = null;
|
||||
this.searchType = null;
|
||||
this.suppressAutocompleteOnce = false;
|
||||
|
||||
// Virtual scrolling state
|
||||
this.virtualScrollOffset = 0;
|
||||
@@ -496,6 +510,11 @@ class AutoComplete {
|
||||
bindEvents() {
|
||||
// Handle input changes
|
||||
this.onInput = (e) => {
|
||||
if (this.suppressAutocompleteOnce) {
|
||||
this.suppressAutocompleteOnce = false;
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
this.handleInput(e.target.value);
|
||||
};
|
||||
this.inputElement.addEventListener('input', this.onInput);
|
||||
@@ -512,6 +531,7 @@ class AutoComplete {
|
||||
const formattedValue = formatAutocompleteTextOnBlur(this.inputElement.value);
|
||||
if (formattedValue !== this.inputElement.value) {
|
||||
this.inputElement.value = formattedValue;
|
||||
this.suppressAutocompleteOnce = true;
|
||||
this.inputElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
@@ -658,6 +678,9 @@ class AutoComplete {
|
||||
// /emb or /embedding command
|
||||
endpoint = '/lm/embeddings/relative-paths';
|
||||
this.searchType = 'embeddings';
|
||||
} else if (isWildcardCommand(commandResult.command)) {
|
||||
endpoint = getWildcardSearchEndpoint();
|
||||
this.searchType = 'wildcards';
|
||||
} else {
|
||||
// Category filter command
|
||||
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();
|
||||
return;
|
||||
}
|
||||
@@ -713,9 +741,24 @@ class AutoComplete {
|
||||
}
|
||||
|
||||
const rawText = beforeCursor.substring(start);
|
||||
const text = rawText.trim();
|
||||
const leadingWhitespaceLength = rawText.length - rawText.trimStart().length;
|
||||
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 {
|
||||
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 = '') {
|
||||
const lastComma = beforeCursor.lastIndexOf(',');
|
||||
const lastAngle = beforeCursor.lastIndexOf('>');
|
||||
@@ -878,12 +988,50 @@ class AutoComplete {
|
||||
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)
|
||||
* @param {string|Object} item - Item to get display text from
|
||||
* @returns {string} - Display text without extension
|
||||
*/
|
||||
_getDisplayText(item) {
|
||||
if (isWildcardInfoItem(item)) {
|
||||
return item.title || item.description || 'Wildcards';
|
||||
}
|
||||
|
||||
const itemText = typeof item === 'object' && item.tag_name ? item.tag_name : String(item);
|
||||
// Remove extension for models to avoid matching/displaying .safetensors etc.
|
||||
if (this.modelType === 'loras' || this.searchType === 'embeddings') {
|
||||
@@ -1013,6 +1161,14 @@ class AutoComplete {
|
||||
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) {
|
||||
try {
|
||||
this.currentSearchTerm = term;
|
||||
@@ -1024,23 +1180,26 @@ class AutoComplete {
|
||||
// This is critical for preventing command suggestions from persisting
|
||||
// when switching from command mode to regular tag search
|
||||
this.items = [];
|
||||
this.wildcardMeta = null;
|
||||
|
||||
if (!endpoint) {
|
||||
endpoint = `/lm/${this.modelType}/relative-paths`;
|
||||
}
|
||||
|
||||
// Generate multiple query variations for better matching
|
||||
const queryVariations = this._generateQueryVariations(term);
|
||||
// Generate multiple query variations for better matching, but avoid
|
||||
// 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.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
// Limit the number of parallel queries to avoid overwhelming the server
|
||||
const queriesToExecute = queryVariations.slice(0, 4);
|
||||
|
||||
// Execute all queries in parallel
|
||||
const searchPromises = queriesToExecute.map(async (query) => {
|
||||
const url = endpoint.includes('?')
|
||||
@@ -1050,10 +1209,16 @@ class AutoComplete {
|
||||
try {
|
||||
const response = await api.fetchApi(url);
|
||||
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) {
|
||||
console.warn(`Search query failed for "${query}":`, error);
|
||||
return [];
|
||||
return {
|
||||
items: [],
|
||||
meta: null,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1071,7 +1236,12 @@ class AutoComplete {
|
||||
const seen = new Set();
|
||||
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) {
|
||||
const itemKey = typeof item === 'object' && item.tag_name
|
||||
? 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
|
||||
// Backend already ranks by: FTS5 bm25 score + post count + exact prefix boost
|
||||
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 searchPart = trimmed.slice(spaceIndex + 1).trim();
|
||||
|
||||
@@ -1178,20 +1359,15 @@ class AutoComplete {
|
||||
|
||||
const filterLower = filter.toLowerCase();
|
||||
|
||||
// Get unique commands (avoid duplicates like /char and /character)
|
||||
const seenLabels = new Set();
|
||||
const 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
|
||||
if (info.type === 'toggle_setting' && info.condition) {
|
||||
if (!info.condition()) continue;
|
||||
}
|
||||
|
||||
if (!filter || cmd.slice(1).startsWith(filterLower)) {
|
||||
seenLabels.add(info.label);
|
||||
commands.push({ command: cmd, ...info });
|
||||
}
|
||||
}
|
||||
@@ -1219,6 +1395,7 @@ class AutoComplete {
|
||||
this.dropdown.innerHTML = '';
|
||||
}
|
||||
this.selectedIndex = -1;
|
||||
this.hasManualSelection = false;
|
||||
|
||||
this.items.forEach((item, index) => {
|
||||
const itemEl = document.createElement('div');
|
||||
@@ -1254,7 +1431,7 @@ class AutoComplete {
|
||||
`;
|
||||
|
||||
itemEl.addEventListener('mouseenter', () => {
|
||||
this.selectItem(index);
|
||||
this.selectItem(index, { manual: true });
|
||||
});
|
||||
|
||||
itemEl.addEventListener('click', () => {
|
||||
@@ -1276,8 +1453,17 @@ class AutoComplete {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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
|
||||
@@ -1288,7 +1474,7 @@ class AutoComplete {
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const currentValue = this.inputElement.value;
|
||||
@@ -1315,6 +1501,7 @@ class AutoComplete {
|
||||
|
||||
render() {
|
||||
this.selectedIndex = -1;
|
||||
this.hasManualSelection = false;
|
||||
|
||||
// Reset virtual scroll state
|
||||
this.virtualScrollOffset = 0;
|
||||
@@ -1346,91 +1533,7 @@ class AutoComplete {
|
||||
const isCommand = this.items[0] && typeof this.items[0] === 'object' && 'command' in this.items[0];
|
||||
|
||||
this.items.forEach((itemData, index) => {
|
||||
const item = document.createElement('div');
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
const item = this.createItemElement(itemData, index, isEnriched, isCommand);
|
||||
this.dropdown.appendChild(item);
|
||||
});
|
||||
|
||||
@@ -1524,6 +1627,124 @@ class AutoComplete {
|
||||
itemEl.appendChild(nameSpan);
|
||||
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) {
|
||||
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>',
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!this.options.showPreview || !this.previewTooltip) return;
|
||||
@@ -1611,6 +1888,8 @@ class AutoComplete {
|
||||
if (this.modelType === 'prompt') {
|
||||
if (this.searchType === 'embeddings') {
|
||||
endpoint = '/lm/embeddings/relative-paths';
|
||||
} else if (this.searchType === 'wildcards') {
|
||||
endpoint = getWildcardSearchEndpoint();
|
||||
} else if (this.searchType === 'custom_words') {
|
||||
if (this.activeCommand?.categories) {
|
||||
const categories = this.activeCommand.categories.join(',');
|
||||
@@ -1621,8 +1900,7 @@ class AutoComplete {
|
||||
}
|
||||
}
|
||||
|
||||
const queryVariations = this._generateQueryVariations(this.currentSearchTerm);
|
||||
const queriesToExecute = queryVariations.slice(0, 4);
|
||||
const queriesToExecute = this._getQueriesToExecute(this.currentSearchTerm);
|
||||
const offset = this.items.length;
|
||||
|
||||
// Execute all queries in parallel with offset
|
||||
@@ -1733,6 +2011,14 @@ class AutoComplete {
|
||||
updateVirtualScrollHeight() {
|
||||
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.contentContainer.style.height = `${this.totalHeight}px`;
|
||||
|
||||
@@ -1751,6 +2037,16 @@ class AutoComplete {
|
||||
updateVisibleItems() {
|
||||
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 containerHeight = this.scrollContainer.clientHeight;
|
||||
|
||||
@@ -1830,7 +2126,9 @@ class AutoComplete {
|
||||
isCommand = true;
|
||||
}
|
||||
|
||||
if (isCommand) {
|
||||
if (isWildcardInfoItem(itemData)) {
|
||||
this._renderInformationalItem(item, itemData);
|
||||
} else if (isCommand) {
|
||||
// Render command item
|
||||
const cmdSpan = document.createElement('span');
|
||||
cmdSpan.className = 'lm-autocomplete-command-name';
|
||||
@@ -1862,7 +2160,7 @@ class AutoComplete {
|
||||
|
||||
// Hover and selection handlers
|
||||
item.addEventListener('mouseenter', () => {
|
||||
this.selectItem(index);
|
||||
this.selectItem(index, { manual: true });
|
||||
});
|
||||
|
||||
item.addEventListener('mouseleave', () => {
|
||||
@@ -1871,6 +2169,10 @@ class AutoComplete {
|
||||
|
||||
// Click handler
|
||||
item.addEventListener('click', () => {
|
||||
if (isWildcardInfoItem(itemData)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCommand) {
|
||||
this._insertCommand(itemData.command);
|
||||
} else {
|
||||
@@ -1954,11 +2256,13 @@ class AutoComplete {
|
||||
this.dropdown.style.display = 'none';
|
||||
this.isVisible = false;
|
||||
this.selectedIndex = -1;
|
||||
this.hasManualSelection = false;
|
||||
this.showingCommands = false;
|
||||
|
||||
// Clear items to prevent stale data from being displayed
|
||||
// when autocomplete is shown again
|
||||
this.items = [];
|
||||
this.wildcardMeta = null;
|
||||
|
||||
// Clear content container to prevent stale items from showing
|
||||
if (this.contentContainer) {
|
||||
@@ -1992,7 +2296,7 @@ class AutoComplete {
|
||||
});
|
||||
}
|
||||
|
||||
selectItem(index) {
|
||||
selectItem(index, { manual = false } = {}) {
|
||||
// Remove previous selection
|
||||
const container = this.options.enableVirtualScroll && this.contentContainer
|
||||
? this.contentContainer
|
||||
@@ -2006,6 +2310,7 @@ class AutoComplete {
|
||||
// Add new selection
|
||||
if (index >= 0 && index < this.items.length) {
|
||||
this.selectedIndex = index;
|
||||
this.hasManualSelection = manual;
|
||||
|
||||
// For virtual scrolling, we need to ensure the item is rendered
|
||||
if (this.options.enableVirtualScroll && this.scrollContainer) {
|
||||
@@ -2046,7 +2351,7 @@ class AutoComplete {
|
||||
item.scrollIntoView({ block: 'nearest' });
|
||||
|
||||
// Show preview for selected item
|
||||
if (this.options.showPreview) {
|
||||
if (this.options.showPreview && !this._isSelectableInfoItem(this.items[index])) {
|
||||
if (typeof this.behavior.showPreview === 'function') {
|
||||
this.behavior.showPreview(this, this.items[index], item);
|
||||
} else if (this.previewTooltip) {
|
||||
@@ -2073,7 +2378,7 @@ class AutoComplete {
|
||||
selectedEl.style.backgroundColor = 'rgba(66, 153, 225, 0.2)';
|
||||
|
||||
// Show preview for selected item
|
||||
if (this.options.showPreview) {
|
||||
if (this.options.showPreview && !this._isSelectableInfoItem(this.items[index])) {
|
||||
if (typeof this.behavior.showPreview === 'function') {
|
||||
this.behavior.showPreview(this, this.items[index], selectedEl);
|
||||
} else if (this.previewTooltip) {
|
||||
@@ -2099,15 +2404,15 @@ class AutoComplete {
|
||||
this.loadMoreItems().then(() => {
|
||||
// After loading more, select the next item
|
||||
if (this.selectedIndex < this.items.length - 1) {
|
||||
this.selectItem(this.selectedIndex + 1);
|
||||
this.selectItem(this.selectedIndex + 1, { manual: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.selectItem(this.selectedIndex + 1);
|
||||
this.selectItem(this.selectedIndex + 1, { manual: true });
|
||||
}
|
||||
} 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;
|
||||
|
||||
@@ -2117,12 +2422,12 @@ class AutoComplete {
|
||||
// For virtual scrolling, handle top boundary
|
||||
if (this.selectedIndex <= 0) {
|
||||
// Already at first item, ensure it's selected
|
||||
this.selectItem(0);
|
||||
this.selectItem(0, { manual: true });
|
||||
} else {
|
||||
this.selectItem(this.selectedIndex - 1);
|
||||
this.selectItem(this.selectedIndex - 1, { manual: true });
|
||||
}
|
||||
} else {
|
||||
this.selectItem(Math.max(this.selectedIndex - 1, 0));
|
||||
this.selectItem(Math.max(this.selectedIndex - 1, 0), { manual: true });
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -2134,9 +2439,9 @@ class AutoComplete {
|
||||
|
||||
{
|
||||
const liveSearchTerm = this._getLiveSearchTermForAcceptance();
|
||||
const preferredIndex = this._getPreferredSelectedIndex(liveSearchTerm);
|
||||
if (preferredIndex !== -1 && preferredIndex !== this.selectedIndex) {
|
||||
this.selectItem(preferredIndex);
|
||||
const acceptIndex = this._getAcceptSelectionIndex(liveSearchTerm);
|
||||
if (acceptIndex !== -1 && acceptIndex !== this.selectedIndex) {
|
||||
this.selectItem(acceptIndex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2146,8 +2451,15 @@ class AutoComplete {
|
||||
// Insert command
|
||||
this._insertCommand(this.items[this.selectedIndex].command);
|
||||
} else {
|
||||
// Insert selection (handle enriched items)
|
||||
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
|
||||
? selectedItem.tag_name
|
||||
: selectedItem;
|
||||
@@ -2180,7 +2492,7 @@ class AutoComplete {
|
||||
// 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"
|
||||
// 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;
|
||||
if (this.modelType === 'prompt' && this.searchType === 'custom_words' && !this.activeCommand) {
|
||||
// Check if the selectedItem exists and its tag_name matches the full search term
|
||||
|
||||
54
web/comfyui/autocomplete_wildcards.js
Normal file
54
web/comfyui/autocomplete_wildcards.js
Normal 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 : [],
|
||||
};
|
||||
}
|
||||
@@ -15557,7 +15557,7 @@ function shouldBypassAutocompleteWidgetMigration(node, widgetValues) {
|
||||
return false;
|
||||
}
|
||||
const originalWidgetsInputs = Object.values(inputDefs).filter(
|
||||
(input) => widgetNames.has(input.name) || input.forceInput
|
||||
(input) => widgetNames.has(input.name)
|
||||
);
|
||||
const widgetIndexHasForceInput = originalWidgetsInputs.flatMap(
|
||||
(input) => input.control_after_generate ? [!!input.forceInput, false] : [!!input.forceInput]
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user