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

File diff suppressed because it is too large Load Diff

View File

@@ -2,19 +2,19 @@
<div id="ms-view-client" class="client-layout">
<div class="client-header">
<div class="header-top-row">
<h1>File Manager</h1>
<h1>{{ t('client.title') }}</h1>
<div class="header-buttons">
<button @click="goUp" :disabled="currentPath === '/'">Up</button>
<button @click="showCreateFolderDialog">New Folder</button>
<button @click="showUploadDialog">Upload</button>
<button @click="confirmDelete" :disabled="!selectedFile">Delete</button>
<button @click="showMoveDialog" :disabled="!selectedFile">Move</button>
<button @click="refreshFiles">Refresh</button>
<button @click="showCreateFolderDialog">{{ t('client.button.newFolder') }}</button>
<button @click="showUploadDialog">{{ t('client.button.upload') }}</button>
<button @click="confirmDelete" :disabled="!selectedFile">{{ t('client.button.delete') }}</button>
<button @click="showMoveDialog" :disabled="!selectedFile">{{ t('client.button.move') }}</button>
<button @click="refreshFiles">{{ t('client.button.refresh') }}</button>
</div>
</div>
<div class="header-path-row">Path: {{ currentPath }}</div>
<div class="client-search">
<input v-model="searchQuery" type="text" placeholder="Search files..." @input="onSearch" />
<input v-model="searchQuery" type="text" :placeholder="t('client.search.placeholder')" @input="onSearch" />
</div>
</div>
@@ -24,10 +24,10 @@
<table v-else class="file-table">
<thead>
<tr>
<th @click="sortBy('name')" class="sortable">Name <span v-if="sortField==='name'">{{ sortAsc ? '▲' : '▼' }}</span></th>
<th @click="sortBy('size')" class="sortable">Size <span v-if="sortField==='size'">{{ sortAsc ? '▲' : '▼' }}</span></th>
<th>Type</th>
<th>Modified</th>
<th @click="sortBy('name')" class="sortable">{{ t('client.table.name') }} <span v-if="sortField==='name'">{{ sortAsc ? '▲' : '▼' }}</span></th>
<th @click="sortBy('size')" class="sortable">{{ t('client.table.size') }} <span v-if="sortField==='size'">{{ sortAsc ? '▲' : '▼' }}</span></th>
<th>{{ t('client.table.type') }}</th>
<th>{{ t('client.table.modified') }}</th>
</tr>
</thead>
<tbody>
@@ -78,11 +78,11 @@
<!-- Create Folder Dialog -->
<div v-if="showCreateFolder" class="dialog-overlay" @click="hideCreateFolderDialog">
<div class="dialog" @click.stop>
<h3>Create New Folder</h3>
<input v-model="newFolderName" type="text" placeholder="Folder name" @keyup.enter="createFolder" />
<h3>{{ t('client.dialog.createFolder') }}</h3>
<input v-model="newFolderName" type="text" :placeholder="t('client.dialog.folderName')" @keyup.enter="createFolder" />
<div class="dialog-buttons">
<button @click="createFolder">Create</button>
<button @click="hideCreateFolderDialog">Cancel</button>
<button @click="createFolder">{{ t('client.button.create') }}</button>
<button @click="hideCreateFolderDialog">{{ t('client.button.cancel') }}</button>
</div>
</div>
</div>
@@ -90,14 +90,14 @@
<!-- Delete Confirm Dialog -->
<div v-if="showDeleteConfirm" class="dialog-overlay" @click="cancelDelete">
<div class="dialog" @click.stop>
<h3>Delete "{{ deleteTarget }}"</h3>
<h3>{{ t('client.dialog.deleteTitle', { name: deleteTarget }) }}</h3>
<p v-if="selectedFile?.isDir && selectedFile.fileCount">
This folder contains <strong>{{ selectedFile.fileCount }}</strong> file(s). They will be permanently deleted.
</p>
<p v-else>Are you sure you want to delete this item?</p>
<p v-else>{{ t('client.dialog.deleteMessage', { type: selectedFile?.isDir ? t('client.type.folder') : t('client.type.file') }) }}</p>
<div class="dialog-buttons">
<button @click="doDelete" class="danger-btn">Delete</button>
<button @click="cancelDelete">Cancel</button>
<button @click="doDelete" class="danger-btn">{{ t('client.button.delete') }}</button>
<button @click="cancelDelete">{{ t('client.button.cancel') }}</button>
</div>
</div>
</div>
@@ -105,11 +105,11 @@
<!-- Rename Dialog -->
<div v-if="showRename" class="dialog-overlay" @click="hideRenameDialog">
<div class="dialog" @click.stop>
<h3>Rename "{{ renameOldName }}"</h3>
<input v-model="renameNewName" type="text" placeholder="New name" @keyup.enter="doRename" autofocus />
<h3>{{ t('client.dialog.renameTitle', { name: renameOldName }) }}</h3>
<input v-model="renameNewName" type="text" :placeholder="t('client.dialog.newName')" @keyup.enter="doRename" autofocus />
<div class="dialog-buttons">
<button @click="doRename">Rename</button>
<button @click="hideRenameDialog">Cancel</button>
<button @click="doRename">{{ t('client.button.rename') }}</button>
<button @click="hideRenameDialog">{{ t('client.button.cancel') }}</button>
</div>
</div>
</div>
@@ -117,7 +117,7 @@
<!-- File Preview Dialog -->
<div v-if="showPreview" class="dialog-overlay" @click="hidePreview">
<div class="dialog preview-dialog" @click.stop>
<h3>File Preview: {{ selectedFile?.name }}</h3>
<h3>{{ t('client.dialog.previewTitle', { name: selectedFile?.name }) }}</h3>
<div v-if="previewLoading" class="loading">Loading preview...</div>
<div v-else-if="previewContent" class="preview-content">
<img v-if="isImage" :src="previewContent" alt="preview" />
@@ -131,8 +131,8 @@
<pre v-else>{{ previewContent }}</pre>
</div>
<div class="dialog-buttons">
<button @click="downloadFile">Download</button>
<button @click="hidePreview">Close</button>
<button @click="downloadFile">{{ t('client.button.download') }}</button>
<button @click="hidePreview">{{ t('common.close') }}</button>
</div>
</div>
</div>
@@ -140,7 +140,7 @@
<!-- Upload Dialog -->
<div v-if="showUpload" class="dialog-overlay" @click="hideUploadDialog">
<div class="dialog upload-dialog" @click.stop>
<h3>Upload File{{ uploadFiles.length > 1 ? 's' : '' }}</h3>
<h3>{{ t('client.dialog.uploadTitle', { s: uploadFiles.length > 1 ? 's' : '' }) }}</h3>
<input ref="fileInput" type="file" multiple @change="selectUploadFile" />
<div v-if="uploadFiles.length > 0" class="upload-info">
<p v-if="!uploading">{{ uploadFiles.length }} file(s) selected</p>
@@ -155,9 +155,9 @@
</div>
<div class="dialog-buttons">
<button @click="uploadFileToServer" :disabled="uploadFiles.length === 0 || uploading">
{{ uploading ? 'Uploading...' : 'Upload' }}
{{ uploading ? t('client.status.uploading') : t('client.button.upload') }}
</button>
<button @click="hideUploadDialog">Cancel</button>
<button @click="hideUploadDialog">{{ t('client.button.cancel') }}</button>
</div>
</div>
</div>
@@ -165,7 +165,7 @@
<!-- Move Dialog -->
<div v-if="showMove" class="dialog-overlay" @click="showMove = false">
<div class="dialog" @click.stop>
<h3>{{ selectedFile?.isDir ? 'Move Folder' : 'Move File' }} "{{ selectedFile?.name }}"</h3>
<h3>{{ t('client.dialog.moveTitle', { type: selectedFile?.isDir ? t('client.type.folder') : t('client.type.file'), name: selectedFile?.name }) }}</h3>
<select v-model="moveTarget" class="move-select">
<option value="" disabled>Select a folder...</option>
<option v-for="folder in availableFolders" :key="folder.name" :value="folder.name">
@@ -173,8 +173,8 @@
</option>
</select>
<div class="dialog-buttons">
<button @click="doMove" :disabled="!moveTarget">Move</button>
<button @click="showMove = false">Cancel</button>
<button @click="doMove" :disabled="!moveTarget">{{ t('client.button.move') }}</button>
<button @click="showMove = false">{{ t('client.button.cancel') }}</button>
</div>
</div>
</div>
@@ -183,6 +183,9 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const currentPath = ref('/')
const files = ref<any[]>([])
@@ -1305,4 +1308,9 @@ onMounted(() => {
font-size: 12px;
color: var(--text-secondary);
}
@media (max-width: 767px) {
table { display: block; overflow-x: auto; white-space: nowrap; }
.client-view { padding: 8px; }
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -88,15 +88,15 @@
</div>
<template v-if="showAliasInput">
<select v-model="aliasLocale" class="ms-ppl-alias-locale-select">
<option value="en">English</option>
<option value="zh-TW">繁體中文</option>
<option value="zh-CN">简体中文</option>
<option value="ja">日本語</option>
<option value="ko">한국어</option>
<option value="en">{{ $t('language.en') }}</option>
<option value="zh-TW">{{ $t('language.zh-TW') }}</option>
<option value="zh-CN">{{ $t('language.zh-CN') }}</option>
<option value="ja">{{ $t('language.ja') }}</option>
<option value="ko">{{ $t('language.ko') }}</option>
</select>
<input v-model="aliasName" class="ms-ppl-alias-inline-input" placeholder="Enter alias and press Enter" @keyup.enter="addAlias">
</template>
<button v-if="!showAliasInput" class="ms-ppl-alias-add-btn" @click="showAliasInput = true; aliasName = ''">+ Alias</button>
<button v-if="!showAliasInput" class="ms-ppl-alias-add-btn" @click="showAliasInput = true; aliasName = ''">{{ $t('people.detail.add_alias') }}</button>
<button v-else class="ms-ppl-alias-add-btn" @click="showAliasInput = false">Collapse</button>
</div>
<div class="ms-ppl-edit-fields">
@@ -122,8 +122,8 @@
<button class="ms-ppl-strip-add-btn" @click="showCandidates = true" title="Add same person">+</button>
<button class="ms-ppl-strip-arrow" :disabled="faceStripPage === 1" @click="prevFacePage">‹</button>
<div class="ms-ppl-face-strip">
<div v-if="loadingTraces" style="padding:10px;color:#999;font-size:12px;">Loading traces...</div>
<div v-else-if="allTraces.length === 0" style="padding:10px;color:#999;font-size:12px;">No traces found</div>
<div v-if="loadingTraces" style="padding:10px;color:#999;font-size:12px;">Loading FS...</div>
<div v-else-if="allTraces.length === 0" style="padding:10px;color:#999;font-size:12px;">No FS found</div>
<div v-for="(t, i) in paginatedTraces" :key="t.trace_id" class="ms-ppl-strip-face ms-ppl-strip-face-clickable" :class="{ selected: selectedTrace?.trace_id === t.trace_id }" @click="selectTrace(t)" @contextmenu.prevent="showTraceCtxMenu($event, t)">
<div class="ms-ppl-strip-face-img">
<img v-if="traceThumbs[thumbKey(t.file_uuid, t.first_frame)]" :src="traceThumbs[thumbKey(t.file_uuid, t.first_frame)]" alt="" loading="lazy" style="width:100%;height:100%;object-fit:contain;border-radius:8px;">
@@ -185,11 +185,11 @@
<span v-if="allSegCard.avg_confidence" class="ms-ppl-media-badge-conf">{{ (allSegCard.avg_confidence * 100).toFixed(0) }}%</span>
</div>
<div class="ms-ppl-media-info">
<div class="ms-ppl-media-title">{{ allSegCard.count }} segments · {{ allTraces.length }} traces</div>
<div class="ms-ppl-media-title">{{ allSegCard.count }} segments · {{ allTraces.length }} FS</div>
<div class="ms-ppl-media-sub">All segments · {{ formatTime(allSegCard.start) }}</div>
</div>
</div>
<div v-else-if="!loadingTraces" class="ms-ppl-media-label" style="margin-bottom:20px;">No trace segments available for this person</div>
<div v-else-if="!loadingTraces" class="ms-ppl-media-label" style="margin-bottom:20px;">No FS segments available for this person</div>
<!-- Tabs -->
<div v-show="activeTab === 'actions'">
@@ -867,7 +867,11 @@ let mergeSearchTimer: any
.ms-ppl-strip-wrap { display: flex; align-items: center; gap: 10px; margin-bottom: 28px; }
.ms-ppl-strip-add-btn { width: 52px; height: 52px; border-radius: 12px; border: 1.5px dashed var(--border-color); background: var(--card-background); font-size: 20px; color: var(--text-secondary); display: grid; place-items: center; cursor: pointer; outline: none; flex-shrink: 0; transition: border-color .15s, color .15s; }
.ms-ppl-strip-add-btn:hover { border-color: var(--text-primary); color: var(--text-primary); }
.ms-ppl-face-strip { display: flex; gap: 10px; overflow-x: auto; padding-bottom: 4px; scrollbar-width: thin; flex: 1; }
.ms-ppl-face-strip { display: flex; gap: 10px; overflow-x: auto; padding-bottom: 8px; scrollbar-width: thin; scrollbar-color: #b8bec6 transparent; flex: 1; min-width: 0; }
.ms-ppl-face-strip::-webkit-scrollbar { height: 8px; }
.ms-ppl-face-strip::-webkit-scrollbar-track { background: transparent; }
.ms-ppl-face-strip::-webkit-scrollbar-thumb { background: #b8bec6; border-radius: 999px; }
.ms-ppl-face-strip::-webkit-scrollbar-thumb:hover { background: #8a919b; }
.ms-ppl-strip-face { position: relative; flex-shrink: 0; cursor: pointer; }
.ms-ppl-strip-face-clickable.selected { outline: 2px solid var(--primary-color); outline-offset: 2px; border-radius: 14px; }
.ms-ppl-strip-face-img { width: 52px; height: 52px; border-radius: 12px; border: 2px solid transparent; background: var(--border-light); overflow: hidden; transition: border-color .15s; }
@@ -925,4 +929,12 @@ let mergeSearchTimer: any
/* Remove button for face strip */
.ms-ppl-strip-remove-btn { display: none; position: absolute; top: -6px; right: -6px; width: 18px; height: 18px; border-radius: 50%; background: #fff; border: 1.5px solid #d1d5db; font-size: 11px; color: #5f6368; line-height: 1; cursor: pointer; outline: none; place-items: center; }
.ms-ppl-edit-mode .ms-ppl-strip-remove-btn { display: grid; }
@media (max-width: 767px) {
.people-view { padding-top: 8px; }
.ms-ppl-strip-wrap { gap: 6px; margin-bottom: 16px; }
.ms-ppl-strip-face-img { width: 40px; height: 40px; }
.ms-ppl-detail-name { font-size: 16px; }
.ms-ppl-detail-info { flex-direction: column; align-items: flex-start; }
}
</style>

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>