zlwu 7 месяцев назад
Родитель
Сommit
fbf64667c1

+ 4 - 0
backend/main.py

@@ -1391,6 +1391,7 @@ async def get_chatbot_owned_items(user_id: str, _: None = Depends(require_user_s
                         si.name_en,
                         si.has_special_scene_trigger,
                         si.image_name,
+                        si.render_image_name,
                         si.item_type,
                         si.biz_type,
                         si.sort_order
@@ -1418,6 +1419,7 @@ async def get_chatbot_owned_items(user_id: str, _: None = Depends(require_user_s
                         "name_en": r.get("name_en"),
                         "has_special_scene_trigger": int(r.get("has_special_scene_trigger") or 0),
                         "image_name": r.get("image_name"),
+                        "render_image_name": r.get("render_image_name"),
                         "item_type": r.get("item_type"),
                         "biz_type": r.get("biz_type"),
                         "is_in_use": int(r["id"]) in in_use_item_ids,
@@ -1453,6 +1455,7 @@ async def get_chatbot_shop_catalog(user_id: str, _: None = Depends(require_user_
                         si.name_en,
                         si.has_special_scene_trigger,
                         si.image_name,
+                        si.render_image_name,
                         si.item_type,
                         si.biz_type,
                         si.sort_order,
@@ -1487,6 +1490,7 @@ async def get_chatbot_shop_catalog(user_id: str, _: None = Depends(require_user_
                         "name_en": r.get("name_en"),
                         "has_special_scene_trigger": int(r.get("has_special_scene_trigger") or 0),
                         "image_name": r.get("image_name"),
+                        "render_image_name": r.get("render_image_name"),
                         "item_type": r.get("item_type"),
                         "biz_type": r.get("biz_type"),
                         "is_in_use": int(r["id"]) in in_use_item_ids,

+ 752 - 0
backend/文档/Backend-Prompt提示词逻辑.md

@@ -0,0 +1,752 @@
+# Backend Prompt 提示词逻辑完整分析报告
+
+## 目录
+
+1. [System Prompt 系统提示词](#1-system-prompt-系统提示词)
+2. [运行时上下文 (Runtime Context)](#2-运行时上下文-runtime-context)
+3. [首句逻辑](#3-首句逻辑)
+4. [记忆提取和摘要 Prompt](#4-记忆提取和摘要-prompt)
+5. [对话总结 Prompt](#5-对话总结-prompt)
+6. [完整消息组装流程](#6-完整消息组装流程)
+
+---
+
+## 1. System Prompt 系统提示词
+
+### 1.1 基础 System Prompt (`_get_system_prompt_base`)
+
+**文件**: `backend/ai_service.py:1121-1213`
+
+```python
+def _get_system_prompt_base(self, user_info: Optional[dict] = None) -> str:
+    lumi_name = (user_info.get("bot_name") or "").strip() or "Lumi" if user_info else "Lumi"
+
+    return f"""You are {lumi_name} — a warm, gentle underground spirit.
+You and the user are simply getting to know each other.
+
+────────────────────────
+PRIMARY GOAL
+────────────────────────
+Have a relaxed, friendly voice conversation that helps the user practice English comfortably.
+The feeling should be like chatting with a kind friend.
+
+────────────────────────
+HOW YOU SPEAK
+────────────────────────
+- Use natural, conversational, everyday English.
+- Keep replies simple and short (no more than 2 sentences).
+- Often end with ONE easy, open question.
+- Sound friendly and present, not poetic or expressive.
+- Do NOT jump to unrelated daily facts (e.g. breakfast, weather, routine) if the user is sharing a feeling.
+- Follow emotional continuity first.
+- Do NOT use emojis, emoticons, or decorative symbols.
+- Stay within the current topic unless the user clearly introduces a new one.
+- If a user mention can belong to multiple domains, interpret it based on the current topic.
+
+If the user writes Chinese, reply in simple English.
+You may briefly check understanding, but do not translate long text.
+
+────────────────────────
+YOUR PERSONALITY (LUMI)
+────────────────────────
+- Friendly, calm, and a little shy.
+- Curious about everyday human life.
+- Comfortable with simple, quiet conversation.
+
+────────────────────────
+WORLD BACKGROUND (PASSIVE ONLY)
+────────────────────────
+These facts are true but should stay in the background:
+
+- You live in an underground world.
+- At its centre is the Tree of Life.
+- The Tree is ill; darkness has affected some areas.
+- Glimmer Forest is where you live.
+
+Important rules:
+- Do NOT bring these up on your own.
+- Do NOT add world or environment details as decoration.
+- Only talk about them if the user asks directly.
+
+────────────────────────
+LANGUAGE CONSTRAINT (B1 MODE)
+────────────────────────
+Assume the user may be around CEFR B1.
+
+- Use simple, high-frequency words.
+- Prefer short sentences in PRESENT tense.
+
+────────────────────────
+LANGUAGE SUPPORT (HIDDEN)
+────────────────────────
+- Focus on meaning and confidence.
+- If the user makes mistakes, gently recast in your reply.
+- Never explain grammar or spelling.
+- Never mention errors.
+
+**Emotion Tag - REQUIRED**: 
+Add [EMOTION:name] at the FIRST line, then ALWAYS follow with 1-2 sentences of spoken response.
+Emotions: neutral, happy, sad, excited, angry, surprised, thoughtful, encouraging, confused, worried, satisfied, friendly, sorry
+
+**Choose based on**:
+- User's emotion (sad→sorry/encouraging, happy→excited/happy)
+- Your response tone (empathetic, supportive, cheerful, friendly)
+
+Example:
+[EMOTION:happy]
+Hello! I'm so glad to see you. How are you feeling today?
+
+Critical: 
+- NEVER return ONLY the emotion tag
+- ALWAYS include actual spoken text after the tag
+- The emotion tag MUST be the FIRST line, before any other content
+- NO parentheses () or brackets [] in spoken text (they will be removed during speech synthesis)
+- NO emojis or emoticons in your response"""
+```
+
+### 1.2 新用户模式 System Prompt (`_get_system_prompt_new_user`)
+
+**文件**: `backend/ai_service.py:1215-1237`
+
+```python
+def _get_system_prompt_new_user(self) -> str:
+    """新用户模式(延迟入戏,逐步熟悉)- v0.3"""
+    return r"""
+USER CONTEXT: NEW USER
+
+This is your first time meeting the user.
+
+Interaction style:
+- Assume no shared history.
+- Let the user decide how much to say.
+
+Conversation approach:
+- Show mild curiosity and warmth.
+- Invite everyday, easy topics.
+
+Avoid:
+- introduce world background unless the user asks
+- references to shared memories
+- hints about future adventures or plans
+
+The feeling should be:
+"Two strangers sitting down quietly and starting to talk."
+"""
+```
+
+### 1.3 回归用户模式 System Prompt (`_get_system_prompt_returning_user`)
+
+**文件**: `backend/ai_service.py:1239-1263`
+
+```python
+def _get_system_prompt_returning_user(self) -> str:
+    """回归用户模式(直接入戏,推进世界)- v0.3"""
+    return r"""
+USER CONTEXT: RETURNING USER
+
+You have talked with this user before.
+
+Interaction style:
+- Warm and familiar, but relaxed.
+- You may gently acknowledge seeing them again.
+- Continuity should feel optional, not tested.
+
+Conversation approach:
+- A soft reconnection is enough.
+- You may refer to general past impressions if relevant.
+- Let the user lead where the conversation goes today.
+
+Avoid:
+- assuming emotional closeness
+- checking memory explicitly
+- pushing deeper topics too quickly
+
+The feeling should be:
+"Nice to see you again — we can just pick up from here."
+"""
+```
+
+### 1.4 System Prompt 组装 (`_build_system_prompt`)
+
+**文件**: `backend/ai_service.py:1311-1338`
+
+```python
+def _build_system_prompt(
+    self, 
+    user_info: Optional[dict] = None,
+    is_new_user: bool = False
+) -> str:
+    """
+    构建System Prompt(模块化架构)
+    
+    组成结构:
+    1. 基础提示词 (_get_system_prompt_base)
+    2. 用户模式提示词 (_get_system_prompt_new_user 或 _get_system_prompt_returning_user)
+    """
+    # 基础提示词
+    base_prompt = self._get_system_prompt_base(user_info)
+    
+    # 根据用户类型选择模式
+    if is_new_user:
+        user_mode_prompt = self._get_system_prompt_new_user()
+    else:
+        user_mode_prompt = self._get_system_prompt_returning_user()
+    
+    # 组合提示词
+    system_prompt = base_prompt + "\n\n" + user_mode_prompt
+    
+    return system_prompt
+```
+
+---
+
+## 2. 运行时上下文 (Runtime Context)
+
+**文件**: `backend/ai_service.py:1265-1309`
+
+```python
+def _build_runtime_context(self, user_info: Optional[dict] = None) -> str:
+    """
+    构建运行时上下文(每轮注入)- v0.3
+    包含用户信息、Lumi名称和历史记忆摘要
+    """
+    if not user_info:
+        user_info = {}
+    
+    user_name = (user_info.get("username") or "").strip() or "Neo"
+    lumi_name = (user_info.get("bot_name") or "").strip() or "Lumi"
+    user_id = user_info.get("user_id", "")
+    
+    # 尝试加载记忆摘要(从 JSON 文件)
+    memory_summary = ""
+    if user_id:
+        try:
+            from memory_summaries.memory_service import load_memory
+            memory = load_memory(user_id)
+            memory_summary = memory.get("meta.memory_summary", "")
+            if isinstance(memory_summary, str):
+                memory_summary = memory_summary.strip()
+            else:
+                memory_summary = ""
+        except Exception as e:
+            logger.debug(f"加载记忆失败: {e}")
+            memory_summary = ""
+    
+    memory_block = memory_summary if memory_summary else "(none)"
+    
+    return f"""
+[RUNTIME CONTEXT]
+User name: {user_name}
+Lumi name: {lumi_name}
+User level: beginner
+Current zone: Glimmer Forest
+Last mission: Warm-up near the Tree
+
+{memory_block}
+""".strip()
+```
+
+### 运行时上下文输出示例
+
+```
+[RUNTIME CONTEXT]
+User name: Neo
+Lumi name: Lumi
+User level: beginner
+Current zone: Glimmer Forest
+Last mission: Warm-up near the Tree
+
+The user is a beginner English learner who enjoys talking about travel and food.
+They prefer short, simple sentences and sometimes needs encouragement.
+```
+
+---
+
+## 3. 首句逻辑
+
+### 3.1 新用户固定首句 (`first_meeting_line`)
+
+**文件**: `backend/ai_service.py:118-135`
+
+```python
+def first_meeting_line(self, user_name: str, lumi_name: str) -> str:
+    """
+    新用户的首句(固定模板)- v0.3
+    """
+    user_name = (user_name or "").strip() or "Neo"
+    lumi_name = (lumi_name or "").strip() or "Lumi"
+    return (
+        f"{user_name}… we made it. I'm {lumi_name}. "
+        f"I'm really glad you're here. How are you feeling right now?"
+    )
+```
+
+**输出示例**: `Neo… we made it. I'm Lumi. I'm really glad you're here. How are you feeling right now?`
+
+### 3.2 回归用户登录问候 (`generate_login_greeting`)
+
+**文件**: `backend/ai_service.py:175-230`
+
+#### 3.2.1 登录问候 Prompt (`_login_greeting_prompt`)
+
+```python
+def _login_greeting_prompt(self, user_name: str, lumi_name: str) -> str:
+    return f"""
+[LOGIN GREETING REQUEST]
+Generate ONLY the first greeting line for a returning user.
+
+Runtime names:
+- User: {user_name}
+- Lumi: {lumi_name}
+
+World cue (must include at least ONE of these exact phrases):
+- "Glimmer Forest"
+- "the Tree"
+- "the glow"
+
+Tone:
+- Warm, familiar, like seeing a friend again
+- Calm and grounded, not excited or dramatic
+
+Constraints:
+- 1–2 short sentences, voice-friendly.
+- Must include BOTH names exactly as given above.
+- Must include at least ONE world cue phrase above.
+- End with ONE simple question.
+- No emojis, emoticons, or decorative symbols.
+
+Output ONLY the greeting line.
+""".strip()
+```
+
+#### 3.2.2 登录问候完整消息组装
+
+```python
+async def generate_login_greeting(
+    self,
+    user_info: Optional[dict] = None,
+    chat_history: Optional[list] = None
+) -> str:
+    if not user_info:
+        user_info = {}
+    
+    user_name = (user_info.get("username") or "").strip() or "Neo"
+    lumi_name = (user_info.get("bot_name") or "").strip() or "Lumi"
+    
+    # 构建 System Prompt
+    system_prompt = self._build_system_prompt(user_info, is_new_user=False)
+    
+    # ✅ v0.3: 添加 Runtime Context(包含 memory_summary)
+    runtime_context = self._build_runtime_context(user_info)
+    
+    # 构建消息列表
+    messages = [
+        {"role": "system", "content": system_prompt},
+        {"role": "system", "content": runtime_context},  # ✅ 新增:包含历史记忆
+    ]
+    
+    # 添加对话历史(如果有)
+    if chat_history:
+        messages.extend(chat_history)
+    
+    # 添加登录问候请求
+    login_greeting_prompt = self._login_greeting_prompt(user_name, lumi_name)
+    messages.append({"role": "user", "content": login_greeting_prompt})
+    
+    # 调用 LLM
+    response = self.openai_client.chat.completions.create(
+        model=settings.llm_model,
+        messages=messages,
+        temperature=0.7,
+        max_tokens=200,
+    )
+    greeting_text = response.choices[0].message.content.strip()
+    return greeting_text
+```
+
+#### 3.2.3 登录问候完整消息示例
+
+```json
+[
+  {
+    "role": "system",
+    "content": "You are Lumi — a warm, gentle underground spirit.\n...\n[完整 System Prompt,包含基础提示词 + 回归用户模式]"
+  },
+  {
+    "role": "system", 
+    "content": "[RUNTIME CONTEXT]\nUser name: Neo\nLumi name: Lumi\nUser level: beginner\nCurrent zone: Glimmer Forest\nLast mission: Warm-up near the Tree\n\nThe user is a beginner English learner who enjoys talking about travel and food."
+  },
+  {
+    "role": "user",
+    "content": "[LOGIN GREETING REQUEST]\nGenerate ONLY the first greeting line for a returning user.\n\nRuntime names:\n- User: Neo\n- Lumi: Lumi\n\nWorld cue (must include at least ONE of these exact phrases):\n- \"Glimmer Forest\"\n- \"the Tree\"\n- \"the glow\"\n\nTone:\n- Warm, familiar, like seeing a friend again\n- Calm and grounded, not excited or dramatic\n\nConstraints:\n- 1–2 short sentences, voice-friendly.\n- Must include BOTH names exactly as given above.\n- Must include at least ONE world cue phrase above.\n- End with ONE simple question.\n- No emojis, emoticons, or decorative symbols.\n\nOutput ONLY the greeting line."
+  }
+]
+```
+
+#### 3.2.4 登录问候输出示例
+
+```
+Hi Neo, welcome back! The glow feels warmer today. How are you feeling?
+```
+
+---
+
+## 4. 记忆提取和摘要 Prompt
+
+### 4.1 记忆提取 Prompt (`KEY_MEMORY_EXTRACTION_PROMPT`)
+
+**文件**: `memory_summaries/memory_service.py:17-65`
+
+```python
+KEY_MEMORY_EXTRACTION_PROMPT = r"""
+You are a memory extraction engine for Lumi.
+Your job: read the full session conversation and decide what (if anything) should be saved as long-term memory for personalization in future chats.
+
+Save only info that is:
+1) Stable for weeks/months/years, AND
+2) Useful for future personalization (tone, interests, preferences, identity, goals, recurring constraints), AND
+3) Not overly private or risky.
+
+Good examples:
+- Name / preferred nickname (if explicitly stated)
+- Preferences (tone, topic likes/dislikes, boundaries like "don't ask X")
+- Long-term goals (e.g., "practice speaking confidence")
+- Persistent interests/hobbies (if clearly stable)
+
+Do NOT save:
+- One-off details (today's breakfast, today's mood)
+- Sensitive personal data (exact address, ID numbers, medical diagnoses, political opinions, etc.)
+- Private secrets the user didn't ask to store
+
+If user explicitly says "remember this / save this", you MAY store it unless it is sensitive.
+If sensitive, store a safer abstract version.
+
+OUTPUT FORMAT (JSON ONLY):
+{
+  "should_write_memory": true/false,
+  "memories_to_add": [
+    {"key":"...", "value":"...", "confidence":0.0-1.0, "evidence":"..."}
+  ],
+  "memories_to_update": [
+    {"key":"...", "old_value":"...", "new_value":"...", "confidence":0.0-1.0, "evidence":"..."}
+  ],
+  "memories_to_forget": [
+    {"key":"...", "reason":"..."}
+  ],
+  "do_not_store": [
+    {"candidate":"...", "reason":"one-off | sensitive | not useful"}
+  ],
+  "notes_for_personalization": [
+    "..."
+  ]
+}
+
+Rules:
+- Return JSON only. No extra text.
+- If nothing useful, should_write_memory=false and arrays empty.
+- Prefer fewer, higher-quality memories.
+- Keys should be namespaced, e.g. identity.name, preference.tone, preference.avoid, goal.long_term.
+""".strip()
+```
+
+### 4.2 增量记忆摘要 Prompt (`INCREMENTAL_MEMORY_SUMMARY_PROMPT`)
+
+**文件**: `memory_summaries/memory_service.py:68-97`
+
+```python
+INCREMENTAL_MEMORY_SUMMARY_PROMPT = r"""
+You are Lumi's memory-summary updater.
+
+You will receive:
+1) The previous memory summary (may be empty)
+2) The latest long-term memory JSON (key-value pairs)
+
+Task:
+Update the previous summary into a single, smooth, assistant-facing paragraph
+that captures the user's stable preferences, interaction style, and recurring tendencies.
+
+Editing principles (important):
+- Prefer minimal edits to the existing summary when still valid.
+- Integrate new information naturally into the paragraph only if it provides clear long-term personalization value.
+- Remove or soften details that are no longer supported by memory.
+- Generalize or omit anything that could become sensitive.
+- Avoid rigid traits or absolute statements that would overly constrain future conversations.
+
+Output constraints:
+- ONE short paragraph only (no bullet points).
+- Around 60–100 words.
+- Neutral, warm, assistant-facing tone.
+- Describe tendencies and preferences, not fixed identities.
+- Avoid explicit instructions, rules, or triggers.
+- Do NOT mention internal memory keys or system concepts.
+- Plain text only. No headings. No JSON.
+
+The goal is to provide gentle background context that supports natural topic flow,
+not to force alignment or explicit recall.
+""".strip()
+```
+
+---
+
+## 5. 对话总结 Prompt
+
+**文件**: `backend/services/summary_service.py:203-331`
+
+### 5.1 首次总结 Prompt
+
+```python
+prompt = f"""请从以下英语口语练习对话中提炼两类关键信息:
+
+**第一类:用户个人信息**
+请提取并整理用户在对话中透露的个人信息,包括但不限于:
+- 姓名、昵称
+- 年龄
+- 家庭住址、所在城市
+- 职业、工作
+- 兴趣爱好
+- 家庭成员信息
+- 其他个人背景信息
+
+如果某些信息未在对话中提及,请标注"未提及"。
+
+**第二类:用户英语水平评估**
+请评估用户当前的英语水平,包括:
+- 词汇量水平(基础/中级/高级)
+- 语法掌握程度(能使用哪些时态、句型)
+- 口语流利度(是否流畅、是否有较多停顿)
+- 发音准确性(是否有明显发音问题)
+- 表达能力(能否清晰表达想法)
+- 当前薄弱环节(需要重点提升的方面)
+
+对话内容:
+{conversation}
+
+请按照以下格式输出总结(每部分100-150字):
+
+**用户个人信息:**
+[提取的用户个人信息,未提及的标注"未提及"]
+
+**英语水平评估:**
+[对用户当前英语水平的详细评估]"""
+```
+
+### 5.2 累积总结 Prompt
+
+```python
+prompt = f"""请基于以下历史总结和新的对话内容,生成一个更新的累积总结。
+
+**历史总结:**
+{previous_summaries_text}
+
+**新的对话内容(第{messages[0].get('round_number', 1)}-{messages[-1].get('round_number', 15)}轮):**
+{conversation}
+
+请整合历史总结和新对话内容,更新以下两类关键信息:
+
+**第一类:用户个人信息**
+请提取并整理用户在对话中透露的个人信息,包括但不限于:
+- 姓名、昵称
+- 年龄
+- 家庭住址、所在城市
+- 职业、工作
+- 兴趣爱好
+- 家庭成员信息
+- 其他个人背景信息
+
+如果某些信息未在对话中提及,请标注"未提及"。
+
+**第二类:用户英语水平评估**
+请评估用户当前的英语水平,包括:
+- 词汇量水平(基础/中级/高级)
+- 语法掌握程度(能使用哪些时态、句型)
+- 口语流利度(是否流畅、是否有较多停顿)
+- 发音准确性(是否有明显发音问题)
+- 表达能力(能否清晰表达想法)
+- 当前薄弱环节(需要重点提升的方面)
+
+请按照以下格式输出总结(每部分100-150字):
+
+**用户个人信息:**
+[整合后的用户个人信息,未提及的标注"未提及"]
+
+**英语水平评估:**
+[对用户当前英语水平的详细评估]"""
+```
+
+---
+
+## 6. 完整消息组装流程
+
+### 6.1 普通对话回复 (`generate_response_stream`)
+
+**文件**: `backend/ai_service.py:232-374`
+
+```python
+async def generate_response_stream(
+    self, 
+    user_text: str,
+    user_info: Optional[dict] = None,
+    is_new_user: bool = False,
+    chat_history: Optional[list] = None
+) -> AsyncGenerator[tuple[str, EmotionType], None]:
+    """
+    生成对话回复(流式)
+    """
+    try:
+        # 1. 构建 System Prompt
+        system_prompt = self._build_system_prompt(user_info, is_new_user)
+        
+        # 2. 构建运行时上下文
+        runtime_context = self._build_runtime_context(user_info)
+        
+        # 3. 构建消息列表
+        messages = [
+            {"role": "system", "content": system_prompt},
+            {"role": "system", "content": runtime_context},  # 注入运行时上下文
+        ]
+        
+        # 4. 添加对话历史(如果有)
+        if chat_history:
+            messages.extend(chat_history)
+        
+        # 5. 格式提醒:确保每次对话都生成情感标签
+        messages.append({
+            "role": "system",
+            "content": "Remember: Start your response with [EMOTION:xxx] format. This is REQUIRED."
+        })
+        
+        # 6. 添加当前用户输入
+        messages.append({"role": "user", "content": user_text})
+        
+        # 7. 调用 LLM 流式 API
+        response = self.openai_client.chat.completions.create(
+            model=settings.llm_model,
+            messages=messages,
+            temperature=0.7,
+            max_tokens=500,
+            stream=True,
+        )
+        
+        # 8. 处理流式响应...
+```
+
+### 6.2 完整消息结构示例
+
+```json
+[
+  {
+    "role": "system",
+    "content": "You are Lumi — a warm, gentle underground spirit.\nYou and the user are simply getting to know each other.\n\n[... 完整基础提示词 ...]\n\nUSER CONTEXT: NEW USER\n\nThis is your first time meeting the user.\n\nInteraction style:\n- Assume no shared history.\n- Let the user decide how much to say.\n\nConversation approach:\n- Show mild curiosity and warmth.\n- Invite everyday, easy topics.\n\nAvoid:\n- introduce world background unless the user asks\n- references to shared memories\n- hints about future adventures or plans\n\nThe feeling should be:\n\"Two strangers sitting down quietly and starting to talk.\""
+  },
+  {
+    "role": "system",
+    "content": "[RUNTIME CONTEXT]\nUser name: Neo\nLumi name: Lumi\nUser level: beginner\nCurrent zone: Glimmer Forest\nLast mission: Warm-up near the Tree\n\nThe user is a beginner English learner who enjoys talking about travel and food."
+  },
+  {
+    "role": "system",
+    "content": "Remember: Start your response with [EMOTION:xxx] format. This is REQUIRED."
+  },
+  {
+    "role": "user",
+    "content": "I went to Tokyo last summer and it was amazing!"
+  }
+]
+```
+
+### 6.3 情感标签约束
+
+在 System Prompt 的基础提示词中已经包含了情感标签的要求:
+
+```
+**Emotion Tag - REQUIRED**: 
+Add [EMOTION:name] at the FIRST line, then ALWAYS follow with 1-2 sentences of spoken response.
+Emotions: neutral, happy, sad, excited, angry, surprised, thoughtful, encouraging, confused, worried, satisfied, friendly, sorry
+
+Example:
+[EMOTION:happy]
+Hello! I'm so glad to see you. How are you feeling today?
+
+Critical: 
+- NEVER return ONLY the emotion tag
+- ALWAYS include actual spoken text after the tag
+- The emotion tag MUST be the FIRST line, before any other content
+- NO parentheses () or brackets [] in spoken text
+- NO emojis or emoticons in your response
+```
+
+---
+
+## 7. 流程图总结
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│                     用户进入 Chatbot 界面                         │
+└─────────────────────────────────────────────────────────────────┘
+                               │
+                               ▼
+┌─────────────────────────────────────────────────────────────────┐
+│  判断用户类型 (total_rounds == 0)                                │
+└─────────────────────────────────────────────────────────────────┘
+                               │
+              ┌────────────────┴────────────────┐
+              ▼                                 ▼
+       ┌─────────────┐                  ┌─────────────┐
+       │   新用户     │                  │   回归用户    │
+       └─────────────┘                  └─────────────┘
+              │                                 │
+              ▼                                 ▼
+       ┌─────────────┐                  ┌─────────────┐
+       │ 固定首句模板 │                  │ 动态生成问候  │
+       │ 无情感标签   │                  │ 完整消息组装  │
+       └─────────────┘                  └─────────────┘
+                                                  │
+                                                  ▼
+                                    ┌─────────────────────────┐
+                                    │    messages 组装         │
+                                    │ 1. System Prompt        │
+                                    │ 2. Runtime Context      │
+                                    │ 3. Login Greeting Prompt │
+                                    └─────────────────────────┘
+                                                  │
+                                                  ▼
+                                        ┌─────────────────┐
+                                        │  LLM 调用生成    │
+                                        └─────────────────┘
+                               │
+                               ▼
+┌─────────────────────────────────────────────────────────────────┐
+│                      用户开始对话                                  │
+└─────────────────────────────────────────────────────────────────┘
+                               │
+                               ▼
+┌─────────────────────────────────────────────────────────────────┐
+│               generate_response_stream()                         │
+│                                                               │
+│  messages = [                                                 │
+│    System Prompt (基础 + 用户模式)                               │
+│    Runtime Context (含记忆摘要)                                  │
+│    chat_history (近20轮)                                        │
+│    情感标签提醒                                                  │
+│    用户输入                                                     │
+│  ]                                                            │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+---
+
+## 8. 关键文件索引
+
+| 功能 | 文件路径 | 关键方法/常量 |
+|------|---------|--------------|
+| 基础 System Prompt | `backend/ai_service.py:1121` | `_get_system_prompt_base()` |
+| 新用户 System Prompt | `backend/ai_service.py:1215` | `_get_system_prompt_new_user()` |
+| 回归用户 System Prompt | `backend/ai_service.py:1239` | `_get_system_prompt_returning_user()` |
+| System Prompt 组装 | `backend/ai_service.py:1311` | `_build_system_prompt()` |
+| Runtime Context | `backend/ai_service.py:1265` | `_build_runtime_context()` |
+| 新用户首句 | `backend/ai_service.py:118` | `first_meeting_line()` |
+| 登录问候 Prompt | `backend/ai_service.py:137` | `_login_greeting_prompt()` |
+| 登录问候生成 | `backend/ai_service.py:175` | `generate_login_greeting()` |
+| 对话回复生成 | `backend/ai_service.py:232` | `generate_response_stream()` |
+| 记忆提取 Prompt | `memory_summaries/memory_service.py:17` | `KEY_MEMORY_EXTRACTION_PROMPT` |
+| 记忆摘要 Prompt | `memory_summaries/memory_service.py:68` | `INCREMENTAL_MEMORY_SUMMARY_PROMPT` |
+| 对话总结 Prompt | `backend/services/summary_service.py:203` | `_generate_summary_with_llm()` |


+ 109 - 3
dashboard/streamlit_growth_dashboard.py

@@ -217,7 +217,8 @@ if segment_sel:
 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 history for rolling-window sets (7/30d) and trend chart(月内).
+# We only need recent history for rolling-window sets (7/30d) and trend chart(最近30天).
+# Keep a small buffer beyond 30 days to avoid off-by-one and sparse-data gaps.
 history_days = 35
 start_recent = target_day - timedelta(days=history_days)
 
@@ -260,7 +261,8 @@ else:
 
 # ---- Trend (DAU, by day) ----
 st.subheader("DAU trend")
-trend_start = target_day.replace(day=1)
+# 最近 30 天(含 target_day);若早于库内最小日期则截断
+trend_start = max(min_day, target_day - timedelta(days=29))
 dau_raw = (
     daily[
         (daily["activity_date"] >= trend_start)
@@ -292,7 +294,7 @@ st.altair_chart(chart, use_container_width=True)
 
 # ---- Trend (CURR, by day from curr_daily) ----
 st.subheader("CURR trend")
-curr_start = target_day.replace(day=1)
+curr_start = max(min_day, target_day - timedelta(days=29))
 curr_df = read_df(
     """
     SELECT activity_date, prev_day_active_count, retained_count, curr_rate_pct, total_active_today
@@ -332,6 +334,110 @@ curr_chart = (
 )
 st.altair_chart(curr_chart, use_container_width=True)
 
+# ---- Trend (Shop/Outfits PV + Conversation duration, 30d; dual-axis, 3 lines) ----
+# 数据源均来自 user_events(已登录用户):
+# - page.lumi_shop__enter: 家具商店页面浏览(次)
+# - page.lumi_outfits__enter: Lumi 装扮页面浏览(次)
+# - conversation.chatbot__end + properties.duration_seconds: 对话时长(秒)
+trend3_start = curr_start
+trend3_df = read_df(
+    """
+    SELECT
+        DATE(event_time) AS activity_date,
+        SUM(CASE WHEN event_name = 'page.lumi_shop__enter' THEN 1 ELSE 0 END) AS lumi_shop_pv,
+        SUM(CASE WHEN event_name = 'page.lumi_outfits__enter' THEN 1 ELSE 0 END) AS lumi_outfits_pv,
+        SUM(
+            CASE
+                WHEN event_name <> 'conversation.chatbot__end' THEN 0
+                WHEN JSON_EXTRACT(properties, '$.duration_seconds') IS NULL THEN 0
+                ELSE CAST(JSON_EXTRACT(properties, '$.duration_seconds') AS DECIMAL(18, 3))
+            END
+        ) AS total_conversation_duration_seconds
+    FROM user_events
+    WHERE event_time >= %s
+      AND event_time < DATE_ADD(%s, INTERVAL 1 DAY)
+      AND event_name IN (
+        'page.lumi_shop__enter',
+        'page.lumi_outfits__enter',
+        'conversation.chatbot__end'
+      )
+    GROUP BY DATE(event_time)
+    ORDER BY activity_date
+    """,
+    (trend3_start.isoformat(), target_day.isoformat()),
+)
+
+if not trend3_df.empty:
+    trend3_df["activity_date"] = pd.to_datetime(trend3_df["activity_date"]).dt.date
+
+# 补齐日期轴到 target_day:无数据的日期显示 0
+trend3_days = pd.DataFrame(
+    {"activity_date": pd.date_range(start=trend3_start, end=target_day, freq="D").date}
+)
+trend3_plot = trend3_days.merge(trend3_df, on="activity_date", how="left")
+for col in ["lumi_shop_pv", "lumi_outfits_pv", "total_conversation_duration_seconds"]:
+    if col in trend3_plot.columns:
+        trend3_plot[col] = trend3_plot[col].fillna(0)
+trend3_plot["day"] = trend3_plot["activity_date"].astype(str)
+
+st.caption("30d 走势:左轴=每日用户对话总时长(秒),右轴=页面浏览量(次)。三条线:lumi-shop、lumi-outfits、对话时长。")
+
+duration_line = (
+    alt.Chart(trend3_plot)
+    .mark_line(point=True, color="#3b82f6")
+    .encode(
+        x=alt.X("day:N", axis=alt.Axis(title="Date", labelAngle=0)),
+        y=alt.Y(
+            "total_conversation_duration_seconds:Q",
+            axis=alt.Axis(title="Conversation duration (s)"),
+            scale=alt.Scale(domainMin=0),
+        ),
+        tooltip=[
+            "day",
+            alt.Tooltip("total_conversation_duration_seconds:Q", title="Conversation duration (s)", format=",.0f"),
+            alt.Tooltip("lumi_shop_pv:Q", title="Lumi shop PV", format=",d"),
+            alt.Tooltip("lumi_outfits_pv:Q", title="Lumi outfits PV", format=",d"),
+        ],
+    )
+)
+
+pv_lines = (
+    alt.Chart(trend3_plot)
+    .transform_fold(
+        ["lumi_shop_pv", "lumi_outfits_pv"],
+        as_=["pv_type", "pv_count"],
+    )
+    .mark_line(point=True)
+    .encode(
+        x=alt.X("day:N"),
+        y=alt.Y(
+            "pv_count:Q",
+            axis=alt.Axis(title="Page views (count)", orient="right"),
+            scale=alt.Scale(domainMin=0),
+        ),
+        color=alt.Color(
+            "pv_type:N",
+            scale=alt.Scale(
+                domain=["lumi_shop_pv", "lumi_outfits_pv"],
+                range=["#f59e0b", "#10b981"],
+            ),
+            legend=alt.Legend(
+                title="PV lines",
+                labelExpr="datum.value === 'lumi_shop_pv' ? 'Lumi shop PV' : 'Lumi outfits PV'",
+            ),
+        ),
+        tooltip=[
+            "day",
+            alt.Tooltip("pv_type:N", title="PV type"),
+            alt.Tooltip("pv_count:Q", title="PV", format=",d"),
+            alt.Tooltip("total_conversation_duration_seconds:Q", title="Conversation duration (s)", format=",.0f"),
+        ],
+    )
+)
+
+trend3_chart = alt.layer(duration_line, pv_lines).resolve_scale(y="independent")
+st.altair_chart(trend3_chart, use_container_width=True)
+
 # ---- Segment computation on target_day ----
 d0 = target_day
 d7 = d0 - timedelta(days=7)

+ 28 - 3
frontend/src/components/ChatBotStaticShell.tsx

@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { useNavigate } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
 import { getUserId, getStorage, STORAGE_KEYS } from '@/utils/storage';
@@ -81,6 +81,15 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
   const introAudioPlayerRef = useRef<AudioPlayer | null>(null);
   const introCancelledRef = useRef<boolean>(false);
 
+  const frostedGlassStyle = useMemo(
+    () => ({
+      backdropFilter: 'blur(12px)',
+      WebkitBackdropFilter: 'blur(12px)',
+      boxShadow: '0 8px 24px rgba(0, 0, 0, 0.18)',
+    }),
+    [],
+  );
+
   const INTRO_STEPS = [1, 3, 5, 9, 10, 11];
   const hasIntroAudio = INTRO_STEPS.includes(step);
 
@@ -511,6 +520,7 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
               width: px2vmin(312, 'v2'),
               height: px2vmin(92, 'v2'),
               backgroundColor: 'rgba(0, 0, 0, 0.4)',
+              ...frostedGlassStyle,
               borderRadius: px2vmin(16, 'v2'),
               display: 'flex',
               alignItems: 'center',
@@ -577,6 +587,9 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
             width: px2vmin(312, 'v2'),
             height: px2vmin(92, 'v2'),
             backgroundColor: 'rgba(0, 0, 0, 0.4)',
+            // 毛玻璃效果:模糊容器后方内容
+            backdropFilter: 'blur(10px)',
+            WebkitBackdropFilter: 'blur(10px)',
             borderRadius: px2vmin(16, 'v2'),
             display: 'flex',
             alignItems: 'center',
@@ -611,6 +624,9 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
             width: px2vmin(312, 'v2'),
             height: px2vmin(92, 'v2'),
             backgroundColor: 'rgba(0, 0, 0, 0.4)',
+            // 毛玻璃效果:模糊容器后方内容
+            backdropFilter: 'blur(10px)',
+            WebkitBackdropFilter: 'blur(10px)',
             borderRadius: px2vmin(16, 'v2'),
             display: 'flex',
             alignItems: 'center',
@@ -645,6 +661,9 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
             width: px2vmin(312, 'v2'),
             height: px2vmin(92, 'v2'),
             backgroundColor: 'rgba(0, 0, 0, 0.4)',
+            // 毛玻璃效果:模糊容器后方内容
+            backdropFilter: 'blur(10px)',
+            WebkitBackdropFilter: 'blur(10px)',
             borderRadius: px2vmin(16, 'v2'),
             display: 'flex',
             alignItems: 'center',
@@ -679,6 +698,9 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
             width: px2vmin(312),
             height: px2vmin(92),
             backgroundColor: 'rgba(0, 0, 0, 0.4)',
+            // 毛玻璃效果:模糊容器后方内容
+            backdropFilter: 'blur(10px)',
+            WebkitBackdropFilter: 'blur(10px)',
             borderRadius: px2vmin(16),
             display: 'flex',
             alignItems: 'center',
@@ -711,11 +733,13 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
           width: px2vmin(70, 'v2'),
           height: px2vmin(54, 'v2'),
           backgroundColor: 'rgba(0, 0, 0, 0.4)',
+          // 毛玻璃效果:模糊容器后方内容
+          backdropFilter: 'blur(10px)',
+          WebkitBackdropFilter: 'blur(10px)',
           borderRadius: px2vmin(8, 'v2'),
           boxSizing: 'border-box',
           // 教学步骤 3/4/5/6/7/8 亲密度在蒙层下方;步骤 9/10/11 亲密度在蒙层上方(与原 tutorial-8/9/10 一致,用 10 确保压住蒙层 zIndex 2)
           zIndex: step === 9 || step === 10 || step === 11 ? 10 : (step === 3 || step === 4 || step === 5 || step === 6 || step === 7 || step === 8 ? 1 : 6),
-          ...(step === 11 ? { border: '1px solid #EA578A' } : {}),
         }}
       >
         {/* 亲密度心形展示 */}
@@ -1311,7 +1335,8 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
                   marginBottom: px2vmin(2),
                   width: px2vmin(126),
                   height: px2vmin(166),
-                  backgroundColor: 'rgba(0, 0, 0, 0.4)',
+                  backgroundColor: 'rgba(0, 0, 0, 0.38)',
+                  ...frostedGlassStyle,
                   padding: px2vmin(8),
                   display: 'grid',
                   gridTemplateColumns: '1fr 1fr',

+ 6 - 1
frontend/src/components/PageVisitReporter.tsx

@@ -1,5 +1,6 @@
 import { useEffect } from 'react';
 import { useLocation } from 'react-router-dom';
+import { reportPageVisit } from '@/utils/api';
 import { trackPageEnter } from '@/utils/tracking';
 
 /**
@@ -10,7 +11,11 @@ export const PageVisitReporter = () => {
   const location = useLocation();
 
   useEffect(() => {
-    trackPageEnter(location.pathname || '/');
+    const path = location.pathname || '/';
+    // 1) 全量 PV(未登录也记录):落库到 page_visit_logs
+    reportPageVisit(path);
+    // 2) 登录态埋点(需要 user_id + token 才会成功写入 user_events)
+    trackPageEnter(path);
   }, [location.pathname]);
 
   return null;

+ 59 - 10
frontend/src/pages/ChatBotPage.tsx

@@ -50,7 +50,7 @@ import { px2vmin } from '@/utils/viewport';
 import type { Message, EnergyStoneSpawnMessage, GreetingContextMessage } from '@/types';
 import '@/styles/chatbot-fix.css';
 import { getOssUrl } from '@/utils/oss';
-import { wrapTrackedClick, trackButtonClick } from '@/utils/tracking';
+import { wrapTrackedClick, trackButtonClick, trackConversationEnd } from '@/utils/tracking';
 import { STOOL_LAYOUT } from '@/constants/chatbot';
 
 const tapToChat = getOssUrl('TapToChat.svg');
@@ -1602,7 +1602,18 @@ export const ChatBotPage = () => {
     const visited = getVisitedRoutes();
     const userId = getUserId();
     // 用于存储用户信息,供后续首次商店引导逻辑使用
-    let userInfo: Record<string, unknown> | null = null;
+    type LoadedUserInfo = {
+      username?: string;
+      bot_name?: string;
+      user_level?: number;
+      conversation_energy_stone_total?: number;
+      user_type?: 'guest' | 'registered' | string;
+      current_background_image_id?: string;
+      current_background_image_metadata?: Record<string, unknown> | null;
+      total_speech_duration_ms?: number;
+      registered_at?: string | null;
+    };
+    let userInfo: LoadedUserInfo | null = null;
     if (userId) {
       // 从数据库加载所有用户信息到 localStorage
       // 1. 先加载用户基本信息(getUserInfo)
@@ -1614,15 +1625,15 @@ export const ChatBotPage = () => {
       ]).then(([userInfoResult, uiStateResult, layoutResult]) => {
         // 加载用户基本信息到 localStorage
         if (userInfoResult?.success && userInfoResult.data) {
-          userInfo = userInfoResult.data;
+          userInfo = userInfoResult.data as LoadedUserInfo;
           
           // Lyrria_user_name
-          if (userInfo.username) {
+          if (typeof userInfo.username === 'string' && userInfo.username) {
             setUserName(userInfo.username);
           }
           
           // Lyrria_lumi_name
-          if (userInfo.bot_name) {
+          if (typeof userInfo.bot_name === 'string' && userInfo.bot_name) {
             setStorage(STORAGE_KEYS.LUMI_NAME, userInfo.bot_name);
           }
           
@@ -1659,7 +1670,7 @@ export const ChatBotPage = () => {
           }
           
           // 初始化进度条(基于 total_speech_duration_ms)
-          const totalDurationMs = userInfo.total_speech_duration_ms ?? 0;
+          const totalDurationMs = typeof userInfo.total_speech_duration_ms === 'number' ? userInfo.total_speech_duration_ms : 0;
           if (totalDurationMs > 0) {
             const PROGRESS_FULL_DURATION_MS = 30 * 1000; // 30000ms
             totalSpeechDurationRef.current = Math.max(
@@ -1846,13 +1857,18 @@ export const ChatBotPage = () => {
           // 老用户条件:
           //   1. user_type === 'registered'(已注册的)
           //   2. 注册时间非当天(避免新注册用户看到引导)
-          const registeredAt = userInfo?.registered_at ? new Date(userInfo.registered_at) : null;
+          const registeredAt =
+            userInfo?.registered_at && typeof userInfo.registered_at === 'string'
+              ? new Date(userInfo.registered_at)
+              : null;
           const today = new Date();
           today.setHours(0, 0, 0, 0);
-          const isNotRegisteredToday = registeredAt && registeredAt < today;
+          const isNotRegisteredToday = !!(registeredAt && registeredAt < today);
           const isOldUser = userInfo?.user_type === 'registered' && isNotRegisteredToday;
           // 同时检查数据库状态和 localStorage 状态
           const localHasShown = getStorage(STORAGE_KEYS.HAS_SHOWN_FIRST_SHOP_TOOLTIP) === 'true';
+          const localHasClickedShopInSettings =
+            getStorage(STORAGE_KEYS.HAS_CLICKED_SHOP_IN_SETTINGS) === 'true';
           const shouldShowTooltip = isOldUser && !state.has_shown_first_shop_tooltip && !localHasShown;
           console.log('[ShopTooltip] isOldUser:', isOldUser, 'registeredAt:', registeredAt, 'isNotRegisteredToday:', isNotRegisteredToday, 'has_shown_first_shop_tooltip:', state.has_shown_first_shop_tooltip, 'localHasShown:', localHasShown, 'shouldShowTooltip:', shouldShowTooltip);
           if (isOldUser) {
@@ -1861,8 +1877,21 @@ export const ChatBotPage = () => {
               // 未展示过 tips,则展示
               setShowFirstShopTooltip(true);
             }
-            // 红色点点的显示条件: 已展示过 tips 且未点击过设置中的商店
-            const shouldShow = (state.has_shown_first_shop_tooltip || localHasShown) && !state.has_clicked_shop_in_settings;
+            // 补偿同步:如果本地记录为 true 但后端为 false,尝试补偿同步
+            if (localHasClickedShopInSettings && !state.has_clicked_shop_in_settings) {
+              updateUserUIState(userId, {
+                has_clicked_shop_in_settings: true,
+              }).catch(() => {
+                // 静默处理
+              });
+            }
+            // 红色点点的显示条件:
+            // - tips 已展示过(db/local)或本次正在展示 tips
+            // - 且未点击过设置中的商店
+            const hasClickedShop = state.has_clicked_shop_in_settings || localHasClickedShopInSettings;
+            const shouldShow =
+              (state.has_shown_first_shop_tooltip || localHasShown || shouldShowTooltip) &&
+              !hasClickedShop;
             console.log('[ShopTooltip] shouldShowRedDot:', shouldShow);
             setShowShopRedDot(shouldShow);
           } else {
@@ -2929,6 +2958,14 @@ export const ChatBotPage = () => {
 
     // 计算说话时长(毫秒)
     const speechDuration = timingRef.current.recordEnd - timingRef.current.recordStart;
+
+    // 埋点:对话时长(从开始录音到录音结束)
+    // 统计口径:用户点击“发送”结束录音时记录一次
+    trackConversationEnd({
+      pageId: 'chatbot',
+      durationSeconds: speechDuration > 0 ? speechDuration / 1000 : 0,
+      requestId: currentRequestId,
+    });
     
     // 进度条与累计说话时长以后端为准:
     // 这里只负责将本次说话时长发送给后端,不在前端进行乐观更新,
@@ -3311,6 +3348,9 @@ export const ChatBotPage = () => {
           width: px2vmin(70, 'v2'),
           height: px2vmin(54, 'v2'),
           backgroundColor: 'rgba(0, 0, 0, 0.4)',
+          // 毛玻璃效果:模糊容器后方内容
+          backdropFilter: 'blur(10px)',
+          WebkitBackdropFilter: 'blur(10px)',
           borderRadius: px2vmin(8, 'v2'),
           boxSizing: 'border-box',
           zIndex: 6 + CHATBOT_Z_INDEX.UI_OFFSET,
@@ -3738,6 +3778,9 @@ export const ChatBotPage = () => {
             height: px2vmin(72, 'v2'),
             borderRadius: px2vmin(16),
             backgroundColor: 'rgba(0, 0, 0, 0.4)',
+            // 毛玻璃效果:模糊容器后方内容
+            backdropFilter: 'blur(10px)',
+            WebkitBackdropFilter: 'blur(10px)',
             color: '#F5F3F0',
             fontSize: px2vmin(14),
             lineHeight: px2vmin(20),
@@ -3746,6 +3789,9 @@ export const ChatBotPage = () => {
             // 方案 A:完整文本 + 可滚动,但通过样式隐藏滚动条
             overflowY: 'auto',
             WebkitOverflowScrolling: 'touch',
+            // 设置弹窗打开时临时不显示字幕
+            opacity: showFuncMenuPopup ? 0 : 1,
+            pointerEvents: showFuncMenuPopup ? 'none' : undefined,
             zIndex: 20 + CHATBOT_Z_INDEX.UI_OFFSET,
           }}
         >
@@ -3929,6 +3975,9 @@ export const ChatBotPage = () => {
                       width: px2vmin(126),
                       height: px2vmin(166),
                       backgroundColor: 'rgba(0, 0, 0, 0.4)',
+                    // 毛玻璃效果:模糊容器后方内容
+                    backdropFilter: 'blur(10px)',
+                    WebkitBackdropFilter: 'blur(10px)',
                       padding: px2vmin(8),
                       display: 'grid',
                       gridTemplateColumns: '1fr 1fr',

+ 238 - 138
frontend/src/pages/LoginRegisterPage.tsx

@@ -2,7 +2,7 @@
  * V2.0 登录/注册首页,设计稿 393×852。
  * Hello + Lumi 动图 + 两个按钮(邮箱/手机)+ 协议勾选,继续采用 px2vmin 响应式。
  */
-import { useState, useEffect } from 'react';
+import { useEffect, useState } from 'react';
 import { useNavigate } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
 import { message } from 'antd';
@@ -18,7 +18,8 @@ import {
   setStorage,
   STORAGE_KEYS,
   getUserId,
-  getVisitedRoutes,
+  getLastLoginMethod,
+  setLastLoginMethod,
   getLastOnboardingRoute,
   syncUserDataFromDatabase,
   clearUserToken,
@@ -34,12 +35,11 @@ const BUTTON_HEIGHT = 52;
 const BUTTON_GAP = 20;
 const SECOND_BUTTON_TOP = 514 + BUTTON_HEIGHT + BUTTON_GAP;
 
-/** 上次使用按钮:宽 72,高 24,圆角 20,与邮箱登录按钮右对齐 */
-const LAST_USED_BTN_WIDTH = 72;
-const LAST_USED_BTN_HEIGHT = 24;
-const LAST_USED_BTN_RADIUS = 20;
-const LAST_USED_BTN_FONT_SIZE = 12;
-const LAST_USED_BTN_TOP = 514 - 24 - 12; // 邮箱按钮上方间距 12px
+/** Last used 标签:宽 72,高 24,圆角 20 */
+const LAST_USED_TAG_WIDTH = 72;
+const LAST_USED_TAG_HEIGHT = 24;
+const LAST_USED_TAG_RADIUS = 20;
+const LAST_USED_TAG_FONT_SIZE = 12;
 
 export const LoginRegisterPage = () => {
   const navigate = useNavigate();
@@ -47,23 +47,139 @@ export const LoginRegisterPage = () => {
   const [agreeTerms, setAgreeTerms] = useState(true);
   const [showAgreementModal, setShowAgreementModal] = useState(false);
   const [showPrivacyPolicyModal, setShowPrivacyPolicyModal] = useState(false);
+  const [derivedLastMethod, setDerivedLastMethod] = useState<ReturnType<typeof getLastLoginMethod>>(null);
 
-  const handleEmail = () => {
+  const restoreLoginByUserId = async (userId: string) => {
+    try {
+      const userInfo = await getUserInfo(userId).catch(async (error: unknown) => {
+        // token 失效(401)时,尝试刷新 token
+        if (error instanceof ApiError && error.status === 401) {
+          try {
+            const sessionResp = await initSession(userId);
+            if (sessionResp.success && sessionResp.data?.token) {
+              setStorage(STORAGE_KEYS.USER_TOKEN, sessionResp.data.token);
+              return await getUserInfo(userId);
+            }
+          } catch {
+            // initSession 失败,忽略
+          }
+        }
+        throw error;
+      });
+
+      if (!userInfo.success || !userInfo.data) {
+        message.warning(i18n.language.startsWith('zh') ? '登录信息已失效' : 'Login info expired');
+        clearUserToken();
+        return;
+      }
+
+      const { data: userData } = userInfo;
+      setStorage(STORAGE_KEYS.USER_ID, userData.user_id);
+      if (userData.username) setStorage(STORAGE_KEYS.USER_NAME, userData.username);
+      if (userData.bot_name) setStorage(STORAGE_KEYS.LUMI_NAME, userData.bot_name);
+      if (typeof userData.user_level === 'number') {
+        setStorage(STORAGE_KEYS.USER_LEVEL, String(userData.user_level));
+      }
+      if (userData.user_type === 'registered') {
+        setStorage(STORAGE_KEYS.IS_LOGGED_IN, 'true');
+      }
+
+      const uiRes = await getUserUIState(userId);
+      if (uiRes?.success && uiRes.data) {
+        syncUserDataFromDatabase(userData, uiRes.data);
+        const target = getLastOnboardingRoute(uiRes.data.visited_routes, uiRes.data.current_page ?? undefined);
+        navigate(target, { replace: true });
+      } else {
+        navigate('/chatbot', { replace: true });
+      }
+    } catch (error) {
+      const code = error instanceof ApiError ? error.code : 'SERVER_ERROR';
+      message.warning(i18n.language.startsWith('zh') ? '登录失败,请重试' : 'Login failed, please try again');
+      console.warn(`[LoginRegisterPage] 自动恢复登录失败: ${code}`);
+      // 只清除 token,保留 user_id 以便下次仍能尝试登录
+      clearUserToken();
+    }
+  };
+
+  useEffect(() => {
+    const userId = getUserId();
+    const lastMethod = getLastLoginMethod();
+    if (!userId || lastMethod) {
+      setDerivedLastMethod(null);
+      return;
+    }
+
+    let cancelled = false;
+    const derive = async () => {
+      try {
+        const userInfo = await getUserInfo(userId).catch(async (error: unknown) => {
+          if (error instanceof ApiError && error.status === 401) {
+            try {
+              const sessionResp = await initSession(userId);
+              if (sessionResp.success && sessionResp.data?.token) {
+                setStorage(STORAGE_KEYS.USER_TOKEN, sessionResp.data.token);
+                return await getUserInfo(userId);
+              }
+            } catch {
+              // ignore
+            }
+          }
+          throw error;
+        });
+
+        if (cancelled) return;
+        if (!userInfo.success || !userInfo.data) return;
+
+        const email = (userInfo.data.email ?? '').trim();
+        const phone = (userInfo.data.phone ?? '').trim();
+        const inferred = email ? 'email' : phone ? 'phone' : null;
+        setDerivedLastMethod(inferred);
+        if (inferred) {
+          setLastLoginMethod(inferred);
+        }
+      } catch {
+        // derive 失败不影响页面
+      }
+    };
+
+    void derive();
+    return () => {
+      cancelled = true;
+    };
+  }, []);
+
+  const handleEmail = async () => {
     if (!agreeTerms) {
       message.warning(t('agreeTermsWarning'));
       return;
     }
+    const userId = getUserId();
+    const lastMethod = getLastLoginMethod();
+    if (userId && lastMethod === 'email') {
+      await restoreLoginByUserId(userId);
+      return;
+    }
     navigate('/email-login', { state: { loginType: 'email' as const } });
   };
 
-  const handlePhone = () => {
+  const handlePhone = async () => {
     if (!agreeTerms) {
       message.warning(t('agreeTermsWarning'));
       return;
     }
+    const userId = getUserId();
+    const lastMethod = getLastLoginMethod();
+    if (userId && lastMethod === 'phone') {
+      await restoreLoginByUserId(userId);
+      return;
+    }
     navigate('/phone-login', { state: { loginType: 'phone' as const } });
   };
 
+  const storedLastMethod = getLastLoginMethod();
+  const lastMethod = storedLastMethod ?? derivedLastMethod;
+  const showLastUsed = !!getUserId() && (lastMethod === 'email' || lastMethod === 'phone');
+
   return (
     <>
       <div
@@ -143,95 +259,8 @@ export const LoginRegisterPage = () => {
             } as React.CSSProperties & { WebkitUserDrag: string }}
           />
 
-          {/* 上次使用按钮:与邮箱登录按钮右对齐,居于其上方 */}
-          <button
-            type="button"
-            style={{
-              position: 'absolute',
-              top: px2vmin(LAST_USED_BTN_TOP, DESIGN),
-              right: `calc(50% - ${px2vmin(156, DESIGN)})`,
-              width: px2vmin(LAST_USED_BTN_WIDTH, DESIGN),
-              height: px2vmin(LAST_USED_BTN_HEIGHT, DESIGN),
-              padding: 0,
-              border: 'none',
-              borderRadius: px2vmin(LAST_USED_BTN_RADIUS, DESIGN),
-              background: 'rgba(0, 0, 0, 0.1)',
-              cursor: 'pointer',
-              transition: 'opacity 0.2s ease',
-              zIndex: 3,
-              display: 'flex',
-              alignItems: 'center',
-              justifyContent: 'center',
-              fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif',
-              fontWeight: 500,
-              fontSize: px2vmin(LAST_USED_BTN_FONT_SIZE, DESIGN),
-              lineHeight: px2vmin(LAST_USED_BTN_FONT_SIZE, DESIGN),
-              color: '#FFFFFF',
-              WebkitTapHighlightColor: 'transparent',
-            }}
-            onClick={wrapTrackedClick('login_register', 'last_used', async () => {
-              const userId = getUserId();
-              if (!userId) {
-                message.warning(i18n.language.startsWith('zh') ? '未找到上次登录信息' : 'No previous login found');
-                return;
-              }
-              try {
-                let userInfo = await getUserInfo(userId).catch(async (error: unknown) => {
-                  // token 失效(401)时,尝试刷新 token
-                  if (error instanceof ApiError && error.status === 401) {
-                    try {
-                      const sessionResp = await initSession(userId);
-                      if (sessionResp.success && sessionResp.data?.token) {
-                        setStorage(STORAGE_KEYS.USER_TOKEN, sessionResp.data.token);
-                        return await getUserInfo(userId);
-                      }
-                    } catch {
-                      // initSession 失败,忽略
-                    }
-                  }
-                  throw error;
-                });
-
-                if (!userInfo.success || !userInfo.data) {
-                  message.warning(i18n.language.startsWith('zh') ? '登录信息已失效' : 'Login info expired');
-                  clearUserToken();
-                  return;
-                }
-
-                const { data: userData } = userInfo;
-                setStorage(STORAGE_KEYS.USER_ID, userData.user_id);
-                if (userData.username) setStorage(STORAGE_KEYS.USER_NAME, userData.username);
-                if (userData.bot_name) setStorage(STORAGE_KEYS.LUMI_NAME, userData.bot_name);
-                if (typeof userData.user_level === 'number') {
-                  setStorage(STORAGE_KEYS.USER_LEVEL, String(userData.user_level));
-                }
-                if (userData.user_type === 'registered') {
-                  setStorage(STORAGE_KEYS.IS_LOGGED_IN, 'true');
-                }
-                const uiRes = await getUserUIState(userId);
-                if (uiRes?.success && uiRes.data) {
-                  syncUserDataFromDatabase(userData, uiRes.data);
-                  const target = getLastOnboardingRoute(uiRes.data.visited_routes, uiRes.data.current_page ?? undefined);
-                  navigate(target, { replace: true });
-                } else {
-                  navigate('/chatbot', { replace: true });
-                }
-              } catch (error) {
-                const code = error instanceof ApiError ? error.code : 'SERVER_ERROR';
-                message.warning(i18n.language.startsWith('zh') ? '登录失败,请重试' : 'Login failed, please try again');
-                console.warn(`[LoginRegisterPage] LastUsed 点击登录失败: ${code}`);
-                // 只清除 token,保留 user_id 以便下次仍能尝试登录
-                clearUserToken();
-              }
-            })}
-            onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); } }}
-          >
-            {t('lastUsed')}
-          </button>
-
           {/* 第一个按钮:邮箱注册/登录,左右居中,距顶 514,宽 312,Ag title-medium 16/24 */}
-          <button
-            type="button"
+          <div
             style={{
               position: 'absolute',
               top: px2vmin(514, DESIGN),
@@ -239,32 +268,67 @@ export const LoginRegisterPage = () => {
               transform: 'translateX(-50%)',
               width: px2vmin(312, DESIGN),
               height: px2vmin(BUTTON_HEIGHT, DESIGN),
-              padding: 0,
-              border: 'none',
-              borderRadius: px2vmin(12, DESIGN),
-              background: `url(${primaryBtn}) center/cover no-repeat`,
-              cursor: agreeTerms ? 'pointer' : 'not-allowed',
-              transition: 'opacity 0.2s ease',
               zIndex: 2,
-              display: 'flex',
-              alignItems: 'center',
-              justifyContent: 'center',
-              fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif',
-              fontWeight: 500,
-              fontSize: px2vmin(16, DESIGN),
-              lineHeight: px2vmin(24, DESIGN),
-              color: '#fff',
-              WebkitTapHighlightColor: 'transparent',
             }}
-            onClick={wrapTrackedClick('login_register', 'email_login', handleEmail)}
-            onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleEmail(); } }}
           >
-            {t('continueEmail')}
-          </button>
+            <button
+              type="button"
+              style={{
+                width: '100%',
+                height: '100%',
+                padding: 0,
+                border: 'none',
+                borderRadius: px2vmin(12, DESIGN),
+                background: `url(${primaryBtn}) center/cover no-repeat`,
+                cursor: agreeTerms ? 'pointer' : 'not-allowed',
+                transition: 'opacity 0.2s ease',
+                display: 'flex',
+                alignItems: 'center',
+                justifyContent: 'center',
+                fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif',
+                fontWeight: 500,
+                fontSize: px2vmin(16, DESIGN),
+                lineHeight: px2vmin(24, DESIGN),
+                color: '#fff',
+                WebkitTapHighlightColor: 'transparent',
+              }}
+              onClick={wrapTrackedClick('login_register', 'email_login', handleEmail)}
+              onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); void handleEmail(); } }}
+            >
+              {t('continueEmail')}
+            </button>
+            {showLastUsed && lastMethod === 'email' ? (
+              <div
+                aria-hidden
+                style={{
+                  position: 'absolute',
+                  top: 0,
+                  right: 0,
+                  transform: 'translateY(-50%)',
+                  width: px2vmin(LAST_USED_TAG_WIDTH, DESIGN),
+                  height: px2vmin(LAST_USED_TAG_HEIGHT, DESIGN),
+                  borderRadius: px2vmin(LAST_USED_TAG_RADIUS, DESIGN),
+                  background: 'rgba(0, 0, 0, 0.1)',
+                  zIndex: 3,
+                  display: 'flex',
+                  alignItems: 'center',
+                  justifyContent: 'center',
+                  fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif',
+                  fontWeight: 500,
+                  fontSize: px2vmin(LAST_USED_TAG_FONT_SIZE, DESIGN),
+                  lineHeight: px2vmin(LAST_USED_TAG_FONT_SIZE, DESIGN),
+                  color: '#FFFFFF',
+                  pointerEvents: 'none',
+                  userSelect: 'none',
+                }}
+              >
+                {t('lastUsed')}
+              </div>
+            ) : null}
+          </div>
 
           {/* 第二个按钮:手机号注册/登录,左右居中,与第一个间距 20,宽 312,Ag title-medium 16/24 */}
-          <button
-            type="button"
+          <div
             style={{
               position: 'absolute',
               top: px2vmin(SECOND_BUTTON_TOP, DESIGN),
@@ -272,28 +336,64 @@ export const LoginRegisterPage = () => {
               transform: 'translateX(-50%)',
               width: px2vmin(312, DESIGN),
               height: px2vmin(BUTTON_HEIGHT, DESIGN),
-              padding: 0,
-              border: 'none',
-              borderRadius: px2vmin(12, DESIGN),
-              background: `url(${secondaryBtn}) center/cover no-repeat`,
-              cursor: agreeTerms ? 'pointer' : 'not-allowed',
-              transition: 'opacity 0.2s ease',
               zIndex: 2,
-              display: 'flex',
-              alignItems: 'center',
-              justifyContent: 'center',
-              fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif',
-              fontWeight: 500,
-              fontSize: px2vmin(16, DESIGN),
-              lineHeight: px2vmin(24, DESIGN),
-              color: '#fff',
-              WebkitTapHighlightColor: 'transparent',
             }}
-            onClick={wrapTrackedClick('login_register', 'phone_login', handlePhone)}
-            onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handlePhone(); } }}
           >
-            {t('continuePhone')}
-          </button>
+            <button
+              type="button"
+              style={{
+                width: '100%',
+                height: '100%',
+                padding: 0,
+                border: 'none',
+                borderRadius: px2vmin(12, DESIGN),
+                background: `url(${secondaryBtn}) center/cover no-repeat`,
+                cursor: agreeTerms ? 'pointer' : 'not-allowed',
+                transition: 'opacity 0.2s ease',
+                display: 'flex',
+                alignItems: 'center',
+                justifyContent: 'center',
+                fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif',
+                fontWeight: 500,
+                fontSize: px2vmin(16, DESIGN),
+                lineHeight: px2vmin(24, DESIGN),
+                color: '#fff',
+                WebkitTapHighlightColor: 'transparent',
+              }}
+              onClick={wrapTrackedClick('login_register', 'phone_login', handlePhone)}
+              onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); void handlePhone(); } }}
+            >
+              {t('continuePhone')}
+            </button>
+            {showLastUsed && lastMethod === 'phone' ? (
+              <div
+                aria-hidden
+                style={{
+                  position: 'absolute',
+                  top: 0,
+                  right: 0,
+                  transform: 'translateY(-50%)',
+                  width: px2vmin(LAST_USED_TAG_WIDTH, DESIGN),
+                  height: px2vmin(LAST_USED_TAG_HEIGHT, DESIGN),
+                  borderRadius: px2vmin(LAST_USED_TAG_RADIUS, DESIGN),
+                  background: 'rgba(0, 0, 0, 0.1)',
+                  zIndex: 3,
+                  display: 'flex',
+                  alignItems: 'center',
+                  justifyContent: 'center',
+                  fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif',
+                  fontWeight: 500,
+                  fontSize: px2vmin(LAST_USED_TAG_FONT_SIZE, DESIGN),
+                  lineHeight: px2vmin(LAST_USED_TAG_FONT_SIZE, DESIGN),
+                  color: '#FFFFFF',
+                  pointerEvents: 'none',
+                  userSelect: 'none',
+                }}
+              >
+                {t('lastUsed')}
+              </div>
+            ) : null}
+          </div>
 
           {/* 我已阅读:左右居中,距顶 658,宽 312,Ag Body-small 16/12 */}
           <div

+ 4 - 1
frontend/src/pages/LoginVerificationInputPage.tsx

@@ -3,7 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
 import { message } from 'antd';
 import { Modal } from 'antd';
-import { setStorage, STORAGE_KEYS, getUserId, getVisitedRoutes, setUserName, getLastOnboardingRoute, syncUserDataFromDatabase } from '@/utils/storage';
+import { setStorage, STORAGE_KEYS, getUserId, getVisitedRoutes, setUserName, getLastOnboardingRoute, syncUserDataFromDatabase, setLastLoginMethod } from '@/utils/storage';
 import { updateUserUIState, verifyAndAuth, updateCommunityChoice, sendCode, getUserInfo, getUserUIState, getEarlyCommunity, submitEarlyCommunity, ApiError } from '@/utils/api';
 import { px2vmin, px2vw, px2vh, type ViewportDesign } from '@/utils/viewport';
 import { getOssUrl } from '@/utils/oss';
@@ -276,6 +276,9 @@ export const LoginVerificationInputPage = () => {
             const isNewUser = !!d.is_new_user;
             setVerifiedIsNewUser(isNewUser);
             setStorage(STORAGE_KEYS.USER_ID, userId);
+            if (state.loginType === 'email' || state.loginType === 'phone') {
+              setLastLoginMethod(state.loginType);
+            }
             if (d.username) setStorage(STORAGE_KEYS.USER_NAME, d.username);
             const { setUserToken } = await import('@/utils/storage');
             if (d.token) setUserToken(d.token);

+ 71 - 31
frontend/src/pages/LumiOutfitsPage.tsx

@@ -252,6 +252,13 @@ export const LumiOutfitsPage = () => {
   const [furnitureEquipPreview, setFurnitureEquipPreview] = useState<ChatbotFurnitureEquipPreviewData | null>(
     null,
   );
+  const frostedGlassStyle = useMemo((): CSSProperties => {
+    return {
+      backdropFilter: 'blur(14px)',
+      WebkitBackdropFilter: 'blur(14px)',
+      boxShadow: '0 10px 28px rgba(0, 0, 0, 0.18)',
+    };
+  }, []);
   const [chatbotGrid, setChatbotGrid] = useState(() => ({
     grid_rows: 5,
     grid_cols: 4,
@@ -361,6 +368,14 @@ export const LumiOutfitsPage = () => {
   }, [furnitureFilter, ownedFurnitureItems]);
   const activeBackdropImageName =
     chatbotBackdrop?.image_name ?? (chatbotLayoutReady ? defaultBackdropImageName : null);
+  const previewBackdropImageName = useMemo(() => {
+    if (!activeFurnitureModalItem || furnitureModalMode !== 'equip') return null;
+    const isBackdrop =
+      activeFurnitureModalItem.item_type === 'backdrop' ||
+      activeFurnitureModalItem.biz_type === 'wallpaper';
+    if (!isBackdrop) return null;
+    return activeFurnitureModalItem.render_image_name || activeFurnitureModalItem.image_name || null;
+  }, [activeFurnitureModalItem, furnitureModalMode]);
   const activeLayoutItems = chatbotLayoutItems;
   const activeLayoutItemsForRender = useMemo(() => {
     if (!activeFurnitureModalItem || furnitureModalMode !== 'equip' || !furnitureEquipPreview) {
@@ -429,41 +444,46 @@ export const LumiOutfitsPage = () => {
       }
 
       if (isBackdrop && !item.is_in_use) {
-        void ensureSession()
-          .then(({ userId }) => updateChatbotBackdrop(userId, item.item_id))
-          .then(() => Promise.all([refreshChatbotLayout(), refreshOwnedFurnitureItems()]))
-          .catch(() => {
-            showFurnitureNotice(t('error.SERVER_ERROR'));
-          });
+        setActiveFurnitureModalItem(item);
+        setFurnitureModalMode('equip');
         return;
       }
 
       setActiveFurnitureModalItem(item);
       setFurnitureModalMode(item.is_in_use ? 'remove' : 'equip');
     },
-    [refreshChatbotLayout, refreshOwnedFurnitureItems, showFurnitureNotice, t, tProfile],
+    [showFurnitureNotice, tProfile],
   );
 
   const handleFurnitureModalConfirm = useCallback(() => {
     if (!activeFurnitureModalItem || furnitureSubmitting) return;
     const itemSnapshot = activeFurnitureModalItem;
+    const isBackdrop =
+      itemSnapshot.item_type === 'backdrop' || itemSnapshot.biz_type === 'wallpaper';
+
+    if (isBackdrop && furnitureModalMode === 'remove') {
+      // 幕布/墙纸不可卸载,只能替换;该分支理论上不会触发
+      showFurnitureNotice(tProfile('lumiWallpaperFixedNotice'));
+      return;
+    }
     setFurnitureSubmitting(true);
-    const actionPromise =
-      furnitureModalMode === 'remove'
+    const actionPromise = isBackdrop
+      ? ensureSession().then(({ userId }) => updateChatbotBackdrop(userId, itemSnapshot.item_id))
+      : furnitureModalMode === 'remove'
         ? ensureSession().then(({ userId }) => unequipChatbotFurniture(userId, itemSnapshot.item_id))
         : ensureSession().then(({ userId }) => equipChatbotFurniture(userId, itemSnapshot.item_id));
 
     void actionPromise
       .then((res) => {
-        if (furnitureModalMode === 'remove') {
+        if (furnitureModalMode === 'remove' && !isBackdrop) {
+          const unequipRes = res as Awaited<ReturnType<typeof unequipChatbotFurniture>>;
           const parts: string[] = [];
           if (
-            'data' in res &&
-            res.data &&
-            'removed_count' in res.data &&
-            Number(res.data.cascade_removed_count || 0) > 0
+            unequipRes.success &&
+            unequipRes.data &&
+            Number(unequipRes.data.cascade_removed_count || 0) > 0
           ) {
-            parts.push(res.data.cascade_notice || tProfile('lumiFurnitureCascadeRemovedNotice'));
+            parts.push(unequipRes.data.cascade_notice || tProfile('lumiFurnitureCascadeRemovedNotice'));
           }
           if (Number(itemSnapshot.has_special_scene_trigger || 0) > 0) {
             const nameForLocale = isZh
@@ -529,7 +549,7 @@ export const LumiOutfitsPage = () => {
             zIndex: 0,
           }}
         >
-          {activeBackdropImageName ? (
+          {previewBackdropImageName || activeBackdropImageName ? (
             <div
               className="absolute pointer-events-none"
               style={{
@@ -540,21 +560,39 @@ export const LumiOutfitsPage = () => {
                 zIndex: CHATBOT_Z_INDEX.GRID_BACKDROP,
               }}
             >
-              <img
-                src={getOssUrl(activeBackdropImageName)}
-                alt={t('a11y.trees')}
-                className="absolute"
-                fetchPriority="low"
-                loading="lazy"
-                draggable={false}
-                style={{
-                  width: '100%',
-                  height: '100%',
-                  left: 0,
-                  top: 0,
-                  objectFit: 'cover',
-                }}
-              />
+              {previewBackdropImageName ? (
+                <img
+                  src={getOssUrl(previewBackdropImageName)}
+                  alt={t('a11y.trees')}
+                  className="absolute"
+                  fetchPriority="low"
+                  loading="eager"
+                  draggable={false}
+                  style={{
+                    width: '100%',
+                    height: '100%',
+                    left: 0,
+                    top: 0,
+                    objectFit: 'cover',
+                  }}
+                />
+              ) : activeBackdropImageName ? (
+                <img
+                  src={getOssUrl(activeBackdropImageName)}
+                  alt={t('a11y.trees')}
+                  className="absolute"
+                  fetchPriority="low"
+                  loading="lazy"
+                  draggable={false}
+                  style={{
+                    width: '100%',
+                    height: '100%',
+                    left: 0,
+                    top: 0,
+                    objectFit: 'cover',
+                  }}
+                />
+              ) : null}
             </div>
           ) : null}
 
@@ -691,6 +729,7 @@ export const LumiOutfitsPage = () => {
             minHeight: px2vmin(60),
             maxHeight: px2vmin(120),
             backgroundColor: 'rgba(0, 0, 0, 0.4)',
+            ...frostedGlassStyle,
             borderRadius: px2vmin(12),
             display: 'flex',
             alignItems: 'flex-start',
@@ -737,6 +776,7 @@ export const LumiOutfitsPage = () => {
               height: px2vmin(32),
               borderRadius: px2vmin(12),
               backgroundColor: 'rgba(0, 0, 0, 0.2)',
+              ...frostedGlassStyle,
               boxSizing: 'border-box',
               display: 'flex',
               flexDirection: 'row',

+ 77 - 36
frontend/src/pages/LumiShopPage.tsx

@@ -234,6 +234,13 @@ export const LumiShopPage = () => {
   const [furnitureEquipPreview, setFurnitureEquipPreview] = useState<ChatbotFurnitureEquipPreviewData | null>(
     null,
   );
+  const frostedGlassStyle = useMemo(() => {
+    return {
+      backdropFilter: 'blur(14px)',
+      WebkitBackdropFilter: 'blur(14px)',
+      boxShadow: '0 10px 28px rgba(0, 0, 0, 0.18)',
+    } as const;
+  }, []);
   const [chatbotGrid, setChatbotGrid] = useState(() => ({
     grid_rows: 5,
     grid_cols: 4,
@@ -379,6 +386,14 @@ export const LumiShopPage = () => {
   }, [furnitureFilter, furnitureCatalogItems]);
   const activeBackdropImageName =
     chatbotBackdrop?.image_name ?? (chatbotLayoutReady ? defaultBackdropImageName : null);
+  const previewBackdropImageName = useMemo(() => {
+    const item = activeFurnitureModalItem || activePurchaseItem;
+    if (!item) return null;
+    const isBackdrop = item.item_type === 'backdrop' || item.biz_type === 'wallpaper';
+    if (!isBackdrop) return null;
+    if (activeFurnitureModalItem && furnitureModalMode !== 'equip') return null;
+    return item.render_image_name || item.image_name || null;
+  }, [activeFurnitureModalItem, activePurchaseItem, furnitureModalMode]);
   const activeLayoutItems = chatbotLayoutItems;
   const activeLayoutItemsForRender = useMemo(() => {
     const preview =
@@ -667,12 +682,8 @@ export const LumiShopPage = () => {
       }
 
       if (isBackdrop && !item.is_in_use) {
-        void ensureSession()
-          .then(({ userId }) => updateChatbotBackdrop(userId, item.item_id))
-          .then(() => Promise.all([refreshChatbotLayout(), refreshFurnitureCatalog()]))
-          .catch(() => {
-            showFurnitureNotice(tError('SERVER_ERROR'));
-          });
+        setActiveFurnitureModalItem(item);
+        setFurnitureModalMode('equip');
         return;
       }
 
@@ -680,7 +691,7 @@ export const LumiShopPage = () => {
       setActiveFurnitureModalItem(item);
       setFurnitureModalMode(item.is_in_use ? 'remove' : 'equip');
     },
-    [refreshChatbotLayout, refreshFurnitureCatalog, showFurnitureNotice, tError, tProfile],
+    [showFurnitureNotice, tProfile],
   );
 
   const purchaseInsufficientEnergy = useMemo(() => {
@@ -706,22 +717,31 @@ export const LumiShopPage = () => {
 
   const handleFurnitureModalConfirm = useCallback(() => {
     if (!activeFurnitureModalItem || furnitureSubmitting) return;
+    const itemSnapshot = activeFurnitureModalItem;
+    const isBackdrop =
+      itemSnapshot.item_type === 'backdrop' || itemSnapshot.biz_type === 'wallpaper';
+
+    if (isBackdrop && furnitureModalMode === 'remove') {
+      // 幕布/墙纸不可卸载,只能替换;该分支理论上不会触发
+      showFurnitureNotice(tProfile('lumiWallpaperFixedNotice'));
+      return;
+    }
     setFurnitureSubmitting(true);
-    const actionPromise =
-      furnitureModalMode === 'remove'
-        ? ensureSession().then(({ userId }) => unequipChatbotFurniture(userId, activeFurnitureModalItem.item_id))
-        : ensureSession().then(({ userId }) => equipChatbotFurniture(userId, activeFurnitureModalItem.item_id));
+    const actionPromise = isBackdrop
+      ? ensureSession().then(({ userId }) => updateChatbotBackdrop(userId, itemSnapshot.item_id))
+      : furnitureModalMode === 'remove'
+        ? ensureSession().then(({ userId }) => unequipChatbotFurniture(userId, itemSnapshot.item_id))
+        : ensureSession().then(({ userId }) => equipChatbotFurniture(userId, itemSnapshot.item_id));
 
     void actionPromise
       .then((res) => {
-        if (
-          furnitureModalMode === 'remove' &&
-          res.success &&
-          res.data &&
-          'cascade_removed_count' in res.data &&
-          Number(res.data.cascade_removed_count || 0) > 0
-        ) {
-          showFurnitureNotice(res.data.cascade_notice || tProfile('lumiFurnitureCascadeRemovedNotice'));
+        if (furnitureModalMode === 'remove' && !isBackdrop) {
+          const unequipRes = res as Awaited<ReturnType<typeof unequipChatbotFurniture>>;
+          if (Number(unequipRes.data?.cascade_removed_count || 0) > 0) {
+            showFurnitureNotice(
+              unequipRes.data.cascade_notice || tProfile('lumiFurnitureCascadeRemovedNotice'),
+            );
+          }
         }
         return Promise.all([refreshChatbotLayout(), refreshFurnitureCatalog()]);
       })
@@ -775,7 +795,7 @@ export const LumiShopPage = () => {
             zIndex: 0,
           }}
         >
-          {activeBackdropImageName ? (
+          {previewBackdropImageName || activeBackdropImageName ? (
             <div
               className="absolute pointer-events-none"
               style={{
@@ -786,21 +806,39 @@ export const LumiShopPage = () => {
                 zIndex: CHATBOT_Z_INDEX.GRID_BACKDROP,
               }}
             >
-              <img
-                src={getOssUrl(activeBackdropImageName)}
-                alt={t('a11y.trees')}
-                className="absolute"
-                fetchPriority="low"
-                loading="lazy"
-                draggable={false}
-                style={{
-                  width: '100%',
-                  height: '100%',
-                  left: 0,
-                  top: 0,
-                  objectFit: 'cover',
-                }}
-              />
+              {previewBackdropImageName ? (
+                <img
+                  src={getOssUrl(previewBackdropImageName)}
+                  alt={t('a11y.trees')}
+                  className="absolute"
+                  fetchPriority="low"
+                  loading="eager"
+                  draggable={false}
+                  style={{
+                    width: '100%',
+                    height: '100%',
+                    left: 0,
+                    top: 0,
+                    objectFit: 'cover',
+                  }}
+                />
+              ) : activeBackdropImageName ? (
+                <img
+                  src={getOssUrl(activeBackdropImageName)}
+                  alt={t('a11y.trees')}
+                  className="absolute"
+                  fetchPriority="low"
+                  loading="lazy"
+                  draggable={false}
+                  style={{
+                    width: '100%',
+                    height: '100%',
+                    left: 0,
+                    top: 0,
+                    objectFit: 'cover',
+                  }}
+                />
+              ) : null}
             </div>
           ) : null}
 
@@ -916,6 +954,7 @@ export const LumiShopPage = () => {
             minHeight: px2vmin(60),
             maxHeight: px2vmin(120),
             backgroundColor: 'rgba(0, 0, 0, 0.4)',
+            ...frostedGlassStyle,
             borderRadius: px2vmin(12),
             display: 'flex',
             alignItems: 'flex-start',
@@ -1041,6 +1080,7 @@ export const LumiShopPage = () => {
                 height: px2vmin(32),
                 borderRadius: px2vmin(12),
                 backgroundColor: 'rgba(0, 0, 0, 0.2)',
+                ...frostedGlassStyle,
                 boxSizing: 'border-box',
                 display: 'flex',
                 flexDirection: 'row',
@@ -1566,7 +1606,8 @@ export const LumiShopPage = () => {
               bottom: 0,
               width: px2vw(393),
               height: px2vmin(318),
-              backgroundColor: '#34313D',
+              backgroundColor: 'rgba(52, 49, 61, 0.72)',
+              ...frostedGlassStyle,
               borderTopLeftRadius: px2vmin(32),
               borderTopRightRadius: px2vmin(32),
               boxSizing: 'border-box',

+ 5 - 0
frontend/src/utils/api.ts

@@ -340,6 +340,9 @@ export interface GetUserResponse {
     is_test_user?: boolean;
     region?: string | null;
     bot_name?: string;
+    area_code?: string | null;
+    phone?: string | null;
+    email?: string | null;
     total_speech_duration_ms?: number;
     user_level?: number;
     /** 对话能量石余额(users.conversation_energy_stone_total) */
@@ -1265,6 +1268,8 @@ export interface ChatbotOwnedItem {
   name_en?: string;
   has_special_scene_trigger?: number;
   image_name: string;
+  /** 场景/幕布/网格渲染图(优先于 image_name);可能为空或后端未返回 */
+  render_image_name?: string | null;
   item_type: 'furniture' | 'backdrop';
   biz_type: ShopItemBizType;
   is_in_use: boolean;

+ 37 - 4
frontend/src/utils/storage.ts

@@ -7,6 +7,7 @@
 export const STORAGE_KEYS = {
   USER_ID: 'Lyrria_user_id', // 用户ID
   USER_TOKEN: 'Lyrria_user_token', // 会话token(单活)
+  LAST_LOGIN_METHOD: 'Lyrria_last_login_method', // 最近一次登录方式:email/phone(用于 login-register 的 Last used 标签定位)
   USER_NAME: 'Lyrria_user_name',
   LUMI_NAME: 'Lyrria_lumi_name', // Lumi 机器人名称
   CURRENT_PAGE: 'Lyrria_current_page',
@@ -32,13 +33,15 @@ export const STORAGE_KEYS = {
   CURRENT_AVATAR_ID: 'Lyrria_current_avatar_id', // 当前形象ID
 } as const;
 
+export type LastLoginMethod = 'email' | 'phone';
+
 // V2.0 路由顺序表:begin → welcome → naming → lumi-naming → tutorial-1~10 → chatbot
 export const ROUTE_ORDER = [
   '/begin', // 开始页
   '/welcome', // 欢迎页(树叶 + Start your journey → MP4)
   '/naming', // 用户命名页
   '/lumi-naming', // Lumi 命名页
-  // 新手教程 10 步
+  // 新手教程
   '/tutorial-1',
   '/tutorial-2',
   '/tutorial-3',
@@ -49,6 +52,7 @@ export const ROUTE_ORDER = [
   '/tutorial-8',
   '/tutorial-9',
   '/tutorial-10',
+  '/tutorial-11',
   '/chatbot', // 聊天页
 ] as const;
 
@@ -66,6 +70,7 @@ const TUTORIAL_ROUTES: RouteType[] = [
   '/tutorial-8',
   '/tutorial-9',
   '/tutorial-10',
+  '/tutorial-11',
 ];
 
 const isKnownRoute = (route: unknown): route is RouteType => {
@@ -210,6 +215,12 @@ export const getLastOnboardingRoute = (
 ): RouteType => {
   const visited = visitedRoutes != null ? sanitizeVisitedRoutes(visitedRoutes) : getVisitedRoutes();
 
+  // 如果后端已明确记录当前页为 chatbot,则优先回到 chatbot
+  // 避免 tutorial 路由残留(如 /tutorial-11)导致老用户登录后被带回教程页
+  if (_currentPage === 'chatbot' || _currentPage === '/chatbot') {
+    return '/chatbot';
+  }
+
   const hasChatbotVisited = visited.includes('/chatbot');
   const hasTutorialVisited = TUTORIAL_ROUTES.some(route => visited.includes(route));
 
@@ -220,7 +231,7 @@ export const getLastOnboardingRoute = (
     }
     const visitedTutorials = TUTORIAL_ROUTES.filter(route => visited.includes(route));
     const highestTutorial = visitedTutorials[visitedTutorials.length - 1];
-    if (highestTutorial === '/tutorial-10') {
+    if (highestTutorial === '/tutorial-10' || highestTutorial === '/tutorial-11') {
       return '/chatbot';
     }
     return highestTutorial;
@@ -266,6 +277,23 @@ export const getUserToken = (): string | null => {
   return getStorage(STORAGE_KEYS.USER_TOKEN);
 };
 
+/**
+ * 最近一次登录方式(仅 email / phone)
+ */
+export const getLastLoginMethod = (): LastLoginMethod | null => {
+  const raw = getStorage(STORAGE_KEYS.LAST_LOGIN_METHOD);
+  if (raw === 'email' || raw === 'phone') return raw;
+  return null;
+};
+
+export const setLastLoginMethod = (method: LastLoginMethod): boolean => {
+  return setStorage(STORAGE_KEYS.LAST_LOGIN_METHOD, method);
+};
+
+export const clearLastLoginMethod = (): boolean => {
+  return removeStorage(STORAGE_KEYS.LAST_LOGIN_METHOD);
+};
+
 /**
  * 设置会话 token
  */
@@ -759,8 +787,13 @@ export const syncUserDataFromDatabase = (
     if (uiState.has_shown_first_shop_tooltip) {
       setStorage(STORAGE_KEYS.HAS_SHOWN_FIRST_SHOP_TOOLTIP, 'true');
     }
-    if (uiState.has_clicked_shop_in_settings) {
-      setStorage(STORAGE_KEYS.HAS_CLICKED_SHOP_IN_SETTINGS, 'true');
+    // 商店(设置弹窗)点击状态需要双向同步,避免本地残留导致 UI 一直显示红点
+    if (typeof uiState.has_clicked_shop_in_settings === 'boolean') {
+      if (uiState.has_clicked_shop_in_settings) {
+        setStorage(STORAGE_KEYS.HAS_CLICKED_SHOP_IN_SETTINGS, 'true');
+      } else {
+        removeStorage(STORAGE_KEYS.HAS_CLICKED_SHOP_IN_SETTINGS);
+      }
     }
     if (typeof uiState.subtitle_on === 'boolean') {
       setStorage(STORAGE_KEYS.SUBTITLE_ON, uiState.subtitle_on ? 'true' : 'false');

+ 45 - 0
frontend/src/utils/tracking.ts

@@ -134,6 +134,51 @@ export const trackButtonClick = (
   reportUserEvent(event);
 };
 
+export const trackConversationEnd = (
+  options: {
+    /** 对话页面固定为 chatbot(如未来有多入口,可扩展) */
+    pageId?: 'chatbot';
+    /** 从开始录音到录音结束的时长(秒) */
+    durationSeconds: number;
+    /** 本轮请求 id(可选,用于排查) */
+    requestId?: string;
+    /** 扩展属性 */
+    properties?: Record<string, unknown>;
+  },
+): void => {
+  const userId = getUserId();
+  if (!userId) {
+    if (import.meta.env.DEV) {
+      console.log('[tracking] trackConversationEnd 跳过: 未登录');
+    }
+    return;
+  }
+
+  const durationSeconds = Number.isFinite(options.durationSeconds) ? options.durationSeconds : 0;
+  const safeDurationSeconds = durationSeconds > 0 ? durationSeconds : 0;
+  const pageId = options.pageId ?? 'chatbot';
+
+  const event: UserEventPayload = {
+    event_id: uuidv4(),
+    user_id: userId,
+    event_name: `conversation.${pageId}__end`,
+    event_label: '对话结束(录音时长)',
+    event_time: new Date().toISOString(),
+    session_id: getOrCreateSessionId(),
+    platform: getPlatform(),
+    app_version: getAppVersion(),
+    timezone_offset_minutes: getTimezoneOffsetMinutes(),
+    properties: {
+      page: pageId,
+      duration_seconds: safeDurationSeconds,
+      request_id: options.requestId,
+      ...(options.properties ?? {}),
+    },
+  };
+
+  reportUserEvent(event);
+};
+
 /** 包装点击回调:先上报 button 埋点再执行原 onClick(用于原生 button/div 等) */
 export function wrapTrackedClick(
   pageId: string,