+
+
+
+
+
+
+
diff --git a/frontend/src/stores/gallery.js b/frontend/src/stores/gallery.js
index 39169f7..bad0d8b 100644
--- a/frontend/src/stores/gallery.js
+++ b/frontend/src/stores/gallery.js
@@ -27,6 +27,9 @@ export const useGalleryStore = defineStore('gallery', () => {
// Phase-2 faceted refine params.
platform: null, untagged: false, no_artist: false,
date_from: null, date_to: null,
+ // Phase-3 visual similarity: when set, the gallery is in "similar mode" —
+ // ranked by cosine distance to this image, bounded top-N, no cursor.
+ similar_to: null,
})
// Live facet counts for the refine panel; fetched on-demand (panel open +
@@ -84,6 +87,39 @@ export const useGalleryStore = defineStore('gallery', () => {
}
}
+ // Visual "more like this": ranked top-N by cosine distance, scope filters
+ // composed (AND). No cursor / no timeline — bounded result set.
+ async function loadSimilar() {
+ inflight.cancel()
+ images.value = []
+ dateGroups.value = []
+ nextCursor.value = null
+ timelineBuckets.value = []
+ loading.value = true
+ error.value = null
+ const t = inflight.claim()
+ try {
+ const f = filter.value
+ const params = { similar_to: f.similar_to, limit: 100 }
+ if (f.tag_ids.length) params.tag_id = f.tag_ids.join(',')
+ if (f.artist_id) params.artist_id = f.artist_id
+ if (f.media_type) params.media = f.media_type
+ if (f.platform) params.platform = f.platform
+ if (f.untagged) params.untagged = '1'
+ if (f.no_artist) params.no_artist = '1'
+ if (f.date_from) params.date_from = f.date_from
+ if (f.date_to) params.date_to = f.date_to
+ const body = await api.get('/api/gallery/similar', { params })
+ if (!t.isCurrent()) return
+ images.value = body.images
+ // ranked + bounded → no next page (nextCursor stays null → hasMore false)
+ } catch (e) {
+ error.value = e.message
+ } finally {
+ if (t.isCurrent()) loading.value = false
+ }
+ }
+
async function jumpTo(year, month) {
// Rapid timeline-jump clicks need the same race guard as
// loadMore — first jump's late body could clobber the second
@@ -150,9 +186,15 @@ export const useGalleryStore = defineStore('gallery', () => {
no_artist: _truthy(q.no_artist),
date_from: _parseDate(q.date_from),
date_to: _parseDate(q.date_to),
+ similar_to: _toId(q.similar_to),
+ }
+ if (filter.value.similar_to) {
+ // Similar mode: ranked, no timeline scroll.
+ await loadSimilar()
+ } else {
+ await loadInitial()
+ await loadTimeline()
}
- await loadInitial()
- await loadTimeline()
_resolveLabels()
}
@@ -200,7 +242,7 @@ export const useGalleryStore = defineStore('gallery', () => {
images, dateGroups, hasMore, isEmpty, loading, error,
filter, tagLabels, artistLabel, timelineBuckets, timelineLoading,
facets, facetsLoading,
- loadInitial, loadMore, loadTimeline, jumpTo, loadFacets,
+ loadInitial, loadMore, loadTimeline, jumpTo, loadFacets, loadSimilar,
applyFilterFromQuery, noteTagLabel, noteArtistLabel,
}
})
@@ -213,6 +255,7 @@ export function cloneFilter(f) {
tag_ids: [...f.tag_ids], artist_id: f.artist_id, media_type: f.media_type,
sort: f.sort, platform: f.platform, untagged: f.untagged,
no_artist: f.no_artist, date_from: f.date_from, date_to: f.date_to,
+ similar_to: f.similar_to,
}
}
@@ -227,6 +270,7 @@ export function filterToQuery(f) {
if (f.no_artist) q.no_artist = '1'
if (f.date_from) q.date_from = f.date_from
if (f.date_to) q.date_to = f.date_to
+ if (f.similar_to) q.similar_to = String(f.similar_to)
return q
}
diff --git a/frontend/src/views/GalleryView.vue b/frontend/src/views/GalleryView.vue
index a264a11..62ddd12 100644
--- a/frontend/src/views/GalleryView.vue
+++ b/frontend/src/views/GalleryView.vue
@@ -16,7 +16,10 @@