@@ -337,6 +346,13 @@ const { bookmarks: bookmarkList, loadBookmarks, saveBookmark, deleteBookmark } =
const markedResults = ref
([])
const searchConfig = ref({ vlm_min_score: 0.0, vlm_max_results: 5, vlm_output_language: 'en' })
+// Pagination states
+const sessionId = ref('')
+const hasMore = ref(false)
+const nextOffset = ref(0)
+const totalResults = ref(0)
+const loadingMore = ref(false)
+
const textDetailModal = ref({ show: false, tag: '', text: '', fileName: '', startFrame: 0, endFrame: 0, startTime: 0, endTime: 0, similarity: 0, summary: '', file_uuid: '', start_frame: 0, end_frame: 0, start_time: 0, end_time: 0, file_name: '', query: '', _traces: null, _isPerson: false })
function parseSummary(summary: string): { tag: string; text: string } {
@@ -939,6 +955,11 @@ async function sendMessage() {
})
messages.value[msgIdx].results = results
messages.value[msgIdx].loading = false
+
+ // Set pagination state
+ hasMore.value = results.length >= 5
+ nextOffset.value = 5
+ totalResults.value = results.length
} else if (mode.value === 'semantic') {
const [semanticRes, keywordRes] = await Promise.all([
apiCall('search_semantic', { query: q, limit: 5 }),
@@ -981,8 +1002,13 @@ async function sendMessage() {
return (b.similarity || 0) - (a.similarity || 0)
})
- messages.value[msgIdx].results = merged.slice(0, 15)
+ messages.value[msgIdx].results = merged.slice(0, 5)
messages.value[msgIdx].loading = false
+
+ // Set pagination state
+ hasMore.value = merged.length >= 5
+ nextOffset.value = 5
+ totalResults.value = merged.length
} else if (mode.value === 'visual') {
// Visual search: Scene VLM + Trace VLM
// min_score is handled by API (uses admin config default)
@@ -1149,10 +1175,15 @@ async function sendMessage() {
// Limit total results to maxResults
const allResults = [...sceneResults, ...traceResults]
- messages.value[msgIdx].results = allResults.slice(0, maxResults)
+ messages.value[msgIdx].results = allResults.slice(0, 5)
messages.value[msgIdx].loading = false
+
+ // Set pagination state
+ hasMore.value = allResults.length >= 5
+ nextOffset.value = 5
+ totalResults.value = allResults.length
} else {
- const results = await apiCall('search_keyword', { query: q, limit: 50 })
+ const results = await apiCall('search_keyword', { query: q, limit: 5 })
if (searchSeq !== seq) return
const rawResults = (results?.results || results || []) as any[]
// Keyword search: skip merge, just deduplicate by file_uuid:start_frame
@@ -1164,8 +1195,13 @@ async function sendMessage() {
return true
})
for (const r of deduped) r.search_query = q
- messages.value[msgIdx].results = deduped.slice(0, 20)
+ messages.value[msgIdx].results = deduped.slice(0, 5)
messages.value[msgIdx].loading = false
+
+ // Set pagination state
+ hasMore.value = deduped.length >= 5
+ nextOffset.value = 5
+ totalResults.value = deduped.length
}
} catch (e: any) {
if (searchSeq !== seq) return
@@ -1185,6 +1221,148 @@ async function sendMessage() {
}
}
+async function loadMore() {
+ if (!hasMore.value || loadingMore.value || messages.value.length < 2) return
+
+ const lastMsg = messages.value[messages.value.length - 1]
+ if (lastMsg.role !== 'assistant' || !lastMsg.results.length) return
+
+ loadingMore.value = true
+
+ try {
+ const lastUserMsg = messages.value[messages.value.length - 2]
+ if (!lastUserMsg || lastUserMsg.role !== 'user') return
+
+ const q = lastUserMsg.query || ''
+ const currentMode = lastUserMsg.mode || mode.value
+
+ let newResults: any[] = []
+
+ if (currentMode === 'visual') {
+ await ensureFiles()
+ const registeredUuids = new Set(filesCache.value.map((f: any) => f.file_uuid || f.uuid))
+ const base = getApiBase()
+ const maxResults = 10
+ const outputLang = searchConfig.value.vlm_output_language || 'en'
+
+ const [scenesRes, tracesRes] = await Promise.all([
+ fetch(`${base}/api/v1/search/vlm/scenes`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query: q, page: Math.floor(nextOffset.value / 10) + 1, page_size: maxResults, min_score: 0.0, output_language: outputLang })
+ }).then(r => r.json()).catch(() => ({})),
+ fetch(`${base}/api/v1/search/vlm/traces`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query: q, page: Math.floor(nextOffset.value / 10) + 1, page_size: maxResults, min_score: 0.0, output_language: outputLang })
+ }).then(r => r.json()).catch(() => ({}))
+ ])
+
+ const sceneResults = (scenesRes.scenes || []).map((s: any) => ({
+ file_uuid: s.file_uuid,
+ file_name: s.file_name || '',
+ summary: `[SCENE] ${s.vlm_description || ''}`,
+ similarity: s.score ?? null,
+ start_frame: s.frame_number ?? Math.round((s.timestamp_sec || 0) * 30),
+ end_frame: (s.frame_number ?? Math.round((s.timestamp_sec || 0) * 30)) + 30,
+ start_time: s.timestamp_sec,
+ end_time: (s.timestamp_sec || 0) + 1,
+ source_type: 'scene',
+ thumbnail_url: s.thumbnail_url,
+ vlm_tags: s.vlm_tags || [],
+ search_query: q
+ }))
+
+ const traceResults = (tracesRes.traces || []).map((t: any) => ({
+ file_uuid: t.file_uuid,
+ file_name: t.file_name || '',
+ summary: `[TRACE] ${t.vlm_description || ''}`,
+ similarity: t.score ?? null,
+ start_frame: t.start_frame,
+ end_frame: t.end_frame,
+ source_type: 'trace',
+ trace_id: t.trace_id,
+ vlm_description: t.vlm_description,
+ search_query: q
+ }))
+
+ newResults = [...sceneResults, ...traceResults].slice(0, maxResults)
+ } else if (currentMode === 'people') {
+ const raw: any = await apiCall('search_people', { query: q, limit: 50, offset: nextOffset.value })
+ const peopleList = raw?.people || []
+
+ const personMap = new Map()
+ for (const t of peopleList) {
+ const name = t.name || 'Unknown'
+ const key = `${t.file_uuid || ''}::${name}`
+ if (!personMap.has(key)) {
+ personMap.set(key, { traces: [], minFrame: Infinity, maxFrame: -Infinity })
+ }
+ const p = personMap.get(key)!
+ p.traces.push(t)
+ if (t.start_frame < p.minFrame) p.minFrame = t.start_frame
+ if (t.end_frame > p.maxFrame) p.maxFrame = t.end_frame
+ }
+
+ const fileUuidToName: Record = {}
+ for (const f of filesCache.value) {
+ if (f.file_uuid) fileUuidToName[f.file_uuid] = f.file_name || f.file_path?.split('/').pop() || ''
+ }
+
+ newResults = Array.from(personMap.entries()).map(([key, p]) => {
+ const firstTrace = p.traces[0]
+ return {
+ file_uuid: firstTrace.file_uuid || '',
+ file_name: fileUuidToName[firstTrace.file_uuid || ''] || firstTrace.file_name || '',
+ summary: firstTrace.name || 'Unknown',
+ text_content: `${p.traces.length} appearances`,
+ similarity: null,
+ start_frame: p.minFrame,
+ end_frame: p.maxFrame,
+ _isPerson: true,
+ _traces: p.traces,
+ search_query: q
+ }
+ }).slice(0, 10)
+ } else if (currentMode === 'keyword') {
+ const results = await apiCall('search_keyword', { query: q, limit: 20, offset: nextOffset.value })
+ const rawResults = (results?.results || results || []) as any[]
+ const seen = new Set()
+ newResults = rawResults.filter(r => {
+ const key = `${r.file_uuid}:${r.start_frame || 0}`
+ if (seen.has(key)) return false
+ seen.add(key)
+ return true
+ }).map(r => ({ ...r, search_query: q })).slice(0, 10)
+ } else if (currentMode === 'semantic') {
+ const results = await apiCall('search_semantic', { query: q, limit: 20, offset: nextOffset.value })
+ const rawResults = (results?.results || results || []) as any[]
+ const seen = new Set()
+ newResults = rawResults.filter(r => {
+ const key = `${r.file_uuid}:${r.start_frame || 0}`
+ if (seen.has(key)) return false
+ seen.add(key)
+ return true
+ }).map(r => ({ ...r, search_query: q })).slice(0, 10)
+ } else {
+ hasMore.value = false
+ return
+ }
+
+ if (newResults.length > 0) {
+ lastMsg.results.push(...newResults)
+ nextOffset.value += newResults.length
+ hasMore.value = newResults.length >= 10
+ } else {
+ hasMore.value = false
+ }
+ } catch (e: any) {
+ console.error('Load more failed:', e)
+ } finally {
+ loadingMore.value = false
+ }
+}
+
function loadThumb(uuid: string, frame?: number) {
loadThumbnail(uuid, frame || 0)
}
@@ -2199,4 +2377,54 @@ mark.highlight {
.ibar input { font-size: 14px; }
.message { max-width: 100%; padding: 8px 10px; font-size: 13px; }
}
+
+/* More Button Styles */
+.more-btn-container {
+ display: flex;
+ justify-content: center;
+ padding: 20px 0;
+ margin-top: 16px;
+}
+
+.more-btn {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 12px 24px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ color: var(--text-primary);
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.more-btn:hover:not(:disabled) {
+ background: var(--bg-hover);
+ border-color: var(--accent-primary);
+}
+
+.more-btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.more-btn svg {
+ flex-shrink: 0;
+}
+
+.spinner-mini {
+ width: 16px;
+ height: 16px;
+ border: 2px solid var(--border-color);
+ border-top-color: var(--accent-primary);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}