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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user