zlwu 7 ماه پیش
والد
کامیت
fedc9e3de2

+ 5 - 0
dailyCount/run_daily_stats_pipeline.py

@@ -23,6 +23,11 @@ except ImportError:
     print("请安装 pymysql: pip install pymysql", file=sys.stderr)
     sys.exit(1)
 
+# 重定向到文件时(如 nohup > a.log)Python 会块缓冲,日志不实时写入;强制行缓冲
+if not sys.stdout.isatty():
+    sys.stdout.reconfigure(line_buffering=True)
+if not sys.stderr.isatty():
+    sys.stderr.reconfigure(line_buffering=True)
 
 SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
 DEFAULT_CONF = os.path.join(SCRIPT_DIR, "setting.conf")

+ 63 - 75
frontend/src/components/ChatBotStaticShell.tsx

@@ -1,10 +1,10 @@
-import { useEffect, useRef, useState } from 'react';
+import { useCallback, 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 { px2vmin } from '@/utils/viewport';
 import { wrapTrackedClick } from '@/utils/tracking';
 import { AudioPlayer } from '@/utils/audioPlayer';
 import './MissionEnergyStoneModal.css';
@@ -77,6 +77,54 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
   const [showFuncMenuPopup, setShowFuncMenuPopup] = useState<boolean>(false);
   const [showBondLevelModal, setShowBondLevelModal] = useState<boolean>(false);
   const introAudioPlayerRef = useRef<AudioPlayer | null>(null);
+  const introCancelledRef = useRef<boolean>(false);
+
+  const INTRO_STEPS = [1, 3, 5, 8, 9, 10];
+  const hasIntroAudio = INTRO_STEPS.includes(step);
+
+  // 与 /chatbot 一致:先建立播放器,收到数据后在回调中播放(避免长 await 链打断“用户手势”延续)
+  const playIntroAudioForStep = useCallback(() => {
+    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;
+    };
+    Promise.all([import('@/utils/api')])
+      .then(([{ getApiBaseUrl, getApiHeaders }]) => {
+        const apiBaseUrl = getApiBaseUrl();
+        return fetch(`${apiBaseUrl}/api/tutorials/${step}/intro-audio`, {
+          method: 'GET',
+          headers: getApiHeaders(),
+        });
+      })
+      .then((response) => (response.ok ? response.json() : null))
+      .then((body: TutorialIntroAudioResponse | null) => {
+        if (!body?.success || !body.data?.audio || introCancelledRef.current) return;
+        const player = introAudioPlayerRef.current;
+        if (!player) return;
+        const audioBytes = base64ToUint8Array(body.data.audio);
+        const sampleRate = body.data.sampleRate ?? 24000;
+        player.addChunk(audioBytes, sampleRate).then(() => {
+          if (!introCancelledRef.current) player.markStreamEnd();
+        });
+      })
+      .catch(() => {
+        // 静默失败
+      });
+  }, [step]);
 
   useEffect(() => {
     // 确保每次进入组件 / 步骤变化时,优先关闭弹窗
@@ -111,88 +159,28 @@ 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 (!hasIntroAudio) return;
 
-        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;
-        }
+    introCancelledRef.current = false;
 
-        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 {
-        // 静默失败,避免影响新手教程体验
+    // 与 /chatbot 一致:先建立播放管线(AudioContext),再请求数据,在回调中播放
+    const run = async () => {
+      if (!introAudioPlayerRef.current) {
+        const player = new AudioPlayer();
+        await player.initialize();
+        introAudioPlayerRef.current = player;
       }
+      playIntroAudioForStep();
     };
-
-    void playIntroAudio();
+    void run();
 
     return () => {
-      cancelled = true;
+      introCancelledRef.current = true;
       if (introAudioPlayerRef.current) {
         introAudioPlayerRef.current.stop();
       }
     };
-  }, [step]);
+  }, [step, hasIntroAudio, playIntroAudioForStep]);
 
   useEffect(() => {
     const userId = getUserId();
@@ -843,7 +831,7 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
         }}
       >
         <img
