zlwu 7 meses atrás
pai
commit
f3e74ae32a

+ 5 - 1
.gitignore

@@ -48,4 +48,8 @@ Thumbs.db
 conversation_log.txt
 
 # user-star 每日扣心执行记录(本地状态,不提交)
-user-star/.last_daily_deduct_date
+user-star/.last_daily_deduct_date
+
+# 本地配置(含数据库密码,勿提交)
+dailyCount/setting.conf
+dashboard/setting.conf

+ 58 - 0
backend/main.py

@@ -9,6 +9,7 @@ import urllib.parse
 import hashlib
 import uuid
 import random
+import base64
 from datetime import datetime, timedelta
 from typing import Optional, List, Dict, Any
 from fastapi import FastAPI, WebSocket, HTTPException, Request, Query, Header, Depends
@@ -29,6 +30,7 @@ from websocket_handler import WebSocketHandler
 from database import init_database, close_database, db_manager
 from services.redis_cache_service import RedisCacheService
 from services.user_service import user_service, AuthError
+from ai_service import ai_service
 from error_codes import (
     UNAUTHORIZED_USER_MISMATCH,
     SESSION_NOT_INITIALIZED,
@@ -783,6 +785,62 @@ async def get_energy_stone_counts(user_id: str, _: None = Depends(require_user_s
         raise HTTPException(status_code=500, detail={"code": GET_ENERGY_STONES_FAILED})
 
 
+def _get_tutorial_intro_text(step: int) -> str | None:
+    """根据教程步骤返回对应的固定英文文案。"""
+    mapping: Dict[int, str] = {
+        1: "Hi! Nice to meet you, my friend. Let me show you my home.",
+        3: "I’ve added captions so we can understand each other even better!",
+        5: "Tap the button and let's talk! How was your day",
+        8: "You can see your Energy Stones here.",
+        9: "You can also see our Bond Level here. It shows how close we are.",
+        10: "That's all! let’s start our first chat!",
+    }
+    return mapping.get(step)
+
+
+@app.get("/api/tutorials/{step}/intro-audio")
+async def get_tutorial_intro_audio(
+    step: int,
+    _: str = Depends(require_user_session_from_headers),
+):
+    """
+    新手教程步骤:返回对应字幕文案的 TTS 音频。
+
+    - 支持步骤:1 / 3 / 5 / 8 / 9 / 10
+    - 文案与前端字幕保持一致(ChatBotStaticShell 中的英文文案)
+    - 使用全局 ai_service 进行 TTS 合成,返回单段 Base64 PCM。
+    """
+    text = _get_tutorial_intro_text(step)
+    if text is None:
+        raise HTTPException(status_code=404, detail={"code": GET_CONFIG_FAILED})
+
+    try:
+        audio_chunks: list[bytes] = []
+        async for chunk in ai_service.text_to_speech_stream(text, emotion="friendly"):
+            audio_chunks.append(chunk)
+
+        if not audio_chunks:
+            raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
+
+        audio_bytes = b"".join(audio_chunks)
+        audio_base64 = base64.b64encode(audio_bytes).decode("utf-8")
+
+        return {
+            "success": True,
+            "data": {
+                "audio": audio_base64,
+                "sampleRate": 24000,
+                "format": "pcm",
+                "text": text,
+            },
+        }
+    except HTTPException:
+        raise
+    except Exception as exc:
+        logger.exception("生成教程步骤 TTS 音频失败: %s", exc)
+        raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
+
+
 @app.get("/api/users/{user_id}/avatars")
 async def get_user_avatars(user_id: str, _: None = Depends(require_user_session)):
     """

BIN
dashboard/Screenshot 1.png


BIN
dashboard/Screenshot 2.png


BIN
dashboard/demo_growth.db


+ 4 - 0
dashboard/requirements.txt

@@ -0,0 +1,4 @@
+streamlit>=1.28.0
+pandas>=1.5.0
+sqlalchemy>=2.0.0
+pymysql>=1.0.0

+ 9 - 0
dashboard/setting.conf.example

@@ -0,0 +1,9 @@
+# Dashboard MySQL 配置示例
+# 复制为 setting.conf 并按环境修改,勿将 setting.conf 提交到版本库(可加入 .gitignore)
+
+[mysql]
+host = 127.0.0.1
+port = 3306
+user = root
+password =
+database = Lyrria

+ 329 - 0
dashboard/streamlit_growth_dashboard.py

@@ -0,0 +1,329 @@
+import configparser
+import os
+from urllib.parse import quote_plus
+
+from datetime import date, timedelta
+
+import altair as alt
+import pandas as pd
+import streamlit as st
+
+try:
+    from sqlalchemy import create_engine, text
+except ImportError:
+    st.set_page_config(page_title="Growth Segmentation Dashboard", layout="wide")
+    st.error("请安装: pip install sqlalchemy pymysql")
+    st.stop()
+
+st.set_page_config(page_title="Growth Segmentation Dashboard", layout="wide")
+
+# ---- MySQL config from setting.conf ----
+SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
+DEFAULT_CONF = os.path.join(SCRIPT_DIR, "setting.conf")
+
+
+def load_config(conf_path: str) -> configparser.ConfigParser | None:
+    if not os.path.isfile(conf_path):
+        return None
+    cfg = configparser.ConfigParser(interpolation=None)
+    cfg.read(conf_path, encoding="utf-8")
+    return cfg
+
+
+def get_mysql_config(cfg: configparser.ConfigParser) -> dict:
+    return {
+        "host": cfg.get("mysql", "host", fallback="127.0.0.1"),
+        "port": cfg.getint("mysql", "port", fallback=3306),
+        "user": cfg.get("mysql", "user", fallback="root"),
+        "password": cfg.get("mysql", "password", fallback=""),
+        "database": cfg.get("mysql", "database", fallback="Lyrria"),
+        "charset": cfg.get("mysql", "charset", fallback="utf8mb4"),
+    }
+
+
+cfg = load_config(os.getenv("DASHBOARD_CONF", DEFAULT_CONF))
+if not cfg or not cfg.has_section("mysql"):
+    st.error(f"配置文件不存在或缺少 [mysql] 节: {DEFAULT_CONF}")
+    st.stop()
+
+mysql_kw = get_mysql_config(cfg)
+
+
+def _make_engine_url() -> str:
+    user = quote_plus(mysql_kw["user"])
+    password = quote_plus(mysql_kw["password"])
+    host = mysql_kw["host"]
+    port = mysql_kw["port"]
+    database = mysql_kw["database"]
+    charset = mysql_kw["charset"]
+    return f"mysql+pymysql://{user}:{password}@{host}:{port}/{database}?charset={quote_plus(charset)}"
+
+
+@st.cache_resource
+def _get_engine():
+    engine = create_engine(_make_engine_url())
+    with engine.connect() as c:
+        c.execute(text("SELECT 1"))
+    return engine
+
+
+try:
+    engine = _get_engine()
+except Exception as e:
+    st.error(f"MySQL 连接失败: {e}")
+    st.stop()
+
+
+def read_df(sql: str, params: tuple = ()) -> pd.DataFrame:
+    return pd.read_sql_query(sql, engine, params=params)
+
+st.title("Lyrria Growth Dashboard")
+
+# ---- Sidebar ----
+st.sidebar.header("Filters")
+
+segments = read_df("SELECT DISTINCT user_segment_snapshot AS seg FROM user_daily_activity ORDER BY seg")["seg"].dropna().tolist()
+orgs = read_df("SELECT DISTINCT org_id_snapshot AS org FROM user_daily_activity WHERE org_id_snapshot IS NOT NULL ORDER BY org")["org"].tolist()
+
+segment_default = [s for s in segments if s != "internal"] if segments else []
+segment_sel = st.sidebar.multiselect("user_segment_snapshot", options=segments, default=segment_default)
+org_sel = st.sidebar.multiselect("org_id_snapshot (school)", options=orgs, default=[])
+
+exclude_test = st.sidebar.checkbox("Exclude users.is_test_user = 1", value=True)
+
+# Date bounds (from daily table)
+max_day = pd.to_datetime(read_df("SELECT MAX(activity_date) AS d FROM user_daily_activity")["d"].iloc[0]).date()
+min_day = pd.to_datetime(read_df("SELECT MIN(activity_date) AS d FROM user_daily_activity")["d"].iloc[0]).date()
+
+target_day = st.sidebar.date_input("Target date", value=max_day, min_value=min_day, max_value=max_day)
+lookback_days = st.sidebar.selectbox("Trend window (days)", [14, 30, 60, 90], index=1)
+
+# ---- Load users (for universe + test filter) ----
+users = read_df("SELECT user_id, is_test_user FROM users")
+if exclude_test:
+    users = users[users["is_test_user"].fillna(0).astype(int) == 0].copy()
+universe_users = set(users["user_id"].astype(str).tolist())
+
+# ---- Pull daily activity for trend window + recent history (for 7/30d sets) ----
+# We only need recent for rolling-window sets & trend chart.
+start_recent = target_day - timedelta(days=max(lookback_days, 35))
+
+daily = read_df(
+    """
+    SELECT activity_date, user_id, active_flag, session_count, message_count,
+           first_seen_date, user_segment_snapshot, org_id_snapshot
+    FROM user_daily_activity
+    WHERE activity_date >= %s AND activity_date <= %s
+    """,
+    (start_recent.isoformat(), target_day.isoformat()),
+)
+
+daily["activity_date"] = pd.to_datetime(daily["activity_date"]).dt.date
+daily["first_seen_date"] = pd.to_datetime(daily["first_seen_date"]).dt.date
+
+# Apply snapshot filters
+if segment_sel:
+    daily = daily[daily["user_segment_snapshot"].isin(segment_sel)]
+if org_sel:
+    daily = daily[daily["org_id_snapshot"].isin(org_sel)]
+
+# Apply user universe filter (test users removed)
+daily = daily[daily["user_id"].isin(universe_users)]
+
+# If segment/org filters are applied, universe becomes "users within that cohort".
+# We derive cohort universe from snapshots (all users who ever appeared in daily within recent window).
+# In production, if you need cohort-universe across all time, build a cohort-user table or widen this query.
+if segment_sel or org_sel:
+    cohort_universe = set(daily["user_id"].unique().tolist())
+else:
+    cohort_universe = universe_users
+
+# ---- Trend (DAU, by day) ----
+st.subheader("DAU trend")
+trend = (
+    daily[
+        (daily["activity_date"] >= (target_day - timedelta(days=lookback_days)))
+        & (daily["activity_date"] <= target_day)
+    ]
+    .groupby("activity_date")["user_id"]
+    .nunique()
+    .reset_index(name="dau")
+)
+# 使用字符串日期 + Altair,控制 X 轴标签为横向
+trend["activity_date"] = pd.to_datetime(trend["activity_date"]).dt.date
+trend["day"] = trend["activity_date"].astype(str)
+
+chart = (
+    alt.Chart(trend)
+    .mark_line(point=True)
+    .encode(
+        x=alt.X("day:N", axis=alt.Axis(title="Date", labelAngle=0)),
+        y=alt.Y("dau:Q", axis=alt.Axis(title="DAU")),
+        tooltip=["day", "dau"],
+    )
+)
+st.altair_chart(chart, use_container_width=True)
+
+# ---- Segment computation on target_day ----
+d0 = target_day
+d7 = d0 - timedelta(days=7)
+d30 = d0 - timedelta(days=30)
+
+# Active sets (from recent daily window)
+active_today = set(daily.loc[(daily["activity_date"] == d0) & (daily["active_flag"] == 1), "user_id"])
+active_past_7 = set(daily.loc[(daily["activity_date"] < d0) & (daily["activity_date"] >= d7) & (daily["active_flag"] == 1), "user_id"])
+active_past_30 = set(daily.loc[(daily["activity_date"] < d0) & (daily["activity_date"] >= d30) & (daily["active_flag"] == 1), "user_id"])
+
+# --- IMPORTANT: Dormant/Resurrected require "last active before today" using FULL history ---
+# We compute:
+# prev_last_active_date(user) = max(activity_date) where active_flag=1 and activity_date < target_day
+#
+# This query uses the daily table (fast) and avoids limiting to a 35-day window.
+# Note: For segment/org filters, we still compute last_active across all time, but only for cohort_universe users.
+params = [d0.isoformat()]
+user_filter_sql = ""
+if cohort_universe:
+    # MySQL 单次 IN 变量过多时可改为临时表
+    placeholders = ",".join(["%s"] * len(cohort_universe))
+    user_filter_sql = f" AND user_id IN ({placeholders}) "
+    params += list(cohort_universe)
+
+last_active_df = read_df(
+    f"""
+    SELECT user_id, MAX(activity_date) AS last_active_date
+    FROM user_daily_activity
+    WHERE active_flag = 1
+      AND activity_date < %s
+      {user_filter_sql}
+    GROUP BY user_id
+    """,
+    tuple(params),
+)
+if not last_active_df.empty:
+    last_active_df["last_active_date"] = pd.to_datetime(last_active_df["last_active_date"]).dt.date
+prev_last_active = dict(zip(last_active_df["user_id"], last_active_df["last_active_date"])) if not last_active_df.empty else {}
+
+def prev_last_active_date(uid: str):
+    return prev_last_active.get(uid, None)
+
+# New users: use daily.first_seen_date (which you specified is users.created_at, written once and never overwritten)
+# Definition: first_seen_date == target_day AND active today
+# (If you want "registered today regardless of active", remove the active_today constraint.)
+first_seen_map = daily.groupby("user_id")["first_seen_date"].min().to_dict()
+new_users = {u for u in active_today if first_seen_map.get(u) == d0}
+
+# Current / Reactivated
+current_users = {u for u in active_today if u in active_past_7}
+reactivated_users = {u for u in active_today if (u in active_past_30) and (u not in active_past_7)}
+
+# Resurrected: active today AND previous last active < d0-30 (strictly before last 30 days)
+resurrected_users = {
+    u for u in active_today
+    if (prev_last_active_date(u) is not None) and (prev_last_active_date(u) < d30)
+}
+
+# Inactive segments
+at_risk_wau = {u for u in active_past_7 if u not in active_today}
+at_risk_mau = {u for u in active_past_30 if (u not in active_past_7) and (u not in active_today)}
+
+# Dormant: users not active today AND (never active before today OR previous last active < d0-30)
+dormant_users = {
+    u for u in cohort_universe
+    if (u not in active_today) and ((prev_last_active_date(u) is None) or (prev_last_active_date(u) < d30))
+}
+
+# ---- Segments visualization (Duolingo-like, no tables) ----
+st.subheader(f"Segments on {d0.isoformat()}")
+
+# Active buckets (composition of DAU). We add an "Other active" bucket as a safe remainder.
+other_active = active_today - new_users - current_users - reactivated_users - resurrected_users
+
+active_buckets = [
+    ("New", len(new_users)),
+    ("Current", len(current_users)),
+    ("Reactivated", len(reactivated_users)),
+    ("Resurrected", len(resurrected_users)),
+    ("Other active", len(other_active)),
+]
+active_total = len(active_today)
+inactive_total = max(0, len(cohort_universe) - len(active_today))
+inactive_buckets = [
+    ("At-risk (7d)", len(at_risk_wau)),
+    ("At-risk (30d)", len(at_risk_mau)),
+    ("Dormant (≥30d)", len(dormant_users)),
+]
+
+def segment_card(label: str, value: int, denom: int, hint: str):
+    pct = (value / denom) if denom > 0 else 0.0
+    st.markdown(
+        f"**{label}**"
+        f"<span style='font-size:30px; font-weight:800;'>{value:,}</span>",
+        unsafe_allow_html=True,
+    )
+    st.progress(min(max(pct, 0.0), 1.0))
+    st.caption(f"{pct:.1%} of {denom:,}. {hint}")
+
+# KPI tiles (top row)
+#k1, k2, k3, k4, k5 = st.columns(5)
+#k1.metric("DAU", len(active_today))
+#k2.metric("New", len(new_users))
+#k3.metric("Current", len(current_users))
+#k4.metric("Reactivated", len(reactivated_users))
+#k5.metric("Resurrected", len(resurrected_users))
+#
+#k6, k7, k8 = st.columns(3)
+#k6.metric("At-risk WAU", len(at_risk_wau))
+#k7.metric("At-risk MAU", len(at_risk_mau))
+#k8.metric("Dormant", len(dormant_users))
+
+st.markdown("")
+
+
+left, right = st.columns(2, gap="large")
+
+with left:
+    t1, t2 = st.columns([3, 1], vertical_alignment="center")
+    with t1:
+        st.markdown("### Active today composition")
+    with t2:
+        st.metric("DAU", len(active_today))
+
+    st.caption("How today's DAU is composed (buckets sum to DAU, with 'Other active' as remainder).")
+    for label, val in active_buckets:
+        if val == 0:
+            continue
+        segment_card(label, val, active_total, "Share of DAU.")
+
+with right:
+    t1, t2 = st.columns([2, 2], vertical_alignment="center")
+    with t1:
+        st.markdown("### Not active today")
+    with t2:
+        st.metric("Inactive / Total", f"{inactive_total:,} / {len(cohort_universe):,}")
+
+    st.caption("Breakdown of users who are not active today (within the current filtered cohort).")
+    for label, val in inactive_buckets:
+        if val == 0:
+            continue
+        segment_card(label, val, inactive_total, "Share of users not active today.")
+
+
+st.markdown("---")
+# ---- Drill down ----
+st.subheader("Drill-down (first 50 user_ids)")
+tab1, tab2, tab3 = st.tabs(["Active today", "At-risk", "Dormant"])
+with tab1:
+    st.write(sorted(list(active_today))[:50])
+with tab2:
+    st.write({
+        "at_risk_wau": sorted(list(at_risk_wau))[:50],
+        "at_risk_mau": sorted(list(at_risk_mau))[:50],
+    })
+with tab3:
+    st.write(sorted(list(dormant_users))[:50])
+
+#st.caption(
+#    "Notes: Dormant/Resurrected use prev_last_active_date computed from full user_daily_activity history "
+#    "(MAX(activity_date) where active_flag=1 and activity_date < target_day). "
+#    "If your cohort is very large, replace the IN (...) filter with a temp table join."
+#)

+ 224 - 0
dashboard/streamlit_growth_dashboard_demo.py

@@ -0,0 +1,224 @@
+"""
+Growth Dashboard - Demo 版:使用本地 SQLite demo_growth.db,便于与 MySQL 版对照。
+启动: streamlit run streamlit_growth_dashboard_demo.py
+"""
+import os
+from datetime import date, timedelta
+
+import pandas as pd
+import streamlit as st
+
+try:
+    from sqlalchemy import create_engine
+except ImportError:
+    st.set_page_config(page_title="Growth Dashboard (Demo)", layout="wide")
+    st.error("请安装: pip install sqlalchemy")
+    st.stop()
+
+st.set_page_config(page_title="Growth Dashboard (Demo)", layout="wide")
+
+# ---- SQLite demo_growth.db ----
+SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
+DEFAULT_DB = os.path.join(SCRIPT_DIR, "demo_growth.db")
+DB_PATH = os.getenv("DEMO_DB_PATH", DEFAULT_DB)
+
+if not os.path.exists(DB_PATH):
+    st.error(f"Demo 数据库不存在: {DB_PATH}")
+    st.stop()
+
+# SQLite 使用 ? 占位符;SQLAlchemy 连接避免 pandas 警告
+@st.cache_resource
+def _get_engine():
+    return create_engine(f"sqlite:///{DB_PATH}", connect_args={"check_same_thread": False})
+
+
+_engine = _get_engine()
+
+
+def read_df(sql: str, params: tuple = ()) -> pd.DataFrame:
+    return pd.read_sql_query(sql, _engine, params=params)
+
+
+st.title("Lyrria Growth Dashboard (Demo)")
+st.sidebar.caption("数据源: SQLite · demo_growth.db")
+
+# ---- Sidebar ----
+st.sidebar.header("Filters")
+
+segments = read_df("SELECT DISTINCT user_segment_snapshot AS seg FROM user_daily_activity ORDER BY seg")["seg"].dropna().tolist()
+orgs = read_df("SELECT DISTINCT org_id_snapshot AS org FROM user_daily_activity WHERE org_id_snapshot IS NOT NULL ORDER BY org")["org"].tolist()
+
+segment_default = [s for s in segments if s != "internal"] if segments else []
+segment_sel = st.sidebar.multiselect("user_segment_snapshot", options=segments, default=segment_default)
+org_sel = st.sidebar.multiselect("org_id_snapshot (school)", options=orgs, default=[])
+
+exclude_test = st.sidebar.checkbox("Exclude users.is_test_user = 1", value=True)
+
+# Date bounds (from daily table)
+max_day = pd.to_datetime(read_df("SELECT MAX(activity_date) AS d FROM user_daily_activity")["d"].iloc[0]).date()
+min_day = pd.to_datetime(read_df("SELECT MIN(activity_date) AS d FROM user_daily_activity")["d"].iloc[0]).date()
+
+target_day = st.sidebar.date_input("Target date", value=max_day, min_value=min_day, max_value=max_day)
+lookback_days = st.sidebar.selectbox("Trend window (days)", [14, 30, 60, 90], index=1)
+
+# ---- Load users (for universe + test filter) ----
+users = read_df("SELECT user_id, is_test_user FROM users")
+if exclude_test:
+    users = users[users["is_test_user"].fillna(0).astype(int) == 0].copy()
+universe_users = set(users["user_id"].astype(str).tolist())
+
+# ---- Pull daily activity for trend window + recent history (for 7/30d sets) ----
+start_recent = target_day - timedelta(days=max(lookback_days, 35))
+
+daily = read_df(
+    """
+    SELECT activity_date, user_id, active_flag, session_count, message_count,
+           first_seen_date, user_segment_snapshot, org_id_snapshot
+    FROM user_daily_activity
+    WHERE activity_date >= ? AND activity_date <= ?
+    """,
+    (start_recent.isoformat(), target_day.isoformat()),
+)
+
+daily["activity_date"] = pd.to_datetime(daily["activity_date"]).dt.date
+daily["first_seen_date"] = pd.to_datetime(daily["first_seen_date"]).dt.date
+
+if segment_sel:
+    daily = daily[daily["user_segment_snapshot"].isin(segment_sel)]
+if org_sel:
+    daily = daily[daily["org_id_snapshot"].isin(org_sel)]
+daily = daily[daily["user_id"].isin(universe_users)]
+
+if segment_sel or org_sel:
+    cohort_universe = set(daily["user_id"].unique().tolist())
+else:
+    cohort_universe = universe_users
+
+# ---- Trend (DAU) ----
+st.subheader("DAU trend")
+trend = (
+    daily[(daily["activity_date"] >= (target_day - timedelta(days=lookback_days))) & (daily["activity_date"] <= target_day)]
+    .groupby("activity_date")["user_id"].nunique()
+    .reset_index(name="dau")
+)
+trend["activity_date"] = pd.to_datetime(trend["activity_date"])
+st.line_chart(trend.set_index("activity_date")["dau"])
+
+# ---- Segment computation on target_day ----
+d0 = target_day
+d7 = d0 - timedelta(days=7)
+d30 = d0 - timedelta(days=30)
+
+active_today = set(daily.loc[(daily["activity_date"] == d0) & (daily["active_flag"] == 1), "user_id"])
+active_past_7 = set(daily.loc[(daily["activity_date"] < d0) & (daily["activity_date"] >= d7) & (daily["active_flag"] == 1), "user_id"])
+active_past_30 = set(daily.loc[(daily["activity_date"] < d0) & (daily["activity_date"] >= d30) & (daily["active_flag"] == 1), "user_id"])
+
+params = [d0.isoformat()]
+user_filter_sql = ""
+if cohort_universe:
+    placeholders = ",".join(["?"] * len(cohort_universe))
+    user_filter_sql = f" AND user_id IN ({placeholders}) "
+    params += list(cohort_universe)
+
+last_active_df = read_df(
+    f"""
+    SELECT user_id, MAX(activity_date) AS last_active_date
+    FROM user_daily_activity
+    WHERE active_flag = 1
+      AND activity_date < ?
+      {user_filter_sql}
+    GROUP BY user_id
+    """,
+    tuple(params),
+)
+if not last_active_df.empty:
+    last_active_df["last_active_date"] = pd.to_datetime(last_active_df["last_active_date"]).dt.date
+prev_last_active = dict(zip(last_active_df["user_id"], last_active_df["last_active_date"])) if not last_active_df.empty else {}
+
+
+def prev_last_active_date(uid: str):
+    return prev_last_active.get(uid, None)
+
+
+first_seen_map = daily.groupby("user_id")["first_seen_date"].min().to_dict()
+new_users = {u for u in active_today if first_seen_map.get(u) == d0}
+current_users = {u for u in active_today if u in active_past_7}
+reactivated_users = {u for u in active_today if (u in active_past_30) and (u not in active_past_7)}
+resurrected_users = {
+    u for u in active_today
+    if (prev_last_active_date(u) is not None) and (prev_last_active_date(u) < d30)
+}
+at_risk_wau = {u for u in active_past_7 if u not in active_today}
+at_risk_mau = {u for u in active_past_30 if (u not in active_past_7) and (u not in active_today)}
+dormant_users = {
+    u for u in cohort_universe
+    if (u not in active_today) and ((prev_last_active_date(u) is None) or (prev_last_active_date(u) < d30))
+}
+
+# ---- Segments visualization ----
+st.subheader(f"Segments on {d0.isoformat()}")
+other_active = active_today - new_users - current_users - reactivated_users - resurrected_users
+active_buckets = [
+    ("New", len(new_users)),
+    ("Current", len(current_users)),
+    ("Reactivated", len(reactivated_users)),
+    ("Resurrected", len(resurrected_users)),
+    ("Other active", len(other_active)),
+]
+active_total = len(active_today)
+inactive_total = max(0, len(cohort_universe) - len(active_today))
+inactive_buckets = [
+    ("At-risk (7d)", len(at_risk_wau)),
+    ("At-risk (30d)", len(at_risk_mau)),
+    ("Dormant (≥30d)", len(dormant_users)),
+]
+
+
+def segment_card(label: str, value: int, denom: int, hint: str):
+    pct = (value / denom) if denom > 0 else 0.0
+    st.markdown(
+        f"**{label}**"
+        f"<span style='font-size:30px; font-weight:800;'>{value:,}</span>",
+        unsafe_allow_html=True,
+    )
+    st.progress(min(max(pct, 0.0), 1.0))
+    st.caption(f"{pct:.1%} of {denom:,}. {hint}")
+
+
+st.markdown("")
+left, right = st.columns(2, gap="large")
+with left:
+    t1, t2 = st.columns([3, 1], vertical_alignment="center")
+    with t1:
+        st.markdown("### Active today composition")
+    with t2:
+        st.metric("DAU", len(active_today))
+    st.caption("How today's DAU is composed (buckets sum to DAU, with 'Other active' as remainder).")
+    for label, val in active_buckets:
+        if val == 0:
+            continue
+        segment_card(label, val, active_total, "Share of DAU.")
+with right:
+    t1, t2 = st.columns([2, 2], vertical_alignment="center")
+    with t1:
+        st.markdown("### Not active today")
+    with t2:
+        st.metric("Inactive / Total", f"{inactive_total:,} / {len(cohort_universe):,}")
+    st.caption("Breakdown of users who are not active today (within the current filtered cohort).")
+    for label, val in inactive_buckets:
+        if val == 0:
+            continue
+        segment_card(label, val, inactive_total, "Share of users not active today.")
+
+st.markdown("---")
+st.subheader("Drill-down (first 50 user_ids)")
+tab1, tab2, tab3 = st.tabs(["Active today", "At-risk", "Dormant"])
+with tab1:
+    st.write(sorted(list(active_today))[:50])
+with tab2:
+    st.write({
+        "at_risk_wau": sorted(list(at_risk_wau))[:50],
+        "at_risk_mau": sorted(list(at_risk_mau))[:50],
+    })
+with tab3:
+    st.write(sorted(list(dormant_users))[:50])

+ 139 - 37
frontend/src/components/ChatBotStaticShell.tsx

@@ -1,17 +1,19 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
 import { useNavigate } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
 import { getUserId, getStorage, STORAGE_KEYS } from '@/utils/storage';
 import { getIntimacyStatus, getAvatarGifs, type AvatarGifSet } from '@/utils/api';
 import { getOssUrl } from '@/utils/oss';
 import { px2vmin, px2vw } from '@/utils/viewport';
-import { STOOL_LAYOUT } from '@/constants/chatbot';
 import { wrapTrackedClick } from '@/utils/tracking';
+import { AudioPlayer } from '@/utils/audioPlayer';
 import './MissionEnergyStoneModal.css';
 
 // 兜底 GIF 资源,与 ChatBotPage 保持一致
 const FALLBACK_DEFAULT_AVATAR_GIF = getOssUrl('01-Default.gif');
 const FALLBACK_REGISTERED_DEFAULT_AVATAR_GIF = getOssUrl('01-Default-Registered.gif');
+// 新手教程欢迎页使用的礼花形象 GIF
+const TUTORIAL_CHEER_AVATAR_GIF = getOssUrl('03-Cheer.gif');
 
 // 形象 ID 常量(与后端一致)
 const AVATAR_ID_DEFAULT = 'avatar_default';
@@ -31,10 +33,8 @@ const diaryMenuIcon = getOssUrl('diary-icon.png');
 const luyinDefault = getOssUrl('luyin-default.png');
 const ccCloseIcon = getOssUrl('cc-close-icon.png');
 const ccOpenIcon = getOssUrl('cc-open-icon.png');
-const treesImage = getOssUrl('init3.svg');
-const stoolImage = getOssUrl('stool.png');
-const t1OverlayImage = getOssUrl('t1-overlay.png');
-const tutorialStep1AvatarGif = getOssUrl('03-Cheer.gif');
+const treesImage = getOssUrl('init2.png');
+const t1OverlayImage = getOssUrl('init2.png');
 const primaryButtonImage = getOssUrl('primary-default.png');
 const tutorialStep2ZhUnionImage = getOssUrl('t2-zh-union.png');
 const tutorialStep2EnUnionImage = getOssUrl('t2-en-union.png');
@@ -76,6 +76,7 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
   const [avatarGif, setAvatarGif] = useState<string>(FALLBACK_DEFAULT_AVATAR_GIF);
   const [showFuncMenuPopup, setShowFuncMenuPopup] = useState<boolean>(false);
   const [showBondLevelModal, setShowBondLevelModal] = useState<boolean>(false);
+  const introAudioPlayerRef = useRef<AudioPlayer | null>(null);
 
   useEffect(() => {
     // 确保每次进入组件 / 步骤变化时,优先关闭弹窗
@@ -109,6 +110,90 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
     };
   }, [step, showBondLevelModal]);
 
+  useEffect(() => {
+    // 仅在指定步骤(1 / 3 / 5 / 8 / 9 / 10)播放字幕对应的引导语音
+    if (step !== 1 && step !== 3 && step !== 5 && step !== 8 && step !== 9 && step !== 10) {
+      return;
+    }
+
+    let cancelled = false;
+
+    interface TutorialIntroAudioResponse {
+      success: boolean;
+      data?: {
+        audio?: string;
+        sampleRate?: number;
+        format?: string;
+        text?: string;
+      };
+    }
+
+    const base64ToUint8Array = (base64: string): Uint8Array => {
+      const binaryString = atob(base64);
+      const len = binaryString.length;
+      const bytes = new Uint8Array(len);
+      for (let i = 0; i < len; i += 1) {
+        bytes[i] = binaryString.charCodeAt(i);
+      }
+      return bytes;
+    };
+
+    const playIntroAudio = async () => {
+      try {
+        const [{ getApiBaseUrl, getApiHeaders }] = await Promise.all([
+          import('@/utils/api'),
+        ]);
+
+        const apiBaseUrl = getApiBaseUrl();
+        const response = await fetch(`${apiBaseUrl}/api/tutorials/${step}/intro-audio`, {
+          method: 'GET',
+          headers: getApiHeaders(),
+        });
+
+        if (!response.ok) {
+          return;
+        }
+
+        const body = (await response.json()) as TutorialIntroAudioResponse;
+        if (!body.success || !body.data?.audio) {
+          return;
+        }
+
+        if (cancelled) {
+          return;
+        }
+
+        if (!introAudioPlayerRef.current) {
+          const player = new AudioPlayer();
+          await player.initialize();
+          introAudioPlayerRef.current = player;
+        }
+
+        const player = introAudioPlayerRef.current;
+        if (!player) {
+          return;
+        }
+
+        const audioBytes = base64ToUint8Array(body.data.audio);
+        const sampleRate = body.data.sampleRate ?? 24000;
+
+        await player.addChunk(audioBytes, sampleRate);
+        player.markStreamEnd();
+      } catch {
+        // 静默失败,避免影响新手教程体验
+      }
+    };
+
+    void playIntroAudio();
+
+    return () => {
+      cancelled = true;
+      if (introAudioPlayerRef.current) {
+        introAudioPlayerRef.current.stop();
+      }
+    };
+  }, [step]);
+
   useEffect(() => {
     const userId = getUserId();
     if (!userId) {
@@ -176,8 +261,19 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
     })();
   }, []);
 
-  // 教学步骤 1:使用专用背景 + 静态欢迎布局
-  if (step === 1) {
+  // 教学步骤 1 & 10:使用专用背景 + 静态欢迎布局
+  if (step === 1 || step === 10) {
+    const isFirstStep = step === 1;
+    const subtitleText = isFirstStep
+      ? 'Hi! Nice to meet you, my friend. Let me show you my home.'
+      : "That's all! let’s start our first chat!";
+    const buttonText = isFirstStep
+      ? tCommon('button.startExploring')
+      : i18n.language?.startsWith('zh')
+          ? '开始对话'
+          : 'Start Chatting';
+    const nextPath = isFirstStep ? '/tutorial-2' : '/chatbot';
+
     return (
       <div
         className="w-full h-full overflow-hidden"
@@ -197,11 +293,34 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
               width: '100%',
               height: '100%',
               objectFit: 'cover',
+              zIndex: 0,
             }}
           />
         )}
 
-        {/* 欢迎形象 03-Cheer.gif */}
+        {/* 底层深色蒙层:与 tutorial-3 相同的黑色遮罩 */}
+        <div
+          style={{
+            position: 'absolute',
+            inset: 0,
+            backgroundColor: 'rgba(0, 0, 0, 0.6)',
+            zIndex: 1,
+          }}
+        />
+
+        {/* 顶层柔和蒙层:在深色蒙层之上,形象 / 字幕 / 按钮之下,带轻微模糊 */}
+        <div
+          style={{
+            position: 'absolute',
+            inset: 0,
+            backgroundColor: 'rgba(0, 0, 0, 0.08)',
+            backdropFilter: 'blur(6px)',
+            WebkitBackdropFilter: 'blur(6px)',
+            zIndex: 2,
+          }}
+        />
+
+        {/* 欢迎形象:使用礼花 GIF(03-Cheer.gif),位于蒙层之上 */}
         <div
           style={{
             position: 'absolute',
@@ -210,12 +329,12 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
             transform: 'translateX(-50%)',
             width: px2vmin(238, 'v2'),
             height: px2vmin(238, 'v2'),
-            zIndex: 2,
+            zIndex: 3,
           }}
         >
           <img
-            src={tutorialStep1AvatarGif}
-            alt={tCommon('a11y.cheerAnimation')}
+            src={TUTORIAL_CHEER_AVATAR_GIF}
+            alt={tCommon('a11y.avatar')}
             style={{
               width: '100%',
               height: '100%',
@@ -242,9 +361,9 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
             paddingLeft: px2vmin(16, 'v2'),
             paddingRight: px2vmin(16, 'v2'),
             boxSizing: 'border-box',
-            zIndex: 2,
+            zIndex: 3,
           }}
-        >
+          >
           <span
             style={{
               color: '#F5F3F0',
@@ -254,7 +373,7 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
               whiteSpace: 'pre-wrap',
             }}
           >
-            Hi! Nice to meet you, my friend. Let me show you my home.
+            {subtitleText}
           </span>
         </div>
 
@@ -273,9 +392,9 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
             alignItems: 'center',
             justifyContent: 'center',
             cursor: 'pointer',
-            zIndex: 2,
+            zIndex: 3,
           }}
-          onClick={wrapTrackedClick(pageId, 'next', () => navigate('/tutorial-2'))}
+          onClick={wrapTrackedClick(pageId, 'next', () => navigate(nextPath))}
         >
           <div
             style={{
@@ -286,7 +405,7 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
           >
             <img
               src={primaryButtonImage}
-              alt={tCommon('button.startExploring')}
+              alt={buttonText}
               style={{
                 width: '100%',
                 height: '100%',
@@ -308,7 +427,7 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
                 whiteSpace: 'nowrap',
               }}
             >
-              {tCommon('button.startExploring')}
+              {buttonText}
             </span>
           </div>
         </button>
@@ -341,23 +460,6 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
           }}
         />
       )}
-      {/* 背景上方凳子图,与 ChatBotPage 共用 STOOL_LAYOUT */}
-      {stoolImage && (
-        <img
-          src={stoolImage}
-          alt=""
-          className="absolute pointer-events-none"
-          style={{
-            width: px2vmin(STOOL_LAYOUT.width),
-            height: px2vmin(STOOL_LAYOUT.height),
-            left: px2vmin(STOOL_LAYOUT.left),
-            top: px2vmin(STOOL_LAYOUT.top),
-            zIndex: 2,
-            objectFit: 'contain',
-          }}
-          draggable={false}
-        />
-      )}
       {/* 教学步骤 2:左侧介绍框位置改为点击图片 */}
       {step === 2 && (
         <button
@@ -741,7 +843,7 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
         }}
       >
         <img
-          src={step === 10 ? tutorialStep1AvatarGif : avatarGif}
+          src={FALLBACK_DEFAULT_AVATAR_GIF}
           alt={tCommon('a11y.avatar')}
           style={{
             position: 'absolute',

+ 15 - 1
frontend/src/main.tsx

@@ -8,7 +8,21 @@ import { getOssUrl } from '@/utils/oss';
 import './index.css';
 
 // 设置 CSS 变量,用于 CSS 文件中的 OSS 资源 URL
-document.documentElement.style.setProperty('--oss-chatbot-bg-url', `url("${getOssUrl('chatbot-bg.svg')}")`);
+const chatbotBgUrl = getOssUrl('chatbot-bg.svg');
+document.documentElement.style.setProperty('--oss-chatbot-bg-url', `url("${chatbotBgUrl}")`);
+
+// 预加载 /chatbot 首屏关键资源,进入页面前即可进入浏览器缓存
+const criticalChatbotAssets: { url: string; as: 'image' }[] = [
+  { url: getOssUrl('01-Default.gif'), as: 'image' },
+  { url: chatbotBgUrl, as: 'image' },
+];
+criticalChatbotAssets.forEach(({ url, as: asType }) => {
+  const link = document.createElement('link');
+  link.rel = 'preload';
+  link.as = asType;
+  link.href = url;
+  document.head.appendChild(link);
+});
 
 // 立即初始化 --vw(在 installViewportHeightFix 之前,确保尽早设置)
 // 解决曲面屏问题:使用 visualViewport.width 获取实际可视宽度

+ 13 - 5
frontend/src/pages/ChatBotPage.tsx

@@ -2912,7 +2912,7 @@ export const ChatBotPage = () => {
 
   // 根据背景图片获取对应的树木装饰图片URL(/chatbot 界面统一使用默认图片,不随用户选择的新年等背景切换)
   const getTreesImage = (): string | null => {
-    return getOssUrl('init3.svg');
+    return getOssUrl('init3.png');
   };
 
   // 根据背景图片ID获取树木装饰图片的样式配置(/chatbot 使用默认背景,透明度固定为 1)
@@ -3336,6 +3336,7 @@ export const ChatBotPage = () => {
             alt={currentAvatarAlt}
             loading="eager"
             decoding="async"
+            fetchPriority="high"
             style={{
               position: 'absolute',
               inset: 0,
@@ -3352,6 +3353,7 @@ export const ChatBotPage = () => {
             alt={currentAvatarAlt}
             loading="eager"
             decoding="async"
+            fetchPriority="high"
             style={{
               position: 'absolute',
               inset: 0,
@@ -3366,19 +3368,25 @@ export const ChatBotPage = () => {
         </div>
       )}
 
-      {/* 情景对话:字幕区域上方话题名称 + 切换话题按钮(仅特殊场景展示),与字幕左对齐;
-          用户在该场景该话题下开始对话后整行隐藏(话题标签与切换按钮都不再显示) */}
+      {/* 情景对话:话题名称 + 切换话题按钮(仅特殊场景展示),与字幕左对齐;
+          - 当用户已打开字幕时:展示在字幕区域上方(保持与字幕容器间距 5px)
+          - 当用户未打开字幕时:这一行放到「字幕区域」的最下方位置展示 */}
       {greetingContext && greetingContext.scene !== 'default' && greetingContext.topics.length > 0 && !scenarioConversationStarted && (() => {
         const currentTopic = greetingContext.topics[currentTopicIndex];
         const topicLabel = currentTopic
           ? (isZh ? (currentTopic.labelZh ?? currentTopic.labelEn ?? currentTopic.key) : (currentTopic.labelEn ?? currentTopic.labelZh ?? currentTopic.key))
           : '';
+        const hasSubtitleOn = subtitleOn;
+        // 字幕开关打开:话题行在字幕区域上方(与当前实现一致)
+        // 字幕开关关闭:话题行移动到「字幕区域」的最下方位置(区域底部 72px 内的底部对齐)
+        const topicRowTop = hasSubtitleOn
+          ? px2vmin(540 - 32 - 5, 'v2')
+          : px2vmin(540 + 72 - 32, 'v2');
         return (
           <div
             className="absolute flex items-center"
             style={{
-              // 保持与字幕容器间距 5px,字幕 top=540、高度 72
-              top: px2vmin(540 - 32 - 5, 'v2'),
+              top: topicRowTop,
               left: '50%',
               transform: 'translateX(-50%)',
               width: px2vmin(312, 'v2'),

+ 2 - 28
frontend/src/pages/TutorialStep10Page.tsx

@@ -1,5 +1,4 @@
-import { useEffect, useCallback } from 'react';
-import { useNavigate } from 'react-router-dom';
+import { useEffect } from 'react';
 import { ChatBotStaticShell } from '@/components/ChatBotStaticShell';
 import {
   setStorage,
@@ -9,11 +8,8 @@ import {
   getUserId,
 } from '@/utils/storage';
 import { updateUserUIState } from '@/utils/api';
-import { wrapTrackedClick } from '@/utils/tracking';
 
 export const TutorialStep10Page: React.FC = () => {
-  const navigate = useNavigate();
-
   useEffect(() => {
     const routePath = '/tutorial-10' as const;
 
@@ -30,28 +26,6 @@ export const TutorialStep10Page: React.FC = () => {
     }
   }, []);
 
-  const handleClick = useCallback(() => {
-    navigate('/chatbot');
-  }, [navigate]);
-  const handleTrackedClick = wrapTrackedClick('tutorial_10', 'next', handleClick);
-
-  return (
-    <>
-      <ChatBotStaticShell step={10} />
-      <div
-        role="button"
-        tabIndex={0}
-        onClick={handleTrackedClick}
-        onKeyDown={(e) => e.key === 'Enter' && handleTrackedClick()}
-        style={{
-          position: 'fixed',
-          inset: 0,
-          cursor: 'pointer',
-          zIndex: 10000,
-        }}
-        aria-label="Go to chat"
-      />
-    </>
-  );
+  return <ChatBotStaticShell step={10} />;
 };
 

+ 26 - 2
frontend/src/router/index.tsx

@@ -1,10 +1,10 @@
+import { lazy, Suspense } from 'react';
 import { createBrowserRouter, Navigate, Outlet } from 'react-router-dom';
 import { WelcomePage } from '@/pages/WelcomePage';
 import { BeginPage } from '@/pages/BeginPage';
 import LoadingPage from '@/pages/LoadingPage';
 import { GreetingPage } from '@/pages/GreetingPage';
 import { LumiNamingPage } from '@/pages/LumiNamingPage';
-import { ChatBotPage } from '@/pages/ChatBotPage';
 import { Level5WelcomePage } from '@/pages/Level5WelcomePage';
 import { Level34WelcomePage } from '@/pages/Level34WelcomePage';
 import { FirstLoginPage } from '@/pages/FirstLoginPage';
@@ -34,6 +34,26 @@ import { TutorialStep10Page } from '@/pages/TutorialStep10Page';
 
 const basename = import.meta.env.BASE_URL || '/';
 
+const ChatBotPage = lazy(() =>
+  import('@/pages/ChatBotPage').then((m) => ({ default: m.ChatBotPage }))
+);
+
+const ChatBotFallback = () => (
+  <div
+    style={{
+      display: 'flex',
+      alignItems: 'center',
+      justifyContent: 'center',
+      minHeight: '100vh',
+      background: 'linear-gradient(180deg, #1a0a2e 0%, #2d1b4e 100%)',
+      color: 'rgba(255,255,255,0.8)',
+      fontSize: '16px',
+    }}
+  >
+    加载中…
+  </div>
+);
+
 export const router = createBrowserRouter([
   {
     path: '/',
@@ -76,7 +96,11 @@ export const router = createBrowserRouter([
       },
       {
         path: '/chatbot',
-        element: <ChatBotPage />,
+        element: (
+          <Suspense fallback={<ChatBotFallback />}>
+            <ChatBotPage />
+          </Suspense>
+        ),
       },
       // 新手教程 10 步(基于 ChatBot 静态壳,每步单独页面)
       {

+ 1 - 1
frontend/src/utils/storage.ts

@@ -445,7 +445,7 @@ export const loadBackgroundImageConfig = async (): Promise<void> => {
       const { getOssUrl } = await import('./oss');
       backgroundImageConfigCache = {
         default: {
-          image_url: getOssUrl('init2.svg'),
+          image_url: getOssUrl('init2.png'),
         },
       };
     } finally {