Files
momentry_studio/src/views/SearchView.vue
Momentry Studio cb7ebfb633 feat: add Face Group search mode back to SearchView
- Add 'Face Group' mode to search modes
- Use search_identities API temporarily
- Update summary: 'Identity' → 'FS'
- Will replace with Core Team's search_tkg_node_face_trace_profile API
2026-07-25 19:24:54 +08:00

1608 lines
52 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-title" @click="restoreHistory(h)">{{ h.title || h.query }}</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">Momentry Studio</h1>
<p class="ms-subtitle">Turn Every Moment Into Intelligence</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">
{{ 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">
<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>
</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>
</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 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.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>
</div>
</div>
</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
:file-uuid="currentVideo.fileUuid"
:start-frame="currentVideo.startFrame"
:end-frame="currentVideo.endFrame"
:title="currentVideo.title"
:show-asrx="true"
:show-ocr="true"
:show-face="false"
:show-pose="false"
@close="playing = false"
/>
<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)"> 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>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick, computed, onMounted, onUnmounted } from 'vue'
import { apiCall } from '@/api'
import VideoPlayer from '../components/VideoPlayer.vue'
import { useRouter } from 'vue-router'
import { useSearchHistory, useBookmarks } from '@/composables/useSearchHistory'
import { thumbnailsCache, loadThumbnail } 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[]
}
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: '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: 'Face Group', 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 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 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: '' })
function parseSummary(summary: string): { tag: string; text: string } {
if (!summary) return { tag: '', text: '' }
const match = summary.match(/^\[(OCR|ASRX)\]\s*(.*)$/i)
if (match) {
return { tag: match[1].toUpperCase(), text: match[2] }
}
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 showTextDetail(r: any) {
const parsed = parseSummary(r.summary)
const sourceType = getSourceType(r)
textDetailModal.value = {
show: true,
tag: parsed.tag || (sourceType ? `[${sourceType}]` : ''),
text: parsed.text,
fileName: r.file_name || '',
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: r.file_name,
query: r.search_query || '',
}
}
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 ocrTextFrames: Map<string, { frames: number[]; results: any[] }> = new Map()
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
if (ocrTextFrames.has(ocrText)) {
const data = ocrTextFrames.get(ocrText)!
data.frames.push(r.start_frame || 0)
data.results.push(r)
} else {
ocrTextFrames.set(ocrText, { frames: [r.start_frame || 0], results: [r] })
}
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 <= 30) {
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 [ocrText, data] of ocrTextFrames) {
const uniqueFrames = [...new Set(data.frames)].sort((a, b) => a - b)
merged.push({
...data.results[0],
summary: `[OCR] ${ocrText}`,
ocr_frames: uniqueFrames,
ocr_details: data.results,
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 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(() => {
document.addEventListener('click', closeDropdowns)
loadHistory()
loadBookmarks()
})
onUnmounted(() => document.removeEventListener('click', closeDropdowns))
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
}
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: '' })
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,
})
}
}
}
}
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_identities', { query: q, limit: 50 })
if (searchSeq !== seq) return
const results = (Array.isArray(raw) ? raw : []).filter((p: any) => p.file_uuid).map((p: any) => ({
file_uuid: p.file_uuid,
file_name: p.name,
summary: `FS - ${p.source || 'unknown'}`,
similarity: 1.0,
start_frame: p.startFrame || p.start_frame,
end_frame: p.endFrame || p.end_frame,
search_query: q,
}))
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 })
])
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)
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' })
}
}
messages.value[msgIdx].results = merged.slice(0, 15)
messages.value[msgIdx].loading = false
} else {
const results = await apiCall('search_keyword', { query: q, limit: 30 })
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)
messages.value[msgIdx].loading = false
}
} 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()
})
}
}
}
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 playVideo(r: any) {
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 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-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-block;
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;
max-width: 70%;
word-break: break-word;
}
.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; }
.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-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;
}
</style>