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')"
|
@keydown.esc="$emit('cancel')"
|
||||||
/>
|
/>
|
||||||
<v-list
|
<v-list
|
||||||
v-if="hits.length || allowCreate"
|
v-if="rows.length"
|
||||||
ref="listRef"
|
ref="listRef"
|
||||||
density="compact" class="fc-tag-autocomplete__list"
|
density="compact" class="fc-tag-autocomplete__list"
|
||||||
>
|
>
|
||||||
<v-list-item
|
<template v-for="(row, idx) in rows" :key="row.key">
|
||||||
v-for="(h, idx) in hits" :key="h.id"
|
<!-- Existing tag match (server autocomplete). -->
|
||||||
:active="idx === highlight" @click="onPick(h)"
|
<v-list-item
|
||||||
>
|
v-if="row.type === 'hit'"
|
||||||
<template #prepend>
|
:active="idx === highlight" @click="onPickRow(row)"
|
||||||
<v-icon size="small" :color="store.colorFor(h.kind)">
|
>
|
||||||
{{ iconFor(h.kind) }}
|
<template #prepend>
|
||||||
</v-icon>
|
<v-icon size="small" :color="store.colorFor(row.hit.kind)">
|
||||||
</template>
|
{{ iconFor(row.hit.kind) }}
|
||||||
<v-list-item-title>
|
</v-icon>
|
||||||
{{ h.name }}
|
</template>
|
||||||
<span v-if="h.fandom_name" class="text-caption">— {{ h.fandom_name }}</span>
|
<v-list-item-title>
|
||||||
</v-list-item-title>
|
{{ row.hit.name }}
|
||||||
<template #append>
|
<span v-if="row.hit.fandom_name" class="text-caption">— {{ row.hit.fandom_name }}</span>
|
||||||
<span class="text-caption">{{ h.kind }}</span>
|
</v-list-item-title>
|
||||||
</template>
|
<template #append>
|
||||||
</v-list-item>
|
<span class="text-caption">{{ row.hit.kind }}</span>
|
||||||
<v-list-item
|
</template>
|
||||||
v-if="allowCreate" :active="highlight === hits.length"
|
</v-list-item>
|
||||||
@click="onCreate"
|
|
||||||
>
|
<!-- ML suggestion for THIS image that matches the typed query. Picking
|
||||||
<template #prepend>
|
it routes through the same accept path as the Suggestions panel, so
|
||||||
<v-icon size="small" :color="store.colorFor(parsedKind)">
|
it's recorded + drops out of the panel (operator-asked 2026-06-07). -->
|
||||||
{{ iconFor(parsedKind) }}
|
<v-list-item
|
||||||
</v-icon>
|
v-else-if="row.type === 'suggestion'"
|
||||||
</template>
|
:active="idx === highlight" @click="onPickRow(row)"
|
||||||
<v-list-item-title>
|
class="fc-tag-autocomplete__sugg"
|
||||||
Create "{{ parsedName }}" as {{ parsedKind }}
|
>
|
||||||
</v-list-item-title>
|
<template #prepend>
|
||||||
</v-list-item>
|
<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>
|
</v-list>
|
||||||
|
|
||||||
<!-- @after-enter is the reliable moment to focus the picker: the dialog's
|
<!-- @after-enter is the reliable moment to focus the picker: the dialog's
|
||||||
@@ -66,10 +96,15 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||||
import { useTagStore } from '../../stores/tags.js'
|
import { useTagStore } from '../../stores/tags.js'
|
||||||
|
import { useSuggestionsStore } from '../../stores/suggestions.js'
|
||||||
import FandomPicker from './FandomPicker.vue'
|
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()
|
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
|
// Autofocus on modal open so the operator can type the moment the view
|
||||||
// modal renders, no extra click required (operator-asked 2026-06-01).
|
// 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) {
|
function moveHighlight (delta) {
|
||||||
const total = hits.value.length + (allowCreate.value ? 1 : 0)
|
const total = rows.value.length
|
||||||
if (total === 0) return
|
if (total === 0) return
|
||||||
highlight.value = (highlight.value + delta + total) % total
|
highlight.value = (highlight.value + delta + total) % total
|
||||||
// Keep the highlighted row visible — the list is capped at 240px and arrowing
|
// 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 () {
|
function onCreate () {
|
||||||
const name = parsedName.value
|
const name = parsedName.value
|
||||||
@@ -203,18 +283,15 @@ function onFandomCancel () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onEnter () {
|
function onEnter () {
|
||||||
if (highlight.value < hits.value.length) {
|
const row = rows.value[highlight.value]
|
||||||
onPick(hits.value[highlight.value])
|
if (row) onPickRow(row)
|
||||||
} else if (allowCreate.value) {
|
|
||||||
onCreate()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// B5 (2026-06-07): when the suggestion list is open, Tab accepts the
|
// B5 (2026-06-07): when the dropdown is open, Tab accepts the highlighted row
|
||||||
// highlighted row (standard autocomplete convention) instead of leaving the
|
// (standard autocomplete convention) instead of leaving the field. With the
|
||||||
// field. With the list closed it falls through to normal focus traversal.
|
// list closed it falls through to normal focus traversal.
|
||||||
function onTab (e) {
|
function onTab (e) {
|
||||||
if (hits.value.length || allowCreate.value) {
|
if (rows.value.length) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onEnter()
|
onEnter()
|
||||||
}
|
}
|
||||||
@@ -232,4 +309,14 @@ function reset () { query.value = ''; hits.value = []; highlight.value = 0 }
|
|||||||
border: 1px solid rgb(var(--v-theme-surface-light));
|
border: 1px solid rgb(var(--v-theme-surface-light));
|
||||||
border-radius: 6px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
<TagAutocomplete
|
<TagAutocomplete
|
||||||
@pick-existing="onPickExisting" @pick-new="onPickNew"
|
@pick-existing="onPickExisting" @pick-new="onPickNew"
|
||||||
|
@accept-suggestion="onAcceptSuggestion"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<v-alert v-if="errorMsg" type="error" variant="tonal" class="mt-2" closable>
|
<v-alert v-if="errorMsg" type="error" variant="tonal" class="mt-2" closable>
|
||||||
@@ -45,6 +46,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useModalStore } from '../../stores/modal.js'
|
import { useModalStore } from '../../stores/modal.js'
|
||||||
|
import { useSuggestionsStore } from '../../stores/suggestions.js'
|
||||||
import TagChip from './TagChip.vue'
|
import TagChip from './TagChip.vue'
|
||||||
import TagAutocomplete from './TagAutocomplete.vue'
|
import TagAutocomplete from './TagAutocomplete.vue'
|
||||||
import SuggestionsPanel from './SuggestionsPanel.vue'
|
import SuggestionsPanel from './SuggestionsPanel.vue'
|
||||||
@@ -52,6 +54,7 @@ import TagRenameDialog from './TagRenameDialog.vue'
|
|||||||
import FandomSetDialog from './FandomSetDialog.vue'
|
import FandomSetDialog from './FandomSetDialog.vue'
|
||||||
|
|
||||||
const modal = useModalStore()
|
const modal = useModalStore()
|
||||||
|
const suggestions = useSuggestionsStore()
|
||||||
const errorMsg = ref(null)
|
const errorMsg = ref(null)
|
||||||
|
|
||||||
async function onRemove(tagId) {
|
async function onRemove(tagId) {
|
||||||
@@ -69,6 +72,16 @@ async function onPickNew(payload) {
|
|||||||
try { await modal.createAndAdd(payload) }
|
try { await modal.createAndAdd(payload) }
|
||||||
catch (e) { errorMsg.value = e.message }
|
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 renameDialog = ref(false)
|
||||||
const renameTarget = ref(null)
|
const renameTarget = ref(null)
|
||||||
|
|||||||
Reference in New Issue
Block a user