feat: Library 批量注册 + PeopleView 修复 + 国际化

Library:
- 添加 checkbox 和鼠标框选功能
- Unregistered 卡片添加快速注册按钮
- 状态标签移至左下方,人脸图标移至左侧中间
- Deep Scan 改为 unregister -> register -> process
- 完整国际化(日语、繁体中文、英语)

PeopleView:
- 修复卡片不显示问题(使用 get_file_traces API)
- 移除对 identity_id 字段的依赖

其他:
- 添加日语语言包
- 添加 useDragSelect composable
- 新增文档:Face Bbox Null Bug Report
This commit is contained in:
2026-10-01 00:42:13 +08:00
parent 7b60ca3d2d
commit f3d75ebce8
35 changed files with 8054 additions and 961 deletions

View File

@@ -38,7 +38,16 @@
<div id="msHSL">
<div v-if="!historyList.length" style="color:var(--muted-text);font-size:12px;padding:8px 0">No history yet</div>
<div v-for="h in historyList" :key="h.id" class="hs-item" :class="{ pinned: h.pinned }">
<div class="hs-title" @click="restoreHistory(h)">{{ h.title || h.query }}</div>
<div class="hs-main">
<div class="hs-row">
<span class="hs-mode">{{ formatMode(h.mode) }}</span>
<div class="hs-title" @click="restoreHistory(h)">{{ h.title || h.query }}</div>
</div>
<div class="hs-time">
<template v-if="h.updated_at && h.updated_at !== h.created_at">{{ formatHistoryTime(h.created_at) }} - {{ formatHistoryTime(h.updated_at) }}</template>
<template v-else>{{ formatHistoryTime(h.created_at || h.updated_at) }}</template>
</div>
</div>
<button class="hs-menu-btn" @click.stop="toggleHistoryMenu(h.id)">⋯</button>
<div v-if="activeHistoryMenu === h.id" class="hs-menu">
<button v-if="!h.pinned" @click="pinHistoryItem(h.id)">Pin</button>
@@ -95,6 +104,7 @@
<template v-else>
<div v-for="(msg, i) in messages" :key="i" class="msg" :class="msg.role">
<div v-if="msg.role === 'user'" class="msg-user">
<span class="msg-mode">{{ formatMode(msg.mode) }}</span>
{{ msg.query }}
</div>
<div v-else>
@@ -108,28 +118,54 @@
</div>
<div v-if="msg.results.length" class="card-grid">
<div v-for="(r, j) in msg.results" :key="j" class="result-card">
<div class="card-thumb" v-observe="() => loadThumb(r.file_uuid, r.start_frame)" @click="playVideo(r)">
<img v-if="thumbs[r.file_uuid + ':' + r.start_frame]" :src="thumbs[r.file_uuid + ':' + r.start_frame]" alt="" loading="lazy">
<div v-else class="thumb-placeholder">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
<div v-for="(r, j) in msg.results" :key="j" class="result-card" :class="{ 'ocr-grouped': r.ocr_frames && r.ocr_frames.length > 1 }">
<div class="card-thumb" @click="playVideo(r)">
<img v-if="r.thumbnail_url" :src="r.thumbnail_url" alt="" loading="lazy">
<img v-else-if="thumbs[r.file_uuid + ':' + r.start_frame]" :src="thumbs[r.file_uuid + ':' + r.start_frame]" alt="" loading="lazy">
<div v-else class="thumb-placeholder" v-observe="() => loadThumb(r.file_uuid, r.start_frame || 0)">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
</div>
<div v-if="r.ocr_frames && r.ocr_frames.length > 1" class="ocr-group-badge">{{ r.ocr_frames.length }}</div>
</div>
<div v-if="r.ocr_frames && r.ocr_frames.length > 1" class="ocr-frame-marks">
<span v-for="f in r.ocr_frames" :key="f" class="ocr-frame-mark"
:class="{ 'ocr-frame-variant': isVariantFrame(r, f) }"
@click.stop="playVideoAtFrame(r, f)"
:title="getFrameText(r, f)">
F{{ f }}
</span>
</div>
<div v-if="r.variants?.length" class="ocr-corrected-list">
<div v-for="(v, vi) in r.variants" :key="vi" class="ocr-corrected-item">
<span class="ocr-corrected-frames">F{{ v.frames.join(', F') }}</span>
<span class="ocr-corrected-arrow">→</span>
<span class="ocr-corrected-original">{{ v.text }}</span>
</div>
</div>
<div class="card-name" @click.stop="showTextDetail(r)">
<span v-if="getSourceType(r)" class="card-source-type" :class="getSourceType(r).toLowerCase()">{{ getSourceType(r) }}</span><span v-html="highlightMatch(parseSummary(r.summary).text, r.search_query)"></span>
<span v-if="r._isPerson" class="card-source-type person">
<svg class="card-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M6 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/></svg>
</span><span v-html="highlightMatch(r._isPerson ? r.file_name : parseSummary(r.summary).text, r.search_query)"></span>
</div>
<div v-if="r.text_content" class="card-text" v-html="highlightMatch(r.text_content.slice(0, 120), r.search_query)"></div>
<div class="card-file">{{ r.file_name || '' }}<br><span class="card-file-uuid">{{ r.file_uuid }}</span></div>
<div v-if="r._isPerson" class="card-text card-person-stats">{{ r.text_content }}</div>
<div v-if="r.text_content && !r._isPerson" class="card-text" v-html="highlightMatch(r.text_content.slice(0, 120), r.search_query)"></div>
<div class="card-file" v-if="!r._isPerson">{{ r.file_name || '' }}<br><span class="card-file-uuid">{{ r.file_uuid }}</span></div>
<div class="card-file card-person-file" v-if="r._isPerson">{{ r.display_file_name }}<br><span class="card-file-uuid">{{ r.file_uuid }}</span></div>
<div class="card-meta">
<span v-if="r.ocr_frames" class="card-frame">F{{ r.ocr_frames.join(', F') }}</span>
<span v-else-if="r.start_frame" class="card-frame">F{{ r.start_frame }}–{{ r.end_frame }}</span>
<span v-if="!r.ocr_frames && r.start_frame" class="card-frame">F{{ r.start_frame }}–{{ r.end_frame }}</span>
<span v-if="r.start_time != null" class="card-time">({{ formatTime(r.start_time) }}–{{ formatTime(r.end_time) }})</span>
<span v-if="r.similarity != null" class="card-score">{{ (r.similarity * 100).toFixed(0) }}%</span>
</div>
<div class="card-actions">
<button class="card-action-btn" @click="playVideo(r)" title="Play">▶</button>
<button class="card-action-btn" @click="expandRange(r)" title="Expand ±150 frames">⬌</button>
<button class="card-action-btn" :class="{ marked: isMarked(r) }" @click="toggleMark(r)" title="Mark for export">⭐</button>
<button class="card-action-btn" @click="playVideo(r)" title="Play">
<svg class="card-icon" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg> Play
</button>
<button class="card-action-btn" @click="expandRange(r)" title="Expand ±150 frames">
<svg class="card-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg> Expand
</button>
<button class="card-action-btn" :class="{ marked: isMarked(r) }" @click="toggleMark(r)" title="Mark for export">
<svg class="card-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg> Mark
</button>
</div>
</div>
</div>
@@ -188,16 +224,18 @@
<VideoPlayer
v-if="playing"
simple
:simple="clusterTraces.length === 0"
:file-uuid="currentVideo.fileUuid"
:start-frame="currentVideo.startFrame"
:end-frame="currentVideo.endFrame"
:all-traces="clusterTraces.length ? clusterTraces : undefined"
:merged-segments="clusterSegments.length ? clusterSegments : undefined"
:title="currentVideo.title"
:show-asrx="true"
:show-ocr="true"
:show-face="false"
:show-face="clusterTraces.length > 0"
:show-pose="false"
@close="playing = false"
@close="playing = false; clusterTraces = []; clusterSegments = []"
/>
<div v-if="selectionPopup.show" class="sel-popup" :style="{ top: selectionPopup.y + 'px', left: selectionPopup.x + 'px' }">
<button class="sel-popup-btn" @click="addSelectionBookmark">Bookmark this</button>
@@ -222,9 +260,15 @@
<div class="text-detail-content" v-html="highlightMatch(textDetailModal.text, textDetailModal.query)"></div>
</div>
<div class="text-detail-actions">
<button class="card-action-btn" @click="playVideo(textDetailModal)">▶ Play</button>
<button class="card-action-btn" @click="expandRange(textDetailModal)">⬌ Expand</button>
<button class="card-action-btn" :class="{ marked: isMarked(textDetailModal) }" @click="toggleMark(textDetailModal)">⭐ Mark</button>
<button class="card-action-btn" @click="playVideo(textDetailModal)">
<svg class="card-icon" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg> Play
</button>
<button class="card-action-btn" @click="expandRange(textDetailModal)">
<svg class="card-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg> Expand
</button>
<button class="card-action-btn" :class="{ marked: isMarked(textDetailModal) }" @click="toggleMark(textDetailModal)">
<svg class="card-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg> Mark
</button>
</div>
</div>
</div>
@@ -234,10 +278,11 @@
<script setup lang="ts">
import { ref, nextTick, computed, onMounted, onUnmounted } from 'vue'
import { apiCall } from '@/api'
import { getApiBase } from '@/api/config'
import VideoPlayer from '../components/VideoPlayer.vue'
import { useRouter } from 'vue-router'
import { useSearchHistory, useBookmarks } from '@/composables/useSearchHistory'
import { thumbnailsCache, loadThumbnail } from '@/store'
import { thumbnailsCache, loadThumbnail, filesCache, ensureFiles } from '@/store'
import type { HistoryItem } from '@/composables/useSearchHistory'
interface SourceInfo {
@@ -255,6 +300,7 @@ interface ChatMessage {
answer?: string
sources?: SourceInfo[]
chips?: string[]
mode?: string
}
const router = useRouter()
@@ -262,6 +308,7 @@ const router = useRouter()
const modes = [
{ label: 'Keyword', value: 'keyword', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>' },
{ label: 'Semantic', value: 'semantic', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>' },
{ label: 'Visual', value: 'visual', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>' },
{ label: 'Agent', value: 'agent', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><line x1="12" y1="6" x2="12" y2="2"/><line x1="12" y1="22" x2="12" y2="18"/></svg>' },
{ label: 'People', value: 'people', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>' },
]
@@ -288,14 +335,28 @@ const { history: historyList, loadHistory, saveToHistory, renameHistory, pinHist
const { bookmarks: bookmarkList, loadBookmarks, saveBookmark, deleteBookmark } = useBookmarks()
const markedResults = ref<any[]>([])
const searchConfig = ref({ vlm_min_score: 0.0, vlm_max_results: 5, vlm_output_language: 'en' })
const textDetailModal = ref<any>({ 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: '' })
const textDetailModal = ref<any>({ 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 } {
if (!summary) return { tag: '', text: '' }
const match = summary.match(/^\[(OCR|ASRX)\]\s*(.*)$/i)
const match = summary.match(/^\[(OCR|ASRX|SCENE)\]\s*(.*)$/i)
if (match) {
return { tag: match[1].toUpperCase(), text: match[2] }
let text = match[2]
if (match[1].toUpperCase() === 'SCENE') {
const draftMatch = text.match(/\* \*Draft[^*]*:\*\*\s*(.+?)(?:\n\n|\n\*|$)/s)
if (draftMatch) {
text = draftMatch[1].trim()
} else {
const envMatch = text.match(/\* \*Environment:\*\*\s*(.+?)(?:\n\n|\n\*|$)/s)
const objMatch = text.match(/\* \*Objects:\*\*\s*(.+?)(?:\n\n|\n\*|$)/s)
if (envMatch && objMatch) {
text = `${envMatch[1].trim()} ${objMatch[1].trim()}`
}
}
}
return { tag: match[1].toUpperCase(), text }
}
return { tag: '', text: summary }
}
@@ -318,14 +379,23 @@ function highlightMatch(text: string, searchQuery: string): string {
return escaped.replace(regex, '<mark class="highlight">$1</mark>')
}
function getFileNameFromCache(fileUuid: string): string {
if (!fileUuid) return ''
const clean = fileUuid.replace(/-/g, '')
const f = filesCache.value.find((f: any) => f.file_uuid?.replace(/-/g, '') === clean)
return f?.file_name || f?.file_path?.split('/').pop() || ''
}
function showTextDetail(r: any) {
const parsed = parseSummary(r.summary)
const sourceType = getSourceType(r)
// Use display_file_name for person results, or lookup from filesCache
const actualFileName = r.display_file_name || getFileNameFromCache(r.file_uuid) || r.file_name || ''
textDetailModal.value = {
show: true,
tag: parsed.tag || (sourceType ? `[${sourceType}]` : ''),
text: parsed.text,
fileName: r.file_name || '',
fileName: actualFileName,
startFrame: r.start_frame || 0,
endFrame: r.end_frame || 0,
startTime: r.start_time || 0,
@@ -337,11 +407,30 @@ function showTextDetail(r: any) {
end_frame: r.end_frame,
start_time: r.start_time,
end_time: r.end_time,
file_name: r.file_name,
file_name: actualFileName,
query: r.search_query || '',
_traces: r._traces || null,
_isPerson: r._isPerson || false,
}
}
function levenshteinDistance(a: string, b: string): number {
const m = a.length, n = b.length
const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0))
for (let i = 0; i <= m; i++) dp[i][0] = i
for (let j = 0; j <= n; j++) dp[0][j] = j
for (let i = 1; i <= m; i++)
for (let j = 1; j <= n; j++)
dp[i][j] = Math.min(dp[i-1][j] + 1, dp[i][j-1] + 1, dp[i-1][j-1] + (a[i-1] !== b[j-1] ? 1 : 0))
return dp[m][n]
}
function textSimilarity(a: string, b: string): number {
const maxLen = Math.max(a.length, b.length)
if (maxLen === 0) return 1
return 1 - levenshteinDistance(a, b) / maxLen
}
function mergeResults(results: any[]): any[] {
if (!results.length) return results
const sorted = [...results].sort((a, b) => {
@@ -350,7 +439,7 @@ function mergeResults(results: any[]): any[] {
return fa - fb
})
const merged: any[] = []
const ocrTextFrames: Map<string, { frames: number[]; results: any[] }> = new Map()
const ocrGroups: Array<{ canonicalText: string; frames: number[]; results: any[]; variants: { text: string; frames: number[] }[] }> = []
for (const r of sorted) {
const rawText = (r.summary || r.text_content || '').trim()
@@ -359,12 +448,24 @@ function mergeResults(results: any[]): any[] {
if (isOcr) {
const ocrText = parsed.tag === '[OCR]' ? parsed.text : rawText
if (ocrTextFrames.has(ocrText)) {
const data = ocrTextFrames.get(ocrText)!
data.frames.push(r.start_frame || 0)
data.results.push(r)
let bestGroup = null
let bestSim = 0
for (const g of ocrGroups) {
const sim = textSimilarity(ocrText, g.canonicalText)
if (sim > bestSim) { bestSim = sim; bestGroup = g }
}
if (bestSim > 0.85 && bestGroup) {
bestGroup.frames.push(r.start_frame || 0)
bestGroup.results.push(r)
if (ocrText !== bestGroup.canonicalText) {
let variant = bestGroup.variants.find(v => v.text === ocrText)
if (!variant) { variant = { text: ocrText, frames: [] }; bestGroup.variants.push(variant) }
variant.frames.push(r.start_frame || 0)
}
} else {
ocrTextFrames.set(ocrText, { frames: [r.start_frame || 0], results: [r] })
ocrGroups.push({ canonicalText: ocrText, frames: [r.start_frame || 0], results: [r], variants: [] })
}
continue
}
@@ -381,7 +482,7 @@ function mergeResults(results: any[]): any[] {
const lastEnd = last.end_frame || last.start_frame + 1
const rStart = r.start_frame || 0
const gap = rStart - lastEnd
if (gap <= 30) {
if (gap <= 100) {
last.end_frame = Math.max(lastEnd, r.end_frame || rStart + 1)
if (r.similarity > (last.similarity || 0)) {
last.summary = r.summary
@@ -392,13 +493,14 @@ function mergeResults(results: any[]): any[] {
}
}
for (const [ocrText, data] of ocrTextFrames) {
const uniqueFrames = [...new Set(data.frames)].sort((a, b) => a - b)
for (const group of ocrGroups) {
const uniqueFrames = [...new Set(group.frames)].sort((a, b) => a - b)
merged.push({
...data.results[0],
summary: `[OCR] ${ocrText}`,
...group.results[0],
summary: `[OCR] ${group.canonicalText}`,
ocr_frames: uniqueFrames,
ocr_details: data.results,
ocr_details: group.results,
variants: group.variants,
start_frame: uniqueFrames[0],
end_frame: uniqueFrames[uniqueFrames.length - 1],
})
@@ -429,6 +531,8 @@ function ensureMinDuration(r: any) {
const playing = ref(false)
const currentVideo = ref({ fileUuid: '', startFrame: 0, endFrame: 0, title: '' })
const clusterTraces = ref<any[]>([])
const clusterSegments = ref<any[]>([])
const currentModeLabel = computed(() => modes.find(m => m.value === mode.value)?.label || 'Keyword')
@@ -473,10 +577,28 @@ function closeDropdowns(e: MouseEvent) {
}
}
onMounted(() => {
onMounted(async () => {
document.addEventListener('click', closeDropdowns)
await ensureFiles()
loadHistory()
loadBookmarks()
// Load search config for visual search parameters
try {
const base = getApiBase()
const res = await fetch(`${base}/api/v1/admin/config`)
if (res.ok) {
const data = await res.json()
if (data.search) {
searchConfig.value = {
vlm_min_score: data.search.vlm_min_score ?? 0.0,
vlm_max_results: data.search.vlm_max_results ?? 5,
vlm_output_language: data.search.vlm_output_language ?? 'en'
}
}
}
} catch (e) {
console.error('[SearchView] Failed to load search config:', e)
}
})
onUnmounted(() => document.removeEventListener('click', closeDropdowns))
@@ -603,7 +725,7 @@ async function sendMessage() {
query.value = ''
const seq = searchSeq
messages.value.push({ role: 'user', query: q, results: [], loading: false, message: '' })
messages.value.push({ role: 'user', query: q, results: [], loading: false, message: '', mode: mode.value })
const msgIdx = messages.value.length
messages.value.push({ role: 'assistant', results: [], loading: true, message: '' })
sending.value = true
@@ -667,6 +789,58 @@ async function sendMessage() {
}
}
}
// Agent tools return "cards" array (e.g. semantic_search, keyword_search, vlm_search)
if (Array.isArray(src.parsed?.cards)) {
for (const card of src.parsed.cards) {
if (card.card_type === 'chunk' && card.file_uuid) {
results.push({
file_uuid: card.file_uuid,
file_name: card.file_name || '',
summary: card.text_preview || '',
text_content: card.text_preview || '',
similarity: card.score ?? null,
start_frame: card.start_frame ?? 0,
end_frame: card.end_frame ?? 0,
source_type: card.chunk_type === 'ocr' ? 'ocr' : 'asrx',
chunk_id: card.chunk_id || '',
})
}
if (card.card_type === 'scene' && card.file_uuid) {
const sceneIndex = card.scene_index ?? 0
results.push({
file_uuid: card.file_uuid,
file_name: card.file_name || '',
summary: card.vlm_description ? `[SCENE] ${card.vlm_description}` : `Scene ${sceneIndex}`,
text_content: card.vlm_description || '',
similarity: card.score ?? null,
start_frame: card.frame_number ?? Math.round((card.timestamp_sec || 0) * 30),
end_frame: (card.frame_number ?? Math.round((card.timestamp_sec || 0) * 30)) + 30,
start_time: card.timestamp_sec,
end_time: (card.timestamp_sec || 0) + 1,
source_type: 'scene',
thumbnail_url: card.thumbnail_url,
vlm_tags: card.vlm_tags || [],
})
}
if (card.card_type === 'face' && card.file_uuid) {
results.push({
file_uuid: card.file_uuid,
file_name: card.file_name || '',
summary: card.vlm_description || card.identity_name || `Trace ${card.trace_id}`,
text_content: card.vlm_description || '',
similarity: card.score ?? null,
start_frame: card.start_frame ?? 0,
end_frame: card.end_frame ?? 0,
source_type: 'trace',
trace_id: card.trace_id,
identity_name: card.identity_name,
vlm_clothing: card.vlm_clothing,
vlm_location: card.vlm_location,
vlm_tags: card.vlm_tags || [],
})
}
}
}
}
const smartSrc = sources.find(s => s.tool === 'smart_search')
@@ -725,20 +899,50 @@ async function sendMessage() {
const raw: any = await apiCall('search_people', { query: q, limit: 50 })
if (searchSeq !== seq) return
const peopleList = raw?.people || []
const results = peopleList.map((p: any) => ({
file_name: p.name || 'Unknown',
summary: `People · ${p.appearance_count || 0} appearances`,
similarity: 1.0,
start_frame: p.first_appearance_time,
end_frame: p.last_appearance_time,
search_query: q,
}))
const personMap = new Map<string, { traces: any[], minFrame: number, maxFrame: number }>()
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<string, string> = {}
for (const f of filesCache.value) {
if (f.file_uuid) fileUuidToName[f.file_uuid] = f.file_name || f.file_path?.split('/').pop() || ''
}
const results = Array.from(personMap.entries()).map(([key, p]) => {
const firstTrace = p.traces[0]
const totalFrames = p.traces.reduce((sum, t) => sum + (t.frame_count || 0), 0)
const avgConfidence = p.traces.reduce((sum, t) => sum + (t.avg_confidence || 0), 0) / p.traces.length
const personName = key.split('::').pop() || 'Unknown'
const fname = fileUuidToName[firstTrace.file_uuid] || ''
return {
file_uuid: firstTrace.file_uuid,
file_name: personName,
display_file_name: fname,
summary: personName,
text_content: `${p.traces.length} FS · ${totalFrames}F · ${(avgConfidence * 100).toFixed(0)}%`,
similarity: 1.0,
start_frame: p.minFrame,
end_frame: p.maxFrame,
search_query: q,
_traces: p.traces,
_isPerson: true,
}
})
messages.value[msgIdx].results = results
messages.value[msgIdx].loading = false
} else if (mode.value === 'semantic') {
const [semanticRes, keywordRes] = await Promise.all([
apiCall('search_semantic', { query: q, limit: 20 }),
apiCall('search_keyword', { query: q, limit: 20 })
apiCall('search_semantic', { query: q, limit: 5 }),
apiCall('search_keyword', { query: q, limit: 5 })
])
if (searchSeq !== seq) return
const semanticResults = (semanticRes?.results || semanticRes || [])
@@ -748,6 +952,11 @@ async function sendMessage() {
const merged: any[] = []
const semanticMerged = mergeResults(semanticResults)
const keywordMerged = mergeResults(keywordResults)
// Rerank: prioritize results containing query text (case-insensitive)
const queryLower = q.toLowerCase()
const hasQueryText = (text: string) => text && text.toLowerCase().includes(queryLower)
for (const r of semanticMerged) {
const key = `${r.file_uuid}:${r.start_frame || 0}`
if (!seen.has(key)) {
@@ -762,15 +971,200 @@ async function sendMessage() {
merged.push({ ...r, search_query: q, source_type: r.source_type || 'ocr' })
}
}
// Rerank: put results with query text first
merged.sort((a, b) => {
const aHasText = hasQueryText(a.text_content || a.summary || '')
const bHasText = hasQueryText(b.text_content || b.summary || '')
if (aHasText && !bHasText) return -1
if (!aHasText && bHasText) return 1
return (b.similarity || 0) - (a.similarity || 0)
})
messages.value[msgIdx].results = merged.slice(0, 15)
messages.value[msgIdx].loading = false
} else if (mode.value === 'visual') {
// Visual search: Scene VLM + Trace VLM
// min_score is handled by API (uses admin config default)
// Bilingual output: English + preferred language (if not English)
await ensureFiles()
const registeredUuids = new Set(filesCache.value.map((f: any) => f.file_uuid || f.uuid))
const base = getApiBase()
const maxResults = searchConfig.value.vlm_max_results || 5
const outputLang = searchConfig.value.vlm_output_language || 'en'
const bilingual = outputLang !== 'en'
let scenes: any[] = []
let traces: any[] = []
try {
if (bilingual) {
// Bilingual mode: fetch both English and preferred language
const [scenesEn, scenesLang, tracesEn, tracesLang] = await Promise.all([
fetch(`${base}/api/v1/search/vlm/scenes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: q, page_size: maxResults, min_score: 0.0, output_language: 'en' })
}).then(r => r.json()).catch(() => ({})),
fetch(`${base}/api/v1/search/vlm/scenes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: q, 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_size: maxResults, min_score: 0.0, output_language: 'en' })
}).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_size: maxResults, min_score: 0.0, output_language: outputLang })
}).then(r => r.json()).catch(() => ({}))
])
// Merge bilingual results
const scenesEnList = (scenesEn?.results || scenesEn || []) as any[]
const scenesLangList = (scenesLang?.results || scenesLang || []) as any[]
const tracesEnList = (tracesEn?.results || tracesEn || []) as any[]
const tracesLangList = (tracesLang?.results || tracesLang || []) as any[]
// Match results by file_uuid + scene_index
scenes = scenesEnList.map((sEn: any) => {
const sceneIndex = sEn.index ?? sEn.scene_index ?? 0
const sLang = scenesLangList.find((s: any) =>
s.file_uuid === sEn.file_uuid && (s.index ?? s.scene_index ?? 0) === sceneIndex
)
return {
...sEn,
vlm_description_en: sEn.vlm_description || '',
vlm_description_lang: sLang?.vlm_description || '',
vlm_description: sEn.vlm_description || ''
}
})
traces = tracesEnList.map((tEn: any) => {
const traceId = tEn.trace_id
const tLang = tracesLangList.find((t: any) => t.file_uuid === tEn.file_uuid && t.trace_id === traceId)
return {
...tEn,
vlm_description_en: tEn.vlm_description || '',
vlm_description_lang: tLang?.vlm_description || '',
vlm_description: tEn.vlm_description || ''
}
})
} else {
// English only
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_size: maxResults, min_score: 0.0, output_language: 'en' })
}).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_size: maxResults, min_score: 0.0, output_language: 'en' })
}).then(r => r.json()).catch(() => ({}))
])
scenes = (scenesRes?.results || scenesRes || []) as any[]
traces = (tracesRes?.results || tracesRes || []) as any[]
}
} catch (e) {
console.error('Visual search API error:', e)
}
if (searchSeq !== seq) return
// Filter by registered files only (API handles min_score filtering)
const filteredScenes = scenes.filter((s: any) => registeredUuids.has(s.file_uuid))
const filteredTraces = traces.filter((t: any) => registeredUuids.has(t.file_uuid))
const sceneResults = filteredScenes.map((s: any) => {
const sceneIndex = s.index ?? s.scene_index ?? 0
const frameNumber = s.frame_number ?? Math.round((s.timestamp_sec || 0) * 30)
// Build bilingual description
let vlmDesc = s.vlm_description || ''
let textContent = vlmDesc
if (bilingual && s.vlm_description_en && s.vlm_description_lang) {
// Show both English and preferred language
textContent = `【English】\n${s.vlm_description_en}\n\n【${outputLang.toUpperCase()}】\n${s.vlm_description_lang}`
vlmDesc = textContent
} else if (bilingual && s.vlm_description_en) {
// Only English available
textContent = `【English】\n${s.vlm_description_en}`
vlmDesc = textContent
}
return {
...s,
search_query: q,
source_type: 'scene',
start_frame: frameNumber,
end_frame: frameNumber + 30,
start_time: s.timestamp_sec,
end_time: (s.timestamp_sec || 0) + 1,
summary: vlmDesc ? `[SCENE] ${vlmDesc}` : `Scene ${sceneIndex}`,
text_content: textContent,
similarity: s.score ?? null,
thumbnail_url: `${base}/api/v1/file/${s.file_uuid}/scenes/scene_${String(sceneIndex).padStart(3, '0')}.jpg`,
bilingual,
vlm_description_en: s.vlm_description_en,
vlm_description_lang: s.vlm_description_lang
}
})
const traceResults = filteredTraces.map((t: any) => {
const frame = t.start_frame ?? t.best_face_frame ?? 0
// Build bilingual description
let vlmDesc = t.vlm_description || ''
let textContent = vlmDesc
if (bilingual && t.vlm_description_en && t.vlm_description_lang) {
textContent = `【English】\n${t.vlm_description_en}\n\n【${outputLang.toUpperCase()}】\n${t.vlm_description_lang}`
vlmDesc = textContent
} else if (bilingual && t.vlm_description_en) {
textContent = `【English】\n${t.vlm_description_en}`
vlmDesc = textContent
}
return {
...t,
search_query: q,
source_type: 'trace',
start_frame: frame,
end_frame: frame + 30,
summary: vlmDesc || t.text_content || 'Person appearance',
text_content: textContent,
similarity: t.score ?? null,
bilingual,
vlm_description_en: t.vlm_description_en,
vlm_description_lang: t.vlm_description_lang
}
})
// Limit total results to maxResults
const allResults = [...sceneResults, ...traceResults]
messages.value[msgIdx].results = allResults.slice(0, maxResults)
messages.value[msgIdx].loading = false
} else {
const results = await apiCall('search_keyword', { query: q, limit: 30 })
const results = await apiCall('search_keyword', { query: q, limit: 50 })
if (searchSeq !== seq) return
const rawResults = (results?.results || results || []) as any[]
const merged = mergeResults(rawResults)
for (const r of merged) r.search_query = q
messages.value[msgIdx].results = merged.slice(0, 10)
// Keyword search: skip merge, just deduplicate by file_uuid:start_frame
const seen = new Set<string>()
const deduped = rawResults.filter(r => {
const key = `${r.file_uuid}:${r.start_frame || 0}`
if (seen.has(key)) return false
seen.add(key)
return true
})
for (const r of deduped) r.search_query = q
messages.value[msgIdx].results = deduped.slice(0, 20)
messages.value[msgIdx].loading = false
}
} catch (e: any) {
@@ -807,7 +1201,32 @@ function formatTime(sec: number): string {
return `${m}:${s.toString().padStart(2, '0')}`
}
function formatMode(mode: string | null): string {
const map: Record<string, string> = {
keyword: 'Keyword',
semantic: 'Semantic',
agent: 'Agent',
people: 'People',
}
return map[mode || ''] || '—'
}
function formatHistoryTime(ts: string | null): string {
if (!ts) return ''
// SQLite datetime('now') writes "YYYY-MM-DD HH:MM:SS" (no TZ) while Rust writes
// "YYYY-MM-DDTHH:MM:SSZ". Normalize the former to UTC before parsing.
const norm = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(ts) ? `${ts}Z` : ts
const d = new Date(norm)
if (isNaN(d.getTime())) return ts
const pad = (n: number) => n.toString().padStart(2, '0')
return `${d.getFullYear()}/${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function playVideo(r: any) {
if (r._traces) {
playPersonGroup(r)
return
}
const sf = r.start_frame || 0
const ef = r.end_frame || sf + 1
currentVideo.value = {
@@ -819,6 +1238,84 @@ function playVideo(r: any) {
playing.value = true
}
function playVideoAtFrame(r: any, frame: number) {
const ef = r.end_frame || frame + 1
currentVideo.value = {
fileUuid: r.file_uuid,
startFrame: frame,
endFrame: ef,
title: r.summary?.slice(0, 60) || r.file_name || 'Video'
}
playing.value = true
}
function isVariantFrame(r: any, frame: number): boolean {
return r.variants?.some((v: any) => v.frames.includes(frame))
}
function getFrameText(r: any, frame: number): string {
const variant = r.variants?.find((v: any) => v.frames.includes(frame))
return variant?.text || (r.summary || '').replace(/^\[OCR\]\s*/, '')
}
async function playPersonGroup(person: any) {
const traces = person._traces || []
if (!traces.length) return
const fileUuid = traces[0].file_uuid
let fps = 24
try {
const fi: any = await apiCall('get_file_info', { uuid: fileUuid })
if (fi?.fps) fps = fi.fps
} catch {}
const all = traces.map((t: any) => {
const sf = t.start_frame || 0
const ef = t.end_frame || sf + (t.frame_count || 1)
return {
trace_id: t.trace_id,
start_frame: sf,
end_frame: ef,
frame_count: t.frame_count || 1,
first_frame: sf,
last_frame: ef,
first_sec: sf / fps,
last_sec: ef / fps,
file_uuid: t.file_uuid,
}
}).sort((a: any, b: any) => a.start_frame - b.start_frame)
const merged: any[] = []
let cur: any = null
all.forEach((item: any, idx: number) => {
const st = item.first_sec
const en = item.last_sec
const fu = item.file_uuid || ''
if (cur && fu === cur.file_uuid && (st - cur.end) < 10) {
cur.end = Math.max(cur.end, en)
cur.end_frame = Math.max(cur.end_frame, item.last_frame)
cur.count++
cur._endIdx = idx
} else {
if (cur) merged.push(cur)
cur = { file_uuid: fu, start_frame: item.first_frame, end_frame: item.last_frame, start: st, end: en, count: 1, _startIdx: idx, _endIdx: idx }
}
})
if (cur) merged.push(cur)
const first = all[0]
const last = all[all.length - 1]
currentVideo.value = {
fileUuid,
startFrame: first ? first.start_frame : 0,
endFrame: last ? last.end_frame : 30,
title: `${person.file_name} · ${traces.length} ${traces.length > 1 ? 'FS' : 'FS'}`,
}
clusterTraces.value = all
clusterSegments.value = merged
playing.value = true
}
function expandRange(r: any) {
const sf = r.start_frame || 0
const ef = r.end_frame || sf + 1
@@ -1002,6 +1499,27 @@ function openSource(src: SourceInfo) {
position: relative;
cursor: pointer;
}
.hs-row { display: flex; align-items: center; gap: 6px; min-width: 0; }
.hs-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.hs-time {
font-size: 10px;
color: var(--muted-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hs-item.pinned .hs-time { color: rgba(var(--primary-color-rgb), 0.8); }
.hs-mode {
font-size: 10px;
padding: 1px 5px;
border-radius: 4px;
background: var(--border-light);
color: var(--text-secondary);
white-space: nowrap;
flex-shrink: 0;
font-weight: 500;
}
.hs-item.pinned .hs-mode { background: rgba(var(--primary-color-rgb), 0.15); color: var(--primary-color); }
.hs-item:hover { background: var(--hover-background); }
.hs-item.pinned { background: rgba(var(--primary-color-rgb), 0.1); }
.hs-item.pinned .hs-title { font-weight: 500; color: var(--primary-color); }
@@ -1177,16 +1695,28 @@ function openSource(src: SourceInfo) {
text-align: right;
}
.msg-user {
display: inline-block;
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(var(--primary-color-rgb), 0.1);
color: var(--text-primary);
padding: 8px 14px;
border-radius: 12px;
border-bottom-right-radius: 4px;
font-size: 14px;
font-size: 15px;
max-width: 70%;
word-break: break-word;
}
.msg-mode {
font-size: 10px;
padding: 1px 5px;
border-radius: 4px;
background: rgba(var(--primary-color-rgb), 0.2);
color: var(--primary-color);
white-space: nowrap;
flex-shrink: 0;
font-weight: 500;
}
.msg.assistant {
background: transparent;
color: var(--text-primary);
@@ -1270,6 +1800,44 @@ function openSource(src: SourceInfo) {
position: relative;
}
.card-thumb img { width: 100%; height: 100%; object-fit: cover; }
.ocr-group-badge {
position: absolute; top: 6px; right: 6px;
background: rgba(var(--primary-color-rgb), 0.85); color: #fff;
font-size: 11px; font-weight: 600; padding: 2px 7px; border-radius: 10px;
}
.ocr-frame-marks {
display: flex; flex-wrap: wrap; gap: 3px; padding: 6px 8px 2px;
max-height: 48px; overflow: hidden;
}
.ocr-frame-mark {
font-size: 9px; padding: 1px 5px; border-radius: 4px;
background: var(--hover-background); color: var(--text-secondary);
cursor: pointer; transition: all .15s; white-space: nowrap;
}
.ocr-frame-mark:hover {
background: rgba(var(--primary-color-rgb), 0.2); color: var(--primary-color);
}
.ocr-frame-mark.ocr-frame-variant {
background: rgba(var(--warning-color-rgb), 0.2);
color: var(--warning-color);
border: 1px solid var(--warning-color);
}
.ocr-corrected-list {
padding: 4px 8px 6px;
border-top: 1px solid var(--border-light);
font-size: 10px;
}
.ocr-corrected-item {
display: flex; align-items: center; gap: 6px;
padding: 2px 0; color: var(--text-secondary);
}
.ocr-corrected-frames {
color: var(--warning-color); font-weight: 500; min-width: 80px;
}
.ocr-corrected-arrow { opacity: 0.5; }
.ocr-corrected-original {
font-style: italic; opacity: 0.7;
}
.thumb-placeholder {
width: 100%;
height: 100%;
@@ -1500,6 +2068,24 @@ mark.highlight {
background: rgba(var(--success-color), 0.2);
color: var(--success-color);
}
.card-source-type.person {
background: rgba(var(--primary-color-rgb), 0.2);
color: var(--primary-color);
display: inline-flex;
align-items: center;
}
.card-icon {
flex-shrink: 0;
vertical-align: middle;
}
.card-person-stats {
color: var(--text-secondary);
font-size: 11px;
font-weight: 500;
}
.card-person-file {
padding: 2px 8px 6px;
}
.card-tag {
font-size: 10px;
@@ -1604,4 +2190,13 @@ mark.highlight {
display: flex;
gap: 8px;
}
@media (max-width: 767px) {
.chat--empty { padding: 40px 16px; }
.ms-hero h1 { font-size: 24px; }
.ms-hero p { font-size: 13px; }
.ibar { padding: 8px; gap: 6px; }
.ibar input { font-size: 14px; }
.message { max-width: 100%; padding: 8px 10px; font-size: 13px; }
}
</style>