feat(modal): surface ML suggestions inline in the tag autocomplete
The image's Camie suggestions now appear in the tag input's dropdown as you type, filtered to the query and de-duped against the server autocomplete hits, so the operator can pick a suggestion without hunting for it in the Suggestions panel below (operator-asked 2026-06-07). - Unified `rows` model (hits → matching suggestions → create row) so the highlight index maps 1:1 to a row across all three sections; arrow/Enter/Tab drive the whole list. - Suggestion rows are marked (accent left-border + mdi-auto-fix score chip) and show a "new" hint when the suggestion would create a tag. - Picking a suggestion emits accept-suggestion → TagPanel runs the SAME accept path as the Suggestions panel (creates raw tags, records acceptance, drops it from the panel), then refreshes the chip rail. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,40 +12,70 @@
|
||||
@keydown.esc="$emit('cancel')"
|
||||
/>
|
||||
<v-list
|
||||
v-if="hits.length || allowCreate"
|
||||
v-if="rows.length"
|
||||
ref="listRef"
|
||||
density="compact" class="fc-tag-autocomplete__list"
|
||||
>
|
||||
<v-list-item
|
||||
v-for="(h, idx) in hits" :key="h.id"
|
||||
:active="idx === highlight" @click="onPick(h)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon size="small" :color="store.colorFor(h.kind)">
|
||||
{{ iconFor(h.kind) }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>
|
||||
{{ h.name }}
|
||||
<span v-if="h.fandom_name" class="text-caption">— {{ h.fandom_name }}</span>
|
||||
</v-list-item-title>
|
||||
<template #append>
|
||||
<span class="text-caption">{{ h.kind }}</span>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-if="allowCreate" :active="highlight === hits.length"
|
||||
@click="onCreate"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon size="small" :color="store.colorFor(parsedKind)">
|
||||
{{ iconFor(parsedKind) }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>
|
||||
Create "{{ parsedName }}" as {{ parsedKind }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
<template v-for="(row, idx) in rows" :key="row.key">
|
||||
<!-- Existing tag match (server autocomplete). -->
|
||||
<v-list-item
|
||||
v-if="row.type === 'hit'"
|
||||
:active="idx === highlight" @click="onPickRow(row)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon size="small" :color="store.colorFor(row.hit.kind)">
|
||||
{{ iconFor(row.hit.kind) }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>
|
||||
{{ row.hit.name }}
|
||||
<span v-if="row.hit.fandom_name" class="text-caption">— {{ row.hit.fandom_name }}</span>
|
||||
</v-list-item-title>
|
||||
<template #append>
|
||||
<span class="text-caption">{{ row.hit.kind }}</span>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<!-- ML suggestion for THIS image that matches the typed query. Picking
|
||||
it routes through the same accept path as the Suggestions panel, so
|
||||
it's recorded + drops out of the panel (operator-asked 2026-06-07). -->
|
||||
<v-list-item
|
||||
v-else-if="row.type === 'suggestion'"
|
||||
:active="idx === highlight" @click="onPickRow(row)"
|
||||
class="fc-tag-autocomplete__sugg"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon size="small" :color="store.colorFor(row.sugg.category)">
|
||||
{{ iconFor(row.sugg.category) }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>
|
||||
{{ row.sugg.display_name }}
|
||||
<span v-if="row.sugg.creates_new_tag" class="text-caption">— new</span>
|
||||
</v-list-item-title>
|
||||
<template #append>
|
||||
<span class="fc-tag-autocomplete__sugg-tag">
|
||||
<v-icon size="x-small">mdi-auto-fix</v-icon>
|
||||
{{ scorePct(row.sugg) }}
|
||||
</span>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<!-- Create-new row. -->
|
||||
<v-list-item
|
||||
v-else
|
||||
:active="idx === highlight" @click="onPickRow(row)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon size="small" :color="store.colorFor(parsedKind)">
|
||||
{{ iconFor(parsedKind) }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>
|
||||
Create "{{ parsedName }}" as {{ parsedKind }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-list>
|
||||
|
||||
<!-- @after-enter is the reliable moment to focus the picker: the dialog's
|
||||
@@ -66,10 +96,15 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useTagStore } from '../../stores/tags.js'
|
||||
import { useSuggestionsStore } from '../../stores/suggestions.js'
|
||||
import FandomPicker from './FandomPicker.vue'
|
||||
|
||||
const emit = defineEmits(['pick-existing', 'pick-new', 'cancel'])
|
||||
const emit = defineEmits(['pick-existing', 'pick-new', 'accept-suggestion', 'cancel'])
|
||||
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
|
||||
// Suggestions panel below (operator-asked 2026-06-07).
|
||||
const suggestions = useSuggestionsStore()
|
||||
|
||||
// Autofocus on modal open so the operator can type the moment the view
|
||||
// modal renders, no extra click required (operator-asked 2026-06-01).
|
||||
@@ -147,8 +182,42 @@ const allowCreate = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
function scorePct (s) { return `${Math.round(s.score * 100)}%` }
|
||||
|
||||
// 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.
|
||||
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 out = []
|
||||
for (const list of Object.values(suggestions.byCategory)) {
|
||||
for (const s of list || []) {
|
||||
const key = `${s.category}:${s.display_name.toLowerCase()}`
|
||||
if (!s.display_name.toLowerCase().includes(q)) continue
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
out.push(s)
|
||||
}
|
||||
}
|
||||
// Best matches first; cap so the dropdown stays scannable.
|
||||
out.sort((a, b) => b.score - a.score)
|
||||
return out.slice(0, 6)
|
||||
})
|
||||
|
||||
// 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 }))
|
||||
for (const s of suggestionHits.value) {
|
||||
r.push({ type: 'suggestion', key: `s${s.category}:${s.display_name}`, sugg: s })
|
||||
}
|
||||
if (allowCreate.value) r.push({ type: 'create', key: 'create' })
|
||||
return r
|
||||
})
|
||||
|
||||
function moveHighlight (delta) {
|
||||
const total = hits.value.length + (allowCreate.value ? 1 : 0)
|
||||
const total = rows.value.length
|
||||
if (total === 0) return
|
||||
highlight.value = (highlight.value + delta + total) % total
|
||||
// Keep the highlighted row visible — the list is capped at 240px and arrowing
|
||||
@@ -161,7 +230,18 @@ function moveHighlight (delta) {
|
||||
})
|
||||
}
|
||||
|
||||
function onPick (hit) { emit('pick-existing', hit); reset() }
|
||||
// Dispatch a chosen dropdown row by its type.
|
||||
function onPickRow (row) {
|
||||
if (row.type === 'hit') { emit('pick-existing', row.hit); reset() }
|
||||
else if (row.type === 'suggestion') { onPickSuggestion(row.sugg) }
|
||||
else { onCreate() }
|
||||
}
|
||||
|
||||
// Picking an ML suggestion hands it to the parent, which runs the same accept
|
||||
// flow as the Suggestions panel (creates the tag if raw, records acceptance,
|
||||
// drops it from the panel). reset() clears the query; the panel-drop makes it
|
||||
// fall out of suggestionHits reactively.
|
||||
function onPickSuggestion (s) { emit('accept-suggestion', s); reset() }
|
||||
|
||||
function onCreate () {
|
||||
const name = parsedName.value
|
||||
@@ -203,18 +283,15 @@ function onFandomCancel () {
|
||||
}
|
||||
|
||||
function onEnter () {
|
||||
if (highlight.value < hits.value.length) {
|
||||
onPick(hits.value[highlight.value])
|
||||
} else if (allowCreate.value) {
|
||||
onCreate()
|
||||
}
|
||||
const row = rows.value[highlight.value]
|
||||
if (row) onPickRow(row)
|
||||
}
|
||||
|
||||
// B5 (2026-06-07): when the suggestion list is open, Tab accepts the
|
||||
// highlighted row (standard autocomplete convention) instead of leaving the
|
||||
// field. With the list closed it falls through to normal focus traversal.
|
||||
// B5 (2026-06-07): when the dropdown is open, Tab accepts the highlighted row
|
||||
// (standard autocomplete convention) instead of leaving the field. With the
|
||||
// list closed it falls through to normal focus traversal.
|
||||
function onTab (e) {
|
||||
if (hits.value.length || allowCreate.value) {
|
||||
if (rows.value.length) {
|
||||
e.preventDefault()
|
||||
onEnter()
|
||||
}
|
||||
@@ -232,4 +309,14 @@ function reset () { query.value = ''; hits.value = []; highlight.value = 0 }
|
||||
border: 1px solid rgb(var(--v-theme-surface-light));
|
||||
border-radius: 6px;
|
||||
}
|
||||
/* Mark ML-suggestion rows so they read as distinct from typed/known matches. */
|
||||
.fc-tag-autocomplete__sugg {
|
||||
border-left: 2px solid rgb(var(--v-theme-accent), 0.5);
|
||||
}
|
||||
.fc-tag-autocomplete__sugg-tag {
|
||||
display: inline-flex; align-items: center; gap: 2px;
|
||||
font-size: 11px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: rgb(var(--v-theme-accent));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
<TagAutocomplete
|
||||
@pick-existing="onPickExisting" @pick-new="onPickNew"
|
||||
@accept-suggestion="onAcceptSuggestion"
|
||||
/>
|
||||
|
||||
<v-alert v-if="errorMsg" type="error" variant="tonal" class="mt-2" closable>
|
||||
@@ -45,6 +46,7 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useModalStore } from '../../stores/modal.js'
|
||||
import { useSuggestionsStore } from '../../stores/suggestions.js'
|
||||
import TagChip from './TagChip.vue'
|
||||
import TagAutocomplete from './TagAutocomplete.vue'
|
||||
import SuggestionsPanel from './SuggestionsPanel.vue'
|
||||
@@ -52,6 +54,7 @@ import TagRenameDialog from './TagRenameDialog.vue'
|
||||
import FandomSetDialog from './FandomSetDialog.vue'
|
||||
|
||||
const modal = useModalStore()
|
||||
const suggestions = useSuggestionsStore()
|
||||
const errorMsg = ref(null)
|
||||
|
||||
async function onRemove(tagId) {
|
||||
@@ -69,6 +72,16 @@ async function onPickNew(payload) {
|
||||
try { await modal.createAndAdd(payload) }
|
||||
catch (e) { errorMsg.value = e.message }
|
||||
}
|
||||
// A suggestion picked from the autocomplete dropdown runs the SAME path as the
|
||||
// Suggestions panel's Accept: the store creates the tag if it's raw, records the
|
||||
// acceptance, and drops it from the panel; then we refresh the chip rail.
|
||||
async function onAcceptSuggestion(s) {
|
||||
errorMsg.value = null
|
||||
try {
|
||||
await suggestions.accept(s)
|
||||
await modal.reloadTags()
|
||||
} catch (e) { errorMsg.value = e.message }
|
||||
}
|
||||
|
||||
const renameDialog = ref(false)
|
||||
const renameTarget = ref(null)
|
||||
|
||||
Reference in New Issue
Block a user