[4.1-5.4] FAM-UI Streamlit - 事件列表/筛选/明细/AI对话/对话历史/成员命名/统计图表 + 配置
This commit is contained in:
459
fam-ui/src/app.py
Normal file
459
fam-ui/src/app.py
Normal 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()
|
||||
29
fam-ui/src/config_loader.py
Normal file
29
fam-ui/src/config_loader.py
Normal 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)
|
||||
Reference in New Issue
Block a user