Files
sentinel-home-ai/fam-ui/src/app.py
ericwyuan 94a8805045 feat: FAM-Edge chat proxy + PyMySQL migration for FAM-UI + config update
- FAM-Edge: add /api/edge/chat proxy endpoint forwarding to local Ollama
  (Ollama port 11434 not exposed externally, FAM-Edge acts as reverse proxy)
- FAM-Core config: edge_url and ollama_url switched from Tailscale IP to
  Oracle public IP (Tailscale firewall blocking between NAS and Oracle)
- FAM-UI: migrate mysql.connector to PyMySQL (same as FAM-Core)
- FAM-UI: cursor(dictionary=True) replaced with cursorclass=DictCursor
- End-to-end chat verified: FAM-Core -> FAM-Edge proxy -> Ollama -> response
  Answer: 今天没有观察到张三 (no events in DB yet, expected)
2026-08-19 23:59:28 +08:00

462 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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()