Commit 804bafc5e50c0b3f2513cd3502ae5669a6889e62

Authored by chenxt
1 parent d5be7bb1

ai

src/mobile/Ai/AiChatStyles.css
@@ -370,6 +370,11 @@ body { @@ -370,6 +370,11 @@ body {
370 border-radius: 25px; 370 border-radius: 25px;
371 margin-bottom: 10px; 371 margin-bottom: 10px;
372 color: white; 372 color: white;
  373 + z-index: 201;
  374 + position: absolute;
  375 + bottom: 120px;
  376 + left: 50%;
  377 + transform: translateX(-50%);
373 } 378 }
374 .voice-mode-indicator .voice-wave { 379 .voice-mode-indicator .voice-wave {
375 display: flex; 380 display: flex;
@@ -404,18 +409,20 @@ body { @@ -404,18 +409,20 @@ body {
404 font-size: 14px; 409 font-size: 14px;
405 font-weight: 500; 410 font-weight: 500;
406 } 411 }
407 -.voice-mode-indicator .voice-cancel-btn {  
408 - padding: 4px 12px;  
409 - background: rgba(255, 255, 255, 0.2); 412 +.voice-cancel-btn {
  413 + padding: 8px 16px;
  414 + background: linear-gradient(90deg, #667eea, #764ba2);
410 border: 1px solid rgba(255, 255, 255, 0.3); 415 border: 1px solid rgba(255, 255, 255, 0.3);
411 - border-radius: 12px; 416 + border-radius: 16px;
412 color: white; 417 color: white;
413 - font-size: 12px; 418 + font-size: 16px;
414 cursor: pointer; 419 cursor: pointer;
415 transition: all 0.2s; 420 transition: all 0.2s;
416 -}  
417 -.voice-mode-indicator .voice-cancel-btn:hover {  
418 - background: rgba(255, 255, 255, 0.3); 421 + z-index: 210;
  422 + position: absolute;
  423 + bottom: 120px;
  424 + left: 50%;
  425 + transform: translateX(-50%);
419 } 426 }
420 @keyframes wave { 427 @keyframes wave {
421 0%, 428 0%,
@@ -447,7 +454,7 @@ body { @@ -447,7 +454,7 @@ body {
447 } 454 }
448 .phone-model .phone-phone { 455 .phone-model .phone-phone {
449 position: absolute; 456 position: absolute;
450 - bottom: 40px; 457 + bottom: 20px;
451 left: 50%; 458 left: 50%;
452 transform: translateX(-50%); 459 transform: translateX(-50%);
453 font-size: 50px; 460 font-size: 50px;
src/mobile/Ai/AiChatStyles.less
@@ -402,6 +402,11 @@ body { @@ -402,6 +402,11 @@ body {
402 border-radius: 25px; 402 border-radius: 25px;
403 margin-bottom: 10px; 403 margin-bottom: 10px;
404 color: white; 404 color: white;
  405 + z-index: 201;
  406 + position: absolute;
  407 + bottom: 120px;
  408 + left: 50%;
  409 + transform: translateX(-50%);
405 410
406 .voice-wave { 411 .voice-wave {
407 display: flex; 412 display: flex;
@@ -430,22 +435,25 @@ body { @@ -430,22 +435,25 @@ body {
430 font-weight: 500; 435 font-weight: 500;
431 } 436 }
432 437
  438 +
  439 +}
433 .voice-cancel-btn { 440 .voice-cancel-btn {
434 - padding: 4px 12px;  
435 - background: rgba(255,255,255,0.2); 441 + padding: 8px 16px;
  442 + background: linear-gradient(90deg, #667eea, #764ba2);
436 border: 1px solid rgba(255,255,255,0.3); 443 border: 1px solid rgba(255,255,255,0.3);
437 - border-radius: 12px; 444 + border-radius: 16px;
438 color: white; 445 color: white;
439 - font-size: 12px; 446 + font-size: 16px;
440 cursor: pointer; 447 cursor: pointer;
441 transition: all 0.2s; 448 transition: all 0.2s;
  449 + z-index: 210;
  450 + position: absolute;
  451 + bottom: 120px;
  452 + left: 50%;
  453 + transform: translateX(-50%);
442 454
443 - &:hover {  
444 - background: rgba(255,255,255,0.3);  
445 - } 455 +
446 } 456 }
447 -}  
448 -  
449 @keyframes wave { 457 @keyframes wave {
450 0%, 100% { height: 20%; } 458 0%, 100% { height: 20%; }
451 50% { height: 100%; } 459 50% { height: 100%; }
@@ -470,7 +478,7 @@ body { @@ -470,7 +478,7 @@ body {
470 } 478 }
471 .phone-phone{ 479 .phone-phone{
472 position: absolute; 480 position: absolute;
473 - bottom: 40px; 481 + bottom: 20px;
474 left: 50%; 482 left: 50%;
475 transform: translateX(-50%); 483 transform: translateX(-50%);
476 font-size: 50px; 484 font-size: 50px;
src/mobile/Ai/newAi.jsx
@@ -17,17 +17,24 @@ const ChatInterface = () => { @@ -17,17 +17,24 @@ const ChatInterface = () => {
17 const [currentModel, setCurrentModel] = useState('general'); 17 const [currentModel, setCurrentModel] = useState('general');
18 const [chatHistory, setChatHistory] = useState([]); 18 const [chatHistory, setChatHistory] = useState([]);
19 const [welcomeContent, setWelcomeContent] = useState(''); 19 const [welcomeContent, setWelcomeContent] = useState('');
20 - vConsole = new VConsole(); 20 + const phoneContentRef = useRef(null);
  21 + // vConsole = new VConsole();
21 22
22 23
23 // 语音输入状态 24 // 语音输入状态
24 const [isRecording, setIsRecording] = useState(false); 25 const [isRecording, setIsRecording] = useState(false);
  26 + const [isAiRecording, setIsAiRecording] = useState(false);
25 const [isRecordingModel, setIsRecordingModel] = useState(false); 27 const [isRecordingModel, setIsRecordingModel] = useState(false);
26 const [isWsConnected, setIsWsConnected] = useState(false); 28 const [isWsConnected, setIsWsConnected] = useState(false);
27 const [isVoiceMode, setIsVoiceMode] = useState(false); 29 const [isVoiceMode, setIsVoiceMode] = useState(false);
28 const [recordingDuration, setRecordingDuration] = useState(0); 30 const [recordingDuration, setRecordingDuration] = useState(0);
29 const [isFlushing, setIsFlushing] = useState(false); 31 const [isFlushing, setIsFlushing] = useState(false);
  32 +
  33 + // ==================== 新增:语音模式下的临时对话展示 ====================
  34 + const [voiceMessages, setVoiceMessages] = useState([]); // 存储语音模式下的对话
  35 +
30 const silenceTimeoutRef = useRef(null); // 静默超时定时器 36 const silenceTimeoutRef = useRef(null); // 静默超时定时器
  37 + const resetSilenceTimeoutRef = useRef(null);
31 38
32 const messagesEndRef = useRef(null); 39 const messagesEndRef = useRef(null);
33 const inputRef = useRef(null); 40 const inputRef = useRef(null);
@@ -37,6 +44,8 @@ const ChatInterface = () => { @@ -37,6 +44,8 @@ const ChatInterface = () => {
37 const inputNodeRef = useRef(null); 44 const inputNodeRef = useRef(null);
38 const recordingTimerRef = useRef(null); 45 const recordingTimerRef = useRef(null);
39 const isRecordingRef = useRef(false); 46 const isRecordingRef = useRef(false);
  47 + // ==================== 关键修复:使用 ref 存储最新输入值 ====================
  48 + const inputValueRef = useRef(inputValue);
40 49
41 // ==================== 配置 ==================== 50 // ==================== 配置 ====================
42 const CONFIG = { 51 const CONFIG = {
@@ -98,10 +107,15 @@ const ChatInterface = () => { @@ -98,10 +107,15 @@ const ChatInterface = () => {
98 if (wsRef.current) { 107 if (wsRef.current) {
99 wsRef.current.hasReceivedSpeech = true; 108 wsRef.current.hasReceivedSpeech = true;
100 } 109 }
101 - setInputValue(prev => {  
102 - const separator = prev && !prev.endsWith(' ') ? ' ' : '';  
103 - return prev ? `${prev}${separator}${res.text}` : res.text;  
104 - }); 110 + // ==================== 关键修复:更新 ref 和 state ====================
  111 + const newValue = inputValueRef.current
  112 + ? `${inputValueRef.current} ${res.text}`.trim()
  113 + : res.text;
  114 + inputValueRef.current = newValue;
  115 + setInputValue(newValue);
  116 +
  117 + // 重置静默检测(收到语音后重新计时2秒)
  118 + resetSilenceTimeout();
105 } 119 }
106 120
107 // 👇 新增:处理 flush 完成 121 // 👇 新增:处理 flush 完成
@@ -113,6 +127,7 @@ const ChatInterface = () => { @@ -113,6 +127,7 @@ const ChatInterface = () => {
113 // 只有在语音模式下才清空(避免干扰手动输入) 127 // 只有在语音模式下才清空(避免干扰手动输入)
114 if (isVoiceMode) { 128 if (isVoiceMode) {
115 setInputValue(''); 129 setInputValue('');
  130 + inputValueRef.current = '';
116 } 131 }
117 }, 100); 132 }, 100);
118 } 133 }
@@ -126,7 +141,7 @@ const ChatInterface = () => { @@ -126,7 +141,7 @@ const ChatInterface = () => {
126 console.log("语音识别WebSocket连接断开"); 141 console.log("语音识别WebSocket连接断开");
127 setIsWsConnected(false); 142 setIsWsConnected(false);
128 if (isRecordingRef.current) { 143 if (isRecordingRef.current) {
129 - stopRecording(); 144 + stopRecordingOnly();
130 } 145 }
131 }; 146 };
132 147
@@ -138,6 +153,278 @@ const ChatInterface = () => { @@ -138,6 +153,278 @@ const ChatInterface = () => {
138 wsRef.current = ws; 153 wsRef.current = ws;
139 }, []); 154 }, []);
140 155
  156 + // ==================== 关键修复:重置静默检测定时器 ====================
  157 + const resetSilenceTimeout = useCallback(() => {
  158 + // 清除旧的定时器
  159 + if (silenceTimeoutRef.current) {
  160 + clearTimeout(silenceTimeoutRef.current);
  161 + }
  162 +
  163 + // 重新设置2秒静默检测
  164 + silenceTimeoutRef.current = setTimeout(() => {
  165 + console.log('2秒内未检测到语音,自动处理');
  166 + const latestInput = inputValueRef.current.trim();
  167 + console.log("🚀 ~ 当前输入值:", latestInput);
  168 +
  169 + if (latestInput) {
  170 + handleSendMessageWithContent(latestInput);
  171 + } else {
  172 + // 没有内容:仅停止当前录音,不清空弹窗,也不关闭 isRecordingModel
  173 + stopRecordingOnly();
  174 + Toast.show('未检测到语音,请继续说话');
  175 +
  176 + // 👇 关键:2秒后自动开始下一轮录音(保持连续对话)
  177 + setTimeout(() => {
  178 + if (isRecordingModel) {
  179 + // startRecordingForContinue();
  180 + }
  181 + }, 500);
  182 + }
  183 + }, 3000);
  184 + }, []);
  185 +
  186 + // ==================== 关键修复:独立的发送消息函数(接收参数) ====================
  187 + const handleSendMessageWithContent = useCallback(async (messageContent) => {
  188 + if (!messageContent || isLoading) return;
  189 +
  190 + // 先停止录音(不关闭弹窗)
  191 + stopRecordingOnly();
  192 +
  193 + let currentSessionId = sessionId;
  194 + if (!currentSessionId) {
  195 + currentSessionId = generateRandomString(20);
  196 + setSessionId(currentSessionId);
  197 + }
  198 +
  199 + setIsLoading(true);
  200 + setIsAiRecording(true);
  201 +
  202 + // ==================== 修改:添加到主消息列表和语音消息列表 ====================
  203 + const userMsg = {
  204 + id: `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
  205 + type: 'user',
  206 + content: messageContent,
  207 + time: getCurrentTime(),
  208 + isError: false
  209 + };
  210 +
  211 + // 添加到主消息列表(后台记录)
  212 + setMessages(prev => [...prev, userMsg]);
  213 + // 添加到语音弹窗的消息列表(展示用)
  214 + setVoiceMessages(prev => [...prev, userMsg]);
  215 +
  216 + const newHistoryItem = { role: 'user', content: messageContent, timestamp: Date.now() };
  217 + setChatHistory(prev => {
  218 + const updated = [...prev, newHistoryItem];
  219 + return updated.length > CONFIG.maxHistory ? updated.slice(-CONFIG.maxHistory) : updated;
  220 + });
  221 +
  222 + try {
  223 + const endpoint = currentModel === 'process' ? CONFIG.endpoints.process : CONFIG.endpoints.chat;
  224 + const requestData = {
  225 + message: messageContent,
  226 + modelType: currentModel,
  227 + sUserId,
  228 + sUserType,
  229 + sessionId: currentSessionId
  230 + };
  231 +
  232 + const response = await fetch(`${CONFIG.backendUrl}${endpoint}`, {
  233 + method: 'POST',
  234 + headers: CONFIG.headers,
  235 + body: JSON.stringify(requestData)
  236 + });
  237 +
  238 + if (!response.ok) {
  239 + throw new Error(`HTTP ${response.status}: ${response.statusText}`);
  240 + }
  241 +
  242 + const data = await response.json();
  243 +
  244 + if (data.data) {
  245 + const aiMsg = {
  246 + id: `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
  247 + type: 'ai',
  248 + content: data.data,
  249 + time: getCurrentTime(),
  250 + isError: false
  251 + };
  252 +
  253 + // 添加到主消息列表
  254 + setMessages(prev => [...prev, aiMsg]);
  255 + // 添加到语音弹窗的消息列表(展示用)
  256 + setVoiceMessages(prev => [...prev, aiMsg]);
  257 +
  258 + setChatHistory(prev => {
  259 + const updated = [...prev, { role: 'assistant', content: data.data, timestamp: Date.now() }];
  260 + return updated.length > CONFIG.maxHistory ? updated.slice(-CONFIG.maxHistory) : updated;
  261 + });
  262 + }
  263 + } catch (error) {
  264 + console.error('请求失败:', error);
  265 + const errorMessage = `
  266 +抱歉,请求出现错误:${error.message}
  267 +
  268 +**可能的原因:**
  269 +1. Spring Boot 后端服务未启动
  270 +2. API 接口路径不正确
  271 +3. 网络连接问题
  272 +
  273 +**检查步骤:**
  274 +1. 确保后端服务在端口 8099 运行
  275 +2. 检查浏览器控制台查看详细错误
  276 +3. 刷新页面重试
  277 + `;
  278 + const errorMsg = {
  279 + id: `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
  280 + type: 'ai',
  281 + content: errorMessage,
  282 + time: getCurrentTime(),
  283 + isError: true
  284 + };
  285 +
  286 + setMessages(prev => [...prev, errorMsg]);
  287 + setVoiceMessages(prev => [...prev, errorMsg]);
  288 + } finally {
  289 + setIsLoading(false);
  290 + // setIsAiRecording(false);
  291 + // ==================== 关键:不关闭 isRecordingModel,只清空输入准备下一轮 ====================
  292 + setInputValue('');
  293 + inputValueRef.current = '';
  294 + // 重新开始录音,实现连续对话
  295 + setTimeout(() => {
  296 + if (isRecordingModel) {
  297 + // startRecordingForContinue();
  298 + }
  299 + }, 500);
  300 + }
  301 + }, [sessionId, currentModel, sUserId, sUserType, isLoading, isRecordingModel]);
  302 +
  303 + // ==================== 连续对话的录音启动(不重复显示弹窗) ====================
  304 + const startRecordingForContinue = async () => {
  305 + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
  306 + Toast.show('浏览器不支持麦克风');
  307 + return;
  308 + }
  309 +
  310 + try {
  311 + // 重置输入值
  312 + setInputValue('');
  313 + inputValueRef.current = '';
  314 +
  315 + if (!isWsConnected || !wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
  316 + connectWebSocket();
  317 + await new Promise(resolve => setTimeout(resolve, 1000));
  318 + }
  319 +
  320 + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
  321 + Toast.show('语音服务未连接');
  322 + return;
  323 + }
  324 +
  325 + const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
  326 + audioContextRef.current = new (window.AudioContext || window.webkitAudioContext)();
  327 + inputNodeRef.current = audioContextRef.current.createMediaStreamSource(stream);
  328 + scriptProcessorRef.current = audioContextRef.current.createScriptProcessor(2048, 1, 1);
  329 +
  330 + scriptProcessorRef.current.onaudioprocess = (event) => {
  331 + if (!isRecordingRef.current) return;
  332 + const inputData = event.inputBuffer.getChannelData(0);
  333 + const resampledData = resampleAudio(inputData, audioContextRef.current.sampleRate, CONFIG.sampleRate);
  334 + const pcmData = float32ToInt16(resampledData);
  335 + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
  336 + wsRef.current.send(pcmData);
  337 + }
  338 + };
  339 +
  340 + inputNodeRef.current.connect(scriptProcessorRef.current);
  341 + scriptProcessorRef.current.connect(audioContextRef.current.destination);
  342 +
  343 + // 关键:重置标志
  344 + wsRef.current.hasReceivedSpeech = false;
  345 +
  346 + // 启动首次静默检测
  347 + silenceTimeoutRef.current = setTimeout(() => {
  348 + console.log('2秒内未检测到语音,自动处理(连续对话)');
  349 + const latestInput = inputValueRef.current.trim();
  350 + console.log("🚀 ~ 当前输入值:", latestInput);
  351 +
  352 + if (latestInput) {
  353 + handleSendMessageWithContent(latestInput);
  354 + } else {
  355 + stopRecordingOnly();
  356 + Toast.show('请继续说话...');
  357 +
  358 + // 👇 自动开始下一轮录音
  359 + setTimeout(() => {
  360 + if (isRecordingModel) {
  361 + // startRecordingForContinue();
  362 + }
  363 + }, 500);
  364 + }
  365 + }, 2000);
  366 +
  367 + // 更新录音状态
  368 + isRecordingRef.current = true;
  369 + setIsRecording(true);
  370 + setIsVoiceMode(true);
  371 + setRecordingDuration(0);
  372 +
  373 + recordingTimerRef.current = setInterval(() => {
  374 + setRecordingDuration(prev => prev + 1);
  375 + }, 1000);
  376 +
  377 + } catch (e) {
  378 + console.error("录音启动失败:", e);
  379 + Toast.show('录音启动失败:' + (e.message || '未知错误'));
  380 + isRecordingRef.current = false;
  381 + setIsRecording(false);
  382 + }
  383 + };
  384 + useEffect(() => {
  385 + // 确保在语音模式下才滚动
  386 + if (isRecordingModel) {
  387 + // 使用微任务或小延迟确保 DOM 已更新
  388 + setTimeout(scrollToPhoneBottom, 50);
  389 + }
  390 + }, [voiceMessages, inputValue, isLoading, isRecordingModel]);
  391 + // ==================== 关键修复:仅停止录音(不发送消息,不关闭弹窗) ====================
  392 + const stopRecordingOnly = useCallback(() => {
  393 + console.log("仅停止录音");
  394 +
  395 + // 清理静默超时
  396 + if (silenceTimeoutRef.current) {
  397 + clearTimeout(silenceTimeoutRef.current);
  398 + silenceTimeoutRef.current = null;
  399 + }
  400 +
  401 + isRecordingRef.current = false;
  402 + setIsRecording(false);
  403 + setIsVoiceMode(false);
  404 + setIsFlushing(true);
  405 +
  406 + if (recordingTimerRef.current) {
  407 + clearInterval(recordingTimerRef.current);
  408 + recordingTimerRef.current = null;
  409 + }
  410 +
  411 + if (inputNodeRef.current) {
  412 + inputNodeRef.current.disconnect();
  413 + inputNodeRef.current = null;
  414 + }
  415 + if (scriptProcessorRef.current) {
  416 + scriptProcessorRef.current.disconnect();
  417 + scriptProcessorRef.current = null;
  418 + }
  419 + if (audioContextRef.current) {
  420 + audioContextRef.current.close();
  421 + audioContextRef.current = null;
  422 + }
  423 +
  424 + // 发送刷新指令获取最终结果
  425 + sendCommand("flush");
  426 + }, [sendCommand]);
  427 +
141 // 断开WebSocket 428 // 断开WebSocket
142 const disconnectWebSocket = useCallback(() => { 429 const disconnectWebSocket = useCallback(() => {
143 if (wsRef.current) { 430 if (wsRef.current) {
@@ -178,7 +465,7 @@ const ChatInterface = () => { @@ -178,7 +465,7 @@ const ChatInterface = () => {
178 return result; 465 return result;
179 }; 466 };
180 467
181 - // 开始录音 468 + // 开始录音(首次打开弹窗)
182 const startRecording = async () => { 469 const startRecording = async () => {
183 if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { 470 if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
184 Toast.show('浏览器不支持麦克风'); 471 Toast.show('浏览器不支持麦克风');
@@ -186,6 +473,12 @@ const ChatInterface = () => { @@ -186,6 +473,12 @@ const ChatInterface = () => {
186 } 473 }
187 474
188 try { 475 try {
  476 + // 清空语音消息列表(新会话开始)
  477 + setVoiceMessages([]);
  478 + // 重置输入值
  479 + setInputValue('');
  480 + inputValueRef.current = '';
  481 +
189 if (!isWsConnected) { 482 if (!isWsConnected) {
190 connectWebSocket(); 483 connectWebSocket();
191 await new Promise(resolve => setTimeout(resolve, 1000)); 484 await new Promise(resolve => setTimeout(resolve, 1000));
@@ -214,18 +507,21 @@ const ChatInterface = () => { @@ -214,18 +507,21 @@ const ChatInterface = () => {
214 inputNodeRef.current.connect(scriptProcessorRef.current); 507 inputNodeRef.current.connect(scriptProcessorRef.current);
215 scriptProcessorRef.current.connect(audioContextRef.current.destination); 508 scriptProcessorRef.current.connect(audioContextRef.current.destination);
216 509
217 - // 👇 关键:重置标志 + 启动静默检测  
218 - let hasReceivedSpeech = false; // 闭包变量,记录是否收到语音  
219 -  
220 - // 保存到 ref,供 onmessage 使用 510 + // 关键:重置标志
221 wsRef.current.hasReceivedSpeech = false; 511 wsRef.current.hasReceivedSpeech = false;
222 512
223 - // 设置 3 秒静默超时 513 + // ==================== 关键修复:启动首次静默检测 ====================
224 silenceTimeoutRef.current = setTimeout(() => { 514 silenceTimeoutRef.current = setTimeout(() => {
225 - if (!wsRef.current?.hasReceivedSpeech) {  
226 - console.log('3秒内未检测到语音,自动停止录音');  
227 - Toast.show('未检测到语音,请重新尝试');  
228 - handleSendMessage() 515 + console.log('2秒内未检测到语音,自动发送并停止录音');
  516 + const latestInput = inputValueRef.current.trim();
  517 + console.log("🚀 ~ 当前输入值:", latestInput);
  518 +
  519 + if (latestInput) {
  520 + handleSendMessageWithContent(latestInput);
  521 + } else {
  522 + stopRecordingOnly();
  523 + // setIsRecordingModel(false); // 没有内容时关闭弹窗
  524 + Toast.show('未检测到语音输入');
229 } 525 }
230 }, 3000); 526 }, 3000);
231 527
@@ -246,19 +542,94 @@ const ChatInterface = () => { @@ -246,19 +542,94 @@ const ChatInterface = () => {
246 setIsRecording(false); 542 setIsRecording(false);
247 } 543 }
248 }; 544 };
  545 + // 用于打断/连续对话,不清空 voiceMessages
  546 + const startRecordingWithoutClear = async () => {
  547 + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
  548 + Toast.show('浏览器不支持麦克风');
  549 + return;
  550 + }
  551 +
  552 + try {
  553 + // ❌ 不清空 voiceMessages!
  554 + // 重置输入值(但保留历史语音消息)
  555 + setInputValue('');
  556 + inputValueRef.current = '';
  557 +
  558 + if (!isWsConnected || !wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
  559 + connectWebSocket();
  560 + await new Promise(resolve => setTimeout(resolve, 1000));
  561 + }
  562 +
  563 + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
  564 + Toast.show('语音服务未连接');
  565 + return;
  566 + }
  567 +
  568 + const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
  569 + audioContextRef.current = new (window.AudioContext || window.webkitAudioContext)();
  570 + inputNodeRef.current = audioContextRef.current.createMediaStreamSource(stream);
  571 + scriptProcessorRef.current = audioContextRef.current.createScriptProcessor(2048, 1, 1);
  572 +
  573 + scriptProcessorRef.current.onaudioprocess = (event) => {
  574 + if (!isRecordingRef.current) return;
  575 + const inputData = event.inputBuffer.getChannelData(0);
  576 + const resampledData = resampleAudio(inputData, audioContextRef.current.sampleRate, CONFIG.sampleRate);
  577 + const pcmData = float32ToInt16(resampledData);
  578 + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
  579 + wsRef.current.send(pcmData);
  580 + }
  581 + };
  582 +
  583 + inputNodeRef.current.connect(scriptProcessorRef.current);
  584 + scriptProcessorRef.current.connect(audioContextRef.current.destination);
  585 +
  586 + wsRef.current.hasReceivedSpeech = false;
  587 +
  588 + // 静默检测(可复用)
  589 + silenceTimeoutRef.current = setTimeout(() => {
  590 + const latestInput = inputValueRef.current.trim();
  591 + if (latestInput) {
  592 + handleSendMessageWithContent(latestInput);
  593 + } else {
  594 + stopRecordingOnly();
  595 + setIsAiRecording(true)
  596 + // Toast.show('请继续说话...');
  597 + // startRecordingForContinue()
  598 + // 可选:自动重试
  599 + }
  600 + }, 3000);
  601 +
  602 + isRecordingRef.current = true;
  603 + setIsRecording(true);
  604 + setIsVoiceMode(true);
  605 + setRecordingDuration(0);
  606 +
  607 + recordingTimerRef.current = setInterval(() => {
  608 + setRecordingDuration(prev => prev + 1);
  609 + }, 1000);
  610 +
  611 + } catch (e) {
  612 + console.error("录音启动失败:", e);
  613 + Toast.show('录音启动失败:' + (e.message || '未知错误'));
  614 + isRecordingRef.current = false;
  615 + setIsRecording(false);
  616 + }
  617 + };
  618 + // 停止录音并关闭弹窗(手动挂断)
  619 + const stopRecordingAndClose = useCallback(() => {
  620 + console.log("停止录音并关闭弹窗");
249 621
250 - // 停止录音  
251 - const stopRecording = useCallback(() => {  
252 - console.log("停止录音");  
253 - // 👇 清理静默超时 622 + // 清理静默超时
254 if (silenceTimeoutRef.current) { 623 if (silenceTimeoutRef.current) {
255 clearTimeout(silenceTimeoutRef.current); 624 clearTimeout(silenceTimeoutRef.current);
256 silenceTimeoutRef.current = null; 625 silenceTimeoutRef.current = null;
257 } 626 }
  627 +
258 isRecordingRef.current = false; 628 isRecordingRef.current = false;
259 setIsRecording(false); 629 setIsRecording(false);
260 setIsVoiceMode(false); 630 setIsVoiceMode(false);
261 setIsFlushing(true); 631 setIsFlushing(true);
  632 +
262 if (recordingTimerRef.current) { 633 if (recordingTimerRef.current) {
263 clearInterval(recordingTimerRef.current); 634 clearInterval(recordingTimerRef.current);
264 recordingTimerRef.current = null; 635 recordingTimerRef.current = null;
@@ -277,32 +648,42 @@ const ChatInterface = () => { @@ -277,32 +648,42 @@ const ChatInterface = () => {
277 audioContextRef.current = null; 648 audioContextRef.current = null;
278 } 649 }
279 650
280 - // 发送刷新指令获取最终结果  
281 sendCommand("flush"); 651 sendCommand("flush");
282 - setTimeout(() => {  
283 - setInputValue('')  
284 - }, 500); 652 +
  653 + // 关闭弹窗并清空
  654 + // setIsRecordingModel(false);
  655 + setVoiceMessages([]);
  656 + setInputValue('');
  657 + inputValueRef.current = '';
285 }, [sendCommand]); 658 }, [sendCommand]);
286 659
287 // 切换录音状态(点击按钮) 660 // 切换录音状态(点击按钮)
288 const toggleRecording = useCallback(() => { 661 const toggleRecording = useCallback(() => {
289 if (isRecordingRef.current) { 662 if (isRecordingRef.current) {
290 - // 正在录音,停止  
291 - stopRecording(); 663 + // 正在录音,停止(手动停止也触发发送)
  664 + const latestInput = inputValueRef.current.trim();
  665 + if (latestInput) {
  666 + handleSendMessageWithContent(latestInput);
  667 + } else {
  668 + stopRecordingAndClose();
  669 + }
292 } else { 670 } else {
293 // 未录音,开始 671 // 未录音,开始
294 startRecording(); 672 startRecording();
295 } 673 }
296 - }, [stopRecording]); 674 + }, [stopRecordingAndClose, handleSendMessageWithContent]);
297 675
298 // 取消录音并清空 676 // 取消录音并清空
299 const cancelRecording = useCallback(() => { 677 const cancelRecording = useCallback(() => {
300 if (isRecordingRef.current) { 678 if (isRecordingRef.current) {
301 - stopRecording(); 679 + stopRecordingOnly();
302 } 680 }
303 setInputValue(''); 681 setInputValue('');
  682 + inputValueRef.current = '';
304 setIsVoiceMode(false); 683 setIsVoiceMode(false);
305 - }, [stopRecording]); 684 + setIsRecordingModel(false);
  685 + setVoiceMessages([]);
  686 + }, [stopRecordingOnly]);
306 687
307 // 格式化录音时长 688 // 格式化录音时长
308 const formatDuration = (seconds) => { 689 const formatDuration = (seconds) => {
@@ -352,9 +733,9 @@ const ChatInterface = () => { @@ -352,9 +733,9 @@ const ChatInterface = () => {
352 } 733 }
353 if (e.key === 'Escape') { 734 if (e.key === 'Escape') {
354 setInputValue(''); 735 setInputValue('');
  736 + inputValueRef.current = '';
355 if (isRecordingRef.current) { 737 if (isRecordingRef.current) {
356 - stopRecording();  
357 - setIsVoiceMode(false); 738 + stopRecordingAndClose();
358 } 739 }
359 } 740 }
360 }; 741 };
@@ -367,12 +748,15 @@ const ChatInterface = () => { @@ -367,12 +748,15 @@ const ChatInterface = () => {
367 if (recordingTimerRef.current) { 748 if (recordingTimerRef.current) {
368 clearInterval(recordingTimerRef.current); 749 clearInterval(recordingTimerRef.current);
369 } 750 }
  751 + if (silenceTimeoutRef.current) {
  752 + clearTimeout(silenceTimeoutRef.current);
  753 + }
370 }; 754 };
371 }, []); 755 }, []);
372 756
373 useEffect(() => { 757 useEffect(() => {
374 scrollToBottom(); 758 scrollToBottom();
375 - }, [messages, isLoading]); 759 + }, [messages, isLoading, voiceMessages]);
376 760
377 // ==================== 消息处理 ==================== 761 // ==================== 消息处理 ====================
378 const addMessage = (content, type, isError = false) => { 762 const addMessage = (content, type, isError = false) => {
@@ -387,15 +771,12 @@ const ChatInterface = () => { @@ -387,15 +771,12 @@ const ChatInterface = () => {
387 return newMessage.id; 771 return newMessage.id;
388 }; 772 };
389 773
  774 + // 原有的 handleSendMessage 保留给手动输入使用
390 const handleSendMessage = async () => { 775 const handleSendMessage = async () => {
391 const message = inputValue.trim(); 776 const message = inputValue.trim();
392 - if (!message || isLoading) return; 777 + console.log("🚀 ~ handleSendMessage ~ message:", message);
393 778
394 - // 如果正在录音,先停止  
395 - if (isRecordingRef.current) {  
396 - stopRecording();  
397 - setIsVoiceMode(false);  
398 - } 779 + if (!message || isLoading) return;
399 780
400 let currentSessionId = sessionId; 781 let currentSessionId = sessionId;
401 if (!currentSessionId) { 782 if (!currentSessionId) {
@@ -404,6 +785,7 @@ const ChatInterface = () => { @@ -404,6 +785,7 @@ const ChatInterface = () => {
404 } 785 }
405 786
406 setInputValue(''); 787 setInputValue('');
  788 + inputValueRef.current = '';
407 setIsLoading(true); 789 setIsLoading(true);
408 790
409 addMessage(message, 'user'); 791 addMessage(message, 'user');
@@ -478,9 +860,9 @@ const ChatInterface = () => { @@ -478,9 +860,9 @@ const ChatInterface = () => {
478 setChatHistory([]); 860 setChatHistory([]);
479 setSessionId(''); 861 setSessionId('');
480 setInputValue(''); 862 setInputValue('');
  863 + inputValueRef.current = '';
481 if (isRecordingRef.current) { 864 if (isRecordingRef.current) {
482 - stopRecording();  
483 - setIsVoiceMode(false); 865 + stopRecordingAndClose();
484 } 866 }
485 } 867 }
486 }; 868 };
@@ -499,38 +881,76 @@ const ChatInterface = () => { @@ -499,38 +881,76 @@ const ChatInterface = () => {
499 setMessages(prev => prev.filter(msg => msg.id !== messageId)); 881 setMessages(prev => prev.filter(msg => msg.id !== messageId));
500 882
501 setInputValue(content); 883 setInputValue(content);
  884 + inputValueRef.current = content;
502 setTimeout(() => handleSendMessage(), 100); 885 setTimeout(() => handleSendMessage(), 100);
503 }; 886 };
504 887
505 const handleModelChange = (e) => { 888 const handleModelChange = (e) => {
506 setCurrentModel(e.target.value); 889 setCurrentModel(e.target.value);
507 }; 890 };
  891 +
  892 + // ==================== 渲染消息组件(复用) ====================
  893 + const renderMessage = (msg) => (
  894 + <div
  895 + key={msg.id}
  896 + className={`message ${msg.type}-message`}
  897 + >
  898 + <div className={`message-bubble ${msg.type}-bubble`}>
  899 + <div className="message-content">
  900 + {msg.type === 'ai' ? (
  901 + <ReactMarkdown
  902 + remarkPlugins={[remarkGfm]}
  903 + components={{
  904 + code: ({ node, inline, className, children, ...props }) => (
  905 + inline ? (
  906 + <code className="inline-code" {...props}>
  907 + {children}
  908 + </code>
  909 + ) : (
  910 + <pre className="code-block">
  911 + <code {...props}>{children}</code>
  912 + </pre>
  913 + )
  914 + )
  915 + }}
  916 + >
  917 + {msg.content}
  918 + </ReactMarkdown>
  919 + ) : (
  920 + msg.content
  921 + )}
  922 + </div>
  923 + <div className="message-meta">
  924 + <span className="message-time">{msg.time}</span>
  925 + {msg.type === 'ai' && !msg.isWelcome && (
  926 + <div className="message-actions">
  927 + <button
  928 + className="action-btn"
  929 + onClick={() => handleCopyMessage(msg.content)}
  930 + >
  931 + 复制
  932 + </button>
  933 + <button
  934 + className="action-btn"
  935 + onClick={() => handleRegenerateMessage(msg.id, msg.content)}
  936 + >
  937 + 重新生成
  938 + </button>
  939 + </div>
  940 + )}
  941 + </div>
  942 + </div>
  943 + </div>
  944 + );
  945 + const scrollToPhoneBottom = () => {
  946 + if (phoneContentRef.current) {
  947 + phoneContentRef.current.scrollTop = phoneContentRef.current.scrollHeight;
  948 + }
  949 + };
508 // ==================== 渲染 ==================== 950 // ==================== 渲染 ====================
509 return ( 951 return (
510 <div className="ai-chat-container"> 952 <div className="ai-chat-container">
511 {/* 头部 */} 953 {/* 头部 */}
512 - {/* <div className="chat-header">  
513 - <div className="header-left">  
514 - <h1>小羚羊Ai-agent智能体</h1>  
515 - <p>AI 印刷助手</p>  
516 - </div>  
517 - <div className="header-right">  
518 - <select  
519 - className="model-selector"  
520 - value={currentModel}  
521 - onChange={handleModelChange}  
522 - >  
523 - <option value="process">小羚羊印刷行业大模型</option>  
524 - <option value="general">qwen2.5:14b</option>  
525 - </select>  
526 - <button  
527 - className="model-selectors"  
528 - onClick={handleClearChat}  
529 - >  
530 - 清空对话  
531 - </button>  
532 - </div>  
533 - </div> */}  
534 <Button 954 <Button
535 className="model-Button" 955 className="model-Button"
536 onClick={handleClearChat} 956 onClick={handleClearChat}
@@ -543,58 +963,7 @@ const ChatInterface = () =&gt; { @@ -543,58 +963,7 @@ const ChatInterface = () =&gt; {
543 <div className="chat-main"> 963 <div className="chat-main">
544 {/* 消息区域 */} 964 {/* 消息区域 */}
545 <div className="messages-container"> 965 <div className="messages-container">
546 - {messages.map((msg) => (  
547 - <div  
548 - key={msg.id}  
549 - className={`message ${msg.type}-message`}  
550 - >  
551 - <div className={`message-bubble ${msg.type}-bubble`}>  
552 - <div className="message-content">  
553 - {msg.type === 'ai' ? (  
554 - <ReactMarkdown  
555 - remarkPlugins={[remarkGfm]}  
556 - components={{  
557 - code: ({ node, inline, className, children, ...props }) => (  
558 - inline ? (  
559 - <code className="inline-code" {...props}>  
560 - {children}  
561 - </code>  
562 - ) : (  
563 - <pre className="code-block">  
564 - <code {...props}>{children}</code>  
565 - </pre>  
566 - )  
567 - )  
568 - }}  
569 - >  
570 - {msg.content || welcomeContent}  
571 - </ReactMarkdown>  
572 - ) : (  
573 - msg.content  
574 - )}  
575 - </div>  
576 - <div className="message-meta">  
577 - <span className="message-time">{msg.time}</span>  
578 - {msg.type === 'ai' && !msg.isWelcome && (  
579 - <div className="message-actions">  
580 - <button  
581 - className="action-btn"  
582 - onClick={() => handleCopyMessage(msg.content)}  
583 - >  
584 - 复制  
585 - </button>  
586 - <button  
587 - className="action-btn"  
588 - onClick={() => handleRegenerateMessage(msg.id, msg.content)}  
589 - >  
590 - 重新生成  
591 - </button>  
592 - </div>  
593 - )}  
594 - </div>  
595 - </div>  
596 - </div>  
597 - ))} 966 + {messages.map(renderMessage)}
598 967
599 {/* 打字机效果 */} 968 {/* 打字机效果 */}
600 {isLoading && ( 969 {isLoading && (
@@ -613,28 +982,6 @@ const ChatInterface = () =&gt; { @@ -613,28 +982,6 @@ const ChatInterface = () =&gt; {
613 982
614 {/* 输入区域 */} 983 {/* 输入区域 */}
615 <div className="input-section"> 984 <div className="input-section">
616 - {/* 语音模式提示 - 仅在录音时显示 */}  
617 - {isRecording && (  
618 - <div className="voice-mode-indicator">  
619 - <div className="voice-wave">  
620 - <span></span>  
621 - <span></span>  
622 - <span></span>  
623 - <span></span>  
624 - <span></span>  
625 - </div>  
626 - <span className="voice-text">  
627 - 正在录音 {formatDuration(recordingDuration)}  
628 - </span>  
629 - <button  
630 - className="voice-cancel-btn"  
631 - onClick={cancelRecording}  
632 - >  
633 - 取消  
634 - </button>  
635 - </div>  
636 - )}  
637 -  
638 <div className="input-wrapper"> 985 <div className="input-wrapper">
639 <input 986 <input
640 ref={inputRef} 987 ref={inputRef}
@@ -645,6 +992,7 @@ const ChatInterface = () =&gt; { @@ -645,6 +992,7 @@ const ChatInterface = () =&gt; {
645 onChange={(e) => { 992 onChange={(e) => {
646 if (!isRecording) { 993 if (!isRecording) {
647 setInputValue(e.target.value); 994 setInputValue(e.target.value);
  995 + inputValueRef.current = e.target.value;
648 } 996 }
649 }} 997 }}
650 onKeyPress={(e) => { 998 onKeyPress={(e) => {
@@ -662,61 +1010,95 @@ const ChatInterface = () =&gt; { @@ -662,61 +1010,95 @@ const ChatInterface = () =&gt; {
662 }} /> 1010 }} />
663 <LocationOutline className='input-icon' onClick={handleSendMessage} 1011 <LocationOutline className='input-icon' onClick={handleSendMessage}
664 disabled={isLoading || isRecording} /> 1012 disabled={isLoading || isRecording} />
665 - {/* 语音按钮 - 点击切换录音状态 */}  
666 - {/* <button  
667 - className={`voice-button ${isRecording ? 'recording' : ''}`}  
668 - onClick={toggleRecording}  
669 - disabled={isLoading}  
670 - >  
671 - {isRecording ? (  
672 - <>  
673 - <span className="voice-text">结束录音</span>  
674 - </>  
675 - ) : (  
676 - <>  
677 - <span className="voice-text">点击录音</span>  
678 - </>  
679 - )}  
680 - </button>  
681 -  
682 - <button  
683 - className={`send-button ${isLoading ? 'disabled' : ''}`}  
684 - onClick={handleSendMessage}  
685 - disabled={isLoading || isRecording}  
686 - >  
687 - 发送  
688 - </button> */}  
689 </div> 1013 </div>
690 </div> 1014 </div>
691 </div> 1015 </div>
692 </div> 1016 </div>
693 -  
694 - {  
695 - isRecordingModel ?  
696 - <div className='phone-model'>  
697 - <div className='phone-zhezhao'></div>  
698 - <div className='phone-content'>  
699 - {inputValue.trim() && (  
700 - <div className="message user-message">  
701 - <div className="message-bubble user-bubble">  
702 - <div className="message-content">  
703 - {inputValue}  
704 - </div>  
705 - <div className="message-meta">  
706 - <span className="message-time">{getCurrentTime()}</span>  
707 - </div> 1017 +
  1018 + {/* ==================== 语音对话弹窗 ==================== */}
  1019 + {isRecordingModel && (
  1020 + <div className='phone-model'>
  1021 + <div className='phone-zhezhao'></div>
  1022 +
  1023 + {/* 消息展示区域 - 展示 voiceMessages 中的对话 */}
  1024 + <div className='phone-content' ref={phoneContentRef}>
  1025 + {voiceMessages.map(renderMessage)}
  1026 +
  1027 + {/* 当前正在输入的语音转文字(实时显示) */}
  1028 + {inputValue.trim() && isRecording && (
  1029 + <div className="message user-message">
  1030 + <div className="message-bubble user-bubble">
  1031 + <div className="message-content">
  1032 + {inputValue}
  1033 + </div>
  1034 + <div className="message-meta">
  1035 + <span className="message-time">{getCurrentTime()}</span>
  1036 + <span style={{ fontSize: '12px', color: '#999', marginLeft: '8px' }}>
  1037 + (识别中...)
  1038 + </span>
708 </div> 1039 </div>
709 </div> 1040 </div>
710 - )} 1041 + </div>
  1042 + )}
  1043 +
  1044 + {/* AI 思考中效果 */}
  1045 + {isLoading && (
  1046 + <div className="message ai-message">
  1047 + <div className="typing-indicator">
  1048 + <div className="typing-dot"></div>
  1049 + <div className="typing-dot" style={{ animationDelay: '0.2s' }}></div>
  1050 + <div className="typing-dot" style={{ animationDelay: '0.4s' }}></div>
  1051 + <span className="typing-text">AI 正在思考...</span>
  1052 + </div>
  1053 + </div>
  1054 + )}
  1055 +
  1056 + <div ref={messagesEndRef} style={{ height: '20px' }} />
  1057 + </div>
  1058 +
  1059 + {/* 录音状态指示器 */}
  1060 + {isRecording && (
  1061 + <div className="voice-mode-indicator">
  1062 + <div className="voice-wave">
  1063 + <span></span>
  1064 + <span></span>
  1065 + <span></span>
  1066 + <span></span>
  1067 + <span></span>
  1068 + </div>
  1069 + <span className="voice-text">
  1070 + 正在录音 {formatDuration(recordingDuration)}
  1071 + </span>
711 </div> 1072 </div>
712 - <div className='phone-phone'>  
713 - <PhoneFill color='red' onClick={() => { 1073 + )}
  1074 +
  1075 + {/* AI 说话时的打断按钮 */}
  1076 + {isAiRecording && (
  1077 + <button
  1078 + className="voice-cancel-btn"
  1079 + onClick={() => {
  1080 + setIsAiRecording(false);
  1081 + setIsLoading(false);
  1082 + startRecordingWithoutClear()
  1083 + // startRecordingForContinue();
  1084 + }}
  1085 + >
  1086 + 打断
  1087 + </button>
  1088 + )}
  1089 +
  1090 + {/* 挂断按钮 */}
  1091 + <div className='phone-phone'>
  1092 + <PhoneFill
  1093 + color='red'
  1094 + onClick={() => {
  1095 + stopRecordingAndClose();
714 setIsRecordingModel(false) 1096 setIsRecordingModel(false)
715 - stopRecording()  
716 - }} />  
717 - </div>  
718 - </div> : ''  
719 - } 1097 + }}
  1098 + />
  1099 + </div>
  1100 + </div>
  1101 + )}
720 </div> 1102 </div>
721 ); 1103 );
722 }; 1104 };