zlwu hai 11 meses
pai
achega
f6e1d47043

+ 7 - 0
backend/ENV_CONFIG.md

@@ -0,0 +1,7 @@
+| 变量名 | 默认值 | 说明 |
+| --- | --- | --- |
+| `MYSQL_CONNECT_TIMEOUT` | `10` | MySQL 连接超时时间(秒),防止初始握手阻塞。 |
+| `MYSQL_READ_TIMEOUT` | `10` | ⚠️ 当前未使用:`aiomysql` 不支持此参数,已从连接池配置中移除。保留配置项以便未来兼容。 |
+| `MYSQL_POOL_RECYCLE` | `7200` | MySQL 连接池自动回收间隔(秒),保持长连接,小于服务器 `wait_timeout` 可避免闲置断连。 |
+| `MYSQL_HEALTHCHECK_INTERVAL` | `600` | MySQL 连接池健康检查间隔(秒),定期 `PING` 保持连接活跃,仅在连接断开时重建。 |
+

+ 6 - 4
backend/ai_service.py

@@ -51,18 +51,20 @@ class AIService:
                 env_backup[var] = os.environ[var]
                 env_backup[var] = os.environ[var]
                 del os.environ[var]
                 del os.environ[var]
         
         
-        # 🔥 优化:创建高性能 HTTP 客户端 - 增加连接池配置
+        # 🔥 优化:创建高性能 HTTP 客户端 - 连接池配置
+        # 注意:连接池不会阻塞新请求,每次 API 调用都是独立的 HTTP 请求
+        # 连接池只是复用 TCP 连接,提高性能,不会影响随时建立新请求的能力
         http_client = httpx.Client(
         http_client = httpx.Client(
             proxy=None,  # 禁用代理
             proxy=None,  # 禁用代理
             timeout=60.0,
             timeout=60.0,
             trust_env=False,  # 不信任环境变量中的代理设置
             trust_env=False,  # 不信任环境变量中的代理设置
-            # 连接池优化
+            # 连接池优化:复用 TCP 连接,但每次 API 调用都是独立请求
             limits=httpx.Limits(
             limits=httpx.Limits(
                 max_keepalive_connections=20,  # 增加keepalive连接数(默认10)
                 max_keepalive_connections=20,  # 增加keepalive连接数(默认10)
-                max_connections=50,  # 增加总连接数(默认20)
+                max_connections=50,  # 增加总连接数(默认20),支持并发请求
                 keepalive_expiry=30.0,  # keepalive过期时间30秒
                 keepalive_expiry=30.0,  # keepalive过期时间30秒
             ),
             ),
-            # HTTP/2支持 - 多路复用,减少连接开销
+            # HTTP/2支持 - 多路复用,减少连接开销,支持并发请求
             http2=True,
             http2=True,
         )
         )
         
         

+ 4 - 0
backend/config.py

@@ -97,6 +97,10 @@ class Settings(BaseSettings):
     MYSQL_DATABASE: str = os.getenv("MYSQL_DATABASE", "Lyrria")
     MYSQL_DATABASE: str = os.getenv("MYSQL_DATABASE", "Lyrria")
     MYSQL_POOL_SIZE: int = int(os.getenv("MYSQL_POOL_SIZE", "10"))
     MYSQL_POOL_SIZE: int = int(os.getenv("MYSQL_POOL_SIZE", "10"))
     MYSQL_MAX_OVERFLOW: int = int(os.getenv("MYSQL_MAX_OVERFLOW", "20"))
     MYSQL_MAX_OVERFLOW: int = int(os.getenv("MYSQL_MAX_OVERFLOW", "20"))
+    MYSQL_CONNECT_TIMEOUT: int = int(os.getenv("MYSQL_CONNECT_TIMEOUT", "10"))
+    MYSQL_READ_TIMEOUT: int = int(os.getenv("MYSQL_READ_TIMEOUT", "10"))
+    MYSQL_POOL_RECYCLE: int = int(os.getenv("MYSQL_POOL_RECYCLE", "7200"))  # 🔥 增加到2小时,保持长连接
+    MYSQL_HEALTHCHECK_INTERVAL: int = int(os.getenv("MYSQL_HEALTHCHECK_INTERVAL", "600"))  # 🔥 增加到10分钟,减少检查频率
     
     
     # Redis配置
     # Redis配置
     REDIS_HOST: str = os.getenv("REDIS_HOST", "47.99.164.66")
     REDIS_HOST: str = os.getenv("REDIS_HOST", "47.99.164.66")

