1009 lines
42 KiB
Python
1009 lines
42 KiB
Python
"""
|
||
FAM-UI - 家庭多模态智能监控系统前端 v2(新架构 v2,2026-08-21)
|
||
|
||
管理后台:仅从甲骨文同步镜像 (sync_videos / sync_events / sync_people) 读取展示,
|
||
不处理任何视频。页面:
|
||
- 🕒 事件时间轴: 视频会话列表 + 事件时间线(纯文本摘要,无帧图)
|
||
- 💬 AI 对话: 基于同步事件上下文问答(走核心 /api/chat/ask -> 甲骨文编排)
|
||
- 📝 对话历史
|
||
- 👤 人物管理: 命名 / 合并(回推甲骨文,不再有帧照片)
|
||
- 📈 统计图表: 模型来源 / 关注事件 / 同步状态
|
||
"""
|
||
import os
|
||
import sys
|
||
import json
|
||
import html as _html
|
||
import requests
|
||
import streamlit as st
|
||
import pymysql
|
||
import pymysql.cursors
|
||
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')
|
||
_oracle_url = (_cfg.get('oracle_url') or '').rstrip('/')
|
||
_oracle_token = _cfg.get('oracle_token') or ''
|
||
_db_cfg = _cfg.get('database', {})
|
||
|
||
esc = _html.escape
|
||
|
||
|
||
def get_db_conn():
|
||
"""获取数据库连接"""
|
||
return pymysql.connect(
|
||
host=_db_cfg.get('host', '127.0.0.1'),
|
||
port=_db_cfg.get('port', 3306),
|
||
user=_db_cfg.get('user', 'root'),
|
||
password=_db_cfg.get('password', ''),
|
||
database=_db_cfg.get('database', 'sentinel_home_ai'),
|
||
charset='utf8mb4',
|
||
cursorclass=pymysql.cursors.DictCursor
|
||
)
|
||
|
||
|
||
def serialize_datetime(obj):
|
||
if hasattr(obj, 'isoformat'):
|
||
return obj.isoformat()
|
||
return str(obj)
|
||
|
||
|
||
def parse_ts(ts):
|
||
"""'2026-08-14 22:31:15' / datetime -> datetime(失败返回 None)"""
|
||
if isinstance(ts, datetime):
|
||
return ts
|
||
if not ts:
|
||
return None
|
||
try:
|
||
return datetime.strptime(str(ts)[:19], '%Y-%m-%d %H:%M:%S')
|
||
except ValueError:
|
||
# 兼容仅相对时间 '00:01:23'
|
||
try:
|
||
return datetime.strptime(str(ts)[:8], '%H:%M:%S')
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def parse_persons(person_list_json):
|
||
"""解析 person_list_json(字符串或数组)为名字集合"""
|
||
if not person_list_json:
|
||
return set()
|
||
if isinstance(person_list_json, (list, tuple)):
|
||
items = person_list_json
|
||
else:
|
||
try:
|
||
items = json.loads(person_list_json)
|
||
except (ValueError, TypeError):
|
||
items = [person_list_json]
|
||
out = set()
|
||
for it in items:
|
||
s = str(it).strip()
|
||
if s and s != '无人':
|
||
out.add(s)
|
||
return out
|
||
|
||
|
||
# ============================================================
|
||
# 页面配置 & 全局样式
|
||
# ============================================================
|
||
st.set_page_config(
|
||
page_title="家庭智能监控",
|
||
page_icon="🏠",
|
||
layout="wide",
|
||
initial_sidebar_state="expanded"
|
||
)
|
||
|
||
GLOBAL_CSS = """
|
||
<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; }
|
||
|
||
.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; white-space: pre-wrap; }
|
||
|
||
.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); }
|
||
|
||
/* 事件时间线 */
|
||
.ev-list { margin-top: 4px; }
|
||
.ev-item {
|
||
display: grid; grid-template-columns: 78px 1fr; gap: 14px;
|
||
padding: 12px 0; border-bottom: 1px solid var(--border);
|
||
}
|
||
.ev-item:last-child { border-bottom: none; }
|
||
.ev-time { font-size: 15px; font-weight: 700; color: #cbd5e1; font-variant-numeric: tabular-nums; padding-top: 2px; }
|
||
.ev-time .cam { display:block; font-size: 11px; color: var(--text-mute); font-weight: 500; margin-top: 3px; }
|
||
.ev-card { background: linear-gradient(180deg, var(--panel-2), var(--panel)); border: 1px solid var(--border); border-radius: 12px; padding: 12px 14px; }
|
||
.ev-item.attention .ev-card { border-color: rgba(255,107,107,.38); }
|
||
.ev-badges { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
|
||
.ev-desc { font-size: 14px; color: var(--text); line-height: 1.65; }
|
||
|
||
.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;
|
||
}
|
||
.sync-box {
|
||
background: var(--panel); border: 1px solid var(--border); border-radius: 12px;
|
||
padding: 12px 16px; font-size: 12px; color: var(--text-dim); line-height: 1.9;
|
||
}
|
||
.sync-box .ok { color: #6ee7b7; }
|
||
.sync-box .err { color: #ff9b9b; }
|
||
|
||
@media (max-width: 960px) {
|
||
.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_event_list(events: list):
|
||
"""渲染事件时间线:左相对时间 + 右事件卡(事件画面截图 + 人物/关注徽章 + 描述)"""
|
||
items = []
|
||
for e in events:
|
||
ts = parse_ts(e.get('ts'))
|
||
time_label = ts.strftime('%H:%M:%S') if ts else (str(e.get('ts') or '')[:8] or '--:--')
|
||
camera = e.get('camera_name') or ''
|
||
persons = parse_persons(e.get('person_list_json'))
|
||
attention = bool(e.get('is_attention_event'))
|
||
desc = e.get('description') or '(无描述)'
|
||
eid = e.get('id')
|
||
|
||
# 事件对应时间点的画面截图(Oracle 带 token 接口;加载失败自动隐藏)
|
||
img_html = ''
|
||
if _oracle_url and eid:
|
||
img_html = (
|
||
f'<img src="{_oracle_url}/api/oracle/event/{eid}/thumb?token={_oracle_token}" '
|
||
f'style="width:100%;max-height:200px;object-fit:cover;border-radius:8px;'
|
||
f'margin-bottom:6px;" '
|
||
f'onerror="this.style.display=\'none\'"/>')
|
||
|
||
badges = ''.join(
|
||
f'<span class="bdg bdg-person">{esc(p)}</span>' for p in sorted(persons))
|
||
if attention:
|
||
badges = '<span class="bdg bdg-att">⚠ 需关注</span>' + badges
|
||
|
||
items.append(
|
||
f'<div class="ev-item {"attention" if attention else ""}">'
|
||
f'<div class="ev-time">{esc(time_label)}'
|
||
f'{f"<span class=cam>{esc(camera)}</span>" if camera else ""}</div>'
|
||
f'<div class="ev-card">{img_html}{badges}'
|
||
f'<div class="ev-desc">{esc(desc)}</div></div>'
|
||
f'</div>'
|
||
)
|
||
st.markdown(f'<div class="ev-list">{"".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.segmented_control(
|
||
"功能页面",
|
||
["🕒 事件时间轴", "💬 AI 对话", "📝 对话历史", "👤 人物管理", "📈 统计图表", "🤖 模型统计"],
|
||
default="🕒 事件时间轴",
|
||
label_visibility="collapsed"
|
||
) or "🕒 事件时间轴"
|
||
|
||
# 侧边栏底部:同步状态
|
||
try:
|
||
resp = requests.get(f"{_core_url}/api/status", timeout=10)
|
||
if resp.status_code == 200:
|
||
sdata = resp.json().get('sync', {})
|
||
last = sdata.get('last_sync_at')
|
||
err = sdata.get('last_error')
|
||
cursor = sdata.get('cursor')
|
||
cnt = sdata.get('last_count')
|
||
cnt_str = f"视频+{cnt[0]} / 事件+{cnt[1]} / 人物+{cnt[2]}" if cnt else "—"
|
||
status_cls = 'ok' if sdata.get('running') else 'err'
|
||
status_txt = '同步中' if sdata.get('running') else '未运行'
|
||
err_line = f'<div class="err">⚠ {esc(err)}</div>' if err else ''
|
||
st.sidebar.markdown('---')
|
||
st.sidebar.markdown(
|
||
f'<div class="sync-box">'
|
||
f'<b style="color:#f1f5f9;">同步状态</b><br>'
|
||
f'状态 <span class="{status_cls}">{esc(status_txt)}</span><br>'
|
||
f'最近 <b style="color:#cbd5e1;">{esc(str(last)[:19]) if last else "—"}</b><br>'
|
||
f'本次增量 {esc(cnt_str)}<br>'
|
||
f'游标 {esc(str(cursor)[:19]) if cursor else "(全量)"}'
|
||
f'{err_line}'
|
||
f'</div>', unsafe_allow_html=True)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# ============================================================
|
||
# 事件时间轴页
|
||
# ============================================================
|
||
if page == "🕒 事件时间轴":
|
||
page_header('🕒', '事件时间轴', '视频会话 · 时间点 · 信息摘要')
|
||
|
||
with st.container():
|
||
col_date, col_sp = st.columns([1, 3])
|
||
with col_date:
|
||
date_filter = st.date_input("日期", value=None)
|
||
|
||
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(*) v FROM sync_videos WHERE status='done' AND processed_at LIKE %s",
|
||
(f'{date_str}%',))
|
||
v = cursor.fetchone().get('v', 0)
|
||
cursor.execute(
|
||
"SELECT COUNT(*) e FROM sync_events se JOIN sync_videos sv ON se.video_id=sv.id WHERE sv.processed_at LIKE %s",
|
||
(f'{date_str}%',))
|
||
e = cursor.fetchone().get('e', 0)
|
||
cursor.execute(
|
||
"""SELECT COALESCE(SUM(se.is_attention_event),0) AS att
|
||
FROM sync_events se JOIN sync_videos sv ON se.video_id=sv.id
|
||
WHERE sv.processed_at LIKE %s""", (f'{date_str}%',))
|
||
att = cursor.fetchone().get('att', 0)
|
||
else:
|
||
cursor.execute("SELECT COUNT(*) v FROM sync_videos WHERE status='done'")
|
||
v = cursor.fetchone().get('v', 0)
|
||
cursor.execute("SELECT COUNT(*) e FROM sync_events")
|
||
e = cursor.fetchone().get('e', 0)
|
||
cursor.execute("SELECT COALESCE(SUM(is_attention_event),0) att FROM sync_events")
|
||
att = cursor.fetchone().get('att', 0)
|
||
# 出现人物数:distinct label 命中 sync_events
|
||
cursor.execute("SELECT label, canonical_name FROM sync_people")
|
||
people = cursor.fetchall()
|
||
ps = 0
|
||
for p in people:
|
||
name = p['canonical_name'] or p['label']
|
||
cursor.execute("SELECT COUNT(*) c FROM sync_events WHERE person_list_json LIKE %s",
|
||
(f'%{name}%',))
|
||
if cursor.fetchone()['c'] > 0:
|
||
ps += 1
|
||
finally:
|
||
conn.close()
|
||
st.markdown(
|
||
f'<div class="stat-row">'
|
||
f'<div class="stat-card"><div class="s-val">{v}</div><div class="s-label">视频会话</div></div>'
|
||
f'<div class="stat-card"><div class="s-val">{e}</div><div class="s-label">事件数</div></div>'
|
||
f'<div class="stat-card ok"><div class="s-val">{ps}</div><div class="s-label">出现人物</div></div>'
|
||
f'<div class="stat-card warn"><div class="s-val">{att}</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:
|
||
st.session_state.event_page = 0
|
||
offset = st.session_state.event_page * page_size
|
||
|
||
videos = []
|
||
try:
|
||
conn = get_db_conn()
|
||
try:
|
||
cursor = conn.cursor()
|
||
if date_str:
|
||
cursor.execute(
|
||
"""SELECT id, filename, camera_name, event_start_time, summary_json,
|
||
compute_provider, processed_at,
|
||
(SELECT COUNT(*) FROM sync_events se WHERE se.video_id=sync_videos.id) AS event_count
|
||
FROM sync_videos
|
||
WHERE status='done' AND processed_at LIKE %s
|
||
ORDER BY COALESCE(processed_at, updated_at, created_at) DESC
|
||
LIMIT %s OFFSET %s""",
|
||
(f'{date_str}%', page_size, offset))
|
||
else:
|
||
cursor.execute(
|
||
"""SELECT id, filename, camera_name, event_start_time, summary_json,
|
||
compute_provider, processed_at,
|
||
(SELECT COUNT(*) FROM sync_events se WHERE se.video_id=sync_videos.id) AS event_count
|
||
FROM sync_videos
|
||
WHERE status='done'
|
||
ORDER BY COALESCE(processed_at, updated_at, created_at) DESC
|
||
LIMIT %s OFFSET %s""",
|
||
(page_size, offset))
|
||
videos = cursor.fetchall()
|
||
finally:
|
||
conn.close()
|
||
except Exception as e:
|
||
st.error(f"读取同步数据失败: {e}")
|
||
|
||
if not videos:
|
||
st.markdown(
|
||
'<div class="fam-empty"><span class="fe-ico">🗓</span>'
|
||
'该日期暂无监控会话(甲骨文尚未同步数据?)</div>', unsafe_allow_html=True)
|
||
else:
|
||
if 'selected_video_id' not in st.session_state:
|
||
st.session_state.selected_video_id = videos[0]['id']
|
||
valid_ids = {v['id'] for v in videos}
|
||
if st.session_state.selected_video_id not in valid_ids:
|
||
st.session_state.selected_video_id = videos[0]['id']
|
||
selected_id = st.session_state.selected_video_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(videos)} 条</div>',
|
||
unsafe_allow_html=True)
|
||
for vid in videos:
|
||
proc = vid.get('processed_at')
|
||
ptime = parse_ts(proc)
|
||
time_label = ptime.strftime('%m-%d %H:%M') if ptime else '--:--'
|
||
date_label = ptime.strftime('%Y-%m-%d') if ptime else ''
|
||
summary = (vid.get('summary_json') or '').strip()
|
||
summary_short = summary if len(summary) <= 28 else summary[:28] + '…'
|
||
if not summary_short:
|
||
summary_short = '暂无摘要'
|
||
is_selected = vid['id'] == selected_id
|
||
if st.button(
|
||
f"{'▶ ' if is_selected else ''}{time_label} · {vid.get('camera_name') or '未知'} · {vid['event_count']}事件",
|
||
key=f"vidbtn_{vid['id']}",
|
||
type="primary" if is_selected else "secondary",
|
||
use_container_width=True
|
||
):
|
||
st.session_state.selected_video_id = vid['id']
|
||
st.rerun()
|
||
st.markdown(
|
||
f'<div style="font-size:11px;color:#64748b;margin:-4px 2px 12px 2px;'
|
||
f'line-height:1.5;">{esc(date_label)} · {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(videos) < page_size):
|
||
st.session_state.event_page += 1
|
||
st.rerun()
|
||
|
||
with col_detail:
|
||
vid = next(v for v in videos if v['id'] == selected_id)
|
||
proc = parse_ts(vid.get('processed_at'))
|
||
start = parse_ts(vid.get('event_start_time'))
|
||
range_str = ''
|
||
if proc:
|
||
range_str = proc.strftime('%Y-%m-%d %H:%M:%S')
|
||
if start:
|
||
range_str += f"(起始 {start.strftime('%H:%M:%S')})"
|
||
|
||
provider = vid.get('compute_provider') or ''
|
||
model_badges = ''
|
||
if provider:
|
||
model_badges = ''.join(
|
||
f'<span class="bdg bdg-model">{esc(p)}</span>' for p in str(provider).split(','))
|
||
|
||
summary = vid.get('summary_json') or '暂无全局摘要'
|
||
# 视频缩略图(Oracle 带 token 接口,无图时优雅降级)
|
||
thumb_html = ''
|
||
if _oracle_url:
|
||
thumb_html = (
|
||
f'<img src="{_oracle_url}/api/oracle/video/{vid["id"]}/thumb'
|
||
f'?token={_oracle_token}" '
|
||
f'style="width:100%;max-height:240px;object-fit:cover;'
|
||
f'border-radius:10px;margin-bottom:10px;'
|
||
f'onerror="this.style.display=\'none\'"/>')
|
||
st.markdown(
|
||
f'<div class="ev-head">'
|
||
f'{thumb_html}'
|
||
f'<div class="ev-title">'
|
||
f'<span>{esc(vid.get("camera_name") or "未知摄像头")}</span>'
|
||
f'<span class="bdg bdg-cam">会话 #{vid["id"]}</span>'
|
||
f'{model_badges}</div>'
|
||
f'<div class="ev-range">⏱ {esc(range_str)} · 文件 {esc(vid.get("filename") or "")}</div>'
|
||
f'<div class="ev-summary">{esc(summary)}</div>'
|
||
f'</div>', unsafe_allow_html=True)
|
||
|
||
events = []
|
||
try:
|
||
conn = get_db_conn()
|
||
try:
|
||
cursor = conn.cursor()
|
||
cursor.execute(
|
||
"""SELECT e.id, e.ts, e.description, e.person_list_json,
|
||
e.is_attention_event, v.camera_name
|
||
FROM sync_events e
|
||
JOIN sync_videos v ON e.video_id = v.id
|
||
WHERE e.video_id=%s ORDER BY e.ts ASC""",
|
||
(selected_id,))
|
||
events = cursor.fetchall()
|
||
finally:
|
||
conn.close()
|
||
except Exception as e:
|
||
st.error(f"读取事件失败: {e}")
|
||
|
||
if events:
|
||
render_event_list(events)
|
||
else:
|
||
st.markdown(
|
||
'<div class="fam-empty"><span class="fe-ico">🎞</span>'
|
||
'该会话暂无时间点事件</div>', unsafe_allow_html=True)
|
||
|
||
|
||
# ============================================================
|
||
# AI 对话页
|
||
# ============================================================
|
||
elif page == "💬 AI 对话":
|
||
page_header('💬', 'AI 对话', '基于同步事件上下文的智能问答')
|
||
|
||
named = []
|
||
try:
|
||
conn = get_db_conn()
|
||
try:
|
||
cursor = conn.cursor()
|
||
cursor.execute(
|
||
"SELECT DISTINCT canonical_name FROM sync_people "
|
||
"WHERE canonical_name IS NOT NULL AND canonical_name != ''")
|
||
named = [r['canonical_name'] for r in cursor.fetchall()]
|
||
finally:
|
||
conn.close()
|
||
except Exception:
|
||
pass
|
||
|
||
col1, col2 = st.columns(2)
|
||
with col1:
|
||
queried_person = st.text_input("查询人物", value=named[0] if named else "")
|
||
with col2:
|
||
queried_date = st.date_input("查询日期", value=date.today())
|
||
|
||
if named:
|
||
quick_person = st.selectbox("快捷选择成员", [""] + named)
|
||
if quick_person:
|
||
queried_person = quick_person
|
||
|
||
quick_questions = [
|
||
f"{queried_person}今天干嘛了?",
|
||
f"{queried_person}有没有发生什么需要注意的事情?",
|
||
f"今天{queried_person}的活动时间线是什么?",
|
||
]
|
||
selected_quick = st.selectbox("快捷提问", ["自定义"] + quick_questions)
|
||
user_question = st.text_area("你的问题", value=selected_quick if selected_quick != "自定义" else "")
|
||
|
||
if st.button("提问", type="primary"):
|
||
if not user_question.strip():
|
||
st.warning("请输入问题")
|
||
elif not queried_person.strip():
|
||
st.warning("请输入查询人物")
|
||
else:
|
||
with st.spinner("AI 正在思考..."):
|
||
try:
|
||
resp = requests.post(
|
||
f"{_core_url}/api/chat/ask",
|
||
json={
|
||
"question": user_question,
|
||
"queried_person": queried_person,
|
||
"queried_date": queried_date.isoformat()
|
||
},
|
||
timeout=120
|
||
)
|
||
if resp.status_code == 200:
|
||
data = resp.json()
|
||
st.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></div>',
|
||
unsafe_allow_html=True)
|
||
st.markdown(data['answer'])
|
||
if data.get('context_summary'):
|
||
st.caption(f"上下文: {data['context_summary']}")
|
||
else:
|
||
st.error(f"请求失败: {resp.status_code} {resp.text}")
|
||
except requests.ConnectionError:
|
||
st.error(f"无法连接 FAM-Core ({_core_url})")
|
||
except Exception as e:
|
||
st.error(f"异常: {e}")
|
||
|
||
|
||
# ============================================================
|
||
# 对话历史页
|
||
# ============================================================
|
||
elif page == "📝 对话历史":
|
||
page_header('📝', '对话历史', '历史问答记录')
|
||
|
||
if 'chat_page' not in st.session_state:
|
||
st.session_state.chat_page = 0
|
||
|
||
page_size = 20
|
||
offset = st.session_state.chat_page * page_size
|
||
|
||
history = []
|
||
try:
|
||
conn = get_db_conn()
|
||
try:
|
||
cursor = conn.cursor()
|
||
cursor.execute(
|
||
"""SELECT chat_id, user_question, ai_answer, context_summary,
|
||
queried_date, queried_person, created_at
|
||
FROM chat_history
|
||
ORDER BY created_at DESC
|
||
LIMIT %s OFFSET %s""",
|
||
(page_size, offset))
|
||
history = cursor.fetchall()
|
||
finally:
|
||
conn.close()
|
||
except Exception as e:
|
||
st.error(f"读取对话历史失败: {e}")
|
||
|
||
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 == "👤 人物管理":
|
||
page_header('👤', '人物管理', '所有出现的人物 · 命名与合并(回推甲骨文)')
|
||
|
||
people = []
|
||
try:
|
||
conn = get_db_conn()
|
||
try:
|
||
cursor = conn.cursor()
|
||
cursor.execute(
|
||
"SELECT id, label, canonical_name, first_seen, appearances, source "
|
||
"FROM sync_people ORDER BY id ASC")
|
||
people = cursor.fetchall()
|
||
finally:
|
||
conn.close()
|
||
except Exception as e:
|
||
st.error(f"读取人物失败: {e}")
|
||
people = []
|
||
|
||
# 按规范名聚合(未命名按 label)
|
||
groups = {}
|
||
for p in people:
|
||
key = p['canonical_name'] or p['label']
|
||
groups.setdefault(key, {'display': key, 'is_named': bool(p['canonical_name']),
|
||
'labels': [], 'appearances': 0, 'first_seen': None})
|
||
g = groups[key]
|
||
g['labels'].append(p['label'])
|
||
g['appearances'] += (p.get('appearances') or 0)
|
||
fs = p.get('first_seen')
|
||
if fs and (g['first_seen'] is None or str(fs) < str(g['first_seen'])):
|
||
g['first_seen'] = fs
|
||
|
||
if not groups:
|
||
st.markdown(
|
||
'<div class="fam-empty"><span class="fe-ico">👤</span>'
|
||
'暂未发现任何人物(甲骨文尚未同步)</div>', unsafe_allow_html=True)
|
||
else:
|
||
st.markdown(
|
||
f'<div style="font-size:12px;color:#8b93a7;margin:2px 0 16px 0;">'
|
||
f'共 <b style="color:#f1f5f9;">{len(groups)}</b> 个身份 · '
|
||
f'已命名 <b style="color:#7dd3fc;">{sum(1 for g in groups.values() if g["is_named"])}</b> · '
|
||
f'未命名 <b style="color:#fbbf24;">{sum(1 for g in groups.values() if not g["is_named"])}</b>'
|
||
f'</div>', unsafe_allow_html=True)
|
||
|
||
# 命名/合并操作
|
||
def do_name(label, new_name):
|
||
try:
|
||
resp = requests.post(
|
||
f"{_core_url}/api/member/name",
|
||
json={"label": label, "canonical_name": new_name, "named_by": "UI管理员"},
|
||
timeout=30)
|
||
if resp.status_code == 200:
|
||
st.success(f"已保存:{label} → {new_name}(已回推甲骨文并即时同步)")
|
||
st.rerun()
|
||
else:
|
||
st.error(f"失败: {resp.status_code} {resp.text[:150]}")
|
||
except Exception as e:
|
||
st.error(f"异常: {e}")
|
||
|
||
def do_merge(src_label, tgt_key):
|
||
try:
|
||
resp = requests.post(
|
||
f"{_core_url}/api/member/merge",
|
||
json={"source": src_label, "target": tgt_key},
|
||
timeout=30)
|
||
if resp.status_code == 200:
|
||
st.success(f"已合并:{src_label} → {tgt_key}")
|
||
st.rerun()
|
||
else:
|
||
st.error(f"合并失败: {resp.status_code} {resp.text[:150]}")
|
||
except Exception as e:
|
||
st.error(f"合并异常: {e}")
|
||
|
||
label_options = [p['label'] for p in people]
|
||
for key in sorted(groups, key=lambda k: -groups[k]['appearances']):
|
||
g = groups[key]
|
||
is_named = g['is_named']
|
||
fs = parse_ts(g['first_seen'])
|
||
first_str = fs.strftime('%m-%d %H:%M') if fs else '--'
|
||
tag = (f'<span style="font-size:11px;color:#7dd3fc;background:#0e2233;'
|
||
f'border:1px solid #164e63;padding:1px 8px;border-radius:10px;'
|
||
f'margin-left:8px;">已命名</span>') if is_named else \
|
||
(f'<span style="font-size:11px;color:#fbbf24;background:#2a2107;'
|
||
f'border:1px solid #713f12;padding:1px 8px;border-radius:10px;'
|
||
f'margin-left:8px;">未命名</span>')
|
||
label_str = ' · '.join(g['labels'])
|
||
st.markdown(
|
||
f'<div style="font-size:16px;font-weight:700;color:#f1f5f9;">'
|
||
f'{esc(key)}{tag}</div>'
|
||
f'<div style="font-size:12px;color:#8b93a7;margin-top:5px;line-height:1.6;">'
|
||
f'标识: {esc(label_str)}</div>'
|
||
f'<div style="font-size:11px;color:#64748b;margin-top:6px;">'
|
||
f'出现 <b style="color:#cbd5e1;">{g["appearances"]}</b> 次 · 首次 {esc(first_str)}</div>',
|
||
unsafe_allow_html=True)
|
||
if not is_named:
|
||
# 未命名身份:每个 label 都给一个命名框
|
||
for lb in g['labels']:
|
||
col_in, col_btn, col_mg = st.columns([2, 1, 1.4])
|
||
with col_in:
|
||
new_name = st.text_input(
|
||
"名字", key=f"nm_{lb}", placeholder=f'为「{lb}」起名',
|
||
label_visibility="collapsed")
|
||
with col_btn:
|
||
if st.button("命名", key=f"btn_{lb}", type="primary",
|
||
use_container_width=True):
|
||
if new_name.strip():
|
||
do_name(lb, new_name.strip())
|
||
else:
|
||
st.warning("请输入名字")
|
||
with col_mg:
|
||
merge_to = st.selectbox(
|
||
"合并到", options=[""] + [o for o in label_options if o != lb],
|
||
key=f"mg_{lb}", label_visibility="collapsed",
|
||
placeholder="合并到其他…")
|
||
if merge_to:
|
||
if st.button("合并", key=f"mbtn_{lb}", use_container_width=True):
|
||
do_merge(lb, merge_to)
|
||
else:
|
||
# 已命名:仅提供合并到其他身份
|
||
merge_to = st.selectbox(
|
||
"合并到", options=[""] + [o for o in label_options if o != key],
|
||
key=f"mg2_{key}", label_visibility="collapsed",
|
||
placeholder="合并到其他身份…")
|
||
if merge_to:
|
||
if st.button("合并", key=f"mbtn2_{key}", use_container_width=True):
|
||
# 用该身份下任一 label 作为 source
|
||
do_merge(g['labels'][0], merge_to)
|
||
st.markdown('<div style="height:12px"></div>', unsafe_allow_html=True)
|
||
|
||
|
||
# ============================================================
|
||
# 统计图表页
|
||
# ============================================================
|
||
elif page == "📈 统计图表":
|
||
page_header('📈', '统计图表', '模型来源 / 关注事件 / 同步状态')
|
||
|
||
conn = None
|
||
try:
|
||
conn = get_db_conn()
|
||
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(
|
||
"SELECT compute_provider, COUNT(*) AS count FROM sync_videos "
|
||
"WHERE status='done' GROUP BY compute_provider")
|
||
stats = cursor.fetchall()
|
||
if stats:
|
||
chart = {}
|
||
for r in stats:
|
||
prov = (r['compute_provider'] or 'unknown')
|
||
# 可能是逗号分隔多个
|
||
for p in str(prov).split(','):
|
||
p = p.strip()
|
||
if p:
|
||
chart[p] = chart.get(p, 0) + r['count']
|
||
st.bar_chart(chart)
|
||
else:
|
||
st.markdown('<div class="fam-empty">暂无统计数据</div>', unsafe_allow_html=True)
|
||
|
||
st.markdown('<div style="font-size:15px;font-weight:700;color:#f1f5f9;'
|
||
'margin:22px 0 14px 0;">关注事件统计</div>', unsafe_allow_html=True)
|
||
cursor.execute(
|
||
"""SELECT v.processed_at, e.person_list_json
|
||
FROM sync_events e JOIN sync_videos v ON e.video_id=v.id
|
||
WHERE e.is_attention_event = 1
|
||
ORDER BY v.processed_at DESC""")
|
||
attention = cursor.fetchall()
|
||
if attention:
|
||
import pandas as pd
|
||
rows = []
|
||
for a in attention:
|
||
persons = parse_persons(a['person_list_json'])
|
||
ptime = parse_ts(a['processed_at'])
|
||
rows.append({
|
||
"日期": ptime.strftime('%Y-%m-%d') if ptime else '?',
|
||
"人物": ','.join(sorted(persons)) or '无人',
|
||
})
|
||
df_att = pd.DataFrame(rows)
|
||
st.dataframe(df_att, use_container_width=True, hide_index=True)
|
||
else:
|
||
st.markdown('<div class="fam-empty">暂无关注事件</div>', unsafe_allow_html=True)
|
||
|
||
st.markdown('<div style="font-size:15px;font-weight:700;color:#f1f5f9;'
|
||
'margin:22px 0 14px 0;">同步状态</div>', unsafe_allow_html=True)
|
||
try:
|
||
resp = requests.get(f"{_core_url}/api/status", timeout=10)
|
||
if resp.status_code == 200:
|
||
sdata = resp.json().get('sync', {})
|
||
cnt = sdata.get('last_count')
|
||
cnt_str = f"视频+{cnt[0]} / 事件+{cnt[1]} / 人物+{cnt[2]}" if cnt else "—"
|
||
err = sdata.get('last_error')
|
||
cls = 'ok' if sdata.get('running') else 'err'
|
||
err_line = f'<div class="err">⚠ {esc(err)}</div>' if err else ''
|
||
st.markdown(
|
||
f'<div class="sync-box">'
|
||
f'运行状态 <span class="{cls}">{"同步中" if sdata.get("running") else "未运行"}</span><br>'
|
||
f'最近同步 <b style="color:#cbd5e1;">{esc(str(sdata.get("last_sync_at") or "—")[:19])}</b><br>'
|
||
f'本次增量 {esc(cnt_str)}<br>'
|
||
f'游标 <b style="color:#cbd5e1;">{esc(str(sdata.get("cursor") or "(全量)")[:19])}</b><br>'
|
||
f'周期 {esc(str(sdata.get("interval_sec")))}s'
|
||
f'{err_line}</div>', unsafe_allow_html=True)
|
||
except Exception:
|
||
st.markdown('<div class="fam-empty">无法获取同步状态</div>', unsafe_allow_html=True)
|
||
|
||
except Exception as e:
|
||
st.error(f"统计查询失败: {e}")
|
||
finally:
|
||
if conn:
|
||
conn.close()
|
||
|
||
# ============================================================
|
||
# 模型统计页(云端模型识别成功/失败统计)
|
||
# ============================================================
|
||
elif page == "🤖 模型统计":
|
||
page_header('🤖', '云端模型统计', '请求时间 · 耗时 · 成功/失败 · 失败原因')
|
||
|
||
conn = None
|
||
try:
|
||
conn = get_db_conn()
|
||
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(
|
||
"""SELECT provider, model,
|
||
SUM(CASE WHEN success=1 THEN 1 ELSE 0 END) AS ok_cnt,
|
||
SUM(CASE WHEN success=0 THEN 1 ELSE 0 END) AS fail_cnt,
|
||
ROUND(AVG(duration_sec),1) AS avg_dur,
|
||
COUNT(*) AS total,
|
||
MAX(created_at) AS last_call
|
||
FROM sync_model_calls GROUP BY provider, model
|
||
ORDER BY total DESC""")
|
||
agg = cursor.fetchall()
|
||
if agg:
|
||
rows = []
|
||
for r in agg:
|
||
total = r['total'] or 0
|
||
ok = r['ok_cnt'] or 0
|
||
rate = (ok / total * 100) if total else 0
|
||
rows.append({
|
||
"模型": f"{r['provider']} / {r['model']}",
|
||
"成功": ok,
|
||
"失败": r['fail_cnt'] or 0,
|
||
"成功率%": round(rate, 1),
|
||
"平均耗时s": r['avg_dur'],
|
||
"最后调用": str(r['last_call'] or '—')[:19],
|
||
})
|
||
st.dataframe(rows, use_container_width=True, hide_index=True)
|
||
else:
|
||
st.markdown('<div class="fam-empty">暂无模型调用记录(视频处理中或尚未同步)</div>',
|
||
unsafe_allow_html=True)
|
||
|
||
# ---- 最近调用明细 ----
|
||
st.markdown('<div style="font-size:15px;font-weight:700;color:#f1f5f9;'
|
||
'margin:22px 0 14px 0;">最近调用明细</div>', unsafe_allow_html=True)
|
||
cursor.execute(
|
||
"""SELECT provider, model, started_at, duration_sec, success, error,
|
||
filename, video_id
|
||
FROM sync_model_calls ORDER BY id DESC LIMIT 100""")
|
||
calls = cursor.fetchall()
|
||
if calls:
|
||
import pandas as pd
|
||
rows = []
|
||
for c in calls:
|
||
rows.append({
|
||
"请求时间": str(c['started_at'] or '—')[:19],
|
||
"模型": f"{c['provider']} / {c['model']}",
|
||
"耗时s": round(c['duration_sec'] or 0, 1),
|
||
"状态": "✅ 成功" if c['success'] else "❌ 失败",
|
||
"失败原因": (c['error'] or '')[:60],
|
||
"视频": (c['filename'] or '')[:40],
|
||
})
|
||
st.dataframe(rows, use_container_width=True, hide_index=True)
|
||
else:
|
||
st.markdown('<div class="fam-empty">暂无调用明细</div>', unsafe_allow_html=True)
|
||
|
||
# ---- 说明 ----
|
||
st.markdown(
|
||
'<div style="font-size:11px;color:#64748b;margin-top:10px;">'
|
||
'统计来自甲骨文端每次云端模型请求的记录(经 30 分钟同步拉取到本地镜像)。'
|
||
'失败原因取值:429_quota(配额耗尽)/ timeout / 503_overload(过载重试)/ http_xxx / json_parse_failed 等。'
|
||
'</div>', unsafe_allow_html=True)
|
||
|
||
except Exception as e:
|
||
st.error(f"模型统计查询失败: {e}")
|
||
finally:
|
||
if conn:
|
||
conn.close()
|