- 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)
462 lines
17 KiB
Python
462 lines
17 KiB
Python
"""
|
||
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()
|