|
|
@@ -0,0 +1,1144 @@
|
|
|
+import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
+import { message as antMessage } from 'antd';
|
|
|
+import { v4 as uuidv4 } from 'uuid';
|
|
|
+import { useAppStore } from '@/store';
|
|
|
+import { AudioRecorder } from '@/utils/audioRecorder';
|
|
|
+import { AudioPlayer } from '@/utils/audioPlayer';
|
|
|
+import { WebSocketManager } from '@/utils/websocket';
|
|
|
+import { prepareMobileAudio, requestVolumeControl, isMobileDevice } from '@/utils/audioHelper';
|
|
|
+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';
|
|
|
+import treesImage from '@/assets/Group-3465183.svg';
|
|
|
+import animaZoneAvatar from '@/assets/Avatar-anima-zone.png';
|
|
|
+import animaZoneAvatarGif from '@/assets/02.GIF';
|
|
|
+import audioButton from '@/assets/audio-button.png';
|
|
|
+import { AudioWave } from '@/components/AudioWave';
|
|
|
+import microphoneIcon from '@/assets/microphone-icon.svg';
|
|
|
+import progressRing from '@/assets/progress-ring.svg';
|
|
|
+import progressFull from '@/assets/progress-full.svg';
|
|
|
+import tapToChat from '@/assets/TapToChat.svg';
|
|
|
+import cancelIcon from '@/assets/cancel-icon.svg';
|
|
|
+import sendIcon from '@/assets/send-icon.svg';
|
|
|
+import tooltipsCancel from '@/assets/tooltips-cancel.svg';
|
|
|
+import tooltipsSend from '@/assets/tooltips-send.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';
|
|
|
+
|
|
|
+export const ChatBotPage = () => {
|
|
|
+ const {
|
|
|
+ currentState,
|
|
|
+ currentRequestId,
|
|
|
+ currentEmotion,
|
|
|
+ conversationProgress,
|
|
|
+ progressLoopCount,
|
|
|
+ setState,
|
|
|
+ setRequestId,
|
|
|
+ setEmotion,
|
|
|
+ setRecognizedText,
|
|
|
+ setError,
|
|
|
+ setConnected,
|
|
|
+ updateProgressByDuration,
|
|
|
+ setProgress,
|
|
|
+ reset,
|
|
|
+ isConnected,
|
|
|
+ } = useAppStore();
|
|
|
+
|
|
|
+ const recorderRef = useRef<AudioRecorder | null>(null);
|
|
|
+ const playerRef = useRef<AudioPlayer | null>(null);
|
|
|
+ const wsRef = useRef<WebSocketManager | null>(null);
|
|
|
+ const chunkIndexRef = useRef(0);
|
|
|
+ const connectionLostRef = useRef(false);
|
|
|
+ const mobileAudioPreparedRef = useRef(false);
|
|
|
+ const sendTimeRef = useRef<number>(0);
|
|
|
+ // 初始化时检查 localStorage,确保第一次打开时立即显示提示
|
|
|
+ const [showTapToChat, setShowTapToChat] = useState(() => {
|
|
|
+ const hasShown = getStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT);
|
|
|
+ if (!hasShown) {
|
|
|
+ // 第一次打开,立即保存标记并显示
|
|
|
+ setStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT, 'true');
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ });
|
|
|
+ const sendingTimeoutRef = useRef<number | null>(null);
|
|
|
+ const [showCancelTooltip, setShowCancelTooltip] = useState(false);
|
|
|
+ const [showSendTooltip, setShowSendTooltip] = useState(false);
|
|
|
+ const [showProgressBarTooltip, setShowProgressBarTooltip] = useState(false);
|
|
|
+ const progressBarTooltipTimerRef = useRef<number | null>(null);
|
|
|
+
|
|
|
+ // 控制声纹容器高度(延迟降低,等待衰减动画完成)
|
|
|
+ const [shouldExpandWaveform, setShouldExpandWaveform] = useState(false);
|
|
|
+ const waveformCollapseTimerRef = useRef<number | null>(null);
|
|
|
+
|
|
|
+ // 用于动画的显示进度(平滑过渡)
|
|
|
+ const [displayProgress, setDisplayProgress] = useState(0);
|
|
|
+ const animationFrameRef = useRef<number>();
|
|
|
+ const totalProgressRef = useRef(0);
|
|
|
+ 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: 完全计算式布局系统
|
|
|
+ const [layout, setLayout] = useState<CalculatedLayout>(() => {
|
|
|
+ const initialLayout = calculateLayout();
|
|
|
+ return initialLayout;
|
|
|
+ });
|
|
|
+
|
|
|
+ const timingRef = useRef({
|
|
|
+ recordStart: 0,
|
|
|
+ recordEnd: 0,
|
|
|
+ firstAudioReceived: 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(() => {
|
|
|
+ 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;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 若变化极小,直接同步
|
|
|
+ if (Math.abs(diff) < 0.1) {
|
|
|
+ totalProgressRef.current = targetTotal;
|
|
|
+ const normalized = ((targetTotal % 100) + 100) % 100;
|
|
|
+ setDisplayProgress(normalized);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 每 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 缓动
|
|
|
+ const easeProgress = 1 - Math.pow(1 - progress, 3);
|
|
|
+ const currentTotal = startTotal + diff * easeProgress;
|
|
|
+ totalProgressRef.current = currentTotal;
|
|
|
+
|
|
|
+ const normalized = ((currentTotal % 100) + 100) % 100;
|
|
|
+ setDisplayProgress(normalized);
|
|
|
+
|
|
|
+ if (progress < 1) {
|
|
|
+ animationFrameRef.current = requestAnimationFrame(animate);
|
|
|
+ } else {
|
|
|
+ totalProgressRef.current = targetTotal;
|
|
|
+ const finalNormalized = ((targetTotal % 100) + 100) % 100;
|
|
|
+ setDisplayProgress(finalNormalized);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ animationFrameRef.current = requestAnimationFrame(animate);
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ if (animationFrameRef.current) {
|
|
|
+ cancelAnimationFrame(animationFrameRef.current);
|
|
|
+ }
|
|
|
+ };
|
|
|
+ }, [conversationProgress, progressLoopCount]);
|
|
|
+
|
|
|
+ // 控制声纹容器高度变化
|
|
|
+ useEffect(() => {
|
|
|
+ // 清除之前的定时器
|
|
|
+ if (waveformCollapseTimerRef.current) {
|
|
|
+ clearTimeout(waveformCollapseTimerRef.current);
|
|
|
+ waveformCollapseTimerRef.current = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (currentState === 'recording' || currentState === 'playing') {
|
|
|
+ // 录音或播放时,立即展开
|
|
|
+ setShouldExpandWaveform(true);
|
|
|
+ } else {
|
|
|
+ // 其他状态(sending/idle 等),延迟 3 秒后再收起(等待衰减动画完成)
|
|
|
+ waveformCollapseTimerRef.current = window.setTimeout(() => {
|
|
|
+ setShouldExpandWaveform(false);
|
|
|
+ }, 3000); // 3秒足够让所有竖条衰减为静态高度
|
|
|
+ }
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ if (waveformCollapseTimerRef.current) {
|
|
|
+ clearTimeout(waveformCollapseTimerRef.current);
|
|
|
+ }
|
|
|
+ };
|
|
|
+ }, [currentState]);
|
|
|
+
|
|
|
+ // 监听录音状态,首次显示取消提示(3秒后自动隐藏)
|
|
|
+ useEffect(() => {
|
|
|
+ if (currentState === 'recording') {
|
|
|
+ const hasShownCancel = getStorage(STORAGE_KEYS.HAS_SHOWN_CANCEL_TOOLTIP);
|
|
|
+ if (!hasShownCancel) {
|
|
|
+ setShowCancelTooltip(true);
|
|
|
+ setStorage(STORAGE_KEYS.HAS_SHOWN_CANCEL_TOOLTIP, 'true');
|
|
|
+
|
|
|
+ // 3秒后自动隐藏
|
|
|
+ const timer = setTimeout(() => {
|
|
|
+ setShowCancelTooltip(false);
|
|
|
+ }, 3000);
|
|
|
+
|
|
|
+ return () => clearTimeout(timer);
|
|
|
+ } else {
|
|
|
+ // 已显示过,确保隐藏
|
|
|
+ setShowCancelTooltip(false);
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // 非录音状态,隐藏提示
|
|
|
+ setShowCancelTooltip(false);
|
|
|
+ }
|
|
|
+ }, [currentState]);
|
|
|
+
|
|
|
+ // 监听录音状态,首次显示发送提示(3秒后自动隐藏)
|
|
|
+ useEffect(() => {
|
|
|
+ if (currentState === 'recording') {
|
|
|
+ const hasShownSend = getStorage(STORAGE_KEYS.HAS_SHOWN_SEND_TOOLTIP);
|
|
|
+ if (!hasShownSend) {
|
|
|
+ setShowSendTooltip(true);
|
|
|
+ setStorage(STORAGE_KEYS.HAS_SHOWN_SEND_TOOLTIP, 'true');
|
|
|
+
|
|
|
+ // 3秒后自动隐藏
|
|
|
+ const timer = setTimeout(() => {
|
|
|
+ setShowSendTooltip(false);
|
|
|
+ }, 3000);
|
|
|
+
|
|
|
+ return () => clearTimeout(timer);
|
|
|
+ } else {
|
|
|
+ // 已显示过,确保隐藏
|
|
|
+ setShowSendTooltip(false);
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // 非录音状态,隐藏提示
|
|
|
+ setShowSendTooltip(false);
|
|
|
+ }
|
|
|
+ }, [currentState]);
|
|
|
+
|
|
|
+ // 监听进度条变化,首次有进度时显示进度条提示(2秒后自动隐藏)
|
|
|
+ // 🔧 临时修改:为了UI调试,进度更新时始终显示提示
|
|
|
+ useEffect(() => {
|
|
|
+ // const hasShownProgressBar = getStorage(STORAGE_KEYS.HAS_SHOWN_PROGRESS_BAR_TOOLTIP);
|
|
|
+
|
|
|
+ // // 如果已经显示过,不再处理
|
|
|
+ // if (hasShownProgressBar) {
|
|
|
+ // return;
|
|
|
+ // }
|
|
|
+
|
|
|
+ // 当进度条第一次大于0时显示提示(只显示一次)
|
|
|
+ const hasShownProgressBarTooltip = getStorage(STORAGE_KEYS.HAS_SHOWN_PROGRESS_BAR_TOOLTIP);
|
|
|
+ if (displayProgress > 0 && !showProgressBarTooltip && !hasShownProgressBarTooltip) {
|
|
|
+ setShowProgressBarTooltip(true);
|
|
|
+ setStorage(STORAGE_KEYS.HAS_SHOWN_PROGRESS_BAR_TOOLTIP, 'true');
|
|
|
+
|
|
|
+ // 2秒后自动隐藏
|
|
|
+ progressBarTooltipTimerRef.current = window.setTimeout(() => {
|
|
|
+ setShowProgressBarTooltip(false);
|
|
|
+ progressBarTooltipTimerRef.current = null;
|
|
|
+ }, 2000);
|
|
|
+ }
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ if (progressBarTooltipTimerRef.current) {
|
|
|
+ clearTimeout(progressBarTooltipTimerRef.current);
|
|
|
+ progressBarTooltipTimerRef.current = null;
|
|
|
+ }
|
|
|
+ };
|
|
|
+ }, [displayProgress, showProgressBarTooltip]);
|
|
|
+
|
|
|
+ // 监听屏幕尺寸变化
|
|
|
+ useEffect(() => {
|
|
|
+ // 初始化时更新标记
|
|
|
+ updateTooSmallFlag();
|
|
|
+
|
|
|
+ const cleanup = onScreenResize(() => {
|
|
|
+ const newLayout = calculateLayout();
|
|
|
+ setLayout(newLayout); // V2: 更新完全计算式布局
|
|
|
+
|
|
|
+ // 更新 data-too-small 标记
|
|
|
+ updateTooSmallFlag();
|
|
|
+ });
|
|
|
+
|
|
|
+ return cleanup;
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ // 初始化
|
|
|
+ useEffect(() => {
|
|
|
+ // 记录用户当前所在页面
|
|
|
+ setStorage(STORAGE_KEYS.CURRENT_PAGE, 'chatbot');
|
|
|
+
|
|
|
+ // 记录路由访问
|
|
|
+ markRouteVisited('/chatbot');
|
|
|
+
|
|
|
+ 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();
|
|
|
+ recorderRef.current = recorder;
|
|
|
+
|
|
|
+ // 初始化播放器
|
|
|
+ const player = new AudioPlayer();
|
|
|
+ await player.initialize();
|
|
|
+ player.onEnd(() => {
|
|
|
+ const requestId = useAppStore.getState().currentRequestId;
|
|
|
+ if (requestId) {
|
|
|
+ performanceLogger.recordPlaybackEnd(requestId);
|
|
|
+ }
|
|
|
+ // 进度条已基于用户说话时长更新,无需在播放完成后再次更新
|
|
|
+ setState('idle');
|
|
|
+ reset();
|
|
|
+ });
|
|
|
+ playerRef.current = player;
|
|
|
+
|
|
|
+ // 初始化WebSocket(添加user_id查询参数)
|
|
|
+ const ws = new WebSocketManager();
|
|
|
+ const wsUrl = user_id ? `${WS_URL}?user_id=${encodeURIComponent(user_id)}` : WS_URL;
|
|
|
+ ws.connect(wsUrl, (connected) => {
|
|
|
+ const appState = useAppStore.getState();
|
|
|
+ const wasConnected = appState.isConnected;
|
|
|
+
|
|
|
+ setConnected(connected);
|
|
|
+
|
|
|
+ if (connected) {
|
|
|
+ // 记录WebSocket连接建立时间
|
|
|
+ performanceLogger.recordWebSocketConnected();
|
|
|
+
|
|
|
+ if (connectionLostRef.current) {
|
|
|
+ antMessage.success('语音服务已恢复,可以继续对话');
|
|
|
+ connectionLostRef.current = false;
|
|
|
+ recorderRef.current?.stop();
|
|
|
+ playerRef.current?.stop();
|
|
|
+ reset();
|
|
|
+ setError(null);
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!wasConnected) {
|
|
|
+ // 初次连接失败,静默等待自动重连
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ connectionLostRef.current = true;
|
|
|
+
|
|
|
+ if (appState.currentRequestId) {
|
|
|
+ performanceLogger.clearRequest(appState.currentRequestId, 'websocket_disconnected');
|
|
|
+ }
|
|
|
+
|
|
|
+ recorderRef.current?.stop();
|
|
|
+ playerRef.current?.stop();
|
|
|
+
|
|
|
+ antMessage.error('语音服务连接断开,请稍后重试');
|
|
|
+ setError('语音服务连接断开,请稍后重试');
|
|
|
+ });
|
|
|
+ ws.onMessage('app', handleMessage);
|
|
|
+ wsRef.current = ws;
|
|
|
+ } catch (error) {
|
|
|
+ antMessage.error('初始化失败,请检查麦克风权限');
|
|
|
+ setError('初始化失败');
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ init();
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ recorderRef.current?.release();
|
|
|
+ playerRef.current?.release();
|
|
|
+ wsRef.current?.disconnect();
|
|
|
+ if (sendingTimeoutRef.current) {
|
|
|
+ clearTimeout(sendingTimeoutRef.current);
|
|
|
+ }
|
|
|
+ if (animationFrameRef.current) {
|
|
|
+ cancelAnimationFrame(animationFrameRef.current);
|
|
|
+ }
|
|
|
+ if (progressBarTooltipTimerRef.current) {
|
|
|
+ clearTimeout(progressBarTooltipTimerRef.current);
|
|
|
+ }
|
|
|
+ };
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ // 处理WebSocket消息
|
|
|
+ const handleMessage = (message: Message) => {
|
|
|
+ const requestId = message.requestId || currentRequestId;
|
|
|
+
|
|
|
+ switch (message.type) {
|
|
|
+ case 'text_result':
|
|
|
+ if (message.isFinal) {
|
|
|
+ performanceLogger.recordFinalText(requestId, message.text, message.timestamp);
|
|
|
+ } else if (!performanceLogger.hasFirstText(requestId)) {
|
|
|
+ performanceLogger.recordFirstText(requestId, message.text, message.timestamp);
|
|
|
+ }
|
|
|
+ setRecognizedText(message.text);
|
|
|
+ break;
|
|
|
+
|
|
|
+ case 'emotion':
|
|
|
+ performanceLogger.recordEmotion(requestId, message.emotion, message.timestamp);
|
|
|
+ setEmotion(message.emotion);
|
|
|
+ break;
|
|
|
+
|
|
|
+ case 'audio_response':
|
|
|
+ // 清除发送超时
|
|
|
+ if (sendingTimeoutRef.current) {
|
|
|
+ clearTimeout(sendingTimeoutRef.current);
|
|
|
+ sendingTimeoutRef.current = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (currentState !== 'playing') {
|
|
|
+ performanceLogger.recordFirstAudio(requestId, message.timestamp);
|
|
|
+ performanceLogger.recordPlaybackStart(requestId);
|
|
|
+ setState('playing');
|
|
|
+ }
|
|
|
+
|
|
|
+ if (message.data) {
|
|
|
+ const audioData = atob(message.data);
|
|
|
+ const audioBytes = new Uint8Array(audioData.length);
|
|
|
+
|
|
|
+ for (let i = 0; i < audioData.length; i++) {
|
|
|
+ audioBytes[i] = audioData.charCodeAt(i);
|
|
|
+ }
|
|
|
+
|
|
|
+ playerRef.current?.addChunk(audioBytes, message.sampleRate || 24000);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (message.isLast) {
|
|
|
+ performanceLogger.recordLastAudio(requestId);
|
|
|
+ playerRef.current?.markStreamEnd();
|
|
|
+ // 进度已在发送时增加,这里不再重复增加
|
|
|
+ }
|
|
|
+ break;
|
|
|
+
|
|
|
+ case 'error':
|
|
|
+ console.error(`[错误] [${requestId?.substring(0, 8) || ''}]`, message.message, message.code);
|
|
|
+ antMessage.error(message.message);
|
|
|
+ setError(message.message);
|
|
|
+ performanceLogger.clearRequest(requestId, `${message.code}: ${message.message}`);
|
|
|
+ break;
|
|
|
+
|
|
|
+ case 'pong':
|
|
|
+ break;
|
|
|
+
|
|
|
+ default:
|
|
|
+ console.warn(`[未知消息] [${requestId?.substring(0, 8) || ''}]`, message.type);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 开始录音
|
|
|
+ const handleStartRecord = async () => {
|
|
|
+ // 如果正在录音,忽略
|
|
|
+ if (currentState === 'recording') {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!isConnected) {
|
|
|
+ antMessage.warning('语音服务正在重连,请稍后再试');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果正在播放,先停止播放
|
|
|
+ if (currentState === 'playing') {
|
|
|
+ playerRef.current?.stop();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 其他状态(idle/error/sending)都允许开始录音
|
|
|
+
|
|
|
+ // 首次点击录音按钮,隐藏 TapToChat 提示
|
|
|
+ if (showTapToChat) {
|
|
|
+ setShowTapToChat(false);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 点击录音按钮时,隐藏进度条提示
|
|
|
+ if (showProgressBarTooltip) {
|
|
|
+ setShowProgressBarTooltip(false);
|
|
|
+ if (progressBarTooltipTimerRef.current) {
|
|
|
+ clearTimeout(progressBarTooltipTimerRef.current);
|
|
|
+ progressBarTooltipTimerRef.current = null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (isMobileDevice() && !mobileAudioPreparedRef.current) {
|
|
|
+ try {
|
|
|
+ await prepareMobileAudio();
|
|
|
+ requestVolumeControl();
|
|
|
+ mobileAudioPreparedRef.current = true;
|
|
|
+ antMessage.info('请确保设备音量已调高');
|
|
|
+ } catch (error) {
|
|
|
+ // 准备移动端音频失败,静默处理
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 🔥 清理播放器状态,确保上一次对话的残留状态被重置
|
|
|
+ playerRef.current?.stop();
|
|
|
+
|
|
|
+ const requestId = uuidv4();
|
|
|
+ performanceLogger.startRequest(requestId);
|
|
|
+ setRequestId(requestId);
|
|
|
+ setState('recording');
|
|
|
+ chunkIndexRef.current = 0;
|
|
|
+ setRecognizedText('');
|
|
|
+
|
|
|
+ timingRef.current = {
|
|
|
+ recordStart: Date.now(),
|
|
|
+ recordEnd: 0,
|
|
|
+ firstAudioReceived: 0,
|
|
|
+ emotionReceived: 0,
|
|
|
+ };
|
|
|
+
|
|
|
+ recorderRef.current?.start(
|
|
|
+ (pcmData) => {
|
|
|
+ if (chunkIndexRef.current === 0) {
|
|
|
+ performanceLogger.recordFirstChunkSent(requestId);
|
|
|
+ }
|
|
|
+ const uint8Array = new Uint8Array(pcmData.buffer);
|
|
|
+ const base64 = btoa(String.fromCharCode(...Array.from(uint8Array)));
|
|
|
+ wsRef.current?.sendAudioChunk(requestId, base64, chunkIndexRef.current);
|
|
|
+ chunkIndexRef.current++;
|
|
|
+ }
|
|
|
+ );
|
|
|
+ startRecordingTimer();
|
|
|
+ };
|
|
|
+
|
|
|
+ // 发送录音
|
|
|
+ const handleSend = () => {
|
|
|
+ if (currentState !== 'recording' || !currentRequestId) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ stopRecordingTimer();
|
|
|
+
|
|
|
+ if (!isConnected) {
|
|
|
+ antMessage.error('语音服务未连接,请重新录音发送');
|
|
|
+ recorderRef.current?.stop();
|
|
|
+ performanceLogger.clearRequest(currentRequestId, 'websocket_disconnected_before_send');
|
|
|
+ reset();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ timingRef.current.recordEnd = Date.now();
|
|
|
+ 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, speechDuration);
|
|
|
+ setState('sending');
|
|
|
+
|
|
|
+ // 设置超时保护:10秒后如果还在 sending 或 playing 状态,自动重置
|
|
|
+ if (sendingTimeoutRef.current) {
|
|
|
+ clearTimeout(sendingTimeoutRef.current);
|
|
|
+ }
|
|
|
+ sendingTimeoutRef.current = window.setTimeout(() => {
|
|
|
+ const state = useAppStore.getState().currentState;
|
|
|
+ if (state === 'sending' || state === 'playing') {
|
|
|
+ console.warn(`[超时] [${currentRequestId.substring(0, 8)}] 响应超时,自动重置`);
|
|
|
+ antMessage.warning('响应超时,请重试');
|
|
|
+ setState('idle');
|
|
|
+ reset();
|
|
|
+ performanceLogger.clearRequest(currentRequestId, '响应超时');
|
|
|
+ }
|
|
|
+ }, 10000);
|
|
|
+ };
|
|
|
+
|
|
|
+ handleSendRef.current = handleSend;
|
|
|
+
|
|
|
+ // 取消录音
|
|
|
+ const handleCancel = () => {
|
|
|
+ if (currentState !== 'recording' || !currentRequestId) return;
|
|
|
+
|
|
|
+ console.log(`[取消] [${currentRequestId.substring(0, 8)}] 取消录音`);
|
|
|
+ recorderRef.current?.stop();
|
|
|
+ stopRecordingTimer();
|
|
|
+
|
|
|
+ wsRef.current?.sendCancel(currentRequestId);
|
|
|
+ performanceLogger.clearRequest(currentRequestId);
|
|
|
+ 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;
|
|
|
+
|
|
|
+ const isAvatarAnimating = currentState === 'playing';
|
|
|
+ const currentAvatarSrc = isAvatarAnimating ? animaZoneAvatarGif : animaZoneAvatar;
|
|
|
+ const currentAvatarAlt = isAvatarAnimating ? 'AnimaZone Avatar Animated' : 'AnimaZone Avatar';
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ className={`chatbot-container relative w-full ${
|
|
|
+ layout.needsScroll ? 'h-auto overflow-y-auto overflow-x-hidden' : 'h-screen overflow-hidden'
|
|
|
+ }`}
|
|
|
+ style={{
|
|
|
+ minHeight: layout.needsScroll ? `${layout.contentHeight}px` : '100vh',
|
|
|
+ // ⚠️ 移除 safe-area-inset class,改用手动控制顶部安全区域
|
|
|
+ // 底部不加 padding,让声波纹可以直接贴底定位
|
|
|
+ paddingTop: 'env(safe-area-inset-top)',
|
|
|
+ paddingLeft: 'env(safe-area-inset-left)',
|
|
|
+ paddingRight: 'env(safe-area-inset-right)',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {/* 背景图片 */}
|
|
|
+ <div
|
|
|
+ className="chatbot-bg absolute inset-0 pointer-events-none"
|
|
|
+ style={{
|
|
|
+ backgroundImage: 'url(/tolan-bg.svg)',
|
|
|
+ backgroundSize: 'cover',
|
|
|
+ backgroundPosition: 'center',
|
|
|
+ backgroundRepeat: 'no-repeat',
|
|
|
+ zIndex: 0,
|
|
|
+ }}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* 内容容器 - 自适应所有屏幕尺寸 */}
|
|
|
+ <div
|
|
|
+ className="relative w-full"
|
|
|
+ style={{
|
|
|
+ boxSizing: 'border-box',
|
|
|
+ minHeight: layout.needsScroll ? `${layout.contentHeight}px` : '100vh',
|
|
|
+ height: layout.needsScroll ? 'auto' : '100%',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {/* 树木装饰 - 设计稿:top 175px, 宽度 395.03px, 高度 220.66px, 左右居中 */}
|
|
|
+ <img
|
|
|
+ src={treesImage}
|
|
|
+ alt="Trees"
|
|
|
+ className="chatbot-trees absolute pointer-events-none"
|
|
|
+ style={{
|
|
|
+ width: `${layout.treesWidth}px`,
|
|
|
+ height: 'auto',
|
|
|
+ top: `${layout.treesTop}px`,
|
|
|
+ left: '50%',
|
|
|
+ transform: 'translateX(-50%)',
|
|
|
+ opacity: 1,
|
|
|
+ zIndex: 1,
|
|
|
+ }}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* Avatar-C 头像 - 设计稿:top 63px, right 63px, 尺寸 40×40 */}
|
|
|
+ <img
|
|
|
+ src={avatarC}
|
|
|
+ alt="Avatar C"
|
|
|
+ className="chatbot-avatar-c absolute pointer-events-none rounded-full"
|
|
|
+ style={{
|
|
|
+ width: `${layout.avatarCSize}px`,
|
|
|
+ height: `${layout.avatarCSize}px`,
|
|
|
+ top: `${layout.avatarCTop}px`,
|
|
|
+ right: `${layout.avatarCRight}px`,
|
|
|
+ opacity: 0.2,
|
|
|
+ zIndex: 5,
|
|
|
+ }}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* AnimaZone 头像 - 设计稿:top 248px (树木175px + 73px), 尺寸 343×340, 左右居中 */}
|
|
|
+ <img
|
|
|
+ src={currentAvatarSrc}
|
|
|
+ alt={currentAvatarAlt}
|
|
|
+ className="chatbot-anima-zone-avatar absolute"
|
|
|
+ style={{
|
|
|
+ width: `${layout.mainAvatarWidth}px`,
|
|
|
+ height: `${layout.mainAvatarHeight}px`,
|
|
|
+ top: `${layout.animaZoneTop}px`,
|
|
|
+ left: '50%',
|
|
|
+ transform: 'translateX(-50%)',
|
|
|
+ opacity: 1,
|
|
|
+ zIndex: 2,
|
|
|
+ }}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* 情感展示 - 设计稿:距离界面顶部 50px,左右居中 */}
|
|
|
+ <div
|
|
|
+ className="chatbot-emotion absolute"
|
|
|
+ style={{
|
|
|
+ top: `${layout.emotionTop}px`,
|
|
|
+ left: '50%',
|
|
|
+ transform: 'translateX(-50%)',
|
|
|
+ textAlign: 'center',
|
|
|
+ zIndex: 3,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ className="text-fluid-sm"
|
|
|
+ style={{
|
|
|
+ fontWeight: 500,
|
|
|
+ color: 'rgba(255, 255, 255, 0.9)',
|
|
|
+ textShadow: '0px 2px 4px rgba(0, 0, 0, 0.3)',
|
|
|
+ padding: '6px 16px',
|
|
|
+ borderRadius: '12px',
|
|
|
+ background: 'rgba(255, 255, 255, 0.1)',
|
|
|
+ backdropFilter: 'blur(10px)',
|
|
|
+ border: '1px solid rgba(255, 255, 255, 0.2)',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {currentEmotion}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 音频波形 - 宽度 100% 自适应 */}
|
|
|
+ <div
|
|
|
+ className="chatbot-audio-wave overflow-hidden"
|
|
|
+ style={{
|
|
|
+ position: 'fixed', // 改为 fixed,直接相对于视口定位
|
|
|
+ width: '100%',
|
|
|
+ height: shouldExpandWaveform
|
|
|
+ ? `${layout.waveformHeight}px`
|
|
|
+ : '20px',
|
|
|
+ bottom: `${layout.waveformBottom}px`,
|
|
|
+ left: 0,
|
|
|
+ transition: 'height 0.3s ease-in-out',
|
|
|
+ zIndex: 10,
|
|
|
+ }}
|
|
|
+ data-expanded={shouldExpandWaveform ? 'true' : 'false'}
|
|
|
+ data-waveform-bottom={layout.waveformBottom}
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ bottom: 0,
|
|
|
+ left: 0,
|
|
|
+ width: '100%',
|
|
|
+ height: `${layout.waveformHeight}px`,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <AudioWave
|
|
|
+ isAnimating={currentState === 'recording'}
|
|
|
+ width={typeof window !== 'undefined' ? window.innerWidth : 375}
|
|
|
+ height={layout.waveformHeight}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 进度条+录音按钮容器 - 设计稿:bottom 84px, 尺寸 114×114, 左右居中 */}
|
|
|
+ {currentState !== 'recording' && (
|
|
|
+ <div
|
|
|
+ className="chatbot-progress-container absolute"
|
|
|
+ style={{
|
|
|
+ width: `${layout.progressRingSize}px`,
|
|
|
+ height: `${layout.progressRingSize}px`,
|
|
|
+ top: `${layout.progressRingTop}px`,
|
|
|
+ left: '50%',
|
|
|
+ transform: 'translateX(-50%)',
|
|
|
+ zIndex: 20,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {/* 进度环 - 背景层 */}
|
|
|
+ <div
|
|
|
+ className="chatbot-progress-ring absolute"
|
|
|
+ style={{
|
|
|
+ width: '100%',
|
|
|
+ height: '100%',
|
|
|
+ top: 0,
|
|
|
+ left: '-0.5px',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {/* 空状态背景 */}
|
|
|
+ <img
|
|
|
+ src={progressRing}
|
|
|
+ alt="Progress Ring"
|
|
|
+ className="pointer-events-none"
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ top: 0,
|
|
|
+ left: 0,
|
|
|
+ width: '100%',
|
|
|
+ height: '100%',
|
|
|
+ opacity: 1,
|
|
|
+ }}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* 进度填充层 - 使用conic-gradient mask */}
|
|
|
+ <div
|
|
|
+ className="pointer-events-none"
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ top: '5px',
|
|
|
+ left: 0,
|
|
|
+ width: '100%',
|
|
|
+ height: '100%',
|
|
|
+ maskImage: `conic-gradient(from 215deg at 50% 50%, black 0deg, black ${displayProgress * 2.88}deg, transparent ${displayProgress * 2.88}deg, transparent 360deg)`,
|
|
|
+ WebkitMaskImage: `conic-gradient(from 215deg at 50% 50%, black 0deg, black ${displayProgress * 2.88}deg, transparent ${displayProgress * 2.88}deg, transparent 360deg)`,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <img
|
|
|
+ src={progressFull}
|
|
|
+ alt="Progress Full"
|
|
|
+ style={{
|
|
|
+ width: '100%',
|
|
|
+ height: '100%',
|
|
|
+ display: 'block',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 录音按钮 - 设计稿:尺寸 96×96,在进度环内居中,左右居中 */}
|
|
|
+ <div
|
|
|
+ className={`chatbot-record-button absolute touch-manipulation ${isConnected ? 'cursor-pointer' : 'cursor-not-allowed'}`}
|
|
|
+ onClick={handleStartRecord}
|
|
|
+ style={{
|
|
|
+ width: `${layout.recordButtonSize}px`,
|
|
|
+ height: `${layout.recordButtonSize}px`,
|
|
|
+ minWidth: '44px',
|
|
|
+ minHeight: '44px',
|
|
|
+ top: `${layout.recordButtonOffset}px`,
|
|
|
+ left: '50%',
|
|
|
+ transform: 'translateX(-50%)',
|
|
|
+ borderRadius: '100px',
|
|
|
+ opacity: isConnected ? 1 : 0.55,
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ justifyContent: 'center',
|
|
|
+ userSelect: 'none',
|
|
|
+ WebkitUserSelect: 'none',
|
|
|
+ caretColor: 'transparent',
|
|
|
+ outline: 'none',
|
|
|
+ filter: isConnected ? 'none' : 'grayscale(0.25)',
|
|
|
+ }}
|
|
|
+ contentEditable={false}
|
|
|
+ tabIndex={-1}
|
|
|
+ aria-disabled={!isConnected}
|
|
|
+ >
|
|
|
+ {/* 录音按钮背景 - PNG 图片 */}
|
|
|
+ <img
|
|
|
+ src={audioButton}
|
|
|
+ alt="Audio Button Background"
|
|
|
+ className="pointer-events-none"
|
|
|
+ style={{
|
|
|
+ width: '100%',
|
|
|
+ height: '100%',
|
|
|
+ position: 'absolute',
|
|
|
+ top: 0,
|
|
|
+ left: 0,
|
|
|
+ }}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* 麦克风图标 - 居中 - 响应式尺寸 */}
|
|
|
+ <img
|
|
|
+ src={microphoneIcon}
|
|
|
+ alt="Microphone"
|
|
|
+ className="pointer-events-none"
|
|
|
+ style={{
|
|
|
+ width: `${layout.recordButtonSize * 0.33}px`,
|
|
|
+ height: `${layout.recordButtonSize * 0.33}px`,
|
|
|
+ opacity: 1,
|
|
|
+ position: 'relative',
|
|
|
+ zIndex: 1,
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {!isConnected && currentState !== 'recording' && (
|
|
|
+ <div
|
|
|
+ className="absolute text-white/80 text-center"
|
|
|
+ style={{
|
|
|
+ top: `${layout.progressRingTop - 36}px`,
|
|
|
+ left: '50%',
|
|
|
+ transform: 'translateX(-50%)',
|
|
|
+ zIndex: 25,
|
|
|
+ fontSize: `${Math.max(12, layout.recordButtonSize * 0.18)}px`,
|
|
|
+ textShadow: '0 2px 6px rgba(0,0,0,0.35)',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ 正在重连语音服务...
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* 进度条提示 - 首次有进度时显示 */}
|
|
|
+ {showProgressBarTooltip && isConnected && (
|
|
|
+ <img
|
|
|
+ src={tooltipsProgressBar}
|
|
|
+ alt="Progress Bar Tooltip"
|
|
|
+ className="chatbot-tooltips absolute pointer-events-none"
|
|
|
+ style={{
|
|
|
+ width: `${layout.tooltips.progressBar.width}px`,
|
|
|
+ height: `${layout.tooltips.progressBar.height}px`,
|
|
|
+ top: `${layout.tooltips.progressBar.top}px`,
|
|
|
+ left: `${layout.tooltips.progressBar.left}px`,
|
|
|
+ opacity: 1,
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Tap to Chat 提示 - 只显示一次 - 等比例缩放 */}
|
|
|
+ {showTapToChat && isConnected && (
|
|
|
+ <img
|
|
|
+ src={tapToChat}
|
|
|
+ alt="Tap to Chat"
|
|
|
+ className="chatbot-tooltips absolute pointer-events-none"
|
|
|
+ style={{
|
|
|
+ width: `${layout.tooltips.tapToChat.width}px`,
|
|
|
+ height: 'auto',
|
|
|
+ top: `${layout.tooltips.tapToChat.top}px`,
|
|
|
+ right: `${layout.tooltips.tapToChat.right}px`,
|
|
|
+ borderRadius: `${Math.round(8 * layout.avatarScale)}px`,
|
|
|
+ opacity: 1,
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* 取消语音发送提示 - 只在首次录音时显示 - 等比例缩放 */}
|
|
|
+ {currentState === 'recording' && showCancelTooltip && (
|
|
|
+ <img
|
|
|
+ src={tooltipsCancel}
|
|
|
+ alt="Cancel Tooltip"
|
|
|
+ className="chatbot-tooltips absolute pointer-events-none"
|
|
|
+ style={{
|
|
|
+ width: `${layout.tooltips.cancel.width}px`,
|
|
|
+ height: 'auto',
|
|
|
+ top: `${layout.tooltips.cancel.top}px`,
|
|
|
+ left: `${layout.tooltips.cancel.left}px`,
|
|
|
+ borderRadius: `${Math.round(8 * layout.avatarScale)}px`,
|
|
|
+ opacity: 1,
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* 取消语音发送按钮 - 录音状态显示,与录音按钮同高度,左侧对齐 */}
|
|
|
+ {currentState === 'recording' && (
|
|
|
+ <div
|
|
|
+ className="chatbot-action-buttons absolute flex items-center justify-center cursor-pointer touch-manipulation"
|
|
|
+ onClick={handleCancel}
|
|
|
+ style={{
|
|
|
+ width: `${actionButtonSize}px`,
|
|
|
+ height: `${actionButtonSize}px`,
|
|
|
+ minWidth: '44px',
|
|
|
+ minHeight: '44px',
|
|
|
+ top: `${actionButtonTop}px`,
|
|
|
+ left: '50%',
|
|
|
+ transform: `translateX(calc(-100% - ${layout.verticalGap}px))`,
|
|
|
+ borderRadius: '100px',
|
|
|
+ opacity: 1,
|
|
|
+ background: 'rgba(255, 255, 255, 0.1)',
|
|
|
+ backdropFilter: 'blur(15px)',
|
|
|
+ zIndex: 20,
|
|
|
+ userSelect: 'none',
|
|
|
+ WebkitUserSelect: 'none',
|
|
|
+ caretColor: 'transparent',
|
|
|
+ outline: 'none',
|
|
|
+ }}
|
|
|
+ contentEditable={false}
|
|
|
+ tabIndex={-1}
|
|
|
+ >
|
|
|
+ {/* 按钮背景装饰图片 */}
|
|
|
+ <img
|
|
|
+ src={audioButton}
|
|
|
+ alt="Button Background"
|
|
|
+ className="pointer-events-none"
|
|
|
+ style={{
|
|
|
+ width: 'calc(100% + 1px)',
|
|
|
+ height: 'calc(100% + 1px)',
|
|
|
+ position: 'absolute',
|
|
|
+ top: '0',
|
|
|
+ left: '0',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* Cancel 图标 - 居中 - 响应式尺寸 */}
|
|
|
+ <img
|
|
|
+ src={cancelIcon}
|
|
|
+ alt="Cancel"
|
|
|
+ className="pointer-events-none"
|
|
|
+ style={{
|
|
|
+ width: `${layout.recordButtonSize * 0.25}px`,
|
|
|
+ height: `${layout.recordButtonSize * 0.25}px`,
|
|
|
+ position: 'relative',
|
|
|
+ zIndex: 1,
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </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 && (
|
|
|
+ <img
|
|
|
+ src={tooltipsSend}
|
|
|
+ alt="Send Tooltip"
|
|
|
+ className="chatbot-tooltips absolute pointer-events-none"
|
|
|
+ style={{
|
|
|
+ width: `${layout.tooltips.send.width}px`,
|
|
|
+ height: 'auto',
|
|
|
+ top: `${layout.tooltips.send.top}px`,
|
|
|
+ left: `${layout.tooltips.send.left}px`,
|
|
|
+ borderRadius: `${Math.round(8 * layout.avatarScale)}px`,
|
|
|
+ opacity: 1,
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* 语音发送按钮 - 录音状态显示,与录音按钮同高度,右侧对齐 */}
|
|
|
+ {currentState === 'recording' && (
|
|
|
+ <div
|
|
|
+ className="chatbot-action-buttons absolute flex items-center justify-center cursor-pointer touch-manipulation"
|
|
|
+ onClick={handleSend}
|
|
|
+ style={{
|
|
|
+ width: `${actionButtonSize}px`,
|
|
|
+ height: `${actionButtonSize}px`,
|
|
|
+ minWidth: '44px',
|
|
|
+ minHeight: '44px',
|
|
|
+ top: `${actionButtonTop}px`,
|
|
|
+ left: '50%',
|
|
|
+ transform: `translateX(${layout.verticalGap}px)`,
|
|
|
+ borderRadius: '100px',
|
|
|
+ opacity: 1,
|
|
|
+ background: 'rgba(255, 255, 255, 0.1)',
|
|
|
+ backdropFilter: 'blur(15px)',
|
|
|
+ zIndex: 20,
|
|
|
+ userSelect: 'none',
|
|
|
+ WebkitUserSelect: 'none',
|
|
|
+ caretColor: 'transparent',
|
|
|
+ outline: 'none',
|
|
|
+ }}
|
|
|
+ contentEditable={false}
|
|
|
+ tabIndex={-1}
|
|
|
+ >
|
|
|
+ {/* 按钮背景装饰图片 */}
|
|
|
+ <img
|
|
|
+ src={audioButton}
|
|
|
+ alt="Button Background"
|
|
|
+ className="pointer-events-none"
|
|
|
+ style={{
|
|
|
+ width: 'calc(100% + 1px)',
|
|
|
+ height: 'calc(100% + 1px)',
|
|
|
+ position: 'absolute',
|
|
|
+ top: '0',
|
|
|
+ left: '0',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* Send 图标 - 居中 - 响应式尺寸 */}
|
|
|
+ <img
|
|
|
+ src={sendIcon}
|
|
|
+ alt="Send"
|
|
|
+ className="pointer-events-none"
|
|
|
+ style={{
|
|
|
+ width: `${layout.recordButtonSize * 0.25}px`,
|
|
|
+ height: `${layout.recordButtonSize * 0.25}px`,
|
|
|
+ position: 'relative',
|
|
|
+ zIndex: 1,
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|