""" FAM-UI - 家庭多模态智能监控系统前端 Streamlit 直读 MariaDB,展示: - 事件列表 + compute_provider 占比 + AI 对话页 + 对话历史 + 成员命名页 """ import os import sys import requests import streamlit as st import pymysql import pymysql.cursors import pandas as pd import json 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', {}) 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) # ============================================================ # 页面配置 # ============================================================ st.set_page_config( page_title="家庭智能监控", page_icon="🏠", layout="wide", initial_sidebar_state="expanded" ) # 侧边栏导航 st.sidebar.title("🏠 家庭智能监控") page = st.sidebar.radio("功能页面", [ "📊 事件列表", "💬 AI 对话", "📝 对话历史", "👤 成员命名", "📈 统计图表" ]) # ============================================================ # 事件列表页 # ============================================================ if page == "📊 事件列表": st.title("📊 监控事件列表") # 日期筛选 col1, col2 = st.columns([1, 3]) with col1: date_filter = st.date_input("日期筛选", value=None) # 分页 page_size = 20 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() # 查询条件 date_str = date_filter.isoformat() if date_filter else None 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, me.created_at, (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, me.created_at, (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() if not events: st.info("暂无事件数据") else: for ev in events: providers = ev.get('compute_provider', '[]') if isinstance(providers, str): providers = json.loads(providers) with st.container(): col1, col2, col3 = st.columns([2, 1, 1]) with col1: start_time = serialize_datetime(ev['event_start_time']) end_time = serialize_datetime(ev['event_end_time']) st.markdown(f"**{ev['camera_name'] or '未知摄像头'}** | {start_time} ~ {end_time}") st.markdown(f"_{ev['global_summary']}_") with col2: st.markdown(f"明细: {ev['detail_count']} 条") st.markdown(f"模型: {', '.join(providers)}") with col3: if st.button("查看明细", key=f"detail_{ev['event_id']}"): st.session_state.selected_event_id = ev['event_id'] # 展开明细 if st.session_state.get('selected_event_id') == ev['event_id']: 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""", (ev['event_id'],) ) details = cursor.fetchall() df = pd.DataFrame([{ '帧号': d['frame_index'], '时间': serialize_datetime(d['frame_timestamp']), '位置': d['camera_name'], '人物': d['person'], '动作': d['action'], '衣着': d['clothing'], '关注': '⚠️' if d['is_attention_event'] else '', '模型来源': json.dumps(d['source_providers']) if isinstance(d['source_providers'], str) else json.dumps(d['source_providers']) } for d in details]) st.dataframe(df, use_container_width=True, hide_index=True) st.divider() # 分页控制 col1, col2, col3 = st.columns([1, 1, 1]) with col1: if st.button("上一页") and st.session_state.event_page > 0: st.session_state.event_page -= 1 st.rerun() with col2: st.markdown(f"第 {st.session_state.event_page + 1} 页") with col3: if st.button("下一页") and len(events) == page_size: st.session_state.event_page += 1 st.rerun() finally: conn.close() # ============================================================ # AI 对话页 # ============================================================ elif page == "💬 AI 对话": st.title("💬 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.success("回答:") st.markdown(data['answer']) st.caption(f"上下文: {data.get('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 == "📝 对话历史": st.title("📝 对话历史") 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() if not history: st.info("暂无对话记录") else: for h in history: with st.container(): col1, col2 = st.columns([1, 4]) with col1: created = serialize_datetime(h['created_at']) st.markdown(f"**{h['queried_person']}**") st.caption(created) if h['queried_date']: st.caption(f"日期: {serialize_datetime(h['queried_date'])}") with col2: st.markdown(f"**Q:** {h['user_question']}") st.markdown(f"**A:** {h['ai_answer']}") st.divider() # 分页 col1, col2, col3 = st.columns([1, 1, 1]) with col1: if st.button("上一页") and st.session_state.chat_page > 0: st.session_state.chat_page -= 1 st.rerun() with col2: st.markdown(f"第 {st.session_state.chat_page + 1} 页") with col3: if st.button("下一页") and len(history) == page_size: st.session_state.chat_page += 1 st.rerun() finally: conn.close() # ============================================================ # 成员命名页 # ============================================================ elif page == "👤 成员命名": st.title("👤 家庭成员命名") # 未命名成员 st.subheader("未命名人物") conn = get_db_conn() try: cursor = conn.cursor() 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.info("所有人物已命名,或暂未发现新人物") else: for m in unnamed: col1, col2, col3 = st.columns([2, 2, 1]) with col1: st.markdown(f"**{m['abstract_label']}**") st.text(m['feature_description'] or '无特征描述') first_seen = serialize_datetime(m['first_seen_at']) st.caption(f"首次出现: {first_seen} | 事件数: {m['event_count']}") with col2: real_name = st.text_input( "输入名字", key=f"name_{m['abstract_label']}", placeholder=f"为{m['abstract_label']}命名" ) with col3: if st.button("命名", key=f"btn_{m['abstract_label']}"): 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.divider() # 已命名成员 st.subheader("已命名成员") 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.info("暂无已命名成员") else: df = pd.DataFrame([{ '抽象标识': m['abstract_label'], '真实名字': m['real_name'], '特征描述': m['feature_description'], '首次出现': serialize_datetime(m['first_seen_at']), '命名时间': serialize_datetime(m['named_at']), '命名人': m['named_by'] } for m in named]) st.dataframe(df, use_container_width=True, hide_index=True) finally: conn.close() # ============================================================ # 统计图表页 # ============================================================ elif page == "📈 统计图表": st.title("📈 统计图表") conn = get_db_conn() try: cursor = conn.cursor() # compute_provider 分布 st.subheader("模型来源分布") 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: df_stats = pd.DataFrame(stats) st.bar_chart(df_stats.set_index('provider')['count']) st.dataframe(df_stats, use_container_width=True, hide_index=True) else: st.info("暂无统计数据") # 关注事件统计 st.subheader("关注事件统计") 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: df_att = pd.DataFrame(attention) st.dataframe(df_att, use_container_width=True, hide_index=True) else: st.info("暂无关注事件") # 任务统计 st.subheader("任务状态统计") cursor.execute(""" SELECT status, COUNT(*) AS count FROM process_tasks GROUP BY status """) task_stats = cursor.fetchall() if task_stats: df_tasks = pd.DataFrame(task_stats) st.bar_chart(df_tasks.set_index('status')['count']) st.dataframe(df_tasks, use_container_width=True, hide_index=True) else: st.info("暂无任务数据") finally: conn.close()