""" 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 = """ """ def page_header(icon: str, title: str, sub: str = ''): st.markdown( f'
' f'
{icon}
' f'
{esc(title)}
' f'{f"
{esc(sub)}
" if sub else ""}' f'
', unsafe_allow_html=True) def render_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'') badges = ''.join( f'{esc(p)}' for p in sorted(persons)) if attention: badges = '⚠ 需关注' + badges items.append( f'
' f'
{esc(time_label)}' f'{f"{esc(camera)}" if camera else ""}
' f'
{img_html}{badges}' f'
{esc(desc)}
' f'
' ) st.markdown(f'
{"".join(items)}
', unsafe_allow_html=True) # ============================================================ # 侧边栏 # ============================================================ st.markdown(GLOBAL_CSS, unsafe_allow_html=True) st.sidebar.markdown( '
' '
🏠 家庭智能监控
' '
SENTINEL HOME AI · 管理后台
' '
', unsafe_allow_html=True) # 顶部横向导航 page = st.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'
⚠ {esc(err)}
' if err else '' st.sidebar.markdown('---') st.sidebar.markdown( f'
' f'同步状态
' f'状态 {esc(status_txt)}
' f'最近 {esc(str(last)[:19]) if last else "—"}
' f'本次增量 {esc(cnt_str)}
' f'游标 {esc(str(cursor)[:19]) if cursor else "(全量)"}' f'{err_line}' f'
', 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'
' f'
{v}
视频会话
' f'
{e}
事件数
' f'
{ps}
出现人物
' f'
{att}
需关注
' f'
', unsafe_allow_html=True) except Exception: pass # 视频会话列表 page_size = 15 if 'event_page' not in st.session_state: st.session_state.event_page = 0 offset = st.session_state.event_page * page_size 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( '
🗓' '该日期暂无监控会话(甲骨文尚未同步数据?)
', 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'
视频会话 · {len(videos)} 条
', 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'
{esc(date_label)} · {esc(summary_short)}
', unsafe_allow_html=True) nav1, nav2, nav3 = st.columns(3) with nav1: if st.button("← 上一页", use_container_width=True, disabled=st.session_state.event_page == 0): st.session_state.event_page -= 1 st.rerun() with nav3: if st.button("下一页 →", use_container_width=True, disabled=len(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'{esc(p)}' for p in str(provider).split(',')) summary = vid.get('summary_json') or '暂无全局摘要' # 视频缩略图(Oracle 带 token 接口,无图时优雅降级) thumb_html = '' if _oracle_url: thumb_html = ( f'') st.markdown( f'
' f'{thumb_html}' f'
' f'{esc(vid.get("camera_name") or "未知摄像头")}' f'会话 #{vid["id"]}' f'{model_badges}
' f'
⏱ {esc(range_str)} · 文件 {esc(vid.get("filename") or "")}
' f'
{esc(summary)}
' f'
', 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( '
🎞' '该会话暂无时间点事件
', 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'
❓ 提问
' f'{esc(user_question)}
', unsafe_allow_html=True) st.markdown( f'
🤖 回答
', 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( '
💬' '暂无对话记录
', unsafe_allow_html=True) else: for h in history: created = serialize_datetime(h['created_at']) st.markdown( f'
' f'👤 {esc(h.get("queried_person") or "未知")} · {esc(created)}
' f'{esc(h["user_question"])}
', unsafe_allow_html=True) st.markdown( f'
🤖 回答
', unsafe_allow_html=True) st.markdown(h['ai_answer'] or '') st.markdown('
', unsafe_allow_html=True) nav1, nav2, nav3 = st.columns([1, 1, 1]) with nav1: if st.button("← 上一页", disabled=st.session_state.chat_page == 0): st.session_state.chat_page -= 1 st.rerun() with nav2: st.markdown( f'
第 {st.session_state.chat_page + 1} 页
', unsafe_allow_html=True) with nav3: if st.button("下一页 →", disabled=len(history) < page_size): st.session_state.chat_page += 1 st.rerun() # ============================================================ # 人物管理页 # ============================================================ elif page == "👤 人物管理": 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( '
👤' '暂未发现任何人物(甲骨文尚未同步)
', unsafe_allow_html=True) else: st.markdown( f'
' f'共 {len(groups)} 个身份 · ' f'已命名 {sum(1 for g in groups.values() if g["is_named"])} · ' f'未命名 {sum(1 for g in groups.values() if not g["is_named"])}' f'
', 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'已命名') if is_named else \ (f'未命名') label_str = ' · '.join(g['labels']) st.markdown( f'
' f'{esc(key)}{tag}
' f'
' f'标识: {esc(label_str)}
' f'
' f'出现 {g["appearances"]} 次 · 首次 {esc(first_str)}
', 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('
', unsafe_allow_html=True) # ============================================================ # 统计图表页 # ============================================================ elif page == "📈 统计图表": page_header('📈', '统计图表', '模型来源 / 关注事件 / 同步状态') conn = None try: conn = get_db_conn() cursor = conn.cursor() st.markdown('
模型来源分布
', 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('
暂无统计数据
', unsafe_allow_html=True) st.markdown('
关注事件统计
', 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('
暂无关注事件
', unsafe_allow_html=True) st.markdown('
同步状态
', 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'
⚠ {esc(err)}
' if err else '' st.markdown( f'
' f'运行状态 {"同步中" if sdata.get("running") else "未运行"}
' f'最近同步 {esc(str(sdata.get("last_sync_at") or "—")[:19])}
' f'本次增量 {esc(cnt_str)}
' f'游标 {esc(str(sdata.get("cursor") or "(全量)")[:19])}
' f'周期 {esc(str(sdata.get("interval_sec")))}s' f'{err_line}
', unsafe_allow_html=True) except Exception: st.markdown('
无法获取同步状态
', 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('
按模型聚合
', 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('
暂无模型调用记录(视频处理中或尚未同步)
', unsafe_allow_html=True) # ---- 最近调用明细 ---- st.markdown('
最近调用明细
', 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('
暂无调用明细
', unsafe_allow_html=True) # ---- 说明 ---- st.markdown( '
' '统计来自甲骨文端每次云端模型请求的记录(经 30 分钟同步拉取到本地镜像)。' '失败原因取值:429_quota(配额耗尽)/ timeout / 503_overload(过载重试)/ http_xxx / json_parse_failed 等。' '
', unsafe_allow_html=True) except Exception as e: st.error(f"模型统计查询失败: {e}") finally: if conn: conn.close()