diff --git a/frontend/src/components/modal/TagAutocomplete.vue b/frontend/src/components/modal/TagAutocomplete.vue index 65f3527..bea6bcc 100644 --- a/frontend/src/components/modal/TagAutocomplete.vue +++ b/frontend/src/components/modal/TagAutocomplete.vue @@ -100,6 +100,13 @@ import { useSuggestionsStore } from '../../stores/suggestions.js' import FandomPicker from './FandomPicker.vue' const emit = defineEmits(['pick-existing', 'pick-new', 'accept-suggestion', 'cancel']) +// The host surface's applied tags (host.current?.tags). Both dropdown sections +// filter against this so a just-added tag drops out of the search the moment +// the chip rail updates, not after the next modal refresh (operator-asked +// 2026-07-03). +const props = defineProps({ + appliedTags: { type: Array, default: () => [] }, +}) const store = useTagStore() // The image's ML (Camie) suggestions, surfaced inline in this dropdown so the // operator can pick a suggestion while typing instead of hunting for it in the @@ -208,6 +215,18 @@ const createLabel = computed(() => function scorePct (s) { return `${Math.round(s.score * 100)}%` } +const appliedIds = computed(() => new Set(props.appliedTags.map(t => t.id))) +const appliedNames = computed(() => + new Set(props.appliedTags.map(t => `${t.kind}:${(t.name || '').toLowerCase()}`)), +) + +// Server matches minus the image's applied tags. allowCreate/sameNameCharExists +// keep reading the RAW hits — they reason about the tag universe (does this tag +// exist?), not about this image. +const visibleHits = computed(() => + hits.value.filter(h => !appliedIds.value.has(h.id)), +) + // This image's suggestions that match the typed query, minus any the server // autocomplete already returned (same name+kind) so a tag never shows twice. // Sources the FULL prediction set (allByCategory, down to the store floor) — NOT @@ -218,7 +237,7 @@ function scorePct (s) { return `${Math.round(s.score * 100)}%` } const suggestionHits = computed(() => { const q = parsedName.value.toLowerCase() if (!q) return [] - const seen = new Set(hits.value.map(h => `${h.kind}:${h.name.toLowerCase()}`)) + const seen = new Set(visibleHits.value.map(h => `${h.kind}:${h.name.toLowerCase()}`)) const out = [] for (const list of Object.values(suggestions.allByCategory)) { for (const s of list || []) { @@ -226,7 +245,12 @@ const suggestionHits = computed(() => { // can show + un-reject them; keep them OUT of the type-to-add dropdown, // whose job is finding a tag to ADD (un-reject lives in the panel). if (s.rejected) continue + // Already on the image (matched by canonical id or name+kind): nothing + // to add. Covers the window between an add and the next suggestions + // fetch, where the stale row would otherwise still be pickable. + if (s.canonical_tag_id != null && appliedIds.value.has(s.canonical_tag_id)) continue const key = `${s.category}:${s.display_name.toLowerCase()}` + if (appliedNames.value.has(key)) continue if (!s.display_name.toLowerCase().includes(q)) continue if (seen.has(key)) continue seen.add(key) @@ -242,7 +266,7 @@ const suggestionHits = computed(() => { // One ordered list backing both the rendered dropdown and keyboard nav, so the // highlight index maps 1:1 to a row regardless of which section it's in. const rows = computed(() => { - const r = hits.value.map(h => ({ type: 'hit', key: `h${h.id}`, hit: h })) + const r = visibleHits.value.map(h => ({ type: 'hit', key: `h${h.id}`, hit: h })) for (const s of suggestionHits.value) { r.push({ type: 'suggestion', key: `s${s.category}:${s.display_name}`, sugg: s }) } diff --git a/frontend/src/components/modal/TagPanel.vue b/frontend/src/components/modal/TagPanel.vue index a0619f6..d5e5639 100644 --- a/frontend/src/components/modal/TagPanel.vue +++ b/frontend/src/components/modal/TagPanel.vue @@ -15,6 +15,7 @@ @@ -107,17 +108,59 @@ defineExpose({ focusTagInput }) // (operator-asked 2026-06-26; the Explore workspace leans on this hard). async function onRemove(tagId) { errorMsg.value = null - try { await host.removeTag(tagId); focusTagInput() } + try { + await host.removeTag(tagId) + // removeTag also records a per-image dismissal server-side, so reloading + // puts a model-suggested tag BACK in the suggestions area (flagged + // rejected — visible + one-click reversible) instead of it vanishing + // until the next modal open (operator-asked 2026-07-03). + if (host.currentImageId != null) { + suggestions.load(host.currentImageId) + suggestions.loadAll(host.currentImageId) + } + focusTagInput() + } catch (e) { errorMsg.value = e.message } } async function onPickExisting(hit) { errorMsg.value = null - try { await host.addExistingTag(hit.id); focusTagInput() } + try { + // Manually picking a tag the model also suggested = accepting the + // suggestion: the acceptance is recorded and the row drops from the + // panel, instead of silently bypassing the feedback loop + // (operator-asked 2026-07-03). + const pending = suggestions.findPending(hit.kind, hit.name, hit.id) + if (pending) { + await suggestions.accept({ + ...pending, + canonical_tag_id: pending.canonical_tag_id ?? hit.id, + }) + await host.reloadTags() + } else { + await host.addExistingTag(hit.id) + } + focusTagInput() + } catch (e) { errorMsg.value = e.message } } async function onPickNew(payload) { errorMsg.value = null - try { await host.createAndAdd(payload); focusTagInput() } + try { + const imageId = host.currentImageId + const created = await host.createAndAdd(payload) + // The typed name can match a raw (or canonical) suggestion; creating it + // by hand still counts as accepting — record it and drop the row. Runs + // AFTER createAndAdd so a character's fandom pick is preserved (accept's + // own create path knows no fandom); the accept apply is idempotent. + // Guard on the image not having changed mid-create (fast prev/next). + if (created && host.currentImageId === imageId) { + const pending = suggestions.findPending(payload.kind, payload.name, created.id) + if (pending) { + await suggestions.accept({ ...pending, canonical_tag_id: created.id }) + } + } + focusTagInput() + } catch (e) { errorMsg.value = e.message } } // A suggestion picked from the autocomplete dropdown runs the SAME path as the diff --git a/frontend/src/stores/explore.js b/frontend/src/stores/explore.js index 8b5c45f..36ee046 100644 --- a/frontend/src/stores/explore.js +++ b/frontend/src/stores/explore.js @@ -176,6 +176,9 @@ export const useExploreStore = defineStore('explore', () => { } if (anchor.value?.id !== id) return // walked away mid-create await addExistingTag(tag.id) + // Returned so TagPanel can match the created tag against a pending + // suggestion and record the acceptance (manual add == accept, 2026-07-03). + return tag } // No overlay to dismiss in the Explore workspace — the chip-body "show me diff --git a/frontend/src/stores/modal.js b/frontend/src/stores/modal.js index 3859b61..567f867 100644 --- a/frontend/src/stores/modal.js +++ b/frontend/src/stores/modal.js @@ -170,6 +170,9 @@ export const useModalStore = defineStore('modal', () => { } if (currentImageId.value !== imageId) return // navigated away await addExistingTag(tag.id) + // Returned so TagPanel can match the created tag against a pending + // suggestion and record the acceptance (manual add == accept, 2026-07-03). + return tag } const isOpen = computed(() => currentImageId.value !== null) diff --git a/frontend/src/stores/suggestions.js b/frontend/src/stores/suggestions.js index 6a1198d..6271825 100644 --- a/frontend/src/stores/suggestions.js +++ b/frontend/src/stores/suggestions.js @@ -169,6 +169,29 @@ export const useSuggestionsStore = defineStore('suggestions', () => { toast({ text: `Alias removed: ${suggestion.display_name}`, type: 'success' }) } + // Find a live (non-rejected) pending suggestion matching a manually-picked + // tag — by canonical id when known, else by (kind, name). Manually adding a + // tag the model also suggested must be indistinguishable from accepting the + // suggestion (recorded + dropped from the panel), so TagPanel routes matches + // through accept() (operator-asked 2026-07-03). Searches the full dropdown + // set first, then the panel list (loadAll is best-effort and can be empty). + function findPending(kind, name, tagId = null) { + const lname = (name || '').toLowerCase() + for (const map of [allByCategory.value, byCategory.value]) { + for (const list of Object.values(map)) { + for (const s of list || []) { + if (s.rejected) continue + if (tagId != null && s.canonical_tag_id === tagId) return s + if ( + s.category === kind && + (s.display_name || '').toLowerCase() === lname + ) return s + } + } + } + return null + } + async function dismiss(suggestion) { const imageId = currentImageId if (imageId == null) return @@ -202,6 +225,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => { return { byCategory, allByCategory, loading, error, - load, loadAll, accept, aliasAccept, removeAlias, dismiss, undismiss + load, loadAll, accept, aliasAccept, removeAlias, dismiss, undismiss, + findPending } }) diff --git a/frontend/test/suggestions.spec.js b/frontend/test/suggestions.spec.js new file mode 100644 index 0000000..0473db9 --- /dev/null +++ b/frontend/test/suggestions.spec.js @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useSuggestionsStore } from '../src/stores/suggestions.js' + +vi.mock('../src/utils/toast.js', () => ({ toast: vi.fn() })) + +function stubFetch(handler) { + globalThis.fetch = vi.fn(async (url, init) => { + const { status, body } = handler(url, init) + return { + ok: status >= 200 && status < 300, + status, + statusText: String(status), + text: async () => (body == null ? '' : JSON.stringify(body)), + } + }) +} + +const sugg = (over = {}) => ({ + canonical_tag_id: 7, + display_name: 'Ichigo', + category: 'character', + score: 0.9, + source: 'head', + creates_new_tag: false, + rejected: false, + ...over, +}) + +// Seed the store through its own load/loadAll so currentImageId is set the +// way the panel sets it (accept() derives the image from it). +async function seed(store, byCategory) { + stubFetch(() => ({ status: 200, body: { by_category: byCategory } })) + await store.load(1) + await store.loadAll(1) +} + +describe('suggestions store: findPending (manual add == accept, 2026-07-03)', () => { + beforeEach(() => setActivePinia(createPinia())) + afterEach(() => vi.restoreAllMocks()) + + it('matches by canonical tag id', async () => { + const s = useSuggestionsStore() + await seed(s, { character: [sugg()] }) + expect(s.findPending('general', 'unrelated', 7)?.display_name).toBe('Ichigo') + }) + + it('matches raw suggestions by kind + name, case-insensitively', async () => { + const s = useSuggestionsStore() + await seed(s, { + general: [sugg({ + canonical_tag_id: null, display_name: 'Holding Sword', + category: 'general', creates_new_tag: true, + })], + }) + expect(s.findPending('general', 'holding sword')).toBeTruthy() + // Kind must match: same name under another kind is a different tag. + expect(s.findPending('character', 'holding sword')).toBeNull() + }) + + it('skips rejected rows and returns null when nothing matches', async () => { + const s = useSuggestionsStore() + await seed(s, { character: [sugg({ rejected: true })] }) + expect(s.findPending('character', 'Ichigo', 7)).toBeNull() + expect(s.findPending('character', 'Rukia')).toBeNull() + }) +}) + +describe('suggestions store: accept with a known tag id', () => { + beforeEach(() => setActivePinia(createPinia())) + afterEach(() => vi.restoreAllMocks()) + + it('POSTs only the accept endpoint (no tag create) and drops the row', async () => { + const s = useSuggestionsStore() + await seed(s, { character: [sugg()] }) + const calls = [] + stubFetch((url, init) => { + calls.push({ url, method: init?.method }) + return { status: 200, body: { accepted: true, tag_id: 7 } } + }) + // TagPanel routes a manual pick that matches a pending suggestion through + // accept, filling canonical_tag_id from the picked hit when the + // suggestion is raw — no /api/tags create must fire in that case. + await s.accept({ ...s.byCategory.character[0], canonical_tag_id: 7 }) + expect(calls).toHaveLength(1) + expect(calls[0].url).toContain('/api/images/1/suggestions/accept') + expect(s.byCategory.character).toHaveLength(0) + expect(s.allByCategory.character).toHaveLength(0) + }) +})