"""
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 = """
"""
def page_header(icon: str, title: str, sub: str = ''):
st.markdown(
f'
'
f'
{icon}
'
f'
{esc(title)}
'
f'{f"
{esc(sub)}
" if sub else ""}'
f'
',
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'
')
else:
img_html = ('📷暂无帧图
')
badges = [f'{esc(person)}']
if attention:
badges.append('⚠ 需关注')
badge_html = ''.join(badges)
action_html = esc(action) if action else '未识别到明显活动'
meta_parts = []
if clothing:
meta_parts.append(f'衣着: {esc(clothing)}')
if model:
meta_parts.append(f'模型: {esc(model)}')
meta_html = f'{" · ".join(meta_parts)}
' if meta_parts else ''
items.append(
f''
f'
{hm}{ss}
'
f'
'
f'
{img_html}'
f'
{badge_html}
'
f'
{action_html}
{meta_html}
'
f'
'
)
st.markdown(
f'{"".join(items)}
',
unsafe_allow_html=True)
# ============================================================
# 侧边栏
# ============================================================
st.markdown(GLOBAL_CSS, unsafe_allow_html=True)
st.sidebar.markdown(
''
'
🏠 家庭智能监控
'
'
SENTINEL HOME AI
'
'
', 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''
f'任务队列
'
f'待处理 {pending} · '
f'处理中 {processing} · '
f'失败 {failed}'
f'
', 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'', 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(
'🗓'
'该日期暂无监控事件
', 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'事件列表 · {len(events)} 条
',
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'{date_label} · '
if date_label else '')
st.markdown(
f'{date_html}{esc(summary_short)}
',
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'{esc(p)}' for p in (providers or []))
st.markdown(
f''
f'
'
f'{esc(ev.get("camera_name") or "未知摄像头")}'
f'事件 #{ev["event_id"]}'
f'{model_badges}
'
f'
⏱ {esc(range_str)}
'
f'
{esc(ev.get("global_summary") or "暂无全局摘要")}
'
f'
', 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(
'🎞'
'该事件暂无关键帧明细
', 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'❓ 提问
'
f'{esc(user_question)}
', unsafe_allow_html=True)
st.markdown(
f'', 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(
'💬'
'暂无对话记录
', unsafe_allow_html=True)
else:
for h in history:
created = serialize_datetime(h['created_at'])
st.markdown(
f''
f'👤 {esc(h.get("queried_person") or "未知")} · {esc(created)}
'
f'{esc(h["user_question"])}
',
unsafe_allow_html=True)
st.markdown(
f'',
unsafe_allow_html=True)
st.markdown(h['ai_answer'] or '')
st.markdown('', 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'第 {st.session_state.chat_page + 1} 页
',
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('未命名人物
', 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(
'✅'
'所有人物已命名,或暂未发现新人物
', 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''
f'
'
f'{esc(m["abstract_label"])}
'
f'
{esc(m["feature_description"] or "无特征描述")}
'
f'
'
f'首次出现 {esc(first_seen)} · 事件数 {m["event_count"]}
'
f'
', 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('', unsafe_allow_html=True)
st.markdown('已命名成员
', 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(
'暂无已命名成员
', unsafe_allow_html=True)
else:
cards = []
for m in named:
cards.append(
f''
f'
'
f'{esc(m["real_name"])} '
f''
f'{esc(m["abstract_label"])}
'
f'
'
f'{esc(m["feature_description"] or "")}
'
f'
'
f'首次出现 {esc(serialize_datetime(m["first_seen_at"]))} · '
f'命名于 {esc(serialize_datetime(m["named_at"]))}
'
f'
')
st.markdown(f'{"".join(cards)}
',
unsafe_allow_html=True)
finally:
conn.close()
# ============================================================
# 统计图表页
# ============================================================
elif page == "📈 统计图表":
page_header('📈', '统计图表', '模型来源 / 关注事件 / 任务状态')
conn = get_db_conn()
try:
cursor = conn.cursor()
st.markdown('模型来源分布
', 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('暂无统计数据
',
unsafe_allow_html=True)
st.markdown('关注事件统计
', 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('暂无关注事件
',
unsafe_allow_html=True)
st.markdown('任务状态统计
', 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('暂无任务数据
',
unsafe_allow_html=True)
finally:
conn.close()