import { defineStore } from 'pinia' import { toast } from '../utils/toast.js' import { computed, 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() // ONE source of truth per image: every trained head scored for this image // (min=0), each row carrying above_threshold. The Suggestions PANEL renders // aboveByCategory; the typed tag-input dropdown reads the full set and // annotates matching DB-tag rows with their score. A single fetch backs both — // every suggestion is a canonical tag now (tagging-v2, #114), so there is no // raw-prediction / create-new / alias-remap path to reconcile. const byCategory = ref({}) const { loading, error, run } = useAsyncAction({ errorAs: 'message' }) let currentImageId = null // Audit 2026-06-02: without an inflight guard a late /suggestions response from // a prior image could overwrite byCategory while currentImageId points at a new // one, and accept() could apply image A's tag to image B. Both guarded below by // capturing imageId at call-time and gating writes on the token. const inflight = useInflightToken() // The curated above-threshold subset the Suggestions panel shows. A rejected // row that still scores above its cut stays (flagged) so the rejection is // visible + reversible; below-threshold rows live only in the dropdown. const aboveByCategory = computed(() => { const out = {} for (const [cat, list] of Object.entries(byCategory.value)) { const kept = (list || []).filter(s => s.above_threshold) if (kept.length) out[cat] = kept } return out }) async function load(imageId) { // Cancel any in-flight load from the previous image so its late response // can't overwrite this image's list. inflight.cancel() currentImageId = imageId byCategory.value = {} // cleared upfront so it stays empty on error const t = inflight.claim() await run(async () => { // min=0 → every head, each flagged above_threshold; the panel + the typed // dropdown are both derived from this one payload. const body = await api.get(`/api/images/${imageId}/suggestions`, { params: { min: 0 }, }) if (!t.isCurrent()) return byCategory.value = body.by_category || {} }) } // A stable identity for a suggestion across the panel + dropdown surfaces: // its canonical tag id (every suggestion has one now). function _keyOf(s) { return `id:${s.canonical_tag_id}` } // Drop an accepted suggestion from the list so it can't reappear. function _dropEverywhere(suggestion) { const key = _keyOf(suggestion) const list = byCategory.value[suggestion.category] if (list) byCategory.value[suggestion.category] = list.filter(s => _keyOf(s) !== key) } // Flip the `rejected` flag on the matching suggestion 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) for (const s of byCategory.value[suggestion.category] || []) { if (_keyOf(s) === key) s.rejected = value } } // opts.tagId: the tag's known id when the caller already resolved it (TagPanel's // manual-add-matches-suggestion path). Passed separately — NOT spread onto the // suggestion — because _dropEverywhere keys off the suggestion object. 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. const imageId = currentImageId if (imageId == null) return const tagId = knownTagId ?? suggestion.canonical_tag_id await api.post(`/api/images/${imageId}/suggestions/accept`, { body: { tag_id: tagId } }) // Only drop from THIS image's 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. The accepted tag is applied to this image // and feeds head training; head auto-apply handles propagation (earned). function _acceptToast(verb, displayName) { toast({ text: `${verb}: ${displayName}`, type: 'success' }) } // Find a live (non-rejected) 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). function findPending(kind, name, tagId = null) { const lname = (name || '').toLowerCase() for (const list of Object.values(byCategory.value)) { 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 // 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). 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 VISIBLE (above-threshold) suggestion in a // category in one go ("confirm the good ones, reject the rest" — operator-asked // 2026-07-06). Only touches what the panel shows, not the low-confidence tail // the dropdown carries. 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.above_threshold && !s.rejected ) if (!targets.length) return await Promise.all(targets.map((s) => api.post(`/api/images/${imageId}/suggestions/dismiss`, { body: { tag_id: s.canonical_tag_id }, }) )) if (currentImageId === imageId) { targets.forEach((s) => _setRejectedEverywhere(s, true)) } } // Undo a per-image dismissal — the suggestion reverts to a live row. async function undismiss(suggestion) { const imageId = currentImageId if (imageId == 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, aboveByCategory, loading, error, load, accept, dismiss, dismissRemaining, undismiss, findPending } })