From 6120922204628036e644b8028f0ec69927a4b13a Mon Sep 17 00:00:00 2001 From: pixelpaws Date: Sat, 11 Oct 2025 17:38:20 +0800 Subject: [PATCH] chore(priority-tags): add newline terminator --- docs/custom_priority_tags_format.md | 46 ++++ locales/de.json | 24 +- locales/en.json | 22 ++ locales/es.json | 24 +- locales/fr.json | 26 +- locales/he.json | 28 ++- locales/ja.json | 28 ++- locales/ko.json | 28 ++- locales/ru.json | 28 ++- locales/zh-CN.json | 36 ++- locales/zh-TW.json | 28 ++- py/routes/handlers/misc_handlers.py | 10 + py/routes/misc_route_registrar.py | 1 + py/services/download_manager.py | 17 +- py/services/settings_manager.py | 64 ++++- py/utils/constants.py | 9 +- py/utils/tag_priorities.py | 104 ++++++++ py/utils/utils.py | 12 +- .../css/components/modal/settings-modal.css | 65 +++++ static/js/components/shared/ModelTags.js | 99 ++++++-- static/js/managers/BulkManager.js | 62 +++-- static/js/managers/SettingsManager.js | 150 +++++++++++- static/js/state/index.js | 4 +- static/js/utils/constants.js | 14 +- static/js/utils/priorityTagHelpers.js | 223 ++++++++++++++++++ .../components/modals/settings_modal.html | 26 ++ 26 files changed, 1079 insertions(+), 99 deletions(-) create mode 100644 docs/custom_priority_tags_format.md create mode 100644 py/utils/tag_priorities.py create mode 100644 static/js/utils/priorityTagHelpers.js diff --git a/docs/custom_priority_tags_format.md b/docs/custom_priority_tags_format.md new file mode 100644 index 00000000..05e068e5 --- /dev/null +++ b/docs/custom_priority_tags_format.md @@ -0,0 +1,46 @@ +# Custom Priority Tag Format Proposal + +To support user-defined priority tags with flexible aliasing across different model types, the configuration will be stored as editable strings. The format balances readability with enough structure for parsing on both the backend and frontend. + +## Format Overview + +- Each model type is declared on its own line: `model_type: entries`. +- Entries are comma-separated and ordered by priority from highest to lowest. +- An entry may be a single canonical tag (e.g., `realistic`) or a canonical tag with aliases. +- Canonical tags define the final folder name that should be used when matching that entry. +- Aliases are enclosed in parentheses and separated by `|` (vertical bar). +- All matching is case-insensitive; stored canonical names preserve the user-specified casing for folder creation and UI suggestions. + +### Grammar + +``` +priority-config := model-config { "\n" model-config } +model-config := model-type ":" entry-list +model-type := +entry-list := entry { "," entry } +entry := canonical [ "(" alias { "|" alias } ")" ] +canonical := +alias := +``` + +Examples: + +``` +lora: celebrity(celeb|celebrity), stylized, character(char) +checkpoint: realistic(realism|realistic), anime(anime-style|toon) +embedding: face, celeb(celebrity|celeb) +``` + +## Parsing Notes + +- Whitespace around separators is ignored to make manual editing more forgiving. +- Duplicate canonical tags within the same model type collapse to a single entry; the first definition wins. +- Aliases map to their canonical tag. When generating folder names, the canonical form is used. +- Tags that do not match any alias or canonical entry fall back to the first tag in the model's tag list, preserving current behavior. + +## Usage + +- **Backend:** Convert each model type's string into an ordered list of canonical tags with alias sets. During path generation, iterate by priority order and match tags against both canonical names and their aliases. +- **Frontend:** Surface canonical tags as suggestions, optionally displaying aliases in tooltips or secondary text. Input validation should warn about duplicate aliases within the same model type. + +This format allows users to customize priority tag handling per model type while keeping editing simple and avoiding proliferation of folder names through alias normalization. diff --git a/locales/de.json b/locales/de.json index b52eeead..4c07fe58 100644 --- a/locales/de.json +++ b/locales/de.json @@ -32,7 +32,7 @@ "korean": "한국어", "french": "Français", "spanish": "Español", - "Hebrew": "עברית" + "Hebrew": "עברית" }, "fileSize": { "zero": "0 Bytes", @@ -199,6 +199,7 @@ "videoSettings": "Video-Einstellungen", "layoutSettings": "Layout-Einstellungen", "folderSettings": "Ordner-Einstellungen", + "priorityTags": "Priority Tags", "downloadPathTemplates": "Download-Pfad-Vorlagen", "exampleImages": "Beispielbilder", "misc": "Verschiedenes", @@ -253,6 +254,27 @@ "defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest", "noDefault": "Kein Standard" }, + "priorityTags": { + "title": "Priority Tags", + "description": "Customize the tag priority order for each model type. The first match becomes the folder name and powers tag suggestions.", + "placeholder": "celebrity(celeb|celebrity), stylized, character(char)", + "help": "Separate entries with commas. Add aliases inside parentheses using the | symbol.", + "aliasHint": "Tip: Keep canonical names short and meaningful. You can use new lines to group related entries.", + "modelTypes": { + "lora": "LoRA priority", + "checkpoint": "Checkpoint priority", + "embedding": "Embedding priority" + }, + "saveSuccess": "Priority tags updated.", + "saveError": "Failed to update priority tags.", + "loadingSuggestions": "Loading suggestions…", + "validation": { + "missingClosingParen": "Entry {index} is missing a closing parenthesis.", + "missingCanonical": "Entry {index} must include a canonical tag name.", + "duplicateCanonical": "The canonical tag \"{tag}\" appears more than once.", + "unknown": "Invalid priority tag configuration." + } + }, "downloadPathTemplates": { "title": "Download-Pfad-Vorlagen", "help": "Konfigurieren Sie Ordnerstrukturen für verschiedene Modelltypen beim Herunterladen von Civitai.", diff --git a/locales/en.json b/locales/en.json index 49fd7004..89ece040 100644 --- a/locales/en.json +++ b/locales/en.json @@ -199,6 +199,7 @@ "videoSettings": "Video Settings", "layoutSettings": "Layout Settings", "folderSettings": "Folder Settings", + "priorityTags": "Priority Tags", "downloadPathTemplates": "Download Path Templates", "exampleImages": "Example Images", "misc": "Misc.", @@ -253,6 +254,27 @@ "defaultEmbeddingRootHelp": "Set the default embedding root directory for downloads, imports and moves", "noDefault": "No Default" }, + "priorityTags": { + "title": "Priority Tags", + "description": "Customize the tag priority order for each model type. The first match becomes the folder name and powers tag suggestions.", + "placeholder": "celebrity(celeb|celebrity), stylized, character(char)", + "help": "Separate entries with commas. Add aliases inside parentheses using the | symbol.", + "aliasHint": "Tip: Keep canonical names short and meaningful. You can use new lines to group related entries.", + "modelTypes": { + "lora": "LoRA priority", + "checkpoint": "Checkpoint priority", + "embedding": "Embedding priority" + }, + "saveSuccess": "Priority tags updated.", + "saveError": "Failed to update priority tags.", + "loadingSuggestions": "Loading suggestions…", + "validation": { + "missingClosingParen": "Entry {index} is missing a closing parenthesis.", + "missingCanonical": "Entry {index} must include a canonical tag name.", + "duplicateCanonical": "The canonical tag \"{tag}\" appears more than once.", + "unknown": "Invalid priority tag configuration." + } + }, "downloadPathTemplates": { "title": "Download Path Templates", "help": "Configure folder structures for different model types when downloading from Civitai.", diff --git a/locales/es.json b/locales/es.json index 84268244..452c4b78 100644 --- a/locales/es.json +++ b/locales/es.json @@ -32,7 +32,7 @@ "korean": "한국어", "french": "Français", "spanish": "Español", - "Hebrew": "עברית" + "Hebrew": "עברית" }, "fileSize": { "zero": "0 Bytes", @@ -199,6 +199,7 @@ "videoSettings": "Configuración de video", "layoutSettings": "Configuración de diseño", "folderSettings": "Configuración de carpetas", + "priorityTags": "Priority Tags", "downloadPathTemplates": "Plantillas de rutas de descarga", "exampleImages": "Imágenes de ejemplo", "misc": "Varios", @@ -253,6 +254,27 @@ "defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos", "noDefault": "Sin predeterminado" }, + "priorityTags": { + "title": "Priority Tags", + "description": "Customize the tag priority order for each model type. The first match becomes the folder name and powers tag suggestions.", + "placeholder": "celebrity(celeb|celebrity), stylized, character(char)", + "help": "Separate entries with commas. Add aliases inside parentheses using the | symbol.", + "aliasHint": "Tip: Keep canonical names short and meaningful. You can use new lines to group related entries.", + "modelTypes": { + "lora": "LoRA priority", + "checkpoint": "Checkpoint priority", + "embedding": "Embedding priority" + }, + "saveSuccess": "Priority tags updated.", + "saveError": "Failed to update priority tags.", + "loadingSuggestions": "Loading suggestions…", + "validation": { + "missingClosingParen": "Entry {index} is missing a closing parenthesis.", + "missingCanonical": "Entry {index} must include a canonical tag name.", + "duplicateCanonical": "The canonical tag \"{tag}\" appears more than once.", + "unknown": "Invalid priority tag configuration." + } + }, "downloadPathTemplates": { "title": "Plantillas de rutas de descarga", "help": "Configurar estructuras de carpetas para diferentes tipos de modelos al descargar de Civitai.", diff --git a/locales/fr.json b/locales/fr.json index 69360a8d..b7f7a3e9 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -32,7 +32,7 @@ "korean": "한국어", "french": "Français", "spanish": "Español", - "Hebrew": "עברית" + "Hebrew": "עברית" }, "fileSize": { "zero": "0 Octets", @@ -203,7 +203,8 @@ "exampleImages": "Images d'exemple", "misc": "Divers", "metadataArchive": "Base de données d'archive des métadonnées", - "proxySettings": "Paramètres du proxy" + "proxySettings": "Paramètres du proxy", + "priorityTags": "Priority Tags" }, "contentFiltering": { "blurNsfwContent": "Flouter le contenu NSFW", @@ -345,6 +346,27 @@ "proxyPassword": "Mot de passe (optionnel)", "proxyPasswordPlaceholder": "mot_de_passe", "proxyPasswordHelp": "Mot de passe pour l'authentification proxy (si nécessaire)" + }, + "priorityTags": { + "title": "Priority Tags", + "description": "Customize the tag priority order for each model type. The first match becomes the folder name and powers tag suggestions.", + "placeholder": "celebrity(celeb|celebrity), stylized, character(char)", + "help": "Separate entries with commas. Add aliases inside parentheses using the | symbol.", + "aliasHint": "Tip: Keep canonical names short and meaningful. You can use new lines to group related entries.", + "modelTypes": { + "lora": "LoRA priority", + "checkpoint": "Checkpoint priority", + "embedding": "Embedding priority" + }, + "saveSuccess": "Priority tags updated.", + "saveError": "Failed to update priority tags.", + "loadingSuggestions": "Loading suggestions…", + "validation": { + "missingClosingParen": "Entry {index} is missing a closing parenthesis.", + "missingCanonical": "Entry {index} must include a canonical tag name.", + "duplicateCanonical": "The canonical tag \"{tag}\" appears more than once.", + "unknown": "Invalid priority tag configuration." + } } }, "loras": { diff --git a/locales/he.json b/locales/he.json index 2c9a7f66..b29a94ac 100644 --- a/locales/he.json +++ b/locales/he.json @@ -32,7 +32,7 @@ "korean": "한국어", "french": "Français", "spanish": "Español", - "Hebrew": "עברית" + "Hebrew": "עברית" }, "fileSize": { "zero": "0 בתים", @@ -203,7 +203,8 @@ "exampleImages": "תמונות דוגמה", "misc": "שונות", "metadataArchive": "מסד נתונים של ארכיון מטא-דאטה", - "proxySettings": "הגדרות פרוקסי" + "proxySettings": "הגדרות פרוקסי", + "priorityTags": "Priority Tags" }, "contentFiltering": { "blurNsfwContent": "טשטש תוכן NSFW", @@ -345,6 +346,27 @@ "proxyPassword": "סיסמה (אופציונלי)", "proxyPasswordPlaceholder": "password", "proxyPasswordHelp": "סיסמה לאימות מול הפרוקסי (אם נדרש)" + }, + "priorityTags": { + "title": "Priority Tags", + "description": "Customize the tag priority order for each model type. The first match becomes the folder name and powers tag suggestions.", + "placeholder": "celebrity(celeb|celebrity), stylized, character(char)", + "help": "Separate entries with commas. Add aliases inside parentheses using the | symbol.", + "aliasHint": "Tip: Keep canonical names short and meaningful. You can use new lines to group related entries.", + "modelTypes": { + "lora": "LoRA priority", + "checkpoint": "Checkpoint priority", + "embedding": "Embedding priority" + }, + "saveSuccess": "Priority tags updated.", + "saveError": "Failed to update priority tags.", + "loadingSuggestions": "Loading suggestions…", + "validation": { + "missingClosingParen": "Entry {index} is missing a closing parenthesis.", + "missingCanonical": "Entry {index} must include a canonical tag name.", + "duplicateCanonical": "The canonical tag \"{tag}\" appears more than once.", + "unknown": "Invalid priority tag configuration." + } } }, "loras": { @@ -1267,4 +1289,4 @@ "learnMore": "LM Civitai Extension Tutorial" } } -} \ No newline at end of file +} diff --git a/locales/ja.json b/locales/ja.json index 319d46aa..38fa24af 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -32,7 +32,7 @@ "korean": "한국어", "french": "Français", "spanish": "Español", - "Hebrew": "עברית" + "Hebrew": "עברית" }, "fileSize": { "zero": "0バイト", @@ -203,7 +203,8 @@ "exampleImages": "例画像", "misc": "その他", "metadataArchive": "メタデータアーカイブデータベース", - "proxySettings": "プロキシ設定" + "proxySettings": "プロキシ設定", + "priorityTags": "Priority Tags" }, "contentFiltering": { "blurNsfwContent": "NSFWコンテンツをぼかす", @@ -345,6 +346,27 @@ "proxyPassword": "パスワード(任意)", "proxyPasswordPlaceholder": "パスワード", "proxyPasswordHelp": "プロキシ認証用のパスワード(必要な場合)" + }, + "priorityTags": { + "title": "Priority Tags", + "description": "Customize the tag priority order for each model type. The first match becomes the folder name and powers tag suggestions.", + "placeholder": "celebrity(celeb|celebrity), stylized, character(char)", + "help": "Separate entries with commas. Add aliases inside parentheses using the | symbol.", + "aliasHint": "Tip: Keep canonical names short and meaningful. You can use new lines to group related entries.", + "modelTypes": { + "lora": "LoRA priority", + "checkpoint": "Checkpoint priority", + "embedding": "Embedding priority" + }, + "saveSuccess": "Priority tags updated.", + "saveError": "Failed to update priority tags.", + "loadingSuggestions": "Loading suggestions…", + "validation": { + "missingClosingParen": "Entry {index} is missing a closing parenthesis.", + "missingCanonical": "Entry {index} must include a canonical tag name.", + "duplicateCanonical": "The canonical tag \"{tag}\" appears more than once.", + "unknown": "Invalid priority tag configuration." + } } }, "loras": { @@ -1267,4 +1289,4 @@ "learnMore": "LM Civitai Extension Tutorial" } } -} \ No newline at end of file +} diff --git a/locales/ko.json b/locales/ko.json index 98813f25..36cc513e 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -32,7 +32,7 @@ "korean": "한국어", "french": "Français", "spanish": "Español", - "Hebrew": "עברית" + "Hebrew": "עברית" }, "fileSize": { "zero": "0 바이트", @@ -203,7 +203,8 @@ "exampleImages": "예시 이미지", "misc": "기타", "metadataArchive": "메타데이터 아카이브 데이터베이스", - "proxySettings": "프록시 설정" + "proxySettings": "프록시 설정", + "priorityTags": "Priority Tags" }, "contentFiltering": { "blurNsfwContent": "NSFW 콘텐츠 블러 처리", @@ -345,6 +346,27 @@ "proxyPassword": "비밀번호 (선택사항)", "proxyPasswordPlaceholder": "password", "proxyPasswordHelp": "프록시 인증에 필요한 비밀번호 (필요한 경우)" + }, + "priorityTags": { + "title": "Priority Tags", + "description": "Customize the tag priority order for each model type. The first match becomes the folder name and powers tag suggestions.", + "placeholder": "celebrity(celeb|celebrity), stylized, character(char)", + "help": "Separate entries with commas. Add aliases inside parentheses using the | symbol.", + "aliasHint": "Tip: Keep canonical names short and meaningful. You can use new lines to group related entries.", + "modelTypes": { + "lora": "LoRA priority", + "checkpoint": "Checkpoint priority", + "embedding": "Embedding priority" + }, + "saveSuccess": "Priority tags updated.", + "saveError": "Failed to update priority tags.", + "loadingSuggestions": "Loading suggestions…", + "validation": { + "missingClosingParen": "Entry {index} is missing a closing parenthesis.", + "missingCanonical": "Entry {index} must include a canonical tag name.", + "duplicateCanonical": "The canonical tag \"{tag}\" appears more than once.", + "unknown": "Invalid priority tag configuration." + } } }, "loras": { @@ -1267,4 +1289,4 @@ "learnMore": "LM Civitai Extension Tutorial" } } -} \ No newline at end of file +} diff --git a/locales/ru.json b/locales/ru.json index ab6a2e5a..32242e82 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -32,7 +32,7 @@ "korean": "한국어", "french": "Français", "spanish": "Español", - "Hebrew": "עברית" + "Hebrew": "עברית" }, "fileSize": { "zero": "0 Байт", @@ -203,7 +203,8 @@ "exampleImages": "Примеры изображений", "misc": "Разное", "metadataArchive": "Архив метаданных", - "proxySettings": "Настройки прокси" + "proxySettings": "Настройки прокси", + "priorityTags": "Priority Tags" }, "contentFiltering": { "blurNsfwContent": "Размывать NSFW контент", @@ -345,6 +346,27 @@ "proxyPassword": "Пароль (необязательно)", "proxyPasswordPlaceholder": "пароль", "proxyPasswordHelp": "Пароль для аутентификации на прокси (если требуется)" + }, + "priorityTags": { + "title": "Priority Tags", + "description": "Customize the tag priority order for each model type. The first match becomes the folder name and powers tag suggestions.", + "placeholder": "celebrity(celeb|celebrity), stylized, character(char)", + "help": "Separate entries with commas. Add aliases inside parentheses using the | symbol.", + "aliasHint": "Tip: Keep canonical names short and meaningful. You can use new lines to group related entries.", + "modelTypes": { + "lora": "LoRA priority", + "checkpoint": "Checkpoint priority", + "embedding": "Embedding priority" + }, + "saveSuccess": "Priority tags updated.", + "saveError": "Failed to update priority tags.", + "loadingSuggestions": "Loading suggestions…", + "validation": { + "missingClosingParen": "Entry {index} is missing a closing parenthesis.", + "missingCanonical": "Entry {index} must include a canonical tag name.", + "duplicateCanonical": "The canonical tag \"{tag}\" appears more than once.", + "unknown": "Invalid priority tag configuration." + } } }, "loras": { @@ -1267,4 +1289,4 @@ "learnMore": "LM Civitai Extension Tutorial" } } -} \ No newline at end of file +} diff --git a/locales/zh-CN.json b/locales/zh-CN.json index ecc4edb8..9437ac55 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -26,19 +26,13 @@ "english": "English", "chinese_simplified": "中文(简体)", "chinese_traditional": "中文(繁体)", - "russian": "俄语", - "german": "德语", - "japanese": "日语", - "korean": "韩语", - "french": "法语", - "spanish": "西班牙语", - "Hebrew": "עברית", "russian": "Русский", "german": "Deutsch", "japanese": "日本語", "korean": "한국어", "french": "Français", - "spanish": "Español" + "spanish": "Español", + "Hebrew": "עברית" }, "fileSize": { "zero": "0 字节", @@ -209,7 +203,8 @@ "exampleImages": "示例图片", "misc": "其他", "metadataArchive": "元数据归档数据库", - "proxySettings": "代理设置" + "proxySettings": "代理设置", + "priorityTags": "Priority Tags" }, "contentFiltering": { "blurNsfwContent": "模糊 NSFW 内容", @@ -351,6 +346,27 @@ "proxyPassword": "密码 (可选)", "proxyPasswordPlaceholder": "密码", "proxyPasswordHelp": "代理认证的密码 (如果需要)" + }, + "priorityTags": { + "title": "Priority Tags", + "description": "Customize the tag priority order for each model type. The first match becomes the folder name and powers tag suggestions.", + "placeholder": "celebrity(celeb|celebrity), stylized, character(char)", + "help": "Separate entries with commas. Add aliases inside parentheses using the | symbol.", + "aliasHint": "Tip: Keep canonical names short and meaningful. You can use new lines to group related entries.", + "modelTypes": { + "lora": "LoRA priority", + "checkpoint": "Checkpoint priority", + "embedding": "Embedding priority" + }, + "saveSuccess": "Priority tags updated.", + "saveError": "Failed to update priority tags.", + "loadingSuggestions": "Loading suggestions…", + "validation": { + "missingClosingParen": "Entry {index} is missing a closing parenthesis.", + "missingCanonical": "Entry {index} must include a canonical tag name.", + "duplicateCanonical": "The canonical tag \"{tag}\" appears more than once.", + "unknown": "Invalid priority tag configuration." + } } }, "loras": { @@ -1273,4 +1289,4 @@ "learnMore": "LM Civitai Extension Tutorial" } } -} \ No newline at end of file +} diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 53ed58be..74ac065a 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -32,7 +32,7 @@ "korean": "한국어", "french": "Français", "spanish": "Español", - "Hebrew": "עברית" + "Hebrew": "עברית" }, "fileSize": { "zero": "0 位元組", @@ -203,7 +203,8 @@ "exampleImages": "範例圖片", "misc": "其他", "metadataArchive": "中繼資料封存資料庫", - "proxySettings": "代理設定" + "proxySettings": "代理設定", + "priorityTags": "Priority Tags" }, "contentFiltering": { "blurNsfwContent": "模糊 NSFW 內容", @@ -345,6 +346,27 @@ "proxyPassword": "密碼(選填)", "proxyPasswordPlaceholder": "password", "proxyPasswordHelp": "代理驗證所需的密碼(如有需要)" + }, + "priorityTags": { + "title": "Priority Tags", + "description": "Customize the tag priority order for each model type. The first match becomes the folder name and powers tag suggestions.", + "placeholder": "celebrity(celeb|celebrity), stylized, character(char)", + "help": "Separate entries with commas. Add aliases inside parentheses using the | symbol.", + "aliasHint": "Tip: Keep canonical names short and meaningful. You can use new lines to group related entries.", + "modelTypes": { + "lora": "LoRA priority", + "checkpoint": "Checkpoint priority", + "embedding": "Embedding priority" + }, + "saveSuccess": "Priority tags updated.", + "saveError": "Failed to update priority tags.", + "loadingSuggestions": "Loading suggestions…", + "validation": { + "missingClosingParen": "Entry {index} is missing a closing parenthesis.", + "missingCanonical": "Entry {index} must include a canonical tag name.", + "duplicateCanonical": "The canonical tag \"{tag}\" appears more than once.", + "unknown": "Invalid priority tag configuration." + } } }, "loras": { @@ -1267,4 +1289,4 @@ "learnMore": "LM Civitai Extension Tutorial" } } -} \ No newline at end of file +} diff --git a/py/routes/handlers/misc_handlers.py b/py/routes/handlers/misc_handlers.py index 2a6359b6..30443149 100644 --- a/py/routes/handlers/misc_handlers.py +++ b/py/routes/handlers/misc_handlers.py @@ -162,6 +162,7 @@ class SettingsHandler: "include_trigger_words", "show_only_sfw", "compact_mode", + "priority_tags", ) _PROXY_KEYS = {"proxy_enabled", "proxy_host", "proxy_port", "proxy_username", "proxy_password", "proxy_type"} @@ -207,6 +208,14 @@ class SettingsHandler: logger.error("Error getting settings: %s", exc, exc_info=True) return web.json_response({"success": False, "error": str(exc)}, status=500) + async def get_priority_tags(self, request: web.Request) -> web.Response: + try: + suggestions = self._settings.get_priority_tag_suggestions() + return web.json_response({"success": True, "tags": suggestions}) + except Exception as exc: # pragma: no cover - defensive logging + logger.error("Error getting priority tags: %s", exc, exc_info=True) + return web.json_response({"success": False, "error": str(exc)}, status=500) + async def activate_library(self, request: web.Request) -> web.Response: """Activate the selected library.""" @@ -942,6 +951,7 @@ class MiscHandlerSet: "health_check": self.health.health_check, "get_settings": self.settings.get_settings, "update_settings": self.settings.update_settings, + "get_priority_tags": self.settings.get_priority_tags, "get_settings_libraries": self.settings.get_libraries, "activate_library": self.settings.activate_library, "update_usage_stats": self.usage_stats.update_usage_stats, diff --git a/py/routes/misc_route_registrar.py b/py/routes/misc_route_registrar.py index 7cf5c62b..53068566 100644 --- a/py/routes/misc_route_registrar.py +++ b/py/routes/misc_route_registrar.py @@ -22,6 +22,7 @@ class RouteDefinition: MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( RouteDefinition("GET", "/api/lm/settings", "get_settings"), RouteDefinition("POST", "/api/lm/settings", "update_settings"), + RouteDefinition("GET", "/api/lm/priority-tags", "get_priority_tags"), RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"), RouteDefinition("POST", "/api/lm/settings/libraries/activate", "activate_library"), RouteDefinition("GET", "/api/lm/health-check", "health_check"), diff --git a/py/services/download_manager.py b/py/services/download_manager.py index 239c17d3..ea3a87d0 100644 --- a/py/services/download_manager.py +++ b/py/services/download_manager.py @@ -6,7 +6,7 @@ import uuid from typing import Dict, List from urllib.parse import urlparse from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata -from ..utils.constants import CARD_PREVIEW_WIDTH, VALID_LORA_TYPES, CIVITAI_MODEL_TAGS +from ..utils.constants import CARD_PREVIEW_WIDTH, VALID_LORA_TYPES from ..utils.civitai_utils import rewrite_preview_url from ..utils.exif_utils import ExifUtils from ..utils.metadata_manager import MetadataManager @@ -386,18 +386,9 @@ class DownloadManager: # Get model tags model_tags = version_info.get('model', {}).get('tags', []) - - # Find the first Civitai model tag that exists in model_tags - first_tag = '' - for civitai_tag in CIVITAI_MODEL_TAGS: - if civitai_tag in model_tags: - first_tag = civitai_tag - break - - # If no Civitai model tag found, fallback to first tag - if not first_tag and model_tags: - first_tag = model_tags[0] - + + first_tag = settings_manager.resolve_priority_tag_for_model(model_tags, model_type) + # Format the template with available data formatted_path = path_template formatted_path = formatted_path.replace('{base_model}', mapped_base_model) diff --git a/py/services/settings_manager.py b/py/services/settings_manager.py index 7e9311bd..62f67169 100644 --- a/py/services/settings_manager.py +++ b/py/services/settings_manager.py @@ -4,9 +4,16 @@ import os import logging from datetime import datetime, timezone from threading import Lock -from typing import Any, Dict, Iterable, List, Mapping, Optional +from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence +from ..utils.constants import DEFAULT_PRIORITY_TAG_CONFIG from ..utils.settings_paths import ensure_settings_file +from ..utils.tag_priorities import ( + PriorityTagEntry, + collect_canonical_tags, + parse_priority_tag_string, + resolve_priority_tag, +) logger = logging.getLogger(__name__) @@ -36,6 +43,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = { "card_info_display": "always", "include_trigger_words": False, "compact_mode": False, + "priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(), } @@ -63,6 +71,12 @@ class SettingsManager: def _ensure_default_settings(self) -> None: """Ensure all default settings keys exist""" updated = False + normalized_priority = self._normalize_priority_tag_config( + self.settings.get("priority_tags") + ) + if normalized_priority != self.settings.get("priority_tags"): + self.settings["priority_tags"] = normalized_priority + updated = True for key, value in self._get_default_settings().items(): if key not in self.settings: if isinstance(value, dict): @@ -385,8 +399,56 @@ class SettingsManager: # Ensure nested dicts are independent copies defaults['base_model_path_mappings'] = {} defaults['download_path_templates'] = {} + defaults['priority_tags'] = DEFAULT_PRIORITY_TAG_CONFIG.copy() return defaults + def _normalize_priority_tag_config(self, value: Any) -> Dict[str, str]: + normalized: Dict[str, str] = {} + if isinstance(value, Mapping): + for key, raw in value.items(): + if not isinstance(key, str) or not isinstance(raw, str): + continue + normalized[key] = raw.strip() + + for model_type, default_value in DEFAULT_PRIORITY_TAG_CONFIG.items(): + normalized.setdefault(model_type, default_value) + + return normalized + + def get_priority_tag_config(self) -> Dict[str, str]: + stored_value = self.settings.get("priority_tags") + normalized = self._normalize_priority_tag_config(stored_value) + if normalized != stored_value: + self.settings["priority_tags"] = normalized + self._save_settings() + return normalized.copy() + + def get_priority_tag_entries(self, model_type: str) -> List[PriorityTagEntry]: + config = self.get_priority_tag_config() + raw_config = config.get(model_type, "") + return parse_priority_tag_string(raw_config) + + def resolve_priority_tag_for_model( + self, tags: Sequence[str] | Iterable[str], model_type: str + ) -> str: + entries = self.get_priority_tag_entries(model_type) + resolved = resolve_priority_tag(tags, entries) + if resolved: + return resolved + + for tag in tags: + if isinstance(tag, str) and tag: + return tag + return "" + + def get_priority_tag_suggestions(self) -> Dict[str, List[str]]: + suggestions: Dict[str, List[str]] = {} + config = self.get_priority_tag_config() + for model_type, raw_value in config.items(): + entries = parse_priority_tag_string(raw_value) + suggestions[model_type] = collect_canonical_tags(entries) + return suggestions + def get(self, key: str, default: Any = None) -> Any: """Get setting value""" return self.settings.get(key, default) diff --git a/py/utils/constants.py b/py/utils/constants.py index 91ac3c54..fb8f578d 100644 --- a/py/utils/constants.py +++ b/py/utils/constants.py @@ -64,4 +64,11 @@ CIVITAI_MODEL_TAGS = [ 'realistic', 'anime', 'toon', 'furry', 'style', 'poses', 'background', 'tool', 'vehicle', 'buildings', 'objects', 'assets', 'animal', 'action' -] \ No newline at end of file +] + +# Default priority tag configuration strings for each model type +DEFAULT_PRIORITY_TAG_CONFIG = { + 'lora': ', '.join(CIVITAI_MODEL_TAGS), + 'checkpoint': ', '.join(CIVITAI_MODEL_TAGS), + 'embedding': ', '.join(CIVITAI_MODEL_TAGS), +} diff --git a/py/utils/tag_priorities.py b/py/utils/tag_priorities.py new file mode 100644 index 00000000..3bf2062a --- /dev/null +++ b/py/utils/tag_priorities.py @@ -0,0 +1,104 @@ +"""Helpers for parsing and resolving priority tag configurations.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Iterable, List, Optional, Sequence, Set + + +@dataclass(frozen=True) +class PriorityTagEntry: + """A parsed priority tag configuration entry.""" + + canonical: str + aliases: Set[str] + + @property + def normalized_aliases(self) -> Set[str]: + return {alias.lower() for alias in self.aliases} + + +def _normalize_alias(alias: str) -> str: + return alias.strip() + + +def parse_priority_tag_string(config: str | None) -> List[PriorityTagEntry]: + """Parse the user-facing priority tag string into structured entries.""" + + if not config: + return [] + + entries: List[PriorityTagEntry] = [] + seen_canonicals: Set[str] = set() + + for raw_entry in _split_priority_entries(config): + canonical, aliases = _parse_priority_entry(raw_entry) + if not canonical: + continue + + normalized_canonical = canonical.lower() + if normalized_canonical in seen_canonicals: + # Skip duplicate canonicals while preserving first occurrence priority + continue + seen_canonicals.add(normalized_canonical) + + alias_set = {canonical, *aliases} + cleaned_aliases = {_normalize_alias(alias) for alias in alias_set if _normalize_alias(alias)} + if not cleaned_aliases: + continue + + entries.append(PriorityTagEntry(canonical=canonical, aliases=cleaned_aliases)) + + return entries + + +def _split_priority_entries(config: str) -> List[str]: + # Split on commas while respecting that users may add new lines for readability + parts = [] + for chunk in config.split('\n'): + parts.extend(chunk.split(',')) + return [part.strip() for part in parts if part.strip()] + + +def _parse_priority_entry(entry: str) -> tuple[str, Set[str]]: + if '(' in entry and entry.endswith(')'): + canonical, raw_aliases = entry.split('(', 1) + canonical = canonical.strip() + alias_section = raw_aliases[:-1] # drop trailing ')' + aliases = {alias.strip() for alias in alias_section.split('|') if alias.strip()} + return canonical, aliases + + if '(' in entry and not entry.endswith(')'): + # Malformed entry; treat as literal canonical to avoid surprises + entry = entry.replace('(', '').replace(')', '') + + canonical = entry.strip() + return canonical, set() + + +def resolve_priority_tag( + tags: Sequence[str] | Iterable[str], + entries: Sequence[PriorityTagEntry], +) -> Optional[str]: + """Resolve the first matching canonical priority tag for the provided tags.""" + + tag_lookup: Dict[str, str] = {} + for tag in tags: + if not isinstance(tag, str): + continue + normalized = tag.lower() + if normalized not in tag_lookup: + tag_lookup[normalized] = tag + + for entry in entries: + for alias in entry.normalized_aliases: + if alias in tag_lookup: + return entry.canonical + + return None + + +def collect_canonical_tags(entries: Iterable[PriorityTagEntry]) -> List[str]: + """Return the ordered list of canonical tags from the parsed entries.""" + + return [entry.canonical for entry in entries] diff --git a/py/utils/utils.py b/py/utils/utils.py index 2c423122..c77136c6 100644 --- a/py/utils/utils.py +++ b/py/utils/utils.py @@ -4,7 +4,6 @@ from typing import Dict from ..services.service_registry import ServiceRegistry from ..config import config from ..services.settings_manager import get_settings_manager -from .constants import CIVITAI_MODEL_TAGS import asyncio def get_lora_info(lora_name): @@ -170,16 +169,7 @@ def calculate_relative_path_for_model(model_data: Dict, model_type: str = 'lora' base_model_mappings = settings_manager.get('base_model_path_mappings', {}) mapped_base_model = base_model_mappings.get(base_model, base_model) - # Find the first Civitai model tag that exists in model_tags - first_tag = '' - for civitai_tag in CIVITAI_MODEL_TAGS: - if civitai_tag in model_tags: - first_tag = civitai_tag - break - - # If no Civitai model tag found, fallback to first tag - if not first_tag and model_tags: - first_tag = model_tags[0] + first_tag = settings_manager.resolve_priority_tag_for_model(model_tags, model_type) if not first_tag: first_tag = 'no tags' # Default if no tags available diff --git a/static/css/components/modal/settings-modal.css b/static/css/components/modal/settings-modal.css index 409b8475..04f6e11a 100644 --- a/static/css/components/modal/settings-modal.css +++ b/static/css/components/modal/settings-modal.css @@ -204,6 +204,71 @@ width: 100%; /* Full width */ } +.settings-help-text { + font-size: 0.9em; + color: var(--text-color); + opacity: 0.8; + margin-bottom: var(--space-2); + line-height: 1.4; +} + +.settings-help-text.subtle { + font-size: 0.85em; + opacity: 0.7; + margin-top: var(--space-1); +} + +.priority-tags-grid { + display: grid; + gap: var(--space-2); +} + +@media (min-width: 640px) { + .priority-tags-grid { + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + } +} + +.priority-tags-group { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.priority-tags-input { + width: 100%; + min-height: 72px; + padding: 8px; + border-radius: var(--border-radius-xs); + border: 1px solid var(--border-color); + background-color: var(--lora-surface); + color: var(--text-color); + resize: vertical; +} + +.priority-tags-input:focus { + border-color: var(--lora-accent); + outline: none; + box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.1); +} + +.priority-tags-input.input-error { + border-color: var(--danger-color, #dc2626); + box-shadow: 0 0 0 2px rgba(220, 38, 38, 0.12); +} + +.input-error-message { + font-size: 0.8em; + color: var(--danger-color, #dc2626); + display: none; +} + +.metadata-suggestions-loading { + font-size: 0.85em; + opacity: 0.7; + padding: 6px 0; +} + /* Settings Styles */ .settings-section { margin-top: var(--space-3); diff --git a/static/js/components/shared/ModelTags.js b/static/js/components/shared/ModelTags.js index 08ae6900..fb270884 100644 --- a/static/js/components/shared/ModelTags.js +++ b/static/js/components/shared/ModelTags.js @@ -4,7 +4,47 @@ */ import { showToast } from '../../utils/uiHelpers.js'; import { getModelApiClient } from '../../api/modelApiFactory.js'; -import { PRESET_TAGS } from '../../utils/constants.js'; +import { translate } from '../../utils/i18nHelpers.js'; +import { getPriorityTagSuggestions } from '../../utils/priorityTagHelpers.js'; + +let priorityTagSuggestions = []; +let priorityTagSuggestionsLoaded = false; +let priorityTagSuggestionsPromise = null; + +function ensurePriorityTagSuggestions() { + if (!priorityTagSuggestionsPromise) { + priorityTagSuggestionsPromise = getPriorityTagSuggestions() + .then((tags) => { + priorityTagSuggestions = tags; + priorityTagSuggestionsLoaded = true; + return tags; + }) + .catch(() => { + priorityTagSuggestions = []; + priorityTagSuggestionsLoaded = true; + return priorityTagSuggestions; + }) + .finally(() => { + priorityTagSuggestionsPromise = null; + }); + } + + return priorityTagSuggestionsLoaded && !priorityTagSuggestionsPromise + ? Promise.resolve(priorityTagSuggestions) + : priorityTagSuggestionsPromise; +} + +ensurePriorityTagSuggestions(); + +window.addEventListener('lm:priority-tags-updated', () => { + priorityTagSuggestionsLoaded = false; + ensurePriorityTagSuggestions().then(() => { + document.querySelectorAll('.metadata-edit-container .metadata-suggestions-container').forEach((container) => { + renderPriorityTagSuggestions(container, getCurrentEditTags()); + }); + updateSuggestionsDropdown(); + }); +}); // Create a named function so we can remove it later let saveTagsHandler = null; @@ -260,7 +300,7 @@ function createTagEditUI(currentTags, editBtnHTML = '') { function createSuggestionsDropdown(existingTags = []) { const dropdown = document.createElement('div'); dropdown.className = 'metadata-suggestions-dropdown'; - + // Create header const header = document.createElement('div'); header.className = 'metadata-suggestions-header'; @@ -273,11 +313,33 @@ function createSuggestionsDropdown(existingTags = []) { // Create tag container const container = document.createElement('div'); container.className = 'metadata-suggestions-container'; - - // Add each preset tag as a suggestion - PRESET_TAGS.forEach(tag => { + if (priorityTagSuggestionsLoaded && !priorityTagSuggestionsPromise) { + renderPriorityTagSuggestions(container, existingTags); + } else { + container.innerHTML = ``; + ensurePriorityTagSuggestions().then(() => { + if (!container.isConnected) { + return; + } + renderPriorityTagSuggestions(container, getCurrentEditTags()); + updateSuggestionsDropdown(); + }).catch(() => { + if (container.isConnected) { + container.innerHTML = ''; + } + }); + } + + dropdown.appendChild(container); + return dropdown; +} + +function renderPriorityTagSuggestions(container, existingTags = []) { + container.innerHTML = ''; + + priorityTagSuggestions.forEach((tag) => { const isAdded = existingTags.includes(tag); - + const item = document.createElement('div'); item.className = `metadata-suggestion-item ${isAdded ? 'already-added' : ''}`; item.title = tag; @@ -285,28 +347,21 @@ function createSuggestionsDropdown(existingTags = []) { ${isAdded ? '' : ''} `; - + if (!isAdded) { item.addEventListener('click', () => { addNewTag(tag); - - // Also populate the input field for potential editing + const input = document.querySelector('.metadata-input'); if (input) input.value = tag; - - // Focus on the input if (input) input.focus(); - - // Update dropdown without removing it + updateSuggestionsDropdown(); }); } - + container.appendChild(item); }); - - dropdown.appendChild(container); - return dropdown; } /** @@ -406,10 +461,9 @@ function addNewTag(tag) { function updateSuggestionsDropdown() { const dropdown = document.querySelector('.metadata-suggestions-dropdown'); if (!dropdown) return; - + // Get all current tags - const currentTags = document.querySelectorAll('.metadata-item'); - const existingTags = Array.from(currentTags).map(tag => tag.dataset.tag); + const existingTags = getCurrentEditTags(); // Update status of each item in dropdown dropdown.querySelectorAll('.metadata-suggestion-item').forEach(item => { @@ -456,6 +510,11 @@ function updateSuggestionsDropdown() { }); } +function getCurrentEditTags() { + const currentTags = document.querySelectorAll('.metadata-item'); + return Array.from(currentTags).map(tag => tag.dataset.tag); +} + /** * Restore original tags when canceling edit * @param {HTMLElement} section - The tags section diff --git a/static/js/managers/BulkManager.js b/static/js/managers/BulkManager.js index 7f596a19..fd0e5449 100644 --- a/static/js/managers/BulkManager.js +++ b/static/js/managers/BulkManager.js @@ -4,7 +4,8 @@ import { updateCardsForBulkMode } from '../components/shared/ModelCard.js'; import { modalManager } from './ModalManager.js'; import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js'; import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js'; -import { PRESET_TAGS, BASE_MODEL_CATEGORIES } from '../utils/constants.js'; +import { BASE_MODEL_CATEGORIES } from '../utils/constants.js'; +import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js'; import { eventManager } from '../utils/EventManager.js'; import { translate } from '../utils/i18nHelpers.js'; @@ -59,6 +60,22 @@ export class BulkManager { setContentRating: true } }; + + window.addEventListener('lm:priority-tags-updated', () => { + const container = document.querySelector('#bulkAddTagsModal .metadata-suggestions-container'); + if (!container) { + return; + } + getPriorityTagSuggestions().then((tags) => { + if (!container.isConnected) { + return; + } + this.renderBulkSuggestionItems(container, tags); + this.updateBulkSuggestionsDropdown(); + }).catch(() => { + // Ignore refresh failures; UI will retry on next open + }); + }); } initialize() { @@ -565,7 +582,7 @@ export class BulkManager { // Create suggestions dropdown const tagForm = document.querySelector('#bulkAddTagsModal .metadata-add-form'); if (tagForm) { - const suggestionsDropdown = this.createBulkSuggestionsDropdown(PRESET_TAGS); + const suggestionsDropdown = this.createBulkSuggestionsDropdown(); tagForm.appendChild(suggestionsDropdown); } @@ -586,10 +603,10 @@ export class BulkManager { } } - createBulkSuggestionsDropdown(presetTags) { + createBulkSuggestionsDropdown() { const dropdown = document.createElement('div'); dropdown.className = 'metadata-suggestions-dropdown'; - + const header = document.createElement('div'); header.className = 'metadata-suggestions-header'; header.innerHTML = ` @@ -597,15 +614,34 @@ export class BulkManager { Click to add `; dropdown.appendChild(header); - + const container = document.createElement('div'); container.className = 'metadata-suggestions-container'; - - presetTags.forEach(tag => { - // Check if tag is already added + container.innerHTML = ``; + + getPriorityTagSuggestions().then((tags) => { + if (!container.isConnected) { + return; + } + this.renderBulkSuggestionItems(container, tags); + this.updateBulkSuggestionsDropdown(); + }).catch(() => { + if (container.isConnected) { + container.innerHTML = ''; + } + }); + + dropdown.appendChild(container); + return dropdown; + } + + renderBulkSuggestionItems(container, tags) { + container.innerHTML = ''; + + tags.forEach(tag => { const existingTags = this.getBulkExistingTags(); const isAdded = existingTags.includes(tag); - + const item = document.createElement('div'); item.className = `metadata-suggestion-item ${isAdded ? 'already-added' : ''}`; item.title = tag; @@ -613,7 +649,7 @@ export class BulkManager { ${isAdded ? '' : ''} `; - + if (!isAdded) { item.addEventListener('click', () => { this.addBulkTag(tag); @@ -622,16 +658,12 @@ export class BulkManager { input.value = tag; input.focus(); } - // Update dropdown to show added indicator this.updateBulkSuggestionsDropdown(); }); } - + container.appendChild(item); }); - - dropdown.appendChild(container); - return dropdown; } addBulkTag(tag) { diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index d5f3f5d4..6d89d7fe 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -2,10 +2,11 @@ import { modalManager } from './ModalManager.js'; import { showToast } from '../utils/uiHelpers.js'; import { state, createDefaultSettings } from '../state/index.js'; import { resetAndReload } from '../api/modelApiFactory.js'; -import { DOWNLOAD_PATH_TEMPLATES, MAPPABLE_BASE_MODELS, PATH_TEMPLATE_PLACEHOLDERS, DEFAULT_PATH_TEMPLATES } from '../utils/constants.js'; +import { DOWNLOAD_PATH_TEMPLATES, MAPPABLE_BASE_MODELS, PATH_TEMPLATE_PLACEHOLDERS, DEFAULT_PATH_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/constants.js'; import { translate } from '../utils/i18nHelpers.js'; import { i18n } from '../i18n/index.js'; import { configureModelCardVideo } from '../components/shared/ModelCard.js'; +import { validatePriorityTagString, getPriorityTagSuggestionsMap, invalidatePriorityTagSuggestionsCache } from '../utils/priorityTagHelpers.js'; export class SettingsManager { constructor() { @@ -111,6 +112,17 @@ export class SettingsManager { merged.download_path_templates = { ...DEFAULT_PATH_TEMPLATES, ...templates }; + const priorityTags = backendSettings?.priority_tags; + const normalizedPriority = { ...DEFAULT_PRIORITY_TAG_CONFIG }; + if (priorityTags && typeof priorityTags === 'object' && !Array.isArray(priorityTags)) { + Object.entries(priorityTags).forEach(([modelType, configValue]) => { + if (typeof configValue === 'string') { + normalizedPriority[modelType] = configValue.trim(); + } + }); + } + merged.priority_tags = normalizedPriority; + Object.keys(merged).forEach(key => this.backendSettingKeys.add(key)); return merged; @@ -201,14 +213,14 @@ export class SettingsManager { settingsManager.validateTemplate(modelType, template); settingsManager.updateTemplatePreview(modelType, template); }); - + customInput.addEventListener('blur', (e) => { const template = e.target.value; if (settingsManager.validateTemplate(modelType, template)) { settingsManager.updateTemplate(modelType, template); } }); - + customInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.target.blur(); @@ -216,7 +228,9 @@ export class SettingsManager { }); } }); - + + this.setupPriorityTagInputs(); + this.initialized = true; } @@ -291,6 +305,9 @@ export class SettingsManager { // Load download path templates this.loadDownloadPathTemplates(); + // Load priority tag settings + this.loadPriorityTagSettings(); + // Set include trigger words setting const includeTriggerWordsCheckbox = document.getElementById('includeTriggerWords'); if (includeTriggerWordsCheckbox) { @@ -325,6 +342,131 @@ export class SettingsManager { this.loadProxySettings(); } + setupPriorityTagInputs() { + ['lora', 'checkpoint', 'embedding'].forEach((modelType) => { + const textarea = document.getElementById(`${modelType}PriorityTagsInput`); + if (!textarea) { + return; + } + + textarea.addEventListener('input', () => this.handlePriorityTagInput(modelType)); + textarea.addEventListener('blur', () => this.handlePriorityTagBlur(modelType)); + }); + } + + loadPriorityTagSettings() { + const priorityConfig = state.global.settings.priority_tags || {}; + ['lora', 'checkpoint', 'embedding'].forEach((modelType) => { + const textarea = document.getElementById(`${modelType}PriorityTagsInput`); + if (!textarea) { + return; + } + + const storedValue = priorityConfig[modelType] ?? DEFAULT_PRIORITY_TAG_CONFIG[modelType] ?? ''; + textarea.value = storedValue; + this.displayPriorityTagValidation(modelType, true, []); + }); + } + + handlePriorityTagInput(modelType) { + const textarea = document.getElementById(`${modelType}PriorityTagsInput`); + if (!textarea) { + return; + } + + const validation = validatePriorityTagString(textarea.value); + this.displayPriorityTagValidation(modelType, validation.valid, validation.errors); + } + + async handlePriorityTagBlur(modelType) { + const textarea = document.getElementById(`${modelType}PriorityTagsInput`); + if (!textarea) { + return; + } + + const validation = validatePriorityTagString(textarea.value); + if (!validation.valid) { + this.displayPriorityTagValidation(modelType, false, validation.errors); + return; + } + + const sanitized = validation.formatted; + const currentValue = state.global.settings.priority_tags?.[modelType] || ''; + this.displayPriorityTagValidation(modelType, true, []); + + if (sanitized === currentValue) { + textarea.value = sanitized; + return; + } + + const updatedConfig = { + ...state.global.settings.priority_tags, + [modelType]: sanitized, + }; + + try { + textarea.value = sanitized; + await this.saveSetting('priority_tags', updatedConfig); + showToast('settings.priorityTags.saveSuccess', {}, 'success'); + await this.refreshPriorityTagSuggestions(); + } catch (error) { + console.error('Failed to save priority tag configuration:', error); + showToast('settings.priorityTags.saveError', {}, 'error'); + } + } + + displayPriorityTagValidation(modelType, isValid, errors = []) { + const textarea = document.getElementById(`${modelType}PriorityTagsInput`); + const errorElement = document.getElementById(`${modelType}PriorityTagsError`); + if (!textarea) { + return; + } + + if (isValid || errors.length === 0) { + textarea.classList.remove('input-error'); + if (errorElement) { + errorElement.textContent = ''; + errorElement.style.display = 'none'; + } + return; + } + + textarea.classList.add('input-error'); + if (errorElement) { + const message = this.getPriorityTagErrorMessage(errors[0]); + errorElement.textContent = message; + errorElement.style.display = 'block'; + } + } + + getPriorityTagErrorMessage(error) { + if (!error) { + return ''; + } + + const entryIndex = error.index ?? 0; + switch (error.type) { + case 'missingClosingParen': + return translate('settings.priorityTags.validation.missingClosingParen', { index: entryIndex }, `Entry ${entryIndex} is missing a closing parenthesis.`); + case 'missingCanonical': + return translate('settings.priorityTags.validation.missingCanonical', { index: entryIndex }, `Entry ${entryIndex} must include a canonical tag.`); + case 'duplicateCanonical': + return translate('settings.priorityTags.validation.duplicateCanonical', { tag: error.canonical }, `The canonical tag "${error.canonical}" is duplicated.`); + default: + return translate('settings.priorityTags.validation.unknown', {}, 'Invalid priority tag configuration.'); + } + } + + async refreshPriorityTagSuggestions() { + invalidatePriorityTagSuggestionsCache(); + try { + await getPriorityTagSuggestionsMap(); + window.dispatchEvent(new CustomEvent('lm:priority-tags-updated')); + } catch (error) { + console.warn('Failed to refresh priority tag suggestions:', error); + } + } + loadProxySettings() { // Load proxy enabled setting const proxyEnabledCheckbox = document.getElementById('proxyEnabled'); diff --git a/static/js/state/index.js b/static/js/state/index.js index 43f30454..867b63ce 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -1,7 +1,7 @@ // Create the new hierarchical state structure import { getStorageItem, getMapFromStorage } from '../utils/storageHelpers.js'; import { MODEL_TYPES } from '../api/apiConfig.js'; -import { DEFAULT_PATH_TEMPLATES } from '../utils/constants.js'; +import { DEFAULT_PATH_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/constants.js'; const DEFAULT_SETTINGS_BASE = Object.freeze({ civitai_api_key: '', @@ -28,6 +28,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({ card_info_display: 'always', include_trigger_words: false, compact_mode: false, + priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG }, }); export function createDefaultSettings() { @@ -35,6 +36,7 @@ export function createDefaultSettings() { ...DEFAULT_SETTINGS_BASE, base_model_path_mappings: {}, download_path_templates: { ...DEFAULT_PATH_TEMPLATES }, + priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG }, }; } diff --git a/static/js/utils/constants.js b/static/js/utils/constants.js index a9a2e8c1..e26a0347 100644 --- a/static/js/utils/constants.js +++ b/static/js/utils/constants.js @@ -194,10 +194,16 @@ export const BASE_MODEL_CATEGORIES = { ] }; -// Preset tag suggestions -export const PRESET_TAGS = [ +// Default priority tag entries for fallback suggestions and initial settings +export const DEFAULT_PRIORITY_TAG_ENTRIES = [ 'character', 'concept', 'clothing', 'realistic', 'anime', 'toon', 'furry', 'style', - 'poses', 'background', 'vehicle', 'buildings', - 'objects', 'animal' + 'poses', 'background', 'tool', 'vehicle', 'buildings', + 'objects', 'assets', 'animal', 'action' ]; + +export const DEFAULT_PRIORITY_TAG_CONFIG = { + lora: DEFAULT_PRIORITY_TAG_ENTRIES.join(', '), + checkpoint: DEFAULT_PRIORITY_TAG_ENTRIES.join(', '), + embedding: DEFAULT_PRIORITY_TAG_ENTRIES.join(', ') +}; diff --git a/static/js/utils/priorityTagHelpers.js b/static/js/utils/priorityTagHelpers.js new file mode 100644 index 00000000..67b4f8dd --- /dev/null +++ b/static/js/utils/priorityTagHelpers.js @@ -0,0 +1,223 @@ +import { DEFAULT_PRIORITY_TAG_CONFIG } from './constants.js'; + +function splitPriorityEntries(raw = '') { + const segments = []; + raw.split('\n').forEach(line => { + line.split(',').forEach(part => { + const trimmed = part.trim(); + if (trimmed) { + segments.push(trimmed); + } + }); + }); + return segments; +} + +export function parsePriorityTagString(raw = '') { + const entries = []; + const rawEntries = splitPriorityEntries(raw); + + rawEntries.forEach((entry) => { + const { canonical, aliases } = parsePriorityEntry(entry); + if (!canonical) { + return; + } + + entries.push({ canonical, aliases }); + }); + + return entries; +} + +function parsePriorityEntry(entry) { + let canonical = entry; + let aliasSection = ''; + + const openIndex = entry.indexOf('('); + if (openIndex !== -1) { + if (!entry.endsWith(')')) { + canonical = entry.replace('(', '').replace(')', ''); + } else { + canonical = entry.slice(0, openIndex).trim(); + aliasSection = entry.slice(openIndex + 1, -1); + } + } + + canonical = canonical.trim(); + if (!canonical) { + return { canonical: '', aliases: [] }; + } + + const aliasList = aliasSection ? aliasSection.split('|').map((alias) => alias.trim()).filter(Boolean) : []; + const seen = new Set(); + const normalizedCanonical = canonical.toLowerCase(); + const uniqueAliases = []; + + aliasList.forEach((alias) => { + const normalized = alias.toLowerCase(); + if (normalized === normalizedCanonical) { + return; + } + if (!seen.has(normalized)) { + seen.add(normalized); + uniqueAliases.push(alias); + } + }); + + return { canonical, aliases: uniqueAliases }; +} + +export function formatPriorityTagEntries(entries, useNewlines = false) { + if (!entries.length) { + return ''; + } + + const separator = useNewlines ? ',\n' : ', '; + return entries.map(({ canonical, aliases }) => { + if (aliases && aliases.length) { + return `${canonical}(${aliases.join('|')})`; + } + return canonical; + }).join(separator); +} + +export function validatePriorityTagString(raw = '') { + const trimmed = raw.trim(); + if (!trimmed) { + return { valid: true, errors: [], entries: [], formatted: '' }; + } + + const errors = []; + const entries = []; + const rawEntries = splitPriorityEntries(raw); + const seenCanonicals = new Set(); + + rawEntries.forEach((entry, index) => { + const hasOpening = entry.includes('('); + const hasClosing = entry.endsWith(')'); + + if (hasOpening && !hasClosing) { + errors.push({ type: 'missingClosingParen', index: index + 1 }); + } + + const { canonical, aliases } = parsePriorityEntry(entry); + if (!canonical) { + errors.push({ type: 'missingCanonical', index: index + 1 }); + return; + } + + const normalizedCanonical = canonical.toLowerCase(); + if (seenCanonicals.has(normalizedCanonical)) { + errors.push({ type: 'duplicateCanonical', canonical }); + } else { + seenCanonicals.add(normalizedCanonical); + } + + entries.push({ canonical, aliases }); + }); + + const formatted = errors.length === 0 + ? formatPriorityTagEntries(entries, raw.includes('\n')) + : raw.trim(); + + return { + valid: errors.length === 0, + errors, + entries, + formatted, + }; +} + +let cachedPriorityTagMap = null; +let fetchPromise = null; + +export async function getPriorityTagSuggestionsMap() { + if (cachedPriorityTagMap) { + return cachedPriorityTagMap; + } + + if (!fetchPromise) { + fetchPromise = fetch('/api/lm/priority-tags') + .then(async (response) => { + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + if (!data || data.success === false || typeof data.tags !== 'object') { + throw new Error(data?.error || 'Invalid response payload'); + } + + const normalized = {}; + Object.entries(data.tags).forEach(([modelType, tags]) => { + if (!Array.isArray(tags)) { + return; + } + normalized[modelType] = tags.filter(tag => typeof tag === 'string' && tag.trim()); + }); + + const withDefaults = applyDefaultPriorityTagFallback(normalized); + cachedPriorityTagMap = withDefaults; + return withDefaults; + }) + .catch(() => { + const fallback = buildDefaultPriorityTagMap(); + cachedPriorityTagMap = fallback; + return fallback; + }) + .finally(() => { + fetchPromise = null; + }); + } + + return fetchPromise; +} + +export async function getPriorityTagSuggestions() { + const map = await getPriorityTagSuggestionsMap(); + const unique = new Set(); + Object.values(map).forEach((tags) => { + tags.forEach((tag) => { + unique.add(tag); + }); + }); + return Array.from(unique); +} + +function applyDefaultPriorityTagFallback(map) { + const result = { ...buildDefaultPriorityTagMap(), ...map }; + Object.entries(result).forEach(([key, tags]) => { + result[key] = dedupeTags(Array.isArray(tags) ? tags : []); + }); + return result; +} + +function buildDefaultPriorityTagMap() { + const map = {}; + Object.entries(DEFAULT_PRIORITY_TAG_CONFIG).forEach(([modelType, configString]) => { + const entries = parsePriorityTagString(configString); + map[modelType] = entries.map((entry) => entry.canonical); + }); + return map; +} + +function dedupeTags(tags) { + const seen = new Set(); + const ordered = []; + tags.forEach((tag) => { + const normalized = tag.toLowerCase(); + if (!seen.has(normalized)) { + seen.add(normalized); + ordered.push(tag); + } + }); + return ordered; +} + +export function getDefaultPriorityTagConfig() { + return { ...DEFAULT_PRIORITY_TAG_CONFIG }; +} + +export function invalidatePriorityTagSuggestionsCache() { + cachedPriorityTagMap = null; + fetchPromise = null; +} diff --git a/templates/components/modals/settings_modal.html b/templates/components/modals/settings_modal.html index 9282fb57..dcd5129b 100644 --- a/templates/components/modals/settings_modal.html +++ b/templates/components/modals/settings_modal.html @@ -250,6 +250,32 @@ +
+

{{ t('settings.priorityTags.title') }}

+

{{ t('settings.priorityTags.description') }}

+
+
+ + +
{{ t('settings.priorityTags.help') }}
+
+
+
+ + +
{{ t('settings.priorityTags.help') }}
+
+
+
+ + +
{{ t('settings.priorityTags.help') }}
+
+
+
+

{{ t('settings.priorityTags.aliasHint') }}

+
+

{{ t('settings.downloadPathTemplates.title') }}