Commit d5be7bb1383fefba2990d4ffd8f31e8b96dcf68b
1 parent
12faefcd
ai
Showing
4 changed files
with
125 additions
and
73 deletions
src/mobile/Ai/AiChatStyles.css
src/mobile/Ai/AiChatStyles.less
src/mobile/Ai/newAi.jsx
| 1 | 1 | import React, { useState, useEffect, useRef, useCallback } from 'react'; |
| 2 | 2 | import ReactMarkdown from 'react-markdown'; |
| 3 | 3 | import remarkGfm from 'remark-gfm'; |
| 4 | -import { Button } from "antd-mobile"; | |
| 5 | -import { LocationOutline, PhoneFill } from "antd-mobile-icons"; | |
| 4 | +import { PhoneFill, LocationOutline } from "antd-mobile-icons"; | |
| 6 | 5 | import './AiChatStyles.less'; |
| 7 | - | |
| 6 | +import { Toast, Input, Tabs, Selector, Grid, Image, Button, Checkbox, Switch, Dialog, Radio, Space, CenterPopup, ImageViewer, Collapse, CapsuleTabs } from "antd-mobile"; | |
| 7 | +import VConsole from 'vconsole'; | |
| 8 | +let vConsole; | |
| 8 | 9 | const ChatInterface = () => { |
| 9 | 10 | // ==================== 状态管理 ==================== |
| 10 | 11 | const [sessionId, setSessionId] = useState(''); |
| ... | ... | @@ -16,13 +17,17 @@ const ChatInterface = () => { |
| 16 | 17 | const [currentModel, setCurrentModel] = useState('general'); |
| 17 | 18 | const [chatHistory, setChatHistory] = useState([]); |
| 18 | 19 | const [welcomeContent, setWelcomeContent] = useState(''); |
| 20 | + vConsole = new VConsole(); | |
| 21 | + | |
| 19 | 22 | |
| 20 | 23 | // 语音输入状态 |
| 21 | - const [isVoiceModel, setIsVoiceModel] = useState(false); | |
| 22 | 24 | const [isRecording, setIsRecording] = useState(false); |
| 25 | + const [isRecordingModel, setIsRecordingModel] = useState(false); | |
| 23 | 26 | const [isWsConnected, setIsWsConnected] = useState(false); |
| 24 | 27 | const [isVoiceMode, setIsVoiceMode] = useState(false); |
| 25 | 28 | const [recordingDuration, setRecordingDuration] = useState(0); |
| 29 | + const [isFlushing, setIsFlushing] = useState(false); | |
| 30 | + const silenceTimeoutRef = useRef(null); // 静默超时定时器 | |
| 26 | 31 | |
| 27 | 32 | const messagesEndRef = useRef(null); |
| 28 | 33 | const inputRef = useRef(null); |
| ... | ... | @@ -87,15 +92,30 @@ const ChatInterface = () => { |
| 87 | 92 | ws.onmessage = (event) => { |
| 88 | 93 | try { |
| 89 | 94 | const res = JSON.parse(event.data); |
| 90 | - if (res.code === 0 && (res.msg === "success" || res.msg === "flush_success")) { | |
| 91 | - if (res.text && res.text.trim()) { | |
| 95 | + if (res.code === 0) { | |
| 96 | + // 处理普通识别结果 | |
| 97 | + if ((res.msg === "success" || res.msg === "partial") && res.text?.trim()) { | |
| 98 | + if (wsRef.current) { | |
| 99 | + wsRef.current.hasReceivedSpeech = true; | |
| 100 | + } | |
| 92 | 101 | setInputValue(prev => { |
| 93 | 102 | const separator = prev && !prev.endsWith(' ') ? ' ' : ''; |
| 94 | - const newValue = prev ? `${prev}${separator}${res.text}` : res.text; | |
| 95 | - console.log('语音识别结果:', res.text, '更新后:', newValue); | |
| 96 | - return newValue; | |
| 103 | + return prev ? `${prev}${separator}${res.text}` : res.text; | |
| 97 | 104 | }); |
| 98 | 105 | } |
| 106 | + | |
| 107 | + // 👇 新增:处理 flush 完成 | |
| 108 | + if (res.msg === "flush_success") { | |
| 109 | + console.log("Flush 完成,语音识别结束"); | |
| 110 | + // 延迟一点确保所有消息处理完毕 | |
| 111 | + setTimeout(() => { | |
| 112 | + setIsFlushing(false); | |
| 113 | + // 只有在语音模式下才清空(避免干扰手动输入) | |
| 114 | + if (isVoiceMode) { | |
| 115 | + setInputValue(''); | |
| 116 | + } | |
| 117 | + }, 100); | |
| 118 | + } | |
| 99 | 119 | } |
| 100 | 120 | } catch (e) { |
| 101 | 121 | console.error("WebSocket消息解析失败:", e); |
| ... | ... | @@ -161,20 +181,18 @@ const ChatInterface = () => { |
| 161 | 181 | // 开始录音 |
| 162 | 182 | const startRecording = async () => { |
| 163 | 183 | if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { |
| 164 | - alert("浏览器不支持麦克风采集"); | |
| 184 | + Toast.show('浏览器不支持麦克风'); | |
| 165 | 185 | return; |
| 166 | 186 | } |
| 167 | 187 | |
| 168 | 188 | try { |
| 169 | - // 先连接WebSocket | |
| 170 | 189 | if (!isWsConnected) { |
| 171 | 190 | connectWebSocket(); |
| 172 | 191 | await new Promise(resolve => setTimeout(resolve, 1000)); |
| 173 | 192 | } |
| 174 | 193 | |
| 175 | - // 检查 WebSocket 是否已连接 | |
| 176 | 194 | if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { |
| 177 | - alert("语音识别服务未连接,请重试"); | |
| 195 | + Toast.show('语音服务未连接'); | |
| 178 | 196 | return; |
| 179 | 197 | } |
| 180 | 198 | |
| ... | ... | @@ -183,7 +201,6 @@ const ChatInterface = () => { |
| 183 | 201 | inputNodeRef.current = audioContextRef.current.createMediaStreamSource(stream); |
| 184 | 202 | scriptProcessorRef.current = audioContextRef.current.createScriptProcessor(2048, 1, 1); |
| 185 | 203 | |
| 186 | - // 使用 ref 来检查录音状态,避免闭包问题 | |
| 187 | 204 | scriptProcessorRef.current.onaudioprocess = (event) => { |
| 188 | 205 | if (!isRecordingRef.current) return; |
| 189 | 206 | const inputData = event.inputBuffer.getChannelData(0); |
| ... | ... | @@ -197,19 +214,34 @@ const ChatInterface = () => { |
| 197 | 214 | inputNodeRef.current.connect(scriptProcessorRef.current); |
| 198 | 215 | scriptProcessorRef.current.connect(audioContextRef.current.destination); |
| 199 | 216 | |
| 200 | - // 关键:先设置 ref,再设置 state | |
| 217 | + // 👇 关键:重置标志 + 启动静默检测 | |
| 218 | + let hasReceivedSpeech = false; // 闭包变量,记录是否收到语音 | |
| 219 | + | |
| 220 | + // 保存到 ref,供 onmessage 使用 | |
| 221 | + wsRef.current.hasReceivedSpeech = false; | |
| 222 | + | |
| 223 | + // 设置 3 秒静默超时 | |
| 224 | + silenceTimeoutRef.current = setTimeout(() => { | |
| 225 | + if (!wsRef.current?.hasReceivedSpeech) { | |
| 226 | + console.log('3秒内未检测到语音,自动停止录音'); | |
| 227 | + Toast.show('未检测到语音,请重新尝试'); | |
| 228 | + handleSendMessage() | |
| 229 | + } | |
| 230 | + }, 3000); | |
| 231 | + | |
| 232 | + // 更新录音状态 | |
| 201 | 233 | isRecordingRef.current = true; |
| 202 | 234 | setIsRecording(true); |
| 203 | 235 | setIsVoiceMode(true); |
| 204 | 236 | setRecordingDuration(0); |
| 205 | 237 | |
| 206 | - // 开始计时 | |
| 207 | 238 | recordingTimerRef.current = setInterval(() => { |
| 208 | 239 | setRecordingDuration(prev => prev + 1); |
| 209 | 240 | }, 1000); |
| 241 | + | |
| 210 | 242 | } catch (e) { |
| 211 | 243 | console.error("录音启动失败:", e); |
| 212 | - alert("录音启动失败:" + e.message); | |
| 244 | + Toast.show('录音启动失败:' + (e.message || '未知错误')); | |
| 213 | 245 | isRecordingRef.current = false; |
| 214 | 246 | setIsRecording(false); |
| 215 | 247 | } |
| ... | ... | @@ -218,10 +250,15 @@ const ChatInterface = () => { |
| 218 | 250 | // 停止录音 |
| 219 | 251 | const stopRecording = useCallback(() => { |
| 220 | 252 | console.log("停止录音"); |
| 253 | + // 👇 清理静默超时 | |
| 254 | + if (silenceTimeoutRef.current) { | |
| 255 | + clearTimeout(silenceTimeoutRef.current); | |
| 256 | + silenceTimeoutRef.current = null; | |
| 257 | + } | |
| 221 | 258 | isRecordingRef.current = false; |
| 222 | 259 | setIsRecording(false); |
| 223 | 260 | setIsVoiceMode(false); |
| 224 | - | |
| 261 | + setIsFlushing(true); | |
| 225 | 262 | if (recordingTimerRef.current) { |
| 226 | 263 | clearInterval(recordingTimerRef.current); |
| 227 | 264 | recordingTimerRef.current = null; |
| ... | ... | @@ -242,6 +279,9 @@ const ChatInterface = () => { |
| 242 | 279 | |
| 243 | 280 | // 发送刷新指令获取最终结果 |
| 244 | 281 | sendCommand("flush"); |
| 282 | + setTimeout(() => { | |
| 283 | + setInputValue('') | |
| 284 | + }, 500); | |
| 245 | 285 | }, [sendCommand]); |
| 246 | 286 | |
| 247 | 287 | // 切换录音状态(点击按钮) |
| ... | ... | @@ -406,17 +446,17 @@ const ChatInterface = () => { |
| 406 | 446 | } catch (error) { |
| 407 | 447 | console.error('请求失败:', error); |
| 408 | 448 | const errorMessage = ` |
| 409 | - 抱歉,请求出现错误:${error.message} | |
| 449 | +抱歉,请求出现错误:${error.message} | |
| 410 | 450 | |
| 411 | - **可能的原因:** | |
| 412 | - 1. Spring Boot 后端服务未启动 | |
| 413 | - 2. API 接口路径不正确 | |
| 414 | - 3. 网络连接问题 | |
| 451 | +**可能的原因:** | |
| 452 | +1. Spring Boot 后端服务未启动 | |
| 453 | +2. API 接口路径不正确 | |
| 454 | +3. 网络连接问题 | |
| 415 | 455 | |
| 416 | - **检查步骤:** | |
| 417 | - 1. 确保后端服务在端口 8099 运行 | |
| 418 | - 2. 检查浏览器控制台查看详细错误 | |
| 419 | - 3. 刷新页面重试 | |
| 456 | +**检查步骤:** | |
| 457 | +1. 确保后端服务在端口 8099 运行 | |
| 458 | +2. 检查浏览器控制台查看详细错误 | |
| 459 | +3. 刷新页面重试 | |
| 420 | 460 | `; |
| 421 | 461 | addMessage(errorMessage, 'ai', true); |
| 422 | 462 | } finally { |
| ... | ... | @@ -465,15 +505,32 @@ const ChatInterface = () => { |
| 465 | 505 | const handleModelChange = (e) => { |
| 466 | 506 | setCurrentModel(e.target.value); |
| 467 | 507 | }; |
| 468 | - // 录音弹窗 | |
| 469 | - const handlePhone = () => { | |
| 470 | - setIsVoiceModel(true) | |
| 471 | - startRecording(); | |
| 472 | - } | |
| 473 | - | |
| 474 | 508 | // ==================== 渲染 ==================== |
| 475 | 509 | return ( |
| 476 | 510 | <div className="ai-chat-container"> |
| 511 | + {/* 头部 */} | |
| 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> */} | |
| 477 | 534 | <Button |
| 478 | 535 | className="model-Button" |
| 479 | 536 | onClick={handleClearChat} |
| ... | ... | @@ -557,7 +614,7 @@ const ChatInterface = () => { |
| 557 | 614 | {/* 输入区域 */} |
| 558 | 615 | <div className="input-section"> |
| 559 | 616 | {/* 语音模式提示 - 仅在录音时显示 */} |
| 560 | - {/* {isRecording && ( | |
| 617 | + {isRecording && ( | |
| 561 | 618 | <div className="voice-mode-indicator"> |
| 562 | 619 | <div className="voice-wave"> |
| 563 | 620 | <span></span> |
| ... | ... | @@ -576,7 +633,7 @@ const ChatInterface = () => { |
| 576 | 633 | 取消 |
| 577 | 634 | </button> |
| 578 | 635 | </div> |
| 579 | - )} */} | |
| 636 | + )} | |
| 580 | 637 | |
| 581 | 638 | <div className="input-wrapper"> |
| 582 | 639 | <input |
| ... | ... | @@ -599,8 +656,10 @@ const ChatInterface = () => { |
| 599 | 656 | disabled={isLoading} |
| 600 | 657 | readOnly={isRecording} |
| 601 | 658 | /> |
| 602 | - <PhoneFill className='input-icon' onClick={handlePhone} /> | |
| 603 | - | |
| 659 | + <PhoneFill className='input-icon' onClick={() => { | |
| 660 | + setIsRecordingModel(true) | |
| 661 | + startRecording() | |
| 662 | + }} /> | |
| 604 | 663 | <LocationOutline className='input-icon' onClick={handleSendMessage} |
| 605 | 664 | disabled={isLoading || isRecording} /> |
| 606 | 665 | {/* 语音按钮 - 点击切换录音状态 */} |
| ... | ... | @@ -618,9 +677,9 @@ const ChatInterface = () => { |
| 618 | 677 | <span className="voice-text">点击录音</span> |
| 619 | 678 | </> |
| 620 | 679 | )} |
| 621 | - </button> */} | |
| 680 | + </button> | |
| 622 | 681 | |
| 623 | - {/* <button | |
| 682 | + <button | |
| 624 | 683 | className={`send-button ${isLoading ? 'disabled' : ''}`} |
| 625 | 684 | onClick={handleSendMessage} |
| 626 | 685 | disabled={isLoading || isRecording} |
| ... | ... | @@ -631,44 +690,33 @@ const ChatInterface = () => { |
| 631 | 690 | </div> |
| 632 | 691 | </div> |
| 633 | 692 | </div> |
| 693 | + | |
| 634 | 694 | { |
| 635 | - isVoiceModel ? | |
| 636 | - <div className='phone-model phone-zhezhao'> | |
| 695 | + isRecordingModel ? | |
| 696 | + <div className='phone-model'> | |
| 637 | 697 | <div className='phone-zhezhao'></div> |
| 638 | 698 | <div className='phone-content'> |
| 639 | - | |
| 640 | - </div> | |
| 641 | - {/* 语音模式提示 - 仅在录音时显示 */} | |
| 642 | - {isRecording && ( | |
| 643 | - <div className="voice-mode-indicator"> | |
| 644 | - <div className="voice-wave"> | |
| 645 | - <span></span> | |
| 646 | - <span></span> | |
| 647 | - <span></span> | |
| 648 | - <span></span> | |
| 649 | - <span></span> | |
| 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> | |
| 708 | + </div> | |
| 650 | 709 | </div> |
| 651 | - <span className="voice-text"> | |
| 652 | - 正在录音 {formatDuration(recordingDuration)} | |
| 653 | - </span> | |
| 654 | - <button | |
| 655 | - className="voice-cancel-btn" | |
| 656 | - onClick={cancelRecording} | |
| 657 | - > | |
| 658 | - 取消 | |
| 659 | - </button> | |
| 660 | - </div> | |
| 661 | - )} | |
| 662 | - <div className='phone-phone' onClick={() => { | |
| 663 | - stopRecording() | |
| 664 | - setIsVoiceModel(false) | |
| 665 | - }}> | |
| 666 | - <PhoneFill color='red' /> | |
| 710 | + )} | |
| 711 | + </div> | |
| 712 | + <div className='phone-phone'> | |
| 713 | + <PhoneFill color='red' onClick={() => { | |
| 714 | + setIsRecordingModel(false) | |
| 715 | + stopRecording() | |
| 716 | + }} /> | |
| 667 | 717 | </div> |
| 668 | - | |
| 669 | 718 | </div> : '' |
| 670 | 719 | } |
| 671 | - | |
| 672 | 720 | </div> |
| 673 | 721 | ); |
| 674 | 722 | }; | ... | ... |
src/utils/config.js
| ... | ... | @@ -9,7 +9,7 @@ const API = process.env.API; |
| 9 | 9 | const bHttps = false; |
| 10 | 10 | export const webSite = { |
| 11 | 11 | faceAddress: isDev ? '//km5cjx.gnway.cc:36867/xlyFace' : '//' + location.host + '/xlyFace', |
| 12 | - ipAddress: localStorage.ipAddress ? localStorage.ipAddress : isDev ? '//118.178.19.35:9198/xlyEntry_saas/' : '//' + location.host + '/xlyEntry/', | |
| 12 | + ipAddress: localStorage.ipAddress ? localStorage.ipAddress : isDev ? '//118.178.19.35:9198/xlyEntry_saas/' : '//118.178.19.35:9198/xlyEntry_saas/', | |
| 13 | 13 | |
| 14 | 14 | // ipAddress: localStorage.ipAddress ? localStorage.ipAddress : isDev ? '//118.178.19.35:8088/xlyEntry/' : '//' + location.host + '/xlyEntry/', |
| 15 | 15 | // ipAddress: localStorage.ipAddress ? localStorage.ipAddress : isDev ? '//km5cjx.gnway.cc:36867/xlyEntry/' : '//' + location.host + '/xlyEntry/', | ... | ... |