diff --git a/locales/de.json b/locales/de.json index ad01fa97..e6a43fdc 100644 --- a/locales/de.json +++ b/locales/de.json @@ -230,6 +230,7 @@ "priorityTags": "Prioritäts-Tags", "downloadPathTemplates": "Download-Pfad-Vorlagen", "exampleImages": "Beispielbilder", + "updateFlags": "Update-Markierungen", "misc": "Verschiedenes", "metadataArchive": "Metadaten-Archiv-Datenbank", "storageLocation": "Einstellungsort", @@ -271,7 +272,6 @@ "hover": "Bei Hover anzeigen" }, "cardInfoDisplayHelp": "Wählen Sie, wann Modellinformationen und Aktionsschaltflächen angezeigt werden sollen", - "modelCardFooterAction": "Aktion der Modellkarten-Schaltfläche", "modelCardFooterActionOptions": { "exampleImages": "Beispielbilder öffnen", @@ -365,6 +365,14 @@ "download": "Herunterladen", "restartRequired": "Neustart erforderlich" }, + "updateFlagStrategy": { + "label": "Strategie für Update-Markierungen", + "help": "Entscheide, ob Update-Badges nur dann erscheinen, wenn eine neue Version dasselbe Basismodell wie deine lokalen Dateien verwendet, oder sobald es irgendein neueres Release für dieses Modell gibt.", + "options": { + "sameBase": "Updates nach Basismodell abgleichen", + "any": "Jede verfügbare Aktualisierung markieren" + } + }, "misc": { "includeTriggerWords": "Trigger Words in LoRA-Syntax einschließen", "includeTriggerWordsHelp": "Trainierte Trigger Words beim Kopieren der LoRA-Syntax in die Zwischenablage einschließen" @@ -930,6 +938,18 @@ "viewLocalVersions": "Alle lokalen Versionen anzeigen", "viewLocalTooltip": "Demnächst verfügbar" }, + "filters": { + "label": "Basisfilter", + "state": { + "showAll": "Alle Versionen", + "showSameBase": "Gleiches Basismodell" + }, + "tooltip": { + "showAllVersions": "Wechseln, um alle Versionen anzuzeigen", + "showSameBaseVersions": "Wechseln, um nur Versionen mit demselben Basismodell anzuzeigen" + }, + "empty": "Keine Versionen entsprechen dem Filter für das aktuelle Basismodell." + }, "empty": "Noch keine Versionshistorie für dieses Modell vorhanden.", "error": "Versionen konnten nicht geladen werden.", "missingModelId": "Für dieses Modell ist keine Civitai-Model-ID vorhanden.", diff --git a/locales/en.json b/locales/en.json index 97aad0fc..19ed4b4f 100644 --- a/locales/en.json +++ b/locales/en.json @@ -230,6 +230,7 @@ "priorityTags": "Priority Tags", "downloadPathTemplates": "Download Path Templates", "exampleImages": "Example Images", + "updateFlags": "Update Flags", "misc": "Misc.", "metadataArchive": "Metadata Archive Database", "storageLocation": "Settings Location", @@ -364,6 +365,14 @@ "download": "Download", "restartRequired": "Requires restart" }, + "updateFlagStrategy": { + "label": "Update Flag Strategy", + "help": "Decide whether update badges should only appear when a new release shares the same base model as your local files or whenever any newer version exists for that model.", + "options": { + "sameBase": "Match updates by base model", + "any": "Flag any available update" + } + }, "misc": { "includeTriggerWords": "Include Trigger Words in LoRA Syntax", "includeTriggerWordsHelp": "Include trained trigger words when copying LoRA syntax to clipboard" @@ -929,6 +938,18 @@ "viewLocalVersions": "View all local versions", "viewLocalTooltip": "Coming soon" }, + "filters": { + "label": "Base filter", + "state": { + "showAll": "All versions", + "showSameBase": "Same base" + }, + "tooltip": { + "showAllVersions": "Switch to showing all versions", + "showSameBaseVersions": "Switch to showing only versions that match the current base model" + }, + "empty": "No versions match the current base model filter." + }, "empty": "No version history available for this model yet.", "error": "Failed to load versions.", "missingModelId": "This model is missing a Civitai model id.", diff --git a/locales/es.json b/locales/es.json index 56de455e..4395c7e7 100644 --- a/locales/es.json +++ b/locales/es.json @@ -230,6 +230,7 @@ "priorityTags": "Etiquetas prioritarias", "downloadPathTemplates": "Plantillas de rutas de descarga", "exampleImages": "Imágenes de ejemplo", + "updateFlags": "Indicadores de actualización", "misc": "Varios", "metadataArchive": "Base de datos de archivo de metadatos", "storageLocation": "Ubicación de ajustes", @@ -364,6 +365,14 @@ "download": "Descargar", "restartRequired": "Requiere reinicio" }, + "updateFlagStrategy": { + "label": "Estrategia de indicadores de actualización", + "help": "Decide si las insignias de actualización deben mostrarse solo cuando una nueva versión comparte el mismo modelo base que tus archivos locales o siempre que exista cualquier versión más reciente de ese modelo.", + "options": { + "sameBase": "Coincidir actualizaciones por modelo base", + "any": "Marcar cualquier actualización disponible" + } + }, "misc": { "includeTriggerWords": "Incluir palabras clave en la sintaxis de LoRA", "includeTriggerWordsHelp": "Incluir palabras clave entrenadas al copiar la sintaxis de LoRA al portapapeles" @@ -929,6 +938,18 @@ "viewLocalVersions": "Ver todas las versiones locales", "viewLocalTooltip": "Disponible pronto" }, + "filters": { + "label": "Filtro base", + "state": { + "showAll": "Todas las versiones", + "showSameBase": "Mismo modelo base" + }, + "tooltip": { + "showAllVersions": "Cambiar para mostrar todas las versiones", + "showSameBaseVersions": "Cambiar para mostrar solo versiones del mismo modelo base" + }, + "empty": "Ninguna versión coincide con el filtro del modelo base actual." + }, "empty": "Aún no hay historial de versiones para este modelo.", "error": "No se pudieron cargar las versiones.", "missingModelId": "Este modelo no tiene un ID de modelo de Civitai.", diff --git a/locales/fr.json b/locales/fr.json index aa8af106..68261795 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -227,13 +227,14 @@ "videoSettings": "Paramètres vidéo", "layoutSettings": "Paramètres d'affichage", "folderSettings": "Paramètres des dossiers", + "priorityTags": "Étiquettes prioritaires", "downloadPathTemplates": "Modèles de chemin de téléchargement", "exampleImages": "Images d'exemple", + "updateFlags": "Indicateurs de mise à jour", "misc": "Divers", "metadataArchive": "Base de données d'archive des métadonnées", "storageLocation": "Emplacement des paramètres", - "proxySettings": "Paramètres du proxy", - "priorityTags": "Étiquettes prioritaires" + "proxySettings": "Paramètres du proxy" }, "storage": { "locationLabel": "Mode portable", @@ -297,6 +298,26 @@ "defaultEmbeddingRootHelp": "Définir le répertoire racine embedding par défaut pour les téléchargements, imports et déplacements", "noDefault": "Aucun par défaut" }, + "priorityTags": { + "title": "Étiquettes prioritaires", + "description": "Personnalisez l'ordre de priorité des étiquettes pour chaque type de modèle (par ex. : character, concept, style(toon|toon_style))", + "placeholder": "character, concept, style(toon|toon_style)", + "helpLinkLabel": "Ouvrir l'aide sur les étiquettes prioritaires", + "modelTypes": { + "lora": "LoRA", + "checkpoint": "Checkpoint", + "embedding": "Embedding" + }, + "saveSuccess": "Étiquettes prioritaires mises à jour.", + "saveError": "Échec de la mise à jour des étiquettes prioritaires.", + "loadingSuggestions": "Chargement des suggestions...", + "validation": { + "missingClosingParen": "L'entrée {index} n'a pas de parenthèse fermante.", + "missingCanonical": "L'entrée {index} doit inclure un nom d'étiquette canonique.", + "duplicateCanonical": "L'étiquette canonique \"{tag}\" apparaît plusieurs fois.", + "unknown": "Configuration d'étiquettes prioritaires invalide." + } + }, "downloadPathTemplates": { "title": "Modèles de chemin de téléchargement", "help": "Configurer les structures de dossiers pour différents types de modèles lors du téléchargement depuis Civitai.", @@ -344,6 +365,14 @@ "download": "Télécharger", "restartRequired": "Redémarrage requis" }, + "updateFlagStrategy": { + "label": "Stratégie des indicateurs de mise à jour", + "help": "Choisissez si les badges de mise à jour doivent apparaître uniquement lorsqu’une nouvelle version partage le même modèle de base que vos fichiers locaux, ou dès qu’il existe une version plus récente pour ce modèle.", + "options": { + "sameBase": "Faire correspondre les mises à jour par modèle de base", + "any": "Signaler n’importe quelle mise à jour disponible" + } + }, "misc": { "includeTriggerWords": "Inclure les mots-clés dans la syntaxe LoRA", "includeTriggerWordsHelp": "Inclure les mots-clés d'entraînement lors de la copie de la syntaxe LoRA dans le presse-papiers" @@ -389,26 +418,6 @@ "proxyPassword": "Mot de passe (optionnel)", "proxyPasswordPlaceholder": "mot_de_passe", "proxyPasswordHelp": "Mot de passe pour l'authentification proxy (si nécessaire)" - }, - "priorityTags": { - "title": "Étiquettes prioritaires", - "description": "Personnalisez l'ordre de priorité des étiquettes pour chaque type de modèle (par ex. : character, concept, style(toon|toon_style))", - "placeholder": "character, concept, style(toon|toon_style)", - "helpLinkLabel": "Ouvrir l'aide sur les étiquettes prioritaires", - "modelTypes": { - "lora": "LoRA", - "checkpoint": "Checkpoint", - "embedding": "Embedding" - }, - "saveSuccess": "Étiquettes prioritaires mises à jour.", - "saveError": "Échec de la mise à jour des étiquettes prioritaires.", - "loadingSuggestions": "Chargement des suggestions...", - "validation": { - "missingClosingParen": "L'entrée {index} n'a pas de parenthèse fermante.", - "missingCanonical": "L'entrée {index} doit inclure un nom d'étiquette canonique.", - "duplicateCanonical": "L'étiquette canonique \"{tag}\" apparaît plusieurs fois.", - "unknown": "Configuration d'étiquettes prioritaires invalide." - } } }, "loras": { @@ -929,6 +938,18 @@ "viewLocalVersions": "Voir toutes les versions locales", "viewLocalTooltip": "Bientôt disponible" }, + "filters": { + "label": "Filtre de base", + "state": { + "showAll": "Toutes les versions", + "showSameBase": "Même modèle de base" + }, + "tooltip": { + "showAllVersions": "Passer à l'affichage de toutes les versions", + "showSameBaseVersions": "Passer à l'affichage des versions du même modèle de base" + }, + "empty": "Aucune version ne correspond au filtre du modèle de base actuel." + }, "empty": "Aucun historique de versions n'est disponible pour ce modèle pour le moment.", "error": "Échec du chargement des versions.", "missingModelId": "Ce modèle ne possède pas d'identifiant de modèle Civitai.", diff --git a/locales/he.json b/locales/he.json index 0cb19801..ec33f7db 100644 --- a/locales/he.json +++ b/locales/he.json @@ -229,6 +229,7 @@ "folderSettings": "הגדרות תיקייה", "downloadPathTemplates": "תבניות נתיב הורדה", "exampleImages": "תמונות דוגמה", + "updateFlags": "תגי עדכון", "misc": "שונות", "metadataArchive": "מסד נתונים של ארכיון מטא-דאטה", "storageLocation": "מיקום ההגדרות", @@ -344,6 +345,14 @@ "download": "הורד", "restartRequired": "דורש הפעלה מחדש" }, + "updateFlagStrategy": { + "label": "אסטרטגיית תגי עדכון", + "help": "בחרו אם תוויות העדכון יוצגו רק כאשר גרסה חדשה חולקת את אותו דגם בסיס כמו הקבצים המקומיים שלכם או בכל מקרה שבו קיימת גרסה חדשה עבור אותו דגם.", + "options": { + "sameBase": "התאמת עדכונים לפי דגם בסיס", + "any": "תוויות לכל עדכון זמין" + } + }, "misc": { "includeTriggerWords": "כלול מילות טריגר בתחביר LoRA", "includeTriggerWordsHelp": "כלול מילות טריגר מאומנות בעת העתקת תחביר LoRA ללוח" @@ -929,6 +938,18 @@ "viewLocalVersions": "הצג את כל הגרסאות המקומיות", "viewLocalTooltip": "יגיע בקרוב" }, + "filters": { + "label": "מסנן בסיס", + "state": { + "showAll": "כל הגרסאות", + "showSameBase": "אותו מודל בסיס" + }, + "tooltip": { + "showAllVersions": "החלף להצגת כל הגרסאות", + "showSameBaseVersions": "החלף להצגת גרסאות עם אותו מודל בסיס" + }, + "empty": "אין גרסאות התואמות את המסנן של מודל הבסיס הנוכחי." + }, "empty": "אין עדיין היסטוריית גרסאות למודל זה.", "error": "טעינת הגרסאות נכשלה.", "missingModelId": "למודל זה אין מזהה מודל של Civitai.", diff --git a/locales/ja.json b/locales/ja.json index 66b1b5ff..45ff3c10 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -227,13 +227,14 @@ "videoSettings": "動画設定", "layoutSettings": "レイアウト設定", "folderSettings": "フォルダ設定", + "priorityTags": "優先タグ", "downloadPathTemplates": "ダウンロードパステンプレート", "exampleImages": "例画像", + "updateFlags": "アップデートフラグ", "misc": "その他", "metadataArchive": "メタデータアーカイブデータベース", "storageLocation": "設定の場所", - "proxySettings": "プロキシ設定", - "priorityTags": "優先タグ" + "proxySettings": "プロキシ設定" }, "storage": { "locationLabel": "ポータブルモード", @@ -297,6 +298,26 @@ "defaultEmbeddingRootHelp": "ダウンロード、インポート、移動用のデフォルトembeddingルートディレクトリを設定", "noDefault": "デフォルトなし" }, + "priorityTags": { + "title": "優先タグ", + "description": "各モデルタイプのタグ優先順位をカスタマイズします (例: character, concept, style(toon|toon_style))", + "placeholder": "character, concept, style(toon|toon_style)", + "helpLinkLabel": "優先タグのヘルプを開く", + "modelTypes": { + "lora": "LoRA", + "checkpoint": "チェックポイント", + "embedding": "埋め込み" + }, + "saveSuccess": "優先タグを更新しました。", + "saveError": "優先タグの更新に失敗しました。", + "loadingSuggestions": "候補を読み込み中...", + "validation": { + "missingClosingParen": "エントリ {index} に閉じ括弧がありません。", + "missingCanonical": "エントリ {index} には正規タグ名を含める必要があります。", + "duplicateCanonical": "正規タグ \"{tag}\" が複数回登場しています。", + "unknown": "無効な優先タグ設定です。" + } + }, "downloadPathTemplates": { "title": "ダウンロードパステンプレート", "help": "Civitaiからダウンロードする際の異なるモデルタイプのフォルダ構造を設定します。", @@ -344,6 +365,14 @@ "download": "ダウンロード", "restartRequired": "再起動が必要" }, + "updateFlagStrategy": { + "label": "アップデートフラグの表示戦略", + "help": "新リリースがローカルファイルと同じベースモデルを共有する場合にのみ更新バッジを表示するか、そのモデルに新しいバージョンがあれば常に表示するかを決めます。", + "options": { + "sameBase": "ベースモデルで更新をマッチ", + "any": "利用可能な更新すべてを表示" + } + }, "misc": { "includeTriggerWords": "LoRA構文にトリガーワードを含める", "includeTriggerWordsHelp": "LoRA構文をクリップボードにコピーする際、学習済みトリガーワードを含めます" @@ -389,26 +418,6 @@ "proxyPassword": "パスワード(任意)", "proxyPasswordPlaceholder": "パスワード", "proxyPasswordHelp": "プロキシ認証用のパスワード(必要な場合)" - }, - "priorityTags": { - "title": "優先タグ", - "description": "各モデルタイプのタグ優先順位をカスタマイズします (例: character, concept, style(toon|toon_style))", - "placeholder": "character, concept, style(toon|toon_style)", - "helpLinkLabel": "優先タグのヘルプを開く", - "modelTypes": { - "lora": "LoRA", - "checkpoint": "チェックポイント", - "embedding": "埋め込み" - }, - "saveSuccess": "優先タグを更新しました。", - "saveError": "優先タグの更新に失敗しました。", - "loadingSuggestions": "候補を読み込み中...", - "validation": { - "missingClosingParen": "エントリ {index} に閉じ括弧がありません。", - "missingCanonical": "エントリ {index} には正規タグ名を含める必要があります。", - "duplicateCanonical": "正規タグ \"{tag}\" が複数回登場しています。", - "unknown": "無効な優先タグ設定です。" - } } }, "loras": { @@ -929,6 +938,18 @@ "viewLocalVersions": "ローカルの全バージョンを表示", "viewLocalTooltip": "近日対応予定" }, + "filters": { + "label": "ベースフィルター", + "state": { + "showAll": "すべてのバージョン", + "showSameBase": "同じベース" + }, + "tooltip": { + "showAllVersions": "すべてのバージョンを表示する", + "showSameBaseVersions": "同じベースモデルのバージョンのみ表示する" + }, + "empty": "現在のベースモデルフィルターに一致するバージョンがありません。" + }, "empty": "このモデルにはまだバージョン履歴がありません。", "error": "バージョンの読み込みに失敗しました。", "missingModelId": "このモデルにはCivitaiのモデルIDがありません。", diff --git a/locales/ko.json b/locales/ko.json index e735b42d..ccedfc57 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -227,13 +227,14 @@ "videoSettings": "비디오 설정", "layoutSettings": "레이아웃 설정", "folderSettings": "폴더 설정", + "priorityTags": "우선순위 태그", "downloadPathTemplates": "다운로드 경로 템플릿", "exampleImages": "예시 이미지", + "updateFlags": "업데이트 표시", "misc": "기타", "metadataArchive": "메타데이터 아카이브 데이터베이스", "storageLocation": "설정 위치", - "proxySettings": "프록시 설정", - "priorityTags": "우선순위 태그" + "proxySettings": "프록시 설정" }, "storage": { "locationLabel": "휴대용 모드", @@ -297,6 +298,26 @@ "defaultEmbeddingRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Embedding 루트 디렉토리를 설정합니다", "noDefault": "기본값 없음" }, + "priorityTags": { + "title": "우선순위 태그", + "description": "모델 유형별 태그 우선순위를 사용자 지정합니다(예: character, concept, style(toon|toon_style)).", + "placeholder": "character, concept, style(toon|toon_style)", + "helpLinkLabel": "우선순위 태그 도움말 열기", + "modelTypes": { + "lora": "LoRA", + "checkpoint": "체크포인트", + "embedding": "임베딩" + }, + "saveSuccess": "우선순위 태그가 업데이트되었습니다.", + "saveError": "우선순위 태그를 업데이트하지 못했습니다.", + "loadingSuggestions": "추천을 불러오는 중...", + "validation": { + "missingClosingParen": "{index}번째 항목에 닫는 괄호가 없습니다.", + "missingCanonical": "{index}번째 항목에는 정식 태그 이름이 포함되어야 합니다.", + "duplicateCanonical": "정식 태그 \"{tag}\"가 여러 번 나타납니다.", + "unknown": "잘못된 우선순위 태그 구성입니다." + } + }, "downloadPathTemplates": { "title": "다운로드 경로 템플릿", "help": "Civitai에서 다운로드할 때 다양한 모델 유형의 폴더 구조를 구성합니다.", @@ -344,6 +365,14 @@ "download": "다운로드", "restartRequired": "재시작 필요" }, + "updateFlagStrategy": { + "label": "업데이트 표시 전략", + "help": "새 릴리스가 로컬 파일과 동일한 베이스 모델을 공유할 때만 업데이트 배지를 표시할지, 또는 해당 모델에 사용 가능한 새 버전이 있으면 항상 표시할지 결정합니다.", + "options": { + "sameBase": "베이스 모델로 업데이트 일치", + "any": "사용 가능한 모든 업데이트 표시" + } + }, "misc": { "includeTriggerWords": "LoRA 문법에 트리거 단어 포함", "includeTriggerWordsHelp": "LoRA 문법을 클립보드에 복사할 때 학습된 트리거 단어를 포함합니다" @@ -389,26 +418,6 @@ "proxyPassword": "비밀번호 (선택사항)", "proxyPasswordPlaceholder": "password", "proxyPasswordHelp": "프록시 인증에 필요한 비밀번호 (필요한 경우)" - }, - "priorityTags": { - "title": "우선순위 태그", - "description": "모델 유형별 태그 우선순위를 사용자 지정합니다(예: character, concept, style(toon|toon_style)).", - "placeholder": "character, concept, style(toon|toon_style)", - "helpLinkLabel": "우선순위 태그 도움말 열기", - "modelTypes": { - "lora": "LoRA", - "checkpoint": "체크포인트", - "embedding": "임베딩" - }, - "saveSuccess": "우선순위 태그가 업데이트되었습니다.", - "saveError": "우선순위 태그를 업데이트하지 못했습니다.", - "loadingSuggestions": "추천을 불러오는 중...", - "validation": { - "missingClosingParen": "{index}번째 항목에 닫는 괄호가 없습니다.", - "missingCanonical": "{index}번째 항목에는 정식 태그 이름이 포함되어야 합니다.", - "duplicateCanonical": "정식 태그 \"{tag}\"가 여러 번 나타납니다.", - "unknown": "잘못된 우선순위 태그 구성입니다." - } } }, "loras": { @@ -929,6 +938,18 @@ "viewLocalVersions": "로컬 버전 모두 보기", "viewLocalTooltip": "곧 제공 예정" }, + "filters": { + "label": "기본 필터", + "state": { + "showAll": "모든 버전", + "showSameBase": "같은 베이스" + }, + "tooltip": { + "showAllVersions": "모든 버전을 표시하도록 전환", + "showSameBaseVersions": "같은 베이스 모델 버전만 표시하도록 전환" + }, + "empty": "현재 베이스 모델 필터와 일치하는 버전이 없습니다." + }, "empty": "이 모델에는 아직 버전 기록이 없습니다.", "error": "버전을 불러오지 못했습니다.", "missingModelId": "이 모델에는 Civitai 모델 ID가 없습니다.", diff --git a/locales/ru.json b/locales/ru.json index 158a9fb8..d703ca4e 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -227,13 +227,14 @@ "videoSettings": "Настройки видео", "layoutSettings": "Настройки макета", "folderSettings": "Настройки папок", + "priorityTags": "Приоритетные теги", "downloadPathTemplates": "Шаблоны путей загрузки", "exampleImages": "Примеры изображений", + "updateFlags": "Метки обновлений", "misc": "Разное", "metadataArchive": "Архив метаданных", "storageLocation": "Расположение настроек", - "proxySettings": "Настройки прокси", - "priorityTags": "Приоритетные теги" + "proxySettings": "Настройки прокси" }, "storage": { "locationLabel": "Портативный режим", @@ -297,6 +298,26 @@ "defaultEmbeddingRootHelp": "Установить корневую папку embedding по умолчанию для загрузок, импорта и перемещений", "noDefault": "Не задано" }, + "priorityTags": { + "title": "Приоритетные теги", + "description": "Настройте порядок приоритетов тегов для каждого типа моделей (например, character, concept, style(toon|toon_style)).", + "placeholder": "character, concept, style(toon|toon_style)", + "helpLinkLabel": "Открыть справку по приоритетным тегам", + "modelTypes": { + "lora": "LoRA", + "checkpoint": "Чекпойнт", + "embedding": "Эмбеддинг" + }, + "saveSuccess": "Приоритетные теги обновлены.", + "saveError": "Не удалось обновить приоритетные теги.", + "loadingSuggestions": "Загрузка подсказок...", + "validation": { + "missingClosingParen": "В записи {index} отсутствует закрывающая скобка.", + "missingCanonical": "Запись {index} должна содержать каноническое имя тега.", + "duplicateCanonical": "Канонический тег \"{tag}\" встречается более одного раза.", + "unknown": "Недопустимая конфигурация приоритетных тегов." + } + }, "downloadPathTemplates": { "title": "Шаблоны путей загрузки", "help": "Настройте структуру папок для разных типов моделей при загрузке с Civitai.", @@ -344,6 +365,14 @@ "download": "Загрузить", "restartRequired": "Требует перезапуска" }, + "updateFlagStrategy": { + "label": "Стратегия меток обновлений", + "help": "Выберите, отображать ли значки обновления только когда новая версия имеет тот же базовый модель, что и локальные файлы, или всегда при наличии любого нового релиза для этой модели.", + "options": { + "sameBase": "Совпадение обновлений по базовой модели", + "any": "Отмечать любые доступные обновления" + } + }, "misc": { "includeTriggerWords": "Включать триггерные слова в синтаксис LoRA", "includeTriggerWordsHelp": "Включать обученные триггерные слова при копировании синтаксиса LoRA в буфер обмена" @@ -389,26 +418,6 @@ "proxyPassword": "Пароль (необязательно)", "proxyPasswordPlaceholder": "пароль", "proxyPasswordHelp": "Пароль для аутентификации на прокси (если требуется)" - }, - "priorityTags": { - "title": "Приоритетные теги", - "description": "Настройте порядок приоритетов тегов для каждого типа моделей (например, character, concept, style(toon|toon_style)).", - "placeholder": "character, concept, style(toon|toon_style)", - "helpLinkLabel": "Открыть справку по приоритетным тегам", - "modelTypes": { - "lora": "LoRA", - "checkpoint": "Чекпойнт", - "embedding": "Эмбеддинг" - }, - "saveSuccess": "Приоритетные теги обновлены.", - "saveError": "Не удалось обновить приоритетные теги.", - "loadingSuggestions": "Загрузка подсказок...", - "validation": { - "missingClosingParen": "В записи {index} отсутствует закрывающая скобка.", - "missingCanonical": "Запись {index} должна содержать каноническое имя тега.", - "duplicateCanonical": "Канонический тег \"{tag}\" встречается более одного раза.", - "unknown": "Недопустимая конфигурация приоритетных тегов." - } } }, "loras": { @@ -929,6 +938,18 @@ "viewLocalVersions": "Показать все локальные версии", "viewLocalTooltip": "Скоро появится" }, + "filters": { + "label": "Фильтр по базе", + "state": { + "showAll": "Все версии", + "showSameBase": "Тот же базовый" + }, + "tooltip": { + "showAllVersions": "Переключиться на отображение всех версий", + "showSameBaseVersions": "Переключиться на отображение только версий с тем же базовым" + }, + "empty": "Нет версий, соответствующих текущему фильтру базовой модели." + }, "empty": "Для этой модели пока нет истории версий.", "error": "Не удалось загрузить версии.", "missingModelId": "У этой модели отсутствует идентификатор модели Civitai.", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index f1fd9cd9..e3804de8 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -227,13 +227,14 @@ "videoSettings": "视频设置", "layoutSettings": "布局设置", "folderSettings": "文件夹设置", + "priorityTags": "优先标签", "downloadPathTemplates": "下载路径模板", "exampleImages": "示例图片", + "updateFlags": "更新标记", "misc": "其他", "metadataArchive": "元数据归档数据库", "storageLocation": "设置位置", - "proxySettings": "代理设置", - "priorityTags": "优先标签" + "proxySettings": "代理设置" }, "storage": { "locationLabel": "便携模式", @@ -297,6 +298,26 @@ "defaultEmbeddingRootHelp": "设置下载、导入和移动时的默认 Embedding 根目录", "noDefault": "无默认" }, + "priorityTags": { + "title": "优先标签", + "description": "为每种模型类型自定义标签优先级顺序 (例如: character, concept, style(toon|toon_style))", + "placeholder": "character, concept, style(toon|toon_style)", + "helpLinkLabel": "打开优先标签帮助", + "modelTypes": { + "lora": "LoRA", + "checkpoint": "Checkpoint", + "embedding": "Embedding" + }, + "saveSuccess": "优先标签已更新。", + "saveError": "优先标签更新失败。", + "loadingSuggestions": "正在加载建议...", + "validation": { + "missingClosingParen": "条目 {index} 缺少右括号。", + "missingCanonical": "条目 {index} 必须包含规范标签名称。", + "duplicateCanonical": "规范标签 \"{tag}\" 出现多次。", + "unknown": "优先标签配置无效。" + } + }, "downloadPathTemplates": { "title": "下载路径模板", "help": "配置从 Civitai 下载不同模型类型的文件夹结构。", @@ -344,6 +365,14 @@ "download": "下载", "restartRequired": "需要重启" }, + "updateFlagStrategy": { + "label": "更新标记策略", + "help": "决定更新徽章是否仅在新版本与本地文件共享相同基础模型时显示,或只要该模型有任何更新版本就显示。", + "options": { + "sameBase": "按基础模型匹配更新", + "any": "显示任何可用更新" + } + }, "misc": { "includeTriggerWords": "复制 LoRA 语法时包含触发词", "includeTriggerWordsHelp": "复制 LoRA 语法到剪贴板时包含训练触发词" @@ -389,26 +418,6 @@ "proxyPassword": "密码 (可选)", "proxyPasswordPlaceholder": "密码", "proxyPasswordHelp": "代理认证的密码 (如果需要)" - }, - "priorityTags": { - "title": "优先标签", - "description": "为每种模型类型自定义标签优先级顺序 (例如: character, concept, style(toon|toon_style))", - "placeholder": "character, concept, style(toon|toon_style)", - "helpLinkLabel": "打开优先标签帮助", - "modelTypes": { - "lora": "LoRA", - "checkpoint": "Checkpoint", - "embedding": "Embedding" - }, - "saveSuccess": "优先标签已更新。", - "saveError": "优先标签更新失败。", - "loadingSuggestions": "正在加载建议...", - "validation": { - "missingClosingParen": "条目 {index} 缺少右括号。", - "missingCanonical": "条目 {index} 必须包含规范标签名称。", - "duplicateCanonical": "规范标签 \"{tag}\" 出现多次。", - "unknown": "优先标签配置无效。" - } } }, "loras": { @@ -929,6 +938,18 @@ "viewLocalVersions": "查看所有本地版本", "viewLocalTooltip": "敬请期待" }, + "filters": { + "label": "基础筛选", + "state": { + "showAll": "全部版本", + "showSameBase": "相同基模型" + }, + "tooltip": { + "showAllVersions": "切换为显示所有版本", + "showSameBaseVersions": "仅显示与当前基模型匹配的版本" + }, + "empty": "没有与当前基模型筛选匹配的版本。" + }, "empty": "该模型还没有版本历史。", "error": "加载版本失败。", "missingModelId": "该模型缺少 Civitai 模型 ID。", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index c5301fb0..9416fd8a 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -227,13 +227,14 @@ "videoSettings": "影片設定", "layoutSettings": "版面設定", "folderSettings": "資料夾設定", + "priorityTags": "優先標籤", "downloadPathTemplates": "下載路徑範本", "exampleImages": "範例圖片", + "updateFlags": "更新標記", "misc": "其他", "metadataArchive": "中繼資料封存資料庫", "storageLocation": "設定位置", - "proxySettings": "代理設定", - "priorityTags": "優先標籤" + "proxySettings": "代理設定" }, "storage": { "locationLabel": "可攜式模式", @@ -297,6 +298,26 @@ "defaultEmbeddingRootHelp": "設定下載、匯入和移動時的預設 Embedding 根目錄", "noDefault": "未設定預設" }, + "priorityTags": { + "title": "優先標籤", + "description": "為每種模型類型自訂標籤的優先順序 (例如: character, concept, style(toon|toon_style))", + "placeholder": "character, concept, style(toon|toon_style)", + "helpLinkLabel": "開啟優先標籤說明", + "modelTypes": { + "lora": "LoRA", + "checkpoint": "Checkpoint", + "embedding": "Embedding" + }, + "saveSuccess": "優先標籤已更新。", + "saveError": "更新優先標籤失敗。", + "loadingSuggestions": "正在載入建議...", + "validation": { + "missingClosingParen": "項目 {index} 缺少右括號。", + "missingCanonical": "項目 {index} 必須包含正規標籤名稱。", + "duplicateCanonical": "正規標籤 \"{tag}\" 出現多於一次。", + "unknown": "優先標籤設定無效。" + } + }, "downloadPathTemplates": { "title": "下載路徑範本", "help": "設定從 Civitai 下載時不同模型類型的資料夾結構。", @@ -344,6 +365,14 @@ "download": "下載", "restartRequired": "需要重新啟動" }, + "updateFlagStrategy": { + "label": "更新標記策略", + "help": "決定更新徽章是否僅在新版本與本地檔案共享相同基礎模型時顯示,或只要該模型有任何更新版本就顯示。", + "options": { + "sameBase": "依基礎模型匹配更新", + "any": "顯示任何可用更新" + } + }, "misc": { "includeTriggerWords": "在 LoRA 語法中包含觸發詞", "includeTriggerWordsHelp": "複製 LoRA 語法到剪貼簿時包含訓練觸發詞" @@ -389,26 +418,6 @@ "proxyPassword": "密碼(選填)", "proxyPasswordPlaceholder": "password", "proxyPasswordHelp": "代理驗證所需的密碼(如有需要)" - }, - "priorityTags": { - "title": "優先標籤", - "description": "為每種模型類型自訂標籤的優先順序 (例如: character, concept, style(toon|toon_style))", - "placeholder": "character, concept, style(toon|toon_style)", - "helpLinkLabel": "開啟優先標籤說明", - "modelTypes": { - "lora": "LoRA", - "checkpoint": "Checkpoint", - "embedding": "Embedding" - }, - "saveSuccess": "優先標籤已更新。", - "saveError": "更新優先標籤失敗。", - "loadingSuggestions": "正在載入建議...", - "validation": { - "missingClosingParen": "項目 {index} 缺少右括號。", - "missingCanonical": "項目 {index} 必須包含正規標籤名稱。", - "duplicateCanonical": "正規標籤 \"{tag}\" 出現多於一次。", - "unknown": "優先標籤設定無效。" - } } }, "loras": { @@ -929,6 +938,18 @@ "viewLocalVersions": "檢視所有本地版本", "viewLocalTooltip": "敬請期待" }, + "filters": { + "label": "基礎篩選", + "state": { + "showAll": "所有版本", + "showSameBase": "相同基礎模型" + }, + "tooltip": { + "showAllVersions": "切換為顯示所有版本", + "showSameBaseVersions": "僅顯示與目前基礎模型相符的版本" + }, + "empty": "沒有符合目前基礎模型篩選的版本。" + }, "empty": "此模型尚無版本歷史。", "error": "載入版本失敗。", "missingModelId": "此模型缺少 Civitai 模型 ID。", diff --git a/py/routes/handlers/misc_handlers.py b/py/routes/handlers/misc_handlers.py index b7750ce1..1e00ab75 100644 --- a/py/routes/handlers/misc_handlers.py +++ b/py/routes/handlers/misc_handlers.py @@ -201,6 +201,7 @@ class SettingsHandler: "priority_tags", "model_card_footer_action", "model_name_display", + "update_flag_strategy", ) _PROXY_KEYS = {"proxy_enabled", "proxy_host", "proxy_port", "proxy_username", "proxy_password", "proxy_type"} diff --git a/py/services/base_model_service.py b/py/services/base_model_service.py index d28b8f72..2fd393ec 100644 --- a/py/services/base_model_service.py +++ b/py/services/base_model_service.py @@ -267,20 +267,49 @@ class BaseModelService(ABC): if not ordered_ids: return annotated + strategy_value = self.settings.get("update_flag_strategy") + if isinstance(strategy_value, str) and strategy_value.strip(): + strategy = strategy_value.strip().lower() + else: + strategy = "same_base" + same_base_mode = strategy == "same_base" + + records = None resolved: Optional[Dict[int, bool]] = None - bulk_method = getattr(self.update_service, "has_updates_bulk", None) - if callable(bulk_method): - try: - resolved = await bulk_method(self.model_type, ordered_ids) - except Exception as exc: - logger.error( - "Failed to resolve update status in bulk for %s models (%s): %s", - self.model_type, - ordered_ids, - exc, - exc_info=True, - ) - resolved = None + if same_base_mode: + record_method = getattr(self.update_service, "get_records_bulk", None) + if callable(record_method): + try: + records = await record_method(self.model_type, ordered_ids) + resolved = { + model_id: record.has_update() + for model_id, record in records.items() + } + except Exception as exc: + logger.error( + "Failed to resolve update records in bulk for %s models (%s): %s", + self.model_type, + ordered_ids, + exc, + exc_info=True, + ) + records = None + resolved = None + + if resolved is None: + bulk_method = getattr(self.update_service, "has_updates_bulk", None) + if callable(bulk_method): + try: + resolved = await bulk_method(self.model_type, ordered_ids) + except Exception as exc: + logger.error( + "Failed to resolve update status in bulk for %s models (%s): %s", + self.model_type, + ordered_ids, + exc, + exc_info=True, + ) + resolved = None if resolved is None: tasks = [ @@ -301,8 +330,24 @@ class BaseModelService(ABC): resolved[model_id] = bool(result) for model_id, items_for_id in id_to_items.items(): - flag = bool(resolved.get(model_id, False)) + default_flag = bool(resolved.get(model_id, False)) if resolved else False + record = records.get(model_id) if records else None + base_highest_versions = ( + self._build_highest_local_versions_by_base(record) if same_base_mode and record else {} + ) for item in items_for_id: + if same_base_mode and record is not None: + base_model = self._extract_base_model(item) + normalized_base = self._normalize_base_model_name(base_model) + threshold_version = base_highest_versions.get(normalized_base) if normalized_base else None + if threshold_version is None: + threshold_version = self._extract_version_id(item) + flag = record.has_update_for_base( + threshold_version, + base_model, + ) + else: + flag = default_flag item['update_available'] = flag return annotated @@ -319,7 +364,71 @@ class BaseModelService(ABC): return int(value) except (TypeError, ValueError): return None - + + @staticmethod + def _extract_version_id(item: Dict) -> Optional[int]: + civitai = item.get('civitai') if isinstance(item, dict) else None + if not isinstance(civitai, dict): + return None + value = civitai.get('id') + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + @staticmethod + def _extract_base_model(item: Dict) -> Optional[str]: + value = item.get('base_model') + if value is None: + return None + if isinstance(value, str): + candidate = value.strip() + else: + try: + candidate = str(value).strip() + except Exception: + return None + return candidate if candidate else None + + @staticmethod + def _normalize_base_model_name(value: Optional[str]) -> Optional[str]: + """Return a lowercased, trimmed base model name for comparison.""" + + if value is None: + return None + if isinstance(value, str): + candidate = value.strip() + else: + try: + candidate = str(value).strip() + except Exception: + return None + return candidate.lower() if candidate else None + + def _build_highest_local_versions_by_base(self, record) -> Dict[str, int]: + """Return the highest local version id known for each normalized base model.""" + + if record is None: + return {} + + highest_by_base: Dict[str, int] = {} + for version in getattr(record, "versions", []): + if not getattr(version, "is_in_library", False): + continue + normalized_base = self._normalize_base_model_name(getattr(version, "base_model", None)) + if normalized_base is None: + continue + version_id = getattr(version, "version_id", None) + if version_id is None: + continue + current_max = highest_by_base.get(normalized_base) + if current_max is None or version_id > current_max: + highest_by_base[normalized_base] = version_id + + return highest_by_base + def _paginate(self, data: List[Dict], page: int, page_size: int) -> Dict: """Apply pagination to filtered data""" total_items = len(data) diff --git a/py/services/model_update_service.py b/py/services/model_update_service.py index d31ad28f..d75e1cfd 100644 --- a/py/services/model_update_service.py +++ b/py/services/model_update_service.py @@ -17,6 +17,41 @@ from ..utils.preview_selection import select_preview_media logger = logging.getLogger(__name__) +def _normalize_int(value) -> Optional[int]: + """Safely convert a value to an integer.""" + + try: + if value is None: + return None + return int(value) + except (TypeError, ValueError): + return None + + +def _normalize_string(value) -> Optional[str]: + """Return a stripped string or None if the value is empty.""" + + if value is None: + return None + if isinstance(value, str): + stripped = value.strip() + return stripped or None + try: + normalized = str(value).strip() + return normalized or None + except Exception: + return None + + +def _normalize_base_model(value) -> Optional[str]: + """Normalize base-model names for case-insensitive comparison.""" + + normalized = _normalize_string(value) + if normalized is None: + return None + return normalized.lower() + + @dataclass class ModelVersionRecord: """Persisted metadata for a single model version.""" @@ -85,6 +120,47 @@ class ModelUpdateRecord: return True return False + def has_update_for_base( + self, + local_version_id: Optional[int], + local_base_model: Optional[str], + ) -> bool: + """Return True when a newer remote version with the same base model exists.""" + + if self.should_ignore_model: + return False + + normalized_base = _normalize_base_model(local_base_model) + if normalized_base is None: + return False + + threshold = _normalize_int(local_version_id) + if threshold is None: + highest_local = None + for version in self.versions: + if not version.is_in_library: + continue + version_base = _normalize_base_model(version.base_model) + if version_base != normalized_base: + continue + if highest_local is None or version.version_id > highest_local: + highest_local = version.version_id + threshold = highest_local + + if threshold is None: + return False + + for version in self.versions: + if version.is_in_library or version.should_ignore: + continue + version_base = _normalize_base_model(version.base_model) + if version_base != normalized_base: + continue + if version.version_id > threshold: + return True + + return False + class ModelUpdateService: """Persist and query remote model version metadata.""" @@ -628,6 +704,20 @@ class ModelUpdateService: for model_id in normalized_ids } + async def get_records_bulk( + self, + model_type: str, + model_ids: Sequence[int], + ) -> Dict[int, ModelUpdateRecord]: + """Return cached update records for the requested models.""" + + normalized_ids = self._normalize_sequence(model_ids) + if not normalized_ids: + return {} + + async with self._lock: + return self._get_records_bulk(model_type, normalized_ids) + async def _refresh_single_model( self, model_type: str, @@ -799,7 +889,7 @@ class ModelUpdateService: ) continue for key, value in response.items(): - normalized_key = self._normalize_int(key) + normalized_key = _normalize_int(key) if normalized_key is None: continue if isinstance(value, Mapping): @@ -832,8 +922,8 @@ class ModelUpdateService: civitai = item.get("civitai") if isinstance(item, dict) else None if not isinstance(civitai, dict): continue - model_id = self._normalize_int(civitai.get("modelId")) - version_id = self._normalize_int(civitai.get("id")) + model_id = _normalize_int(civitai.get("modelId")) + version_id = _normalize_int(civitai.get("id")) if model_id is None or version_id is None: continue if target_set is not None and model_id not in target_set: @@ -973,35 +1063,14 @@ class ModelUpdateService: return True return (now - record.last_checked_at) >= self._ttl_seconds - @staticmethod - def _normalize_int(value) -> Optional[int]: - try: - if value is None: - return None - return int(value) - except (TypeError, ValueError): - return None - def _normalize_sequence(self, values: Sequence[int]) -> List[int]: normalized = [ item - for item in (self._normalize_int(value) for value in values) + for item in (_normalize_int(value) for value in values) if item is not None ] return sorted(dict.fromkeys(normalized)) - @staticmethod - def _normalize_string(value) -> Optional[str]: - if value is None: - return None - if isinstance(value, str): - stripped = value.strip() - return stripped or None - try: - return str(value) - except Exception: # pragma: no cover - defensive conversion - return None - def _extract_versions(self, response) -> Optional[List[ModelVersionRecord]]: if not isinstance(response, Mapping): return None @@ -1014,12 +1083,12 @@ class ModelUpdateService: for index, entry in enumerate(versions): if not isinstance(entry, Mapping): continue - version_id = self._normalize_int(entry.get("id")) + version_id = _normalize_int(entry.get("id")) if version_id is None: continue - name = self._normalize_string(entry.get("name")) - base_model = self._normalize_string(entry.get("baseModel")) - released_at = self._normalize_string(entry.get("publishedAt") or entry.get("createdAt")) + name = _normalize_string(entry.get("name")) + base_model = _normalize_string(entry.get("baseModel")) + released_at = _normalize_string(entry.get("publishedAt") or entry.get("createdAt")) size_bytes = self._extract_size_bytes(entry.get("files")) preview_url = self._extract_preview_url(entry.get("images")) extracted.append( @@ -1152,11 +1221,11 @@ class ModelUpdateService: name=row["name"], base_model=row["base_model"], released_at=row["released_at"], - size_bytes=self._normalize_int(row["size_bytes"]), + size_bytes=_normalize_int(row["size_bytes"]), preview_url=row["preview_url"], is_in_library=bool(row["is_in_library"]), should_ignore=bool(row["should_ignore"]), - sort_index=self._normalize_int(row["sort_index"]) or 0, + sort_index=_normalize_int(row["sort_index"]) or 0, ) ) diff --git a/py/services/settings_manager.py b/py/services/settings_manager.py index 37476f68..03b628da 100644 --- a/py/services/settings_manager.py +++ b/py/services/settings_manager.py @@ -61,6 +61,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = { "priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(), "model_name_display": "model_name", "model_card_footer_action": "example_images", + "update_flag_strategy": "same_base", } diff --git a/static/css/components/lora-modal/versions.css b/static/css/components/lora-modal/versions.css index 1eaa9eac..929bf6d4 100644 --- a/static/css/components/lora-modal/versions.css +++ b/static/css/components/lora-modal/versions.css @@ -24,12 +24,29 @@ color: var(--text-color); } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + .versions-toolbar-info p { margin: 0; font-size: 0.85rem; color: var(--text-muted); } +.versions-toolbar-info-heading { + display: flex; + align-items: center; + gap: var(--space-2); +} + .versions-toolbar-actions { display: flex; flex-wrap: wrap; @@ -68,6 +85,41 @@ color: var(--text-color); } +.versions-filter-toggle { + appearance: none; + border: 1px solid var(--border-color); + border-radius: var(--border-radius-sm); + padding: 0; + margin-bottom: 4px; + width: 30px; + height: 30px; + background: color-mix(in oklch, var(--card-bg) 80%, var(--bg-color)); + align-self: center; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease, transform 0.2s ease; + position: relative; + cursor: pointer; +} + +.versions-filter-toggle i { + font-size: 1rem; +} + +.versions-filter-toggle:hover:not(:disabled) { + border-color: var(--text-color); + color: var(--text-color); + transform: translateY(-1px); +} + +.versions-filter-toggle[data-filter-active="true"] { + border-color: color-mix(in oklch, var(--lora-accent) 65%, transparent); + color: var(--lora-accent); + background: color-mix(in oklch, var(--lora-accent) 20%, var(--card-bg) 80%); +} + .versions-toolbar-btn:disabled { opacity: 0.6; cursor: not-allowed; diff --git a/static/js/components/shared/ModelVersionsTab.js b/static/js/components/shared/ModelVersionsTab.js index 8aff2efd..8b04e25c 100644 --- a/static/js/components/shared/ModelVersionsTab.js +++ b/static/js/components/shared/ModelVersionsTab.js @@ -152,6 +152,81 @@ function buildBadge(label, tone) { return `${escapeHtml(label)}`; } +const DISPLAY_FILTER_MODES = Object.freeze({ + SAME_BASE: 'same_base', + ANY: 'any', +}); + +const FILTER_LABEL_KEY = 'modals.model.versions.filters.label'; +const FILTER_STATE_KEYS = { + [DISPLAY_FILTER_MODES.SAME_BASE]: 'modals.model.versions.filters.state.showSameBase', + [DISPLAY_FILTER_MODES.ANY]: 'modals.model.versions.filters.state.showAll', +}; +const FILTER_TOOLTIP_KEYS = { + [DISPLAY_FILTER_MODES.SAME_BASE]: 'modals.model.versions.filters.tooltip.showAllVersions', + [DISPLAY_FILTER_MODES.ANY]: 'modals.model.versions.filters.tooltip.showSameBaseVersions', +}; + +function normalizeBaseModelName(value) { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + return trimmed.toLowerCase(); +} + +function getToggleLabelText() { + return translate(FILTER_LABEL_KEY, {}, 'Base filter'); +} + +function getToggleStateText(mode) { + const key = FILTER_STATE_KEYS[mode] || FILTER_STATE_KEYS[DISPLAY_FILTER_MODES.ANY]; + const fallback = + mode === DISPLAY_FILTER_MODES.SAME_BASE ? 'Same base' : 'All versions'; + return translate(key, {}, fallback); +} + +function getToggleTooltipText(mode) { + const key = + FILTER_TOOLTIP_KEYS[mode] || FILTER_TOOLTIP_KEYS[DISPLAY_FILTER_MODES.ANY]; + const fallback = + mode === DISPLAY_FILTER_MODES.SAME_BASE + ? 'Switch to showing all versions' + : 'Switch to showing only versions with the current base model'; + return translate(key, {}, fallback); +} + +function getDefaultDisplayMode() { + const strategy = state?.global?.settings?.update_flag_strategy; + return strategy === DISPLAY_FILTER_MODES.SAME_BASE + ? DISPLAY_FILTER_MODES.SAME_BASE + : DISPLAY_FILTER_MODES.ANY; +} + +function getCurrentVersionBaseModel(record, versionId) { + if (!record || typeof versionId !== 'number' || !Array.isArray(record.versions)) { + return { + normalized: null, + raw: null, + }; + } + const currentVersion = record.versions.find(v => v.versionId === versionId); + if (!currentVersion) { + return { + normalized: null, + raw: null, + }; + } + const baseModelRaw = currentVersion.baseModel ?? null; + return { + normalized: normalizeBaseModelName(baseModelRaw), + raw: baseModelRaw, + }; +} + function getAutoplaySetting() { try { return Boolean(state?.global?.settings?.autoplay_on_hover); @@ -314,7 +389,7 @@ function getLatestLibraryVersionId(record) { return Math.max(...record.inLibraryVersionIds); } -function renderToolbar(record) { +function renderToolbar(record, toolbarState = {}) { const ignoreText = record.shouldIgnore ? translate('modals.model.versions.actions.resumeModelUpdates', {}, 'Resume updates for this model') : translate('modals.model.versions.actions.ignoreModelUpdates', {}, 'Ignore updates for this model'); @@ -325,10 +400,23 @@ function renderToolbar(record) { 'Track and manage every version of this model in one place.' ); + const displayMode = toolbarState.displayMode || DISPLAY_FILTER_MODES.ANY; + const toggleLabel = getToggleLabelText(); + const toggleState = getToggleStateText(displayMode); + const toggleTooltip = getToggleTooltipText(displayMode); + const filterActive = toolbarState.isFilteringActive ? 'true' : 'false'; + const screenReaderText = [toggleLabel, toggleState].filter(Boolean).join(': '); + return `
-