+ 120 - 27
backend/database.py

@@ -1,15 +1,28 @@
 """
 """
 数据库连接管理模块
 数据库连接管理模块
 """
 """
+import asyncio
 import aiomysql
 import aiomysql
 import redis.asyncio as aioredis
 import redis.asyncio as aioredis
-from typing import Optional
+from typing import Optional, Callable, Awaitable
 from contextlib import asynccontextmanager
 from contextlib import asynccontextmanager
 from config import settings
 from config import settings
 import logging
 import logging
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
+MYSQL_RETRY_EXCEPTIONS = (
+    aiomysql.OperationalError,
+    aiomysql.InterfaceError,
+    aiomysql.InternalError,
+    ConnectionError,
+    ConnectionResetError,
+    ConnectionAbortedError,
+    BrokenPipeError,
+    TimeoutError,
+    asyncio.TimeoutError,
+)
+
 
 
 class DatabaseManager:
 class DatabaseManager:
     """数据库连接管理器(MySQL + Redis)"""
     """数据库连接管理器(MySQL + Redis)"""
@@ -17,11 +30,26 @@ class DatabaseManager:
     def __init__(self):
     def __init__(self):
         self.mysql_pool: Optional[aiomysql.Pool] = None
         self.mysql_pool: Optional[aiomysql.Pool] = None
         self.redis_client: Optional[aioredis.Redis] = None
         self.redis_client: Optional[aioredis.Redis] = None
+        self._mysql_lock = asyncio.Lock()
+        self._redis_lock = asyncio.Lock()
+        self._last_mysql_healthcheck: float = 0.0
     
     
     async def connect(self):
     async def connect(self):
         """初始化数据库连接"""
         """初始化数据库连接"""
         try:
         try:
-            # 连接MySQL
+            await self._create_mysql_pool()
+            await self._create_redis_client()
+        except Exception as e:
+            logger.error(f"❌ 数据库连接失败: {e}")
+            raise
+    
+    async def _create_mysql_pool(self):
+        async with self._mysql_lock:
+            if self.mysql_pool:
+                self.mysql_pool.close()
+                await self.mysql_pool.wait_closed()
+                self.mysql_pool = None
+            
             self.mysql_pool = await aiomysql.create_pool(
             self.mysql_pool = await aiomysql.create_pool(
                 host=settings.MYSQL_HOST,
                 host=settings.MYSQL_HOST,
                 port=settings.MYSQL_PORT,
                 port=settings.MYSQL_PORT,
@@ -32,33 +60,43 @@ class DatabaseManager:
                 minsize=5,
                 minsize=5,
                 maxsize=settings.MYSQL_POOL_SIZE,
                 maxsize=settings.MYSQL_POOL_SIZE,
                 autocommit=True,
                 autocommit=True,
-                echo=False
+                echo=False,
+                connect_timeout=settings.MYSQL_CONNECT_TIMEOUT,
+                # 注意:aiomysql 不支持 read_timeout 参数,已移除
+                pool_recycle=settings.MYSQL_POOL_RECYCLE,
+            )
+            self._last_mysql_healthcheck = 0.0
+            logger.info(
+                f"✅ MySQL连接池已创建: {settings.MYSQL_HOST}:{settings.MYSQL_PORT}/{settings.MYSQL_DATABASE}"
             )
             )
-            logger.info(f"✅ MySQL连接池已创建: {settings.MYSQL_HOST}:{settings.MYSQL_PORT}/{settings.MYSQL_DATABASE}")
+    
+    async def _create_redis_client(self):
+        async with self._redis_lock:
+            if self.redis_client:
+                await self.redis_client.close()
+                self.redis_client = None
             
             
-            # 连接Redis
-            # 根据SSL配置选择协议(rediss:// 或 redis://)
             redis_protocol = "rediss" if settings.REDIS_SSL else "redis"
             redis_protocol = "rediss" if settings.REDIS_SSL else "redis"
