1193 lines
52 KiB
Python
1193 lines
52 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()
|
||
# 日期维度统一用视频实际录制时间 event_start_time(文件名解析),
|
||
# 为空时回退 processed_at(分析时间),避免"处理时间=8/21"把旧视频全堆同一天
|
||
_DATE_EXPR = "COALESCE(NULLIF(event_start_time,''), processed_at)"
|
||
if date_str:
|
||
cursor.execute(
|
||
f"SELECT COUNT(*) v FROM sync_videos WHERE status='done' AND {_DATE_EXPR} LIKE %s",
|
||
(f'{date_str}%',))
|
||
v = cursor.fetchone().get('v', 0)
|
||
cursor.execute(
|
||
f"SELECT COUNT(*) e FROM sync_events se JOIN sync_videos sv ON se.video_id=sv.id WHERE {_DATE_EXPR} LIKE %s",
|
||
(f'{date_str}%',))
|
||
e = cursor.fetchone().get('e', 0)
|
||
cursor.execute(
|
||
f"""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 {_DATE_EXPR} 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(
|
||
f"""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 {_DATE_EXPR} LIKE %s
|
||
ORDER BY {_DATE_EXPR} DESC
|
||
LIMIT %s OFFSET %s""",
|
||
(f'{date_str}%', page_size, offset))
|
||
else:
|
||
cursor.execute(
|
||
f"""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 {_DATE_EXPR} 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:
|
||
# 日期标签/时间用视频实际录制时间(event_start_time),为空回退处理时间
|
||
ev_time = parse_ts(vid.get('event_start_time')) or parse_ts(vid.get('processed_at'))
|
||
time_label = ev_time.strftime('%m-%d %H:%M') if ev_time else '--:--'
|
||
date_label = ev_time.strftime('%Y-%m-%d') if ev_time 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)
|
||
start = parse_ts(vid.get('event_start_time'))
|
||
proc = parse_ts(vid.get('processed_at'))
|
||
# 主时间 = 录制时间(event_start_time),回退分析时间(processed_at)
|
||
main_t = start or proc
|
||
range_str = ''
|
||
if main_t:
|
||
range_str = main_t.strftime('%Y-%m-%d %H:%M:%S')
|
||
if start and proc and (proc - start).total_seconds() > 60:
|
||
range_str += f"(分析于 {proc.strftime('%m-%d %H:%M')})"
|
||
|
||
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'])
|
||
# 人物代表画面:Oracle 端在该人物出现的所有事件中挑第一个有截图的事件画面
|
||
# (该人物在多个视频多次出现,肯定能找到出现时刻的画面,不用视频首帧冒充)
|
||
rep_img = ''
|
||
if _oracle_url:
|
||
for lb in g['labels']:
|
||
try:
|
||
rp = requests.get(
|
||
f"{_oracle_url}/api/oracle/person/avatar",
|
||
params={"label": lb, "token": _oracle_token},
|
||
timeout=10)
|
||
if rp.status_code == 200:
|
||
import urllib.parse
|
||
rep_img = (
|
||
f'<img src="{_oracle_url}/api/oracle/person/avatar'
|
||
f'?label={urllib.parse.quote(lb)}&token={_oracle_token}" '
|
||
f'style="width:140px;height:94px;object-fit:cover;border-radius:8px;'
|
||
f'border:1px solid #1e293b;margin:8px 0;" '
|
||
f'onerror="this.style.display=\'none\'"/>')
|
||
break
|
||
except Exception:
|
||
continue
|
||
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>'
|
||
f'{rep_img}',
|
||
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 COALESCE(NULLIF(v.event_start_time,''), v.processed_at) AS ev_date,
|
||
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 ev_date 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['ev_date'])
|
||
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()
|
||
|
||
|
||
# ============================================================
|
||
# 服务状态页(实时查看各服务在做什么:队列/模型/rclone/人物/NAS 同步)
|
||
# ============================================================
|
||
elif page == "🖥 服务状态":
|
||
page_header('🖥', '服务状态', '各服务实时动态 · 最近 7 天活动记录')
|
||
|
||
c1, c2, c3 = st.columns([1, 1, 5])
|
||
with c1:
|
||
if st.button("🔄 刷新", use_container_width=True):
|
||
st.rerun()
|
||
with c2:
|
||
st.caption("自动每 5 分钟更新页面(点击刷新立即更新)")
|
||
|
||
# ---- 拉取 Oracle 实时状态 + NAS 同步状态 ----
|
||
act_data = None
|
||
act_err = None
|
||
if _oracle_url and _oracle_token:
|
||
try:
|
||
r = requests.get(
|
||
f"{_oracle_url}/api/oracle/activity?token={_oracle_token}",
|
||
timeout=15)
|
||
if r.status_code == 200:
|
||
act_data = r.json()
|
||
else:
|
||
act_err = f"Oracle activity HTTP {r.status_code}"
|
||
except Exception as e:
|
||
act_err = f"连接 Oracle 失败: {e}"
|
||
else:
|
||
act_err = "未配置 oracle_url / oracle_token"
|
||
|
||
nas_sync = None
|
||
try:
|
||
rr = requests.get(f"{_core_url}/api/status", timeout=10)
|
||
if rr.status_code == 200:
|
||
nas_sync = rr.json().get('sync', {})
|
||
except Exception:
|
||
pass
|
||
|
||
if act_err:
|
||
st.warning(act_err)
|
||
if act_data is None and nas_sync is None:
|
||
st.markdown('<div class="fam-empty">暂时无法获取服务状态</div>', unsafe_allow_html=True)
|
||
else:
|
||
# ---- 服务状态卡 ----
|
||
st.markdown('<div style="font-size:15px;font-weight:700;color:#f1f5f9;'
|
||
'margin:6px 0 12px 0;">各服务当前状态</div>', unsafe_allow_html=True)
|
||
|
||
q = (act_data or {}).get('queue') or {}
|
||
dbinfo = (act_data or {}).get('db') or {}
|
||
cur_vid = q.get('current')
|
||
cur_txt = '—'
|
||
if cur_vid:
|
||
cur_txt = f"#{cur_vid.get('video_id')} {esc((cur_vid.get('filename') or '')[-42:])}"
|
||
by_status = dbinfo.get('by_status') or {}
|
||
st.markdown(
|
||
f'<div class="stat-row">'
|
||
f'<div class="stat-card"><div class="s-val">{"运行中" if q.get("running") else "停止"}</div>'
|
||
f'<div class="s-label">FAM-Edge 队列</div></div>'
|
||
f'<div class="stat-card"><div class="s-val">{q.get("queued", 0)}</div>'
|
||
f'<div class="s-label">排队中</div></div>'
|
||
f'<div class="stat-card ok"><div class="s-val">{by_status.get("done", 0)}</div>'
|
||
f'<div class="s-label">已完成</div></div>'
|
||
f'<div class="stat-card warn"><div class="s-val">{by_status.get("pending", 0)}</div>'
|
||
f'<div class="s-label">待处理</div></div>'
|
||
f'<div class="stat-card err"><div class="s-val">{by_status.get("failed", 0)}</div>'
|
||
f'<div class="s-label">失败</div></div>'
|
||
f'</div>', unsafe_allow_html=True)
|
||
|
||
# 详细状态行
|
||
qs = q.get('stats') or {}
|
||
st.markdown(
|
||
f'<div style="font-size:12px;color:#8b93a7;margin:8px 0 4px 0;">'
|
||
f'▶ 正在处理:<b style="color:#7dd3fc;">{cur_txt}</b>'
|
||
f'<span style="margin-left:14px;">生产者已入队 {qs.get("produced", 0)} · '
|
||
f'成功 {qs.get("consumed_ok", 0)} · 失败 {qs.get("consumed_fail", 0)}</span></div>',
|
||
unsafe_allow_html=True)
|
||
|
||
# 服务卡:rclone / 人物 / NAS 同步 / 模型
|
||
def _svc_card(icon, name, val, sub, color='#cbd5e1'):
|
||
return (
|
||
f'<div style="flex:1;min-width:180px;background:#0f172a;border:1px solid #1e293b;'
|
||
f'border-radius:12px;padding:12px 14px;margin:6px 6px 6px 0;">'
|
||
f'<div style="font-size:12px;color:#64748b;">{icon} {esc(name)}</div>'
|
||
f'<div style="font-size:13px;color:{color};margin-top:6px;line-height:1.5;">{val}</div>'
|
||
f'<div style="font-size:11px;color:#475569;margin-top:4px;">{esc(sub)}</div>'
|
||
f'</div>')
|
||
|
||
rclone = (act_data or {}).get('rclone') or {}
|
||
person = (act_data or {}).get('person') or {}
|
||
cards = []
|
||
# rclone
|
||
if rclone:
|
||
cards.append(_svc_card('🔄', 'rclone 同步',
|
||
f"{esc((rclone.get('detail') or '')[:70])}",
|
||
f"最近 {(rclone.get('ts') or '')[:19]}", '#4ade80'))
|
||
else:
|
||
cards.append(_svc_card('🔄', 'rclone 同步', '暂无记录', '—'))
|
||
# 人物合并
|
||
if person:
|
||
cards.append(_svc_card('👤', '人物合并',
|
||
f"{esc((person.get('action') or ''))}",
|
||
f"{(person.get('ts') or '')[:19]} · {esc((person.get('detail') or '')[:46])}", '#c084fc'))
|
||
else:
|
||
cards.append(_svc_card('👤', '人物合并', '暂无记录', '—'))
|
||
# NAS 同步
|
||
if nas_sync:
|
||
cnt = nas_sync.get('last_count') or [0, 0, 0, 0]
|
||
cards.append(_svc_card('📡', 'NAS 同步',
|
||
f"游标 {esc(str(nas_sync.get('cursor') or '')[:19])}",
|
||
f"最近 {(str(nas_sync.get('last_sync_at') or ''))[:19]} · 增量 V{cnt[0]} E{cnt[1]} P{cnt[2]} M{cnt[3]}",
|
||
'#fbbf24'))
|
||
else:
|
||
cards.append(_svc_card('📡', 'NAS 同步', '不可达', '—'))
|
||
# 模型
|
||
mcs = (act_data or {}).get('model_calls') or []
|
||
if mcs:
|
||
m0 = mcs[0]
|
||
cards.append(_svc_card('🧠', '云端模型',
|
||
f"最近:{esc(m0.get('model') or '')} {'✅' if m0.get('success') else '❌ ' + esc((m0.get('error') or '')[:30])}",
|
||
f"{(m0.get('started_at') or '')[:19]} · 耗时 {round(m0.get('duration_sec') or 0, 1)}s · 近5次 成功{sum(1 for m in mcs if m.get('success'))}/{len(mcs)}",
|
||
'#4ade80'))
|
||
else:
|
||
cards.append(_svc_card('🧠', '云端模型', '暂无调用', '—'))
|
||
st.markdown(
|
||
f'<div style="display:flex;flex-wrap:wrap;gap:2px;">{"".join(cards)}</div>',
|
||
unsafe_allow_html=True)
|
||
|
||
# ---- 活动时间流 ----
|
||
acts = (act_data or {}).get('activities') or []
|
||
st.markdown('<div style="font-size:15px;font-weight:700;color:#f1f5f9;'
|
||
'margin:24px 0 12px 0;">最近活动</div>', unsafe_allow_html=True)
|
||
if not acts:
|
||
st.markdown('<div class="fam-empty">暂无活动记录(服务刚启动?)</div>',
|
||
unsafe_allow_html=True)
|
||
else:
|
||
_svc_badge = {
|
||
'queue': ('<span class="bdg" style="background:#0e2233;border:1px solid #164e63;color:#7dd3fc;">队列</span>'),
|
||
'rclone': ('<span class="bdg" style="background:#0c1f17;border:1px solid #14532d;color:#4ade80;">同步</span>'),
|
||
'person': ('<span class="bdg" style="background:#1f122e;border:1px solid #4a1d96;color:#c084fc;">人物</span>'),
|
||
}
|
||
items = []
|
||
for a in acts[:50]:
|
||
badge = _svc_badge.get(a.get('service'), f"<span class='bdg'>{esc(a.get('service'))}</span>")
|
||
ts = str(a.get('ts') or '')[:19]
|
||
items.append(
|
||
f'<div class="ev-item" style="margin-bottom:5px;">'
|
||
f'<div class="ev-time" style="min-width:110px;">{esc(ts)}</div>'
|
||
f'<div class="ev-card" style="padding:6px 10px;">{badge}'
|
||
f'<span style="font-weight:600;color:#e2e8f0;margin:0 6px;">{esc(a.get("action"))}</span>'
|
||
f'<span style="font-size:12px;color:#94a3b8;">{esc(a.get("detail") or "")}</span>'
|
||
f'</div></div>')
|
||
st.markdown(
|
||
f'<div class="ev-list">{"".join(items)}</div>', unsafe_allow_html=True)
|
||
st.caption(f'共 {len(acts)} 条记录(活动日志仅保留最近 7 天)')
|