Add File
This commit is contained in:
605
src/elderly/components/XmovAvatar.tsx
Normal file
605
src/elderly/components/XmovAvatar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user