[4.1-5.4] FAM-UI Streamlit - 事件列表/筛选/明细/AI对话/对话历史/成员命名/统计图表 + 配置

This commit is contained in:
ericwyuan
2026-08-19 22:26:56 +08:00
parent cdd1f21d4c
commit 4cfb327455
4 changed files with 504 additions and 0 deletions

View File

@@ -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"

5
fam-ui/requirements.txt Normal file
View File

@@ -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

459
fam-ui/src/app.py Normal file
View File

@@ -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()

View File

@@ -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)