mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-13 01:10:14 -03:00
Compare commits
31 Commits
f34c02756d
...
v1.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2228627ff4 | ||
|
|
4c647ad9c8 | ||
|
|
8ca3e6c33f | ||
|
|
dd6bdbf297 | ||
|
|
b47dde87e4 | ||
|
|
99e65cccd8 | ||
|
|
3bdacb8f46 | ||
|
|
b4f9c224d3 | ||
|
|
5ec0399c81 | ||
|
|
b464fdc333 | ||
|
|
53825500db | ||
|
|
f2ac790752 | ||
|
|
0d8805cdee | ||
|
|
656e24ac9b | ||
|
|
6718b37403 | ||
|
|
c9e5e784fc | ||
|
|
f92f958682 | ||
|
|
f63fab0676 | ||
|
|
cfc4903c0c | ||
|
|
a527a847fe | ||
|
|
91b0bf8933 | ||
|
|
66d1c96783 | ||
|
|
986128076e | ||
|
|
1de0a53241 | ||
|
|
0ec7eaf606 | ||
|
|
d9fcb0e92b | ||
|
|
f49b4ba4db | ||
|
|
84e708328b | ||
|
|
125bed3f09 | ||
|
|
077e70169d | ||
|
|
e6dc169a05 |
@@ -18,6 +18,7 @@ try: # pragma: no cover - import fallback for pytest collection
|
|||||||
from .py.nodes.lora_info import LoraInfoLM
|
from .py.nodes.lora_info import LoraInfoLM
|
||||||
from .py.nodes.lora_syntax_to_path import LoraSyntaxToPath
|
from .py.nodes.lora_syntax_to_path import LoraSyntaxToPath
|
||||||
from .py.nodes.create_hook_lora import CreateHookLoraLM
|
from .py.nodes.create_hook_lora import CreateHookLoraLM
|
||||||
|
from .py.nodes.metadata_overwrite import MetadataOverwriteLM
|
||||||
from .py.metadata_collector import init as init_metadata_collector
|
from .py.metadata_collector import init as init_metadata_collector
|
||||||
except (
|
except (
|
||||||
ImportError
|
ImportError
|
||||||
@@ -66,6 +67,9 @@ except (
|
|||||||
CreateHookLoraLM = importlib.import_module(
|
CreateHookLoraLM = importlib.import_module(
|
||||||
"py.nodes.create_hook_lora"
|
"py.nodes.create_hook_lora"
|
||||||
).CreateHookLoraLM
|
).CreateHookLoraLM
|
||||||
|
MetadataOverwriteLM = importlib.import_module(
|
||||||
|
"py.nodes.metadata_overwrite"
|
||||||
|
).MetadataOverwriteLM
|
||||||
init_metadata_collector = importlib.import_module("py.metadata_collector").init
|
init_metadata_collector = importlib.import_module("py.metadata_collector").init
|
||||||
|
|
||||||
NODE_CLASS_MAPPINGS = {
|
NODE_CLASS_MAPPINGS = {
|
||||||
@@ -88,6 +92,7 @@ NODE_CLASS_MAPPINGS = {
|
|||||||
LoraInfoLM.NAME: LoraInfoLM,
|
LoraInfoLM.NAME: LoraInfoLM,
|
||||||
LoraSyntaxToPath.NAME: LoraSyntaxToPath,
|
LoraSyntaxToPath.NAME: LoraSyntaxToPath,
|
||||||
CreateHookLoraLM.NAME: CreateHookLoraLM,
|
CreateHookLoraLM.NAME: CreateHookLoraLM,
|
||||||
|
MetadataOverwriteLM.NAME: MetadataOverwriteLM,
|
||||||
}
|
}
|
||||||
|
|
||||||
WEB_DIRECTORY = "./web/comfyui"
|
WEB_DIRECTORY = "./web/comfyui"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1548,6 +1548,7 @@
|
|||||||
"empty": "Noch keine Versionshistorie für dieses Modell vorhanden.",
|
"empty": "Noch keine Versionshistorie für dieses Modell vorhanden.",
|
||||||
"error": "Versionen konnten nicht geladen werden.",
|
"error": "Versionen konnten nicht geladen werden.",
|
||||||
"missingModelId": "Für dieses Modell ist keine Civitai-Model-ID vorhanden.",
|
"missingModelId": "Für dieses Modell ist keine Civitai-Model-ID vorhanden.",
|
||||||
|
"hfGroupInfo": "Dies ist eine HuggingFace-Modellgruppe. Öffnen Sie die Bibliothek, um alle Versionen im Raster zu sehen.",
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"delete": "Diese Version aus Ihrer Bibliothek löschen?"
|
"delete": "Diese Version aus Ihrer Bibliothek löschen?"
|
||||||
},
|
},
|
||||||
@@ -1751,6 +1752,12 @@
|
|||||||
"checkingMessage": "Bitte warten Sie, während wir nach der neuesten Version suchen.",
|
"checkingMessage": "Bitte warten Sie, während wir nach der neuesten Version suchen.",
|
||||||
"showNotifications": "Update-Benachrichtigungen anzeigen",
|
"showNotifications": "Update-Benachrichtigungen anzeigen",
|
||||||
"latestBadge": "Neueste",
|
"latestBadge": "Neueste",
|
||||||
|
"latestMain": "Main-Branch",
|
||||||
|
"channel": "Update-Kanal",
|
||||||
|
"channels": {
|
||||||
|
"release": "Release",
|
||||||
|
"nightly": "Nightly"
|
||||||
|
},
|
||||||
"updateProgress": {
|
"updateProgress": {
|
||||||
"preparing": "Update wird vorbereitet...",
|
"preparing": "Update wird vorbereitet...",
|
||||||
"installing": "Update wird installiert...",
|
"installing": "Update wird installiert...",
|
||||||
@@ -1771,6 +1778,15 @@
|
|||||||
"warning": "Warnung: Nightly Builds können experimentelle Funktionen enthalten und könnten instabil sein.",
|
"warning": "Warnung: Nightly Builds können experimentelle Funktionen enthalten und könnten instabil sein.",
|
||||||
"enable": "Nightly Updates aktivieren"
|
"enable": "Nightly Updates aktivieren"
|
||||||
},
|
},
|
||||||
|
"channelSwitch": {
|
||||||
|
"nightlyTitle": "Zu Nightly-Kanal wechseln",
|
||||||
|
"nightlyMessage": "Der Wechsel zu Nightly initialisiert ein Git-Repository und verfolgt die neuesten Commits des main-Branches. Updates sind häufiger, können aber instabil sein. Sie können jederzeit zu Release zurückwechseln.",
|
||||||
|
"releaseTitle": "Zu Release-Kanal wechseln",
|
||||||
|
"releaseMessage": "Der Wechsel zu Release checkt den neuesten stabilen Versions-Tag aus. Sie können jederzeit zu Nightly zurückwechseln.",
|
||||||
|
"switching": "Wechsle zu {channel}-Kanal...",
|
||||||
|
"completed": "Erfolgreich zu {channel}-Kanal gewechselt",
|
||||||
|
"failed": "Kanalwechsel fehlgeschlagen"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"recent": "Neueste Mitteilungen",
|
"recent": "Neueste Mitteilungen",
|
||||||
"empty": "Keine aktuellen Banner verfügbar.",
|
"empty": "Keine aktuellen Banner verfügbar.",
|
||||||
|
|||||||
@@ -1548,6 +1548,7 @@
|
|||||||
"empty": "No version history available for this model yet.",
|
"empty": "No version history available for this model yet.",
|
||||||
"error": "Failed to load versions.",
|
"error": "Failed to load versions.",
|
||||||
"missingModelId": "This model is missing a Civitai model id.",
|
"missingModelId": "This model is missing a Civitai model id.",
|
||||||
|
"hfGroupInfo": "This is a HuggingFace model group. Open the library to see all versions in the grid.",
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"delete": "Delete this version from your library?"
|
"delete": "Delete this version from your library?"
|
||||||
},
|
},
|
||||||
@@ -1751,6 +1752,12 @@
|
|||||||
"checkingMessage": "Please wait while we check for the latest version.",
|
"checkingMessage": "Please wait while we check for the latest version.",
|
||||||
"showNotifications": "Show update notifications",
|
"showNotifications": "Show update notifications",
|
||||||
"latestBadge": "Latest",
|
"latestBadge": "Latest",
|
||||||
|
"latestMain": "Latest main",
|
||||||
|
"channel": "Update Channel",
|
||||||
|
"channels": {
|
||||||
|
"release": "Release",
|
||||||
|
"nightly": "Nightly"
|
||||||
|
},
|
||||||
"updateProgress": {
|
"updateProgress": {
|
||||||
"preparing": "Preparing update...",
|
"preparing": "Preparing update...",
|
||||||
"installing": "Installing update...",
|
"installing": "Installing update...",
|
||||||
@@ -1771,6 +1778,15 @@
|
|||||||
"warning": "Warning: Nightly builds may contain experimental features and could be unstable.",
|
"warning": "Warning: Nightly builds may contain experimental features and could be unstable.",
|
||||||
"enable": "Enable Nightly Updates"
|
"enable": "Enable Nightly Updates"
|
||||||
},
|
},
|
||||||
|
"channelSwitch": {
|
||||||
|
"nightlyTitle": "Switch to Nightly Channel",
|
||||||
|
"nightlyMessage": "Switching to Nightly will initialize a Git repository and track the latest main branch commits. Updates will be more frequent but may be unstable. You can switch back to Release at any time.",
|
||||||
|
"releaseTitle": "Switch to Release Channel",
|
||||||
|
"releaseMessage": "Switching to Release will checkout the latest stable release tag. You can switch back to Nightly at any time.",
|
||||||
|
"switching": "Switching to {channel} channel...",
|
||||||
|
"completed": "Successfully switched to {channel} channel",
|
||||||
|
"failed": "Failed to switch channel"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"recent": "Recent messages",
|
"recent": "Recent messages",
|
||||||
"empty": "No recent banners yet.",
|
"empty": "No recent banners yet.",
|
||||||
|
|||||||
@@ -1548,6 +1548,7 @@
|
|||||||
"empty": "Aún no hay historial de versiones para este modelo.",
|
"empty": "Aún no hay historial de versiones para este modelo.",
|
||||||
"error": "No se pudieron cargar las versiones.",
|
"error": "No se pudieron cargar las versiones.",
|
||||||
"missingModelId": "Este modelo no tiene un ID de modelo de Civitai.",
|
"missingModelId": "Este modelo no tiene un ID de modelo de Civitai.",
|
||||||
|
"hfGroupInfo": "Este es un grupo de modelos de HuggingFace. Abra la biblioteca para ver todas las versiones en la cuadrícula.",
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"delete": "¿Eliminar esta versión de tu biblioteca?"
|
"delete": "¿Eliminar esta versión de tu biblioteca?"
|
||||||
},
|
},
|
||||||
@@ -1750,7 +1751,13 @@
|
|||||||
"checkingUpdates": "Comprobando actualizaciones...",
|
"checkingUpdates": "Comprobando actualizaciones...",
|
||||||
"checkingMessage": "Por favor espera mientras comprobamos la última versión.",
|
"checkingMessage": "Por favor espera mientras comprobamos la última versión.",
|
||||||
"showNotifications": "Mostrar notificaciones de actualización",
|
"showNotifications": "Mostrar notificaciones de actualización",
|
||||||
"latestBadge": "Último",
|
"latestBadge": "Última",
|
||||||
|
"latestMain": "Rama main",
|
||||||
|
"channel": "Canal de actualizacion",
|
||||||
|
"channels": {
|
||||||
|
"release": "Release",
|
||||||
|
"nightly": "Nightly"
|
||||||
|
},
|
||||||
"updateProgress": {
|
"updateProgress": {
|
||||||
"preparing": "Preparando actualización...",
|
"preparing": "Preparando actualización...",
|
||||||
"installing": "Instalando actualización...",
|
"installing": "Instalando actualización...",
|
||||||
@@ -1771,6 +1778,15 @@
|
|||||||
"warning": "Advertencia: Las compilaciones nocturnas pueden contener características experimentales y podrían ser inestables.",
|
"warning": "Advertencia: Las compilaciones nocturnas pueden contener características experimentales y podrían ser inestables.",
|
||||||
"enable": "Habilitar actualizaciones nocturnas"
|
"enable": "Habilitar actualizaciones nocturnas"
|
||||||
},
|
},
|
||||||
|
"channelSwitch": {
|
||||||
|
"nightlyTitle": "Cambiar a canal Nightly",
|
||||||
|
"nightlyMessage": "Cambiar a Nightly inicializara un repositorio Git y seguira los ultimos commits de la rama main. Las actualizaciones son mas frecuentes pero pueden ser inestables. Puede volver a Release en cualquier momento.",
|
||||||
|
"releaseTitle": "Cambiar a canal Release",
|
||||||
|
"releaseMessage": "Cambiar a Release hara checkout de la ultima etiqueta de version estable. Puede volver a Nightly en cualquier momento.",
|
||||||
|
"switching": "Cambiando a canal {channel}...",
|
||||||
|
"completed": "Cambio a canal {channel} exitoso",
|
||||||
|
"failed": "Error al cambiar de canal"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"recent": "Notificaciones recientes",
|
"recent": "Notificaciones recientes",
|
||||||
"empty": "No hay banners recientes.",
|
"empty": "No hay banners recientes.",
|
||||||
|
|||||||
@@ -1548,6 +1548,7 @@
|
|||||||
"empty": "Aucun historique de versions n'est disponible pour ce modèle pour le moment.",
|
"empty": "Aucun historique de versions n'est disponible pour ce modèle pour le moment.",
|
||||||
"error": "Échec du chargement des versions.",
|
"error": "Échec du chargement des versions.",
|
||||||
"missingModelId": "Ce modèle ne possède pas d'identifiant de modèle Civitai.",
|
"missingModelId": "Ce modèle ne possède pas d'identifiant de modèle Civitai.",
|
||||||
|
"hfGroupInfo": "Ceci est un groupe de modèles HuggingFace. Ouvrez la bibliothèque pour voir toutes les versions dans la grille.",
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"delete": "Supprimer cette version de votre bibliothèque ?"
|
"delete": "Supprimer cette version de votre bibliothèque ?"
|
||||||
},
|
},
|
||||||
@@ -1750,7 +1751,13 @@
|
|||||||
"checkingUpdates": "Vérification des mises à jour...",
|
"checkingUpdates": "Vérification des mises à jour...",
|
||||||
"checkingMessage": "Veuillez patienter pendant la vérification de la dernière version.",
|
"checkingMessage": "Veuillez patienter pendant la vérification de la dernière version.",
|
||||||
"showNotifications": "Afficher les notifications de mise à jour",
|
"showNotifications": "Afficher les notifications de mise à jour",
|
||||||
"latestBadge": "Dernier",
|
"latestBadge": "Dernière",
|
||||||
|
"latestMain": "Branche main",
|
||||||
|
"channel": "Canal de mise a jour",
|
||||||
|
"channels": {
|
||||||
|
"release": "Release",
|
||||||
|
"nightly": "Nightly"
|
||||||
|
},
|
||||||
"updateProgress": {
|
"updateProgress": {
|
||||||
"preparing": "Préparation de la mise à jour...",
|
"preparing": "Préparation de la mise à jour...",
|
||||||
"installing": "Installation de la mise à jour...",
|
"installing": "Installation de la mise à jour...",
|
||||||
@@ -1771,6 +1778,15 @@
|
|||||||
"warning": "Attention : Les versions nightly peuvent contenir des fonctionnalités expérimentales et être instables.",
|
"warning": "Attention : Les versions nightly peuvent contenir des fonctionnalités expérimentales et être instables.",
|
||||||
"enable": "Activer les mises à jour nightly"
|
"enable": "Activer les mises à jour nightly"
|
||||||
},
|
},
|
||||||
|
"channelSwitch": {
|
||||||
|
"nightlyTitle": "Passer au canal Nightly",
|
||||||
|
"nightlyMessage": "Passer a Nightly initialisera un depot Git et suivra les derniers commits de la branche main. Les mises a jour sont plus frequentes mais peuvent etre instables. Vous pouvez revenir a Release a tout moment.",
|
||||||
|
"releaseTitle": "Passer au canal Release",
|
||||||
|
"releaseMessage": "Passer a Release passera au dernier tag de version stable. Vous pouvez revenir a Nightly a tout moment.",
|
||||||
|
"switching": "Passage au canal {channel}...",
|
||||||
|
"completed": "Basculement vers le canal {channel} reussi",
|
||||||
|
"failed": "Echec du changement de canal"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"recent": "Messages récents",
|
"recent": "Messages récents",
|
||||||
"empty": "Aucune bannière récente.",
|
"empty": "Aucune bannière récente.",
|
||||||
|
|||||||
@@ -1548,6 +1548,7 @@
|
|||||||
"empty": "אין עדיין היסטוריית גרסאות למודל זה.",
|
"empty": "אין עדיין היסטוריית גרסאות למודל זה.",
|
||||||
"error": "טעינת הגרסאות נכשלה.",
|
"error": "טעינת הגרסאות נכשלה.",
|
||||||
"missingModelId": "למודל זה אין מזהה מודל של Civitai.",
|
"missingModelId": "למודל זה אין מזהה מודל של Civitai.",
|
||||||
|
"hfGroupInfo": "זוהי קבוצת דגמים של HuggingFace. פתח את הספרייה כדי לראות את כל הגרסאות ברשת.",
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"delete": "למחוק גרסה זו מהספרייה שלך?"
|
"delete": "למחוק גרסה זו מהספרייה שלך?"
|
||||||
},
|
},
|
||||||
@@ -1750,7 +1751,13 @@
|
|||||||
"checkingUpdates": "בודק עדכונים...",
|
"checkingUpdates": "בודק עדכונים...",
|
||||||
"checkingMessage": "אנא המתן בזמן שאנו בודקים את הגרסה האחרונה.",
|
"checkingMessage": "אנא המתן בזמן שאנו בודקים את הגרסה האחרונה.",
|
||||||
"showNotifications": "הצג התראות עדכון",
|
"showNotifications": "הצג התראות עדכון",
|
||||||
"latestBadge": "עדכן",
|
"latestBadge": "אחרון",
|
||||||
|
"latestMain": "ענף main",
|
||||||
|
"channel": "ערוץ עדכון",
|
||||||
|
"channels": {
|
||||||
|
"release": "Release",
|
||||||
|
"nightly": "Nightly"
|
||||||
|
},
|
||||||
"updateProgress": {
|
"updateProgress": {
|
||||||
"preparing": "מכין עדכון...",
|
"preparing": "מכין עדכון...",
|
||||||
"installing": "מתקין עדכון...",
|
"installing": "מתקין עדכון...",
|
||||||
@@ -1771,6 +1778,15 @@
|
|||||||
"warning": "אזהרה: גרסאות ליליות עשויות להכיל תכונות ניסיוניות ועלולות להיות לא יציבות.",
|
"warning": "אזהרה: גרסאות ליליות עשויות להכיל תכונות ניסיוניות ועלולות להיות לא יציבות.",
|
||||||
"enable": "הפעל עדכונים ליליים"
|
"enable": "הפעל עדכונים ליליים"
|
||||||
},
|
},
|
||||||
|
"channelSwitch": {
|
||||||
|
"nightlyTitle": "מעבר לערוץ Nightly",
|
||||||
|
"nightlyMessage": "מעבר ל-Nightly יאתחל מאגר Git ויעקוב אחר הקומיטים האחרונים בענף main. העדכונים תכופים יותר אך עשויים להיות לא יציבים. ניתן לחזור ל-Release בכל עת.",
|
||||||
|
"releaseTitle": "מעבר לערוץ Release",
|
||||||
|
"releaseMessage": "מעבר ל-Release יעבור לתגית הגרסה היציבה האחרונה. ניתן לחזור ל-Nightly בכל עת.",
|
||||||
|
"switching": "מעבר לערוץ {channel}...",
|
||||||
|
"completed": "המעבר לערוץ {channel} הושלם",
|
||||||
|
"failed": "החלפת ערוץ נכשלה"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"recent": "הודעות אחרונות",
|
"recent": "הודעות אחרונות",
|
||||||
"empty": "אין כרגע באנרים אחרונים.",
|
"empty": "אין כרגע באנרים אחרונים.",
|
||||||
|
|||||||
@@ -1548,6 +1548,7 @@
|
|||||||
"empty": "このモデルにはまだバージョン履歴がありません。",
|
"empty": "このモデルにはまだバージョン履歴がありません。",
|
||||||
"error": "バージョンの読み込みに失敗しました。",
|
"error": "バージョンの読み込みに失敗しました。",
|
||||||
"missingModelId": "このモデルにはCivitaiのモデルIDがありません。",
|
"missingModelId": "このモデルにはCivitaiのモデルIDがありません。",
|
||||||
|
"hfGroupInfo": "これは HuggingFace モデルグループです。ライブラリを開いてグリッドですべてのバージョンを表示してください。",
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"delete": "このバージョンをライブラリから削除しますか?"
|
"delete": "このバージョンをライブラリから削除しますか?"
|
||||||
},
|
},
|
||||||
@@ -1751,6 +1752,12 @@
|
|||||||
"checkingMessage": "最新バージョンを確認しています。お待ちください。",
|
"checkingMessage": "最新バージョンを確認しています。お待ちください。",
|
||||||
"showNotifications": "更新通知を表示",
|
"showNotifications": "更新通知を表示",
|
||||||
"latestBadge": "最新",
|
"latestBadge": "最新",
|
||||||
|
"latestMain": "Main ブランチ",
|
||||||
|
"channel": "更新チャンネル",
|
||||||
|
"channels": {
|
||||||
|
"release": "リリース",
|
||||||
|
"nightly": "ナイトリー"
|
||||||
|
},
|
||||||
"updateProgress": {
|
"updateProgress": {
|
||||||
"preparing": "更新を準備中...",
|
"preparing": "更新を準備中...",
|
||||||
"installing": "更新をインストール中...",
|
"installing": "更新をインストール中...",
|
||||||
@@ -1771,6 +1778,15 @@
|
|||||||
"warning": "警告:ナイトリービルドには実験的機能が含まれており、不安定な場合があります。",
|
"warning": "警告:ナイトリービルドには実験的機能が含まれており、不安定な場合があります。",
|
||||||
"enable": "ナイトリー更新を有効にする"
|
"enable": "ナイトリー更新を有効にする"
|
||||||
},
|
},
|
||||||
|
"channelSwitch": {
|
||||||
|
"nightlyTitle": "ナイトリーチャンネルに切り替え",
|
||||||
|
"nightlyMessage": "ナイトリーに切り替えると、Gitリポジトリが初期化され、mainブランチの最新コミットを追跡します。更新頻度は高くなりますが、不安定な場合があります。いつでもリリース版に戻せます。",
|
||||||
|
"releaseTitle": "リリースチャンネルに切り替え",
|
||||||
|
"releaseMessage": "リリースに切り替えると、最新の安定版タグにチェックアウトされます。いつでもNightlyに戻せます。",
|
||||||
|
"switching": "{channel} チャンネルに切り替え中...",
|
||||||
|
"completed": "{channel} チャンネルに切り替えました",
|
||||||
|
"failed": "チャンネルの切り替えに失敗しました"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"recent": "最近の通知",
|
"recent": "最近の通知",
|
||||||
"empty": "最近のバナーはありません。",
|
"empty": "最近のバナーはありません。",
|
||||||
|
|||||||
@@ -1548,6 +1548,7 @@
|
|||||||
"empty": "이 모델에는 아직 버전 기록이 없습니다.",
|
"empty": "이 모델에는 아직 버전 기록이 없습니다.",
|
||||||
"error": "버전을 불러오지 못했습니다.",
|
"error": "버전을 불러오지 못했습니다.",
|
||||||
"missingModelId": "이 모델에는 Civitai 모델 ID가 없습니다.",
|
"missingModelId": "이 모델에는 Civitai 모델 ID가 없습니다.",
|
||||||
|
"hfGroupInfo": "HuggingFace 모델 그룹입니다. 라이브러리를 열어 그리드에서 모든 버전을 확인하세요.",
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"delete": "이 버전을 라이브러리에서 삭제하시겠습니까?"
|
"delete": "이 버전을 라이브러리에서 삭제하시겠습니까?"
|
||||||
},
|
},
|
||||||
@@ -1751,6 +1752,12 @@
|
|||||||
"checkingMessage": "최신 버전을 확인하는 동안 잠시 기다려주세요.",
|
"checkingMessage": "최신 버전을 확인하는 동안 잠시 기다려주세요.",
|
||||||
"showNotifications": "업데이트 알림 표시",
|
"showNotifications": "업데이트 알림 표시",
|
||||||
"latestBadge": "최신",
|
"latestBadge": "최신",
|
||||||
|
"latestMain": "Main 브랜치",
|
||||||
|
"channel": "업데이트 채널",
|
||||||
|
"channels": {
|
||||||
|
"release": "릴리스",
|
||||||
|
"nightly": "나이틀리"
|
||||||
|
},
|
||||||
"updateProgress": {
|
"updateProgress": {
|
||||||
"preparing": "업데이트 준비 중...",
|
"preparing": "업데이트 준비 중...",
|
||||||
"installing": "업데이트 설치 중...",
|
"installing": "업데이트 설치 중...",
|
||||||
@@ -1771,6 +1778,15 @@
|
|||||||
"warning": "경고: 나이틀리 빌드는 실험적 기능을 포함할 수 있으며 불안정할 수 있습니다.",
|
"warning": "경고: 나이틀리 빌드는 실험적 기능을 포함할 수 있으며 불안정할 수 있습니다.",
|
||||||
"enable": "나이틀리 업데이트 활성화"
|
"enable": "나이틀리 업데이트 활성화"
|
||||||
},
|
},
|
||||||
|
"channelSwitch": {
|
||||||
|
"nightlyTitle": "나이틀리 채널로 전환",
|
||||||
|
"nightlyMessage": "나이틀리로 전환하면 Git 저장소가 초기화되고 main 브랜치의 최신 커밋을 추적합니다. 업데이트 빈도는 높지만 불안정할 수 있습니다. 언제든지 릴리스로 돌아갈 수 있습니다.",
|
||||||
|
"releaseTitle": "릴리스 채널로 전환",
|
||||||
|
"releaseMessage": "릴리스로 전환하면 최신 안정 버전 태그로 체크아웃됩니다. 언제든지 나이틀리로 돌아갈 수 있습니다.",
|
||||||
|
"switching": "{channel} 채널로 전환 중...",
|
||||||
|
"completed": "{channel} 채널로 전환 완료",
|
||||||
|
"failed": "채널 전환 실패"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"recent": "최근 알림",
|
"recent": "최근 알림",
|
||||||
"empty": "최근 배너가 없습니다.",
|
"empty": "최근 배너가 없습니다.",
|
||||||
|
|||||||
@@ -1548,6 +1548,7 @@
|
|||||||
"empty": "Для этой модели пока нет истории версий.",
|
"empty": "Для этой модели пока нет истории версий.",
|
||||||
"error": "Не удалось загрузить версии.",
|
"error": "Не удалось загрузить версии.",
|
||||||
"missingModelId": "У этой модели отсутствует идентификатор модели Civitai.",
|
"missingModelId": "У этой модели отсутствует идентификатор модели Civitai.",
|
||||||
|
"hfGroupInfo": "Это группа моделей HuggingFace. Откройте библиотеку, чтобы увидеть все версии в сетке.",
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"delete": "Удалить эту версию из библиотеки?"
|
"delete": "Удалить эту версию из библиотеки?"
|
||||||
},
|
},
|
||||||
@@ -1750,7 +1751,13 @@
|
|||||||
"checkingUpdates": "Проверка обновлений...",
|
"checkingUpdates": "Проверка обновлений...",
|
||||||
"checkingMessage": "Пожалуйста, подождите, пока мы проверяем последнюю версию.",
|
"checkingMessage": "Пожалуйста, подождите, пока мы проверяем последнюю версию.",
|
||||||
"showNotifications": "Показывать уведомления об обновлениях",
|
"showNotifications": "Показывать уведомления об обновлениях",
|
||||||
"latestBadge": "Последний",
|
"latestBadge": "Последняя",
|
||||||
|
"latestMain": "Ветка main",
|
||||||
|
"channel": "Канал обновлений",
|
||||||
|
"channels": {
|
||||||
|
"release": "Релиз",
|
||||||
|
"nightly": "Nightly"
|
||||||
|
},
|
||||||
"updateProgress": {
|
"updateProgress": {
|
||||||
"preparing": "Подготовка обновления...",
|
"preparing": "Подготовка обновления...",
|
||||||
"installing": "Установка обновления...",
|
"installing": "Установка обновления...",
|
||||||
@@ -1771,6 +1778,15 @@
|
|||||||
"warning": "Предупреждение: Ночные сборки могут содержать экспериментальные функции и могут быть нестабильными.",
|
"warning": "Предупреждение: Ночные сборки могут содержать экспериментальные функции и могут быть нестабильными.",
|
||||||
"enable": "Включить ночные обновления"
|
"enable": "Включить ночные обновления"
|
||||||
},
|
},
|
||||||
|
"channelSwitch": {
|
||||||
|
"nightlyTitle": "Переключиться на Nightly",
|
||||||
|
"nightlyMessage": "Переключение на Nightly инициализирует Git-репозиторий и отслеживает последние коммиты ветки main. Обновления чаще, но могут быть нестабильными. Вы можете вернуться к Release в любое время.",
|
||||||
|
"releaseTitle": "Переключиться на Release",
|
||||||
|
"releaseMessage": "Переключение на Release выполнит checkout последнего стабильного тега. Вы можете вернуться к Nightly в любое время.",
|
||||||
|
"switching": "Переключение на канал {channel}...",
|
||||||
|
"completed": "Успешно переключено на канал {channel}",
|
||||||
|
"failed": "Не удалось переключить канал"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"recent": "Недавние уведомления",
|
"recent": "Недавние уведомления",
|
||||||
"empty": "Недавних баннеров нет.",
|
"empty": "Недавних баннеров нет.",
|
||||||
|
|||||||
@@ -1548,6 +1548,7 @@
|
|||||||
"empty": "该模型还没有版本历史。",
|
"empty": "该模型还没有版本历史。",
|
||||||
"error": "加载版本失败。",
|
"error": "加载版本失败。",
|
||||||
"missingModelId": "该模型缺少 Civitai 模型 ID。",
|
"missingModelId": "该模型缺少 Civitai 模型 ID。",
|
||||||
|
"hfGroupInfo": "这是一个 HuggingFace 模型组。打开库页面即可在网格中查看所有版本。",
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"delete": "从库中删除此版本?"
|
"delete": "从库中删除此版本?"
|
||||||
},
|
},
|
||||||
@@ -1751,6 +1752,12 @@
|
|||||||
"checkingMessage": "请稍候,正在检查最新版本。",
|
"checkingMessage": "请稍候,正在检查最新版本。",
|
||||||
"showNotifications": "显示更新通知",
|
"showNotifications": "显示更新通知",
|
||||||
"latestBadge": "最新",
|
"latestBadge": "最新",
|
||||||
|
"latestMain": "Main 分支",
|
||||||
|
"channel": "更新频道",
|
||||||
|
"channels": {
|
||||||
|
"release": "稳定版",
|
||||||
|
"nightly": "Nightly"
|
||||||
|
},
|
||||||
"updateProgress": {
|
"updateProgress": {
|
||||||
"preparing": "正在准备更新...",
|
"preparing": "正在准备更新...",
|
||||||
"installing": "正在安装更新...",
|
"installing": "正在安装更新...",
|
||||||
@@ -1771,6 +1778,15 @@
|
|||||||
"warning": "警告:Nightly 版本可能包含实验性功能,可能不稳定。",
|
"warning": "警告:Nightly 版本可能包含实验性功能,可能不稳定。",
|
||||||
"enable": "启用 Nightly 更新"
|
"enable": "启用 Nightly 更新"
|
||||||
},
|
},
|
||||||
|
"channelSwitch": {
|
||||||
|
"nightlyTitle": "切换到 Nightly",
|
||||||
|
"nightlyMessage": "切换到 Nightly 将初始化 Git 仓库并跟踪 main 分支的最新提交。更新更频繁但可能不稳定,可随时切回稳定版。",
|
||||||
|
"releaseTitle": "切换到稳定版",
|
||||||
|
"releaseMessage": "切换到稳定版将检出最新的发布标签。可随时切换回每日构建版。",
|
||||||
|
"switching": "正在切换到 {channel} 频道...",
|
||||||
|
"completed": "已切换到 {channel} 频道",
|
||||||
|
"failed": "切换频道失败"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"recent": "最近的通知",
|
"recent": "最近的通知",
|
||||||
"empty": "暂无最近的横幅通知。",
|
"empty": "暂无最近的横幅通知。",
|
||||||
|
|||||||
@@ -1548,6 +1548,7 @@
|
|||||||
"empty": "此模型尚無版本歷史。",
|
"empty": "此模型尚無版本歷史。",
|
||||||
"error": "載入版本失敗。",
|
"error": "載入版本失敗。",
|
||||||
"missingModelId": "此模型缺少 Civitai 模型 ID。",
|
"missingModelId": "此模型缺少 Civitai 模型 ID。",
|
||||||
|
"hfGroupInfo": "這是一個 HuggingFace 模型組。打開庫頁面即可在網格中查看所有版本。",
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"delete": "要從庫中刪除此版本嗎?"
|
"delete": "要從庫中刪除此版本嗎?"
|
||||||
},
|
},
|
||||||
@@ -1751,6 +1752,12 @@
|
|||||||
"checkingMessage": "請稍候,正在檢查最新版本。",
|
"checkingMessage": "請稍候,正在檢查最新版本。",
|
||||||
"showNotifications": "顯示更新通知",
|
"showNotifications": "顯示更新通知",
|
||||||
"latestBadge": "最新",
|
"latestBadge": "最新",
|
||||||
|
"latestMain": "Main 分支",
|
||||||
|
"channel": "更新頻道",
|
||||||
|
"channels": {
|
||||||
|
"release": "稳定版",
|
||||||
|
"nightly": "Nightly"
|
||||||
|
},
|
||||||
"updateProgress": {
|
"updateProgress": {
|
||||||
"preparing": "正在準備更新...",
|
"preparing": "正在準備更新...",
|
||||||
"installing": "正在安裝更新...",
|
"installing": "正在安裝更新...",
|
||||||
@@ -1771,6 +1778,15 @@
|
|||||||
"warning": "警告:Nightly 版本可能包含實驗性功能且可能不穩定。",
|
"warning": "警告:Nightly 版本可能包含實驗性功能且可能不穩定。",
|
||||||
"enable": "啟用 Nightly 更新"
|
"enable": "啟用 Nightly 更新"
|
||||||
},
|
},
|
||||||
|
"channelSwitch": {
|
||||||
|
"nightlyTitle": "切换到 Nightly",
|
||||||
|
"nightlyMessage": "切换到 Nightly 将初始化 Git 仓库并跟踪 main 分支的最新提交。更新更频繁但可能不稳定,可随时切回稳定版。",
|
||||||
|
"releaseTitle": "切换到稳定版",
|
||||||
|
"releaseMessage": "切換到穩定版將檢出最新的發布標籤。可隨時切換回每日構建版。",
|
||||||
|
"switching": "正在切換到 {channel} 頻道...",
|
||||||
|
"completed": "已切換到 {channel} 頻道",
|
||||||
|
"failed": "切換頻道失敗"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"recent": "最新通知",
|
"recent": "最新通知",
|
||||||
"empty": "目前沒有最近的橫幅通知。",
|
"empty": "目前沒有最近的橫幅通知。",
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
"""Constants used by the metadata collector"""
|
"""Constants used by the metadata collector"""
|
||||||
|
|
||||||
|
# Sentinel value for clip_skip to distinguish "unconnected / widget default"
|
||||||
|
# from "user wired value 0". Both ComfyUI CLIPSetLastLayer (-24..-1) and
|
||||||
|
# A1111 conventions treat 0 as meaningless for clip skipping, but users may
|
||||||
|
# explicitly wire 0 to the overwrite node to express "no clip skip / default".
|
||||||
|
CLIP_SKIP_SENTINEL = -25
|
||||||
|
|
||||||
# Metadata categories
|
# Metadata categories
|
||||||
MODELS = "models"
|
MODELS = "models"
|
||||||
PROMPTS = "prompts"
|
PROMPTS = "prompts"
|
||||||
@@ -9,6 +15,14 @@ EMBEDDINGS = "embeddings"
|
|||||||
SIZE = "size"
|
SIZE = "size"
|
||||||
IMAGES = "images"
|
IMAGES = "images"
|
||||||
IS_SAMPLER = "is_sampler" # New constant to mark sampler nodes
|
IS_SAMPLER = "is_sampler" # New constant to mark sampler nodes
|
||||||
|
OVERWRITE = "overwrite" # Manual metadata overwrite from MetadataOverwriteLM node
|
||||||
|
|
||||||
|
# Field names that the MetadataOverwriteLM node and its extractor share
|
||||||
|
METADATA_OVERWRITE_FIELDS = (
|
||||||
|
"prompt", "negative_prompt", "seed", "steps", "cfg_scale",
|
||||||
|
"sampler", "scheduler", "model", "loras", "size",
|
||||||
|
"clip_skip", "additional_data",
|
||||||
|
)
|
||||||
|
|
||||||
# Complete list of categories to track
|
# Complete list of categories to track
|
||||||
METADATA_CATEGORIES = [MODELS, PROMPTS, SAMPLING, LORAS, EMBEDDINGS, SIZE, IMAGES]
|
METADATA_CATEGORIES = [MODELS, PROMPTS, SAMPLING, LORAS, EMBEDDINGS, SIZE, IMAGES, OVERWRITE]
|
||||||
|
|||||||
@@ -83,7 +83,8 @@ class MetadataHook:
|
|||||||
|
|
||||||
# Record inputs before execution
|
# Record inputs before execution
|
||||||
if node_id is not None:
|
if node_id is not None:
|
||||||
registry.record_node_execution(node_id, class_type, input_data_all, None)
|
return_types = getattr(obj, 'RETURN_TYPES', None)
|
||||||
|
registry.record_node_execution(node_id, class_type, input_data_all, None, return_types=return_types)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error collecting metadata (pre-execution): {str(e)}")
|
logger.error(f"Error collecting metadata (pre-execution): {str(e)}")
|
||||||
|
|
||||||
@@ -114,7 +115,8 @@ class MetadataHook:
|
|||||||
|
|
||||||
# Record outputs after execution
|
# Record outputs after execution
|
||||||
if node_id is not None:
|
if node_id is not None:
|
||||||
registry.update_node_execution(node_id, class_type, results)
|
return_types = getattr(obj, 'RETURN_TYPES', None)
|
||||||
|
registry.update_node_execution(node_id, class_type, results, return_types=return_types)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error collecting metadata (post-execution): {str(e)}")
|
logger.error(f"Error collecting metadata (post-execution): {str(e)}")
|
||||||
|
|
||||||
@@ -136,6 +138,9 @@ class MetadataHook:
|
|||||||
if hasattr(prompt, 'original_prompt'):
|
if hasattr(prompt, 'original_prompt'):
|
||||||
registry.set_current_prompt(prompt)
|
registry.set_current_prompt(prompt)
|
||||||
|
|
||||||
|
# Store extra_data for accessing full workflow node properties
|
||||||
|
registry.set_extra_data(extra_data)
|
||||||
|
|
||||||
# Execute the original function
|
# Execute the original function
|
||||||
return original_execute(*args, **kwargs)
|
return original_execute(*args, **kwargs)
|
||||||
|
|
||||||
@@ -163,7 +168,8 @@ class MetadataHook:
|
|||||||
class_type = obj.__class__.__name__
|
class_type = obj.__class__.__name__
|
||||||
node_id = unique_id
|
node_id = unique_id
|
||||||
if node_id is not None:
|
if node_id is not None:
|
||||||
registry.record_node_execution(node_id, class_type, input_data_all, None)
|
return_types = getattr(obj, 'RETURN_TYPES', None)
|
||||||
|
registry.record_node_execution(node_id, class_type, input_data_all, None, return_types=return_types)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error collecting metadata (pre-execution): {str(e)}")
|
logger.error(f"Error collecting metadata (pre-execution): {str(e)}")
|
||||||
|
|
||||||
@@ -180,7 +186,8 @@ class MetadataHook:
|
|||||||
class_type = obj.__class__.__name__
|
class_type = obj.__class__.__name__
|
||||||
node_id = unique_id
|
node_id = unique_id
|
||||||
if node_id is not None:
|
if node_id is not None:
|
||||||
registry.update_node_execution(node_id, class_type, results)
|
return_types = getattr(obj, 'RETURN_TYPES', None)
|
||||||
|
registry.update_node_execution(node_id, class_type, results, return_types=return_types)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error collecting metadata (post-execution): {str(e)}")
|
logger.error(f"Error collecting metadata (post-execution): {str(e)}")
|
||||||
|
|
||||||
@@ -202,6 +209,9 @@ class MetadataHook:
|
|||||||
if hasattr(prompt, 'original_prompt'):
|
if hasattr(prompt, 'original_prompt'):
|
||||||
registry.set_current_prompt(prompt)
|
registry.set_current_prompt(prompt)
|
||||||
|
|
||||||
|
# Store extra_data for accessing full workflow node properties
|
||||||
|
registry.set_extra_data(extra_data)
|
||||||
|
|
||||||
# Execute the original function
|
# Execute the original function
|
||||||
return await original_execute(*args, **kwargs)
|
return await original_execute(*args, **kwargs)
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,68 @@
|
|||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from .constants import IMAGES
|
from .constants import IMAGES
|
||||||
|
|
||||||
# Check if running in standalone mode
|
# Check if running in standalone mode
|
||||||
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"
|
||||||
|
|
||||||
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IS_SAMPLER
|
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IS_SAMPLER, OVERWRITE
|
||||||
|
from .node_extractors import NODE_EXTRACTORS
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Keys that identify metadata hint marks stored in node.properties.lm_marker_role
|
||||||
|
_META_MARK_PREFIX = "meta_"
|
||||||
|
_MARK_PRIMARY_MODEL = "primary_model"
|
||||||
|
_MARK_PRIMARY_SAMPLER = "primary_sampler"
|
||||||
|
_MARK_POSITIVE_PROMPT = "positive_prompt"
|
||||||
|
_MARK_NEGATIVE_PROMPT = "negative_prompt"
|
||||||
|
|
||||||
class MetadataProcessor:
|
class MetadataProcessor:
|
||||||
"""Process and format collected metadata"""
|
"""Process and format collected metadata"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_user_marks(metadata):
|
||||||
|
"""Scan workflow nodes (from extra_data.extra_pnginfo.workflow) for user-assigned
|
||||||
|
metadata hint marks stored in node.properties.lm_marker_role.
|
||||||
|
|
||||||
|
Returns a dict mapping mark type keys to node IDs.
|
||||||
|
Example: {'primary_model': '42', 'primary_sampler': '17'}
|
||||||
|
"""
|
||||||
|
marks: dict[str, str] = {}
|
||||||
|
|
||||||
|
# Primary source: extra_data.extra_pnginfo.workflow.nodes (has full properties)
|
||||||
|
extra_data = metadata.get("extra_data")
|
||||||
|
if extra_data and isinstance(extra_data, dict):
|
||||||
|
extra_pnginfo = extra_data.get("extra_pnginfo", {})
|
||||||
|
if isinstance(extra_pnginfo, dict):
|
||||||
|
workflow = extra_pnginfo.get("workflow", {})
|
||||||
|
nodes = workflow.get("nodes", [])
|
||||||
|
for node in nodes:
|
||||||
|
node_id = str(node.get("id", ""))
|
||||||
|
role = node.get("properties", {}).get("lm_marker_role", "")
|
||||||
|
if role.startswith(_META_MARK_PREFIX):
|
||||||
|
mark_type = role[len(_META_MARK_PREFIX):]
|
||||||
|
if mark_type in marks:
|
||||||
|
logger.warning(
|
||||||
|
"Duplicate meta hint '%s': node %s (previous: %s), "
|
||||||
|
"last match wins",
|
||||||
|
mark_type, node_id, marks[mark_type],
|
||||||
|
)
|
||||||
|
marks[mark_type] = node_id
|
||||||
|
|
||||||
|
# Fallback: try prompt.original_prompt (API-only submissions may not have workflow)
|
||||||
|
if not marks:
|
||||||
|
prompt = metadata.get("current_prompt")
|
||||||
|
if prompt and getattr(prompt, "original_prompt", None):
|
||||||
|
for node_id, node_data in prompt.original_prompt.items():
|
||||||
|
role = node_data.get("properties", {}).get("lm_marker_role", "")
|
||||||
|
if role.startswith(_META_MARK_PREFIX):
|
||||||
|
mark_type = role[len(_META_MARK_PREFIX):]
|
||||||
|
marks[mark_type] = node_id
|
||||||
|
|
||||||
|
return marks
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def find_primary_sampler(metadata, downstream_id=None):
|
def find_primary_sampler(metadata, downstream_id=None):
|
||||||
"""
|
"""
|
||||||
@@ -471,20 +524,57 @@ class MetadataProcessor:
|
|||||||
"checkpoint": None,
|
"checkpoint": None,
|
||||||
"loras": "",
|
"loras": "",
|
||||||
"size": None,
|
"size": None,
|
||||||
"clip_skip": None
|
"clip_skip": None,
|
||||||
|
"additional_data": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get the prompt object for node relationship tracing
|
# Get the prompt object for node relationship tracing
|
||||||
prompt = metadata.get("current_prompt")
|
prompt = metadata.get("current_prompt")
|
||||||
|
|
||||||
# Find the primary KSampler node
|
# ---- User marks: override heuristic inference with user-assigned hints ----
|
||||||
primary_sampler_id, primary_sampler = MetadataProcessor.find_primary_sampler(metadata, id)
|
user_marks = MetadataProcessor._get_user_marks(metadata)
|
||||||
|
|
||||||
# Directly get checkpoint from metadata instead of tracing
|
# Find the primary KSampler node (user mark takes priority)
|
||||||
# Pass primary_sampler_id to avoid redundant calculation
|
primary_sampler_id = None
|
||||||
checkpoint = MetadataProcessor.find_primary_checkpoint(metadata, id, primary_sampler_id)
|
primary_sampler = None
|
||||||
if checkpoint:
|
if _MARK_PRIMARY_SAMPLER in user_marks:
|
||||||
params["checkpoint"] = checkpoint
|
marked_id = user_marks[_MARK_PRIMARY_SAMPLER]
|
||||||
|
sampler_data = metadata.get(SAMPLING, {}).get(marked_id)
|
||||||
|
if sampler_data and sampler_data.get(IS_SAMPLER):
|
||||||
|
primary_sampler_id = marked_id
|
||||||
|
primary_sampler = sampler_data
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"User-marked primary sampler %s has no runtime metadata, "
|
||||||
|
"falling back to heuristic",
|
||||||
|
marked_id,
|
||||||
|
)
|
||||||
|
if primary_sampler is None:
|
||||||
|
primary_sampler_id, primary_sampler = MetadataProcessor.find_primary_sampler(metadata, id)
|
||||||
|
|
||||||
|
# Resolve checkpoint / model (user mark takes priority)
|
||||||
|
if _MARK_PRIMARY_MODEL in user_marks:
|
||||||
|
marked_id = user_marks[_MARK_PRIMARY_MODEL]
|
||||||
|
if marked_id in metadata.get(MODELS, {}):
|
||||||
|
params["checkpoint"] = metadata[MODELS][marked_id].get("name")
|
||||||
|
else:
|
||||||
|
extra_data = metadata.get("extra_data")
|
||||||
|
extra_pnginfo = extra_data.get("extra_pnginfo", {}) if extra_data and isinstance(extra_data, dict) else {}
|
||||||
|
workflow = extra_pnginfo.get("workflow", {}) if isinstance(extra_pnginfo, dict) else {}
|
||||||
|
node_type = "unknown"
|
||||||
|
for n in workflow.get("nodes", []):
|
||||||
|
if str(n.get("id", "")) == marked_id:
|
||||||
|
node_type = n.get("type", "unknown")
|
||||||
|
break
|
||||||
|
logger.warning(
|
||||||
|
"User-marked primary model %s (type=%s, registered=%s) has no runtime metadata, "
|
||||||
|
"falling back to heuristic",
|
||||||
|
marked_id, node_type, node_type in NODE_EXTRACTORS,
|
||||||
|
)
|
||||||
|
if params["checkpoint"] is None:
|
||||||
|
checkpoint = MetadataProcessor.find_primary_checkpoint(metadata, id, primary_sampler_id)
|
||||||
|
if checkpoint:
|
||||||
|
params["checkpoint"] = checkpoint
|
||||||
|
|
||||||
# Check if guidance parameter exists in any sampling node
|
# Check if guidance parameter exists in any sampling node
|
||||||
for node_id, sampler_info in metadata.get(SAMPLING, {}).items():
|
for node_id, sampler_info in metadata.get(SAMPLING, {}).items():
|
||||||
@@ -540,6 +630,21 @@ class MetadataProcessor:
|
|||||||
# For SamplerCustom, handle any additional parameters
|
# For SamplerCustom, handle any additional parameters
|
||||||
MetadataProcessor.handle_custom_advanced_sampler(metadata, prompt, primary_sampler_id, params)
|
MetadataProcessor.handle_custom_advanced_sampler(metadata, prompt, primary_sampler_id, params)
|
||||||
|
|
||||||
|
# ---- User marks: override prompts with explicitly tagged nodes ----
|
||||||
|
prompts_data = metadata.get(PROMPTS, {})
|
||||||
|
if _MARK_POSITIVE_PROMPT in user_marks:
|
||||||
|
pos_id = user_marks[_MARK_POSITIVE_PROMPT]
|
||||||
|
if pos_id in prompts_data:
|
||||||
|
prompt_text = prompts_data[pos_id].get("text") or prompts_data[pos_id].get("positive_text")
|
||||||
|
if prompt_text:
|
||||||
|
params["prompt"] = prompt_text
|
||||||
|
if _MARK_NEGATIVE_PROMPT in user_marks:
|
||||||
|
neg_id = user_marks[_MARK_NEGATIVE_PROMPT]
|
||||||
|
if neg_id in prompts_data:
|
||||||
|
prompt_text = prompts_data[neg_id].get("text") or prompts_data[neg_id].get("negative_text")
|
||||||
|
if prompt_text:
|
||||||
|
params["negative_prompt"] = prompt_text
|
||||||
|
|
||||||
# Size extraction is same for all sampler types
|
# Size extraction is same for all sampler types
|
||||||
# Check if the sampler itself has size information (from latent_image)
|
# Check if the sampler itself has size information (from latent_image)
|
||||||
if primary_sampler_id in metadata.get(SIZE, {}):
|
if primary_sampler_id in metadata.get(SIZE, {}):
|
||||||
@@ -569,6 +674,25 @@ class MetadataProcessor:
|
|||||||
if params["clip_skip"] is None:
|
if params["clip_skip"] is None:
|
||||||
params["clip_skip"] = "1"
|
params["clip_skip"] = "1"
|
||||||
|
|
||||||
|
# ---- Apply manual metadata overwrites ----
|
||||||
|
for overwrite_info in metadata.get(OVERWRITE, {}).values():
|
||||||
|
overwrite_params = overwrite_info.get("parameters", {})
|
||||||
|
for key, value in overwrite_params.items():
|
||||||
|
if key == "clip_skip":
|
||||||
|
# Accept any value from overwrite node (sentinel -25 already
|
||||||
|
# filtered upstream). Needed because falsy check treats 0
|
||||||
|
# as "not set" even though 0 is a valid wired input here.
|
||||||
|
params[key] = value
|
||||||
|
elif value: # truthy check — only overwrite when user provided a real value
|
||||||
|
params[key] = value
|
||||||
|
|
||||||
|
# Bridge: the overwrite node exposes the field as "model" (more accurate),
|
||||||
|
# but the internal pipeline key remains "checkpoint" for backward compatibility
|
||||||
|
# with A1111 metadata format and downstream consumers.
|
||||||
|
if params.get("model"):
|
||||||
|
params["checkpoint"] = params["model"]
|
||||||
|
del params["model"]
|
||||||
|
|
||||||
return params
|
return params
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import time
|
import time
|
||||||
from nodes import NODE_CLASS_MAPPINGS # type: ignore
|
from nodes import NODE_CLASS_MAPPINGS # type: ignore
|
||||||
from .node_extractors import NODE_EXTRACTORS, GenericNodeExtractor
|
from .node_extractors import NODE_EXTRACTORS, GenericNodeExtractor
|
||||||
from .constants import METADATA_CATEGORIES, IMAGES
|
from .constants import METADATA_CATEGORIES, IMAGES, OVERWRITE
|
||||||
|
|
||||||
|
|
||||||
class MetadataRegistry:
|
class MetadataRegistry:
|
||||||
@@ -61,6 +61,7 @@ class MetadataRegistry:
|
|||||||
{
|
{
|
||||||
"execution_order": [],
|
"execution_order": [],
|
||||||
"current_prompt": None, # Will store the prompt object
|
"current_prompt": None, # Will store the prompt object
|
||||||
|
"extra_data": None, # Will store the API extra_data for workflow metadata
|
||||||
"timestamp": time.time(),
|
"timestamp": time.time(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -75,6 +76,11 @@ class MetadataRegistry:
|
|||||||
# Store the prompt in the metadata for later relationship tracing
|
# Store the prompt in the metadata for later relationship tracing
|
||||||
self.prompt_metadata[self.current_prompt_id]["current_prompt"] = prompt
|
self.prompt_metadata[self.current_prompt_id]["current_prompt"] = prompt
|
||||||
|
|
||||||
|
def set_extra_data(self, extra_data):
|
||||||
|
"""Store the API extra_data (contains extra_pnginfo.workflow with node properties)"""
|
||||||
|
if self.current_prompt_id and self.current_prompt_id in self.prompt_metadata:
|
||||||
|
self.prompt_metadata[self.current_prompt_id]["extra_data"] = extra_data
|
||||||
|
|
||||||
def get_metadata(self, prompt_id=None):
|
def get_metadata(self, prompt_id=None):
|
||||||
"""Get collected metadata for a prompt"""
|
"""Get collected metadata for a prompt"""
|
||||||
key = prompt_id if prompt_id is not None else self.current_prompt_id
|
key = prompt_id if prompt_id is not None else self.current_prompt_id
|
||||||
@@ -122,20 +128,28 @@ class MetadataRegistry:
|
|||||||
cache_key = f"{node_id}:{class_type}"
|
cache_key = f"{node_id}:{class_type}"
|
||||||
|
|
||||||
# Check if this node type is relevant for metadata collection
|
# Check if this node type is relevant for metadata collection
|
||||||
if class_type in NODE_EXTRACTORS:
|
if class_type in NODE_EXTRACTORS or cache_key in self.node_cache:
|
||||||
# Check if we have cached metadata for this node
|
# Check if we have cached metadata for this node
|
||||||
if cache_key in self.node_cache:
|
if cache_key in self.node_cache:
|
||||||
cached_data = self.node_cache[cache_key]
|
cached_data = self.node_cache[cache_key]
|
||||||
|
|
||||||
|
# Detect bypass (mode=4) / mute (mode=2) — these nodes
|
||||||
|
# were intentionally disabled and should not contribute
|
||||||
|
# overwrite values from a previous execution's cache.
|
||||||
|
node_mode = node_data.get("mode", 0)
|
||||||
|
node_is_disabled = node_mode in (2, 4)
|
||||||
|
|
||||||
# Apply cached metadata to the current metadata
|
# Apply cached metadata to the current metadata
|
||||||
for category in self.metadata_categories:
|
for category in self.metadata_categories:
|
||||||
|
if category == OVERWRITE and node_is_disabled:
|
||||||
|
continue
|
||||||
if category in cached_data and node_id in cached_data[category]:
|
if category in cached_data and node_id in cached_data[category]:
|
||||||
if node_id not in metadata[category]:
|
if node_id not in metadata[category]:
|
||||||
metadata[category][node_id] = cached_data[category][
|
metadata[category][node_id] = cached_data[category][
|
||||||
node_id
|
node_id
|
||||||
]
|
]
|
||||||
|
|
||||||
def record_node_execution(self, node_id, class_type, inputs, outputs):
|
def record_node_execution(self, node_id, class_type, inputs, outputs, return_types=None):
|
||||||
"""Record information about a node's execution"""
|
"""Record information about a node's execution"""
|
||||||
if not self.current_prompt_id:
|
if not self.current_prompt_id:
|
||||||
return
|
return
|
||||||
@@ -158,17 +172,18 @@ class MetadataRegistry:
|
|||||||
|
|
||||||
# Extract node-specific metadata
|
# Extract node-specific metadata
|
||||||
extractor = NODE_EXTRACTORS.get(class_type, GenericNodeExtractor)
|
extractor = NODE_EXTRACTORS.get(class_type, GenericNodeExtractor)
|
||||||
extractor.extract(
|
if extractor is GenericNodeExtractor:
|
||||||
node_id,
|
extractor.extract(node_id, processed_inputs, outputs,
|
||||||
processed_inputs,
|
self.prompt_metadata[self.current_prompt_id],
|
||||||
outputs,
|
return_types=return_types)
|
||||||
self.prompt_metadata[self.current_prompt_id],
|
else:
|
||||||
)
|
extractor.extract(node_id, processed_inputs, outputs,
|
||||||
|
self.prompt_metadata[self.current_prompt_id])
|
||||||
|
|
||||||
# Cache this node's metadata
|
# Cache this node's metadata
|
||||||
self._cache_node_metadata(node_id, class_type)
|
self._cache_node_metadata(node_id, class_type)
|
||||||
|
|
||||||
def update_node_execution(self, node_id, class_type, outputs):
|
def update_node_execution(self, node_id, class_type, outputs, return_types=None):
|
||||||
"""Update node metadata with output information"""
|
"""Update node metadata with output information"""
|
||||||
if not self.current_prompt_id:
|
if not self.current_prompt_id:
|
||||||
return
|
return
|
||||||
@@ -179,9 +194,17 @@ class MetadataRegistry:
|
|||||||
# Use the same extractor to update with outputs
|
# Use the same extractor to update with outputs
|
||||||
extractor = NODE_EXTRACTORS.get(class_type, GenericNodeExtractor)
|
extractor = NODE_EXTRACTORS.get(class_type, GenericNodeExtractor)
|
||||||
if hasattr(extractor, "update"):
|
if hasattr(extractor, "update"):
|
||||||
extractor.update(
|
if extractor is GenericNodeExtractor:
|
||||||
node_id, processed_outputs, self.prompt_metadata[self.current_prompt_id]
|
extractor.update(
|
||||||
)
|
node_id, processed_outputs,
|
||||||
|
self.prompt_metadata[self.current_prompt_id],
|
||||||
|
return_types=return_types,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
extractor.update(
|
||||||
|
node_id, processed_outputs,
|
||||||
|
self.prompt_metadata[self.current_prompt_id],
|
||||||
|
)
|
||||||
|
|
||||||
# Update the cached metadata for this node
|
# Update the cached metadata for this node
|
||||||
self._cache_node_metadata(node_id, class_type)
|
self._cache_node_metadata(node_id, class_type)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IMAGES, IS_SAMPLER
|
from .constants import CLIP_SKIP_SENTINEL, MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IMAGES, IS_SAMPLER, OVERWRITE, METADATA_OVERWRITE_FIELDS
|
||||||
|
|
||||||
|
|
||||||
def _store_checkpoint_metadata(metadata, node_id, model_name):
|
def _store_checkpoint_metadata(metadata, node_id, model_name):
|
||||||
@@ -31,10 +31,77 @@ class NodeMetadataExtractor:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
class GenericNodeExtractor(NodeMetadataExtractor):
|
class GenericNodeExtractor(NodeMetadataExtractor):
|
||||||
"""Default extractor for nodes without specific handling"""
|
"""Fallback extractor with type-signature-based detection.
|
||||||
|
|
||||||
|
When a node is not in the NODE_EXTRACTORS registry, the hook layer
|
||||||
|
passes ``return_types`` from ``obj.RETURN_TYPES``:
|
||||||
|
|
||||||
|
* ``MODEL`` output: common input fields (ckpt_name, unet_name, etc.)
|
||||||
|
are checked for a model file name and stored as checkpoint metadata.
|
||||||
|
* ``CONDITIONING`` output: common text input fields are checked for
|
||||||
|
prompt text and stored as prompt metadata.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Input field names that carry a model path in loader-style nodes.
|
||||||
|
_MODEL_NAME_FIELDS = (
|
||||||
|
"ckpt_name", "unet_name", "model_path", "model_name", "gguf_name",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extensions used by checkpoint_scanner.py — only record values that look
|
||||||
|
# like real model filenames to avoid capturing unrelated string fields.
|
||||||
|
_MODEL_EXTENSIONS = {
|
||||||
|
".ckpt", ".pt", ".pt2", ".bin", ".pth", ".safetensors", ".pkl", ".sft", ".gguf",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Input field names that may carry prompt text in encoder-style nodes.
|
||||||
|
_TEXT_FIELDS = ("text", "clip_l", "t5xxl", "prompt", "positive", "negative")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def extract(node_id, inputs, outputs, metadata):
|
def extract(node_id, inputs, outputs, metadata, return_types=None):
|
||||||
pass
|
if return_types is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# — MODEL loader detection (checkpoint / UNET / GGUF) —
|
||||||
|
if "MODEL" in return_types or any("MODEL" in str(t) for t in return_types):
|
||||||
|
for field in GenericNodeExtractor._MODEL_NAME_FIELDS:
|
||||||
|
val = inputs.get(field)
|
||||||
|
if val and isinstance(val, str) and val.strip():
|
||||||
|
name = val.strip()
|
||||||
|
if not any(name.lower().endswith(ext) for ext in GenericNodeExtractor._MODEL_EXTENSIONS):
|
||||||
|
continue
|
||||||
|
_store_checkpoint_metadata(metadata, node_id, name)
|
||||||
|
return
|
||||||
|
|
||||||
|
# — CONDITIONING encoder detection (CLIPTextEncode, Flux, custom) —
|
||||||
|
if "CONDITIONING" in return_types or any("CONDITIONING" in str(t) for t in return_types):
|
||||||
|
text = None
|
||||||
|
for field in GenericNodeExtractor._TEXT_FIELDS:
|
||||||
|
val = inputs.get(field)
|
||||||
|
if val and isinstance(val, str) and val.strip():
|
||||||
|
text = val.strip()
|
||||||
|
break
|
||||||
|
if text:
|
||||||
|
prompt_data = metadata.setdefault(PROMPTS, {})
|
||||||
|
prompt_data[node_id] = {
|
||||||
|
"text": text,
|
||||||
|
"node_id": node_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update(node_id, outputs, metadata, return_types=None):
|
||||||
|
if return_types is None:
|
||||||
|
return
|
||||||
|
if "CONDITIONING" not in return_types and not any(
|
||||||
|
"CONDITIONING" in str(t) for t in return_types
|
||||||
|
):
|
||||||
|
return
|
||||||
|
if node_id not in metadata.get(PROMPTS, {}):
|
||||||
|
return
|
||||||
|
if outputs and isinstance(outputs, list) and len(outputs) > 0:
|
||||||
|
if isinstance(outputs[0], tuple) and len(outputs[0]) > 0:
|
||||||
|
cond = outputs[0][0]
|
||||||
|
if cond is not None:
|
||||||
|
metadata[PROMPTS][node_id]["conditioning"] = cond
|
||||||
|
|
||||||
class CheckpointLoaderExtractor(NodeMetadataExtractor):
|
class CheckpointLoaderExtractor(NodeMetadataExtractor):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -1154,6 +1221,35 @@ class CR_ApplyControlNetStackExtractor(NodeMetadataExtractor):
|
|||||||
metadata[PROMPTS][node_id]["positive_encoded"] = transformed_positive
|
metadata[PROMPTS][node_id]["positive_encoded"] = transformed_positive
|
||||||
metadata[PROMPTS][node_id]["negative_encoded"] = transformed_negative
|
metadata[PROMPTS][node_id]["negative_encoded"] = transformed_negative
|
||||||
|
|
||||||
|
class MetadataOverwriteExtractor(NodeMetadataExtractor):
|
||||||
|
"""Extract manually specified metadata from MetadataOverwriteLM node.
|
||||||
|
|
||||||
|
Stores truthy input values under the OVERWRITE category so that
|
||||||
|
extract_generation_params can merge them over the inferred params.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
|
if not inputs:
|
||||||
|
return
|
||||||
|
|
||||||
|
overwrite_params = {}
|
||||||
|
for key in METADATA_OVERWRITE_FIELDS:
|
||||||
|
value = inputs.get(key)
|
||||||
|
if key == "clip_skip":
|
||||||
|
if value != CLIP_SKIP_SENTINEL:
|
||||||
|
overwrite_params[key] = value
|
||||||
|
elif value: # truthy — only overwrite when user provided a real value
|
||||||
|
overwrite_params[key] = value
|
||||||
|
|
||||||
|
if overwrite_params:
|
||||||
|
metadata.setdefault(OVERWRITE, {})
|
||||||
|
metadata[OVERWRITE][node_id] = {
|
||||||
|
"parameters": overwrite_params,
|
||||||
|
"node_id": node_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# Registry of node-specific extractors
|
# Registry of node-specific extractors
|
||||||
# Keys are node class names
|
# Keys are node class names
|
||||||
NODE_EXTRACTORS = {
|
NODE_EXTRACTORS = {
|
||||||
@@ -1221,5 +1317,7 @@ NODE_EXTRACTORS = {
|
|||||||
"CFGGuider": CFGGuiderExtractor, # Add CFGGuider
|
"CFGGuider": CFGGuiderExtractor, # Add CFGGuider
|
||||||
# Image
|
# Image
|
||||||
"VAEDecode": VAEDecodeExtractor, # Added VAEDecode extractor
|
"VAEDecode": VAEDecodeExtractor, # Added VAEDecode extractor
|
||||||
|
# Metadata overwrite
|
||||||
|
"MetadataOverwriteLM": MetadataOverwriteExtractor,
|
||||||
# Add other nodes as needed
|
# Add other nodes as needed
|
||||||
}
|
}
|
||||||
|
|||||||
170
py/nodes/metadata_overwrite.py
Normal file
170
py/nodes/metadata_overwrite.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
"""Metadata Overwrite node — allows users to manually specify generation parameters
|
||||||
|
that override the automatically collected/inferred metadata.
|
||||||
|
|
||||||
|
Most inputs have falsy defaults (empty string / 0) which are skipped.
|
||||||
|
clip_skip uses a sentinel default (-25) so that a wired value of 0 is
|
||||||
|
preserved — both ComfyUI and A1111 conventions have no meaningful 0 value,
|
||||||
|
but users may wire 0 to express "no clip skip / default".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ..metadata_collector.constants import (
|
||||||
|
CLIP_SKIP_SENTINEL as _CLIP_SKIP_SENTINEL,
|
||||||
|
METADATA_OVERWRITE_FIELDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MetadataOverwriteLM:
|
||||||
|
NAME = "Metadata Overwrite (LoraManager)"
|
||||||
|
CATEGORY = "Lora Manager/utils"
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Manually specify generation parameters to override automatically collected "
|
||||||
|
"metadata. Only filled/connected inputs will take effect — empty defaults "
|
||||||
|
"are ignored."
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"optional": {
|
||||||
|
"prompt": (
|
||||||
|
"STRING",
|
||||||
|
{
|
||||||
|
"default": "",
|
||||||
|
"multiline": True,
|
||||||
|
"tooltip": "Positive prompt. Only overwrites when non-empty.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"negative_prompt": (
|
||||||
|
"STRING",
|
||||||
|
{
|
||||||
|
"default": "",
|
||||||
|
"multiline": True,
|
||||||
|
"tooltip": "Negative prompt. Only overwrites when non-empty.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"seed": (
|
||||||
|
"INT",
|
||||||
|
{
|
||||||
|
"default": 0,
|
||||||
|
"min": 0,
|
||||||
|
"max": 0xFFFFFFFFFFFFFFFF,
|
||||||
|
"control_after_generate": False,
|
||||||
|
"tooltip": "Seed value. Only overwrites when > 0.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"steps": (
|
||||||
|
"INT",
|
||||||
|
{
|
||||||
|
"default": 0,
|
||||||
|
"min": 0,
|
||||||
|
"max": 10000,
|
||||||
|
"tooltip": "Number of steps. Only overwrites when > 0.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"cfg_scale": (
|
||||||
|
"FLOAT",
|
||||||
|
{
|
||||||
|
"default": 0.0,
|
||||||
|
"min": 0.0,
|
||||||
|
"max": 100.0,
|
||||||
|
"tooltip": "CFG scale. Only overwrites when > 0.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"sampler": (
|
||||||
|
"STRING",
|
||||||
|
{
|
||||||
|
"default": "",
|
||||||
|
"tooltip": "Sampler name. Only overwrites when non-empty.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"scheduler": (
|
||||||
|
"STRING",
|
||||||
|
{
|
||||||
|
"default": "",
|
||||||
|
"tooltip": "Scheduler name. Only overwrites when non-empty.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"model": (
|
||||||
|
"STRING",
|
||||||
|
{
|
||||||
|
"default": "",
|
||||||
|
"tooltip": (
|
||||||
|
"The checkpoint or diffusion model (UNet) used "
|
||||||
|
"for generation. Only overwrites when non-empty."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"loras": (
|
||||||
|
"STRING",
|
||||||
|
{
|
||||||
|
"default": "",
|
||||||
|
"multiline": True,
|
||||||
|
"tooltip": (
|
||||||
|
"LoRA syntax, e.g. <lora:name:strength> "
|
||||||
|
"or <lora:name:model_strength:clip_strength>, "
|
||||||
|
"separated by spaces. Only overwrites when non-empty."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"size": (
|
||||||
|
"STRING",
|
||||||
|
{
|
||||||
|
"default": "",
|
||||||
|
"tooltip": (
|
||||||
|
"Image size in WIDTHxHEIGHT format (e.g. 512x768). "
|
||||||
|
"Only overwrites when non-empty."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"clip_skip": (
|
||||||
|
"INT",
|
||||||
|
{
|
||||||
|
"default": _CLIP_SKIP_SENTINEL,
|
||||||
|
"min": -25,
|
||||||
|
"max": 24,
|
||||||
|
"tooltip": (
|
||||||
|
"Clip skip (ComfyUI: -24..-1, A1111: 1+). "
|
||||||
|
"Default -25 means not set — any other value "
|
||||||
|
"overwrites."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"additional_data": (
|
||||||
|
"STRING",
|
||||||
|
{
|
||||||
|
"default": "",
|
||||||
|
"multiline": True,
|
||||||
|
"tooltip": (
|
||||||
|
"Additional data to embed in the image metadata. "
|
||||||
|
"Inserted between Clip skip and Model hash in the "
|
||||||
|
"A1111-compatible parameters string. "
|
||||||
|
'Example: "Copyright": "Some license info"'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ("METADATA",)
|
||||||
|
RETURN_NAMES = ("metadata",)
|
||||||
|
FUNCTION = "collect_metadata"
|
||||||
|
OUTPUT_NODE = True
|
||||||
|
|
||||||
|
def collect_metadata(self, **kwargs: Any) -> tuple[dict[str, Any]]:
|
||||||
|
"""Collect non-default input values into a metadata dict.
|
||||||
|
|
||||||
|
For most fields, a falsy value (empty string, 0) means "not set"
|
||||||
|
and is skipped. clip_skip uses a dedicated sentinel (-25) so that
|
||||||
|
a wired value of 0 is preserved and reaches the metadata pipeline.
|
||||||
|
"""
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
for key in METADATA_OVERWRITE_FIELDS:
|
||||||
|
value = kwargs.get(key)
|
||||||
|
if key == "clip_skip":
|
||||||
|
if value != _CLIP_SKIP_SENTINEL:
|
||||||
|
result[key] = value
|
||||||
|
elif value:
|
||||||
|
result[key] = value
|
||||||
|
return (result,)
|
||||||
@@ -446,7 +446,16 @@ class SaveImageLM:
|
|||||||
lora_resource["versionName"] = lora_civitai["name"]
|
lora_resource["versionName"] = lora_civitai["name"]
|
||||||
civitai_resources.append(lora_resource)
|
civitai_resources.append(lora_resource)
|
||||||
|
|
||||||
sampler_display = self._get_civitai_sampler_name(sampler, scheduler)
|
sampler_name = CIVITAI_SAMPLER_MAP.get(sampler, sampler) if sampler else None
|
||||||
|
|
||||||
|
scheduler_mapping = {
|
||||||
|
"normal": "Normal",
|
||||||
|
"karras": "Karras",
|
||||||
|
"exponential": "Exponential",
|
||||||
|
"sgm_uniform": "SGM Uniform",
|
||||||
|
"sgm_quadratic": "SGM Quadratic",
|
||||||
|
}
|
||||||
|
scheduler_name = scheduler_mapping.get(scheduler, scheduler) if scheduler else None
|
||||||
|
|
||||||
# Build output lines
|
# Build output lines
|
||||||
lines = [prompt] if prompt else [""]
|
lines = [prompt] if prompt else [""]
|
||||||
@@ -456,21 +465,25 @@ class SaveImageLM:
|
|||||||
params: list[str] = []
|
params: list[str] = []
|
||||||
if steps is not None:
|
if steps is not None:
|
||||||
params.append(f"Steps: {steps}")
|
params.append(f"Steps: {steps}")
|
||||||
if sampler_display:
|
if sampler_name:
|
||||||
params.append(f"Sampler: {sampler_display}")
|
if scheduler_name:
|
||||||
|
params.append(f"Sampler: {sampler_name} {scheduler_name}")
|
||||||
|
else:
|
||||||
|
params.append(f"Sampler: {sampler_name}")
|
||||||
if cfg is not None:
|
if cfg is not None:
|
||||||
params.append(f"CFG scale: {cfg}")
|
params.append(f"CFG scale: {cfg}")
|
||||||
if seed is not None:
|
if seed is not None:
|
||||||
params.append(f"Seed: {seed}")
|
params.append(f"Seed: {seed}")
|
||||||
if size:
|
if size:
|
||||||
params.append(f"Size: {size}")
|
params.append(f"Size: {size}")
|
||||||
if clip_skip:
|
if clip_skip is not None:
|
||||||
try:
|
try:
|
||||||
cs = int(clip_skip)
|
params.append(f"Clip skip: {abs(int(clip_skip))}")
|
||||||
if cs != 0:
|
|
||||||
params.append(f"Clip skip: {abs(cs)}")
|
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
additional_data = metadata_dict.get("additional_data", "")
|
||||||
|
if additional_data:
|
||||||
|
params.append(additional_data)
|
||||||
if ckpt_hash:
|
if ckpt_hash:
|
||||||
params.append(f"Model hash: {ckpt_hash[:10].upper()}")
|
params.append(f"Model hash: {ckpt_hash[:10].upper()}")
|
||||||
if ckpt_display_name:
|
if ckpt_display_name:
|
||||||
|
|||||||
@@ -1562,6 +1562,11 @@ class SettingsHandler:
|
|||||||
{"success": False, "error": validation_error}
|
{"success": False, "error": validation_error}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if key == "update_channel" and value not in ("release", "nightly"):
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "update_channel must be 'release' or 'nightly'"}
|
||||||
|
)
|
||||||
|
|
||||||
if value == "__DELETE__" and key in (
|
if value == "__DELETE__" and key in (
|
||||||
"proxy_username",
|
"proxy_username",
|
||||||
"proxy_password",
|
"proxy_password",
|
||||||
|
|||||||
@@ -394,12 +394,14 @@ class ModelListingHandler:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# View-local-versions filter: show all local versions of a specific model
|
# View-local-versions filter: show all local versions of a specific model
|
||||||
|
# Accepts either a CivitAI modelId (int) or a HF group key like "hf:user/repo"
|
||||||
civitai_model_id = request.query.get("civitai_model_id")
|
civitai_model_id = request.query.get("civitai_model_id")
|
||||||
if civitai_model_id is not None:
|
if civitai_model_id is not None:
|
||||||
try:
|
try:
|
||||||
civitai_model_id = int(civitai_model_id)
|
civitai_model_id = int(civitai_model_id)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
civitai_model_id = None
|
# Keep as string — could be an HF group key (e.g. "hf:user/repo")
|
||||||
|
pass
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"page": page,
|
"page": page,
|
||||||
|
|||||||
@@ -38,6 +38,84 @@ def _clean_excludes() -> List[str]:
|
|||||||
return excludes
|
return excludes
|
||||||
|
|
||||||
|
|
||||||
|
def _stage_preserved_items(plugin_root: str) -> tuple[str, list[str]]:
|
||||||
|
"""Move preserved user-data items to a temp directory outside *plugin_root*.
|
||||||
|
|
||||||
|
This ensures that ``git reset --hard``, ``git clean -fd``, and ZIP-based
|
||||||
|
replacement cannot touch these files even when ``-e`` exclusion patterns
|
||||||
|
are mishandled (e.g. on Windows where forward-slash patterns may not
|
||||||
|
match backslash-prefixed paths in some Git builds, or where file locks
|
||||||
|
prevent deletion/recreation).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``(backup_root, staged_names)``: the temp directory path and the
|
||||||
|
list of item names that were successfully moved.
|
||||||
|
"""
|
||||||
|
backup_root = tempfile.mkdtemp(prefix='lora_manager_update_')
|
||||||
|
staged: list[str] = []
|
||||||
|
for name in _PRESERVE_DIRS:
|
||||||
|
src = os.path.join(plugin_root, name)
|
||||||
|
if not os.path.lexists(src):
|
||||||
|
continue
|
||||||
|
dst = os.path.join(backup_root, name)
|
||||||
|
try:
|
||||||
|
shutil.move(src, dst)
|
||||||
|
staged.append(name)
|
||||||
|
logger.debug("Staged '%s' for update safety", name)
|
||||||
|
except OSError:
|
||||||
|
# ``shutil.move`` may fail on Windows if a file handle inside
|
||||||
|
# the directory is still open (e.g. a SQLite WAL file). Fall
|
||||||
|
# back to copy-then-remove.
|
||||||
|
logger.debug("Move failed for '%s', falling back to copy", name)
|
||||||
|
try:
|
||||||
|
if os.path.isdir(src) and not os.path.islink(src):
|
||||||
|
shutil.copytree(src, dst, symlinks=True)
|
||||||
|
shutil.rmtree(src, ignore_errors=True)
|
||||||
|
else:
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
os.remove(src)
|
||||||
|
staged.append(name)
|
||||||
|
logger.info("Copied (then removed) '%s' for update safety", name)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Could not stage '%s': %s (will rely on git -e / skip lists)", name, exc
|
||||||
|
)
|
||||||
|
return backup_root, staged
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_preserved_items(plugin_root: str, backup_root: str, staged: list[str]) -> None:
|
||||||
|
"""Move staged items back from *backup_root* into *plugin_root*.
|
||||||
|
|
||||||
|
Any leftover placeholder at the destination (created by git checkout or
|
||||||
|
ZIP extraction) is removed before the move.
|
||||||
|
"""
|
||||||
|
for name in staged:
|
||||||
|
src = os.path.join(backup_root, name)
|
||||||
|
dst = os.path.join(plugin_root, name)
|
||||||
|
try:
|
||||||
|
if os.path.lexists(dst):
|
||||||
|
if os.path.isdir(dst) and not os.path.islink(dst):
|
||||||
|
shutil.rmtree(dst, ignore_errors=True)
|
||||||
|
else:
|
||||||
|
os.remove(dst)
|
||||||
|
shutil.move(src, dst)
|
||||||
|
logger.debug("Restored '%s' after update", name)
|
||||||
|
except OSError:
|
||||||
|
logger.debug("Move failed restoring '%s', falling back to copy", name)
|
||||||
|
try:
|
||||||
|
if os.path.isdir(src) and not os.path.islink(src):
|
||||||
|
shutil.copytree(src, dst, symlinks=True, dirs_exist_ok=True)
|
||||||
|
shutil.rmtree(src, ignore_errors=True)
|
||||||
|
else:
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
os.remove(src)
|
||||||
|
logger.info("Copied '%s' back after update", name)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to restore '%s': %s", name, exc)
|
||||||
|
shutil.rmtree(backup_root, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateRoutes:
|
class UpdateRoutes:
|
||||||
"""Routes for handling plugin update checks"""
|
"""Routes for handling plugin update checks"""
|
||||||
|
|
||||||
@@ -47,6 +125,7 @@ class UpdateRoutes:
|
|||||||
app.router.add_get('/api/lm/check-updates', UpdateRoutes.check_updates)
|
app.router.add_get('/api/lm/check-updates', UpdateRoutes.check_updates)
|
||||||
app.router.add_get('/api/lm/version-info', UpdateRoutes.get_version_info)
|
app.router.add_get('/api/lm/version-info', UpdateRoutes.get_version_info)
|
||||||
app.router.add_post('/api/lm/perform-update', UpdateRoutes.perform_update)
|
app.router.add_post('/api/lm/perform-update', UpdateRoutes.perform_update)
|
||||||
|
app.router.add_post('/api/lm/switch-channel', UpdateRoutes.switch_channel)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def check_updates(request):
|
async def check_updates(request):
|
||||||
@@ -65,10 +144,17 @@ class UpdateRoutes:
|
|||||||
|
|
||||||
# Fetch remote version from GitHub
|
# Fetch remote version from GitHub
|
||||||
if nightly:
|
if nightly:
|
||||||
remote_version, changelog = await UpdateRoutes._get_nightly_version()
|
local_hash = git_info.get('short_hash', '')
|
||||||
releases = None
|
nightly_version, releases_result = await asyncio.gather(
|
||||||
|
UpdateRoutes._get_nightly_version(local_hash),
|
||||||
|
UpdateRoutes._get_remote_version()
|
||||||
|
)
|
||||||
|
remote_version, _, behind_by, commit_date = nightly_version
|
||||||
|
_, changelog, releases = releases_result
|
||||||
else:
|
else:
|
||||||
remote_version, changelog, releases = await UpdateRoutes._get_remote_version()
|
remote_version, changelog, releases = await UpdateRoutes._get_remote_version()
|
||||||
|
behind_by = 0
|
||||||
|
commit_date = ''
|
||||||
|
|
||||||
# Compare versions
|
# Compare versions
|
||||||
if nightly:
|
if nightly:
|
||||||
@@ -81,6 +167,10 @@ class UpdateRoutes:
|
|||||||
remote_version.replace('v', '')
|
remote_version.replace('v', '')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
plugin_root = os.path.dirname(os.path.dirname(current_dir))
|
||||||
|
has_git = os.path.exists(os.path.join(plugin_root, '.git'))
|
||||||
|
|
||||||
response_data = {
|
response_data = {
|
||||||
'success': True,
|
'success': True,
|
||||||
'current_version': local_version,
|
'current_version': local_version,
|
||||||
@@ -88,13 +178,13 @@ class UpdateRoutes:
|
|||||||
'update_available': update_available,
|
'update_available': update_available,
|
||||||
'changelog': changelog,
|
'changelog': changelog,
|
||||||
'git_info': git_info,
|
'git_info': git_info,
|
||||||
'nightly': nightly
|
'nightly': nightly,
|
||||||
|
'has_git': has_git,
|
||||||
|
'releases': releases,
|
||||||
|
'behind_by': behind_by,
|
||||||
|
'commit_date': commit_date
|
||||||
}
|
}
|
||||||
|
|
||||||
# Include releases list for stable mode
|
|
||||||
if releases is not None:
|
|
||||||
response_data['releases'] = releases
|
|
||||||
|
|
||||||
return web.json_response(response_data)
|
return web.json_response(response_data)
|
||||||
|
|
||||||
except NETWORK_EXCEPTIONS as e:
|
except NETWORK_EXCEPTIONS as e:
|
||||||
@@ -126,9 +216,14 @@ class UpdateRoutes:
|
|||||||
# Format: version-short_hash
|
# Format: version-short_hash
|
||||||
version_string = f"{local_version}-{short_hash}"
|
version_string = f"{local_version}-{short_hash}"
|
||||||
|
|
||||||
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
plugin_root = os.path.dirname(os.path.dirname(current_dir))
|
||||||
|
has_git = os.path.exists(os.path.join(plugin_root, '.git'))
|
||||||
|
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
'success': True,
|
'success': True,
|
||||||
'version': version_string
|
'version': version_string,
|
||||||
|
'has_git': has_git
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -156,20 +251,22 @@ class UpdateRoutes:
|
|||||||
if os.path.exists(settings_path):
|
if os.path.exists(settings_path):
|
||||||
with open(settings_path, 'r', encoding='utf-8') as f:
|
with open(settings_path, 'r', encoding='utf-8') as f:
|
||||||
settings_backup = f.read()
|
settings_backup = f.read()
|
||||||
logger.info("Backed up settings.json")
|
logger.debug("Backed up settings.json (%d bytes)", len(settings_backup))
|
||||||
|
|
||||||
git_folder = os.path.join(plugin_root, '.git')
|
staged_backup_dir, staged_items = _stage_preserved_items(plugin_root)
|
||||||
if os.path.exists(git_folder):
|
try:
|
||||||
# Git update
|
git_folder = os.path.join(plugin_root, '.git')
|
||||||
success, new_version = await UpdateRoutes._perform_git_update(plugin_root, nightly)
|
if os.path.exists(git_folder):
|
||||||
else:
|
success, new_version = await UpdateRoutes._perform_git_update(plugin_root, nightly)
|
||||||
# Fallback: Download ZIP and replace files
|
else:
|
||||||
success, new_version = await UpdateRoutes._download_and_replace_zip(plugin_root)
|
success, new_version = await UpdateRoutes._download_and_replace_zip(plugin_root)
|
||||||
|
finally:
|
||||||
|
_restore_preserved_items(plugin_root, staged_backup_dir, staged_items)
|
||||||
|
|
||||||
if settings_backup and success:
|
if settings_backup and success:
|
||||||
with open(settings_path, 'w', encoding='utf-8') as f:
|
with open(settings_path, 'w', encoding='utf-8') as f:
|
||||||
f.write(settings_backup)
|
f.write(settings_backup)
|
||||||
logger.info("Restored settings.json")
|
logger.debug("Restored settings.json content (%d bytes)", len(settings_backup))
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
@@ -190,6 +287,164 @@ class UpdateRoutes:
|
|||||||
'error': str(e)
|
'error': str(e)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def switch_channel(request):
|
||||||
|
"""
|
||||||
|
Switch between release and nightly update channels.
|
||||||
|
|
||||||
|
ZIP/CNR install → Nightly: git init + checkout main (one-way upgrade)
|
||||||
|
Git install → Release: git checkout latest tag (.git preserved)
|
||||||
|
ZIP/CNR install → Release: ZIP download (no .git, stays in ZIP mode)
|
||||||
|
Git install → Nightly: git checkout main + pull
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
body = await request.json() if request.has_body else {}
|
||||||
|
channel = body.get('channel', '')
|
||||||
|
|
||||||
|
if channel not in ('release', 'nightly'):
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': f'Invalid channel: {channel}. Must be "release" or "nightly".'
|
||||||
|
})
|
||||||
|
|
||||||
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
plugin_root = os.path.dirname(os.path.dirname(current_dir))
|
||||||
|
|
||||||
|
settings_path = ensure_settings_file(logger)
|
||||||
|
settings_backup = None
|
||||||
|
if os.path.exists(settings_path):
|
||||||
|
with open(settings_path, 'r', encoding='utf-8') as f:
|
||||||
|
settings_backup = f.read()
|
||||||
|
logger.debug("Backed up settings.json before channel switch (%d bytes)", len(settings_backup))
|
||||||
|
|
||||||
|
staged_backup_dir, staged_items = _stage_preserved_items(plugin_root)
|
||||||
|
try:
|
||||||
|
git_folder = os.path.join(plugin_root, '.git')
|
||||||
|
|
||||||
|
if channel == 'nightly':
|
||||||
|
git_backup = None
|
||||||
|
if os.path.exists(git_folder):
|
||||||
|
git_backup = UpdateRoutes._backup_git(git_folder, 'nightly')
|
||||||
|
|
||||||
|
success = False
|
||||||
|
new_version = ''
|
||||||
|
try:
|
||||||
|
if os.path.exists(git_folder):
|
||||||
|
success, new_version = await UpdateRoutes._perform_git_update(
|
||||||
|
plugin_root, nightly=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
success, new_version = UpdateRoutes._init_git_repo(plugin_root)
|
||||||
|
finally:
|
||||||
|
UpdateRoutes._restore_git(git_backup, git_folder, success, 'nightly')
|
||||||
|
else:
|
||||||
|
success = False
|
||||||
|
new_version = ''
|
||||||
|
if os.path.exists(git_folder):
|
||||||
|
success, new_version = await UpdateRoutes._perform_git_update(
|
||||||
|
plugin_root, nightly=False
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
tracking_file = os.path.join(plugin_root, '.tracking')
|
||||||
|
if os.path.exists(tracking_file):
|
||||||
|
os.remove(tracking_file)
|
||||||
|
success, new_version = await UpdateRoutes._download_and_replace_zip(plugin_root)
|
||||||
|
finally:
|
||||||
|
_restore_preserved_items(plugin_root, staged_backup_dir, staged_items)
|
||||||
|
|
||||||
|
if settings_backup and success:
|
||||||
|
with open(settings_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(settings_backup)
|
||||||
|
logger.debug("Restored settings.json content after channel switch (%d bytes)", len(settings_backup))
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'channel': channel,
|
||||||
|
'new_version': new_version,
|
||||||
|
'message': f'Switched to {channel} channel'
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': f'Failed to switch to {channel} channel'
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to switch channel: %s", e, exc_info=True)
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _init_git_repo(plugin_root: str) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Initialize a Git repository in a ZIP-installed plugin folder.
|
||||||
|
Clones the remote history and checks out main branch.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import git
|
||||||
|
except ImportError:
|
||||||
|
logger.error(
|
||||||
|
"GitPython is not available: cannot initialize git repo. "
|
||||||
|
"Install git or set $GIT_PYTHON_GIT_EXECUTABLE to the git binary path."
|
||||||
|
)
|
||||||
|
return False, ""
|
||||||
|
|
||||||
|
clean_excludes = _clean_excludes()
|
||||||
|
|
||||||
|
try:
|
||||||
|
repo = git.Repo.init(plugin_root)
|
||||||
|
origin = repo.create_remote(
|
||||||
|
'origin',
|
||||||
|
'https://github.com/willmiao/ComfyUI-Lora-Manager.git'
|
||||||
|
)
|
||||||
|
origin.fetch()
|
||||||
|
|
||||||
|
repo.create_head('main', origin.refs.main)
|
||||||
|
repo.git.checkout('main', '--force')
|
||||||
|
repo.git.reset('--hard')
|
||||||
|
repo.git.clean('-fd', *clean_excludes)
|
||||||
|
|
||||||
|
tracking_file = os.path.join(plugin_root, '.tracking')
|
||||||
|
if os.path.exists(tracking_file):
|
||||||
|
os.remove(tracking_file)
|
||||||
|
logger.info("Removed .tracking file (now in git mode)")
|
||||||
|
|
||||||
|
new_version = f"main-{repo.head.commit.hexsha[:7]}"
|
||||||
|
logger.info("Initialized git repo on main branch: %s", new_version)
|
||||||
|
return True, new_version
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to initialize git repo: %s", e, exc_info=True)
|
||||||
|
return False, ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _backup_git(git_folder, label):
|
||||||
|
try:
|
||||||
|
backup_dir = tempfile.mkdtemp()
|
||||||
|
backup = os.path.join(backup_dir, '.git')
|
||||||
|
shutil.copytree(git_folder, backup)
|
||||||
|
logger.info("Backed up .git before switching to %s", label)
|
||||||
|
return backup
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to backup .git before %s switch: %s", label, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _restore_git(git_backup, git_folder, success, label):
|
||||||
|
if git_backup and not success:
|
||||||
|
try:
|
||||||
|
if os.path.exists(git_folder):
|
||||||
|
shutil.rmtree(git_folder)
|
||||||
|
shutil.copytree(git_backup, git_folder)
|
||||||
|
logger.info("Restored .git after failed %s switch", label)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to restore .git after %s switch: %s", label, e)
|
||||||
|
if git_backup:
|
||||||
|
shutil.rmtree(os.path.dirname(git_backup), ignore_errors=True)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _download_and_replace_zip(plugin_root: str) -> tuple[bool, str]:
|
async def _download_and_replace_zip(plugin_root: str) -> tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
@@ -244,8 +499,7 @@ class UpdateRoutes:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Could not close downloaded-version history database", exc_info=True)
|
logger.debug("Could not close downloaded-version history database", exc_info=True)
|
||||||
|
|
||||||
# Skip settings.json, civitai, model cache and runtime cache folders
|
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=list(_PRESERVE_DIRS))
|
||||||
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json', 'civitai', 'model_cache', 'cache', 'wildcards', 'backups', 'stats'])
|
|
||||||
|
|
||||||
# Extract ZIP to temp dir
|
# Extract ZIP to temp dir
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
@@ -255,7 +509,7 @@ class UpdateRoutes:
|
|||||||
extracted_root = next(os.scandir(tmp_dir)).path
|
extracted_root = next(os.scandir(tmp_dir)).path
|
||||||
|
|
||||||
# Copy files, skipping user data that should be preserved
|
# Copy files, skipping user data that should be preserved
|
||||||
skip_items = {'settings.json', 'civitai', 'wildcards', 'backups', 'stats'}
|
skip_items = set(_PRESERVE_DIRS)
|
||||||
for item in os.listdir(extracted_root):
|
for item in os.listdir(extracted_root):
|
||||||
if item in skip_items:
|
if item in skip_items:
|
||||||
continue
|
continue
|
||||||
@@ -272,7 +526,7 @@ class UpdateRoutes:
|
|||||||
# for ComfyUI Manager to work properly
|
# for ComfyUI Manager to work properly
|
||||||
tracking_info_file = os.path.join(plugin_root, '.tracking')
|
tracking_info_file = os.path.join(plugin_root, '.tracking')
|
||||||
tracking_files = []
|
tracking_files = []
|
||||||
skip_tracked = {'civitai', 'wildcards', 'backups', 'stats'}
|
skip_tracked = set(_PRESERVE_DIRS) - {'settings.json'}
|
||||||
for root, dirs, files in os.walk(extracted_root):
|
for root, dirs, files in os.walk(extracted_root):
|
||||||
# Skip user data directories and their contents
|
# Skip user data directories and their contents
|
||||||
rel_root = os.path.relpath(root, extracted_root)
|
rel_root = os.path.relpath(root, extracted_root)
|
||||||
@@ -296,6 +550,7 @@ class UpdateRoutes:
|
|||||||
logger.error(f"ZIP update failed: {e}", exc_info=True)
|
logger.error(f"ZIP update failed: {e}", exc_info=True)
|
||||||
return False, ""
|
return False, ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
def _clean_plugin_folder(plugin_root, skip_files=None):
|
def _clean_plugin_folder(plugin_root, skip_files=None):
|
||||||
skip_files = skip_files or []
|
skip_files = skip_files or []
|
||||||
for item in os.listdir(plugin_root):
|
for item in os.listdir(plugin_root):
|
||||||
@@ -308,41 +563,54 @@ class UpdateRoutes:
|
|||||||
os.remove(path)
|
os.remove(path)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _get_nightly_version() -> tuple[str, List[str]]:
|
async def _get_nightly_version(local_hash: str = "") -> tuple[str, List[str], int, str]:
|
||||||
"""
|
|
||||||
Fetch latest commit from main branch
|
|
||||||
"""
|
|
||||||
repo_owner = "willmiao"
|
repo_owner = "willmiao"
|
||||||
repo_name = "ComfyUI-Lora-Manager"
|
repo_name = "ComfyUI-Lora-Manager"
|
||||||
|
|
||||||
# Use GitHub API to fetch the latest commit from main branch
|
|
||||||
github_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/commits/main"
|
github_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/commits/main"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
downloader = await get_downloader()
|
downloader = await get_downloader()
|
||||||
success, data = await downloader.make_request('GET', github_url, custom_headers={'Accept': 'application/vnd.github+json'})
|
success, data = await downloader.make_request(
|
||||||
|
'GET', github_url,
|
||||||
|
custom_headers={'Accept': 'application/vnd.github+json'}
|
||||||
|
)
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
logger.warning(f"Failed to fetch GitHub commit: {data}")
|
logger.warning("Failed to fetch GitHub commit: %s", data)
|
||||||
return "main", []
|
return "main", [], 0, ""
|
||||||
|
|
||||||
commit_sha = data.get('sha', '')[:7] # Short hash
|
commit_sha = data.get('sha', '')[:7]
|
||||||
commit_message = data.get('commit', {}).get('message', '')
|
commit_message = data.get('commit', {}).get('message', '')
|
||||||
|
commit_date = data.get('commit', {}).get('committer', {}).get('date', '')[:10]
|
||||||
|
|
||||||
# Format as "main-{short_hash}"
|
|
||||||
version = f"main-{commit_sha}"
|
version = f"main-{commit_sha}"
|
||||||
|
|
||||||
# Use commit message as changelog
|
|
||||||
changelog = [commit_message] if commit_message else []
|
changelog = [commit_message] if commit_message else []
|
||||||
|
|
||||||
return version, changelog
|
behind_by = 0
|
||||||
|
if local_hash and local_hash not in ('unknown', 'stable'):
|
||||||
|
compare_url = (
|
||||||
|
f"https://api.github.com/repos/{repo_owner}/{repo_name}"
|
||||||
|
f"/compare/{local_hash}...main"
|
||||||
|
)
|
||||||
|
c_ok, c_data = await downloader.make_request(
|
||||||
|
'GET', compare_url,
|
||||||
|
custom_headers={'Accept': 'application/vnd.github+json'}
|
||||||
|
)
|
||||||
|
if c_ok:
|
||||||
|
if c_data.get('status') in ('ahead', 'diverged'):
|
||||||
|
behind_by = c_data.get('ahead_by', 0)
|
||||||
|
else:
|
||||||
|
behind_by = c_data.get('behind_by', 0)
|
||||||
|
|
||||||
|
return version, changelog, behind_by, commit_date
|
||||||
|
|
||||||
except NETWORK_EXCEPTIONS as e:
|
except NETWORK_EXCEPTIONS as e:
|
||||||
logger.warning("Unable to reach GitHub for nightly version: %s", e)
|
logger.warning("Unable to reach GitHub for nightly version: %s", e)
|
||||||
return "main", []
|
return "main", [], 0, ""
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching nightly version: {e}", exc_info=True)
|
logger.error("Error fetching nightly version: %s", e, exc_info=True)
|
||||||
return "main", []
|
return "main", [], 0, ""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _compare_nightly_versions(local_git_info: Dict[str, str], remote_version: str) -> bool:
|
def _compare_nightly_versions(local_git_info: Dict[str, str], remote_version: str) -> bool:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
import asyncio
|
import asyncio
|
||||||
import re
|
import re
|
||||||
from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING
|
from typing import Any, Dict, List, Optional, Type, Union, TYPE_CHECKING
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
@@ -109,12 +109,15 @@ class BaseModelService(ABC):
|
|||||||
if civitai_model_id is not None:
|
if civitai_model_id is not None:
|
||||||
sorted_data = [
|
sorted_data = [
|
||||||
item for item in sorted_data
|
item for item in sorted_data
|
||||||
if self._extract_model_id(item) == civitai_model_id
|
if self._extract_group_key(item) == civitai_model_id
|
||||||
]
|
]
|
||||||
# VLM mode: always sort by version ID descending (newest version first),
|
# VLM mode: always sort by version ID descending (newest version first),
|
||||||
# regardless of the current sort_by preference.
|
# regardless of the current sort_by preference.
|
||||||
|
# Fall back to modified timestamp for non-CivitAI sources.
|
||||||
sorted_data.sort(
|
sorted_data.sort(
|
||||||
key=lambda x: self._extract_version_id(x) or 0,
|
key=lambda x: self._extract_version_id(x)
|
||||||
|
or x.get("modified", 0)
|
||||||
|
or 0,
|
||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -129,18 +132,21 @@ class BaseModelService(ABC):
|
|||||||
ufs = self.settings.get("version_grouping", "same_base")
|
ufs = self.settings.get("version_grouping", "same_base")
|
||||||
group_by_base = ufs == "same_base"
|
group_by_base = ufs == "same_base"
|
||||||
|
|
||||||
dedup_map = {} # (modelId [,base_model]) -> (item, version_id)
|
dedup_map = {} # (modelId [,base_model]) -> (item, version_or_modified)
|
||||||
version_counter = {} # same-key -> count
|
version_counter = {} # same-key -> count
|
||||||
standalone = []
|
standalone = []
|
||||||
for item in sorted_data:
|
for item in sorted_data:
|
||||||
mid = self._extract_model_id(item)
|
mid = self._extract_group_key(item)
|
||||||
if mid is None:
|
if mid is None:
|
||||||
standalone.append(item)
|
standalone.append(item)
|
||||||
continue
|
continue
|
||||||
key = (mid, item.get("base_model") or "") if group_by_base else mid
|
key = (mid, item.get("base_model") or "") if group_by_base else mid
|
||||||
# Count all versions per key
|
# Count all versions per key
|
||||||
version_counter[key] = version_counter.get(key, 0) + 1
|
version_counter[key] = version_counter.get(key, 0) + 1
|
||||||
vid = self._extract_version_id(item) or 0
|
# Prefer CivitAI version_id; fall back to modified timestamp
|
||||||
|
vid = self._extract_version_id(item)
|
||||||
|
if vid is None:
|
||||||
|
vid = item.get("modified", 0) or 0
|
||||||
if key not in dedup_map or vid > dedup_map[key][1]:
|
if key not in dedup_map or vid > dedup_map[key][1]:
|
||||||
dedup_map[key] = (item, vid)
|
dedup_map[key] = (item, vid)
|
||||||
# Attach version_count to each surviving grouped item (shallow copy
|
# Attach version_count to each surviving grouped item (shallow copy
|
||||||
@@ -174,16 +180,19 @@ class BaseModelService(ABC):
|
|||||||
model_groups: Dict[Any, List[Dict]] = {}
|
model_groups: Dict[Any, List[Dict]] = {}
|
||||||
ungrouped_standalone: List[Dict] = []
|
ungrouped_standalone: List[Dict] = []
|
||||||
for item in sorted_data:
|
for item in sorted_data:
|
||||||
mid = self._extract_model_id(item)
|
mid = self._extract_group_key(item)
|
||||||
if mid is None:
|
if mid is None:
|
||||||
ungrouped_standalone.append(item)
|
ungrouped_standalone.append(item)
|
||||||
continue
|
continue
|
||||||
key = (mid, item.get("base_model") or "") if group_by_base else mid
|
key = (mid, item.get("base_model") or "") if group_by_base else mid
|
||||||
model_groups.setdefault(key, []).append(item)
|
model_groups.setdefault(key, []).append(item)
|
||||||
# Sort versions within each group by version id descending
|
# Sort versions within each group by version id (descending);
|
||||||
|
# fall back to modified timestamp for non-CivitAI sources.
|
||||||
for items in model_groups.values():
|
for items in model_groups.values():
|
||||||
items.sort(
|
items.sort(
|
||||||
key=lambda x: self._extract_version_id(x) or 0,
|
key=lambda x: self._extract_version_id(x)
|
||||||
|
or x.get("modified", 0)
|
||||||
|
or 0,
|
||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
# Sort groups by version count
|
# Sort groups by version count
|
||||||
@@ -697,6 +706,33 @@ class BaseModelService(ABC):
|
|||||||
|
|
||||||
return annotated
|
return annotated
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_hf_group_key(item: Dict) -> Optional[str]:
|
||||||
|
"""Extract `hf:{owner}/{repo}` from item's ``hf_url``, or None."""
|
||||||
|
hf_url = item.get("hf_url") if isinstance(item, dict) else None
|
||||||
|
if not hf_url or not isinstance(hf_url, str):
|
||||||
|
return None
|
||||||
|
m = re.match(
|
||||||
|
r"https?://huggingface\.co/([^/]+/[^/]+)", hf_url.strip()
|
||||||
|
)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
return f"hf:{m.group(1)}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_group_key(item: Dict) -> Union[int, str, None]:
|
||||||
|
"""Return the group identity key: CivitAI modelId (int) or HF repo (str).
|
||||||
|
|
||||||
|
Preference order:
|
||||||
|
1. CivitAI ``modelId`` (int)
|
||||||
|
2. HF repo identity ``hf:{owner}/{repo}`` (str)
|
||||||
|
3. ``None`` (no known grouping source)
|
||||||
|
"""
|
||||||
|
mid = BaseModelService._extract_model_id(item)
|
||||||
|
if mid is not None:
|
||||||
|
return mid
|
||||||
|
return BaseModelService._extract_hf_group_key(item)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_model_id(item: Dict) -> Optional[int]:
|
def _extract_model_id(item: Dict) -> Optional[int]:
|
||||||
civitai = item.get("civitai") if isinstance(item, dict) else None
|
civitai = item.get("civitai") if isinstance(item, dict) else None
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class DownloadQueueService:
|
|||||||
_instance: Optional[DownloadQueueService] = None
|
_instance: Optional[DownloadQueueService] = None
|
||||||
_class_lock: asyncio.Lock = asyncio.Lock()
|
_class_lock: asyncio.Lock = asyncio.Lock()
|
||||||
|
|
||||||
_SCHEMA = """
|
_SCHEMA_TABLES = """
|
||||||
CREATE TABLE IF NOT EXISTS download_queue (
|
CREATE TABLE IF NOT EXISTS download_queue (
|
||||||
download_id TEXT PRIMARY KEY,
|
download_id TEXT PRIMARY KEY,
|
||||||
model_id INTEGER,
|
model_id INTEGER,
|
||||||
@@ -74,6 +74,9 @@ class DownloadQueueService:
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_dh_completed ON download_history(completed_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_dh_completed ON download_history(completed_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_dh_status ON download_history(status);
|
CREATE INDEX IF NOT EXISTS idx_dh_status ON download_history(status);
|
||||||
|
"""
|
||||||
|
|
||||||
|
_CREATE_UNIQUE_INDEX = """
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_dh_download_id
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_dh_download_id
|
||||||
ON download_history(download_id) WHERE download_id IS NOT NULL;
|
ON download_history(download_id) WHERE download_id IS NOT NULL;
|
||||||
"""
|
"""
|
||||||
@@ -115,10 +118,39 @@ class DownloadQueueService:
|
|||||||
if self._schema_initialized:
|
if self._schema_initialized:
|
||||||
return
|
return
|
||||||
with self._connect() as conn:
|
with self._connect() as conn:
|
||||||
conn.executescript(self._SCHEMA)
|
conn.executescript(self._SCHEMA_TABLES)
|
||||||
|
|
||||||
|
# Creating the unique index on download_history.download_id can
|
||||||
|
# fail if pre-existing rows have duplicate values (e.g. from a
|
||||||
|
# previous version that lacked the index). Deduplicate first so
|
||||||
|
# that the migration does not crash on startup.
|
||||||
|
if not self._index_exists(conn, "idx_dh_download_id"):
|
||||||
|
self._remove_duplicate_download_ids(conn)
|
||||||
|
conn.executescript(self._CREATE_UNIQUE_INDEX)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
self._schema_initialized = True
|
self._schema_initialized = True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _index_exists(conn: sqlite3.Connection, name: str) -> bool:
|
||||||
|
return conn.execute(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type='index' AND name=?",
|
||||||
|
(name,),
|
||||||
|
).fetchone() is not None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _remove_duplicate_download_ids(conn: sqlite3.Connection) -> None:
|
||||||
|
conn.execute("""
|
||||||
|
DELETE FROM download_history
|
||||||
|
WHERE id NOT IN (
|
||||||
|
SELECT MIN(id)
|
||||||
|
FROM download_history
|
||||||
|
WHERE download_id IS NOT NULL
|
||||||
|
GROUP BY download_id
|
||||||
|
)
|
||||||
|
AND download_id IS NOT NULL
|
||||||
|
""")
|
||||||
|
|
||||||
def get_database_path(self) -> str:
|
def get_database_path(self) -> str:
|
||||||
"""Return the resolved database file path."""
|
"""Return the resolved database file path."""
|
||||||
return self._db_path
|
return self._db_path
|
||||||
|
|||||||
@@ -927,6 +927,25 @@ class ModelScanner:
|
|||||||
# Update cache data
|
# Update cache data
|
||||||
self._cache.raw_data = [item for item in self._cache.raw_data if item['file_path'] not in missing_files]
|
self._cache.raw_data = [item for item in self._cache.raw_data if item['file_path'] not in missing_files]
|
||||||
|
|
||||||
|
dedup_removed = 0
|
||||||
|
seen_paths: set = set()
|
||||||
|
deduped: list = []
|
||||||
|
for item in reversed(self._cache.raw_data):
|
||||||
|
path = item.get('file_path', '')
|
||||||
|
if path not in seen_paths:
|
||||||
|
seen_paths.add(path)
|
||||||
|
deduped.append(item)
|
||||||
|
else:
|
||||||
|
for tag in item.get('tags', []):
|
||||||
|
if tag in self._tags_count:
|
||||||
|
self._tags_count[tag] = max(0, self._tags_count[tag] - 1)
|
||||||
|
if self._tags_count[tag] == 0:
|
||||||
|
del self._tags_count[tag]
|
||||||
|
dedup_removed += 1
|
||||||
|
if dedup_removed > 0:
|
||||||
|
self._cache.raw_data = list(reversed(deduped))
|
||||||
|
total_removed += dedup_removed
|
||||||
|
|
||||||
# Resort cache if changes were made
|
# Resort cache if changes were made
|
||||||
if total_added > 0 or total_removed > 0:
|
if total_added > 0 or total_removed > 0:
|
||||||
# Update folders list
|
# Update folders list
|
||||||
@@ -1352,18 +1371,25 @@ class ModelScanner:
|
|||||||
# Update folder in metadata
|
# Update folder in metadata
|
||||||
metadata_dict['folder'] = folder
|
metadata_dict['folder'] = folder
|
||||||
|
|
||||||
# Add to cache
|
file_path = metadata_dict.get('file_path', '')
|
||||||
|
if file_path:
|
||||||
|
old_entries = [item for item in self._cache.raw_data if item.get('file_path') == file_path]
|
||||||
|
for old_entry in old_entries:
|
||||||
|
for tag in old_entry.get('tags', []):
|
||||||
|
if tag in self._tags_count:
|
||||||
|
self._tags_count[tag] = max(0, self._tags_count[tag] - 1)
|
||||||
|
if self._tags_count[tag] == 0:
|
||||||
|
del self._tags_count[tag]
|
||||||
|
self._hash_index.remove_by_path(file_path)
|
||||||
|
self._cache.raw_data = [item for item in self._cache.raw_data if item.get('file_path') != file_path]
|
||||||
|
|
||||||
|
for tag in metadata_dict.get('tags', []):
|
||||||
|
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
|
||||||
|
|
||||||
self._cache.raw_data.append(metadata_dict)
|
self._cache.raw_data.append(metadata_dict)
|
||||||
self._cache.add_to_version_index(metadata_dict)
|
|
||||||
|
|
||||||
# Resort cache data
|
|
||||||
await self._cache.resort()
|
await self._cache.resort()
|
||||||
|
|
||||||
# Update folders list
|
|
||||||
all_folders = set(self._cache.folders)
|
|
||||||
all_folders.add(folder)
|
|
||||||
self._cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
|
|
||||||
|
|
||||||
# Update the hash index
|
# Update the hash index
|
||||||
self._hash_index.add_entry(metadata_dict['sha256'], metadata_dict['file_path'])
|
self._hash_index.add_entry(metadata_dict['sha256'], metadata_dict['file_path'])
|
||||||
await self._persist_current_cache()
|
await self._persist_current_cache()
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ logger = logging.getLogger(__name__)
|
|||||||
_WILDCARD_PATTERN = re.compile(r"__([\w\s.\-+/*\\]+?)__")
|
_WILDCARD_PATTERN = re.compile(r"__([\w\s.\-+/*\\]+?)__")
|
||||||
_OPTION_PATTERN = re.compile(r"{([^{}]*?)}")
|
_OPTION_PATTERN = re.compile(r"{([^{}]*?)}")
|
||||||
_TRIGGER_WORD_PATTERN = re.compile(r"^trigger_words\d+$")
|
_TRIGGER_WORD_PATTERN = re.compile(r"^trigger_words\d+$")
|
||||||
_WEIGHTED_OPTION_PATTERN = re.compile(r"^\s*([0-9.]+)::")
|
_WEIGHTED_OPTION_PATTERN = re.compile(r"^\s*-?\d+(\.\d+)?::")
|
||||||
_NUMERIC_PATTERN = re.compile(r"^-?\d+(\.\d+)?$")
|
_NUMERIC_PATTERN = re.compile(r"^-?\d+(\.\d+)?$")
|
||||||
|
|
||||||
|
|
||||||
@@ -390,7 +390,7 @@ class WildcardService:
|
|||||||
) -> str | None:
|
) -> str | None:
|
||||||
keyword = _normalize_wildcard_key(raw_key)
|
keyword = _normalize_wildcard_key(raw_key)
|
||||||
if keyword in wildcard_dict:
|
if keyword in wildcard_dict:
|
||||||
return rng.choice(wildcard_dict[keyword])
|
return self._pick_weighted_or_plain(wildcard_dict[keyword], rng)
|
||||||
|
|
||||||
if "*" in keyword:
|
if "*" in keyword:
|
||||||
regex_pattern = keyword.replace("*", ".*").replace("+", r"\+")
|
regex_pattern = keyword.replace("*", ".*").replace("+", r"\+")
|
||||||
@@ -400,7 +400,7 @@ class WildcardService:
|
|||||||
if compiled.match(key):
|
if compiled.match(key):
|
||||||
aggregated.extend(values)
|
aggregated.extend(values)
|
||||||
if aggregated:
|
if aggregated:
|
||||||
return rng.choice(aggregated)
|
return self._pick_weighted_or_plain(aggregated, rng)
|
||||||
|
|
||||||
if "/" not in keyword:
|
if "/" not in keyword:
|
||||||
fallback_keyword = _normalize_wildcard_key(f"*/{keyword}")
|
fallback_keyword = _normalize_wildcard_key(f"*/{keyword}")
|
||||||
@@ -409,6 +409,39 @@ class WildcardService:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _pick_weighted_or_plain(
|
||||||
|
self, values: list[str], rng: random.Random
|
||||||
|
) -> str:
|
||||||
|
"""Pick a value from the list, respecting N::weight prefix if present.
|
||||||
|
|
||||||
|
When any value in the list uses the ``N::value`` weighted syntax with a
|
||||||
|
weight different from 1, the pick uses weighted random selection. When
|
||||||
|
no such weighting is present, a plain ``rng.choice`` is used (preserving
|
||||||
|
backward compatibility for unweighted wildcard files).
|
||||||
|
|
||||||
|
In either case the ``N::`` prefix is always stripped from the returned
|
||||||
|
value, matching the behaviour of ``{...}`` option groups.
|
||||||
|
"""
|
||||||
|
# Fast path: skip weighting logic entirely when no :: syntax exists
|
||||||
|
if not any("::" in v for v in values):
|
||||||
|
return rng.choice(values)
|
||||||
|
|
||||||
|
weighted_options: list[tuple[float, str]] = []
|
||||||
|
for value in values:
|
||||||
|
weight = 1.0
|
||||||
|
parts = value.split("::", 1)
|
||||||
|
if len(parts) == 2 and _is_numeric_string(parts[0].strip()):
|
||||||
|
weight = float(parts[0].strip())
|
||||||
|
weighted_options.append((weight, value))
|
||||||
|
|
||||||
|
any_weighted = any(w != 1.0 for w, _ in weighted_options)
|
||||||
|
if any_weighted:
|
||||||
|
picked = self._weighted_choice(weighted_options, rng)
|
||||||
|
else:
|
||||||
|
picked = rng.choice(values)
|
||||||
|
|
||||||
|
return self._strip_weight_prefix(picked)
|
||||||
|
|
||||||
|
|
||||||
def is_trigger_words_input(name: str) -> bool:
|
def is_trigger_words_input(name: str) -> bool:
|
||||||
return bool(_TRIGGER_WORD_PATTERN.match(name))
|
return bool(_TRIGGER_WORD_PATTERN.match(name))
|
||||||
|
|||||||
@@ -475,13 +475,19 @@ class MetadataUpdater:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
model_folder = get_model_folder(model_hash)
|
model_folder = get_model_folder(model_hash)
|
||||||
if not model_folder:
|
if not model_folder or not os.path.isdir(model_folder):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
civitai = getattr(metadata, "civitai", None)
|
civitai = getattr(metadata, "civitai", None)
|
||||||
if not isinstance(civitai, dict):
|
if not isinstance(civitai, dict):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Read the directory listing once so every image entry reuses it.
|
||||||
|
try:
|
||||||
|
dir_entries = os.listdir(model_folder)
|
||||||
|
except OSError:
|
||||||
|
dir_entries = []
|
||||||
|
|
||||||
has_changes = False
|
has_changes = False
|
||||||
|
|
||||||
custom_images = civitai.get("customImages")
|
custom_images = civitai.get("customImages")
|
||||||
@@ -493,24 +499,15 @@ class MetadataUpdater:
|
|||||||
if not img_id:
|
if not img_id:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not os.path.isdir(model_folder):
|
prefix = f"custom_{img_id}"
|
||||||
|
found = any(
|
||||||
|
f.startswith(prefix) and os.path.isfile(
|
||||||
|
os.path.join(model_folder, f)
|
||||||
|
)
|
||||||
|
for f in dir_entries
|
||||||
|
)
|
||||||
|
if not found:
|
||||||
stale.append(idx)
|
stale.append(idx)
|
||||||
else:
|
|
||||||
found = False
|
|
||||||
try:
|
|
||||||
prefix = f"custom_{img_id}"
|
|
||||||
for fname in os.listdir(model_folder):
|
|
||||||
if fname.startswith(prefix) and os.path.isfile(
|
|
||||||
os.path.join(model_folder, fname)
|
|
||||||
):
|
|
||||||
found = True
|
|
||||||
break
|
|
||||||
except OSError:
|
|
||||||
stale.append(idx)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not found:
|
|
||||||
stale.append(idx)
|
|
||||||
|
|
||||||
if stale:
|
if stale:
|
||||||
for idx in reversed(stale):
|
for idx in reversed(stale):
|
||||||
@@ -532,22 +529,9 @@ class MetadataUpdater:
|
|||||||
# is gone.
|
# is gone.
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not os.path.isdir(model_folder):
|
prefix = f"image_{idx}."
|
||||||
|
if not any(f.startswith(prefix) for f in dir_entries):
|
||||||
stale.append(idx)
|
stale.append(idx)
|
||||||
else:
|
|
||||||
found = False
|
|
||||||
try:
|
|
||||||
prefix = f"image_{idx}."
|
|
||||||
for fname in os.listdir(model_folder):
|
|
||||||
if fname.startswith(prefix):
|
|
||||||
found = True
|
|
||||||
break
|
|
||||||
except OSError:
|
|
||||||
stale.append(idx)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not found:
|
|
||||||
stale.append(idx)
|
|
||||||
|
|
||||||
if stale:
|
if stale:
|
||||||
for idx in reversed(stale):
|
for idx in reversed(stale):
|
||||||
|
|||||||
@@ -3,9 +3,16 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import json
|
import json
|
||||||
|
import shutil
|
||||||
from ..services.settings_manager import get_settings_manager
|
from ..services.settings_manager import get_settings_manager
|
||||||
from ..services.service_registry import ServiceRegistry
|
from ..services.service_registry import ServiceRegistry
|
||||||
from ..utils.example_images_paths import iter_library_roots
|
from ..utils.example_images_paths import (
|
||||||
|
get_example_images_root,
|
||||||
|
is_hash_folder,
|
||||||
|
iter_library_roots,
|
||||||
|
uses_library_scoped_folders,
|
||||||
|
_library_folder_has_only_hash_dirs,
|
||||||
|
)
|
||||||
from ..utils.metadata_manager import MetadataManager
|
from ..utils.metadata_manager import MetadataManager
|
||||||
from ..utils.example_images_processor import ExampleImagesProcessor
|
from ..utils.example_images_processor import ExampleImagesProcessor
|
||||||
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
|
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
|
||||||
@@ -36,6 +43,90 @@ settings = _SettingsProxy()
|
|||||||
class ExampleImagesMigration:
|
class ExampleImagesMigration:
|
||||||
"""Handles migrations for example images naming conventions"""
|
"""Handles migrations for example images naming conventions"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _consolidate_library_folders():
|
||||||
|
"""Move hash folders from library-named subdirectories back to root.
|
||||||
|
|
||||||
|
When a user switches from multi-library mode back to single-library
|
||||||
|
mode, example images previously stored under e.g.
|
||||||
|
``<root>/default/<hash>/`` need to be moved back to
|
||||||
|
``<root>/<hash>/``. Running this once at startup removes the need
|
||||||
|
for ``get_model_folder()`` to perform directory scans on every
|
||||||
|
request.
|
||||||
|
"""
|
||||||
|
if uses_library_scoped_folders():
|
||||||
|
return
|
||||||
|
|
||||||
|
root = get_example_images_root()
|
||||||
|
if not root or not os.path.isdir(root):
|
||||||
|
return
|
||||||
|
|
||||||
|
moved: list[str] = []
|
||||||
|
cleaned: list[str] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
for entry in os.listdir(root):
|
||||||
|
# Fast regex checks first — no filesystem I/O.
|
||||||
|
if is_hash_folder(entry) or entry == "_deleted":
|
||||||
|
continue
|
||||||
|
|
||||||
|
entry_path = os.path.join(root, entry)
|
||||||
|
if not os.path.isdir(entry_path):
|
||||||
|
continue
|
||||||
|
if not _library_folder_has_only_hash_dirs(entry_path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
for hash_entry in os.listdir(entry_path):
|
||||||
|
hash_path = os.path.join(entry_path, hash_entry)
|
||||||
|
if not os.path.isdir(hash_path) or not is_hash_folder(hash_entry):
|
||||||
|
continue
|
||||||
|
target = os.path.join(root, hash_entry)
|
||||||
|
if not os.path.exists(target):
|
||||||
|
try:
|
||||||
|
shutil.move(hash_path, target)
|
||||||
|
moved.append(hash_entry)
|
||||||
|
except (OSError, shutil.Error) as exc:
|
||||||
|
logger.error(
|
||||||
|
"Failed to move '%s' → '%s': %s",
|
||||||
|
hash_path, target, exc,
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error(
|
||||||
|
"Failed to list library subdirectory '%s': %s",
|
||||||
|
entry_path, exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
remaining = os.listdir(entry_path)
|
||||||
|
except OSError:
|
||||||
|
remaining = []
|
||||||
|
if not remaining:
|
||||||
|
try:
|
||||||
|
os.rmdir(entry_path)
|
||||||
|
cleaned.append(entry)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.debug(
|
||||||
|
"Could not remove empty library dir '%s': %s",
|
||||||
|
entry_path, exc,
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error(
|
||||||
|
"Failed to list example images root during consolidation: %s",
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
if moved:
|
||||||
|
logger.info(
|
||||||
|
"Consolidated %d example image folder(s) to root",
|
||||||
|
len(moved),
|
||||||
|
)
|
||||||
|
if cleaned:
|
||||||
|
logger.info(
|
||||||
|
"Removed %d empty library directories",
|
||||||
|
len(cleaned),
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def check_and_run_migrations():
|
async def check_and_run_migrations():
|
||||||
"""Check if migrations are needed and run them in background"""
|
"""Check if migrations are needed and run them in background"""
|
||||||
@@ -44,6 +135,10 @@ class ExampleImagesMigration:
|
|||||||
logger.debug("No example images path configured or path doesn't exist, skipping migrations")
|
logger.debug("No example images path configured or path doesn't exist, skipping migrations")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Run library-to-root consolidation once at startup so the hot
|
||||||
|
# path (get_model_folder) stays a pure-path computation.
|
||||||
|
ExampleImagesMigration._consolidate_library_folders()
|
||||||
|
|
||||||
for library_name, library_path in iter_library_roots():
|
for library_name, library_path in iter_library_roots():
|
||||||
if not library_path or not os.path.exists(library_path):
|
if not library_path or not os.path.exists(library_path):
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -83,7 +83,12 @@ def ensure_library_root_exists(library_name: Optional[str] = None) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def get_model_folder(model_hash: str, library_name: Optional[str] = None) -> str:
|
def get_model_folder(model_hash: str, library_name: Optional[str] = None) -> str:
|
||||||
"""Return the folder path for a model's example images."""
|
"""Return the folder path for a model's example images.
|
||||||
|
|
||||||
|
Multi-library ↔ single-library consolidation is handled once at startup by
|
||||||
|
``ExampleImagesMigration._consolidate_library_folders`` — this function is a
|
||||||
|
pure path computation on the hot path (no directory scans).
|
||||||
|
"""
|
||||||
|
|
||||||
if not model_hash:
|
if not model_hash:
|
||||||
return ""
|
return ""
|
||||||
@@ -113,35 +118,6 @@ def get_model_folder(model_hash: str, library_name: Optional[str] = None) -> str
|
|||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
return legacy_folder
|
return legacy_folder
|
||||||
elif not os.path.exists(resolved_folder):
|
|
||||||
# Reverse migration: when consolidating from multi-library to
|
|
||||||
# single-library mode (e.g. after "default" was cleaned up), look
|
|
||||||
# for existing example images inside library-named subdirectories
|
|
||||||
# and bring them back to the root level.
|
|
||||||
root = get_example_images_root()
|
|
||||||
if root:
|
|
||||||
try:
|
|
||||||
for entry in os.listdir(root):
|
|
||||||
entry_path = os.path.join(root, entry)
|
|
||||||
if not os.path.isdir(entry_path):
|
|
||||||
continue
|
|
||||||
if is_hash_folder(entry) or entry == "_deleted":
|
|
||||||
continue
|
|
||||||
if not _library_folder_has_only_hash_dirs(entry_path):
|
|
||||||
continue
|
|
||||||
legacy = os.path.join(entry_path, normalized_hash)
|
|
||||||
if os.path.exists(legacy):
|
|
||||||
shutil.move(legacy, resolved_folder)
|
|
||||||
logger.info(
|
|
||||||
"Consolidated example images from '%s' to '%s'",
|
|
||||||
legacy, resolved_folder,
|
|
||||||
)
|
|
||||||
break
|
|
||||||
except OSError as exc:
|
|
||||||
logger.error(
|
|
||||||
"Failed to consolidate example images during "
|
|
||||||
"library merge: %s", exc,
|
|
||||||
)
|
|
||||||
|
|
||||||
return resolved_folder
|
return resolved_folder
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "comfyui-lora-manager"
|
name = "comfyui-lora-manager"
|
||||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||||
version = "1.1.9"
|
version = "1.2.0"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ body.modal-open {
|
|||||||
.support-section,
|
.support-section,
|
||||||
.changelog-section,
|
.changelog-section,
|
||||||
.update-info,
|
.update-info,
|
||||||
|
.update-channels,
|
||||||
.info-item,
|
.info-item,
|
||||||
.path-preview {
|
.path-preview {
|
||||||
background: var(--surface-subtle);
|
background: var(--surface-subtle);
|
||||||
|
|||||||
@@ -93,15 +93,13 @@
|
|||||||
.update-content {
|
.update-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-3);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.update-info {
|
.update-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
padding: var(--space-3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.update-info .version-info {
|
.update-info .version-info {
|
||||||
@@ -175,7 +173,6 @@
|
|||||||
border: 1px solid var(--lora-border);
|
border: 1px solid var(--lora-border);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
padding: var(--space-2);
|
padding: var(--space-2);
|
||||||
margin: var(--space-2) 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .update-progress {
|
[data-theme="dark"] .update-progress {
|
||||||
@@ -233,11 +230,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Changelog section */
|
/* Changelog section */
|
||||||
.changelog-section {
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
padding: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.changelog-section h3 {
|
.changelog-section h3 {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
@@ -349,6 +341,131 @@
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Channel Toggle */
|
||||||
|
.update-channels {
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-label {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
background: var(--lora-surface);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 3px;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary, #999);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-btn:hover {
|
||||||
|
color: var(--text-primary, #ddd);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-btn.active {
|
||||||
|
background: var(--lora-accent, #4285F4);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-btn.active i {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-btn i {
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Channel Switch Confirmation Overlay */
|
||||||
|
.channel-switch-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10000;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-switch-dialog {
|
||||||
|
background: var(--lora-surface);
|
||||||
|
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 28px 32px;
|
||||||
|
max-width: 420px;
|
||||||
|
width: 90%;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-switch-dialog h3 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 1.1em;
|
||||||
|
color: var(--text-primary, #eee);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-switch-dialog p {
|
||||||
|
margin: 0 0 24px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text-secondary, #aaa);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-switch-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-switch-cancel {
|
||||||
|
padding: 8px 18px;
|
||||||
|
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary, #aaa);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-switch-cancel:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-switch-confirm {
|
||||||
|
padding: 8px 18px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--lora-accent, #4285F4);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-switch-confirm:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
/* Update preferences section */
|
/* Update preferences section */
|
||||||
.update-preferences {
|
.update-preferences {
|
||||||
border-top: 1px solid var(--lora-border);
|
border-top: 1px solid var(--lora-border);
|
||||||
|
|||||||
@@ -260,8 +260,9 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
strength: lora.strength || 1.0,
|
strength: lora.strength || 1.0,
|
||||||
|
|
||||||
// Model identifiers
|
// Model identifiers
|
||||||
|
modelId: lora.modelId || lora.model_id || civitaiInfo.modelId,
|
||||||
hash: modelFile?.hashes?.SHA256?.toLowerCase() || lora.hash,
|
hash: modelFile?.hashes?.SHA256?.toLowerCase() || lora.hash,
|
||||||
modelVersionId: civitaiInfo.id || lora.modelVersionId,
|
id: civitaiInfo.id || lora.modelVersionId,
|
||||||
|
|
||||||
// Metadata
|
// Metadata
|
||||||
thumbnailUrl: civitaiInfo.images?.[0]?.url || '',
|
thumbnailUrl: civitaiInfo.images?.[0]?.url || '',
|
||||||
|
|||||||
@@ -1421,6 +1421,7 @@ class RecipeModal {
|
|||||||
strength: lora.strength || 1.0,
|
strength: lora.strength || 1.0,
|
||||||
|
|
||||||
// Model identifiers
|
// Model identifiers
|
||||||
|
modelId: lora.modelId || lora.model_id || civitaiInfo.modelId,
|
||||||
hash: modelFile?.hashes?.SHA256?.toLowerCase() || lora.hash,
|
hash: modelFile?.hashes?.SHA256?.toLowerCase() || lora.hash,
|
||||||
id: civitaiInfo.id || lora.modelVersionId,
|
id: civitaiInfo.id || lora.modelVersionId,
|
||||||
|
|
||||||
|
|||||||
@@ -489,6 +489,12 @@ export function createModelCard(model, modelType) {
|
|||||||
const modelId = civitaiData?.modelId ?? civitaiData?.model_id;
|
const modelId = civitaiData?.modelId ?? civitaiData?.model_id;
|
||||||
if (modelId !== undefined && modelId !== null && modelId !== '') {
|
if (modelId !== undefined && modelId !== null && modelId !== '') {
|
||||||
card.dataset.modelId = modelId;
|
card.dataset.modelId = modelId;
|
||||||
|
} else if (model.hf_url) {
|
||||||
|
// For HF-only models, derive a group key from hf_url for version grouping
|
||||||
|
const match = model.hf_url.match(/https?:\/\/huggingface\.co\/([^/]+\/[^/]+)/);
|
||||||
|
if (match) {
|
||||||
|
card.dataset.modelId = 'hf:' + match[1];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoRA specific data
|
// LoRA specific data
|
||||||
|
|||||||
@@ -473,7 +473,14 @@ export async function showModelModal(model, modelType) {
|
|||||||
const loadingExamplesText = translate('modals.model.loading.examples', {}, 'Loading examples...');
|
const loadingExamplesText = translate('modals.model.loading.examples', {}, 'Loading examples...');
|
||||||
|
|
||||||
const loadingVersionsText = translate('modals.model.loading.versions', {}, 'Loading versions...');
|
const loadingVersionsText = translate('modals.model.loading.versions', {}, 'Loading versions...');
|
||||||
const civitaiModelId = modelWithFullData.civitai?.modelId || '';
|
// Use CivitAI modelId, or derive HF group key for HF-only models
|
||||||
|
let civitaiModelId = modelWithFullData.civitai?.modelId || '';
|
||||||
|
if (!civitaiModelId && modelWithFullData.hf_url) {
|
||||||
|
const match = modelWithFullData.hf_url.match(/https?:\/\/huggingface\.co\/([^/]+\/[^/]+)/);
|
||||||
|
if (match) {
|
||||||
|
civitaiModelId = 'hf:' + match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
const civitaiVersionId = modelWithFullData.civitai?.id || '';
|
const civitaiVersionId = modelWithFullData.civitai?.id || '';
|
||||||
const navAriaLabel = translate('modals.model.navigation.label', {}, 'Model navigation');
|
const navAriaLabel = translate('modals.model.navigation.label', {}, 'Model navigation');
|
||||||
const previousTitle = translate('modals.model.navigation.previousWithShortcut', {}, 'Previous model (←)');
|
const previousTitle = translate('modals.model.navigation.previousWithShortcut', {}, 'Previous model (←)');
|
||||||
|
|||||||
@@ -950,6 +950,26 @@ export function initVersionsTab({
|
|||||||
renderErrorState(container, translate('modals.model.versions.missingModelId', {}, 'This model is missing a Civitai model id.'));
|
renderErrorState(container, translate('modals.model.versions.missingModelId', {}, 'This model is missing a Civitai model id.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// HF group keys (e.g. "hf:user/repo") are not real CivitAI model IDs —
|
||||||
|
// skip the remote API call and show a helpful message instead.
|
||||||
|
const isHfGroupKey = typeof modelId === 'string' && modelId.startsWith('hf:');
|
||||||
|
if (isHfGroupKey) {
|
||||||
|
controller.isLoading = false;
|
||||||
|
controller.hasLoaded = true;
|
||||||
|
controller.record = null;
|
||||||
|
const hfMsg = translate(
|
||||||
|
'modals.model.versions.hfGroupInfo',
|
||||||
|
{},
|
||||||
|
'This is a HuggingFace model group. Open the library to see all versions in the grid.'
|
||||||
|
);
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="versions-empty-state">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
<p>${escapeHtml(hfMsg)}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (controller.hasLoaded && !forceRefresh) {
|
if (controller.hasLoaded && !forceRefresh) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export class BulkManager {
|
|||||||
|
|
||||||
// Drag detection properties
|
// Drag detection properties
|
||||||
this.dragThreshold = 5; // Pixels to move before considering it a drag
|
this.dragThreshold = 5; // Pixels to move before considering it a drag
|
||||||
|
this.dragDelayMs = 100; // Minimum hold time before a drag is treated as a marquee
|
||||||
|
this.minMarqueeSize = 10; // Minimum drag box (px) before a marquee counts as a selection
|
||||||
this.mouseDownTime = 0;
|
this.mouseDownTime = 0;
|
||||||
this.mouseDownPosition = { x: 0, y: 0 };
|
this.mouseDownPosition = { x: 0, y: 0 };
|
||||||
|
|
||||||
@@ -173,6 +175,19 @@ export class BulkManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
eventManager.addHandler('mousemove', 'bulkManager-marquee-move', (e) => {
|
eventManager.addHandler('mousemove', 'bulkManager-marquee-move', (e) => {
|
||||||
|
// Only track marquee/drag while the left button is physically held.
|
||||||
|
// mouseup can be missed (release outside the window, focus loss, driver quirks),
|
||||||
|
// so mousemove must verify the button state itself instead of relying on it.
|
||||||
|
if (!(e.buttons & 1)) {
|
||||||
|
if (this.isMarqueeActive) {
|
||||||
|
this.endMarqueeSelection(e);
|
||||||
|
} else {
|
||||||
|
this.mouseDownTime = 0;
|
||||||
|
this.isDragging = false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.isMarqueeActive) {
|
if (this.isMarqueeActive) {
|
||||||
this.lastClientX = e.clientX;
|
this.lastClientX = e.clientX;
|
||||||
this.lastClientY = e.clientY;
|
this.lastClientY = e.clientY;
|
||||||
@@ -184,7 +199,10 @@ export class BulkManager {
|
|||||||
const dy = e.clientY - this.mouseDownPosition.y;
|
const dy = e.clientY - this.mouseDownPosition.y;
|
||||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
if (distance >= this.dragThreshold) {
|
// Require both enough movement AND enough hold time so quick
|
||||||
|
// click jitter from micro-movement input devices is not a marquee.
|
||||||
|
const heldTime = Date.now() - this.mouseDownTime;
|
||||||
|
if (heldTime >= this.dragDelayMs && distance >= this.dragThreshold) {
|
||||||
this.isDragging = true;
|
this.isDragging = true;
|
||||||
this.startMarqueeSelection(e, true);
|
this.startMarqueeSelection(e, true);
|
||||||
}
|
}
|
||||||
@@ -1958,9 +1976,31 @@ export class BulkManager {
|
|||||||
// Remove visual feedback class
|
// Remove visual feedback class
|
||||||
document.body.classList.remove('marquee-selecting');
|
document.body.classList.remove('marquee-selecting');
|
||||||
|
|
||||||
|
// Compute the actual drag box size in document coordinates, matching how
|
||||||
|
// updateMarqueeSelectionFromPosition tracks the rectangle. Client-space
|
||||||
|
// size would wrongly flag auto-scroll marquees (tiny pointer movement,
|
||||||
|
// large document-space box) as accidental clicks.
|
||||||
|
const container = document.querySelector('.page-content');
|
||||||
|
const scrollX = container?.scrollLeft || 0;
|
||||||
|
const scrollY = container?.scrollTop || 0;
|
||||||
|
const dragWidth = Math.abs((e.clientX + scrollX) - this.marqueeStartDoc.x);
|
||||||
|
const dragHeight = Math.abs((e.clientY + scrollY) - this.marqueeStartDoc.y);
|
||||||
|
const isTinyMarquee = dragWidth < this.minMarqueeSize && dragHeight < this.minMarqueeSize;
|
||||||
|
|
||||||
// Get selection count
|
// Get selection count
|
||||||
const selectionCount = state.selectedModels.size;
|
const selectionCount = state.selectedModels.size;
|
||||||
|
|
||||||
|
// A tiny box (e.g. click jitter that happened to graze a card) is treated
|
||||||
|
// as an accidental click: undo any selection and leave bulk mode.
|
||||||
|
if (isTinyMarquee) {
|
||||||
|
this.clearSelection();
|
||||||
|
if (state.bulkMode) {
|
||||||
|
this.toggleBulkMode();
|
||||||
|
}
|
||||||
|
this.initialSelectedModels.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If no models were selected, exit bulk mode
|
// If no models were selected, exit bulk mode
|
||||||
if (selectionCount === 0) {
|
if (selectionCount === 0) {
|
||||||
if (state.bulkMode) {
|
if (state.bulkMode) {
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ export class DownloadManager {
|
|||||||
this.modelVersionId = null;
|
this.modelVersionId = null;
|
||||||
this.source = null;
|
this.source = null;
|
||||||
this.selectedFile = null;
|
this.selectedFile = null;
|
||||||
|
this._isDiffusionModel = false;
|
||||||
|
|
||||||
this.selectedFolder = '';
|
this.selectedFolder = '';
|
||||||
this.batchModels = [];
|
this.batchModels = [];
|
||||||
@@ -787,24 +788,40 @@ export class DownloadManager {
|
|||||||
async proceedToLocationContent() {
|
async proceedToLocationContent() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch model roots
|
const _isDiffusionModel = this.selectedFile
|
||||||
const rootsData = await this.apiClient.fetchModelRoots();
|
? (this.selectedFile.type === 'UNet' || this.selectedFile.type === 'Diffusion Model')
|
||||||
|
: (this.currentVersion?.files || []).some(
|
||||||
|
f => f.type === 'UNet' || f.type === 'Diffusion Model'
|
||||||
|
);
|
||||||
|
this._isDiffusionModel = _isDiffusionModel;
|
||||||
|
|
||||||
|
let rootsData;
|
||||||
|
if (this._isDiffusionModel && this.apiClient.modelType === 'checkpoints') {
|
||||||
|
rootsData = await this.apiClient.fetchModelRoots('diffusion_model');
|
||||||
|
} else {
|
||||||
|
rootsData = await this.apiClient.fetchModelRoots();
|
||||||
|
}
|
||||||
const modelRoot = document.getElementById('modelRoot');
|
const modelRoot = document.getElementById('modelRoot');
|
||||||
modelRoot.innerHTML = rootsData.roots.map(root =>
|
modelRoot.innerHTML = rootsData.roots.map(root =>
|
||||||
`<option value="${root}">${root}</option>`
|
`<option value="${root}">${root}</option>`
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
// Set default root if available
|
const singularType = this._isDiffusionModel
|
||||||
const singularType = this.apiClient.modelType.replace(/s$/, '');
|
? 'unet'
|
||||||
|
: this.apiClient.modelType.replace(/s$/, '');
|
||||||
const defaultRootKey = `default_${singularType}_root`;
|
const defaultRootKey = `default_${singularType}_root`;
|
||||||
const defaultRoot = state.global.settings[defaultRootKey];
|
const defaultRoot = state.global.settings[defaultRootKey];
|
||||||
console.log(`Default root for ${this.apiClient.modelType}:`, defaultRoot);
|
console.log(`Default root for ${singularType}:`, defaultRoot);
|
||||||
console.log('Available roots:', rootsData.roots);
|
console.log('Available roots:', rootsData.roots);
|
||||||
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
||||||
console.log(`Setting default root: ${defaultRoot}`);
|
console.log(`Setting default root: ${defaultRoot}`);
|
||||||
modelRoot.value = defaultRoot;
|
modelRoot.value = defaultRoot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const subtypeDisplay = this._isDiffusionModel ? 'Diffusion Model' : this.apiClient.apiConfig.config.displayName;
|
||||||
|
document.getElementById('modelRootLabel').textContent =
|
||||||
|
translate('modals.download.selectTypeRoot', { type: subtypeDisplay });
|
||||||
|
|
||||||
// Set autocomplete="off" on folderPath input
|
// Set autocomplete="off" on folderPath input
|
||||||
const folderPathInput = document.getElementById('folderPath');
|
const folderPathInput = document.getElementById('folderPath');
|
||||||
if (folderPathInput) {
|
if (folderPathInput) {
|
||||||
@@ -1776,13 +1793,15 @@ export class DownloadManager {
|
|||||||
const modelRoot = document.getElementById('modelRoot').value;
|
const modelRoot = document.getElementById('modelRoot').value;
|
||||||
const config = this.apiClient.apiConfig.config;
|
const config = this.apiClient.apiConfig.config;
|
||||||
|
|
||||||
let fullPath = modelRoot || translate('modals.download.selectTypeRoot', { type: config.displayName });
|
const subtypeDisplay = this._isDiffusionModel ? 'Diffusion Model' : config.displayName;
|
||||||
|
let fullPath = modelRoot || translate('modals.download.selectTypeRoot', { type: subtypeDisplay });
|
||||||
|
|
||||||
if (modelRoot) {
|
if (modelRoot) {
|
||||||
if (this.useDefaultPath) {
|
if (this.useDefaultPath) {
|
||||||
// Show actual template path
|
|
||||||
try {
|
try {
|
||||||
const singularType = this.apiClient.modelType.replace(/s$/, '');
|
const singularType = this._isDiffusionModel
|
||||||
|
? 'unet'
|
||||||
|
: this.apiClient.modelType.replace(/s$/, '');
|
||||||
const templates = state.global.settings.download_path_templates;
|
const templates = state.global.settings.download_path_templates;
|
||||||
const template = templates[singularType];
|
const template = templates[singularType];
|
||||||
fullPath += `/${template}`;
|
fullPath += `/${template}`;
|
||||||
|
|||||||
@@ -729,10 +729,12 @@ export class FilterManager {
|
|||||||
const pageState = getCurrentPageState();
|
const pageState = getCurrentPageState();
|
||||||
const storageKey = `${this.currentPage}_filters`;
|
const storageKey = `${this.currentPage}_filters`;
|
||||||
|
|
||||||
// Save filters to localStorage (exclude EMPTY_WILDCARD_MARKER)
|
// Save filters to localStorage (exclude EMPTY_WILDCARD_MARKER and transient search)
|
||||||
const filtersSnapshot = this.cloneFilters();
|
const filtersSnapshot = this.cloneFilters();
|
||||||
// Don't persist EMPTY_WILDCARD_MARKER - it's a runtime-only marker
|
// Don't persist EMPTY_WILDCARD_MARKER - it's a runtime-only marker
|
||||||
filtersSnapshot.baseModel = filtersSnapshot.baseModel.filter(m => m !== EMPTY_WILDCARD_MARKER);
|
filtersSnapshot.baseModel = filtersSnapshot.baseModel.filter(m => m !== EMPTY_WILDCARD_MARKER);
|
||||||
|
// Don't persist search - it's transient and managed by SearchManager
|
||||||
|
delete filtersSnapshot.search;
|
||||||
setStorageItem(storageKey, filtersSnapshot);
|
setStorageItem(storageKey, filtersSnapshot);
|
||||||
|
|
||||||
// Update state with current filters
|
// Update state with current filters
|
||||||
@@ -984,6 +986,7 @@ export class FilterManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cloneFilters() {
|
cloneFilters() {
|
||||||
|
const pageState = getCurrentPageState();
|
||||||
return {
|
return {
|
||||||
...this.filters,
|
...this.filters,
|
||||||
baseModel: [...(this.filters.baseModel || [])],
|
baseModel: [...(this.filters.baseModel || [])],
|
||||||
@@ -991,7 +994,8 @@ export class FilterManager {
|
|||||||
autoTags: { ...(this.filters.autoTags || {}) },
|
autoTags: { ...(this.filters.autoTags || {}) },
|
||||||
license: { ...(this.filters.license || {}) },
|
license: { ...(this.filters.license || {}) },
|
||||||
modelTypes: [...(this.filters.modelTypes || [])],
|
modelTypes: [...(this.filters.modelTypes || [])],
|
||||||
tagLogic: this.filters.tagLogic || 'any'
|
tagLogic: this.filters.tagLogic || 'any',
|
||||||
|
search: pageState?.filters?.search ?? ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1517,11 +1517,20 @@ export class SettingsManager {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadLoraRoots() {
|
showNoRootsPlaceholder(select) {
|
||||||
try {
|
select.innerHTML = '';
|
||||||
const defaultLoraRootSelect = document.getElementById('defaultLoraRoot');
|
const option = document.createElement('option');
|
||||||
if (!defaultLoraRootSelect) return;
|
option.value = '';
|
||||||
|
option.textContent = translate('settings.folderSettings.noDefault', {}, 'No Default');
|
||||||
|
select.appendChild(option);
|
||||||
|
select.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadLoraRoots() {
|
||||||
|
const defaultLoraRootSelect = document.getElementById('defaultLoraRoot');
|
||||||
|
if (!defaultLoraRootSelect) return;
|
||||||
|
|
||||||
|
try {
|
||||||
// Fetch lora roots
|
// Fetch lora roots
|
||||||
const response = await fetch('/api/lm/loras/roots');
|
const response = await fetch('/api/lm/loras/roots');
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -1530,10 +1539,12 @@ export class SettingsManager {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!data.roots || data.roots.length === 0) {
|
if (!data.roots || data.roots.length === 0) {
|
||||||
throw new Error('No LoRA roots found');
|
this.showNoRootsPlaceholder(defaultLoraRootSelect);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultLoraRootSelect.innerHTML = '';
|
defaultLoraRootSelect.innerHTML = '';
|
||||||
|
defaultLoraRootSelect.disabled = false;
|
||||||
|
|
||||||
// Add options for each root
|
// Add options for each root
|
||||||
data.roots.forEach(root => {
|
data.roots.forEach(root => {
|
||||||
@@ -1548,15 +1559,16 @@ export class SettingsManager {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading LoRA roots:', error);
|
console.error('Error loading LoRA roots:', error);
|
||||||
|
this.showNoRootsPlaceholder(defaultLoraRootSelect);
|
||||||
showToast('toast.settings.loraRootsFailed', { message: error.message }, 'error');
|
showToast('toast.settings.loraRootsFailed', { message: error.message }, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadCheckpointRoots() {
|
async loadCheckpointRoots() {
|
||||||
try {
|
const defaultCheckpointRootSelect = document.getElementById('defaultCheckpointRoot');
|
||||||
const defaultCheckpointRootSelect = document.getElementById('defaultCheckpointRoot');
|
if (!defaultCheckpointRootSelect) return;
|
||||||
if (!defaultCheckpointRootSelect) return;
|
|
||||||
|
|
||||||
|
try {
|
||||||
// Fetch checkpoint roots (checkpoint paths only, not unet)
|
// Fetch checkpoint roots (checkpoint paths only, not unet)
|
||||||
const response = await fetch('/api/lm/checkpoints/checkpoints_roots');
|
const response = await fetch('/api/lm/checkpoints/checkpoints_roots');
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -1565,10 +1577,12 @@ export class SettingsManager {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!data.roots || data.roots.length === 0) {
|
if (!data.roots || data.roots.length === 0) {
|
||||||
throw new Error('No checkpoint roots found');
|
this.showNoRootsPlaceholder(defaultCheckpointRootSelect);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultCheckpointRootSelect.innerHTML = '';
|
defaultCheckpointRootSelect.innerHTML = '';
|
||||||
|
defaultCheckpointRootSelect.disabled = false;
|
||||||
|
|
||||||
// Add options for each root
|
// Add options for each root
|
||||||
data.roots.forEach(root => {
|
data.roots.forEach(root => {
|
||||||
@@ -1583,15 +1597,16 @@ export class SettingsManager {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading checkpoint roots:', error);
|
console.error('Error loading checkpoint roots:', error);
|
||||||
|
this.showNoRootsPlaceholder(defaultCheckpointRootSelect);
|
||||||
showToast('toast.settings.checkpointRootsFailed', { message: error.message }, 'error');
|
showToast('toast.settings.checkpointRootsFailed', { message: error.message }, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadUnetRoots() {
|
async loadUnetRoots() {
|
||||||
try {
|
const defaultUnetRootSelect = document.getElementById('defaultUnetRoot');
|
||||||
const defaultUnetRootSelect = document.getElementById('defaultUnetRoot');
|
if (!defaultUnetRootSelect) return;
|
||||||
if (!defaultUnetRootSelect) return;
|
|
||||||
|
|
||||||
|
try {
|
||||||
// Fetch unet roots (diffusion model paths only)
|
// Fetch unet roots (diffusion model paths only)
|
||||||
const response = await fetch('/api/lm/checkpoints/unet_roots');
|
const response = await fetch('/api/lm/checkpoints/unet_roots');
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -1600,10 +1615,12 @@ export class SettingsManager {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!data.roots || data.roots.length === 0) {
|
if (!data.roots || data.roots.length === 0) {
|
||||||
throw new Error('No diffusion model roots found');
|
this.showNoRootsPlaceholder(defaultUnetRootSelect);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultUnetRootSelect.innerHTML = '';
|
defaultUnetRootSelect.innerHTML = '';
|
||||||
|
defaultUnetRootSelect.disabled = false;
|
||||||
|
|
||||||
// Add options for each root
|
// Add options for each root
|
||||||
data.roots.forEach(root => {
|
data.roots.forEach(root => {
|
||||||
@@ -1618,15 +1635,16 @@ export class SettingsManager {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading diffusion model roots:', error);
|
console.error('Error loading diffusion model roots:', error);
|
||||||
|
this.showNoRootsPlaceholder(defaultUnetRootSelect);
|
||||||
showToast('toast.settings.unetRootsFailed', { message: error.message }, 'error');
|
showToast('toast.settings.unetRootsFailed', { message: error.message }, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadEmbeddingRoots() {
|
async loadEmbeddingRoots() {
|
||||||
try {
|
const defaultEmbeddingRootSelect = document.getElementById('defaultEmbeddingRoot');
|
||||||
const defaultEmbeddingRootSelect = document.getElementById('defaultEmbeddingRoot');
|
if (!defaultEmbeddingRootSelect) return;
|
||||||
if (!defaultEmbeddingRootSelect) return;
|
|
||||||
|
|
||||||
|
try {
|
||||||
// Fetch embedding roots
|
// Fetch embedding roots
|
||||||
const response = await fetch('/api/lm/embeddings/roots');
|
const response = await fetch('/api/lm/embeddings/roots');
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -1635,10 +1653,12 @@ export class SettingsManager {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!data.roots || data.roots.length === 0) {
|
if (!data.roots || data.roots.length === 0) {
|
||||||
throw new Error('No embedding roots found');
|
this.showNoRootsPlaceholder(defaultEmbeddingRootSelect);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultEmbeddingRootSelect.innerHTML = '';
|
defaultEmbeddingRootSelect.innerHTML = '';
|
||||||
|
defaultEmbeddingRootSelect.disabled = false;
|
||||||
|
|
||||||
// Add options for each root
|
// Add options for each root
|
||||||
data.roots.forEach(root => {
|
data.roots.forEach(root => {
|
||||||
@@ -1653,6 +1673,7 @@ export class SettingsManager {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading embedding roots:', error);
|
console.error('Error loading embedding roots:', error);
|
||||||
|
this.showNoRootsPlaceholder(defaultEmbeddingRootSelect);
|
||||||
showToast('toast.settings.embeddingRootsFailed', { message: error.message }, 'error');
|
showToast('toast.settings.embeddingRootsFailed', { message: error.message }, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
setStoredVersionInfo,
|
setStoredVersionInfo,
|
||||||
isVersionMatch
|
isVersionMatch
|
||||||
} from '../utils/storageHelpers.js';
|
} from '../utils/storageHelpers.js';
|
||||||
|
import { state } from '../state/index.js';
|
||||||
import { bannerService } from './BannerService.js';
|
import { bannerService } from './BannerService.js';
|
||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
|
|
||||||
@@ -24,7 +25,11 @@ export class UpdateService {
|
|||||||
this.updateNotificationsEnabled = getStorageItem('show_update_notifications', true);
|
this.updateNotificationsEnabled = getStorageItem('show_update_notifications', true);
|
||||||
this.lastCheckTime = parseInt(getStorageItem('last_update_check') || '0');
|
this.lastCheckTime = parseInt(getStorageItem('last_update_check') || '0');
|
||||||
this.isUpdating = false;
|
this.isUpdating = false;
|
||||||
this.nightlyMode = getStorageItem('nightly_updates', false);
|
this.channelMode = null;
|
||||||
|
this.hasGit = false;
|
||||||
|
this.nightlyNotifyDate = getStorageItem('nightly_notify_date', '');
|
||||||
|
this.nightlyBadgeShown = false;
|
||||||
|
this.progressKeepVisible = false;
|
||||||
this.currentVersionInfo = null;
|
this.currentVersionInfo = null;
|
||||||
this.versionMismatch = false;
|
this.versionMismatch = false;
|
||||||
this.activeNotificationTab = 'updates';
|
this.activeNotificationTab = 'updates';
|
||||||
@@ -49,43 +54,180 @@ export class UpdateService {
|
|||||||
updateBtn.addEventListener('click', () => this.performUpdate());
|
updateBtn.addEventListener('click', () => this.performUpdate());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register event listener for nightly update toggle
|
this.wireChannelButtons();
|
||||||
const nightlyCheckbox = document.getElementById('nightlyUpdateToggle');
|
|
||||||
if (nightlyCheckbox) {
|
|
||||||
nightlyCheckbox.checked = this.nightlyMode;
|
|
||||||
nightlyCheckbox.addEventListener('change', (e) => {
|
|
||||||
this.nightlyMode = e.target.checked;
|
|
||||||
setStorageItem('nightly_updates', e.target.checked);
|
|
||||||
this.updateNightlyWarning();
|
|
||||||
this.updateModalContent();
|
|
||||||
// Re-check for updates when switching channels
|
|
||||||
this.manualCheckForUpdates();
|
|
||||||
});
|
|
||||||
this.updateNightlyWarning();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setupNotificationCenter();
|
this.setupNotificationCenter();
|
||||||
window.addEventListener('lm:banner-history-updated', this.handleBannerHistoryUpdated);
|
window.addEventListener('lm:banner-history-updated', this.handleBannerHistoryUpdated);
|
||||||
this.updateTabBadges();
|
this.updateTabBadges();
|
||||||
|
|
||||||
// Perform update check if needed
|
// Perform update check if needed
|
||||||
this.checkForUpdates().then(() => {
|
this.checkVersionInfo().then(() => {
|
||||||
// Ensure badges are updated after checking
|
this.checkForUpdates().then(() => {
|
||||||
this.updateBadgeVisibility();
|
this.updateBadgeVisibility();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Immediately update modal content with current values (even if from default)
|
|
||||||
this.updateModalContent();
|
this.updateModalContent();
|
||||||
|
|
||||||
// Check version info for mismatch after loading basic info
|
|
||||||
this.checkVersionInfo();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateNightlyWarning() {
|
wireChannelButtons() {
|
||||||
const warning = document.getElementById('nightlyWarning');
|
const releaseBtn = document.getElementById('channelRelease');
|
||||||
if (warning) {
|
const nightlyBtn = document.getElementById('channelNightly');
|
||||||
warning.style.display = this.nightlyMode ? 'flex' : 'none';
|
if (releaseBtn) {
|
||||||
|
releaseBtn.addEventListener('click', () => this.switchChannel('release'));
|
||||||
}
|
}
|
||||||
|
if (nightlyBtn) {
|
||||||
|
nightlyBtn.addEventListener('click', () => this.switchChannel('nightly'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async switchChannel(channel) {
|
||||||
|
if (channel === this.channelMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.isUpdating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.hasGit && channel === 'nightly') {
|
||||||
|
const confirmed = await this._confirmChannelSwitch(
|
||||||
|
'update.channelSwitch.nightlyTitle',
|
||||||
|
'update.channelSwitch.nightlyMessage'
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
|
if (this.hasGit && channel === 'release') {
|
||||||
|
const confirmed = await this._confirmChannelSwitch(
|
||||||
|
'update.channelSwitch.releaseTitle',
|
||||||
|
'update.channelSwitch.releaseMessage'
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.isUpdating = true;
|
||||||
|
this.showUpdateProgress(true);
|
||||||
|
this.updateProgress(10, translate('update.channelSwitch.switching', { channel }));
|
||||||
|
|
||||||
|
const response = await fetch('/api/lm/switch-channel', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ channel })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.channelMode = channel;
|
||||||
|
// Persist channel preference to settings.json
|
||||||
|
fetch('/api/lm/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ update_channel: channel })
|
||||||
|
}).then(r => {
|
||||||
|
if (!r.ok) console.warn('Failed to persist update channel:', r.status);
|
||||||
|
}).catch(e => console.warn('Failed to persist update channel:', e));
|
||||||
|
await this.checkForUpdates({ force: true });
|
||||||
|
this.updateModalContent();
|
||||||
|
this.updateChannelUI();
|
||||||
|
this._showSwitchCompleteMessage(data.new_version);
|
||||||
|
this.progressKeepVisible = true;
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || translate('update.channelSwitch.failed'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Channel switch failed:', error);
|
||||||
|
this.updateProgress(0, translate('update.channelSwitch.failed'));
|
||||||
|
} finally {
|
||||||
|
if (this.progressKeepVisible) {
|
||||||
|
this.isUpdating = false;
|
||||||
|
this.progressKeepVisible = false;
|
||||||
|
} else {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.showUpdateProgress(false);
|
||||||
|
this.isUpdating = false;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateChannelUI() {
|
||||||
|
const releaseBtn = document.getElementById('channelRelease');
|
||||||
|
const nightlyBtn = document.getElementById('channelNightly');
|
||||||
|
|
||||||
|
if (releaseBtn) {
|
||||||
|
releaseBtn.classList.toggle('active', this.channelMode === 'release');
|
||||||
|
}
|
||||||
|
if (nightlyBtn) {
|
||||||
|
nightlyBtn.classList.toggle('active', this.channelMode === 'nightly');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_resolveChannelFromSettings() {
|
||||||
|
const stored = state?.global?.settings?.update_channel;
|
||||||
|
if (stored === 'nightly' || stored === 'release') {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
if (!this.hasGit) {
|
||||||
|
return 'release';
|
||||||
|
}
|
||||||
|
if (this.gitInfo?.branch === 'detached') {
|
||||||
|
return 'release';
|
||||||
|
}
|
||||||
|
return 'nightly';
|
||||||
|
}
|
||||||
|
|
||||||
|
async _confirmChannelSwitch(titleKey, messageKey) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const title = translate(titleKey);
|
||||||
|
const message = translate(messageKey);
|
||||||
|
const cancelText = translate('common.cancel');
|
||||||
|
const confirmText = translate('common.confirm');
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'channel-switch-overlay';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="channel-switch-dialog">
|
||||||
|
<h3>${title}</h3>
|
||||||
|
<p>${message}</p>
|
||||||
|
<div class="channel-switch-actions">
|
||||||
|
<button class="secondary-btn channel-switch-cancel">${cancelText}</button>
|
||||||
|
<button class="primary-btn channel-switch-confirm">${confirmText}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const dismiss = (result) => {
|
||||||
|
document.removeEventListener('keydown', onKeydown);
|
||||||
|
overlay.remove();
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeydown = (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
dismiss(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', onKeydown, { capture: true });
|
||||||
|
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === overlay) {
|
||||||
|
dismiss(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay.querySelector('.channel-switch-cancel').addEventListener('click', () => {
|
||||||
|
dismiss(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay.querySelector('.channel-switch-confirm').addEventListener('click', () => {
|
||||||
|
dismiss(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setupNotificationCenter() {
|
setupNotificationCenter() {
|
||||||
@@ -355,6 +497,18 @@ export class UpdateService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async checkForUpdates({ force = false } = {}) {
|
async checkForUpdates({ force = false } = {}) {
|
||||||
|
let needsMigration = false;
|
||||||
|
if (this.channelMode === null) {
|
||||||
|
const stored = state?.global?.settings?.update_channel;
|
||||||
|
if (stored === 'nightly' || stored === 'release') {
|
||||||
|
this.channelMode = stored;
|
||||||
|
} else if (!this.hasGit) {
|
||||||
|
this.channelMode = 'release';
|
||||||
|
needsMigration = true;
|
||||||
|
}
|
||||||
|
// hasGit=true with no stored value: wait for gitInfo.branch
|
||||||
|
}
|
||||||
|
|
||||||
if (!force && !this.updateNotificationsEnabled) {
|
if (!force && !this.updateNotificationsEnabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -373,7 +527,8 @@ export class UpdateService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Call backend API to check for updates with nightly flag
|
// Call backend API to check for updates with nightly flag
|
||||||
const response = await fetch(`/api/lm/check-updates?nightly=${this.nightlyMode}`);
|
const nightly = (this.channelMode ?? (this.hasGit ? 'nightly' : 'release')) === 'nightly';
|
||||||
|
const response = await fetch(`/api/lm/check-updates?nightly=${nightly}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
@@ -381,17 +536,35 @@ export class UpdateService {
|
|||||||
this.latestVersion = data.latest_version || "v0.0.0";
|
this.latestVersion = data.latest_version || "v0.0.0";
|
||||||
this.updateInfo = data;
|
this.updateInfo = data;
|
||||||
this.gitInfo = data.git_info || this.gitInfo;
|
this.gitInfo = data.git_info || this.gitInfo;
|
||||||
|
this.hasGit = data.has_git || false;
|
||||||
|
|
||||||
// Explicitly set update availability based on version comparison
|
if (needsMigration || this.channelMode === null) {
|
||||||
this.updateAvailable = this.isNewerVersion(this.latestVersion, this.currentVersion);
|
this.channelMode = this._resolveChannelFromSettings();
|
||||||
|
if (state?.global?.settings) {
|
||||||
|
state.global.settings.update_channel = this.channelMode;
|
||||||
|
}
|
||||||
|
fetch('/api/lm/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ update_channel: this.channelMode })
|
||||||
|
}).then(r => {
|
||||||
|
if (!r.ok) console.warn('Failed to persist update channel:', r.status);
|
||||||
|
}).catch(e => console.warn('Failed to persist update channel:', e));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateAvailable = data.update_available;
|
||||||
|
|
||||||
|
// Nightly channel: surface the update badge at most once per calendar day.
|
||||||
|
if (this.updateAvailable && this.channelMode === 'nightly' && this.nightlyNotifyDate !== this._getTodayKey()) {
|
||||||
|
this._markNightlyNotified();
|
||||||
|
}
|
||||||
|
|
||||||
// Update last check time
|
|
||||||
this.lastCheckTime = now;
|
this.lastCheckTime = now;
|
||||||
setStorageItem('last_update_check', now.toString());
|
setStorageItem('last_update_check', now.toString());
|
||||||
|
|
||||||
// Update UI
|
|
||||||
this.updateBadgeVisibility();
|
this.updateBadgeVisibility();
|
||||||
this.updateModalContent();
|
this.updateModalContent();
|
||||||
|
this.updateChannelUI();
|
||||||
|
|
||||||
console.log("Update check complete:", {
|
console.log("Update check complete:", {
|
||||||
currentVersion: this.currentVersion,
|
currentVersion: this.currentVersion,
|
||||||
@@ -436,6 +609,28 @@ export class UpdateService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_getTodayKey() {
|
||||||
|
const now = new Date();
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(now.getDate()).padStart(2, '0');
|
||||||
|
return `${now.getFullYear()}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isNightlyBadgeAllowed() {
|
||||||
|
if (this.channelMode !== 'nightly') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Keep the badge visible for the rest of the session once shown, but do
|
||||||
|
// not show it again on later sessions within the same calendar day.
|
||||||
|
return this.nightlyNotifyDate !== this._getTodayKey() || this.nightlyBadgeShown;
|
||||||
|
}
|
||||||
|
|
||||||
|
_markNightlyNotified() {
|
||||||
|
this.nightlyNotifyDate = this._getTodayKey();
|
||||||
|
this.nightlyBadgeShown = true;
|
||||||
|
setStorageItem('nightly_notify_date', this.nightlyNotifyDate);
|
||||||
|
}
|
||||||
|
|
||||||
updateBadgeVisibility() {
|
updateBadgeVisibility() {
|
||||||
const updateToggle = document.querySelector('.update-toggle');
|
const updateToggle = document.querySelector('.update-toggle');
|
||||||
const updateBadge = document.querySelector('.update-toggle .update-badge');
|
const updateBadge = document.querySelector('.update-toggle .update-badge');
|
||||||
@@ -443,9 +638,12 @@ export class UpdateService {
|
|||||||
? bannerService.getUnreadBannerCount()
|
? bannerService.getUnreadBannerCount()
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
|
// Force updating badges visibility based on current state
|
||||||
|
const shouldShowUpdate = this.updateNotificationsEnabled && this.updateAvailable && this._isNightlyBadgeAllowed();
|
||||||
|
|
||||||
if (updateToggle) {
|
if (updateToggle) {
|
||||||
let tooltipKey = 'header.actions.notifications';
|
let tooltipKey = 'header.actions.notifications';
|
||||||
if (this.updateNotificationsEnabled && this.updateAvailable) {
|
if (shouldShowUpdate) {
|
||||||
tooltipKey = 'update.updateAvailable';
|
tooltipKey = 'update.updateAvailable';
|
||||||
} else if (unreadBanners > 0) {
|
} else if (unreadBanners > 0) {
|
||||||
tooltipKey = 'update.tabs.messages';
|
tooltipKey = 'update.tabs.messages';
|
||||||
@@ -453,8 +651,6 @@ export class UpdateService {
|
|||||||
updateToggle.title = translate(tooltipKey);
|
updateToggle.title = translate(tooltipKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force updating badges visibility based on current state
|
|
||||||
const shouldShowUpdate = this.updateNotificationsEnabled && this.updateAvailable;
|
|
||||||
const shouldShow = shouldShowUpdate || unreadBanners > 0;
|
const shouldShow = shouldShowUpdate || unreadBanners > 0;
|
||||||
|
|
||||||
if (updateBadge) {
|
if (updateBadge) {
|
||||||
@@ -482,8 +678,31 @@ export class UpdateService {
|
|||||||
|
|
||||||
if (currentVersionEl) currentVersionEl.textContent = this.currentVersion;
|
if (currentVersionEl) currentVersionEl.textContent = this.currentVersion;
|
||||||
|
|
||||||
|
const newVersionLabel = modal.querySelector('.new-version .label');
|
||||||
|
if (newVersionLabel) {
|
||||||
|
newVersionLabel.textContent = (this.updateInfo?.nightly)
|
||||||
|
? `${translate('update.latestMain')}:`
|
||||||
|
: `${translate('update.newVersion')}:`;
|
||||||
|
}
|
||||||
|
|
||||||
if (newVersionEl) {
|
if (newVersionEl) {
|
||||||
newVersionEl.textContent = this.latestVersion;
|
if (this.updateInfo?.nightly) {
|
||||||
|
const behind = this.updateInfo.behind_by || 0;
|
||||||
|
const remoteHash = this.latestVersion.replace('main-', '');
|
||||||
|
const localHash = this.gitInfo.short_hash || '';
|
||||||
|
const date = this.updateInfo.commit_date || '';
|
||||||
|
const datePart = date ? ` · ${date}` : '';
|
||||||
|
|
||||||
|
if (behind > 0) {
|
||||||
|
newVersionEl.textContent = `${behind} commit${behind !== 1 ? 's' : ''} behind main (${remoteHash}${datePart})`;
|
||||||
|
} else if (localHash !== remoteHash) {
|
||||||
|
newVersionEl.textContent = `Behind main (${remoteHash}${datePart})`;
|
||||||
|
} else {
|
||||||
|
newVersionEl.textContent = `Up to date (${remoteHash}${datePart})`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newVersionEl.textContent = this.latestVersion;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update update button state
|
// Update update button state
|
||||||
@@ -599,8 +818,12 @@ export class UpdateService {
|
|||||||
// Update GitHub link to point to the specific release if available
|
// Update GitHub link to point to the specific release if available
|
||||||
const githubLink = modal.querySelector('.update-link');
|
const githubLink = modal.querySelector('.update-link');
|
||||||
if (githubLink && this.latestVersion) {
|
if (githubLink && this.latestVersion) {
|
||||||
const versionTag = this.latestVersion.replace(/^v/, '');
|
if (this.updateInfo?.nightly) {
|
||||||
githubLink.href = `https://github.com/willmiao/ComfyUI-Lora-Manager/releases/tag/v${versionTag}`;
|
githubLink.href = 'https://github.com/willmiao/ComfyUI-Lora-Manager/commits/main';
|
||||||
|
} else {
|
||||||
|
const versionTag = this.latestVersion.replace(/^v/, '');
|
||||||
|
githubLink.href = `https://github.com/willmiao/ComfyUI-Lora-Manager/releases/tag/v${versionTag}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,7 +846,7 @@ export class UpdateService {
|
|||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
nightly: this.nightlyMode
|
nightly: this.channelMode === 'nightly'
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -699,6 +922,25 @@ export class UpdateService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_showSwitchCompleteMessage(version) {
|
||||||
|
this.showUpdateProgress(true);
|
||||||
|
this.updateProgress(100, '');
|
||||||
|
const progressText = document.getElementById('updateProgressText');
|
||||||
|
if (progressText) {
|
||||||
|
progressText.innerHTML = `
|
||||||
|
<div style="text-align: center; color: var(--lora-success);">
|
||||||
|
<i class="fas fa-check-circle" style="margin-right: 8px;"></i>
|
||||||
|
${translate('update.completion.successMessage', { version })}
|
||||||
|
<br><br>
|
||||||
|
<div style="opacity: 0.95; color: var(--lora-error); font-size: 1em;">
|
||||||
|
${translate('update.completion.restartMessage')}<br>
|
||||||
|
${translate('update.completion.reloadMessage')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
showUpdateCompleteMessage(newVersion) {
|
showUpdateCompleteMessage(newVersion) {
|
||||||
const modal = document.getElementById('updateModal');
|
const modal = document.getElementById('updateModal');
|
||||||
if (!modal) return;
|
if (!modal) return;
|
||||||
@@ -771,6 +1013,7 @@ export class UpdateService {
|
|||||||
|
|
||||||
// Update the modal content immediately with current data
|
// Update the modal content immediately with current data
|
||||||
this.updateModalContent();
|
this.updateModalContent();
|
||||||
|
this.updateChannelUI();
|
||||||
this.renderRecentBanners();
|
this.renderRecentBanners();
|
||||||
|
|
||||||
// Show the modal with current data
|
// Show the modal with current data
|
||||||
@@ -801,8 +1044,8 @@ export class UpdateService {
|
|||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
this.currentVersionInfo = data.version;
|
this.currentVersionInfo = data.version;
|
||||||
|
this.hasGit = data.has_git || false;
|
||||||
|
|
||||||
// Check if version matches stored version
|
|
||||||
this.versionMismatch = !isVersionMatch(this.currentVersionInfo);
|
this.versionMismatch = !isVersionMatch(this.currentVersionInfo);
|
||||||
|
|
||||||
if (this.versionMismatch) {
|
if (this.versionMismatch) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { translate } from '../../utils/i18nHelpers.js';
|
|||||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||||
import { MODEL_TYPES } from '../../api/apiConfig.js';
|
import { MODEL_TYPES } from '../../api/apiConfig.js';
|
||||||
import { getStorageItem } from '../../utils/storageHelpers.js';
|
import { getStorageItem } from '../../utils/storageHelpers.js';
|
||||||
|
import { state } from '../../state/index.js';
|
||||||
|
|
||||||
export class DownloadManager {
|
export class DownloadManager {
|
||||||
constructor(importManager) {
|
constructor(importManager) {
|
||||||
@@ -125,11 +126,25 @@ export class DownloadManager {
|
|||||||
showToast('toast.recipes.nameSaved', { name: this.importManager.recipeName }, 'success');
|
showToast('toast.recipes.nameSaved', { name: this.importManager.recipeName }, 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close modal
|
|
||||||
modalManager.closeModal('importModal');
|
modalManager.closeModal('importModal');
|
||||||
|
|
||||||
// Refresh the recipe
|
if (isDownloadOnly && state.virtualScroller) {
|
||||||
window.recipeManager.loadRecipes(true);
|
const recipeId = this.importManager.recipeId;
|
||||||
|
try {
|
||||||
|
const detailRes = await fetch(`/api/lm/recipe/${encodeURIComponent(recipeId)}`);
|
||||||
|
if (detailRes.ok) {
|
||||||
|
const updated = await detailRes.json();
|
||||||
|
state.virtualScroller.updateSingleItem(updated.file_path, updated);
|
||||||
|
} else {
|
||||||
|
throw new Error(`API returned ${detailRes.status}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to update recipe card in-place, falling back to reload:', e);
|
||||||
|
await window.recipeManager.loadRecipes({ resetPage: true, preserveScroll: true });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
window.recipeManager.loadRecipes({ resetPage: true, preserveScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
|
|||||||
@@ -333,6 +333,7 @@ export const PATH_TEMPLATE_PLACEHOLDERS = [
|
|||||||
export const DEFAULT_PATH_TEMPLATES = {
|
export const DEFAULT_PATH_TEMPLATES = {
|
||||||
lora: '{base_model}/{first_tag}',
|
lora: '{base_model}/{first_tag}',
|
||||||
checkpoint: '{base_model}',
|
checkpoint: '{base_model}',
|
||||||
|
unet: '{base_model}',
|
||||||
embedding: '{first_tag}'
|
embedding: '{first_tag}'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,20 @@
|
|||||||
<div class="notification-panels">
|
<div class="notification-panels">
|
||||||
<div class="notification-panel active" id="updatesPanel" role="tabpanel" aria-labelledby="updatesTab" aria-hidden="false" tabindex="0" data-notification-panel="updates">
|
<div class="notification-panel active" id="updatesPanel" role="tabpanel" aria-labelledby="updatesTab" aria-hidden="false" tabindex="0" data-notification-panel="updates">
|
||||||
<div class="update-content">
|
<div class="update-content">
|
||||||
|
|
||||||
|
<!-- Channel Selector -->
|
||||||
|
<div class="update-channels" id="updateChannels">
|
||||||
|
<div class="channels-label">{{ t('update.channel') }}</div>
|
||||||
|
<div class="channel-toggle">
|
||||||
|
<button type="button" class="channel-btn" data-channel="release" id="channelRelease">
|
||||||
|
<i class="fas fa-tag"></i> {{ t('update.channels.release') }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="channel-btn" data-channel="nightly" id="channelNightly">
|
||||||
|
<i class="fas fa-moon"></i> {{ t('update.channels.nightly') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="update-info">
|
<div class="update-info">
|
||||||
<div class="version-info">
|
<div class="version-info">
|
||||||
<div class="current-version">
|
<div class="current-version">
|
||||||
|
|||||||
186
tests/frontend/managers/BulkManager.marquee.test.js
Normal file
186
tests/frontend/managers/BulkManager.marquee.test.js
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
import { state } from '../../../static/js/state/index.js';
|
||||||
|
import { MODEL_TYPES } from '../../../static/js/api/apiConfig.js';
|
||||||
|
import { eventManager } from '../../../static/js/utils/EventManager.js';
|
||||||
|
import { BulkManager } from '../../../static/js/managers/BulkManager.js';
|
||||||
|
|
||||||
|
function fire(type, init = {}) {
|
||||||
|
return new MouseEvent(type, { bubbles: true, cancelable: true, ...init });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BulkManager marquee guards', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
// jsdom may not provide requestAnimationFrame; stub it so the auto-scroll loop is a no-op.
|
||||||
|
window.requestAnimationFrame = vi.fn();
|
||||||
|
window.cancelAnimationFrame = vi.fn();
|
||||||
|
|
||||||
|
eventManager.cleanup();
|
||||||
|
state.currentPageType = MODEL_TYPES.LORA;
|
||||||
|
state.bulkMode = false;
|
||||||
|
state.selectedModels.clear();
|
||||||
|
|
||||||
|
document.body.innerHTML = '<div class="page-content"></div>';
|
||||||
|
const pageContent = document.querySelector('.page-content');
|
||||||
|
pageContent.getBoundingClientRect = () => ({
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 1000,
|
||||||
|
bottom: 1000,
|
||||||
|
width: 1000,
|
||||||
|
height: 1000,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
});
|
||||||
|
pageContent.scrollBy = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
eventManager.cleanup();
|
||||||
|
vi.useRealTimers();
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
function createBulkManager() {
|
||||||
|
const bulk = new BulkManager();
|
||||||
|
bulk.initialize();
|
||||||
|
return bulk;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('never starts a marquee when the left button is not held', () => {
|
||||||
|
const bulk = createBulkManager();
|
||||||
|
const pageContent = document.querySelector('.page-content');
|
||||||
|
|
||||||
|
pageContent.dispatchEvent(fire('mousedown', { button: 0, clientX: 10, clientY: 10 }));
|
||||||
|
document.dispatchEvent(fire('mousemove', { buttons: 0, clientX: 50, clientY: 50 }));
|
||||||
|
|
||||||
|
expect(bulk.mouseDownTime).toBe(0);
|
||||||
|
expect(bulk.isMarqueeActive).toBe(false);
|
||||||
|
expect(state.bulkMode).toBe(false);
|
||||||
|
expect(document.querySelector('.marquee-selection')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires holding the left button for the drag delay before starting a marquee', () => {
|
||||||
|
const bulk = createBulkManager();
|
||||||
|
const pageContent = document.querySelector('.page-content');
|
||||||
|
|
||||||
|
pageContent.dispatchEvent(fire('mousedown', { button: 0, clientX: 10, clientY: 10 }));
|
||||||
|
|
||||||
|
// Fast movement: far enough, but too soon after mousedown.
|
||||||
|
document.dispatchEvent(fire('mousemove', { buttons: 1, clientX: 30, clientY: 10 }));
|
||||||
|
expect(state.bulkMode).toBe(false);
|
||||||
|
expect(bulk.isMarqueeActive).toBe(false);
|
||||||
|
|
||||||
|
// Once the hold time has elapsed, the same drag qualifies.
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
document.dispatchEvent(fire('mousemove', { buttons: 1, clientX: 35, clientY: 12 }));
|
||||||
|
expect(state.bulkMode).toBe(true);
|
||||||
|
expect(bulk.isMarqueeActive).toBe(true);
|
||||||
|
expect(document.querySelector('.marquee-selection')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ends an active marquee if the left button is released without a mouseup event', () => {
|
||||||
|
const bulk = createBulkManager();
|
||||||
|
bulk.mouseDownPosition = { x: 10, y: 10 };
|
||||||
|
bulk.startMarqueeSelection({}, true);
|
||||||
|
expect(state.bulkMode).toBe(true);
|
||||||
|
expect(document.querySelector('.marquee-selection')).not.toBeNull();
|
||||||
|
|
||||||
|
// No mouseup was dispatched; a plain move with the button released finalizes it.
|
||||||
|
document.dispatchEvent(fire('mousemove', { buttons: 0, clientX: 50, clientY: 50 }));
|
||||||
|
|
||||||
|
expect(bulk.isMarqueeActive).toBe(false);
|
||||||
|
expect(document.querySelector('.marquee-selection')).toBeNull();
|
||||||
|
expect(state.bulkMode).toBe(false); // zero selected -> auto-exit
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a tiny marquee as an accidental click: clears selection and exits bulk mode', () => {
|
||||||
|
const bulk = createBulkManager();
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'model-card selected';
|
||||||
|
card.dataset.filepath = '/models/test.safetensors';
|
||||||
|
document.body.appendChild(card);
|
||||||
|
state.selectedModels.add('/models/test.safetensors');
|
||||||
|
|
||||||
|
bulk.mouseDownPosition = { x: 100, y: 100 };
|
||||||
|
bulk.startMarqueeSelection({}, true);
|
||||||
|
expect(state.bulkMode).toBe(true);
|
||||||
|
|
||||||
|
bulk.endMarqueeSelection({ clientX: 103, clientY: 104 });
|
||||||
|
|
||||||
|
expect(state.bulkMode).toBe(false);
|
||||||
|
expect(state.selectedModels.size).toBe(0);
|
||||||
|
expect(card.classList.contains('selected')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps selection and bulk mode when the marquee is large enough', () => {
|
||||||
|
const bulk = createBulkManager();
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'model-card selected';
|
||||||
|
card.dataset.filepath = '/models/test.safetensors';
|
||||||
|
document.body.appendChild(card);
|
||||||
|
state.selectedModels.add('/models/test.safetensors');
|
||||||
|
|
||||||
|
bulk.mouseDownPosition = { x: 100, y: 100 };
|
||||||
|
bulk.startMarqueeSelection({}, true);
|
||||||
|
|
||||||
|
bulk.endMarqueeSelection({ clientX: 130, clientY: 140 });
|
||||||
|
|
||||||
|
expect(state.bulkMode).toBe(true);
|
||||||
|
expect(state.selectedModels.has('/models/test.safetensors')).toBe(true);
|
||||||
|
expect(card.classList.contains('selected')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps auto-scroll marquee selections when the pointer only moved a few pixels', () => {
|
||||||
|
const bulk = createBulkManager();
|
||||||
|
const pageContent = document.querySelector('.page-content');
|
||||||
|
|
||||||
|
// Card just below the press point in document coordinates.
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'model-card';
|
||||||
|
card.dataset.filepath = '/models/off-screen.safetensors';
|
||||||
|
card.getBoundingClientRect = () => ({
|
||||||
|
top: 950,
|
||||||
|
left: 400,
|
||||||
|
right: 600,
|
||||||
|
bottom: 1050,
|
||||||
|
width: 200,
|
||||||
|
height: 100,
|
||||||
|
x: 400,
|
||||||
|
y: 950,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
});
|
||||||
|
document.body.appendChild(card);
|
||||||
|
|
||||||
|
pageContent.dispatchEvent(fire('mousedown', { button: 0, clientX: 500, clientY: 900 }));
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
|
||||||
|
// Small pointer move: enough to start the marquee, but under minMarqueeSize.
|
||||||
|
document.dispatchEvent(fire('mousemove', { buttons: 1, clientX: 506, clientY: 906 }));
|
||||||
|
expect(bulk.isMarqueeActive).toBe(true);
|
||||||
|
|
||||||
|
// Auto-scroll grows the document-space box while the pointer stays nearly still.
|
||||||
|
pageContent.scrollTop = 200;
|
||||||
|
card.getBoundingClientRect = () => ({
|
||||||
|
top: 750,
|
||||||
|
left: 400,
|
||||||
|
right: 600,
|
||||||
|
bottom: 850,
|
||||||
|
width: 200,
|
||||||
|
height: 100,
|
||||||
|
x: 400,
|
||||||
|
y: 750,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
});
|
||||||
|
document.dispatchEvent(fire('mousemove', { buttons: 1, clientX: 506, clientY: 906 }));
|
||||||
|
|
||||||
|
expect(state.selectedModels.has('/models/off-screen.safetensors')).toBe(true);
|
||||||
|
|
||||||
|
// Release: the client-space box is tiny, but the document-space box is not.
|
||||||
|
document.dispatchEvent(fire('mouseup', { button: 0, clientX: 506, clientY: 906 }));
|
||||||
|
|
||||||
|
expect(state.selectedModels.has('/models/off-screen.safetensors')).toBe(true);
|
||||||
|
expect(state.bulkMode).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -106,6 +106,118 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('SettingsManager root selects', () => {
|
||||||
|
const rootCases = [
|
||||||
|
{
|
||||||
|
method: 'loadLoraRoots',
|
||||||
|
selectId: 'defaultLoraRoot',
|
||||||
|
endpoint: '/api/lm/loras/roots',
|
||||||
|
errorKey: 'toast.settings.loraRootsFailed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'loadCheckpointRoots',
|
||||||
|
selectId: 'defaultCheckpointRoot',
|
||||||
|
endpoint: '/api/lm/checkpoints/checkpoints_roots',
|
||||||
|
errorKey: 'toast.settings.checkpointRootsFailed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'loadUnetRoots',
|
||||||
|
selectId: 'defaultUnetRoot',
|
||||||
|
endpoint: '/api/lm/checkpoints/unet_roots',
|
||||||
|
errorKey: 'toast.settings.unetRootsFailed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'loadEmbeddingRoots',
|
||||||
|
selectId: 'defaultEmbeddingRoot',
|
||||||
|
endpoint: '/api/lm/embeddings/roots',
|
||||||
|
errorKey: 'toast.settings.embeddingRootsFailed',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const appendRootSelect = (id) => {
|
||||||
|
const select = document.createElement('select');
|
||||||
|
select.id = id;
|
||||||
|
document.body.appendChild(select);
|
||||||
|
return select;
|
||||||
|
};
|
||||||
|
|
||||||
|
it.each(rootCases)(
|
||||||
|
'populates the $method select with roots and keeps it enabled',
|
||||||
|
async ({ method, selectId, endpoint }) => {
|
||||||
|
const manager = createManager();
|
||||||
|
const select = appendRootSelect(selectId);
|
||||||
|
select.disabled = true;
|
||||||
|
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
roots: ['/models/root-a', '/models/root-b'],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager[method]();
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(endpoint);
|
||||||
|
expect(Array.from(select.options).map(option => option.value)).toEqual([
|
||||||
|
'/models/root-a',
|
||||||
|
'/models/root-b',
|
||||||
|
]);
|
||||||
|
expect(select.disabled).toBe(false);
|
||||||
|
expect(showToast).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(rootCases)(
|
||||||
|
'shows a placeholder and no error toast when $method has empty roots',
|
||||||
|
async ({ method, selectId, endpoint }) => {
|
||||||
|
const manager = createManager();
|
||||||
|
const select = appendRootSelect(selectId);
|
||||||
|
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
roots: [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager[method]();
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(endpoint);
|
||||||
|
expect(select.options).toHaveLength(1);
|
||||||
|
expect(select.options[0].value).toBe('');
|
||||||
|
expect(select.options[0].textContent).toBe('No Default');
|
||||||
|
expect(select.disabled).toBe(true);
|
||||||
|
expect(showToast).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(rootCases)(
|
||||||
|
'shows an error toast when the $method roots request fails',
|
||||||
|
async ({ method, selectId, errorKey }) => {
|
||||||
|
const manager = createManager();
|
||||||
|
const select = appendRootSelect(selectId);
|
||||||
|
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager[method]();
|
||||||
|
|
||||||
|
expect(select.options).toHaveLength(1);
|
||||||
|
expect(select.options[0].value).toBe('');
|
||||||
|
expect(select.disabled).toBe(true);
|
||||||
|
expect(showToast).toHaveBeenCalledWith(
|
||||||
|
errorKey,
|
||||||
|
expect.objectContaining({ message: expect.any(String) }),
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
describe('SettingsManager library controls', () => {
|
describe('SettingsManager library controls', () => {
|
||||||
it('loads libraries and populates the select', async () => {
|
it('loads libraries and populates the select', async () => {
|
||||||
const manager = createManager();
|
const manager = createManager();
|
||||||
|
|||||||
@@ -1,12 +1,26 @@
|
|||||||
import { describe, beforeEach, afterEach, expect, it, vi } from 'vitest';
|
import { describe, beforeEach, afterEach, expect, it, vi } from 'vitest';
|
||||||
import { UpdateService } from '../../../static/js/managers/UpdateService.js';
|
import { UpdateService } from '../../../static/js/managers/UpdateService.js';
|
||||||
|
import { state } from '../../../static/js/state/index.js';
|
||||||
|
|
||||||
function createFetchResponse(payload) {
|
function createFetchResponse(payload) {
|
||||||
return {
|
return {
|
||||||
json: vi.fn().mockResolvedValue(payload)
|
json: vi.fn().mockResolvedValue(payload),
|
||||||
|
ok: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stubSettingsUpdateChannel(channel) {
|
||||||
|
state.global = state.global || {};
|
||||||
|
state.global.settings = state.global.settings || {};
|
||||||
|
state.global.settings.update_channel = channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSettingsUpdateChannel() {
|
||||||
|
if (state.global?.settings) {
|
||||||
|
delete state.global.settings.update_channel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe('UpdateService passive checks', () => {
|
describe('UpdateService passive checks', () => {
|
||||||
let service;
|
let service;
|
||||||
let fetchMock;
|
let fetchMock;
|
||||||
@@ -16,10 +30,13 @@ describe('UpdateService passive checks', () => {
|
|||||||
success: true,
|
success: true,
|
||||||
current_version: 'v1.0.0',
|
current_version: 'v1.0.0',
|
||||||
latest_version: 'v1.0.0',
|
latest_version: 'v1.0.0',
|
||||||
git_info: { short_hash: 'abc123' }
|
git_info: { short_hash: 'abc123' },
|
||||||
|
has_git: true,
|
||||||
}));
|
}));
|
||||||
global.fetch = fetchMock;
|
global.fetch = fetchMock;
|
||||||
|
|
||||||
|
stubSettingsUpdateChannel('release');
|
||||||
|
|
||||||
service = new UpdateService();
|
service = new UpdateService();
|
||||||
service.updateNotificationsEnabled = false;
|
service.updateNotificationsEnabled = false;
|
||||||
service.lastCheckTime = 0;
|
service.lastCheckTime = 0;
|
||||||
@@ -28,6 +45,7 @@ describe('UpdateService passive checks', () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
delete global.fetch;
|
delete global.fetch;
|
||||||
|
clearSettingsUpdateChannel();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips passive update checks when notifications are disabled', async () => {
|
it('skips passive update checks when notifications are disabled', async () => {
|
||||||
@@ -43,3 +61,106 @@ describe('UpdateService passive checks', () => {
|
|||||||
expect(fetchMock).toHaveBeenCalledWith('/api/lm/check-updates?nightly=false');
|
expect(fetchMock).toHaveBeenCalledWith('/api/lm/check-updates?nightly=false');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('UpdateService nightly notification throttling', () => {
|
||||||
|
let fetchMock;
|
||||||
|
let updateToggle;
|
||||||
|
let updateBadge;
|
||||||
|
|
||||||
|
function stubUpdateBadgeDom() {
|
||||||
|
updateToggle = document.createElement('div');
|
||||||
|
updateToggle.className = 'update-toggle';
|
||||||
|
updateBadge = document.createElement('span');
|
||||||
|
updateBadge.className = 'update-badge';
|
||||||
|
updateToggle.appendChild(updateBadge);
|
||||||
|
document.body.appendChild(updateToggle);
|
||||||
|
|
||||||
|
vi.spyOn(document, 'querySelector').mockImplementation((selector) => {
|
||||||
|
if (selector === '.update-toggle') return updateToggle;
|
||||||
|
if (selector === '.update-toggle .update-badge') return updateBadge;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeUpdateResponse(channel) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
current_version: 'v1.0.0',
|
||||||
|
latest_version: channel === 'nightly' ? 'main-abc1234' : 'v1.1.0',
|
||||||
|
update_available: true,
|
||||||
|
git_info: { short_hash: 'abc123' },
|
||||||
|
has_git: true,
|
||||||
|
nightly: channel === 'nightly',
|
||||||
|
changelog: ['test: change'],
|
||||||
|
releases: [],
|
||||||
|
behind_by: 3,
|
||||||
|
commit_date: '2026-07-31',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock = vi.fn().mockResolvedValue(createFetchResponse(makeUpdateResponse('release')));
|
||||||
|
global.fetch = fetchMock;
|
||||||
|
stubUpdateBadgeDom();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
delete global.fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the nightly badge once and keeps it visible for the session', async () => {
|
||||||
|
stubSettingsUpdateChannel('nightly');
|
||||||
|
fetchMock.mockResolvedValue(createFetchResponse(makeUpdateResponse('nightly')));
|
||||||
|
|
||||||
|
const service = new UpdateService();
|
||||||
|
service.updateNotificationsEnabled = true;
|
||||||
|
|
||||||
|
await service.checkForUpdates({ force: true });
|
||||||
|
|
||||||
|
expect(service.updateAvailable).toBe(true);
|
||||||
|
expect(service.nightlyBadgeShown).toBe(true);
|
||||||
|
expect(service.nightlyNotifyDate).toBe(service._getTodayKey());
|
||||||
|
expect(updateBadge.classList.contains('visible')).toBe(true);
|
||||||
|
|
||||||
|
// A repeated check within the same session keeps the badge visible.
|
||||||
|
await service.checkForUpdates({ force: true });
|
||||||
|
expect(updateBadge.classList.contains('visible')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suppresses the nightly badge on a later session in the same day', async () => {
|
||||||
|
stubSettingsUpdateChannel('nightly');
|
||||||
|
fetchMock.mockResolvedValue(createFetchResponse(makeUpdateResponse('nightly')));
|
||||||
|
|
||||||
|
const firstService = new UpdateService();
|
||||||
|
firstService.updateNotificationsEnabled = true;
|
||||||
|
await firstService.checkForUpdates({ force: true });
|
||||||
|
expect(updateBadge.classList.contains('visible')).toBe(true);
|
||||||
|
|
||||||
|
// Simulate a fresh page session on the same calendar day.
|
||||||
|
const secondService = new UpdateService();
|
||||||
|
secondService.updateNotificationsEnabled = true;
|
||||||
|
await secondService.checkForUpdates({ force: true });
|
||||||
|
|
||||||
|
expect(secondService.updateAvailable).toBe(true);
|
||||||
|
expect(secondService.nightlyBadgeShown).toBe(false);
|
||||||
|
expect(updateBadge.classList.contains('visible')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not affected by the daily limit on the release channel', async () => {
|
||||||
|
stubSettingsUpdateChannel('release');
|
||||||
|
fetchMock.mockResolvedValue(createFetchResponse(makeUpdateResponse('release')));
|
||||||
|
|
||||||
|
const firstService = new UpdateService();
|
||||||
|
firstService.updateNotificationsEnabled = true;
|
||||||
|
await firstService.checkForUpdates({ force: true });
|
||||||
|
expect(updateBadge.classList.contains('visible')).toBe(true);
|
||||||
|
|
||||||
|
const secondService = new UpdateService();
|
||||||
|
secondService.updateNotificationsEnabled = true;
|
||||||
|
await secondService.checkForUpdates({ force: true });
|
||||||
|
|
||||||
|
expect(secondService.updateAvailable).toBe(true);
|
||||||
|
expect(updateBadge.classList.contains('visible')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ def test_metadata_hook_installs_and_traces_execution(monkeypatch, metadata_regis
|
|||||||
|
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
def record_stub(self, node_id, class_type, inputs, outputs):
|
def record_stub(self, node_id, class_type, inputs, outputs, return_types=None):
|
||||||
calls.append(("record", node_id, class_type, inputs))
|
calls.append(("record", node_id, class_type, inputs))
|
||||||
|
|
||||||
def update_stub(self, node_id, class_type, outputs):
|
def update_stub(self, node_id, class_type, outputs, return_types=None):
|
||||||
calls.append(("update", node_id, class_type, outputs))
|
calls.append(("update", node_id, class_type, outputs))
|
||||||
|
|
||||||
monkeypatch.setattr(MetadataRegistry, "record_node_execution", record_stub)
|
monkeypatch.setattr(MetadataRegistry, "record_node_execution", record_stub)
|
||||||
@@ -820,3 +820,227 @@ def test_lora_manager_checkpoint_and_unet_loaders_extract_models(metadata_regist
|
|||||||
"type": "checkpoint",
|
"type": "checkpoint",
|
||||||
"node_id": "unet_node",
|
"node_id": "unet_node",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MetadataOverwriteExtractor & overwrite merge tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
from py.metadata_collector.constants import OVERWRITE, METADATA_OVERWRITE_FIELDS
|
||||||
|
from py.metadata_collector.node_extractors import MetadataOverwriteExtractor
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_overwrite_extractor_stores_truthy_values(metadata_registry):
|
||||||
|
"""Extractor should store truthy inputs under the OVERWRITE category."""
|
||||||
|
metadata_registry.start_collection("prompt-ow")
|
||||||
|
metadata = metadata_registry.prompt_metadata["prompt-ow"]
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
"prompt": "a beautiful landscape",
|
||||||
|
"negative_prompt": "",
|
||||||
|
"seed": 42,
|
||||||
|
"steps": 0,
|
||||||
|
"cfg_scale": 7.5,
|
||||||
|
"sampler": "",
|
||||||
|
"scheduler": "",
|
||||||
|
"model": "myModel.safetensors",
|
||||||
|
"loras": "<lora:detail:0.8>",
|
||||||
|
"size": "1024x768",
|
||||||
|
"clip_skip": 0,
|
||||||
|
"additional_data": '{"Copyright": "CC0"}',
|
||||||
|
}
|
||||||
|
|
||||||
|
MetadataOverwriteExtractor.extract("ow-1", inputs, None, metadata)
|
||||||
|
|
||||||
|
assert OVERWRITE in metadata
|
||||||
|
assert "ow-1" in metadata[OVERWRITE]
|
||||||
|
params = metadata[OVERWRITE]["ow-1"]["parameters"]
|
||||||
|
|
||||||
|
# Truthy values stored
|
||||||
|
assert params["prompt"] == "a beautiful landscape"
|
||||||
|
assert params["seed"] == 42
|
||||||
|
assert params["cfg_scale"] == 7.5
|
||||||
|
assert params["model"] == "myModel.safetensors"
|
||||||
|
assert params["loras"] == "<lora:detail:0.8>"
|
||||||
|
assert params["size"] == "1024x768"
|
||||||
|
assert params["additional_data"] == '{"Copyright": "CC0"}'
|
||||||
|
|
||||||
|
# Falsy values NOT stored
|
||||||
|
assert "negative_prompt" not in params
|
||||||
|
assert "steps" not in params
|
||||||
|
assert "sampler" not in params
|
||||||
|
assert "scheduler" not in params
|
||||||
|
# clip_skip=0 is now stored (0 != sentinel -25) — wired 0 is valid
|
||||||
|
assert params["clip_skip"] == 0
|
||||||
|
|
||||||
|
metadata_registry.clear_metadata()
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_overwrite_extractor_empty_inputs(metadata_registry):
|
||||||
|
"""Extractor with all-falsy inputs should NOT create OVERWRITE category."""
|
||||||
|
metadata_registry.start_collection("prompt-ow2")
|
||||||
|
metadata = metadata_registry.prompt_metadata["prompt-ow2"]
|
||||||
|
|
||||||
|
from py.metadata_collector.constants import CLIP_SKIP_SENTINEL
|
||||||
|
|
||||||
|
inputs = {key: "" for key in METADATA_OVERWRITE_FIELDS}
|
||||||
|
inputs.update({"seed": 0, "steps": 0, "cfg_scale": 0.0, "clip_skip": CLIP_SKIP_SENTINEL})
|
||||||
|
|
||||||
|
MetadataOverwriteExtractor.extract("ow-2", inputs, None, metadata)
|
||||||
|
|
||||||
|
# start_collection pre-creates empty dicts for all categories,
|
||||||
|
# but no node should have populated OVERWRITE with any data
|
||||||
|
assert not metadata[OVERWRITE]
|
||||||
|
|
||||||
|
metadata_registry.clear_metadata()
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_generation_params_applies_overwrite(metadata_registry, populated_registry, monkeypatch):
|
||||||
|
"""overwrite values should replace inferred params in extract_generation_params."""
|
||||||
|
import py.metadata_collector.metadata_processor as mp
|
||||||
|
|
||||||
|
monkeypatch.setattr(mp, "standalone_mode", False)
|
||||||
|
|
||||||
|
metadata = populated_registry["metadata"]
|
||||||
|
registry_obj = populated_registry["registry"]
|
||||||
|
|
||||||
|
# Simulate the MetadataOverwriteLM node having been executed with overwrite values
|
||||||
|
registry_obj.start_collection("promptA")
|
||||||
|
# Re-populate with the same data (start_collection resets)
|
||||||
|
registry_obj.set_current_prompt(populated_registry["prompt"])
|
||||||
|
metadata2 = registry_obj.prompt_metadata["promptA"]
|
||||||
|
|
||||||
|
# Inject overwrite data into metadata
|
||||||
|
metadata2[OVERWRITE] = {
|
||||||
|
"ow-1": {
|
||||||
|
"parameters": {
|
||||||
|
"seed": 777,
|
||||||
|
"additional_data": '{"AuthorURL": "https://civitai.com/user/foo"}',
|
||||||
|
},
|
||||||
|
"node_id": "ow-1",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# Copy other categories from original populated metadata
|
||||||
|
for cat in ("models", "prompts", "sampling", "loras", "size", "images"):
|
||||||
|
if cat in metadata:
|
||||||
|
metadata2[cat] = metadata[cat]
|
||||||
|
metadata2["execution_order"] = metadata["execution_order"]
|
||||||
|
|
||||||
|
params = MetadataProcessor.extract_generation_params(metadata2, id="vae")
|
||||||
|
|
||||||
|
# Overwritten values
|
||||||
|
assert params["seed"] == 777
|
||||||
|
assert params["additional_data"] == '{"AuthorURL": "https://civitai.com/user/foo"}'
|
||||||
|
|
||||||
|
# Inferred values still present (not overwritten)
|
||||||
|
assert params["prompt"] == "A castle on a hill"
|
||||||
|
assert params["cfg_scale"] == 7.5
|
||||||
|
assert params["checkpoint"] == "model.safetensors"
|
||||||
|
|
||||||
|
registry_obj.clear_metadata()
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_generation_params_overwrite_falsy_skipped(metadata_registry, populated_registry, monkeypatch):
|
||||||
|
"""Overwrite entries with falsy values should NOT replace inferred params."""
|
||||||
|
import py.metadata_collector.metadata_processor as mp
|
||||||
|
|
||||||
|
monkeypatch.setattr(mp, "standalone_mode", False)
|
||||||
|
|
||||||
|
metadata = populated_registry["metadata"]
|
||||||
|
registry_obj = populated_registry["registry"]
|
||||||
|
|
||||||
|
registry_obj.start_collection("promptA")
|
||||||
|
registry_obj.set_current_prompt(populated_registry["prompt"])
|
||||||
|
metadata2 = registry_obj.prompt_metadata["promptA"]
|
||||||
|
|
||||||
|
# Inject overwrite with falsy values (except clip_skip=0 which is now
|
||||||
|
# treated as a valid wired input thanks to the -25 sentinel)
|
||||||
|
metadata2[OVERWRITE] = {
|
||||||
|
"ow-1": {
|
||||||
|
"parameters": {
|
||||||
|
"seed": 0,
|
||||||
|
"steps": 0,
|
||||||
|
"cfg_scale": 0.0,
|
||||||
|
"prompt": "",
|
||||||
|
"clip_skip": 0,
|
||||||
|
},
|
||||||
|
"node_id": "ow-1",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for cat in ("models", "prompts", "sampling", "loras", "size", "images"):
|
||||||
|
if cat in metadata:
|
||||||
|
metadata2[cat] = metadata[cat]
|
||||||
|
metadata2["execution_order"] = metadata["execution_order"]
|
||||||
|
|
||||||
|
params = MetadataProcessor.extract_generation_params(metadata2, id="vae")
|
||||||
|
|
||||||
|
# Falsy overwrites should NOT have replaced inferred values
|
||||||
|
assert params["prompt"] == "A castle on a hill"
|
||||||
|
assert params["cfg_scale"] == 7.5
|
||||||
|
|
||||||
|
# clip_skip=0 is a valid wired value (not the -25 sentinel) — should be applied
|
||||||
|
assert params["clip_skip"] == 0
|
||||||
|
|
||||||
|
registry_obj.clear_metadata()
|
||||||
|
|
||||||
|
|
||||||
|
def test_fill_missing_metadata_skips_overwrite_for_bypassed_node(metadata_registry):
|
||||||
|
"""Bypassed (mode=4) node should not have OVERWRITE filled from cache."""
|
||||||
|
metadata_registry.start_collection("prompt-bypass")
|
||||||
|
|
||||||
|
# Simulate a previous execution that cached overwrite data
|
||||||
|
metadata_registry.record_node_execution(
|
||||||
|
"ow-1",
|
||||||
|
"MetadataOverwriteLM",
|
||||||
|
{"seed": 99, "prompt": "test", "steps": 0, "cfg_scale": 0.0,
|
||||||
|
"negative_prompt": "", "sampler": "", "scheduler": "", "model": "",
|
||||||
|
"loras": "", "size": "", "clip_skip": 0, "additional_data": ""},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Now start a new prompt where the node is bypassed (mode=4)
|
||||||
|
metadata_registry.start_collection("prompt-bypass-2")
|
||||||
|
original_prompt = {
|
||||||
|
"ow-1": {"class_type": "MetadataOverwriteLM", "inputs": {}, "mode": 4},
|
||||||
|
}
|
||||||
|
metadata_registry.set_current_prompt(
|
||||||
|
SimpleNamespace(original_prompt=original_prompt)
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata = metadata_registry.get_metadata("prompt-bypass-2")
|
||||||
|
|
||||||
|
# The overwrite data should NOT be present (node was bypassed, not
|
||||||
|
# a cache hit — it should not inherit previous execution's overwrite)
|
||||||
|
assert "ow-1" not in metadata.get(OVERWRITE, {})
|
||||||
|
|
||||||
|
metadata_registry.clear_metadata()
|
||||||
|
|
||||||
|
|
||||||
|
def test_fill_missing_metadata_fills_overwrite_for_muted_node(metadata_registry):
|
||||||
|
"""Muted (mode=2) node should also not have OVERWRITE filled from cache."""
|
||||||
|
metadata_registry.start_collection("prompt-mute")
|
||||||
|
|
||||||
|
# Simulate a previous execution that cached overwrite data
|
||||||
|
metadata_registry.record_node_execution(
|
||||||
|
"ow-1",
|
||||||
|
"MetadataOverwriteLM",
|
||||||
|
{"seed": 88, "prompt": "test2", "steps": 0, "cfg_scale": 0.0,
|
||||||
|
"negative_prompt": "", "sampler": "", "scheduler": "", "model": "",
|
||||||
|
"loras": "", "size": "", "clip_skip": 0, "additional_data": ""},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start a new prompt where the node is muted (mode=2)
|
||||||
|
metadata_registry.start_collection("prompt-mute-2")
|
||||||
|
original_prompt = {
|
||||||
|
"ow-1": {"class_type": "MetadataOverwriteLM", "inputs": {}, "mode": 2},
|
||||||
|
}
|
||||||
|
metadata_registry.set_current_prompt(
|
||||||
|
SimpleNamespace(original_prompt=original_prompt)
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata = metadata_registry.get_metadata("prompt-mute-2")
|
||||||
|
|
||||||
|
assert "ow-1" not in metadata.get(OVERWRITE, {})
|
||||||
|
|
||||||
|
metadata_registry.clear_metadata()
|
||||||
|
|||||||
@@ -1,10 +1,33 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
from aiohttp import ClientError
|
from aiohttp import ClientError
|
||||||
|
from aiohttp import web
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from py.routes import update_routes
|
from py.routes import update_routes
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_request(body=None, query_params=None):
|
||||||
|
from multidict import MultiDict
|
||||||
|
|
||||||
|
q = MultiDict(query_params or {})
|
||||||
|
|
||||||
|
req = type("Req", (), {
|
||||||
|
"has_body": body is not None,
|
||||||
|
"match_info": {},
|
||||||
|
"rel_url": type("U", (), {"query": q})(),
|
||||||
|
"query": q,
|
||||||
|
"app": {},
|
||||||
|
})()
|
||||||
|
|
||||||
|
async def _json():
|
||||||
|
return body or {}
|
||||||
|
|
||||||
|
req.json = _json
|
||||||
|
return req
|
||||||
|
|
||||||
|
|
||||||
class OfflineDownloader:
|
class OfflineDownloader:
|
||||||
async def make_request(self, *_, **__):
|
async def make_request(self, *_, **__):
|
||||||
return False, "Cannot connect to host"
|
return False, "Cannot connect to host"
|
||||||
@@ -53,10 +76,12 @@ async def test_get_nightly_version_network_error_logs_warning(monkeypatch, caplo
|
|||||||
caplog.set_level(logging.WARNING)
|
caplog.set_level(logging.WARNING)
|
||||||
monkeypatch.setattr(update_routes, "get_downloader", lambda: _stub_downloader(RaisingDownloader()))
|
monkeypatch.setattr(update_routes, "get_downloader", lambda: _stub_downloader(RaisingDownloader()))
|
||||||
|
|
||||||
version, changelog = await update_routes.UpdateRoutes._get_nightly_version()
|
version, changelog, behind_by, commit_date = await update_routes.UpdateRoutes._get_nightly_version()
|
||||||
|
|
||||||
assert version == "main"
|
assert version == "main"
|
||||||
assert changelog == []
|
assert changelog == []
|
||||||
|
assert behind_by == 0
|
||||||
|
assert commit_date == ""
|
||||||
assert "Unable to reach GitHub for nightly version" in caplog.text
|
assert "Unable to reach GitHub for nightly version" in caplog.text
|
||||||
assert "Traceback" not in caplog.text
|
assert "Traceback" not in caplog.text
|
||||||
|
|
||||||
@@ -236,3 +261,305 @@ async def test_perform_git_update_stable_preserves_user_dirs(monkeypatch, tmp_pa
|
|||||||
clean_args = clean_calls[0][1]
|
clean_args = clean_calls[0][1]
|
||||||
for name in update_routes._PRESERVE_DIRS:
|
for name in update_routes._PRESERVE_DIRS:
|
||||||
assert name in clean_args, f"{name} missing from git clean excludes (stable)"
|
assert name in clean_args, f"{name} missing from git clean excludes (stable)"
|
||||||
|
|
||||||
|
def test_init_git_repo_creates_valid_repo(tmp_path, monkeypatch):
|
||||||
|
if not shutil.which("git"):
|
||||||
|
pytest.skip("git executable not found")
|
||||||
|
|
||||||
|
plugin_root = tmp_path / "plugin"
|
||||||
|
plugin_root.mkdir()
|
||||||
|
(plugin_root / ".tracking").write_text("pyproject.toml")
|
||||||
|
(plugin_root / "settings.json").write_text('{"some": "value"}')
|
||||||
|
|
||||||
|
try:
|
||||||
|
success, version = update_routes.UpdateRoutes._init_git_repo(str(plugin_root))
|
||||||
|
except Exception as e:
|
||||||
|
pytest.skip(f"Network unavailable for git fetch: {e}")
|
||||||
|
|
||||||
|
assert success is True
|
||||||
|
assert version.startswith("main-")
|
||||||
|
assert len(version) > len("main-")
|
||||||
|
assert (plugin_root / ".git").is_dir()
|
||||||
|
assert not (plugin_root / ".tracking").exists()
|
||||||
|
assert (plugin_root / "settings.json").exists()
|
||||||
|
assert (plugin_root / "pyproject.toml").exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_switch_channel_invalid_channel_returns_error():
|
||||||
|
req = _fake_request({"channel": "bad_channel"})
|
||||||
|
resp = await update_routes.UpdateRoutes.switch_channel(req)
|
||||||
|
|
||||||
|
data = _raw_body(resp)
|
||||||
|
assert not data["success"]
|
||||||
|
assert "Invalid channel" in data["error"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_switch_channel_to_nightly_without_git_inits_repo(monkeypatch, tmp_path):
|
||||||
|
routes_file = tmp_path / "py" / "routes" / "update_routes.py"
|
||||||
|
routes_file.parent.mkdir(parents=True)
|
||||||
|
routes_file.write_text("")
|
||||||
|
monkeypatch.setattr(update_routes, "__file__", str(routes_file))
|
||||||
|
monkeypatch.setattr(update_routes, "ensure_settings_file", lambda logger: str(tmp_path / "settings.json"))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
update_routes.UpdateRoutes,
|
||||||
|
"_init_git_repo",
|
||||||
|
staticmethod(lambda plugin_root: (True, "main-fedcba9")),
|
||||||
|
)
|
||||||
|
|
||||||
|
req = _fake_request({"channel": "nightly"})
|
||||||
|
resp = await update_routes.UpdateRoutes.switch_channel(req)
|
||||||
|
data = _raw_body(resp)
|
||||||
|
|
||||||
|
assert data["success"] is True
|
||||||
|
assert data["channel"] == "nightly"
|
||||||
|
assert data["new_version"] == "main-fedcba9"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_switch_channel_to_nightly_with_git_calls_git_update(monkeypatch, tmp_path):
|
||||||
|
routes_file = tmp_path / "py" / "routes" / "update_routes.py"
|
||||||
|
routes_file.parent.mkdir(parents=True)
|
||||||
|
routes_file.write_text("")
|
||||||
|
monkeypatch.setattr(update_routes, "__file__", str(routes_file))
|
||||||
|
monkeypatch.setattr(update_routes, "ensure_settings_file", lambda logger: str(tmp_path / "settings.json"))
|
||||||
|
|
||||||
|
(tmp_path / ".git").mkdir()
|
||||||
|
|
||||||
|
async def _fake_git_update(*args, **kwargs):
|
||||||
|
return True, "main-1111111"
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
update_routes.UpdateRoutes, "_perform_git_update", _fake_git_update
|
||||||
|
)
|
||||||
|
|
||||||
|
req = _fake_request({"channel": "nightly"})
|
||||||
|
resp = await update_routes.UpdateRoutes.switch_channel(req)
|
||||||
|
data = _raw_body(resp)
|
||||||
|
|
||||||
|
assert data["success"] is True
|
||||||
|
assert data["channel"] == "nightly"
|
||||||
|
assert data["new_version"] == "main-1111111"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_switch_channel_to_release_with_git_calls_git_update(monkeypatch, tmp_path):
|
||||||
|
routes_file = tmp_path / "py" / "routes" / "update_routes.py"
|
||||||
|
routes_file.parent.mkdir(parents=True)
|
||||||
|
routes_file.write_text("")
|
||||||
|
monkeypatch.setattr(update_routes, "__file__", str(routes_file))
|
||||||
|
monkeypatch.setattr(update_routes, "ensure_settings_file", lambda logger: str(tmp_path / "settings.json"))
|
||||||
|
|
||||||
|
(tmp_path / ".git").mkdir()
|
||||||
|
|
||||||
|
async def _fake_git_update(*args, **kwargs):
|
||||||
|
return True, "v9.9.9"
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
update_routes.UpdateRoutes, "_perform_git_update", _fake_git_update
|
||||||
|
)
|
||||||
|
|
||||||
|
req = _fake_request({"channel": "release"})
|
||||||
|
resp = await update_routes.UpdateRoutes.switch_channel(req)
|
||||||
|
data = _raw_body(resp)
|
||||||
|
|
||||||
|
assert data["success"] is True
|
||||||
|
assert data["channel"] == "release"
|
||||||
|
assert data["new_version"] == "v9.9.9"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_switch_channel_to_release_without_git_still_downloads_zip(monkeypatch, tmp_path):
|
||||||
|
routes_file = tmp_path / "py" / "routes" / "update_routes.py"
|
||||||
|
routes_file.parent.mkdir(parents=True)
|
||||||
|
routes_file.write_text("")
|
||||||
|
monkeypatch.setattr(update_routes, "__file__", str(routes_file))
|
||||||
|
monkeypatch.setattr(update_routes, "ensure_settings_file", lambda logger: str(tmp_path / "settings.json"))
|
||||||
|
|
||||||
|
async def _fake_zip(*args, **kwargs):
|
||||||
|
return True, "v2.0.0"
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
update_routes.UpdateRoutes, "_download_and_replace_zip", _fake_zip
|
||||||
|
)
|
||||||
|
|
||||||
|
req = _fake_request({"channel": "release"})
|
||||||
|
resp = await update_routes.UpdateRoutes.switch_channel(req)
|
||||||
|
data = _raw_body(resp)
|
||||||
|
|
||||||
|
assert data["success"] is True
|
||||||
|
assert data["channel"] == "release"
|
||||||
|
assert data["new_version"] == "v2.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
class _NightlyDownloader:
|
||||||
|
"""Returns a fake main-branch commit AND a compare response."""
|
||||||
|
|
||||||
|
commit_sha = "7777777"
|
||||||
|
commit_msg = "test: add nightly feature"
|
||||||
|
commit_date = "2026-07-27T12:00:00Z"
|
||||||
|
behind_by = 5
|
||||||
|
|
||||||
|
async def make_request(self, method, url, **kwargs):
|
||||||
|
if "/compare/" in url:
|
||||||
|
return True, {"behind_by": self.behind_by}
|
||||||
|
return True, {
|
||||||
|
"sha": self.commit_sha,
|
||||||
|
"commit": {
|
||||||
|
"message": self.commit_msg,
|
||||||
|
"committer": {"date": self.commit_date},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_nightly_version_parses_behind_by(monkeypatch):
|
||||||
|
monkeypatch.setattr(update_routes, "get_downloader", lambda: _stub_downloader(_NightlyDownloader()))
|
||||||
|
|
||||||
|
version, changelog, behind_by, commit_date = await update_routes.UpdateRoutes._get_nightly_version(
|
||||||
|
local_hash="abc1234"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert version == "main-7777777"
|
||||||
|
assert behind_by == 5
|
||||||
|
assert commit_date == "2026-07-27"
|
||||||
|
assert len(changelog) == 1
|
||||||
|
assert changelog[0] == "test: add nightly feature"
|
||||||
|
|
||||||
|
|
||||||
|
class _AheadCompareDownloader:
|
||||||
|
"""Fake compare API response with status='ahead' (main is ahead of local)."""
|
||||||
|
|
||||||
|
commit_sha = "9999999"
|
||||||
|
commit_msg = "latest commit"
|
||||||
|
commit_date = "2026-07-28T00:00:00Z"
|
||||||
|
ahead_by = 3
|
||||||
|
|
||||||
|
async def make_request(self, method, url, **kwargs):
|
||||||
|
if "/compare/" in url:
|
||||||
|
return True, {"status": "ahead", "ahead_by": self.ahead_by, "behind_by": 0}
|
||||||
|
return True, {
|
||||||
|
"sha": self.commit_sha,
|
||||||
|
"commit": {
|
||||||
|
"message": self.commit_msg,
|
||||||
|
"committer": {"date": self.commit_date},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_nightly_version_reads_ahead_by_when_ahead(monkeypatch):
|
||||||
|
"""compare/{local}...main returns status='ahead' → read ahead_by."""
|
||||||
|
monkeypatch.setattr(update_routes, "get_downloader", lambda: _stub_downloader(_AheadCompareDownloader()))
|
||||||
|
|
||||||
|
version, changelog, behind_by, commit_date = await update_routes.UpdateRoutes._get_nightly_version(
|
||||||
|
local_hash="oldhash"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert version == "main-9999999"
|
||||||
|
assert behind_by == 3
|
||||||
|
assert commit_date == "2026-07-28"
|
||||||
|
|
||||||
|
|
||||||
|
class _DivergedCompareDownloader:
|
||||||
|
"""Fake compare API response with status='diverged' (both have unique commits)."""
|
||||||
|
|
||||||
|
commit_sha = "aaaaaaa"
|
||||||
|
commit_msg = "diverged test"
|
||||||
|
commit_date = "2026-07-29T00:00:00Z"
|
||||||
|
|
||||||
|
async def make_request(self, method, url, **kwargs):
|
||||||
|
if "/compare/" in url:
|
||||||
|
return True, {"status": "diverged", "ahead_by": 5, "behind_by": 2}
|
||||||
|
return True, {
|
||||||
|
"sha": self.commit_sha,
|
||||||
|
"commit": {
|
||||||
|
"message": self.commit_msg,
|
||||||
|
"committer": {"date": self.commit_date},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_nightly_version_reads_ahead_by_when_diverged(monkeypatch):
|
||||||
|
"""compare/{local}...main returns status='diverged' → read ahead_by (remote ahead)."""
|
||||||
|
monkeypatch.setattr(update_routes, "get_downloader", lambda: _stub_downloader(_DivergedCompareDownloader()))
|
||||||
|
|
||||||
|
version, changelog, behind_by, commit_date = await update_routes.UpdateRoutes._get_nightly_version(
|
||||||
|
local_hash="divhash"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert behind_by == 5
|
||||||
|
|
||||||
|
|
||||||
|
class _CheckUpdatesDownloader:
|
||||||
|
"""Fake downloader returning both a release list and a nightly commit + compare."""
|
||||||
|
|
||||||
|
commit_sha = "8888888"
|
||||||
|
commit_date = "2026-07-28T00:00:00Z"
|
||||||
|
|
||||||
|
async def make_request(self, method, url, **kwargs):
|
||||||
|
if "/releases" in url:
|
||||||
|
return True, [
|
||||||
|
{
|
||||||
|
"tag_name": "v3.0.0",
|
||||||
|
"body": "- Feature A\n- Feature B",
|
||||||
|
"published_at": "2026-07-20T00:00:00Z",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
if "/compare/" in url:
|
||||||
|
return True, {"behind_by": 3}
|
||||||
|
return True, {
|
||||||
|
"sha": self.commit_sha + "0" * 33,
|
||||||
|
"commit": {
|
||||||
|
"message": "latest commit",
|
||||||
|
"committer": {"date": self.commit_date},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_updates_nightly_response_includes_behind_and_date(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(update_routes, "get_downloader", lambda: _stub_downloader(_CheckUpdatesDownloader()))
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
update_routes.UpdateRoutes,
|
||||||
|
"_get_local_version",
|
||||||
|
staticmethod(lambda: "v1.0.0"),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
update_routes.UpdateRoutes,
|
||||||
|
"_get_git_info",
|
||||||
|
staticmethod(lambda: {
|
||||||
|
"commit_hash": "abc1234",
|
||||||
|
"short_hash": "abc1234",
|
||||||
|
"branch": "main",
|
||||||
|
"commit_date": "2026-01-01",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
routes_file = tmp_path / "py" / "routes" / "update_routes.py"
|
||||||
|
routes_file.parent.mkdir(parents=True)
|
||||||
|
routes_file.write_text("")
|
||||||
|
monkeypatch.setattr(update_routes, "__file__", str(routes_file))
|
||||||
|
(tmp_path / ".git").mkdir()
|
||||||
|
|
||||||
|
req = _fake_request(query_params={"nightly": "true"})
|
||||||
|
resp = await update_routes.UpdateRoutes.check_updates(req)
|
||||||
|
data = _raw_body(resp)
|
||||||
|
|
||||||
|
assert data["success"] is True
|
||||||
|
assert data["nightly"] is True
|
||||||
|
assert data["has_git"] is True
|
||||||
|
assert data["behind_by"] == 3
|
||||||
|
assert data["commit_date"] == "2026-07-28"
|
||||||
|
assert data["latest_version"] == "main-8888888"
|
||||||
|
assert isinstance(data["releases"], list)
|
||||||
|
assert len(data["releases"]) == 1
|
||||||
|
assert data["releases"][0]["version"] == "v3.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_body(response):
|
||||||
|
import json
|
||||||
|
return json.loads(response._body.decode())
|
||||||
|
|||||||
@@ -1252,3 +1252,69 @@ async def test_get_model_civitai_url_falls_back_when_host_setting_is_not_a_strin
|
|||||||
"model_id": "123",
|
"model_id": "123",
|
||||||
"version_id": "456",
|
"version_id": "456",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestHfGroupKey:
|
||||||
|
"""Tests for _extract_hf_group_key and _extract_group_key."""
|
||||||
|
|
||||||
|
# --- _extract_hf_group_key ---
|
||||||
|
|
||||||
|
def test_hf_group_key_valid_url(self):
|
||||||
|
"""Standard HF URL returns hf:user/repo."""
|
||||||
|
item = {"hf_url": "https://huggingface.co/unsloth/qwen-edit"}
|
||||||
|
assert BaseModelService._extract_hf_group_key(item) == "hf:unsloth/qwen-edit"
|
||||||
|
|
||||||
|
def test_hf_group_key_url_with_subpath(self):
|
||||||
|
"""URL with subpath still extracts just owner/repo."""
|
||||||
|
item = {"hf_url": "https://huggingface.co/user/repo/resolve/main/file.safetensors"}
|
||||||
|
assert BaseModelService._extract_hf_group_key(item) == "hf:user/repo"
|
||||||
|
|
||||||
|
def test_hf_group_key_empty_url(self):
|
||||||
|
"""Empty hf_url returns None."""
|
||||||
|
assert BaseModelService._extract_hf_group_key({"hf_url": ""}) is None
|
||||||
|
|
||||||
|
def test_hf_group_key_no_url(self):
|
||||||
|
"""Missing hf_url key returns None."""
|
||||||
|
assert BaseModelService._extract_hf_group_key({}) is None
|
||||||
|
|
||||||
|
def test_hf_group_key_none_url(self):
|
||||||
|
"""None hf_url returns None."""
|
||||||
|
assert BaseModelService._extract_hf_group_key({"hf_url": None}) is None
|
||||||
|
|
||||||
|
def test_hf_group_key_invalid_url(self):
|
||||||
|
"""Malformed HF URL returns None."""
|
||||||
|
assert BaseModelService._extract_hf_group_key({"hf_url": "not-a-url"}) is None
|
||||||
|
assert BaseModelService._extract_hf_group_key({"hf_url": "https://example.com"}) is None
|
||||||
|
|
||||||
|
# --- _extract_group_key ---
|
||||||
|
|
||||||
|
def test_group_key_civitai_only(self):
|
||||||
|
"""CivitAI modelId returned as int."""
|
||||||
|
item = {"civitai": {"modelId": 123}}
|
||||||
|
assert BaseModelService._extract_group_key(item) == 123
|
||||||
|
|
||||||
|
def test_group_key_hf_only(self):
|
||||||
|
"""HF-only item returns hf:user/repo string."""
|
||||||
|
item = {"hf_url": "https://huggingface.co/user/repo"}
|
||||||
|
assert BaseModelService._extract_group_key(item) == "hf:user/repo"
|
||||||
|
|
||||||
|
def test_group_key_civitai_preferred(self):
|
||||||
|
"""CivitAI modelId takes precedence over hf_url."""
|
||||||
|
item = {
|
||||||
|
"civitai": {"modelId": 456},
|
||||||
|
"hf_url": "https://huggingface.co/other/repo",
|
||||||
|
}
|
||||||
|
assert BaseModelService._extract_group_key(item) == 456
|
||||||
|
|
||||||
|
def test_group_key_neither(self):
|
||||||
|
"""No CivitAI or HF returns None."""
|
||||||
|
assert BaseModelService._extract_group_key({}) is None
|
||||||
|
assert BaseModelService._extract_group_key({"some": "data"}) is None
|
||||||
|
|
||||||
|
def test_group_key_civitai_none_model_id(self):
|
||||||
|
"""civitai.modelId=None falls through to HF."""
|
||||||
|
item = {
|
||||||
|
"civitai": {"modelId": None},
|
||||||
|
"hf_url": "https://huggingface.co/user/repo",
|
||||||
|
}
|
||||||
|
assert BaseModelService._extract_group_key(item) == "hf:user/repo"
|
||||||
|
|||||||
@@ -139,3 +139,122 @@ def test_contains_dynamic_syntax_detects_wildcards_and_options():
|
|||||||
assert contains_dynamic_syntax("__flower__") is True
|
assert contains_dynamic_syntax("__flower__") is True
|
||||||
assert contains_dynamic_syntax("{red|blue}") is True
|
assert contains_dynamic_syntax("{red|blue}") is True
|
||||||
assert contains_dynamic_syntax("{2$$, $$red|blue|green}") is True
|
assert contains_dynamic_syntax("{2$$, $$red|blue|green}") is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _pick_weighted_or_plain
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_weighted_or_plain_plain_values(monkeypatch, tmp_path):
|
||||||
|
"""Plain values without :: are picked via rng.choice (fast path)."""
|
||||||
|
service, _ = _make_service(monkeypatch, tmp_path)
|
||||||
|
|
||||||
|
import random
|
||||||
|
rng = random.Random(42)
|
||||||
|
|
||||||
|
result = service._pick_weighted_or_plain(["red", "green", "blue"], rng)
|
||||||
|
assert result in {"red", "green", "blue"}
|
||||||
|
assert "::" not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_weighted_or_plain_deterministic_with_seed(monkeypatch, tmp_path):
|
||||||
|
"""Same seed produces the same result for plain values."""
|
||||||
|
service, _ = _make_service(monkeypatch, tmp_path)
|
||||||
|
|
||||||
|
import random
|
||||||
|
first = service._pick_weighted_or_plain(["a", "b", "c"], random.Random(99))
|
||||||
|
second = service._pick_weighted_or_plain(["a", "b", "c"], random.Random(99))
|
||||||
|
assert first == second
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_weighted_or_plain_weighted_values(monkeypatch, tmp_path):
|
||||||
|
"""Weighted values use weighted selection and strip the N:: prefix."""
|
||||||
|
service, _ = _make_service(monkeypatch, tmp_path)
|
||||||
|
|
||||||
|
import random
|
||||||
|
values = ["3::apple", "1::banana"]
|
||||||
|
results = {"apple": 0, "banana": 0}
|
||||||
|
for seed in range(4000):
|
||||||
|
result = service._pick_weighted_or_plain(values, random.Random(seed))
|
||||||
|
assert result in results, f"Unexpected result: {result!r}"
|
||||||
|
assert "::" not in result
|
||||||
|
results[result] += 1
|
||||||
|
|
||||||
|
total = results["apple"] + results["banana"]
|
||||||
|
# 3:1 weight → apple ≈ 75%, banana ≈ 25%
|
||||||
|
assert 2700 < results["apple"] < 3300, f"apple count out of range: {results['apple']}"
|
||||||
|
assert 700 < results["banana"] < 1300, f"banana count out of range: {results['banana']}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_weighted_or_plain_weight_one_values(monkeypatch, tmp_path):
|
||||||
|
"""Values with explicit 1:: prefix have prefix stripped but are not weighted."""
|
||||||
|
service, _ = _make_service(monkeypatch, tmp_path)
|
||||||
|
|
||||||
|
import random
|
||||||
|
# All weights are 1.0 → no actual weighting, but :: prefix is stripped
|
||||||
|
values = ["1::foo", "1::bar"]
|
||||||
|
rng = random.Random(42)
|
||||||
|
results = {service._pick_weighted_or_plain(values, rng) for _ in range(200)}
|
||||||
|
assert results == {"foo", "bar"}
|
||||||
|
# Ensure the prefix is always stripped
|
||||||
|
for result in results:
|
||||||
|
assert "::" not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_weighted_or_plain_mixed_weighted_and_plain(monkeypatch, tmp_path):
|
||||||
|
"""Mixed list with some weighted and some unweighted values."""
|
||||||
|
service, _ = _make_service(monkeypatch, tmp_path)
|
||||||
|
|
||||||
|
import random
|
||||||
|
values = ["5::x", "y", "z"] # x has weight 5, y/z have default weight 1
|
||||||
|
results = {"x": 0, "y": 0, "z": 0}
|
||||||
|
for seed in range(4000):
|
||||||
|
result = service._pick_weighted_or_plain(values, random.Random(seed))
|
||||||
|
assert result in results
|
||||||
|
assert "::" not in result
|
||||||
|
results[result] += 1
|
||||||
|
|
||||||
|
# x (5) vs combined y+z (1+1=2) → ~71% / ~29%
|
||||||
|
x_pct = results["x"] / sum(results.values())
|
||||||
|
assert 0.65 < x_pct < 0.78, f"x proportion out of range: {x_pct:.3f}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_weighted_or_plain_invalid_weight_prefix(monkeypatch, tmp_path):
|
||||||
|
"""Invalid numeric prefix (e.g. 1.2.3) is NOT treated as a weight and
|
||||||
|
the prefix is NOT stripped, matching the updated strict regex."""
|
||||||
|
service, _ = _make_service(monkeypatch, tmp_path)
|
||||||
|
|
||||||
|
import random
|
||||||
|
rng = random.Random(42)
|
||||||
|
|
||||||
|
# "1.2.3::a" is not a valid number → treated as plain text value
|
||||||
|
result = service._pick_weighted_or_plain(["1.2.3::a", "b"], rng)
|
||||||
|
# It should keep the full text including :: because the prefix isn't a
|
||||||
|
# valid numeric weight according to the strict regex
|
||||||
|
assert result == "1.2.3::a" or result == "b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_weighted_or_plain_glob_aggregation(monkeypatch, tmp_path):
|
||||||
|
"""Weighted wildcard resolution through glob aggregation (__*__)."""
|
||||||
|
service, wildcards_dir = _make_service(monkeypatch, tmp_path)
|
||||||
|
wildcards_dir.mkdir()
|
||||||
|
|
||||||
|
(wildcards_dir / "animals").mkdir()
|
||||||
|
(wildcards_dir / "animals" / "cat.txt").write_text("3::tabby\n1::persian\n", encoding="utf-8")
|
||||||
|
(wildcards_dir / "animals" / "dog.txt").write_text("retriever\npoodle\n", encoding="utf-8")
|
||||||
|
|
||||||
|
# __animals/*__ aggregates all values across both files
|
||||||
|
# Weighted values should have :: stripped
|
||||||
|
results = {"tabby": 0, "persian": 0, "retriever": 0, "poodle": 0}
|
||||||
|
for seed in range(4000):
|
||||||
|
expanded = service.expand_text("__animals/*__", seed=seed)
|
||||||
|
assert expanded in results, f"Unexpected result: {expanded!r}"
|
||||||
|
assert "::" not in expanded
|
||||||
|
results[expanded] += 1
|
||||||
|
|
||||||
|
# tabby (3) vs persian (1) → ~75% / ~25% within the cat subset
|
||||||
|
cat_total = results["tabby"] + results["persian"]
|
||||||
|
if cat_total > 0:
|
||||||
|
tabby_pct = results["tabby"] / cat_total
|
||||||
|
assert 0.65 < tabby_pct < 0.85, f"tabby proportion out of range: {tabby_pct:.3f}"
|
||||||
|
|||||||
@@ -718,8 +718,10 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
setValue: function(v) {
|
setValue: function(v) {
|
||||||
|
// Ensure v is an array; handle falsy, string, or object values safely
|
||||||
|
v = Array.isArray(v) ? v : [];
|
||||||
// Remove duplicates by keeping the last occurrence of each lora name
|
// Remove duplicates by keeping the last occurrence of each lora name
|
||||||
const uniqueValue = (v || []).reduce((acc, lora) => {
|
const uniqueValue = v.reduce((acc, lora) => {
|
||||||
// Remove any existing lora with the same name
|
// Remove any existing lora with the same name
|
||||||
const filtered = acc.filter(l => l.name !== lora.name);
|
const filtered = acc.filter(l => l.name !== lora.name);
|
||||||
// Add the current lora
|
// Add the current lora
|
||||||
|
|||||||
@@ -7,12 +7,16 @@ import { app } from "../../scripts/app.js";
|
|||||||
// Roles are stored in ``node.properties.lm_marker_role`` and automatically
|
// Roles are stored in ``node.properties.lm_marker_role`` and automatically
|
||||||
// persist with the workflow JSON.
|
// persist with the workflow JSON.
|
||||||
//
|
//
|
||||||
|
// Two categories:
|
||||||
|
// send_* – consumed by the standalone UI's "Send to Workflow" feature
|
||||||
|
// meta_* – consumed by the metadata processor to override heuristic inference
|
||||||
|
//
|
||||||
// The workflow registry reads these markers and makes them available to the
|
// The workflow registry reads these markers and makes them available to the
|
||||||
// standalone UI (e.g. ``sendEmbeddingToWorkflow`` also considers nodes marked
|
// standalone UI (e.g. ``sendEmbeddingToWorkflow`` also considers nodes marked
|
||||||
// as ``send_prompt_target``).
|
// as ``send_prompt_target``).
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
const ROLES = {
|
const SEND_ROLES = {
|
||||||
send_prompt_target: {
|
send_prompt_target: {
|
||||||
label: "Send Prompt Target",
|
label: "Send Prompt Target",
|
||||||
emoji: "\uD83D\uDCDD",
|
emoji: "\uD83D\uDCDD",
|
||||||
@@ -23,6 +27,28 @@ const ROLES = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const META_ROLES = {
|
||||||
|
meta_primary_model: {
|
||||||
|
label: "Meta hints: Primary Model",
|
||||||
|
emoji: "\uD83D\uDCA1",
|
||||||
|
},
|
||||||
|
meta_primary_sampler: {
|
||||||
|
label: "Meta hints: Primary Sampler",
|
||||||
|
emoji: "\uD83D\uDCA1",
|
||||||
|
},
|
||||||
|
meta_positive_prompt: {
|
||||||
|
label: "Meta hints: Positive Prompt",
|
||||||
|
emoji: "\uD83D\uDCA1",
|
||||||
|
},
|
||||||
|
meta_negative_prompt: {
|
||||||
|
label: "Meta hints: Negative Prompt",
|
||||||
|
emoji: "\uD83D\uDCA1",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Flat lookup for setMarker / getMarker / clearMarker
|
||||||
|
const ROLES = { ...SEND_ROLES, ...META_ROLES };
|
||||||
|
|
||||||
// ---- Helpers ----------------------------------------------------------------
|
// ---- Helpers ----------------------------------------------------------------
|
||||||
|
|
||||||
function getMarker(node) {
|
function getMarker(node) {
|
||||||
@@ -54,7 +80,7 @@ function clearMarker(node) {
|
|||||||
// Restore original title: prefer stripping emoji from current title
|
// Restore original title: prefer stripping emoji from current title
|
||||||
// (captures user renames after marking), fall back to saved original.
|
// (captures user renames after marking), fall back to saved original.
|
||||||
const cleaned = node.title?.replace(
|
const cleaned = node.title?.replace(
|
||||||
/^(\u2709\uFE0F?|\u2699\uFE0F?|\uD83D\uDCDD|\uD83C\uDF9B\uFE0F?|\uD83D\uDD27)\s*/,
|
/^(\u2709\uFE0F?|\u2699\uFE0F?|\uD83D\uDCDD|\uD83C\uDF9B\uFE0F?|\uD83D\uDD27|\uD83D\uDCA1)\s*/,
|
||||||
''
|
''
|
||||||
);
|
);
|
||||||
if (cleaned && cleaned !== node.title) {
|
if (cleaned && cleaned !== node.title) {
|
||||||
@@ -84,16 +110,23 @@ function buildSubmenuOptions(node) {
|
|||||||
const currentRole = getMarker(node);
|
const currentRole = getMarker(node);
|
||||||
const options = [];
|
const options = [];
|
||||||
|
|
||||||
for (const [key, def] of Object.entries(ROLES)) {
|
const buildGroup = (roles) => {
|
||||||
const isActive = currentRole === key;
|
for (const [key, def] of Object.entries(roles)) {
|
||||||
options.push({
|
const isActive = currentRole === key;
|
||||||
content: `${isActive ? "\u2713 " : ""}${def.label}`,
|
options.push({
|
||||||
disabled: isActive,
|
content: `${isActive ? "\u2713 " : ""}${def.label}`,
|
||||||
callback: () => setMarker(node, key),
|
disabled: isActive,
|
||||||
});
|
callback: () => setMarker(node, key),
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
buildGroup(SEND_ROLES);
|
||||||
|
options.push(null); // separator
|
||||||
|
buildGroup(META_ROLES);
|
||||||
|
|
||||||
if (currentRole) {
|
if (currentRole) {
|
||||||
|
options.push(null); // separator
|
||||||
options.push({
|
options.push({
|
||||||
content: "Clear marker",
|
content: "Clear marker",
|
||||||
callback: () => clearMarker(node),
|
callback: () => clearMarker(node),
|
||||||
|
|||||||
Reference in New Issue
Block a user