import { defineStore } from 'pinia' import { toast } from '../utils/toast.js' import { ref } from 'vue' import { useApi } from '../composables/useApi.js' import { useAsyncAction } from '../composables/useAsyncAction.js' import { useInflightToken } from '../composables/useInflightToken.js' // Category display order: system (hygiene flags) first for quick review, then // people, general last. 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) // retired. 'system' groups the wip/banner/editor hygiene tags apart from content // tags (they're kind=general on the backend but surface under their own category). export const CATEGORY_ORDER = ['system', 'character', 'general'] export const CATEGORY_LABELS = { system: 'System', character: 'Character', general: 'General' } export const useSuggestionsStore = defineStore('suggestions', () => { const api = useApi() const byCategory = ref({}) // { category: [suggestion, ...] } — panel (≥ threshold) // The typed tag-input dropdown searches the model's FULL prediction set for // the image (min=0, down to the store floor) so low-confidence actions/ // features can be picked in canonical formatting (operator-asked 2026-06-09). const allByCategory = ref({}) const { loading, error, run } = useAsyncAction({ errorAs: 'message' }) let currentImageId = null const inflightAll = useInflightToken() // Audit 2026-06-02: this store had no inflight guard — a late // /suggestions response from a prior image could overwrite // byCategory while currentImageId pointed at a new one, and // accept() dereferenced currentImageId AFTER an awaited POST so // the subsequent /suggestions/accept could apply A's chosen tag // to image B (and push it to the allowlist). Both fixed below // by capturing imageId at call-time and gating writes on the token. const inflight = useInflightToken() async function load(imageId) { // Cancel any in-flight load from the previous image so its late // response can't overwrite this image's byCategory. inflight.cancel() currentImageId = imageId byCategory.value = {} // cleared upfront so it stays empty on error const t = inflight.claim() await run(async () => { const body = await api.get(`/api/images/${imageId}/suggestions`) if (!t.isCurrent()) return byCategory.value = body.by_category || {} }) } // Load the full prediction set for the dropdown (separate inflight guard so a // late response from a prior image can't overwrite the current one's list). async function loadAll(imageId) { inflightAll.cancel() allByCategory.value = {} const t = inflightAll.claim() try { const body = await api.get(`/api/images/${imageId}/suggestions`, { params: { min: 0 }, }) if (!t.isCurrent()) return allByCategory.value = body.by_category || {} } catch { // Dropdown is best-effort — the panel surfaces load errors. } } // A stable identity for a suggestion across the two lists (panel vs dropdown // are separate fetches, so object identity differs): tag id when known, else // the raw display key. function _keyOf(s) { return s.canonical_tag_id != null ? `id:${s.canonical_tag_id}` : `raw:${s.category}:${(s.display_name || '').toLowerCase()}` } // Drop an accepted/dismissed suggestion from BOTH the panel list and the // dropdown's full list so it can't reappear in either surface. function _dropEverywhere(suggestion) { const key = _keyOf(suggestion) const cat = suggestion.category for (const map of [byCategory.value, allByCategory.value]) { const list = map[cat] if (list) map[cat] = list.filter(s => _keyOf(s) !== key) } } // Flip the `rejected` flag on the matching suggestion in BOTH lists in place, // so a reject/un-reject shows immediately without dropping the row (visible, // reversible rejection — misclick recovery, operator-asked 2026-06-27). function _setRejectedEverywhere(suggestion, value) { const key = _keyOf(suggestion) const cat = suggestion.category for (const map of [byCategory.value, allByCategory.value]) { for (const s of map[cat] || []) { if (_keyOf(s) === key) s.rejected = value } } } // opts.tagId: the tag's known id, when the caller already created/resolved // it (TagPanel's manual-add-matches-suggestion path). Passed separately — // NOT spread onto the suggestion — because _dropEverywhere keys off the // suggestion object, and mutating canonical_tag_id on a raw suggestion // would stop it matching its own row in the lists. async function accept(suggestion, { tagId: knownTagId = null } = {}) { // Capture imageId so a mid-flight prev/next can't reroute the // accept POST to a different image AND push the tag to that // image's allowlist. const imageId = currentImageId if (imageId == null) return // Raw tags (creates_new_tag) have no canonical_tag_id; the backend's // accept endpoint needs a tag_id, so for raw tags we create the tag // first via the existing /api/tags endpoint, then accept by id. let tagId = knownTagId ?? suggestion.canonical_tag_id if (tagId == null) { const created = await api.post('/api/tags', { body: { name: suggestion.display_name, kind: suggestion.category } }) tagId = created.id } await api.post(`/api/images/${imageId}/suggestions/accept`, { body: { tag_id: tagId } }) // Only drop from THIS image's category list — if the user navigated, // the new image has its own suggestions and this drop would corrupt them. if (currentImageId === imageId) { _dropEverywhere(suggestion) } _acceptToast('Tagged', suggestion.display_name) } // One non-blocking toast for accept/alias. The accepted tag is applied to this // image and feeds head training; head auto-apply handles propagation (earned), // so there's no instant fan-out to project. function _acceptToast(verb, displayName) { toast({ text: `${verb}: ${displayName}`, type: 'success' }) } async function aliasAccept(suggestion, canonicalTagId) { const imageId = currentImageId if (imageId == null) return // The alias MUST be stored under the raw model key — resolution looks up the // raw prediction key, not the normalized display name. Sending display_name // (the old bug) stored an alias that never resolved, so the prediction kept // reappearing unaliased. raw_name is null only for centroid hits, which // can't be aliased (the UI hides the action for them). const aliasString = suggestion.raw_name ?? suggestion.display_name await api.post(`/api/images/${imageId}/suggestions/alias`, { body: { alias_string: aliasString, alias_category: suggestion.category, canonical_tag_id: canonicalTagId } }) if (currentImageId === imageId) { _dropEverywhere(suggestion) } _acceptToast('Aliased & tagged', suggestion.display_name) } // Remove the alias behind an aliased suggestion (the raw prediction reverts to // its unaliased form on reload). The canonical tag stays applied if it was // accepted — this only undoes the model-key→tag mapping. async function removeAlias(suggestion) { const imageId = currentImageId if (imageId == null || suggestion.raw_name == null) return await api.delete( `/api/aliases/${encodeURIComponent(suggestion.raw_name)}/${encodeURIComponent(suggestion.category)}` ) if (currentImageId === imageId) { await load(imageId) await loadAll(imageId) } 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 // Dismiss needs a tag_id. Raw tags (creates_new_tag) have none, so there's // nothing to persist a rejection against — drop them client-side as before. // Canonical tags persist a rejection and STAY in the list flagged rejected, // so the operator can see it and one-click un-reject (misclick recovery). if (suggestion.canonical_tag_id == null) { if (currentImageId === imageId) _dropEverywhere(suggestion) return } await api.post(`/api/images/${imageId}/suggestions/dismiss`, { body: { tag_id: suggestion.canonical_tag_id } }) if (currentImageId === imageId) { _setRejectedEverywhere(suggestion, true) } } // Reject every still-unhandled suggestion in a category in one go ("confirm // the good ones, reject the rest" — operator-asked 2026-07-06). Canonical tags // persist a rejection and STAY flagged rejected (reversible, one-click // un-reject); raw creates-new-tag rows drop client-side. Dispatched in parallel // so a big section clears fast. async function dismissRemaining(category) { const imageId = currentImageId if (imageId == null) return const targets = (byCategory.value[category] || []).filter((s) => !s.rejected) if (!targets.length) return const canon = targets.filter((s) => s.canonical_tag_id != null) const raw = targets.filter((s) => s.canonical_tag_id == null) await Promise.all(canon.map((s) => api.post(`/api/images/${imageId}/suggestions/dismiss`, { body: { tag_id: s.canonical_tag_id }, }) )) if (currentImageId === imageId) { canon.forEach((s) => _setRejectedEverywhere(s, true)) raw.forEach((s) => _dropEverywhere(s)) } } // Undo a per-image dismissal — the suggestion reverts to a live row. async function undismiss(suggestion) { const imageId = currentImageId if (imageId == null || suggestion.canonical_tag_id == null) return await api.post(`/api/images/${imageId}/suggestions/undismiss`, { body: { tag_id: suggestion.canonical_tag_id } }) if (currentImageId === imageId) { _setRejectedEverywhere(suggestion, false) } } return { byCategory, allByCategory, loading, error, load, loadAll, accept, aliasAccept, removeAlias, dismiss, dismissRemaining, undismiss, findPending } })