Commit 804bafc5e50c0b3f2513cd3502ae5669a6889e62

Authored by chenxt
1 parent d5be7bb1

ai

src/mobile/Ai/AiChatStyles.css
... ... @@ -370,6 +370,11 @@ body {
370 370 border-radius: 25px;
371 371 margin-bottom: 10px;
372 372 color: white;
  373 + z-index: 201;
  374 + position: absolute;
  375 + bottom: 120px;
  376 + left: 50%;
  377 + transform: translateX(-50%);
373 378 }
374 379 .voice-mode-indicator .voice-wave {
375 380 display: flex;
... ... @@ -404,18 +409,20 @@ body {
404 409 font-size: 14px;
405 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 415 border: 1px solid rgba(255, 255, 255, 0.3);
411   - border-radius: 12px;
  416 + border-radius: 16px;
412 417 color: white;
413   - font-size: 12px;
  418 + font-size: 16px;
414 419 cursor: pointer;
415 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 427 @keyframes wave {
421 428 0%,
... ... @@ -447,7 +454,7 @@ body {
447 454 }
448 455 .phone-model .phone-phone {
449 456 position: absolute;
450   - bottom: 40px;
  457 + bottom: 20px;
451 458 left: 50%;
452 459 transform: translateX(-50%);
453 460 font-size: 50px;
... ...
src/mobile/Ai/AiChatStyles.less
... ... @@ -402,6 +402,11 @@ body {
402 402 border-radius: 25px;
403 403 margin-bottom: 10px;
404 404 color: white;
  405 + z-index: 201;
  406 + position: absolute;
  407 + bottom: 120px;
  408 + left: 50%;
  409 + transform: translateX(-50%);
405 410  
406 411 .voice-wave {
407 412 display: flex;
... ... @@ -430,22 +435,25 @@ body {
430 435 font-weight: 500;
431 436 }
432 437  
  438 +
  439 +}
433 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 443 border: 1px solid rgba(255,255,255,0.3);
437   - border-radius: 12px;
  444 + border-radius: 16px;
438 445 color: white;
439   - font-size: 12px;
  446 + font-size: 16px;
440 447 cursor: pointer;
441 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 457 @keyframes wave {
450 458 0%, 100% { height: 20%; }
451 459 50% { height: 100%; }
... ... @@ -470,7 +478,7 @@ body {
470 478 }
471 479 .phone-phone{
472 480 position: absolute;
473   - bottom: 40px;
  481 + bottom: 20px;
474 482 left: 50%;
475 483 transform: translateX(-50%);
476 484 font-size: 50px;
... ...
src/mobile/Ai/newAi.jsx
... ... @@ -17,17 +17,24 @@ const ChatInterface = () => {
17 17 const [currentModel, setCurrentModel] = useState('general');
18 18 const [chatHistory, setChatHistory] = useState([]);
19 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 25 const [isRecording, setIsRecording] = useState(false);
  26 + const [isAiRecording, setIsAiRecording] = useState(false);
25 27 const [isRecordingModel, setIsRecordingModel] = useState(false);
26 28 const [isWsConnected, setIsWsConnected] = useState(false);
27 29 const [isVoiceMode, setIsVoiceMode] = useState(false);
28 30 const [recordingDuration, setRecordingDuration] = useState(0);
29 31 const [isFlushing, setIsFlushing] = useState(false);
  32 +
  33 + // ==================== 新增:语音模式下的临时对话展示 ====================
  34 + const [voiceMessages, setVoiceMessages] = useState([]); // 存储语音模式下的对话
  35 +
30 36 const silenceTimeoutRef = useRef(null); // 静默超时定时器
  37 + const resetSilenceTimeoutRef = useRef(null);
31 38  
32 39 const messagesEndRef = useRef(null);
33 40 const inputRef = useRef(null);
... ... @@ -37,6 +44,8 @@ const ChatInterface = () => {
37 44 const inputNodeRef = useRef(null);
38 45 const recordingTimerRef = useRef(null);
39 46 const isRecordingRef = useRef(false);
  47 + // ==================== 关键修复:使用 ref 存储最新输入值 ====================
  48 + const inputValueRef = useRef(inputValue);
40 49  
41 50 // ==================== 配置 ====================
42 51 const CONFIG = {
... ... @@ -98,10 +107,15 @@ const ChatInterface = () => {
98 107 if (wsRef.current) {
99 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 121 // 👇 新增:处理 flush 完成
... ... @@ -113,6 +127,7 @@ const ChatInterface = () => {
113 127 // 只有在语音模式下才清空(避免干扰手动输入)
114 128 if (isVoiceMode) {
115 129 setInputValue('');
  130 + inputValueRef.current = '';
116 131 }
117 132 }, 100);
118 133 }
... ... @@ -126,7 +141,7 @@ const ChatInterface = () => {
126 141 console.log("语音识别WebSocket连接断开");
127 142 setIsWsConnected(false);
128 143 if (isRecordingRef.current) {
129   - stopRecording();
  144 + stopRecordingOnly();
130 145 }
131 146 };
132 147  
... ... @@ -138,6 +153,278 @@ const ChatInterface = () => {
138 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 428 // 断开WebSocket
142 429 const disconnectWebSocket = useCallback(() => {
143 430 if (wsRef.current) {
... ... @@ -178,7 +465,7 @@ const ChatInterface = () => {
178 465 return result;
179 466 };
180 467  
181   - // 开始录音
  468 + // 开始录音(首次打开弹窗)
182 469 const startRecording = async () => {
183 470 if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
184 471 Toast.show('浏览器不支持麦克风');
... ... @@ -186,6 +473,12 @@ const ChatInterface = () => {
186 473 }
187 474  
188 475 try {
  476 + // 清空语音消息列表(新会话开始)
  477 + setVoiceMessages([]);
  478 + // 重置输入值
  479 + setInputValue('');
  480 + inputValueRef.current = '';
  481 +
189 482 if (!isWsConnected) {
190 483 connectWebSocket();
191 484 await new Promise(resolve => setTimeout(resolve, 1000));
... ... @@ -214,18 +507,21 @@ const ChatInterface = () => {
214 507 inputNodeRef.current.connect(scriptProcessorRef.current);
215 508 scriptProcessorRef.current.connect(audioContextRef.current.destination);
216 509  
217   - // 👇 关键:重置标志 + 启动静默检测
218   - let hasReceivedSpeech = false; // 闭包变量,记录是否收到语音
219   -
220   - // 保存到 ref,供 onmessage 使用
  510 + // 关键:重置标志
221 511 wsRef.current.hasReceivedSpeech = false;
222 512  
223   - // 设置 3 秒静默超时
  513 + // ==================== 关键修复:启动首次静默检测 ====================
224 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 526 }, 3000);
231 527  
... ... @@ -246,19 +542,94 @@ const ChatInterface = () => {
246 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 623 if (silenceTimeoutRef.current) {
255 624 clearTimeout(silenceTimeoutRef.current);
256 625 silenceTimeoutRef.current = null;
257 626 }
  627 +
258 628 isRecordingRef.current = false;
259 629 setIsRecording(false);
260 630 setIsVoiceMode(false);
261 631 setIsFlushing(true);
  632 +
262 633 if (recordingTimerRef.current) {
263 634 clearInterval(recordingTimerRef.current);
264 635 recordingTimerRef.current = null;
... ... @@ -277,32 +648,42 @@ const ChatInterface = () => {
277 648 audioContextRef.current = null;
278 649 }
279 650  
280   - // 发送刷新指令获取最终结果
281 651 sendCommand("flush");
282   - setTimeout(() => {
283   - setInputValue('')
284   - }, 500);
  652 +
  653 + // 关闭弹窗并清空
  654 + // setIsRecordingModel(false);
  655 + setVoiceMessages([]);
  656 + setInputValue('');
  657 + inputValueRef.current = '';
285 658 }, [sendCommand]);
286 659  
287 660 // 切换录音状态(点击按钮)
288 661 const toggleRecording = useCallback(() => {
289 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 670 } else {
293 671 // 未录音,开始
294 672 startRecording();
295 673 }
296   - }, [stopRecording]);
  674 + }, [stopRecordingAndClose, handleSendMessageWithContent]);
297 675  
298 676 // 取消录音并清空
299 677 const cancelRecording = useCallback(() => {
300 678 if (isRecordingRef.current) {
301   - stopRecording();
  679 + stopRecordingOnly();
302 680 }
303 681 setInputValue('');
  682 + inputValueRef.current = '';
304 683 setIsVoiceMode(false);
305   - }, [stopRecording]);
  684 + setIsRecordingModel(false);
  685 + setVoiceMessages([]);
  686 + }, [stopRecordingOnly]);
306 687  
307 688 // 格式化录音时长
308 689 const formatDuration = (seconds) => {
... ... @@ -352,9 +733,9 @@ const ChatInterface = () => {
352 733 }
353 734 if (e.key === 'Escape') {
354 735 setInputValue('');
  736 + inputValueRef.current = '';
355 737 if (isRecordingRef.current) {
356   - stopRecording();
357   - setIsVoiceMode(false);
  738 + stopRecordingAndClose();
358 739 }
359 740 }
360 741 };
... ... @@ -367,12 +748,15 @@ const ChatInterface = () => {
367 748 if (recordingTimerRef.current) {
368 749 clearInterval(recordingTimerRef.current);
369 750 }
  751 + if (silenceTimeoutRef.current) {
  752 + clearTimeout(silenceTimeoutRef.current);
  753 + }
370 754 };
371 755 }, []);
372 756  
373 757 useEffect(() => {
374 758 scrollToBottom();
375   - }, [messages, isLoading]);
  759 + }, [messages, isLoading, voiceMessages]);
376 760  
377 761 // ==================== 消息处理 ====================
378 762 const addMessage = (content, type, isError = false) => {
... ... @@ -387,15 +771,12 @@ const ChatInterface = () => {
387 771 return newMessage.id;
388 772 };
389 773  
  774 + // 原有的 handleSendMessage 保留给手动输入使用
390 775 const handleSendMessage = async () => {
391 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 781 let currentSessionId = sessionId;
401 782 if (!currentSessionId) {
... ... @@ -404,6 +785,7 @@ const ChatInterface = () => {
404 785 }
405 786  
406 787 setInputValue('');
  788 + inputValueRef.current = '';
407 789 setIsLoading(true);
408 790  
409 791 addMessage(message, 'user');
... ... @@ -478,9 +860,9 @@ const ChatInterface = () => {
478 860 setChatHistory([]);
479 861 setSessionId('');
480 862 setInputValue('');
  863 + inputValueRef.current = '';
481 864 if (isRecordingRef.current) {
482   - stopRecording();
483   - setIsVoiceMode(false);
  865 + stopRecordingAndClose();
484 866 }
485 867 }
486 868 };
... ... @@ -499,38 +881,76 @@ const ChatInterface = () => {
499 881 setMessages(prev => prev.filter(msg => msg.id !== messageId));
500 882  
501 883 setInputValue(content);
  884 + inputValueRef.current = content;
502 885 setTimeout(() => handleSendMessage(), 100);
503 886 };
504 887  
505 888 const handleModelChange = (e) => {
506 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 951 return (
510 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 954 <Button
535 955 className="model-Button"
536 956 onClick={handleClearChat}
... ... @@ -543,58 +963,7 @@ const ChatInterface = () =&gt; {
543 963 <div className="chat-main">
544 964 {/* 消息区域 */}
545 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 969 {isLoading && (
... ... @@ -613,28 +982,6 @@ const ChatInterface = () =&gt; {
613 982  
614 983 {/* 输入区域 */}
615 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 985 <div className="input-wrapper">
639 986 <input
640 987 ref={inputRef}
... ... @@ -645,6 +992,7 @@ const ChatInterface = () =&gt; {
645 992 onChange={(e) => {
646 993 if (!isRecording) {
647 994 setInputValue(e.target.value);
  995 + inputValueRef.current = e.target.value;
648 996 }
649 997 }}
650 998 onKeyPress={(e) => {
... ... @@ -662,61 +1010,95 @@ const ChatInterface = () =&gt; {
662 1010 }} />
663 1011 <LocationOutline className='input-icon' onClick={handleSendMessage}
664 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 1013 </div>
690 1014 </div>
691 1015 </div>
692 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 1039 </div>
709 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 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 1096 setIsRecordingModel(false)
715   - stopRecording()
716   - }} />
717   - </div>
718   - </div> : ''
719   - }
  1097 + }}
  1098 + />
  1099 + </div>
  1100 + </div>
  1101 + )}
720 1102 </div>
721 1103 );
722 1104 };
... ...