From aa7852e9dd0ff744ac6b4048981fc389f0323a24 Mon Sep 17 00:00:00 2001 From: 15945162479 <15945162479@qq.com> Date: Sat, 13 Dec 2025 14:46:10 +0800 Subject: [PATCH] Add File --- src/elderly/components/XmovAvatar.tsx | 605 ++++++++++++++++++++++++++ 1 file changed, 605 insertions(+) create mode 100644 src/elderly/components/XmovAvatar.tsx diff --git a/src/elderly/components/XmovAvatar.tsx b/src/elderly/components/XmovAvatar.tsx new file mode 100644 index 0000000..6101f5d --- /dev/null +++ b/src/elderly/components/XmovAvatar.tsx @@ -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 = ({ + isActive: _isActive = false, + websocketUrl = 'ws://127.0.0.1:10002', + onSDKReady, + onSDKError, + onSpeaking, + onSDKStatusChange, + onWSStatusChange, + onLogMessage, +}) => { + const containerRef = useRef(null); + const sdkRef = useRef(null); + const wsServiceRef = useRef(null); + const [sdkStatus, setSDKStatus] = useState<'loading' | 'ready' | 'error' | 'config-missing'>('loading'); + const [wsStatus, setWsStatus] = useState<'disconnected' | 'connecting' | 'connected'>('disconnected'); + const [errorMessage, setErrorMessage] = useState(''); + const [loadingProgress, setLoadingProgress] = useState(0); + const isConversationActiveRef = useRef(false); // 追踪当前对话是否在进行中 + const hasSpeakStartedRef = useRef(false); // 追踪当前对话是否已开始播放(用于处理 think 标签后的第一次播放) + const isThinkingRef = useRef(false); // 追踪是否正在思考中( 之间) + const conversationTimeoutRef = useRef(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(''); + const thinkEndIndex = text.indexOf(''); + + // 检测思考结束标签( 通过 log 消息处理) + if (thinkStartIndex !== -1 && !isThinkingRef.current) { + // 如果在 text 消息中检测到 ,也切换状态(兼容处理) + isThinkingRef.current = true; + console.log('[xmov] 🧠 在 text 消息中检测到 ,调用 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] 🧠 检测到 ,退出思考模式'); + // 注意:不需要调用 interactiveIdle,因为接下来会调用 speak + } + + let contentToSpeak = ''; + + // 情况1: 当前正在思考中,且消息中没有 + 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: 同时包含 (在同一条消息中完成思考) + else if (thinkStartIndex !== -1 && thinkEndIndex !== -1 && thinkStartIndex < thinkEndIndex) { + const beforeThink = text.substring(0, thinkStartIndex); + const afterThink = text.substring(thinkEndIndex + 8); // 8 是 '' 的长度 + contentToSpeak = (beforeThink + afterThink).trim(); + console.log('[xmov] 🧠 检测到完整的 标签,移除思考内容'); + console.log('[xmov] 原始文本:', text); + console.log('[xmov] 移除思考后:', contentToSpeak); + } + // 情况3: 只有 (跨消息思考结束) + else if (thinkStartIndex === -1 && thinkEndIndex !== -1) { + contentToSpeak = text.substring(thinkEndIndex + 8).trim(); + console.log('[xmov] 🧠 检测到 标签(跨消息思考结束),提取后续内容:', 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 ( +
+ {/* SDK 容器 - 使用style设置z-index为1,确保UI按钮可以覆盖在上面 */} +
+ + {/* 状态覆盖层 */} + {sdkStatus !== 'ready' && ( +
+
+ {sdkStatus === 'loading' && ( + <> +
+

初始化中...

+ {loadingProgress > 0 && ( +

加载资源 {loadingProgress}%

+ )} + + )} + {sdkStatus === 'config-missing' && ( + <> +
⚠️
+

配置未完成

+

{errorMessage}

+
+

请在系统环境变量中配置:

+
+

变量名: XMOV_APP_ID

+

变量值: 您的AppID

+

变量名: XMOV_APP_SECRET

+

变量值: 您的AppSecret

+
+

+ 在 魔珐星云 应用中心创建驱动应用后获取。 + 配置后需重启应用程序。 +

+
+ + )} + {sdkStatus === 'error' && ( + <> +
+

加载失败

+

{errorMessage}

+ + )} +
+
+ )} + +
+ ); +};