Files
momentry_studio/src/views/SearchView.vue
Momentry Studio fc32fb0f86 fix: full file playback and file name display
- Use endFrame=999999 for full file playback (backend clips correctly)
- Add z-index:10 to card-file-info to prevent being obscured
- Full file now plays complete duration
2026-10-20 22:26:38 +08:00

2563 lines
90 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div id="ms-view-search">
<div id="msP">
<div class="topbar">
<div class="tbacts">
<button v-if="markedResults.length" class="icobtn export-btn" title="Export Marked ({{ markedResults.length }})" @click="exportMarked">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<span class="export-count">{{ markedResults.length }}</span>
</button>
<button class="icobtn" title="Bookmark" @click="toggleBookmark">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/></svg>
</button>
<button class="icobtn" title="Search History" @click="toggleHistory">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</button>
</div>
<div id="msBK" :class="{ show: showBookmark }">
<div class="bkh">
<span class="bkht">Bookmarks</span>
<button class="bkx" @click="showBookmark = false">x</button>
</div>
<div id="msBKL">
<div v-if="!bookmarkList.length" style="color:var(--muted-text);font-size:12px;padding:8px 0">No bookmarks yet</div>
<div v-for="b in bookmarkList" :key="b.id" class="bk-item">
<span class="bk-label" @click="query = b.label; showBookmark = false; sendMessage()">{{ b.label }}</span>
<button class="bk-del" @click.stop="removeBookmark(b.id)">x</button>
</div>
</div>
</div>
<div id="msHS" :class="{ show: showHistory }">
<div class="bkh">
<span class="bkht">History</span>
<div class="bkh-actions">
<button class="bkh-btn" title="New Chat" @click="startNewChat">+ New</button>
<button class="bkx" @click="showHistory = false">x</button>
</div>
</div>
<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-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>
<button v-else @click="unpinHistoryItem(h.id)">Unpin</button>
<button @click="startRenameHistory(h)">Rename</button>
<button class="hs-menu-del" @click="deleteHistoryItem(h.id)">Delete</button>
</div>
</div>
</div>
</div>
<div v-if="renamingHistory" class="rename-overlay" @click.self="cancelRename">
<div class="rename-dialog">
<div class="rename-label">Rename conversation</div>
<input class="rename-input" v-model="renameValue" @keyup.enter="confirmRename" ref="renameInputEl" />
<div class="rename-actions">
<button class="rename-cancel" @click="cancelRename">Cancel</button>
<button class="rename-confirm" @click="confirmRename">Save</button>
</div>
</div>
</div>
</div>
<div class="chat" id="msChat" ref="messagesEl" :class="{ 'chat--empty': !messages.length }" @mouseup="onChatMouseUp">
<template v-if="!messages.length">
<header class="ms-hero">
<h1 class="ms-title">{{ $t('app.productName') }}</h1>
<p class="ms-subtitle">{{ $t('app.tagline') }}</p>
</header>
<div class="ibar ibar--hero">
<div class="iwrap">
<textarea class="ifield" v-model="query" @keydown.enter.meta="sendMessage" @keydown.enter.ctrl="sendMessage"
ref="queryInputEl"
placeholder="Search photo or video keywords..."
:disabled="sending" autocomplete="off" rows="1"
@input="autoResizeTextarea"></textarea>
<div class="ms-chat-mode-wrap" id="msChatModeWrap">
<button class="ms-chat-mode-btn" id="msChatModeBtn" type="button" @click="toggleModeMenu" :aria-expanded="showModeMenu">
<span id="msChatModeLabel">{{ currentModeLabel }}</span>
<span class="ms-chat-mode-arrow">&#8964;</span>
</button>
<div class="ms-chat-mode-menu" id="msChatModeMenu" :class="{ 'is-open': showModeMenu }">
<div v-for="m in modes" :key="m.value" class="ms-chat-mode-item" :class="{ 'is-active': mode === m.value }" @click="selectMode(m.value)">
<span class="ms-chat-mode-item-icon" v-html="m.icon"></span>
<span>{{ m.label }}</span>
</div>
</div>
</div>
<button class="sendbtn" title="Send (Cmd+Enter)" @click="sendMessage" :disabled="sending || !query.trim()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
</div>
</div>
</template>
<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>
<div v-if="msg.loading" class="msg status">
<div class="spinner-mini"></div>
<span>Searching...</span>
</div>
<div v-else-if="msg.answer" class="msg agent-answer">
<p>{{ msg.answer }}</p>
</div>
<div v-if="msg.results.length" class="card-grid">
<div v-for="(r, j) in msg.results" :key="j" class="result-card" :class="{ 'ocr-grouped': r.ocr_frames && r.ocr_frames.length > 1 }">
<!-- File info bar at top-left -->
<div class="card-file-info" @click.stop="playFullFile(r)">
<svg v-if="isVideoFile(r)" class="card-file-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
<svg v-else class="card-file-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
<span class="card-file-name">{{ r.file_name || 'Unknown' }}</span>
<span class="card-file-uuid-badge">{{ r.file_uuid }}</span>
</div>
<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="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._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-meta">
<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">
<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>
<!-- More Button -->
<div v-if="i === messages.length - 1 && hasMore && !msg.loading" class="more-btn-container">
<button class="more-btn" @click="loadMore" :disabled="loadingMore">
<div v-if="loadingMore" class="spinner-mini"></div>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
<span>{{ loadingMore ? 'Loading...' : `Load More (${totalResults - nextOffset} remaining)` }}</span>
</button>
</div>
<div v-if="msg.sources?.length" class="msg agent-sources">
<div class="src-title">References</div>
<div class="src-list">
<div v-for="(src, k) in msg.sources" :key="k" class="src-item" @click="openSource(src)">
<span class="src-tool">{{ src.tool }}</span>
<template v-if="src.parsed?.identity">
{{ src.parsed.identity.name || 'Unknown' }}
</template>
<template v-else>
{{ (src.parsed?.text_content || JSON.stringify(src.parsed || src.result)).slice(0, 60) }}
</template>
</div>
</div>
</div>
<div v-if="msg.chips?.length" class="chip-row">
<button v-for="(c, ci) in msg.chips" :key="ci" class="chip-btn" @click="query = c; sendMessage()">{{ c }}</button>
</div>
<p v-if="!msg.loading && !msg.results.length && !msg.answer" class="msg error">{{ msg.message || 'No results found' }}</p>
</div>
</div>
</template>
</div>
<div v-if="messages.length" class="ibar">
<div class="iwrap">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<textarea class="ifield" v-model="query" @keydown.enter.meta="sendMessage" @keydown.enter.ctrl="sendMessage"
ref="queryInputEl"
placeholder="Search..."
:disabled="sending" id="msInp" autocomplete="off" rows="1"
@input="autoResizeTextarea"></textarea>
<div class="ms-chat-mode-wrap" id="msChatModeWrap">
<button class="ms-chat-mode-btn" id="msChatModeBtn" type="button" @click="toggleModeMenu" :aria-expanded="showModeMenu">
<span id="msChatModeLabel">{{ currentModeLabel }}</span>
<span class="ms-chat-mode-arrow">&#8964;</span>
</button>
<div class="ms-chat-mode-menu" id="msChatModeMenu" :class="{ 'is-open': showModeMenu }">
<div v-for="m in modes" :key="m.value" class="ms-chat-mode-item" :class="{ 'is-active': mode === m.value }" @click="selectMode(m.value)">
<span class="ms-chat-mode-item-icon" v-html="m.icon"></span>
<span>{{ m.label }}</span>
</div>
</div>
</div>
<button class="sendbtn" title="Send (Cmd+Enter)" @click="sendMessage" :disabled="sending || !query.trim()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
</div>
</div>
</div>
<VideoPlayer
v-if="playing"
: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="clusterTraces.length > 0"
:show-pose="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>
</div>
<div v-if="textDetailModal.show" class="text-detail-overlay" @click.self="textDetailModal.show = false">
<div class="text-detail-modal">
<div class="text-detail-header">
<span v-if="textDetailModal.tag" class="text-detail-tag">{{ textDetailModal.tag }}</span>
<span class="text-detail-file">{{ textDetailModal.fileName }}</span>
<button class="text-detail-close" @click="textDetailModal.show = false">×</button>
</div>
<div class="text-detail-file-info">
<span class="text-detail-file-name">{{ textDetailModal.file_name }}</span>
<span class="text-detail-file-uuid">{{ textDetailModal.file_uuid }}</span>
</div>
<div class="text-detail-body">
<div class="text-detail-meta">
<span>F{{ textDetailModal.startFrame }}–{{ textDetailModal.endFrame }}</span>
<span>({{ formatTime(textDetailModal.startTime) }}–{{ formatTime(textDetailModal.endTime) }})</span>
</div>
<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)">
<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>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick, computed, onMounted, onUnmounted } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
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, filesCache, ensureFiles } from '@/store'
import type { HistoryItem } from '@/composables/useSearchHistory'
interface SourceInfo {
tool: string
result: string
parsed?: any
}
interface ChatMessage {
role: 'user' | 'assistant'
query?: string
results: any[]
loading: boolean
message: string
answer?: string
sources?: SourceInfo[]
chips?: string[]
mode?: string
}
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>' },
]
const mode = ref(localStorage.getItem('ms_chat_mode_v1') || 'keyword')
const query = ref('')
const messages = ref<ChatMessage[]>([])
const SESSION_STORAGE_KEY = 'ms_search_session'
const thumbs = thumbnailsCache
const sending = ref(false)
const messagesEl = ref<HTMLElement | null>(null)
const queryInputEl = ref<HTMLInputElement | null>(null)
const conversationId = ref('')
const fileUuidCache = ref<string[]>([])
const showModeMenu = ref(false)
const showBookmark = ref(false)
const showHistory = ref(false)
const isListening = ref(false)
const activeHistoryMenu = ref<string | null>(null)
const renamingHistory = ref<HistoryItem | null>(null)
const renameValue = ref('')
const renameInputEl = ref<HTMLInputElement | null>(null)
const { history: historyList, loadHistory, saveToHistory, renameHistory, pinHistory, deleteHistory, restoreFromHistory } = useSearchHistory()
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' })
// Pagination states
const sessionId = ref('')
const hasMore = ref(false)
const nextOffset = ref(0)
const totalResults = ref(0)
const loadingMore = ref(false)
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|SCENE)\]\s*(.*)$/i)
if (match) {
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 }
}
function getSourceType(r: any): string {
if (r.source_type) return r.source_type.toUpperCase()
return ''
}
function highlightMatch(text: string, searchQuery: string): string {
if (!searchQuery || !text) return text
// Escape HTML entities first
const escaped = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
const queryEscaped = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const regex = new RegExp(`(${queryEscaped})`, 'gi')
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: actualFileName,
startFrame: r.start_frame || 0,
endFrame: r.end_frame || 0,
startTime: r.start_time || 0,
endTime: r.end_time || 0,
similarity: r.similarity || 0,
summary: r.summary,
file_uuid: r.file_uuid,
start_frame: r.start_frame,
end_frame: r.end_frame,
start_time: r.start_time,
end_time: r.end_time,
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) => {
const fa = a.start_frame || 0
const fb = b.start_frame || 0
return fa - fb
})
const merged: any[] = []
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()
const parsed = parseSummary(rawText)
const isOcr = parsed.tag === '[OCR]' || (r.source_type || '').toLowerCase() === 'ocr'
if (isOcr) {
const ocrText = parsed.tag === '[OCR]' ? parsed.text : rawText
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 {
ocrGroups.push({ canonicalText: ocrText, frames: [r.start_frame || 0], results: [r], variants: [] })
}
continue
}
const last = merged[merged.length - 1]
if (!last) {
merged.push({ ...r })
continue
}
if (last.file_uuid !== r.file_uuid) {
merged.push({ ...r })
continue
}
const lastEnd = last.end_frame || last.start_frame + 1
const rStart = r.start_frame || 0
const gap = rStart - lastEnd
if (gap <= 100) {
last.end_frame = Math.max(lastEnd, r.end_frame || rStart + 1)
if (r.similarity > (last.similarity || 0)) {
last.summary = r.summary
last.similarity = r.similarity
}
} else {
merged.push({ ...r })
}
}
for (const group of ocrGroups) {
const uniqueFrames = [...new Set(group.frames)].sort((a, b) => a - b)
merged.push({
...group.results[0],
summary: `[OCR] ${group.canonicalText}`,
ocr_frames: uniqueFrames,
ocr_details: group.results,
variants: group.variants,
start_frame: uniqueFrames[0],
end_frame: uniqueFrames[uniqueFrames.length - 1],
})
}
const finalResults = merged.sort((a, b) => (a.start_frame || 0) - (b.start_frame || 0))
for (const r of finalResults) {
ensureMinDuration(r)
}
return finalResults
}
function ensureMinDuration(r: any) {
if (r.ocr_frames) return
const startFrame = r.start_frame || 0
const endFrame = r.end_frame || startFrame + 1
const frameCount = endFrame - startFrame
if (frameCount >= 240) return
const needed = 240 - frameCount
const expandBefore = Math.floor(needed / 2)
const expandAfter = needed - expandBefore
r.start_frame = Math.max(0, startFrame - expandBefore)
r.end_frame = endFrame + expandAfter
}
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')
function selectMode(v: string) {
mode.value = v
showModeMenu.value = false
localStorage.setItem('ms_chat_mode_v1', v)
}
function toggleModeMenu() {
showModeMenu.value = !showModeMenu.value
}
function toggleBookmark() {
showBookmark.value = !showBookmark.value
showHistory.value = false
if (showBookmark.value) loadBookmarks()
}
function toggleHistory() {
showHistory.value = !showHistory.value
showBookmark.value = false
if (showHistory.value) loadHistory()
}
function toggleVoice() {
isListening.value = !isListening.value
}
function closeDropdowns(e: MouseEvent) {
const target = e.target as HTMLElement
if (!target.closest('.ms-chat-mode-wrap')) showModeMenu.value = false
if (!target.closest('.topbar')) {
showBookmark.value = false
showHistory.value = false
}
if (!target.closest('.hs-menu-btn') && !target.closest('.hs-menu')) {
activeHistoryMenu.value = null
}
if (!target.closest('.sel-popup-btn')) {
selectionPopup.value.show = false
}
}
onMounted(async () => {
document.addEventListener('click', closeDropdowns)
await ensureFiles()
loadHistory()
loadBookmarks()
// Restore session from localStorage
try {
const saved = localStorage.getItem(SESSION_STORAGE_KEY)
if (saved) {
const session = JSON.parse(saved)
if (session.messages && Array.isArray(session.messages)) {
messages.value = session.messages
conversationId.value = session.conversationId || ''
fileUuidCache.value = session.fileUuidCache || []
mode.value = session.mode || mode.value
localStorage.setItem('ms_chat_mode_v1', mode.value)
}
}
} catch (e) {
console.error('[SearchView] Failed to restore session:', e)
}
// 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)
}
})
onBeforeRouteLeave(() => {
// Save current session to localStorage
try {
if (messages.value.length > 0) {
const session = {
messages: messages.value,
conversationId: conversationId.value,
fileUuidCache: fileUuidCache.value,
mode: mode.value,
savedAt: Date.now()
}
localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session))
}
} catch (e) {
console.error('[SearchView] Failed to save session:', e)
}
})
onUnmounted(() => {
document.removeEventListener('click', closeDropdowns)
// Save session on unmount as well
try {
if (messages.value.length > 0) {
const session = {
messages: messages.value,
conversationId: conversationId.value,
fileUuidCache: fileUuidCache.value,
mode: mode.value,
savedAt: Date.now()
}
localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session))
}
} catch (e) {
console.error('[SearchView] Failed to save session:', e)
}
})
function scrollToBottom() {
nextTick(() => {
const el = messagesEl.value
if (el) el.scrollTop = el.scrollHeight
})
}
let searchSeq = 0
function newChat() {
searchSeq++
messages.value = []
conversationId.value = ''
fileUuidCache.value = []
sending.value = false
// Clear saved session
localStorage.removeItem(SESSION_STORAGE_KEY)
}
function startNewChat() {
newChat()
showHistory.value = false
}
function toggleHistoryMenu(id: string) {
activeHistoryMenu.value = activeHistoryMenu.value === id ? null : id
}
async function pinHistoryItem(id: string) {
await pinHistory(id, true)
activeHistoryMenu.value = null
}
async function unpinHistoryItem(id: string) {
await pinHistory(id, false)
activeHistoryMenu.value = null
}
async function deleteHistoryItem(id: string) {
await deleteHistory(id)
activeHistoryMenu.value = null
}
function startRenameHistory(h: HistoryItem) {
renamingHistory.value = h
renameValue.value = h.title || h.query
activeHistoryMenu.value = null
nextTick(() => renameInputEl.value?.focus())
}
async function confirmRename() {
if (!renamingHistory.value) return
await renameHistory(renamingHistory.value.id, renameValue.value)
renamingHistory.value = null
renameValue.value = ''
}
function cancelRename() {
renamingHistory.value = null
renameValue.value = ''
}
function restoreHistory(h: HistoryItem) {
const restored = restoreFromHistory(h)
if (h.mode && modes.some(m => m.value === h.mode)) {
mode.value = h.mode!
localStorage.setItem('ms_chat_mode_v1', h.mode!)
}
if (restored) {
messages.value = restored
query.value = ''
showHistory.value = false
nextTick(() => scrollToBottom())
} else {
query.value = h.query
showHistory.value = false
sendMessage()
}
}
const removeBookmark = (id: number) => {
deleteBookmark(id)
}
const selectionPopup = ref({ show: false, x: 0, y: 0, text: '' })
function onChatMouseUp(e: MouseEvent) {
const sel = window.getSelection()
const text = sel?.toString().trim() || ''
if (text.length > 0 && e.target instanceof HTMLElement && e.target.closest('#msChat')) {
const rect = sel!.getRangeAt(0).getBoundingClientRect()
const containerRect = (e.currentTarget as HTMLElement).getBoundingClientRect()
selectionPopup.value = {
show: true,
x: rect.left - containerRect.left + rect.width / 2 - 50,
y: rect.top - containerRect.top - 36,
text,
}
} else {
selectionPopup.value.show = false
}
}
function addSelectionBookmark() {
if (selectionPopup.value.text) {
saveBookmark(selectionPopup.value.text)
selectionPopup.value.show = false
window.getSelection()?.removeAllRanges()
}
}
function parseSourceResult(result: string): any {
try {
return JSON.parse(result)
} catch {
return result
}
}
async function sendMessage() {
const q = query.value.trim()
if (!q) return
query.value = ''
const seq = searchSeq
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
scrollToBottom()
try {
if (mode.value === 'agent') {
const res: any = await apiCall('search_agents', {
query: q,
conversationId: conversationId.value || null,
})
if (searchSeq !== seq) return
if (res?.conversation_id) conversationId.value = res.conversation_id
const sources: SourceInfo[] = (res?.sources || []).map((s: any) => ({
tool: s.tool || 'unknown',
result: typeof s.result === 'string' ? s.result : JSON.stringify(s.result),
parsed: typeof s.result === 'string' ? parseSourceResult(s.result) : s.result,
}))
for (const src of sources) {
if (src.tool === 'find_file' && src.parsed?.files) {
for (const f of src.parsed.files) {
if (f.file_uuid && !fileUuidCache.value.includes(f.file_uuid)) {
fileUuidCache.value.push(f.file_uuid)
}
}
}
}
const fu = fileUuidCache.value[0] || ''
const results: any[] = []
for (const src of sources) {
if (src.tool === 'tkg_query' && Array.isArray(src.parsed?.traces)) {
for (const tr of src.parsed.traces) {
if (Array.isArray(tr) && tr.length >= 2 && fu) {
results.push({
file_uuid: fu,
file_name: '',
summary: `Scene at frame ${tr[0]}`,
similarity: null,
start_frame: tr[0],
end_frame: tr[0] + (tr[1] || 0) - 1,
})
}
}
}
if (Array.isArray(src.parsed?.results)) {
for (const r of src.parsed.results) {
if (typeof r === 'object' && !Array.isArray(r) && r !== null) {
const rf = r.file_uuid || fu
if (!rf) continue
results.push({
file_uuid: rf,
file_name: r.file_name || '',
summary: r.text || r.summary || '',
similarity: r.score ?? r.similarity ?? null,
start_frame: r.start_frame ?? 0,
end_frame: r.end_frame ?? 0,
})
}
}
}
// 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')
const smartResults = smartSrc && Array.isArray(smartSrc.parsed?.results) ? smartSrc.parsed.results : []
if (smartResults.length && !fileUuidCache.value.length) {
try {
const fallback: any = await apiCall('search_llm_smart', { query: q, limit: 20 })
if (searchSeq !== seq) return
if (Array.isArray(fallback) && fallback.length) {
const fu = fallback[0].file_uuid || ''
if (fu && !fileUuidCache.value.includes(fu)) fileUuidCache.value.push(fu)
for (const r of fallback) {
results.push({
file_uuid: r.file_uuid || fu,
file_name: r.file_name || '',
summary: r.summary || '',
similarity: r.similarity ?? null,
start_frame: r.start_frame ?? 0,
end_frame: r.end_frame ?? 0,
})
}
}
} catch {}
}
const fuid = fileUuidCache.value[0] || ''
if (fuid) {
for (const r of smartResults) {
if (Array.isArray(r) && r.length >= 4) {
const summary = (r[1] || '').slice(0, 120)
const startFrame = Number(r[2]) || 0
const endFrame = Number(r[3]) || startFrame + 10
const cut = results.find((x: any) => x.file_uuid === fuid && startFrame >= x.start_frame && startFrame <= x.end_frame)
if (cut) {
cut.summary = cut.summary || summary
} else {
results.push({
file_uuid: fuid,
file_name: '',
summary: summary,
similarity: null,
start_frame: startFrame,
end_frame: endFrame,
})
}
}
}
}
messages.value[msgIdx].answer = res?.answer || 'No answer'
messages.value[msgIdx].sources = sources
for (const r of results) r.search_query = q
messages.value[msgIdx].results = results
messages.value[msgIdx].loading = false
if (res?.chips) messages.value[msgIdx].chips = res.chips
} else if (mode.value === 'people') {
const raw: any = await apiCall('search_people', { query: q, limit: 50 })
if (searchSeq !== seq) return
const peopleList = raw?.people || []
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
// Set pagination state
hasMore.value = results.length >= 5
nextOffset.value = 5
totalResults.value = results.length
} else if (mode.value === 'semantic') {
const [semanticRes, keywordRes] = await Promise.all([
apiCall('search_semantic', { query: q, limit: 5 }),
apiCall('search_keyword', { query: q, limit: 5 })
])
if (searchSeq !== seq) return
const semanticResults = (semanticRes?.results || semanticRes || [])
.filter((r: any) => (r.similarity ?? 0) >= 0.6) as any[]
const keywordResults = (keywordRes?.results || keywordRes || []) as any[]
const seen = new Set<string>()
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)) {
seen.add(key)
merged.push({ ...r, search_query: q, source_type: r.source_type || 'asrx' })
}
}
for (const r of keywordMerged) {
const key = `${r.file_uuid}:${r.start_frame || 0}`
if (!seen.has(key)) {
seen.add(key)
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, 5)
messages.value[msgIdx].loading = false
// Set pagination state
hasMore.value = merged.length >= 5
nextOffset.value = 5
totalResults.value = merged.length
} else if (mode.value === 'visual') {
// Visual search: Scene VLM + Trace VLM
// min_score is handled by API (uses admin config default)
// 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 fileInfo = filesCache.value.find((f: any) => (f.file_uuid || f.uuid) === s.file_uuid)
const fps = fileInfo?.fps || 30
const ts = s.timestamp_sec ?? (s.frame_number != null ? s.frame_number / fps : 0)
const frameNumber = s.frame_number ?? Math.round(ts * fps)
const windowSec = 5
// 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: Math.max(0, frameNumber - Math.round(windowSec * fps)),
end_frame: frameNumber + Math.round(windowSec * fps),
start_time: Math.max(0, ts - windowSec),
end_time: ts + windowSec,
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 fileInfo = filesCache.value.find((f: any) => (f.file_uuid || f.uuid) === t.file_uuid)
const fps = fileInfo?.fps || 30
const frame = t.start_frame ?? t.best_face_frame ?? t.frame_number ?? Math.round((t.timestamp_sec || 0) * fps)
const ts = t.start_time ?? t.timestamp_sec ?? (frame / fps)
const windowSec = 5
// 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: Math.max(0, frame - Math.round(windowSec * fps)),
end_frame: frame + Math.round(windowSec * fps),
start_time: Math.max(0, ts - windowSec),
end_time: ts + windowSec,
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, 5)
messages.value[msgIdx].loading = false
// Set pagination state
hasMore.value = allResults.length >= 5
nextOffset.value = 5
totalResults.value = allResults.length
} else {
const results = await apiCall('search_keyword', { query: q, limit: 5 })
if (searchSeq !== seq) return
const rawResults = (results?.results || results || []) as any[]
// 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, 5)
messages.value[msgIdx].loading = false
// Set pagination state
hasMore.value = deduped.length >= 5
nextOffset.value = 5
totalResults.value = deduped.length
}
} catch (e: any) {
if (searchSeq !== seq) return
messages.value[msgIdx].message = e?.message || 'Search failed'
messages.value[msgIdx].loading = false
} finally {
sending.value = false
if (searchSeq === seq) {
scrollToBottom()
try {
await saveToHistory(q, JSON.stringify(messages.value), mode.value)
} catch {}
nextTick(() => {
queryInputEl.value?.focus()
})
}
}
}
async function loadMore() {
if (!hasMore.value || loadingMore.value || messages.value.length < 2) return
const lastMsg = messages.value[messages.value.length - 1]
if (lastMsg.role !== 'assistant' || !lastMsg.results.length) return
loadingMore.value = true
try {
const lastUserMsg = messages.value[messages.value.length - 2]
if (!lastUserMsg || lastUserMsg.role !== 'user') return
const q = lastUserMsg.query || ''
const currentMode = lastUserMsg.mode || mode.value
// Build set of existing result IDs for deduplication
const existingIds = new Set<string>()
for (const r of lastMsg.results) {
const id = `${r.file_uuid}:${r.start_frame || 0}:${r.source_type || 'unknown'}`
existingIds.add(id)
}
let newResults: any[] = []
if (currentMode === 'visual') {
await ensureFiles()
const base = getApiBase()
const maxResults = 10
const outputLang = searchConfig.value.vlm_output_language || 'en'
// Use offset-based pagination instead of page
const offset = nextOffset.value
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, offset: offset, 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, offset: offset, page_size: maxResults, min_score: 0.0, output_language: outputLang })
}).then(r => r.json()).catch(() => ({}))
])
const sceneResults = (scenesRes.scenes || []).map((s: any) => ({
file_uuid: s.file_uuid,
file_name: s.file_name || '',
summary: `[SCENE] ${s.vlm_description || ''}`,
similarity: s.score ?? null,
start_frame: s.frame_number ?? Math.round((s.timestamp_sec || 0) * 30),
end_frame: (s.frame_number ?? Math.round((s.timestamp_sec || 0) * 30)) + 30,
start_time: s.timestamp_sec,
end_time: (s.timestamp_sec || 0) + 1,
source_type: 'scene',
thumbnail_url: s.thumbnail_url,
vlm_tags: s.vlm_tags || [],
search_query: q
}))
const traceResults = (tracesRes.traces || []).map((t: any) => ({
file_uuid: t.file_uuid,
file_name: t.file_name || '',
summary: `[TRACE] ${t.vlm_description || ''}`,
similarity: t.score ?? null,
start_frame: t.start_frame,
end_frame: t.end_frame,
source_type: 'trace',
trace_id: t.trace_id,
vlm_description: t.vlm_description,
search_query: q
}))
newResults = [...sceneResults, ...traceResults].slice(0, maxResults)
} else if (currentMode === 'people') {
const raw: any = await apiCall('search_people', { query: q, limit: 10, offset: nextOffset.value })
const peopleList = raw?.people || []
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() || ''
}
newResults = Array.from(personMap.entries()).map(([key, p]) => {
const firstTrace = p.traces[0]
return {
file_uuid: firstTrace.file_uuid || '',
file_name: fileUuidToName[firstTrace.file_uuid || ''] || firstTrace.file_name || '',
summary: firstTrace.name || 'Unknown',
text_content: `${p.traces.length} appearances`,
similarity: null,
start_frame: p.minFrame,
end_frame: p.maxFrame,
_isPerson: true,
_traces: p.traces,
search_query: q
}
}).slice(0, 10)
} else if (currentMode === 'keyword') {
const results = await apiCall('search_keyword', { query: q, limit: 15, offset: nextOffset.value })
const rawResults = (results?.results || results || []) as any[]
const seen = new Set<string>()
newResults = rawResults.filter(r => {
const key = `${r.file_uuid}:${r.start_frame || 0}`
const globalKey = `${r.file_uuid}:${r.start_frame || 0}:${r.source_type || 'unknown'}`
if (seen.has(key) || existingIds.has(globalKey)) return false
seen.add(key)
return true
}).map(r => ({ ...r, search_query: q })).slice(0, 10)
} else if (currentMode === 'semantic') {
const results = await apiCall('search_semantic', { query: q, limit: 15, offset: nextOffset.value })
const rawResults = (results?.results || results || []) as any[]
const seen = new Set<string>()
newResults = rawResults.filter(r => {
const key = `${r.file_uuid}:${r.start_frame || 0}`
const globalKey = `${r.file_uuid}:${r.start_frame || 0}:${r.source_type || 'unknown'}`
if (seen.has(key) || existingIds.has(globalKey)) return false
seen.add(key)
return true
}).map(r => ({ ...r, search_query: q })).slice(0, 10)
} else if (currentMode === 'agent') {
// Agent mode: fallback to semantic search for more results
const results = await apiCall('search_semantic', { query: q, limit: 15, offset: nextOffset.value })
const rawResults = (results?.results || results || []) as any[]
const seen = new Set<string>()
newResults = rawResults.filter(r => {
const key = `${r.file_uuid}:${r.start_frame || 0}`
const globalKey = `${r.file_uuid}:${r.start_frame || 0}:${r.source_type || 'unknown'}`
if (seen.has(key) || existingIds.has(globalKey)) return false
seen.add(key)
return true
}).map(r => ({ ...r, search_query: q })).slice(0, 10)
} else {
hasMore.value = false
return
}
if (newResults.length > 0) {
lastMsg.results.push(...newResults)
nextOffset.value += newResults.length
hasMore.value = newResults.length >= 10
} else {
hasMore.value = false
}
} catch (e: any) {
console.error('Load more failed:', e)
} finally {
loadingMore.value = false
}
}
function loadThumb(uuid: string, frame?: number) {
loadThumbnail(uuid, frame || 0)
}
function autoResizeTextarea(e: Event) {
const target = e.target as HTMLTextAreaElement
target.style.height = 'auto'
target.style.height = Math.min(target.scrollHeight, 120) + 'px'
}
function formatTime(sec: number): string {
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
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 = {
fileUuid: r.file_uuid,
startFrame: sf,
endFrame: ef,
title: r.summary?.slice(0, 60) || r.file_name || 'Video'
}
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 playFullFile(r: any) {
currentVideo.value = {
fileUuid: r.file_uuid,
startFrame: 0,
endFrame: 999999, // Very large value = full file
title: r.file_name || 'Full Video'
}
playing.value = true
}
function isVideoFile(r: any): boolean {
if (!r.file_name) return false
const ext = r.file_name.split('.').pop()?.toLowerCase()
return ['mp4', 'mov', 'mkv', 'avi', 'webm'].includes(ext || '')
}
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
r.start_frame = Math.max(0, sf - 150)
r.end_frame = ef + 150
}
function isMarked(r: any): boolean {
const key = `${r.file_uuid}:${r.start_frame}-${r.end_frame}`
return markedResults.value.some(m => `${m.file_uuid}:${m.start_frame}-${m.end_frame}` === key)
}
function toggleMark(r: any) {
const key = `${r.file_uuid}:${r.start_frame}-${r.end_frame}`
const idx = markedResults.value.findIndex(m => `${m.file_uuid}:${m.start_frame}-${m.end_frame}` === key)
if (idx >= 0) {
markedResults.value.splice(idx, 1)
} else {
markedResults.value.push({
file_uuid: r.file_uuid,
file_name: r.file_name,
start_frame: r.start_frame,
end_frame: r.end_frame,
start_time: r.start_time,
end_time: r.end_time,
summary: r.summary,
similarity: r.similarity
})
}
}
function exportMarked() {
const data = {
exportedAt: new Date().toISOString(),
count: markedResults.value.length,
results: markedResults.value
}
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `search-export-${Date.now()}.json`
a.click()
URL.revokeObjectURL(url)
}
function openSource(src: SourceInfo) {
const parsed = src.parsed
if (parsed?.identity?.uuid) {
router.push(`/people/${parsed.identity.uuid}`)
}
}
</script>
<style scoped>
#ms-view-search {
max-width: 800px;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: var(--text-primary);
}
#msP {
display: flex;
flex-direction: column;
height: calc(100vh - 80px);
position: relative;
}
.topbar {
display: flex;
align-items: center;
padding: 12px 0 8px;
gap: 8px;
position: relative;
border-bottom: 1px solid var(--border-color);
}
.tbacts {
display: flex;
gap: 6px;
margin-left: auto;
}
.icobtn {
width: 34px;
height: 34px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--card-background);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
}
.icobtn:hover { background: var(--hover-background); }
#msBK, #msHS {
display: none;
position: absolute;
top: 50px;
right: 0;
width: 300px;
background: var(--card-background);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: var(--shadow);
z-index: 100;
padding: 12px;
}
#msBK.show, #msHS.show { display: block; }
.bkh {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.bkht { font-size: 13px; font-weight: 600; color: var(--text-primary); }
.bkx {
width: 24px; height: 24px;
border: none; background: transparent;
cursor: pointer; color: var(--muted-text); font-size: 16px;
display: flex; align-items: center; justify-content: center;
}
.bk-item {
padding: 6px 8px;
font-size: 13px;
color: var(--text-primary);
cursor: pointer;
border-radius: 6px;
}
.bk-item:hover { background: var(--hover-background); }
.bk-item {
display: flex;
align-items: center;
justify-content: space-between;
}
.bk-label {
flex: 1;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bk-del {
visibility: hidden;
width: 20px; height: 20px;
border: none; background: transparent;
cursor: pointer; color: var(--muted-text); font-size: 13px;
display: flex; align-items: center; justify-content: center;
border-radius: 4px;
flex-shrink: 0;
}
.bk-item:hover .bk-del { visibility: visible; }
.bk-del:hover { color: var(--danger-color); background: var(--danger-background); }
.bkh-actions {
display: flex;
align-items: center;
gap: 4px;
}
.bkh-btn {
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--card-background);
cursor: pointer;
font-size: 11px;
color: var(--text-secondary);
padding: 2px 8px;
}
.bkh-btn:hover { background: var(--hover-background); }
.hs-item {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
border-radius: 6px;
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); }
.hs-title {
flex: 1;
font-size: 13px;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hs-menu-btn {
visibility: hidden;
width: 22px; height: 22px;
border: none; background: transparent;
cursor: pointer; color: var(--muted-text); font-size: 16px;
display: flex; align-items: center; justify-content: center;
border-radius: 4px;
flex-shrink: 0;
}
.hs-item:hover .hs-menu-btn { visibility: visible; }
.hs-menu-btn:hover { background: var(--border-light); }
.hs-menu {
position: absolute;
right: 0;
top: 100%;
background: var(--card-background);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: var(--shadow);
z-index: 120;
min-width: 120px;
padding: 4px;
}
.hs-menu button {
display: block;
width: 100%;
text-align: left;
padding: 6px 10px;
border: none;
background: transparent;
cursor: pointer;
font-size: 12px;
color: var(--text-primary);
border-radius: 4px;
}
.hs-menu button:hover { background: var(--hover-background); }
.hs-menu-del { color: var(--danger-color) !important; }
.hs-menu-del:hover { background: var(--danger-background) !important; }
.rename-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,.3);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
}
.rename-dialog {
background: var(--card-background);
border-radius: 12px;
padding: 20px;
width: 320px;
box-shadow: var(--shadow);
}
.rename-label {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 12px;
}
.rename-input {
width: 100%;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 8px 12px;
font-size: 14px;
outline: none;
box-sizing: border-box;
background: var(--card-background);
color: var(--text-primary);
}
.rename-input:focus { border-color: var(--primary-color); }
.rename-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 14px;
}
.rename-cancel {
padding: 6px 14px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--card-background);
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
}
.rename-cancel:hover { background: var(--hover-background); }
.rename-confirm {
padding: 6px 14px;
border: none;
border-radius: 8px;
background: var(--primary-color);
cursor: pointer;
font-size: 13px;
color: #fff;
}
.rename-confirm:hover { background: var(--primary-color); opacity: 0.9; }
.sel-popup {
position: absolute;
z-index: 150;
}
.sel-popup-btn {
background: var(--primary-color);
color: #fff;
border: none;
border-radius: 6px;
padding: 5px 12px;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
box-shadow: var(--shadow);
}
.sel-popup-btn:hover { background: var(--primary-color); opacity: 0.9; }
.chat {
flex: 1;
overflow-y: auto;
padding: 12px 0;
}
.chat--empty {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 0;
}
.ms-hero {
text-align: center;
margin-top: 10vh;
}
.ms-title {
display: block;
font-size: 44px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 8px 0;
line-height: 1.15;
}
.ms-subtitle {
color: var(--text-secondary);
font-size: 18px;
margin: 0;
}
.ibar--hero {
margin-top: 30px;
max-width: 750px;
width: 100%;
border-top: none;
}
.msg {
padding: 8px 12px;
margin: 4px 0;
border-radius: 8px;
font-size: 14px;
line-height: 1.5;
}
.msg.user {
text-align: right;
}
.msg-user {
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: 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);
}
.msg.status {
color: var(--text-secondary);
font-style: italic;
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
}
.msg.error {
color: var(--danger-color);
background: var(--danger-background);
}
.msg.agent-answer {
background: rgba(var(--success-color), 0.1);
color: var(--success-color);
border: 1px solid rgba(var(--success-color), 0.3);
border-radius: 10px;
padding: 10px 14px;
margin: 8px 0;
font-size: 14px;
line-height: 1.6;
}
.msg.agent-answer p { margin: 0; }
.msg.agent-sources { margin: 4px 0 8px; }
.spinner-mini {
width: 14px;
height: 14px;
border: 2px solid var(--border-light);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
.src-title { font-size: 11px; color: var(--muted-text); margin-bottom: 4px; font-weight: 500; }
.src-list { display: flex; flex-wrap: wrap; gap: 4px; }
.src-item {
background: var(--hover-background); border-radius: 6px; padding: 3px 8px;
font-size: 11px; color: var(--text-secondary); max-width: 200px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
cursor: pointer;
}
.src-item:hover { background: rgba(var(--primary-color-rgb), 0.1); color: var(--primary-color); }
.src-tool {
background: rgba(var(--primary-color-rgb), 0.1); color: var(--primary-color); border-radius: 3px;
padding: 1px 4px; font-size: 10px; font-weight: 500; margin-right: 4px;
}
.chip-row { display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 0; }
.chip-btn {
border: 1px solid var(--border-color); border-radius: 999px; background: var(--card-background);
padding: 5px 12px; font-size: 12px; color: var(--text-primary); cursor: pointer;
transition: all .15s;
}
.chip-btn:hover { border-color: var(--primary-color); color: var(--primary-color); background: rgba(var(--primary-color-rgb), 0.1); }
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 10px;
margin-top: 8px;
}
.result-card {
background: var(--hover-background);
border-radius: 8px;
overflow: hidden;
cursor: pointer;
}
.result-card:hover {
background: var(--border-light);
}
.card-thumb {
width: 100%;
aspect-ratio: 16/9;
background: var(--border-light);
overflow: hidden;
position: relative;
}
.card-thumb img { width: 100%; height: 100%; object-fit: cover; }
.card-file-info {
display: flex; align-items: center; gap: 5px;
padding: 5px 8px; background: var(--card-background);
border-bottom: 1px solid var(--border-light);
cursor: pointer; transition: background .15s;
position: relative; z-index: 10;
}
.card-file-info:hover {
background: rgba(var(--primary-color-rgb), 0.1);
}
.card-file-icon {
flex-shrink: 0; color: var(--primary-color);
}
.card-file-name {
font-size: 11px; font-weight: 600; color: var(--text-primary);
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.card-file-uuid-badge {
font-size: 8px; color: var(--muted-text); font-family: monospace;
max-width: 80px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.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%;
background: var(--border-light);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
}
.card-name {
padding: 6px 8px 2px; font-size: 12px; color: var(--text-primary);
line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; overflow: hidden;
}
.card-text {
padding: 0 8px 4px; font-size: 11px; color: var(--text-secondary);
line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; overflow: hidden;
}
.card-file {
padding: 0 8px 4px; font-size: 10px; color: var(--muted-text);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.card-file-uuid {
font-size: 9px; color: var(--muted-text); opacity: 0.6;
font-family: monospace;
}
.card-meta {
display: flex; flex-wrap: wrap; align-items: center;
gap: 4px 8px; padding: 0 8px 6px; font-size: 11px;
color: var(--text-secondary); line-height: 1.4;
}
.card-chunk { color: var(--primary-color); font-weight: 500; }
.card-frame { color: var(--primary-color); }
.card-time { color: var(--muted-text); }
.card-score { color: var(--primary-color); font-weight: 500; }
.card-source { background: rgba(var(--primary-color-rgb), 0.1); color: var(--primary-color); border-radius: 4px; padding: 1px 5px; font-size: 10px; font-weight: 500; }
.card-asr { border-radius: 4px; padding: 1px 5px; font-size: 10px; font-weight: 500; }
.card-asr--no_audio_track { background: var(--hover-background); color: var(--text-secondary); }
.card-asr--silent_audio { background: rgba(var(--warning-color), 0.2); color: var(--warning-color); }
.card-asr--has_transcript { background: rgba(var(--success-color), 0.2); color: var(--success-color); }
.card-asr--processing { background: rgba(var(--primary-color), 0.2); color: var(--primary-color); }
.ibar {
border-top: 1px solid var(--border-color);
padding: 12px 0;
}
.iwrap {
display: flex;
align-items: flex-end;
gap: 8px;
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 6px 12px;
background: var(--card-background);
transition: border-color 0.15s;
color: var(--text-secondary);
}
.iwrap:focus-within { border-color: var(--primary-color); }
.ifield {
flex: 1;
border: none;
outline: none;
font-size: 14px;
padding: 4px 0;
color: var(--text-primary);
background: transparent;
resize: none;
min-height: 24px;
max-height: 120px;
line-height: 1.5;
font-family: inherit;
}
.ifield::placeholder { color: var(--muted-text); }
.sendbtn {
width: 34px; height: 34px;
border: none; border-radius: 8px;
background: var(--primary-color);
cursor: pointer;
display: flex; align-items: center; justify-content: center;
color: #fff;
transition: all 0.15s;
flex-shrink: 0;
}
.sendbtn:hover:not(:disabled) { background: var(--primary-color); opacity: 0.9; }
.sendbtn:disabled { background: var(--border-color); cursor: default; }
.micbtn {
width: 34px; height: 34px;
border: none; border-radius: 8px;
background: transparent;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
color: var(--text-secondary);
transition: all 0.15s;
}
.micbtn:hover { background: var(--hover-background); }
.micbtn.active { background: rgba(var(--primary-color-rgb), 0.1); color: var(--primary-color); }
.ms-chat-mode-wrap { position: relative; }
.ms-chat-mode-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--card-background);
cursor: pointer;
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
}
.ms-chat-mode-btn:hover { background: var(--hover-background); }
.ms-chat-mode-arrow { font-size: 10px; margin-left: 2px; }
.ms-chat-mode-menu {
position: absolute;
bottom: 100%;
left: 0;
margin-bottom: 4px;
background: var(--card-background);
border: 1px solid var(--border-color);
border-radius: 10px;
box-shadow: var(--shadow);
padding: 4px;
display: none;
z-index: 50;
min-width: 160px;
}
.ms-chat-mode-menu.is-open { display: block; }
.ms-chat-mode-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
border-radius: 6px;
font-size: 13px;
color: var(--text-primary);
}
.ms-chat-mode-item:hover { background: var(--hover-background); }
.ms-chat-mode-item.is-active {
background: rgba(var(--primary-color-rgb), 0.1);
color: var(--primary-color);
font-weight: 500;
}
.ms-chat-mode-item-icon {
display: flex;
align-items: center;
width: 16px;
justify-content: center;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.export-btn {
position: relative;
background: rgba(var(--primary-color-rgb), 0.1);
border-color: var(--primary-color);
}
.export-btn:hover { background: rgba(var(--primary-color-rgb), 0.2); }
.export-count {
position: absolute;
top: -4px;
right: -4px;
background: var(--primary-color);
color: #fff;
font-size: 10px;
font-weight: 600;
border-radius: 8px;
padding: 1px 5px;
min-width: 16px;
text-align: center;
}
.card-actions {
display: flex;
justify-content: space-around;
padding: 6px 8px 8px;
}
.card-action-btn {
flex: 1;
padding: 4px 0;
background: none;
border: none;
font-size: 14px;
cursor: pointer;
color: var(--text-secondary);
opacity: 0.6;
transition: opacity 0.15s, color 0.15s;
}
.card-action-btn:hover {
opacity: 1;
color: var(--primary-color);
}
.card-action-btn.marked {
color: var(--warning-color);
opacity: 1;
}
.card-source-type {
font-size: 9px;
font-weight: 600;
padding: 1px 4px;
border-radius: 3px;
margin-right: 4px;
letter-spacing: 0.5px;
}
mark.highlight {
background: rgba(var(--warning-color), 0.3);
color: var(--warning-color);
padding: 0 2px;
border-radius: 2px;
}
.card-source-type.ocr {
background: rgba(var(--primary-color), 0.2);
color: var(--primary-color);
}
.card-source-type.asrx {
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;
font-weight: 600;
color: var(--primary-color);
background: rgba(var(--primary-color-rgb), 0.1);
padding: 1px 4px;
border-radius: 3px;
margin-right: 4px;
}
.text-detail-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.text-detail-modal {
background: var(--card-background);
border-radius: 16px;
width: 90%;
max-width: 480px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.text-detail-header {
padding: 16px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: 8px;
}
.text-detail-tag {
font-size: 11px;
font-weight: 600;
color: var(--primary-color);
background: rgba(var(--primary-color-rgb), 0.1);
padding: 2px 6px;
border-radius: 4px;
}
.text-detail-file {
flex: 1;
font-size: 12px;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.text-detail-file-info {
display: flex; flex-direction: column; gap: 2px;
padding: 4px 0 8px; font-size: 10px; color: var(--muted-text);
}
.text-detail-file-name {
font-size: 12px; color: var(--text-primary);
}
.text-detail-file-uuid {
font-size: 10px; color: var(--muted-text); opacity: 0.6;
font-family: monospace;
}
.text-detail-close {
width: 28px;
height: 28px;
border: none;
background: transparent;
font-size: 20px;
color: var(--muted-text);
cursor: pointer;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
}
.text-detail-close:hover {
background: var(--hover-background);
color: var(--text-primary);
}
.text-detail-body {
padding: 16px;
overflow-y: auto;
}
.text-detail-meta {
display: flex;
gap: 12px;
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 12px;
}
.text-detail-content {
font-size: 14px;
line-height: 1.6;
color: var(--text-primary);
white-space: pre-wrap;
}
.text-detail-actions {
padding: 12px 16px;
border-top: 1px solid var(--border-color);
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; }
}
/* More Button Styles */
.more-btn-container {
display: flex;
justify-content: center;
padding: 20px 0;
margin-top: 16px;
}
.more-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.more-btn:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--accent-primary);
}
.more-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.more-btn svg {
flex-shrink: 0;
}
.spinner-mini {
width: 16px;
height: 16px;
border: 2px solid var(--border-color);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>