-            redis_url = f"{redis_protocol}://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:{settings.REDIS_PORT}/{settings.REDIS_DB}"
+            redis_url = (
+                f"{redis_protocol}://:{settings.REDIS_PASSWORD}@"
+                f"{settings.REDIS_HOST}:{settings.REDIS_PORT}/{settings.REDIS_DB}"
+            )
             
             
             self.redis_client = await aioredis.from_url(
             self.redis_client = await aioredis.from_url(
                 redis_url,
                 redis_url,
                 encoding="utf-8",
                 encoding="utf-8",
                 decode_responses=True,
                 decode_responses=True,
                 max_connections=settings.REDIS_MAX_CONNECTIONS,
                 max_connections=settings.REDIS_MAX_CONNECTIONS,
-                socket_connect_timeout=10,  # 增加连接超时到10秒
-                socket_timeout=10,  # 增加操作超时到10秒
-                retry_on_timeout=True,  # 超时自动重试
-                health_check_interval=30  # 健康检查间隔30秒
+                socket_connect_timeout=10,
+                socket_timeout=30,  # 🔥 增加超时时间,避免短时网络波动断开连接
+                retry_on_timeout=True,
+                health_check_interval=30,
+                socket_keepalive=True,  # 🔥 启用 TCP keepalive,保持长连接(Redis 3.2+ 默认启用)
             )
             )
-            
-            # 测试Redis连接
             await self.redis_client.ping()
             await self.redis_client.ping()
-            logger.info(f"✅ Redis连接成功: {settings.REDIS_HOST}:{settings.REDIS_PORT}/{settings.REDIS_DB}")
-            
-        except Exception as e:
-            logger.error(f"❌ 数据库连接失败: {e}")
-            raise
+            logger.info(
+                f"✅ Redis连接成功: {settings.REDIS_HOST}:{settings.REDIS_PORT}/{settings.REDIS_DB}"
+            )
     
     
     async def disconnect(self):
     async def disconnect(self):
         """关闭数据库连接"""
         """关闭数据库连接"""
@@ -66,18 +104,58 @@ class DatabaseManager:
             if self.mysql_pool:
             if self.mysql_pool:
                 self.mysql_pool.close()
                 self.mysql_pool.close()
                 await self.mysql_pool.wait_closed()
                 await self.mysql_pool.wait_closed()
+                self.mysql_pool = None
                 logger.info("✅ MySQL连接池已关闭")
                 logger.info("✅ MySQL连接池已关闭")
             
             
             if self.redis_client:
             if self.redis_client:
                 await self.redis_client.close()
                 await self.redis_client.close()
+                self.redis_client = None
                 logger.info("✅ Redis连接已关闭")
                 logger.info("✅ Redis连接已关闭")
                 
                 
         except Exception as e:
         except Exception as e:
             logger.error(f"❌ 关闭数据库连接失败: {e}")
             logger.error(f"❌ 关闭数据库连接失败: {e}")
     
     
+    async def _ensure_mysql_pool(self):
+        """确保MySQL连接池可用(定期健康检查,保持长连接)"""
+        if not self.mysql_pool:
+            await self._create_mysql_pool()
+            return
+        
+        loop = asyncio.get_running_loop()
+        now = loop.time()
+        # 🔥 健康检查间隔:只在超过间隔时检查,避免频繁检查
+        if now - self._last_mysql_healthcheck < settings.MYSQL_HEALTHCHECK_INTERVAL:
+            return
+        
+        # 🔥 只做健康检查,不重建连接(保持长连接)
+        try:
+            async with self.mysql_pool.acquire() as conn:
+                await conn.ping()
+            self._last_mysql_healthcheck = now
+            # 健康检查成功,连接正常,不重建
+        except Exception as exc:
+            # 🔥 只有在健康检查失败时才重建连接(连接已断开)
+            logger.warning(f"⚠️ MySQL健康检查失败,连接可能已断开,准备重建: {exc}")
+            await self._create_mysql_pool()
+            self._last_mysql_healthcheck = loop.time()
+    
+    async def _run_with_retry(self, operation: Callable[[], Awaitable], description: str):
+        """执行MySQL操作并在连接异常时重试一次"""
+        for attempt in range(2):
+            try:
+                await self._ensure_mysql_pool()
+                return await operation()
+            except MYSQL_RETRY_EXCEPTIONS as exc:
+                if attempt == 1:
+                    logger.error(f"❌ {description} 失败,已重试: {exc}")
+                    raise
+                logger.warning(f"⚠️ {description} 连接异常,尝试重建连接: {exc}")
+                await self._create_mysql_pool()
+    
     @asynccontextmanager
     @asynccontextmanager
     async def get_mysql_connection(self):
     async def get_mysql_connection(self):
         """获取MySQL连接(上下文管理器)"""
         """获取MySQL连接(上下文管理器)"""
+        await self._ensure_mysql_pool()
         if not self.mysql_pool:
         if not self.mysql_pool:
             raise RuntimeError("MySQL连接池未初始化")
             raise RuntimeError("MySQL连接池未初始化")
         
         
