Files
sentinel-home-ai/fam-ui/src/app.py
ericwyuan 3fb86ee449 fix: 出现人物统计去重 + 导航移至页面顶部
- person 字段是自由文本,一帧可为多人组合且带括号特征描述
  ('张三 (红色T恤), 汤圆, 人物3'),COUNT(DISTINCT person) 把每种
  组合字符串当一个人物,全局误报 20 人。新增 resolve_persons:
  去括号描述 -> 按中英逗号/顿号/斜杠拆分 -> family_members
  抽象标签映射真名,去重后全局实际 6 人
- 导航从侧边栏 radio 改为顶部 segmented_control:窄屏下
  Streamlit 侧边栏自动收起,页面入口藏进汉堡菜单导致
  "成员命名/问答界面不见了";放主区域顶部任何屏宽可见
2026-08-21 00:22:24 +08:00

946 lines
38 KiB
Python
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.
"""
FAM-UI - 家庭多模态智能监控系统前端 v2
深色监控面板主题 + 关键帧时间轴:
- 事件时间轴页: 左侧事件列表,右侧时间轴(时间点 + 关键帧 + 摘要)
- AI 对话 / 对话历史 / 成员命名 / 统计图表
"""
import os
import re
import sys
import json
import base64
import html as _html
import requests
import streamlit as st
import pymysql
import pymysql.cursors
from datetime import datetime, date
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from config_loader import load_config
_cfg = load_config()
_core_url = _cfg.get('core_url', 'http://127.0.0.1:8000')
_db_cfg = _cfg.get('database', {})
FRAME_DIR = _cfg.get('storage', {}).get(
'frame_image_dir', '/volume1/web/sentinel-home-ai/fam-ui/static/frames')
esc = _html.escape
def get_db_conn():
"""获取数据库连接"""
return pymysql.connect(
host=_db_cfg.get('host', '127.0.0.1'),
port=_db_cfg.get('port', 3306),
user=_db_cfg.get('user', 'root'),
password=_db_cfg.get('password', ''),
database=_db_cfg.get('database', 'sentinel_home_ai'),
charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor
)
def serialize_datetime(obj):
"""序列化 datetime"""
if hasattr(obj, 'isoformat'):
return obj.isoformat()
return str(obj)
def parse_ts(ts):
"""'2026-08-14 22:31:15' / datetime -> datetime"""
if isinstance(ts, datetime):
return ts
try:
return datetime.strptime(str(ts)[:19], '%Y-%m-%d %H:%M:%S')
except ValueError:
return None
def resolve_persons(person_str, alias_map):
"""解析一帧 person 字段为真实人物集合
person 是自由文本,可能为多人组合且带括号特征描述,如
'张三 (红色T恤, 白色裤子), 汤圆 (蓝色上衣), 人物3'
统计真实人数需: 去括号描述 -> 按分隔符拆分 -> 抽象标签映射真名。
"""
s = re.sub(r'[(][^()]*[)]', '', person_str or '')
out = set()
for n in re.split(r'[,,、/]+', s):
n = n.strip()
if n and n != '无人':
out.add(alias_map.get(n, n))
return out
def load_frame_b64(event_id, frame_index):
"""读取落盘关键帧为 base64不存在返回 None"""
path = os.path.join(FRAME_DIR, f'event_{event_id}', f'frame_{frame_index}.jpg')
try:
if os.path.isfile(path):
with open(path, 'rb') as f:
return base64.b64encode(f.read()).decode('ascii')
except OSError:
pass
return None
# ============================================================
# 页面配置 & 全局样式
# ============================================================
st.set_page_config(
page_title="家庭智能监控",
page_icon="🏠",
layout="wide",
initial_sidebar_state="expanded"
)
GLOBAL_CSS = """
<style>
:root {
--bg: #0b0e14;
--panel: #131826;
--panel-2: #161b28;
--border: #232a3b;
--border-hi: #33405c;
--text: #e2e8f0;
--text-dim: #8b93a7;
--text-mute: #64748b;
--accent: #5b8cff;
--accent-soft: rgba(91,140,255,.15);
--danger: #ff6b6b;
--ok: #34d399;
}
.stApp { background: var(--bg); color: var(--text); }
#MainMenu, footer, header { visibility: hidden; }
h1, h2, h3 { color: #f1f5f9 !important; font-weight: 700 !important; }
a { color: var(--accent) !important; }
section[data-testid="stSidebar"] {
background: #0e1220 !important;
border-right: 1px solid var(--border);
}
[data-testid="stSidebar"] * { color: var(--text-dim) !important; }
[data-testid="stSidebar"] .stRadio label { font-size: 14px; }
/* Streamlit 组件暗色适配 */
.stTabs [data-baseweb="tab"] { color: var(--text-dim); }
.stTabs [aria-selected="true"] { color: #fff !important; }
div[data-testid="stDataFrame"] { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
.stProgress > div > div { background: var(--accent); }
hr { border-color: var(--border) !important; }
/* 输入组件 */
.stTextInput > div > div > input, .stTextArea textarea {
background: var(--panel) !important;
border: 1px solid var(--border) !important;
color: var(--text) !important;
border-radius: 10px !important;
}
.stTextInput > div > div > input:focus, .stTextArea textarea:focus {
border-color: var(--accent) !important;
box-shadow: 0 0 0 2px var(--accent-soft) !important;
}
div[data-testid="stDateInput"] > div > div > input {
background: var(--panel) !important;
border: 1px solid var(--border) !important;
color: var(--text) !important;
border-radius: 10px !important;
}
/* 按钮卡片化 */
div[data-testid="stButton"] > button {
background: var(--panel);
color: var(--text-dim);
border: 1px solid var(--border);
border-radius: 12px;
padding: 8px 14px;
font-size: 13px;
transition: all .18s ease;
}
div[data-testid="stButton"] > button:hover {
border-color: var(--accent);
color: #fff;
background: #182034;
box-shadow: 0 4px 14px rgba(91,140,255,.12);
}
div[data-testid="stButton"] > button[kind="primary"],
.stButton > button[kind="primary"] {
background: linear-gradient(135deg, #3d6bff, #5b8cff);
border: none;
color: #fff;
font-weight: 600;
box-shadow: 0 4px 14px rgba(91,140,255,.25);
}
/* 页面标题 */
.fam-pagehead {
display: flex; align-items: center; gap: 12px;
margin: 4px 0 20px 0;
}
.fam-pagehead .fp-ico {
width: 42px; height: 42px; border-radius: 12px;
background: linear-gradient(135deg, rgba(91,140,255,.25), rgba(124,91,255,.18));
border: 1px solid rgba(91,140,255,.35);
display: flex; align-items: center; justify-content: center;
font-size: 21px;
}
.fam-pagehead .fp-title { font-size: 21px; font-weight: 700; color: #f1f5f9; }
.fam-pagehead .fp-sub { font-size: 12px; color: var(--text-mute); margin-top: 2px; }
/* 统计卡 */
.stat-row { display: flex; gap: 14px; margin: 0 0 22px 0; flex-wrap: wrap; }
.stat-card {
flex: 1; min-width: 130px;
background: linear-gradient(180deg, var(--panel-2), var(--panel));
border: 1px solid var(--border);
border-radius: 14px;
padding: 14px 18px;
}
.stat-card .s-val {
font-size: 26px; font-weight: 700; color: #f1f5f9;
font-variant-numeric: tabular-nums; line-height: 1.2;
}
.stat-card .s-label { font-size: 12px; color: var(--text-mute); margin-top: 3px; }
.stat-card.warn .s-val { color: #ff9b9b; }
.stat-card.ok .s-val { color: #6ee7b7; }
/* 事件头卡片 */
.ev-head {
background: linear-gradient(135deg, rgba(91,140,255,.12), rgba(124,91,255,.07));
border: 1px solid #26304a;
border-radius: 16px;
padding: 18px 22px;
margin-bottom: 26px;
}
.ev-head .ev-title { font-size: 18px; font-weight: 700; color: #f1f5f9; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.ev-head .ev-range {
font-size: 13px; color: var(--text-dim); margin-top: 6px;
font-variant-numeric: tabular-nums;
}
.ev-head .ev-summary { margin-top: 10px; font-size: 14px; color: #cbd5e1; line-height: 1.65; }
/* 徽章 */
.bdg {
display: inline-block; font-size: 11px; padding: 3px 10px;
border-radius: 999px; font-weight: 600; line-height: 1.4;
}
.bdg-person { background: var(--accent-soft); color: #8fb0ff; border: 1px solid rgba(91,140,255,.32); }
.bdg-att { background: rgba(255,107,107,.14); color: #ff9b9b; border: 1px solid rgba(255,107,107,.35); }
.bdg-cam { background: rgba(52,211,153,.12); color: #6ee7b7; border: 1px solid rgba(52,211,153,.3); }
.bdg-model { background: rgba(148,163,184,.12); color: #a8b3c7; border: 1px solid rgba(148,163,184,.28); }
/* ===== 关键帧时间轴 ===== */
.fam-timeline { margin-top: 4px; }
.tl-item {
display: grid;
grid-template-columns: 86px 34px 1fr;
position: relative;
padding-bottom: 26px;
}
.tl-item:last-child { padding-bottom: 4px; }
.tl-time { padding: 18px 8px 0 0; text-align: right; }
.tl-time .hm {
display: block; font-size: 16px; font-weight: 700; color: #cbd5e1;
font-variant-numeric: tabular-nums; line-height: 1.15;
}
.tl-time .ss { font-size: 11px; color: var(--text-mute); font-variant-numeric: tabular-nums; }
.tl-rail { position: relative; }
.tl-rail::before {
content: ''; position: absolute; left: 50%; top: 0; bottom: -26px;
width: 2px; margin-left: -1px; background: #1e2740;
}
.tl-item:last-child .tl-rail::before { display: none; }
.tl-dot {
position: absolute; left: 50%; top: 21px;
width: 11px; height: 11px; margin-left: -5.5px;
border-radius: 50%; background: var(--accent);
box-shadow: 0 0 0 4px rgba(91,140,255,.16);
z-index: 1;
}
.tl-item.attention .tl-dot { background: var(--danger); box-shadow: 0 0 0 4px rgba(255,107,107,.16); }
.tl-card {
background: linear-gradient(180deg, var(--panel-2), var(--panel));
border: 1px solid var(--border);
border-radius: 14px;
padding: 12px;
display: flex; gap: 14px;
transition: border-color .18s ease, transform .18s ease, box-shadow .18s ease;
}
.tl-card:hover { border-color: var(--border-hi); transform: translateX(3px); box-shadow: 0 6px 20px rgba(0,0,0,.35); }
.tl-item.attention .tl-card { border-color: rgba(255,107,107,.38); }
.tl-img {
width: 224px; height: 126px; border-radius: 10px;
object-fit: cover; background: #0d1019; flex-shrink: 0;
border: 1px solid #1c2233;
}
.tl-noimg {
width: 224px; height: 126px; border-radius: 10px; flex-shrink: 0;
background:
linear-gradient(135deg, #12161f 25%, #0f1320 25%, #0f1320 50%, #12161f 50%, #12161f 75%, #0f1320 75%);
background-size: 14px 14px;
border: 1px dashed #26304a;
display: flex; flex-direction: column; align-items: center; justify-content: center;
color: #475069; font-size: 12px; gap: 6px;
}
.tl-info { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.tl-badges { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 9px; }
.tl-action { font-size: 14px; color: var(--text); line-height: 1.6; flex: 1; }
.tl-action .no-action { color: var(--text-mute); }
.tl-meta { margin-top: 9px; font-size: 12px; color: var(--text-mute); }
/* 空态 */
.fam-empty {
border: 1px dashed var(--border); border-radius: 14px;
padding: 40px 20px; text-align: center; color: var(--text-mute);
font-size: 14px; background: rgba(19,24,38,.5);
}
.fam-empty .fe-ico { font-size: 30px; display: block; margin-bottom: 10px; opacity: .55; }
/* 对话气泡 */
.chat-q, .chat-a {
border-radius: 14px; padding: 14px 18px; margin-bottom: 14px;
font-size: 14px; line-height: 1.7;
}
.chat-q { background: var(--accent-soft); border: 1px solid rgba(91,140,255,.28); }
.chat-a { background: var(--panel); border: 1px solid var(--border); }
.chat-who { font-size: 11px; color: var(--text-mute); margin-bottom: 6px; }
/* 成员卡 */
.member-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 14px; }
.member-card {
background: var(--panel); border: 1px solid var(--border); border-radius: 14px;
padding: 16px 18px;
}
@media (max-width: 960px) {
.tl-card { flex-direction: column; }
.tl-img, .tl-noimg { width: 100%; height: 180px; }
.stat-card { min-width: 45%; }
}
</style>
"""
def page_header(icon: str, title: str, sub: str = ''):
st.markdown(
f'<div class="fam-pagehead">'
f'<div class="fp-ico">{icon}</div>'
f'<div><div class="fp-title">{esc(title)}</div>'
f'{f"<div class=fp-sub>{esc(sub)}</div>" if sub else ""}'
f'</div></div>',
unsafe_allow_html=True)
def render_timeline(details: list, event_id: int):
"""渲染关键帧时间轴:左时间点 + 中轴 + 右(帧图 + 摘要)"""
items = []
for d in details:
ts = parse_ts(d['frame_timestamp'])
hm = ts.strftime('%H:%M') if ts else '--:--'
ss = ts.strftime(':%S') if ts else ''
person = d.get('person') or '无人'
attention = bool(d.get('is_attention_event'))
action = d.get('action') or ''
clothing = d.get('clothing') or ''
providers = d.get('source_providers')
if isinstance(providers, str):
try:
providers = json.loads(providers)
except ValueError:
providers = [providers] if providers else []
providers = providers or []
model = providers[0] if providers else ''
img_b64 = load_frame_b64(event_id, d.get('frame_index', 0))
if img_b64:
img_html = (f'<img class="tl-img" alt="关键帧" '
f'src="data:image/jpeg;base64,{img_b64}">')
else:
img_html = ('<div class="tl-noimg"><span>📷</span>暂无帧图</div>')
badges = [f'<span class="bdg bdg-person">{esc(person)}</span>']
if attention:
badges.append('<span class="bdg bdg-att">⚠ 需关注</span>')
badge_html = ''.join(badges)
action_html = esc(action) if action else '<span class="no-action">未识别到明显活动</span>'
meta_parts = []
if clothing:
meta_parts.append(f'衣着: {esc(clothing)}')
if model:
meta_parts.append(f'模型: {esc(model)}')
meta_html = f'<div class="tl-meta">{" · ".join(meta_parts)}</div>' if meta_parts else ''
items.append(
f'<div class="tl-item {"attention" if attention else ""}">'
f'<div class="tl-time"><span class="hm">{hm}</span><span class="ss">{ss}</span></div>'
f'<div class="tl-rail"><span class="tl-dot"></span></div>'
f'<div class="tl-card">{img_html}'
f'<div class="tl-info"><div class="tl-badges">{badge_html}</div>'
f'<div class="tl-action">{action_html}</div>{meta_html}</div>'
f'</div></div>'
)
st.markdown(
f'<div class="fam-timeline">{"".join(items)}</div>',
unsafe_allow_html=True)
# ============================================================
# 侧边栏
# ============================================================
st.markdown(GLOBAL_CSS, unsafe_allow_html=True)
st.sidebar.markdown(
'<div style="padding:6px 2px 14px 2px;">'
'<div style="font-size:17px;font-weight:700;color:#f1f5f9;">🏠 家庭智能监控</div>'
'<div style="font-size:11px;color:#64748b;margin-top:3px;">SENTINEL HOME AI</div>'
'</div>', unsafe_allow_html=True)
# 顶部横向导航(窄屏下侧边栏自动收起,放主区域保证任何屏宽都可见)
page = st.segmented_control(
"功能页面",
["🕒 事件时间轴", "💬 AI 对话", "📝 对话历史", "👤 成员命名", "📈 统计图表"],
default="🕒 事件时间轴",
label_visibility="collapsed"
) or "🕒 事件时间轴"
# 侧边栏底部:任务队列状态
try:
conn = get_db_conn()
try:
cursor = conn.cursor()
cursor.execute("SELECT status, COUNT(*) AS c FROM process_tasks GROUP BY status")
rows = {r['status']: r['c'] for r in cursor.fetchall()}
finally:
conn.close()
pending = rows.get('PENDING', 0)
processing = rows.get('PROCESSING', 0)
failed = rows.get('FAILED', 0)
st.sidebar.markdown('---')
st.sidebar.markdown(
f'<div style="font-size:11px;color:#64748b;line-height:2.1;">'
f'任务队列<br>'
f'待处理 <b style="color:#cbd5e1">{pending}</b> · '
f'处理中 <b style="color:#8fb0ff">{processing}</b> · '
f'失败 <b style="color:#ff9b9b">{failed}</b>'
f'</div>', unsafe_allow_html=True)
except Exception:
pass
# ============================================================
# 事件时间轴页
# ============================================================
if page == "🕒 事件时间轴":
page_header('🕒', '事件时间轴', '关键时间点 · 视频帧 · 信息摘要')
with st.container():
col_date, col_sp = st.columns([1, 3])
with col_date:
date_filter = st.date_input("日期", value=None)
date_str = date_filter.isoformat() if date_filter else None
# 统计卡
try:
conn = get_db_conn()
try:
cursor = conn.cursor()
cursor.execute(
"SELECT abstract_label, real_name FROM family_members "
"WHERE real_name IS NOT NULL AND is_active = TRUE")
alias_map = {r['abstract_label']: r['real_name'] for r in cursor.fetchall()}
if date_str:
cursor.execute("""
SELECT COUNT(DISTINCT me.event_id) AS ev,
COUNT(ed.detail_id) AS fr,
COALESCE(SUM(ed.is_attention_event), 0) AS att
FROM monitor_events me
LEFT JOIN event_details ed ON ed.event_id = me.event_id
WHERE DATE(me.event_start_time) = %s
""", (date_str,))
s = cursor.fetchone() or {}
cursor.execute(
"""SELECT ed.person FROM event_details ed
JOIN monitor_events me ON me.event_id = ed.event_id
WHERE DATE(me.event_start_time) = %s""", (date_str,))
else:
cursor.execute("""
SELECT COUNT(DISTINCT me.event_id) AS ev,
COUNT(ed.detail_id) AS fr,
COALESCE(SUM(ed.is_attention_event), 0) AS att
FROM monitor_events me
LEFT JOIN event_details ed ON ed.event_id = me.event_id
""")
s = cursor.fetchone() or {}
cursor.execute(
"SELECT ed.person FROM event_details ed "
"JOIN monitor_events me ON me.event_id = ed.event_id")
persons = set()
for r in cursor.fetchall():
persons |= resolve_persons(r['person'], alias_map)
s['ps'] = len(persons)
finally:
conn.close()
st.markdown(
f'<div class="stat-row">'
f'<div class="stat-card"><div class="s-val">{s.get("ev", 0)}</div><div class="s-label">监控事件</div></div>'
f'<div class="stat-card"><div class="s-val">{s.get("fr", 0)}</div><div class="s-label">关键帧</div></div>'
f'<div class="stat-card ok"><div class="s-val">{s.get("ps", 0)}</div><div class="s-label">出现人物</div></div>'
f'<div class="stat-card warn"><div class="s-val">{s.get("att", 0)}</div><div class="s-label">需关注</div></div>'
f'</div>', unsafe_allow_html=True)
except Exception:
pass
# 事件列表
page_size = 15
if 'event_page' not in st.session_state:
st.session_state.event_page = 0
offset = st.session_state.event_page * page_size
conn = get_db_conn()
try:
cursor = conn.cursor()
if date_str:
cursor.execute(
"""SELECT me.event_id, me.task_id, me.event_start_time, me.event_end_time,
me.camera_name, me.global_summary, me.compute_provider,
(SELECT COUNT(*) FROM event_details ed WHERE ed.event_id = me.event_id) AS detail_count
FROM monitor_events me
WHERE DATE(me.event_start_time) = %s
ORDER BY me.event_start_time DESC
LIMIT %s OFFSET %s""",
(date_str, page_size, offset))
else:
cursor.execute(
"""SELECT me.event_id, me.task_id, me.event_start_time, me.event_end_time,
me.camera_name, me.global_summary, me.compute_provider,
(SELECT COUNT(*) FROM event_details ed WHERE ed.event_id = me.event_id) AS detail_count
FROM monitor_events me
ORDER BY me.event_start_time DESC
LIMIT %s OFFSET %s""",
(page_size, offset))
events = cursor.fetchall()
finally:
conn.close()
if not events:
st.markdown(
'<div class="fam-empty"><span class="fe-ico">🗓</span>'
'该日期暂无监控事件</div>', unsafe_allow_html=True)
else:
# 默认选中最新事件
if 'selected_event_id' not in st.session_state:
st.session_state.selected_event_id = events[0]['event_id']
# 选中的事件不在当前列表时重置
valid_ids = {e['event_id'] for e in events}
if st.session_state.selected_event_id not in valid_ids:
st.session_state.selected_event_id = events[0]['event_id']
selected_id = st.session_state.selected_event_id
col_list, col_detail = st.columns([1, 2.35], gap='large')
with col_list:
st.markdown(
f'<div style="font-size:13px;font-weight:600;color:#8b93a7;'
f'margin-bottom:10px;">事件列表 · {len(events)} 条</div>',
unsafe_allow_html=True)
for ev in events:
ts = parse_ts(ev['event_start_time'])
time_label = ts.strftime('%m-%d %H:%M') if ts else '--:--'
date_label = ts.strftime('%Y-%m-%d') if ts else ''
summary = (ev.get('global_summary') or '').strip()
if not summary:
summary = '暂无摘要'
summary_short = summary if len(summary) <= 30 else summary[:30] + ''
is_selected = ev['event_id'] == selected_id
if st.button(
f"{'' if is_selected else ''}{time_label} · {ev.get('camera_name') or '未知'} · {ev['detail_count']}",
key=f"evbtn_{ev['event_id']}",
type="primary" if is_selected else "secondary",
use_container_width=True
):
st.session_state.selected_event_id = ev['event_id']
st.rerun()
date_html = (f'<span style="color:#7dd3fc;font-weight:600;">{date_label}</span> · '
if date_label else '')
st.markdown(
f'<div style="font-size:11px;color:#64748b;margin:-4px 2px 12px 2px;'
f'line-height:1.5;">{date_html}{esc(summary_short)}</div>',
unsafe_allow_html=True)
# 分页
nav1, nav2, nav3 = st.columns(3)
with nav1:
if st.button("← 上一页", use_container_width=True,
disabled=st.session_state.event_page == 0):
st.session_state.event_page -= 1
st.rerun()
with nav3:
if st.button("下一页 →", use_container_width=True,
disabled=len(events) < page_size):
st.session_state.event_page += 1
st.rerun()
with col_detail:
ev = next(e for e in events if e['event_id'] == selected_id)
start = parse_ts(ev['event_start_time'])
end = parse_ts(ev['event_end_time'])
range_str = ''
if start and end:
span_min = (end - start).total_seconds() / 60
range_str = (f"{start.strftime('%Y-%m-%d %H:%M:%S')}"
f"{end.strftime('%H:%M:%S')}"
f"(约 {int(span_min)} 分钟)")
providers = ev.get('compute_provider', '[]')
if isinstance(providers, str):
try:
providers = json.loads(providers)
except ValueError:
providers = []
model_badges = ''.join(
f'<span class="bdg bdg-model">{esc(p)}</span>' for p in (providers or []))
st.markdown(
f'<div class="ev-head">'
f'<div class="ev-title">'
f'<span>{esc(ev.get("camera_name") or "未知摄像头")}</span>'
f'<span class="bdg bdg-cam">事件 #{ev["event_id"]}</span>'
f'{model_badges}</div>'
f'<div class="ev-range">⏱ {esc(range_str)}</div>'
f'<div class="ev-summary">{esc(ev.get("global_summary") or "暂无全局摘要")}</div>'
f'</div>', unsafe_allow_html=True)
conn = get_db_conn()
try:
cursor = conn.cursor()
cursor.execute(
"""SELECT frame_index, frame_timestamp, camera_name, person,
action, clothing, is_attention_event, source_providers
FROM event_details
WHERE event_id = %s
ORDER BY frame_index ASC""",
(selected_id,))
details = cursor.fetchall()
finally:
conn.close()
if details:
render_timeline(details, selected_id)
else:
st.markdown(
'<div class="fam-empty"><span class="fe-ico">🎞</span>'
'该事件暂无关键帧明细</div>', unsafe_allow_html=True)
# ============================================================
# AI 对话页
# ============================================================
elif page == "💬 AI 对话":
page_header('💬', 'AI 对话', '基于监控数据的智能问答')
conn = get_db_conn()
try:
cursor = conn.cursor()
cursor.execute("SELECT DISTINCT real_name FROM family_members WHERE real_name IS NOT NULL AND is_active = TRUE")
named = [row['real_name'] for row in cursor.fetchall()]
finally:
conn.close()
col1, col2 = st.columns(2)
with col1:
queried_person = st.text_input("查询人物", value=named[0] if named else "")
with col2:
queried_date = st.date_input("查询日期", value=date.today())
if named:
quick_person = st.selectbox("快捷选择成员", [""] + named)
if quick_person:
queried_person = quick_person
quick_questions = [
f"{queried_person}今天干嘛了?",
f"{queried_person}有没有发生什么需要注意的事情?",
f"今天{queried_person}的活动时间线是什么?",
]
selected_quick = st.selectbox("快捷提问", ["自定义"] + quick_questions)
user_question = st.text_area("你的问题", value=selected_quick if selected_quick != "自定义" else "")
if st.button("提问", type="primary"):
if not user_question.strip():
st.warning("请输入问题")
elif not queried_person.strip():
st.warning("请输入查询人物")
else:
with st.spinner("AI 正在思考..."):
try:
resp = requests.post(
f"{_core_url}/api/chat/ask",
json={
"question": user_question,
"queried_person": queried_person,
"queried_date": queried_date.isoformat()
},
timeout=120
)
if resp.status_code == 200:
data = resp.json()
st.markdown(
f'<div class="chat-q"><div class="chat-who">❓ 提问</div>'
f'{esc(user_question)}</div>', unsafe_allow_html=True)
st.markdown(
f'<div class="chat-a"><div class="chat-who">🤖 回答</div>'
f'</div>', unsafe_allow_html=True)
st.markdown(data['answer'])
if data.get('context_summary'):
st.caption(f"上下文: {data['context_summary']}")
else:
st.error(f"请求失败: {resp.status_code} {resp.text}")
except requests.ConnectionError:
st.error(f"无法连接 FAM-Core ({_core_url})")
except Exception as e:
st.error(f"异常: {e}")
# ============================================================
# 对话历史页
# ============================================================
elif page == "📝 对话历史":
page_header('📝', '对话历史', '历史问答记录')
if 'chat_page' not in st.session_state:
st.session_state.chat_page = 0
page_size = 20
offset = st.session_state.chat_page * page_size
conn = get_db_conn()
try:
cursor = conn.cursor()
cursor.execute(
"""SELECT chat_id, user_question, ai_answer, context_summary,
queried_date, queried_person, created_at
FROM chat_history
ORDER BY created_at DESC
LIMIT %s OFFSET %s""",
(page_size, offset)
)
history = cursor.fetchall()
finally:
conn.close()
if not history:
st.markdown(
'<div class="fam-empty"><span class="fe-ico">💬</span>'
'暂无对话记录</div>', unsafe_allow_html=True)
else:
for h in history:
created = serialize_datetime(h['created_at'])
st.markdown(
f'<div class="chat-q"><div class="chat-who">'
f'👤 {esc(h.get("queried_person") or "未知")} · {esc(created)}</div>'
f'{esc(h["user_question"])}</div>',
unsafe_allow_html=True)
st.markdown(
f'<div class="chat-a"><div class="chat-who">🤖 回答</div></div>',
unsafe_allow_html=True)
st.markdown(h['ai_answer'] or '')
st.markdown('<div style="height:14px"></div>', unsafe_allow_html=True)
nav1, nav2, nav3 = st.columns([1, 1, 1])
with nav1:
if st.button("← 上一页", disabled=st.session_state.chat_page == 0):
st.session_state.chat_page -= 1
st.rerun()
with nav2:
st.markdown(
f'<div style="text-align:center;color:#64748b;font-size:13px;'
f'padding-top:8px;">第 {st.session_state.chat_page + 1} 页</div>',
unsafe_allow_html=True)
with nav3:
if st.button("下一页 →", disabled=len(history) < page_size):
st.session_state.chat_page += 1
st.rerun()
# ============================================================
# 成员命名页
# ============================================================
elif page == "👤 成员命名":
page_header('👤', '家庭成员命名', '为识别到的人物起名')
conn = get_db_conn()
try:
cursor = conn.cursor()
st.markdown('<div style="font-size:15px;font-weight:700;color:#f1f5f9;'
'margin:6px 0 14px 0;">未命名人物</div>', unsafe_allow_html=True)
cursor.execute(
"""SELECT fm.abstract_label, fm.feature_description, fm.first_seen_at,
(SELECT COUNT(*) FROM event_details ed WHERE ed.person = fm.abstract_label) AS event_count
FROM family_members fm
WHERE fm.real_name IS NULL AND fm.is_active = TRUE
ORDER BY fm.first_seen_at ASC"""
)
unnamed = cursor.fetchall()
if not unnamed:
st.markdown(
'<div class="fam-empty"><span class="fe-ico">✅</span>'
'所有人物已命名,或暂未发现新人物</div>', unsafe_allow_html=True)
else:
for m in unnamed:
col1, col2, col3 = st.columns([2, 1.6, 1])
with col1:
first_seen = serialize_datetime(m['first_seen_at'])
st.markdown(
f'<div class="member-card">'
f'<div style="font-size:15px;font-weight:700;color:#8fb0ff;">'
f'{esc(m["abstract_label"])}</div>'
f'<div style="font-size:12px;color:#8b93a7;margin-top:6px;'
f'line-height:1.6;">{esc(m["feature_description"] or "无特征描述")}</div>'
f'<div style="font-size:11px;color:#64748b;margin-top:8px;">'
f'首次出现 {esc(first_seen)} · 事件数 {m["event_count"]}</div>'
f'</div>', unsafe_allow_html=True)
with col2:
real_name = st.text_input(
"输入名字", key=f"name_{m['abstract_label']}",
placeholder=f"{m['abstract_label']}命名",
label_visibility="collapsed")
with col3:
if st.button("命名", key=f"btn_{m['abstract_label']}",
type="primary", use_container_width=True):
if real_name.strip():
try:
resp = requests.post(
f"{_core_url}/api/member/name",
json={
"abstract_label": m['abstract_label'],
"real_name": real_name.strip(),
"named_by": "UI管理员"
},
timeout=10
)
if resp.status_code == 200:
result = resp.json()
st.success(
f"命名成功!{m['abstract_label']}{real_name}"
f"更新明细 {result.get('updated_event_details_count', 0)}")
st.rerun()
else:
st.error(f"命名失败: {resp.status_code} {resp.text}")
except Exception as e:
st.error(f"异常: {e}")
else:
st.warning("请输入名字")
st.markdown('<div style="height:8px"></div>', unsafe_allow_html=True)
st.markdown('<div style="font-size:15px;font-weight:700;color:#f1f5f9;'
'margin:22px 0 14px 0;">已命名成员</div>', unsafe_allow_html=True)
cursor.execute(
"""SELECT abstract_label, real_name, feature_description, first_seen_at, named_at, named_by
FROM family_members
WHERE real_name IS NOT NULL AND is_active = TRUE
ORDER BY named_at DESC"""
)
named = cursor.fetchall()
if not named:
st.markdown(
'<div class="fam-empty">暂无已命名成员</div>', unsafe_allow_html=True)
else:
cards = []
for m in named:
cards.append(
f'<div class="member-card">'
f'<div style="font-size:15px;font-weight:700;color:#f1f5f9;">'
f'{esc(m["real_name"])} '
f'<span style="font-size:11px;color:#64748b;font-weight:400;">'
f'{esc(m["abstract_label"])}</span></div>'
f'<div style="font-size:12px;color:#8b93a7;margin-top:6px;line-height:1.6;">'
f'{esc(m["feature_description"] or "")}</div>'
f'<div style="font-size:11px;color:#64748b;margin-top:8px;">'
f'首次出现 {esc(serialize_datetime(m["first_seen_at"]))} · '
f'命名于 {esc(serialize_datetime(m["named_at"]))}</div>'
f'</div>')
st.markdown(f'<div class="member-grid">{"".join(cards)}</div>',
unsafe_allow_html=True)
finally:
conn.close()
# ============================================================
# 统计图表页
# ============================================================
elif page == "📈 统计图表":
page_header('📈', '统计图表', '模型来源 / 关注事件 / 任务状态')
conn = get_db_conn()
try:
cursor = conn.cursor()
st.markdown('<div style="font-size:15px;font-weight:700;color:#f1f5f9;'
'margin:6px 0 14px 0;">模型来源分布</div>', unsafe_allow_html=True)
try:
cursor.execute("""
SELECT JSON_UNQUOTE(JSON_EXTRACT(item, '$')) AS provider, COUNT(*) AS count
FROM monitor_events,
JSON_TABLE(compute_provider, '$[*]'
COLUMNS(item VARCHAR(50) PATH '$')
) AS jt
GROUP BY provider ORDER BY count DESC
""")
stats = cursor.fetchall()
except Exception:
cursor.execute("SELECT compute_provider, COUNT(*) AS count FROM monitor_events GROUP BY compute_provider")
stats = cursor.fetchall()
if stats:
st.bar_chart({r['provider']: r['count'] for r in stats})
else:
st.markdown('<div class="fam-empty">暂无统计数据</div>',
unsafe_allow_html=True)
st.markdown('<div style="font-size:15px;font-weight:700;color:#f1f5f9;'
'margin:22px 0 14px 0;">关注事件统计</div>', unsafe_allow_html=True)
cursor.execute("""
SELECT DATE(frame_timestamp) AS date, person, COUNT(*) AS count
FROM event_details
WHERE is_attention_event = TRUE
GROUP BY DATE(frame_timestamp), person
ORDER BY date DESC
""")
attention = cursor.fetchall()
if attention:
import pandas as pd
df_att = pd.DataFrame(attention)
st.dataframe(df_att, use_container_width=True, hide_index=True)
else:
st.markdown('<div class="fam-empty">暂无关注事件</div>',
unsafe_allow_html=True)
st.markdown('<div style="font-size:15px;font-weight:700;color:#f1f5f9;'
'margin:22px 0 14px 0;">任务状态统计</div>', unsafe_allow_html=True)
cursor.execute("""
SELECT status, COUNT(*) AS count
FROM process_tasks
GROUP BY status
""")
task_stats = cursor.fetchall()
if task_stats:
st.bar_chart({r['status']: r['count'] for r in task_stats})
else:
st.markdown('<div class="fam-empty">暂无任务数据</div>',
unsafe_allow_html=True)
finally:
conn.close()