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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user