@@ -87,21 +165,36 @@ class DatabaseManager:
     
     
     async def execute_query(self, query: str, params: dict = None):
     async def execute_query(self, query: str, params: dict = None):
         """执行查询(SELECT)"""
         """执行查询(SELECT)"""
-        async with self.get_mysql_connection() as cursor:
-            await cursor.execute(query, params or {})
-            return await cursor.fetchall()
+        params = params or {}
+        
+        async def _operation():
+            async with self.get_mysql_connection() as cursor:
+                await cursor.execute(query, params)
+                return await cursor.fetchall()
+        
+        return await self._run_with_retry(_operation, "执行查询")
     
     
     async def execute_one(self, query: str, params: dict = None):
     async def execute_one(self, query: str, params: dict = None):
         """执行查询并返回一条记录"""
         """执行查询并返回一条记录"""
-        async with self.get_mysql_connection() as cursor:
-            await cursor.execute(query, params or {})
-            return await cursor.fetchone()
+        params = params or {}
+        
+        async def _operation():
+            async with self.get_mysql_connection() as cursor:
+                await cursor.execute(query, params)
+                return await cursor.fetchone()
+        
+        return await self._run_with_retry(_operation, "执行单条查询")
     
     
     async def execute_update(self, query: str, params: dict = None):
     async def execute_update(self, query: str, params: dict = None):
         """执行更新(INSERT/UPDATE/DELETE)"""
         """执行更新(INSERT/UPDATE/DELETE)"""
-        async with self.get_mysql_connection() as cursor:
-            await cursor.execute(query, params or {})
-            return cursor.lastrowid
+        params = params or {}
+        
+        async def _operation():
+            async with self.get_mysql_connection() as cursor:
+                await cursor.execute(query, params)
+                return cursor.lastrowid
+        
+        return await self._run_with_retry(_operation, "执行更新")
     
     
     def get_redis(self) -> aioredis.Redis:
     def get_redis(self) -> aioredis.Redis:
         """获取Redis客户端"""
         """获取Redis客户端"""

+ 5 - 2
backend/stt_service.py

@@ -257,6 +257,9 @@ class STTSession:
         """
         """
         连接STT服务并初始化会话
         连接STT服务并初始化会话
         
         
+        🔥 每次请求都创建新的 WebSocket 会话,确保可以随时建立请求
+        不会因为连接复用而阻塞,每个 request_id 都有独立的会话
+        
         Returns:
         Returns:
             是否连接成功
             是否连接成功
         """
         """
@@ -266,7 +269,7 @@ class STTSession:
             # 设置API Key
             # 设置API Key
             dashscope.api_key = settings.active_dashscope_api_key
             dashscope.api_key = settings.active_dashscope_api_key
             
             
-            # 创建会话对象(参考官方demo)
+            # 🔥 创建新的会话对象(每次请求独立创建,参考官方demo)
             def create_conversation():
             def create_conversation():
                 ws_url = get_dashscope_ws_url()
                 ws_url = get_dashscope_ws_url()
                 # print(f"[{self.request_id}] STT WebSocket URL: {ws_url}")
                 # print(f"[{self.request_id}] STT WebSocket URL: {ws_url}")
@@ -280,7 +283,7 @@ class STTSession:
                 self.callback.conversation = conversation
                 self.callback.conversation = conversation
                 return conversation
                 return conversation
             
             
-            # 异步创建会话
+            # 异步创建会话(每次都是新连接)
             self.conversation = await asyncio.to_thread(create_conversation)
             self.conversation = await asyncio.to_thread(create_conversation)
             
             
             # 连接WebSocket
             # 连接WebSocket

+ 1 - 0
frontend/SSL证书配置.md

@@ -116,5 +116,6 @@ ssl/
 
 
 
 
 
 
+
 
 
 
 

+ 90 - 7
frontend/src/pages/ChatBotPage.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
 import { message as antMessage } from 'antd';
 import { message as antMessage } from 'antd';
 import { v4 as uuidv4 } from 'uuid';
 import { v4 as uuidv4 } from 'uuid';
 import { useAppStore } from '@/store';
 import { useAppStore } from '@/store';
@@ -27,6 +27,9 @@ import tooltipsCancel from '@/assets/tooltips-cancel.svg';
 import tooltipsSend from '@/assets/tooltips-send.svg';
 import tooltipsSend from '@/assets/tooltips-send.svg';
 import tooltipsProgressBar from '@/assets/tooltips-progress-bar.svg';
 import tooltipsProgressBar from '@/assets/tooltips-progress-bar.svg';
 
 
