feat: UI 时间轴重构 + 关键帧图片全链路持久化
界面重做(深色监控面板主题):
- 事件时间轴页: 左侧事件列表卡片 + 右侧关键帧时间轴
(时间点 + 视频帧 + 人物/动作/衣着摘要 + 关注标记)
- 全局 CSS: 深色主题、卡片化按钮、统计卡、对话气泡、成员卡片
- 侧边栏任务队列状态徽章; 历史帧无图时优雅降级占位
关键帧持久化链路:
- Edge orchestrator: process_push_task 成功后把 keyframes
base64 注入 frame_details[i].frame_image (位置对齐视觉输入帧)
- NAS event_receiver: 落库前 base64 解码写盘到
fam-ui/static/frames/event_{id}/frame_{idx}.jpg
- payload 增量 ~300KB/事件 (6帧 jpeg q80), 队列/拉取均无压力
配置: fam-core/fam-ui 新增 storage.frame_image_dir
This commit is contained in:
@@ -47,3 +47,7 @@ chat_handler:
|
|||||||
# 智能问答统一走 FAM-Edge 编排端点(Gemini → NVIDIA → 本地 Ollama 兜底)
|
# 智能问答统一走 FAM-Edge 编排端点(Gemini → NVIDIA → 本地 Ollama 兜底)
|
||||||
qa_url: "http://129.146.203.203:5000/api/edge/chat/ask"
|
qa_url: "http://129.146.203.203:5000/api/edge/chat/ask"
|
||||||
timeout: 120
|
timeout: 120
|
||||||
|
|
||||||
|
storage:
|
||||||
|
# 关键帧落盘目录(event_receiver 写入,fam-ui 读取展示时间轴)
|
||||||
|
frame_image_dir: "/volume1/web/sentinel-home-ai/fam-ui/static/frames"
|
||||||
|
|||||||
@@ -36,3 +36,7 @@ chat_handler:
|
|||||||
ollama_url: "http://100.x.x.20:11434/api/generate"
|
ollama_url: "http://100.x.x.20:11434/api/generate"
|
||||||
model_name: "llava-phi3"
|
model_name: "llava-phi3"
|
||||||
timeout: 120
|
timeout: 120
|
||||||
|
|
||||||
|
storage:
|
||||||
|
# 关键帧落盘目录(event_receiver 写入,fam-ui 读取展示时间轴)
|
||||||
|
frame_image_dir: "/volume1/web/sentinel-home-ai/fam-ui/static/frames"
|
||||||
|
|||||||
@@ -3,21 +3,31 @@ Event-Receiver - Flask 蓝图,接收 Edge 回调,写库
|
|||||||
|
|
||||||
处理逻辑:
|
处理逻辑:
|
||||||
1. 成功回调: 插入 monitor_events 1 条 + 遍历 frame_details 逐条插入 event_details
|
1. 成功回调: 插入 monitor_events 1 条 + 遍历 frame_details 逐条插入 event_details
|
||||||
2. 对未命名的 abstract_label 自动 upsert 到 family_members
|
2. frame_details 携带的关键帧 base64 落盘到 fam-ui 静态目录(供时间轴展示)
|
||||||
3. 更新 process_tasks 状态为 SUCCESS
|
3. 对未命名的 abstract_label 自动 upsert 到 family_members
|
||||||
4. 失败回调: 更新任务状态为 FAILED,记录 failure_stage
|
4. 更新 process_tasks 状态为 SUCCESS
|
||||||
|
5. 失败回调: 更新任务状态为 FAILED,记录 failure_stage
|
||||||
"""
|
"""
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import json
|
import json
|
||||||
|
import base64
|
||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
|
|
||||||
from ..logger import setup_logger
|
from ..logger import setup_logger
|
||||||
|
from ..config_loader import load_config
|
||||||
from .. import db_layer
|
from .. import db_layer
|
||||||
|
|
||||||
logger = setup_logger('fam-core.event_receiver')
|
logger = setup_logger('fam-core.event_receiver')
|
||||||
|
|
||||||
event_bp = Blueprint('event_receiver', __name__)
|
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
|
# 匹配 "人物A" / "人物B" 等 abstract_label
|
||||||
_ABSTRACT_LABEL_PATTERN = re.compile(r'^人物[A-Z]$')
|
_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))
|
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):
|
def _upsert_abstract_members(frame_details: list):
|
||||||
"""对未命名的 abstract_label 自动 upsert 到 family_members"""
|
"""对未命名的 abstract_label 自动 upsert 到 family_members"""
|
||||||
seen = {}
|
seen = {}
|
||||||
@@ -63,8 +95,14 @@ def apply_success_event(task_id, data: dict) -> int:
|
|||||||
compute_provider=data.get('compute_provider', [])
|
compute_provider=data.get('compute_provider', [])
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. 遍历 frame_details 逐条插入
|
# 2. 关键帧图片落盘(先落盘再入库:pop 掉 base64 后 insert,避免大字段进 DB)
|
||||||
frame_details = data.get('frame_details', [])
|
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:
|
for frame in frame_details:
|
||||||
db_layer.insert_event_detail(
|
db_layer.insert_event_detail(
|
||||||
event_id=event_id,
|
event_id=event_id,
|
||||||
|
|||||||
@@ -171,6 +171,18 @@ class AIOrchestrator:
|
|||||||
adapter.get_circuit_breaker().record_failure()
|
adapter.get_circuit_breaker().record_failure()
|
||||||
return model_outputs
|
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,
|
def format_cloud_result(self, provider: str, raw_result: dict,
|
||||||
known_members_context: str = '',
|
known_members_context: str = '',
|
||||||
task_id: int = 0) -> dict:
|
task_id: int = 0) -> dict:
|
||||||
@@ -481,6 +493,10 @@ class AIOrchestrator:
|
|||||||
fusion_result = self.format_cloud_result(
|
fusion_result = self.format_cloud_result(
|
||||||
provider, model_outputs[provider], known_members, task_id)
|
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)
|
total_ms = int((time.time() - start_time) * 1000)
|
||||||
log_task(logger, task_id, 'overall', '推送任务完成', duration_ms=total_ms)
|
log_task(logger, task_id, 'overall', '推送任务完成', duration_ms=total_ms)
|
||||||
|
|
||||||
@@ -492,7 +508,7 @@ class AIOrchestrator:
|
|||||||
"camera_name": task_data.get('camera_name', ''),
|
"camera_name": task_data.get('camera_name', ''),
|
||||||
"global_summary": fusion_result.get('global_summary', ''),
|
"global_summary": fusion_result.get('global_summary', ''),
|
||||||
"entities_json": fusion_result.get('entities_json', []),
|
"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', []),
|
"compute_provider": fusion_result.get('compute_provider', []),
|
||||||
"error_message": None
|
"error_message": None
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,3 +10,7 @@ database:
|
|||||||
password: "iLoveJava5!"
|
password: "iLoveJava5!"
|
||||||
database: "sentinel_home_ai"
|
database: "sentinel_home_ai"
|
||||||
unix_socket: "/run/mysqld/mysqld10.sock"
|
unix_socket: "/run/mysqld/mysqld10.sock"
|
||||||
|
|
||||||
|
storage:
|
||||||
|
# 关键帧目录(fam-core event_receiver 落盘,UI 读取展示时间轴)
|
||||||
|
frame_image_dir: "/volume1/web/sentinel-home-ai/fam-ui/static/frames"
|
||||||
|
|||||||
@@ -1,28 +1,32 @@
|
|||||||
"""
|
"""
|
||||||
FAM-UI - 家庭多模态智能监控系统前端
|
FAM-UI - 家庭多模态智能监控系统前端 v2
|
||||||
|
|
||||||
Streamlit 直读 MariaDB,展示:
|
深色监控面板主题 + 关键帧时间轴:
|
||||||
- 事件列表 + compute_provider 占比 + AI 对话页 + 对话历史 + 成员命名页
|
- 事件时间轴页: 左侧事件列表,右侧时间轴(时间点 + 关键帧 + 摘要)
|
||||||
|
- AI 对话 / 对话历史 / 成员命名 / 统计图表
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import json
|
||||||
|
import base64
|
||||||
|
import html as _html
|
||||||
import requests
|
import requests
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
import pymysql
|
import pymysql
|
||||||
import pymysql.cursors
|
import pymysql.cursors
|
||||||
import pandas as pd
|
|
||||||
import json
|
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
|
|
||||||
# 添加共享模块路径
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||||
|
|
||||||
from config_loader import load_config
|
from config_loader import load_config
|
||||||
|
|
||||||
# 加载配置
|
|
||||||
_cfg = load_config()
|
_cfg = load_config()
|
||||||
_core_url = _cfg.get('core_url', 'http://127.0.0.1:8000')
|
_core_url = _cfg.get('core_url', 'http://127.0.0.1:8000')
|
||||||
_db_cfg = _cfg.get('database', {})
|
_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():
|
def get_db_conn():
|
||||||
@@ -45,8 +49,30 @@ def serialize_datetime(obj):
|
|||||||
return str(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(
|
st.set_page_config(
|
||||||
page_title="家庭智能监控",
|
page_title="家庭智能监控",
|
||||||
@@ -55,136 +81,536 @@ st.set_page_config(
|
|||||||
initial_sidebar_state="expanded"
|
initial_sidebar_state="expanded"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 侧边栏导航
|
GLOBAL_CSS = """
|
||||||
st.sidebar.title("🏠 家庭智能监控")
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0b0e14;
|
||||||
|
--panel: #131826;
|
||||||
|
--panel-2: #161b28;
|
||||||
|
--border: #232a3b;
|
||||||
|
--border-hi: #33405c;
|
||||||
|
--text: #e2e8f0;
|
||||||
|
--text-dim: #8b93a7;
|
||||||
|
--text-mute: #64748b;
|
||||||
|
--accent: #5b8cff;
|
||||||
|
--accent-soft: rgba(91,140,255,.15);
|
||||||
|
--danger: #ff6b6b;
|
||||||
|
--ok: #34d399;
|
||||||
|
}
|
||||||
|
.stApp { background: var(--bg); color: var(--text); }
|
||||||
|
#MainMenu, footer, header { visibility: hidden; }
|
||||||
|
h1, h2, h3 { color: #f1f5f9 !important; font-weight: 700 !important; }
|
||||||
|
a { color: var(--accent) !important; }
|
||||||
|
section[data-testid="stSidebar"] {
|
||||||
|
background: #0e1220 !important;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
[data-testid="stSidebar"] * { color: var(--text-dim) !important; }
|
||||||
|
[data-testid="stSidebar"] .stRadio label { font-size: 14px; }
|
||||||
|
|
||||||
|
/* Streamlit 组件暗色适配 */
|
||||||
|
.stTabs [data-baseweb="tab"] { color: var(--text-dim); }
|
||||||
|
.stTabs [aria-selected="true"] { color: #fff !important; }
|
||||||
|
div[data-testid="stDataFrame"] { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
|
||||||
|
.stProgress > div > div { background: var(--accent); }
|
||||||
|
hr { border-color: var(--border) !important; }
|
||||||
|
|
||||||
|
/* 输入组件 */
|
||||||
|
.stTextInput > div > div > input, .stTextArea textarea {
|
||||||
|
background: var(--panel) !important;
|
||||||
|
border: 1px solid var(--border) !important;
|
||||||
|
color: var(--text) !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
}
|
||||||
|
.stTextInput > div > div > input:focus, .stTextArea textarea:focus {
|
||||||
|
border-color: var(--accent) !important;
|
||||||
|
box-shadow: 0 0 0 2px var(--accent-soft) !important;
|
||||||
|
}
|
||||||
|
div[data-testid="stDateInput"] > div > div > input {
|
||||||
|
background: var(--panel) !important;
|
||||||
|
border: 1px solid var(--border) !important;
|
||||||
|
color: var(--text) !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 按钮卡片化 */
|
||||||
|
div[data-testid="stButton"] > button {
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text-dim);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: all .18s ease;
|
||||||
|
}
|
||||||
|
div[data-testid="stButton"] > button:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
background: #182034;
|
||||||
|
box-shadow: 0 4px 14px rgba(91,140,255,.12);
|
||||||
|
}
|
||||||
|
div[data-testid="stButton"] > button[kind="primary"],
|
||||||
|
.stButton > button[kind="primary"] {
|
||||||
|
background: linear-gradient(135deg, #3d6bff, #5b8cff);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 4px 14px rgba(91,140,255,.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 页面标题 */
|
||||||
|
.fam-pagehead {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
margin: 4px 0 20px 0;
|
||||||
|
}
|
||||||
|
.fam-pagehead .fp-ico {
|
||||||
|
width: 42px; height: 42px; border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, rgba(91,140,255,.25), rgba(124,91,255,.18));
|
||||||
|
border: 1px solid rgba(91,140,255,.35);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 21px;
|
||||||
|
}
|
||||||
|
.fam-pagehead .fp-title { font-size: 21px; font-weight: 700; color: #f1f5f9; }
|
||||||
|
.fam-pagehead .fp-sub { font-size: 12px; color: var(--text-mute); margin-top: 2px; }
|
||||||
|
|
||||||
|
/* 统计卡 */
|
||||||
|
.stat-row { display: flex; gap: 14px; margin: 0 0 22px 0; flex-wrap: wrap; }
|
||||||
|
.stat-card {
|
||||||
|
flex: 1; min-width: 130px;
|
||||||
|
background: linear-gradient(180deg, var(--panel-2), var(--panel));
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
}
|
||||||
|
.stat-card .s-val {
|
||||||
|
font-size: 26px; font-weight: 700; color: #f1f5f9;
|
||||||
|
font-variant-numeric: tabular-nums; line-height: 1.2;
|
||||||
|
}
|
||||||
|
.stat-card .s-label { font-size: 12px; color: var(--text-mute); margin-top: 3px; }
|
||||||
|
.stat-card.warn .s-val { color: #ff9b9b; }
|
||||||
|
.stat-card.ok .s-val { color: #6ee7b7; }
|
||||||
|
|
||||||
|
/* 事件头卡片 */
|
||||||
|
.ev-head {
|
||||||
|
background: linear-gradient(135deg, rgba(91,140,255,.12), rgba(124,91,255,.07));
|
||||||
|
border: 1px solid #26304a;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 18px 22px;
|
||||||
|
margin-bottom: 26px;
|
||||||
|
}
|
||||||
|
.ev-head .ev-title { font-size: 18px; font-weight: 700; color: #f1f5f9; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.ev-head .ev-range {
|
||||||
|
font-size: 13px; color: var(--text-dim); margin-top: 6px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.ev-head .ev-summary { margin-top: 10px; font-size: 14px; color: #cbd5e1; line-height: 1.65; }
|
||||||
|
|
||||||
|
/* 徽章 */
|
||||||
|
.bdg {
|
||||||
|
display: inline-block; font-size: 11px; padding: 3px 10px;
|
||||||
|
border-radius: 999px; font-weight: 600; line-height: 1.4;
|
||||||
|
}
|
||||||
|
.bdg-person { background: var(--accent-soft); color: #8fb0ff; border: 1px solid rgba(91,140,255,.32); }
|
||||||
|
.bdg-att { background: rgba(255,107,107,.14); color: #ff9b9b; border: 1px solid rgba(255,107,107,.35); }
|
||||||
|
.bdg-cam { background: rgba(52,211,153,.12); color: #6ee7b7; border: 1px solid rgba(52,211,153,.3); }
|
||||||
|
.bdg-model { background: rgba(148,163,184,.12); color: #a8b3c7; border: 1px solid rgba(148,163,184,.28); }
|
||||||
|
|
||||||
|
/* ===== 关键帧时间轴 ===== */
|
||||||
|
.fam-timeline { margin-top: 4px; }
|
||||||
|
.tl-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 86px 34px 1fr;
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 26px;
|
||||||
|
}
|
||||||
|
.tl-item:last-child { padding-bottom: 4px; }
|
||||||
|
.tl-time { padding: 18px 8px 0 0; text-align: right; }
|
||||||
|
.tl-time .hm {
|
||||||
|
display: block; font-size: 16px; font-weight: 700; color: #cbd5e1;
|
||||||
|
font-variant-numeric: tabular-nums; line-height: 1.15;
|
||||||
|
}
|
||||||
|
.tl-time .ss { font-size: 11px; color: var(--text-mute); font-variant-numeric: tabular-nums; }
|
||||||
|
.tl-rail { position: relative; }
|
||||||
|
.tl-rail::before {
|
||||||
|
content: ''; position: absolute; left: 50%; top: 0; bottom: -26px;
|
||||||
|
width: 2px; margin-left: -1px; background: #1e2740;
|
||||||
|
}
|
||||||
|
.tl-item:last-child .tl-rail::before { display: none; }
|
||||||
|
.tl-dot {
|
||||||
|
position: absolute; left: 50%; top: 21px;
|
||||||
|
width: 11px; height: 11px; margin-left: -5.5px;
|
||||||
|
border-radius: 50%; background: var(--accent);
|
||||||
|
box-shadow: 0 0 0 4px rgba(91,140,255,.16);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.tl-item.attention .tl-dot { background: var(--danger); box-shadow: 0 0 0 4px rgba(255,107,107,.16); }
|
||||||
|
.tl-card {
|
||||||
|
background: linear-gradient(180deg, var(--panel-2), var(--panel));
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
display: flex; gap: 14px;
|
||||||
|
transition: border-color .18s ease, transform .18s ease, box-shadow .18s ease;
|
||||||
|
}
|
||||||
|
.tl-card:hover { border-color: var(--border-hi); transform: translateX(3px); box-shadow: 0 6px 20px rgba(0,0,0,.35); }
|
||||||
|
.tl-item.attention .tl-card { border-color: rgba(255,107,107,.38); }
|
||||||
|
.tl-img {
|
||||||
|
width: 224px; height: 126px; border-radius: 10px;
|
||||||
|
object-fit: cover; background: #0d1019; flex-shrink: 0;
|
||||||
|
border: 1px solid #1c2233;
|
||||||
|
}
|
||||||
|
.tl-noimg {
|
||||||
|
width: 224px; height: 126px; border-radius: 10px; flex-shrink: 0;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, #12161f 25%, #0f1320 25%, #0f1320 50%, #12161f 50%, #12161f 75%, #0f1320 75%);
|
||||||
|
background-size: 14px 14px;
|
||||||
|
border: 1px dashed #26304a;
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
color: #475069; font-size: 12px; gap: 6px;
|
||||||
|
}
|
||||||
|
.tl-info { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||||
|
.tl-badges { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 9px; }
|
||||||
|
.tl-action { font-size: 14px; color: var(--text); line-height: 1.6; flex: 1; }
|
||||||
|
.tl-action .no-action { color: var(--text-mute); }
|
||||||
|
.tl-meta { margin-top: 9px; font-size: 12px; color: var(--text-mute); }
|
||||||
|
|
||||||
|
/* 空态 */
|
||||||
|
.fam-empty {
|
||||||
|
border: 1px dashed var(--border); border-radius: 14px;
|
||||||
|
padding: 40px 20px; text-align: center; color: var(--text-mute);
|
||||||
|
font-size: 14px; background: rgba(19,24,38,.5);
|
||||||
|
}
|
||||||
|
.fam-empty .fe-ico { font-size: 30px; display: block; margin-bottom: 10px; opacity: .55; }
|
||||||
|
|
||||||
|
/* 对话气泡 */
|
||||||
|
.chat-q, .chat-a {
|
||||||
|
border-radius: 14px; padding: 14px 18px; margin-bottom: 14px;
|
||||||
|
font-size: 14px; line-height: 1.7;
|
||||||
|
}
|
||||||
|
.chat-q { background: var(--accent-soft); border: 1px solid rgba(91,140,255,.28); }
|
||||||
|
.chat-a { background: var(--panel); border: 1px solid var(--border); }
|
||||||
|
.chat-who { font-size: 11px; color: var(--text-mute); margin-bottom: 6px; }
|
||||||
|
|
||||||
|
/* 成员卡 */
|
||||||
|
.member-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 14px; }
|
||||||
|
.member-card {
|
||||||
|
background: var(--panel); border: 1px solid var(--border); border-radius: 14px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.tl-card { flex-direction: column; }
|
||||||
|
.tl-img, .tl-noimg { width: 100%; height: 180px; }
|
||||||
|
.stat-card { min-width: 45%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def page_header(icon: str, title: str, sub: str = ''):
|
||||||
|
st.markdown(
|
||||||
|
f'<div class="fam-pagehead">'
|
||||||
|
f'<div class="fp-ico">{icon}</div>'
|
||||||
|
f'<div><div class="fp-title">{esc(title)}</div>'
|
||||||
|
f'{f"<div class=fp-sub>{esc(sub)}</div>" if sub else ""}'
|
||||||
|
f'</div></div>',
|
||||||
|
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'<img class="tl-img" alt="关键帧" '
|
||||||
|
f'src="data:image/jpeg;base64,{img_b64}">')
|
||||||
|
else:
|
||||||
|
img_html = ('<div class="tl-noimg"><span>📷</span>暂无帧图</div>')
|
||||||
|
|
||||||
|
badges = [f'<span class="bdg bdg-person">{esc(person)}</span>']
|
||||||
|
if attention:
|
||||||
|
badges.append('<span class="bdg bdg-att">⚠ 需关注</span>')
|
||||||
|
badge_html = ''.join(badges)
|
||||||
|
|
||||||
|
action_html = esc(action) if action else '<span class="no-action">未识别到明显活动</span>'
|
||||||
|
meta_parts = []
|
||||||
|
if clothing:
|
||||||
|
meta_parts.append(f'衣着: {esc(clothing)}')
|
||||||
|
if model:
|
||||||
|
meta_parts.append(f'模型: {esc(model)}')
|
||||||
|
meta_html = f'<div class="tl-meta">{" · ".join(meta_parts)}</div>' if meta_parts else ''
|
||||||
|
|
||||||
|
items.append(
|
||||||
|
f'<div class="tl-item {"attention" if attention else ""}">'
|
||||||
|
f'<div class="tl-time"><span class="hm">{hm}</span><span class="ss">{ss}</span></div>'
|
||||||
|
f'<div class="tl-rail"><span class="tl-dot"></span></div>'
|
||||||
|
f'<div class="tl-card">{img_html}'
|
||||||
|
f'<div class="tl-info"><div class="tl-badges">{badge_html}</div>'
|
||||||
|
f'<div class="tl-action">{action_html}</div>{meta_html}</div>'
|
||||||
|
f'</div></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
st.markdown(
|
||||||
|
f'<div class="fam-timeline">{"".join(items)}</div>',
|
||||||
|
unsafe_allow_html=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 侧边栏
|
||||||
|
# ============================================================
|
||||||
|
st.markdown(GLOBAL_CSS, unsafe_allow_html=True)
|
||||||
|
|
||||||
|
st.sidebar.markdown(
|
||||||
|
'<div style="padding:6px 2px 14px 2px;">'
|
||||||
|
'<div style="font-size:17px;font-weight:700;color:#f1f5f9;">🏠 家庭智能监控</div>'
|
||||||
|
'<div style="font-size:11px;color:#64748b;margin-top:3px;">SENTINEL HOME AI</div>'
|
||||||
|
'</div>', unsafe_allow_html=True)
|
||||||
|
|
||||||
page = st.sidebar.radio("功能页面", [
|
page = st.sidebar.radio("功能页面", [
|
||||||
"📊 事件列表",
|
"🕒 事件时间轴",
|
||||||
"💬 AI 对话",
|
"💬 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'<div style="font-size:11px;color:#64748b;line-height:2.1;">'
|
||||||
|
f'任务队列<br>'
|
||||||
|
f'待处理 <b style="color:#cbd5e1">{pending}</b> · '
|
||||||
|
f'处理中 <b style="color:#8fb0ff">{processing}</b> · '
|
||||||
|
f'失败 <b style="color:#ff9b9b">{failed}</b>'
|
||||||
|
f'</div>', unsafe_allow_html=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# 事件列表页
|
# 事件时间轴页
|
||||||
# ============================================================
|
# ============================================================
|
||||||
if page == "📊 事件列表":
|
if page == "🕒 事件时间轴":
|
||||||
st.title("📊 监控事件列表")
|
page_header('🕒', '事件时间轴', '关键时间点 · 视频帧 · 信息摘要')
|
||||||
|
|
||||||
# 日期筛选
|
with st.container():
|
||||||
col1, col2 = st.columns([1, 3])
|
col_date, col_sp = st.columns([1, 3])
|
||||||
with col1:
|
with col_date:
|
||||||
date_filter = st.date_input("日期筛选", value=None)
|
date_filter = st.date_input("日期", value=None)
|
||||||
|
|
||||||
# 分页
|
date_str = date_filter.isoformat() if date_filter else None
|
||||||
page_size = 20
|
|
||||||
|
# 统计卡
|
||||||
|
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'<div class="stat-row">'
|
||||||
|
f'<div class="stat-card"><div class="s-val">{s.get("ev", 0)}</div><div class="s-label">监控事件</div></div>'
|
||||||
|
f'<div class="stat-card"><div class="s-val">{s.get("fr", 0)}</div><div class="s-label">关键帧</div></div>'
|
||||||
|
f'<div class="stat-card ok"><div class="s-val">{s.get("ps", 0)}</div><div class="s-label">出现人物</div></div>'
|
||||||
|
f'<div class="stat-card warn"><div class="s-val">{s.get("att", 0)}</div><div class="s-label">需关注</div></div>'
|
||||||
|
f'</div>', unsafe_allow_html=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 事件列表
|
||||||
|
page_size = 15
|
||||||
if 'event_page' not in st.session_state:
|
if 'event_page' not in st.session_state:
|
||||||
st.session_state.event_page = 0
|
st.session_state.event_page = 0
|
||||||
|
|
||||||
offset = st.session_state.event_page * page_size
|
offset = st.session_state.event_page * page_size
|
||||||
|
|
||||||
conn = get_db_conn()
|
conn = get_db_conn()
|
||||||
try:
|
try:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
# 查询条件
|
|
||||||
date_str = date_filter.isoformat() if date_filter else None
|
|
||||||
|
|
||||||
if date_str:
|
if date_str:
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"""SELECT me.event_id, me.task_id, me.event_start_time, me.event_end_time,
|
"""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
|
(SELECT COUNT(*) FROM event_details ed WHERE ed.event_id = me.event_id) AS detail_count
|
||||||
FROM monitor_events me
|
FROM monitor_events me
|
||||||
WHERE DATE(me.event_start_time) = %s
|
WHERE DATE(me.event_start_time) = %s
|
||||||
ORDER BY me.event_start_time DESC
|
ORDER BY me.event_start_time DESC
|
||||||
LIMIT %s OFFSET %s""",
|
LIMIT %s OFFSET %s""",
|
||||||
(date_str, page_size, offset)
|
(date_str, page_size, offset))
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"""SELECT me.event_id, me.task_id, me.event_start_time, me.event_end_time,
|
"""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
|
(SELECT COUNT(*) FROM event_details ed WHERE ed.event_id = me.event_id) AS detail_count
|
||||||
FROM monitor_events me
|
FROM monitor_events me
|
||||||
ORDER BY me.event_start_time DESC
|
ORDER BY me.event_start_time DESC
|
||||||
LIMIT %s OFFSET %s""",
|
LIMIT %s OFFSET %s""",
|
||||||
(page_size, offset)
|
(page_size, offset))
|
||||||
)
|
|
||||||
|
|
||||||
events = cursor.fetchall()
|
events = cursor.fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
if not events:
|
if not events:
|
||||||
st.info("暂无事件数据")
|
st.markdown(
|
||||||
|
'<div class="fam-empty"><span class="fe-ico">🗓</span>'
|
||||||
|
'该日期暂无监控事件</div>', unsafe_allow_html=True)
|
||||||
else:
|
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'<div style="font-size:13px;font-weight:600;color:#8b93a7;'
|
||||||
|
f'margin-bottom:10px;">事件列表 · {len(events)} 条</div>',
|
||||||
|
unsafe_allow_html=True)
|
||||||
for ev in events:
|
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'<div style="font-size:11px;color:#64748b;margin:-4px 2px 12px 2px;'
|
||||||
|
f'line-height:1.5;">{esc(summary_short)}</div>',
|
||||||
|
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', '[]')
|
providers = ev.get('compute_provider', '[]')
|
||||||
if isinstance(providers, str):
|
if isinstance(providers, str):
|
||||||
|
try:
|
||||||
providers = json.loads(providers)
|
providers = json.loads(providers)
|
||||||
|
except ValueError:
|
||||||
|
providers = []
|
||||||
|
model_badges = ''.join(
|
||||||
|
f'<span class="bdg bdg-model">{esc(p)}</span>' for p in (providers or []))
|
||||||
|
|
||||||
with st.container():
|
st.markdown(
|
||||||
col1, col2, col3 = st.columns([2, 1, 1])
|
f'<div class="ev-head">'
|
||||||
with col1:
|
f'<div class="ev-title">'
|
||||||
start_time = serialize_datetime(ev['event_start_time'])
|
f'<span>{esc(ev.get("camera_name") or "未知摄像头")}</span>'
|
||||||
end_time = serialize_datetime(ev['event_end_time'])
|
f'<span class="bdg bdg-cam">事件 #{ev["event_id"]}</span>'
|
||||||
st.markdown(f"**{ev['camera_name'] or '未知摄像头'}** | {start_time} ~ {end_time}")
|
f'{model_badges}</div>'
|
||||||
st.markdown(f"_{ev['global_summary']}_")
|
f'<div class="ev-range">⏱ {esc(range_str)}</div>'
|
||||||
with col2:
|
f'<div class="ev-summary">{esc(ev.get("global_summary") or "暂无全局摘要")}</div>'
|
||||||
st.markdown(f"明细: {ev['detail_count']} 条")
|
f'</div>', unsafe_allow_html=True)
|
||||||
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']
|
|
||||||
|
|
||||||
# 展开明细
|
conn = get_db_conn()
|
||||||
if st.session_state.get('selected_event_id') == ev['event_id']:
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"""SELECT frame_index, frame_timestamp, camera_name, person,
|
"""SELECT frame_index, frame_timestamp, camera_name, person,
|
||||||
action, clothing, is_attention_event, source_providers
|
action, clothing, is_attention_event, source_providers
|
||||||
FROM event_details
|
FROM event_details
|
||||||
WHERE event_id = %s
|
WHERE event_id = %s
|
||||||
ORDER BY frame_index ASC""",
|
ORDER BY frame_index ASC""",
|
||||||
(ev['event_id'],)
|
(selected_id,))
|
||||||
)
|
|
||||||
details = cursor.fetchall()
|
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:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
if details:
|
||||||
|
render_timeline(details, selected_id)
|
||||||
|
else:
|
||||||
|
st.markdown(
|
||||||
|
'<div class="fam-empty"><span class="fe-ico">🎞</span>'
|
||||||
|
'该事件暂无关键帧明细</div>', unsafe_allow_html=True)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# AI 对话页
|
# AI 对话页
|
||||||
# ============================================================
|
# ============================================================
|
||||||
elif page == "💬 AI 对话":
|
elif page == "💬 AI 对话":
|
||||||
st.title("💬 AI 对话")
|
page_header('💬', 'AI 对话', '基于监控数据的智能问答')
|
||||||
|
|
||||||
# 快捷人物按钮
|
|
||||||
conn = get_db_conn()
|
conn = get_db_conn()
|
||||||
try:
|
try:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
@@ -199,7 +625,6 @@ elif page == "💬 AI 对话":
|
|||||||
with col2:
|
with col2:
|
||||||
queried_date = st.date_input("查询日期", value=date.today())
|
queried_date = st.date_input("查询日期", value=date.today())
|
||||||
|
|
||||||
# 快捷提问
|
|
||||||
if named:
|
if named:
|
||||||
quick_person = st.selectbox("快捷选择成员", [""] + named)
|
quick_person = st.selectbox("快捷选择成员", [""] + named)
|
||||||
if quick_person:
|
if quick_person:
|
||||||
@@ -211,7 +636,6 @@ elif page == "💬 AI 对话":
|
|||||||
f"今天{queried_person}的活动时间线是什么?",
|
f"今天{queried_person}的活动时间线是什么?",
|
||||||
]
|
]
|
||||||
selected_quick = st.selectbox("快捷提问", ["自定义"] + quick_questions)
|
selected_quick = st.selectbox("快捷提问", ["自定义"] + quick_questions)
|
||||||
|
|
||||||
user_question = st.text_area("你的问题", value=selected_quick if selected_quick != "自定义" else "")
|
user_question = st.text_area("你的问题", value=selected_quick if selected_quick != "自定义" else "")
|
||||||
|
|
||||||
if st.button("提问", type="primary"):
|
if st.button("提问", type="primary"):
|
||||||
@@ -233,9 +657,15 @@ elif page == "💬 AI 对话":
|
|||||||
)
|
)
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
st.success("回答:")
|
st.markdown(
|
||||||
|
f'<div class="chat-q"><div class="chat-who">❓ 提问</div>'
|
||||||
|
f'{esc(user_question)}</div>', unsafe_allow_html=True)
|
||||||
|
st.markdown(
|
||||||
|
f'<div class="chat-a"><div class="chat-who">🤖 回答</div>'
|
||||||
|
f'</div>', unsafe_allow_html=True)
|
||||||
st.markdown(data['answer'])
|
st.markdown(data['answer'])
|
||||||
st.caption(f"上下文: {data.get('context_summary', '')}")
|
if data.get('context_summary'):
|
||||||
|
st.caption(f"上下文: {data['context_summary']}")
|
||||||
else:
|
else:
|
||||||
st.error(f"请求失败: {resp.status_code} {resp.text}")
|
st.error(f"请求失败: {resp.status_code} {resp.text}")
|
||||||
except requests.ConnectionError:
|
except requests.ConnectionError:
|
||||||
@@ -248,7 +678,7 @@ elif page == "💬 AI 对话":
|
|||||||
# 对话历史页
|
# 对话历史页
|
||||||
# ============================================================
|
# ============================================================
|
||||||
elif page == "📝 对话历史":
|
elif page == "📝 对话历史":
|
||||||
st.title("📝 对话历史")
|
page_header('📝', '对话历史', '历史问答记录')
|
||||||
|
|
||||||
if 'chat_page' not in st.session_state:
|
if 'chat_page' not in st.session_state:
|
||||||
st.session_state.chat_page = 0
|
st.session_state.chat_page = 0
|
||||||
@@ -268,52 +698,55 @@ elif page == "📝 对话历史":
|
|||||||
(page_size, offset)
|
(page_size, offset)
|
||||||
)
|
)
|
||||||
history = cursor.fetchall()
|
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:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
if not history:
|
||||||
|
st.markdown(
|
||||||
|
'<div class="fam-empty"><span class="fe-ico">💬</span>'
|
||||||
|
'暂无对话记录</div>', unsafe_allow_html=True)
|
||||||
|
else:
|
||||||
|
for h in history:
|
||||||
|
created = serialize_datetime(h['created_at'])
|
||||||
|
st.markdown(
|
||||||
|
f'<div class="chat-q"><div class="chat-who">'
|
||||||
|
f'👤 {esc(h.get("queried_person") or "未知")} · {esc(created)}</div>'
|
||||||
|
f'{esc(h["user_question"])}</div>',
|
||||||
|
unsafe_allow_html=True)
|
||||||
|
st.markdown(
|
||||||
|
f'<div class="chat-a"><div class="chat-who">🤖 回答</div></div>',
|
||||||
|
unsafe_allow_html=True)
|
||||||
|
st.markdown(h['ai_answer'] or '')
|
||||||
|
st.markdown('<div style="height:14px"></div>', 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'<div style="text-align:center;color:#64748b;font-size:13px;'
|
||||||
|
f'padding-top:8px;">第 {st.session_state.chat_page + 1} 页</div>',
|
||||||
|
unsafe_allow_html=True)
|
||||||
|
with nav3:
|
||||||
|
if st.button("下一页 →", disabled=len(history) < page_size):
|
||||||
|
st.session_state.chat_page += 1
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# 成员命名页
|
# 成员命名页
|
||||||
# ============================================================
|
# ============================================================
|
||||||
elif page == "👤 成员命名":
|
elif page == "👤 成员命名":
|
||||||
st.title("👤 家庭成员命名")
|
page_header('👤', '家庭成员命名', '为识别到的人物起名')
|
||||||
|
|
||||||
# 未命名成员
|
|
||||||
st.subheader("未命名人物")
|
|
||||||
conn = get_db_conn()
|
conn = get_db_conn()
|
||||||
try:
|
try:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
st.markdown('<div style="font-size:15px;font-weight:700;color:#f1f5f9;'
|
||||||
|
'margin:6px 0 14px 0;">未命名人物</div>', unsafe_allow_html=True)
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"""SELECT fm.abstract_label, fm.feature_description, fm.first_seen_at,
|
"""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
|
(SELECT COUNT(*) FROM event_details ed WHERE ed.person = fm.abstract_label) AS event_count
|
||||||
@@ -324,22 +757,31 @@ elif page == "👤 成员命名":
|
|||||||
unnamed = cursor.fetchall()
|
unnamed = cursor.fetchall()
|
||||||
|
|
||||||
if not unnamed:
|
if not unnamed:
|
||||||
st.info("所有人物已命名,或暂未发现新人物")
|
st.markdown(
|
||||||
|
'<div class="fam-empty"><span class="fe-ico">✅</span>'
|
||||||
|
'所有人物已命名,或暂未发现新人物</div>', unsafe_allow_html=True)
|
||||||
else:
|
else:
|
||||||
for m in unnamed:
|
for m in unnamed:
|
||||||
col1, col2, col3 = st.columns([2, 2, 1])
|
col1, col2, col3 = st.columns([2, 1.6, 1])
|
||||||
with col1:
|
with col1:
|
||||||
st.markdown(f"**{m['abstract_label']}**")
|
|
||||||
st.text(m['feature_description'] or '无特征描述')
|
|
||||||
first_seen = serialize_datetime(m['first_seen_at'])
|
first_seen = serialize_datetime(m['first_seen_at'])
|
||||||
st.caption(f"首次出现: {first_seen} | 事件数: {m['event_count']}")
|
st.markdown(
|
||||||
|
f'<div class="member-card">'
|
||||||
|
f'<div style="font-size:15px;font-weight:700;color:#8fb0ff;">'
|
||||||
|
f'{esc(m["abstract_label"])}</div>'
|
||||||
|
f'<div style="font-size:12px;color:#8b93a7;margin-top:6px;'
|
||||||
|
f'line-height:1.6;">{esc(m["feature_description"] or "无特征描述")}</div>'
|
||||||
|
f'<div style="font-size:11px;color:#64748b;margin-top:8px;">'
|
||||||
|
f'首次出现 {esc(first_seen)} · 事件数 {m["event_count"]}</div>'
|
||||||
|
f'</div>', unsafe_allow_html=True)
|
||||||
with col2:
|
with col2:
|
||||||
real_name = st.text_input(
|
real_name = st.text_input(
|
||||||
"输入名字", key=f"name_{m['abstract_label']}",
|
"输入名字", key=f"name_{m['abstract_label']}",
|
||||||
placeholder=f"为{m['abstract_label']}命名"
|
placeholder=f"为{m['abstract_label']}命名",
|
||||||
)
|
label_visibility="collapsed")
|
||||||
with col3:
|
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():
|
if real_name.strip():
|
||||||
try:
|
try:
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
@@ -354,9 +796,8 @@ elif page == "👤 成员命名":
|
|||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
result = resp.json()
|
result = resp.json()
|
||||||
st.success(
|
st.success(
|
||||||
f"命名成功!{m['abstract_label']} -> {real_name},"
|
f"命名成功!{m['abstract_label']} → {real_name},"
|
||||||
f"更新明细 {result.get('updated_event_details_count', 0)} 条"
|
f"更新明细 {result.get('updated_event_details_count', 0)} 条")
|
||||||
)
|
|
||||||
st.rerun()
|
st.rerun()
|
||||||
else:
|
else:
|
||||||
st.error(f"命名失败: {resp.status_code} {resp.text}")
|
st.error(f"命名失败: {resp.status_code} {resp.text}")
|
||||||
@@ -364,10 +805,10 @@ elif page == "👤 成员命名":
|
|||||||
st.error(f"异常: {e}")
|
st.error(f"异常: {e}")
|
||||||
else:
|
else:
|
||||||
st.warning("请输入名字")
|
st.warning("请输入名字")
|
||||||
st.divider()
|
st.markdown('<div style="height:8px"></div>', unsafe_allow_html=True)
|
||||||
|
|
||||||
# 已命名成员
|
st.markdown('<div style="font-size:15px;font-weight:700;color:#f1f5f9;'
|
||||||
st.subheader("已命名成员")
|
'margin:22px 0 14px 0;">已命名成员</div>', unsafe_allow_html=True)
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"""SELECT abstract_label, real_name, feature_description, first_seen_at, named_at, named_by
|
"""SELECT abstract_label, real_name, feature_description, first_seen_at, named_at, named_by
|
||||||
FROM family_members
|
FROM family_members
|
||||||
@@ -377,17 +818,25 @@ elif page == "👤 成员命名":
|
|||||||
named = cursor.fetchall()
|
named = cursor.fetchall()
|
||||||
|
|
||||||
if not named:
|
if not named:
|
||||||
st.info("暂无已命名成员")
|
st.markdown(
|
||||||
|
'<div class="fam-empty">暂无已命名成员</div>', unsafe_allow_html=True)
|
||||||
else:
|
else:
|
||||||
df = pd.DataFrame([{
|
cards = []
|
||||||
'抽象标识': m['abstract_label'],
|
for m in named:
|
||||||
'真实名字': m['real_name'],
|
cards.append(
|
||||||
'特征描述': m['feature_description'],
|
f'<div class="member-card">'
|
||||||
'首次出现': serialize_datetime(m['first_seen_at']),
|
f'<div style="font-size:15px;font-weight:700;color:#f1f5f9;">'
|
||||||
'命名时间': serialize_datetime(m['named_at']),
|
f'{esc(m["real_name"])} '
|
||||||
'命名人': m['named_by']
|
f'<span style="font-size:11px;color:#64748b;font-weight:400;">'
|
||||||
} for m in named])
|
f'{esc(m["abstract_label"])}</span></div>'
|
||||||
st.dataframe(df, use_container_width=True, hide_index=True)
|
f'<div style="font-size:12px;color:#8b93a7;margin-top:6px;line-height:1.6;">'
|
||||||
|
f'{esc(m["feature_description"] or "")}</div>'
|
||||||
|
f'<div style="font-size:11px;color:#64748b;margin-top:8px;">'
|
||||||
|
f'首次出现 {esc(serialize_datetime(m["first_seen_at"]))} · '
|
||||||
|
f'命名于 {esc(serialize_datetime(m["named_at"]))}</div>'
|
||||||
|
f'</div>')
|
||||||
|
st.markdown(f'<div class="member-grid">{"".join(cards)}</div>',
|
||||||
|
unsafe_allow_html=True)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -397,14 +846,14 @@ elif page == "👤 成员命名":
|
|||||||
# 统计图表页
|
# 统计图表页
|
||||||
# ============================================================
|
# ============================================================
|
||||||
elif page == "📈 统计图表":
|
elif page == "📈 统计图表":
|
||||||
st.title("📈 统计图表")
|
page_header('📈', '统计图表', '模型来源 / 关注事件 / 任务状态')
|
||||||
|
|
||||||
conn = get_db_conn()
|
conn = get_db_conn()
|
||||||
try:
|
try:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
# compute_provider 分布
|
st.markdown('<div style="font-size:15px;font-weight:700;color:#f1f5f9;'
|
||||||
st.subheader("模型来源分布")
|
'margin:6px 0 14px 0;">模型来源分布</div>', unsafe_allow_html=True)
|
||||||
try:
|
try:
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT JSON_UNQUOTE(JSON_EXTRACT(item, '$')) AS provider, COUNT(*) AS count
|
SELECT JSON_UNQUOTE(JSON_EXTRACT(item, '$')) AS provider, COUNT(*) AS count
|
||||||
@@ -420,14 +869,13 @@ elif page == "📈 统计图表":
|
|||||||
stats = cursor.fetchall()
|
stats = cursor.fetchall()
|
||||||
|
|
||||||
if stats:
|
if stats:
|
||||||
df_stats = pd.DataFrame(stats)
|
st.bar_chart({r['provider']: r['count'] for r in stats})
|
||||||
st.bar_chart(df_stats.set_index('provider')['count'])
|
|
||||||
st.dataframe(df_stats, use_container_width=True, hide_index=True)
|
|
||||||
else:
|
else:
|
||||||
st.info("暂无统计数据")
|
st.markdown('<div class="fam-empty">暂无统计数据</div>',
|
||||||
|
unsafe_allow_html=True)
|
||||||
|
|
||||||
# 关注事件统计
|
st.markdown('<div style="font-size:15px;font-weight:700;color:#f1f5f9;'
|
||||||
st.subheader("关注事件统计")
|
'margin:22px 0 14px 0;">关注事件统计</div>', unsafe_allow_html=True)
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT DATE(frame_timestamp) AS date, person, COUNT(*) AS count
|
SELECT DATE(frame_timestamp) AS date, person, COUNT(*) AS count
|
||||||
FROM event_details
|
FROM event_details
|
||||||
@@ -437,13 +885,15 @@ elif page == "📈 统计图表":
|
|||||||
""")
|
""")
|
||||||
attention = cursor.fetchall()
|
attention = cursor.fetchall()
|
||||||
if attention:
|
if attention:
|
||||||
|
import pandas as pd
|
||||||
df_att = pd.DataFrame(attention)
|
df_att = pd.DataFrame(attention)
|
||||||
st.dataframe(df_att, use_container_width=True, hide_index=True)
|
st.dataframe(df_att, use_container_width=True, hide_index=True)
|
||||||
else:
|
else:
|
||||||
st.info("暂无关注事件")
|
st.markdown('<div class="fam-empty">暂无关注事件</div>',
|
||||||
|
unsafe_allow_html=True)
|
||||||
|
|
||||||
# 任务统计
|
st.markdown('<div style="font-size:15px;font-weight:700;color:#f1f5f9;'
|
||||||
st.subheader("任务状态统计")
|
'margin:22px 0 14px 0;">任务状态统计</div>', unsafe_allow_html=True)
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT status, COUNT(*) AS count
|
SELECT status, COUNT(*) AS count
|
||||||
FROM process_tasks
|
FROM process_tasks
|
||||||
@@ -451,11 +901,10 @@ elif page == "📈 统计图表":
|
|||||||
""")
|
""")
|
||||||
task_stats = cursor.fetchall()
|
task_stats = cursor.fetchall()
|
||||||
if task_stats:
|
if task_stats:
|
||||||
df_tasks = pd.DataFrame(task_stats)
|
st.bar_chart({r['status']: r['count'] for r in task_stats})
|
||||||
st.bar_chart(df_tasks.set_index('status')['count'])
|
|
||||||
st.dataframe(df_tasks, use_container_width=True, hide_index=True)
|
|
||||||
else:
|
else:
|
||||||
st.info("暂无任务数据")
|
st.markdown('<div class="fam-empty">暂无任务数据</div>',
|
||||||
|
unsafe_allow_html=True)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
Reference in New Issue
Block a user