zlwu vor 10 Monaten
Ursprung
Commit
de79bb8101

+ 16 - 9
backend/.env.lyrria

@@ -50,23 +50,30 @@ STT_LANGUAGE=zh
 
 # TTS 模型配置
 # 语音合成模型名称
-TTS_MODEL=qwen3-tts-flash
+#TTS_MODEL=qwen3-tts-flash
 # 自定义 TTS HTTP API 地址
-TTS_API_URL=
+#TTS_API_URL=
 # 语音风格(如 Cherry/Jada)
-TTS_VOICE=Jada
+#TTS_VOICE=Jada
 # 语速倍率(1.0 为正常)
-TTS_SPEECH_RATE=0.75
+#TTS_SPEECH_RATE=0.75
 # 连续 TTS 调用的最小间隔(秒)
-TTS_MIN_INTERVAL=0.8
+#TTS_MIN_INTERVAL=0.8
 # TTS 请求最大重试次数
-TTS_MAX_RETRIES=3
+#TTS_MAX_RETRIES=3
 # TTS 初始重试等待时间(秒)
-TTS_RETRY_DELAY=5.0
+#TTS_RETRY_DELAY=5.0
 # TTS 指数退避倍数
-TTS_RETRY_BACKOFF=2.0
+#TTS_RETRY_BACKOFF=2.0
 # TTS 连接健康检查间隔(秒)
-TTS_HEALTHCHECK_INTERVAL=300
+#TTS_HEALTHCHECK_INTERVAL=300
+TTS_MODEL=cosyvoice-v3-flash
+TTS_VOICE=longanhuan
+
+# 语速/音调/语言提示
+TTS_SPEECH_RATE=1.0
+TTS_PITCH_RATE=1.15
+TTS_LANGUAGE_HINTS=en
 
 # AI 助手提示词配置
 # 学习者语言水平标签

+ 237 - 92
backend/ai_service.py

@@ -23,9 +23,9 @@ from http_pool import http_pool  # 🔥 使用连接池管理器
 logger = logging.getLogger(__name__)
 
 # 🔥 TTS API选择
-# 注意: SpeechSynthesizer (tts_v2) 使用callback模式,不适合我们的generator流式场景
-# MultiModalConversation 已经是最适合流式语音合成的API
-USE_NEW_TTS_API = False  # 保持使用 MultiModalConversation
+# 使用 SpeechSynthesizer (tts_v2) 支持 cosyvoice-v3-flash 模型
+# 使用 streaming_call 实现真正的流式传输
+from dashscope.audio.tts_v2 import SpeechSynthesizer, ResultCallback
 
 # 设置API Key和endpoint(根据国际版/国内版自动选择)
 dashscope.api_key = settings.active_dashscope_api_key
@@ -89,16 +89,17 @@ class AIService:
             try:
                 start_time = time.time()
                 
-                response = dashscope.MultiModalConversation.call(
+                # 使用 SpeechSynthesizer 预热(同步调用即可)
+                synthesizer = SpeechSynthesizer(
                     model=settings.tts_model,
-                    text="Hi",  # 极短的测试文本
                     voice=settings.tts_voice,
-                    stream=True,
+                    speech_rate=settings.tts_speech_rate,
+                    pitch_rate=settings.tts_pitch_rate,
+                    language_hints=settings.tts_language_hints,
                 )
                 
-                # 只接收首块即停止
-                for _ in response:
-                    break
+                # 发送极短文本进行预热(使用同步调用)
+                audio = synthesizer.call("Hi")
                 
                 AIService._tts_warmed_up = True  # 标记为已预热
                 
@@ -465,104 +466,223 @@ class AIService:
     ) -> AsyncGenerator[bytes, None]:
         """
         TTS流式调用的内部实现(不含重试逻辑)
+        使用 SpeechSynthesizer.streaming_call() 实现真正的流式传输
         
         Args:
             text: 要合成的文本
             emotion: 情感类型
             
         Yields:
-            audio_data: PCM 音频数据块(Base64 解码后的原始数据)
+            audio_data: PCM 音频数据块(原始音频数据)
         """
         try:
             import time
             tts_start = time.time()
             
-            # 根据情感选择音色
+            # 根据情感选择音色和构建 instruction
             voice = self._get_voice_for_emotion(emotion)
