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:
ericwyuan
2026-08-23 00:13:56 +08:00
parent aca1a674b1
commit 2c5bf950c5
31 changed files with 2166 additions and 25 deletions

View File

@@ -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'),与后端归一化一致 */

View File

@@ -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>

View File

@@ -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>

View File

@@ -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="加载服务状态…" />

View File

@@ -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>