feat: add migrations, test scripts, and utility tools

- Add database migrations (006-028) for face recognition, identity, file_uuid
- Add test scripts for ASR, face, search, processing
- Add portal frontend (Tauri)
- Add config, benchmark, and monitoring utilities
- Add model checkpoints and pretrained model references
This commit is contained in:
Warren
2026-04-30 15:11:53 +08:00
parent 4d75b2e251
commit b54c2def30
192 changed files with 46721 additions and 0 deletions

View File

@@ -0,0 +1,550 @@
<template>
<div class="space-y-8">
<!-- Hero Section -->
<div class="bg-gradient-to-r from-blue-900 to-purple-900 rounded-lg p-8">
<h2 class="text-3xl font-bold mb-4">歡迎使用 Momentry Portal</h2>
<p class="text-gray-300 mb-6">
影片內容搜尋與人物管理平台
</p>
<div class="flex space-x-4">
<router-link
to="/search"
class="bg-blue-600 hover:bg-blue-700 px-6 py-3 rounded-lg font-semibold transition"
>
開始搜尋
</router-link>
<router-link
to="/persons"
class="bg-gray-700 hover:bg-gray-600 px-6 py-3 rounded-lg font-semibold transition"
>
人物管理
</router-link>
</div>
</div>
<!-- Ingest Stats Section -->
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
<h3 class="text-xl font-semibold text-green-400 mb-4">入庫統計</h3>
<div v-if="ingestStats" class="space-y-4">
<!-- Row 1: Videos + Total Chunks -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- Total Videos -->
<div class="bg-gray-900 p-4 rounded border border-gray-600 text-center">
<div class="text-3xl font-bold text-blue-400">{{ ingestStats.total_videos }}</div>
<div class="text-sm text-gray-400 mt-1">影片總數</div>
</div>
<!-- Total Chunks -->
<div class="bg-gray-900 p-4 rounded border border-gray-600 text-center">
<div class="text-3xl font-bold text-purple-400">{{ ingestStats.total_chunks }}</div>
<div class="text-sm text-gray-400 mt-1">片段總數</div>
</div>
<!-- Searchable Chunks -->
<div class="bg-gray-900 p-4 rounded border border-green-700 text-center">
<div class="text-3xl font-bold text-green-400">{{ ingestStats.searchable_chunks }}</div>
<div class="text-sm text-gray-400 mt-1">可搜尋</div>
</div>
</div>
<!-- Row 2: Chunk Types Breakdown -->
<div class="bg-gray-900 p-4 rounded border border-gray-600">
<div class="text-sm text-gray-500 mb-3">片段類型分類</div>
<div class="grid grid-cols-3 gap-4 text-center">
<div>
<div class="text-2xl font-bold text-pink-400">{{ ingestStats.sentence_chunks }}</div>
<div class="text-xs text-gray-400">Sentence (句子)</div>
</div>
<div>
<div class="text-2xl font-bold text-orange-400">{{ ingestStats.cut_chunks }}</div>
<div class="text-xs text-gray-400">Cut (剪輯點)</div>
</div>
<div>
<div class="text-2xl font-bold text-indigo-400">{{ ingestStats.time_chunks }}</div>
<div class="text-xs text-gray-400">Time (時間段)</div>
</div>
</div>
<!-- Chunk Type Definitions -->
<div class="mt-4 pt-3 border-t border-gray-700 space-y-2 text-xs text-gray-500">
<div class="flex items-start space-x-2">
<span class="text-pink-400 font-semibold">Sentence</span>
<span>基於語音辨識的自然語句分割每個片段代表一句完整的對話或敘述適合語意搜尋與內容理解</span>
</div>
<div class="flex items-start space-x-2">
<span class="text-orange-400 font-semibold">Cut</span>
<span>基於影片場景切換的分割點偵測畫面變化如鏡頭切換場景轉換作為片段邊界適合視覺內容分析</span>
</div>
<div class="flex items-start space-x-2">
<span class="text-indigo-400 font-semibold">Time</span>
<span>基於固定時間間隔的分割如每 60 確保片段長度一致適合時間序列分析與段落瀏覽</span>
</div>
</div>
</div>
<!-- Row 3: Processing Status -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- Chunks with Summary -->
<div class="bg-gray-900 p-4 rounded border border-emerald-700 text-center">
<div class="text-3xl font-bold text-emerald-400">{{ ingestStats.chunks_with_summary }}</div>
<div class="text-sm text-gray-400 mt-1">已生成摘要</div>
</div>
<!-- Chunks with Visual -->
<div class="bg-gray-900 p-4 rounded border border-cyan-700 text-center">
<div class="text-3xl font-bold text-cyan-400">{{ ingestStats.chunks_with_visual }}</div>
<div class="text-sm text-gray-400 mt-1">有視覺分析</div>
</div>
<!-- Pending Videos -->
<div class="bg-gray-900 p-4 rounded border border-yellow-700 text-center">
<div class="text-3xl font-bold text-yellow-400">{{ ingestStats.pending_videos }}</div>
<div class="text-sm text-gray-400 mt-1">待處理</div>
</div>
</div>
</div>
<div v-else class="text-gray-400">載入中...</div>
</div>
<!-- SFTPGo Status Section -->
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
<h3 class="text-xl font-semibold text-orange-400 mb-4">SFTPGo 狀態</h3>
<div v-if="sftpgoStatus" class="space-y-4">
<!-- Basic Info -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
<div class="bg-gray-900 p-4 rounded border border-gray-600">
<span class="text-xs text-gray-500 uppercase tracking-wider">Username</span>
<p class="text-white mt-1 text-lg font-semibold">{{ sftpgoStatus.username }}</p>
</div>
<div class="bg-gray-900 p-4 rounded border border-gray-600 lg:col-span-2">
<span class="text-xs text-gray-500 uppercase tracking-wider">Home Path</span>
<p class="text-gray-300 mt-1 text-sm font-mono break-all">{{ sftpgoStatus.home_dir }}</p>
</div>
<div class="bg-gray-900 p-4 rounded border border-gray-600">
<span class="text-xs text-gray-500 uppercase tracking-wider">Files Count</span>
<button
@click="openSftpgoFiles"
class="text-orange-400 mt-1 text-lg font-semibold hover:text-orange-300 cursor-pointer flex items-center space-x-1"
>
<span>{{ sftpgoStatus.files_count }}</span>
<span>🔗</span>
</button>
</div>
<div class="bg-gray-900 p-4 rounded border border-gray-600">
<span class="text-xs text-gray-500 uppercase tracking-wider">Registered Videos</span>
<p class="text-green-400 mt-1 text-lg font-semibold">{{ sftpgoStatus.registered_videos.length }}</p>
</div>
</div>
<!-- SFTPGo URL -->
<div class="mt-4 bg-gray-900 p-4 rounded border border-gray-600">
<span class="text-xs text-gray-500 uppercase tracking-wider">SFTPGo 檔案管理</span>
<div class="mt-2 space-y-2">
<div class="flex items-center space-x-2">
<button
@click="openSftpgoFiles"
class="flex-1 px-4 py-3 bg-orange-700 hover:bg-orange-600 text-white text-center rounded font-medium no-underline"
>
📂 點擊開啟 SFTPGo 檔案管理
</button>
</div>
<div class="flex items-center space-x-2 text-sm">
<span class="text-gray-500">或複製網址</span>
<input
:value="sftpgoUrl"
readonly
class="flex-1 px-3 py-1 bg-gray-800 border border-gray-600 rounded text-gray-300 text-xs font-mono"
/>
<button
@click="copySftpgoUrl"
class="px-3 py-1 bg-gray-700 hover:bg-gray-600 text-white text-xs rounded"
>
複製
</button>
</div>
</div>
</div>
<!-- Registered Videos List -->
<div v-if="sftpgoStatus.registered_videos.length > 0" class="bg-gray-900 rounded border border-gray-600">
<div class="p-3 border-b border-gray-700">
<span class="text-sm font-semibold text-gray-300">已註冊影片</span>
</div>
<div class="divide-y divide-gray-700">
<div v-for="video in sftpgoStatus.registered_videos" :key="video.uuid" class="p-3 flex justify-between items-center">
<div>
<p class="text-white text-sm">{{ video.file_name }}</p>
<p class="text-gray-500 text-xs">{{ video.uuid }}</p>
</div>
<span :class="video.status === 'completed' ? 'bg-green-900 text-green-300' : 'bg-yellow-900 text-yellow-300'" class="px-2 py-1 rounded text-xs">
{{ video.status }}
</span>
</div>
</div>
</div>
<div v-else class="bg-gray-900 p-4 rounded border border-gray-600 text-center text-gray-500 text-sm">
尚未註冊任何影片
</div>
</div>
<div v-else class="text-gray-400">載入中...</div>
</div>
<!-- Inference Engines Section -->
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
<h3 class="text-xl font-semibold text-pink-400 mb-4">推理引擎狀態</h3>
<div v-if="inferenceHealth" class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Ollama (Embedding) -->
<div class="bg-gray-900 p-4 rounded border border-gray-600">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center space-x-2">
<span class="text-pink-400">🧠</span>
<span class="font-semibold">{{ inferenceHealth.ollama.engine }}</span>
</div>
<span :class="inferenceHealth.ollama.status === 'ok' ? 'text-green-400' : 'text-red-400'">
{{ inferenceHealth.ollama.status === 'ok' ? '●' : '○' }}
</span>
</div>
<div class="space-y-1 text-sm">
<div class="flex justify-between">
<span class="text-gray-500">模型</span>
<span class="text-white">{{ inferenceHealth.ollama.model }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">用途</span>
<span class="text-purple-400">Embedding</span>
</div>
<div v-if="inferenceHealth.ollama.latency_ms" class="flex justify-between">
<span class="text-gray-500">延遲</span>
<span class="text-white">{{ inferenceHealth.ollama.latency_ms }}ms</span>
</div>
<div v-if="inferenceHealth.ollama.error" class="text-red-400 text-xs mt-2">
{{ inferenceHealth.ollama.error }}
</div>
</div>
</div>
<!-- llama-server (LLM) -->
<div class="bg-gray-900 p-4 rounded border border-gray-600">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center space-x-2">
<span class="text-cyan-400">💬</span>
<span class="font-semibold">{{ inferenceHealth.llama_server.engine }}</span>
</div>
<span :class="inferenceHealth.llama_server.status === 'ok' ? 'text-green-400' : 'text-red-400'">
{{ inferenceHealth.llama_server.status === 'ok' ? '●' : '○' }}
</span>
</div>
<div class="space-y-1 text-sm">
<div class="flex justify-between">
<span class="text-gray-500">模型</span>
<span class="text-white">{{ inferenceHealth.llama_server.model }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">用途</span>
<span class="text-cyan-400">LLM (5W1H, Summary)</span>
</div>
<div v-if="inferenceHealth.llama_server.latency_ms" class="flex justify-between">
<span class="text-gray-500">延遲</span>
<span class="text-white">{{ inferenceHealth.llama_server.latency_ms }}ms</span>
</div>
<div v-if="inferenceHealth.llama_server.error" class="text-red-400 text-xs mt-2">
{{ inferenceHealth.llama_server.error }}
</div>
</div>
</div>
</div>
<div v-else class="text-gray-400">載入中...</div>
</div>
<!-- Health Check Section -->
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
<div class="flex items-center justify-between mb-4">
<h3 class="text-xl font-semibold text-blue-400">服務狀態</h3>
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-400">API: {{ apiBaseUrl }}</span>
<button
@click="refreshHealth"
class="text-blue-400 hover:text-blue-300 text-sm"
:disabled="loading"
>
{{ loading ? '檢查中...' : '重新檢查' }}
</button>
</div>
</div>
<!-- Overall Status -->
<div v-if="healthError" class="bg-red-900/30 rounded-lg p-4 border border-red-700">
<div class="flex items-center space-x-2">
<span class="text-red-400"></span>
<span class="text-red-300">{{ healthError }}</span>
</div>
</div>
<div v-else-if="health" class="grid grid-cols-1 md:grid-cols-4 gap-4">
<!-- PostgreSQL -->
<ServiceStatusCard
name="PostgreSQL"
:status="health.services.postgres.status"
:latency="health.services.postgres.latency_ms"
:error="health.services.postgres.error"
/>
<!-- Redis -->
<ServiceStatusCard
name="Redis"
:status="health.services.redis.status"
:latency="health.services.redis.latency_ms"
:error="health.services.redis.error"
/>
<!-- Qdrant -->
<ServiceStatusCard
name="Qdrant"
:status="health.services.qdrant.status"
:latency="health.services.qdrant.latency_ms"
:error="health.services.qdrant.error"
/>
<!-- MongoDB -->
<ServiceStatusCard
name="MongoDB"
:status="health.services.mongodb.status"
:latency="health.services.mongodb.latency_ms"
:error="health.services.mongodb.error"
/>
</div>
<div v-else class="text-gray-400 text-sm">載入中...</div>
<!-- Version Info -->
<div v-if="health" class="mt-4 pt-4 border-t border-gray-700 flex justify-between text-sm text-gray-400">
<span>版本: {{ health.version }}</span>
<span>運行時間: {{ formatUptime(health.uptime_ms) }}</span>
</div>
</div>
<!-- Quick Stats -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
<h3 class="text-xl font-semibold text-blue-400 mb-2">搜尋功能</h3>
<p class="text-gray-400">智能搜尋影片內容支援語意向量與關鍵字檢索</p>
</div>
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
<h3 class="text-xl font-semibold text-green-400 mb-2">人物管理</h3>
<p class="text-gray-400">管理全域身份區域人物與臉部特徵</p>
</div>
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
<h3 class="text-xl font-semibold text-purple-400 mb-2">臉部擷取</h3>
<p class="text-gray-400">擷取並管理人物臉部截圖</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getHealth, getIngestStats, getSftpgoStatus, getInferenceHealth } from '@/api/client'
const isTauri = () => {
return (window as any).__TAURI__ !== undefined
}
interface ServiceStatus {
status: string
latency_ms: number | null
error: string | null
}
interface ServiceHealth {
postgres: ServiceStatus
redis: ServiceStatus
qdrant: ServiceStatus
mongodb: ServiceStatus
}
interface DetailedHealthResponse {
status: string
version: string
uptime_ms: number
services: ServiceHealth
}
interface IngestStats {
total_videos: number
total_chunks: number
sentence_chunks: number
cut_chunks: number
time_chunks: number
searchable_chunks: number
chunks_with_visual: number
chunks_with_summary: number
pending_videos: number
}
interface RegisteredVideo {
uuid: string
file_name: string
status: string
}
interface SftpgoStatus {
username: string
home_dir: string
files_count: number
registered_videos: RegisteredVideo[]
last_login: string | null
}
interface InferenceEngineStatus {
engine: string
model: string
status: string
latency_ms: number | null
error: string | null
}
interface InferenceHealthResponse {
ollama: InferenceEngineStatus
llama_server: InferenceEngineStatus
}
const health = ref<DetailedHealthResponse | null>(null)
const healthError = ref<string | null>(null)
const ingestStats = ref<IngestStats | null>(null)
const sftpgoStatus = ref<SftpgoStatus | null>(null)
const inferenceHealth = ref<InferenceHealthResponse | null>(null)
const loading = ref(false)
const apiBaseUrl = ref('http://127.0.0.1:3003 (dev)')
const sftpgoUrl = ref('https://sftpgo.momentry.ddns.net/web/client')
async function fetchHealth() {
loading.value = true
healthError.value = null
try {
health.value = await getHealth()
} catch (e) {
healthError.value = String(e)
}
loading.value = false
}
async function fetchIngestStats() {
try {
ingestStats.value = await getIngestStats()
} catch (e) {
console.error('Failed to fetch ingest stats:', e)
}
}
async function fetchSftpgoStatus() {
try {
sftpgoStatus.value = await getSftpgoStatus()
} catch (e) {
console.error('Failed to fetch sftpgo status:', e)
}
}
async function fetchInferenceHealth() {
try {
inferenceHealth.value = await getInferenceHealth()
} catch (e) {
console.error('Failed to fetch inference health:', e)
}
}
function openSftpgoFiles() {
const url = sftpgoUrl.value
console.log('Momentry: Opening URL:', url, 'isTauri:', isTauri())
alert('即將開啟:' + url)
if (isTauri()) {
// Use Tauri invoke in app mode
try {
import('@tauri-apps/api/core').then(({ invoke }) => {
invoke('plugin:shell|open', { path: url }).then(() => {
console.log('Momentry: Opened with shell')
alert('已開啟')
}).catch((e) => {
console.error('Momentry: Shell error:', e)
alert('開啟失敗:' + e)
})
})
} catch (e) {
console.error('Momentry: Import error:', e)
alert('導入失敗:' + e)
}
return
}
// Use browser open in web mode
window.open(url, '_blank')?.focus()
}
function copySftpgoUrl() {
navigator.clipboard.writeText(sftpgoUrl.value)
alert('已複製網址:' + sftpgoUrl.value)
}
async function refreshHealth() {
await fetchHealth()
}
function formatUptime(ms: number): string {
const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 0) return `${days}d ${hours % 24}h`
if (hours > 0) return `${hours}h ${minutes % 60}m`
if (minutes > 0) return `${minutes}m ${seconds % 60}s`
return `${seconds}s`
}
onMounted(() => {
fetchHealth()
fetchIngestStats()
fetchSftpgoStatus()
fetchInferenceHealth()
})
</script>
<script lang="ts">
import { defineComponent, h } from 'vue'
const ServiceStatusCard = defineComponent({
props: {
name: String,
status: String,
latency: [Number, null],
error: [String, null]
},
setup(props) {
const statusColor = () => {
if (props.status === 'ok') return 'text-green-400'
if (props.status === 'degraded') return 'text-yellow-400'
return 'text-red-400'
}
const bgColor = () => {
if (props.status === 'ok') return 'bg-green-900/20 border-green-700'
if (props.status === 'degraded') return 'bg-yellow-900/20 border-yellow-700'
return 'bg-red-900/20 border-red-700'
}
return () => h('div', {
class: `rounded-lg p-3 border ${bgColor()}`
}, [
h('div', { class: 'flex items-center justify-between' }, [
h('span', { class: 'font-semibold' }, props.name),
h('span', { class: statusColor() }, props.status === 'ok' ? '●' : '○')
]),
props.latency ? h('div', { class: 'text-xs text-gray-400 mt-1' }, `${props.latency}ms`) : null,
props.error ? h('div', { class: 'text-xs text-red-400 mt-1 truncate' }, props.error) : null
])
}
})
export default {
components: { ServiceStatusCard }
}
</script>