This commit is contained in:
2025-12-13 14:46:10 +08:00
parent ed1688be6c
commit aa7852e9dd

View File

@@ -0,0 +1,605 @@
import React, { useEffect, useRef, useState } from 'react';
import { loadXmovSDK } from '../utils/sdkLoader';
import { getXmovConfig, isXmovConfigValid } from '../services/xmovConfig';
import { WebSocketService, WebSocketMessage } from '../services/websocketService';
import type { XmovAvatarSDK } from '../types/xmov';
interface XmovAvatarProps {
isActive?: boolean;
websocketUrl?: string;
onSDKReady?: () => void;
onSDKError?: (error: any) => void;
onSpeaking?: (isSpeaking: boolean) => void;
onSDKStatusChange?: (status: 'loading' | 'ready' | 'error' | 'config-missing') => void;
onWSStatusChange?: (status: 'disconnected' | 'connecting' | 'connected') => void;
onLogMessage?: (message: string) => void;
}
/**
* Xmov 数字人组件
* 连接到 WebSocket (10002端口) 接收消息驱动数字人说话
* 与 XmovAvatarSDK 原项目保持一致
*/
export const XmovAvatar: React.FC<XmovAvatarProps> = ({
isActive: _isActive = false,
websocketUrl = 'ws://127.0.0.1:10002',
onSDKReady,
onSDKError,
onSpeaking,
onSDKStatusChange,
onWSStatusChange,
onLogMessage,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const sdkRef = useRef<XmovAvatarSDK | null>(null);
const wsServiceRef = useRef<WebSocketService | null>(null);
const [sdkStatus, setSDKStatus] = useState<'loading' | 'ready' | 'error' | 'config-missing'>('loading');
const [wsStatus, setWsStatus] = useState<'disconnected' | 'connecting' | 'connected'>('disconnected');
const [errorMessage, setErrorMessage] = useState<string>('');
const [loadingProgress, setLoadingProgress] = useState<number>(0);
const isConversationActiveRef = useRef<boolean>(false); // 追踪当前对话是否在进行中
const hasSpeakStartedRef = useRef<boolean>(false); // 追踪当前对话是否已开始播放(用于处理 think 标签后的第一次播放)
const isThinkingRef = useRef<boolean>(false); // 追踪是否正在思考中(<think> 和 </think> 之间)
const conversationTimeoutRef = useRef<number | null>(null); // 对话超时定时器
// 使用 ref 保存最新的回调函数,避免触发 useEffect 重新执行
const onSDKReadyRef = useRef(onSDKReady);
const onSDKErrorRef = useRef(onSDKError);
const onSpeakingRef = useRef(onSpeaking);
const onSDKStatusChangeRef = useRef(onSDKStatusChange);
const onWSStatusChangeRef = useRef(onWSStatusChange);
useEffect(() => {
onSDKReadyRef.current = onSDKReady;
onSDKErrorRef.current = onSDKError;
onSpeakingRef.current = onSpeaking;
onSDKStatusChangeRef.current = onSDKStatusChange;
onWSStatusChangeRef.current = onWSStatusChange;
});
// 当状态变化时通知父组件
useEffect(() => {
onSDKStatusChangeRef.current?.(sdkStatus);
}, [sdkStatus]);
useEffect(() => {
onWSStatusChangeRef.current?.(wsStatus);
}, [wsStatus]);
useEffect(() => {
let mounted = true;
let initTimeout: number | null = null;
const initializeSDK = async () => {
try {
// 检查配置
const config = getXmovConfig();
if (!isXmovConfigValid(config)) {
console.error('[xmov] 配置无效');
setSDKStatus('config-missing');
setErrorMessage('请配置 XMOV_APP_ID 和 XMOV_APP_SECRET');
return;
}
// 加载 SDK
console.log('[xmov] 正在加载SDK...');
await loadXmovSDK();
if (!mounted) return;
// 创建 SDK 实例
console.log('[xmov] 正在创建SDK实例...');
const containerId = `xmov-container-${Date.now()}`;
if (containerRef.current) {
containerRef.current.id = containerId;
}
initTimeout = window.setTimeout(() => {
console.error('[xmov] SDK初始化超时');
setSDKStatus('error');
setErrorMessage('SDK初始化超时');
onSDKErrorRef.current?.(new Error('SDK initialization timeout'));
}, 30000);
const sdk = new window.XmovAvatar({
containerId: `#${containerId}`,
appId: config.appId,
appSecret: config.appSecret,
gatewayServer: config.gatewayServer || 'https://nebula-agent.xingyun3d.com/user/v1/ttsa/session',
onWidgetEvent(data: any) {
console.log('[xmov] Widget事件:', data);
},
onNetworkInfo(networkInfo: any) {
console.log('[xmov] 网络信息:', networkInfo);
},
onMessage(message: any) {
console.log('[xmov] 📩 SDK消息:', message);
// 检查是否有音频相关的消息
if (message && typeof message === 'object') {
if (message.type === 'audio' || message.audio) {
console.log('[xmov] 🔊 收到音频消息:', message);
}
}
},
onStateChange(state: string) {
console.log('[xmov] 状态变化:', state);
},
onStatusChange(status: string) {
console.log('[xmov] SDK状态:', status);
},
onStateRenderChange(state: string, duration: number) {
console.log('[xmov] 渲染状态:', state, duration);
},
onVoiceStateChange(status: 'start' | 'end') {
console.log('[xmov] 🎵 音频状态变化:', status);
if (status === 'start') {
console.log('[xmov] 🎵 数字人开始说话');
onSpeakingRef.current?.(true);
} else if (status === 'end') {
console.log('[xmov] 🎵 数字人结束说话');
onSpeakingRef.current?.(false);
}
},
enableLogger: true,
});
sdkRef.current = sdk;
// 初始化 SDK
await sdk.init({
onDownloadProgress: (progress: number) => {
console.log('[xmov] 加载资源:', progress + '%');
setLoadingProgress(progress);
},
onError: (error: any) => {
console.error('[xmov] SDK错误:', error);
if (initTimeout) clearTimeout(initTimeout);
setSDKStatus('error');
setErrorMessage('SDK初始化失败');
onSDKErrorRef.current?.(error);
},
onClose: () => {
console.log('[xmov] SDK连接关闭');
},
});
if (!mounted) return;
if (initTimeout) clearTimeout(initTimeout);
console.log('[xmov] SDK初始化成功');
// 检查音频上下文状态
console.log('[xmov] 🔊 检查浏览器音频支持...');
if (typeof AudioContext !== 'undefined' || typeof (window as any).webkitAudioContext !== 'undefined') {
console.log('[xmov] ✅ 浏览器支持 Web Audio API');
// 检查自动播放策略
const checkAutoplay = async () => {
try {
const AudioContextClass = (window as any).AudioContext || (window as any).webkitAudioContext;
const audioCtx = new AudioContextClass();
console.log('[xmov] 🔊 AudioContext 状态:', audioCtx.state);
if (audioCtx.state === 'suspended') {
console.warn('[xmov] ⚠️ AudioContext 被暂停,尝试恢复...');
await audioCtx.resume();
console.log('[xmov] ✅ AudioContext 已恢复');
}
audioCtx.close();
} catch (error) {
console.error('[xmov] ❌ AudioContext 检查失败:', error);
}
};
checkAutoplay();
} else {
console.error('[xmov] ❌ 浏览器不支持 Web Audio API');
}
setSDKStatus('ready');
onSDKReadyRef.current?.();
// 检查SDK实例的所有方法和属性
console.log('[xmov] 🔍 SDK实例方法:', Object.getOwnPropertyNames(Object.getPrototypeOf(sdk)));
console.log('[xmov] 🔍 SDK实例属性:', Object.keys(sdk));
// 暴露SDK实例到全局方便在控制台调试
(window as any).xmovSDK = sdk;
console.log('[xmov] 💡 SDK实例已暴露到 window.xmovSDK可在控制台检查');
// 暴露测试函数到全局,方便调试
(window as any).testXmovSpeak = (text: string = '你好,这是测试') => {
console.log('[xmov] 🧪 测试speak功能:', text);
if (sdkRef.current) {
console.log('[xmov] 🧪 SDK实例存在调用speak...');
try {
sdkRef.current.speak(text, true, true);
console.log('[xmov] 🧪 speak调用完成');
} catch (error) {
console.error('[xmov] 🧪 speak调用失败:', error);
}
} else {
console.error('[xmov] 🧪 SDK实例不存在');
}
};
console.log('[xmov] 💡 提示:在控制台输入 testXmovSpeak("测试文字") 来测试音频播放');
// SDK 就绪后,连接 WebSocket
connectWebSocket();
} catch (error) {
console.error('[xmov] 初始化错误:', error);
if (initTimeout) clearTimeout(initTimeout);
if (mounted) {
setSDKStatus('error');
setErrorMessage(error instanceof Error ? error.message : '未知错误');
onSDKErrorRef.current?.(error);
}
}
};
const connectWebSocket = () => {
if (wsServiceRef.current) {
wsServiceRef.current.disconnect();
}
wsServiceRef.current = new WebSocketService({
url: websocketUrl,
onConnect: () => {
setWsStatus('connected');
},
onDisconnect: () => {
setWsStatus('disconnected');
},
onError: () => {
setWsStatus('disconnected');
},
onMessage: (message: WebSocketMessage) => {
handleWebSocketMessage(message);
},
});
setWsStatus('connecting');
wsServiceRef.current.connect();
};
// 清除对话超时定时器
const clearConversationTimeout = () => {
if (conversationTimeoutRef.current) {
clearTimeout(conversationTimeoutRef.current);
conversationTimeoutRef.current = null;
console.log('[xmov] ⏱️ 清除对话超时定时器');
}
};
// 设置对话超时定时器30秒无消息自动结束
const resetConversationTimeout = () => {
clearConversationTimeout();
if (isConversationActiveRef.current && hasSpeakStartedRef.current) {
conversationTimeoutRef.current = window.setTimeout(() => {
console.warn('[xmov] ⏰ 对话超时30秒未收到结束信号强制结束对话');
if (sdkRef.current && hasSpeakStartedRef.current) {
try {
sdkRef.current.speak(' ', false, true);
console.log('[xmov] ✅ 已发送超时结束信号');
} catch (error) {
console.error('[xmov] ❌ 发送超时结束信号失败:', error);
}
}
isConversationActiveRef.current = false;
hasSpeakStartedRef.current = false;
isThinkingRef.current = false;
}, 30000); // 30秒超时
console.log('[xmov] ⏱️ 设置对话超时定时器30秒');
}
};
// 确保 AudioContext 处于运行状态修复iOS和浏览器自动播放策略问题
const ensureAudioContextRunning = async () => {
try {
if (typeof AudioContext === 'undefined' && typeof (window as any).webkitAudioContext === 'undefined') {
console.warn('[xmov] ⚠️ 浏览器不支持 AudioContext');
return;
}
const AudioContextClass = (window as any).AudioContext || (window as any).webkitAudioContext;
// 尝试获取全局的 audioContext如果SDK暴露了的话
const audioCtx = (window as any).audioContext || new AudioContextClass();
if (audioCtx.state === 'suspended') {
console.warn('[xmov] ⚠️ AudioContext 处于暂停状态,尝试恢复...');
await audioCtx.resume();
console.log('[xmov] ✅ AudioContext 已恢复到运行状态:', audioCtx.state);
} else {
console.log('[xmov] ✅ AudioContext 状态正常:', audioCtx.state);
}
// 保存到全局以便后续检查
(window as any).audioContext = audioCtx;
} catch (error) {
console.error('[xmov] ❌ AudioContext 检查/恢复失败:', error);
}
};
const handleWebSocketMessage = async (message: WebSocketMessage) => {
console.log('[xmov] 📨 handleWebSocketMessage 被调用');
console.log('[xmov] 📨 消息内容:', JSON.stringify(message, null, 2));
if (!sdkRef.current) {
console.error('[xmov] ❌ SDK未就绪或speak方法不可用');
return;
}
const { Data } = message;
let text = '';
const isFirst = Data.IsFirst === 1;
const isEnd = Data.IsEnd === 1;
// 处理 log 消息(用于思考状态切换)
if (Data.Key === 'log') {
const logText = Data.Value || '';
console.log('[xmov] 📝 收到日志消息:', logText);
// 传递log消息给父组件显示
if (onLogMessage) {
onLogMessage(logText);
}
// 检测是否是思考状态
if (logText.includes('思考')) {
console.log('[xmov] 🧠 检测到思考状态日志,调用 SDK think()');
isThinkingRef.current = true;
try {
sdkRef.current.think();
console.log('[xmov] ✅ SDK 已切换到思考状态');
} catch (error) {
console.error('[xmov] ❌ 切换到思考状态失败:', error);
}
}
return; // log 消息不需要播放
}
if (Data.Key === 'text') {
text = Data.Value || '';
} else if (Data.Key === 'audio') {
text = Data.Text || '';
}
console.log('[xmov] 📋 提取的文本:', text);
console.log('[xmov] 📋 isFirst:', isFirst, 'isEnd:', isEnd);
// 检测到新对话开始isFirst=1
if (isFirst) {
// 清除旧的超时定时器
clearConversationTimeout();
// 如果上一轮对话未结束,强制结束
if (isConversationActiveRef.current && hasSpeakStartedRef.current) {
console.warn('[xmov] ⚠️ 检测到新对话开始,上一轮对话未结束,强制发送结束信号');
try {
// 先发送一个 is_end=true 来结束上一轮对话
sdkRef.current.speak(' ', false, true);
console.log('[xmov] ✅ 步骤1已发送结束信号 (is_start=false, is_end=true)');
// 🔧 根据SDK文档要求speak不允许连续调用必须中间调用状态切换方法
// 调用 interactiveIdle() 切换状态避免连续speak调用
sdkRef.current.interactiveIdle();
console.log('[xmov] ✅ 步骤2已切换到待机互动状态避免连续speak');
} catch (error) {
console.error('[xmov] ❌ 强制结束对话失败:', error);
}
}
// 重置状态,开始新对话
isConversationActiveRef.current = true;
hasSpeakStartedRef.current = false;
isThinkingRef.current = false; // 新对话开始,重置思考状态
console.log('[xmov] 🆕 新对话开始');
}
if (text && text.trim()) {
const thinkStartIndex = text.indexOf('<think>');
const thinkEndIndex = text.indexOf('</think>');
// 检测思考结束标签(<think> 通过 log 消息处理)
if (thinkStartIndex !== -1 && !isThinkingRef.current) {
// 如果在 text 消息中检测到 <think>,也切换状态(兼容处理)
isThinkingRef.current = true;
console.log('[xmov] 🧠 在 text 消息中检测到 <think>,调用 SDK think()');
try {
sdkRef.current.think();
console.log('[xmov] ✅ SDK 已切换到思考状态');
} catch (error) {
console.error('[xmov] ❌ 切换到思考状态失败:', error);
}
}
if (thinkEndIndex !== -1 && isThinkingRef.current) {
isThinkingRef.current = false;
console.log('[xmov] 🧠 检测到 </think>,退出思考模式');
// 注意:不需要调用 interactiveIdle因为接下来会调用 speak
}
let contentToSpeak = '';
// 情况1: 当前正在思考中,且消息中没有 </think>
if (isThinkingRef.current && thinkEndIndex === -1) {
console.log('[xmov] 🧠 正在思考中,跳过内容播放');
console.log('[xmov] 原始文本:', text);
// 如果是 isEnd必须发送结束信号给 SDK
if (isEnd && hasSpeakStartedRef.current) {
console.log('[xmov] 📢 虽然在思考中,但必须发送 is_end=true 结束对话');
try {
sdkRef.current.speak(' ', false, true);
console.log('[xmov] ✅ 已发送结束信号');
} catch (error) {
console.error('[xmov] ❌ 发送结束信号失败:', error);
}
isConversationActiveRef.current = false;
hasSpeakStartedRef.current = false;
}
return; // 不播放思考内容
}
// 情况2: 同时包含 <think> 和 </think>(在同一条消息中完成思考)
else if (thinkStartIndex !== -1 && thinkEndIndex !== -1 && thinkStartIndex < thinkEndIndex) {
const beforeThink = text.substring(0, thinkStartIndex);
const afterThink = text.substring(thinkEndIndex + 8); // 8 是 '</think>' 的长度
contentToSpeak = (beforeThink + afterThink).trim();
console.log('[xmov] 🧠 检测到完整的 <think> 标签,移除思考内容');
console.log('[xmov] 原始文本:', text);
console.log('[xmov] 移除思考后:', contentToSpeak);
}
// 情况3: 只有 </think>(跨消息思考结束)
else if (thinkStartIndex === -1 && thinkEndIndex !== -1) {
contentToSpeak = text.substring(thinkEndIndex + 8).trim();
console.log('[xmov] 🧠 检测到 </think> 标签(跨消息思考结束),提取后续内容:', contentToSpeak);
}
// 情况4: 没有思考标签,正常内容
else {
contentToSpeak = text;
}
// 如果有内容要播放
if (contentToSpeak && contentToSpeak.trim()) {
const shouldBeStart = !hasSpeakStartedRef.current;
console.log('[xmov] 🔊 准备调用speak');
console.log('[xmov] 🔊 参数: contentToSpeak=', contentToSpeak);
console.log('[xmov] 🔊 参数: shouldBeStart=', shouldBeStart);
console.log('[xmov] 🔊 参数: isEnd=', isEnd);
console.log('[xmov] 🔊 状态: hasSpeakStartedRef=', hasSpeakStartedRef.current);
console.log('[xmov] 🔊 状态: isConversationActiveRef=', isConversationActiveRef.current);
console.log('[xmov] 🔊 SDK实例状态:', {
sdkExists: !!sdkRef.current,
speakMethod: typeof sdkRef.current?.speak,
});
try {
// 🔧 在调用speak之前确保AudioContext处于运行状态
await ensureAudioContextRunning();
console.log('[xmov] 🔊 ===== 正在调用 sdkRef.current.speak() =====');
sdkRef.current.speak(contentToSpeak, shouldBeStart, isEnd);
console.log('[xmov] 🔊 ===== speak() 调用完成,无异常 =====');
hasSpeakStartedRef.current = true; // 标记已开始播放
if (isEnd) {
// 对话结束,清除超时定时器
clearConversationTimeout();
isConversationActiveRef.current = false;
hasSpeakStartedRef.current = false;
console.log('[xmov] 🔊 对话已结束,状态已重置');
} else {
// 对话继续,重置超时定时器
resetConversationTimeout();
console.log('[xmov] 🔊 对话进行中,已重置超时定时器');
}
console.log('[xmov] 🔊 最终状态: 对话状态=', isConversationActiveRef.current ? '进行中' : '已结束');
} catch (error) {
console.error('[xmov] ❌ speak调用失败:', error);
console.error('[xmov] ❌ 错误详情:', error);
}
} else {
console.log('[xmov] ⚠️ 移除思考内容后无有效内容, contentToSpeak=', contentToSpeak);
// 如果是 isEnd必须发送结束信号给 SDK即使没有内容
if (isEnd && hasSpeakStartedRef.current) {
console.log('[xmov] 📢 虽然无内容,但必须发送 is_end=true 结束对话');
try {
sdkRef.current.speak(' ', false, true);
console.log('[xmov] ✅ 已发送结束信号');
} catch (error) {
console.error('[xmov] ❌ 发送结束信号失败:', error);
}
clearConversationTimeout();
isConversationActiveRef.current = false;
hasSpeakStartedRef.current = false;
}
}
}
};
initializeSDK();
return () => {
mounted = false;
if (initTimeout) clearTimeout(initTimeout);
// 清理对话超时定时器
clearConversationTimeout();
// 清理 WebSocket
if (wsServiceRef.current) {
wsServiceRef.current.disconnect();
wsServiceRef.current = null;
}
// 清理 SDK
if (sdkRef.current) {
try {
sdkRef.current.destroy();
console.log('[xmov] SDK已销毁');
} catch (error) {
console.warn('[xmov] 销毁SDK时出错:', error);
}
sdkRef.current = null;
}
};
}, [websocketUrl]);
return (
<div className="relative w-full h-full bg-gradient-to-br from-blue-50 via-cyan-50 to-blue-100">
{/* SDK 容器 - 使用style设置z-index为1确保UI按钮可以覆盖在上面 */}
<div ref={containerRef} className="w-full h-full relative" style={{ zIndex: 1 }} />
{/* 状态覆盖层 */}
{sdkStatus !== 'ready' && (
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-blue-50 via-cyan-50 to-blue-100 z-10">
<div className="text-center px-8">
{sdkStatus === 'loading' && (
<>
<div className="w-16 h-16 mx-auto mb-4 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
<p className="text-xl text-gray-700 font-medium mb-2">...</p>
{loadingProgress > 0 && (
<p className="text-base text-gray-500"> {loadingProgress}%</p>
)}
</>
)}
{sdkStatus === 'config-missing' && (
<>
<div className="w-20 h-20 mx-auto mb-4 flex items-center justify-center text-6xl"></div>
<p className="text-xl text-red-600 font-medium mb-2"></p>
<p className="text-base text-gray-600 mb-4">{errorMessage}</p>
<div className="bg-white p-4 rounded-lg text-left text-sm text-gray-700 max-w-md">
<p className="font-semibold mb-2"></p>
<div className="bg-gray-100 p-3 rounded font-mono text-xs">
<p>变量名: XMOV_APP_ID</p>
<p>变量值: 您的AppID</p>
<p className="mt-2">变量名: XMOV_APP_SECRET</p>
<p>变量值: 您的AppSecret</p>
</div>
<p className="text-xs text-gray-600 mt-3">
<a href="https://xingyun3d.com/" target="_blank" rel="noopener noreferrer" className="text-blue-600 underline"></a>
<strong className="text-red-600"></strong>
</p>
</div>
</>
)}
{sdkStatus === 'error' && (
<>
<div className="w-20 h-20 mx-auto mb-4 flex items-center justify-center text-6xl"></div>
<p className="text-xl text-red-600 font-medium mb-2"></p>
<p className="text-base text-gray-600">{errorMessage}</p>
</>
)}
</div>
</div>
)}
</div>
);
};