6684907577
Each category section header gets a subtle "Reject rest" action that dismisses every still-unhandled suggestion in it at once (store.dismissRemaining, parallel dispatch). Canonical tags persist a rejection and stay flagged (reversible, one-click un-reject); raw creates-new-tag rows drop client-side. Shows only when the section has unhandled items. No confirm dialog — it's fully reversible. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
190 lines
7.2 KiB
Vue
190 lines
7.2 KiB
Vue
<template>
|
|
<section class="fc-suggestions" aria-label="Tag suggestions">
|
|
<h3 class="fc-suggestions__title">Suggestions</h3>
|
|
|
|
<div v-if="store.loading" class="fc-suggestions__skeleton">
|
|
<div v-for="i in 4" :key="i" class="fc-suggestions__skel-row" />
|
|
</div>
|
|
<v-alert v-else-if="store.error" type="error" variant="tonal" density="compact">
|
|
{{ store.error }}
|
|
</v-alert>
|
|
<div v-else-if="isEmpty" class="text-caption">
|
|
No suggestions above threshold.
|
|
</div>
|
|
|
|
<!-- Flows in the rail's main scroll area; Related is pinned to the bottom
|
|
of the rail (ImageViewer side layout), so a long suggestion set no
|
|
longer needs an internal scroll cap to keep Related reachable. -->
|
|
<div v-else class="fc-suggestions__list">
|
|
<!-- System hygiene tags (wip/banner/editor) surface in their own group,
|
|
first, so false positives are easy to spot and reject (reject-to-train
|
|
for these heads). Small set — collapsible, open by default. -->
|
|
<SuggestionsCategoryGroup
|
|
v-if="store.byCategory.system && store.byCategory.system.length"
|
|
label="System" :items="store.byCategory.system"
|
|
collapsible :default-open="true"
|
|
@accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias"
|
|
@dismiss="onDismiss" @undismiss="onUndismiss"
|
|
@reject-all="onRejectAll('system')"
|
|
/>
|
|
<SuggestionsCategoryGroup
|
|
v-for="cat in peopleCats" :key="cat"
|
|
v-show="store.byCategory[cat] && store.byCategory[cat].length"
|
|
:label="labelFor(cat)" :items="store.byCategory[cat] || []"
|
|
@accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias"
|
|
@dismiss="onDismiss" @undismiss="onUndismiss"
|
|
@reject-all="onRejectAll(cat)"
|
|
/>
|
|
<SuggestionsCategoryGroup
|
|
v-if="store.byCategory.general && store.byCategory.general.length"
|
|
label="General" :items="store.byCategory.general"
|
|
collapsible :default-open="true"
|
|
@accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias"
|
|
@dismiss="onDismiss" @undismiss="onUndismiss"
|
|
@reject-all="onRejectAll('general')"
|
|
/>
|
|
</div>
|
|
|
|
<v-dialog v-model="aliasDialog" max-width="480">
|
|
<AliasPickerDialog
|
|
v-if="aliasTarget"
|
|
:category="aliasTarget.category"
|
|
@confirm="onAliasConfirm" @cancel="aliasDialog = false"
|
|
/>
|
|
</v-dialog>
|
|
</section>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { toast } from '../../utils/toast.js'
|
|
import { computed, ref, watch } from 'vue'
|
|
import { useSuggestionsStore, CATEGORY_LABELS } from '../../stores/suggestions.js'
|
|
import { useModalStore } from '../../stores/modal.js'
|
|
import SuggestionsCategoryGroup from './SuggestionsCategoryGroup.vue'
|
|
import AliasPickerDialog from './AliasPickerDialog.vue'
|
|
|
|
const props = defineProps({
|
|
imageId: { type: Number, required: true },
|
|
// The tagging host whose chip rail to refresh after an accept. Defaults to
|
|
// the modal store (image modal); the Explore workspace passes its anchor host
|
|
// so the same panel refreshes the right surface. See TagPanel.
|
|
host: { type: Object, default: null },
|
|
})
|
|
// 'accepted'/'dismissed' let the parent return focus to the tag input after a
|
|
// suggestion is accepted OR rejected, so the operator keeps the keyboard flow on
|
|
// the input without re-clicking (operator-asked 2026-06-08, 2026-06-30).
|
|
const emit = defineEmits(['accepted', 'dismissed'])
|
|
|
|
// Reject (✗) / un-reject (↶): apply the store change, then signal the parent to
|
|
// re-focus the tag input — same return-to-input behaviour as accept.
|
|
function onDismiss (s) { store.dismiss(s); emit('dismissed') }
|
|
function onUndismiss (s) { store.undismiss(s); emit('dismissed') }
|
|
// Section-level "Reject rest": dismiss every still-unhandled suggestion in the
|
|
// category, then return focus to the input like a single reject does.
|
|
async function onRejectAll (category) {
|
|
try {
|
|
await store.dismissRemaining(category)
|
|
emit('dismissed')
|
|
} catch (e) {
|
|
toast({ text: `Reject failed: ${e.message}`, type: 'error' })
|
|
}
|
|
}
|
|
const store = useSuggestionsStore()
|
|
const modalStore = useModalStore()
|
|
const host = props.host || modalStore
|
|
|
|
// 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired as
|
|
// suggestion categories. Only 'character' remains as a people-style
|
|
// category alongside the general bucket.
|
|
const peopleCats = ['character']
|
|
function labelFor(c) { return CATEGORY_LABELS[c] || c }
|
|
|
|
const isEmpty = computed(() =>
|
|
Object.values(store.byCategory).every(list => !list || list.length === 0)
|
|
)
|
|
|
|
watch(() => props.imageId, (id) => {
|
|
if (id == null) return
|
|
store.load(id) // panel: curated, ≥ threshold
|
|
store.loadAll(id) // dropdown: full prediction set (low-confidence included)
|
|
}, { immediate: true })
|
|
|
|
// After a successful accept/alias-accept, refresh the modal's current
|
|
// tag list so TagPanel's chip rail reflects the newly-attached tag.
|
|
// Operator-flagged 2026-06-01: the suggestion store dropped the
|
|
// suggestion (correct) but didn't propagate the new tag back into the
|
|
// modal store, so the chip rail looked unchanged even though the
|
|
// backend had recorded the application. Mirrors the addExistingTag /
|
|
// createAndAdd flows which already call reloadTags() after applying.
|
|
async function onAccept(s) {
|
|
try {
|
|
await store.accept(s)
|
|
await host.reloadTags()
|
|
emit('accepted')
|
|
} catch (e) {
|
|
toast({ text: `Accept failed: ${e.message}`, type: 'error' })
|
|
}
|
|
}
|
|
|
|
const aliasDialog = ref(false)
|
|
const aliasTarget = ref(null)
|
|
function onAlias(s) { aliasTarget.value = s; aliasDialog.value = true }
|
|
async function onAliasConfirm(canonicalTagId) {
|
|
try {
|
|
await store.aliasAccept(aliasTarget.value, canonicalTagId)
|
|
aliasDialog.value = false
|
|
await host.reloadTags()
|
|
emit('accepted')
|
|
} catch (e) {
|
|
toast({ text: `Alias failed: ${e.message}`, type: 'error' })
|
|
}
|
|
}
|
|
|
|
// Undo the model-key→tag mapping behind an aliased suggestion. The store
|
|
// reloads suggestions so the prediction reverts to its raw form; the applied
|
|
// canonical tag (if any) stays, so no tag-rail reload is needed.
|
|
async function onRemoveAlias(s) {
|
|
try {
|
|
await store.removeAlias(s)
|
|
} catch (e) {
|
|
toast({ text: `Remove alias failed: ${e.message}`, type: 'error' })
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.fc-suggestions {
|
|
margin-top: 16px;
|
|
padding-top: 12px;
|
|
border-top: 1px solid rgb(var(--v-theme-surface-light));
|
|
}
|
|
.fc-suggestions__title {
|
|
font-family: 'Fraunces', Georgia, serif;
|
|
font-size: 16px; font-weight: 500;
|
|
color: rgb(var(--v-theme-on-surface));
|
|
margin-bottom: 8px;
|
|
}
|
|
.fc-suggestions__list {
|
|
/* No internal scroll cap: the rail now scrolls suggestions in its single
|
|
main scroll area while Related is pinned to the bottom (ImageViewer side
|
|
layout), so suggestions flow naturally without a nested scrollbar. */
|
|
padding-right: 4px;
|
|
}
|
|
.fc-suggestions__skeleton { display: flex; flex-direction: column; gap: 8px; }
|
|
.fc-suggestions__skel-row {
|
|
height: 18px; border-radius: 4px;
|
|
background: linear-gradient(
|
|
90deg,
|
|
rgb(var(--v-theme-surface)) 0%,
|
|
rgb(var(--v-theme-surface-light)) 50%,
|
|
rgb(var(--v-theme-surface)) 100%
|
|
);
|
|
background-size: 200% 100%;
|
|
animation: fc-shimmer 1.4s infinite;
|
|
}
|
|
@keyframes fc-shimmer {
|
|
0% { background-position: 200% 0; }
|
|
100% { background-position: -200% 0; }
|
|
}
|
|
</style>
|