+const MAX_RECORDING_DURATION_MS = 60_000;
+const COUNTDOWN_THRESHOLD_MS = 10_000;
+
 const WS_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:8000/ws';
 const WS_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:8000/ws';
 
 
 export const ChatBotPage = () => {
 export const ChatBotPage = () => {
@@ -80,6 +83,11 @@ export const ChatBotPage = () => {
   const animationFrameRef = useRef<number>();
   const animationFrameRef = useRef<number>();
   const totalProgressRef = useRef(0);
   const totalProgressRef = useRef(0);
   const shouldSkipProgressAnimationRef = useRef(true);
   const shouldSkipProgressAnimationRef = useRef(true);
+
+  const recordingTimerRef = useRef<number | null>(null);
+  const recordingDeadlineRef = useRef<number>(0);
+  const handleSendRef = useRef<() => void>();
+  const [remainingTimeMs, setRemainingTimeMs] = useState<number | null>(null);
   
   
   // V2: 完全计算式布局系统
   // V2: 完全计算式布局系统
   const [layout, setLayout] = useState<CalculatedLayout>(() => {
   const [layout, setLayout] = useState<CalculatedLayout>(() => {
@@ -94,6 +102,51 @@ export const ChatBotPage = () => {
     emotionReceived: 0,
     emotionReceived: 0,
   });
   });
 
 
+  const stopRecordingTimer = useCallback((options?: { preserveCountdown?: boolean }) => {
+    if (recordingTimerRef.current) {
+      clearInterval(recordingTimerRef.current);
+      recordingTimerRef.current = null;
+    }
+    recordingDeadlineRef.current = 0;
+    if (!options?.preserveCountdown) {
+      setRemainingTimeMs(null);
+    }
+  }, []);
+
+  const startRecordingTimer = useCallback(() => {
+    stopRecordingTimer();
+    recordingDeadlineRef.current = Date.now() + MAX_RECORDING_DURATION_MS;
+    setRemainingTimeMs(MAX_RECORDING_DURATION_MS);
+    recordingTimerRef.current = window.setInterval(() => {
+      const remaining = recordingDeadlineRef.current - Date.now();
+      if (remaining <= 0) {
+        setRemainingTimeMs(0);
+        stopRecordingTimer({ preserveCountdown: true });
+        if (useAppStore.getState().currentState === 'recording') {
+          handleSendRef.current?.();
+        }
+        return;
+      }
+      setRemainingTimeMs(remaining);
+    }, 250);
+  }, [stopRecordingTimer]);
+
+  useEffect(() => {
+    return () => {
+      stopRecordingTimer();
+    };
+  }, [stopRecordingTimer]);
+
+  useEffect(() => {
+    if (currentState !== 'recording') {
+      stopRecordingTimer();
+    }
+  }, [currentState, stopRecordingTimer]);
+
+  const formatCountdown = (ms: number) => {
+    return `${Math.max(0, Math.ceil(ms / 1000))}s`;
+  };
+
   // 进度动画效果(支持多圈连续动画)
   // 进度动画效果(支持多圈连续动画)
   useEffect(() => {
   useEffect(() => {
     const targetTotal = progressLoopCount * 100 + conversationProgress;
     const targetTotal = progressLoopCount * 100 + conversationProgress;
@@ -530,6 +583,7 @@ export const ChatBotPage = () => {
         chunkIndexRef.current++;
         chunkIndexRef.current++;
       }
       }
     );
     );
+    startRecordingTimer();
   };
   };
 
 
   // 发送录音
   // 发送录音
@@ -537,6 +591,8 @@ export const ChatBotPage = () => {
     if (currentState !== 'recording' || !currentRequestId) {
     if (currentState !== 'recording' || !currentRequestId) {
       return;
       return;
     }
     }
+
+    stopRecordingTimer();
     
     
     if (!isConnected) {
     if (!isConnected) {
       antMessage.error('语音服务未连接,请重新录音发送');
       antMessage.error('语音服务未连接,请重新录音发送');
@@ -580,18 +636,28 @@ export const ChatBotPage = () => {
     }, 10000);
     }, 10000);
   };
   };
 
 
+  handleSendRef.current = handleSend;
+
   // 取消录音
   // 取消录音
   const handleCancel = () => {
   const handleCancel = () => {
     if (currentState !== 'recording' || !currentRequestId) return;
     if (currentState !== 'recording' || !currentRequestId) return;
 
 
     console.log(`[取消] [${currentRequestId.substring(0, 8)}] 取消录音`);
     console.log(`[取消] [${currentRequestId.substring(0, 8)}] 取消录音`);
     recorderRef.current?.stop();
     recorderRef.current?.stop();
+    stopRecordingTimer();
 
 
     wsRef.current?.sendCancel(currentRequestId);
     wsRef.current?.sendCancel(currentRequestId);
     performanceLogger.clearRequest(currentRequestId);
     performanceLogger.clearRequest(currentRequestId);
     reset();
     reset();
   };
   };
 
 
+  const actionButtonSize = Math.round(layout.recordButtonSize * 0.58);
+  const actionButtonTop = layout.progressRingTop + layout.recordButtonOffset;
+  const shouldShowCountdown =
+    currentState === 'recording' &&
+    remainingTimeMs !== null &&
+    remainingTimeMs <= COUNTDOWN_THRESHOLD_MS;
+
   return (
   return (
     <div 
     <div 
       className={`chatbot-container relative w-full ${
       className={`chatbot-container relative w-full ${
@@ -929,11 +995,11 @@ export const ChatBotPage = () => {
           className="chatbot-action-buttons absolute flex items-center justify-center cursor-pointer touch-manipulation"
           className="chatbot-action-buttons absolute flex items-center justify-center cursor-pointer touch-manipulation"
           onClick={handleCancel}
           onClick={handleCancel}
           style={{
           style={{
-            width: `${Math.round(layout.recordButtonSize * 0.58)}px`,
-            height: `${Math.round(layout.recordButtonSize * 0.58)}px`,
+            width: `${actionButtonSize}px`,
+            height: `${actionButtonSize}px`,
             minWidth: '44px',
             minWidth: '44px',
             minHeight: '44px',
             minHeight: '44px',
-            top: `${layout.progressRingTop + layout.recordButtonOffset}px`,
+            top: `${actionButtonTop}px`,
             left: '50%',
             left: '50%',
             transform: `translateX(calc(-100% - ${layout.verticalGap}px))`,
             transform: `translateX(calc(-100% - ${layout.verticalGap}px))`,
             borderRadius: '100px',
             borderRadius: '100px',
@@ -978,6 +1044,23 @@ export const ChatBotPage = () => {
         </div>
         </div>
       )}
       )}
 
 
+      {shouldShowCountdown && (
+      <div
+        className="chatbot-countdown absolute text-white font-semibold"
+        style={{
+          top: `${actionButtonTop + actionButtonSize / 2}px`,
+          left: '50%',
+          transform: 'translate(-50%, -50%)',
+          zIndex: 21,
+          fontSize: `${Math.max(12, layout.recordButtonSize * 0.2)}px`,
+          textShadow: '0 2px 8px rgba(0,0,0,0.55)',
+          letterSpacing: '0.08em',
+        }}
+      >
+        {formatCountdown(remainingTimeMs ?? 0)}
+        </div>
+      )}
+
       {/* 语音发送提示 - 只在首次录音时显示 - 等比例缩放 */}
       {/* 语音发送提示 - 只在首次录音时显示 - 等比例缩放 */}
       {currentState === 'recording' && showSendTooltip && (
       {currentState === 'recording' && showSendTooltip && (
         <img
         <img
@@ -1001,11 +1084,11 @@ export const ChatBotPage = () => {
           className="chatbot-action-buttons absolute flex items-center justify-center cursor-pointer touch-manipulation"
           className="chatbot-action-buttons absolute flex items-center justify-center cursor-pointer touch-manipulation"
           onClick={handleSend}
           onClick={handleSend}
           style={{
           style={{
-            width: `${Math.round(layout.recordButtonSize * 0.58)}px`,
-            height: `${Math.round(layout.recordButtonSize * 0.58)}px`,
+            width: `${actionButtonSize}px`,
+            height: `${actionButtonSize}px`,
             minWidth: '44px',
             minWidth: '44px',
             minHeight: '44px',
             minHeight: '44px',
-            top: `${layout.progressRingTop + layout.recordButtonOffset}px`,
+            top: `${actionButtonTop}px`,
             left: '50%',
             left: '50%',
             transform: `translateX(${layout.verticalGap}px)`,
             transform: `translateX(${layout.verticalGap}px)`,
             borderRadius: '100px',
             borderRadius: '100px',