-          src={FALLBACK_DEFAULT_AVATAR_GIF}
+          src={avatarGif}
           alt={tCommon('a11y.avatar')}
           style={{
             position: 'absolute',

+ 2 - 1
frontend/src/main.tsx

@@ -11,10 +11,11 @@ import './index.css';
 const chatbotBgUrl = getOssUrl('chatbot-bg.svg');
 document.documentElement.style.setProperty('--oss-chatbot-bg-url', `url("${chatbotBgUrl}")`);
 
-// 预加载 /chatbot 首屏关键资源,进入页面前即可进入浏览器缓存
+// 预加载 /chatbot 首屏关键资源,进入页面前即可进入浏览器缓存(减少首屏与主形象争抢带宽)
 const criticalChatbotAssets: { url: string; as: 'image' }[] = [
   { url: getOssUrl('01-Default.gif'), as: 'image' },
   { url: chatbotBgUrl, as: 'image' },
+  { url: getOssUrl('stool.png'), as: 'image' },
 ];
 criticalChatbotAssets.forEach(({ url, as: asType }) => {
   const link = document.createElement('link');

+ 61 - 23
frontend/src/pages/ChatBotPage.tsx

@@ -300,13 +300,16 @@ export const ChatBotPage = () => {
     pendingGreetingRef.current = false;
     hasGreetingStartedRef.current = true;
     const requestId = `greeting_${uuidv4().replace(/-/g, '').slice(0, 8)}`;
-    const forceScene = searchParams.get('scene');
-    const validScene = ['default', 'window', 'reading', 'eating'].includes(forceScene || '');
-    debugLog('[首句] 发送 start_greeting 消息', { requestId, forceScene: validScene ? forceScene : undefined });
+    const rawScene = searchParams.get('scene');
+    const forceScene: 'default' | 'window' | 'reading' | 'eating' | undefined =
+      rawScene === 'default' || rawScene === 'window' || rawScene === 'reading' || rawScene === 'eating'
+        ? rawScene
+        : undefined;
+    debugLog('[首句] 发送 start_greeting 消息', { requestId, forceScene });
     ws.send({
       type: 'start_greeting',
       requestId,
-      ...(validScene && forceScene ? { data: { forceScene } } : {}),
+      ...(forceScene ? { data: { forceScene } } : {}),
     });
   };
   const [conversationStoneCount, setConversationStoneCount] = useState<number>(0);
@@ -693,25 +696,53 @@ export const ChatBotPage = () => {
     prevAvatarStateRef.current = currentState;
   }, [currentState, showDefaultGif]);
 
-  // 预加载动画形象的 GIF
+  // 预加载动画形象的 GIF:首帧优先 default,其余情绪 GIF 延迟加载,减少首屏争抢带宽
   const preloadAvatarGifs = useCallback(
     (avatarId: string | null) => {
       const gifSet = resolveAvatarGifSet(avatarId);
-      const gifUrls = [gifSet.default, gifSet.positive, gifSet.negative, gifSet.talking];
-      
-      // 使用 Image 对象预加载所有 GIF
-      gifUrls.forEach((url) => {
-        if (url) {
-          const img = new Image();
-          img.src = url;
-          // 静默处理加载错误,避免影响主流程
-          img.onerror = () => {
-            console.warn(`[预加载] GIF 加载失败: ${url}`);
-          };
+      const defaultUrl = gifSet.default;
+      const emotionUrls = [gifSet.positive, gifSet.negative, gifSet.talking].filter(Boolean);
+
+      if (defaultUrl) {
+        const img = new Image();
+        img.src = defaultUrl;
+        img.onerror = () => {
+          console.warn(`[预加载] 默认 GIF 加载失败: ${defaultUrl}`);
+        };
+      }
+
+      const scheduleRest = () => {
+        if (typeof requestIdleCallback !== 'undefined') {
+          requestIdleCallback(
+            () => {
+              emotionUrls.forEach((url) => {
+                if (url) {
+                  const img = new Image();
+                  img.src = url;
+                  img.onerror = () => {
+                    console.warn(`[预加载] GIF 加载失败: ${url}`);
+                  };
+                }
+              });
+            },
+            { timeout: 2000 }
+          );
+        } else {
+          setTimeout(() => {
+            emotionUrls.forEach((url) => {
+              if (url) {
+                const img = new Image();
+                img.src = url;
+                img.onerror = () => {
+                  console.warn(`[预加载] GIF 加载失败: ${url}`);
+                };
+              }
+            });
+          }, 500);
         }
-      });
-      
-      debugLog(`[预加载] 已开始预加载形象 ${avatarId || 'default'} 的所有 GIF`);
+      };
+      scheduleRest();
+      debugLog(`[预加载] 已预加载默认 GIF,情绪 GIF 已排队延迟加载`);
     },
     [resolveAvatarGifSet]
   );
@@ -2665,6 +2696,9 @@ export const ChatBotPage = () => {
     const requestId = uuidv4();
     performanceLogger.startRequest(requestId);
     updateRequestId(requestId);
+    // 录音 UI 会显示 audio-wave.gif,提前预加载避免首次点击录音时闪烁
+    const waveImg = new Image();
+    waveImg.src = audioWaveGif;
     setState('recording');
     chunkIndexRef.current = 0;
     // 字幕已在上面点击录音时隐藏并清空
@@ -2961,6 +2995,8 @@ export const ChatBotPage = () => {
           alt={tCommon('a11y.trees')}
           className="chatbot-trees absolute pointer-events-none"
           style={getTreesImageStyle()}
+          fetchPriority="low"
+          loading="lazy"
         />
       )}
 
@@ -2969,6 +3005,7 @@ export const ChatBotPage = () => {
         src={getOssUrl('stool.png')}
         alt=""
         className="absolute pointer-events-none"
+        fetchPriority="low"
         style={{
           width: px2vmin(STOOL_LAYOUT.width),
           height: px2vmin(STOOL_LAYOUT.height),
@@ -2984,11 +3021,12 @@ export const ChatBotPage = () => {
       {greetingContext?.scene &&
         greetingContext.scene !== 'default' &&
         !specialSceneAvatarDismissed &&
-        SPECIAL_SCENE_GIF[greetingContext.scene] && (
+        SPECIAL_SCENE_GIF[greetingContext.scene as keyof typeof SPECIAL_SCENE_GIF] && (
           <img
-            src={SPECIAL_SCENE_GIF[greetingContext.scene]}
+            src={SPECIAL_SCENE_GIF[greetingContext.scene as keyof typeof SPECIAL_SCENE_GIF]}
             alt=""
             className="absolute pointer-events-none"
+            loading="lazy"
             style={{
               width: px2vmin(STOOL_LAYOUT.sceneGifSize),
               height: px2vmin(STOOL_LAYOUT.sceneGifSize),
@@ -3325,7 +3363,7 @@ export const ChatBotPage = () => {
             );
       })}
 
-      {/* AnimaZone 动画形象 - 场景未确定前不显示,避免特殊场景下「先出现再消失」;确定后:默认场景直接显示,特殊场景下未点击录音时隐藏、点击录音后显示 */}
+      {/* AnimaZone 动画形象 - 仅场景解析后且为默认(或特殊场景已点录音收起)时显示,避免特殊场景下先出现再消失 */}
       {hasGreetingScenarioResolved && !(greetingContext?.scene && greetingContext.scene !== 'default' && !specialSceneAvatarDismissed) && (
         <div
           className="chatbot-anima-zone-avatar absolute"
@@ -3486,7 +3524,7 @@ export const ChatBotPage = () => {
         </div>
       )}
 
-      {/* Yp 图片 - 设计稿 393×852,相对动画形象顶部 191,64×14;场景确定后且非特殊场景未点录音时才显示 */}
+      {/* Yp 图片 - 设计稿 393×852,相对动画形象顶部 191,64×14;与中间动画同显隐(仅默认或特殊已收起时显示) */}
       {hasGreetingScenarioResolved && !(greetingContext?.scene && greetingContext.scene !== 'default' && !specialSceneAvatarDismissed) && (
         <div
           className="absolute"

+ 0 - 1
frontend/src/pages/GreetingPage.tsx

@@ -1,7 +1,6 @@
 import { useState, useEffect, useRef } from 'react';
 import { useNavigate } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
-import { message as antMessage } from 'antd';
 import {
   getStorage,
   setStorage,

+ 2 - 1
frontend/src/pages/TutorialPage.tsx

@@ -6,6 +6,7 @@ import {
   markRouteVisited,
   getVisitedRoutes,
   getUserId,
+  type RouteType,
 } from '@/utils/storage';
 import { updateUserUIState } from '@/utils/api';
 
@@ -15,7 +16,7 @@ interface TutorialPageProps {
 
 export const TutorialPage: React.FC<TutorialPageProps> = ({ step }) => {
   useEffect(() => {
-    const routePath = `/tutorial-${step}` as const;
+    const routePath = `/tutorial-${step}` as RouteType;
 
     // 记录当前页面
     setStorage(STORAGE_KEYS.CURRENT_PAGE, routePath.replace('/', ''));

+ 7 - 0
frontend/src/types/index.ts

@@ -141,6 +141,12 @@ export interface StartGreetingMessage extends BaseMessage {
   data?: { forceScene?: 'default' | 'window' | 'reading' | 'eating' };
 }
 
+// 情景对话:请求播放某话题首句 TTS(由前端主动触发)
+export interface PlayTopicFirstLineMessage extends BaseMessage {
+  type: 'play_topic_first_line';
+  data: { topicKey: string };
+}
+
 // 情景对话:后端下发的场景与话题上下文(特殊场景首句前)
 export interface GreetingContextMessage {
   type: 'greeting_context';
@@ -194,6 +200,7 @@ export type Message =
   | PingMessage
   | PongMessage
   | StartGreetingMessage
+  | PlayTopicFirstLineMessage
   | GreetingContextMessage
   | EnergyStoneSpawnMessage
   | EnergyStoneEventMessage