0f472b2f9e
Pure nearest-cosine piled near-identical images into the neighbour grid — a reposted banner filled all 24 slots, and once you wandered into a B&W / comic-panel cluster every neighbour was more of the same with no way back to colour without the Random button (operator-reported, with screenshot). similar() now over-fetches a wide candidate pool (5x the requested limit, cap 200), then diversifies down to `limit`: - pHash near-duplicate collapse: drop candidates within 6 Hamming bits of the anchor or an already-kept candidate, so a repost (and the anchor's own clones) appears at most once. - MMR re-rank: greedily pick for closeness-to-anchor minus similarity-to-already -picked (lambda 0.55), so the result SPANS clusters instead of returning 40 variations of one image. Falls back to nearest-order on any failure / small pool, so existing nearest-first behaviour is unchanged when there's nothing to diversify. Frontend forwardTarget drops the now-redundant skip-nearest-third hack (the list is already diversified server-side) — plain random-over-unvisited gives the variance now. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
194 lines
7.7 KiB
JavaScript
194 lines
7.7 KiB
JavaScript
import { defineStore } from 'pinia'
|
|
import { ref, computed } from 'vue'
|
|
import { useApi } from '../composables/useApi.js'
|
|
import { useInflightToken } from '../composables/useInflightToken.js'
|
|
import { toast } from '../utils/toast.js'
|
|
|
|
// Explore (#94): anchor on an image, walk its visual neighbours (pgvector
|
|
// SigLIP via /api/gallery/similar), keep an in-memory breadcrumb of the walked
|
|
// path. The ROUTE (`/explore/:imageId`) is the source of truth for the anchor;
|
|
// the view calls anchorOn() on every param change and the store reconciles the
|
|
// trail. The store ALSO acts as a TagPanel "host" (current/currentImageId +
|
|
// tag CRUD over the anchor) so the Explore workspace reuses the modal's tag
|
|
// rail verbatim for modal-parity tagging while rabbit-holing.
|
|
const NEIGHBOR_LIMIT = 40 // a wider pool → more variety to browse + jump into
|
|
|
|
export const useExploreStore = defineStore('explore', () => {
|
|
const api = useApi()
|
|
|
|
const anchor = ref(null) // /api/gallery/image/<id> payload
|
|
const neighbors = ref([]) // [{id, thumbnail_url, ...}]
|
|
const breadcrumb = ref([]) // [{id, thumbnail_url}] walked path
|
|
// Index of the current anchor within breadcrumb — browser-style back/forward.
|
|
// The trail keeps its forward branch when you step back (so ← / → can move
|
|
// through visited items); a NEW walk off a back-step truncates that branch.
|
|
const cursor = ref(-1)
|
|
const loading = ref(false)
|
|
const error = ref(null)
|
|
|
|
const inflight = useInflightToken()
|
|
|
|
async function anchorOn (id) {
|
|
const numId = Number(id)
|
|
if (!Number.isInteger(numId) || numId <= 0) return
|
|
inflight.cancel()
|
|
const t = inflight.claim()
|
|
loading.value = true
|
|
error.value = null
|
|
try {
|
|
const detail = await api.get(`/api/gallery/image/${numId}`)
|
|
if (!t.isCurrent()) return
|
|
anchor.value = detail
|
|
_reconcileTrail(numId, detail.thumbnail_url)
|
|
// Videos / not-yet-embedded images have no neighbours — leave the grid
|
|
// empty and let the view explain why (anchor.has_embedding === false).
|
|
if (detail.has_embedding) {
|
|
const body = await api.get('/api/gallery/similar', {
|
|
params: { similar_to: numId, limit: NEIGHBOR_LIMIT },
|
|
})
|
|
if (!t.isCurrent()) return
|
|
neighbors.value = body.images || []
|
|
} else {
|
|
neighbors.value = []
|
|
}
|
|
} catch (e) {
|
|
if (t.isCurrent()) { error.value = e.message; neighbors.value = [] }
|
|
} finally {
|
|
if (t.isCurrent()) loading.value = false
|
|
}
|
|
}
|
|
|
|
// The route is the source of truth; this reconciles the trail + cursor to it.
|
|
// Revisiting an id already on the path (← / →, a crumb click, or a loop) just
|
|
// MOVES the cursor there — the forward branch is preserved so → can return to
|
|
// it. A genuinely new image off a back-step truncates the stale forward branch
|
|
// (browser semantics), then appends and points the cursor at the new tip.
|
|
function _reconcileTrail (id, thumbnailUrl) {
|
|
const idx = breadcrumb.value.findIndex((c) => c.id === id)
|
|
if (idx >= 0) {
|
|
cursor.value = idx
|
|
return
|
|
}
|
|
const base = cursor.value < breadcrumb.value.length - 1
|
|
? breadcrumb.value.slice(0, cursor.value + 1)
|
|
: breadcrumb.value
|
|
breadcrumb.value = [...base, { id, thumbnail_url: thumbnailUrl }]
|
|
cursor.value = breadcrumb.value.length - 1
|
|
}
|
|
|
|
// ← target: the previous crumb (null at the start of the trail).
|
|
function backTarget () {
|
|
return cursor.value > 0 ? breadcrumb.value[cursor.value - 1].id : null
|
|
}
|
|
|
|
// → target: after a ←, walk forward through the already-visited trail
|
|
// (browser-style). Otherwise jump to a varied neighbour to keep the
|
|
// rabbit-hole going — null if neither exists.
|
|
function forwardTarget () {
|
|
if (cursor.value >= 0 && cursor.value < breadcrumb.value.length - 1) {
|
|
return breadcrumb.value[cursor.value + 1].id
|
|
}
|
|
if (!neighbors.value.length) return null
|
|
// Prefer UNVISITED neighbours so → opens something new instead of landing on
|
|
// a crumb (which snaps the cursor back into the trail — the "loops back"
|
|
// report). Fall back to the full set only if every neighbour's been seen.
|
|
const seen = new Set(breadcrumb.value.map((c) => c.id))
|
|
const pool = neighbors.value.filter((n) => !seen.has(n.id))
|
|
const cands = pool.length ? pool : neighbors.value
|
|
// The list is already pHash-deduped + MMR-diversified server-side (it spans
|
|
// clusters, not 40 near-dupes), so a plain random pick gives real variance —
|
|
// no need to skip the nearest slice the way the raw nearest-list required.
|
|
return cands[Math.floor(Math.random() * cands.length)].id
|
|
}
|
|
|
|
function reset () {
|
|
inflight.cancel()
|
|
anchor.value = null
|
|
neighbors.value = []
|
|
breadcrumb.value = []
|
|
cursor.value = -1
|
|
error.value = null
|
|
loading.value = false
|
|
}
|
|
|
|
// --- TagPanel "host" surface ---------------------------------------------
|
|
// The anchor IS the current image (same /api/gallery/image/<id> payload the
|
|
// modal uses), so these mirror the modal store's tag-CRUD, targeting the
|
|
// anchor. Kept separate from the modal store so the audited overlay flow is
|
|
// untouched; the id is captured at call-time so a fast walk can't misroute a
|
|
// mutation to the next anchor (same guard as the modal store).
|
|
const current = computed(() => anchor.value)
|
|
const currentImageId = computed(() => anchor.value?.id ?? null)
|
|
|
|
async function reloadTags () {
|
|
const id = anchor.value?.id
|
|
if (!id) return
|
|
const tags = await api.get(`/api/images/${id}/tags`)
|
|
if (anchor.value && anchor.value.id === id) anchor.value.tags = tags
|
|
}
|
|
|
|
async function addExistingTag (tagId) {
|
|
const id = anchor.value?.id
|
|
if (!id) return
|
|
await api.post(`/api/images/${id}/tags`, {
|
|
body: { tag_id: tagId, source: 'manual' },
|
|
})
|
|
await reloadTags()
|
|
}
|
|
|
|
async function removeTag (tagId) {
|
|
const id = anchor.value?.id
|
|
if (!id) return
|
|
const prev = anchor.value.tags
|
|
anchor.value.tags = (anchor.value.tags || []).filter((t) => t.id !== tagId)
|
|
try {
|
|
await api.delete(`/api/images/${id}/tags/${tagId}`)
|
|
} catch (e) {
|
|
if (anchor.value && anchor.value.id === id) anchor.value.tags = prev
|
|
toast({ text: `Failed to remove tag: ${e.message}`, type: 'error' })
|
|
throw e
|
|
}
|
|
// The dismiss is best-effort — the tag is gone server-side regardless.
|
|
try {
|
|
await api.post(`/api/images/${id}/suggestions/dismiss`, {
|
|
body: { tag_id: tagId },
|
|
})
|
|
} catch (e) {
|
|
toast({
|
|
text: `Tag removed, but failed to dismiss suggestion: ${e.message}`,
|
|
type: 'warning',
|
|
})
|
|
}
|
|
}
|
|
|
|
async function createAndAdd ({ name, kind, fandom_id = null }) {
|
|
const id = anchor.value?.id
|
|
if (!id) return
|
|
const tag = await api.post('/api/tags', { body: { name, kind, fandom_id } })
|
|
if (kind === 'fandom') {
|
|
// Mirror the modal store: a freshly-created fandom must land in the cache
|
|
// so FandomPicker sees it without a reload.
|
|
const { useTagStore } = await import('./tags.js')
|
|
useTagStore().fandomCache.push({
|
|
id: tag.id, name: tag.name, kind: 'fandom',
|
|
fandom_id: null, fandom_name: null, image_count: 0,
|
|
})
|
|
}
|
|
if (anchor.value?.id !== id) return // walked away mid-create
|
|
await addExistingTag(tag.id)
|
|
}
|
|
|
|
// No overlay to dismiss in the Explore workspace — the chip-body "show me
|
|
// more of this tag" navigation just routes away. Present so TagPanel's
|
|
// host.close?.() is a no-op here.
|
|
function close () {}
|
|
|
|
return {
|
|
anchor, neighbors, breadcrumb, cursor, loading, error, NEIGHBOR_LIMIT,
|
|
anchorOn, reset, backTarget, forwardTarget,
|
|
// host surface
|
|
current, currentImageId,
|
|
reloadTags, addExistingTag, removeTag, createAndAdd, close,
|
|
}
|
|
})
|