feat: 事件时间轴缩略帧 + 人物管理头像 + 人物合并硬规则校验
## 新架构:Oracle 集中计算 + NAS 代理展示 ### Oracle 端 (fam-edge) - 新增 frame_service: ffmpeg 视频抽帧 + VLM 人物定位裁剪头像(磁盘缓存) - 新增 /api/oracle/frame: 按 video_id+ts 抽帧返回 jpeg(带 token) - 新增 /api/oracle/avatar: 按 label 生成人物头像(VLM 定位人物 + 兜底整帧居中) - 新增 person_identifier: 人物身份识别模块 - Gemini 适配器支持 flash/flash-lite 双模型切换,429 自动降级 - frame_service VLM 全模型 429 时进入 10 分钟熔断,避免每次请求白打配额 - 兜底头像不落缓存,配额恢复后自动重试 VLM 精确定位 ### 人物合并硬规则校验(框架级修复) - person_service: LLM 合并结果落库前加硬冲突检测 - 性别冲突 → 绝不合并 - 年龄档跨未成年/成年 → 绝不合并(防止把爷爷/宝宝并进同一人) - oracle_db: upsert_person 入口剥离括号后缀(人物A(别名:人物B) → 人物A),消灭垃圾人物行 - 修复 set_canonical 丢弃 source 参数的 bug(旧代码硬编码 'manual' 导致错误合并被永久固化) - get_events_for_label: 只提取该身份组的特征文本,头像定位更精准 ### NAS 端 (fam-core) - 新增 img_proxy: /api/proxy/frame 和 /api/proxy/avatar 代理 Oracle 图片 - app.py 注册 img_bp 蓝图 - oracle_sync / db_layer / member_manager 同步人物表 ### UI 端 (fam-ui) - 事件时间轴: 每条事件卡片加时间点缩略帧 - 人物管理: 每人卡片加头像(150x150 圆角) - parse_persons: 剥离括号备注,与 Oracle 归一化一致 - 新增 EventItem 组件、Timeline 页改造 - Chat / ServiceStatus 页相应调整 ### 数据库 - scripts/ddl.sql: 同步表结构更新 - Oracle people 表: features_json / display_uid / source 字段完善
This commit is contained in:
@@ -51,6 +51,13 @@ export const api = {
|
||||
request('/api/member/merge', { method: 'POST', body: JSON.stringify({ source, target }) }),
|
||||
|
||||
status: () => request('/api/status'),
|
||||
syncTrigger: () => request('/api/sync/trigger', { method: 'POST' }),
|
||||
|
||||
identityCorrect: (video_id, current_name, new_name) =>
|
||||
request('/api/member/identity-correct', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ video_id, current_name, new_name }),
|
||||
}),
|
||||
}
|
||||
|
||||
/** 剥离全角/半角括号备注(如 '人物A(别名:人物B)' -> '人物A'),与后端归一化一致 */
|
||||
|
||||
@@ -1,12 +1,39 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { fmtTime, parsePersons } from '../api.js'
|
||||
import { computed, ref } from 'vue'
|
||||
import { api, fmtTime, parsePersons } from '../api.js'
|
||||
import Badge from './Badge.vue'
|
||||
|
||||
const props = defineProps({
|
||||
event: { type: Object, required: true },
|
||||
videoId: { type: [Number, String], required: true },
|
||||
})
|
||||
const emit = defineEmits(['corrected'])
|
||||
|
||||
// 闭集人物识别只有这 4 个真实成员,纠错时二选/四选一,不是自由填字符串
|
||||
const CANONICAL_NAMES = ['爷爷', '爸爸', '媳妇', '汤圆']
|
||||
const fixingPerson = ref(null) // 当前正在纠错的人物名(打开选择器)
|
||||
const fixingBusy = ref(false)
|
||||
const fixError = ref('')
|
||||
|
||||
function toggleFix(name) {
|
||||
fixError.value = ''
|
||||
fixingPerson.value = fixingPerson.value === name ? null : name
|
||||
}
|
||||
|
||||
async function correctPerson(oldName, newName) {
|
||||
if (oldName === newName) { fixingPerson.value = null; return }
|
||||
fixingBusy.value = true
|
||||
fixError.value = ''
|
||||
try {
|
||||
await api.identityCorrect(props.videoId, oldName, newName)
|
||||
fixingPerson.value = null
|
||||
emit('corrected')
|
||||
} catch (e) {
|
||||
fixError.value = e.message
|
||||
} finally {
|
||||
fixingBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const timeLabel = computed(() => fmtTime(props.event.ts))
|
||||
const persons = computed(() => parsePersons(props.event.person_list_json))
|
||||
@@ -49,10 +76,27 @@ const appearances = computed(() => {
|
||||
<div v-if="thumbUrl" class="mb-2.5 leading-none">
|
||||
<img loading="lazy" alt="事件帧" :src="thumbUrl" class="block w-full rounded-lg border border-border" />
|
||||
</div>
|
||||
<div class="mb-2 flex flex-wrap gap-2">
|
||||
<div class="mb-2 flex flex-wrap items-start gap-2">
|
||||
<Badge v-if="isAttention" tone="danger">⚠ 需关注</Badge>
|
||||
<Badge v-for="p in persons" :key="p" tone="accent">{{ p }}</Badge>
|
||||
<div v-for="p in persons" :key="p" class="relative">
|
||||
<button type="button" class="group inline-flex items-center gap-1" @click="toggleFix(p)" title="点击纠正这个人是谁">
|
||||
<Badge tone="accent">{{ p }}</Badge>
|
||||
<span class="text-[10px] text-text-faint opacity-0 transition-opacity group-hover:opacity-100">✎</span>
|
||||
</button>
|
||||
<div v-if="fixingPerson === p"
|
||||
class="absolute left-0 top-full z-10 mt-1 flex gap-1.5 rounded-lg border border-border-hi bg-panel p-2 shadow-[var(--shadow-card)]">
|
||||
<button v-for="name in CANONICAL_NAMES" :key="name" :disabled="fixingBusy"
|
||||
@click="correctPerson(p, name)"
|
||||
class="rounded-md border px-2 py-1 text-xs disabled:opacity-50"
|
||||
:class="name === p
|
||||
? 'border-accent/50 bg-accent/15 text-accent-bright'
|
||||
: 'border-border bg-panel-2 text-text-dim hover:border-accent/40 hover:text-white'">
|
||||
{{ name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="fixError" class="mb-2 text-xs text-danger">纠错失败:{{ fixError }}</div>
|
||||
<div v-if="appearances.length" class="mb-2">
|
||||
<div v-for="a in appearances" :key="a.uid" class="my-1.5 rounded-lg border border-border bg-panel-3 px-2.5 py-1.5 text-xs">
|
||||
<b class="text-accent-bright">{{ a.uid }}</b>
|
||||
|
||||
@@ -11,6 +11,9 @@ const selectedQuick = ref('自定义')
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const result = ref(null)
|
||||
const showThinking = ref(true)
|
||||
|
||||
const PROVIDER_LABEL = { gemini: 'Gemini', nvidia: 'NVIDIA', ollama: '本地 Ollama' }
|
||||
|
||||
const quickQuestions = computed(() => [
|
||||
`${queriedPerson.value}今天干嘛了?`,
|
||||
@@ -30,15 +33,66 @@ onMounted(async () => {
|
||||
} catch { /* 忽略:下拉留空即可 */ }
|
||||
})
|
||||
|
||||
function handleEvent(obj) {
|
||||
if (obj.type === 'context') {
|
||||
result.value.contextCount = obj.count
|
||||
result.value.contextSummary = obj.summary
|
||||
result.value.contextPreview = obj.preview || ''
|
||||
} else if (obj.type === 'provider_trying') {
|
||||
result.value.tried.push(obj.provider)
|
||||
result.value.provider = obj.provider
|
||||
} else if (obj.type === 'chunk') {
|
||||
result.value.answer += obj.text
|
||||
if (obj.provider) result.value.provider = obj.provider
|
||||
} else if (obj.type === 'done') {
|
||||
if (obj.provider) result.value.provider = obj.provider
|
||||
result.value.finished = true
|
||||
showThinking.value = false // 回答完了自动收起思考过程,用户可以再点开
|
||||
} else if (obj.type === 'error' || obj.type === 'all_failed') {
|
||||
errorMsg.value = 'AI 服务暂时不可用,请稍后重试'
|
||||
}
|
||||
}
|
||||
|
||||
async function ask() {
|
||||
errorMsg.value = ''
|
||||
result.value = null
|
||||
if (!userQuestion.value.trim()) { errorMsg.value = '请输入问题'; return }
|
||||
if (!queriedPerson.value.trim()) { errorMsg.value = '请输入查询人物'; return }
|
||||
loading.value = true
|
||||
showThinking.value = true
|
||||
result.value = {
|
||||
question: userQuestion.value, answer: '', provider: '', tried: [],
|
||||
contextCount: null, contextSummary: '', contextPreview: '', finished: false,
|
||||
}
|
||||
try {
|
||||
const data = await api.chatAsk(userQuestion.value, queriedPerson.value, queriedDate.value)
|
||||
result.value = { question: userQuestion.value, answer: data.answer, contextSummary: data.context_summary }
|
||||
const res = await fetch('/api/chat/ask/stream', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
question: userQuestion.value,
|
||||
queried_person: queriedPerson.value,
|
||||
queried_date: queriedDate.value,
|
||||
}),
|
||||
})
|
||||
if (!res.ok || !res.body) {
|
||||
let msg = `HTTP ${res.status}`
|
||||
try { msg = (await res.json()).error || msg } catch { /* 非 JSON 错误体 */ }
|
||||
throw new Error(msg)
|
||||
}
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buf = ''
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buf += decoder.decode(value, { stream: true })
|
||||
const parts = buf.split('\n\n')
|
||||
buf = parts.pop() ?? ''
|
||||
for (const part of parts) {
|
||||
const line = part.trim()
|
||||
if (!line.startsWith('data: ')) continue
|
||||
try { handleEvent(JSON.parse(line.slice(6))) } catch { /* 忽略半截 JSON */ }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
errorMsg.value = e.message
|
||||
} finally {
|
||||
@@ -84,7 +138,7 @@ async function ask() {
|
||||
|
||||
<button :disabled="loading" @click="ask"
|
||||
class="mt-4 rounded-xl bg-gradient-to-br from-accent to-accent-2 px-5 py-2.5 text-sm font-semibold text-white shadow-[0_4px_16px_-2px_rgba(91,140,255,.4)] disabled:opacity-50">
|
||||
{{ loading ? 'AI 正在思考…' : '提问' }}
|
||||
{{ loading ? 'AI 正在回答…' : '提问' }}
|
||||
</button>
|
||||
|
||||
<p v-if="errorMsg" class="mt-3 text-sm text-danger">{{ errorMsg }}</p>
|
||||
@@ -94,10 +148,33 @@ async function ask() {
|
||||
<div class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-text-mute">❓ 提问</div>
|
||||
{{ result.question }}
|
||||
</div>
|
||||
|
||||
<!-- 思考过程/使用数据:可展开/收起 -->
|
||||
<div class="rounded-2xl border border-border bg-panel-3 shadow-[var(--shadow-card)]">
|
||||
<button type="button" @click="showThinking = !showThinking"
|
||||
class="flex w-full items-center gap-2 px-4 py-2.5 text-left text-xs font-semibold text-text-dim hover:text-white">
|
||||
<span class="inline-block transition-transform" :class="showThinking ? 'rotate-90' : ''">▶</span>
|
||||
<span>🔍 思考过程 · 使用数据</span>
|
||||
<span v-if="!result.finished && loading" class="text-accent">生成中…</span>
|
||||
<span v-if="result.provider" class="ml-auto font-normal text-text-faint">
|
||||
{{ PROVIDER_LABEL[result.provider] || result.provider }}
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="showThinking" class="border-t border-border px-4 py-3 text-xs leading-relaxed text-text-mute">
|
||||
<div v-if="result.contextCount === null">正在检索相关事件…</div>
|
||||
<template v-else>
|
||||
<div>{{ result.contextSummary }}</div>
|
||||
<div v-if="result.contextPreview" class="mt-2 max-h-40 overflow-y-auto whitespace-pre-wrap rounded-lg bg-panel-2 p-2 font-mono text-[11px] text-text-faint">{{ result.contextPreview }}</div>
|
||||
</template>
|
||||
<div v-if="result.tried.length" class="mt-2">
|
||||
尝试模型: {{ result.tried.map(p => PROVIDER_LABEL[p] || p).join(' → ') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-border bg-panel-2 p-4 text-sm leading-relaxed shadow-[var(--shadow-card)]">
|
||||
<div class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-text-mute">🤖 回答</div>
|
||||
<div class="whitespace-pre-wrap">{{ result.answer }}</div>
|
||||
<div class="whitespace-pre-wrap">{{ result.answer }}<span v-if="loading && !result.finished" class="animate-pulse">▍</span></div>
|
||||
</div>
|
||||
<p v-if="result.contextSummary" class="text-xs text-text-faint">上下文: {{ result.contextSummary }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -11,6 +11,8 @@ import Spinner from '../components/Spinner.vue'
|
||||
const data = ref(null)
|
||||
const oracleError = ref('')
|
||||
const loading = ref(false)
|
||||
const syncing = ref(false)
|
||||
const syncMsg = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
@@ -24,6 +26,23 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
async function syncNow() {
|
||||
syncing.value = true
|
||||
syncMsg.value = ''
|
||||
try {
|
||||
const r = await api.syncTrigger()
|
||||
const c = r.last_count || []
|
||||
syncMsg.value = c.length
|
||||
? `同步完成:视频+${c[0] ?? 0} 事件+${c[1] ?? 0} 人物+${c[2] ?? 0} 模型调用+${c[3] ?? 0} 身份映射+${c[4] ?? 0}`
|
||||
: '同步完成'
|
||||
await load()
|
||||
} catch (e) {
|
||||
syncMsg.value = `同步失败:${e.message}`
|
||||
} finally {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
const oracle = computed(() => data.value?.oracle || {})
|
||||
@@ -58,7 +77,10 @@ const SVC_BADGE = {
|
||||
<button :disabled="loading" @click="load" class="rounded-lg border border-border bg-panel-2 px-3.5 py-1.5 text-sm text-text-dim hover:border-accent/40 hover:text-white disabled:opacity-50">
|
||||
{{ loading && data ? '刷新中…' : '🔄 刷新' }}
|
||||
</button>
|
||||
<span class="text-xs text-text-mute">点击刷新立即更新</span>
|
||||
<button :disabled="syncing" @click="syncNow" class="rounded-lg border border-accent/40 bg-accent/10 px-3.5 py-1.5 text-sm text-accent hover:bg-accent/20 disabled:opacity-50">
|
||||
{{ syncing ? '同步中…' : '⇄ 立即同步' }}
|
||||
</button>
|
||||
<span class="text-xs text-text-mute">{{ syncMsg || '点击刷新立即更新;立即同步会马上从甲骨文拉一次增量' }}</span>
|
||||
</div>
|
||||
|
||||
<Spinner v-if="loading && !data" text="加载服务状态…" />
|
||||
|
||||
@@ -150,7 +150,8 @@ const modelBadges = computed(() => {
|
||||
|
||||
<EmptyState v-if="!detail.events.length" icon="🎞" text="该会话暂无时间点事件" />
|
||||
<div v-else>
|
||||
<EventItem v-for="ev in detail.events" :key="ev.id" :event="ev" :video-id="detail.video.id" />
|
||||
<EventItem v-for="ev in detail.events" :key="ev.id" :event="ev" :video-id="detail.video.id"
|
||||
@corrected="loadDetail(detail.video.id)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user