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'
{s.get("ev", 0)}
监控事件
' + f'
{s.get("fr", 0)}
关键帧
' + f'
{s.get("ps", 0)}
出现人物
' + f'
{s.get("att", 0)}
需关注
' + 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'
🤖 回答
' + 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()