Files
sentinel-home-ai/fam-ui/src/views/Chat.vue
ericwyuan 2c5bf950c5 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 字段完善
2026-08-23 00:13:56 +08:00

181 lines
7.7 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.
<script setup>
import { computed, onMounted, ref } from 'vue'
import { api } from '../api.js'
import PageHeader from '../components/PageHeader.vue'
const named = ref([])
const queriedPerson = ref('')
const queriedDate = ref(new Date().toISOString().slice(0, 10))
const userQuestion = ref('')
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}今天干嘛了?`,
`${queriedPerson.value}有没有发生什么需要注意的事情?`,
`今天${queriedPerson.value}的活动时间线是什么?`,
])
function applyQuick() {
if (selectedQuick.value !== '自定义') userQuestion.value = selectedQuick.value
}
onMounted(async () => {
try {
const data = await api.namedMembers()
named.value = data.members
if (named.value.length) queriedPerson.value = named.value[0]
} 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 = ''
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 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 {
loading.value = false
}
}
</script>
<template>
<PageHeader icon="💬" title="AI 对话" sub="基于同步事件上下文的智能问答" />
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="mb-1.5 block text-xs font-medium text-text-dim">查询人物</label>
<input v-model="queriedPerson" class="w-full rounded-lg border border-border bg-panel px-3 py-2 text-sm text-text outline-none focus:border-accent focus:ring-2 focus:ring-accent/15" />
</div>
<div>
<label class="mb-1.5 block text-xs font-medium text-text-dim">查询日期</label>
<input type="date" v-model="queriedDate" class="w-full rounded-lg border border-border bg-panel px-3 py-2 text-sm text-text outline-none focus:border-accent focus:ring-2 focus:ring-accent/15" />
</div>
</div>
<div v-if="named.length" class="mt-4">
<label class="mb-1.5 block text-xs font-medium text-text-dim">快捷选择成员</label>
<select v-model="queriedPerson" class="w-full rounded-lg border border-border bg-panel px-3 py-2 text-sm text-text-dim">
<option value="">不选</option>
<option v-for="n in named" :key="n" :value="n">{{ n }}</option>
</select>
</div>
<div class="mt-4">
<label class="mb-1.5 block text-xs font-medium text-text-dim">快捷提问</label>
<select v-model="selectedQuick" @change="applyQuick" class="w-full rounded-lg border border-border bg-panel px-3 py-2 text-sm text-text-dim">
<option>自定义</option>
<option v-for="q in quickQuestions" :key="q" :value="q">{{ q }}</option>
</select>
</div>
<div class="mt-4">
<label class="mb-1.5 block text-xs font-medium text-text-dim">你的问题</label>
<textarea v-model="userQuestion" rows="3" class="w-full rounded-lg border border-border bg-panel px-3 py-2 text-sm text-text outline-none focus:border-accent focus:ring-2 focus:ring-accent/15"></textarea>
</div>
<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 正在回答…' : '提问' }}
</button>
<p v-if="errorMsg" class="mt-3 text-sm text-danger">{{ errorMsg }}</p>
<div v-if="result" class="mt-6 space-y-3.5">
<div class="rounded-2xl border border-accent/30 bg-accent/15 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>
{{ 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 }}<span v-if="loading && !result.finished" class="animate-pulse"></span></div>
</div>
</div>
</template>