From 4cfb3274555bd040bdfe79e075dee4757bb16590 Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Wed, 19 Aug 2026 22:26:56 +0800 Subject: [PATCH] =?UTF-8?q?[4.1-5.4]=20FAM-UI=20Streamlit=20-=20=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E5=88=97=E8=A1=A8/=E7=AD=9B=E9=80=89/=E6=98=8E?= =?UTF-8?q?=E7=BB=86/AI=E5=AF=B9=E8=AF=9D/=E5=AF=B9=E8=AF=9D=E5=8E=86?= =?UTF-8?q?=E5=8F=B2/=E6=88=90=E5=91=98=E5=91=BD=E5=90=8D/=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=E5=9B=BE=E8=A1=A8=20+=20=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fam-ui/config/config.yaml.example | 11 + fam-ui/requirements.txt | 5 + fam-ui/src/app.py | 459 ++++++++++++++++++++++++++++++ fam-ui/src/config_loader.py | 29 ++ 4 files changed, 504 insertions(+) create mode 100644 fam-ui/config/config.yaml.example create mode 100644 fam-ui/requirements.txt create mode 100644 fam-ui/src/app.py create mode 100644 fam-ui/src/config_loader.py diff --git a/fam-ui/config/config.yaml.example b/fam-ui/config/config.yaml.example new file mode 100644 index 0000000..d733e97 --- /dev/null +++ b/fam-ui/config/config.yaml.example @@ -0,0 +1,11 @@ +# FAM-UI 配置文件 (NAS 端) +# 复制此文件为 config.yaml 并修改实际值 + +core_url: "http://127.0.0.1:8000" # FAM-Core 地址 + +database: + host: "127.0.0.1" + port: 3306 + user: "root" + password: "" + database: "sentinel_home_ai" diff --git a/fam-ui/requirements.txt b/fam-ui/requirements.txt new file mode 100644 index 0000000..58c127b --- /dev/null +++ b/fam-ui/requirements.txt @@ -0,0 +1,5 @@ +streamlit>=1.30.0 +mysql-connector-python>=8.3.0 +pandas>=2.1.0 +requests>=2.31.0 +PyYAML>=6.0 diff --git a/fam-ui/src/app.py b/fam-ui/src/app.py new file mode 100644 index 0000000..56703b6 --- /dev/null +++ b/fam-ui/src/app.py @@ -0,0 +1,459 @@ +""" +FAM-UI - 家庭多模态智能监控系统前端 + +Streamlit 直读 MariaDB,展示: +- 事件列表 + compute_provider 占比 + AI 对话页 + 对话历史 + 成员命名页 +""" +import os +import sys +import requests +import streamlit as st +import mysql.connector +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 mysql.connector.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' + ) + + +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(dictionary=True) + + # 查询条件 + 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(dictionary=True) + 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(dictionary=True) + 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(dictionary=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.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(dictionary=True) + + # 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() diff --git a/fam-ui/src/config_loader.py b/fam-ui/src/config_loader.py new file mode 100644 index 0000000..b4c89e2 --- /dev/null +++ b/fam-ui/src/config_loader.py @@ -0,0 +1,29 @@ +""" +配置加载器 (FAM-UI 复用) +""" +import os +import re +import yaml + + +def _resolve_env_vars(value): + if isinstance(value, str): + def replace_env(match): + return os.environ.get(match.group(1), match.group(0)) + return re.sub(r'\$\{(\w+)\}', replace_env, value) + elif isinstance(value, dict): + return {k: _resolve_env_vars(v) for k, v in value.items()} + elif isinstance(value, list): + return [_resolve_env_vars(item) for item in value] + return value + + +def load_config(config_path=None): + if config_path is None: + config_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + 'config', 'config.yaml' + ) + with open(config_path, 'r', encoding='utf-8') as f: + raw = yaml.safe_load(f) + return _resolve_env_vars(raw)