mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
Compare commits
20 Commits
v1.0.0
...
55a18d401b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55a18d401b | ||
|
|
7570936c75 | ||
|
|
4fcf641d57 | ||
|
|
5c29e26c4e | ||
|
|
ee765a6d22 | ||
|
|
c02f603ed2 | ||
|
|
ee84b30023 | ||
|
|
97979d9e7c | ||
|
|
cda271890a | ||
|
|
2fbe6c8843 | ||
|
|
4fb07370dd | ||
|
|
43f6bfab36 | ||
|
|
a802a89ff9 | ||
|
|
343dd91e4b | ||
|
|
3756f88368 | ||
|
|
acc625ead3 | ||
|
|
f402505f97 | ||
|
|
4d8113464c | ||
|
|
1ed503a6b5 | ||
|
|
d67914e095 |
@@ -135,7 +135,7 @@ npm run test:coverage # Generate coverage report
|
||||
- ALWAYS use English for comments (per copilot-instructions.md)
|
||||
- Dual mode: ComfyUI plugin (folder_paths) vs standalone (settings.json)
|
||||
- Detection: `os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"`
|
||||
- Run `python scripts/sync_translation_keys.py` after UI string updates
|
||||
- Run `python scripts/sync_translation_keys.py` after adding UI strings to `locales/en.json`
|
||||
- Symlinks require normalized paths
|
||||
|
||||
## Frontend UI Architecture
|
||||
|
||||
@@ -59,6 +59,7 @@ Insomnia Art Designs, megakirbs, Brennok, wackop, 2018cfh, Takkan, stone9k, $Met
|
||||
### v1.0.0
|
||||
* **Extra Folder Paths Support** - Added support for additional model root paths exclusive to LoRA Manager. This allows loading LoRAs from extra locations outside ComfyUI's standard folders, helping avoid performance issues when working with large model libraries.
|
||||
* **Settings UI Overhaul** - Redesigned the Settings interface with a more organized layout, making it easier to find and configure application settings.
|
||||
* **Lazy Hash Computation** - Implemented lazy hash calculation for large model files (checkpoints and diffusion models). Hashes are now computed only when strictly necessary, minimizing redundant disk I/O and significantly accelerating application initialization.
|
||||
* **Milestone & Supporter Recognition** - Updated the Supporter window to show appreciation for all project supporters as this v1.0.0 milestone is reached. Great thanks to the community for the ongoing support!
|
||||
* **Bug Fixes & UX Enhancements** - Various bug fixes and user experience improvements for a smoother workflow.
|
||||
|
||||
@@ -193,7 +194,7 @@ Insomnia Art Designs, megakirbs, Brennok, wackop, 2018cfh, Takkan, stone9k, $Met
|
||||
|
||||
### Option 2: **Portable Standalone Edition** (No ComfyUI required)
|
||||
|
||||
1. Download the [Portable Package](https://github.com/willmiao/ComfyUI-Lora-Manager/releases/download/v0.9.8/lora_manager_portable.7z)
|
||||
1. Download the [Portable Package](https://github.com/willmiao/ComfyUI-Lora-Manager/releases/download/v1.0.0/lora_manager_portable.7z)
|
||||
2. Copy the provided `settings.json.example` file to create a new file named `settings.json` in `comfyui-lora-manager` folder.
|
||||
3. Edit the new `settings.json` to include your correct model folder paths and CivitAI API key
|
||||
- Set `"use_portable_settings": true` if you want the configuration to remain inside the repository folder instead of your user settings directory.
|
||||
|
||||
@@ -222,7 +222,7 @@
|
||||
"presetNamePlaceholder": "Voreinstellungsname...",
|
||||
"baseModel": "Basis-Modell",
|
||||
"modelTags": "Tags (Top 20)",
|
||||
"modelTypes": "Model Types",
|
||||
"modelTypes": "Modelltypen",
|
||||
"license": "Lizenz",
|
||||
"noCreditRequired": "Kein Credit erforderlich",
|
||||
"allowSellingGeneratedContent": "Verkauf erlaubt",
|
||||
@@ -685,7 +685,11 @@
|
||||
"lorasCountAsc": "Wenigste"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Rezeptliste aktualisieren"
|
||||
"title": "Rezeptliste aktualisieren",
|
||||
"quick": "Änderungen synchronisieren",
|
||||
"quickTooltip": "Änderungen synchronisieren - schnelle Aktualisierung ohne Cache-Neubau",
|
||||
"full": "Cache neu aufbauen",
|
||||
"fullTooltip": "Cache neu aufbauen - vollständiger Rescan aller Rezeptdateien"
|
||||
},
|
||||
"filteredByLora": "Gefiltert nach LoRA",
|
||||
"favorites": {
|
||||
@@ -1396,6 +1400,8 @@
|
||||
"loadFailed": "Fehler beim Laden der {modelType}s: {message}",
|
||||
"refreshComplete": "Aktualisierung abgeschlossen",
|
||||
"refreshFailed": "Fehler beim Aktualisieren der Rezepte: {message}",
|
||||
"syncComplete": "Synchronisation abgeschlossen",
|
||||
"syncFailed": "Fehler beim Synchronisieren der Rezepte: {message}",
|
||||
"updateFailed": "Fehler beim Aktualisieren des Rezepts: {error}",
|
||||
"updateError": "Fehler beim Aktualisieren des Rezepts: {message}",
|
||||
"nameSaved": "Rezept \"{name}\" erfolgreich gespeichert",
|
||||
|
||||
@@ -685,7 +685,11 @@
|
||||
"lorasCountAsc": "Least"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Refresh recipe list"
|
||||
"title": "Refresh recipe list",
|
||||
"quick": "Sync Changes",
|
||||
"quickTooltip": "Sync changes - quick refresh without rebuilding cache",
|
||||
"full": "Rebuild Cache",
|
||||
"fullTooltip": "Rebuild cache - full rescan of all recipe files"
|
||||
},
|
||||
"filteredByLora": "Filtered by LoRA",
|
||||
"favorites": {
|
||||
@@ -1396,6 +1400,8 @@
|
||||
"loadFailed": "Failed to load {modelType}s: {message}",
|
||||
"refreshComplete": "Refresh complete",
|
||||
"refreshFailed": "Failed to refresh recipes: {message}",
|
||||
"syncComplete": "Sync complete",
|
||||
"syncFailed": "Failed to sync recipes: {message}",
|
||||
"updateFailed": "Failed to update recipe: {error}",
|
||||
"updateError": "Error updating recipe: {message}",
|
||||
"nameSaved": "Recipe \"{name}\" saved successfully",
|
||||
|
||||
@@ -222,7 +222,7 @@
|
||||
"presetNamePlaceholder": "Nombre del preajuste...",
|
||||
"baseModel": "Modelo base",
|
||||
"modelTags": "Etiquetas (Top 20)",
|
||||
"modelTypes": "Model Types",
|
||||
"modelTypes": "Tipos de modelos",
|
||||
"license": "Licencia",
|
||||
"noCreditRequired": "Sin crédito requerido",
|
||||
"allowSellingGeneratedContent": "Venta permitida",
|
||||
@@ -685,7 +685,11 @@
|
||||
"lorasCountAsc": "Menos"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Actualizar lista de recetas"
|
||||
"title": "Actualizar lista de recetas",
|
||||
"quick": "Sincronizar cambios",
|
||||
"quickTooltip": "Sincronizar cambios - actualización rápida sin reconstruir caché",
|
||||
"full": "Reconstruir caché",
|
||||
"fullTooltip": "Reconstruir caché - reescaneo completo de todos los archivos de recetas"
|
||||
},
|
||||
"filteredByLora": "Filtrado por LoRA",
|
||||
"favorites": {
|
||||
@@ -1396,6 +1400,8 @@
|
||||
"loadFailed": "Error al cargar {modelType}s: {message}",
|
||||
"refreshComplete": "Actualización completa",
|
||||
"refreshFailed": "Error al actualizar recetas: {message}",
|
||||
"syncComplete": "Sincronización completa",
|
||||
"syncFailed": "Error al sincronizar recetas: {message}",
|
||||
"updateFailed": "Error al actualizar receta: {error}",
|
||||
"updateError": "Error actualizando receta: {message}",
|
||||
"nameSaved": "Receta \"{name}\" guardada exitosamente",
|
||||
|
||||
@@ -222,7 +222,7 @@
|
||||
"presetNamePlaceholder": "Nom du préréglage...",
|
||||
"baseModel": "Modèle de base",
|
||||
"modelTags": "Tags (Top 20)",
|
||||
"modelTypes": "Model Types",
|
||||
"modelTypes": "Types de modèles",
|
||||
"license": "Licence",
|
||||
"noCreditRequired": "Crédit non requis",
|
||||
"allowSellingGeneratedContent": "Vente autorisée",
|
||||
@@ -685,7 +685,11 @@
|
||||
"lorasCountAsc": "Moins"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Actualiser la liste des recipes"
|
||||
"title": "Actualiser la liste des recipes",
|
||||
"quick": "Synchroniser les changements",
|
||||
"quickTooltip": "Synchroniser les changements - actualisation rapide sans reconstruire le cache",
|
||||
"full": "Reconstruire le cache",
|
||||
"fullTooltip": "Reconstruire le cache - rescan complet de tous les fichiers de recipes"
|
||||
},
|
||||
"filteredByLora": "Filtré par LoRA",
|
||||
"favorites": {
|
||||
@@ -1396,6 +1400,8 @@
|
||||
"loadFailed": "Échec du chargement des {modelType}s : {message}",
|
||||
"refreshComplete": "Actualisation terminée",
|
||||
"refreshFailed": "Échec de l'actualisation des recipes : {message}",
|
||||
"syncComplete": "Synchronisation terminée",
|
||||
"syncFailed": "Échec de la synchronisation des recipes : {message}",
|
||||
"updateFailed": "Échec de la mise à jour de la recipe : {error}",
|
||||
"updateError": "Erreur lors de la mise à jour de la recipe : {message}",
|
||||
"nameSaved": "Recipe \"{name}\" sauvegardée avec succès",
|
||||
|
||||
@@ -222,7 +222,7 @@
|
||||
"presetNamePlaceholder": "שם קביעה מראש...",
|
||||
"baseModel": "מודל בסיס",
|
||||
"modelTags": "תגיות (20 המובילות)",
|
||||
"modelTypes": "Model Types",
|
||||
"modelTypes": "סוגי מודלים",
|
||||
"license": "רישיון",
|
||||
"noCreditRequired": "ללא קרדיט נדרש",
|
||||
"allowSellingGeneratedContent": "אפשר מכירה",
|
||||
@@ -685,7 +685,11 @@
|
||||
"lorasCountAsc": "הכי פחות"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "רענן רשימת מתכונים"
|
||||
"title": "רענן רשימת מתכונים",
|
||||
"quick": "סנכרן שינויים",
|
||||
"quickTooltip": "סנכרן שינויים - רענון מהיר ללא בניית מטמון מחדש",
|
||||
"full": "בנה מטמון מחדש",
|
||||
"fullTooltip": "בנה מטמון מחדש - סריקה מחדש מלאה של כל קבצי המתכונים"
|
||||
},
|
||||
"filteredByLora": "מסונן לפי LoRA",
|
||||
"favorites": {
|
||||
@@ -1396,6 +1400,8 @@
|
||||
"loadFailed": "טעינת {modelType}s נכשלה: {message}",
|
||||
"refreshComplete": "הרענון הושלם",
|
||||
"refreshFailed": "רענון המתכונים נכשל: {message}",
|
||||
"syncComplete": "הסנכרון הושלם",
|
||||
"syncFailed": "סנכרון המתכונים נכשל: {message}",
|
||||
"updateFailed": "עדכון המתכון נכשל: {error}",
|
||||
"updateError": "שגיאה בעדכון המתכון: {message}",
|
||||
"nameSaved": "המתכון \"{name}\" נשמר בהצלחה",
|
||||
|
||||
@@ -222,7 +222,7 @@
|
||||
"presetNamePlaceholder": "プリセット名...",
|
||||
"baseModel": "ベースモデル",
|
||||
"modelTags": "タグ(上位20)",
|
||||
"modelTypes": "Model Types",
|
||||
"modelTypes": "モデルタイプ",
|
||||
"license": "ライセンス",
|
||||
"noCreditRequired": "クレジット不要",
|
||||
"allowSellingGeneratedContent": "販売許可",
|
||||
@@ -685,7 +685,11 @@
|
||||
"lorasCountAsc": "少ない順"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "レシピリストを更新"
|
||||
"title": "レシピリストを更新",
|
||||
"quick": "変更を同期",
|
||||
"quickTooltip": "変更を同期 - キャッシュを再構築せずにクイック更新",
|
||||
"full": "キャッシュを再構築",
|
||||
"fullTooltip": "キャッシュを再構築 - すべてのレシピファイルを完全に再スキャン"
|
||||
},
|
||||
"filteredByLora": "LoRAでフィルタ済み",
|
||||
"favorites": {
|
||||
@@ -1396,6 +1400,8 @@
|
||||
"loadFailed": "{modelType}の読み込みに失敗しました:{message}",
|
||||
"refreshComplete": "更新完了",
|
||||
"refreshFailed": "レシピの更新に失敗しました:{message}",
|
||||
"syncComplete": "同期完了",
|
||||
"syncFailed": "レシピの同期に失敗しました:{message}",
|
||||
"updateFailed": "レシピの更新に失敗しました:{error}",
|
||||
"updateError": "レシピ更新エラー:{message}",
|
||||
"nameSaved": "レシピ\"{name}\"が正常に保存されました",
|
||||
|
||||
@@ -222,7 +222,7 @@
|
||||
"presetNamePlaceholder": "프리셋 이름...",
|
||||
"baseModel": "베이스 모델",
|
||||
"modelTags": "태그 (상위 20개)",
|
||||
"modelTypes": "Model Types",
|
||||
"modelTypes": "모델 유형",
|
||||
"license": "라이선스",
|
||||
"noCreditRequired": "크레딧 표기 없음",
|
||||
"allowSellingGeneratedContent": "판매 허용",
|
||||
@@ -685,7 +685,11 @@
|
||||
"lorasCountAsc": "적은순"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "레시피 목록 새로고침"
|
||||
"title": "레시피 목록 새로고침",
|
||||
"quick": "변경 사항 동기화",
|
||||
"quickTooltip": "변경 사항 동기화 - 캐시를 재구성하지 않고 빠른 새로고침",
|
||||
"full": "캐시 재구성",
|
||||
"fullTooltip": "캐시 재구성 - 모든 레시피 파일을 완전히 다시 스캔"
|
||||
},
|
||||
"filteredByLora": "LoRA로 필터링됨",
|
||||
"favorites": {
|
||||
@@ -1396,6 +1400,8 @@
|
||||
"loadFailed": "{modelType} 로딩 실패: {message}",
|
||||
"refreshComplete": "새로고침 완료",
|
||||
"refreshFailed": "레시피 새로고침 실패: {message}",
|
||||
"syncComplete": "동기화 완료",
|
||||
"syncFailed": "레시피 동기화 실패: {message}",
|
||||
"updateFailed": "레시피 업데이트 실패: {error}",
|
||||
"updateError": "레시피 업데이트 오류: {message}",
|
||||
"nameSaved": "레시피 \"{name}\"이 성공적으로 저장되었습니다",
|
||||
|
||||
@@ -222,7 +222,7 @@
|
||||
"presetNamePlaceholder": "Имя пресета...",
|
||||
"baseModel": "Базовая модель",
|
||||
"modelTags": "Теги (Топ 20)",
|
||||
"modelTypes": "Model Types",
|
||||
"modelTypes": "Типы моделей",
|
||||
"license": "Лицензия",
|
||||
"noCreditRequired": "Без указания авторства",
|
||||
"allowSellingGeneratedContent": "Продажа разрешена",
|
||||
@@ -685,7 +685,11 @@
|
||||
"lorasCountAsc": "Меньше всего"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Обновить список рецептов"
|
||||
"title": "Обновить список рецептов",
|
||||
"quick": "Синхронизировать изменения",
|
||||
"quickTooltip": "Синхронизировать изменения - быстрое обновление без перестроения кэша",
|
||||
"full": "Перестроить кэш",
|
||||
"fullTooltip": "Перестроить кэш - полное повторное сканирование всех файлов рецептов"
|
||||
},
|
||||
"filteredByLora": "Фильтр по LoRA",
|
||||
"favorites": {
|
||||
@@ -1396,6 +1400,8 @@
|
||||
"loadFailed": "Не удалось загрузить {modelType}s: {message}",
|
||||
"refreshComplete": "Обновление завершено",
|
||||
"refreshFailed": "Не удалось обновить рецепты: {message}",
|
||||
"syncComplete": "Синхронизация завершена",
|
||||
"syncFailed": "Не удалось синхронизировать рецепты: {message}",
|
||||
"updateFailed": "Не удалось обновить рецепт: {error}",
|
||||
"updateError": "Ошибка обновления рецепта: {message}",
|
||||
"nameSaved": "Рецепт \"{name}\" успешно сохранен",
|
||||
|
||||
@@ -162,11 +162,11 @@
|
||||
"error": "清理示例图片文件夹失败:{message}"
|
||||
},
|
||||
"fetchMissingLicenses": {
|
||||
"label": "Refresh license metadata",
|
||||
"loading": "Refreshing license metadata for {typePlural}...",
|
||||
"success": "Updated license metadata for {count} {typePlural}",
|
||||
"none": "All {typePlural} already have license metadata",
|
||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||
"label": "刷新许可证元数据",
|
||||
"loading": "正在刷新 {typePlural} 的许可证元数据...",
|
||||
"success": "已更新 {count} 个 {typePlural} 的许可证元数据",
|
||||
"none": "所有 {typePlural} 都已具备许可证元数据",
|
||||
"error": "刷新 {typePlural} 的许可证元数据失败:{message}"
|
||||
},
|
||||
"repairRecipes": {
|
||||
"label": "修复配方数据",
|
||||
@@ -222,7 +222,7 @@
|
||||
"presetNamePlaceholder": "预设名称...",
|
||||
"baseModel": "基础模型",
|
||||
"modelTags": "标签(前20)",
|
||||
"modelTypes": "Model Types",
|
||||
"modelTypes": "模型类型",
|
||||
"license": "许可证",
|
||||
"noCreditRequired": "无需署名",
|
||||
"allowSellingGeneratedContent": "允许销售",
|
||||
@@ -685,7 +685,11 @@
|
||||
"lorasCountAsc": "最少"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "刷新配方列表"
|
||||
"title": "刷新配方列表",
|
||||
"quick": "同步变更",
|
||||
"quickTooltip": "同步变更 - 快速刷新而不重建缓存",
|
||||
"full": "重建缓存",
|
||||
"fullTooltip": "重建缓存 - 重新扫描所有配方文件"
|
||||
},
|
||||
"filteredByLora": "按 LoRA 筛选",
|
||||
"favorites": {
|
||||
@@ -1396,6 +1400,8 @@
|
||||
"loadFailed": "加载 {modelType} 失败:{message}",
|
||||
"refreshComplete": "刷新完成",
|
||||
"refreshFailed": "刷新配方失败:{message}",
|
||||
"syncComplete": "同步完成",
|
||||
"syncFailed": "同步配方失败:{message}",
|
||||
"updateFailed": "更新配方失败:{error}",
|
||||
"updateError": "更新配方出错:{message}",
|
||||
"nameSaved": "配方“{name}”保存成功",
|
||||
|
||||
@@ -222,7 +222,7 @@
|
||||
"presetNamePlaceholder": "預設名稱...",
|
||||
"baseModel": "基礎模型",
|
||||
"modelTags": "標籤(前 20)",
|
||||
"modelTypes": "Model Types",
|
||||
"modelTypes": "模型類型",
|
||||
"license": "授權",
|
||||
"noCreditRequired": "無需署名",
|
||||
"allowSellingGeneratedContent": "允許銷售",
|
||||
@@ -685,7 +685,11 @@
|
||||
"lorasCountAsc": "最少"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "重新整理配方列表"
|
||||
"title": "重新整理配方列表",
|
||||
"quick": "同步變更",
|
||||
"quickTooltip": "同步變更 - 快速重新整理而不重建快取",
|
||||
"full": "重建快取",
|
||||
"fullTooltip": "重建快取 - 重新掃描所有配方檔案"
|
||||
},
|
||||
"filteredByLora": "已依 LoRA 篩選",
|
||||
"favorites": {
|
||||
@@ -1396,6 +1400,8 @@
|
||||
"loadFailed": "載入 {modelType} 失敗:{message}",
|
||||
"refreshComplete": "刷新完成",
|
||||
"refreshFailed": "刷新配方失敗:{message}",
|
||||
"syncComplete": "同步完成",
|
||||
"syncFailed": "同步配方失敗:{message}",
|
||||
"updateFailed": "更新配方失敗:{error}",
|
||||
"updateError": "更新配方錯誤:{message}",
|
||||
"nameSaved": "配方「{name}」已成功儲存",
|
||||
|
||||
@@ -240,11 +240,7 @@ class SupportersHandler:
|
||||
except Exception as e:
|
||||
self._logger.debug(f"Failed to load supporters data: {e}")
|
||||
|
||||
return {
|
||||
"specialThanks": [],
|
||||
"allSupporters": [],
|
||||
"totalCount": 0
|
||||
}
|
||||
return {"specialThanks": [], "allSupporters": [], "totalCount": 0}
|
||||
|
||||
async def get_supporters(self, request: web.Request) -> web.Response:
|
||||
"""Return supporters data as JSON."""
|
||||
@@ -253,9 +249,101 @@ class SupportersHandler:
|
||||
return web.json_response({"success": True, "supporters": supporters})
|
||||
except Exception as exc:
|
||||
self._logger.error("Error loading supporters: %s", exc, exc_info=True)
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(exc)}, status=500
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
|
||||
class ExampleWorkflowsHandler:
|
||||
"""Handler for example workflow templates."""
|
||||
|
||||
def __init__(self, logger: logging.Logger | None = None) -> None:
|
||||
self._logger = logger or logging.getLogger(__name__)
|
||||
|
||||
def _get_workflows_dir(self) -> str:
|
||||
"""Get the example workflows directory path."""
|
||||
current_file = os.path.abspath(__file__)
|
||||
root_dir = os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(current_file)))
|
||||
)
|
||||
return os.path.join(root_dir, "example_workflows")
|
||||
|
||||
def _format_workflow_name(self, filename: str) -> str:
|
||||
"""Convert filename to human-readable name."""
|
||||
name = os.path.splitext(filename)[0]
|
||||
name = name.replace("_", " ")
|
||||
return name
|
||||
|
||||
async def get_example_workflows(self, request: web.Request) -> web.Response:
|
||||
"""Return list of available example workflows."""
|
||||
try:
|
||||
workflows_dir = self._get_workflows_dir()
|
||||
workflows = [
|
||||
{
|
||||
"value": "Default",
|
||||
"label": "Default (Blank)",
|
||||
"path": None,
|
||||
}
|
||||
]
|
||||
|
||||
if os.path.exists(workflows_dir):
|
||||
for filename in sorted(os.listdir(workflows_dir)):
|
||||
if filename.endswith(".json"):
|
||||
workflows.append(
|
||||
{
|
||||
"value": filename,
|
||||
"label": self._format_workflow_name(filename),
|
||||
"path": f"example_workflows/{filename}",
|
||||
}
|
||||
)
|
||||
|
||||
return web.json_response({"success": True, "workflows": workflows})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error listing example workflows: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def get_example_workflow(self, request: web.Request) -> web.Response:
|
||||
"""Return a specific example workflow JSON content."""
|
||||
try:
|
||||
filename = request.match_info.get("filename")
|
||||
if not filename:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Filename not provided"}, status=400
|
||||
)
|
||||
|
||||
if filename == "Default":
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"workflow": {
|
||||
"last_node_id": 0,
|
||||
"last_link_id": 0,
|
||||
"nodes": [],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
workflows_dir = self._get_workflows_dir()
|
||||
filepath = os.path.join(workflows_dir, filename)
|
||||
|
||||
if not os.path.exists(filepath):
|
||||
return web.json_response(
|
||||
{"success": False, "error": f"Workflow not found: {filename}"},
|
||||
status=404,
|
||||
)
|
||||
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
workflow = json.load(f)
|
||||
|
||||
return web.json_response({"success": True, "workflow": workflow})
|
||||
except Exception as exc:
|
||||
self._logger.error("Error loading example workflow: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
|
||||
class SettingsHandler:
|
||||
@@ -263,15 +351,17 @@ class SettingsHandler:
|
||||
|
||||
# Settings keys that should NOT be synced to frontend.
|
||||
# All other settings are synced by default.
|
||||
_NO_SYNC_KEYS = frozenset({
|
||||
# Internal/performance settings (not used by frontend)
|
||||
"hash_chunk_size_mb",
|
||||
"download_stall_timeout_seconds",
|
||||
# Complex internal structures retrieved via separate endpoints
|
||||
"folder_paths",
|
||||
"libraries",
|
||||
"active_library",
|
||||
})
|
||||
_NO_SYNC_KEYS = frozenset(
|
||||
{
|
||||
# Internal/performance settings (not used by frontend)
|
||||
"hash_chunk_size_mb",
|
||||
"download_stall_timeout_seconds",
|
||||
# Complex internal structures retrieved via separate endpoints
|
||||
"folder_paths",
|
||||
"libraries",
|
||||
"active_library",
|
||||
}
|
||||
)
|
||||
|
||||
_PROXY_KEYS = {
|
||||
"proxy_enabled",
|
||||
@@ -1226,6 +1316,7 @@ class CustomWordsHandler:
|
||||
|
||||
def __init__(self) -> None:
|
||||
from ...services.custom_words_service import get_custom_words_service
|
||||
|
||||
self._service = get_custom_words_service()
|
||||
|
||||
async def search_custom_words(self, request: web.Request) -> web.Response:
|
||||
@@ -1234,6 +1325,7 @@ class CustomWordsHandler:
|
||||
Query parameters:
|
||||
search: The search term to match against.
|
||||
limit: Maximum number of results to return (default: 20).
|
||||
offset: Number of results to skip (default: 0).
|
||||
category: Optional category filter. Can be:
|
||||
- A category name (e.g., "character", "artist", "general")
|
||||
- Comma-separated category IDs (e.g., "4,11" for character)
|
||||
@@ -1243,6 +1335,7 @@ class CustomWordsHandler:
|
||||
try:
|
||||
search_term = request.query.get("search", "")
|
||||
limit = int(request.query.get("limit", "20"))
|
||||
offset = max(0, int(request.query.get("offset", "0")))
|
||||
category_param = request.query.get("category", "")
|
||||
enriched_param = request.query.get("enriched", "").lower() == "true"
|
||||
|
||||
@@ -1252,13 +1345,14 @@ class CustomWordsHandler:
|
||||
categories = self._parse_category_param(category_param)
|
||||
|
||||
results = self._service.search_words(
|
||||
search_term, limit, categories=categories, enriched=enriched_param
|
||||
search_term,
|
||||
limit,
|
||||
offset=offset,
|
||||
categories=categories,
|
||||
enriched=enriched_param,
|
||||
)
|
||||
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"words": results
|
||||
})
|
||||
return web.json_response({"success": True, "words": results})
|
||||
except Exception as exc:
|
||||
logger.error("Error searching custom words: %s", exc, exc_info=True)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
@@ -1523,6 +1617,7 @@ class MiscHandlerSet:
|
||||
filesystem: FileSystemHandler,
|
||||
custom_words: CustomWordsHandler,
|
||||
supporters: SupportersHandler,
|
||||
example_workflows: ExampleWorkflowsHandler,
|
||||
) -> None:
|
||||
self.health = health
|
||||
self.settings = settings
|
||||
@@ -1536,6 +1631,7 @@ class MiscHandlerSet:
|
||||
self.filesystem = filesystem
|
||||
self.custom_words = custom_words
|
||||
self.supporters = supporters
|
||||
self.example_workflows = example_workflows
|
||||
|
||||
def to_route_mapping(
|
||||
self,
|
||||
@@ -1565,6 +1661,8 @@ class MiscHandlerSet:
|
||||
"open_settings_location": self.filesystem.open_settings_location,
|
||||
"search_custom_words": self.custom_words.search_custom_words,
|
||||
"get_supporters": self.supporters.get_supporters,
|
||||
"get_example_workflows": self.example_workflows.get_example_workflows,
|
||||
"get_example_workflow": self.example_workflows.get_example_workflow,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1268,8 +1268,11 @@ class ModelQueryHandler:
|
||||
async def get_relative_paths(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
search = request.query.get("search", "").strip()
|
||||
limit = min(int(request.query.get("limit", "15")), 50)
|
||||
matching_paths = await self._service.search_relative_paths(search, limit)
|
||||
limit = min(int(request.query.get("limit", "15")), 100)
|
||||
offset = max(0, int(request.query.get("offset", "0")))
|
||||
matching_paths = await self._service.search_relative_paths(
|
||||
search, limit, offset
|
||||
)
|
||||
return web.json_response(
|
||||
{"success": True, "relative_paths": matching_paths}
|
||||
)
|
||||
|
||||
@@ -38,12 +38,24 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
|
||||
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
|
||||
RouteDefinition("GET", "/api/lm/civitai/user-models", "get_civitai_user_models"),
|
||||
RouteDefinition("POST", "/api/lm/download-metadata-archive", "download_metadata_archive"),
|
||||
RouteDefinition("POST", "/api/lm/remove-metadata-archive", "remove_metadata_archive"),
|
||||
RouteDefinition("GET", "/api/lm/metadata-archive-status", "get_metadata_archive_status"),
|
||||
RouteDefinition("GET", "/api/lm/model-versions-status", "get_model_versions_status"),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/download-metadata-archive", "download_metadata_archive"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/remove-metadata-archive", "remove_metadata_archive"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/metadata-archive-status", "get_metadata_archive_status"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/model-versions-status", "get_model_versions_status"
|
||||
),
|
||||
RouteDefinition("POST", "/api/lm/settings/open-location", "open_settings_location"),
|
||||
RouteDefinition("GET", "/api/lm/custom-words/search", "search_custom_words"),
|
||||
RouteDefinition("GET", "/api/lm/example-workflows", "get_example_workflows"),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/example-workflows/{filename}", "get_example_workflow"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -67,7 +79,11 @@ class MiscRouteRegistrar:
|
||||
definitions: Iterable[RouteDefinition] = MISC_ROUTE_DEFINITIONS,
|
||||
) -> None:
|
||||
for definition in definitions:
|
||||
self._bind(definition.method, definition.path, handler_lookup[definition.handler_name])
|
||||
self._bind(
|
||||
definition.method,
|
||||
definition.path,
|
||||
handler_lookup[definition.handler_name],
|
||||
)
|
||||
|
||||
def _bind(self, method: str, path: str, handler: Callable) -> None:
|
||||
add_method_name = self._METHOD_MAP[method.upper()]
|
||||
|
||||
@@ -19,6 +19,7 @@ from ..services.downloader import get_downloader
|
||||
from ..utils.usage_stats import UsageStats
|
||||
from .handlers.misc_handlers import (
|
||||
CustomWordsHandler,
|
||||
ExampleWorkflowsHandler,
|
||||
FileSystemHandler,
|
||||
HealthCheckHandler,
|
||||
LoraCodeHandler,
|
||||
@@ -38,9 +39,10 @@ from .misc_route_registrar import MiscRouteRegistrar
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get(
|
||||
"HF_HUB_DISABLE_TELEMETRY", "0"
|
||||
) == "0"
|
||||
standalone_mode = (
|
||||
os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"
|
||||
or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
|
||||
)
|
||||
|
||||
|
||||
class MiscRoutes:
|
||||
@@ -75,7 +77,9 @@ class MiscRoutes:
|
||||
self._node_registry = node_registry or NodeRegistry()
|
||||
self._standalone_mode = standalone_mode_flag
|
||||
|
||||
self._handler_mapping: Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]] | None = None
|
||||
self._handler_mapping: (
|
||||
Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]] | None
|
||||
) = None
|
||||
|
||||
@staticmethod
|
||||
def setup_routes(app: web.Application) -> None:
|
||||
@@ -87,7 +91,9 @@ class MiscRoutes:
|
||||
registrar = self._registrar_factory(app)
|
||||
registrar.register_routes(self._ensure_handler_mapping())
|
||||
|
||||
def _ensure_handler_mapping(self) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
|
||||
def _ensure_handler_mapping(
|
||||
self,
|
||||
) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
|
||||
if self._handler_mapping is None:
|
||||
handler_set = self._create_handler_set()
|
||||
self._handler_mapping = handler_set.to_route_mapping()
|
||||
@@ -121,6 +127,7 @@ class MiscRoutes:
|
||||
)
|
||||
custom_words = CustomWordsHandler()
|
||||
supporters = SupportersHandler()
|
||||
example_workflows = ExampleWorkflowsHandler()
|
||||
|
||||
return self._handler_set_factory(
|
||||
health=health,
|
||||
@@ -135,6 +142,7 @@ class MiscRoutes:
|
||||
filesystem=filesystem,
|
||||
custom_words=custom_words,
|
||||
supporters=supporters,
|
||||
example_workflows=example_workflows,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from abc import ABC, abstractmethod
|
||||
import asyncio
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING
|
||||
import logging
|
||||
import os
|
||||
@@ -383,7 +384,9 @@ class BaseModelService(ABC):
|
||||
# Check user setting for hiding early access updates
|
||||
hide_early_access = False
|
||||
try:
|
||||
hide_early_access = bool(self.settings.get("hide_early_access_updates", False))
|
||||
hide_early_access = bool(
|
||||
self.settings.get("hide_early_access_updates", False)
|
||||
)
|
||||
except Exception:
|
||||
hide_early_access = False
|
||||
|
||||
@@ -413,7 +416,11 @@ class BaseModelService(ABC):
|
||||
bulk_method = getattr(self.update_service, "has_updates_bulk", None)
|
||||
if callable(bulk_method):
|
||||
try:
|
||||
resolved = await bulk_method(self.model_type, ordered_ids, hide_early_access=hide_early_access)
|
||||
resolved = await bulk_method(
|
||||
self.model_type,
|
||||
ordered_ids,
|
||||
hide_early_access=hide_early_access,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to resolve update status in bulk for %s models (%s): %s",
|
||||
@@ -426,7 +433,9 @@ class BaseModelService(ABC):
|
||||
|
||||
if resolved is None:
|
||||
tasks = [
|
||||
self.update_service.has_update(self.model_type, model_id, hide_early_access=hide_early_access)
|
||||
self.update_service.has_update(
|
||||
self.model_type, model_id, hide_early_access=hide_early_access
|
||||
)
|
||||
for model_id in ordered_ids
|
||||
]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
@@ -590,9 +599,15 @@ class BaseModelService(ABC):
|
||||
continue
|
||||
|
||||
# Filter by valid sub-types based on scanner type
|
||||
if self.model_type == "lora" and normalized_type not in VALID_LORA_SUB_TYPES:
|
||||
if (
|
||||
self.model_type == "lora"
|
||||
and normalized_type not in VALID_LORA_SUB_TYPES
|
||||
):
|
||||
continue
|
||||
if self.model_type == "checkpoint" and normalized_type not in VALID_CHECKPOINT_SUB_TYPES:
|
||||
if (
|
||||
self.model_type == "checkpoint"
|
||||
and normalized_type not in VALID_CHECKPOINT_SUB_TYPES
|
||||
):
|
||||
continue
|
||||
|
||||
type_counts[normalized_type] = type_counts.get(normalized_type, 0) + 1
|
||||
@@ -807,38 +822,61 @@ class BaseModelService(ABC):
|
||||
|
||||
return include_terms, exclude_terms
|
||||
|
||||
@staticmethod
|
||||
def _remove_model_extension(path: str) -> str:
|
||||
"""Remove model file extension (.safetensors, .ckpt, .pt, .bin) for cleaner matching."""
|
||||
return re.sub(r"\.(safetensors|ckpt|pt|bin)$", "", path, flags=re.IGNORECASE)
|
||||
|
||||
@staticmethod
|
||||
def _relative_path_matches_tokens(
|
||||
path_lower: str, include_terms: List[str], exclude_terms: List[str]
|
||||
) -> bool:
|
||||
"""Determine whether a relative path string satisfies include/exclude tokens."""
|
||||
if any(term and term in path_lower for term in exclude_terms):
|
||||
"""Determine whether a relative path string satisfies include/exclude tokens.
|
||||
|
||||
Matches against the path without extension to avoid matching .safetensors
|
||||
when searching for 's'.
|
||||
"""
|
||||
# Use path without extension for matching
|
||||
path_for_matching = BaseModelService._remove_model_extension(path_lower)
|
||||
|
||||
if any(term and term in path_for_matching for term in exclude_terms):
|
||||
return False
|
||||
|
||||
for term in include_terms:
|
||||
if term and term not in path_lower:
|
||||
if term and term not in path_for_matching:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _relative_path_sort_key(relative_path: str, include_terms: List[str]) -> tuple:
|
||||
"""Sort paths by how well they satisfy the include tokens."""
|
||||
path_lower = relative_path.lower()
|
||||
"""Sort paths by how well they satisfy the include tokens.
|
||||
|
||||
Sorts based on path without extension for consistent ordering.
|
||||
"""
|
||||
# Use path without extension for sorting
|
||||
path_for_sorting = BaseModelService._remove_model_extension(
|
||||
relative_path.lower()
|
||||
)
|
||||
prefix_hits = sum(
|
||||
1 for term in include_terms if term and path_lower.startswith(term)
|
||||
1 for term in include_terms if term and path_for_sorting.startswith(term)
|
||||
)
|
||||
match_positions = [
|
||||
path_lower.find(term)
|
||||
path_for_sorting.find(term)
|
||||
for term in include_terms
|
||||
if term and term in path_lower
|
||||
if term and term in path_for_sorting
|
||||
]
|
||||
first_match_index = min(match_positions) if match_positions else 0
|
||||
|
||||
return (-prefix_hits, first_match_index, len(relative_path), path_lower)
|
||||
return (
|
||||
-prefix_hits,
|
||||
first_match_index,
|
||||
len(path_for_sorting),
|
||||
path_for_sorting,
|
||||
)
|
||||
|
||||
async def search_relative_paths(
|
||||
self, search_term: str, limit: int = 15
|
||||
self, search_term: str, limit: int = 15, offset: int = 0
|
||||
) -> List[str]:
|
||||
"""Search model relative file paths for autocomplete functionality"""
|
||||
cache = await self.scanner.get_cached_data()
|
||||
@@ -849,6 +887,7 @@ class BaseModelService(ABC):
|
||||
# Get model roots for path calculation
|
||||
model_roots = self.scanner.get_model_roots()
|
||||
|
||||
# Collect all matching paths first (needed for proper sorting and offset)
|
||||
for model in cache.raw_data:
|
||||
file_path = model.get("file_path", "")
|
||||
if not file_path:
|
||||
@@ -877,12 +916,12 @@ class BaseModelService(ABC):
|
||||
):
|
||||
matching_paths.append(relative_path)
|
||||
|
||||
if len(matching_paths) >= limit * 2: # Get more for better sorting
|
||||
break
|
||||
|
||||
# Sort by relevance (prefix and earliest hits first, then by length and alphabetically)
|
||||
matching_paths.sort(
|
||||
key=lambda relative: self._relative_path_sort_key(relative, include_terms)
|
||||
)
|
||||
|
||||
return matching_paths[:limit]
|
||||
# Apply offset and limit
|
||||
start = min(offset, len(matching_paths))
|
||||
end = min(start + limit, len(matching_paths))
|
||||
return matching_paths[start:end]
|
||||
|
||||
@@ -49,6 +49,7 @@ class CustomWordsService:
|
||||
if self._tag_index is None:
|
||||
try:
|
||||
from .tag_fts_index import get_tag_fts_index
|
||||
|
||||
self._tag_index = get_tag_fts_index()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to initialize TagFTSIndex: {e}")
|
||||
@@ -59,14 +60,16 @@ class CustomWordsService:
|
||||
self,
|
||||
search_term: str,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
categories: Optional[List[int]] = None,
|
||||
enriched: bool = False
|
||||
enriched: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search tags using TagFTSIndex with category filtering.
|
||||
|
||||
Args:
|
||||
search_term: The search term to match against.
|
||||
limit: Maximum number of results to return.
|
||||
offset: Number of results to skip.
|
||||
categories: Optional list of category IDs to filter by.
|
||||
enriched: If True, always return enriched results with category
|
||||
and post_count (default behavior now).
|
||||
@@ -76,7 +79,9 @@ class CustomWordsService:
|
||||
"""
|
||||
tag_index = self._get_tag_index()
|
||||
if tag_index is not None:
|
||||
results = tag_index.search(search_term, categories=categories, limit=limit)
|
||||
results = tag_index.search(
|
||||
search_term, categories=categories, limit=limit, offset=offset
|
||||
)
|
||||
return results
|
||||
|
||||
logger.debug("TagFTSIndex not available, returning empty results")
|
||||
|
||||
@@ -4,6 +4,7 @@ from dataclasses import dataclass
|
||||
from operator import itemgetter
|
||||
from natsort import natsorted
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecipeCache:
|
||||
"""Cache structure for Recipe data"""
|
||||
@@ -21,11 +22,18 @@ class RecipeCache:
|
||||
self.folder_tree = self.folder_tree or {}
|
||||
|
||||
async def resort(self, name_only: bool = False):
|
||||
"""Resort all cached data views"""
|
||||
"""Resort all cached data views in a thread pool to avoid blocking the event loop."""
|
||||
async with self._lock:
|
||||
self._resort_locked(name_only=name_only)
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
self._resort_locked,
|
||||
name_only,
|
||||
)
|
||||
|
||||
async def update_recipe_metadata(self, recipe_id: str, metadata: Dict, *, resort: bool = True) -> bool:
|
||||
async def update_recipe_metadata(
|
||||
self, recipe_id: str, metadata: Dict, *, resort: bool = True
|
||||
) -> bool:
|
||||
"""Update metadata for a specific recipe in all cached data
|
||||
|
||||
Args:
|
||||
@@ -37,7 +45,7 @@ class RecipeCache:
|
||||
"""
|
||||
async with self._lock:
|
||||
for item in self.raw_data:
|
||||
if str(item.get('id')) == str(recipe_id):
|
||||
if str(item.get("id")) == str(recipe_id):
|
||||
item.update(metadata)
|
||||
if resort:
|
||||
self._resort_locked()
|
||||
@@ -52,7 +60,9 @@ class RecipeCache:
|
||||
if resort:
|
||||
self._resort_locked()
|
||||
|
||||
async def remove_recipe(self, recipe_id: str, *, resort: bool = False) -> Optional[Dict]:
|
||||
async def remove_recipe(
|
||||
self, recipe_id: str, *, resort: bool = False
|
||||
) -> Optional[Dict]:
|
||||
"""Remove a recipe from the cache by ID.
|
||||
|
||||
Args:
|
||||
@@ -64,14 +74,16 @@ class RecipeCache:
|
||||
|
||||
async with self._lock:
|
||||
for index, recipe in enumerate(self.raw_data):
|
||||
if str(recipe.get('id')) == str(recipe_id):
|
||||
if str(recipe.get("id")) == str(recipe_id):
|
||||
removed = self.raw_data.pop(index)
|
||||
if resort:
|
||||
self._resort_locked()
|
||||
return removed
|
||||
return None
|
||||
|
||||
async def bulk_remove(self, recipe_ids: Iterable[str], *, resort: bool = False) -> List[Dict]:
|
||||
async def bulk_remove(
|
||||
self, recipe_ids: Iterable[str], *, resort: bool = False
|
||||
) -> List[Dict]:
|
||||
"""Remove multiple recipes from the cache."""
|
||||
|
||||
id_set = {str(recipe_id) for recipe_id in recipe_ids}
|
||||
@@ -79,21 +91,25 @@ class RecipeCache:
|
||||
return []
|
||||
|
||||
async with self._lock:
|
||||
removed = [item for item in self.raw_data if str(item.get('id')) in id_set]
|
||||
removed = [item for item in self.raw_data if str(item.get("id")) in id_set]
|
||||
if not removed:
|
||||
return []
|
||||
|
||||
self.raw_data = [item for item in self.raw_data if str(item.get('id')) not in id_set]
|
||||
self.raw_data = [
|
||||
item for item in self.raw_data if str(item.get("id")) not in id_set
|
||||
]
|
||||
if resort:
|
||||
self._resort_locked()
|
||||
return removed
|
||||
|
||||
async def replace_recipe(self, recipe_id: str, new_data: Dict, *, resort: bool = False) -> bool:
|
||||
async def replace_recipe(
|
||||
self, recipe_id: str, new_data: Dict, *, resort: bool = False
|
||||
) -> bool:
|
||||
"""Replace cached data for a recipe."""
|
||||
|
||||
async with self._lock:
|
||||
for index, recipe in enumerate(self.raw_data):
|
||||
if str(recipe.get('id')) == str(recipe_id):
|
||||
if str(recipe.get("id")) == str(recipe_id):
|
||||
self.raw_data[index] = new_data
|
||||
if resort:
|
||||
self._resort_locked()
|
||||
@@ -105,7 +121,7 @@ class RecipeCache:
|
||||
|
||||
async with self._lock:
|
||||
for recipe in self.raw_data:
|
||||
if str(recipe.get('id')) == str(recipe_id):
|
||||
if str(recipe.get("id")) == str(recipe_id):
|
||||
return dict(recipe)
|
||||
return None
|
||||
|
||||
@@ -115,16 +131,13 @@ class RecipeCache:
|
||||
async with self._lock:
|
||||
return [dict(item) for item in self.raw_data]
|
||||
|
||||
def _resort_locked(self, *, name_only: bool = False) -> None:
|
||||
def _resort_locked(self, name_only: bool = False) -> None:
|
||||
"""Sort cached views. Caller must hold ``_lock``."""
|
||||
|
||||
self.sorted_by_name = natsorted(
|
||||
self.raw_data,
|
||||
key=lambda x: x.get('title', '').lower()
|
||||
self.raw_data, key=lambda x: x.get("title", "").lower()
|
||||
)
|
||||
if not name_only:
|
||||
self.sorted_by_date = sorted(
|
||||
self.raw_data,
|
||||
key=itemgetter('created_date', 'file_path'),
|
||||
reverse=True
|
||||
self.raw_data, key=itemgetter("created_date", "file_path"), reverse=True
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -69,7 +69,9 @@ class TagFTSIndex:
|
||||
_DEFAULT_FILENAME = "tag_fts.sqlite"
|
||||
_CSV_FILENAME = "danbooru_e621_merged.csv"
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None, csv_path: Optional[str] = None) -> None:
|
||||
def __init__(
|
||||
self, db_path: Optional[str] = None, csv_path: Optional[str] = None
|
||||
) -> None:
|
||||
"""Initialize the FTS index.
|
||||
|
||||
Args:
|
||||
@@ -92,7 +94,9 @@ class TagFTSIndex:
|
||||
if directory:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
except Exception as exc:
|
||||
logger.warning("Could not create FTS index directory %s: %s", directory, exc)
|
||||
logger.warning(
|
||||
"Could not create FTS index directory %s: %s", directory, exc
|
||||
)
|
||||
|
||||
def _resolve_default_db_path(self) -> str:
|
||||
"""Resolve the default database path."""
|
||||
@@ -173,13 +177,15 @@ class TagFTSIndex:
|
||||
# Set schema version
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||
("schema_version", str(SCHEMA_VERSION))
|
||||
("schema_version", str(SCHEMA_VERSION)),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
self._schema_initialized = True
|
||||
self._needs_rebuild = needs_rebuild
|
||||
logger.debug("Tag FTS index schema initialized at %s", self._db_path)
|
||||
logger.debug(
|
||||
"Tag FTS index schema initialized at %s", self._db_path
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
@@ -206,13 +212,20 @@ class TagFTSIndex:
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
# Old schema without version, needs rebuild
|
||||
logger.info("Migrating tag FTS index to schema version %d (adding alias support)", SCHEMA_VERSION)
|
||||
logger.info(
|
||||
"Migrating tag FTS index to schema version %d (adding alias support)",
|
||||
SCHEMA_VERSION,
|
||||
)
|
||||
self._drop_old_tables(conn)
|
||||
return True
|
||||
|
||||
current_version = int(row[0])
|
||||
if current_version < SCHEMA_VERSION:
|
||||
logger.info("Migrating tag FTS index from version %d to %d", current_version, SCHEMA_VERSION)
|
||||
logger.info(
|
||||
"Migrating tag FTS index from version %d to %d",
|
||||
current_version,
|
||||
SCHEMA_VERSION,
|
||||
)
|
||||
self._drop_old_tables(conn)
|
||||
return True
|
||||
|
||||
@@ -246,7 +259,9 @@ class TagFTSIndex:
|
||||
return
|
||||
|
||||
if not os.path.exists(self._csv_path):
|
||||
logger.warning("CSV file not found at %s, cannot build tag index", self._csv_path)
|
||||
logger.warning(
|
||||
"CSV file not found at %s, cannot build tag index", self._csv_path
|
||||
)
|
||||
return
|
||||
|
||||
self._indexing_in_progress = True
|
||||
@@ -314,22 +329,24 @@ class TagFTSIndex:
|
||||
# Update metadata
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||
("last_build_time", str(time.time()))
|
||||
("last_build_time", str(time.time())),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||
("tag_count", str(total_inserted))
|
||||
("tag_count", str(total_inserted)),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||
("schema_version", str(SCHEMA_VERSION))
|
||||
("schema_version", str(SCHEMA_VERSION)),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
elapsed = time.time() - start_time
|
||||
logger.info(
|
||||
"Tag FTS index built: %d tags indexed (%d with aliases) in %.2fs",
|
||||
total_inserted, tags_with_aliases, elapsed
|
||||
total_inserted,
|
||||
tags_with_aliases,
|
||||
elapsed,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -350,7 +367,7 @@ class TagFTSIndex:
|
||||
# Insert into tags table (with aliases)
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO tags (tag_name, category, post_count, aliases) VALUES (?, ?, ?, ?)",
|
||||
rows
|
||||
rows,
|
||||
)
|
||||
|
||||
# Build a map of tag_name -> aliases for FTS insertion
|
||||
@@ -362,7 +379,7 @@ class TagFTSIndex:
|
||||
placeholders = ",".join("?" * len(tag_names))
|
||||
cursor = conn.execute(
|
||||
f"SELECT rowid, tag_name FROM tags WHERE tag_name IN ({placeholders})",
|
||||
tag_names
|
||||
tag_names,
|
||||
)
|
||||
|
||||
# Build FTS rows with (rowid, searchable_text) = (tags.rowid, "tag_name alias1 alias2 ...")
|
||||
@@ -379,13 +396,17 @@ class TagFTSIndex:
|
||||
alias = alias[1:] # Remove leading slash
|
||||
if alias:
|
||||
alias_parts.append(alias)
|
||||
searchable_text = f"{tag_name} {' '.join(alias_parts)}" if alias_parts else tag_name
|
||||
searchable_text = (
|
||||
f"{tag_name} {' '.join(alias_parts)}" if alias_parts else tag_name
|
||||
)
|
||||
else:
|
||||
searchable_text = tag_name
|
||||
fts_rows.append((rowid, searchable_text))
|
||||
|
||||
if fts_rows:
|
||||
conn.executemany("INSERT INTO tag_fts (rowid, searchable_text) VALUES (?, ?)", fts_rows)
|
||||
conn.executemany(
|
||||
"INSERT INTO tag_fts (rowid, searchable_text) VALUES (?, ?)", fts_rows
|
||||
)
|
||||
|
||||
def ensure_ready(self) -> bool:
|
||||
"""Ensure the index is ready, building if necessary.
|
||||
@@ -420,7 +441,8 @@ class TagFTSIndex:
|
||||
self,
|
||||
query: str,
|
||||
categories: Optional[List[int]] = None,
|
||||
limit: int = 20
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
) -> List[Dict]:
|
||||
"""Search tags using FTS5 with prefix matching.
|
||||
|
||||
@@ -431,6 +453,7 @@ class TagFTSIndex:
|
||||
query: The search query string.
|
||||
categories: Optional list of category IDs to filter by.
|
||||
limit: Maximum number of results to return.
|
||||
offset: Number of results to skip.
|
||||
|
||||
Returns:
|
||||
List of dictionaries with tag_name, category, post_count,
|
||||
@@ -466,9 +489,9 @@ class TagFTSIndex:
|
||||
)
|
||||
AND t.category IN ({placeholders})
|
||||
ORDER BY t.post_count DESC
|
||||
LIMIT ?
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
params = [fts_query] + categories + [limit]
|
||||
params = [fts_query] + categories + [limit, offset]
|
||||
else:
|
||||
sql = """
|
||||
SELECT t.tag_name, t.category, t.post_count, t.aliases
|
||||
@@ -476,9 +499,9 @@ class TagFTSIndex:
|
||||
JOIN tags t ON f.rowid = t.rowid
|
||||
WHERE f.searchable_text MATCH ?
|
||||
ORDER BY t.post_count DESC
|
||||
LIMIT ?
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
params = [fts_query, limit]
|
||||
params = [fts_query, limit, offset]
|
||||
|
||||
cursor = conn.execute(sql, params)
|
||||
results = []
|
||||
@@ -502,7 +525,9 @@ class TagFTSIndex:
|
||||
logger.debug("Tag FTS search error for query '%s': %s", query, exc)
|
||||
return []
|
||||
|
||||
def _find_matched_alias(self, query: str, tag_name: str, aliases_str: str) -> Optional[str]:
|
||||
def _find_matched_alias(
|
||||
self, query: str, tag_name: str, aliases_str: str
|
||||
) -> Optional[str]:
|
||||
"""Find which alias matched the query, if any.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
z-index: 1000;
|
||||
z-index: var(--z-overlay);
|
||||
display: none;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
@@ -259,6 +259,26 @@ export async function resetAndReload(updateFolders = false) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync changes - quick refresh without rebuilding cache (similar to models page)
|
||||
*/
|
||||
export async function syncChanges() {
|
||||
try {
|
||||
state.loadingManager.showSimpleLoading('Syncing changes...');
|
||||
|
||||
// Simply reload the recipes without rebuilding cache
|
||||
await resetAndReload();
|
||||
|
||||
showToast('toast.recipes.syncComplete', {}, 'success');
|
||||
} catch (error) {
|
||||
console.error('Error syncing recipes:', error);
|
||||
showToast('toast.recipes.syncFailed', { message: error.message }, 'error');
|
||||
} finally {
|
||||
state.loadingManager.hide();
|
||||
state.loadingManager.restoreProgressBar();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the recipe list by first rebuilding the cache and then loading recipes
|
||||
*/
|
||||
|
||||
@@ -117,7 +117,10 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
countSkipStatus(skipState) {
|
||||
let count = 0;
|
||||
for (const filePath of state.selectedModels) {
|
||||
const card = document.querySelector(`.model-card[data-filepath="${filePath}"]`);
|
||||
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
|
||||
? window.CSS.escape(filePath)
|
||||
: filePath.replace(/["\\]/g, '\\$&');
|
||||
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
||||
if (card) {
|
||||
const isSkipped = card.dataset.skip_metadata_refresh === 'true';
|
||||
if (isSkipped === skipState) {
|
||||
|
||||
@@ -201,8 +201,9 @@ class RecipeCard {
|
||||
this.recipe.favorite = isFavorite;
|
||||
|
||||
// Re-find star icon in case of re-render during fault
|
||||
const filePathForXpath = this.recipe.file_path.replace(/"/g, '"');
|
||||
const currentCard = card.ownerDocument.evaluate(
|
||||
`.//*[@data-filepath="${this.recipe.file_path}"]`,
|
||||
`.//*[@data-filepath="${filePathForXpath}"]`,
|
||||
card.ownerDocument, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null
|
||||
).singleNodeValue || card;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { translate } from '../utils/i18nHelpers.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { bulkManager } from '../managers/BulkManager.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { escapeHtml, escapeAttribute } from './shared/utils.js';
|
||||
|
||||
export class SidebarManager {
|
||||
constructor() {
|
||||
@@ -1294,15 +1295,19 @@ export class SidebarManager {
|
||||
const isExpanded = this.expandedNodes.has(currentPath);
|
||||
const isSelected = this.selectedPath === currentPath;
|
||||
|
||||
const escapedPath = escapeAttribute(currentPath);
|
||||
const escapedFolderName = escapeHtml(folderName);
|
||||
const escapedTitle = escapeAttribute(folderName);
|
||||
|
||||
return `
|
||||
<div class="sidebar-tree-node" data-path="${currentPath}">
|
||||
<div class="sidebar-tree-node-content ${isSelected ? 'selected' : ''}" data-path="${currentPath}">
|
||||
<div class="sidebar-tree-node" data-path="${escapedPath}">
|
||||
<div class="sidebar-tree-node-content ${isSelected ? 'selected' : ''}" data-path="${escapedPath}">
|
||||
<div class="sidebar-tree-expand-icon ${isExpanded ? 'expanded' : ''}"
|
||||
style="${hasChildren ? '' : 'opacity: 0; pointer-events: none;'}">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</div>
|
||||
<i class="fas fa-folder sidebar-tree-folder-icon"></i>
|
||||
<div class="sidebar-tree-folder-name" title="${folderName}">${folderName}</div>
|
||||
<div class="sidebar-tree-folder-name" title="${escapedTitle}">${escapedFolderName}</div>
|
||||
</div>
|
||||
${hasChildren ? `
|
||||
<div class="sidebar-tree-children ${isExpanded ? 'expanded' : ''}">
|
||||
@@ -1342,12 +1347,15 @@ export class SidebarManager {
|
||||
const foldersHtml = this.foldersList.map(folder => {
|
||||
const displayName = folder === '' ? '/' : folder;
|
||||
const isSelected = this.selectedPath === folder;
|
||||
const escapedPath = escapeAttribute(folder);
|
||||
const escapedDisplayName = escapeHtml(displayName);
|
||||
const escapedTitle = escapeAttribute(displayName);
|
||||
|
||||
return `
|
||||
<div class="sidebar-folder-item ${isSelected ? 'selected' : ''}" data-path="${folder}">
|
||||
<div class="sidebar-node-content" data-path="${folder}">
|
||||
<div class="sidebar-folder-item ${isSelected ? 'selected' : ''}" data-path="${escapedPath}">
|
||||
<div class="sidebar-node-content" data-path="${escapedPath}">
|
||||
<i class="fas fa-folder sidebar-folder-icon"></i>
|
||||
<div class="sidebar-folder-name" title="${displayName}">${displayName}</div>
|
||||
<div class="sidebar-folder-name" title="${escapedTitle}">${escapedDisplayName}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1570,7 +1578,8 @@ export class SidebarManager {
|
||||
|
||||
// Add selection to current path
|
||||
if (this.selectedPath !== null && this.selectedPath !== undefined) {
|
||||
const selectedItem = folderTree.querySelector(`[data-path="${this.selectedPath}"]`);
|
||||
const escapedPathSelector = CSS.escape(this.selectedPath);
|
||||
const selectedItem = folderTree.querySelector(`[data-path="${escapedPathSelector}"]`);
|
||||
if (selectedItem) {
|
||||
selectedItem.classList.add('selected');
|
||||
}
|
||||
@@ -1581,7 +1590,8 @@ export class SidebarManager {
|
||||
});
|
||||
|
||||
if (this.selectedPath !== null && this.selectedPath !== undefined) {
|
||||
const selectedNode = folderTree.querySelector(`[data-path="${this.selectedPath}"] .sidebar-tree-node-content`);
|
||||
const escapedPathSelector = CSS.escape(this.selectedPath);
|
||||
const selectedNode = folderTree.querySelector(`[data-path="${escapedPathSelector}"] .sidebar-tree-node-content`);
|
||||
if (selectedNode) {
|
||||
selectedNode.classList.add('selected');
|
||||
this.expandPathParents(this.selectedPath);
|
||||
@@ -1655,7 +1665,7 @@ export class SidebarManager {
|
||||
const breadcrumbs = [`
|
||||
<div class="breadcrumb-dropdown">
|
||||
<span class="sidebar-breadcrumb-item ${isRootSelected ? 'active' : ''}" data-path="">
|
||||
<i class="fas fa-home"></i> ${this.apiClient.apiConfig.config.displayName} root
|
||||
<i class="fas fa-home"></i> ${escapeHtml(this.apiClient.apiConfig.config.displayName)} root
|
||||
</span>
|
||||
</div>
|
||||
`];
|
||||
@@ -1675,8 +1685,8 @@ export class SidebarManager {
|
||||
</span>
|
||||
<div class="breadcrumb-dropdown-menu">
|
||||
${nextLevelFolders.map(folder => `
|
||||
<div class="breadcrumb-dropdown-item" data-path="${folder}">
|
||||
${folder}
|
||||
<div class="breadcrumb-dropdown-item" data-path="${escapeAttribute(folder)}">
|
||||
${escapeHtml(folder)}
|
||||
</div>`).join('')
|
||||
}
|
||||
</div>
|
||||
@@ -1692,12 +1702,14 @@ export class SidebarManager {
|
||||
|
||||
// Get siblings for this level
|
||||
const siblings = this.getSiblingFolders(parts, index);
|
||||
const escapedCurrentPath = escapeAttribute(currentPath);
|
||||
const escapedPart = escapeHtml(part);
|
||||
|
||||
breadcrumbs.push(`<span class="sidebar-breadcrumb-separator">/</span>`);
|
||||
breadcrumbs.push(`
|
||||
<div class="breadcrumb-dropdown">
|
||||
<span class="sidebar-breadcrumb-item ${isLast ? 'active' : ''}" data-path="${currentPath}">
|
||||
${part}
|
||||
<span class="sidebar-breadcrumb-item ${isLast ? 'active' : ''}" data-path="${escapedCurrentPath}">
|
||||
${escapedPart}
|
||||
${siblings.length > 1 ? `
|
||||
<span class="breadcrumb-dropdown-indicator">
|
||||
<i class="fas fa-caret-down"></i>
|
||||
@@ -1706,11 +1718,14 @@ export class SidebarManager {
|
||||
</span>
|
||||
${siblings.length > 1 ? `
|
||||
<div class="breadcrumb-dropdown-menu">
|
||||
${siblings.map(folder => `
|
||||
<div class="breadcrumb-dropdown-item ${folder === part ? 'active' : ''}"
|
||||
data-path="${currentPath.replace(part, folder)}">
|
||||
${folder}
|
||||
</div>`).join('')
|
||||
${siblings.map(folder => {
|
||||
const siblingPath = parts.slice(0, index).concat(folder).join('/');
|
||||
return `
|
||||
<div class="breadcrumb-dropdown-item ${folder === part ? 'active' : ''}"
|
||||
data-path="${escapeAttribute(siblingPath)}">
|
||||
${escapeHtml(folder)}
|
||||
</div>`;
|
||||
}).join('')
|
||||
}
|
||||
</div>
|
||||
` : ''}
|
||||
@@ -1732,8 +1747,8 @@ export class SidebarManager {
|
||||
</span>
|
||||
<div class="breadcrumb-dropdown-menu">
|
||||
${childFolders.map(folder => `
|
||||
<div class="breadcrumb-dropdown-item" data-path="${currentPath}/${folder}">
|
||||
${folder}
|
||||
<div class="breadcrumb-dropdown-item" data-path="${escapeAttribute(currentPath + '/' + folder)}">
|
||||
${escapeHtml(folder)}
|
||||
</div>`).join('')
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -846,8 +846,14 @@ function setupLoraSpecificFields(filePath) {
|
||||
|
||||
const currentPath = resolveFilePath();
|
||||
if (!currentPath) return;
|
||||
const loraCard = document.querySelector(`.model-card[data-filepath="${currentPath}"]`) ||
|
||||
document.querySelector(`.model-card[data-filepath="${filePath}"]`);
|
||||
const escapedCurrentPath = window.CSS && typeof window.CSS.escape === 'function'
|
||||
? window.CSS.escape(currentPath)
|
||||
: currentPath.replace(/["\\]/g, '\\$&');
|
||||
const escapedFilePath = window.CSS && typeof window.CSS.escape === 'function'
|
||||
? window.CSS.escape(filePath)
|
||||
: filePath.replace(/["\\]/g, '\\$&');
|
||||
const loraCard = document.querySelector(`.model-card[data-filepath="${escapedCurrentPath}"]`) ||
|
||||
document.querySelector(`.model-card[data-filepath="${escapedFilePath}"]`);
|
||||
const currentPresets = parsePresets(loraCard?.dataset.usage_tips);
|
||||
|
||||
if (key === 'strength_range') {
|
||||
|
||||
@@ -49,7 +49,10 @@ function formatPresetKey(key) {
|
||||
*/
|
||||
window.removePreset = async function(key) {
|
||||
const filePath = document.querySelector('#modelModal .modal-content .file-path').dataset.filepath;
|
||||
const loraCard = document.querySelector(`.model-card[data-filepath="${filePath}"]`);
|
||||
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
|
||||
? window.CSS.escape(filePath)
|
||||
: filePath.replace(/["\\]/g, '\\$&');
|
||||
const loraCard = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
||||
const currentPresets = parsePresets(loraCard.dataset.usage_tips);
|
||||
|
||||
delete currentPresets[key];
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* MetadataPanel.js
|
||||
* Generates metadata panels for showcase media items
|
||||
*/
|
||||
import { escapeHtml } from '../utils.js';
|
||||
|
||||
/**
|
||||
* Generate metadata panel HTML
|
||||
@@ -49,6 +50,7 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
|
||||
}
|
||||
|
||||
if (prompt) {
|
||||
prompt = escapeHtml(prompt);
|
||||
content += `
|
||||
<div class="metadata-row prompt-row">
|
||||
<span class="metadata-label">Prompt:</span>
|
||||
@@ -64,6 +66,7 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
|
||||
}
|
||||
|
||||
if (negativePrompt) {
|
||||
negativePrompt = escapeHtml(negativePrompt);
|
||||
content += `
|
||||
<div class="metadata-row prompt-row">
|
||||
<span class="metadata-label">Negative Prompt:</span>
|
||||
|
||||
@@ -568,7 +568,8 @@ export class BulkManager {
|
||||
}
|
||||
|
||||
deselectItem(filepath) {
|
||||
const card = document.querySelector(`.model-card[data-filepath="${filepath}"]`);
|
||||
const escapedPath = this.escapeAttributeValue(filepath);
|
||||
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
||||
if (card) {
|
||||
card.classList.remove('selected');
|
||||
}
|
||||
@@ -632,7 +633,8 @@ export class BulkManager {
|
||||
for (const filepath of state.selectedModels) {
|
||||
const metadata = metadataCache.get(filepath);
|
||||
if (metadata) {
|
||||
const card = document.querySelector(`.model-card[data-filepath="${filepath}"]`);
|
||||
const escapedPath = this.escapeAttributeValue(filepath);
|
||||
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
||||
if (card) {
|
||||
this.updateMetadataCacheFromCard(filepath, card);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
||||
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
||||
import { DuplicatesManager } from './components/DuplicatesManager.js';
|
||||
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
|
||||
import { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js';
|
||||
import { refreshRecipes, syncChanges, RecipeSidebarApiClient } from './api/recipeApi.js';
|
||||
import { sidebarManager } from './components/SidebarManager.js';
|
||||
|
||||
class RecipePageControls {
|
||||
@@ -27,7 +27,7 @@ class RecipePageControls {
|
||||
return;
|
||||
}
|
||||
|
||||
refreshVirtualScroll();
|
||||
await syncChanges();
|
||||
}
|
||||
|
||||
getSidebarApiClient() {
|
||||
@@ -236,6 +236,70 @@ class RecipeManager {
|
||||
refreshVirtualScroll();
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize dropdown functionality for refresh button
|
||||
this.initDropdowns();
|
||||
}
|
||||
|
||||
initDropdowns() {
|
||||
// Handle dropdown toggles
|
||||
const dropdownToggles = document.querySelectorAll('.dropdown-toggle');
|
||||
dropdownToggles.forEach(toggle => {
|
||||
toggle.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const dropdownGroup = toggle.closest('.dropdown-group');
|
||||
|
||||
// Close all other open dropdowns first
|
||||
document.querySelectorAll('.dropdown-group.active').forEach(group => {
|
||||
if (group !== dropdownGroup) {
|
||||
group.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
dropdownGroup.classList.toggle('active');
|
||||
});
|
||||
});
|
||||
|
||||
// Handle quick refresh option (Sync Changes)
|
||||
const quickRefreshOption = document.querySelector('[data-action="quick-refresh"]');
|
||||
if (quickRefreshOption) {
|
||||
quickRefreshOption.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.pageControls.refreshModels(false);
|
||||
this.closeDropdowns();
|
||||
});
|
||||
}
|
||||
|
||||
// Handle full rebuild option (Rebuild Cache)
|
||||
const fullRebuildOption = document.querySelector('[data-action="full-rebuild"]');
|
||||
if (fullRebuildOption) {
|
||||
fullRebuildOption.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.pageControls.refreshModels(true);
|
||||
this.closeDropdowns();
|
||||
});
|
||||
}
|
||||
|
||||
// Handle main refresh button (default: sync changes)
|
||||
const refreshBtn = document.querySelector('[data-action="refresh"]');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', () => {
|
||||
this.pageControls.refreshModels(false);
|
||||
});
|
||||
}
|
||||
|
||||
// Close dropdowns when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.dropdown-group')) {
|
||||
this.closeDropdowns();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
closeDropdowns() {
|
||||
document.querySelectorAll('.dropdown-group.active').forEach(group => {
|
||||
group.classList.remove('active');
|
||||
});
|
||||
}
|
||||
|
||||
// This method is kept for compatibility but now uses virtual scrolling
|
||||
|
||||
@@ -7,7 +7,10 @@ let pendingExcludePath = null;
|
||||
export function showDeleteModal(filePath) {
|
||||
pendingDeletePath = filePath;
|
||||
|
||||
const card = document.querySelector(`.model-card[data-filepath="${filePath}"]`);
|
||||
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
|
||||
? window.CSS.escape(filePath)
|
||||
: filePath.replace(/["\\]/g, '\\$&');
|
||||
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
||||
const modelName = card ? card.dataset.name : filePath.split('/').pop();
|
||||
const modal = modalManager.getModal('deleteModal').element;
|
||||
const modelInfo = modal.querySelector('.delete-model-info');
|
||||
@@ -47,7 +50,10 @@ export function closeDeleteModal() {
|
||||
export function showExcludeModal(filePath) {
|
||||
pendingExcludePath = filePath;
|
||||
|
||||
const card = document.querySelector(`.model-card[data-filepath="${filePath}"]`);
|
||||
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
|
||||
? window.CSS.escape(filePath)
|
||||
: filePath.replace(/["\\]/g, '\\$&');
|
||||
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
||||
const modelName = card ? card.dataset.name : filePath.split('/').pop();
|
||||
const modal = modalManager.getModal('excludeModal').element;
|
||||
const modelInfo = modal.querySelector('.exclude-model-info');
|
||||
|
||||
@@ -197,7 +197,10 @@ export function openCivitaiByMetadata(civitaiId, versionId, modelName = null) {
|
||||
}
|
||||
|
||||
export function openCivitai(filePath) {
|
||||
const loraCard = document.querySelector(`.model-card[data-filepath="${filePath}"]`);
|
||||
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
|
||||
? window.CSS.escape(filePath)
|
||||
: filePath.replace(/["\\]/g, '\\$&');
|
||||
const loraCard = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
||||
if (!loraCard) return;
|
||||
|
||||
const metaData = JSON.parse(loraCard.dataset.meta);
|
||||
@@ -483,8 +486,12 @@ async function ensureRelativeModelPath(modelPath, collectionType) {
|
||||
return modelPath;
|
||||
}
|
||||
|
||||
// Remove model file extension (.safetensors, .ckpt, .pt, .bin) for cleaner matching
|
||||
// Backend removes extensions from paths before matching, so search term should not include extension
|
||||
const searchTerm = fileName.replace(/\.(safetensors|ckpt|pt|bin)$/i, '');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/lm/${collectionType}/relative-paths?search=${encodeURIComponent(fileName)}&limit=10`);
|
||||
const response = await fetch(`/api/lm/${collectionType}/relative-paths?search=${encodeURIComponent(searchTerm)}&limit=10`);
|
||||
if (!response.ok) {
|
||||
return modelPath;
|
||||
}
|
||||
|
||||
@@ -66,10 +66,20 @@
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div title="{{ t('recipes.controls.refresh.title') }}" class="control-group">
|
||||
<button onclick="recipeManager.refreshRecipes()"><i class="fas fa-sync"></i> {{
|
||||
t('common.actions.refresh')
|
||||
}}</button>
|
||||
<div title="{{ t('recipes.controls.refresh.title') }}" class="control-group dropdown-group">
|
||||
<button data-action="refresh" class="dropdown-main"><i class="fas fa-sync"></i> <span>{{
|
||||
t('common.actions.refresh') }}</span></button>
|
||||
<button class="dropdown-toggle" aria-label="Show refresh options">
|
||||
<i class="fas fa-caret-down"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<div class="dropdown-item" data-action="quick-refresh" title="{{ t('recipes.controls.refresh.quickTooltip', default='Sync changes - quick refresh without rebuilding cache') }}">
|
||||
<i class="fas fa-bolt"></i> <span>{{ t('loras.controls.refresh.quick', default='Sync Changes') }}</span>
|
||||
</div>
|
||||
<div class="dropdown-item" data-action="full-rebuild" title="{{ t('recipes.controls.refresh.fullTooltip', default='Rebuild cache - full rescan of all recipe files') }}">
|
||||
<i class="fas fa-tools"></i> <span>{{ t('loras.controls.refresh.full', default='Rebuild Cache') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div title="{{ t('recipes.controls.import.title') }}" class="control-group">
|
||||
<button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> {{
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fetchApiMock).toHaveBeenCalledWith('/lm/loras/relative-paths?search=example&limit=20');
|
||||
expect(fetchApiMock).toHaveBeenCalledWith('/lm/loras/relative-paths?search=example&limit=100');
|
||||
const items = autoComplete.dropdown.querySelectorAll('.comfy-autocomplete-item');
|
||||
expect(items).toHaveLength(1);
|
||||
expect(autoComplete.dropdown.style.display).toBe('block');
|
||||
|
||||
75
tests/frontend/versionDetection.test.js
Normal file
75
tests/frontend/versionDetection.test.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('Version Detection Logic', () => {
|
||||
const parseVersion = (versionStr) => {
|
||||
if (!versionStr || typeof versionStr !== 'string') {
|
||||
return [0, 0, 0];
|
||||
}
|
||||
|
||||
const cleanVersion = versionStr.replace(/^[vV]/, '').split('-')[0];
|
||||
const parts = cleanVersion.split('.').map(part => parseInt(part, 10) || 0);
|
||||
|
||||
while (parts.length < 3) {
|
||||
parts.push(0);
|
||||
}
|
||||
|
||||
return parts;
|
||||
};
|
||||
|
||||
const compareVersions = (version1, version2) => {
|
||||
const v1 = typeof version1 === 'string' ? parseVersion(version1) : version1;
|
||||
const v2 = typeof version2 === 'string' ? parseVersion(version2) : version2;
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (v1[i] > v2[i]) return 1;
|
||||
if (v1[i] < v2[i]) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
const MIN_VERSION_FOR_ACTION_BAR = [1, 33, 9];
|
||||
|
||||
const supportsActionBarButtons = (version) => {
|
||||
return compareVersions(version, MIN_VERSION_FOR_ACTION_BAR) >= 0;
|
||||
};
|
||||
|
||||
it('should parse version strings correctly', () => {
|
||||
expect(parseVersion('1.33.9')).toEqual([1, 33, 9]);
|
||||
expect(parseVersion('v1.33.9')).toEqual([1, 33, 9]);
|
||||
expect(parseVersion('1.33.9-beta')).toEqual([1, 33, 9]);
|
||||
expect(parseVersion('1.33')).toEqual([1, 33, 0]);
|
||||
expect(parseVersion('1')).toEqual([1, 0, 0]);
|
||||
expect(parseVersion('')).toEqual([0, 0, 0]);
|
||||
expect(parseVersion(null)).toEqual([0, 0, 0]);
|
||||
});
|
||||
|
||||
it('should compare versions correctly', () => {
|
||||
expect(compareVersions('1.33.9', '1.33.9')).toBe(0);
|
||||
expect(compareVersions('1.33.10', '1.33.9')).toBe(1);
|
||||
expect(compareVersions('1.34.0', '1.33.9')).toBe(1);
|
||||
expect(compareVersions('2.0.0', '1.33.9')).toBe(1);
|
||||
expect(compareVersions('1.33.8', '1.33.9')).toBe(-1);
|
||||
expect(compareVersions('1.32.0', '1.33.9')).toBe(-1);
|
||||
expect(compareVersions('0.9.9', '1.33.9')).toBe(-1);
|
||||
});
|
||||
|
||||
it('should return false for versions below 1.33.9', () => {
|
||||
expect(supportsActionBarButtons('1.33.8')).toBe(false);
|
||||
expect(supportsActionBarButtons('1.32.0')).toBe(false);
|
||||
expect(supportsActionBarButtons('0.9.9')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for versions 1.33.9 and above', () => {
|
||||
expect(supportsActionBarButtons('1.33.9')).toBe(true);
|
||||
expect(supportsActionBarButtons('1.33.10')).toBe(true);
|
||||
expect(supportsActionBarButtons('1.34.0')).toBe(true);
|
||||
expect(supportsActionBarButtons('2.0.0')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle edge cases in version parsing', () => {
|
||||
expect(supportsActionBarButtons('v1.33.9')).toBe(true);
|
||||
expect(supportsActionBarButtons('1.33.9-rc.1')).toBe(true);
|
||||
expect(supportsActionBarButtons('1.33.9-beta')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -60,11 +60,13 @@ class StubLoraScanner:
|
||||
"preview_url": info.get("preview_url", ""),
|
||||
"civitai": info.get("civitai", {}),
|
||||
}
|
||||
self._cache.raw_data.append({
|
||||
"sha256": info.get("sha256", ""),
|
||||
"path": info.get("file_path", ""),
|
||||
"civitai": info.get("civitai", {}),
|
||||
})
|
||||
self._cache.raw_data.append(
|
||||
{
|
||||
"sha256": info.get("sha256", ""),
|
||||
"path": info.get("file_path", ""),
|
||||
"civitai": info.get("civitai", {}),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -107,7 +109,8 @@ async def test_add_recipe_during_concurrent_reads(recipe_scanner):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
await asyncio.gather(reader_task(), reader_task(), scanner.add_recipe(new_recipe))
|
||||
await asyncio.sleep(0)
|
||||
# Wait a bit longer for the thread-pool resort to complete
|
||||
await asyncio.sleep(0.1)
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
assert {item["id"] for item in cache.raw_data} == {"one", "two"}
|
||||
@@ -119,14 +122,16 @@ async def test_remove_recipe_during_reads(recipe_scanner):
|
||||
|
||||
recipe_ids = ["alpha", "beta", "gamma"]
|
||||
for index, recipe_id in enumerate(recipe_ids):
|
||||
await scanner.add_recipe({
|
||||
"id": recipe_id,
|
||||
"file_path": f"path/{recipe_id}.png",
|
||||
"title": recipe_id,
|
||||
"modified": float(index),
|
||||
"created_date": float(index),
|
||||
"loras": [],
|
||||
})
|
||||
await scanner.add_recipe(
|
||||
{
|
||||
"id": recipe_id,
|
||||
"file_path": f"path/{recipe_id}.png",
|
||||
"title": recipe_id,
|
||||
"modified": float(index),
|
||||
"created_date": float(index),
|
||||
"loras": [],
|
||||
}
|
||||
)
|
||||
|
||||
async def reader_task():
|
||||
for _ in range(5):
|
||||
@@ -155,7 +160,13 @@ async def test_update_lora_entry_updates_cache_and_file(tmp_path: Path, recipe_s
|
||||
"modified": 0.0,
|
||||
"created_date": 0.0,
|
||||
"loras": [
|
||||
{"file_name": "old", "strength": 1.0, "hash": "", "isDeleted": True, "exclude": True},
|
||||
{
|
||||
"file_name": "old",
|
||||
"strength": 1.0,
|
||||
"hash": "",
|
||||
"isDeleted": True,
|
||||
"exclude": True,
|
||||
},
|
||||
],
|
||||
}
|
||||
recipe_path.write_text(json.dumps(recipe_data))
|
||||
@@ -380,7 +391,9 @@ async def test_initialize_waits_for_lora_scanner(monkeypatch):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_model_version_marked_deleted_and_not_retried(monkeypatch, recipe_scanner):
|
||||
async def test_invalid_model_version_marked_deleted_and_not_retried(
|
||||
monkeypatch, recipe_scanner
|
||||
):
|
||||
scanner, _ = recipe_scanner
|
||||
recipes_dir = Path(config.loras_roots[0]) / "recipes"
|
||||
recipes_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -417,7 +430,9 @@ async def test_invalid_model_version_marked_deleted_and_not_retried(monkeypatch,
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_recipe_persists_deleted_flag_on_invalid_version(monkeypatch, recipe_scanner, tmp_path):
|
||||
async def test_load_recipe_persists_deleted_flag_on_invalid_version(
|
||||
monkeypatch, recipe_scanner, tmp_path
|
||||
):
|
||||
scanner, _ = recipe_scanner
|
||||
recipes_dir = Path(config.loras_roots[0]) / "recipes"
|
||||
recipes_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -448,7 +463,9 @@ async def test_load_recipe_persists_deleted_flag_on_invalid_version(monkeypatch,
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_lora_filename_by_hash_updates_affected_recipes(tmp_path: Path, recipe_scanner):
|
||||
async def test_update_lora_filename_by_hash_updates_affected_recipes(
|
||||
tmp_path: Path, recipe_scanner
|
||||
):
|
||||
scanner, _ = recipe_scanner
|
||||
recipes_dir = Path(config.loras_roots[0]) / "recipes"
|
||||
recipes_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -464,7 +481,7 @@ async def test_update_lora_filename_by_hash_updates_affected_recipes(tmp_path: P
|
||||
"created_date": 0.0,
|
||||
"loras": [
|
||||
{"file_name": "old_name", "hash": "hash1"},
|
||||
{"file_name": "other_lora", "hash": "hash2"}
|
||||
{"file_name": "other_lora", "hash": "hash2"},
|
||||
],
|
||||
}
|
||||
recipe1_path.write_text(json.dumps(recipe1_data))
|
||||
@@ -479,16 +496,16 @@ async def test_update_lora_filename_by_hash_updates_affected_recipes(tmp_path: P
|
||||
"title": "Recipe 2",
|
||||
"modified": 0.0,
|
||||
"created_date": 0.0,
|
||||
"loras": [
|
||||
{"file_name": "other_lora", "hash": "hash2"}
|
||||
],
|
||||
"loras": [{"file_name": "other_lora", "hash": "hash2"}],
|
||||
}
|
||||
recipe2_path.write_text(json.dumps(recipe2_data))
|
||||
await scanner.add_recipe(dict(recipe2_data))
|
||||
|
||||
# Update LoRA name for "hash1" (using different case to test normalization)
|
||||
new_name = "new_name"
|
||||
file_count, cache_count = await scanner.update_lora_filename_by_hash("HASH1", new_name)
|
||||
file_count, cache_count = await scanner.update_lora_filename_by_hash(
|
||||
"HASH1", new_name
|
||||
)
|
||||
|
||||
assert file_count == 1
|
||||
assert cache_count == 1
|
||||
@@ -512,25 +529,29 @@ async def test_get_paginated_data_filters_by_favorite(recipe_scanner):
|
||||
scanner, _ = recipe_scanner
|
||||
|
||||
# Add a normal recipe
|
||||
await scanner.add_recipe({
|
||||
"id": "regular",
|
||||
"file_path": "path/regular.png",
|
||||
"title": "Regular Recipe",
|
||||
"modified": 1.0,
|
||||
"created_date": 1.0,
|
||||
"loras": [],
|
||||
})
|
||||
await scanner.add_recipe(
|
||||
{
|
||||
"id": "regular",
|
||||
"file_path": "path/regular.png",
|
||||
"title": "Regular Recipe",
|
||||
"modified": 1.0,
|
||||
"created_date": 1.0,
|
||||
"loras": [],
|
||||
}
|
||||
)
|
||||
|
||||
# Add a favorite recipe
|
||||
await scanner.add_recipe({
|
||||
"id": "favorite",
|
||||
"file_path": "path/favorite.png",
|
||||
"title": "Favorite Recipe",
|
||||
"modified": 2.0,
|
||||
"created_date": 2.0,
|
||||
"loras": [],
|
||||
"favorite": True
|
||||
})
|
||||
await scanner.add_recipe(
|
||||
{
|
||||
"id": "favorite",
|
||||
"file_path": "path/favorite.png",
|
||||
"title": "Favorite Recipe",
|
||||
"modified": 2.0,
|
||||
"created_date": 2.0,
|
||||
"loras": [],
|
||||
"favorite": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Wait for cache update (it's async in some places, add_recipe is usually enough but let's be safe)
|
||||
await asyncio.sleep(0)
|
||||
@@ -540,13 +561,17 @@ async def test_get_paginated_data_filters_by_favorite(recipe_scanner):
|
||||
assert len(result_all["items"]) == 2
|
||||
|
||||
# Test with favorite filter
|
||||
result_fav = await scanner.get_paginated_data(page=1, page_size=10, filters={"favorite": True})
|
||||
result_fav = await scanner.get_paginated_data(
|
||||
page=1, page_size=10, filters={"favorite": True}
|
||||
)
|
||||
assert len(result_fav["items"]) == 1
|
||||
assert result_fav["items"][0]["id"] == "favorite"
|
||||
|
||||
# Test with favorite filter set to False (should return both or at least not filter if it's the default)
|
||||
# Actually our implementation checks if 'favorite' in filters and filters['favorite']
|
||||
result_fav_false = await scanner.get_paginated_data(page=1, page_size=10, filters={"favorite": False})
|
||||
result_fav_false = await scanner.get_paginated_data(
|
||||
page=1, page_size=10, filters={"favorite": False}
|
||||
)
|
||||
assert len(result_fav_false["items"]) == 2
|
||||
|
||||
|
||||
@@ -555,30 +580,30 @@ async def test_get_paginated_data_filters_by_prompt(recipe_scanner):
|
||||
scanner, _ = recipe_scanner
|
||||
|
||||
# Add a recipe with a specific prompt
|
||||
await scanner.add_recipe({
|
||||
"id": "prompt-recipe",
|
||||
"file_path": "path/prompt.png",
|
||||
"title": "Prompt Recipe",
|
||||
"modified": 1.0,
|
||||
"created_date": 1.0,
|
||||
"loras": [],
|
||||
"gen_params": {
|
||||
"prompt": "a beautiful forest landscape"
|
||||
await scanner.add_recipe(
|
||||
{
|
||||
"id": "prompt-recipe",
|
||||
"file_path": "path/prompt.png",
|
||||
"title": "Prompt Recipe",
|
||||
"modified": 1.0,
|
||||
"created_date": 1.0,
|
||||
"loras": [],
|
||||
"gen_params": {"prompt": "a beautiful forest landscape"},
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
# Add a recipe with a specific negative prompt
|
||||
await scanner.add_recipe({
|
||||
"id": "neg-prompt-recipe",
|
||||
"file_path": "path/neg.png",
|
||||
"title": "Negative Prompt Recipe",
|
||||
"modified": 2.0,
|
||||
"created_date": 2.0,
|
||||
"loras": [],
|
||||
"gen_params": {
|
||||
"negative_prompt": "ugly, blurry mountains"
|
||||
await scanner.add_recipe(
|
||||
{
|
||||
"id": "neg-prompt-recipe",
|
||||
"file_path": "path/neg.png",
|
||||
"title": "Negative Prompt Recipe",
|
||||
"modified": 2.0,
|
||||
"created_date": 2.0,
|
||||
"loras": [],
|
||||
"gen_params": {"negative_prompt": "ugly, blurry mountains"},
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
await asyncio.sleep(0)
|
||||
|
||||
@@ -609,20 +634,35 @@ async def test_get_paginated_data_sorting(recipe_scanner):
|
||||
|
||||
# Add test recipes
|
||||
# Recipe A: Name "Alpha", Date 10, LoRAs 2
|
||||
await scanner.add_recipe({
|
||||
"id": "A", "title": "Alpha", "created_date": 10.0,
|
||||
"loras": [{}, {}], "file_path": "a.png"
|
||||
})
|
||||
await scanner.add_recipe(
|
||||
{
|
||||
"id": "A",
|
||||
"title": "Alpha",
|
||||
"created_date": 10.0,
|
||||
"loras": [{}, {}],
|
||||
"file_path": "a.png",
|
||||
}
|
||||
)
|
||||
# Recipe B: Name "Beta", Date 20, LoRAs 1
|
||||
await scanner.add_recipe({
|
||||
"id": "B", "title": "Beta", "created_date": 20.0,
|
||||
"loras": [{}], "file_path": "b.png"
|
||||
})
|
||||
await scanner.add_recipe(
|
||||
{
|
||||
"id": "B",
|
||||
"title": "Beta",
|
||||
"created_date": 20.0,
|
||||
"loras": [{}],
|
||||
"file_path": "b.png",
|
||||
}
|
||||
)
|
||||
# Recipe C: Name "Gamma", Date 5, LoRAs 3
|
||||
await scanner.add_recipe({
|
||||
"id": "C", "title": "Gamma", "created_date": 5.0,
|
||||
"loras": [{}, {}, {}], "file_path": "c.png"
|
||||
})
|
||||
await scanner.add_recipe(
|
||||
{
|
||||
"id": "C",
|
||||
"title": "Gamma",
|
||||
"created_date": 5.0,
|
||||
"loras": [{}, {}, {}],
|
||||
"file_path": "c.png",
|
||||
}
|
||||
)
|
||||
|
||||
await asyncio.sleep(0)
|
||||
|
||||
@@ -631,11 +671,15 @@ async def test_get_paginated_data_sorting(recipe_scanner):
|
||||
assert [i["id"] for i in res["items"]] == ["C", "B", "A"]
|
||||
|
||||
# Test LoRA Count DESC: Gamma (3), Alpha (2), Beta (1)
|
||||
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="loras_count:desc")
|
||||
res = await scanner.get_paginated_data(
|
||||
page=1, page_size=10, sort_by="loras_count:desc"
|
||||
)
|
||||
assert [i["id"] for i in res["items"]] == ["C", "A", "B"]
|
||||
|
||||
# Test LoRA Count ASC: Beta (1), Alpha (2), Gamma (3)
|
||||
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="loras_count:asc")
|
||||
res = await scanner.get_paginated_data(
|
||||
page=1, page_size=10, sort_by="loras_count:asc"
|
||||
)
|
||||
assert [i["id"] for i in res["items"]] == ["B", "A", "C"]
|
||||
|
||||
# Test Date ASC: Gamma (5), Alpha (10), Beta (20)
|
||||
|
||||
@@ -62,3 +62,42 @@ async def test_search_relative_paths_excludes_tokens():
|
||||
matching = await service.search_relative_paths("flux -detail")
|
||||
|
||||
assert matching == [f"flux{os.sep}keep-me.safetensors"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_does_not_match_extension():
|
||||
"""Searching for 's' or 'safe' should not match .safetensors extension."""
|
||||
scanner = FakeScanner(
|
||||
[
|
||||
{"file_path": "/models/lora1.safetensors"},
|
||||
{"file_path": "/models/lora2.safetensors"},
|
||||
{"file_path": "/models/special-model.safetensors"}, # 's' in filename
|
||||
],
|
||||
["/models"],
|
||||
)
|
||||
service = DummyService("stub", scanner, BaseModelMetadata)
|
||||
|
||||
# Searching for 's' should only match 'special-model', not all .safetensors
|
||||
matching = await service.search_relative_paths("s")
|
||||
|
||||
# Should only match 'special-model' because 's' is in the filename
|
||||
assert len(matching) == 1
|
||||
assert "special-model" in matching[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_safe_does_not_match_all_files():
|
||||
"""Searching for 'safe' should not match .safetensors extension."""
|
||||
scanner = FakeScanner(
|
||||
[
|
||||
{"file_path": "/models/flux.safetensors"},
|
||||
{"file_path": "/models/detail.safetensors"},
|
||||
],
|
||||
["/models"],
|
||||
)
|
||||
service = DummyService("stub", scanner, BaseModelMetadata)
|
||||
|
||||
# Searching for 'safe' should return nothing (no file has 'safe' in its name)
|
||||
matching = await service.search_relative_paths("safe")
|
||||
|
||||
assert len(matching) == 0
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from py.services.custom_words_service import CustomWordsService, get_custom_words_service
|
||||
from py.services.custom_words_service import (
|
||||
CustomWordsService,
|
||||
get_custom_words_service,
|
||||
)
|
||||
|
||||
|
||||
class TestCustomWordsService:
|
||||
@@ -99,13 +102,19 @@ class MockTagFTSIndex:
|
||||
self.called = False
|
||||
self._results = [
|
||||
{"tag_name": "hatsune_miku", "category": 4, "post_count": 500000},
|
||||
{"tag_name": "hatsune_miku_(vocaloid)", "category": 4, "post_count": 250000},
|
||||
{
|
||||
"tag_name": "hatsune_miku_(vocaloid)",
|
||||
"category": 4,
|
||||
"post_count": 250000,
|
||||
},
|
||||
]
|
||||
|
||||
def search(self, query, categories=None, limit=20):
|
||||
def search(self, query, categories=None, limit=20, offset=0):
|
||||
self.called = True
|
||||
if not query:
|
||||
return []
|
||||
if categories:
|
||||
return [r for r in self._results if r["category"] in categories][:limit]
|
||||
return self._results[:limit]
|
||||
results = [r for r in self._results if r["category"] in categories]
|
||||
else:
|
||||
results = self._results
|
||||
return results[offset : offset + limit]
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
:spellcheck="spellcheck ?? false"
|
||||
:class="['text-input', { 'vue-dom-mode': isVueDomMode }]"
|
||||
@input="onInput"
|
||||
@wheel="onWheel"
|
||||
/>
|
||||
<button
|
||||
v-if="showClearButton"
|
||||
@@ -82,6 +83,59 @@ const onInput = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse wheel events on the textarea.
|
||||
* Forwards the event to the ComfyUI canvas for zooming when the textarea has no scrollbar,
|
||||
* or handles pinch-to-zoom gestures.
|
||||
*
|
||||
* Logic aligns with ComfyUI's built-in multiline widget:
|
||||
* src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts
|
||||
*/
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
const textarea = textareaRef.value
|
||||
if (!textarea) return
|
||||
|
||||
// Track if we have a vertical scrollbar
|
||||
const canScrollY = textarea.scrollHeight > textarea.clientHeight
|
||||
const deltaX = event.deltaX
|
||||
const deltaY = event.deltaY
|
||||
const isHorizontal = Math.abs(deltaX) > Math.abs(deltaY)
|
||||
|
||||
// Access ComfyUI app from global window
|
||||
const app = (window as any).app
|
||||
if (!app || !app.canvas || typeof app.canvas.processMouseWheel !== 'function') {
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Handle pinch-to-zoom (ctrlKey is true for pinch-to-zoom on most browsers)
|
||||
if (event.ctrlKey) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
app.canvas.processMouseWheel(event)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Horizontal scroll: pass to canvas (textareas usually don't scroll horizontally)
|
||||
if (isHorizontal) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
app.canvas.processMouseWheel(event)
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Vertical scrolling:
|
||||
if (canScrollY) {
|
||||
// If the textarea is scrollable, let it handle the wheel event but stop propagation
|
||||
// to prevent the canvas from zooming while the user is trying to scroll the text
|
||||
event.stopPropagation()
|
||||
} else {
|
||||
// If the textarea is NOT scrollable, forward the wheel event to the canvas
|
||||
// so it can trigger zoom in/out
|
||||
event.preventDefault()
|
||||
app.canvas.processMouseWheel(event)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle external value changes (e.g., from "send lora to workflow")
|
||||
const onExternalValueChange = (event: CustomEvent<{ value: string }>) => {
|
||||
updateHasTextState()
|
||||
@@ -191,7 +245,7 @@ onUnmounted(() => {
|
||||
color: var(--input-text, #ddd);
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 2px;
|
||||
padding: 2px 2px 24px 2px; /* Reserve bottom space for clear button */
|
||||
resize: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
@@ -204,7 +258,7 @@ onUnmounted(() => {
|
||||
.text-input.vue-dom-mode {
|
||||
background-color: var(--color-charcoal-400, #313235);
|
||||
color: #fff;
|
||||
padding: 8px 12px;
|
||||
padding: 8px 12px 30px 12px; /* Reserve bottom space for clear button */
|
||||
margin: 0 0 4px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
@@ -218,8 +272,8 @@ onUnmounted(() => {
|
||||
/* Clear button styles */
|
||||
.clear-button {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 4px;
|
||||
right: 6px;
|
||||
bottom: 6px; /* Changed from top to bottom */
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
@@ -232,11 +286,18 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.7;
|
||||
opacity: 0; /* Hidden by default */
|
||||
pointer-events: none; /* Not clickable when hidden */
|
||||
transition: opacity 0.2s ease, background-color 0.2s ease;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Show clear button when hovering over input wrapper */
|
||||
.input-wrapper:hover .clear-button {
|
||||
opacity: 0.7;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.clear-button:hover {
|
||||
opacity: 1;
|
||||
background: rgba(255, 100, 100, 0.8);
|
||||
@@ -250,7 +311,7 @@ onUnmounted(() => {
|
||||
/* Vue DOM mode adjustments for clear button */
|
||||
.text-input.vue-dom-mode ~ .clear-button {
|
||||
right: 8px;
|
||||
top: 8px;
|
||||
bottom: 10px; /* Changed from top to bottom, adjusted for Vue DOM padding */
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: rgba(107, 114, 128, 0.6);
|
||||
|
||||
@@ -269,10 +269,14 @@ class AutoComplete {
|
||||
this.modelType = modelType;
|
||||
this.behavior = getModelBehavior(modelType);
|
||||
this.options = {
|
||||
maxItems: 20,
|
||||
maxItems: 100,
|
||||
pageSize: 20,
|
||||
visibleItems: 15, // Fixed at 15 items for balanced UX
|
||||
itemHeight: 40,
|
||||
minChars: 1,
|
||||
debounceDelay: 200,
|
||||
showPreview: this.behavior.enablePreview ?? false,
|
||||
enableVirtualScroll: true,
|
||||
...options
|
||||
};
|
||||
|
||||
@@ -286,6 +290,15 @@ class AutoComplete {
|
||||
this.previewTooltipPromise = null;
|
||||
this.searchType = null;
|
||||
|
||||
// Virtual scrolling state
|
||||
this.virtualScrollOffset = 0;
|
||||
this.hasMoreItems = true;
|
||||
this.isLoadingMore = false;
|
||||
this.currentPage = 0;
|
||||
this.scrollContainer = null;
|
||||
this.contentContainer = null;
|
||||
this.totalHeight = 0;
|
||||
|
||||
// Command mode state
|
||||
this.activeCommand = null; // Current active command (e.g., { categories: [4, 11], label: 'Character' })
|
||||
this.showingCommands = false; // Whether showing command list dropdown
|
||||
@@ -297,6 +310,7 @@ class AutoComplete {
|
||||
this.onKeyDown = null;
|
||||
this.onBlur = null;
|
||||
this.onDocumentClick = null;
|
||||
this.onScroll = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
@@ -314,7 +328,7 @@ class AutoComplete {
|
||||
this.dropdown.style.cssText = `
|
||||
position: absolute;
|
||||
z-index: 10000;
|
||||
overflow-y: visible;
|
||||
overflow: hidden;
|
||||
background-color: rgba(40, 44, 52, 0.95);
|
||||
border: 1px solid rgba(226, 232, 240, 0.2);
|
||||
border-radius: 8px;
|
||||
@@ -326,6 +340,28 @@ class AutoComplete {
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
`;
|
||||
|
||||
if (this.options.enableVirtualScroll) {
|
||||
// Create scroll container for virtual scrolling
|
||||
this.scrollContainer = document.createElement('div');
|
||||
this.scrollContainer.className = 'comfy-autocomplete-scroll-container';
|
||||
this.scrollContainer.style.cssText = `
|
||||
overflow-y: auto;
|
||||
max-height: ${this.options.visibleItems * this.options.itemHeight}px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
// Create content container for virtual items
|
||||
this.contentContainer = document.createElement('div');
|
||||
this.contentContainer.className = 'comfy-autocomplete-content';
|
||||
this.contentContainer.style.cssText = `
|
||||
position: relative;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
this.scrollContainer.appendChild(this.contentContainer);
|
||||
this.dropdown.appendChild(this.scrollContainer);
|
||||
}
|
||||
|
||||
// Custom scrollbar styles with new color scheme
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@@ -343,6 +379,26 @@ class AutoComplete {
|
||||
.comfy-autocomplete-dropdown::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(226, 232, 240, 0.4);
|
||||
}
|
||||
.comfy-autocomplete-scroll-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
.comfy-autocomplete-scroll-container::-webkit-scrollbar-track {
|
||||
background: rgba(40, 44, 52, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.comfy-autocomplete-scroll-container::-webkit-scrollbar-thumb {
|
||||
background: rgba(226, 232, 240, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.comfy-autocomplete-scroll-container::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(226, 232, 240, 0.4);
|
||||
}
|
||||
.comfy-autocomplete-loading {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: rgba(226, 232, 240, 0.5);
|
||||
font-size: 12px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
@@ -410,6 +466,14 @@ class AutoComplete {
|
||||
|
||||
// Mark this element as having autocomplete events bound
|
||||
this.inputElement._autocompleteEventsBound = true;
|
||||
|
||||
// Bind scroll event for virtual scrolling
|
||||
if (this.options.enableVirtualScroll && this.scrollContainer) {
|
||||
this.onScroll = () => {
|
||||
this.handleScroll();
|
||||
};
|
||||
this.scrollContainer.addEventListener('scroll', this.onScroll);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -529,10 +593,12 @@ class AutoComplete {
|
||||
this.showingCommands = false;
|
||||
this.activeCommand = null;
|
||||
endpoint = '/lm/custom-words/search?enriched=true';
|
||||
// Extract last space-separated token for search
|
||||
// Tag names don't contain spaces, so we only need the last token
|
||||
// This allows "hello 1gi" to search for "1gi" and find "1girl"
|
||||
searchTerm = this._getLastSpaceToken(rawSearchTerm);
|
||||
// Use full search term for query variation generation
|
||||
// The search() method will generate multiple query variations including:
|
||||
// - Original query (for natural language matching)
|
||||
// - Underscore version (e.g., "looking_to_the_side" for "looking to the side")
|
||||
// - Last token (for backward compatibility with continuous typing)
|
||||
searchTerm = rawSearchTerm;
|
||||
this.searchType = 'custom_words';
|
||||
} else {
|
||||
// No command and setting disabled - no autocomplete for direct typing
|
||||
@@ -579,6 +645,99 @@ class AutoComplete {
|
||||
return tokens[tokens.length - 1] || term;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate query variations for better autocomplete matching
|
||||
* Includes original query and normalized versions (spaces to underscores, etc.)
|
||||
* @param {string} term - Original search term
|
||||
* @returns {string[]} - Array of query variations
|
||||
*/
|
||||
_generateQueryVariations(term) {
|
||||
if (!term || term.length < this.options.minChars) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const variations = new Set();
|
||||
const trimmed = term.trim();
|
||||
|
||||
// Always include original query
|
||||
variations.add(trimmed);
|
||||
variations.add(trimmed.toLowerCase());
|
||||
|
||||
// Add underscore version (Danbooru convention: spaces become underscores)
|
||||
// e.g., "looking to the side" -> "looking_to_the_side"
|
||||
if (trimmed.includes(' ')) {
|
||||
const underscoreVersion = trimmed.replace(/ /g, '_');
|
||||
variations.add(underscoreVersion);
|
||||
variations.add(underscoreVersion.toLowerCase());
|
||||
}
|
||||
|
||||
// Add no-space version for flexible matching
|
||||
// e.g., "blue hair" -> "bluehair"
|
||||
if (trimmed.includes(' ') || trimmed.includes('_')) {
|
||||
const noSpaceVersion = trimmed.replace(/[ _]/g, '');
|
||||
variations.add(noSpaceVersion);
|
||||
variations.add(noSpaceVersion.toLowerCase());
|
||||
}
|
||||
|
||||
// Add last token only (legacy behavior for continuous typing)
|
||||
const lastToken = this._getLastSpaceToken(trimmed);
|
||||
if (lastToken !== trimmed) {
|
||||
variations.add(lastToken);
|
||||
variations.add(lastToken.toLowerCase());
|
||||
}
|
||||
|
||||
return Array.from(variations).filter(v => v.length >= this.options.minChars);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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') {
|
||||
return removeLoraExtension(itemText);
|
||||
} else if (this.modelType === 'embeddings') {
|
||||
return removeGeneralExtension(itemText);
|
||||
}
|
||||
return itemText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an item matches a search term
|
||||
* Supports both string items and enriched items with tag_name property
|
||||
* @param {string|Object} item - Item to check
|
||||
* @param {string} searchTerm - Search term to match against
|
||||
* @returns {Object} - { matched: boolean, isExactMatch: boolean }
|
||||
*/
|
||||
_matchItem(item, searchTerm) {
|
||||
const itemText = this._getDisplayText(item);
|
||||
const itemTextLower = itemText.toLowerCase();
|
||||
const searchTermLower = searchTerm.toLowerCase();
|
||||
|
||||
// Exact match (case-insensitive)
|
||||
if (itemTextLower === searchTermLower) {
|
||||
return { matched: true, isExactMatch: true };
|
||||
}
|
||||
|
||||
// Partial match (contains)
|
||||
if (itemTextLower.includes(searchTermLower)) {
|
||||
return { matched: true, isExactMatch: false };
|
||||
}
|
||||
|
||||
// Symbol-insensitive match: remove common separators and retry
|
||||
// e.g., "blue hair" can match "blue_hair" or "bluehair"
|
||||
const normalizedItem = itemTextLower.replace(/[-_\s']/g, '');
|
||||
const normalizedSearch = searchTermLower.replace(/[-_\s']/g, '');
|
||||
if (normalizedItem.includes(normalizedSearch)) {
|
||||
return { matched: true, isExactMatch: false };
|
||||
}
|
||||
|
||||
return { matched: false, isExactMatch: false };
|
||||
}
|
||||
|
||||
async search(term = '', endpoint = null) {
|
||||
try {
|
||||
this.currentSearchTerm = term;
|
||||
@@ -587,26 +746,88 @@ class AutoComplete {
|
||||
endpoint = `/lm/${this.modelType}/relative-paths`;
|
||||
}
|
||||
|
||||
const url = endpoint.includes('?')
|
||||
? `${endpoint}&search=${encodeURIComponent(term)}&limit=${this.options.maxItems}`
|
||||
: `${endpoint}?search=${encodeURIComponent(term)}&limit=${this.options.maxItems}`;
|
||||
// Generate multiple query variations for better matching
|
||||
const queryVariations = this._generateQueryVariations(term);
|
||||
|
||||
const response = await api.fetchApi(url);
|
||||
const data = await response.json();
|
||||
if (queryVariations.length === 0) {
|
||||
this.items = [];
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
// Support both response formats:
|
||||
// 1. Model endpoint format: { success: true, relative_paths: [...] }
|
||||
// 2. Custom words format: { success: true, words: [...] }
|
||||
if (data.success) {
|
||||
const items = data.relative_paths || data.words || [];
|
||||
if (items.length > 0) {
|
||||
this.items = items;
|
||||
this.render();
|
||||
this.show();
|
||||
} else {
|
||||
this.items = [];
|
||||
this.hide();
|
||||
// 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('?')
|
||||
? `${endpoint}&search=${encodeURIComponent(query)}&limit=${this.options.maxItems}`
|
||||
: `${endpoint}?search=${encodeURIComponent(query)}&limit=${this.options.maxItems}`;
|
||||
|
||||
try {
|
||||
const response = await api.fetchApi(url);
|
||||
const data = await response.json();
|
||||
return data.success ? (data.relative_paths || data.words || []) : [];
|
||||
} catch (error) {
|
||||
console.warn(`Search query failed for "${query}":`, error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const resultsArrays = await Promise.all(searchPromises);
|
||||
|
||||
// Merge and deduplicate results
|
||||
const seen = new Set();
|
||||
const mergedItems = [];
|
||||
|
||||
for (const resultArray of resultsArrays) {
|
||||
for (const item of resultArray) {
|
||||
const itemKey = typeof item === 'object' && item.tag_name
|
||||
? item.tag_name.toLowerCase()
|
||||
: String(item).toLowerCase();
|
||||
|
||||
if (!seen.has(itemKey)) {
|
||||
seen.add(itemKey);
|
||||
mergedItems.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Score and sort results: exact matches first, then by match quality
|
||||
const scoredItems = mergedItems.map(item => {
|
||||
let bestScore = -1;
|
||||
let isExact = false;
|
||||
|
||||
for (const query of queriesToExecute) {
|
||||
const match = this._matchItem(item, query);
|
||||
if (match.matched) {
|
||||
// Higher score for exact matches
|
||||
const score = match.isExactMatch ? 1000 : 100;
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
isExact = match.isExactMatch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { item, score: bestScore, isExact };
|
||||
});
|
||||
|
||||
// Sort by score (descending), exact matches first
|
||||
scoredItems.sort((a, b) => {
|
||||
if (b.isExact !== a.isExact) {
|
||||
return b.isExact ? 1 : -1;
|
||||
}
|
||||
return b.score - a.score;
|
||||
});
|
||||
|
||||
// Extract just the items
|
||||
const sortedItems = scoredItems.map(s => s.item);
|
||||
|
||||
if (sortedItems.length > 0) {
|
||||
this.items = sortedItems;
|
||||
this.render();
|
||||
this.show();
|
||||
} else {
|
||||
this.items = [];
|
||||
this.hide();
|
||||
@@ -817,80 +1038,104 @@ class AutoComplete {
|
||||
}
|
||||
|
||||
render() {
|
||||
this.dropdown.innerHTML = '';
|
||||
this.selectedIndex = -1;
|
||||
|
||||
// Reset virtual scroll state
|
||||
this.virtualScrollOffset = 0;
|
||||
this.currentPage = 0;
|
||||
this.hasMoreItems = true;
|
||||
this.isLoadingMore = false;
|
||||
|
||||
// Early return if no items to prevent empty dropdown
|
||||
if (!this.items || this.items.length === 0) {
|
||||
if (this.contentContainer) {
|
||||
this.contentContainer.innerHTML = '';
|
||||
} else {
|
||||
this.dropdown.innerHTML = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if items are enriched (have tag_name, category, post_count)
|
||||
const isEnriched = this.items[0] && typeof this.items[0] === 'object' && 'tag_name' in this.items[0];
|
||||
if (this.options.enableVirtualScroll && this.contentContainer) {
|
||||
// Use virtual scrolling - only update visible items if dropdown is already visible
|
||||
// If not visible, updateVisibleItems() will be called from show() after display:block
|
||||
this.updateVirtualScrollHeight();
|
||||
if (this.isVisible && this.dropdown.style.display !== 'none') {
|
||||
this.updateVisibleItems();
|
||||
}
|
||||
} else {
|
||||
// Traditional rendering (fallback)
|
||||
this.dropdown.innerHTML = '';
|
||||
|
||||
this.items.forEach((itemData, index) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'comfy-autocomplete-item';
|
||||
// Check if items are enriched (have tag_name, category, post_count)
|
||||
const isEnriched = this.items[0] && typeof this.items[0] === 'object' && 'tag_name' in this.items[0];
|
||||
|
||||
// Get the display text and path for insertion
|
||||
const displayText = isEnriched ? itemData.tag_name : itemData;
|
||||
const insertPath = isEnriched ? itemData.tag_name : itemData;
|
||||
this.items.forEach((itemData, index) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'comfy-autocomplete-item';
|
||||
|
||||
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';
|
||||
nameSpan.innerHTML = this.highlightMatch(displayText, this.currentSearchTerm);
|
||||
nameSpan.style.cssText = `
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
// Get the display text and path for insertion
|
||||
const displayText = isEnriched ? itemData.tag_name : itemData;
|
||||
const insertPath = isEnriched ? itemData.tag_name : itemData;
|
||||
|
||||
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(displayText);
|
||||
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;
|
||||
`;
|
||||
item.appendChild(nameSpan);
|
||||
|
||||
// Hover and selection handlers
|
||||
item.addEventListener('mouseenter', () => {
|
||||
this.selectItem(index);
|
||||
});
|
||||
|
||||
item.addEventListener('mouseleave', () => {
|
||||
this.hidePreview();
|
||||
});
|
||||
|
||||
// Click handler
|
||||
item.addEventListener('click', () => {
|
||||
this.insertSelection(insertPath);
|
||||
});
|
||||
|
||||
this.dropdown.appendChild(item);
|
||||
});
|
||||
|
||||
// Remove border from last item
|
||||
if (this.dropdown.lastChild) {
|
||||
this.dropdown.lastChild.style.borderBottom = 'none';
|
||||
}
|
||||
|
||||
// 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', () => {
|
||||
this.insertSelection(insertPath);
|
||||
});
|
||||
|
||||
this.dropdown.appendChild(item);
|
||||
});
|
||||
|
||||
// Remove border from last item
|
||||
if (this.dropdown.lastChild) {
|
||||
this.dropdown.lastChild.style.borderBottom = 'none';
|
||||
}
|
||||
|
||||
// Auto-select the first item with a small delay
|
||||
@@ -1022,16 +1267,325 @@ class AutoComplete {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle scroll event for virtual scrolling and loading more items
|
||||
*/
|
||||
handleScroll() {
|
||||
if (!this.scrollContainer || this.isLoadingMore) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = this.scrollContainer;
|
||||
const scrollBottom = scrollTop + clientHeight;
|
||||
const threshold = this.options.itemHeight * 2; // Load more when within 2 items of bottom
|
||||
|
||||
// Check if we need to load more items
|
||||
if (scrollBottom >= scrollHeight - threshold && this.hasMoreItems) {
|
||||
this.loadMoreItems();
|
||||
}
|
||||
|
||||
// Update visible items for virtual scrolling
|
||||
if (this.options.enableVirtualScroll) {
|
||||
this.updateVisibleItems();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load more items (pagination)
|
||||
*/
|
||||
async loadMoreItems() {
|
||||
if (this.isLoadingMore || !this.hasMoreItems || this.showingCommands) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoadingMore = true;
|
||||
this.currentPage++;
|
||||
|
||||
try {
|
||||
// Show loading indicator
|
||||
this.showLoadingIndicator();
|
||||
|
||||
// Get the current endpoint
|
||||
let endpoint = `/lm/${this.modelType}/relative-paths`;
|
||||
if (this.modelType === 'prompt') {
|
||||
if (this.searchType === 'embeddings') {
|
||||
endpoint = '/lm/embeddings/relative-paths';
|
||||
} else if (this.searchType === 'custom_words') {
|
||||
if (this.activeCommand?.categories) {
|
||||
const categories = this.activeCommand.categories.join(',');
|
||||
endpoint = `/lm/custom-words/search?category=${categories}`;
|
||||
} else {
|
||||
endpoint = '/lm/custom-words/search?enriched=true';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const queryVariations = this._generateQueryVariations(this.currentSearchTerm);
|
||||
const queriesToExecute = queryVariations.slice(0, 4);
|
||||
const offset = this.items.length;
|
||||
|
||||
// Execute all queries in parallel with offset
|
||||
const searchPromises = queriesToExecute.map(async (query) => {
|
||||
const url = endpoint.includes('?')
|
||||
? `${endpoint}&search=${encodeURIComponent(query)}&limit=${this.options.pageSize}&offset=${offset}`
|
||||
: `${endpoint}?search=${encodeURIComponent(query)}&limit=${this.options.pageSize}&offset=${offset}`;
|
||||
|
||||
try {
|
||||
const response = await api.fetchApi(url);
|
||||
const data = await response.json();
|
||||
return data.success ? (data.relative_paths || data.words || []) : [];
|
||||
} catch (error) {
|
||||
console.warn(`Search query failed for "${query}":`, error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const resultsArrays = await Promise.all(searchPromises);
|
||||
|
||||
// Merge and deduplicate results with existing items
|
||||
const seen = new Set(this.items.map(item => {
|
||||
const itemKey = typeof item === 'object' && item.tag_name
|
||||
? item.tag_name.toLowerCase()
|
||||
: String(item).toLowerCase();
|
||||
return itemKey;
|
||||
}));
|
||||
const newItems = [];
|
||||
|
||||
for (const resultArray of resultsArrays) {
|
||||
for (const item of resultArray) {
|
||||
const itemKey = typeof item === 'object' && item.tag_name
|
||||
? item.tag_name.toLowerCase()
|
||||
: String(item).toLowerCase();
|
||||
|
||||
if (!seen.has(itemKey)) {
|
||||
seen.add(itemKey);
|
||||
newItems.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we got fewer items than requested, we've reached the end
|
||||
if (newItems.length < this.options.pageSize) {
|
||||
this.hasMoreItems = false;
|
||||
}
|
||||
|
||||
// If we got new items, add them and re-render
|
||||
if (newItems.length > 0) {
|
||||
const currentLength = this.items.length;
|
||||
this.items.push(...newItems);
|
||||
|
||||
// Re-score and sort all items
|
||||
const scoredItems = this.items.map(item => {
|
||||
let bestScore = -1;
|
||||
let isExact = false;
|
||||
|
||||
for (const query of queriesToExecute) {
|
||||
const match = this._matchItem(item, query);
|
||||
if (match.matched) {
|
||||
const score = match.isExactMatch ? 1000 : 100;
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
isExact = match.isExactMatch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { item, score: bestScore, isExact };
|
||||
});
|
||||
|
||||
scoredItems.sort((a, b) => {
|
||||
if (b.isExact !== a.isExact) {
|
||||
return b.isExact ? 1 : -1;
|
||||
}
|
||||
return b.score - a.score;
|
||||
});
|
||||
|
||||
this.items = scoredItems.map(s => s.item);
|
||||
|
||||
// Update render
|
||||
if (this.options.enableVirtualScroll) {
|
||||
this.updateVirtualScrollHeight();
|
||||
this.updateVisibleItems();
|
||||
} else {
|
||||
this.render();
|
||||
}
|
||||
} else {
|
||||
this.hasMoreItems = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading more items:', error);
|
||||
this.hasMoreItems = false;
|
||||
} finally {
|
||||
this.isLoadingMore = false;
|
||||
this.hideLoadingIndicator();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show loading indicator at the bottom of the list
|
||||
*/
|
||||
showLoadingIndicator() {
|
||||
if (!this.contentContainer) return;
|
||||
|
||||
let loadingEl = this.contentContainer.querySelector('.comfy-autocomplete-loading');
|
||||
if (!loadingEl) {
|
||||
loadingEl = document.createElement('div');
|
||||
loadingEl.className = 'comfy-autocomplete-loading';
|
||||
loadingEl.textContent = 'Loading more...';
|
||||
loadingEl.style.cssText = `
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: rgba(226, 232, 240, 0.5);
|
||||
font-size: 12px;
|
||||
`;
|
||||
this.contentContainer.appendChild(loadingEl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide loading indicator
|
||||
*/
|
||||
hideLoadingIndicator() {
|
||||
if (!this.contentContainer) return;
|
||||
|
||||
const loadingEl = this.contentContainer.querySelector('.comfy-autocomplete-loading');
|
||||
if (loadingEl) {
|
||||
loadingEl.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the total height of the virtual scroll container
|
||||
*/
|
||||
updateVirtualScrollHeight() {
|
||||
if (!this.contentContainer) return;
|
||||
|
||||
this.totalHeight = this.items.length * this.options.itemHeight;
|
||||
this.contentContainer.style.height = `${this.totalHeight}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update which items are visible based on scroll position
|
||||
*/
|
||||
updateVisibleItems() {
|
||||
if (!this.scrollContainer || !this.contentContainer) return;
|
||||
|
||||
const scrollTop = this.scrollContainer.scrollTop;
|
||||
const containerHeight = this.scrollContainer.clientHeight;
|
||||
|
||||
// Calculate which items should be visible
|
||||
const startIndex = Math.max(0, Math.floor(scrollTop / this.options.itemHeight) - 2);
|
||||
const endIndex = Math.min(
|
||||
this.items.length - 1,
|
||||
Math.ceil((scrollTop + containerHeight) / this.options.itemHeight) + 2
|
||||
);
|
||||
|
||||
// Clear current content
|
||||
this.contentContainer.innerHTML = '';
|
||||
|
||||
// Create spacer for items before visible range
|
||||
if (startIndex > 0) {
|
||||
const topSpacer = document.createElement('div');
|
||||
topSpacer.style.height = `${startIndex * this.options.itemHeight}px`;
|
||||
this.contentContainer.appendChild(topSpacer);
|
||||
}
|
||||
|
||||
// Render visible items
|
||||
const isEnriched = this.items[0] && typeof this.items[0] === 'object' && 'tag_name' in this.items[0];
|
||||
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
const itemData = this.items[i];
|
||||
const itemEl = this.createItemElement(itemData, i, isEnriched);
|
||||
this.contentContainer.appendChild(itemEl);
|
||||
}
|
||||
|
||||
// Create spacer for items after visible range
|
||||
if (endIndex < this.items.length - 1) {
|
||||
const bottomSpacer = document.createElement('div');
|
||||
bottomSpacer.style.height = `${(this.items.length - 1 - endIndex) * this.options.itemHeight}px`;
|
||||
this.contentContainer.appendChild(bottomSpacer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single item element
|
||||
*/
|
||||
createItemElement(itemData, index, isEnriched) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'comfy-autocomplete-item';
|
||||
item.dataset.index = index.toString();
|
||||
item.style.cssText = `
|
||||
height: ${this.options.itemHeight}px;
|
||||
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;
|
||||
box-sizing: border-box;
|
||||
`;
|
||||
|
||||
const displayText = isEnriched ? itemData.tag_name : itemData;
|
||||
const insertPath = isEnriched ? itemData.tag_name : itemData;
|
||||
|
||||
if (isEnriched) {
|
||||
this._renderEnrichedItem(item, itemData, this.currentSearchTerm);
|
||||
} else {
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'lm-autocomplete-name';
|
||||
// Use display text without extension for cleaner UI
|
||||
const displayTextWithoutExt = this._getDisplayText(displayText);
|
||||
nameSpan.innerHTML = this.highlightMatch(displayTextWithoutExt, this.currentSearchTerm);
|
||||
nameSpan.style.cssText = `
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
item.appendChild(nameSpan);
|
||||
}
|
||||
|
||||
// Hover and selection handlers
|
||||
item.addEventListener('mouseenter', () => {
|
||||
this.selectItem(index);
|
||||
});
|
||||
|
||||
item.addEventListener('mouseleave', () => {
|
||||
this.hidePreview();
|
||||
});
|
||||
|
||||
item.addEventListener('click', () => {
|
||||
this.insertSelection(insertPath);
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
show() {
|
||||
if (!this.items || this.items.length === 0) {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
// Position dropdown at cursor position using TextAreaCaretHelper
|
||||
this.positionAtCursor();
|
||||
this.dropdown.style.display = 'block';
|
||||
this.isVisible = true;
|
||||
// For virtual scrolling, render items first so positionAtCursor can measure width correctly
|
||||
if (this.options.enableVirtualScroll && this.contentContainer) {
|
||||
this.dropdown.style.display = 'block';
|
||||
this.isVisible = true;
|
||||
this.updateVisibleItems();
|
||||
this.positionAtCursor();
|
||||
} else {
|
||||
// Position dropdown at cursor position using TextAreaCaretHelper
|
||||
this.positionAtCursor();
|
||||
this.dropdown.style.display = 'block';
|
||||
this.isVisible = true;
|
||||
}
|
||||
}
|
||||
|
||||
positionAtCursor() {
|
||||
@@ -1046,9 +1600,19 @@ class AutoComplete {
|
||||
this.dropdown.style.display = 'block';
|
||||
this.dropdown.style.visibility = 'hidden';
|
||||
|
||||
// Temporarily remove width constraints to allow content to expand naturally
|
||||
// This prevents items.scrollWidth from being limited by a narrow container
|
||||
const originalWidth = this.dropdown.style.width;
|
||||
this.dropdown.style.width = 'auto';
|
||||
this.dropdown.style.minWidth = '200px';
|
||||
|
||||
// Measure the content width
|
||||
let maxWidth = 200; // minimum width
|
||||
const items = this.dropdown.querySelectorAll('.comfy-autocomplete-item');
|
||||
// For virtual scrolling, query items from contentContainer; otherwise from dropdown
|
||||
const container = this.options.enableVirtualScroll && this.contentContainer
|
||||
? this.contentContainer
|
||||
: this.dropdown;
|
||||
const items = container.querySelectorAll('.comfy-autocomplete-item');
|
||||
items.forEach(item => {
|
||||
const itemWidth = item.scrollWidth + 24; // Add padding
|
||||
maxWidth = Math.max(maxWidth, itemWidth);
|
||||
@@ -1056,6 +1620,7 @@ class AutoComplete {
|
||||
|
||||
// Set the width and restore visibility
|
||||
this.dropdown.style.width = Math.min(maxWidth, 400) + 'px'; // Cap at 400px
|
||||
this.dropdown.style.minWidth = '';
|
||||
this.dropdown.style.visibility = 'visible';
|
||||
this.dropdown.style.display = originalDisplay;
|
||||
}
|
||||
@@ -1074,6 +1639,18 @@ class AutoComplete {
|
||||
this.selectedIndex = -1;
|
||||
this.showingCommands = false;
|
||||
|
||||
// Reset virtual scrolling state
|
||||
this.virtualScrollOffset = 0;
|
||||
this.currentPage = 0;
|
||||
this.hasMoreItems = true;
|
||||
this.isLoadingMore = false;
|
||||
this.totalHeight = 0;
|
||||
|
||||
// Reset scroll position
|
||||
if (this.scrollContainer) {
|
||||
this.scrollContainer.scrollTop = 0;
|
||||
}
|
||||
|
||||
// Hide preview tooltip
|
||||
this.hidePreview();
|
||||
|
||||
@@ -1087,7 +1664,10 @@ class AutoComplete {
|
||||
|
||||
selectItem(index) {
|
||||
// Remove previous selection
|
||||
const prevSelected = this.dropdown.querySelector('.comfy-autocomplete-item-selected');
|
||||
const container = this.options.enableVirtualScroll && this.contentContainer
|
||||
? this.contentContainer
|
||||
: this.dropdown;
|
||||
const prevSelected = container.querySelector('.comfy-autocomplete-item-selected');
|
||||
if (prevSelected) {
|
||||
prevSelected.classList.remove('comfy-autocomplete-item-selected');
|
||||
prevSelected.style.backgroundColor = '';
|
||||
@@ -1096,19 +1676,57 @@ class AutoComplete {
|
||||
// Add new selection
|
||||
if (index >= 0 && index < this.items.length) {
|
||||
this.selectedIndex = index;
|
||||
const item = this.dropdown.children[index];
|
||||
item.classList.add('comfy-autocomplete-item-selected');
|
||||
item.style.backgroundColor = 'rgba(66, 153, 225, 0.2)';
|
||||
|
||||
// Scroll into view if needed
|
||||
item.scrollIntoView({ block: 'nearest' });
|
||||
// For virtual scrolling, we need to ensure the item is rendered
|
||||
if (this.options.enableVirtualScroll && this.scrollContainer) {
|
||||
// Calculate if the item is currently visible
|
||||
const itemTop = index * this.options.itemHeight;
|
||||
const itemBottom = itemTop + this.options.itemHeight;
|
||||
const scrollTop = this.scrollContainer.scrollTop;
|
||||
const containerHeight = this.scrollContainer.clientHeight;
|
||||
const scrollBottom = scrollTop + containerHeight;
|
||||
|
||||
// Show preview for selected item
|
||||
if (this.options.showPreview) {
|
||||
if (typeof this.behavior.showPreview === 'function') {
|
||||
this.behavior.showPreview(this, this.items[index], item);
|
||||
} else if (this.previewTooltip) {
|
||||
this.showPreviewForItem(this.items[index], item);
|
||||
// If item is not visible, scroll to make it visible
|
||||
if (itemTop < scrollTop || itemBottom > scrollBottom) {
|
||||
this.scrollContainer.scrollTop = itemTop - containerHeight / 2;
|
||||
// Re-render visible items after scroll
|
||||
this.updateVisibleItems();
|
||||
}
|
||||
|
||||
// Find the item element using data-index attribute
|
||||
const selectedEl = container.querySelector(`.comfy-autocomplete-item[data-index="${index}"]`);
|
||||
|
||||
if (selectedEl) {
|
||||
selectedEl.classList.add('comfy-autocomplete-item-selected');
|
||||
selectedEl.style.backgroundColor = 'rgba(66, 153, 225, 0.2)';
|
||||
|
||||
// Show preview for selected item
|
||||
if (this.options.showPreview) {
|
||||
if (typeof this.behavior.showPreview === 'function') {
|
||||
this.behavior.showPreview(this, this.items[index], selectedEl);
|
||||
} else if (this.previewTooltip) {
|
||||
this.showPreviewForItem(this.items[index], selectedEl);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Traditional rendering
|
||||
const item = container.children[index];
|
||||
if (item) {
|
||||
item.classList.add('comfy-autocomplete-item-selected');
|
||||
item.style.backgroundColor = 'rgba(66, 153, 225, 0.2)';
|
||||
|
||||
// Scroll into view if needed
|
||||
item.scrollIntoView({ block: 'nearest' });
|
||||
|
||||
// Show preview for selected item
|
||||
if (this.options.showPreview) {
|
||||
if (typeof this.behavior.showPreview === 'function') {
|
||||
this.behavior.showPreview(this, this.items[index], item);
|
||||
} else if (this.previewTooltip) {
|
||||
this.showPreviewForItem(this.items[index], item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1371,6 +1989,11 @@ class AutoComplete {
|
||||
this.onDocumentClick = null;
|
||||
}
|
||||
|
||||
if (this.onScroll && this.scrollContainer) {
|
||||
this.scrollContainer.removeEventListener('scroll', this.onScroll);
|
||||
this.onScroll = null;
|
||||
}
|
||||
|
||||
if (typeof this.behavior.destroy === 'function') {
|
||||
this.behavior.destroy(this);
|
||||
} else if (this.previewTooltip) {
|
||||
|
||||
@@ -19,10 +19,63 @@ const TAG_SPACE_REPLACEMENT_DEFAULT = false;
|
||||
const USAGE_STATISTICS_SETTING_ID = "loramanager.usage_statistics";
|
||||
const USAGE_STATISTICS_DEFAULT = true;
|
||||
|
||||
const NEW_TAB_TEMPLATE_ID = "loramanager.new_tab_template";
|
||||
const NEW_TAB_TEMPLATE_DEFAULT = "Default";
|
||||
|
||||
const NEW_TAB_ZOOM_LEVEL = 0.8;
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
let workflowOptions = [NEW_TAB_TEMPLATE_DEFAULT];
|
||||
let workflowOptionsFull = [{ value: "Default", label: "Default (Blank)", path: null }];
|
||||
let workflowOptionsLoaded = false;
|
||||
|
||||
const loadWorkflowOptions = async () => {
|
||||
if (workflowOptionsLoaded) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch("/api/lm/example-workflows");
|
||||
const data = await response.json();
|
||||
if (data.success && data.workflows) {
|
||||
workflowOptionsFull = data.workflows;
|
||||
workflowOptions = data.workflows.map((w) => w.label);
|
||||
workflowOptionsLoaded = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("LoRA Manager: Failed to fetch workflow options", error);
|
||||
}
|
||||
};
|
||||
|
||||
const getWorkflowOptions = () => {
|
||||
// Function may be called with or without parameters
|
||||
// Return the current workflow options array
|
||||
return workflowOptions;
|
||||
};
|
||||
|
||||
const loadTemplateWorkflow = async (templateName) => {
|
||||
if (!templateName || templateName === NEW_TAB_TEMPLATE_DEFAULT) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const workflow = workflowOptionsFull.find((w) => w.label === templateName);
|
||||
if (workflow && workflow.value) {
|
||||
const workflowResponse = await fetch(
|
||||
`/api/lm/example-workflows/${encodeURIComponent(workflow.value)}`
|
||||
);
|
||||
const workflowData = await workflowResponse.json();
|
||||
if (workflowData.success && workflowData.workflow) {
|
||||
return workflowData.workflow;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("LoRA Manager: Failed to load template workflow", error);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getWheelSensitivity = (() => {
|
||||
let settingsUnavailableLogged = false;
|
||||
|
||||
@@ -153,6 +206,32 @@ const getUsageStatisticsPreference = (() => {
|
||||
};
|
||||
})();
|
||||
|
||||
const getNewTabTemplatePreference = (() => {
|
||||
let settingsUnavailableLogged = false;
|
||||
|
||||
return () => {
|
||||
const settingManager = app?.extensionManager?.setting;
|
||||
if (!settingManager || typeof settingManager.get !== "function") {
|
||||
if (!settingsUnavailableLogged) {
|
||||
console.warn("LoRA Manager: settings API unavailable, using default new tab template.");
|
||||
settingsUnavailableLogged = true;
|
||||
}
|
||||
return NEW_TAB_TEMPLATE_DEFAULT;
|
||||
}
|
||||
|
||||
try {
|
||||
const value = settingManager.get(NEW_TAB_TEMPLATE_ID);
|
||||
return value ?? NEW_TAB_TEMPLATE_DEFAULT;
|
||||
} catch (error) {
|
||||
if (!settingsUnavailableLogged) {
|
||||
console.warn("LoRA Manager: unable to read new tab template setting, using default.", error);
|
||||
settingsUnavailableLogged = true;
|
||||
}
|
||||
return NEW_TAB_TEMPLATE_DEFAULT;
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
// ============================================================================
|
||||
// Register Extension with All Settings
|
||||
// ============================================================================
|
||||
@@ -205,11 +284,95 @@ app.registerExtension({
|
||||
tooltip: "When enabled, LoRA Manager will track model usage statistics during workflow execution. Disabling this will prevent unnecessary disk writes.",
|
||||
category: ["LoRA Manager", "Statistics", "Usage Tracking"],
|
||||
},
|
||||
{
|
||||
id: NEW_TAB_TEMPLATE_ID,
|
||||
name: "New Tab Template Workflow",
|
||||
type: "combo",
|
||||
options: getWorkflowOptions,
|
||||
defaultValue: NEW_TAB_TEMPLATE_DEFAULT,
|
||||
tooltip: "Choose a template workflow to load when creating a new workflow tab. 'Default (Blank)' keeps ComfyUI's original blank workflow behavior.",
|
||||
category: ["LoRA Manager", "Workflow", "New Tab Template"],
|
||||
},
|
||||
],
|
||||
async setup() {
|
||||
await loadWorkflowOptions();
|
||||
|
||||
const originalNewBlankWorkflow = async () => {
|
||||
const blankGraph = {
|
||||
last_node_id: 0,
|
||||
last_link_id: 0,
|
||||
nodes: [],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4,
|
||||
};
|
||||
await app.loadGraphData(blankGraph);
|
||||
};
|
||||
|
||||
const waitForCommandStore = async (maxWaitMs = 5000) => {
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < maxWaitMs) {
|
||||
if (app.extensionManager?.command?.commands) {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const patchCommand = async () => {
|
||||
const storeReady = await waitForCommandStore();
|
||||
if (!storeReady) {
|
||||
console.warn("LoRA Manager: Could not access command store to patch NewBlankWorkflow");
|
||||
return;
|
||||
}
|
||||
|
||||
const commands = app.extensionManager.command.commands;
|
||||
for (const cmd of commands) {
|
||||
if (cmd.id === "Comfy.NewBlankWorkflow") {
|
||||
const originalFunc = cmd.function;
|
||||
cmd.function = async (metadata) => {
|
||||
const templateName = getNewTabTemplatePreference();
|
||||
|
||||
if (templateName && templateName !== NEW_TAB_TEMPLATE_DEFAULT) {
|
||||
const workflowData = await loadTemplateWorkflow(templateName);
|
||||
if (workflowData) {
|
||||
// Override the workflow's saved view settings with our custom zoom
|
||||
if (!workflowData.extra) {
|
||||
workflowData.extra = {};
|
||||
}
|
||||
if (!workflowData.extra.ds) {
|
||||
workflowData.extra.ds = { offset: [0, 0], scale: 1 };
|
||||
}
|
||||
workflowData.extra.ds.scale = NEW_TAB_ZOOM_LEVEL;
|
||||
|
||||
await app.loadGraphData(workflowData);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await originalNewBlankWorkflow();
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
patchCommand();
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Exports
|
||||
// ============================================================================
|
||||
|
||||
export { getWheelSensitivity, getAutoPathCorrectionPreference, getPromptTagAutocompletePreference, getTagSpaceReplacementPreference, getUsageStatisticsPreference };
|
||||
export {
|
||||
getWheelSensitivity,
|
||||
getAutoPathCorrectionPreference,
|
||||
getPromptTagAutocompletePreference,
|
||||
getTagSpaceReplacementPreference,
|
||||
getUsageStatisticsPreference,
|
||||
getNewTabTemplatePreference,
|
||||
};
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { ComfyButtonGroup } from "../../scripts/ui/components/buttonGroup.js";
|
||||
import { ComfyButton } from "../../scripts/ui/components/button.js";
|
||||
|
||||
const BUTTON_TOOLTIP = "Launch LoRA Manager (Shift+Click opens in new window)";
|
||||
const LORA_MANAGER_PATH = "/loras";
|
||||
const NEW_WINDOW_FEATURES = "width=1200,height=800,resizable=yes,scrollbars=yes,status=yes";
|
||||
const MAX_ATTACH_ATTEMPTS = 120;
|
||||
const BUTTON_GROUP_CLASS = "lora-manager-top-menu-group";
|
||||
|
||||
const MIN_VERSION_FOR_ACTION_BAR = [1, 33, 9];
|
||||
|
||||
const openLoraManager = (event) => {
|
||||
const url = `${window.location.origin}${LORA_MANAGER_PATH}`;
|
||||
@@ -15,6 +21,65 @@ const openLoraManager = (event) => {
|
||||
window.open(url, "_blank");
|
||||
};
|
||||
|
||||
const getComfyUIFrontendVersion = async () => {
|
||||
try {
|
||||
if (window['__COMFYUI_FRONTEND_VERSION__']) {
|
||||
return window['__COMFYUI_FRONTEND_VERSION__'];
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("LoRA Manager: unable to read __COMFYUI_FRONTEND_VERSION__:", error);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/system_stats");
|
||||
const data = await response.json();
|
||||
|
||||
if (data?.system?.comfyui_frontend_version) {
|
||||
return data.system.comfyui_frontend_version;
|
||||
}
|
||||
|
||||
if (data?.system?.required_frontend_version) {
|
||||
return data.system.required_frontend_version;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("LoRA Manager: unable to fetch system_stats:", error);
|
||||
}
|
||||
|
||||
return "0.0.0";
|
||||
};
|
||||
|
||||
const parseVersion = (versionStr) => {
|
||||
if (!versionStr || typeof versionStr !== 'string') {
|
||||
return [0, 0, 0];
|
||||
}
|
||||
|
||||
const cleanVersion = versionStr.replace(/^[vV]/, '').split('-')[0];
|
||||
const parts = cleanVersion.split('.').map(part => parseInt(part, 10) || 0);
|
||||
|
||||
while (parts.length < 3) {
|
||||
parts.push(0);
|
||||
}
|
||||
|
||||
return parts;
|
||||
};
|
||||
|
||||
const compareVersions = (version1, version2) => {
|
||||
const v1 = typeof version1 === 'string' ? parseVersion(version1) : version1;
|
||||
const v2 = typeof version2 === 'string' ? parseVersion(version2) : version2;
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (v1[i] > v2[i]) return 1;
|
||||
if (v1[i] < v2[i]) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
const supportsActionBarButtons = async () => {
|
||||
const version = await getComfyUIFrontendVersion();
|
||||
return compareVersions(version, MIN_VERSION_FOR_ACTION_BAR) >= 0;
|
||||
};
|
||||
|
||||
const fetchVersionInfo = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/lm/version-info");
|
||||
@@ -30,6 +95,51 @@ const fetchVersionInfo = async () => {
|
||||
return "";
|
||||
};
|
||||
|
||||
const createTopMenuButton = () => {
|
||||
const button = new ComfyButton({
|
||||
icon: "loramanager",
|
||||
tooltip: BUTTON_TOOLTIP,
|
||||
app,
|
||||
enabled: true,
|
||||
classList: "comfyui-button comfyui-menu-mobile-collapse primary",
|
||||
});
|
||||
|
||||
button.element.setAttribute("aria-label", BUTTON_TOOLTIP);
|
||||
button.element.title = BUTTON_TOOLTIP;
|
||||
|
||||
if (button.iconElement) {
|
||||
button.iconElement.innerHTML = getLoraManagerIcon();
|
||||
button.iconElement.style.width = "1.2rem";
|
||||
button.iconElement.style.height = "1.2rem";
|
||||
}
|
||||
|
||||
button.element.addEventListener("click", openLoraManager);
|
||||
return button;
|
||||
};
|
||||
|
||||
const attachTopMenuButton = (attempt = 0) => {
|
||||
if (document.querySelector(`.${BUTTON_GROUP_CLASS}`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const settingsGroup = app.menu?.settingsGroup;
|
||||
if (!settingsGroup?.element?.parentElement) {
|
||||
if (attempt >= MAX_ATTACH_ATTEMPTS) {
|
||||
console.warn("LoRA Manager: unable to locate the ComfyUI settings button group.");
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => attachTopMenuButton(attempt + 1));
|
||||
return;
|
||||
}
|
||||
|
||||
const loraManagerButton = createTopMenuButton();
|
||||
const buttonGroup = new ComfyButtonGroup(loraManagerButton);
|
||||
buttonGroup.element.classList.add(BUTTON_GROUP_CLASS);
|
||||
|
||||
settingsGroup.element.before(buttonGroup.element);
|
||||
};
|
||||
|
||||
const createAboutBadge = (version) => {
|
||||
const label = version ? `LoRA Manager v${version}` : "LoRA Manager";
|
||||
|
||||
@@ -40,60 +150,80 @@ const createAboutBadge = (version) => {
|
||||
};
|
||||
};
|
||||
|
||||
app.registerExtension({
|
||||
name: "LoraManager.TopMenu",
|
||||
actionBarButtons: [
|
||||
{
|
||||
icon: "icon-[mdi--alpha-l-box] size-4",
|
||||
tooltip: BUTTON_TOOLTIP,
|
||||
onClick: openLoraManager
|
||||
}
|
||||
],
|
||||
aboutPageBadges: [createAboutBadge()],
|
||||
async setup() {
|
||||
const version = await fetchVersionInfo();
|
||||
this.aboutPageBadges = [createAboutBadge(version)];
|
||||
const createExtensionObject = (useActionBar) => {
|
||||
const extensionObj = {
|
||||
name: "LoraManager.TopMenu",
|
||||
async setup() {
|
||||
const version = await fetchVersionInfo();
|
||||
|
||||
const injectStyles = () => {
|
||||
const styleId = 'lm-top-menu-button-styles';
|
||||
if (document.getElementById(styleId)) return;
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
button[aria-label="Launch LoRA Manager (Shift+Click opens in new window)"].lm-top-menu-button {
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
button[aria-label="Launch LoRA Manager (Shift+Click opens in new window)"].lm-top-menu-button:hover {
|
||||
background-color: var(--primary-hover-bg) !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
};
|
||||
injectStyles();
|
||||
|
||||
const replaceButtonIcon = () => {
|
||||
const buttons = document.querySelectorAll('button[aria-label="Launch LoRA Manager (Shift+Click opens in new window)"]');
|
||||
buttons.forEach(button => {
|
||||
button.classList.add('lm-top-menu-button');
|
||||
button.innerHTML = getLoraManagerIcon();
|
||||
button.style.borderRadius = '4px';
|
||||
button.style.padding = '6px';
|
||||
button.style.backgroundColor = 'var(--primary-bg)';
|
||||
const svg = button.querySelector('svg');
|
||||
if (svg) {
|
||||
svg.style.width = '20px';
|
||||
svg.style.height = '20px';
|
||||
}
|
||||
});
|
||||
if (buttons.length === 0) {
|
||||
requestAnimationFrame(replaceButtonIcon);
|
||||
if (!useActionBar) {
|
||||
console.log("LoRA Manager: using legacy button attachment (frontend version < 1.33.9)");
|
||||
attachTopMenuButton();
|
||||
} else {
|
||||
console.log("LoRA Manager: using actionBarButtons API (frontend version >= 1.33.9)");
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(replaceButtonIcon);
|
||||
},
|
||||
});
|
||||
|
||||
this.aboutPageBadges = [createAboutBadge(version)];
|
||||
|
||||
const injectStyles = () => {
|
||||
const styleId = 'lm-top-menu-button-styles';
|
||||
if (document.getElementById(styleId)) return;
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
button[aria-label="Launch LoRA Manager (Shift+Click opens in new window)"].lm-top-menu-button {
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
button[aria-label="Launch LoRA Manager (Shift+Click opens in new window)"].lm-top-menu-button:hover {
|
||||
background-color: var(--primary-hover-bg) !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
};
|
||||
injectStyles();
|
||||
|
||||
const replaceButtonIcon = () => {
|
||||
const buttons = document.querySelectorAll('button[aria-label="Launch LoRA Manager (Shift+Click opens in new window)"]');
|
||||
buttons.forEach(button => {
|
||||
button.classList.add('lm-top-menu-button');
|
||||
button.innerHTML = getLoraManagerIcon();
|
||||
button.style.borderRadius = '4px';
|
||||
button.style.padding = '6px';
|
||||
button.style.backgroundColor = 'var(--primary-bg)';
|
||||
const svg = button.querySelector('svg');
|
||||
if (svg) {
|
||||
svg.style.width = '20px';
|
||||
svg.style.height = '20px';
|
||||
}
|
||||
});
|
||||
if (buttons.length === 0) {
|
||||
requestAnimationFrame(replaceButtonIcon);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(replaceButtonIcon);
|
||||
},
|
||||
};
|
||||
|
||||
if (useActionBar) {
|
||||
extensionObj.actionBarButtons = [
|
||||
{
|
||||
icon: "icon-[mdi--alpha-l-box] size-4",
|
||||
tooltip: BUTTON_TOOLTIP,
|
||||
onClick: openLoraManager
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
return extensionObj;
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const useActionBar = await supportsActionBarButtons();
|
||||
const extensionObj = createExtensionObject(useActionBar);
|
||||
app.registerExtension(extensionObj);
|
||||
})();
|
||||
|
||||
const getLoraManagerIcon = () => {
|
||||
return `
|
||||
|
||||
@@ -1988,14 +1988,14 @@ to { transform: rotate(360deg);
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.autocomplete-text-widget[data-v-653446fd] {
|
||||
.autocomplete-text-widget[data-v-b3b00fdd] {
|
||||
background: transparent;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.input-wrapper[data-v-653446fd] {
|
||||
.input-wrapper[data-v-b3b00fdd] {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -2003,14 +2003,14 @@ to { transform: rotate(360deg);
|
||||
}
|
||||
|
||||
/* Canvas mode styles (default) - matches built-in comfy-multiline-input */
|
||||
.text-input[data-v-653446fd] {
|
||||
.text-input[data-v-b3b00fdd] {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
background-color: var(--comfy-input-bg, #222);
|
||||
color: var(--input-text, #ddd);
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 2px;
|
||||
padding: 2px 2px 24px 2px; /* Reserve bottom space for clear button */
|
||||
resize: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
@@ -2020,24 +2020,24 @@ to { transform: rotate(360deg);
|
||||
}
|
||||
|
||||
/* Vue DOM mode styles - matches built-in p-textarea in Vue DOM mode */
|
||||
.text-input.vue-dom-mode[data-v-653446fd] {
|
||||
.text-input.vue-dom-mode[data-v-b3b00fdd] {
|
||||
background-color: var(--color-charcoal-400, #313235);
|
||||
color: #fff;
|
||||
padding: 8px 12px;
|
||||
padding: 8px 12px 30px 12px; /* Reserve bottom space for clear button */
|
||||
margin: 0 0 4px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.text-input[data-v-653446fd]:focus {
|
||||
.text-input[data-v-b3b00fdd]:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Clear button styles */
|
||||
.clear-button[data-v-653446fd] {
|
||||
.clear-button[data-v-b3b00fdd] {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 4px;
|
||||
right: 6px;
|
||||
bottom: 6px; /* Changed from top to bottom */
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
@@ -2050,31 +2050,38 @@ to { transform: rotate(360deg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.7;
|
||||
opacity: 0; /* Hidden by default */
|
||||
pointer-events: none; /* Not clickable when hidden */
|
||||
transition: opacity 0.2s ease, background-color 0.2s ease;
|
||||
z-index: 10;
|
||||
}
|
||||
.clear-button[data-v-653446fd]:hover {
|
||||
|
||||
/* Show clear button when hovering over input wrapper */
|
||||
.input-wrapper:hover .clear-button[data-v-b3b00fdd] {
|
||||
opacity: 0.7;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.clear-button[data-v-b3b00fdd]:hover {
|
||||
opacity: 1;
|
||||
background: rgba(255, 100, 100, 0.8);
|
||||
}
|
||||
.clear-button svg[data-v-653446fd] {
|
||||
.clear-button svg[data-v-b3b00fdd] {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
/* Vue DOM mode adjustments for clear button */
|
||||
.text-input.vue-dom-mode ~ .clear-button[data-v-653446fd] {
|
||||
.text-input.vue-dom-mode ~ .clear-button[data-v-b3b00fdd] {
|
||||
right: 8px;
|
||||
top: 8px;
|
||||
bottom: 10px; /* Changed from top to bottom, adjusted for Vue DOM padding */
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: rgba(107, 114, 128, 0.6);
|
||||
}
|
||||
.text-input.vue-dom-mode ~ .clear-button[data-v-653446fd]:hover {
|
||||
.text-input.vue-dom-mode ~ .clear-button[data-v-b3b00fdd]:hover {
|
||||
background: oklch(62% 0.18 25);
|
||||
}
|
||||
.text-input.vue-dom-mode ~ .clear-button svg[data-v-653446fd] {
|
||||
.text-input.vue-dom-mode ~ .clear-button svg[data-v-b3b00fdd] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}`));
|
||||
@@ -14232,6 +14239,36 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
||||
props.widget.callback(textareaRef.value.value);
|
||||
}
|
||||
};
|
||||
const onWheel = (event) => {
|
||||
const textarea = textareaRef.value;
|
||||
if (!textarea) return;
|
||||
const canScrollY = textarea.scrollHeight > textarea.clientHeight;
|
||||
const deltaX = event.deltaX;
|
||||
const deltaY = event.deltaY;
|
||||
const isHorizontal = Math.abs(deltaX) > Math.abs(deltaY);
|
||||
const app2 = window.app;
|
||||
if (!app2 || !app2.canvas || typeof app2.canvas.processMouseWheel !== "function") {
|
||||
return;
|
||||
}
|
||||
if (event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
app2.canvas.processMouseWheel(event);
|
||||
return;
|
||||
}
|
||||
if (isHorizontal) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
app2.canvas.processMouseWheel(event);
|
||||
return;
|
||||
}
|
||||
if (canScrollY) {
|
||||
event.stopPropagation();
|
||||
} else {
|
||||
event.preventDefault();
|
||||
app2.canvas.processMouseWheel(event);
|
||||
}
|
||||
};
|
||||
const onExternalValueChange = (event) => {
|
||||
updateHasTextState();
|
||||
};
|
||||
@@ -14290,7 +14327,8 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
||||
placeholder: __props.placeholder,
|
||||
spellcheck: __props.spellcheck ?? false,
|
||||
class: normalizeClass(["text-input", { "vue-dom-mode": isVueDomMode.value }]),
|
||||
onInput
|
||||
onInput,
|
||||
onWheel
|
||||
}, null, 42, _hoisted_3),
|
||||
showClearButton.value ? (openBlock(), createElementBlock("button", {
|
||||
key: 0,
|
||||
@@ -14324,7 +14362,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
||||
};
|
||||
}
|
||||
});
|
||||
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-653446fd"]]);
|
||||
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-b3b00fdd"]]);
|
||||
const LORA_PROVIDER_NODE_TYPES$1 = [
|
||||
"Lora Stacker (LoraManager)",
|
||||
"Lora Randomizer (LoraManager)",
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user