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:
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user