feat(bulk): consensus ML suggestions + frontend polish

Bulk editor now loads consensus ML suggestions across the current
selection via a new POST /api/suggestions/bulk endpoint, powered by
get_bulk_suggestions() in tag_suggestions.py. A tag is surfaced only if
it was suggested for or already applied to >= 80% of the selection;
coverage counts include images that already have the tag so a near-
universal tag isn't penalized for dropping out of suggestion lists.
Accept-only chips (no reject) match the rest of the suggestions UX.

Frontend polish in the same commit:
- Showcase session dedup (exclude seen ids, reset on second lap) and
  aspect-ratio-aware placeholder heights for better column balancing
- Modal touch-swipe navigation on the image wrapper, auto-focus of the
  tag input on desktop (>=768px), and autocomplete flip-up when the
  dropdown would cover the Suggestions section
- Settings template inline styles replaced with utility classes
- Inline series-editor script extracted to gallery-series-editor.js

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 18:39:47 -04:00
parent bc6b0a4a58
commit 3944605d33
9 changed files with 693 additions and 158 deletions
+131
View File
@@ -32,6 +32,8 @@
dom.tagInput = document.getElementById('bulkTagInput');
dom.autocomplete = document.getElementById('bulkTagAutocomplete');
dom.commonTags = document.getElementById('commonTagsList');
dom.bulkSuggestionsList = document.getElementById('bulkSuggestionsList');
dom.bulkSuggestionsHint = document.getElementById('bulkSuggestionsHint');
if (!dom.selectBtn || !dom.panel) return;
@@ -124,6 +126,7 @@
updateAllOrderBadges();
updateCount();
loadCommonTags();
loadBulkSuggestions();
dispatchSelectionChanged();
}
@@ -137,6 +140,7 @@
});
updateCount();
updateCommonTagsDisplay();
clearBulkSuggestions();
dispatchSelectionChanged();
}
@@ -288,6 +292,133 @@
});
}
// ---------------------------
// Bulk ML suggestions (consensus across selection)
// ---------------------------
const BULK_SUGGESTIONS_MIN = 2;
const BULK_SUGGESTIONS_THRESHOLD = 0.8;
const BULK_SUGGESTIONS_CATEGORY_ORDER = ['character', 'copyright', 'general'];
const BULK_SUGGESTIONS_CATEGORY_LABEL = {
character: 'Characters',
copyright: 'Fandoms',
general: 'General',
};
function clearBulkSuggestions() {
if (dom.bulkSuggestionsList) dom.bulkSuggestionsList.innerHTML = '';
setBulkSuggestionsHint('Select 2+ images to see consensus suggestions.');
}
function setBulkSuggestionsHint(text, visible = true) {
if (!dom.bulkSuggestionsHint) return;
dom.bulkSuggestionsHint.textContent = text;
dom.bulkSuggestionsHint.style.display = visible ? '' : 'none';
}
async function loadBulkSuggestions() {
if (!dom.bulkSuggestionsList) return;
const ids = Array.from(state.selectedIds);
if (ids.length < BULK_SUGGESTIONS_MIN) {
clearBulkSuggestions();
return;
}
setBulkSuggestionsHint('Loading…');
dom.bulkSuggestionsList.innerHTML = '';
try {
const r = await fetch('/api/suggestions/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_ids: ids, threshold: BULK_SUGGESTIONS_THRESHOLD }),
});
const j = await r.json();
if (!j.ok) {
setBulkSuggestionsHint('Could not load suggestions.');
return;
}
renderBulkSuggestions(j.suggestions || {}, ids.length);
} catch (e) {
setBulkSuggestionsHint('Could not load suggestions.');
}
}
function renderBulkSuggestions(grouped, selectionSize) {
if (!dom.bulkSuggestionsList) return;
dom.bulkSuggestionsList.innerHTML = '';
let total = 0;
for (const cat of BULK_SUGGESTIONS_CATEGORY_ORDER) {
const items = (grouped && grouped[cat]) || [];
if (items.length === 0) continue;
total += items.length;
const group = document.createElement('div');
group.className = 'suggestions-category';
group.dataset.category = cat;
const label = document.createElement('div');
label.className = 'suggestions-category-label';
label.textContent = BULK_SUGGESTIONS_CATEGORY_LABEL[cat] || cat;
group.appendChild(label);
const chips = document.createElement('div');
chips.className = 'suggestions-chips';
for (const item of items) {
chips.appendChild(buildBulkSuggestionChip(item, selectionSize));
}
group.appendChild(chips);
dom.bulkSuggestionsList.appendChild(group);
}
if (total === 0) {
setBulkSuggestionsHint('No consensus suggestions for this selection.');
} else {
setBulkSuggestionsHint('', false);
}
}
function buildBulkSuggestionChip(item, selectionSize) {
const chip = document.createElement('div');
chip.className = 'suggestion-chip suggestion-chip-bulk';
chip.dataset.tagName = item.name;
chip.dataset.category = item.category;
const pct = Math.round((item.confidence || 0) * 100);
const covered = item.covered_count != null ? item.covered_count : selectionSize;
chip.title = `${item.name} — suggested for ${covered}/${selectionSize}`;
chip.innerHTML = `
<button type="button" class="sugg-btn sugg-accept" aria-label="Add to all selected">✓</button>
<span class="sugg-body">
<span class="sugg-label">${escapeHtml(item.name)}</span>
<span class="sugg-confidence">${pct}% · ${covered}/${selectionSize}</span>
</span>
`;
const acceptBtn = chip.querySelector('.sugg-accept');
if (acceptBtn) {
acceptBtn.addEventListener('click', () => acceptBulkSuggestion(chip));
}
return chip;
}
async function acceptBulkSuggestion(chip) {
if (chip.dataset.disabled === 'true') return;
chip.dataset.disabled = 'true';
chip.style.pointerEvents = 'none';
chip.style.opacity = '0.5';
const name = chip.dataset.tagName;
try {
await bulkAddTag(name);
chip.classList.add('leaving');
setTimeout(() => chip.remove(), 200);
} catch {
chip.dataset.disabled = 'false';
chip.style.pointerEvents = '';
chip.style.opacity = '';
}
}
async function handleBulkAddTag(e) {
e.preventDefault();
const name = dom.tagInput.value.trim();