diff --git a/locales/de.json b/locales/de.json index 2278ad83..d69d4a26 100644 --- a/locales/de.json +++ b/locales/de.json @@ -935,8 +935,16 @@ }, "duplicates": { "found": "{count} Duplikat-Gruppen gefunden", + "noGroups": "Keine Duplikat-Gruppen mit dem aktuellen Abgleichskriterium gefunden", "keepLatest": "Neueste Versionen behalten", - "deleteSelected": "Ausgewählte löschen" + "deleteSelected": "Ausgewählte löschen", + "includePromptLabel": "Prompt beim Abgleich berücksichtigen", + "basis": { + "loraCombo": "Abgeglichen nach: LoRA-Kombination", + "loraComboAndPrompt": "Abgeglichen nach: LoRA-Kombination + Prompt", + "hintLoraCombo": "Rezepte mit denselben LoRAs bei identischen Stärken werden gruppiert.", + "hintPromptIncluded": "Rezepte werden nur gruppiert, wenn sie dieselben LoRAs bei identischen Stärken UND denselben Prompt verwenden." + } }, "contextMenu": { "copyRecipe": { diff --git a/locales/en.json b/locales/en.json index e54eaa12..9f554140 100644 --- a/locales/en.json +++ b/locales/en.json @@ -935,8 +935,16 @@ }, "duplicates": { "found": "Found {count} duplicate groups", + "noGroups": "No duplicate groups found with the current matching basis", "keepLatest": "Keep Latest Versions", - "deleteSelected": "Delete Selected" + "deleteSelected": "Delete Selected", + "includePromptLabel": "Include prompt in matching", + "basis": { + "loraCombo": "Matched by: LoRA combination", + "loraComboAndPrompt": "Matched by: LoRA combination + prompt", + "hintLoraCombo": "Recipes with the same LoRAs at identical strengths are grouped.", + "hintPromptIncluded": "Recipes are grouped only when they use the same LoRAs at identical strengths AND have the same prompt." + } }, "contextMenu": { "copyRecipe": { diff --git a/locales/es.json b/locales/es.json index fe46a19d..39159d84 100644 --- a/locales/es.json +++ b/locales/es.json @@ -935,8 +935,16 @@ }, "duplicates": { "found": "Se encontraron {count} grupos de duplicados", + "noGroups": "No se encontraron grupos de duplicados con el criterio de coincidencia actual", "keepLatest": "Mantener versiones más recientes", - "deleteSelected": "Eliminar seleccionados" + "deleteSelected": "Eliminar seleccionados", + "includePromptLabel": "Incluir prompt en la coincidencia", + "basis": { + "loraCombo": "Coincidencia por: combinación de LoRA", + "loraComboAndPrompt": "Coincidencia por: combinación de LoRA + prompt", + "hintLoraCombo": "Se agrupan las recetas con los mismos LoRAs y las mismas intensidades.", + "hintPromptIncluded": "Las recetas solo se agrupan cuando usan los mismos LoRAs con intensidades idénticas Y tienen el mismo prompt." + } }, "contextMenu": { "copyRecipe": { diff --git a/locales/fr.json b/locales/fr.json index 9fbffbbc..7be374b2 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -935,8 +935,16 @@ }, "duplicates": { "found": "Trouvé {count} groupes de doublons", + "noGroups": "Aucun groupe de doublons trouvé avec le critère de correspondance actuel", "keepLatest": "Garder les dernières versions", - "deleteSelected": "Supprimer la sélection" + "deleteSelected": "Supprimer la sélection", + "includePromptLabel": "Inclure le prompt dans la correspondance", + "basis": { + "loraCombo": "Correspondance : combinaison de LoRA", + "loraComboAndPrompt": "Correspondance : combinaison de LoRA + prompt", + "hintLoraCombo": "Les recettes avec les mêmes LoRAs et des forces identiques sont regroupées.", + "hintPromptIncluded": "Les recettes ne sont regroupées que si elles utilisent les mêmes LoRAs avec des forces identiques ET ont le même prompt." + } }, "contextMenu": { "copyRecipe": { diff --git a/locales/he.json b/locales/he.json index c1e4db01..e292c775 100644 --- a/locales/he.json +++ b/locales/he.json @@ -935,8 +935,16 @@ }, "duplicates": { "found": "נמצאו {count} קבוצות כפולות", + "noGroups": "לא נמצאו קבוצות כפולות לפי קריטריון ההתאמה הנוכחי", "keepLatest": "שמור גרסאות אחרונות", - "deleteSelected": "מחק נבחרים" + "deleteSelected": "מחק נבחרים", + "includePromptLabel": "כלול הנחיה בהתאמה", + "basis": { + "loraCombo": "התאמה לפי: שילוב LoRA", + "loraComboAndPrompt": "התאמה לפי: שילוב LoRA + הנחיה", + "hintLoraCombo": "מתכונים עם אותם LoRAs בעוצמות זהות מקובצים יחד.", + "hintPromptIncluded": "מתכונים מקובצים רק כאשר הם משתמשים באותם LoRAs בעוצמות זהות ויש להם אותה הנחיה." + } }, "contextMenu": { "copyRecipe": { diff --git a/locales/ja.json b/locales/ja.json index 4a1465ac..7fbf6250 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -935,8 +935,16 @@ }, "duplicates": { "found": "{count} 個の重複グループが見つかりました", + "noGroups": "現在の一致基準では重複グループが見つかりませんでした", "keepLatest": "最新バージョンを保持", - "deleteSelected": "選択したものを削除" + "deleteSelected": "選択したものを削除", + "includePromptLabel": "一致判定にプロンプトを含める", + "basis": { + "loraCombo": "一致基準: LoRA の組み合わせ", + "loraComboAndPrompt": "一致基準: LoRA の組み合わせ + プロンプト", + "hintLoraCombo": "同じ LoRA を同じ強度で使用するレシピがグループ化されます。", + "hintPromptIncluded": "レシピは、同じ LoRA を同じ強度で使用し、かつプロンプトが同じ場合にのみグループ化されます。" + } }, "contextMenu": { "copyRecipe": { diff --git a/locales/ko.json b/locales/ko.json index d6de1d1c..8eaf4173 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -935,8 +935,16 @@ }, "duplicates": { "found": "{count}개의 중복 그룹 발견", + "noGroups": "현재 일치 기준으로 중복 그룹을 찾을 수 없습니다", "keepLatest": "최신 버전 유지", - "deleteSelected": "선택된 항목 삭제" + "deleteSelected": "선택된 항목 삭제", + "includePromptLabel": "일치 항목에 프롬프트 포함", + "basis": { + "loraCombo": "일치 기준: LoRA 조합", + "loraComboAndPrompt": "일치 기준: LoRA 조합 + 프롬프트", + "hintLoraCombo": "동일한 LoRA를 동일한 강도로 사용하는 레시피가 그룹화됩니다.", + "hintPromptIncluded": "동일한 LoRA를 동일한 강도로 사용하고 프롬프트도 동일한 경우에만 레시피가 그룹화됩니다." + } }, "contextMenu": { "copyRecipe": { diff --git a/locales/ru.json b/locales/ru.json index 01e6898c..4657ae7d 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -935,8 +935,16 @@ }, "duplicates": { "found": "Найдено {count} групп дубликатов", + "noGroups": "Дубликатов с текущим критерием не найдено", "keepLatest": "Оставить последние версии", - "deleteSelected": "Удалить выбранные" + "deleteSelected": "Удалить выбранные", + "includePromptLabel": "Учитывать запрос при поиске дубликатов", + "basis": { + "loraCombo": "Критерий: комбинация LoRA", + "loraComboAndPrompt": "Критерий: комбинация LoRA + запрос", + "hintLoraCombo": "Рецепты с одинаковыми LoRA и одинаковой силой группируются вместе.", + "hintPromptIncluded": "Рецепты группируются только при одинаковых LoRA с одинаковой силой И одинаковом запросе." + } }, "contextMenu": { "copyRecipe": { diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 7085edf3..4fe7fccb 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -935,8 +935,16 @@ }, "duplicates": { "found": "发现 {count} 个重复组", + "noGroups": "按当前判重依据未找到重复组", "keepLatest": "保留最新版本", - "deleteSelected": "删除已选" + "deleteSelected": "删除已选", + "includePromptLabel": "将提示词纳入判重", + "basis": { + "loraCombo": "判重依据:LoRA 组合", + "loraComboAndPrompt": "判重依据:LoRA 组合 + 提示词", + "hintLoraCombo": "使用相同 LoRA(强度一致)的配方会被分组。", + "hintPromptIncluded": "仅当配方使用相同的 LoRA(强度一致)且提示词相同时才会被分组。" + } }, "contextMenu": { "copyRecipe": { diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 45f504cb..e1d42d59 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -935,8 +935,16 @@ }, "duplicates": { "found": "發現 {count} 組重複項", + "noGroups": "按目前判重依據未找到重複組", "keepLatest": "保留最新版本", - "deleteSelected": "刪除所選" + "deleteSelected": "刪除所選", + "includePromptLabel": "將提示詞納入判重", + "basis": { + "loraCombo": "判重依據:LoRA 組合", + "loraComboAndPrompt": "判重依據:LoRA 組合 + 提示詞", + "hintLoraCombo": "使用相同 LoRA(強度一致)的配方會被分組。", + "hintPromptIncluded": "僅當配方使用相同的 LoRA(強度一致)且提示詞相同時才會被分組。" + } }, "contextMenu": { "copyRecipe": { diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py index 9f74b720..3ef9de9e 100644 --- a/py/routes/handlers/recipe_handlers.py +++ b/py/routes/handlers/recipe_handlers.py @@ -580,7 +580,12 @@ class RecipeQueryHandler: if recipe_scanner is None: raise RuntimeError("Recipe scanner unavailable") - fingerprint_groups = await recipe_scanner.find_all_duplicate_recipes() + include_prompt = ( + request.query.get("include_prompt", "false").lower() in ("1", "true") + ) + fingerprint_groups = await recipe_scanner.find_all_duplicate_recipes( + include_prompt=include_prompt + ) url_groups = await recipe_scanner.find_duplicate_recipes_by_source() response_data = [] @@ -613,6 +618,7 @@ class RecipeQueryHandler: response_data.append( { "type": "fingerprint", + "key": f"g-{len(response_data) + 1}", "fingerprint": fingerprint, "count": len(recipes), "recipes": recipes, @@ -648,6 +654,7 @@ class RecipeQueryHandler: response_data.append( { "type": "source_path", + "key": f"g-{len(response_data) + 1}", "fingerprint": url, "count": len(recipes), "recipes": recipes, diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py index 4b53a9f4..72e82fc2 100644 --- a/py/services/recipe_scanner.py +++ b/py/services/recipe_scanner.py @@ -3540,32 +3540,70 @@ class RecipeScanner: return matching_recipes - async def find_all_duplicate_recipes(self) -> Dict[str, List[Any]]: + async def find_all_duplicate_recipes( + self, include_prompt: bool = False + ) -> Dict[str, List[Any]]: """Find all recipe duplicates based on fingerprints + When ``include_prompt`` is True, the grouping key additionally + includes the normalized positive prompt, so recipes are only grouped + when they share both the same LoRA combination (with identical + strengths) and the same prompt. Recipes with neither a fingerprint + nor a prompt are skipped. + + Args: + include_prompt: Whether to require an identical prompt as well + Returns: - Dictionary where keys are fingerprints and values are lists of recipe IDs + Dictionary where keys are grouping keys and values are lists of recipe IDs """ # Get all recipes from cache cache = await self.get_cached_data() - # Group recipes by fingerprint + # Group recipes by fingerprint (optionally combined with the prompt) fingerprint_groups = {} for recipe in cache.raw_data: - fingerprint = recipe.get("fingerprint") - if not fingerprint: + grouping_key = self._build_duplicate_grouping_key( + recipe, include_prompt + ) + if not grouping_key: continue - if fingerprint not in fingerprint_groups: - fingerprint_groups[fingerprint] = [] + if grouping_key not in fingerprint_groups: + fingerprint_groups[grouping_key] = [] - fingerprint_groups[fingerprint].append(recipe.get("id")) + fingerprint_groups[grouping_key].append(recipe.get("id")) # Filter to only include groups with more than one recipe duplicate_groups = {k: v for k, v in fingerprint_groups.items() if len(v) > 1} return duplicate_groups + def _build_duplicate_grouping_key( + self, recipe: Dict[str, Any], include_prompt: bool + ) -> str: + """Build the grouping key used for duplicate detection. + + Without ``include_prompt`` this is the stored fingerprint (same LoRA + combination at identical strengths). With it, the normalized positive + prompt is appended (separated by ``\\x1f``), so recipes must share + both factors to be grouped. Recipes with no loras still participate + when they carry a prompt, matching other no-lora recipes with the + same prompt. + """ + fingerprint = recipe.get("fingerprint") or "" + if not include_prompt: + return fingerprint + + from ..utils.utils import normalize_prompt_for_dedup + + prompt = normalize_prompt_for_dedup( + (recipe.get("gen_params") or {}).get("prompt") + ) + if not fingerprint and not prompt: + return "" + return f"{fingerprint}\x1f{prompt}" + async def find_duplicate_recipes_by_source(self) -> Dict[str, List[Any]]: """Find all recipe duplicates based on source_path (Civitai image URLs) diff --git a/py/utils/utils.py b/py/utils/utils.py index cbf67d3d..8670d497 100644 --- a/py/utils/utils.py +++ b/py/utils/utils.py @@ -469,6 +469,24 @@ def calculate_recipe_fingerprint(loras): return fingerprint +def normalize_prompt_for_dedup(prompt) -> str: + """Normalize a positive prompt for duplicate recipe matching. + + Applies casefolding, collapses whitespace runs into single spaces, and + trims leading/trailing whitespace. Missing or non-string prompts + normalize to an empty string. + + Args: + prompt: The positive prompt text (or None) + + Returns: + str: The normalized prompt + """ + if not prompt or not isinstance(prompt, str): + return "" + return re.sub(r"\s+", " ", prompt).strip().casefold() + + def calculate_relative_path_for_model( model_data: Dict[str, Any], model_type: str = "lora" ) -> str: diff --git a/static/css/components/duplicates.css b/static/css/components/duplicates.css index bef12ad9..fdcd772b 100644 --- a/static/css/components/duplicates.css +++ b/static/css/components/duplicates.css @@ -486,6 +486,26 @@ } } +/* Empty-state hint in the duplicates view */ +.duplicates-empty-state { + padding: 48px 16px; + text-align: center; + opacity: 0.7; + font-size: 0.95em; + width: 100%; +} + +/* Matching basis text in the duplicates banner */ +.duplicates-basis { + font-size: 0.85em; + opacity: 0.8; + padding: 3px 10px; + border-radius: var(--border-radius-xs); + background: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.12); + border: 1px solid oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.25); + white-space: nowrap; +} + /* Help icon styling */ .help-icon { color: var(--text-color); diff --git a/static/js/components/DuplicatesManager.js b/static/js/components/DuplicatesManager.js index 5c95c7aa..b17e124f 100644 --- a/static/js/components/DuplicatesManager.js +++ b/static/js/components/DuplicatesManager.js @@ -1,5 +1,6 @@ // Duplicates Manager Component import { showToast } from '../utils/uiHelpers.js'; +import { translate } from '../utils/i18nHelpers.js'; import { RecipeCard } from './RecipeCard.js'; import { state, getCurrentPageState } from '../state/index.js'; import { recreateVirtualScroll } from '../utils/infiniteScroll.js'; @@ -10,11 +11,87 @@ export class DuplicatesManager { this.duplicateGroups = []; this.inDuplicateMode = false; this.selectedForDeletion = new Set(); + this._initPromptMatchToggle(); + this._initHelpTooltip(); } - + + _getPromptMatchPreference() { + return localStorage.getItem('recipes_duplicates_include_prompt') === '1'; + } + + _setPromptMatchPreference(enabled) { + localStorage.setItem('recipes_duplicates_include_prompt', enabled ? '1' : '0'); + } + + updateBasisDisplay() { + const basisEl = document.getElementById('duplicatesBasis'); + const helpTextEl = document.getElementById('duplicatesHelpText'); + const checkbox = document.getElementById('promptMatchInput'); + const includePrompt = this._getPromptMatchPreference(); + if (checkbox) { + checkbox.checked = includePrompt; + } + if (basisEl) { + basisEl.textContent = translate( + includePrompt + ? 'recipes.duplicates.basis.loraComboAndPrompt' + : 'recipes.duplicates.basis.loraCombo' + ); + } + if (helpTextEl) { + helpTextEl.textContent = translate( + includePrompt + ? 'recipes.duplicates.basis.hintPromptIncluded' + : 'recipes.duplicates.basis.hintLoraCombo' + ); + } + } + + _initPromptMatchToggle() { + const checkbox = document.getElementById('promptMatchInput'); + if (!checkbox) return; + checkbox.addEventListener('change', async (e) => { + this._setPromptMatchPreference(e.target.checked); + this.updateBasisDisplay(); + checkbox.disabled = true; + try { + await this.findDuplicates(); + } finally { + checkbox.disabled = false; + } + }); + } + + _initHelpTooltip() { + const helpIcon = document.getElementById('duplicatesHelp'); + const helpTooltip = document.getElementById('duplicatesHelpTooltip'); + if (!helpIcon || !helpTooltip) return; + + helpIcon.addEventListener('mouseenter', () => { + const bannerContent = helpIcon.closest('.banner-content'); + if (!bannerContent) return; + const iconRect = helpIcon.getBoundingClientRect(); + const bannerRect = bannerContent.getBoundingClientRect(); + helpTooltip.style.display = 'block'; + helpTooltip.style.top = `${iconRect.bottom - bannerRect.top + 10}px`; + helpTooltip.style.left = `${iconRect.left - bannerRect.left - 10}px`; + const tooltipRect = helpTooltip.getBoundingClientRect(); + if (tooltipRect.right > window.innerWidth - 20) { + helpTooltip.style.left = `${bannerContent.offsetWidth - tooltipRect.width - 20}px`; + } + }); + helpIcon.addEventListener('mouseleave', () => { + helpTooltip.style.display = 'none'; + }); + } + async findDuplicates() { try { - const response = await fetch('/api/lm/recipes/find-duplicates'); + const includePrompt = this._getPromptMatchPreference(); + const endpoint = includePrompt + ? '/api/lm/recipes/find-duplicates?include_prompt=1' + : '/api/lm/recipes/find-duplicates'; + const response = await fetch(endpoint); if (!response.ok) { throw new Error('Failed to find duplicates'); } @@ -28,7 +105,14 @@ export class DuplicatesManager { if (this.duplicateGroups.length === 0) { showToast('toast.duplicates.noDuplicatesFound', { type: 'recipes' }, 'info'); - return false; + // Keep (or enter) the duplicates view when the user is tuning + // the matching basis, so the prompt-matching toggle stays + // reachable; otherwise just toast and stay on the library grid. + if (!this.inDuplicateMode && !includePrompt) { + return false; + } + this.enterDuplicateMode(); + return true; } this.enterDuplicateMode(); @@ -53,9 +137,14 @@ export class DuplicatesManager { const countSpan = document.getElementById('duplicatesCount'); if (banner && countSpan) { - countSpan.textContent = `Found ${this.duplicateGroups.length} duplicate group${this.duplicateGroups.length !== 1 ? 's' : ''}`; + countSpan.textContent = this.duplicateGroups.length === 0 + ? translate('recipes.duplicates.noGroups') + : translate('recipes.duplicates.found', { count: this.duplicateGroups.length }); banner.style.display = 'block'; } + + // Restore the prompt-matching preference and show the matching basis + this.updateBasisDisplay(); // Disable virtual scrolling if active if (state.virtualScroller) { @@ -113,12 +202,23 @@ export class DuplicatesManager { // Clear existing content recipeGrid.innerHTML = ''; + + // Empty-state view: keep the banner (and the matching-basis toggle) + // reachable when no groups match the current basis + if (this.duplicateGroups.length === 0) { + const emptyState = document.createElement('div'); + emptyState.className = 'duplicates-empty-state'; + emptyState.textContent = translate('recipes.duplicates.noGroups'); + recipeGrid.appendChild(emptyState); + return; + } // Render each duplicate group this.duplicateGroups.forEach((group, groupIndex) => { + const groupKey = group.key; const groupDiv = document.createElement('div'); groupDiv.className = 'duplicate-group'; - groupDiv.dataset.fingerprint = group.fingerprint; + groupDiv.dataset.groupKey = groupKey; // Create group header const header = document.createElement('div'); @@ -126,10 +226,10 @@ export class DuplicatesManager { header.innerHTML = ` Duplicate Group #${groupIndex + 1} (${group.recipes.length} recipes) - @@ -182,7 +282,7 @@ export class DuplicatesManager { checkbox.type = 'checkbox'; checkbox.className = 'selector-checkbox'; checkbox.dataset.recipeId = recipe.id; - checkbox.dataset.groupFingerprint = group.fingerprint; + checkbox.dataset.groupKey = groupKey; // Check if already selected if (this.selectedForDeletion.has(recipe.id)) { @@ -244,8 +344,8 @@ export class DuplicatesManager { } } - toggleSelectAllInGroup(fingerprint) { - const checkboxes = document.querySelectorAll(`.selector-checkbox[data-group-fingerprint="${fingerprint}"]`); + toggleSelectAllInGroup(groupKey) { + const checkboxes = document.querySelectorAll(`.selector-checkbox[data-group-key="${groupKey}"]`); const allSelected = Array.from(checkboxes).every(checkbox => checkbox.checked); // If all are selected, deselect all; otherwise select all @@ -264,7 +364,7 @@ export class DuplicatesManager { }); // Update the button text - const button = document.querySelector(`.duplicate-group[data-fingerprint="${fingerprint}"] .btn-select-all`); + const button = document.querySelector(`.duplicate-group[data-group-key="${groupKey}"] .btn-select-all`); if (button) { button.textContent = !allSelected ? "Deselect All" : "Select All"; } @@ -272,8 +372,8 @@ export class DuplicatesManager { this.updateSelectedCount(); } - selectAllInGroup(fingerprint) { - const checkboxes = document.querySelectorAll(`.selector-checkbox[data-group-fingerprint="${fingerprint}"]`); + selectAllInGroup(groupKey) { + const checkboxes = document.querySelectorAll(`.selector-checkbox[data-group-key="${groupKey}"]`); checkboxes.forEach(checkbox => { checkbox.checked = true; this.selectedForDeletion.add(checkbox.dataset.recipeId); @@ -281,7 +381,7 @@ export class DuplicatesManager { }); // Update the button text - const button = document.querySelector(`.duplicate-group[data-fingerprint="${fingerprint}"] .btn-select-all`); + const button = document.querySelector(`.duplicate-group[data-group-key="${groupKey}"] .btn-select-all`); if (button) { button.textContent = "Deselect All"; } @@ -289,12 +389,12 @@ export class DuplicatesManager { this.updateSelectedCount(); } - selectLatestInGroup(fingerprint) { + selectLatestInGroup(groupKey) { // Find all checkboxes in this group - const checkboxes = document.querySelectorAll(`.selector-checkbox[data-group-fingerprint="${fingerprint}"]`); + const checkboxes = document.querySelectorAll(`.selector-checkbox[data-group-key="${groupKey}"]`); // Get all the recipes in this group - const group = this.duplicateGroups.find(g => g.fingerprint === fingerprint); + const group = this.duplicateGroups.find(g => g.key === groupKey); if (!group) return; // Sort recipes by date (newest first) @@ -328,7 +428,7 @@ export class DuplicatesManager { selectLatestDuplicates() { // For each duplicate group, select all but the latest recipe this.duplicateGroups.forEach(group => { - this.selectLatestInGroup(group.fingerprint); + this.selectLatestInGroup(group.key); }); } diff --git a/templates/recipes.html b/templates/recipes.html index b10eea65..a847f2bb 100644 --- a/templates/recipes.html +++ b/templates/recipes.html @@ -74,7 +74,16 @@
+