-            # 🔍 调试:打印实际使用的语速配置
-            logger.info(f"🔍 TTS调用 - 使用语速 speech_rate={settings.tts_speech_rate}, 音色={voice}")
-            # TODO: 添加语速调整 、测试语速调整是否有效
-            def sync_streaming_tts():
-                return dashscope.MultiModalConversation.call(
-                    model=settings.tts_model,
-                    text=text,
-                    voice=voice,
-                    speech_rate=settings.tts_speech_rate,  # 语速调整
-                    #speech_rate=0.9,
-                    stream=True,  # 流式输出
-                    # 注意: incremental_output 参数在此API中可能不生效
+            instruction = self._build_emotion_instruction(emotion)
+            
+            # 🔍 调试:打印实际使用的配置
+            logger.info(
+                f"🔍 TTS调用 - 模型={settings.tts_model}, "
+                f"音色={voice}, "
+                f"语速={settings.tts_speech_rate}, "
+                f"音调={settings.tts_pitch_rate}, "
+                f"语言={settings.tts_language_hints}, "
+                f"情感={emotion}"
                 )
             
-            # 🔥 修复:使用队列在线程和异步之间传递数据
+            # 🔥 使用队列在线程和异步之间传递数据
             import queue
             import threading
+            from pydub import AudioSegment
+            import io
             
             audio_queue = queue.Queue()
             error_holder = [None]  # 用列表包装以便在线程间共享
+            completion_event = threading.Event()  # 用于等待流式完成
             
-            def stream_producer():
-                """在独立线程中执行同步流式API调用"""
-                nonlocal chunk_count, first_chunk_time, total_audio_size
-                try:
-                    # 🔥 关键修复:在锁内完成整个API调用和迭代
-                    # 原因:DashScope SDK内部可能有全局状态,并发迭代会冲突
-                    with AIService._tts_call_lock:
-                        response = sync_streaming_tts()
+            # MP3 音频缓冲区(用于累积 MP3 数据,然后转换为 PCM)
+            mp3_buffer = io.BytesIO()
+            
+            class TTSStreamCallback(ResultCallback):
+                """TTS流式回调处理器"""
+                
+                def __init__(self, queue: queue.Queue, mp3_buffer: io.BytesIO, 
+                           error_holder: list, completion_event: threading.Event):
+                    self.queue = queue
+                    self.mp3_buffer = mp3_buffer
+                    self.error_holder = error_holder
+                    self.completion_event = completion_event
+                    self.first_chunk_time = None
+                    self.chunk_count = 0
+                    self.total_audio_size = 0
+                    self.mp3_min_size = 4096  # MP3最小解码大小(4KB),确保能解码一个完整帧
+                    
+                def on_open(self):
+                    """连接打开"""
+                    logger.debug("TTS流式连接已建立")
+                    self.first_chunk_time = time.time()
+                
+                def _process_mp3_buffer(self):
+                    """处理累积的MP3数据,转换为PCM并发送"""
+                    try:
+                        buffer_size = self.mp3_buffer.tell()
+                        if buffer_size < self.mp3_min_size:
+                            return  # 数据不足,继续累积
+                        
+                        # 尝试解码MP3
+                        self.mp3_buffer.seek(0)
+                        audio_segment = AudioSegment.from_mp3(self.mp3_buffer)
+                        
+                        # 转换为PCM格式:24kHz, 16bit, Mono
+                        audio_segment = audio_segment.set_frame_rate(24000).set_channels(1).set_sample_width(2)
+                        pcm_data = audio_segment.raw_data
                         
-                        chunk_index = 0
-                        for chunk in response:
-                            chunk_index += 1
+                        # 分块发送PCM数据(3200字节/块)
+                        chunk_size = 3200
+                        for i in range(0, len(pcm_data), chunk_size):
+                            chunk = pcm_data[i:i + chunk_size]
+                            self.chunk_count += 1
+                            self.queue.put(('data', chunk))
+                        
+                        # 清空缓冲区(已处理的数据)
+                        self.mp3_buffer.seek(0)
+                        self.mp3_buffer.truncate(0)
+                        
+                    except FileNotFoundError as e:
+                        # ffmpeg/ffprobe 未安装
+                        error_msg = str(e)
+                        if 'ffprobe' in error_msg or 'ffmpeg' in error_msg or 'avprobe' in error_msg:
+                            logger.error(
+                                f"[TTS错误] 系统缺少 ffmpeg,无法解码 MP3 音频。"
+                                f"请在生产服务器安装 ffmpeg: apt-get install ffmpeg (Ubuntu/Debian) 或 yum install ffmpeg (CentOS/RHEL)"
+                            )
+                            self.error_holder[0] = Exception(
+                                "系统缺少 ffmpeg,无法处理音频。请安装: apt-get install ffmpeg 或 yum install ffmpeg"
+                            )
+                            self.queue.put(('error', self.error_holder[0]))
+                            return
+                        raise
+                    except Exception as e:
+                        # MP3数据不完整或格式错误,继续累积
+                        # 这是正常的,因为流式传输中MP3数据是分片到达的
+                        # 但如果是其他错误,记录日志
+                        error_str = str(e).lower()
+                        if 'ffprobe' not in error_str and 'ffmpeg' not in error_str and 'avprobe' not in error_str:
+                            # 不是 ffmpeg 相关错误,可能是数据不完整,继续累积
+                            pass
+                        else:
+                            # ffmpeg 相关错误,记录并抛出
+                            logger.error(f"[TTS错误] MP3解码失败: {e}")
+                            raise
+                
+                def on_data(self, data: bytes):
+                    """接收音频数据(MP3格式)"""
+                    try:
+                        if data:
+                            # 累积 MP3 数据
+                            self.mp3_buffer.write(data)
+                            self.total_audio_size += len(data)
                             
-                            # 🔥 检查API错误(特别是速率限制)
-                            if hasattr(chunk, 'status_code') and chunk.status_code != 200:
-                                error_code = getattr(chunk, 'code', 'Unknown')
-                                error_msg = getattr(chunk, 'message', 'No error message')
-                                
-                                # 详细记录TTS API错误
-                                logger.error(
-                                    f"[TTS异常] 错误类型=TTS API响应错误, "
-                                    f"HTTP状态码={chunk.status_code}, "
-                                    f"错误码={error_code}, "
-                                    f"错误消息={error_msg}, "
-                                    f"模型={settings.tts_model}, "
-                                    f"音色={voice}, "
-                                    f"文本长度={len(text)}, "
-                                    f"chunk索引={chunk_index}, "
-                                    f"完整chunk对象={chunk}"
-                                )
+                            # 🔥 当累积足够数据时,尝试解码并发送PCM
+                            # 策略:累积到最小解码大小(4KB)后尝试解码
+                            self._process_mp3_buffer()
                                 
-                                # 构造友好的错误信息
-                                if chunk.status_code == 429 or 'Throttling' in error_code:
-                                    raise Exception(f"TTS API速率限制: {error_msg} (code: {error_code})")
-                                else:
-                                    raise Exception(f"TTS API错误 (status={chunk.status_code}): {error_msg} (code: {error_code})")
-                            
-                            if hasattr(chunk, 'output') and hasattr(chunk.output, 'audio'):
-                                audio = chunk.output.audio
+                    except Exception as e:
+                        logger.error(f"[TTS回调异常] on_data错误: {e}")
+                        self.error_holder[0] = e
+                        self.queue.put(('error', e))
+                
+                def on_complete(self):
+                    """流式合成完成"""
+                    try:
+                        # 🔥 处理剩余的 MP3 数据
+                        if self.mp3_buffer.tell() > 0:
+                            self.mp3_buffer.seek(0)
+                            try:
+                                audio_segment = AudioSegment.from_mp3(self.mp3_buffer)
+                                audio_segment = audio_segment.set_frame_rate(24000).set_channels(1).set_sample_width(2)
+                                pcm_data = audio_segment.raw_data
                                 
-                                # 接收到音频数据块
-                                if audio.data is not None:
-                                    # 记录首字节时间
-                                    if first_chunk_time is None:
-                                        first_chunk_time = time.time()
-                                    
-                                    # Base64 解码 PCM 数据
-                                    pcm_bytes = base64.b64decode(audio.data)
-                                    chunk_count += 1
-                                    total_audio_size += len(pcm_bytes)
-                                    
-                                    # 放入队列
-                                    audio_queue.put(('data', pcm_bytes))
+                                # 发送剩余的PCM数据
+                                chunk_size = 3200
+                                for i in range(0, len(pcm_data), chunk_size):
+                                    chunk = pcm_data[i:i + chunk_size]
+                                    self.chunk_count += 1
+                                    self.queue.put(('data', chunk))
+                            except FileNotFoundError as e:
+                                # ffmpeg/ffprobe 未安装
+                                error_msg = str(e)
+                                if 'ffprobe' in error_msg or 'ffmpeg' in error_msg or 'avprobe' in error_msg:
+                                    logger.error(
+                                        f"[TTS错误] 系统缺少 ffmpeg,无法解码 MP3 音频。"
+                                        f"请在生产服务器安装 ffmpeg: apt-get install ffmpeg (Ubuntu/Debian) 或 yum install ffmpeg (CentOS/RHEL)"
+                                    )
+                                    self.error_holder[0] = Exception(
+                                        "系统缺少 ffmpeg,无法处理音频。请安装: apt-get install ffmpeg 或 yum install ffmpeg"
+                                    )
+                                    self.queue.put(('error', self.error_holder[0]))
+                                    return
+                                raise
+                            except Exception as e:
+                                logger.warning(f"[TTS回调] 处理剩余数据失败: {e}")
+                        
+                        logger.debug(f"TTS流式合成完成,共{self.chunk_count}个chunk,总大小{self.total_audio_size}字节")
+                        self.queue.put(('done', None))
+                        self.completion_event.set()
+                        
+                    except Exception as e:
+                        logger.error(f"[TTS回调异常] on_complete错误: {e}")
+                        self.error_holder[0] = e
+                        self.queue.put(('error', e))
+                
+                def on_error(self, message: str):
+                    """发生错误"""
+                    error = Exception(f"TTS流式合成错误: {message}")
+                    logger.error(f"[TTS回调错误] {message}")
+                    self.error_holder[0] = error
+                    self.queue.put(('error', error))
+                    self.completion_event.set()
+                
+                def on_close(self):
+                    """连接关闭"""
+                    logger.debug("TTS流式连接已关闭")
+            
+            def stream_producer():
+                """在独立线程中执行流式TTS API调用"""
+                nonlocal chunk_count, first_chunk_time, total_audio_size
+                try:
+                    # 🔥 关键修复:在锁内完成整个API调用
+                    with AIService._tts_call_lock:
+                        # 创建回调实例
+                        callback = TTSStreamCallback(audio_queue, mp3_buffer, error_holder, completion_event)
                                 
-                                # 检查是否结束
-                                if hasattr(chunk.output, 'finish_reason') and chunk.output.finish_reason == "stop":
-                                    tts_end = time.time()
-                                    total_duration = (tts_end - tts_start) * 1000
-                                    
-                                    break
-                    
-                    # 标记完成
-                    audio_queue.put(('done', None))
+                        # 创建 SpeechSynthesizer 实例(使用回调)
+                        # 🔥 注意:instruction 参数可能在某些情况下导致错误,先尝试不传
+                        synthesizer_params = {
+                            "model": settings.tts_model,
+                            "voice": voice,
+                            "speech_rate": settings.tts_speech_rate,
+                            "pitch_rate": settings.tts_pitch_rate,
+                            "language_hints": settings.tts_language_hints,
+                            "callback": callback,
+                        }
+                        # 只在 instruction 不为空时添加(避免参数错误)
+                        if instruction and instruction.strip():
+                            synthesizer_params["instruction"] = instruction
+                        
+                        synthesizer = SpeechSynthesizer(**synthesizer_params)
+                        
+                        # 🔥 使用流式调用(真正的流式传输)
+                        synthesizer.streaming_call(text)
+                        
+                        # 🔥 重要:必须调用 streaming_complete() 确保结尾部分被合成
+                        synthesizer.streaming_complete()
+                        
+                        # 等待回调完成
+                        completion_event.wait(timeout=30)
                     
                 except Exception as e:
                     logger.error(
@@ -572,11 +692,11 @@ class AIService:
                         f"模型={settings.tts_model}, "
                         f"音色={voice}, "
                         f"文本长度={len(text)}, "
-                        f"已接收chunk数={chunk_index}, "
                         f"堆栈跟踪:\n{traceback.format_exc()}"
                     )
                     error_holder[0] = e
                     audio_queue.put(('error', e))
+                    completion_event.set()
             
             # 启动生产者线程
             chunk_count = 0
@@ -603,6 +723,9 @@ class AIService:
                         raise data
                         
                 except queue.Empty:
+                    # 检查是否已完成
+                    if completion_event.is_set():
+                        break
                     error_msg = "TTS API 超时(30秒无响应)"
                     logger.error(
                         f"[TTS异常] 错误类型=TTS API超时, "
@@ -626,9 +749,7 @@ class AIService:
                     f"模型={settings.tts_model}, "
                     f"音色={voice}, "
                     f"文本长度={len(text)}, "
-                    f"文本内容={text[:100]}... (前100字符), "
-                    f"chunk计数={chunk_count}, "
-                    f"总音频大小={total_audio_size} bytes"
+                    f"文本内容={text[:100]}... (前100字符)"
                 )
                 raise Exception(error_msg)
                 
@@ -735,15 +856,16 @@ class AIService:
                 self._healthcheck_tasks.add("tts")
                 # 执行简单的健康检查:发送一个极短的测试TTS请求
                 def health_check():
-                    response = dashscope.MultiModalConversation.call(
+                    # 使用 SpeechSynthesizer 进行健康检查
+                    synthesizer = SpeechSynthesizer(
                         model=settings.tts_model,
-                        text="Hi",  # 极短的测试文本
                         voice=settings.tts_voice,
-                        stream=True,
+                        speech_rate=settings.tts_speech_rate,
+                        pitch_rate=settings.tts_pitch_rate,
+                        language_hints=settings.tts_language_hints,
                     )
-                    # 只接收首块即停止
-                    for _ in response:
-                        break
+                    # 发送极短文本进行测试
+                    audio = synthesizer.call("Hi")
                     return True
                 
                 await asyncio.to_thread(health_check)
@@ -800,6 +922,29 @@ class AIService:
         # 所有情感统一使用配置文件中的音色
         return settings.tts_voice
     
+    def _build_emotion_instruction(self, emotion: EmotionType) -> str:
+        """根据情感构建 instruction 参数(用于 cosyvoice-v3)"""
+        # 🔥 根据官方文档,instruction 格式应为英文,且情感名称需小写
+        # 暂时返回空字符串,避免参数错误(428),后续可根据需要启用
+        # emotion_map = {
+        #     "neutral": "You are having a casual conversation, your emotion is neutral.",
+        #     "happy": "You are having a casual conversation, your emotion is happy.",
+        #     "sad": "You are having a casual conversation, your emotion is sad.",
+        #     "excited": "You are having a casual conversation, your emotion is excited.",
+        #     "angry": "You are having a casual conversation, your emotion is angry.",
+        #     "surprised": "You are having a casual conversation, your emotion is surprised.",
+        #     "thoughtful": "You are having a casual conversation, your emotion is thoughtful.",
+        #     "encouraging": "You are having a casual conversation, your emotion is encouraging.",
+        #     "confused": "You are having a casual conversation, your emotion is confused.",
+        #     "worried": "You are having a casual conversation, your emotion is worried.",
+        #     "satisfied": "You are having a casual conversation, your emotion is satisfied.",
+        #     "friendly": "You are having a casual conversation, your emotion is friendly.",
+        #     "sorry": "You are having a casual conversation, your emotion is sorry.",
+        #     "fearful": "You are having a casual conversation, your emotion is fearful.",
+        # }
+        # return emotion_map.get(emotion, emotion_map["neutral"])
+        return ""  # 暂时禁用 instruction,避免 428 错误
+    
     def _build_system_prompt(self) -> str:
         """构建System Prompt"""
         learner_proficiency = settings.LEARNER_PROFICIENCY

+ 5 - 3
backend/config.py

@@ -82,10 +82,12 @@ class Settings(BaseSettings):
     STT_LANGUAGE: str = os.getenv("STT_LANGUAGE", "zh")
     
     # TTS配置
-    tts_model: str = os.getenv("TTS_MODEL", "qwen3-tts-flash")
+    tts_model: str = os.getenv("TTS_MODEL", "cosyvoice-v3-flash")
     TTS_API_URL: str = os.getenv("TTS_API_URL", "")
-    tts_voice: str = os.getenv("TTS_VOICE", "Cherry")  # 女声(支持:Cherry, Stella)
-    tts_speech_rate: float = float(os.getenv("TTS_SPEECH_RATE", "1.1"))  # 语速:1.1 = 10%加速(优化延迟)
+    tts_voice: str = os.getenv("TTS_VOICE", "longanhuan")  # cosyvoice-v3 音色(支持:longanhuan, longanyang 等)
+    tts_speech_rate: float = float(os.getenv("TTS_SPEECH_RATE", "1.0"))  # 语速:1.0 = 正常速度
+    tts_pitch_rate: float = float(os.getenv("TTS_PITCH_RATE", "1.15"))  # 音调:1.15 = 提升15%
+    tts_language_hints: list = os.getenv("TTS_LANGUAGE_HINTS", "en").split(",") if os.getenv("TTS_LANGUAGE_HINTS") else ["en"]  # 语言提示
     
     # TTS重试配置(处理API速率限制)
     TTS_MAX_RETRIES: int = int(os.getenv("TTS_MAX_RETRIES", "3"))  # 最大重试次数

+ 5 - 3
backend/文档/ENV_CONFIG.md

@@ -17,10 +17,12 @@
 | `STT_LANGUAGE` | `zh` | STT 目标语种(`zh`/`en`/`ja`/`ko`/`auto`),用于 DashScope 识别参数。 |
 | `LLM_MODEL` | `qwen-flash` | LLM 模型名称,传递给 DashScope 兼容模式 Chat Completions API。 |
 | `LLM_BASE_URL` | *(空)* | LLM HTTP 调用地址,留空则自动根据 `USE_DASHSCOPE_INTL` 选择 `compatible-mode/v1` 域名。 |
-| `TTS_MODEL` | `qwen3-tts-flash` | TTS 模型名称,传递给 DashScope `MultiModalConversation`。 |
+| `TTS_MODEL` | `cosyvoice-v3-flash` | TTS 模型名称,使用 DashScope `SpeechSynthesizer` (tts_v2)。支持 `cosyvoice-v3-flash`、`cosyvoice-v3-plus`、`cosyvoice-v2` 等。 |
 | `TTS_API_URL` | *(空)* | 自定义 TTS HTTP 调用地址,留空则使用 `https://dashscope(或 dashscope-intl).aliyuncs.com/api/v1`。 |
-| `TTS_VOICE` | `Cherry` | TTS 音色(如 `Cherry`/`Stella`),用于生成目标发音。 |
-| `TTS_SPEECH_RATE` | `1.1` | TTS 语速,1.0 为标准,>1 加速,<1 减速。 |
+| `TTS_VOICE` | `longanhuan` | TTS 音色,cosyvoice-v3 系列使用 `longanhuan`、`longanyang` 等音色。cosyvoice-v2 使用 `longxiaochun_v2` 等音色。 |
+| `TTS_SPEECH_RATE` | `1.0` | TTS 语速,1.0 为标准,>1 加速,<1 减速。 |
+| `TTS_PITCH_RATE` | `1.15` | TTS 音调,1.0 为标准,>1 提升,<1 降低。 |
+| `TTS_LANGUAGE_HINTS` | `en` | TTS 语言提示(逗号分隔),如 `en`、`zh` 等,用于多语言场景。 |
 | `TTS_MIN_INTERVAL` | `0.8` | 两次 TTS 调用之间的最小时长(秒),避免触发限流。 |
 | `TTS_MAX_RETRIES` | `3` | 单次 TTS 调用的最大重试次数。 |
 | `TTS_RETRY_DELAY` | `5.0` | TTS 初次重试等待时间(秒)。 |

+ 129 - 0
backend/文档/TTS流式优化说明.md

@@ -0,0 +1,129 @@
+# TTS 流式优化说明
+
+## 优化概述
+
+根据[阿里云 CosyVoice Python SDK 官方文档](https://help.aliyun.com/zh/model-studio/cosyvoice-python-sdk),将 TTS 实现从同步调用改为**真正的流式调用**,大幅降低延迟并提升实时性。
+
+## 主要优化点
+
+### 1. 使用 `streaming_call()` 替代 `call()`
+
+**优化前**:
+- 使用 `synthesizer.call(text)` 同步调用
+- 等待完整音频返回后手动分块
+- 首包延迟 = 完整合成时间(通常 1-2 秒)
+
+**优化后**:
+- 使用 `synthesizer.streaming_call(text)` 流式调用
+- 通过回调函数实时接收音频数据
+- 首包延迟 ≈ 500ms(官方文档标准)
+
+### 2. 必须调用 `streaming_complete()`
+
+根据官方文档,**必须调用 `streaming_complete()` 方法**,否则缓存中的结尾部分文本可能无法被合成为语音。
+
+```python
+synthesizer.streaming_call(text)
+synthesizer.streaming_complete()  # 🔥 必须调用
+```
+
+### 3. 实现回调类 `ResultCallback`
+
+创建 `TTSStreamCallback` 类继承 `ResultCallback`,实现以下回调方法:
+
+| 方法 | 说明 |
+|------|------|
+| `on_open()` | 连接建立时调用 |
+| `on_data(data: bytes)` | 接收音频数据时调用(MP3格式) |
+| `on_complete()` | 流式合成完成时调用 |
+| `on_error(message: str)` | 发生错误时调用 |
+| `on_close()` | 连接关闭时调用 |
+
+### 4. MP3 流式数据处理优化
+
+**挑战**:MP3 是压缩格式,需要累积足够数据才能解码。
+
+**策略**:
+- 累积 MP3 数据到最小解码大小(4KB)
+- 尝试解码并转换为 PCM(24kHz, 16bit, Mono)
+- 解码成功后立即发送 PCM 数据块
+- 解码失败则继续累积(数据不完整)
+
+**实现**:
+```python
+def _process_mp3_buffer(self):
+    """处理累积的MP3数据,转换为PCM并发送"""
+    buffer_size = self.mp3_buffer.tell()
+    if buffer_size < self.mp3_min_size:  # 4KB
+        return  # 数据不足,继续累积
+    
+    # 解码MP3 → PCM
+    audio_segment = AudioSegment.from_mp3(self.mp3_buffer)
+    audio_segment = audio_segment.set_frame_rate(24000).set_channels(1).set_sample_width(2)
+    pcm_data = audio_segment.raw_data
+    
+    # 分块发送(3200字节/块)
+    for i in range(0, len(pcm_data), 3200):
+        chunk = pcm_data[i:i + 3200]
+        self.queue.put(('data', chunk))
+```
+
+## 性能对比
+
+| 指标 | 优化前(同步调用) | 优化后(流式调用) |
+|------|------------------|------------------|
+| 首包延迟 | 1-2 秒 | ~500ms |
+| 实时性 | 低(需等待完整音频) | 高(边合成边传输) |
+| 用户体验 | 有明显等待感 | 几乎无感知延迟 |
+
+## 代码变更
+
+### 文件:`backend/ai_service.py`
+
+1. **导入更新**:
+```python
+from dashscope.audio.tts_v2 import SpeechSynthesizer, ResultCallback
+```
+
+2. **新增回调类**:
+```python
+class TTSStreamCallback(ResultCallback):
+    """TTS流式回调处理器"""
+    # 实现 on_open, on_data, on_complete, on_error, on_close
+```
+
+3. **流式调用实现**:
+```python
+synthesizer = SpeechSynthesizer(
+    model=settings.tts_model,
+    voice=voice,
+    callback=callback,  # 🔥 传入回调
+)
+synthesizer.streaming_call(text)
+synthesizer.streaming_complete()  # 🔥 必须调用
+```
+
+## 注意事项
+
+1. **文本长度限制**:
+   - 单次发送文本 ≤ 2000 字符
+   - 累计发送文本 ≤ 20 万字符
+
+2. **字符计算规则**:
+   - 汉字(简/繁/日/韩)按 2 字符计算
+   - 其他字符按 1 字符计算
+
+3. **MP3 解码策略**:
+   - 最小累积大小:4KB
+   - 解码失败时继续累积(正常现象)
+   - 在 `on_complete()` 中处理剩余数据
+
+4. **错误处理**:
+   - 回调函数中的异常会通过队列传递
+   - 确保所有异常都被正确捕获和记录
+
+## 参考文档
+
+- [CosyVoice Python SDK 官方文档](https://help.aliyun.com/zh/model-studio/cosyvoice-python-sdk)
+- DashScope SDK 版本要求:≥ 1.23.4(支持 SSML)
+

+ 105 - 0
backend/文档/生产环境ffmpeg安装指南.md

@@ -0,0 +1,105 @@
+# 生产环境 ffmpeg 安装指南
+
+## CentOS/RHEL/Alibaba Cloud Linux 安装 ffmpeg
+
+### 方法 1:使用 EPEL 仓库(推荐)
+
+```bash
+# 1. 安装 EPEL 仓库
+sudo yum install -y epel-release
+
+# 2. 安装 ffmpeg
+sudo yum install -y ffmpeg
+
+# 3. 验证安装
+ffmpeg -version
+ffprobe -version
+```
+
+### 方法 2:使用 RPM Fusion 仓库
+
+如果 EPEL 中没有 ffmpeg,使用 RPM Fusion:
+
+```bash
+# 1. 安装 RPM Fusion 仓库
+sudo yum install -y https://download1.rpmfusion.org/free/el/rpmfusion-free-release-8.noarch.rpm
+sudo yum install -y https://download1.rpmfusion.org/nonfree/el/rpmfusion-nonfree-release-8.noarch.rpm
+
+# 2. 安装 ffmpeg
+sudo yum install -y ffmpeg
+
+# 3. 验证安装
+ffmpeg -version
+```
+
+### 方法 3:Alibaba Cloud Linux 专用方案
+
+对于 Alibaba Cloud Linux 3,可能需要:
+
+```bash
+# 1. 启用 EPEL
+sudo yum install -y epel-release
+
+# 2. 如果还是找不到,尝试安装 ffmpeg-free(EPEL 中的包名)
+sudo yum install -y ffmpeg-free
+
+# 3. 或者使用 RPM Fusion(见方法2)
+```
+
+### 方法 4:使用静态编译版本(备选)
+
+如果以上方法都不行,可以下载预编译的静态版本:
+
+```bash
+# 下载 ffmpeg 静态编译版本
+cd /tmp
+wget https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
+tar xf ffmpeg-release-amd64-static.tar.xz
+cd ffmpeg-*-amd64-static
+
+# 复制到系统路径
+sudo cp ffmpeg ffprobe /usr/local/bin/
+sudo chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe
+
+# 验证
+ffmpeg -version
+```
+
+### 验证安装
+
+安装完成后,验证:
+
+```bash
+# 检查 ffmpeg
+ffmpeg -version
+
+# 检查 ffprobe
+ffprobe -version
+
+# 测试 MP3 解码(可选)
+echo "test" | ffmpeg -f lavfi -i testsrc=duration=1:size=320x240:rate=1 -f mp3 - > /dev/null 2>&1 && echo "✅ ffmpeg 工作正常"
+```
+
+### 常见问题
+
+#### Q: EPEL 仓库安装失败?
+A: 检查网络连接,或手动下载 EPEL 安装包:
+```bash
+wget https://dl.fedoraproject.org/pub/epel/epel-release-latest-8.noarch.rpm
+sudo rpm -ivh epel-release-latest-8.noarch.rpm
+```
+
+#### Q: 安装后还是找不到 ffmpeg?
+A: 检查 PATH:
+```bash
+which ffmpeg
+# 如果找不到,可能需要:
+export PATH=$PATH:/usr/local/bin
+```
+
+#### Q: 权限问题?
+A: 确保使用 sudo 安装,或检查文件权限:
+```bash
+ls -l $(which ffmpeg)
+```
+

+ 53 - 0
backend/文档/生产环境部署依赖说明.md

@@ -0,0 +1,53 @@
+# 生产环境部署依赖说明
+
+## 系统依赖
+
+### ffmpeg(必需)
+
+**用途**:`pydub` 库依赖 `ffmpeg` 来解码 MP3 音频文件(cosyvoice-v3-flash 返回 MP3 格式,需要转换为 PCM)。
+
+**安装方法**:
+
+#### Ubuntu/Debian
+```bash
+sudo apt-get update
+sudo apt-get install -y ffmpeg
+```
+
+#### CentOS/RHEL
+```bash
+sudo yum install -y ffmpeg
+# 或使用 dnf (CentOS 8+)
+sudo dnf install -y ffmpeg
+```
+
+#### 验证安装
+```bash
+ffmpeg -version
+ffprobe -version
+```
+
+**错误提示**:
+如果未安装 ffmpeg,TTS 功能会报错:
+```
+[Errno 2] No such file or directory: 'ffprobe'
+Couldn't find ffprobe or avprobe
+```
+
+## Python 依赖
+
+所有 Python 依赖已在 `requirements.txt` 中列出,使用以下命令安装:
+
+```bash
+pip install -r requirements.txt
+```
+
+## 检查清单
+
+部署前请确认:
+
+- [ ] 已安装 `ffmpeg` 和 `ffprobe`
+- [ ] 已安装所有 Python 依赖(`pip install -r requirements.txt`)
+- [ ] 已配置 `.env` 文件(包含 TTS 相关配置)
+- [ ] API Key 已正确配置
+

+ 10 - 42
frontend/public/privacy-policy-modal.html

@@ -14,60 +14,43 @@
         }
         
         body {
-            /* background-color: #34313D; */
             color: #FFFFFF;
-            padding: 16px;
+            padding: 0;
         }
         
         .policy-container {
             max-width: 100%;
             margin: 0 auto;
-            background-color: transparent;
-            border-radius: 0;
-            box-shadow: none;
-            padding: 0;
+            padding: 16px 0;
         }
         
         .header {
             text-align: center;
             margin-bottom: 40px;
-            /* border-bottom: 1px solid rgba(255, 255, 255, 0.2); */
             padding-bottom: 30px;
         }
         
         .header h1 {
             font-size: 28px;
             color: #FFFFFF;
+            font-weight: 700;
             margin-bottom: 10px;
         }
         
         .date {
-            /* color: rgba(255, 255, 255, 0.6); */
             font-size: 14px;
             margin-bottom: 20px;
         }
         
         .intro {
-            /* background-color: rgba(255, 255, 255, 0.1); */
-            padding: 20px;
-            border-radius: 6px;
             margin-bottom: 30px;
-            /* border-left: 4px solid #9159f0; */
         }
         
         .intro p {
             margin-bottom: 10px;
         }
         
-        .mark {
-            /* background-color: rgba(255, 193, 7, 0.3); */
-            padding: 2px 4px;
-            border-radius: 3px;
-            /* color: #FFC107; */
-        }
-        
         .bold {
-            font-weight: 700;
             color: #FFFFFF;
         }
         
@@ -78,9 +61,9 @@
         .chapter-title {
             font-size: 22px;
             color: #FFFFFF;
+            font-weight: 700;
             margin-bottom: 20px;
             padding-bottom: 10px;
-            /* border-bottom: 1px solid rgba(255, 255, 255, 0.2); */
         }
         
         .section {
@@ -90,12 +73,12 @@
         .section-title {
             font-size: 18px;
             color: #FFFFFF;
+            font-weight: 700;
             margin-bottom: 15px;
         }
         
         .content {
             font-size: 16px;
-            /* color: rgba(255, 255, 255, 0.9); */
             margin-bottom: 15px;
         }
         
@@ -129,31 +112,22 @@
         
         th {
             background-color: rgba(255, 255, 255, 0.1);
-            font-weight: 600;
+            font-weight: 700;
             color: #FFFFFF;
         }
         
         .highlight {
-            /* background-color: rgba(255, 193, 7, 0.2); */
-            padding: 15px;
-            border-radius: 6px;
-            /* border-left: 4px solid #FFC107; */
             margin: 20px 0;
         }
         
         .footer {
             margin-top: 40px;
             padding-top: 20px;
-            /* border-top: 1px solid rgba(255, 255, 255, 0.2); */
             font-size: 14px;
-            /* color: rgba(255, 255, 255, 0.6); */
             text-align: center;
         }
         
         .contact-info {
-            /* background-color: rgba(255, 255, 255, 0.1); */
-            padding: 20px;
-            border-radius: 6px;
             margin-top: 20px;
         }
         
@@ -162,14 +136,6 @@
         }
         
         @media (max-width: 768px) {
-            .policy-container {
-                padding: 0;
-            }
-            
-            body {
-                padding: 16px;
-            }
-            
             .header h1 {
                 font-size: 24px;
             }
@@ -195,11 +161,13 @@
             <div class="date">创建日期:2025年12月13日</div>
         </div>
         
-        <div class="intro">
-            <p><span class="bold">一、引言</span></p>
+        <div class="chapter">
+            <h2 class="chapter-title">一、引言</h2>
+            <div class="content">
             <p>欢迎您使用灵语网,灵语网由合肥灵语科技有限公司(以下简称我方)管理,灵语网对每一位用户的隐私权采取高度尊重且保护的态度。我方非常重视用户的个人信息和隐私保护,为同时给您提供更准确和更安全的互联网环境,我们依据《中华人民共和国网络安全法》《信息安全技术个人信息安全规范》(GB/T35273-2017)以及其他相关法律法规和技术规范,将"用户隐私"相关单独以本《隐私政策》的形式进行发布,明确了收集/使用/对外提供的原则,进一步阐述了关于您个人信息的相关权利。</p>
             <p>本政策适用于我们的所有产品及服务(包括但不限于灵语网产品内的所有课程,如地道发音、真人对话练习),本政策与您所使用的我们的产品与服务息息相关,您在下载、启动、浏览、注册、登录、使用灵语网的产品及服务时,我们将按照本政策的约定处理和保护您的个人信息。我们将尽量以简明、扼要的表述向您解释本政策所涉及的技术词汇,以便于您理解。</p>
             <p><span class="bold">您在使用灵语网的产品及服务时,灵语网可能会收集和使用您的相关信息。</span>为此,希望通过本《隐私政策》向您说明,灵语网如何取得、使用、分享、保存和管理这些信息。<span class="mark">请在使用/继续使用我们的各项产品与服务前,仔细阅读并充分理解本政策(尤其是加粗提示的内容),</span>并在需要时,按照本政策的指引,作出适当的选择。<span class="bold">如果您不同意本政策的内容,将可能导致我们的产品与服务无法正常运行,或者无法达到我们拟达到的服务效果,您应立即停止访问/使用我们的产品与服务。您使用或继续使用我们提供的产品与服务的行为,都表示您充分理解和同意本《隐私政策》(包括更新版本)的全部内容。</span></p>
+            </div>
         </div>
         
         <div class="chapter">

+ 9 - 42
frontend/public/user-agreement-modal.html

@@ -14,66 +14,48 @@
         }
         
         body {
-            /* background-color: #34313D; */
             color: #FFFFFF;
-            padding: 16px;
+            padding: 0;
         }
         
         .agreement-container {
             max-width: 100%;
             margin: 0 auto;
-            background-color: transparent;
-            border-radius: 0;
-            box-shadow: none;
-            padding: 0;
+            padding: 16px 0;
         }
         
         .header {
             text-align: center;
             margin-bottom: 40px;
-            /* border-bottom: 1px solid rgba(255, 255, 255, 0.2); */
             padding-bottom: 30px;
         }
         
         .header h1 {
             font-size: 28px;
             color: #FFFFFF;
+            font-weight: 700;
             margin-bottom: 10px;
         }
         
         .subtitle {
             font-size: 18px;
-            /* color: rgba(255, 255, 255, 0.8); */
             margin-bottom: 20px;
         }
         
         .date {
-            /* color: rgba(255, 255, 255, 0.6); */
             font-size: 14px;
             margin-bottom: 20px;
         }
         
         .intro {
-            /* background-color: rgba(255, 255, 255, 0.1); */
-            padding: 20px;
-            border-radius: 6px;
             margin-bottom: 30px;
-            /* border-left: 4px solid #9159f0; */
         }
         
         .intro p {
             margin-bottom: 10px;
         }
         
-        .mark {
-            /* background-color: rgba(255, 193, 7, 0.3); */
-            padding: 2px 4px;
-            border-radius: 3px;
-            /* color: #FFC107; */
-        }
-        
         .bold {
-            font-weight: 700;
             color: #FFFFFF;
         }
         
@@ -89,9 +71,9 @@
         .chapter-title {
             font-size: 22px;
             color: #FFFFFF;
+            font-weight: 700;
             margin-bottom: 20px;
             padding-bottom: 10px;
-            /* border-bottom: 1px solid rgba(255, 255, 255, 0.2); */
         }
         
         .section {
@@ -101,12 +83,12 @@
         .section-title {
             font-size: 18px;
             color: #FFFFFF;
+            font-weight: 700;
             margin-bottom: 15px;
         }
         
         .content {
             font-size: 16px;
-            /* color: rgba(255, 255, 255, 0.9); */
             margin-bottom: 15px;
         }
         
@@ -120,10 +102,6 @@
         }
         
         .highlight {
-            /* background-color: rgba(255, 193, 7, 0.2); */
-            padding: 15px;
-            border-radius: 6px;
-            /* border-left: 4px solid #FFC107; */
             margin: 20px 0;
         }
         
@@ -147,16 +125,11 @@
         .footer {
             margin-top: 40px;
             padding-top: 20px;
-            /* border-top: 1px solid rgba(255, 255, 255, 0.2); */
             font-size: 14px;
-            /* color: rgba(255, 255, 255, 0.6); */
             text-align: center;
         }
         
         .contact-info {
-            /* background-color: rgba(255, 255, 255, 0.1); */
-            padding: 20px;
-            border-radius: 6px;
             margin-top: 20px;
         }
         
@@ -165,14 +138,6 @@
         }
         
         @media (max-width: 768px) {
-            .agreement-container {
-                padding: 0;
-            }
-            
-            body {
-                padding: 16px;
-            }
-            
             .header h1 {
                 font-size: 24px;
             }
@@ -199,8 +164,9 @@
             <div class="date">创建日期:2025年12月13日</div>
         </div>
         
-        <div class="intro">
-            <p><span class="bold">一、提示条款</span></p>
+        <div class="chapter">
+            <h2 class="chapter-title">一、提示条款</h2>
+            <div class="content">
             <p>1.1 我方(定义详见下文定义部分)同意按照本用户协议(以下称"本协议")的规定及其不时发布的操作规则提供基于互联网以及移动网的相关服务(以下称"服务"),为获得服务,服务使用人(以下称"用户"或"您")应当同意本协议的全部条款并按照页面上的提示完成全部的注册程序。</p>
             <p><span class="bold">1.2 请务必审慎阅读、充分理解本协议各条款内容,请注意免除或限制我方责任的条款、权利许可和信息使用的条款、法律适用和争议解决的条款等。为使容易辨析,<span class="mark">免除或限制我方责任的条款已以加粗的形式提示用户注意。</span>本协议是注册程序中不可跳过的部分,我方预留有合理的时间阅读本协议,<span class="bold">用户点击"同意"按钮即表示用户接受本协议条款。如果用户不同意本协议或其中任何条款约定,应立即停止使用行为(包括下载、安装、启动、浏览、激活、注册、登录、使用、订阅、付费购买等行为)。</span></span></p>
             <p>1.3 灵语产品与服务的官方获取渠道为官网(https://www.lyrria.com)。通过官方渠道和指定方式启动为取得灵语产品与服务的唯一合法方式。您和您孩子通过其他任何未经我方合法授权的渠道、途径、方式获取的灵语产品,有可能为侵权、仿冒的产品,我方将不承认其合法性,且一经发现,我方有权拒绝对该等非官方渠道获取的产品的终端用户提供服务,任何因此导致的一切不利后果均由您自行承担。若您无法判断特定渠道是否为我方的官方渠道或某款产品是否为官方正版产品,您可通过本协议第13条所述方式与我司联系进行确认。</p>
@@ -208,6 +174,7 @@
             <p><span class="bold">1.5 若用户为未满18周岁的未成年人,应在监护人的监护、指导并获得监护人明示同意后使用本服务。未成年人的任何注册、登录、使用行为,均视为已获得其监护人的同意。监护人有责任引导未成年人正确使用本服务。</span></p>
             <p>1.6 用户注册成功后,<span class="bold">我方将给予每个用户一个用户账号及相应的密码,该用户账号和密码由用户负责保管,仅限用户个人使用;用户应当对其用户账号进行的所有活动和事件负法律责任;</span>用户注册成功,我方将会向每位注册用户通过电话、短信、客户端、绑定微信等方式进一步介绍本公司的相关服务情况。</p>
             <p>1.7 本协议内容同时包括附件<span class="underline">《个人信息保护政策》,</span>且您在使用灵语产品与服务某一特定功能时,该服务会另有单独的协议、相关业务规则等(以下统称为"单独协议")。单独协议与本协议一起,构成服务约定不可分割的整体,需要您与我方共同遵守。</p>
+            </div>
         </div>
         
         <div class="chapter">

+ 3 - 2
frontend/src/components/PrivacyPolicyModal.tsx

@@ -1,5 +1,5 @@
 import React, { useEffect } from 'react';
-import { px2vmin, px2vh } from '@/utils/viewport';
+import { px2vmin, px2vh, px2vw } from '@/utils/viewport';
 
 interface PrivacyPolicyModalProps {
   visible: boolean;
@@ -77,7 +77,8 @@ export const PrivacyPolicyModal: React.FC<PrivacyPolicyModalProps> = ({
           style={{
             flex: 1,
             width: '100%',
-            padding: 0,
+            paddingLeft: px2vw(32),
+            paddingRight: px2vw(32),
             overflow: 'hidden',
           }}
         >

+ 3 - 2
frontend/src/components/UserAgreementModal.tsx

@@ -1,5 +1,5 @@
 import React, { useEffect } from 'react';
-import { px2vmin, px2vh } from '@/utils/viewport';
+import { px2vmin, px2vh, px2vw } from '@/utils/viewport';
 
 interface UserAgreementModalProps {
   visible: boolean;
@@ -77,7 +77,8 @@ export const UserAgreementModal: React.FC<UserAgreementModalProps> = ({
           style={{
             flex: 1,
             width: '100%',
-            padding: 0,
+            paddingLeft: px2vw(32),
+            paddingRight: px2vw(32),
             overflow: 'hidden',
           }}
         >

+ 1 - 1
frontend/src/pages/BeginPage.tsx

@@ -10,7 +10,7 @@ import { px2vmin } from '@/utils/viewport';
 import { UserAgreementModal } from '@/components/UserAgreementModal';
 import { PrivacyPolicyModal } from '@/components/PrivacyPolicyModal';
 
-const lumiImage = getOssUrl('Lumi-image.png');
+const lumiImage = getOssUrl('01-Default.gif');
 
 // 判断是否为后端生成的默认用户名
 const isBackendGeneratedName = (name: string): boolean => /^User\d+$/.test(name.trim());

+ 40 - 38
frontend/src/pages/ChatBotPage.tsx

@@ -1081,29 +1081,25 @@ export const ChatBotPage = () => {
     };
   }, [currentState]);
 
-  // 监听录音状态,首次显示取消提示(3秒后自动隐藏)
+  // 监听录音状态,首次显示取消提示(延迟 2 秒出现)
   useEffect(() => {
+    let timer: number | undefined;
+
     if (currentState === 'recording') {
       const hasShownCancel = getStorage(STORAGE_KEYS.HAS_SHOWN_CANCEL_TOOLTIP);
       if (!hasShownCancel) {
-        setShowCancelTooltip(true);
-        setStorage(STORAGE_KEYS.HAS_SHOWN_CANCEL_TOOLTIP, 'true');
-        const userId = getUserId();
-        if (userId) {
-          updateUserUIState(userId, {
-            has_shown_cancel_tooltip: true,
-          }).catch(() => {
-            // 同步失败不影响主流程
-          });
-        }
-        
-        // 🔧 调试模式:注释掉自动隐藏,方便调试
-        // 3秒后自动隐藏
-        // const timer = setTimeout(() => {
-        //   setShowCancelTooltip(false);
-        // }, 3000);
-        
-        // return () => clearTimeout(timer);
+        timer = window.setTimeout(() => {
+          setShowCancelTooltip(true);
+          setStorage(STORAGE_KEYS.HAS_SHOWN_CANCEL_TOOLTIP, 'true');
+          const userId = getUserId();
+          if (userId) {
+            updateUserUIState(userId, {
+              has_shown_cancel_tooltip: true,
+            }).catch(() => {
+              // 同步失败不影响主流程
+            });
+          }
+        }, 2000);
       } else {
         // 已显示过,确保隐藏
         setShowCancelTooltip(false);
@@ -1112,31 +1108,32 @@ export const ChatBotPage = () => {
       // 非录音状态,隐藏提示
       setShowCancelTooltip(false);
     }
+    return () => {
+      if (timer !== undefined) {
+        window.clearTimeout(timer);
+      }
+    };
   }, [currentState]);
 
-  // 监听录音状态,首次显示发送提示(3秒后自动隐藏)
+  // 监听录音状态,首次显示发送提示(延迟 2 秒出现)
   useEffect(() => {
+    let timer: number | undefined;
+
     if (currentState === 'recording') {
       const hasShownSend = getStorage(STORAGE_KEYS.HAS_SHOWN_SEND_TOOLTIP);
       if (!hasShownSend) {
-        setShowSendTooltip(true);
-        setStorage(STORAGE_KEYS.HAS_SHOWN_SEND_TOOLTIP, 'true');
-        const userId = getUserId();
-        if (userId) {
-          updateUserUIState(userId, {
-            has_shown_send_tooltip: true,
-          }).catch(() => {
-            // 同步失败不影响主流程
-          });
-        }
-        
-        // 🔧 调试模式:注释掉自动隐藏,方便调试
-        // 3秒后自动隐藏
-        // const timer = setTimeout(() => {
-        //   setShowSendTooltip(false);
-        // }, 3000);
-        
-        // return () => clearTimeout(timer);
+        timer = window.setTimeout(() => {
+          setShowSendTooltip(true);
+          setStorage(STORAGE_KEYS.HAS_SHOWN_SEND_TOOLTIP, 'true');
+          const userId = getUserId();
+          if (userId) {
+            updateUserUIState(userId, {
+              has_shown_send_tooltip: true,
+            }).catch(() => {
+              // 同步失败不影响主流程
+            });
+          }
+        }, 2000);
       } else {
         // 已显示过,确保隐藏
         setShowSendTooltip(false);
@@ -1145,6 +1142,11 @@ export const ChatBotPage = () => {
       // 非录音状态,隐藏提示
       setShowSendTooltip(false);
     }
+    return () => {
+      if (timer !== undefined) {
+        window.clearTimeout(timer);
+      }
+    };
   }, [currentState]);
 
   const dismissProgressBarTooltip = useCallback(() => {

+ 1 - 1
frontend/src/pages/FirstLoginRewardPage.tsx

@@ -17,7 +17,7 @@ import { getOssUrl } from '@/utils/oss';
 
 const backButton = getOssUrl('backbutn.png');
 // const shareProgressBar = getOssUrl('share-progress-bar.png'); // Unused
-const avatarAnimaZone = getOssUrl('Lumi-cap.png');
+const avatarAnimaZone = getOssUrl('Avatar-anima-zone-cap.png');
 const shareBtn = getOssUrl('share-btn.png');
 const closeBtn = getOssUrl('close-btn.png');
 // const wechatIcon = getOssUrl('wechat.png'); // 已注释,未使用

+ 1 - 1
frontend/src/pages/ShareSuccessPage.tsx

@@ -6,7 +6,7 @@ import { px2vmin } from '@/utils/viewport';
 import { getOssUrl } from '@/utils/oss';
 
 // const fullProgressBar = getOssUrl('full-progress-bar.png'); // Unused
-const avatarAnimaZone = getOssUrl('Lumi-cap.png');
+const avatarAnimaZone = getOssUrl('Avatar-anima-zone-cap.png');
 const continueChatBtn = getOssUrl('continue-chat.png');
 
 // 新年背景图片 ID

+ 5 - 0
项目文档/小进度值异常阴影修复方案.md

@@ -152,3 +152,8 @@ if (progress > 0) {
 
 
 
+
+
+
+
+