Commit c6ddce2bbce26ddd61aeece6a9a30c6fc73e14b1

Authored by chenxt
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 import ReactMarkdown from 'react-markdown'; 2 import ReactMarkdown from 'react-markdown';
3 import remarkGfm from 'remark-gfm'; 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 const ChatInterface = () => { 7 const ChatInterface = () => {
8 // ==================== 状态管理 ==================== 8 // ==================== 状态管理 ====================
@@ -15,13 +15,27 @@ const ChatInterface = () => { @@ -15,13 +15,27 @@ const ChatInterface = () => {
15 const [currentModel, setCurrentModel] = useState('general'); 15 const [currentModel, setCurrentModel] = useState('general');
16 const [chatHistory, setChatHistory] = useState([]); 16 const [chatHistory, setChatHistory] = useState([]);
17 const [welcomeContent, setWelcomeContent] = useState(''); 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 const messagesEndRef = useRef(null); 25 const messagesEndRef = useRef(null);
20 const inputRef = useRef(null); 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 const CONFIG = { 35 const CONFIG = {
24 backendUrl: 'http://localhost:8099/xlyAi', 36 backendUrl: 'http://localhost:8099/xlyAi',
  37 + wsUrl: 'ws://121.43.128.225:10096', // 语音识别WebSocket地址
  38 + sampleRate: 16000,
25 endpoints: { 39 endpoints: {
26 chat: '/api/v1/chat/query', 40 chat: '/api/v1/chat/query',
27 process: '/api/v1/chat/query', 41 process: '/api/v1/chat/query',
@@ -52,6 +66,212 @@ const ChatInterface = () => { @@ -52,6 +66,212 @@ const ChatInterface = () => {
52 messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); 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 useEffect(() => { 276 useEffect(() => {
57 setMessages([ 277 setMessages([
@@ -75,7 +295,7 @@ const ChatInterface = () =&gt; { @@ -75,7 +295,7 @@ const ChatInterface = () =&gt; {
75 const data = await response.json(); 295 const data = await response.json();
76 if (data.data) { 296 if (data.data) {
77 setWelcomeContent(data.data); 297 setWelcomeContent(data.data);
78 - setMessages(prev => prev.map(msg => 298 + setMessages(prev => prev.map(msg =>
79 msg.id === 'welcome' ? { ...msg, content: data.data } : msg 299 msg.id === 'welcome' ? { ...msg, content: data.data } : msg
80 )); 300 ));
81 } 301 }
@@ -93,20 +313,22 @@ const ChatInterface = () =&gt; { @@ -93,20 +313,22 @@ const ChatInterface = () =&gt; {
93 } 313 }
94 if (e.key === 'Escape') { 314 if (e.key === 'Escape') {
95 setInputValue(''); 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 document.addEventListener('keydown', handleKeyDown); 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 useEffect(() => { 334 useEffect(() => {
@@ -130,6 +352,12 @@ const ChatInterface = () =&gt; { @@ -130,6 +352,12 @@ const ChatInterface = () =&gt; {
130 const message = inputValue.trim(); 352 const message = inputValue.trim();
131 if (!message || isLoading) return; 353 if (!message || isLoading) return;
132 354
  355 + // 如果正在录音,先停止
  356 + if (isRecordingRef.current) {
  357 + stopRecording();
  358 + setIsVoiceMode(false);
  359 + }
  360 +
133 let currentSessionId = sessionId; 361 let currentSessionId = sessionId;
134 if (!currentSessionId) { 362 if (!currentSessionId) {
135 currentSessionId = generateRandomString(20); 363 currentSessionId = generateRandomString(20);
@@ -168,7 +396,7 @@ const ChatInterface = () =&gt; { @@ -168,7 +396,7 @@ const ChatInterface = () =&gt; {
168 } 396 }
169 397
170 const data = await response.json(); 398 const data = await response.json();
171 - 399 +
172 if (data.data) { 400 if (data.data) {
173 addMessage(data.data, 'ai'); 401 addMessage(data.data, 'ai');
174 setChatHistory(prev => { 402 setChatHistory(prev => {
@@ -210,6 +438,11 @@ const ChatInterface = () =&gt; { @@ -210,6 +438,11 @@ const ChatInterface = () =&gt; {
210 ]); 438 ]);
211 setChatHistory([]); 439 setChatHistory([]);
212 setSessionId(''); 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 = () =&gt; { @@ -220,12 +453,12 @@ const ChatInterface = () =&gt; {
220 }; 453 };
221 454
222 const handleRegenerateMessage = (messageId, content) => { 455 const handleRegenerateMessage = (messageId, content) => {
223 - setChatHistory(prev => prev.filter(item => 456 + setChatHistory(prev => prev.filter(item =>
224 !(item.role === 'assistant' && item.content === content) 457 !(item.role === 'assistant' && item.content === content)
225 )); 458 ));
226 - 459 +
227 setMessages(prev => prev.filter(msg => msg.id !== messageId)); 460 setMessages(prev => prev.filter(msg => msg.id !== messageId));
228 - 461 +
229 setInputValue(content); 462 setInputValue(content);
230 setTimeout(() => handleSendMessage(), 100); 463 setTimeout(() => handleSendMessage(), 100);
231 }; 464 };
@@ -244,7 +477,7 @@ const ChatInterface = () =&gt; { @@ -244,7 +477,7 @@ const ChatInterface = () =&gt; {
244 <p>AI 印刷助手</p> 477 <p>AI 印刷助手</p>
245 </div> 478 </div>
246 <div className="header-right"> 479 <div className="header-right">
247 - <select 480 + <select
248 className="model-selector" 481 className="model-selector"
249 value={currentModel} 482 value={currentModel}
250 onChange={handleModelChange} 483 onChange={handleModelChange}
@@ -252,7 +485,7 @@ const ChatInterface = () =&gt; { @@ -252,7 +485,7 @@ const ChatInterface = () =&gt; {
252 <option value="process">小羚羊印刷行业大模型</option> 485 <option value="process">小羚羊印刷行业大模型</option>
253 <option value="general">qwen2.5:14b</option> 486 <option value="general">qwen2.5:14b</option>
254 </select> 487 </select>
255 - <button 488 + <button
256 className="model-selectors" 489 className="model-selectors"
257 onClick={handleClearChat} 490 onClick={handleClearChat}
258 > 491 >
@@ -267,14 +500,14 @@ const ChatInterface = () =&gt; { @@ -267,14 +500,14 @@ const ChatInterface = () =&gt; {
267 {/* 消息区域 */} 500 {/* 消息区域 */}
268 <div className="messages-container"> 501 <div className="messages-container">
269 {messages.map((msg) => ( 502 {messages.map((msg) => (
270 - <div 503 + <div
271 key={msg.id} 504 key={msg.id}
272 className={`message ${msg.type}-message`} 505 className={`message ${msg.type}-message`}
273 > 506 >
274 <div className={`message-bubble ${msg.type}-bubble`}> 507 <div className={`message-bubble ${msg.type}-bubble`}>
275 <div className="message-content"> 508 <div className="message-content">
276 {msg.type === 'ai' ? ( 509 {msg.type === 'ai' ? (
277 - <ReactMarkdown 510 + <ReactMarkdown
278 remarkPlugins={[remarkGfm]} 511 remarkPlugins={[remarkGfm]}
279 components={{ 512 components={{
280 code: ({ node, inline, className, children, ...props }) => ( 513 code: ({ node, inline, className, children, ...props }) => (
@@ -300,13 +533,13 @@ const ChatInterface = () =&gt; { @@ -300,13 +533,13 @@ const ChatInterface = () =&gt; {
300 <span className="message-time">{msg.time}</span> 533 <span className="message-time">{msg.time}</span>
301 {msg.type === 'ai' && !msg.isWelcome && ( 534 {msg.type === 'ai' && !msg.isWelcome && (
302 <div className="message-actions"> 535 <div className="message-actions">
303 - <button 536 + <button
304 className="action-btn" 537 className="action-btn"
305 onClick={() => handleCopyMessage(msg.content)} 538 onClick={() => handleCopyMessage(msg.content)}
306 > 539 >
307 复制 540 复制
308 </button> 541 </button>
309 - <button 542 + <button
310 className="action-btn" 543 className="action-btn"
311 onClick={() => handleRegenerateMessage(msg.id, msg.content)} 544 onClick={() => handleRegenerateMessage(msg.id, msg.content)}
312 > 545 >
@@ -318,7 +551,7 @@ const ChatInterface = () =&gt; { @@ -318,7 +551,7 @@ const ChatInterface = () =&gt; {
318 </div> 551 </div>
319 </div> 552 </div>
320 ))} 553 ))}
321 - 554 +
322 {/* 打字机效果 */} 555 {/* 打字机效果 */}
323 {isLoading && ( 556 {isLoading && (
324 <div className="message ai-message"> 557 <div className="message ai-message">
@@ -330,20 +563,46 @@ const ChatInterface = () =&gt; { @@ -330,20 +563,46 @@ const ChatInterface = () =&gt; {
330 </div> 563 </div>
331 </div> 564 </div>
332 )} 565 )}
333 - 566 +
334 <div ref={messagesEndRef} className="bottom-spacer" /> 567 <div ref={messagesEndRef} className="bottom-spacer" />
335 </div> 568 </div>
336 569
337 {/* 输入区域 */} 570 {/* 输入区域 */}
338 <div className="input-section"> 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 <div className="input-wrapper"> 594 <div className="input-wrapper">
340 <input 595 <input
341 ref={inputRef} 596 ref={inputRef}
342 type="text" 597 type="text"
343 className="message-input" 598 className="message-input"
344 - placeholder="输入您的问题..." 599 + placeholder={isRecording ? "正在听您说话..." : "输入您的问题..."}
345 value={inputValue} 600 value={inputValue}
346 - onChange={(e) => setInputValue(e.target.value)} 601 + onChange={(e) => {
  602 + if (!isRecording) {
  603 + setInputValue(e.target.value);
  604 + }
  605 + }}
347 onKeyPress={(e) => { 606 onKeyPress={(e) => {
348 if (e.key === 'Enter' && !e.shiftKey) { 607 if (e.key === 'Enter' && !e.shiftKey) {
349 e.preventDefault(); 608 e.preventDefault();
@@ -351,12 +610,32 @@ const ChatInterface = () =&gt; { @@ -351,12 +610,32 @@ const ChatInterface = () =&gt; {
351 } 610 }
352 }} 611 }}
353 disabled={isLoading} 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 className={`send-button ${isLoading ? 'disabled' : ''}`} 636 className={`send-button ${isLoading ? 'disabled' : ''}`}
358 onClick={handleSendMessage} 637 onClick={handleSendMessage}
359 - disabled={isLoading} 638 + disabled={isLoading || isRecording}
360 > 639 >
361 发送 640 发送
362 </button> 641 </button>