|
|
@@ -16,8 +16,6 @@ import type { Message, EnergyStoneSpawnMessage } 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 avatarHint from '@/assets/Avatar-hint.png';
|
|
|
import { AudioWave, type AudioWaveHandle } from '@/components/AudioWave';
|
|
|
@@ -33,6 +31,7 @@ import tooltipsProgressBar from '@/assets/tooltips-progress-bar.svg';
|
|
|
import missionEnergyStone from '@/assets/Mission-Energy-Stone.png';
|
|
|
import conversationEnergyStone from '@/assets/Conversation-Energy-Stone.png';
|
|
|
import { useEnergyStones, type EnergyStoneEventPayload } from '@/hooks/useEnergyStones';
|
|
|
+import { MissionEnergyStoneModal } from '@/components/MissionEnergyStoneModal';
|
|
|
|
|
|
const MAX_RECORDING_DURATION_MS = 60_000;
|
|
|
const AVATAR_C_UNLOCK_THRESHOLD_MS = 60_000;
|
|
|
@@ -40,6 +39,29 @@ const COUNTDOWN_THRESHOLD_MS = 10_000;
|
|
|
|
|
|
const WS_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:8000/ws';
|
|
|
|
|
|
+// GIF URL 常量
|
|
|
+const DEFAULT_AVATAR_GIF = 'https://tolan.oss-cn-hangzhou.aliyuncs.com/01-Default.gif';
|
|
|
+const POSITIVE_EMOTION_GIF = 'https://tolan.oss-cn-hangzhou.aliyuncs.com/04-Positive%20emotion.gif';
|
|
|
+const NEGATIVE_EMOTION_GIF = 'https://tolan.oss-cn-hangzhou.aliyuncs.com/05-Negative%20emotion.gif';
|
|
|
+const TALKING_GIF = 'https://tolan.oss-cn-hangzhou.aliyuncs.com/06-talking.gif';
|
|
|
+
|
|
|
+// 情绪分类函数
|
|
|
+const getEmotionCategory = (emotion: string): 'positive' | 'negative' | 'neutral' => {
|
|
|
+ // 正向情绪
|
|
|
+ const positiveEmotions = ['happy', 'excited', 'encouraging', 'satisfied', 'friendly'];
|
|
|
+ // 反向情绪
|
|
|
+ const negativeEmotions = ['sad', 'angry', 'worried', 'confused', 'sorry'];
|
|
|
+
|
|
|
+ if (positiveEmotions.includes(emotion)) {
|
|
|
+ return 'positive';
|
|
|
+ }
|
|
|
+ if (negativeEmotions.includes(emotion)) {
|
|
|
+ return 'negative';
|
|
|
+ }
|
|
|
+ // neutral, surprised, thoughtful 等归为中性
|
|
|
+ return 'neutral';
|
|
|
+};
|
|
|
+
|
|
|
export const ChatBotPage = () => {
|
|
|
const navigate = useNavigate();
|
|
|
const {
|
|
|
@@ -65,6 +87,8 @@ export const ChatBotPage = () => {
|
|
|
const wsRef = useRef<WebSocketManager | null>(null);
|
|
|
const chunkIndexRef = useRef(0);
|
|
|
const connectionLostRef = useRef(false);
|
|
|
+ const activeRequestIdRef = useRef<string | null>(null);
|
|
|
+ const cancelledRequestIdsRef = useRef<Set<string>>(new Set());
|
|
|
const mobileAudioPreparedRef = useRef(false);
|
|
|
const sendTimeRef = useRef<number>(0);
|
|
|
const energyStoneZoneRef = useRef<HTMLDivElement | null>(null);
|
|
|
@@ -91,9 +115,22 @@ export const ChatBotPage = () => {
|
|
|
return false;
|
|
|
});
|
|
|
const sendingTimeoutRef = useRef<number | null>(null);
|
|
|
+ const updateRequestId = useCallback(
|
|
|
+ (id: string | null) => {
|
|
|
+ activeRequestIdRef.current = id;
|
|
|
+ setRequestId(id);
|
|
|
+ },
|
|
|
+ [setRequestId]
|
|
|
+ );
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ activeRequestIdRef.current = currentRequestId;
|
|
|
+ }, [currentRequestId]);
|
|
|
const [showCancelTooltip, setShowCancelTooltip] = useState(false);
|
|
|
const [showSendTooltip, setShowSendTooltip] = useState(false);
|
|
|
const [showProgressBarTooltip, setShowProgressBarTooltip] = useState(false);
|
|
|
+ const [showMissionStoneModal, setShowMissionStoneModal] = useState(false);
|
|
|
+ const [energyStoneModalType, setEnergyStoneModalType] = useState<'mission' | 'conversation'>('mission');
|
|
|
const [hasShownProgressBarTooltip, setHasShownProgressBarTooltip] = useState(() => {
|
|
|
const stored = getStorage(STORAGE_KEYS.HAS_SHOWN_PROGRESS_BAR_TOOLTIP);
|
|
|
return stored === 'true';
|
|
|
@@ -143,7 +180,10 @@ export const ChatBotPage = () => {
|
|
|
const initialLayout = calculateLayout();
|
|
|
return initialLayout;
|
|
|
});
|
|
|
- const [isAvatarGifReady, setIsAvatarGifReady] = useState(false);
|
|
|
+ // 当前显示的头像 GIF URL
|
|
|
+ const [currentAvatarGif, setCurrentAvatarGif] = useState<string>(DEFAULT_AVATAR_GIF);
|
|
|
+ // GIF 切换定时器
|
|
|
+ const emotionGifTimerRef = useRef<number | null>(null);
|
|
|
|
|
|
const timingRef = useRef({
|
|
|
recordStart: 0,
|
|
|
@@ -184,38 +224,6 @@ export const ChatBotPage = () => {
|
|
|
[unlockAvatarC]
|
|
|
);
|
|
|
|
|
|
- useEffect(() => {
|
|
|
- let isCanceled = false;
|
|
|
- const preloader = new Image();
|
|
|
-
|
|
|
- const handleLoad = () => {
|
|
|
- if (!isCanceled) {
|
|
|
- setIsAvatarGifReady(true);
|
|
|
- }
|
|
|
- };
|
|
|
-
|
|
|
- const handleError = () => {
|
|
|
- if (!isCanceled) {
|
|
|
- setIsAvatarGifReady(false);
|
|
|
- }
|
|
|
- };
|
|
|
-
|
|
|
- preloader.addEventListener('load', handleLoad);
|
|
|
- preloader.addEventListener('error', handleError);
|
|
|
- preloader.decoding = 'sync';
|
|
|
- preloader.src = animaZoneAvatarGif;
|
|
|
-
|
|
|
- if (preloader.complete) {
|
|
|
- handleLoad();
|
|
|
- }
|
|
|
-
|
|
|
- return () => {
|
|
|
- isCanceled = true;
|
|
|
- preloader.removeEventListener('load', handleLoad);
|
|
|
- preloader.removeEventListener('error', handleError);
|
|
|
- };
|
|
|
- }, []);
|
|
|
-
|
|
|
const emitStoneEvent = useCallback(
|
|
|
(stoneId: string, event: 'click' | 'timeout', position: EnergyStoneEventPayload) => {
|
|
|
if (!wsRef.current) {
|
|
|
@@ -583,6 +591,32 @@ export const ChatBotPage = () => {
|
|
|
setIsAvatarCUnlocked(true);
|
|
|
setStorage(STORAGE_KEYS.AVATAR_C_UNLOCKED, 'true');
|
|
|
}
|
|
|
+ if (state.has_shown_mission_stone_modal) {
|
|
|
+ setStorage(STORAGE_KEYS.HAS_SHOWN_MISSION_STONE_MODAL, 'true');
|
|
|
+ }
|
|
|
+ if (state.has_shown_conversation_stone_modal) {
|
|
|
+ setStorage(STORAGE_KEYS.HAS_SHOWN_CONVERSATION_STONE_MODAL, 'true');
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果本地记录为 true 但后端为 false,尝试补偿同步
|
|
|
+ const localMissionShown = getStorage(STORAGE_KEYS.HAS_SHOWN_MISSION_STONE_MODAL) === 'true';
|
|
|
+ if (localMissionShown && !state.has_shown_mission_stone_modal) {
|
|
|
+ updateUserUIState(userId, {
|
|
|
+ has_shown_mission_stone_modal: true,
|
|
|
+ }).catch(() => {
|
|
|
+ // 静默处理,后续会继续尝试
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ const localConversationShown =
|
|
|
+ getStorage(STORAGE_KEYS.HAS_SHOWN_CONVERSATION_STONE_MODAL) === 'true';
|
|
|
+ if (localConversationShown && !state.has_shown_conversation_stone_modal) {
|
|
|
+ updateUserUIState(userId, {
|
|
|
+ has_shown_conversation_stone_modal: true,
|
|
|
+ }).catch(() => {
|
|
|
+ // 静默处理
|
|
|
+ });
|
|
|
+ }
|
|
|
})
|
|
|
.catch(() => {
|
|
|
// 拉取失败不影响主流程
|
|
|
@@ -641,6 +675,13 @@ export const ChatBotPage = () => {
|
|
|
performanceLogger.recordPlaybackEnd(requestId);
|
|
|
}
|
|
|
// 进度条已基于用户说话时长更新,无需在播放完成后再次更新
|
|
|
+ // 清理情绪 GIF 切换定时器
|
|
|
+ if (emotionGifTimerRef.current) {
|
|
|
+ clearTimeout(emotionGifTimerRef.current);
|
|
|
+ emotionGifTimerRef.current = null;
|
|
|
+ }
|
|
|
+ // 恢复默认头像 GIF
|
|
|
+ setCurrentAvatarGif(DEFAULT_AVATAR_GIF);
|
|
|
setState('idle');
|
|
|
reset();
|
|
|
});
|
|
|
@@ -665,6 +706,13 @@ export const ChatBotPage = () => {
|
|
|
recorderRef.current?.stop();
|
|
|
resetFrequencyVisualization();
|
|
|
playerRef.current?.stop();
|
|
|
+ // 清理情绪 GIF 切换定时器
|
|
|
+ if (emotionGifTimerRef.current) {
|
|
|
+ clearTimeout(emotionGifTimerRef.current);
|
|
|
+ emotionGifTimerRef.current = null;
|
|
|
+ }
|
|
|
+ // 恢复默认头像 GIF
|
|
|
+ setCurrentAvatarGif(DEFAULT_AVATAR_GIF);
|
|
|
reset();
|
|
|
setError(null);
|
|
|
}
|
|
|
@@ -686,6 +734,14 @@ export const ChatBotPage = () => {
|
|
|
resetFrequencyVisualization();
|
|
|
playerRef.current?.stop();
|
|
|
|
|
|
+ // 清理情绪 GIF 切换定时器
|
|
|
+ if (emotionGifTimerRef.current) {
|
|
|
+ clearTimeout(emotionGifTimerRef.current);
|
|
|
+ emotionGifTimerRef.current = null;
|
|
|
+ }
|
|
|
+ // 恢复默认头像 GIF
|
|
|
+ setCurrentAvatarGif(DEFAULT_AVATAR_GIF);
|
|
|
+
|
|
|
antMessage.error('语音服务连接断开,请稍后重试');
|
|
|
setError('语音服务连接断开,请稍后重试');
|
|
|
});
|
|
|
@@ -713,6 +769,9 @@ export const ChatBotPage = () => {
|
|
|
if (progressBarTooltipTimerRef.current) {
|
|
|
clearTimeout(progressBarTooltipTimerRef.current);
|
|
|
}
|
|
|
+ if (emotionGifTimerRef.current) {
|
|
|
+ clearTimeout(emotionGifTimerRef.current);
|
|
|
+ }
|
|
|
};
|
|
|
}, [resetFrequencyVisualization]);
|
|
|
|
|
|
@@ -723,9 +782,26 @@ export const ChatBotPage = () => {
|
|
|
console.warn('[ChatBotPage] 收到缺少 requestId 的消息,已忽略', message.type);
|
|
|
return;
|
|
|
}
|
|
|
+ const activeRequestId = activeRequestIdRef.current;
|
|
|
+ const isCancelledRequest = cancelledRequestIdsRef.current.has(requestId);
|
|
|
+
|
|
|
+ if (isCancelledRequest) {
|
|
|
+ if (message.type === 'audio_response' && message.isLast) {
|
|
|
+ cancelledRequestIdsRef.current.delete(requestId);
|
|
|
+ }
|
|
|
+ console.log(`[${message.type}] [${requestId.substring(0, 8)}] 已取消的请求,忽略响应`);
|
|
|
+ return;
|
|
|
+ }
|
|
|
|
|
|
switch (message.type) {
|
|
|
case 'text_result':
|
|
|
+ // 如果当前状态是 recording,且 requestId 不匹配当前请求,说明这是旧请求的返回,直接丢弃
|
|
|
+ // 如果 activeRequestId 为 null,说明是新请求的开始,允许处理
|
|
|
+ if (activeRequestId !== null && requestId !== activeRequestId) {
|
|
|
+ console.log(`[文本结果] [${requestId.substring(0, 8)}] 请求ID不匹配,丢弃旧请求的文本结果`);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
if (message.isFinal) {
|
|
|
performanceLogger.recordFinalText(requestId, message.text, message.timestamp);
|
|
|
} else if (!performanceLogger.hasFirstText(requestId)) {
|
|
|
@@ -735,11 +811,62 @@ export const ChatBotPage = () => {
|
|
|
break;
|
|
|
|
|
|
case 'emotion':
|
|
|
+ // 如果当前状态是 recording,且 requestId 不匹配当前请求,说明这是旧请求的返回,直接丢弃
|
|
|
+ // 如果 activeRequestId 为 null,说明是新请求的开始,允许处理
|
|
|
+ if (activeRequestId !== null && requestId !== activeRequestId) {
|
|
|
+ console.log(`[情绪] [${requestId.substring(0, 8)}] 请求ID不匹配,丢弃旧请求的情绪`);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
performanceLogger.recordEmotion(requestId, message.emotion, message.timestamp);
|
|
|
setEmotion(message.emotion);
|
|
|
+
|
|
|
+ // 获取最新状态,确保能正确判断是否正在播放
|
|
|
+ const appState = useAppStore.getState();
|
|
|
+ // 如果正在播放或发送,根据新收到的情绪更新 GIF
|
|
|
+ if (appState.currentState === 'playing' || appState.currentState === 'sending') {
|
|
|
+ const emotionCategory = getEmotionCategory(message.emotion);
|
|
|
+ if (emotionCategory === 'positive') {
|
|
|
+ // 正向情绪:先显示正向情绪 GIF,2秒后切换到 talking GIF
|
|
|
+ setCurrentAvatarGif(POSITIVE_EMOTION_GIF);
|
|
|
+ if (emotionGifTimerRef.current) {
|
|
|
+ clearTimeout(emotionGifTimerRef.current);
|
|
|
+ }
|
|
|
+ emotionGifTimerRef.current = window.setTimeout(() => {
|
|
|
+ setCurrentAvatarGif(TALKING_GIF);
|
|
|
+ emotionGifTimerRef.current = null;
|
|
|
+ }, 2000);
|
|
|
+ } else if (emotionCategory === 'negative') {
|
|
|
+ // 反向情绪:先显示反向情绪 GIF,5秒后切换到 talking GIF
|
|
|
+ setCurrentAvatarGif(NEGATIVE_EMOTION_GIF);
|
|
|
+ if (emotionGifTimerRef.current) {
|
|
|
+ clearTimeout(emotionGifTimerRef.current);
|
|
|
+ }
|
|
|
+ emotionGifTimerRef.current = window.setTimeout(() => {
|
|
|
+ setCurrentAvatarGif(TALKING_GIF);
|
|
|
+ emotionGifTimerRef.current = null;
|
|
|
+ }, 5000);
|
|
|
+ } else {
|
|
|
+ // 中性情绪:直接显示 talking GIF
|
|
|
+ setCurrentAvatarGif(TALKING_GIF);
|
|
|
+ }
|
|
|
+ }
|
|
|
break;
|
|
|
|
|
|
case 'audio_response':
|
|
|
+ // 如果当前状态是 recording,说明用户已经点击了录音按钮,忽略后续的音频响应
|
|
|
+ if (currentState === 'recording') {
|
|
|
+ console.log(`[音频响应] [${requestId.substring(0, 8)}] 用户已开始录音,忽略音频响应`);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果 currentRequestId 不为 null,且 requestId 不匹配当前请求,说明这是旧请求的返回,直接丢弃
|
|
|
+ // 如果 activeRequestId 为 null,说明是新请求的开始,允许处理
|
|
|
+ if (activeRequestId !== null && requestId !== activeRequestId) {
|
|
|
+ console.log(`[音频响应] [${requestId.substring(0, 8)}] 请求ID不匹配,丢弃旧请求的音频响应`);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
// 清除发送超时
|
|
|
if (sendingTimeoutRef.current) {
|
|
|
clearTimeout(sendingTimeoutRef.current);
|
|
|
@@ -750,6 +877,35 @@ export const ChatBotPage = () => {
|
|
|
performanceLogger.recordFirstAudio(requestId, message.timestamp);
|
|
|
performanceLogger.recordPlaybackStart(requestId);
|
|
|
setState('playing');
|
|
|
+
|
|
|
+ // 获取最新的情绪状态(可能情绪消息已经到达)
|
|
|
+ const latestEmotion = useAppStore.getState().currentEmotion;
|
|
|
+ // 根据情绪类型设置 GIF
|
|
|
+ const emotionCategory = getEmotionCategory(latestEmotion);
|
|
|
+ if (emotionCategory === 'positive') {
|
|
|
+ // 正向情绪:先显示正向情绪 GIF,2秒后切换到 talking GIF
|
|
|
+ setCurrentAvatarGif(POSITIVE_EMOTION_GIF);
|
|
|
+ if (emotionGifTimerRef.current) {
|
|
|
+ clearTimeout(emotionGifTimerRef.current);
|
|
|
+ }
|
|
|
+ emotionGifTimerRef.current = window.setTimeout(() => {
|
|
|
+ setCurrentAvatarGif(TALKING_GIF);
|
|
|
+ emotionGifTimerRef.current = null;
|
|
|
+ }, 2000);
|
|
|
+ } else if (emotionCategory === 'negative') {
|
|
|
+ // 反向情绪:先显示反向情绪 GIF,5秒后切换到 talking GIF
|
|
|
+ setCurrentAvatarGif(NEGATIVE_EMOTION_GIF);
|
|
|
+ if (emotionGifTimerRef.current) {
|
|
|
+ clearTimeout(emotionGifTimerRef.current);
|
|
|
+ }
|
|
|
+ emotionGifTimerRef.current = window.setTimeout(() => {
|
|
|
+ setCurrentAvatarGif(TALKING_GIF);
|
|
|
+ emotionGifTimerRef.current = null;
|
|
|
+ }, 5000);
|
|
|
+ } else {
|
|
|
+ // 中性情绪或无情绪:直接显示 talking GIF
|
|
|
+ setCurrentAvatarGif(TALKING_GIF);
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
if (message.data) {
|
|
|
@@ -778,6 +934,13 @@ export const ChatBotPage = () => {
|
|
|
console.error(`[错误] [${requestId?.substring(0, 8) || ''}]`, message.message, message.code);
|
|
|
antMessage.error(message.message);
|
|
|
setError(message.message);
|
|
|
+ // 清理情绪 GIF 切换定时器
|
|
|
+ if (emotionGifTimerRef.current) {
|
|
|
+ clearTimeout(emotionGifTimerRef.current);
|
|
|
+ emotionGifTimerRef.current = null;
|
|
|
+ }
|
|
|
+ // 恢复默认头像 GIF
|
|
|
+ setCurrentAvatarGif(DEFAULT_AVATAR_GIF);
|
|
|
performanceLogger.clearRequest(requestId, `${message.code}: ${message.message}`);
|
|
|
break;
|
|
|
|
|
|
@@ -807,9 +970,40 @@ export const ChatBotPage = () => {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- // 如果正在播放,先停止播放
|
|
|
- if (currentState === 'playing') {
|
|
|
+ // 保存旧请求ID(用于取消和过滤旧消息)
|
|
|
+ const oldRequestId = currentRequestId;
|
|
|
+ const wasPlaying = currentState === 'playing' || currentState === 'sending';
|
|
|
+
|
|
|
+ // 如果正在播放,先停止播放并重置状态
|
|
|
+ if (wasPlaying) {
|
|
|
+ // 停止音频播放
|
|
|
playerRef.current?.stop();
|
|
|
+
|
|
|
+ // 清理情绪 GIF 切换定时器
|
|
|
+ if (emotionGifTimerRef.current) {
|
|
|
+ clearTimeout(emotionGifTimerRef.current);
|
|
|
+ emotionGifTimerRef.current = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 立即恢复默认头像 GIF
|
|
|
+ setCurrentAvatarGif(DEFAULT_AVATAR_GIF);
|
|
|
+
|
|
|
+ // 取消当前的请求(如果有),丢弃上一次请求的返回
|
|
|
+ if (oldRequestId) {
|
|
|
+ wsRef.current?.sendCancel(oldRequestId);
|
|
|
+ cancelledRequestIdsRef.current.add(oldRequestId);
|
|
|
+ performanceLogger.clearRequest(oldRequestId, '用户中断播放开始新录音');
|
|
|
+ }
|
|
|
+
|
|
|
+ // 清理旧请求的状态(丢弃上一次请求的返回)
|
|
|
+ setRecognizedText('');
|
|
|
+ setEmotion('neutral');
|
|
|
+
|
|
|
+ // 清理发送超时
|
|
|
+ if (sendingTimeoutRef.current) {
|
|
|
+ clearTimeout(sendingTimeoutRef.current);
|
|
|
+ sendingTimeoutRef.current = null;
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
// 其他状态(idle/error/sending)都允许开始录音
|
|
|
@@ -822,27 +1016,31 @@ export const ChatBotPage = () => {
|
|
|
// 点击录音按钮时,隐藏进度条提示
|
|
|
dismissProgressBarTooltip();
|
|
|
|
|
|
- if (isMobileDevice() && !mobileAudioPreparedRef.current) {
|
|
|
- try {
|
|
|
- await prepareMobileAudio();
|
|
|
- requestVolumeControl();
|
|
|
- mobileAudioPreparedRef.current = true;
|
|
|
- antMessage.info('请确保设备音量已调高');
|
|
|
- } catch (error) {
|
|
|
- // 准备移动端音频失败,静默处理
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
// 🔥 清理播放器状态,确保上一次对话的残留状态被重置
|
|
|
playerRef.current?.stop();
|
|
|
|
|
|
+ // 生成新的请求ID并立即设置,确保后续消息能正确匹配
|
|
|
const requestId = uuidv4();
|
|
|
performanceLogger.startRequest(requestId);
|
|
|
- setRequestId(requestId);
|
|
|
+ updateRequestId(requestId);
|
|
|
setState('recording');
|
|
|
chunkIndexRef.current = 0;
|
|
|
setRecognizedText('');
|
|
|
|
|
|
+ // 准备移动端音频(在设置状态之后,避免阻塞状态更新)
|
|
|
+ if (isMobileDevice() && !mobileAudioPreparedRef.current) {
|
|
|
+ // 异步准备,不阻塞录音开始
|
|
|
+ prepareMobileAudio()
|
|
|
+ .then(() => {
|
|
|
+ requestVolumeControl();
|
|
|
+ mobileAudioPreparedRef.current = true;
|
|
|
+ antMessage.info('请确保设备音量已调高');
|
|
|
+ })
|
|
|
+ .catch(() => {
|
|
|
+ // 准备移动端音频失败,静默处理
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
timingRef.current = {
|
|
|
recordStart: Date.now(),
|
|
|
recordEnd: 0,
|
|
|
@@ -850,8 +1048,15 @@ export const ChatBotPage = () => {
|
|
|
emotionReceived: 0,
|
|
|
};
|
|
|
|
|
|
+ // 立即启动录音,确保第一次点击就能开始
|
|
|
try {
|
|
|
await recorderRef.current?.start((pcmData) => {
|
|
|
+ // 检查当前状态,如果已经不是recording,说明用户可能又点击了,忽略
|
|
|
+ const currentStateCheck = useAppStore.getState().currentState;
|
|
|
+ if (currentStateCheck !== 'recording') {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
if (chunkIndexRef.current === 0) {
|
|
|
performanceLogger.recordFirstChunkSent(requestId);
|
|
|
}
|
|
|
@@ -860,16 +1065,25 @@ export const ChatBotPage = () => {
|
|
|
wsRef.current?.sendAudioChunk(requestId, base64, chunkIndexRef.current);
|
|
|
chunkIndexRef.current++;
|
|
|
}, pushFrequencyFrame);
|
|
|
+
|
|
|
+ // 录音启动成功,开始计时
|
|
|
+ startRecordingTimer();
|
|
|
} catch (error) {
|
|
|
console.error(`[录音] [${requestId.substring(0, 8)}] 启动失败`, error);
|
|
|
antMessage.error('麦克风启动失败,请检查权限或重试');
|
|
|
performanceLogger.clearRequest(requestId, 'recorder_start_failed');
|
|
|
resetFrequencyVisualization();
|
|
|
+ // 清理情绪 GIF 切换定时器
|
|
|
+ if (emotionGifTimerRef.current) {
|
|
|
+ clearTimeout(emotionGifTimerRef.current);
|
|
|
+ emotionGifTimerRef.current = null;
|
|
|
+ }
|
|
|
+ // 恢复默认头像 GIF
|
|
|
+ setCurrentAvatarGif(DEFAULT_AVATAR_GIF);
|
|
|
setState('idle');
|
|
|
reset();
|
|
|
return;
|
|
|
}
|
|
|
- startRecordingTimer();
|
|
|
};
|
|
|
|
|
|
// 发送录音
|
|
|
@@ -884,6 +1098,13 @@ export const ChatBotPage = () => {
|
|
|
antMessage.error('语音服务未连接,请重新录音发送');
|
|
|
recorderRef.current?.stop();
|
|
|
resetFrequencyVisualization();
|
|
|
+ // 清理情绪 GIF 切换定时器
|
|
|
+ if (emotionGifTimerRef.current) {
|
|
|
+ clearTimeout(emotionGifTimerRef.current);
|
|
|
+ emotionGifTimerRef.current = null;
|
|
|
+ }
|
|
|
+ // 恢复默认头像 GIF
|
|
|
+ setCurrentAvatarGif(DEFAULT_AVATAR_GIF);
|
|
|
performanceLogger.clearRequest(currentRequestId, 'websocket_disconnected_before_send');
|
|
|
reset();
|
|
|
return;
|
|
|
@@ -893,6 +1114,13 @@ export const ChatBotPage = () => {
|
|
|
antMessage.error('语音服务尚未准备完成,请重新录音发送');
|
|
|
recorderRef.current?.stop();
|
|
|
resetFrequencyVisualization();
|
|
|
+ // 清理情绪 GIF 切换定时器
|
|
|
+ if (emotionGifTimerRef.current) {
|
|
|
+ clearTimeout(emotionGifTimerRef.current);
|
|
|
+ emotionGifTimerRef.current = null;
|
|
|
+ }
|
|
|
+ // 恢复默认头像 GIF
|
|
|
+ setCurrentAvatarGif(DEFAULT_AVATAR_GIF);
|
|
|
performanceLogger.clearRequest(currentRequestId, 'websocket_unready_before_send');
|
|
|
reset();
|
|
|
return;
|
|
|
@@ -928,6 +1156,13 @@ export const ChatBotPage = () => {
|
|
|
const shortId = currentRequestId ? currentRequestId.substring(0, 8) : '';
|
|
|
console.warn(`[超时] [${shortId}] 响应超时,自动重置`);
|
|
|
antMessage.warning('响应超时,请重试');
|
|
|
+ // 清理情绪 GIF 切换定时器
|
|
|
+ if (emotionGifTimerRef.current) {
|
|
|
+ clearTimeout(emotionGifTimerRef.current);
|
|
|
+ emotionGifTimerRef.current = null;
|
|
|
+ }
|
|
|
+ // 恢复默认头像 GIF
|
|
|
+ setCurrentAvatarGif(DEFAULT_AVATAR_GIF);
|
|
|
setState('idle');
|
|
|
reset();
|
|
|
performanceLogger.clearRequest(currentRequestId, '响应超时');
|
|
|
@@ -947,17 +1182,23 @@ export const ChatBotPage = () => {
|
|
|
resetFrequencyVisualization();
|
|
|
stopRecordingTimer();
|
|
|
|
|
|
+ // 清理情绪 GIF 切换定时器
|
|
|
+ if (emotionGifTimerRef.current) {
|
|
|
+ clearTimeout(emotionGifTimerRef.current);
|
|
|
+ emotionGifTimerRef.current = null;
|
|
|
+ }
|
|
|
+ // 恢复默认头像 GIF
|
|
|
+ setCurrentAvatarGif(DEFAULT_AVATAR_GIF);
|
|
|
+
|
|
|
wsRef.current?.sendCancel(currentRequestId);
|
|
|
+ cancelledRequestIdsRef.current.add(currentRequestId);
|
|
|
performanceLogger.clearRequest(currentRequestId);
|
|
|
reset();
|
|
|
};
|
|
|
|
|
|
const avatarHintSize = Math.round(layout.avatarCSize * 1.8);
|
|
|
|
|
|
- const isAvatarAnimating = currentState === 'playing';
|
|
|
- const currentAvatarSrc =
|
|
|
- isAvatarAnimating && isAvatarGifReady ? animaZoneAvatarGif : animaZoneAvatar;
|
|
|
- const currentAvatarAlt = isAvatarAnimating ? 'AnimaZone Avatar Animated' : 'AnimaZone Avatar';
|
|
|
+ const currentAvatarAlt = currentState === 'playing' ? 'AnimaZone Avatar Animated' : 'AnimaZone Avatar';
|
|
|
const energyStoneAssets: Record<'mission' | 'conversation', string> = {
|
|
|
mission: missionEnergyStone,
|
|
|
conversation: conversationEnergyStone,
|
|
|
@@ -1088,7 +1329,49 @@ export const ChatBotPage = () => {
|
|
|
className="energy-stone"
|
|
|
data-type={stone.stoneType}
|
|
|
data-fading={stone.fading ? 'true' : 'false'}
|
|
|
- onClick={() => handleStoneClick(stone.stoneId)}
|
|
|
+ onClick={async () => {
|
|
|
+ handleStoneClick(stone.stoneId);
|
|
|
+
|
|
|
+ // 检查是否是第一次点击,决定是否显示弹窗
|
|
|
+ const storageKey = stone.stoneType === 'mission'
|
|
|
+ ? STORAGE_KEYS.HAS_SHOWN_MISSION_STONE_MODAL
|
|
|
+ : STORAGE_KEYS.HAS_SHOWN_CONVERSATION_STONE_MODAL;
|
|
|
+
|
|
|
+ const hasShown = getStorage(storageKey);
|
|
|
+
|
|
|
+ if (hasShown !== 'true') {
|
|
|
+ // 第一次点击,显示弹窗
|
|
|
+ setEnergyStoneModalType(stone.stoneType);
|
|
|
+ setShowMissionStoneModal(true);
|
|
|
+
|
|
|
+ // 同步到数据库(成功后再更新 localStorage)
|
|
|
+ const userId = getUserId();
|
|
|
+ let synced = false;
|
|
|
+ if (userId) {
|
|
|
+ try {
|
|
|
+ if (stone.stoneType === 'mission') {
|
|
|
+ await updateUserUIState(userId, {
|
|
|
+ has_shown_mission_stone_modal: true,
|
|
|
+ });
|
|
|
+ } else {
|
|
|
+ await updateUserUIState(userId, {
|
|
|
+ has_shown_conversation_stone_modal: true,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ synced = true;
|
|
|
+ } catch (error) {
|
|
|
+ console.error('同步能量石弹窗状态到数据库失败:', error);
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // 没有 userId(游客态),仅更新本地,等获取 userId 后再补偿
|
|
|
+ synced = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (synced) {
|
|
|
+ setStorage(storageKey, 'true');
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }}
|
|
|
style={{
|
|
|
width: px2vmin(baseSize),
|
|
|
height: px2vmin(baseSize),
|
|
|
@@ -1121,7 +1404,7 @@ export const ChatBotPage = () => {
|
|
|
}}
|
|
|
>
|
|
|
<img
|
|
|
- src={currentAvatarSrc}
|
|
|
+ src={currentAvatarGif}
|
|
|
alt={currentAvatarAlt}
|
|
|
loading="eager"
|
|
|
decoding="sync"
|
|
|
@@ -1562,6 +1845,13 @@ export const ChatBotPage = () => {
|
|
|
正在重连语音服务...
|
|
|
</div>
|
|
|
)}
|
|
|
+
|
|
|
+ {/* 能量石弹窗 */}
|
|
|
+ <MissionEnergyStoneModal
|
|
|
+ visible={showMissionStoneModal}
|
|
|
+ onClose={() => setShowMissionStoneModal(false)}
|
|
|
+ stoneType={energyStoneModalType}
|
|
|
+ />
|
|
|
</div>
|
|
|
);
|
|
|
};
|