Commit c6ddce2bbce26ddd61aeece6a9a30c6fc73e14b1
1 parent
8acabc8a
ai语音输入
Showing
1 changed file
with
310 additions
and
31 deletions
src/mobile/Ai/newAi.jsx
| 1 | -import React, { useState, useEffect, useRef } from 'react'; | |
| 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 { AudioOutline } from "antd-mobile-icons"; | |
| 5 | -import './AiChatStyles.less'; // 引入外部样式文件 | |
| 4 | +import { AudioOutline, AudioFill } from "antd-mobile-icons"; | |
| 5 | +import './AiChatStyles.less'; | |
| 6 | 6 | |
| 7 | 7 | const ChatInterface = () => { |
| 8 | 8 | // ==================== 状态管理 ==================== |
| ... | ... | @@ -15,13 +15,27 @@ const ChatInterface = () => { |
| 15 | 15 | const [currentModel, setCurrentModel] = useState('general'); |
| 16 | 16 | const [chatHistory, setChatHistory] = useState([]); |
| 17 | 17 | const [welcomeContent, setWelcomeContent] = useState(''); |
| 18 | - | |
| 18 | + | |
| 19 | + // 语音输入状态 | |
| 20 | + const [isRecording, setIsRecording] = useState(false); | |
| 21 | + const [isWsConnected, setIsWsConnected] = useState(false); | |
| 22 | + const [isVoiceMode, setIsVoiceMode] = useState(false); | |
| 23 | + const [recordingDuration, setRecordingDuration] = useState(0); | |
| 24 | + | |
| 19 | 25 | const messagesEndRef = useRef(null); |
| 20 | 26 | const inputRef = useRef(null); |
| 27 | + const wsRef = useRef(null); | |
| 28 | + const audioContextRef = useRef(null); | |
| 29 | + const scriptProcessorRef = useRef(null); | |
| 30 | + const inputNodeRef = useRef(null); | |
| 31 | + const recordingTimerRef = useRef(null); | |
| 32 | + const isRecordingRef = useRef(false); | |
| 21 | 33 | |
| 22 | 34 | // ==================== 配置 ==================== |
| 23 | 35 | const CONFIG = { |
| 24 | 36 | backendUrl: 'http://localhost:8099/xlyAi', |
| 37 | + wsUrl: 'ws://121.43.128.225:10096', // 语音识别WebSocket地址 | |
| 38 | + sampleRate: 16000, | |
| 25 | 39 | endpoints: { |
| 26 | 40 | chat: '/api/v1/chat/query', |
| 27 | 41 | process: '/api/v1/chat/query', |
| ... | ... | @@ -52,6 +66,212 @@ const ChatInterface = () => { |
| 52 | 66 | messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); |
| 53 | 67 | }; |
| 54 | 68 | |
| 69 | + // ==================== WebSocket 语音识别 ==================== | |
| 70 | + | |
| 71 | + // 连接语音识别WebSocket | |
| 72 | + const connectWebSocket = useCallback(() => { | |
| 73 | + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { | |
| 74 | + return; | |
| 75 | + } | |
| 76 | + | |
| 77 | + const ws = new WebSocket(CONFIG.wsUrl); | |
| 78 | + ws.binaryType = "arraybuffer"; | |
| 79 | + | |
| 80 | + ws.onopen = () => { | |
| 81 | + console.log("语音识别WebSocket连接成功"); | |
| 82 | + setIsWsConnected(true); | |
| 83 | + }; | |
| 84 | + | |
| 85 | + ws.onmessage = (event) => { | |
| 86 | + try { | |
| 87 | + const res = JSON.parse(event.data); | |
| 88 | + if (res.code === 0 && (res.msg === "success" || res.msg === "flush_success")) { | |
| 89 | + if (res.text && res.text.trim()) { | |
| 90 | + setInputValue(prev => { | |
| 91 | + const separator = prev && !prev.endsWith(' ') ? ' ' : ''; | |
| 92 | + const newValue = prev ? `${prev}${separator}${res.text}` : res.text; | |
| 93 | + console.log('语音识别结果:', res.text, '更新后:', newValue); | |
| 94 | + return newValue; | |
| 95 | + }); | |
| 96 | + } | |
| 97 | + } | |
| 98 | + } catch (e) { | |
| 99 | + console.error("WebSocket消息解析失败:", e); | |
| 100 | + } | |
| 101 | + }; | |
| 102 | + | |
| 103 | + ws.onclose = () => { | |
| 104 | + console.log("语音识别WebSocket连接断开"); | |
| 105 | + setIsWsConnected(false); | |
| 106 | + if (isRecordingRef.current) { | |
| 107 | + stopRecording(); | |
| 108 | + } | |
| 109 | + }; | |
| 110 | + | |
| 111 | + ws.onerror = (err) => { | |
| 112 | + console.error("WebSocket错误:", err); | |
| 113 | + setIsWsConnected(false); | |
| 114 | + }; | |
| 115 | + | |
| 116 | + wsRef.current = ws; | |
| 117 | + }, []); | |
| 118 | + | |
| 119 | + // 断开WebSocket | |
| 120 | + const disconnectWebSocket = useCallback(() => { | |
| 121 | + if (wsRef.current) { | |
| 122 | + wsRef.current.close(); | |
| 123 | + wsRef.current = null; | |
| 124 | + } | |
| 125 | + setIsWsConnected(false); | |
| 126 | + }, []); | |
| 127 | + | |
| 128 | + // 发送指令 | |
| 129 | + const sendCommand = useCallback((action) => { | |
| 130 | + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { | |
| 131 | + return; | |
| 132 | + } | |
| 133 | + const cmd = JSON.stringify({ action }); | |
| 134 | + wsRef.current.send(cmd); | |
| 135 | + }, []); | |
| 136 | + | |
| 137 | + // Float32 转 Int16 PCM | |
| 138 | + const float32ToInt16 = (float32Array) => { | |
| 139 | + const int16Array = new Int16Array(float32Array.length); | |
| 140 | + for (let i = 0; i < float32Array.length; i++) { | |
| 141 | + let s = Math.max(-1, Math.min(1, float32Array[i])); | |
| 142 | + int16Array[i] = s < 0 ? s * 0x8000 : s * 0x7FFF; | |
| 143 | + } | |
| 144 | + return new Uint8Array(int16Array.buffer); | |
| 145 | + }; | |
| 146 | + | |
| 147 | + // 音频重采样 | |
| 148 | + const resampleAudio = (data, originalRate, targetRate) => { | |
| 149 | + if (originalRate === targetRate) return data; | |
| 150 | + const ratio = targetRate / originalRate; | |
| 151 | + const newLength = Math.round(data.length * ratio); | |
| 152 | + const result = new Float32Array(newLength); | |
| 153 | + for (let i = 0; i < newLength; i++) { | |
| 154 | + result[i] = data[Math.round(i / ratio)] || 0; | |
| 155 | + } | |
| 156 | + return result; | |
| 157 | + }; | |
| 158 | + | |
| 159 | + // 开始录音 | |
| 160 | + const startRecording = async () => { | |
| 161 | + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { | |
| 162 | + alert("浏览器不支持麦克风采集"); | |
| 163 | + return; | |
| 164 | + } | |
| 165 | + | |
| 166 | + try { | |
| 167 | + // 先连接WebSocket | |
| 168 | + if (!isWsConnected) { | |
| 169 | + connectWebSocket(); | |
| 170 | + await new Promise(resolve => setTimeout(resolve, 1000)); | |
| 171 | + } | |
| 172 | + | |
| 173 | + // 检查 WebSocket 是否已连接 | |
| 174 | + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { | |
| 175 | + alert("语音识别服务未连接,请重试"); | |
| 176 | + return; | |
| 177 | + } | |
| 178 | + | |
| 179 | + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| 180 | + audioContextRef.current = new (window.AudioContext || window.webkitAudioContext)(); | |
| 181 | + inputNodeRef.current = audioContextRef.current.createMediaStreamSource(stream); | |
| 182 | + scriptProcessorRef.current = audioContextRef.current.createScriptProcessor(2048, 1, 1); | |
| 183 | + | |
| 184 | + // 使用 ref 来检查录音状态,避免闭包问题 | |
| 185 | + scriptProcessorRef.current.onaudioprocess = (event) => { | |
| 186 | + if (!isRecordingRef.current) return; | |
| 187 | + const inputData = event.inputBuffer.getChannelData(0); | |
| 188 | + const resampledData = resampleAudio(inputData, audioContextRef.current.sampleRate, CONFIG.sampleRate); | |
| 189 | + const pcmData = float32ToInt16(resampledData); | |
| 190 | + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { | |
| 191 | + wsRef.current.send(pcmData); | |
| 192 | + } | |
| 193 | + }; | |
| 194 | + | |
| 195 | + inputNodeRef.current.connect(scriptProcessorRef.current); | |
| 196 | + scriptProcessorRef.current.connect(audioContextRef.current.destination); | |
| 197 | + | |
| 198 | + // 关键:先设置 ref,再设置 state | |
| 199 | + isRecordingRef.current = true; | |
| 200 | + setIsRecording(true); | |
| 201 | + setIsVoiceMode(true); | |
| 202 | + setRecordingDuration(0); | |
| 203 | + | |
| 204 | + // 开始计时 | |
| 205 | + recordingTimerRef.current = setInterval(() => { | |
| 206 | + setRecordingDuration(prev => prev + 1); | |
| 207 | + }, 1000); | |
| 208 | + | |
| 209 | + console.log("录音已开始"); | |
| 210 | + | |
| 211 | + } catch (e) { | |
| 212 | + console.error("录音启动失败:", e); | |
| 213 | + alert("录音启动失败:" + e.message); | |
| 214 | + isRecordingRef.current = false; | |
| 215 | + setIsRecording(false); | |
| 216 | + } | |
| 217 | + }; | |
| 218 | + | |
| 219 | + // 停止录音 | |
| 220 | + const stopRecording = useCallback(() => { | |
| 221 | + console.log("停止录音"); | |
| 222 | + isRecordingRef.current = false; | |
| 223 | + setIsRecording(false); | |
| 224 | + setIsVoiceMode(false); | |
| 225 | + | |
| 226 | + if (recordingTimerRef.current) { | |
| 227 | + clearInterval(recordingTimerRef.current); | |
| 228 | + recordingTimerRef.current = null; | |
| 229 | + } | |
| 230 | + | |
| 231 | + if (inputNodeRef.current) { | |
| 232 | + inputNodeRef.current.disconnect(); | |
| 233 | + inputNodeRef.current = null; | |
| 234 | + } | |
| 235 | + if (scriptProcessorRef.current) { | |
| 236 | + scriptProcessorRef.current.disconnect(); | |
| 237 | + scriptProcessorRef.current = null; | |
| 238 | + } | |
| 239 | + if (audioContextRef.current) { | |
| 240 | + audioContextRef.current.close(); | |
| 241 | + audioContextRef.current = null; | |
| 242 | + } | |
| 243 | + | |
| 244 | + // 发送刷新指令获取最终结果 | |
| 245 | + sendCommand("flush"); | |
| 246 | + }, [sendCommand]); | |
| 247 | + | |
| 248 | + // 切换录音状态(点击按钮) | |
| 249 | + const toggleRecording = useCallback(() => { | |
| 250 | + if (isRecordingRef.current) { | |
| 251 | + // 正在录音,停止 | |
| 252 | + stopRecording(); | |
| 253 | + } else { | |
| 254 | + // 未录音,开始 | |
| 255 | + startRecording(); | |
| 256 | + } | |
| 257 | + }, [stopRecording]); | |
| 258 | + | |
| 259 | + // 取消录音并清空 | |
| 260 | + const cancelRecording = useCallback(() => { | |
| 261 | + if (isRecordingRef.current) { | |
| 262 | + stopRecording(); | |
| 263 | + } | |
| 264 | + setInputValue(''); | |
| 265 | + setIsVoiceMode(false); | |
| 266 | + }, [stopRecording]); | |
| 267 | + | |
| 268 | + // 格式化录音时长 | |
| 269 | + const formatDuration = (seconds) => { | |
| 270 | + const mins = Math.floor(seconds / 60).toString().padStart(2, '0'); | |
| 271 | + const secs = (seconds % 60).toString().padStart(2, '0'); | |
| 272 | + return `${mins}:${secs}`; | |
| 273 | + }; | |
| 274 | + | |
| 55 | 275 | // ==================== 初始化 ==================== |
| 56 | 276 | useEffect(() => { |
| 57 | 277 | setMessages([ |
| ... | ... | @@ -75,7 +295,7 @@ const ChatInterface = () => { |
| 75 | 295 | const data = await response.json(); |
| 76 | 296 | if (data.data) { |
| 77 | 297 | setWelcomeContent(data.data); |
| 78 | - setMessages(prev => prev.map(msg => | |
| 298 | + setMessages(prev => prev.map(msg => | |
| 79 | 299 | msg.id === 'welcome' ? { ...msg, content: data.data } : msg |
| 80 | 300 | )); |
| 81 | 301 | } |
| ... | ... | @@ -93,20 +313,22 @@ const ChatInterface = () => { |
| 93 | 313 | } |
| 94 | 314 | if (e.key === 'Escape') { |
| 95 | 315 | setInputValue(''); |
| 96 | - } | |
| 97 | - if (e.key === 'ArrowUp' && inputValue === '') { | |
| 98 | - const lastUserMessage = chatHistory | |
| 99 | - .filter(item => item.role === 'user') | |
| 100 | - .pop(); | |
| 101 | - if (lastUserMessage) { | |
| 102 | - setInputValue(lastUserMessage.content); | |
| 103 | - e.preventDefault(); | |
| 316 | + if (isRecordingRef.current) { | |
| 317 | + stopRecording(); | |
| 318 | + setIsVoiceMode(false); | |
| 104 | 319 | } |
| 105 | 320 | } |
| 106 | 321 | }; |
| 107 | 322 | |
| 108 | 323 | document.addEventListener('keydown', handleKeyDown); |
| 109 | - return () => document.removeEventListener('keydown', handleKeyDown); | |
| 324 | + | |
| 325 | + return () => { | |
| 326 | + document.removeEventListener('keydown', handleKeyDown); | |
| 327 | + disconnectWebSocket(); | |
| 328 | + if (recordingTimerRef.current) { | |
| 329 | + clearInterval(recordingTimerRef.current); | |
| 330 | + } | |
| 331 | + }; | |
| 110 | 332 | }, []); |
| 111 | 333 | |
| 112 | 334 | useEffect(() => { |
| ... | ... | @@ -130,6 +352,12 @@ const ChatInterface = () => { |
| 130 | 352 | const message = inputValue.trim(); |
| 131 | 353 | if (!message || isLoading) return; |
| 132 | 354 | |
| 355 | + // 如果正在录音,先停止 | |
| 356 | + if (isRecordingRef.current) { | |
| 357 | + stopRecording(); | |
| 358 | + setIsVoiceMode(false); | |
| 359 | + } | |
| 360 | + | |
| 133 | 361 | let currentSessionId = sessionId; |
| 134 | 362 | if (!currentSessionId) { |
| 135 | 363 | currentSessionId = generateRandomString(20); |
| ... | ... | @@ -168,7 +396,7 @@ const ChatInterface = () => { |
| 168 | 396 | } |
| 169 | 397 | |
| 170 | 398 | const data = await response.json(); |
| 171 | - | |
| 399 | + | |
| 172 | 400 | if (data.data) { |
| 173 | 401 | addMessage(data.data, 'ai'); |
| 174 | 402 | setChatHistory(prev => { |
| ... | ... | @@ -210,6 +438,11 @@ const ChatInterface = () => { |
| 210 | 438 | ]); |
| 211 | 439 | setChatHistory([]); |
| 212 | 440 | setSessionId(''); |
| 441 | + setInputValue(''); | |
| 442 | + if (isRecordingRef.current) { | |
| 443 | + stopRecording(); | |
| 444 | + setIsVoiceMode(false); | |
| 445 | + } | |
| 213 | 446 | } |
| 214 | 447 | }; |
| 215 | 448 | |
| ... | ... | @@ -220,12 +453,12 @@ const ChatInterface = () => { |
| 220 | 453 | }; |
| 221 | 454 | |
| 222 | 455 | const handleRegenerateMessage = (messageId, content) => { |
| 223 | - setChatHistory(prev => prev.filter(item => | |
| 456 | + setChatHistory(prev => prev.filter(item => | |
| 224 | 457 | !(item.role === 'assistant' && item.content === content) |
| 225 | 458 | )); |
| 226 | - | |
| 459 | + | |
| 227 | 460 | setMessages(prev => prev.filter(msg => msg.id !== messageId)); |
| 228 | - | |
| 461 | + | |
| 229 | 462 | setInputValue(content); |
| 230 | 463 | setTimeout(() => handleSendMessage(), 100); |
| 231 | 464 | }; |
| ... | ... | @@ -244,7 +477,7 @@ const ChatInterface = () => { |
| 244 | 477 | <p>AI 印刷助手</p> |
| 245 | 478 | </div> |
| 246 | 479 | <div className="header-right"> |
| 247 | - <select | |
| 480 | + <select | |
| 248 | 481 | className="model-selector" |
| 249 | 482 | value={currentModel} |
| 250 | 483 | onChange={handleModelChange} |
| ... | ... | @@ -252,7 +485,7 @@ const ChatInterface = () => { |
| 252 | 485 | <option value="process">小羚羊印刷行业大模型</option> |
| 253 | 486 | <option value="general">qwen2.5:14b</option> |
| 254 | 487 | </select> |
| 255 | - <button | |
| 488 | + <button | |
| 256 | 489 | className="model-selectors" |
| 257 | 490 | onClick={handleClearChat} |
| 258 | 491 | > |
| ... | ... | @@ -267,14 +500,14 @@ const ChatInterface = () => { |
| 267 | 500 | {/* 消息区域 */} |
| 268 | 501 | <div className="messages-container"> |
| 269 | 502 | {messages.map((msg) => ( |
| 270 | - <div | |
| 503 | + <div | |
| 271 | 504 | key={msg.id} |
| 272 | 505 | className={`message ${msg.type}-message`} |
| 273 | 506 | > |
| 274 | 507 | <div className={`message-bubble ${msg.type}-bubble`}> |
| 275 | 508 | <div className="message-content"> |
| 276 | 509 | {msg.type === 'ai' ? ( |
| 277 | - <ReactMarkdown | |
| 510 | + <ReactMarkdown | |
| 278 | 511 | remarkPlugins={[remarkGfm]} |
| 279 | 512 | components={{ |
| 280 | 513 | code: ({ node, inline, className, children, ...props }) => ( |
| ... | ... | @@ -300,13 +533,13 @@ const ChatInterface = () => { |
| 300 | 533 | <span className="message-time">{msg.time}</span> |
| 301 | 534 | {msg.type === 'ai' && !msg.isWelcome && ( |
| 302 | 535 | <div className="message-actions"> |
| 303 | - <button | |
| 536 | + <button | |
| 304 | 537 | className="action-btn" |
| 305 | 538 | onClick={() => handleCopyMessage(msg.content)} |
| 306 | 539 | > |
| 307 | 540 | 复制 |
| 308 | 541 | </button> |
| 309 | - <button | |
| 542 | + <button | |
| 310 | 543 | className="action-btn" |
| 311 | 544 | onClick={() => handleRegenerateMessage(msg.id, msg.content)} |
| 312 | 545 | > |
| ... | ... | @@ -318,7 +551,7 @@ const ChatInterface = () => { |
| 318 | 551 | </div> |
| 319 | 552 | </div> |
| 320 | 553 | ))} |
| 321 | - | |
| 554 | + | |
| 322 | 555 | {/* 打字机效果 */} |
| 323 | 556 | {isLoading && ( |
| 324 | 557 | <div className="message ai-message"> |
| ... | ... | @@ -330,20 +563,46 @@ const ChatInterface = () => { |
| 330 | 563 | </div> |
| 331 | 564 | </div> |
| 332 | 565 | )} |
| 333 | - | |
| 566 | + | |
| 334 | 567 | <div ref={messagesEndRef} className="bottom-spacer" /> |
| 335 | 568 | </div> |
| 336 | 569 | |
| 337 | 570 | {/* 输入区域 */} |
| 338 | 571 | <div className="input-section"> |
| 572 | + {/* 语音模式提示 - 仅在录音时显示 */} | |
| 573 | + {isRecording && ( | |
| 574 | + <div className="voice-mode-indicator"> | |
| 575 | + <div className="voice-wave"> | |
| 576 | + <span></span> | |
| 577 | + <span></span> | |
| 578 | + <span></span> | |
| 579 | + <span></span> | |
| 580 | + <span></span> | |
| 581 | + </div> | |
| 582 | + <span className="voice-text"> | |
| 583 | + 正在录音 {formatDuration(recordingDuration)} | |
| 584 | + </span> | |
| 585 | + <button | |
| 586 | + className="voice-cancel-btn" | |
| 587 | + onClick={cancelRecording} | |
| 588 | + > | |
| 589 | + 取消 | |
| 590 | + </button> | |
| 591 | + </div> | |
| 592 | + )} | |
| 593 | + | |
| 339 | 594 | <div className="input-wrapper"> |
| 340 | 595 | <input |
| 341 | 596 | ref={inputRef} |
| 342 | 597 | type="text" |
| 343 | 598 | className="message-input" |
| 344 | - placeholder="输入您的问题..." | |
| 599 | + placeholder={isRecording ? "正在听您说话..." : "输入您的问题..."} | |
| 345 | 600 | value={inputValue} |
| 346 | - onChange={(e) => setInputValue(e.target.value)} | |
| 601 | + onChange={(e) => { | |
| 602 | + if (!isRecording) { | |
| 603 | + setInputValue(e.target.value); | |
| 604 | + } | |
| 605 | + }} | |
| 347 | 606 | onKeyPress={(e) => { |
| 348 | 607 | if (e.key === 'Enter' && !e.shiftKey) { |
| 349 | 608 | e.preventDefault(); |
| ... | ... | @@ -351,12 +610,32 @@ const ChatInterface = () => { |
| 351 | 610 | } |
| 352 | 611 | }} |
| 353 | 612 | disabled={isLoading} |
| 613 | + readOnly={isRecording} | |
| 354 | 614 | /> |
| 355 | - <AudioOutline className='message-icon'/> | |
| 356 | - <button | |
| 615 | + | |
| 616 | + {/* 语音按钮 - 点击切换录音状态 */} | |
| 617 | + <button | |
| 618 | + className={`voice-button ${isRecording ? 'recording' : ''}`} | |
| 619 | + onClick={toggleRecording} | |
| 620 | + disabled={isLoading} | |
| 621 | + > | |
| 622 | + {isRecording ? ( | |
| 623 | + <> | |
| 624 | + {/* <AudioFill className="voice-icon" /> */} | |
| 625 | + <span className="voice-text">结束录音</span> | |
| 626 | + </> | |
| 627 | + ) : ( | |
| 628 | + <> | |
| 629 | + {/* <AudioOutline className="voice-icon" /> */} | |
| 630 | + <span className="voice-text">点击录音</span> | |
| 631 | + </> | |
| 632 | + )} | |
| 633 | + </button> | |
| 634 | + | |
| 635 | + <button | |
| 357 | 636 | className={`send-button ${isLoading ? 'disabled' : ''}`} |
| 358 | 637 | onClick={handleSendMessage} |
| 359 | - disabled={isLoading} | |
| 638 | + disabled={isLoading || isRecording} | |
| 360 | 639 | > |
| 361 | 640 | 发送 |
| 362 | 641 | </button> | ... | ... |