mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-05-07 00:46:44 -03:00
Compare commits
20 Commits
1817142a7b
...
v1.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4fa1631ee | ||
|
|
506d763dc2 | ||
|
|
a2cd09b619 | ||
|
|
cdd77029b6 | ||
|
|
439679e15f | ||
|
|
2640258902 | ||
|
|
b910388d54 | ||
|
|
083de395b1 | ||
|
|
4514ca94b7 | ||
|
|
62247bdd87 | ||
|
|
6d0d9600a7 | ||
|
|
70cd3f4e1b | ||
|
|
a95c518b30 | ||
|
|
ba1800095e | ||
|
|
39c083db79 | ||
|
|
55e9e4bb6f | ||
|
|
0253d001e6 | ||
|
|
9998da3241 | ||
|
|
6666a72775 | ||
|
|
5f1bd894b9 |
46
README.md
46
README.md
@@ -56,6 +56,15 @@ Insomnia Art Designs, megakirbs, Brennok, 2018cfh, W+K+White, wackop, Takkan, Ca
|
||||
|
||||
## Release Notes
|
||||
|
||||
### 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 +110,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 +262,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
@@ -957,6 +957,8 @@
|
||||
"earlyAccess": "Early Access",
|
||||
"earlyAccessTooltip": "Early Access erforderlich",
|
||||
"inLibrary": "In Bibliothek",
|
||||
"downloaded": "Heruntergeladen",
|
||||
"downloadedTooltip": "Zuvor heruntergeladen, aber derzeit nicht in Ihrer Bibliothek.",
|
||||
"alreadyInLibrary": "Bereits in Bibliothek",
|
||||
"autoOrganizedPath": "[Automatisch organisiert durch Pfadvorlage]",
|
||||
"errors": {
|
||||
@@ -1226,17 +1228,30 @@
|
||||
"days": "in {count}d"
|
||||
},
|
||||
"badges": {
|
||||
"current": "Aktuelle Version",
|
||||
"current": "Geöffnete Version",
|
||||
"currentTooltip": "Das ist die Version, mit der dieses Modal geöffnet wurde",
|
||||
"inLibrary": "In der Bibliothek",
|
||||
"inLibraryTooltip": "Diese Version befindet sich in Ihrer lokalen Bibliothek",
|
||||
"downloaded": "Heruntergeladen",
|
||||
"downloadedTooltip": "Diese Version wurde bereits heruntergeladen, befindet sich aber derzeit nicht in Ihrer Bibliothek",
|
||||
"newer": "Neuere Version",
|
||||
"newerTooltip": "Diese Version ist neuer als Ihre neueste lokale Version",
|
||||
"earlyAccess": "Früher Zugriff",
|
||||
"ignored": "Ignoriert"
|
||||
"earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich",
|
||||
"ignored": "Ignoriert",
|
||||
"ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Herunterladen",
|
||||
"downloadTooltip": "Diese Version herunterladen",
|
||||
"downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai herunterladen",
|
||||
"delete": "Löschen",
|
||||
"deleteTooltip": "Diese lokale Version löschen",
|
||||
"ignore": "Ignorieren",
|
||||
"unignore": "Ignorierung aufheben",
|
||||
"ignoreTooltip": "Update-Benachrichtigungen für diese Version ignorieren",
|
||||
"unignoreTooltip": "Update-Benachrichtigungen für diese Version fortsetzen",
|
||||
"viewVersionOnCivitai": "Version auf Civitai anzeigen",
|
||||
"earlyAccessTooltip": "Erfordert Early-Access-Kauf",
|
||||
"resumeModelUpdates": "Aktualisierungen für dieses Modell fortsetzen",
|
||||
"ignoreModelUpdates": "Aktualisierungen für dieses Modell ignorieren",
|
||||
|
||||
@@ -957,6 +957,8 @@
|
||||
"earlyAccess": "Early Access",
|
||||
"earlyAccessTooltip": "Early access required",
|
||||
"inLibrary": "In Library",
|
||||
"downloaded": "Downloaded",
|
||||
"downloadedTooltip": "Previously downloaded, but it is not currently in your library.",
|
||||
"alreadyInLibrary": "Already in Library",
|
||||
"autoOrganizedPath": "[Auto-organized by path template]",
|
||||
"errors": {
|
||||
@@ -1226,17 +1228,30 @@
|
||||
"days": "in {count}d"
|
||||
},
|
||||
"badges": {
|
||||
"current": "Current Version",
|
||||
"current": "Opened Version",
|
||||
"currentTooltip": "This is the version you opened this modal from",
|
||||
"inLibrary": "In Library",
|
||||
"inLibraryTooltip": "This version exists in your local library",
|
||||
"downloaded": "Downloaded",
|
||||
"downloadedTooltip": "This version was downloaded before, but is not currently in your library",
|
||||
"newer": "Newer Version",
|
||||
"newerTooltip": "This version is newer than your latest local version",
|
||||
"earlyAccess": "Early Access",
|
||||
"ignored": "Ignored"
|
||||
"earlyAccessTooltip": "This version currently requires Civitai early access",
|
||||
"ignored": "Ignored",
|
||||
"ignoredTooltip": "Update notifications are disabled for this version"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Download",
|
||||
"downloadTooltip": "Download this version",
|
||||
"downloadEarlyAccessTooltip": "Download this early access version from Civitai",
|
||||
"delete": "Delete",
|
||||
"deleteTooltip": "Delete this local version",
|
||||
"ignore": "Ignore",
|
||||
"unignore": "Unignore",
|
||||
"ignoreTooltip": "Ignore update notifications for this version",
|
||||
"unignoreTooltip": "Resume update notifications for this version",
|
||||
"viewVersionOnCivitai": "View version on Civitai",
|
||||
"earlyAccessTooltip": "Requires early access purchase",
|
||||
"resumeModelUpdates": "Resume updates for this model",
|
||||
"ignoreModelUpdates": "Ignore updates for this model",
|
||||
|
||||
@@ -957,6 +957,8 @@
|
||||
"earlyAccess": "Acceso temprano",
|
||||
"earlyAccessTooltip": "Acceso temprano requerido",
|
||||
"inLibrary": "En la biblioteca",
|
||||
"downloaded": "Descargado",
|
||||
"downloadedTooltip": "Descargado anteriormente, pero actualmente no está en tu biblioteca.",
|
||||
"alreadyInLibrary": "Ya en la biblioteca",
|
||||
"autoOrganizedPath": "[Auto-organizado por plantilla de ruta]",
|
||||
"errors": {
|
||||
@@ -1226,17 +1228,30 @@
|
||||
"days": "en {count}d"
|
||||
},
|
||||
"badges": {
|
||||
"current": "Versión actual",
|
||||
"current": "Versión abierta",
|
||||
"currentTooltip": "Es la versión con la que abriste este modal",
|
||||
"inLibrary": "En la biblioteca",
|
||||
"inLibraryTooltip": "Esta versión existe en tu biblioteca local",
|
||||
"downloaded": "Descargado",
|
||||
"downloadedTooltip": "Esta versión se descargó antes, pero ahora no está en tu biblioteca",
|
||||
"newer": "Versión más reciente",
|
||||
"newerTooltip": "Esta versión es más reciente que tu última versión local",
|
||||
"earlyAccess": "Acceso temprano",
|
||||
"ignored": "Ignorada"
|
||||
"earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai",
|
||||
"ignored": "Ignorada",
|
||||
"ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Descargar",
|
||||
"downloadTooltip": "Descargar esta versión",
|
||||
"downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai",
|
||||
"delete": "Eliminar",
|
||||
"deleteTooltip": "Eliminar esta versión local",
|
||||
"ignore": "Ignorar",
|
||||
"unignore": "Dejar de ignorar",
|
||||
"ignoreTooltip": "Ignorar las notificaciones de actualización de esta versión",
|
||||
"unignoreTooltip": "Reanudar las notificaciones de actualización de esta versión",
|
||||
"viewVersionOnCivitai": "Ver versión en Civitai",
|
||||
"earlyAccessTooltip": "Requiere compra de acceso temprano",
|
||||
"resumeModelUpdates": "Reanudar actualizaciones para este modelo",
|
||||
"ignoreModelUpdates": "Ignorar actualizaciones para este modelo",
|
||||
|
||||
@@ -957,6 +957,8 @@
|
||||
"earlyAccess": "Accès anticipé",
|
||||
"earlyAccessTooltip": "Accès anticipé requis",
|
||||
"inLibrary": "Dans la bibliothèque",
|
||||
"downloaded": "Téléchargé",
|
||||
"downloadedTooltip": "Déjà téléchargé, mais il n'est actuellement pas dans votre bibliothèque.",
|
||||
"alreadyInLibrary": "Déjà dans la bibliothèque",
|
||||
"autoOrganizedPath": "[Auto-organisé par modèle de chemin]",
|
||||
"errors": {
|
||||
@@ -1226,17 +1228,30 @@
|
||||
"days": "dans {count}j"
|
||||
},
|
||||
"badges": {
|
||||
"current": "Version actuelle",
|
||||
"current": "Version ouverte",
|
||||
"currentTooltip": "C'est la version à partir de laquelle cette fenêtre a été ouverte",
|
||||
"inLibrary": "Dans la bibliothèque",
|
||||
"inLibraryTooltip": "Cette version existe dans votre bibliothèque locale",
|
||||
"downloaded": "Téléchargé",
|
||||
"downloadedTooltip": "Cette version a déjà été téléchargée, mais n'est pas actuellement dans votre bibliothèque",
|
||||
"newer": "Version plus récente",
|
||||
"newerTooltip": "Cette version est plus récente que votre dernière version locale",
|
||||
"earlyAccess": "Accès anticipé",
|
||||
"ignored": "Ignorée"
|
||||
"earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai",
|
||||
"ignored": "Ignorée",
|
||||
"ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Télécharger",
|
||||
"downloadTooltip": "Télécharger cette version",
|
||||
"downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai",
|
||||
"delete": "Supprimer",
|
||||
"deleteTooltip": "Supprimer cette version locale",
|
||||
"ignore": "Ignorer",
|
||||
"unignore": "Ne plus ignorer",
|
||||
"ignoreTooltip": "Ignorer les notifications de mise à jour pour cette version",
|
||||
"unignoreTooltip": "Reprendre les notifications de mise à jour pour cette version",
|
||||
"viewVersionOnCivitai": "Voir la version sur Civitai",
|
||||
"earlyAccessTooltip": "Nécessite l'achat de l'accès anticipé",
|
||||
"resumeModelUpdates": "Reprendre les mises à jour pour ce modèle",
|
||||
"ignoreModelUpdates": "Ignorer les mises à jour pour ce modèle",
|
||||
|
||||
@@ -957,6 +957,8 @@
|
||||
"earlyAccess": "גישה מוקדמת",
|
||||
"earlyAccessTooltip": "נדרשת גישה מוקדמת",
|
||||
"inLibrary": "בספרייה",
|
||||
"downloaded": "הורד",
|
||||
"downloadedTooltip": "הורד בעבר, אך הוא אינו נמצא כרגע בספרייה שלך.",
|
||||
"alreadyInLibrary": "כבר בספרייה",
|
||||
"autoOrganizedPath": "[מאורגן אוטומטית לפי תבנית נתיב]",
|
||||
"errors": {
|
||||
@@ -1226,17 +1228,30 @@
|
||||
"days": "בעוד {count} ימים"
|
||||
},
|
||||
"badges": {
|
||||
"current": "גרסה נוכחית",
|
||||
"current": "גרסה שנפתחה",
|
||||
"currentTooltip": "זוהי הגרסה שממנה נפתח החלון הזה",
|
||||
"inLibrary": "בספרייה",
|
||||
"inLibraryTooltip": "גרסה זו קיימת בספרייה המקומית שלך",
|
||||
"downloaded": "הורד",
|
||||
"downloadedTooltip": "גרסה זו הורדה בעבר, אך אינה נמצאת כרגע בספרייה שלך",
|
||||
"newer": "גרסה חדשה יותר",
|
||||
"newerTooltip": "גרסה זו חדשה יותר מהגרסה המקומית האחרונה שלך",
|
||||
"earlyAccess": "גישה מוקדמת",
|
||||
"ignored": "התעלם"
|
||||
"earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai",
|
||||
"ignored": "התעלם",
|
||||
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו"
|
||||
},
|
||||
"actions": {
|
||||
"download": "הורדה",
|
||||
"downloadTooltip": "הורד את הגרסה הזו",
|
||||
"downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai",
|
||||
"delete": "מחיקה",
|
||||
"deleteTooltip": "מחק את הגרסה המקומית הזו",
|
||||
"ignore": "התעלם",
|
||||
"unignore": "בטל התעלמות",
|
||||
"ignoreTooltip": "התעלם מהתראות העדכון עבור גרסה זו",
|
||||
"unignoreTooltip": "חזור לקבל התראות עדכון עבור גרסה זו",
|
||||
"viewVersionOnCivitai": "הצג את הגרסה ב-Civitai",
|
||||
"earlyAccessTooltip": "נדרש רכישת גישה מוקדמת",
|
||||
"resumeModelUpdates": "המשך עדכונים עבור מודל זה",
|
||||
"ignoreModelUpdates": "התעלם מעדכונים עבור מודל זה",
|
||||
|
||||
@@ -957,6 +957,8 @@
|
||||
"earlyAccess": "アーリーアクセス",
|
||||
"earlyAccessTooltip": "アーリーアクセスが必要",
|
||||
"inLibrary": "ライブラリ内",
|
||||
"downloaded": "ダウンロード済み",
|
||||
"downloadedTooltip": "以前にダウンロード済みですが、現在はライブラリにありません。",
|
||||
"alreadyInLibrary": "既にライブラリ内",
|
||||
"autoOrganizedPath": "[パステンプレートによる自動整理]",
|
||||
"errors": {
|
||||
@@ -1226,17 +1228,30 @@
|
||||
"days": "{count}日後"
|
||||
},
|
||||
"badges": {
|
||||
"current": "現在のバージョン",
|
||||
"current": "開いたバージョン",
|
||||
"currentTooltip": "このモーダルを開くために選択したバージョンです",
|
||||
"inLibrary": "ライブラリにあります",
|
||||
"inLibraryTooltip": "このバージョンはローカルライブラリに存在します",
|
||||
"downloaded": "ダウンロード済み",
|
||||
"downloadedTooltip": "このバージョンは以前ダウンロードされましたが、現在はライブラリにありません",
|
||||
"newer": "新しいバージョン",
|
||||
"newerTooltip": "このバージョンはローカルの最新バージョンより新しいです",
|
||||
"earlyAccess": "早期アクセス",
|
||||
"ignored": "無視中"
|
||||
"earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です",
|
||||
"ignored": "無視中",
|
||||
"ignoredTooltip": "このバージョンの更新通知は無効です"
|
||||
},
|
||||
"actions": {
|
||||
"download": "ダウンロード",
|
||||
"downloadTooltip": "このバージョンをダウンロード",
|
||||
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
|
||||
"delete": "削除",
|
||||
"deleteTooltip": "このローカルバージョンを削除",
|
||||
"ignore": "無視",
|
||||
"unignore": "無視を解除",
|
||||
"ignoreTooltip": "このバージョンの更新通知を無視",
|
||||
"unignoreTooltip": "このバージョンの更新通知を再開",
|
||||
"viewVersionOnCivitai": "Civitai でバージョンを表示",
|
||||
"earlyAccessTooltip": "早期アクセス購入が必要",
|
||||
"resumeModelUpdates": "このモデルの更新を再開",
|
||||
"ignoreModelUpdates": "このモデルの更新を無視",
|
||||
|
||||
@@ -957,6 +957,8 @@
|
||||
"earlyAccess": "얼리 액세스",
|
||||
"earlyAccessTooltip": "얼리 액세스 필요",
|
||||
"inLibrary": "라이브러리에 있음",
|
||||
"downloaded": "다운로드됨",
|
||||
"downloadedTooltip": "이전에 다운로드했지만 현재 라이브러리에 없습니다.",
|
||||
"alreadyInLibrary": "이미 라이브러리에 있음",
|
||||
"autoOrganizedPath": "[경로 템플릿으로 자동 정리됨]",
|
||||
"errors": {
|
||||
@@ -1226,17 +1228,30 @@
|
||||
"days": "{count}일 후"
|
||||
},
|
||||
"badges": {
|
||||
"current": "현재 버전",
|
||||
"current": "열린 버전",
|
||||
"currentTooltip": "이 모달을 열 때 사용한 버전입니다",
|
||||
"inLibrary": "라이브러리에 있음",
|
||||
"inLibraryTooltip": "이 버전은 로컬 라이브러리에 있습니다",
|
||||
"downloaded": "다운로드됨",
|
||||
"downloadedTooltip": "이 버전은 이전에 다운로드되었지만 현재는 라이브러리에 없습니다",
|
||||
"newer": "최신 버전",
|
||||
"newerTooltip": "이 버전은 로컬의 최신 버전보다 더 새롭습니다",
|
||||
"earlyAccess": "얼리 액세스",
|
||||
"ignored": "무시됨"
|
||||
"earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다",
|
||||
"ignored": "무시됨",
|
||||
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다"
|
||||
},
|
||||
"actions": {
|
||||
"download": "다운로드",
|
||||
"downloadTooltip": "이 버전 다운로드",
|
||||
"downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드",
|
||||
"delete": "삭제",
|
||||
"deleteTooltip": "이 로컬 버전 삭제",
|
||||
"ignore": "무시",
|
||||
"unignore": "무시 해제",
|
||||
"ignoreTooltip": "이 버전의 업데이트 알림 무시",
|
||||
"unignoreTooltip": "이 버전의 업데이트 알림 다시 받기",
|
||||
"viewVersionOnCivitai": "Civitai에서 버전 보기",
|
||||
"earlyAccessTooltip": "얼리 액세스 구매 필요",
|
||||
"resumeModelUpdates": "이 모델 업데이트 재개",
|
||||
"ignoreModelUpdates": "이 모델 업데이트 무시",
|
||||
|
||||
@@ -957,6 +957,8 @@
|
||||
"earlyAccess": "Ранний доступ",
|
||||
"earlyAccessTooltip": "Требуется ранний доступ",
|
||||
"inLibrary": "В библиотеке",
|
||||
"downloaded": "Загружено",
|
||||
"downloadedTooltip": "Ранее загружено, но сейчас этого нет в вашей библиотеке.",
|
||||
"alreadyInLibrary": "Уже в библиотеке",
|
||||
"autoOrganizedPath": "[Автоматически организовано по шаблону пути]",
|
||||
"errors": {
|
||||
@@ -1226,17 +1228,30 @@
|
||||
"days": "через {count}д"
|
||||
},
|
||||
"badges": {
|
||||
"current": "Текущая версия",
|
||||
"current": "Открытая версия",
|
||||
"currentTooltip": "Это версия, с которой было открыто это окно",
|
||||
"inLibrary": "В библиотеке",
|
||||
"inLibraryTooltip": "Эта версия есть в вашей локальной библиотеке",
|
||||
"downloaded": "Загружено",
|
||||
"downloadedTooltip": "Эта версия уже загружалась, но сейчас отсутствует в вашей библиотеке",
|
||||
"newer": "Более новая версия",
|
||||
"newerTooltip": "Эта версия новее вашей последней локальной версии",
|
||||
"earlyAccess": "Ранний доступ",
|
||||
"ignored": "Игнорируется"
|
||||
"earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai",
|
||||
"ignored": "Игнорируется",
|
||||
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Скачать",
|
||||
"downloadTooltip": "Скачать эту версию",
|
||||
"downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai",
|
||||
"delete": "Удалить",
|
||||
"deleteTooltip": "Удалить эту локальную версию",
|
||||
"ignore": "Игнорировать",
|
||||
"unignore": "Перестать игнорировать",
|
||||
"ignoreTooltip": "Игнорировать уведомления об обновлениях для этой версии",
|
||||
"unignoreTooltip": "Возобновить уведомления об обновлениях для этой версии",
|
||||
"viewVersionOnCivitai": "Посмотреть версию на Civitai",
|
||||
"earlyAccessTooltip": "Требуется покупка раннего доступа",
|
||||
"resumeModelUpdates": "Возобновить обновления для этой модели",
|
||||
"ignoreModelUpdates": "Игнорировать обновления для этой модели",
|
||||
|
||||
@@ -957,6 +957,8 @@
|
||||
"earlyAccess": "早期访问",
|
||||
"earlyAccessTooltip": "需要早期访问权限",
|
||||
"inLibrary": "已在库中",
|
||||
"downloaded": "已下载",
|
||||
"downloadedTooltip": "之前已下载,但当前不在你的库中。",
|
||||
"alreadyInLibrary": "已存在于库中",
|
||||
"autoOrganizedPath": "【已按路径模板自动整理】",
|
||||
"errors": {
|
||||
@@ -1226,17 +1228,30 @@
|
||||
"days": "{count}天后"
|
||||
},
|
||||
"badges": {
|
||||
"current": "当前版本",
|
||||
"current": "已打开版本",
|
||||
"currentTooltip": "这是你用来打开此弹窗的版本",
|
||||
"inLibrary": "已在库中",
|
||||
"inLibraryTooltip": "此版本已存在于你的本地库中",
|
||||
"downloaded": "已下载",
|
||||
"downloadedTooltip": "此版本之前下载过,但当前不在你的本地库中",
|
||||
"newer": "较新的版本",
|
||||
"newerTooltip": "此版本比你本地的最新版本更新",
|
||||
"earlyAccess": "抢先体验",
|
||||
"ignored": "已忽略"
|
||||
"earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限",
|
||||
"ignored": "已忽略",
|
||||
"ignoredTooltip": "此版本已关闭更新通知"
|
||||
},
|
||||
"actions": {
|
||||
"download": "下载",
|
||||
"downloadTooltip": "下载此版本",
|
||||
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
|
||||
"delete": "删除",
|
||||
"deleteTooltip": "删除此本地版本",
|
||||
"ignore": "忽略",
|
||||
"unignore": "取消忽略",
|
||||
"ignoreTooltip": "忽略此版本的更新通知",
|
||||
"unignoreTooltip": "恢复此版本的更新通知",
|
||||
"viewVersionOnCivitai": "在 Civitai 上查看版本",
|
||||
"earlyAccessTooltip": "需要购买抢先体验",
|
||||
"resumeModelUpdates": "继续跟踪该模型的更新",
|
||||
"ignoreModelUpdates": "忽略该模型的更新",
|
||||
|
||||
@@ -957,6 +957,8 @@
|
||||
"earlyAccess": "早期存取",
|
||||
"earlyAccessTooltip": "需要早期存取",
|
||||
"inLibrary": "已在庫存",
|
||||
"downloaded": "已下載",
|
||||
"downloadedTooltip": "先前已下載,但目前不在你的庫中。",
|
||||
"alreadyInLibrary": "已在庫存",
|
||||
"autoOrganizedPath": "[依路徑範本自動整理]",
|
||||
"errors": {
|
||||
@@ -1226,17 +1228,30 @@
|
||||
"days": "{count}天後"
|
||||
},
|
||||
"badges": {
|
||||
"current": "目前版本",
|
||||
"current": "已開啟版本",
|
||||
"currentTooltip": "這是你用來開啟此彈窗的版本",
|
||||
"inLibrary": "已在庫中",
|
||||
"inLibraryTooltip": "此版本已存在於你的本地庫中",
|
||||
"downloaded": "已下載",
|
||||
"downloadedTooltip": "此版本之前下載過,但目前不在你的本地庫中",
|
||||
"newer": "較新版本",
|
||||
"newerTooltip": "此版本比你本地的最新版本更新",
|
||||
"earlyAccess": "搶先體驗",
|
||||
"ignored": "已忽略"
|
||||
"earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限",
|
||||
"ignored": "已忽略",
|
||||
"ignoredTooltip": "此版本已關閉更新通知"
|
||||
},
|
||||
"actions": {
|
||||
"download": "下載",
|
||||
"downloadTooltip": "下載此版本",
|
||||
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
|
||||
"delete": "刪除",
|
||||
"deleteTooltip": "刪除此本地版本",
|
||||
"ignore": "忽略",
|
||||
"unignore": "取消忽略",
|
||||
"ignoreTooltip": "忽略此版本的更新通知",
|
||||
"unignoreTooltip": "恢復此版本的更新通知",
|
||||
"viewVersionOnCivitai": "在 Civitai 上查看版本",
|
||||
"earlyAccessTooltip": "需要購買搶先體驗",
|
||||
"resumeModelUpdates": "恢復追蹤此模型的更新",
|
||||
"ignoreModelUpdates": "忽略此模型的更新",
|
||||
|
||||
@@ -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),)
|
||||
|
||||
@@ -13,4 +13,5 @@ GEN_PARAM_KEYS = [
|
||||
'seed',
|
||||
'size',
|
||||
'clip_skip',
|
||||
'denoising_strength',
|
||||
]
|
||||
|
||||
@@ -1,27 +1,33 @@
|
||||
from typing import Any, Dict, Optional
|
||||
import logging
|
||||
|
||||
from .constants import GEN_PARAM_KEYS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GenParamsMerger:
|
||||
"""Utility to merge generation parameters from multiple sources with priority."""
|
||||
|
||||
ALLOWED_KEYS = set(GEN_PARAM_KEYS)
|
||||
|
||||
BLACKLISTED_KEYS = {
|
||||
"id", "url", "userId", "username", "createdAt", "updatedAt", "hash", "meta",
|
||||
"draft", "extra", "width", "height", "process", "quantity", "workflow",
|
||||
"baseModel", "resources", "disablePoi", "aspectRatio", "Created Date",
|
||||
"experimental", "civitaiResources", "civitai_resources", "Civitai resources",
|
||||
"modelVersionId", "modelId", "hashes", "Model", "Model hash", "checkpoint_hash",
|
||||
"checkpoint", "checksum", "model_checksum"
|
||||
"checkpoint", "checksum", "model_checksum", "raw_metadata",
|
||||
}
|
||||
|
||||
|
||||
NORMALIZATION_MAPPING = {
|
||||
# Civitai specific
|
||||
"cfg": "cfg_scale",
|
||||
"cfgScale": "cfg_scale",
|
||||
"clipSkip": "clip_skip",
|
||||
"negativePrompt": "negative_prompt",
|
||||
# Case variations
|
||||
"Sampler": "sampler",
|
||||
"sampler_name": "sampler",
|
||||
"scheduler": "sampler",
|
||||
"Steps": "steps",
|
||||
"Seed": "seed",
|
||||
"Size": "size",
|
||||
@@ -36,63 +42,40 @@ class GenParamsMerger:
|
||||
def merge(
|
||||
request_params: Optional[Dict[str, Any]] = None,
|
||||
civitai_meta: Optional[Dict[str, Any]] = None,
|
||||
embedded_metadata: Optional[Dict[str, Any]] = None
|
||||
embedded_metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Merge generation parameters from three sources.
|
||||
|
||||
Priority: request_params > civitai_meta > embedded_metadata
|
||||
|
||||
Args:
|
||||
request_params: Params provided directly in the import request
|
||||
civitai_meta: Params from Civitai Image API 'meta' field
|
||||
embedded_metadata: Params extracted from image EXIF/embedded metadata
|
||||
|
||||
Returns:
|
||||
Merged parameters dictionary
|
||||
"""
|
||||
result = {}
|
||||
|
||||
# 1. Start with embedded metadata (lowest priority)
|
||||
Priority: request_params > civitai_meta > embedded_metadata
|
||||
"""
|
||||
result: Dict[str, Any] = {}
|
||||
|
||||
if embedded_metadata:
|
||||
# If it's a full recipe metadata, we use its gen_params
|
||||
if "gen_params" in embedded_metadata and isinstance(embedded_metadata["gen_params"], dict):
|
||||
if "gen_params" in embedded_metadata and isinstance(
|
||||
embedded_metadata["gen_params"], dict
|
||||
):
|
||||
GenParamsMerger._update_normalized(result, embedded_metadata["gen_params"])
|
||||
else:
|
||||
# Otherwise assume the dict itself contains gen_params
|
||||
GenParamsMerger._update_normalized(result, embedded_metadata)
|
||||
|
||||
# 2. Layer Civitai meta (medium priority)
|
||||
if civitai_meta:
|
||||
GenParamsMerger._update_normalized(result, civitai_meta)
|
||||
|
||||
# 3. Layer request params (highest priority)
|
||||
if request_params:
|
||||
GenParamsMerger._update_normalized(result, request_params)
|
||||
|
||||
# Filter out blacklisted keys and also the original camelCase keys if they were normalized
|
||||
final_result = {}
|
||||
for k, v in result.items():
|
||||
if k in GenParamsMerger.BLACKLISTED_KEYS:
|
||||
continue
|
||||
if k in GenParamsMerger.NORMALIZATION_MAPPING:
|
||||
continue
|
||||
final_result[k] = v
|
||||
|
||||
return final_result
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _update_normalized(target: Dict[str, Any], source: Dict[str, Any]) -> None:
|
||||
"""Update target dict with normalized keys from source."""
|
||||
for k, v in source.items():
|
||||
normalized_key = GenParamsMerger.NORMALIZATION_MAPPING.get(k, k)
|
||||
target[normalized_key] = v
|
||||
# Also keep the original key for now if it's not the same,
|
||||
# so we can filter at the end or avoid losing it if it wasn't supposed to be renamed?
|
||||
# Actually, if we rename it, we should probably NOT keep both in 'target'
|
||||
# because we want to filter them out at the end anyway.
|
||||
if normalized_key != k:
|
||||
# If we are overwriting an existing snake_case key with a camelCase one's value,
|
||||
# that's fine because of the priority order of calls to _update_normalized.
|
||||
pass
|
||||
target[k] = v
|
||||
"""Update target dict with normalized, persistence-safe keys from source."""
|
||||
for key, value in source.items():
|
||||
if key in GenParamsMerger.BLACKLISTED_KEYS:
|
||||
continue
|
||||
|
||||
normalized_key = GenParamsMerger.NORMALIZATION_MAPPING.get(key, key)
|
||||
if normalized_key not in GenParamsMerger.ALLOWED_KEYS:
|
||||
continue
|
||||
|
||||
target[normalized_key] = value
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,6 +19,7 @@ from ...services.download_coordinator import DownloadCoordinator
|
||||
from ...services.metadata_sync_service import MetadataSyncService
|
||||
from ...services.model_file_service import ModelMoveService
|
||||
from ...services.preview_asset_service import PreviewAssetService
|
||||
from ...services.service_registry import ServiceRegistry
|
||||
from ...services.settings_manager import SettingsManager, get_settings_manager
|
||||
from ...services.tag_update_service import TagUpdateService
|
||||
from ...services.use_cases import (
|
||||
@@ -64,7 +65,6 @@ class ModelPageView:
|
||||
self._settings = settings_service
|
||||
self._server_i18n = server_i18n
|
||||
self._logger = logger
|
||||
self._app_version = self._get_app_version()
|
||||
|
||||
def _load_supporters(self) -> dict:
|
||||
"""Load supporters data from JSON file."""
|
||||
@@ -155,7 +155,7 @@ class ModelPageView:
|
||||
"request": request,
|
||||
"folders": [],
|
||||
"t": self._server_i18n.get_translation,
|
||||
"version": self._app_version,
|
||||
"version": self._get_app_version(),
|
||||
}
|
||||
|
||||
if not is_initializing:
|
||||
@@ -1532,6 +1532,20 @@ class ModelCivitaiHandler:
|
||||
|
||||
cache = await self._service.scanner.get_cached_data()
|
||||
version_index = cache.version_index
|
||||
downloaded_version_ids: set[int] = set()
|
||||
try:
|
||||
history_service = await ServiceRegistry.get_downloaded_version_history_service()
|
||||
downloaded_version_ids = set(
|
||||
await history_service.get_downloaded_version_ids(
|
||||
self._service.model_type,
|
||||
model_id,
|
||||
)
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
self._logger.debug(
|
||||
"Failed to load download history for CivitAI versions: %s",
|
||||
exc,
|
||||
)
|
||||
|
||||
for version in versions:
|
||||
version_id = None
|
||||
@@ -1548,6 +1562,9 @@ class ModelCivitaiHandler:
|
||||
else None
|
||||
)
|
||||
version["existsLocally"] = cache_entry is not None
|
||||
version["hasBeenDownloaded"] = (
|
||||
version_id in downloaded_version_ids if version_id is not None else False
|
||||
)
|
||||
if cache_entry and isinstance(cache_entry, Mapping):
|
||||
local_path = cache_entry.get("file_path")
|
||||
if local_path:
|
||||
@@ -2266,7 +2283,7 @@ class ModelUpdateHandler:
|
||||
self,
|
||||
record,
|
||||
*,
|
||||
version_context: Optional[Dict[int, Dict[str, Optional[str]]]] = None,
|
||||
version_context: Optional[Dict[int, Dict[str, Any]]] = None,
|
||||
) -> Dict:
|
||||
context = version_context or {}
|
||||
# Check user setting for hiding early access versions
|
||||
@@ -2295,7 +2312,7 @@ class ModelUpdateHandler:
|
||||
|
||||
@staticmethod
|
||||
def _serialize_version(
|
||||
version, context: Optional[Dict[str, Optional[str]]]
|
||||
version, context: Optional[Dict[str, Any]]
|
||||
) -> Dict:
|
||||
context = context or {}
|
||||
preview_override = context.get("preview_override")
|
||||
@@ -2329,6 +2346,7 @@ class ModelUpdateHandler:
|
||||
"sizeBytes": version.size_bytes,
|
||||
"previewUrl": preview_url,
|
||||
"isInLibrary": version.is_in_library,
|
||||
"hasBeenDownloaded": bool(context.get("has_been_downloaded", False)),
|
||||
"shouldIgnore": version.should_ignore,
|
||||
"earlyAccessEndsAt": version.early_access_ends_at,
|
||||
"isEarlyAccess": is_early_access,
|
||||
@@ -2338,8 +2356,31 @@ class ModelUpdateHandler:
|
||||
|
||||
async def _build_version_context(
|
||||
self, record
|
||||
) -> Dict[int, Dict[str, Optional[str]]]:
|
||||
context: Dict[int, Dict[str, Optional[str]]] = {}
|
||||
) -> Dict[int, Dict[str, Any]]:
|
||||
context: Dict[int, Dict[str, Any]] = {}
|
||||
downloaded_version_ids: set[int] = set()
|
||||
try:
|
||||
history_service = await ServiceRegistry.get_downloaded_version_history_service()
|
||||
downloaded_version_ids = set(
|
||||
await history_service.get_downloaded_version_ids(
|
||||
record.model_type,
|
||||
record.model_id,
|
||||
)
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
self._logger.debug(
|
||||
"Failed to load download history while building version context: %s",
|
||||
exc,
|
||||
)
|
||||
|
||||
for version in record.versions:
|
||||
context[version.version_id] = {
|
||||
"file_path": None,
|
||||
"file_name": None,
|
||||
"preview_override": None,
|
||||
"has_been_downloaded": version.version_id in downloaded_version_ids,
|
||||
}
|
||||
|
||||
try:
|
||||
cache = await self._service.scanner.get_cached_data()
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
@@ -2358,16 +2399,21 @@ class ModelUpdateHandler:
|
||||
cache_entry = version_index.get(version.version_id)
|
||||
if isinstance(cache_entry, Mapping):
|
||||
preview = cache_entry.get("preview_url")
|
||||
context_entry: Dict[str, Optional[str]] = {
|
||||
"file_path": cache_entry.get("file_path"),
|
||||
"file_name": cache_entry.get("file_name"),
|
||||
"preview_override": None,
|
||||
}
|
||||
context_entry = context.setdefault(
|
||||
version.version_id,
|
||||
{
|
||||
"file_path": None,
|
||||
"file_name": None,
|
||||
"preview_override": None,
|
||||
"has_been_downloaded": version.version_id in downloaded_version_ids,
|
||||
},
|
||||
)
|
||||
context_entry["file_path"] = cache_entry.get("file_path")
|
||||
context_entry["file_name"] = cache_entry.get("file_name")
|
||||
if isinstance(preview, str) and preview:
|
||||
context_entry["preview_override"] = config.get_preview_static_url(
|
||||
preview
|
||||
)
|
||||
context[version.version_id] = context_entry
|
||||
return context
|
||||
|
||||
|
||||
|
||||
@@ -756,6 +756,14 @@ class RecipeManagementHandler:
|
||||
)
|
||||
gen_params_request = self._parse_gen_params(params.get("gen_params"))
|
||||
|
||||
self._logger.info(
|
||||
"Remote recipe import received: url=%s, request_gen_params_keys=%s, lora_count=%d, checkpoint_keys=%s",
|
||||
image_url,
|
||||
sorted(gen_params_request.keys()) if gen_params_request else [],
|
||||
len(lora_entries),
|
||||
sorted(checkpoint_entry.keys()) if isinstance(checkpoint_entry, dict) else [],
|
||||
)
|
||||
|
||||
# 2. Initial Metadata Construction
|
||||
metadata: Dict[str, Any] = {
|
||||
"base_model": params.get("base_model", "") or "",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -952,6 +952,30 @@ class RecipeScanner:
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to update FTS index for recipe: %s", exc)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_recipe_gen_params(recipe_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Return a recipe copy with normalized generation parameter aliases added."""
|
||||
|
||||
normalized_recipe = dict(recipe_data)
|
||||
gen_params = recipe_data.get("gen_params")
|
||||
if not isinstance(gen_params, dict):
|
||||
return normalized_recipe
|
||||
|
||||
normalized_gen_params = dict(gen_params)
|
||||
for key, value in gen_params.items():
|
||||
if value in (None, ""):
|
||||
continue
|
||||
|
||||
normalized_key = GenParamsMerger.NORMALIZATION_MAPPING.get(key, key)
|
||||
if normalized_key not in GenParamsMerger.ALLOWED_KEYS:
|
||||
continue
|
||||
|
||||
if normalized_gen_params.get(normalized_key) in (None, ""):
|
||||
normalized_gen_params[normalized_key] = value
|
||||
|
||||
normalized_recipe["gen_params"] = normalized_gen_params
|
||||
return normalized_recipe
|
||||
|
||||
async def _enrich_cache_metadata(self) -> None:
|
||||
"""Perform remote metadata enrichment after the initial scan."""
|
||||
|
||||
@@ -1345,6 +1369,7 @@ class RecipeScanner:
|
||||
# Ensure gen_params exists
|
||||
if "gen_params" not in recipe_data:
|
||||
recipe_data["gen_params"] = {}
|
||||
recipe_data = self._normalize_recipe_gen_params(recipe_data)
|
||||
|
||||
# Update lora information with local paths and availability
|
||||
lora_metadata_updated = await self._update_lora_information(recipe_data)
|
||||
@@ -2055,7 +2080,10 @@ class RecipeScanner:
|
||||
end_idx = min(start_idx + page_size, total_items)
|
||||
|
||||
# Get paginated items
|
||||
paginated_items = filtered_data[start_idx:end_idx]
|
||||
paginated_items = [
|
||||
self._normalize_recipe_gen_params(item)
|
||||
for item in filtered_data[start_idx:end_idx]
|
||||
]
|
||||
|
||||
# Add inLibrary information and URLs for each recipe
|
||||
for item in paginated_items:
|
||||
@@ -2114,8 +2142,18 @@ class RecipeScanner:
|
||||
if not recipe:
|
||||
return None
|
||||
|
||||
# Prefer the on-disk recipe JSON for fields that are not persisted in the
|
||||
# SQLite cache yet, such as source_path.
|
||||
merged_recipe = self._normalize_recipe_gen_params({**recipe})
|
||||
recipe_json = await self._load_recipe_json(recipe_id)
|
||||
if recipe_json:
|
||||
for field in ("source_path", "checkpoint", "loras", "gen_params"):
|
||||
if field not in recipe_json:
|
||||
merged_recipe.pop(field, None)
|
||||
merged_recipe.update(recipe_json)
|
||||
|
||||
# Format the recipe with all needed information
|
||||
formatted_recipe = {**recipe} # Copy all fields
|
||||
formatted_recipe = {**merged_recipe}
|
||||
|
||||
# Format file path to URL
|
||||
if "file_path" in formatted_recipe:
|
||||
@@ -2149,6 +2187,30 @@ class RecipeScanner:
|
||||
|
||||
return formatted_recipe
|
||||
|
||||
async def _load_recipe_json(self, recipe_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Load the raw recipe JSON payload for a recipe ID if it exists."""
|
||||
|
||||
recipe_json_path = await self.get_recipe_json_path(recipe_id)
|
||||
if not recipe_json_path or not os.path.exists(recipe_json_path):
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(recipe_json_path, "r", encoding="utf-8") as f:
|
||||
recipe_data = json.load(f)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Failed to load recipe JSON for %s from %s: %s",
|
||||
recipe_id,
|
||||
recipe_json_path,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
if not isinstance(recipe_data, dict):
|
||||
return None
|
||||
|
||||
return self._normalize_recipe_gen_params(recipe_data)
|
||||
|
||||
def _format_file_url(self, file_path: str) -> str:
|
||||
"""Format file path as URL for serving in web UI"""
|
||||
if not file_path:
|
||||
|
||||
@@ -12,6 +12,7 @@ from dataclasses import dataclass
|
||||
from typing import Any, Dict, Iterable, Optional
|
||||
|
||||
from ...config import config
|
||||
from ...recipes.constants import GEN_PARAM_KEYS
|
||||
from ...utils.utils import calculate_recipe_fingerprint
|
||||
from .errors import RecipeNotFoundError, RecipeValidationError
|
||||
|
||||
@@ -90,23 +91,7 @@ class RecipePersistenceService:
|
||||
current_time = time.time()
|
||||
loras_data = [self._normalise_lora_entry(lora) for lora in (metadata.get("loras") or [])]
|
||||
checkpoint_entry = self._sanitize_checkpoint_entry(self._extract_checkpoint_entry(metadata))
|
||||
|
||||
gen_params = metadata.get("gen_params") or {}
|
||||
if not gen_params and "raw_metadata" in metadata:
|
||||
raw_metadata = metadata.get("raw_metadata", {})
|
||||
gen_params = {
|
||||
"prompt": raw_metadata.get("prompt", ""),
|
||||
"negative_prompt": raw_metadata.get("negative_prompt", ""),
|
||||
"steps": raw_metadata.get("steps", ""),
|
||||
"sampler": raw_metadata.get("sampler", ""),
|
||||
"cfg_scale": raw_metadata.get("cfg_scale", ""),
|
||||
"seed": raw_metadata.get("seed", ""),
|
||||
"size": raw_metadata.get("size", ""),
|
||||
"clip_skip": raw_metadata.get("clip_skip", ""),
|
||||
}
|
||||
|
||||
# Drop checkpoint duplication from generation parameters to store it only at top level
|
||||
gen_params.pop("checkpoint", None)
|
||||
gen_params = self._sanitize_gen_params_for_storage(metadata)
|
||||
|
||||
fingerprint = calculate_recipe_fingerprint(loras_data)
|
||||
recipe_data: Dict[str, Any] = {
|
||||
@@ -133,6 +118,7 @@ class RecipePersistenceService:
|
||||
json_filename = f"{recipe_id}.recipe.json"
|
||||
json_path = os.path.join(recipes_dir, json_filename)
|
||||
json_path = os.path.normpath(json_path)
|
||||
|
||||
with open(json_path, "w", encoding="utf-8") as file_obj:
|
||||
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
|
||||
|
||||
@@ -152,6 +138,30 @@ class RecipePersistenceService:
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_gen_params_for_storage(metadata: dict[str, Any]) -> dict[str, Any]:
|
||||
gen_params = metadata.get("gen_params")
|
||||
if isinstance(gen_params, dict) and gen_params:
|
||||
source = gen_params
|
||||
else:
|
||||
source = metadata.get("raw_metadata")
|
||||
|
||||
if not isinstance(source, dict):
|
||||
return {}
|
||||
|
||||
allowed_keys = set(GEN_PARAM_KEYS)
|
||||
sanitized: dict[str, Any] = {}
|
||||
for key in allowed_keys:
|
||||
if key not in source:
|
||||
continue
|
||||
value = source.get(key)
|
||||
if value in (None, ""):
|
||||
continue
|
||||
sanitized[key] = value
|
||||
|
||||
sanitized.pop("checkpoint", None)
|
||||
return sanitized
|
||||
|
||||
async def delete_recipe(self, *, recipe_scanner, recipe_id: str) -> PersistenceResult:
|
||||
"""Delete an existing recipe."""
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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.3"
|
||||
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
|
||||
|
||||
@@ -163,6 +163,18 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.model-version-row.is-clickable .version-actions,
|
||||
.model-version-row.is-clickable .version-badges,
|
||||
.model-version-row.is-clickable .version-action,
|
||||
.model-version-row.is-clickable .version-civitai-link {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.model-version-row.is-clickable .version-action,
|
||||
.model-version-row.is-clickable .version-civitai-link {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.model-version-row.is-current {
|
||||
border-color: var(--lora-accent);
|
||||
box-shadow: 0 0 0 1px color-mix(in oklch, var(--lora-accent) 65%, transparent),
|
||||
@@ -217,6 +229,7 @@
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.versions-tab-version-name {
|
||||
@@ -226,6 +239,27 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.version-civitai-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
flex: 0 0 auto;
|
||||
transition: color 0.2s ease, background-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.version-civitai-link:hover,
|
||||
.version-civitai-link:focus-visible {
|
||||
color: var(--lora-accent);
|
||||
background: color-mix(in oklch, var(--lora-accent) 12%, transparent);
|
||||
transform: translateY(-1px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.version-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
}
|
||||
|
||||
.doctor-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
@@ -16,7 +19,7 @@
|
||||
background: var(--lora-error);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,27 @@
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.downloaded-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: color-mix(in oklch, var(--badge-update-bg, #4a90e2) 22%, transparent);
|
||||
color: var(--badge-update-bg, #4a90e2);
|
||||
border: 1px solid color-mix(in oklch, var(--badge-update-bg, #4a90e2) 50%, transparent);
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.8em;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
transform: translateZ(0);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.downloaded-badge i {
|
||||
margin-right: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Early Access Badge */
|
||||
.early-access-badge {
|
||||
display: inline-flex;
|
||||
@@ -108,4 +129,4 @@
|
||||
color: var(--lora-error);
|
||||
font-size: 0.9em;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { RecipeCard } from '../components/RecipeCard.js';
|
||||
import { state, getCurrentPageState } from '../state/index.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { captureScrollPosition, restoreScrollPosition } from '../utils/infiniteScroll.js';
|
||||
|
||||
const RECIPE_ENDPOINTS = {
|
||||
list: '/api/lm/recipes',
|
||||
@@ -31,6 +32,20 @@ export function extractRecipeId(filePath) {
|
||||
return dotIndex > 0 ? basename.substring(0, dotIndex) : basename;
|
||||
}
|
||||
|
||||
export async function fetchRecipeDetails(recipeId) {
|
||||
if (!recipeId) {
|
||||
throw new Error('Unable to determine recipe ID');
|
||||
}
|
||||
|
||||
const encodedRecipeId = encodeURIComponent(recipeId);
|
||||
const response = await fetch(`${RECIPE_ENDPOINTS.detail}/${encodedRecipeId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load recipe: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch recipes with pagination for virtual scrolling
|
||||
* @param {number} page - Page number to fetch
|
||||
@@ -61,7 +76,9 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
|
||||
// If we have a specific recipe ID to load
|
||||
if (pageState.customFilter?.active && pageState.customFilter?.recipeId) {
|
||||
// Special case: load specific recipe
|
||||
const response = await fetch(`${RECIPE_ENDPOINTS.detail}/${pageState.customFilter.recipeId}`);
|
||||
const response = await fetch(
|
||||
`${RECIPE_ENDPOINTS.detail}/${encodeURIComponent(pageState.customFilter.recipeId)}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load recipe: ${response.statusText}`);
|
||||
@@ -166,10 +183,12 @@ export async function resetAndReloadWithVirtualScroll(options = {}) {
|
||||
const {
|
||||
modelType = 'lora',
|
||||
updateFolders = false,
|
||||
fetchPageFunction
|
||||
fetchPageFunction,
|
||||
preserveScroll = false
|
||||
} = options;
|
||||
|
||||
const pageState = getCurrentPageState();
|
||||
const scrollSnapshot = preserveScroll ? captureScrollPosition() : null;
|
||||
|
||||
try {
|
||||
pageState.isLoading = true;
|
||||
@@ -191,6 +210,10 @@ export async function resetAndReloadWithVirtualScroll(options = {}) {
|
||||
pageState.hasMore = result.hasMore;
|
||||
pageState.currentPage = 2; // Next page will be 2
|
||||
|
||||
if (scrollSnapshot) {
|
||||
await restoreScrollPosition(scrollSnapshot);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Error reloading ${modelType}s:`, error);
|
||||
@@ -211,10 +234,12 @@ export async function loadMoreWithVirtualScroll(options = {}) {
|
||||
modelType = 'lora',
|
||||
resetPage = false,
|
||||
updateFolders = false,
|
||||
fetchPageFunction
|
||||
fetchPageFunction,
|
||||
preserveScroll = false
|
||||
} = options;
|
||||
|
||||
const pageState = getCurrentPageState();
|
||||
const scrollSnapshot = preserveScroll ? captureScrollPosition() : null;
|
||||
|
||||
try {
|
||||
// Start loading state
|
||||
@@ -239,6 +264,10 @@ export async function loadMoreWithVirtualScroll(options = {}) {
|
||||
pageState.hasMore = result.hasMore;
|
||||
pageState.currentPage = 2; // Next page to load would be 2
|
||||
|
||||
if (scrollSnapshot) {
|
||||
await restoreScrollPosition(scrollSnapshot);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Error loading ${modelType}s:`, error);
|
||||
@@ -254,11 +283,12 @@ export async function loadMoreWithVirtualScroll(options = {}) {
|
||||
* @param {boolean} updateFolders - Whether to update folder tags
|
||||
* @returns {Promise<Object>} The fetch result
|
||||
*/
|
||||
export async function resetAndReload(updateFolders = false) {
|
||||
export async function resetAndReload(updateFolders = false, options = {}) {
|
||||
return resetAndReloadWithVirtualScroll({
|
||||
modelType: 'recipe',
|
||||
updateFolders,
|
||||
fetchPageFunction: fetchRecipesPage
|
||||
fetchPageFunction: fetchRecipesPage,
|
||||
preserveScroll: options.preserveScroll === true
|
||||
});
|
||||
}
|
||||
|
||||
@@ -270,7 +300,7 @@ export async function syncChanges() {
|
||||
state.loadingManager.showSimpleLoading('Syncing changes...');
|
||||
|
||||
// Simply reload the recipes without rebuilding cache
|
||||
await resetAndReload();
|
||||
await resetAndReload(false, { preserveScroll: true });
|
||||
|
||||
showToast('toast.recipes.syncComplete', {}, 'success');
|
||||
} catch (error) {
|
||||
@@ -298,7 +328,7 @@ export async function refreshRecipes() {
|
||||
}
|
||||
|
||||
// After successful cache rebuild, reload the recipes
|
||||
await resetAndReload();
|
||||
await resetAndReload(false, { preserveScroll: true });
|
||||
|
||||
showToast('toast.recipes.refreshComplete', {}, 'success');
|
||||
} catch (error) {
|
||||
@@ -349,9 +379,10 @@ export function createRecipeCard(recipe) {
|
||||
* @param {Object} updates - The metadata updates to apply
|
||||
* @returns {Promise<Object>} The updated recipe data
|
||||
*/
|
||||
export async function updateRecipeMetadata(filePath, updates) {
|
||||
export async function updateRecipeMetadata(filePath, updates, options = {}) {
|
||||
try {
|
||||
state.loadingManager.showSimpleLoading('Saving metadata...');
|
||||
const listFilePath = options.listFilePath || filePath;
|
||||
|
||||
// Extract recipeId from filePath (basename without extension)
|
||||
const recipeId = extractRecipeId(filePath);
|
||||
@@ -359,7 +390,7 @@ export async function updateRecipeMetadata(filePath, updates) {
|
||||
throw new Error('Unable to determine recipe ID');
|
||||
}
|
||||
|
||||
const response = await fetch(`${RECIPE_ENDPOINTS.update}/${recipeId}/update`, {
|
||||
const response = await fetch(`${RECIPE_ENDPOINTS.update}/${encodeURIComponent(recipeId)}/update`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -374,7 +405,7 @@ export async function updateRecipeMetadata(filePath, updates) {
|
||||
throw new Error(data.error || 'Failed to update recipe');
|
||||
}
|
||||
|
||||
state.virtualScroller.updateSingleItem(filePath, updates);
|
||||
state.virtualScroller.updateSingleItem(listFilePath, updates);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
|
||||
@@ -23,7 +23,7 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
// Override resetAndReload for recipe context
|
||||
async resetAndReload() {
|
||||
const { resetAndReload } = await import('../../api/recipeApi.js');
|
||||
return resetAndReload();
|
||||
return resetAndReload(false, { preserveScroll: true });
|
||||
}
|
||||
|
||||
showMenu(x, y, card) {
|
||||
|
||||
@@ -3,16 +3,105 @@ import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js';
|
||||
import { updateRecipeMetadata } from '../api/recipeApi.js';
|
||||
import { fetchRecipeDetails, updateRecipeMetadata } from '../api/recipeApi.js';
|
||||
import { downloadManager } from '../managers/DownloadManager.js';
|
||||
import { MODEL_TYPES } from '../api/apiConfig.js';
|
||||
|
||||
const ALLOWED_GEN_PARAM_KEYS = new Set([
|
||||
'prompt',
|
||||
'negative_prompt',
|
||||
'steps',
|
||||
'sampler',
|
||||
'cfg_scale',
|
||||
'seed',
|
||||
'size',
|
||||
'clip_skip',
|
||||
'denoising_strength',
|
||||
]);
|
||||
|
||||
const GEN_PARAM_NORMALIZATION = {
|
||||
cfg: 'cfg_scale',
|
||||
cfgScale: 'cfg_scale',
|
||||
clipSkip: 'clip_skip',
|
||||
negativePrompt: 'negative_prompt',
|
||||
Sampler: 'sampler',
|
||||
sampler_name: 'sampler',
|
||||
scheduler: 'sampler',
|
||||
Steps: 'steps',
|
||||
Seed: 'seed',
|
||||
Size: 'size',
|
||||
Prompt: 'prompt',
|
||||
'Negative prompt': 'negative_prompt',
|
||||
'Cfg scale': 'cfg_scale',
|
||||
'Clip skip': 'clip_skip',
|
||||
'Denoising strength': 'denoising_strength',
|
||||
};
|
||||
|
||||
class RecipeModal {
|
||||
constructor() {
|
||||
this.promptEditorState = {};
|
||||
this.recipeHydrationRequestId = 0;
|
||||
this.resetLocalEditState();
|
||||
this.init();
|
||||
}
|
||||
|
||||
createLocalEditState() {
|
||||
return {
|
||||
title: { commitVersion: 0, isDirty: false },
|
||||
tags: { commitVersion: 0, isDirty: false },
|
||||
prompt: { commitVersion: 0, isDirty: false },
|
||||
negative_prompt: { commitVersion: 0, isDirty: false },
|
||||
source_path: { commitVersion: 0, isDirty: false },
|
||||
};
|
||||
}
|
||||
|
||||
resetLocalEditState() {
|
||||
this.localEditState = this.createLocalEditState();
|
||||
this.sourceUrlEditState = this.localEditState.source_path;
|
||||
}
|
||||
|
||||
getLocalEditState(field) {
|
||||
if (!this.localEditState[field]) {
|
||||
this.localEditState[field] = { commitVersion: 0, isDirty: false };
|
||||
}
|
||||
return this.localEditState[field];
|
||||
}
|
||||
|
||||
markFieldDirty(field) {
|
||||
this.getLocalEditState(field).isDirty = true;
|
||||
}
|
||||
|
||||
clearFieldDirty(field) {
|
||||
this.getLocalEditState(field).isDirty = false;
|
||||
}
|
||||
|
||||
commitField(field) {
|
||||
const fieldState = this.getLocalEditState(field);
|
||||
fieldState.isDirty = false;
|
||||
fieldState.commitVersion += 1;
|
||||
}
|
||||
|
||||
captureLocalEditVersions() {
|
||||
return Object.fromEntries(
|
||||
Object.entries(this.localEditState).map(([field, state]) => [
|
||||
field,
|
||||
state.commitVersion,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
shouldPreserveField(field, requestVersions) {
|
||||
const fieldState = this.getLocalEditState(field);
|
||||
const requestVersion = requestVersions?.[field] ?? fieldState.commitVersion;
|
||||
return fieldState.isDirty || fieldState.commitVersion !== requestVersion;
|
||||
}
|
||||
|
||||
hasFieldCommittedSinceRequest(field, requestVersions) {
|
||||
const fieldState = this.getLocalEditState(field);
|
||||
const requestVersion = requestVersions?.[field] ?? fieldState.commitVersion;
|
||||
return fieldState.commitVersion !== requestVersion;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupCopyButtons();
|
||||
this.setupPromptEditors();
|
||||
@@ -87,8 +176,10 @@ class RecipeModal {
|
||||
}
|
||||
|
||||
showRecipeDetails(recipe) {
|
||||
const hydratedRecipe = recipe || {};
|
||||
this.resetLocalEditState();
|
||||
// Store the full recipe for editing
|
||||
this.currentRecipe = recipe;
|
||||
this.currentRecipe = hydratedRecipe;
|
||||
this.resetPromptEditors();
|
||||
|
||||
// Set modal title with edit icon
|
||||
@@ -96,11 +187,11 @@ class RecipeModal {
|
||||
if (modalTitle) {
|
||||
modalTitle.innerHTML = `
|
||||
<div class="editable-content">
|
||||
<span class="content-text">${recipe.title || 'Recipe Details'}</span>
|
||||
<span class="content-text">${hydratedRecipe.title || 'Recipe Details'}</span>
|
||||
<button class="edit-icon" title="Edit recipe name"><i class="fas fa-pencil-alt"></i></button>
|
||||
</div>
|
||||
<div id="recipeTitleEditor" class="content-editor">
|
||||
<input type="text" class="title-input" value="${recipe.title || ''}">
|
||||
<input type="text" class="title-input" value="${hydratedRecipe.title || ''}">
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -122,8 +213,9 @@ class RecipeModal {
|
||||
}
|
||||
|
||||
// Store the recipe ID for copy syntax API call
|
||||
this.recipeId = recipe.id;
|
||||
this.filePath = recipe.file_path;
|
||||
this.recipeId = hydratedRecipe.id;
|
||||
this.filePath = hydratedRecipe.file_path;
|
||||
this.listFilePath = hydratedRecipe.file_path;
|
||||
|
||||
// Set recipe tags if they exist
|
||||
const tagsCompactElement = document.getElementById('recipeTagsCompact');
|
||||
@@ -143,11 +235,11 @@ class RecipeModal {
|
||||
|
||||
const tagsDisplay = tagsCompactElement.querySelector('.tags-display');
|
||||
|
||||
if (recipe.tags && recipe.tags.length > 0) {
|
||||
if (hydratedRecipe.tags && hydratedRecipe.tags.length > 0) {
|
||||
// Limit displayed tags to 5, show a "+X more" button if needed
|
||||
const maxVisibleTags = 5;
|
||||
const visibleTags = recipe.tags.slice(0, maxVisibleTags);
|
||||
const remainingTags = recipe.tags.length > maxVisibleTags ? recipe.tags.slice(maxVisibleTags) : [];
|
||||
const visibleTags = hydratedRecipe.tags.slice(0, maxVisibleTags);
|
||||
const remainingTags = hydratedRecipe.tags.length > maxVisibleTags ? hydratedRecipe.tags.slice(maxVisibleTags) : [];
|
||||
|
||||
// Add visible tags
|
||||
visibleTags.forEach(tag => {
|
||||
@@ -184,7 +276,7 @@ class RecipeModal {
|
||||
// Add all tags to tooltip
|
||||
if (tagsTooltipContent) {
|
||||
tagsTooltipContent.innerHTML = '';
|
||||
recipe.tags.forEach(tag => {
|
||||
hydratedRecipe.tags.forEach(tag => {
|
||||
const tooltipTag = document.createElement('div');
|
||||
tooltipTag.className = 'tooltip-tag';
|
||||
tooltipTag.textContent = tag;
|
||||
@@ -201,8 +293,8 @@ class RecipeModal {
|
||||
const tagsInput = tagsCompactElement.querySelector('.tags-input');
|
||||
|
||||
// Set current tags in the input
|
||||
if (recipe.tags && recipe.tags.length > 0) {
|
||||
tagsInput.value = recipe.tags.join(', ');
|
||||
if (hydratedRecipe.tags && hydratedRecipe.tags.length > 0) {
|
||||
tagsInput.value = hydratedRecipe.tags.join(', ');
|
||||
}
|
||||
|
||||
editTagsIcon.addEventListener('click', () => this.showTagsEditor());
|
||||
@@ -222,49 +314,15 @@ class RecipeModal {
|
||||
// Set recipe image
|
||||
const mediaContainer = document.getElementById('recipePreviewContainer');
|
||||
if (mediaContainer) {
|
||||
// Stop any playing video before replacing content
|
||||
const existingVideo = mediaContainer.querySelector('video');
|
||||
if (existingVideo) {
|
||||
existingVideo.pause();
|
||||
existingVideo.currentTime = 0;
|
||||
}
|
||||
|
||||
// Clear the container
|
||||
mediaContainer.innerHTML = '';
|
||||
|
||||
// Ensure file_url exists, fallback to file_path if needed
|
||||
const imageUrl = recipe.file_url ||
|
||||
(recipe.file_path ? `/loras_static/root1/preview/${recipe.file_path.split('/').pop()}` :
|
||||
'/loras_static/images/no-preview.png');
|
||||
|
||||
// Check if the file is a video (mp4)
|
||||
const isVideo = imageUrl.toLowerCase().endsWith('.mp4');
|
||||
|
||||
if (isVideo) {
|
||||
const videoElement = document.createElement('video');
|
||||
videoElement.id = 'recipeModalVideo';
|
||||
videoElement.src = imageUrl;
|
||||
videoElement.controls = true;
|
||||
videoElement.autoplay = false;
|
||||
videoElement.loop = true;
|
||||
videoElement.muted = true;
|
||||
videoElement.className = 'recipe-preview-media';
|
||||
videoElement.alt = recipe.title || 'Recipe Preview';
|
||||
mediaContainer.appendChild(videoElement);
|
||||
} else {
|
||||
const imgElement = document.createElement('img');
|
||||
imgElement.id = 'recipeModalImage';
|
||||
imgElement.src = imageUrl;
|
||||
imgElement.className = 'recipe-preview-media';
|
||||
imgElement.alt = recipe.title || 'Recipe Preview';
|
||||
mediaContainer.appendChild(imgElement);
|
||||
}
|
||||
this.syncPreviewMedia(hydratedRecipe);
|
||||
mediaContainer.querySelector('.source-url-container')?.remove();
|
||||
mediaContainer.querySelector('.source-url-editor')?.remove();
|
||||
|
||||
// Add source URL container if the recipe has a source_path
|
||||
const sourceUrlContainer = document.createElement('div');
|
||||
sourceUrlContainer.className = 'source-url-container';
|
||||
const hasSourceUrl = recipe.source_path && recipe.source_path.trim().length > 0;
|
||||
const sourceUrl = hasSourceUrl ? recipe.source_path : '';
|
||||
const hasSourceUrl = hydratedRecipe.source_path && hydratedRecipe.source_path.trim().length > 0;
|
||||
const sourceUrl = hasSourceUrl ? hydratedRecipe.source_path : '';
|
||||
const isValidUrl = hasSourceUrl && (sourceUrl.startsWith('http://') || sourceUrl.startsWith('https://'));
|
||||
|
||||
sourceUrlContainer.innerHTML = `
|
||||
@@ -293,40 +351,273 @@ class RecipeModal {
|
||||
mediaContainer.appendChild(sourceUrlContainer);
|
||||
mediaContainer.appendChild(sourceUrlEditor);
|
||||
|
||||
// Set up event listeners for source URL functionality
|
||||
// Delay binding slightly so modal layout is stable, but skip if this render was torn down.
|
||||
const sourceUrlContainerRef = sourceUrlContainer;
|
||||
const sourceUrlEditorRef = sourceUrlEditor;
|
||||
setTimeout(() => {
|
||||
if (!document.body.contains(sourceUrlContainerRef) || !document.body.contains(sourceUrlEditorRef)) {
|
||||
return;
|
||||
}
|
||||
this.setupSourceUrlHandlers();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
// Set generation parameters
|
||||
this.syncGenerationParams(hydratedRecipe.gen_params);
|
||||
this.syncResourcesSection(hydratedRecipe);
|
||||
|
||||
// Show the modal
|
||||
modalManager.showModal('recipeModal');
|
||||
|
||||
if (this.recipeId) {
|
||||
const hydrationRequestId = ++this.recipeHydrationRequestId;
|
||||
const requestEditVersions = this.captureLocalEditVersions();
|
||||
this.hydrateRecipeDetails(
|
||||
this.recipeId,
|
||||
hydrationRequestId,
|
||||
requestEditVersions
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async hydrateRecipeDetails(recipeId, requestId, requestEditVersions = {}) {
|
||||
try {
|
||||
const fullRecipe = await fetchRecipeDetails(recipeId);
|
||||
if (requestId !== this.recipeHydrationRequestId || !fullRecipe) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextRecipe = { ...this.currentRecipe };
|
||||
|
||||
if (!this.hasFieldCommittedSinceRequest('title', requestEditVersions) && fullRecipe.title !== undefined) {
|
||||
nextRecipe.title = fullRecipe.title;
|
||||
}
|
||||
|
||||
if (!this.hasFieldCommittedSinceRequest('tags', requestEditVersions) && fullRecipe.tags !== undefined) {
|
||||
nextRecipe.tags = Array.isArray(fullRecipe.tags) ? [...fullRecipe.tags] : fullRecipe.tags;
|
||||
}
|
||||
|
||||
if (!this.hasFieldCommittedSinceRequest('source_path', requestEditVersions)) {
|
||||
nextRecipe.source_path = fullRecipe.source_path || '';
|
||||
}
|
||||
|
||||
const previousFilePath = nextRecipe.file_path;
|
||||
if (fullRecipe.file_path !== undefined) {
|
||||
nextRecipe.file_path = fullRecipe.file_path;
|
||||
}
|
||||
if (fullRecipe.file_url !== undefined) {
|
||||
nextRecipe.file_url = fullRecipe.file_url;
|
||||
}
|
||||
if (fullRecipe.preview_url !== undefined) {
|
||||
nextRecipe.preview_url = fullRecipe.preview_url;
|
||||
}
|
||||
if (
|
||||
fullRecipe.file_path !== undefined &&
|
||||
fullRecipe.file_path !== previousFilePath &&
|
||||
fullRecipe.file_url === undefined &&
|
||||
fullRecipe.preview_url === undefined
|
||||
) {
|
||||
delete nextRecipe.file_url;
|
||||
delete nextRecipe.preview_url;
|
||||
}
|
||||
|
||||
if (fullRecipe.gen_params !== undefined) {
|
||||
const previousGenParams = nextRecipe.gen_params || {};
|
||||
const incomingGenParams = { ...(fullRecipe.gen_params || {}) };
|
||||
for (const [key, value] of Object.entries(previousGenParams)) {
|
||||
if (this.hasFieldCommittedSinceRequest(key, requestEditVersions)) {
|
||||
incomingGenParams[key] = value;
|
||||
}
|
||||
}
|
||||
nextRecipe.gen_params = incomingGenParams;
|
||||
} else {
|
||||
const previousGenParams = nextRecipe.gen_params || {};
|
||||
const preservedGenParams = {};
|
||||
for (const [key, value] of Object.entries(previousGenParams)) {
|
||||
if (this.hasFieldCommittedSinceRequest(key, requestEditVersions)) {
|
||||
preservedGenParams[key] = value;
|
||||
}
|
||||
}
|
||||
nextRecipe.gen_params = preservedGenParams;
|
||||
}
|
||||
|
||||
if (fullRecipe.checkpoint !== undefined) {
|
||||
nextRecipe.checkpoint = fullRecipe.checkpoint;
|
||||
} else {
|
||||
delete nextRecipe.checkpoint;
|
||||
}
|
||||
if (fullRecipe.loras !== undefined) {
|
||||
nextRecipe.loras = Array.isArray(fullRecipe.loras) ? [...fullRecipe.loras] : fullRecipe.loras;
|
||||
} else {
|
||||
delete nextRecipe.loras;
|
||||
}
|
||||
|
||||
this.currentRecipe = nextRecipe;
|
||||
this.filePath = this.currentRecipe.file_path || this.filePath;
|
||||
|
||||
this.syncHydratedRecipeFields(requestEditVersions);
|
||||
} catch (error) {
|
||||
// Keep the cached recipe visible if hydration fails.
|
||||
console.warn('Failed to hydrate recipe details:', error);
|
||||
}
|
||||
}
|
||||
|
||||
syncHydratedRecipeFields(requestEditVersions = {}) {
|
||||
this.syncPreviewMedia(this.currentRecipe);
|
||||
|
||||
if (!this.shouldPreserveField('title', requestEditVersions)) {
|
||||
this.syncTitleDisplay(this.currentRecipe?.title || '');
|
||||
}
|
||||
|
||||
if (!this.shouldPreserveField('tags', requestEditVersions)) {
|
||||
this.syncTagsDisplay(this.currentRecipe?.tags || []);
|
||||
}
|
||||
|
||||
if (!this.shouldPreserveField('prompt', requestEditVersions)) {
|
||||
this.syncPromptField(
|
||||
'prompt',
|
||||
this.currentRecipe?.gen_params?.prompt || '',
|
||||
'No prompt information available'
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.shouldPreserveField('negative_prompt', requestEditVersions)) {
|
||||
this.syncPromptField(
|
||||
'negative_prompt',
|
||||
this.currentRecipe?.gen_params?.negative_prompt || '',
|
||||
'No negative prompt information available'
|
||||
);
|
||||
}
|
||||
|
||||
this.syncGenerationParams(this.currentRecipe?.gen_params, { promptFieldsOnly: true });
|
||||
this.syncResourcesSection(this.currentRecipe);
|
||||
|
||||
if (!this.shouldPreserveField('source_path', requestEditVersions)) {
|
||||
this.updateSourceUrlDisplay(this.currentRecipe.source_path || '', { forceInputSync: true });
|
||||
} else {
|
||||
this.updateSourceUrlDisplay(this.currentRecipe.source_path || '');
|
||||
}
|
||||
}
|
||||
|
||||
getPreviewMediaUrl(recipe = {}) {
|
||||
return recipe.file_url ||
|
||||
recipe.preview_url ||
|
||||
(recipe.file_path ? `/loras_static/root1/preview/${recipe.file_path.split('/').pop()}` :
|
||||
'/loras_static/images/no-preview.png');
|
||||
}
|
||||
|
||||
syncPreviewMedia(recipe = {}) {
|
||||
const mediaContainer = document.getElementById('recipePreviewContainer');
|
||||
if (!mediaContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previewUrl = this.getPreviewMediaUrl(recipe);
|
||||
const isVideo = previewUrl.toLowerCase().endsWith('.mp4');
|
||||
const expectedElementId = isVideo ? 'recipeModalVideo' : 'recipeModalImage';
|
||||
let previewElement = mediaContainer.querySelector(`#${expectedElementId}`);
|
||||
const existingPreviewElement = mediaContainer.querySelector('.recipe-preview-media');
|
||||
|
||||
if (!previewElement || (existingPreviewElement && existingPreviewElement !== previewElement)) {
|
||||
if (existingPreviewElement?.tagName === 'VIDEO') {
|
||||
const existingVideo = existingPreviewElement;
|
||||
existingVideo.pause();
|
||||
existingVideo.currentTime = 0;
|
||||
}
|
||||
|
||||
existingPreviewElement?.remove();
|
||||
previewElement = document.createElement(isVideo ? 'video' : 'img');
|
||||
previewElement.id = expectedElementId;
|
||||
previewElement.className = 'recipe-preview-media';
|
||||
mediaContainer.prepend(previewElement);
|
||||
}
|
||||
|
||||
previewElement.src = previewUrl;
|
||||
previewElement.alt = recipe.title || 'Recipe Preview';
|
||||
|
||||
if (isVideo) {
|
||||
previewElement.controls = true;
|
||||
previewElement.autoplay = false;
|
||||
previewElement.loop = true;
|
||||
previewElement.muted = true;
|
||||
}
|
||||
}
|
||||
|
||||
getMetadataUpdateOptions() {
|
||||
return this.listFilePath ? { listFilePath: this.listFilePath } : {};
|
||||
}
|
||||
|
||||
syncTitleDisplay(title) {
|
||||
const titleContainer = document.getElementById('recipeModalTitle');
|
||||
if (!titleContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentText = titleContainer.querySelector('.content-text');
|
||||
if (contentText) {
|
||||
contentText.textContent = title || 'Recipe Details';
|
||||
}
|
||||
|
||||
const titleInput = titleContainer.querySelector('.title-input');
|
||||
if (titleInput) {
|
||||
titleInput.value = title || '';
|
||||
}
|
||||
}
|
||||
|
||||
syncTagsDisplay(tags) {
|
||||
const tagsContainer = document.getElementById('recipeTagsCompact');
|
||||
if (!tagsContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateTagsDisplay(tagsContainer, tags || []);
|
||||
|
||||
const tagsInput = tagsContainer.querySelector('.tags-input');
|
||||
if (tagsInput) {
|
||||
tagsInput.value = tags && tags.length > 0 ? tags.join(', ') : '';
|
||||
}
|
||||
}
|
||||
|
||||
syncPromptField(field, value, placeholder) {
|
||||
const contentId = field === 'prompt' ? 'recipePrompt' : 'recipeNegativePrompt';
|
||||
const editorId = field === 'prompt' ? 'recipePromptEditor' : 'recipeNegativePromptEditor';
|
||||
const inputId = field === 'prompt' ? 'recipePromptInput' : 'recipeNegativePromptInput';
|
||||
|
||||
this.renderPromptContent(document.getElementById(contentId), value, placeholder);
|
||||
|
||||
const input = document.getElementById(inputId);
|
||||
if (input) {
|
||||
input.value = value || '';
|
||||
}
|
||||
}
|
||||
|
||||
syncGenerationParams(genParams, options = {}) {
|
||||
const promptElement = document.getElementById('recipePrompt');
|
||||
const negativePromptElement = document.getElementById('recipeNegativePrompt');
|
||||
const otherParamsElement = document.getElementById('recipeOtherParams');
|
||||
const promptInput = document.getElementById('recipePromptInput');
|
||||
const negativePromptInput = document.getElementById('recipeNegativePromptInput');
|
||||
const promptFieldsOnly = options.promptFieldsOnly === true;
|
||||
const sanitizedGenParams = this.sanitizeGenParams(genParams);
|
||||
|
||||
if (recipe.gen_params) {
|
||||
this.renderPromptContent(promptElement, recipe.gen_params.prompt, 'No prompt information available');
|
||||
this.renderPromptContent(negativePromptElement, recipe.gen_params.negative_prompt, 'No negative prompt information available');
|
||||
if (sanitizedGenParams) {
|
||||
if (!promptFieldsOnly) {
|
||||
this.renderPromptContent(promptElement, sanitizedGenParams.prompt, 'No prompt information available');
|
||||
this.renderPromptContent(negativePromptElement, sanitizedGenParams.negative_prompt, 'No negative prompt information available');
|
||||
|
||||
if (promptInput) {
|
||||
promptInput.value = recipe.gen_params.prompt || '';
|
||||
if (promptInput) {
|
||||
promptInput.value = sanitizedGenParams.prompt || '';
|
||||
}
|
||||
|
||||
if (negativePromptInput) {
|
||||
negativePromptInput.value = sanitizedGenParams.negative_prompt || '';
|
||||
}
|
||||
}
|
||||
|
||||
if (negativePromptInput) {
|
||||
negativePromptInput.value = recipe.gen_params.negative_prompt || '';
|
||||
}
|
||||
|
||||
// Set other parameters
|
||||
if (otherParamsElement) {
|
||||
// Clear previous params
|
||||
otherParamsElement.innerHTML = '';
|
||||
|
||||
// Add all other parameters except prompt and negative_prompt
|
||||
const excludedParams = ['prompt', 'negative_prompt'];
|
||||
|
||||
for (const [key, value] of Object.entries(recipe.gen_params)) {
|
||||
for (const [key, value] of Object.entries(sanitizedGenParams)) {
|
||||
if (!excludedParams.includes(key) && value !== undefined && value !== null) {
|
||||
const paramTag = document.createElement('div');
|
||||
paramTag.className = 'param-tag';
|
||||
@@ -338,22 +629,68 @@ class RecipeModal {
|
||||
}
|
||||
}
|
||||
|
||||
// If no other params, show a message
|
||||
if (otherParamsElement.children.length === 0) {
|
||||
otherParamsElement.innerHTML = '<div class="no-params">No additional parameters available</div>';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No generation parameters available
|
||||
return;
|
||||
}
|
||||
|
||||
if (!promptFieldsOnly) {
|
||||
this.renderPromptContent(promptElement, '', 'No prompt information available');
|
||||
this.renderPromptContent(negativePromptElement, '', 'No negative prompt information available');
|
||||
if (promptInput) promptInput.value = '';
|
||||
if (negativePromptInput) negativePromptInput.value = '';
|
||||
if (otherParamsElement) otherParamsElement.innerHTML = '<div class="no-params">No parameters available</div>';
|
||||
}
|
||||
|
||||
if (otherParamsElement) {
|
||||
otherParamsElement.innerHTML = '<div class="no-params">No parameters available</div>';
|
||||
}
|
||||
}
|
||||
|
||||
sanitizeGenParams(genParams) {
|
||||
if (!genParams || typeof genParams !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sanitized = {};
|
||||
|
||||
for (const [key, value] of Object.entries(genParams)) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ALLOWED_GEN_PARAM_KEYS.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
sanitized[key] = value;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(genParams)) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedKey = GEN_PARAM_NORMALIZATION[key] || key;
|
||||
if (!ALLOWED_GEN_PARAM_KEYS.has(normalizedKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sanitized[normalizedKey] === undefined || sanitized[normalizedKey] === null || sanitized[normalizedKey] === '') {
|
||||
sanitized[normalizedKey] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
syncResourcesSection(recipe = {}) {
|
||||
const checkpointContainer = document.getElementById('recipeCheckpoint');
|
||||
const resourceDivider = document.getElementById('recipeResourceDivider');
|
||||
const lorasListElement = document.getElementById('recipeLorasList');
|
||||
const lorasCountElement = document.getElementById('recipeLorasCount');
|
||||
const loras = Array.isArray(recipe.loras) ? recipe.loras : [];
|
||||
|
||||
if (checkpointContainer) {
|
||||
checkpointContainer.innerHTML = '';
|
||||
@@ -364,59 +701,43 @@ class RecipeModal {
|
||||
}
|
||||
}
|
||||
|
||||
// Set LoRAs list and count
|
||||
const lorasListElement = document.getElementById('recipeLorasList');
|
||||
const lorasCountElement = document.getElementById('recipeLorasCount');
|
||||
|
||||
// Check all LoRAs status
|
||||
let allLorasAvailable = true;
|
||||
let missingLorasCount = 0;
|
||||
let deletedLorasCount = 0;
|
||||
|
||||
if (recipe.loras && recipe.loras.length > 0) {
|
||||
recipe.loras.forEach(lora => {
|
||||
if (lora.isDeleted) {
|
||||
deletedLorasCount++;
|
||||
} else if (!lora.inLibrary) {
|
||||
allLorasAvailable = false;
|
||||
missingLorasCount++;
|
||||
}
|
||||
});
|
||||
}
|
||||
loras.forEach(lora => {
|
||||
if (lora.isDeleted) {
|
||||
deletedLorasCount++;
|
||||
} else if (!lora.inLibrary) {
|
||||
allLorasAvailable = false;
|
||||
missingLorasCount++;
|
||||
}
|
||||
});
|
||||
|
||||
// Set LoRAs count and status
|
||||
if (lorasCountElement && recipe.loras) {
|
||||
const totalCount = recipe.loras.length;
|
||||
|
||||
// Create status indicator based on LoRA states
|
||||
if (lorasCountElement) {
|
||||
const totalCount = loras.length;
|
||||
let statusHTML = '';
|
||||
if (totalCount > 0) {
|
||||
if (allLorasAvailable && deletedLorasCount === 0) {
|
||||
// All LoRAs are available
|
||||
statusHTML = `<div class="recipe-status ready"><i class="fas fa-check-circle"></i> Ready to use</div>`;
|
||||
} else if (missingLorasCount > 0) {
|
||||
// Some LoRAs are missing (prioritize showing missing over deleted)
|
||||
statusHTML = `<div class="recipe-status missing">
|
||||
<i class="fas fa-exclamation-triangle"></i> ${missingLorasCount} missing
|
||||
<div class="missing-tooltip">Click to download missing LoRAs</div>
|
||||
</div>`;
|
||||
} else if (deletedLorasCount > 0 && missingLorasCount === 0) {
|
||||
// Some LoRAs are deleted but none are missing
|
||||
statusHTML = `<div class="recipe-status partial"><i class="fas fa-info-circle"></i> ${deletedLorasCount} deleted</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
lorasCountElement.innerHTML = `<i class="fas fa-layer-group"></i> ${totalCount} LoRAs ${statusHTML}`;
|
||||
|
||||
// Add event listeners for buttons and status indicators
|
||||
setTimeout(() => {
|
||||
// Set up click handler for View LoRAs button
|
||||
const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn');
|
||||
if (viewRecipeLorasBtn) {
|
||||
viewRecipeLorasBtn.addEventListener('click', () => this.navigateToLorasPage());
|
||||
}
|
||||
|
||||
// Add click handler for missing LoRAs status
|
||||
const missingStatus = document.querySelector('.recipe-status.missing');
|
||||
if (missingStatus && missingLorasCount > 0) {
|
||||
missingStatus.classList.add('clickable');
|
||||
@@ -425,13 +746,12 @@ class RecipeModal {
|
||||
}, 100);
|
||||
}
|
||||
|
||||
if (lorasListElement && recipe.loras && recipe.loras.length > 0) {
|
||||
lorasListElement.innerHTML = recipe.loras.map(lora => {
|
||||
if (lorasListElement && loras.length > 0) {
|
||||
lorasListElement.innerHTML = loras.map(lora => {
|
||||
const existsLocally = lora.inLibrary;
|
||||
const isDeleted = lora.isDeleted;
|
||||
const localPath = lora.localPath || '';
|
||||
|
||||
// Create status badge based on LoRA state
|
||||
let localStatus;
|
||||
if (existsLocally) {
|
||||
localStatus = `
|
||||
@@ -441,7 +761,7 @@ class RecipeModal {
|
||||
</div>`;
|
||||
} else if (isDeleted) {
|
||||
localStatus = `
|
||||
<div class="deleted-badge reconnectable" data-lora-index="${recipe.loras.indexOf(lora)}">
|
||||
<div class="deleted-badge reconnectable" data-lora-index="${loras.indexOf(lora)}">
|
||||
<span class="badge-text"><i class="fas fa-trash-alt"></i> Deleted</span>
|
||||
<div class="reconnect-tooltip">Click to reconnect with a local LoRA</div>
|
||||
</div>`;
|
||||
@@ -452,7 +772,6 @@ class RecipeModal {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Check if preview is a video
|
||||
const isPreviewVideo = lora.preview_url && lora.preview_url.toLowerCase().endsWith('.mp4');
|
||||
const previewMedia = isPreviewVideo ?
|
||||
`<video class="thumbnail-video" autoplay loop muted playsinline>
|
||||
@@ -460,7 +779,6 @@ class RecipeModal {
|
||||
</video>` :
|
||||
`<img src="${lora.preview_url || '/loras_static/images/no-preview.png'}" alt="LoRA preview">`;
|
||||
|
||||
// Determine CSS class based on LoRA state
|
||||
let loraItemClass = 'recipe-lora-item';
|
||||
if (existsLocally) {
|
||||
loraItemClass += ' exists-locally';
|
||||
@@ -471,7 +789,7 @@ class RecipeModal {
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="${loraItemClass}" data-lora-index="${recipe.loras.indexOf(lora)}">
|
||||
<div class="${loraItemClass}" data-lora-index="${loras.indexOf(lora)}">
|
||||
<div class="recipe-lora-thumbnail">
|
||||
${previewMedia}
|
||||
</div>
|
||||
@@ -485,7 +803,7 @@ class RecipeModal {
|
||||
<div class="recipe-lora-weight">Weight: ${lora.strength || 1.0}</div>
|
||||
${lora.baseModel ? `<div class="base-model">${lora.baseModel}</div>` : ''}
|
||||
</div>
|
||||
<div class="lora-reconnect-container" data-lora-index="${recipe.loras.indexOf(lora)}">
|
||||
<div class="lora-reconnect-container" data-lora-index="${loras.indexOf(lora)}">
|
||||
<div class="reconnect-instructions">
|
||||
<p>Enter LoRA Syntax or Name to Reconnect:</p>
|
||||
<small>Example: <code><lora:Boris_Vallejo_BV_flux_D:1></code> or just <code>Boris_Vallejo_BV_flux_D</code></small>
|
||||
@@ -503,15 +821,12 @@ class RecipeModal {
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Add event listeners for reconnect functionality
|
||||
setTimeout(() => {
|
||||
this.setupReconnectButtons();
|
||||
this.setupLoraItemsClickable();
|
||||
}, 100);
|
||||
|
||||
// Generate recipe syntax for copy button (this is now a placeholder, actual syntax will be fetched from the API)
|
||||
this.recipeLorasSyntax = '';
|
||||
|
||||
} else if (lorasListElement) {
|
||||
lorasListElement.innerHTML = '<div class="no-loras">No LoRAs associated with this recipe</div>';
|
||||
this.recipeLorasSyntax = '';
|
||||
@@ -522,9 +837,31 @@ class RecipeModal {
|
||||
const hasLoraItems = lorasListElement && lorasListElement.querySelector('.recipe-lora-item');
|
||||
resourceDivider.style.display = hasCheckpoint && hasLoraItems ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Show the modal
|
||||
modalManager.showModal('recipeModal');
|
||||
updateSourceUrlDisplay(sourcePath, options = {}) {
|
||||
const sourceUrlContainer = document.querySelector('.source-url-container');
|
||||
const sourceUrlEditor = document.querySelector('.source-url-editor');
|
||||
if (!sourceUrlContainer || !sourceUrlEditor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceUrlText = sourceUrlContainer.querySelector('.source-url-text');
|
||||
const sourceUrlInput = sourceUrlEditor.querySelector('.source-url-input');
|
||||
if (!sourceUrlText || !sourceUrlInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedSourcePath = typeof sourcePath === 'string' ? sourcePath.trim() : '';
|
||||
const isValidUrl = normalizedSourcePath.startsWith('http://') || normalizedSourcePath.startsWith('https://');
|
||||
|
||||
sourceUrlText.textContent = normalizedSourcePath || 'No source URL';
|
||||
sourceUrlText.title = normalizedSourcePath
|
||||
? (isValidUrl ? 'Click to open source URL' : 'No valid URL')
|
||||
: 'No valid URL';
|
||||
if (options.forceInputSync || !sourceUrlEditor.classList.contains('active') || !this.sourceUrlEditState.isDirty) {
|
||||
sourceUrlInput.value = normalizedSourcePath;
|
||||
}
|
||||
}
|
||||
|
||||
// Title editing methods
|
||||
@@ -535,6 +872,7 @@ class RecipeModal {
|
||||
const editor = titleContainer.querySelector('#recipeTitleEditor');
|
||||
editor.classList.add('active');
|
||||
const input = editor.querySelector('input');
|
||||
input.oninput = () => this.markFieldDirty('title');
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
@@ -553,19 +891,23 @@ class RecipeModal {
|
||||
titleContainer.querySelector('.content-text').textContent = newTitle;
|
||||
|
||||
// Update the recipe on the server
|
||||
updateRecipeMetadata(this.filePath, { title: newTitle })
|
||||
updateRecipeMetadata(this.filePath, { title: newTitle }, this.getMetadataUpdateOptions())
|
||||
.then(data => {
|
||||
// Show success toast
|
||||
showToast('toast.recipes.nameUpdated', {}, 'success');
|
||||
|
||||
// Update the current recipe object
|
||||
this.currentRecipe.title = newTitle;
|
||||
this.commitField('title');
|
||||
})
|
||||
.catch(error => {
|
||||
// Error is handled in the API function
|
||||
// Reset the UI if needed
|
||||
titleContainer.querySelector('.content-text').textContent = this.currentRecipe.title || '';
|
||||
this.clearFieldDirty('title');
|
||||
});
|
||||
} else {
|
||||
this.clearFieldDirty('title');
|
||||
}
|
||||
|
||||
// Hide editor
|
||||
@@ -581,6 +923,7 @@ class RecipeModal {
|
||||
const editor = titleContainer.querySelector('#recipeTitleEditor');
|
||||
const input = editor.querySelector('input');
|
||||
input.value = this.currentRecipe.title || '';
|
||||
this.clearFieldDirty('title');
|
||||
|
||||
// Hide editor
|
||||
editor.classList.remove('active');
|
||||
@@ -596,6 +939,7 @@ class RecipeModal {
|
||||
const editor = tagsContainer.querySelector('#recipeTagsEditor');
|
||||
editor.classList.add('active');
|
||||
const input = editor.querySelector('input');
|
||||
input.oninput = () => this.markFieldDirty('tags');
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
@@ -623,20 +967,24 @@ class RecipeModal {
|
||||
|
||||
if (tagsChanged) {
|
||||
// Update the recipe on the server
|
||||
updateRecipeMetadata(this.filePath, { tags: newTags })
|
||||
updateRecipeMetadata(this.filePath, { tags: newTags }, this.getMetadataUpdateOptions())
|
||||
.then(data => {
|
||||
// Show success toast
|
||||
showToast('toast.recipes.tagsUpdated', {}, 'success');
|
||||
|
||||
// Update the current recipe object
|
||||
this.currentRecipe.tags = newTags;
|
||||
this.commitField('tags');
|
||||
|
||||
// Update tags in the UI
|
||||
this.updateTagsDisplay(tagsContainer, newTags);
|
||||
})
|
||||
.catch(error => {
|
||||
// Error is handled in the API function
|
||||
this.clearFieldDirty('tags');
|
||||
});
|
||||
} else {
|
||||
this.clearFieldDirty('tags');
|
||||
}
|
||||
|
||||
// Hide editor
|
||||
@@ -708,6 +1056,7 @@ class RecipeModal {
|
||||
const editor = tagsContainer.querySelector('#recipeTagsEditor');
|
||||
const input = editor.querySelector('input');
|
||||
input.value = this.currentRecipe.tags ? this.currentRecipe.tags.join(', ') : '';
|
||||
this.clearFieldDirty('tags');
|
||||
|
||||
// Hide editor
|
||||
editor.classList.remove('active');
|
||||
@@ -748,6 +1097,7 @@ class RecipeModal {
|
||||
}
|
||||
|
||||
if (input) {
|
||||
input.addEventListener('input', () => this.markFieldDirty(config.field));
|
||||
input.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
@@ -840,9 +1190,10 @@ class RecipeModal {
|
||||
|
||||
const currentGenParams = this.currentRecipe.gen_params || {};
|
||||
const nextValue = input.value.trim() === '' ? '' : input.value;
|
||||
const currentValue = currentGenParams[config.field] || '';
|
||||
const currentValue = this.sanitizeGenParams(currentGenParams)?.[config.field] || '';
|
||||
|
||||
if (nextValue === currentValue) {
|
||||
this.clearFieldDirty(config.field);
|
||||
this.hidePromptEditor(config);
|
||||
return;
|
||||
}
|
||||
@@ -857,14 +1208,17 @@ class RecipeModal {
|
||||
...promptState,
|
||||
isSaving: true,
|
||||
};
|
||||
await updateRecipeMetadata(this.filePath, { gen_params: nextGenParams });
|
||||
await updateRecipeMetadata(this.filePath, { gen_params: nextGenParams }, this.getMetadataUpdateOptions());
|
||||
this.currentRecipe.gen_params = nextGenParams;
|
||||
this.renderPromptContent(content, nextValue, config.placeholder);
|
||||
showToast(config.successKey, {}, 'success', config.successFallback);
|
||||
this.commitField(config.field);
|
||||
} catch (error) {
|
||||
this.renderPromptContent(content, currentValue, config.placeholder);
|
||||
input.value = currentValue;
|
||||
this.clearFieldDirty(config.field);
|
||||
} finally {
|
||||
this.clearFieldDirty(config.field);
|
||||
this.hidePromptEditor(config);
|
||||
}
|
||||
}
|
||||
@@ -872,10 +1226,10 @@ class RecipeModal {
|
||||
cancelPromptEdit(config) {
|
||||
const input = document.getElementById(config.inputId);
|
||||
if (input) {
|
||||
const initialValue = this.promptEditorState[config.field]?.initialValue;
|
||||
input.value = initialValue ?? (this.currentRecipe?.gen_params?.[config.field] || '');
|
||||
input.value = this.currentRecipe?.gen_params?.[config.field] || '';
|
||||
}
|
||||
|
||||
this.clearFieldDirty(config.field);
|
||||
this.hidePromptEditor(config);
|
||||
}
|
||||
|
||||
@@ -918,11 +1272,16 @@ class RecipeModal {
|
||||
sourceUrlInput.focus();
|
||||
});
|
||||
|
||||
sourceUrlInput.addEventListener('input', () => {
|
||||
this.sourceUrlEditState.isDirty = true;
|
||||
});
|
||||
|
||||
// Cancel editing
|
||||
sourceUrlCancelBtn.addEventListener('click', () => {
|
||||
sourceUrlEditor.classList.remove('active');
|
||||
sourceUrlContainer.classList.remove('hide');
|
||||
sourceUrlInput.value = this.currentRecipe.source_path || '';
|
||||
this.updateSourceUrlDisplay(this.currentRecipe.source_path || '', { forceInputSync: true });
|
||||
this.clearFieldDirty('source_path');
|
||||
});
|
||||
|
||||
// Save new source URL
|
||||
@@ -930,23 +1289,24 @@ class RecipeModal {
|
||||
const newSourceUrl = sourceUrlInput.value.trim();
|
||||
if (newSourceUrl !== this.currentRecipe.source_path) {
|
||||
// Update the recipe on the server
|
||||
updateRecipeMetadata(this.filePath, { source_path: newSourceUrl })
|
||||
updateRecipeMetadata(this.filePath, { source_path: newSourceUrl }, this.getMetadataUpdateOptions())
|
||||
.then(data => {
|
||||
// Show success toast
|
||||
showToast('toast.recipes.sourceUrlUpdated', {}, 'success');
|
||||
|
||||
// Update source URL in the UI
|
||||
sourceUrlText.textContent = newSourceUrl || 'No source URL';
|
||||
sourceUrlText.title = newSourceUrl && (newSourceUrl.startsWith('http://') ||
|
||||
newSourceUrl.startsWith('https://')) ?
|
||||
'Click to open source URL' : 'No valid URL';
|
||||
this.commitField('source_path');
|
||||
this.updateSourceUrlDisplay(newSourceUrl, { forceInputSync: true });
|
||||
|
||||
// Update the current recipe object
|
||||
this.currentRecipe.source_path = newSourceUrl;
|
||||
})
|
||||
.catch(error => {
|
||||
// Error is handled in the API function
|
||||
this.clearFieldDirty('source_path');
|
||||
});
|
||||
} else {
|
||||
this.clearFieldDirty('source_path');
|
||||
}
|
||||
|
||||
// Hide editor
|
||||
@@ -1286,7 +1646,7 @@ class RecipeModal {
|
||||
this.showRecipeDetails(this.currentRecipe);
|
||||
}, 500);
|
||||
|
||||
state.virtualScroller.updateSingleItem(this.currentRecipe.file_path, {
|
||||
state.virtualScroller.updateSingleItem(this.listFilePath || this.currentRecipe.file_path, {
|
||||
loras: this.currentRecipe.loras
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -220,8 +220,31 @@ function buildMetaMarkup(version, options = {}) {
|
||||
.join('<span class="version-meta-separator">•</span>');
|
||||
}
|
||||
|
||||
function buildBadge(label, tone) {
|
||||
return `<span class="version-badge version-badge-${tone}">${escapeHtml(label)}</span>`;
|
||||
function buildBadge(label, tone, options = {}) {
|
||||
const attributes = [];
|
||||
if (options.title) {
|
||||
attributes.push(`title="${escapeHtml(options.title)}"`);
|
||||
}
|
||||
if (options.ariaLabel) {
|
||||
attributes.push(`aria-label="${escapeHtml(options.ariaLabel)}"`);
|
||||
}
|
||||
const suffix = attributes.length ? ` ${attributes.join(' ')}` : '';
|
||||
return `<span class="version-badge version-badge-${tone}"${suffix}>${escapeHtml(label)}</span>`;
|
||||
}
|
||||
|
||||
function buildActionButton(label, variant, action, options = {}) {
|
||||
const attributes = [
|
||||
`class="version-action ${variant}"`,
|
||||
`data-version-action="${escapeHtml(action)}"`,
|
||||
];
|
||||
if (options.title) {
|
||||
attributes.push(`title="${escapeHtml(options.title)}"`);
|
||||
attributes.push(`aria-label="${escapeHtml(options.title)}"`);
|
||||
}
|
||||
if (options.extraAttributes) {
|
||||
attributes.push(options.extraAttributes);
|
||||
}
|
||||
return `<button ${attributes.join(' ')}>${options.iconMarkup || ''}${escapeHtml(label)}</button>`;
|
||||
}
|
||||
|
||||
const DISPLAY_FILTER_MODES = Object.freeze({
|
||||
@@ -426,23 +449,72 @@ function renderRow(version, options) {
|
||||
version.versionId > latestLibraryVersionId;
|
||||
const isEarlyAccess = isEarlyAccessActive(version);
|
||||
const badges = [];
|
||||
const openedBadgeLabel = translate('modals.model.versions.badges.current', {}, 'Opened Version');
|
||||
const inLibraryBadgeLabel = translate('modals.model.versions.badges.inLibrary', {}, 'In Library');
|
||||
const downloadedBadgeLabel = translate('modals.model.versions.badges.downloaded', {}, 'Downloaded');
|
||||
const newerBadgeLabel = translate('modals.model.versions.badges.newer', {}, 'Newer Version');
|
||||
const earlyAccessBadgeLabel = translate('modals.model.versions.badges.earlyAccess', {}, 'Early Access');
|
||||
const ignoredBadgeLabel = translate('modals.model.versions.badges.ignored', {}, 'Ignored');
|
||||
const versionName = version.name || translate('modals.model.versions.labels.unnamed', {}, 'Untitled Version');
|
||||
|
||||
if (isCurrent) {
|
||||
badges.push(buildBadge(translate('modals.model.versions.badges.current', {}, 'Current Version'), 'current'));
|
||||
badges.push(buildBadge(openedBadgeLabel, 'current', {
|
||||
title: translate(
|
||||
'modals.model.versions.badges.currentTooltip',
|
||||
{},
|
||||
'This is the version you opened this modal from'
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
if (version.isInLibrary) {
|
||||
badges.push(buildBadge(translate('modals.model.versions.badges.inLibrary', {}, 'In Library'), 'success'));
|
||||
} else if (isNewer && !version.shouldIgnore) {
|
||||
badges.push(buildBadge(translate('modals.model.versions.badges.newer', {}, 'Newer Version'), 'info'));
|
||||
badges.push(buildBadge(inLibraryBadgeLabel, 'success', {
|
||||
title: translate(
|
||||
'modals.model.versions.badges.inLibraryTooltip',
|
||||
{},
|
||||
'This version exists in your local library'
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
if (!version.isInLibrary && version.hasBeenDownloaded) {
|
||||
badges.push(buildBadge(downloadedBadgeLabel, 'info', {
|
||||
title: translate(
|
||||
'modals.model.versions.badges.downloadedTooltip',
|
||||
{},
|
||||
'This version was downloaded before, but is not currently in your library'
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
if (!version.isInLibrary && isNewer && !version.shouldIgnore) {
|
||||
badges.push(buildBadge(newerBadgeLabel, 'info', {
|
||||
title: translate(
|
||||
'modals.model.versions.badges.newerTooltip',
|
||||
{},
|
||||
'This version is newer than your latest local version'
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
if (isEarlyAccess) {
|
||||
badges.push(buildBadge(translate('modals.model.versions.badges.earlyAccess', {}, 'Early Access'), 'early-access'));
|
||||
badges.push(buildBadge(earlyAccessBadgeLabel, 'early-access', {
|
||||
title: translate(
|
||||
'modals.model.versions.badges.earlyAccessTooltip',
|
||||
{},
|
||||
'This version currently requires Civitai early access'
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
if (version.shouldIgnore) {
|
||||
badges.push(buildBadge(translate('modals.model.versions.badges.ignored', {}, 'Ignored'), 'muted'));
|
||||
badges.push(buildBadge(ignoredBadgeLabel, 'muted', {
|
||||
title: translate(
|
||||
'modals.model.versions.badges.ignoredTooltip',
|
||||
{},
|
||||
'Update notifications are disabled for this version'
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
const downloadLabel = translate('modals.model.versions.actions.download', {}, 'Download');
|
||||
@@ -459,29 +531,82 @@ function renderRow(version, options) {
|
||||
if (!version.isInLibrary) {
|
||||
// Download button with optional EA bolt icon
|
||||
const downloadIcon = isEarlyAccess ? '<i class="fas fa-bolt"></i> ' : '';
|
||||
actions.push(
|
||||
`<button class="version-action version-action-primary" data-version-action="download">${downloadIcon}${escapeHtml(downloadLabel)}</button>`
|
||||
);
|
||||
actions.push(buildActionButton(
|
||||
downloadLabel,
|
||||
'version-action-primary',
|
||||
'download',
|
||||
{
|
||||
title: isEarlyAccess
|
||||
? translate(
|
||||
'modals.model.versions.actions.downloadEarlyAccessTooltip',
|
||||
{},
|
||||
'Download this early access version from Civitai'
|
||||
)
|
||||
: translate(
|
||||
'modals.model.versions.actions.downloadTooltip',
|
||||
{},
|
||||
'Download this version'
|
||||
),
|
||||
iconMarkup: downloadIcon,
|
||||
}
|
||||
));
|
||||
} else if (version.filePath) {
|
||||
actions.push(
|
||||
`<button class="version-action version-action-danger" data-version-action="delete">${escapeHtml(deleteLabel)}</button>`
|
||||
);
|
||||
actions.push(buildActionButton(
|
||||
deleteLabel,
|
||||
'version-action-danger',
|
||||
'delete',
|
||||
{
|
||||
title: translate(
|
||||
'modals.model.versions.actions.deleteTooltip',
|
||||
{},
|
||||
'Delete this local version'
|
||||
),
|
||||
}
|
||||
));
|
||||
}
|
||||
actions.push(
|
||||
`<button class="version-action version-action-ghost" data-version-action="toggle-ignore" data-ignore-state="${
|
||||
version.shouldIgnore ? 'ignored' : 'active'
|
||||
}">${escapeHtml(ignoreLabel)}</button>`
|
||||
);
|
||||
actions.push(buildActionButton(
|
||||
ignoreLabel,
|
||||
'version-action-ghost',
|
||||
'toggle-ignore',
|
||||
{
|
||||
title: version.shouldIgnore
|
||||
? translate(
|
||||
'modals.model.versions.actions.unignoreTooltip',
|
||||
{},
|
||||
'Resume update notifications for this version'
|
||||
)
|
||||
: translate(
|
||||
'modals.model.versions.actions.ignoreTooltip',
|
||||
{},
|
||||
'Ignore update notifications for this version'
|
||||
),
|
||||
extraAttributes: `data-ignore-state="${version.shouldIgnore ? 'ignored' : 'active'}"`,
|
||||
}
|
||||
));
|
||||
|
||||
const linkTarget = buildCivitaiVersionUrl(
|
||||
version.modelId || parentModelId,
|
||||
version.versionId
|
||||
);
|
||||
const civitaiTooltip = translate(
|
||||
'modals.model.actions.viewOnCivitai',
|
||||
'modals.model.versions.actions.viewVersionOnCivitai',
|
||||
{},
|
||||
'View on Civitai'
|
||||
'View version on Civitai'
|
||||
);
|
||||
const civitaiLinkMarkup = linkTarget
|
||||
? `
|
||||
<a
|
||||
class="version-civitai-link"
|
||||
href="${escapeHtml(linkTarget)}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="${escapeHtml(civitaiTooltip)}"
|
||||
aria-label="${escapeHtml(civitaiTooltip)}"
|
||||
>
|
||||
<i class="fas fa-arrow-up-right-from-square" aria-hidden="true"></i>
|
||||
</a>
|
||||
`
|
||||
: '';
|
||||
|
||||
const rowAttributes = [
|
||||
`class="model-version-row${isCurrent ? ' is-current' : ''}${linkTarget ? ' is-clickable' : ''}${isEarlyAccess ? ' is-early-access' : ''}"`,
|
||||
@@ -489,7 +614,6 @@ function renderRow(version, options) {
|
||||
];
|
||||
if (linkTarget) {
|
||||
rowAttributes.push(`data-civitai-url="${escapeHtml(linkTarget)}"`);
|
||||
rowAttributes.push(`title="${escapeHtml(civitaiTooltip)}"`);
|
||||
}
|
||||
|
||||
return `
|
||||
@@ -497,7 +621,8 @@ function renderRow(version, options) {
|
||||
${renderMediaMarkup(version)}
|
||||
<div class="version-details">
|
||||
<div class="version-title">
|
||||
<span class="versions-tab-version-name">${escapeHtml(version.name || translate('modals.model.versions.labels.unnamed', {}, 'Untitled Version'))}</span>
|
||||
<span class="versions-tab-version-name">${escapeHtml(versionName)}</span>
|
||||
${civitaiLinkMarkup}
|
||||
</div>
|
||||
<div class="version-badges">${badges.join('')}</div>
|
||||
<div class="version-meta">
|
||||
@@ -1230,6 +1355,7 @@ export function initVersionsTab({
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest('button')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -432,7 +432,7 @@ export class BatchImportManager {
|
||||
|
||||
// Refresh recipes list to show newly imported recipes
|
||||
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
|
||||
window.recipeManager.loadRecipes();
|
||||
window.recipeManager.loadRecipes({ preserveScroll: true });
|
||||
}
|
||||
|
||||
// Show results step
|
||||
|
||||
@@ -311,7 +311,7 @@ export class BulkMissingLoraDownloadManager {
|
||||
|
||||
// Refresh the recipes list to update LoRA status
|
||||
if (window.recipeManager) {
|
||||
window.recipeManager.loadRecipes();
|
||||
window.recipeManager.loadRecipes({ preserveScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +103,16 @@ export class DoctorManager {
|
||||
return document.body?.dataset?.appVersion || '';
|
||||
}
|
||||
|
||||
buildReloadUrl() {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('_lm_reload', Date.now().toString());
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
reloadUi() {
|
||||
window.location.replace(this.buildReloadUrl());
|
||||
}
|
||||
|
||||
setLoading(isLoading) {
|
||||
if (this.loadingState) {
|
||||
this.loadingState.classList.toggle('visible', isLoading);
|
||||
@@ -308,7 +318,7 @@ export class DoctorManager {
|
||||
await this.repairCache();
|
||||
break;
|
||||
case 'reload-page':
|
||||
window.location.reload();
|
||||
this.reloadUi();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
|
||||
@@ -250,6 +250,7 @@ export class DownloadManager {
|
||||
(version.files[0]?.sizeKB / 1024).toFixed(2);
|
||||
|
||||
const existsLocally = version.existsLocally;
|
||||
const hasBeenDownloaded = version.hasBeenDownloaded && !existsLocally;
|
||||
const localPath = version.localPath;
|
||||
const isEarlyAccess = version.availability === 'EarlyAccess';
|
||||
|
||||
@@ -262,11 +263,22 @@ export class DownloadManager {
|
||||
`;
|
||||
}
|
||||
|
||||
const localStatus = existsLocally ?
|
||||
`<div class="local-badge">
|
||||
let localStatus = '';
|
||||
if (existsLocally) {
|
||||
localStatus = `<div class="local-badge">
|
||||
<i class="fas fa-check"></i> ${translate('modals.download.inLibrary')}
|
||||
<div class="local-path">${localPath || ''}</div>
|
||||
</div>` : '';
|
||||
</div>`;
|
||||
} else if (hasBeenDownloaded) {
|
||||
const downloadedTooltip = translate(
|
||||
'modals.download.downloadedTooltip',
|
||||
{},
|
||||
'Previously downloaded, but it is not currently in your library.'
|
||||
);
|
||||
localStatus = `<div class="downloaded-badge" title="${downloadedTooltip.replace(/"/g, '"')}">
|
||||
<i class="fas fa-history"></i> ${translate('modals.download.downloaded', {}, 'Downloaded')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="version-item ${this.currentVersion?.id === version.id ? 'selected' : ''}
|
||||
|
||||
@@ -1443,12 +1443,12 @@ export class SettingsManager {
|
||||
|
||||
// Add empty row for new path if no paths exist
|
||||
if (paths.length === 0) {
|
||||
this.addExtraFolderPathRow(modelType, '');
|
||||
this.addExtraFolderPathRow(modelType, '', false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addExtraFolderPathRow(modelType, path = '') {
|
||||
addExtraFolderPathRow(modelType, path = '', shouldFocus = true) {
|
||||
const container = document.getElementById(`extraFolderPaths-${modelType}`);
|
||||
if (!container) return;
|
||||
|
||||
@@ -1472,7 +1472,7 @@ export class SettingsManager {
|
||||
container.appendChild(row);
|
||||
|
||||
// Focus the input if it's empty (new row)
|
||||
if (!path) {
|
||||
if (!path && shouldFocus) {
|
||||
const input = row.querySelector('.extra-folder-path-input');
|
||||
if (input) {
|
||||
setTimeout(() => input.focus(), 0);
|
||||
|
||||
@@ -122,7 +122,7 @@ export class DownloadManager {
|
||||
modalManager.closeModal('importModal');
|
||||
|
||||
// Refresh the recipe
|
||||
window.recipeManager.loadRecipes();
|
||||
window.recipeManager.loadRecipes({ preserveScroll: true });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
|
||||
@@ -328,16 +328,32 @@ class RecipeManager {
|
||||
});
|
||||
}
|
||||
|
||||
normalizeLoadRecipesOptions(options = true) {
|
||||
if (typeof options === 'boolean') {
|
||||
return {
|
||||
resetPage: options,
|
||||
preserveScroll: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
resetPage: options?.resetPage !== false,
|
||||
preserveScroll: options?.preserveScroll === true
|
||||
};
|
||||
}
|
||||
|
||||
// This method is kept for compatibility but now uses virtual scrolling
|
||||
async loadRecipes(resetPage = true) {
|
||||
async loadRecipes(options = true) {
|
||||
// Skip loading if in duplicates mode
|
||||
const pageState = getCurrentPageState();
|
||||
if (pageState.duplicatesMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { resetPage, preserveScroll } = this.normalizeLoadRecipesOptions(options);
|
||||
|
||||
if (resetPage) {
|
||||
refreshVirtualScroll();
|
||||
await refreshVirtualScroll({ preserveScroll });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,43 @@ import { createModelCard, setupModelCardEventDelegation } from '../components/sh
|
||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||
import { showToast } from './uiHelpers.js';
|
||||
|
||||
function getScrollContainer() {
|
||||
return document.querySelector('.page-content');
|
||||
}
|
||||
|
||||
function getClampedScrollTop(scrollContainer, scrollTop) {
|
||||
const maxScrollTop = Math.max(0, scrollContainer.scrollHeight - scrollContainer.clientHeight);
|
||||
return Math.min(Math.max(scrollTop, 0), maxScrollTop);
|
||||
}
|
||||
|
||||
function waitForAnimationFrame() {
|
||||
return new Promise(resolve => requestAnimationFrame(resolve));
|
||||
}
|
||||
|
||||
export function captureScrollPosition() {
|
||||
const scrollContainer = getScrollContainer();
|
||||
if (!scrollContainer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
scrollContainer,
|
||||
scrollTop: scrollContainer.scrollTop
|
||||
};
|
||||
}
|
||||
|
||||
export async function restoreScrollPosition(snapshot) {
|
||||
if (!snapshot?.scrollContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for layout and the scheduled virtual-scroll render to settle.
|
||||
await waitForAnimationFrame();
|
||||
await waitForAnimationFrame();
|
||||
|
||||
snapshot.scrollContainer.scrollTop = getClampedScrollTop(snapshot.scrollContainer, snapshot.scrollTop);
|
||||
}
|
||||
|
||||
// Function to dynamically import the appropriate card creator based on page type
|
||||
async function getCardCreator(pageType) {
|
||||
if (pageType === 'recipes') {
|
||||
@@ -87,7 +124,7 @@ async function initializeVirtualScroll(pageType) {
|
||||
}
|
||||
|
||||
// Change this line to get the actual scrolling container
|
||||
const scrollContainer = document.querySelector('.page-content');
|
||||
const scrollContainer = getScrollContainer();
|
||||
const gridContainer = scrollContainer.querySelector('.container');
|
||||
|
||||
if (!gridContainer) {
|
||||
@@ -200,9 +237,16 @@ export function cleanupKeyboardNavigation() {
|
||||
}
|
||||
|
||||
// Export a method to refresh the virtual scroller when filters change
|
||||
export function refreshVirtualScroll() {
|
||||
export async function refreshVirtualScroll(options = {}) {
|
||||
const { preserveScroll = false } = options;
|
||||
|
||||
if (state.virtualScroller) {
|
||||
const scrollSnapshot = preserveScroll ? captureScrollPosition() : null;
|
||||
state.virtualScroller.reset();
|
||||
state.virtualScroller.initialize();
|
||||
await state.virtualScroller.initialize();
|
||||
|
||||
if (scrollSnapshot) {
|
||||
await restoreScrollPosition(scrollSnapshot);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,15 @@ const showToastMock = vi.hoisted(() => vi.fn());
|
||||
const loadingManagerMock = vi.hoisted(() => ({
|
||||
showSimpleLoading: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
restoreProgressBar: vi.fn(),
|
||||
}));
|
||||
const virtualScrollerMock = vi.hoisted(() => ({
|
||||
updateSingleItem: vi.fn(),
|
||||
refreshWithData: vi.fn(),
|
||||
}));
|
||||
const getCurrentPageStateMock = vi.hoisted(() => vi.fn());
|
||||
const captureScrollPositionMock = vi.hoisted(() => vi.fn());
|
||||
const restoreScrollPositionMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => {
|
||||
return {
|
||||
@@ -20,17 +28,41 @@ vi.mock('../../../static/js/state/index.js', () => {
|
||||
return {
|
||||
state: {
|
||||
loadingManager: loadingManagerMock,
|
||||
virtualScroller: virtualScrollerMock,
|
||||
},
|
||||
getCurrentPageState: vi.fn(),
|
||||
getCurrentPageState: getCurrentPageStateMock,
|
||||
};
|
||||
});
|
||||
|
||||
import { RecipeSidebarApiClient } from '../../../static/js/api/recipeApi.js';
|
||||
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
|
||||
captureScrollPosition: captureScrollPositionMock,
|
||||
restoreScrollPosition: restoreScrollPositionMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
RecipeSidebarApiClient,
|
||||
fetchRecipeDetails,
|
||||
resetAndReload,
|
||||
syncChanges,
|
||||
updateRecipeMetadata
|
||||
} from '../../../static/js/api/recipeApi.js';
|
||||
|
||||
describe('RecipeSidebarApiClient bulk operations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
global.fetch = vi.fn();
|
||||
getCurrentPageStateMock.mockReturnValue({
|
||||
pageSize: 50,
|
||||
currentPage: 1,
|
||||
hasMore: true,
|
||||
isLoading: false,
|
||||
sortBy: 'date:desc',
|
||||
showFavoritesOnly: false,
|
||||
activeFolder: null,
|
||||
searchOptions: { recursive: true },
|
||||
customFilter: { active: false },
|
||||
filters: {},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -111,4 +143,77 @@ describe('RecipeSidebarApiClient bulk operations', () => {
|
||||
});
|
||||
expect(loadingManagerMock.hide).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('encodes recipe IDs when fetching recipe details', async () => {
|
||||
global.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'abc' }),
|
||||
});
|
||||
|
||||
await fetchRecipeDetails('recipe#1?name=foo%bar');
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe%231%3Fname%3Dfoo%25bar');
|
||||
});
|
||||
|
||||
it('updates the virtual scroller using the original list path when provided', async () => {
|
||||
global.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
await updateRecipeMetadata(
|
||||
'/recipes/new-folder/recipe#1.webp',
|
||||
{ title: 'Updated Title' },
|
||||
{ listFilePath: '/recipes/old-folder/recipe#1.webp' }
|
||||
);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/lm/recipe/recipe%231/update',
|
||||
expect.objectContaining({ method: 'PUT' })
|
||||
);
|
||||
expect(virtualScrollerMock.updateSingleItem).toHaveBeenCalledWith(
|
||||
'/recipes/old-folder/recipe#1.webp',
|
||||
{ title: 'Updated Title' }
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves scroll position for recipe reloads when requested', async () => {
|
||||
const scrollSnapshot = { scrollContainer: { scrollTop: 480 }, scrollTop: 480 };
|
||||
captureScrollPositionMock.mockReturnValue(scrollSnapshot);
|
||||
global.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
items: [{ id: 'recipe-1' }],
|
||||
total: 1,
|
||||
total_pages: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
await resetAndReload(false, { preserveScroll: true });
|
||||
|
||||
expect(captureScrollPositionMock).toHaveBeenCalledTimes(1);
|
||||
expect(virtualScrollerMock.refreshWithData).toHaveBeenCalledWith(
|
||||
[{ id: 'recipe-1' }],
|
||||
1,
|
||||
false
|
||||
);
|
||||
expect(restoreScrollPositionMock).toHaveBeenCalledWith(scrollSnapshot);
|
||||
});
|
||||
|
||||
it('uses scroll-preserving reloads for syncChanges', async () => {
|
||||
global.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
items: [],
|
||||
total: 0,
|
||||
total_pages: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
await syncChanges();
|
||||
|
||||
expect(captureScrollPositionMock).toHaveBeenCalledTimes(1);
|
||||
expect(restoreScrollPositionMock).toHaveBeenCalledTimes(1);
|
||||
expect(loadingManagerMock.restoreProgressBar).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,6 +126,31 @@ describe('AutoComplete widget interactions', () => {
|
||||
expect(caretHelperInstance.getCursorOffset).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deduplicates duplicate-equivalent query variations before issuing requests', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({ success: true, words: [] }),
|
||||
});
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('Example');
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
new AutoComplete(input, 'prompt', { debounceDelay: 0, showPreview: false, minChars: 1 });
|
||||
|
||||
input.value = 'Example';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchApiMock).toHaveBeenCalledWith('/lm/custom-words/search?enriched=true&search=Example&limit=100');
|
||||
});
|
||||
|
||||
it('inserts the selected LoRA with usage tip strengths and restores focus', async () => {
|
||||
fetchApiMock.mockImplementation((url) => {
|
||||
if (url.includes('usage-tips-by-path')) {
|
||||
@@ -244,6 +269,53 @@ describe('AutoComplete widget interactions', () => {
|
||||
expect(inputListener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the full command list when typing a single slash', async () => {
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '/';
|
||||
input.selectionStart = input.value.length;
|
||||
document.body.append(input);
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'prompt', { showPreview: false, minChars: 1 });
|
||||
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
const commandNames = autoComplete.items.map((item) => item.command);
|
||||
|
||||
expect(commandNames).toContain('/character');
|
||||
expect(commandNames).toContain('/artist');
|
||||
expect(commandNames).toContain('/general');
|
||||
expect(commandNames).toContain('/copyright');
|
||||
expect(commandNames).toContain('/meta');
|
||||
expect(commandNames).toContain('/species');
|
||||
expect(commandNames).toContain('/lore');
|
||||
expect(commandNames).toContain('/emb');
|
||||
expect(commandNames).toContain('/embedding');
|
||||
expect(commandNames).toContain('/wildcard');
|
||||
});
|
||||
|
||||
it('renders every command item when slash opens the command list', async () => {
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '/';
|
||||
input.selectionStart = input.value.length;
|
||||
document.body.append(input);
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', { showPreview: false, minChars: 1 });
|
||||
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
const renderedCommands = autoComplete.contentContainer.querySelectorAll('.lm-autocomplete-command-name');
|
||||
|
||||
expect(renderedCommands).toHaveLength(autoComplete.items.length);
|
||||
});
|
||||
|
||||
it('accepts the selected suggestion with Enter', async () => {
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
||||
|
||||
@@ -300,6 +372,66 @@ describe('AutoComplete widget interactions', () => {
|
||||
expect(insertSelectionSpy).toHaveBeenCalledWith('loop');
|
||||
});
|
||||
|
||||
it('preserves manual ArrowDown selection when Tab accepts a suggestion', async () => {
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('loop');
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = 'loop';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'prompt', { showPreview: false, minChars: 1 });
|
||||
|
||||
autoComplete.searchType = 'custom_words';
|
||||
autoComplete.items = [
|
||||
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1000 },
|
||||
{ tag_name: 'loop', category: 0, post_count: 500 },
|
||||
];
|
||||
autoComplete.currentSearchTerm = 'loo';
|
||||
autoComplete.selectedIndex = 0;
|
||||
autoComplete.isVisible = true;
|
||||
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
|
||||
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
|
||||
|
||||
expect(autoComplete.selectedIndex).toBe(1);
|
||||
expect(insertSelectionSpy).toHaveBeenCalledWith('loop');
|
||||
});
|
||||
|
||||
it('preserves manual ArrowDown selection when Enter accepts a suggestion', async () => {
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('loop');
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = 'loop';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'prompt', { showPreview: false, minChars: 1 });
|
||||
|
||||
autoComplete.searchType = 'custom_words';
|
||||
autoComplete.items = [
|
||||
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1000 },
|
||||
{ tag_name: 'loop', category: 0, post_count: 500 },
|
||||
];
|
||||
autoComplete.currentSearchTerm = 'loo';
|
||||
autoComplete.selectedIndex = 0;
|
||||
autoComplete.isVisible = true;
|
||||
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
|
||||
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
|
||||
|
||||
expect(autoComplete.selectedIndex).toBe(1);
|
||||
expect(insertSelectionSpy).toHaveBeenCalledWith('loop');
|
||||
});
|
||||
|
||||
it('accepts the first available suggestion with Tab even if delayed auto-selection has not happened yet', async () => {
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('loop');
|
||||
|
||||
@@ -743,12 +875,12 @@ describe('AutoComplete widget interactions', () => {
|
||||
json: () => Promise.resolve({ success: true, words: mockTags }),
|
||||
});
|
||||
|
||||
// Simulate "/char looking to the side" input
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('/char looking to the side');
|
||||
// Simulate "/character looking to the side" input
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('/character looking to the side');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '/char looking to the side';
|
||||
input.value = '/character looking to the side';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
@@ -766,7 +898,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
autoComplete.activeCommand = { categories: [4, 11], label: 'Character' };
|
||||
autoComplete.items = mockTags;
|
||||
autoComplete.selectedIndex = 0;
|
||||
autoComplete.currentSearchTerm = '/char looking to the side';
|
||||
autoComplete.currentSearchTerm = '/character looking to the side';
|
||||
|
||||
await autoComplete.insertSelection('looking_to_the_side');
|
||||
|
||||
@@ -1073,6 +1205,253 @@ describe('AutoComplete widget interactions', () => {
|
||||
expect(fetchApiMock).toHaveBeenCalledWith('/lm/custom-words/search?enriched=true&search=cat&limit=100');
|
||||
});
|
||||
|
||||
it('searches wildcard keys when using the /wildcard command', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({
|
||||
success: true,
|
||||
words: ['animals/cat'],
|
||||
meta: {
|
||||
has_wildcards: true,
|
||||
wildcards_dir: '/tmp/settings/wildcards',
|
||||
supported_formats: ['.txt', '.yaml', '.yml', '.json'],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard cat');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '/wildcard cat';
|
||||
input.selectionStart = input.value.length;
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
fetchApiMock.mockClear();
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fetchApiMock).toHaveBeenCalledWith('/lm/wildcards/search?search=cat&limit=100');
|
||||
expect(autoComplete.searchType).toBe('wildcards');
|
||||
expect(autoComplete.items).toEqual(['animals/cat']);
|
||||
});
|
||||
|
||||
it('shows wildcard onboarding when /wildcard is used before any files exist', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({
|
||||
success: true,
|
||||
words: [],
|
||||
meta: {
|
||||
has_wildcards: false,
|
||||
wildcards_dir: '/tmp/settings/wildcards',
|
||||
supported_formats: ['.txt', '.yaml', '.yml', '.json'],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard cat');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '/wildcard cat';
|
||||
input.selectionStart = input.value.length;
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(autoComplete.isVisible).toBe(true);
|
||||
expect(autoComplete.items).toHaveLength(1);
|
||||
expect(autoComplete.items[0].type).toBe('wildcard_empty_state');
|
||||
expect(autoComplete.dropdown.textContent).toContain('No wildcards found yet');
|
||||
expect(autoComplete.dropdown.textContent).toContain('/tmp/settings/wildcards');
|
||||
expect(autoComplete.dropdown.textContent).toContain('.txt, .yaml, .yml, .json');
|
||||
});
|
||||
|
||||
it('shows wildcard onboarding when only the /wildcard command is entered', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({
|
||||
success: true,
|
||||
words: [],
|
||||
meta: {
|
||||
has_wildcards: false,
|
||||
wildcards_dir: '/tmp/settings/wildcards',
|
||||
supported_formats: ['.txt', '.yaml', '.yml', '.json'],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard ');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '/wildcard ';
|
||||
input.selectionStart = input.value.length;
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fetchApiMock).toHaveBeenCalledWith('/lm/wildcards/search?search=&limit=100');
|
||||
expect(autoComplete.isVisible).toBe(true);
|
||||
expect(autoComplete.items).toHaveLength(1);
|
||||
expect(autoComplete.items[0].type).toBe('wildcard_empty_state');
|
||||
expect(autoComplete.dropdown.textContent).toContain('No wildcards found yet');
|
||||
});
|
||||
|
||||
it('shows a lightweight no-match state when wildcard files exist but search misses', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({
|
||||
success: true,
|
||||
words: [],
|
||||
meta: {
|
||||
has_wildcards: true,
|
||||
wildcards_dir: '/tmp/settings/wildcards',
|
||||
supported_formats: ['.txt', '.yaml', '.yml', '.json'],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard dragon');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '/wildcard dragon';
|
||||
input.selectionStart = input.value.length;
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(autoComplete.items).toHaveLength(1);
|
||||
expect(autoComplete.items[0].type).toBe('wildcard_no_matches');
|
||||
expect(autoComplete.dropdown.textContent).toContain('No wildcard matches');
|
||||
expect(autoComplete.dropdown.textContent).not.toContain('Open wildcards folder');
|
||||
});
|
||||
|
||||
it('inserts wildcard references when accepting a /wildcard result', async () => {
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard animals/cat');
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '/wildcard animals/cat';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
autoComplete.searchType = 'wildcards';
|
||||
autoComplete.activeCommand = { type: 'wildcard', label: 'Wildcards' };
|
||||
autoComplete.items = ['animals/cat'];
|
||||
autoComplete.selectedIndex = 0;
|
||||
|
||||
await autoComplete.insertSelection('animals/cat');
|
||||
|
||||
expect(input.value).toBe('__animals/cat__,');
|
||||
expect(input.focus).toHaveBeenCalled();
|
||||
expect(input.setSelectionRange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not reopen autocomplete on blur after inserting a wildcard literal', async () => {
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '__flower__,';
|
||||
input.selectionStart = input.value.length;
|
||||
document.body.append(input);
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('__flower__,');
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
const hideSpy = vi.spyOn(autoComplete, 'hide');
|
||||
input.dispatchEvent(new Event('blur', { bubbles: true }));
|
||||
|
||||
expect(fetchApiMock).not.toHaveBeenCalled();
|
||||
expect(hideSpy).toHaveBeenCalled();
|
||||
expect(autoComplete.isVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a command after a wildcard literal as the active token', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({
|
||||
success: true,
|
||||
words: [{ tag_name: 'flower_field', category: 4, post_count: 1234 }],
|
||||
}),
|
||||
});
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '__flower__ /character f';
|
||||
input.selectionStart = input.value.length;
|
||||
document.body.append(input);
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('__flower__ /character f');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(autoComplete.getSearchTerm(input.value)).toBe('/character f');
|
||||
});
|
||||
|
||||
it('invalidates stale autocomplete metadata and falls back to delimiter-based matching', async () => {
|
||||
settingGetMock.mockImplementation((key) => {
|
||||
if (key === 'loramanager.autocomplete_append_comma') {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -349,4 +349,59 @@ describe('ModelVersionsTab media rendering', () => {
|
||||
);
|
||||
expect(firstBadges).toContain('Newer Version');
|
||||
});
|
||||
|
||||
it('shows downloaded badge only for previously downloaded versions that are not in library', async () => {
|
||||
fetchModelUpdateVersions.mockResolvedValue({
|
||||
success: true,
|
||||
record: {
|
||||
shouldIgnore: false,
|
||||
inLibraryVersionIds: [8],
|
||||
versions: [
|
||||
{
|
||||
versionId: 9,
|
||||
name: 'History only',
|
||||
baseModel: 'SDXL',
|
||||
previewUrl: '/api/lm/previews/v9.png',
|
||||
sizeBytes: 1024,
|
||||
isInLibrary: false,
|
||||
hasBeenDownloaded: true,
|
||||
shouldIgnore: false,
|
||||
},
|
||||
{
|
||||
versionId: 8,
|
||||
name: 'Local copy',
|
||||
baseModel: 'SDXL',
|
||||
previewUrl: '/api/lm/previews/v8.png',
|
||||
sizeBytes: 2048,
|
||||
isInLibrary: true,
|
||||
hasBeenDownloaded: true,
|
||||
shouldIgnore: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const { initVersionsTab } = await import(MODEL_VERSIONS_MODULE);
|
||||
const controller = initVersionsTab({
|
||||
modalId: 'model-versions-modal',
|
||||
modelType: 'loras',
|
||||
modelId: 654,
|
||||
currentVersionId: 8,
|
||||
});
|
||||
|
||||
await controller.load();
|
||||
|
||||
const rows = document.querySelectorAll('.model-version-row');
|
||||
const historyBadges = Array.from(rows[0].querySelectorAll('.version-badge')).map(
|
||||
badge => badge.textContent?.trim()
|
||||
);
|
||||
const localBadges = Array.from(rows[1].querySelectorAll('.version-badge')).map(
|
||||
badge => badge.textContent?.trim()
|
||||
);
|
||||
|
||||
expect(historyBadges).toContain('Downloaded');
|
||||
expect(historyBadges).not.toContain('In Library');
|
||||
expect(localBadges).toContain('In Library');
|
||||
expect(localBadges).not.toContain('Downloaded');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,4 +53,26 @@ describe('DoctorManager', () => {
|
||||
|
||||
expect(refreshSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('builds a cache-busted reload URL that preserves the current location', () => {
|
||||
renderDoctorFixture();
|
||||
window.history.replaceState({}, '', '/loras?filter=active#details');
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1234567890);
|
||||
|
||||
const manager = new DoctorManager();
|
||||
|
||||
const url = manager.buildReloadUrl();
|
||||
|
||||
expect(url).toBe('http://localhost:3000/loras?filter=active&_lm_reload=1234567890#details');
|
||||
});
|
||||
|
||||
it('delegates reload-page actions to reloadUi', async () => {
|
||||
renderDoctorFixture();
|
||||
const manager = new DoctorManager();
|
||||
const reloadSpy = vi.spyOn(manager, 'reloadUi').mockImplementation(() => undefined);
|
||||
|
||||
await manager.handleAction('reload-page');
|
||||
|
||||
expect(reloadSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
139
tests/frontend/managers/downloadManager.history.test.js
Normal file
139
tests/frontend/managers/downloadManager.history.test.js
Normal file
@@ -0,0 +1,139 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
DOWNLOAD_MANAGER_MODULE,
|
||||
MODAL_MANAGER_MODULE,
|
||||
UI_HELPERS_MODULE,
|
||||
STATE_MODULE,
|
||||
LOADING_MANAGER_MODULE,
|
||||
API_FACTORY_MODULE,
|
||||
STORAGE_HELPERS_MODULE,
|
||||
FOLDER_TREE_MANAGER_MODULE,
|
||||
I18N_HELPERS_MODULE,
|
||||
} = vi.hoisted(() => ({
|
||||
DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname,
|
||||
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
|
||||
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
|
||||
LOADING_MANAGER_MODULE: new URL('../../../static/js/managers/LoadingManager.js', import.meta.url).pathname,
|
||||
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
|
||||
STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
|
||||
FOLDER_TREE_MANAGER_MODULE: new URL('../../../static/js/components/FolderTreeManager.js', import.meta.url).pathname,
|
||||
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||
}));
|
||||
|
||||
vi.mock(MODAL_MANAGER_MODULE, () => ({
|
||||
modalManager: {
|
||||
showModal: vi.fn(),
|
||||
closeModal: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||
showToast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(STATE_MODULE, () => ({
|
||||
state: {
|
||||
global: {
|
||||
settings: {},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock(LOADING_MANAGER_MODULE, () => ({
|
||||
LoadingManager: vi.fn(() => ({
|
||||
showSimpleLoading: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
restoreProgressBar: vi.fn(),
|
||||
showDownloadProgress: vi.fn(() => vi.fn()),
|
||||
setStatus: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock(API_FACTORY_MODULE, () => ({
|
||||
getModelApiClient: vi.fn(() => ({
|
||||
apiConfig: {
|
||||
config: {
|
||||
displayName: 'LoRA',
|
||||
singularName: 'lora',
|
||||
},
|
||||
},
|
||||
})),
|
||||
resetAndReload: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(STORAGE_HELPERS_MODULE, () => ({
|
||||
getStorageItem: vi.fn((_key, defaultValue) => defaultValue),
|
||||
setStorageItem: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(FOLDER_TREE_MANAGER_MODULE, () => ({
|
||||
FolderTreeManager: vi.fn(() => ({
|
||||
clearSelection: vi.fn(),
|
||||
init: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock(I18N_HELPERS_MODULE, () => ({
|
||||
translate: vi.fn((_, __, fallback) => fallback ?? ''),
|
||||
}));
|
||||
|
||||
describe('DownloadManager version history badges', () => {
|
||||
let DownloadManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
document.body.innerHTML = `
|
||||
<div id="urlStep"></div>
|
||||
<div id="versionStep"></div>
|
||||
<div id="versionList"></div>
|
||||
<button id="nextFromVersion"></button>
|
||||
`;
|
||||
({ DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('shows downloaded badge only for versions missing locally', () => {
|
||||
const manager = new DownloadManager();
|
||||
manager.versions = [
|
||||
{
|
||||
id: 101,
|
||||
name: 'History only',
|
||||
images: [],
|
||||
files: [{ sizeKB: 2048 }],
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
existsLocally: false,
|
||||
hasBeenDownloaded: true,
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
name: 'Still local',
|
||||
images: [],
|
||||
files: [{ sizeKB: 2048 }],
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
existsLocally: true,
|
||||
hasBeenDownloaded: true,
|
||||
localPath: '/models/still-local.safetensors',
|
||||
},
|
||||
];
|
||||
|
||||
manager.showVersionStep();
|
||||
|
||||
const items = document.querySelectorAll('.version-item');
|
||||
expect(items).toHaveLength(2);
|
||||
|
||||
expect(items[0].querySelector('.downloaded-badge')?.textContent).toContain('Downloaded');
|
||||
expect(items[0].querySelector('.downloaded-badge')?.getAttribute('title')).toContain(
|
||||
'Previously downloaded, but it is not currently in your library.'
|
||||
);
|
||||
expect(items[0].querySelector('.local-badge')).toBeNull();
|
||||
|
||||
expect(items[1].querySelector('.local-badge')).not.toBeNull();
|
||||
expect(items[1].querySelector('.local-path')?.textContent).toContain('/models/still-local.safetensors');
|
||||
expect(items[1].querySelector('.downloaded-badge')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -96,6 +96,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
delete global.fetch;
|
||||
delete document.hidden;
|
||||
Object.defineProperty(window, 'location', {
|
||||
@@ -231,6 +232,51 @@ describe('SettingsManager library controls', () => {
|
||||
expect(input.value).toBe('/custom/recipes');
|
||||
});
|
||||
|
||||
it('does not autofocus empty extra folder path rows during initial settings load', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const manager = createManager();
|
||||
document.body.innerHTML = `
|
||||
<div id="extraFolderPaths-loras"></div>
|
||||
<div id="extraFolderPaths-checkpoints"></div>
|
||||
<div id="extraFolderPaths-unet"></div>
|
||||
<div id="extraFolderPaths-embeddings"></div>
|
||||
`;
|
||||
|
||||
vi.spyOn(manager, 'loadMetadataArchiveSettings').mockResolvedValue();
|
||||
vi.spyOn(manager, 'loadBackupSettings').mockResolvedValue();
|
||||
vi.spyOn(manager, 'loadLibraries').mockResolvedValue();
|
||||
vi.spyOn(manager, 'loadLoraRoots').mockResolvedValue();
|
||||
vi.spyOn(manager, 'loadCheckpointRoots').mockResolvedValue();
|
||||
vi.spyOn(manager, 'loadUnetRoots').mockResolvedValue();
|
||||
vi.spyOn(manager, 'loadEmbeddingRoots').mockResolvedValue();
|
||||
|
||||
const focusSpy = vi.spyOn(HTMLElement.prototype, 'focus').mockImplementation(() => {});
|
||||
|
||||
state.global.settings = {
|
||||
extra_folder_paths: {},
|
||||
};
|
||||
|
||||
await manager.loadSettingsToUI();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(focusSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still focuses an extra folder path row when it is added explicitly', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const manager = createManager();
|
||||
document.body.innerHTML = '<div id="extraFolderPaths-embeddings"></div>';
|
||||
|
||||
const focusSpy = vi.spyOn(HTMLElement.prototype, 'focus').mockImplementation(() => {});
|
||||
|
||||
manager.addExtraFolderPathRow('embeddings', '');
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(focusSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows loading while saving recipes_path', async () => {
|
||||
const manager = createManager();
|
||||
const input = document.createElement('input');
|
||||
|
||||
@@ -212,6 +212,19 @@ describe('RecipeManager', () => {
|
||||
expect(refreshVirtualScrollMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('supports preserve-scroll options while keeping boolean compatibility', async () => {
|
||||
const manager = new RecipeManager();
|
||||
|
||||
await manager.loadRecipes({ preserveScroll: true });
|
||||
expect(refreshVirtualScrollMock).toHaveBeenNthCalledWith(1, { preserveScroll: true });
|
||||
|
||||
await manager.loadRecipes(false);
|
||||
expect(refreshVirtualScrollMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await manager.loadRecipes({ resetPage: true, preserveScroll: false });
|
||||
expect(refreshVirtualScrollMock).toHaveBeenNthCalledWith(2, { preserveScroll: false });
|
||||
});
|
||||
|
||||
it('proxies duplicate management and refresh helpers', async () => {
|
||||
const manager = new RecipeManager();
|
||||
|
||||
|
||||
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
|
||||
@@ -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 = []
|
||||
|
||||
66
tests/routes/test_model_page_view.py
Normal file
66
tests/routes/test_model_page_view.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import jinja2
|
||||
|
||||
from py.routes.handlers.model_handlers import ModelPageView
|
||||
|
||||
|
||||
class DummySettings:
|
||||
def get(self, key, default=None):
|
||||
return default
|
||||
|
||||
|
||||
class DummyI18n:
|
||||
def __init__(self):
|
||||
self.locale = None
|
||||
|
||||
def set_locale(self, locale):
|
||||
self.locale = locale
|
||||
|
||||
def get_translation(self, key, default=None, **_kwargs):
|
||||
return default or key
|
||||
|
||||
def create_template_filter(self):
|
||||
return lambda key, *_args, **_kwargs: key
|
||||
|
||||
|
||||
class DummyScanner:
|
||||
def __init__(self):
|
||||
self._cache = SimpleNamespace()
|
||||
|
||||
async def get_cached_data(self, *_args, **_kwargs):
|
||||
return SimpleNamespace(folders=[])
|
||||
|
||||
|
||||
class DummyService:
|
||||
def __init__(self):
|
||||
self.scanner = DummyScanner()
|
||||
|
||||
|
||||
async def test_model_page_view_reads_version_per_request():
|
||||
template_env = jinja2.Environment(
|
||||
loader=jinja2.DictLoader({"dummy.html": "{{ version }}"}),
|
||||
autoescape=True,
|
||||
)
|
||||
view = ModelPageView(
|
||||
template_env=template_env,
|
||||
template_name="dummy.html",
|
||||
service=DummyService(),
|
||||
settings_service=DummySettings(),
|
||||
server_i18n=DummyI18n(),
|
||||
logger=SimpleNamespace(
|
||||
debug=lambda *_args, **_kwargs: None,
|
||||
error=lambda *_args, **_kwargs: None,
|
||||
),
|
||||
)
|
||||
|
||||
view._get_app_version = lambda: "1.0.2-old"
|
||||
first = await view.handle(SimpleNamespace())
|
||||
|
||||
view._get_app_version = lambda: "1.0.2-new"
|
||||
second = await view.handle(SimpleNamespace())
|
||||
|
||||
assert first.text == "1.0.2-old"
|
||||
assert second.text == "1.0.2-new"
|
||||
@@ -6,7 +6,8 @@ from types import SimpleNamespace
|
||||
import pytest
|
||||
|
||||
from py.config import config
|
||||
from py.routes.handlers.model_handlers import ModelUpdateHandler
|
||||
from py.routes.handlers.model_handlers import ModelCivitaiHandler, ModelUpdateHandler
|
||||
from py.services.service_registry import ServiceRegistry
|
||||
from py.utils.metadata_manager import MetadataManager
|
||||
from py.services.model_update_service import ModelUpdateRecord, ModelVersionRecord
|
||||
|
||||
@@ -91,7 +92,131 @@ async def test_build_version_context_includes_static_urls():
|
||||
|
||||
overrides = await handler._build_version_context(record)
|
||||
expected = config.get_preview_static_url("/tmp/previews/example.png")
|
||||
assert overrides == {123: {"file_path": None, "file_name": None, "preview_override": expected}}
|
||||
assert overrides == {
|
||||
123: {
|
||||
"file_path": None,
|
||||
"file_name": None,
|
||||
"preview_override": expected,
|
||||
"has_been_downloaded": False,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_version_context_includes_download_history(monkeypatch):
|
||||
cache = SimpleNamespace(version_index={})
|
||||
service = DummyService(cache)
|
||||
handler = ModelUpdateHandler(
|
||||
service=service,
|
||||
update_service=SimpleNamespace(),
|
||||
metadata_provider_selector=lambda *_: None,
|
||||
settings_service=SimpleNamespace(get=lambda *_: False),
|
||||
logger=logging.getLogger(__name__),
|
||||
)
|
||||
|
||||
class DummyHistoryService:
|
||||
async def get_downloaded_version_ids(self, model_type, model_id):
|
||||
assert model_type == "lora"
|
||||
assert model_id == 42
|
||||
return [123]
|
||||
|
||||
async def fake_history_service_factory():
|
||||
return DummyHistoryService()
|
||||
|
||||
monkeypatch.setattr(
|
||||
ServiceRegistry,
|
||||
"get_downloaded_version_history_service",
|
||||
staticmethod(fake_history_service_factory),
|
||||
)
|
||||
|
||||
record = ModelUpdateRecord(
|
||||
model_type="lora",
|
||||
model_id=42,
|
||||
versions=[
|
||||
ModelVersionRecord(
|
||||
version_id=123,
|
||||
name="Downloaded",
|
||||
base_model=None,
|
||||
released_at=None,
|
||||
size_bytes=None,
|
||||
preview_url=None,
|
||||
is_in_library=False,
|
||||
should_ignore=False,
|
||||
),
|
||||
ModelVersionRecord(
|
||||
version_id=124,
|
||||
name="Fresh",
|
||||
base_model=None,
|
||||
released_at=None,
|
||||
size_bytes=None,
|
||||
preview_url=None,
|
||||
is_in_library=False,
|
||||
should_ignore=False,
|
||||
),
|
||||
],
|
||||
last_checked_at=None,
|
||||
should_ignore_model=False,
|
||||
)
|
||||
|
||||
overrides = await handler._build_version_context(record)
|
||||
assert overrides[123]["has_been_downloaded"] is True
|
||||
assert overrides[124]["has_been_downloaded"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_civitai_versions_degrades_when_download_history_unavailable(monkeypatch):
|
||||
cache = SimpleNamespace(version_index={})
|
||||
service = DummyService(cache)
|
||||
|
||||
class DummyProvider:
|
||||
async def get_model_versions(self, model_id):
|
||||
assert model_id == "42"
|
||||
return {
|
||||
"type": "lora",
|
||||
"modelVersions": [
|
||||
{
|
||||
"id": 7,
|
||||
"name": "Version 7",
|
||||
"files": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
async def fake_history_service_factory():
|
||||
raise RuntimeError("download history unavailable")
|
||||
|
||||
monkeypatch.setattr(
|
||||
ServiceRegistry,
|
||||
"get_downloaded_version_history_service",
|
||||
staticmethod(fake_history_service_factory),
|
||||
)
|
||||
|
||||
async def metadata_provider_factory():
|
||||
return DummyProvider()
|
||||
|
||||
handler = ModelCivitaiHandler(
|
||||
service=service,
|
||||
settings_service=SimpleNamespace(get=lambda *_: False),
|
||||
ws_manager=SimpleNamespace(),
|
||||
logger=logging.getLogger(__name__),
|
||||
metadata_provider_factory=metadata_provider_factory,
|
||||
validate_model_type=lambda *_: True,
|
||||
expected_model_types=lambda: "LoRA",
|
||||
find_model_file=lambda *_: None,
|
||||
metadata_sync=SimpleNamespace(),
|
||||
metadata_refresh_use_case=SimpleNamespace(),
|
||||
metadata_progress_callback=lambda *_args, **_kwargs: None,
|
||||
)
|
||||
|
||||
response = await handler.get_civitai_versions(
|
||||
SimpleNamespace(match_info={"model_id": "42"})
|
||||
)
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert response.status == 200
|
||||
assert payload[0]["id"] == 7
|
||||
assert payload[0]["existsLocally"] is False
|
||||
assert payload[0]["hasBeenDownloaded"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
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"
|
||||
@@ -1,95 +1,86 @@
|
||||
import pytest
|
||||
from py.recipes.merger import GenParamsMerger
|
||||
|
||||
def test_merge_priority():
|
||||
request_params = {"prompt": "from request", "steps": 20}
|
||||
civitai_meta = {"prompt": "from civitai", "cfg": 7.0}
|
||||
|
||||
def test_merge_priority_and_normalization():
|
||||
request_params = {"prompt": "from request", "Steps": 20, "cfg": 7.5}
|
||||
civitai_meta = {"prompt": "from civitai", "cfgScale": 6.5, "negativePrompt": "bad"}
|
||||
embedded_metadata = {"gen_params": {"prompt": "from embedded", "seed": 123}}
|
||||
|
||||
|
||||
merged = GenParamsMerger.merge(request_params, civitai_meta, embedded_metadata)
|
||||
|
||||
assert merged["prompt"] == "from request"
|
||||
assert merged["steps"] == 20
|
||||
assert merged["cfg"] == 7.0
|
||||
assert merged["seed"] == 123
|
||||
|
||||
def test_merge_no_request_params():
|
||||
civitai_meta = {"prompt": "from civitai", "cfg": 7.0}
|
||||
embedded_metadata = {"gen_params": {"prompt": "from embedded", "seed": 123}}
|
||||
|
||||
merged = GenParamsMerger.merge(None, civitai_meta, embedded_metadata)
|
||||
|
||||
assert merged["prompt"] == "from civitai"
|
||||
assert merged["cfg"] == 7.0
|
||||
assert merged["seed"] == 123
|
||||
assert merged == {
|
||||
"prompt": "from request",
|
||||
"steps": 20,
|
||||
"cfg_scale": 7.5,
|
||||
"negative_prompt": "bad",
|
||||
"seed": 123,
|
||||
}
|
||||
|
||||
|
||||
def test_merge_accepts_raw_embedded_metadata():
|
||||
embedded_metadata = {"prompt": "from raw embedded", "seed": 456, "scheduler": "karras"}
|
||||
|
||||
def test_merge_only_embedded():
|
||||
embedded_metadata = {"gen_params": {"prompt": "from embedded", "seed": 123}}
|
||||
|
||||
merged = GenParamsMerger.merge(None, None, embedded_metadata)
|
||||
|
||||
assert merged["prompt"] == "from embedded"
|
||||
assert merged["seed"] == 123
|
||||
|
||||
def test_merge_raw_embedded():
|
||||
# Test when embedded metadata is just the gen_params themselves
|
||||
embedded_metadata = {"prompt": "from raw embedded", "seed": 456}
|
||||
|
||||
merged = GenParamsMerger.merge(None, None, embedded_metadata)
|
||||
|
||||
assert merged["prompt"] == "from raw embedded"
|
||||
assert merged["seed"] == 456
|
||||
assert merged == {
|
||||
"prompt": "from raw embedded",
|
||||
"seed": 456,
|
||||
"sampler": "karras",
|
||||
}
|
||||
|
||||
def test_merge_none_values():
|
||||
merged = GenParamsMerger.merge(None, None, None)
|
||||
assert merged == {}
|
||||
|
||||
def test_merge_filters_blacklisted_keys():
|
||||
request_params = {"prompt": "test", "id": "should-be-removed", "checkpoint": "should-not-be-here"}
|
||||
civitai_meta = {"cfg": 7, "url": "remove-me"}
|
||||
embedded_metadata = {"seed": 123, "hash": "remove-also"}
|
||||
|
||||
merged = GenParamsMerger.merge(request_params, civitai_meta, embedded_metadata)
|
||||
|
||||
assert "prompt" in merged
|
||||
assert "cfg" in merged
|
||||
assert "seed" in merged
|
||||
assert "id" not in merged
|
||||
assert "url" not in merged
|
||||
assert "hash" not in merged
|
||||
assert "checkpoint" not in merged
|
||||
|
||||
def test_merge_filters_meta_and_normalizes_keys():
|
||||
def test_merge_filters_unknown_and_blacklisted_keys():
|
||||
request_params = {
|
||||
"prompt": "test",
|
||||
"id": "should-be-removed",
|
||||
"checkpoint": "should-not-be-here",
|
||||
"raw_metadata": {"prompt": "remove"},
|
||||
}
|
||||
civitai_meta = {
|
||||
"Version": "ComfyUI",
|
||||
"RNG": "cpu",
|
||||
"cfgScale": 7,
|
||||
"url": "remove-me",
|
||||
}
|
||||
embedded_metadata = {
|
||||
"seed": 123,
|
||||
"hash": "remove-also",
|
||||
"Discard penultimate sigma": True,
|
||||
"eps_scaling_factor": 0.1,
|
||||
}
|
||||
|
||||
merged = GenParamsMerger.merge(request_params, civitai_meta, embedded_metadata)
|
||||
|
||||
assert merged == {
|
||||
"prompt": "test",
|
||||
"cfg_scale": 7,
|
||||
"seed": 123,
|
||||
}
|
||||
|
||||
|
||||
def test_merge_does_not_keep_original_key_variants():
|
||||
civitai_meta = {
|
||||
"prompt": "masterpiece",
|
||||
"cfgScale": 5,
|
||||
"clipSkip": 2,
|
||||
"negativePrompt": "low quality",
|
||||
"meta": {"irrelevant": "data"},
|
||||
"Size": "1024x1024",
|
||||
"draft": False,
|
||||
"workflow": "txt2img",
|
||||
"civitaiResources": [{"type": "checkpoint"}]
|
||||
"Denoising strength": 0.35,
|
||||
}
|
||||
request_params = {
|
||||
"cfg_scale": 5.0,
|
||||
"clip_skip": "2",
|
||||
"Steps": 30
|
||||
}
|
||||
|
||||
|
||||
merged = GenParamsMerger.merge(request_params, civitai_meta)
|
||||
|
||||
assert "meta" not in merged
|
||||
assert "cfgScale" not in merged
|
||||
assert "clipSkip" not in merged
|
||||
assert "negativePrompt" not in merged
|
||||
assert "Size" not in merged
|
||||
assert "draft" not in merged
|
||||
assert "workflow" not in merged
|
||||
assert "civitaiResources" not in merged
|
||||
|
||||
assert merged["cfg_scale"] == 5.0 # From request_params
|
||||
assert merged["clip_skip"] == "2" # From request_params
|
||||
assert merged["negative_prompt"] == "low quality" # Normalized from civitai_meta
|
||||
assert merged["size"] == "1024x1024" # Normalized from civitai_meta
|
||||
assert merged["steps"] == 30 # Normalized from request_params
|
||||
|
||||
assert merged == {
|
||||
"cfg_scale": 5.0,
|
||||
"clip_skip": "2",
|
||||
"negative_prompt": "low quality",
|
||||
"size": "1024x1024",
|
||||
"denoising_strength": 0.35,
|
||||
}
|
||||
|
||||
|
||||
def test_merge_none_values():
|
||||
assert GenParamsMerger.merge(None, None, None) == {}
|
||||
|
||||
@@ -358,6 +358,188 @@ async def test_get_recipe_by_id_handles_non_dict_checkpoint(recipe_scanner):
|
||||
assert recipe["checkpoint"]["file_name"] == "by-id"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_recipe_by_id_merges_recipe_json_details(recipe_scanner):
|
||||
scanner, _ = recipe_scanner
|
||||
recipes_dir = Path(scanner.recipes_dir)
|
||||
recipe_id = "hydrate-me"
|
||||
recipe_json_path = recipes_dir / f"{recipe_id}.recipe.json"
|
||||
recipe_json_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"id": recipe_id,
|
||||
"file_path": "/tmp/hydrate-me.png",
|
||||
"title": "Hydrated Recipe",
|
||||
"source_path": "https://example.com/source",
|
||||
"gen_params": {
|
||||
"prompt": "prompt from json",
|
||||
"negative_prompt": "negative from json",
|
||||
},
|
||||
"loras": [],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
scanner._cache.raw_data = [
|
||||
{
|
||||
"id": recipe_id,
|
||||
"file_path": "/tmp/hydrate-me.png",
|
||||
"title": "Cached Recipe",
|
||||
"folder": "",
|
||||
"modified": 0.0,
|
||||
"created_date": 0.0,
|
||||
"loras": [],
|
||||
"gen_params": {},
|
||||
}
|
||||
]
|
||||
|
||||
recipe = await scanner.get_recipe_by_id(recipe_id)
|
||||
|
||||
assert recipe is not None
|
||||
assert recipe["title"] == "Hydrated Recipe"
|
||||
assert recipe["source_path"] == "https://example.com/source"
|
||||
assert recipe["gen_params"]["prompt"] == "prompt from json"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_recipe_by_id_normalizes_gen_params_aliases_without_dropping_metadata(
|
||||
recipe_scanner,
|
||||
):
|
||||
scanner, _ = recipe_scanner
|
||||
recipes_dir = Path(scanner.recipes_dir)
|
||||
recipe_id = "dirty-json-gen-params"
|
||||
recipe_json_path = recipes_dir / f"{recipe_id}.recipe.json"
|
||||
recipe_json_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"id": recipe_id,
|
||||
"file_path": "/tmp/dirty-json-gen-params.png",
|
||||
"title": "Dirty Recipe",
|
||||
"gen_params": {
|
||||
"Prompt": "prompt from json",
|
||||
"negativePrompt": "negative from json",
|
||||
"cfgScale": 7,
|
||||
"raw_metadata": {"prompt": "nested"},
|
||||
"Version": "ComfyUI",
|
||||
"RNG": "cpu",
|
||||
},
|
||||
"loras": [],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
scanner._cache.raw_data = [
|
||||
{
|
||||
"id": recipe_id,
|
||||
"file_path": "/tmp/dirty-json-gen-params.png",
|
||||
"title": "Cached Recipe",
|
||||
"folder": "",
|
||||
"modified": 0.0,
|
||||
"created_date": 0.0,
|
||||
"loras": [],
|
||||
"gen_params": {"prompt": "cached prompt", "raw_metadata": {"bad": True}},
|
||||
}
|
||||
]
|
||||
|
||||
recipe = await scanner.get_recipe_by_id(recipe_id)
|
||||
|
||||
assert recipe is not None
|
||||
assert recipe["gen_params"]["Prompt"] == "prompt from json"
|
||||
assert recipe["gen_params"]["negativePrompt"] == "negative from json"
|
||||
assert recipe["gen_params"]["cfgScale"] == 7
|
||||
assert recipe["gen_params"]["raw_metadata"] == {"prompt": "nested"}
|
||||
assert recipe["gen_params"]["Version"] == "ComfyUI"
|
||||
assert recipe["gen_params"]["RNG"] == "cpu"
|
||||
assert recipe["gen_params"]["prompt"] == "prompt from json"
|
||||
assert recipe["gen_params"]["negative_prompt"] == "negative from json"
|
||||
assert recipe["gen_params"]["cfg_scale"] == 7
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_recipe_by_id_prefers_json_file_path(recipe_scanner):
|
||||
scanner, _ = recipe_scanner
|
||||
recipes_dir = Path(scanner.recipes_dir)
|
||||
recipe_id = "move-me"
|
||||
recipe_json_path = recipes_dir / f"{recipe_id}.recipe.json"
|
||||
recipe_json_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"id": recipe_id,
|
||||
"file_path": "/tmp/new-location.png",
|
||||
"title": "Moved Recipe",
|
||||
"source_path": "https://example.com/moved",
|
||||
"gen_params": {},
|
||||
"loras": [],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
scanner._cache.raw_data = [
|
||||
{
|
||||
"id": recipe_id,
|
||||
"file_path": "/tmp/old-location.png",
|
||||
"title": "Cached Title",
|
||||
"folder": "",
|
||||
"modified": 0.0,
|
||||
"created_date": 0.0,
|
||||
"loras": [],
|
||||
"gen_params": {},
|
||||
}
|
||||
]
|
||||
|
||||
recipe = await scanner.get_recipe_by_id(recipe_id)
|
||||
|
||||
assert recipe is not None
|
||||
assert recipe["file_path"] == "/tmp/new-location.png"
|
||||
assert recipe["title"] == "Moved Recipe"
|
||||
assert recipe["source_path"] == "https://example.com/moved"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_recipe_by_id_drops_deleted_optional_json_fields(recipe_scanner):
|
||||
scanner, _ = recipe_scanner
|
||||
recipes_dir = Path(scanner.recipes_dir)
|
||||
recipe_id = "drop-optional-fields"
|
||||
recipe_json_path = recipes_dir / f"{recipe_id}.recipe.json"
|
||||
recipe_json_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"id": recipe_id,
|
||||
"file_path": "/tmp/drop-optional-fields.png",
|
||||
"title": "Trimmed Recipe",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
scanner._cache.raw_data = [
|
||||
{
|
||||
"id": recipe_id,
|
||||
"file_path": "/tmp/drop-optional-fields.png",
|
||||
"title": "Cached Recipe",
|
||||
"folder": "",
|
||||
"modified": 0.0,
|
||||
"created_date": 0.0,
|
||||
"source_path": "https://example.com/stale-source",
|
||||
"checkpoint": {"name": "stale-checkpoint.safetensors"},
|
||||
"loras": [{"modelName": "stale-lora"}],
|
||||
"gen_params": {"prompt": "stale prompt"},
|
||||
}
|
||||
]
|
||||
|
||||
recipe = await scanner.get_recipe_by_id(recipe_id)
|
||||
|
||||
assert recipe is not None
|
||||
assert recipe["title"] == "Trimmed Recipe"
|
||||
assert "source_path" not in recipe
|
||||
assert "checkpoint" not in recipe
|
||||
assert "gen_params" not in recipe
|
||||
assert "loras" not in recipe
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_paginated_data_filters_by_checkpoint_hash(recipe_scanner):
|
||||
scanner, _ = recipe_scanner
|
||||
@@ -401,6 +583,40 @@ async def test_get_paginated_data_filters_by_checkpoint_hash(recipe_scanner):
|
||||
assert [item["id"] for item in result["items"]] == ["checkpoint-match"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_paginated_data_normalizes_gen_params_aliases_without_dropping_metadata(
|
||||
recipe_scanner,
|
||||
):
|
||||
scanner, _ = recipe_scanner
|
||||
await scanner.add_recipe(
|
||||
{
|
||||
"id": "dirty-listing",
|
||||
"file_path": str(Path(config.loras_roots[0]) / "dirty-listing.webp"),
|
||||
"title": "Dirty Listing",
|
||||
"modified": 0.0,
|
||||
"created_date": 0.0,
|
||||
"loras": [],
|
||||
"gen_params": {
|
||||
"Prompt": "a beautiful forest landscape",
|
||||
"cfgScale": 7,
|
||||
"Version": "ComfyUI",
|
||||
"raw_metadata": {"bad": True},
|
||||
},
|
||||
}
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
result = await scanner.get_paginated_data(page=1, page_size=10)
|
||||
item = next(entry for entry in result["items"] if entry["id"] == "dirty-listing")
|
||||
|
||||
assert item["gen_params"]["Prompt"] == "a beautiful forest landscape"
|
||||
assert item["gen_params"]["cfgScale"] == 7
|
||||
assert item["gen_params"]["Version"] == "ComfyUI"
|
||||
assert item["gen_params"]["raw_metadata"] == {"bad": True}
|
||||
assert item["gen_params"]["prompt"] == "a beautiful forest landscape"
|
||||
assert item["gen_params"]["cfg_scale"] == 7
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_recipes_for_checkpoint_matches_hash_case_insensitively(recipe_scanner):
|
||||
scanner, _ = recipe_scanner
|
||||
|
||||
@@ -306,6 +306,120 @@ async def test_save_recipe_promotes_checkpoint_from_gen_params(tmp_path):
|
||||
assert "checkpoint" not in stored["gen_params"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_recipe_strips_non_persistable_gen_params(tmp_path):
|
||||
exif_utils = DummyExifUtils()
|
||||
|
||||
class DummyScanner:
|
||||
def __init__(self, root):
|
||||
self.recipes_dir = str(root)
|
||||
|
||||
async def find_recipes_by_fingerprint(self, fingerprint):
|
||||
return []
|
||||
|
||||
async def add_recipe(self, recipe_data):
|
||||
return None
|
||||
|
||||
scanner = DummyScanner(tmp_path)
|
||||
service = RecipePersistenceService(
|
||||
exif_utils=exif_utils,
|
||||
card_preview_width=512,
|
||||
logger=logging.getLogger("test"),
|
||||
)
|
||||
|
||||
metadata = {
|
||||
"base_model": "Flux",
|
||||
"loras": [],
|
||||
"gen_params": {
|
||||
"prompt": "hello world",
|
||||
"negative_prompt": "bad hands",
|
||||
"cfg_scale": 7,
|
||||
"raw_metadata": {"prompt": "should not persist"},
|
||||
"Version": "ComfyUI",
|
||||
"RNG": "cpu",
|
||||
"Schedule type": "karras",
|
||||
"Discard penultimate sigma": True,
|
||||
"eps_scaling_factor": 0.1,
|
||||
},
|
||||
}
|
||||
|
||||
result = await service.save_recipe(
|
||||
recipe_scanner=scanner,
|
||||
image_bytes=b"img",
|
||||
image_base64=None,
|
||||
name="Sanitized",
|
||||
tags=[],
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
stored = json.loads(Path(result.payload["json_path"]).read_text())
|
||||
assert stored["gen_params"] == {
|
||||
"prompt": "hello world",
|
||||
"negative_prompt": "bad hands",
|
||||
"cfg_scale": 7,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_recipe_derives_allowed_fields_from_raw_metadata(tmp_path):
|
||||
exif_utils = DummyExifUtils()
|
||||
|
||||
class DummyScanner:
|
||||
def __init__(self, root):
|
||||
self.recipes_dir = str(root)
|
||||
|
||||
async def find_recipes_by_fingerprint(self, fingerprint):
|
||||
return []
|
||||
|
||||
async def add_recipe(self, recipe_data):
|
||||
return None
|
||||
|
||||
scanner = DummyScanner(tmp_path)
|
||||
service = RecipePersistenceService(
|
||||
exif_utils=exif_utils,
|
||||
card_preview_width=512,
|
||||
logger=logging.getLogger("test"),
|
||||
)
|
||||
|
||||
metadata = {
|
||||
"base_model": "Flux",
|
||||
"loras": [],
|
||||
"raw_metadata": {
|
||||
"prompt": "hello world",
|
||||
"negative_prompt": "bad hands",
|
||||
"steps": 30,
|
||||
"sampler": "Euler",
|
||||
"cfg_scale": 7,
|
||||
"seed": 123,
|
||||
"size": "1024x1024",
|
||||
"clip_skip": 2,
|
||||
"Version": "ComfyUI",
|
||||
"raw_metadata": {"nested": True},
|
||||
},
|
||||
}
|
||||
|
||||
result = await service.save_recipe(
|
||||
recipe_scanner=scanner,
|
||||
image_bytes=b"img",
|
||||
image_base64=None,
|
||||
name="Derived",
|
||||
tags=[],
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
stored = json.loads(Path(result.payload["json_path"]).read_text())
|
||||
assert stored["gen_params"] == {
|
||||
"prompt": "hello world",
|
||||
"negative_prompt": "bad hands",
|
||||
"steps": 30,
|
||||
"sampler": "Euler",
|
||||
"cfg_scale": 7,
|
||||
"seed": 123,
|
||||
"size": "1024x1024",
|
||||
"clip_skip": 2,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_recipe_strips_checkpoint_local_fields(tmp_path):
|
||||
exif_utils = DummyExifUtils()
|
||||
|
||||
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
|
||||
|
||||
@@ -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