소스 검색

提交代码

zlwu 11 달 전
부모
커밋
79f4ea8764

+ 1 - 0
backend/init_database.py

@@ -31,6 +31,7 @@ CREATE TABLE IF NOT EXISTS users (
     -- 统计信息
     total_messages INT DEFAULT 0 COMMENT '总消息数',
     total_rounds INT DEFAULT 0 COMMENT '总对话轮次',
+    total_speech_duration_ms BIGINT DEFAULT 0 COMMENT '累积说话时长(毫秒)',
     last_active_at TIMESTAMP NULL COMMENT '最后活跃时间',
     
     created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

+ 1 - 0
backend/models.py

@@ -70,6 +70,7 @@ class AudioEndMessage(BaseMessage):
     """音频结束消息"""
     type: Literal["audio_end"] = "audio_end"
     totalChunks: int
+    speechDuration: Optional[int] = None  # 说话时长(毫秒)
 
 
 class CancelMessage(BaseMessage):

+ 31 - 1
backend/services/user_service.py

@@ -70,7 +70,7 @@ class UserService:
             query = """
                 SELECT user_id, username, user_type, bot_name, phone, 
                        is_verified, registered_at, total_messages, 
-                       total_rounds, last_active_at, created_at
+                       total_rounds, total_speech_duration_ms, last_active_at, created_at
                 FROM users
                 WHERE user_id = %(user_id)s
             """
@@ -162,6 +162,36 @@ class UserService:
         except Exception as e:
             logger.error(f"❌ 更新机器人名字失败: user_id={user_id}, error={e}")
             return False
+    
+    async def update_speech_duration(self, user_id: str, duration_ms: int) -> bool:
+        """
+        更新用户累积说话时长
+        
+        Args:
+            user_id: 用户ID
+            duration_ms: 本次说话时长(毫秒)
+        
+        Returns:
+            是否更新成功
+        """
+        try:
+            if duration_ms <= 0:
+                return True  # 时长无效,跳过更新
+            
+            query = """
+                UPDATE users 
+                SET total_speech_duration_ms = total_speech_duration_ms + %(duration_ms)s
+                WHERE user_id = %(user_id)s
+            """
+            await db_manager.execute_update(query, {
+                "user_id": user_id,
+                "duration_ms": duration_ms
+            })
+            logger.info(f"✅ 更新用户说话时长成功: user_id={user_id}, duration_ms={duration_ms}")
+            return True
+        except Exception as e:
+            logger.error(f"❌ 更新用户说话时长失败: user_id={user_id}, error={e}")
+            return False
 
 
 # 全局用户服务实例

+ 8 - 0
backend/websocket_handler.py

@@ -30,6 +30,7 @@ from ai_service import ai_service
 from request_logger import request_logger, PerformanceMetrics
 from config import settings
 from stt_service import stt_service, STTSession
+from services.user_service import user_service
 
 
 class WebSocketHandler:
@@ -185,6 +186,13 @@ class WebSocketHandler:
             self.performance_metrics[request_id].user_speech_end = audio_end_time
             self.performance_metrics[request_id].stt_last_audio_received = audio_end_time
         
+        # 🔥 更新用户累积说话时长(如果有提供)
+        if message.speechDuration and message.speechDuration > 0:
+            try:
+                await user_service.update_speech_duration(self.user_id, message.speechDuration)
+            except Exception as e:
+                logger.warning(f"⚠️ 更新用户说话时长失败: user_id={self.user_id}, duration_ms={message.speechDuration}, error={e}")
+        
         # 初始化AI响应收集器
         self.ai_responses[request_id] = []
         

+ 77 - 63
frontend/src/pages/ChatBotPage.tsx

@@ -9,6 +9,7 @@ import { prepareMobileAudio, requestVolumeControl, isMobileDevice } from '@/util
 import { setStorage, getStorage, STORAGE_KEYS, markRouteVisited, getUserId } from '@/utils/storage';
 import { onScreenResize, calculateLayout, type CalculatedLayout, updateTooSmallFlag } from '@/utils/screenSize';
 import { performanceLogger } from '@/utils/logger';
+import { getUserInfo } from '@/utils/api';
 import type { Message } from '@/types';
 import '@/styles/chatbot-fix.css';
 import avatarC from '@/assets/Avatar-C.svg';
@@ -34,13 +35,15 @@ export const ChatBotPage = () => {
     currentRequestId,
     currentEmotion,
     conversationProgress,
+    progressLoopCount,
     setState,
     setRequestId,
     setEmotion,
     setRecognizedText,
     setError,
     setConnected,
-    incrementProgress,
+    updateProgressByDuration,
+    setProgress,
     reset,
   } = useAppStore();
 
@@ -73,6 +76,8 @@ export const ChatBotPage = () => {
   // 用于动画的显示进度(平滑过渡)
   const [displayProgress, setDisplayProgress] = useState(0);
   const animationFrameRef = useRef<number>();
+  const totalProgressRef = useRef(0);
+  const shouldSkipProgressAnimationRef = useRef(true);
   
   // V2: 完全计算式布局系统
   const [layout, setLayout] = useState<CalculatedLayout>(() => {
@@ -87,86 +92,66 @@ export const ChatBotPage = () => {
     emotionReceived: 0,
   });
 
-  // 进度动画效果
+  // 进度动画效果(支持多圈连续动画)
   useEffect(() => {
-    const targetProgress = conversationProgress;
-    const startProgress = displayProgress;
-    const diff = targetProgress - startProgress;
-    
-    if (Math.abs(diff) < 0.1) {
-      setDisplayProgress(targetProgress);
+    const targetTotal = progressLoopCount * 100 + conversationProgress;
+    const startTotal = totalProgressRef.current;
+    const diff = targetTotal - startTotal;
+
+    if (shouldSkipProgressAnimationRef.current) {
+      shouldSkipProgressAnimationRef.current = false;
+      totalProgressRef.current = targetTotal;
+      const normalized = ((targetTotal % 100) + 100) % 100;
+      setDisplayProgress(normalized);
       return;
     }
-    
-    // 检测循环重置:从 100% 跳到 10% 的情况
-    const isLoopReset = startProgress >= 100 && targetProgress < startProgress;
-    
-    // 如果检测到循环重置,先重置为 0,然后从 0 动画到目标值
-    if (isLoopReset) {
-      // 取消之前的动画
-      if (animationFrameRef.current) {
-        cancelAnimationFrame(animationFrameRef.current);
-      }
-      
-      // 先重置为 0,使用双重 requestAnimationFrame 确保重置完成
-      setDisplayProgress(0);
-      requestAnimationFrame(() => {
-        requestAnimationFrame(() => {
-          const duration = 600; // 600ms 动画时长
-          const startTime = performance.now();
-          
-          const animate = (currentTime: number) => {
-            const elapsed = currentTime - startTime;
-            const progress = Math.min(elapsed / duration, 1);
-            
-            // 使用 easeOutCubic 缓动函数
-            const easeProgress = 1 - Math.pow(1 - progress, 3);
-            const currentValue = 0 + targetProgress * easeProgress;
-            
-            setDisplayProgress(currentValue);
-            
-            if (progress < 1) {
-              animationFrameRef.current = requestAnimationFrame(animate);
-            } else {
-              setDisplayProgress(targetProgress);
-            }
-          };
-          
-          animationFrameRef.current = requestAnimationFrame(animate);
-        });
-      });
+
+    // 若变化极小,直接同步
+    if (Math.abs(diff) < 0.1) {
+      totalProgressRef.current = targetTotal;
+      const normalized = ((targetTotal % 100) + 100) % 100;
+      setDisplayProgress(normalized);
       return;
     }
-    
-    // 正常增长动画
-    const duration = 600; // 600ms 动画时长
+
+    // 每 1% 的进度约 8ms,限制在 0.4s~2.4s 之间
+    const durationPerPercent = 8;
+    const duration = Math.max(400, Math.min(2400, Math.abs(diff) * durationPerPercent));
     const startTime = performance.now();
-    
+
+    if (animationFrameRef.current) {
+      cancelAnimationFrame(animationFrameRef.current);
+    }
+
     const animate = (currentTime: number) => {
       const elapsed = currentTime - startTime;
       const progress = Math.min(elapsed / duration, 1);
-      
-      // 使用 easeOutCubic 缓动函数
+
+      // easeOutCubic 缓动
       const easeProgress = 1 - Math.pow(1 - progress, 3);
-      const currentValue = startProgress + diff * easeProgress;
-      
-      setDisplayProgress(currentValue);
-      
+      const currentTotal = startTotal + diff * easeProgress;
+      totalProgressRef.current = currentTotal;
+
+      const normalized = ((currentTotal % 100) + 100) % 100;
+      setDisplayProgress(normalized);
+
       if (progress < 1) {
         animationFrameRef.current = requestAnimationFrame(animate);
       } else {
-        setDisplayProgress(targetProgress);
+        totalProgressRef.current = targetTotal;
+        const finalNormalized = ((targetTotal % 100) + 100) % 100;
+        setDisplayProgress(finalNormalized);
       }
     };
-    
+
     animationFrameRef.current = requestAnimationFrame(animate);
-    
+
     return () => {
       if (animationFrameRef.current) {
         cancelAnimationFrame(animationFrameRef.current);
       }
     };
-  }, [conversationProgress]);
+  }, [conversationProgress, progressLoopCount]);
 
   // 控制声纹容器高度变化
   useEffect(() => {
@@ -298,6 +283,27 @@ export const ChatBotPage = () => {
     
     const init = async () => {
       try {
+        // 获取用户信息并初始化进度条
+        const user_id = getUserId();
+        if (user_id) {
+          try {
+            const userInfo = await getUserInfo(user_id);
+            if (userInfo.success && userInfo.data.total_speech_duration_ms) {
+              // 根据总说话时长计算初始进度(30秒=100%)
+              const PROGRESS_FULL_DURATION_MS = 30 * 1000; // 30000ms
+              const totalDurationMs = userInfo.data.total_speech_duration_ms || 0;
+              const totalProgressPercent = (totalDurationMs / PROGRESS_FULL_DURATION_MS) * 100;
+              const initialLoopCount = Math.floor(totalProgressPercent / 100);
+              const initialProgress = totalProgressPercent % 100;
+              shouldSkipProgressAnimationRef.current = true;
+              setProgress(initialProgress, initialLoopCount);
+            }
+          } catch (error) {
+            // 获取用户信息失败不影响主流程,静默处理
+            console.warn('获取用户信息失败:', error);
+          }
+        }
+
         // 初始化录音器
         const recorder = new AudioRecorder();
         await recorder.initialize();
@@ -311,7 +317,7 @@ export const ChatBotPage = () => {
           if (requestId) {
             performanceLogger.recordPlaybackEnd(requestId);
           }
-          incrementProgress(); // AI说话完成后增加进度
+          // 进度条已基于用户说话时长更新,无需在播放完成后再次更新
           setState('idle');
           reset();
         });
@@ -319,7 +325,6 @@ export const ChatBotPage = () => {
 
         // 初始化WebSocket(添加user_id查询参数)
         const ws = new WebSocketManager();
-        const user_id = getUserId();
         const wsUrl = user_id ? `${WS_URL}?user_id=${encodeURIComponent(user_id)}` : WS_URL;
         ws.connect(wsUrl, (connected) => {
           setConnected(connected);
@@ -502,9 +507,18 @@ export const ChatBotPage = () => {
     sendTimeRef.current = Date.now();
     performanceLogger.recordEnd(currentRequestId, chunkIndexRef.current);
 
+    // 计算说话时长(毫秒)
+    const speechDuration = timingRef.current.recordEnd - timingRef.current.recordStart;
+    
+    // 更新进度条(基于说话时长,30秒=100%)
+    if (speechDuration > 0) {
+      updateProgressByDuration(speechDuration);
+    }
+
     recorderRef.current?.stop();
 
-    wsRef.current?.sendAudioEnd(currentRequestId, chunkIndexRef.current);
+    // 发送音频结束消息,包含说话时长信息
+    wsRef.current?.sendAudioEnd(currentRequestId, chunkIndexRef.current, speechDuration);
     setState('sending');
     
     // 设置超时保护:10秒后如果还在 sending 或 playing 状态,自动重置

+ 33 - 6
frontend/src/store/index.ts

@@ -14,7 +14,8 @@ interface AppState {
   recognizedText: string;
   errorMessage: string | null;
   isConnected: boolean;
-  conversationProgress: number; // 对话进度 0-100
+  conversationProgress: number; // 当前进度 0-100
+  progressLoopCount: number; // 已完成的整圈数量
 
   // Actions
   setPage: (page: PageType) => void;
@@ -24,7 +25,8 @@ interface AppState {
   setRecognizedText: (text: string) => void;
   setError: (message: string | null) => void;
   setConnected: (connected: boolean) => void;
-  incrementProgress: () => void; // 增加进度
+  updateProgressByDuration: (durationMs: number) => void; // 根据说话时长更新进度(30秒=100%)
+  setProgress: (progress: number, loopCount?: number) => void; // 直接设置进度值(0-100),可选指定整圈数量
   reset: () => void;
 }
 
@@ -38,6 +40,7 @@ export const useAppStore = create<AppState>((set) => ({
   errorMessage: null,
   isConnected: false,
   conversationProgress: 0,
+  progressLoopCount: 0,
 
   // Actions
   setPage: (page) => {
@@ -56,11 +59,35 @@ export const useAppStore = create<AppState>((set) => ({
   setRecognizedText: (text) => set({ recognizedText: text }),
   setError: (message) => set({ errorMessage: message, currentState: message ? 'error' : 'idle' }),
   setConnected: (connected) => set({ isConnected: connected }),
-  incrementProgress: () => set((state) => {
-    const newProgress = state.conversationProgress + 10;
-    // 达到100%后,下次从10%开始循环
-    return { conversationProgress: newProgress > 100 ? 10 : newProgress };
+  updateProgressByDuration: (durationMs) => set((state) => {
+    // 30秒 = 100% 进度
+    const PROGRESS_FULL_DURATION_MS = 30 * 1000; // 30000ms
+    // 计算本次增加的进度百分比
+    const progressIncrement = (durationMs / PROGRESS_FULL_DURATION_MS) * 100;
+    // 计算新的进度值
+    const newProgress = state.conversationProgress + progressIncrement;
+    
+    // 如果超过100%,转一圈后再继续
+    // 例如:当前90%,说话31秒,则:90% + 103.33% = 193.33% → 转一圈后剩余 93.33%
+    if (newProgress >= 100) {
+      const cycles = Math.floor(newProgress / 100); // 转了几圈
+      const remainder = newProgress % 100; // 剩余进度
+      return {
+        conversationProgress: remainder,
+        progressLoopCount: state.progressLoopCount + cycles,
+      };
+    }
+    
+    return { conversationProgress: newProgress };
   }),
+  setProgress: (progress, loopCount) =>
+    set((state) => ({
+      conversationProgress: Math.max(0, Math.min(100, progress)),
+      progressLoopCount:
+        typeof loopCount === 'number'
+          ? Math.max(0, Math.floor(loopCount))
+          : state.progressLoopCount,
+    })),
   reset: () =>
     set({
       currentState: 'idle',

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

@@ -70,6 +70,7 @@ export interface AudioChunkMessage extends BaseMessage {
 export interface AudioEndMessage extends BaseMessage {
   type: 'audio_end';
   totalChunks: number;
+  speechDuration?: number; // 说话时长(毫秒)
 }
 
 // 取消消息

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

@@ -59,6 +59,7 @@ export interface GetUserResponse {
     username: string;
     user_type: string;
     bot_name?: string;
+    total_speech_duration_ms?: number;
     created_at: string;
   };
 }

+ 2 - 1
frontend/src/utils/websocket.ts

@@ -102,11 +102,12 @@ export class WebSocketManager {
     });
   }
 
-  sendAudioEnd(requestId: string, totalChunks: number): void {
+  sendAudioEnd(requestId: string, totalChunks: number, speechDuration?: number): void {
     this.send({
       type: 'audio_end',
       requestId,
       totalChunks,
+      speechDuration,
     });
   }