Files
momentry_studio/src/views/SearchView.vue

1156 lines
37 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 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:#9ca3af;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:#9ca3af;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">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9ca3af" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input class="ifield" v-model="query" @keyup.enter="sendMessage"
placeholder="Search photo or video keywords..."
:disabled="sending" autocomplete="off">
<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="micbtn" title="Voice Input" @click="toggleVoice" :class="{ active: isListening }">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></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" @click="playVideo(r)">
<div class="card-thumb" v-observe="() => loadThumb(r.file_uuid, r.start_frame)">
<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="#9ca3af" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
</div>
</div>
<div class="card-name">{{ r.summary || r.file_name || 'Video segment' }}</div>
<div v-if="r.text_content" class="card-text">{{ r.text_content.slice(0, 120) }}</div>
<div class="card-file">{{ r.file_name || '' }}</div>
<div class="card-meta">
<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>
<span v-if="r.chunk_id" class="card-chunk">{{ r.chunk_id }}</span>
<span v-if="r.start_frame" class="card-frame">F{{ r.start_frame }}{{ r.end_frame }}</span>
<span v-if="r.source" class="card-source">{{ r.source }}</span>
<span v-if="r.asr_status" class="card-asr" :class="'card-asr--' + r.asr_status">{{ r.asr_message }}</span>
</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="#9ca3af" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input class="ifield" v-model="query" @keyup.enter="sendMessage"
placeholder="Search photo or video keywords..."
:disabled="sending" id="msInp" autocomplete="off">
<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="micbtn" title="Voice Input" @click="toggleVoice" :class="{ active: isListening }">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
</button>
</div>
</div>
</div>
<VideoPlayer
v-if="playing"
simple
:file-uuid="currentVideo.fileUuid"
:start-time="currentVideo.startTime"
:end-time="currentVideo.endTime"
:title="currentVideo.title"
@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>
</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: 'People', value: 'people', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></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>' },
]
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 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 playing = ref(false)
const currentVideo = ref({ fileUuid: '', startTime: 0, endTime: 0, startFrame: null as number | null, endFrame: null as number | null, 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 >= 4 && 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,
start_time: tr[2] / 1000,
end_time: tr[3] / 1000,
})
}
}
}
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 ?? (r.start_time != null ? Math.round(r.start_time * 24) : 0),
end_frame: r.end_frame ?? (r.end_time != null ? Math.round(r.end_time * 24) : 0),
start_time: r.start_time ?? 0,
end_time: r.end_time ?? (r.start_time ?? 0) + 10,
})
}
}
}
}
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,
start_time: r.start_time ?? 0,
end_time: r.end_time ?? 0,
})
}
}
} catch {}
}
const fuid = fileUuidCache.value[0] || ''
if (fuid) {
for (const r of smartResults) {
if (Array.isArray(r) && r.length >= 3) {
const ts = Number(r[2]) || 0
const startSec = ts / 1000
const newSf = Math.round(startSec * 24)
const newEf = newSf + 10
const cut = results.find((x: any) => x.file_uuid === fuid && newSf >= x.start_frame && newSf <= x.end_frame)
if (cut) {
cut.summary = cut.summary || (r[1] || '').slice(0, 120)
} else {
results.push({
file_uuid: fuid,
file_name: '',
summary: (r[1] || '').slice(0, 120),
similarity: null,
start_frame: newSf,
end_frame: newEf,
start_time: startSec,
end_time: startSec + 10,
})
}
}
}
}
messages.value[msgIdx].answer = res?.answer || 'No answer'
messages.value[msgIdx].sources = sources
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: `Identity - ${p.source || 'unknown'}`,
similarity: 1.0,
start_time: p.startTime || p.start_time || 0,
end_time: p.endTime || p.end_time || 0,
start_frame: p.startFrame || p.start_frame,
end_frame: p.endFrame || p.end_frame,
}))
messages.value[msgIdx].results = results
messages.value[msgIdx].loading = false
} else if (mode.value === 'semantic') {
const results = await apiCall('search_llm_smart', { query: q, limit: 20 })
if (searchSeq !== seq) return
messages.value[msgIdx].results = results as any[]
messages.value[msgIdx].loading = false
} else {
const results = await apiCall('search_smart', { query: q, limit: 20 })
if (searchSeq !== seq) return
messages.value[msgIdx].results = results as any[]
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 {}
}
}
}
function loadThumb(uuid: string, frame?: number) {
loadThumbnail(uuid, frame || 0)
}
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) {
currentVideo.value = {
fileUuid: r.file_uuid,
startTime: r.start_time,
endTime: r.end_time,
startFrame: r.start_frame,
endFrame: r.end_frame,
title: r.summary?.slice(0, 60) || r.file_name || 'Video'
}
playing.value = true
}
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: #202124;
}
#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 #e5e7eb;
}
.tbacts {
display: flex;
gap: 6px;
margin-left: auto;
}
.icobtn {
width: 34px;
height: 34px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #6b7280;
}
.icobtn:hover { background: #f3f4f6; }
#msBK, #msHS {
display: none;
position: absolute;
top: 50px;
right: 0;
width: 300px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0,0,0,.1);
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: #374151; }
.bkx {
width: 24px; height: 24px;
border: none; background: transparent;
cursor: pointer; color: #9ca3af; font-size: 16px;
display: flex; align-items: center; justify-content: center;
}
.bk-item {
padding: 6px 8px;
font-size: 13px;
color: #374151;
cursor: pointer;
border-radius: 6px;
}
.bk-item:hover { background: #f3f4f6; }
.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: #9ca3af; 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: #ef4444; background: #fef2f2; }
.bkh-actions {
display: flex;
align-items: center;
gap: 4px;
}
.bkh-btn {
border: 1px solid #d1d5db;
border-radius: 6px;
background: #fff;
cursor: pointer;
font-size: 11px;
color: #6b7280;
padding: 2px 8px;
}
.bkh-btn:hover { background: #f3f4f6; }
.hs-item {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
border-radius: 6px;
position: relative;
cursor: pointer;
}
.hs-item:hover { background: #f3f4f6; }
.hs-item.pinned { background: #eef2ff; }
.hs-item.pinned .hs-title { font-weight: 500; color: #4f46e5; }
.hs-title {
flex: 1;
font-size: 13px;
color: #374151;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hs-menu-btn {
visibility: hidden;
width: 22px; height: 22px;
border: none; background: transparent;
cursor: pointer; color: #9ca3af; 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: #e5e7eb; }
.hs-menu {
position: absolute;
right: 0;
top: 100%;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,.1);
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: #374151;
border-radius: 4px;
}
.hs-menu button:hover { background: #f3f4f6; }
.hs-menu-del { color: #ef4444 !important; }
.hs-menu-del:hover { background: #fef2f2 !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: #fff;
border-radius: 12px;
padding: 20px;
width: 320px;
box-shadow: 0 8px 32px rgba(0,0,0,.15);
}
.rename-label {
font-size: 14px;
font-weight: 600;
color: #374151;
margin-bottom: 12px;
}
.rename-input {
width: 100%;
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 8px 12px;
font-size: 14px;
outline: none;
box-sizing: border-box;
}
.rename-input:focus { border-color: #6366f1; }
.rename-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 14px;
}
.rename-cancel {
padding: 6px 14px;
border: 1px solid #d1d5db;
border-radius: 8px;
background: #fff;
cursor: pointer;
font-size: 13px;
color: #6b7280;
}
.rename-cancel:hover { background: #f3f4f6; }
.rename-confirm {
padding: 6px 14px;
border: none;
border-radius: 8px;
background: #4f46e5;
cursor: pointer;
font-size: 13px;
color: #fff;
}
.rename-confirm:hover { background: #4338ca; }
.sel-popup {
position: absolute;
z-index: 150;
}
.sel-popup-btn {
background: #4f46e5;
color: #fff;
border: none;
border-radius: 6px;
padding: 5px 12px;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
box-shadow: 0 2px 8px rgba(0,0,0,.15);
}
.sel-popup-btn:hover { background: #4338ca; }
.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: #111;
margin: 0 0 8px 0;
line-height: 1.15;
}
.ms-subtitle {
color: #6e6e6e;
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: #eef2ff;
color: #1e293b;
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: #374151;
}
.msg.status {
color: #6b7280;
font-style: italic;
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
}
.msg.error {
color: #ef4444;
background: #fef2f2;
}
.msg.agent-answer {
background: #f0fdf4;
color: #166534;
border: 1px solid #bbf7d0;
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 #e5e7eb;
border-top-color: #6366f1;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
.src-title { font-size: 11px; color: #9ca3af; margin-bottom: 4px; font-weight: 500; }
.src-list { display: flex; flex-wrap: wrap; gap: 4px; }
.src-item {
background: #f3f4f6; border-radius: 6px; padding: 3px 8px;
font-size: 11px; color: #6b7280; max-width: 200px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
cursor: pointer;
}
.src-item:hover { background: #eef2ff; color: #4f46e5; }
.src-tool {
background: #eef2ff; color: #4f46e5; 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 #d1d5db; border-radius: 999px; background: #fff;
padding: 5px 12px; font-size: 12px; color: #374151; cursor: pointer;
transition: all .15s;
}
.chip-btn:hover { border-color: #6366f1; color: #4f46e5; background: #eef2ff; }
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 10px;
margin-top: 8px;
}
.result-card {
border: 1px solid #e5e7eb;
border-radius: 10px;
overflow: hidden;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
}
.result-card:hover {
border-color: #6366f1;
box-shadow: 0 2px 8px rgba(0,0,0,.08);
}
.card-thumb {
width: 100%;
aspect-ratio: 16/9;
background: #f3f4f6;
overflow: hidden;
position: relative;
}
.card-thumb img { width: 100%; height: 100%; object-fit: cover; }
.thumb-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
}
.card-name {
padding: 6px 8px 2px; font-size: 12px; color: #374151;
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: #6b7280;
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: #9ca3af;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.card-meta {
display: flex; flex-wrap: wrap; align-items: center;
gap: 4px 8px; padding: 0 8px 6px; font-size: 11px;
color: #6b7280; line-height: 1.4;
}
.card-chunk { color: #6366f1; font-weight: 500; }
.card-frame { color: #8b5cf6; }
.card-time { color: #9ca3af; }
.card-score { color: #6366f1; font-weight: 500; }
.card-source { background: #eef2ff; color: #4f46e5; 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: #f3f4f6; color: #6b7280; }
.card-asr--silent_audio { background: #fef3c7; color: #d97706; }
.card-asr--has_transcript { background: #d1fae5; color: #059669; }
.card-asr--processing { background: #dbeafe; color: #2563eb; }
.ibar {
border-top: 1px solid #e5e7eb;
padding: 12px 0;
}
.iwrap {
display: flex;
align-items: center;
gap: 8px;
border: 1px solid #d1d5db;
border-radius: 12px;
padding: 6px 12px;
background: #fff;
transition: border-color 0.15s;
}
.iwrap:focus-within { border-color: #6366f1; }
.ifield {
flex: 1;
border: none;
outline: none;
font-size: 14px;
padding: 4px 0;
color: #202124;
background: transparent;
}
.ifield::placeholder { color: #9ca3af; }
.micbtn {
width: 34px; height: 34px;
border: none; border-radius: 8px;
background: transparent;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
color: #6b7280;
transition: all 0.15s;
}
.micbtn:hover { background: #f3f4f6; }
.micbtn.active { background: #eef2ff; color: #4f46e5; }
.ms-chat-mode-wrap { position: relative; }
.ms-chat-mode-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
cursor: pointer;
font-size: 12px;
color: #6b7280;
white-space: nowrap;
}
.ms-chat-mode-btn:hover { background: #f3f4f6; }
.ms-chat-mode-arrow { font-size: 10px; margin-left: 2px; }
.ms-chat-mode-menu {
position: absolute;
bottom: 100%;
left: 0;
margin-bottom: 4px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0,0,0,.1);
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: #374151;
}
.ms-chat-mode-item:hover { background: #f3f4f6; }
.ms-chat-mode-item.is-active {
background: #eef2ff;
color: #4f46e5;
font-weight: 500;
}
.ms-chat-mode-item-icon {
display: flex;
align-items: center;
width: 16px;
justify-content: center;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>