feat(modal): ambiguous-candidate picker + accept payload rename

Accept suggestion requests now send 'name' (matches Task 8 backend)
and preserve source/confidence for feedback logging. New 409 handler
renders an inline picker with each candidate's display_name; user
clicks one, which re-POSTs with explicit tag_id.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-22 16:14:57 -04:00
parent 3cd6aa2a6a
commit 54c3d96e45
2 changed files with 118 additions and 7 deletions
+75 -7
View File
@@ -206,17 +206,23 @@ document.addEventListener('DOMContentLoaded', () => {
if (chip.dataset.disabled === 'true') return;
chip.dataset.disabled = 'true';
chip.style.pointerEvents = 'none';
const name = chip.dataset.tagName || '';
const category = chip.dataset.category || '';
const source = chip.dataset.source || '';
const confidence = Number(chip.dataset.confidence) || 0;
try {
const r = await fetch(`/image/${imageId}/suggestions/accept`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tag_name: chip.dataset.tagName,
source: chip.dataset.source,
category: chip.dataset.category,
confidence: Number(chip.dataset.confidence) || 0,
}),
body: JSON.stringify({ name, category, source, confidence }),
});
if (r.status === 409) {
const body = await r.json();
if (body && body.error === 'ambiguous' && Array.isArray(body.candidates)) {
renderAmbiguousPicker(chip, body.candidates, { name, category, source, confidence, imageId });
return; // chip stays disabled; picker handles next step
}
}
const j = await r.json();
if (!j.ok) {
chip.dataset.disabled = 'false';
@@ -227,7 +233,7 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
if (typeof loadTags === 'function') await loadTags(imageId);
if (tagActionFeedback) tagActionFeedback.textContent = `Added ${j.tag.name}`;
if (tagActionFeedback) tagActionFeedback.textContent = `Added ${j.tag.display_name || j.tag.name}`;
if (typeof window.refreshItemTags === 'function') window.refreshItemTags(imageId);
animateChipOut(chip);
} catch {
@@ -236,6 +242,68 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
function renderAmbiguousPicker(chip, candidates, ctx) {
const picker = document.createElement('div');
picker.className = 'suggestion-ambiguous-picker';
const label = document.createElement('div');
label.className = 'ambiguous-label';
label.textContent = 'Which one?';
picker.appendChild(label);
for (const c of candidates) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ambiguous-candidate';
btn.dataset.tagId = String(c.id);
btn.textContent = c.display_name;
btn.addEventListener('click', async () => {
picker.querySelectorAll('button').forEach(b => { b.disabled = true; });
try {
const r = await fetch(`/image/${ctx.imageId}/suggestions/accept`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: ctx.name,
category: ctx.category,
source: ctx.source,
confidence: ctx.confidence,
tag_id: c.id,
}),
});
const j = await r.json();
if (!j.ok) {
picker.querySelectorAll('button').forEach(b => { b.disabled = false; });
if (tagActionFeedback) {
tagActionFeedback.textContent = `Couldn't accept: ${j.error || 'error'}`;
}
return;
}
if (typeof loadTags === 'function') await loadTags(ctx.imageId);
if (tagActionFeedback) tagActionFeedback.textContent = `Added ${j.tag.display_name || j.tag.name}`;
if (typeof window.refreshItemTags === 'function') window.refreshItemTags(ctx.imageId);
animateChipOut(chip);
} catch {
picker.querySelectorAll('button').forEach(b => { b.disabled = false; });
}
});
picker.appendChild(btn);
}
const cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className = 'ambiguous-cancel';
cancelBtn.textContent = 'Cancel';
cancelBtn.addEventListener('click', () => {
picker.remove();
chip.dataset.disabled = 'false';
chip.style.pointerEvents = '';
});
picker.appendChild(cancelBtn);
chip.appendChild(picker);
}
async function rejectSuggestion(chip, imageId) {
chip.style.pointerEvents = 'none';
try {
+43
View File
@@ -3065,3 +3065,46 @@ body.select-mode .gallery-infinite-container {
border-radius: 0.25rem;
color: var(--fg, #eee);
}
/* Ambiguous-candidate picker (Task 24) */
.suggestion-ambiguous-picker {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
padding: 0.5rem;
margin-top: 0.3rem;
background: rgba(255, 180, 0, 0.06);
border: 1px solid rgba(255, 180, 0, 0.25);
border-radius: 0.25rem;
font-size: 0.85rem;
}
.suggestion-ambiguous-picker .ambiguous-label {
width: 100%;
font-weight: 600;
color: #ffb400;
margin-bottom: 0.2rem;
}
.suggestion-ambiguous-picker .ambiguous-candidate {
padding: 0.25rem 0.5rem;
background: var(--button-bg, #222);
border: 1px solid var(--button-border, #444);
border-radius: 0.25rem;
color: var(--fg, #eee);
cursor: pointer;
font-size: 0.85rem;
}
.suggestion-ambiguous-picker .ambiguous-candidate:hover {
background: var(--button-hover-bg, #333);
}
.suggestion-ambiguous-picker .ambiguous-candidate:disabled {
opacity: 0.5;
cursor: wait;
}
.suggestion-ambiguous-picker .ambiguous-cancel {
padding: 0.25rem 0.5rem;
background: transparent;
border: none;
color: var(--muted, #888);
cursor: pointer;
margin-left: auto;
}