437bf4d37a
System tags are kind=general, so their suggestions previously landed in the General group. Give them their own 'system' suggestion category so the operator reviews them apart from content tags: _current_heads maps is_system heads to category 'system' (still trained as general heads, still gated by the 0.65 floor). Frontend: CATEGORY_ORDER/LABELS gain 'system'; SuggestionsPanel renders a 'System' group first (small, collapsible, open — false positives easy to spot and reject); the typed-dropdown shows the shield icon for system entries. Safe: system-tag suggestions always carry a canonical_tag_id, so the create-by-kind path (which would send 'system' as a TagKind) is never hit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
239 lines
9.9 KiB
JavaScript
239 lines
9.9 KiB
JavaScript
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)
|
|
}
|
|
}
|
|
|
|
// 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, undismiss,
|
|
findPending
|
|
}
|
|
})
|