Merge pull request #681 from willmiao/update-strategy, see #676

Add update flag strategy
This commit is contained in:
pixelpaws
2025-11-18 08:44:46 +08:00
committed by GitHub
22 changed files with 1230 additions and 238 deletions

View File

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

View File

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

View File

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

View File

@@ -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 lorsquune nouvelle version partage le même modèle de base que vos fichiers locaux, ou dès quil existe une version plus récente pour ce modèle.",
"options": {
"sameBase": "Faire correspondre les mises à jour par modèle de base",
"any": "Signaler nimporte 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.",

View File

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

View File

@@ -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がありません。",

View File

@@ -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가 없습니다.",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -152,6 +152,81 @@ function buildBadge(label, tone) {
return `<span class="version-badge version-badge-${tone}">${escapeHtml(label)}</span>`;
}
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 `
<header class="versions-toolbar">
<div class="versions-toolbar-info">
<h3>${translate('modals.model.versions.heading', {}, 'Model versions')}</h3>
<div class="versions-toolbar-info-heading">
<h3>${translate('modals.model.versions.heading', {}, 'Model versions')}</h3>
<button class="versions-filter-toggle" data-versions-action="toggle-version-display-mode" type="button" title="${escapeHtml(toggleTooltip)}" aria-label="${escapeHtml(toggleTooltip)}" data-filter-active="${filterActive}" aria-pressed="${filterActive}">
<i class="fas fa-th-list" aria-hidden="true"></i>
<span class="sr-only">${escapeHtml(screenReaderText)}</span>
</button>
</div>
<p>${escapeHtml(infoText)}</p>
</div>
<div class="versions-toolbar-actions">
@@ -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 `
<div class="versions-empty versions-empty-filter">
<i class="fas fa-info-circle"></i>
<p>${escapeHtml(message)}</p>
</div>
`;
}
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 += '<div class="version-divider" role="presentation"></div>';
}
markup += renderRow(version, {
latestLibraryVersionId,
currentVersionId: normalizedCurrentVersionId,
modelId: record?.modelId ?? modelId,
});
return markup;
})
.join('');
container.innerHTML = `
${renderToolbar(record)}
<div class="versions-list">
${rowsMarkup}
</div>
`;
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 += '<div class="version-divider" role="presentation"></div>';
}
markup += renderRow(version, {
latestLibraryVersionId,
currentVersionId: normalizedCurrentVersionId,
modelId: record?.modelId ?? modelId,
});
return markup;
})
.join('');
const listContent =
rowsMarkup || renderFilteredEmptyState(currentBaseModelLabel);
container.innerHTML = `
${renderToolbar(record, {
displayMode,
isFilteringActive,
})}
<div class="versions-list">
${listContent}
</div>
`;
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;
}

View File

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

View File

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

View File

@@ -320,6 +320,27 @@
</div>
</div>
<!-- Update Flag Strategy Section -->
<div class="settings-section">
<h3>{{ t('settings.sections.updateFlags') }}</h3>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="updateFlagStrategy">{{ t('settings.updateFlagStrategy.label') }}</label>
</div>
<div class="setting-control select-control">
<select id="updateFlagStrategy" onchange="settingsManager.saveSelectSetting('updateFlagStrategy', 'update_flag_strategy')">
<option value="same_base">{{ t('settings.updateFlagStrategy.options.sameBase') }}</option>
<option value="any">{{ t('settings.updateFlagStrategy.options.any') }}</option>
</select>
</div>
</div>
<div class="input-help">
{{ t('settings.updateFlagStrategy.help') }}
</div>
</div>
</div>
<!-- Default Path Customization Section -->
<div class="settings-section">
<h3>{{ t('settings.downloadPathTemplates.title') }}</h3>

View File

@@ -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', () => {
</div>
`;
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');
});
});

View File

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

View File

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