chore(priority-tags): add newline terminator

This commit is contained in:
pixelpaws
2025-10-11 17:38:20 +08:00
parent 47da9949d9
commit 6120922204
26 changed files with 1079 additions and 99 deletions

View File

@@ -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 := <identifier without spaces>
entry-list := entry { "," entry }
entry := canonical [ "(" alias { "|" alias } ")" ]
canonical := <tag text without parentheses or commas>
alias := <tag text without parentheses, commas, or pipes>
```
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.

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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": {

View File

@@ -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"
}
}
}
}

View File

@@ -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"
}
}
}
}

View File

@@ -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"
}
}
}
}

View File

@@ -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"
}
}
}
}

View File

@@ -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"
}
}
}
}

View File

@@ -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"
}
}
}
}

View File

@@ -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,

View File

@@ -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"),

View File

@@ -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)

View File

@@ -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)

View File

@@ -64,4 +64,11 @@ CIVITAI_MODEL_TAGS = [
'realistic', 'anime', 'toon', 'furry', 'style',
'poses', 'background', 'tool', 'vehicle', 'buildings',
'objects', 'assets', 'animal', 'action'
]
]
# 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),
}

104
py/utils/tag_priorities.py Normal file
View File

@@ -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]

View File

@@ -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

View File

@@ -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);

View File

@@ -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 = `<div class="metadata-suggestions-loading">${translate('settings.priorityTags.loadingSuggestions', 'Loading suggestions…')}</div>`;
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 = []) {
<span class="metadata-suggestion-text">${tag}</span>
${isAdded ? '<span class="added-indicator"><i class="fas fa-check"></i></span>' : ''}
`;
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

View File

@@ -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 {
<small>Click to add</small>
`;
dropdown.appendChild(header);
const container = document.createElement('div');
container.className = 'metadata-suggestions-container';
presetTags.forEach(tag => {
// Check if tag is already added
container.innerHTML = `<div class="metadata-suggestions-loading">${translate('settings.priorityTags.loadingSuggestions', 'Loading suggestions…')}</div>`;
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 {
<span class="metadata-suggestion-text">${tag}</span>
${isAdded ? '<span class="added-indicator"><i class="fas fa-check"></i></span>' : ''}
`;
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) {

View File

@@ -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');

View File

@@ -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 },
};
}

View File

@@ -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(', ')
};

View File

@@ -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;
}

View File

@@ -250,6 +250,32 @@
</div>
</div>
<div class="settings-section">
<h3>{{ t('settings.priorityTags.title') }}</h3>
<p class="settings-help-text">{{ t('settings.priorityTags.description') }}</p>
<div class="priority-tags-grid">
<div class="priority-tags-group">
<label for="loraPriorityTagsInput">{{ t('settings.priorityTags.modelTypes.lora') }}</label>
<textarea id="loraPriorityTagsInput" class="priority-tags-input" rows="3" placeholder="{{ t('settings.priorityTags.placeholder') }}"></textarea>
<div class="input-help">{{ t('settings.priorityTags.help') }}</div>
<div class="input-error-message" id="loraPriorityTagsError"></div>
</div>
<div class="priority-tags-group">
<label for="checkpointPriorityTagsInput">{{ t('settings.priorityTags.modelTypes.checkpoint') }}</label>
<textarea id="checkpointPriorityTagsInput" class="priority-tags-input" rows="3" placeholder="{{ t('settings.priorityTags.placeholder') }}"></textarea>
<div class="input-help">{{ t('settings.priorityTags.help') }}</div>
<div class="input-error-message" id="checkpointPriorityTagsError"></div>
</div>
<div class="priority-tags-group">
<label for="embeddingPriorityTagsInput">{{ t('settings.priorityTags.modelTypes.embedding') }}</label>
<textarea id="embeddingPriorityTagsInput" class="priority-tags-input" rows="3" placeholder="{{ t('settings.priorityTags.placeholder') }}"></textarea>
<div class="input-help">{{ t('settings.priorityTags.help') }}</div>
<div class="input-error-message" id="embeddingPriorityTagsError"></div>
</div>
</div>
<p class="settings-help-text subtle">{{ t('settings.priorityTags.aliasHint') }}</p>
</div>
<!-- Default Path Customization Section -->
<div class="settings-section">
<h3>{{ t('settings.downloadPathTemplates.title') }}</h3>