${translate('modals.model.versions.heading', {}, 'Model versions')}

+
+

${translate('modals.model.versions.heading', {}, 'Model versions')}

+ +

${escapeHtml(infoText)}

@@ -353,6 +441,20 @@ function renderEmptyState(container) { `; } +function renderFilteredEmptyState(baseModelLabel) { + const message = translate( + 'modals.model.versions.filters.empty', + { baseModel: baseModelLabel }, + 'No versions match the current base model filter.' + ); + return ` +
+ +

${escapeHtml(message)}

+
+ `; +} + function renderErrorState(container, message) { const fallback = translate('modals.model.versions.error', {}, 'Failed to load versions.'); container.innerHTML = ` @@ -391,6 +493,8 @@ export function initVersionsTab({ record: null, }; + let displayMode = getDefaultDisplayMode(); + let apiClient; function ensureClient() { @@ -414,55 +518,92 @@ export function initVersionsTab({ `; } - function render(record) { - controller.record = record; - controller.hasLoaded = true; +function render(record) { + controller.record = record; + controller.hasLoaded = true; - if (!record || !Array.isArray(record.versions) || record.versions.length === 0) { - renderEmptyState(container); - return; - } - - const latestLibraryVersionId = getLatestLibraryVersionId(record); - let dividerInserted = false; - - const sortedVersions = [...record.versions].sort( - (a, b) => Number(b.versionId) - Number(a.versionId) - ); - - const rowsMarkup = sortedVersions - .map(version => { - const isNewer = - typeof latestLibraryVersionId === 'number' && - version.versionId > latestLibraryVersionId; - let markup = ''; - if ( - !dividerInserted && - typeof latestLibraryVersionId === 'number' && - !isNewer - ) { - dividerInserted = true; - markup += ''; - } - markup += renderRow(version, { - latestLibraryVersionId, - currentVersionId: normalizedCurrentVersionId, - modelId: record?.modelId ?? modelId, - }); - return markup; - }) - .join(''); - - container.innerHTML = ` - ${renderToolbar(record)} -
- ${rowsMarkup} -
- `; - - setupMediaHoverInteractions(container); + if (!record || !Array.isArray(record.versions) || record.versions.length === 0) { + renderEmptyState(container); + return; } + const latestLibraryVersionId = getLatestLibraryVersionId(record); + const { normalized: currentBaseModelNormalized, raw: currentBaseModelLabel } = + getCurrentVersionBaseModel(record, normalizedCurrentVersionId); + const isFilteringActive = + displayMode === DISPLAY_FILTER_MODES.SAME_BASE && + Boolean(currentBaseModelNormalized); + + const sortedVersions = [...record.versions].sort( + (a, b) => Number(b.versionId) - Number(a.versionId) + ); + + const filteredVersions = sortedVersions.filter(version => { + if (!isFilteringActive) { + return true; + } + return normalizeBaseModelName(version.baseModel) === currentBaseModelNormalized; + }); + + const dividerThresholdVersionId = (() => { + if (!isFilteringActive) { + return latestLibraryVersionId; + } + const baseLocalVersionIds = record.versions + .filter( + version => + version.isInLibrary && + normalizeBaseModelName(version.baseModel) === currentBaseModelNormalized && + typeof version.versionId === 'number' + ) + .map(version => version.versionId); + if (!baseLocalVersionIds.length) { + return null; + } + return Math.max(...baseLocalVersionIds); + })(); + + let dividerInserted = false; + + const rowsMarkup = filteredVersions + .map(version => { + const isNewer = + typeof latestLibraryVersionId === 'number' && + version.versionId > latestLibraryVersionId; + let markup = ''; + if ( + !dividerInserted && + typeof dividerThresholdVersionId === 'number' && + !(version.versionId > dividerThresholdVersionId) + ) { + dividerInserted = true; + markup += ''; + } + markup += renderRow(version, { + latestLibraryVersionId, + currentVersionId: normalizedCurrentVersionId, + modelId: record?.modelId ?? modelId, + }); + return markup; + }) + .join(''); + + const listContent = + rowsMarkup || renderFilteredEmptyState(currentBaseModelLabel); + + container.innerHTML = ` + ${renderToolbar(record, { + displayMode, + isFilteringActive, + })} +
+ ${listContent} +
+ `; + + setupMediaHoverInteractions(container); +} + async function loadVersions({ forceRefresh = false, eager = false } = {}) { if (controller.isLoading) { return; @@ -531,6 +672,17 @@ export function initVersionsTab({ } } + function handleToggleVersionDisplayMode() { + displayMode = + displayMode === DISPLAY_FILTER_MODES.SAME_BASE + ? DISPLAY_FILTER_MODES.ANY + : DISPLAY_FILTER_MODES.SAME_BASE; + if (!controller.record) { + return; + } + render(controller.record); + } + async function handleToggleVersionIgnore(button, versionId) { if (!controller.record) { return; @@ -799,9 +951,17 @@ export function initVersionsTab({ const toolbarAction = event.target.closest('[data-versions-action]'); if (toolbarAction) { const action = toolbarAction.dataset.versionsAction; - if (action === 'toggle-model-ignore') { - event.preventDefault(); - await handleToggleModelIgnore(toolbarAction); + switch (action) { + case 'toggle-model-ignore': + event.preventDefault(); + await handleToggleModelIgnore(toolbarAction); + break; + case 'toggle-version-display-mode': + event.preventDefault(); + handleToggleVersionDisplayMode(); + break; + default: + break; } return; } diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index 705afc43..7a6b02ca 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -412,6 +412,11 @@ export class SettingsManager { modelNameDisplaySelect.value = state.global.settings.model_name_display || 'model_name'; } + const updateFlagStrategySelect = document.getElementById('updateFlagStrategy'); + if (updateFlagStrategySelect) { + updateFlagStrategySelect.value = state.global.settings.update_flag_strategy || 'same_base'; + } + // Set optimize example images setting const optimizeExampleImagesCheckbox = document.getElementById('optimizeExampleImages'); if (optimizeExampleImagesCheckbox) { @@ -1334,11 +1339,7 @@ export class SettingsManager { showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success'); - if (settingKey === 'model_name_display') { - this.reloadContent(); - } - - if (settingKey === 'model_card_footer_action') { + if (settingKey === 'model_name_display' || settingKey === 'model_card_footer_action' || settingKey === 'update_flag_strategy') { this.reloadContent(); } } catch (error) { diff --git a/static/js/state/index.js b/static/js/state/index.js index ee54c066..b8157f66 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -33,6 +33,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({ include_trigger_words: false, compact_mode: false, priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG }, + update_flag_strategy: 'same_base', }); export function createDefaultSettings() { diff --git a/templates/components/modals/settings_modal.html b/templates/components/modals/settings_modal.html index 4b40e976..3efcea9a 100644 --- a/templates/components/modals/settings_modal.html +++ b/templates/components/modals/settings_modal.html @@ -320,6 +320,27 @@
+ +
+

{{ t('settings.sections.updateFlags') }}

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

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

diff --git a/tests/frontend/components/modelVersionsTab.media.test.js b/tests/frontend/components/modelVersionsTab.media.test.js index b8b3a287..a4c6af7c 100644 --- a/tests/frontend/components/modelVersionsTab.media.test.js +++ b/tests/frontend/components/modelVersionsTab.media.test.js @@ -28,7 +28,14 @@ vi.mock(UI_HELPERS_MODULE, () => ({ showToast: vi.fn(), })); -const stateMock = { global: { settings: { autoplay_on_hover: false } } }; +const stateMock = { + global: { + settings: { + autoplay_on_hover: false, + update_flag_strategy: 'any', + }, + }, +}; vi.mock(STATE_MODULE, () => ({ state: stateMock, })); @@ -59,6 +66,7 @@ describe('ModelVersionsTab media rendering', () => {
`; stateMock.global.settings.autoplay_on_hover = false; + stateMock.global.settings.update_flag_strategy = 'any'; ({ getModelApiClient } = await import(API_FACTORY_MODULE)); fetchModelUpdateVersions = vi.fn(); getModelApiClient.mockReturnValue({ @@ -146,4 +154,133 @@ describe('ModelVersionsTab media rendering', () => { expect(imageElement?.getAttribute('src')).toBe(previewUrl); expect(document.querySelector('.version-media video')).toBeFalsy(); }); + + it('shows a stable label with a short state indicator', async () => { + stateMock.global.settings.update_flag_strategy = 'any'; + fetchModelUpdateVersions.mockResolvedValue({ + success: true, + record: { + shouldIgnore: false, + inLibraryVersionIds: [5], + versions: [ + { + versionId: 5, + name: 'base', + baseModel: 'SDXL', + previewUrl: '/api/lm/previews/v-base.png', + sizeBytes: 1024, + isInLibrary: true, + shouldIgnore: false, + }, + ], + }, + }); + + const { initVersionsTab } = await import(MODEL_VERSIONS_MODULE); + const controller = initVersionsTab({ + modalId: 'model-versions-modal', + modelType: 'loras', + modelId: 321, + currentVersionId: 5, + }); + + await controller.load(); + + const toggleText = document.querySelector('.versions-filter-toggle .sr-only'); + expect(toggleText?.textContent?.trim()).toBe('Base filter: All versions'); + }); + + it('filters versions to the current base model when strategy is same_base', async () => { + stateMock.global.settings.update_flag_strategy = 'same_base'; + fetchModelUpdateVersions.mockResolvedValue({ + success: true, + record: { + shouldIgnore: false, + inLibraryVersionIds: [10], + versions: [ + { + versionId: 10, + name: 'v1.0', + baseModel: 'SDXL', + previewUrl: '/api/lm/previews/v1.png', + sizeBytes: 1024, + isInLibrary: true, + shouldIgnore: false, + }, + { + versionId: 11, + name: 'v1.1', + baseModel: 'Realistic', + previewUrl: '/api/lm/previews/v1-1.png', + sizeBytes: 2048, + isInLibrary: false, + shouldIgnore: false, + }, + ], + }, + }); + + const { initVersionsTab } = await import(MODEL_VERSIONS_MODULE); + const controller = initVersionsTab({ + modalId: 'model-versions-modal', + modelType: 'loras', + modelId: 789, + currentVersionId: 10, + }); + + await controller.load(); + + expect(document.querySelectorAll('.model-version-row').length).toBe(1); + }); + + it('toggle button can switch to display all versions', async () => { + stateMock.global.settings.update_flag_strategy = 'same_base'; + fetchModelUpdateVersions.mockResolvedValue({ + success: true, + record: { + shouldIgnore: false, + inLibraryVersionIds: [10], + versions: [ + { + versionId: 10, + name: 'v1.0', + baseModel: 'SDXL', + previewUrl: '/api/lm/previews/v1.png', + sizeBytes: 1024, + isInLibrary: true, + shouldIgnore: false, + }, + { + versionId: 11, + name: 'v1.1', + baseModel: 'Realistic', + previewUrl: '/api/lm/previews/v1-1.png', + sizeBytes: 2048, + isInLibrary: false, + shouldIgnore: false, + }, + ], + }, + }); + + const { initVersionsTab } = await import(MODEL_VERSIONS_MODULE); + const controller = initVersionsTab({ + modalId: 'model-versions-modal', + modelType: 'loras', + modelId: 987, + currentVersionId: 10, + }); + + await controller.load(); + + expect(document.querySelectorAll('.model-version-row').length).toBe(1); + const toggleButton = document.querySelector('[data-versions-action="toggle-version-display-mode"]'); + expect(toggleButton).toBeTruthy(); + const toggleTextBefore = document.querySelector('.versions-filter-toggle .sr-only'); + expect(toggleTextBefore?.textContent?.trim()).toContain('Same base'); + toggleButton?.click(); + expect(document.querySelectorAll('.model-version-row').length).toBe(2); + const toggleTextAfter = document.querySelector('.versions-filter-toggle .sr-only'); + expect(toggleTextAfter?.textContent?.trim()).toContain('All versions'); + }); }); diff --git a/tests/services/test_base_model_service.py b/tests/services/test_base_model_service.py index 77a2a1b4..8c412aa1 100644 --- a/tests/services/test_base_model_service.py +++ b/tests/services/test_base_model_service.py @@ -11,6 +11,7 @@ from py.services.model_query import ( SearchStrategy, SortParams, ) +from py.services.model_update_service import ModelUpdateRecord, ModelVersionRecord from py.utils.models import BaseModelMetadata @@ -98,6 +99,25 @@ class StubUpdateService: return result +class StubUpdateServiceWithRecords(StubUpdateService): + def __init__(self, records, *, bulk_error: bool = False): + decisions = { + model_id: record.has_update() + for model_id, record in records.items() + } + super().__init__(decisions, bulk_error=bulk_error) + self.records = dict(records) + self.records_bulk_calls = [] + + async def get_records_bulk(self, model_type, model_ids): + self.records_bulk_calls.append((model_type, list(model_ids))) + return { + model_id: self.records[model_id] + for model_id in model_ids + if model_id in self.records + } + + @pytest.mark.asyncio async def test_get_paginated_data_uses_injected_collaborators(): data = [ @@ -461,6 +481,198 @@ async def test_get_paginated_data_annotates_update_flags_with_bulk_dedup(): assert response["total_pages"] == 1 +@pytest.mark.asyncio +async def test_update_flag_strategy_same_base_prefers_matching_base(): + items = [ + { + "model_name": "Pony Version", + "civitai": {"modelId": 1, "id": 10, "baseModel": "Pony"}, + "base_model": "Pony", + }, + { + "model_name": "Flux Version", + "civitai": {"modelId": 1, "id": 20, "baseModel": "Flux 1.D"}, + "base_model": "Flux 1.D", + }, + ] + repository = StubRepository(items) + filter_set = PassThroughFilterSet() + search_strategy = NoSearchStrategy() + record = ModelUpdateRecord( + model_type="stub", + model_id=1, + versions=[ + ModelVersionRecord( + version_id=10, + name="Pony Local", + base_model="Pony", + released_at=None, + size_bytes=None, + preview_url=None, + is_in_library=True, + should_ignore=False, + sort_index=0, + ), + ModelVersionRecord( + version_id=20, + name="Flux Local", + base_model="Flux 1.D", + released_at=None, + size_bytes=None, + preview_url=None, + is_in_library=True, + should_ignore=False, + sort_index=1, + ), + ModelVersionRecord( + version_id=30, + name="Pony Remote", + base_model="Pony", + released_at=None, + size_bytes=None, + preview_url=None, + is_in_library=False, + should_ignore=False, + sort_index=2, + ), + ModelVersionRecord( + version_id=40, + name="SDXL Remote", + base_model="SDXL", + released_at=None, + size_bytes=None, + preview_url=None, + is_in_library=False, + should_ignore=False, + sort_index=3, + ), + ], + last_checked_at=None, + should_ignore_model=False, + ) + update_service = StubUpdateServiceWithRecords({1: record}) + settings = StubSettings({"update_flag_strategy": "same_base"}) + + service = DummyService( + model_type="stub", + scanner=object(), + metadata_class=BaseModelMetadata, + cache_repository=repository, + filter_set=filter_set, + search_strategy=search_strategy, + settings_provider=settings, + update_service=update_service, + ) + + response = await service.get_paginated_data( + page=1, + page_size=10, + sort_by="name:asc", + ) + + assert update_service.records_bulk_calls == [("stub", [1])] + assert update_service.bulk_calls == [] + assert len(response["items"]) == 2 + flags = {item["model_name"]: item["update_available"] for item in response["items"]} + assert flags["Pony Version"] is True + assert flags["Flux Version"] is False + + +@pytest.mark.asyncio +async def test_update_flag_strategy_same_base_honors_latest_local_version(): + items = [ + { + "model_name": "Pony v0.1", + "civitai": {"modelId": 1, "id": 101, "baseModel": "Pony"}, + "base_model": "Pony", + }, + { + "model_name": "Pony v0.3", + "civitai": {"modelId": 1, "id": 103, "baseModel": "Pony"}, + "base_model": "Pony", + }, + ] + repository = StubRepository(items) + filter_set = PassThroughFilterSet() + search_strategy = NoSearchStrategy() + record = ModelUpdateRecord( + model_type="stub", + model_id=1, + versions=[ + ModelVersionRecord( + version_id=101, + name="Old Pony", + base_model="Pony", + released_at=None, + size_bytes=None, + preview_url=None, + is_in_library=True, + should_ignore=False, + sort_index=0, + ), + ModelVersionRecord( + version_id=102, + name="Pony Remote", + base_model="Pony", + released_at=None, + size_bytes=None, + preview_url=None, + is_in_library=False, + should_ignore=False, + sort_index=1, + ), + ModelVersionRecord( + version_id=103, + name="Middle Pony", + base_model="Pony", + released_at=None, + size_bytes=None, + preview_url=None, + is_in_library=True, + should_ignore=False, + sort_index=2, + ), + ModelVersionRecord( + version_id=104, + name="Latest Pony", + base_model="Pony", + released_at=None, + size_bytes=None, + preview_url=None, + is_in_library=True, + should_ignore=False, + sort_index=3, + ), + ], + last_checked_at=None, + should_ignore_model=False, + ) + update_service = StubUpdateServiceWithRecords({1: record}) + settings = StubSettings({"update_flag_strategy": "same_base"}) + + service = DummyService( + model_type="stub", + scanner=object(), + metadata_class=BaseModelMetadata, + cache_repository=repository, + filter_set=filter_set, + search_strategy=search_strategy, + settings_provider=settings, + update_service=update_service, + ) + + response = await service.get_paginated_data( + page=1, + page_size=10, + sort_by="name:asc", + ) + + assert update_service.records_bulk_calls == [("stub", [1])] + flags = {item["model_name"]: item["update_available"] for item in response["items"]} + assert flags["Pony v0.1"] is False + assert flags["Pony v0.3"] is False + + @pytest.mark.asyncio async def test_get_paginated_data_filters_update_available_only(): items = [ diff --git a/tests/services/test_model_update_service.py b/tests/services/test_model_update_service.py index 23c7d003..5e4534e8 100644 --- a/tests/services/test_model_update_service.py +++ b/tests/services/test_model_update_service.py @@ -52,11 +52,11 @@ class NotFoundProvider: return {} -def make_version(version_id, *, in_library, should_ignore=False): +def make_version(version_id, *, in_library, base_model=None, should_ignore=False): return ModelVersionRecord( version_id=version_id, name=None, - base_model=None, + base_model=base_model, released_at=None, size_bytes=None, preview_url=None, @@ -147,6 +147,25 @@ def test_has_update_detects_newer_remote_version(): assert record.has_update() is True +def test_has_update_for_base_matches_same_base_model(): + record = make_record( + make_version(5, in_library=True, base_model="Pony"), + make_version(6, in_library=False, base_model="Pony"), + make_version(7, in_library=False, base_model="Flux.1"), + ) + + assert record.has_update_for_base(5, "Pony") is True + + +def test_has_update_for_base_rejects_other_base_models(): + record = make_record( + make_version(10, in_library=True, base_model="Flux"), + make_version(20, in_library=False, base_model="SDXL"), + ) + + assert record.has_update_for_base(10, "Flux") is False + + @pytest.mark.asyncio async def test_refresh_persists_versions_and_uses_cache(tmp_path): db_path = tmp_path / "updates.sqlite"