diff --git a/src/views/SearchView.vue b/src/views/SearchView.vue index 4a00736..38aa339 100644 --- a/src/views/SearchView.vue +++ b/src/views/SearchView.vue @@ -170,6 +170,15 @@ + +
+ +
+
References
@@ -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); } +}