diff --git a/fam-core/config/config.yaml b/fam-core/config/config.yaml
index 7189a28..662ea09 100644
--- a/fam-core/config/config.yaml
+++ b/fam-core/config/config.yaml
@@ -47,3 +47,7 @@ chat_handler:
# 智能问答统一走 FAM-Edge 编排端点(Gemini → NVIDIA → 本地 Ollama 兜底)
qa_url: "http://129.146.203.203:5000/api/edge/chat/ask"
timeout: 120
+
+storage:
+ # 关键帧落盘目录(event_receiver 写入,fam-ui 读取展示时间轴)
+ frame_image_dir: "/volume1/web/sentinel-home-ai/fam-ui/static/frames"
diff --git a/fam-core/config/config.yaml.example b/fam-core/config/config.yaml.example
index 856f012..f1d2586 100644
--- a/fam-core/config/config.yaml.example
+++ b/fam-core/config/config.yaml.example
@@ -36,3 +36,7 @@ chat_handler:
ollama_url: "http://100.x.x.20:11434/api/generate"
model_name: "llava-phi3"
timeout: 120
+
+storage:
+ # 关键帧落盘目录(event_receiver 写入,fam-ui 读取展示时间轴)
+ frame_image_dir: "/volume1/web/sentinel-home-ai/fam-ui/static/frames"
diff --git a/fam-core/src/fam_core/event_receiver/event_receiver.py b/fam-core/src/fam_core/event_receiver/event_receiver.py
index 7e6a093..8448d58 100644
--- a/fam-core/src/fam_core/event_receiver/event_receiver.py
+++ b/fam-core/src/fam_core/event_receiver/event_receiver.py
@@ -3,21 +3,31 @@ Event-Receiver - Flask 蓝图,接收 Edge 回调,写库
处理逻辑:
1. 成功回调: 插入 monitor_events 1 条 + 遍历 frame_details 逐条插入 event_details
-2. 对未命名的 abstract_label 自动 upsert 到 family_members
-3. 更新 process_tasks 状态为 SUCCESS
-4. 失败回调: 更新任务状态为 FAILED,记录 failure_stage
+2. frame_details 携带的关键帧 base64 落盘到 fam-ui 静态目录(供时间轴展示)
+3. 对未命名的 abstract_label 自动 upsert 到 family_members
+4. 更新 process_tasks 状态为 SUCCESS
+5. 失败回调: 更新任务状态为 FAILED,记录 failure_stage
"""
+import os
import re
import json
+import base64
from flask import Blueprint, request, jsonify
from ..logger import setup_logger
+from ..config_loader import load_config
from .. import db_layer
logger = setup_logger('fam-core.event_receiver')
event_bp = Blueprint('event_receiver', __name__)
+# 关键帧落盘目录(fam-ui 读取展示;fam-core 与 fam-ui 同机部署)
+_cfg = load_config()
+FRAME_IMAGE_DIR = _cfg.get('storage', {}).get(
+ 'frame_image_dir',
+ '/volume1/web/sentinel-home-ai/fam-ui/static/frames')
+
# 匹配 "人物A" / "人物B" 等 abstract_label
_ABSTRACT_LABEL_PATTERN = re.compile(r'^人物[A-Z]$')
@@ -27,6 +37,28 @@ def _is_abstract_label(person: str) -> bool:
return bool(_ABSTRACT_LABEL_PATTERN.match(person))
+def _save_frame_images(event_id: int, frame_details: list) -> int:
+ """把 frame_details 中的 base64 关键帧落盘,返回成功张数"""
+ saved = 0
+ for frame in frame_details:
+ img_b64 = frame.pop('frame_image', None)
+ if not img_b64:
+ continue
+ idx = frame.get('frame_index', 0)
+ try:
+ out_dir = os.path.join(FRAME_IMAGE_DIR, f'event_{event_id}')
+ os.makedirs(out_dir, exist_ok=True)
+ out_path = os.path.join(out_dir, f'frame_{idx}.jpg')
+ with open(out_path, 'wb') as f:
+ f.write(base64.b64decode(img_b64))
+ saved += 1
+ except Exception as e:
+ logger.warning(f"[event_id={event_id}] 关键帧落盘失败 frame_{idx}: {e}")
+ if saved:
+ logger.info(f"[event_id={event_id}] 关键帧落盘 {saved} 张 -> {FRAME_IMAGE_DIR}")
+ return saved
+
+
def _upsert_abstract_members(frame_details: list):
"""对未命名的 abstract_label 自动 upsert 到 family_members"""
seen = {}
@@ -63,8 +95,14 @@ def apply_success_event(task_id, data: dict) -> int:
compute_provider=data.get('compute_provider', [])
)
- # 2. 遍历 frame_details 逐条插入
+ # 2. 关键帧图片落盘(先落盘再入库:pop 掉 base64 后 insert,避免大字段进 DB)
frame_details = data.get('frame_details', [])
+ try:
+ _save_frame_images(event_id, frame_details)
+ except Exception as e:
+ logger.warning(f"[event_id={event_id}] 关键帧落盘异常(不影响入库): {e}")
+
+ # 3. 遍历 frame_details 逐条插入
for frame in frame_details:
db_layer.insert_event_detail(
event_id=event_id,
diff --git a/fam-edge/src/fam_edge/ai_orchestrator/orchestrator.py b/fam-edge/src/fam_edge/ai_orchestrator/orchestrator.py
index eb99ccd..856e567 100644
--- a/fam-edge/src/fam_edge/ai_orchestrator/orchestrator.py
+++ b/fam-edge/src/fam_edge/ai_orchestrator/orchestrator.py
@@ -171,6 +171,18 @@ class AIOrchestrator:
adapter.get_circuit_breaker().record_failure()
return model_outputs
+ @staticmethod
+ def _attach_frame_images(frame_details: List[dict], frame_paths: List[str]) -> None:
+ """把关键帧图片 base64 附加到 frame_details(按位置对齐视觉分析输入帧)"""
+ for i, fd in enumerate(frame_details):
+ if i >= len(frame_paths):
+ break
+ try:
+ with open(frame_paths[i], 'rb') as f:
+ fd['frame_image'] = base64.b64encode(f.read()).decode('ascii')
+ except OSError as e:
+ logger.warning(f"关键帧图片读取失败: {frame_paths[i]}: {e}")
+
def format_cloud_result(self, provider: str, raw_result: dict,
known_members_context: str = '',
task_id: int = 0) -> dict:
@@ -481,6 +493,10 @@ class AIOrchestrator:
fusion_result = self.format_cloud_result(
provider, model_outputs[provider], known_members, task_id)
+ # 5. 附加关键帧图片(NAS 落盘后供 UI 时间轴展示)
+ frame_details = fusion_result.get('frame_details', [])
+ self._attach_frame_images(frame_details, compressed_frames)
+
total_ms = int((time.time() - start_time) * 1000)
log_task(logger, task_id, 'overall', '推送任务完成', duration_ms=total_ms)
@@ -492,7 +508,7 @@ class AIOrchestrator:
"camera_name": task_data.get('camera_name', ''),
"global_summary": fusion_result.get('global_summary', ''),
"entities_json": fusion_result.get('entities_json', []),
- "frame_details": fusion_result.get('frame_details', []),
+ "frame_details": frame_details,
"compute_provider": fusion_result.get('compute_provider', []),
"error_message": None
}
diff --git a/fam-ui/config/config.yaml b/fam-ui/config/config.yaml
index 81df84a..286ca57 100644
--- a/fam-ui/config/config.yaml
+++ b/fam-ui/config/config.yaml
@@ -10,3 +10,7 @@ database:
password: "iLoveJava5!"
database: "sentinel_home_ai"
unix_socket: "/run/mysqld/mysqld10.sock"
+
+storage:
+ # 关键帧目录(fam-core event_receiver 落盘,UI 读取展示时间轴)
+ frame_image_dir: "/volume1/web/sentinel-home-ai/fam-ui/static/frames"
diff --git a/fam-ui/src/app.py b/fam-ui/src/app.py
index 64095d6..2e9370c 100644
--- a/fam-ui/src/app.py
+++ b/fam-ui/src/app.py
@@ -1,28 +1,32 @@
"""
-FAM-UI - 家庭多模态智能监控系统前端
+FAM-UI - 家庭多模态智能监控系统前端 v2
-Streamlit 直读 MariaDB,展示:
-- 事件列表 + compute_provider 占比 + AI 对话页 + 对话历史 + 成员命名页
+深色监控面板主题 + 关键帧时间轴:
+- 事件时间轴页: 左侧事件列表,右侧时间轴(时间点 + 关键帧 + 摘要)
+- AI 对话 / 对话历史 / 成员命名 / 统计图表
"""
import os
import sys
+import json
+import base64
+import html as _html
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', {})
+FRAME_DIR = _cfg.get('storage', {}).get(
+ 'frame_image_dir', '/volume1/web/sentinel-home-ai/fam-ui/static/frames')
+
+esc = _html.escape
def get_db_conn():
@@ -45,8 +49,30 @@ def serialize_datetime(obj):
return str(obj)
+def parse_ts(ts):
+ """'2026-08-14 22:31:15' / datetime -> datetime"""
+ if isinstance(ts, datetime):
+ return ts
+ try:
+ return datetime.strptime(str(ts)[:19], '%Y-%m-%d %H:%M:%S')
+ except ValueError:
+ return None
+
+
+def load_frame_b64(event_id, frame_index):
+ """读取落盘关键帧为 base64,不存在返回 None"""
+ path = os.path.join(FRAME_DIR, f'event_{event_id}', f'frame_{frame_index}.jpg')
+ try:
+ if os.path.isfile(path):
+ with open(path, 'rb') as f:
+ return base64.b64encode(f.read()).decode('ascii')
+ except OSError:
+ pass
+ return None
+
+
# ============================================================
-# 页面配置
+# 页面配置 & 全局样式
# ============================================================
st.set_page_config(
page_title="家庭智能监控",
@@ -55,136 +81,536 @@ st.set_page_config(
initial_sidebar_state="expanded"
)
-# 侧边栏导航
-st.sidebar.title("🏠 家庭智能监控")
+GLOBAL_CSS = """
+
+"""
+
+
+def page_header(icon: str, title: str, sub: str = ''):
+ st.markdown(
+ f'
'
+ f'
{icon}
'
+ f'
{esc(title)}
'
+ f'{f"
{esc(sub)}
" if sub else ""}'
+ f'
',
+ unsafe_allow_html=True)
+
+
+def render_timeline(details: list, event_id: int):
+ """渲染关键帧时间轴:左时间点 + 中轴 + 右(帧图 + 摘要)"""
+ items = []
+ for d in details:
+ ts = parse_ts(d['frame_timestamp'])
+ hm = ts.strftime('%H:%M') if ts else '--:--'
+ ss = ts.strftime(':%S') if ts else ''
+ person = d.get('person') or '无人'
+ attention = bool(d.get('is_attention_event'))
+ action = d.get('action') or ''
+ clothing = d.get('clothing') or ''
+ providers = d.get('source_providers')
+ if isinstance(providers, str):
+ try:
+ providers = json.loads(providers)
+ except ValueError:
+ providers = [providers] if providers else []
+ providers = providers or []
+ model = providers[0] if providers else ''
+
+ img_b64 = load_frame_b64(event_id, d.get('frame_index', 0))
+ if img_b64:
+ img_html = (f'
')
+ else:
+ img_html = ('📷暂无帧图
')
+
+ badges = [f'{esc(person)}']
+ if attention:
+ badges.append('⚠ 需关注')
+ badge_html = ''.join(badges)
+
+ action_html = esc(action) if action else '未识别到明显活动'
+ meta_parts = []
+ if clothing:
+ meta_parts.append(f'衣着: {esc(clothing)}')
+ if model:
+ meta_parts.append(f'模型: {esc(model)}')
+ meta_html = f'{" · ".join(meta_parts)}
' if meta_parts else ''
+
+ items.append(
+ f''
+ f'
{hm}{ss}
'
+ f'
'
+ f'
{img_html}'
+ f'
{badge_html}
'
+ f'
{action_html}
{meta_html}
'
+ f'
'
+ )
+
+ st.markdown(
+ f'{"".join(items)}
',
+ unsafe_allow_html=True)
+
+
+# ============================================================
+# 侧边栏
+# ============================================================
+st.markdown(GLOBAL_CSS, unsafe_allow_html=True)
+
+st.sidebar.markdown(
+ ''
+ '
🏠 家庭智能监控
'
+ '
SENTINEL HOME AI
'
+ '
', unsafe_allow_html=True)
+
page = st.sidebar.radio("功能页面", [
- "📊 事件列表",
+ "🕒 事件时间轴",
"💬 AI 对话",
"📝 对话历史",
"👤 成员命名",
"📈 统计图表"
-])
+], label_visibility="collapsed")
+
+# 侧边栏底部:任务队列状态
+try:
+ conn = get_db_conn()
+ try:
+ cursor = conn.cursor()
+ cursor.execute("SELECT status, COUNT(*) AS c FROM process_tasks GROUP BY status")
+ rows = {r['status']: r['c'] for r in cursor.fetchall()}
+ finally:
+ conn.close()
+ pending = rows.get('PENDING', 0)
+ processing = rows.get('PROCESSING', 0)
+ failed = rows.get('FAILED', 0)
+ st.sidebar.markdown('---')
+ st.sidebar.markdown(
+ f''
+ f'任务队列
'
+ f'待处理 {pending} · '
+ f'处理中 {processing} · '
+ f'失败 {failed}'
+ f'
', unsafe_allow_html=True)
+except Exception:
+ pass
+
# ============================================================
-# 事件列表页
+# 事件时间轴页
# ============================================================
-if page == "📊 事件列表":
- st.title("📊 监控事件列表")
+if page == "🕒 事件时间轴":
+ page_header('🕒', '事件时间轴', '关键时间点 · 视频帧 · 信息摘要')
- # 日期筛选
- col1, col2 = st.columns([1, 3])
- with col1:
- date_filter = st.date_input("日期筛选", value=None)
+ with st.container():
+ col_date, col_sp = st.columns([1, 3])
+ with col_date:
+ date_filter = st.date_input("日期", value=None)
- # 分页
- page_size = 20
+ date_str = date_filter.isoformat() if date_filter else None
+
+ # 统计卡
+ try:
+ conn = get_db_conn()
+ try:
+ cursor = conn.cursor()
+ if date_str:
+ cursor.execute("""
+ SELECT COUNT(DISTINCT me.event_id) AS ev,
+ COUNT(ed.detail_id) AS fr,
+ COALESCE(SUM(ed.is_attention_event), 0) AS att,
+ COUNT(DISTINCT CASE WHEN ed.person <> '无人' THEN ed.person END) AS ps
+ FROM monitor_events me
+ LEFT JOIN event_details ed ON ed.event_id = me.event_id
+ WHERE DATE(me.event_start_time) = %s
+ """, (date_str,))
+ else:
+ cursor.execute("""
+ SELECT COUNT(DISTINCT me.event_id) AS ev,
+ COUNT(ed.detail_id) AS fr,
+ COALESCE(SUM(ed.is_attention_event), 0) AS att,
+ COUNT(DISTINCT CASE WHEN ed.person <> '无人' THEN ed.person END) AS ps
+ FROM monitor_events me
+ LEFT JOIN event_details ed ON ed.event_id = me.event_id
+ """)
+ s = cursor.fetchone() or {}
+ finally:
+ conn.close()
+
+ st.markdown(
+ f''
+ f'
'
+ f'
'
+ f'
'
+ f'
'
+ f'
', unsafe_allow_html=True)
+ except Exception:
+ pass
+
+ # 事件列表
+ page_size = 15
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,
+ me.camera_name, me.global_summary, me.compute_provider,
(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)
- )
+ (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,
+ me.camera_name, me.global_summary, me.compute_provider,
(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)
- )
-
+ (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()
+ if not events:
+ st.markdown(
+ '🗓'
+ '该日期暂无监控事件
', unsafe_allow_html=True)
+ else:
+ # 默认选中最新事件
+ if 'selected_event_id' not in st.session_state:
+ st.session_state.selected_event_id = events[0]['event_id']
+ # 选中的事件不在当前列表时重置
+ valid_ids = {e['event_id'] for e in events}
+ if st.session_state.selected_event_id not in valid_ids:
+ st.session_state.selected_event_id = events[0]['event_id']
+ selected_id = st.session_state.selected_event_id
+
+ col_list, col_detail = st.columns([1, 2.35], gap='large')
+
+ with col_list:
+ st.markdown(
+ f'事件列表 · {len(events)} 条
',
+ unsafe_allow_html=True)
+ for ev in events:
+ ts = parse_ts(ev['event_start_time'])
+ time_label = ts.strftime('%H:%M') if ts else '--:--'
+ summary = (ev.get('global_summary') or '').strip()
+ if not summary:
+ summary = '暂无摘要'
+ summary_short = summary if len(summary) <= 26 else summary[:26] + '…'
+ is_selected = ev['event_id'] == selected_id
+ if st.button(
+ f"{'▶ ' if is_selected else ''}{time_label} · {ev.get('camera_name') or '未知'} · {ev['detail_count']}帧",
+ key=f"evbtn_{ev['event_id']}",
+ type="primary" if is_selected else "secondary",
+ use_container_width=True
+ ):
+ st.session_state.selected_event_id = ev['event_id']
+ st.rerun()
+ st.markdown(
+ f'{esc(summary_short)}
',
+ unsafe_allow_html=True)
+
+ # 分页
+ nav1, nav2, nav3 = st.columns(3)
+ with nav1:
+ if st.button("← 上一页", use_container_width=True,
+ disabled=st.session_state.event_page == 0):
+ st.session_state.event_page -= 1
+ st.rerun()
+ with nav3:
+ if st.button("下一页 →", use_container_width=True,
+ disabled=len(events) < page_size):
+ st.session_state.event_page += 1
+ st.rerun()
+
+ with col_detail:
+ ev = next(e for e in events if e['event_id'] == selected_id)
+ start = parse_ts(ev['event_start_time'])
+ end = parse_ts(ev['event_end_time'])
+ range_str = ''
+ if start and end:
+ span_min = (end - start).total_seconds() / 60
+ range_str = (f"{start.strftime('%Y-%m-%d %H:%M:%S')} → "
+ f"{end.strftime('%H:%M:%S')}"
+ f"(约 {int(span_min)} 分钟)")
+
+ providers = ev.get('compute_provider', '[]')
+ if isinstance(providers, str):
+ try:
+ providers = json.loads(providers)
+ except ValueError:
+ providers = []
+ model_badges = ''.join(
+ f'{esc(p)}' for p in (providers or []))
+
+ st.markdown(
+ f''
+ f'
'
+ f'{esc(ev.get("camera_name") or "未知摄像头")}'
+ f'事件 #{ev["event_id"]}'
+ f'{model_badges}
'
+ f'
⏱ {esc(range_str)}
'
+ f'
{esc(ev.get("global_summary") or "暂无全局摘要")}
'
+ f'
', unsafe_allow_html=True)
+
+ conn = get_db_conn()
+ try:
+ cursor = conn.cursor()
+ 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""",
+ (selected_id,))
+ details = cursor.fetchall()
+ finally:
+ conn.close()
+
+ if details:
+ render_timeline(details, selected_id)
+ else:
+ st.markdown(
+ '🎞'
+ '该事件暂无关键帧明细
', unsafe_allow_html=True)
+
# ============================================================
# AI 对话页
# ============================================================
elif page == "💬 AI 对话":
- st.title("💬 AI 对话")
+ page_header('💬', 'AI 对话', '基于监控数据的智能问答')
- # 快捷人物按钮
conn = get_db_conn()
try:
cursor = conn.cursor()
@@ -199,7 +625,6 @@ elif page == "💬 AI 对话":
with col2:
queried_date = st.date_input("查询日期", value=date.today())
- # 快捷提问
if named:
quick_person = st.selectbox("快捷选择成员", [""] + named)
if quick_person:
@@ -211,7 +636,6 @@ elif page == "💬 AI 对话":
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"):
@@ -233,9 +657,15 @@ elif page == "💬 AI 对话":
)
if resp.status_code == 200:
data = resp.json()
- st.success("回答:")
+ st.markdown(
+ f'❓ 提问
'
+ f'{esc(user_question)}
', unsafe_allow_html=True)
+ st.markdown(
+ f'', unsafe_allow_html=True)
st.markdown(data['answer'])
- st.caption(f"上下文: {data.get('context_summary', '')}")
+ if data.get('context_summary'):
+ st.caption(f"上下文: {data['context_summary']}")
else:
st.error(f"请求失败: {resp.status_code} {resp.text}")
except requests.ConnectionError:
@@ -248,7 +678,7 @@ elif page == "💬 AI 对话":
# 对话历史页
# ============================================================
elif page == "📝 对话历史":
- st.title("📝 对话历史")
+ page_header('📝', '对话历史', '历史问答记录')
if 'chat_page' not in st.session_state:
st.session_state.chat_page = 0
@@ -268,52 +698,55 @@ elif page == "📝 对话历史":
(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()
+ if not history:
+ st.markdown(
+ '💬'
+ '暂无对话记录
', unsafe_allow_html=True)
+ else:
+ for h in history:
+ created = serialize_datetime(h['created_at'])
+ st.markdown(
+ f''
+ f'👤 {esc(h.get("queried_person") or "未知")} · {esc(created)}
'
+ f'{esc(h["user_question"])}
',
+ unsafe_allow_html=True)
+ st.markdown(
+ f'',
+ unsafe_allow_html=True)
+ st.markdown(h['ai_answer'] or '')
+ st.markdown('', unsafe_allow_html=True)
+
+ nav1, nav2, nav3 = st.columns([1, 1, 1])
+ with nav1:
+ if st.button("← 上一页", disabled=st.session_state.chat_page == 0):
+ st.session_state.chat_page -= 1
+ st.rerun()
+ with nav2:
+ st.markdown(
+ f'第 {st.session_state.chat_page + 1} 页
',
+ unsafe_allow_html=True)
+ with nav3:
+ if st.button("下一页 →", disabled=len(history) < page_size):
+ st.session_state.chat_page += 1
+ st.rerun()
+
# ============================================================
# 成员命名页
# ============================================================
elif page == "👤 成员命名":
- st.title("👤 家庭成员命名")
+ page_header('👤', '家庭成员命名', '为识别到的人物起名')
- # 未命名成员
- st.subheader("未命名人物")
conn = get_db_conn()
try:
cursor = conn.cursor()
+
+ st.markdown('未命名人物
', unsafe_allow_html=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
@@ -324,22 +757,31 @@ elif page == "👤 成员命名":
unnamed = cursor.fetchall()
if not unnamed:
- st.info("所有人物已命名,或暂未发现新人物")
+ st.markdown(
+ '✅'
+ '所有人物已命名,或暂未发现新人物
', unsafe_allow_html=True)
else:
for m in unnamed:
- col1, col2, col3 = st.columns([2, 2, 1])
+ col1, col2, col3 = st.columns([2, 1.6, 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']}")
+ st.markdown(
+ f''
+ f'
'
+ f'{esc(m["abstract_label"])}
'
+ f'
{esc(m["feature_description"] or "无特征描述")}
'
+ f'
'
+ f'首次出现 {esc(first_seen)} · 事件数 {m["event_count"]}
'
+ f'
', unsafe_allow_html=True)
with col2:
real_name = st.text_input(
"输入名字", key=f"name_{m['abstract_label']}",
- placeholder=f"为{m['abstract_label']}命名"
- )
+ placeholder=f"为{m['abstract_label']}命名",
+ label_visibility="collapsed")
with col3:
- if st.button("命名", key=f"btn_{m['abstract_label']}"):
+ if st.button("命名", key=f"btn_{m['abstract_label']}",
+ type="primary", use_container_width=True):
if real_name.strip():
try:
resp = requests.post(
@@ -354,9 +796,8 @@ elif page == "👤 成员命名":
if resp.status_code == 200:
result = resp.json()
st.success(
- f"命名成功!{m['abstract_label']} -> {real_name},"
- f"更新明细 {result.get('updated_event_details_count', 0)} 条"
- )
+ 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}")
@@ -364,10 +805,10 @@ elif page == "👤 成员命名":
st.error(f"异常: {e}")
else:
st.warning("请输入名字")
- st.divider()
+ st.markdown('', unsafe_allow_html=True)
- # 已命名成员
- st.subheader("已命名成员")
+ st.markdown('已命名成员
', unsafe_allow_html=True)
cursor.execute(
"""SELECT abstract_label, real_name, feature_description, first_seen_at, named_at, named_by
FROM family_members
@@ -377,17 +818,25 @@ elif page == "👤 成员命名":
named = cursor.fetchall()
if not named:
- st.info("暂无已命名成员")
+ st.markdown(
+ '暂无已命名成员
', unsafe_allow_html=True)
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)
+ cards = []
+ for m in named:
+ cards.append(
+ f''
+ f'
'
+ f'{esc(m["real_name"])} '
+ f''
+ f'{esc(m["abstract_label"])}
'
+ f'
'
+ f'{esc(m["feature_description"] or "")}
'
+ f'
'
+ f'首次出现 {esc(serialize_datetime(m["first_seen_at"]))} · '
+ f'命名于 {esc(serialize_datetime(m["named_at"]))}
'
+ f'
')
+ st.markdown(f'{"".join(cards)}
',
+ unsafe_allow_html=True)
finally:
conn.close()
@@ -397,14 +846,14 @@ elif page == "👤 成员命名":
# 统计图表页
# ============================================================
elif page == "📈 统计图表":
- st.title("📈 统计图表")
+ page_header('📈', '统计图表', '模型来源 / 关注事件 / 任务状态')
conn = get_db_conn()
try:
cursor = conn.cursor()
- # compute_provider 分布
- st.subheader("模型来源分布")
+ st.markdown('模型来源分布
', unsafe_allow_html=True)
try:
cursor.execute("""
SELECT JSON_UNQUOTE(JSON_EXTRACT(item, '$')) AS provider, COUNT(*) AS count
@@ -420,14 +869,13 @@ elif page == "📈 统计图表":
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)
+ st.bar_chart({r['provider']: r['count'] for r in stats})
else:
- st.info("暂无统计数据")
+ st.markdown('暂无统计数据
',
+ unsafe_allow_html=True)
- # 关注事件统计
- st.subheader("关注事件统计")
+ st.markdown('关注事件统计
', unsafe_allow_html=True)
cursor.execute("""
SELECT DATE(frame_timestamp) AS date, person, COUNT(*) AS count
FROM event_details
@@ -437,13 +885,15 @@ elif page == "📈 统计图表":
""")
attention = cursor.fetchall()
if attention:
+ import pandas as pd
df_att = pd.DataFrame(attention)
st.dataframe(df_att, use_container_width=True, hide_index=True)
else:
- st.info("暂无关注事件")
+ st.markdown('暂无关注事件
',
+ unsafe_allow_html=True)
- # 任务统计
- st.subheader("任务状态统计")
+ st.markdown('任务状态统计
', unsafe_allow_html=True)
cursor.execute("""
SELECT status, COUNT(*) AS count
FROM process_tasks
@@ -451,11 +901,10 @@ elif page == "📈 统计图表":
""")
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)
+ st.bar_chart({r['status']: r['count'] for r in task_stats})
else:
- st.info("暂无任务数据")
+ st.markdown('暂无任务数据
',
+ unsafe_allow_html=True)
finally:
conn.close()