import React, { useState, useEffect, useRef, useCallback } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { AudioOutline, AudioFill } from "antd-mobile-icons"; import './AiChatStyles.less'; const ChatInterface = () => { // ==================== 状态管理 ==================== const [sessionId, setSessionId] = useState(''); const [sUserId] = useState('user-001'); const [sUserType] = useState('sysadmin'); const [messages, setMessages] = useState([]); const [inputValue, setInputValue] = useState(''); const [isLoading, setIsLoading] = useState(false); const [currentModel, setCurrentModel] = useState('general'); const [chatHistory, setChatHistory] = useState([]); const [welcomeContent, setWelcomeContent] = useState(''); // 语音输入状态 const [isRecording, setIsRecording] = useState(false); const [isWsConnected, setIsWsConnected] = useState(false); const [isVoiceMode, setIsVoiceMode] = useState(false); const [recordingDuration, setRecordingDuration] = useState(0); const messagesEndRef = useRef(null); const inputRef = useRef(null); const wsRef = useRef(null); const audioContextRef = useRef(null); const scriptProcessorRef = useRef(null); const inputNodeRef = useRef(null); const recordingTimerRef = useRef(null); const isRecordingRef = useRef(false); // ==================== 配置 ==================== const CONFIG = { backendUrl: 'http://localhost:8099/xlyAi', wsUrl: 'ws://121.43.128.225:10096', // 语音识别WebSocket地址 sampleRate: 16000, endpoints: { chat: '/api/v1/chat/query', process: '/api/v1/chat/query', }, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, maxHistory: 20, }; // ==================== 工具函数 ==================== const getCurrentTime = () => { const now = new Date(); return `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`; }; const generateRandomString = (length) => { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; let result = ''; for (let i = 0; i < length; i++) { result += chars.charAt(Math.floor(Math.random() * chars.length)); } return result; }; const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }; // ==================== WebSocket 语音识别 ==================== // 连接语音识别WebSocket const connectWebSocket = useCallback(() => { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { return; } const ws = new WebSocket(CONFIG.wsUrl); ws.binaryType = "arraybuffer"; ws.onopen = () => { console.log("语音识别WebSocket连接成功"); setIsWsConnected(true); }; ws.onmessage = (event) => { try { const res = JSON.parse(event.data); if (res.code === 0 && (res.msg === "success" || res.msg === "flush_success")) { if (res.text && res.text.trim()) { setInputValue(prev => { const separator = prev && !prev.endsWith(' ') ? ' ' : ''; const newValue = prev ? `${prev}${separator}${res.text}` : res.text; console.log('语音识别结果:', res.text, '更新后:', newValue); return newValue; }); } } } catch (e) { console.error("WebSocket消息解析失败:", e); } }; ws.onclose = () => { console.log("语音识别WebSocket连接断开"); setIsWsConnected(false); if (isRecordingRef.current) { stopRecording(); } }; ws.onerror = (err) => { console.error("WebSocket错误:", err); setIsWsConnected(false); }; wsRef.current = ws; }, []); // 断开WebSocket const disconnectWebSocket = useCallback(() => { if (wsRef.current) { wsRef.current.close(); wsRef.current = null; } setIsWsConnected(false); }, []); // 发送指令 const sendCommand = useCallback((action) => { if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { return; } const cmd = JSON.stringify({ action }); wsRef.current.send(cmd); }, []); // Float32 转 Int16 PCM const float32ToInt16 = (float32Array) => { const int16Array = new Int16Array(float32Array.length); for (let i = 0; i < float32Array.length; i++) { let s = Math.max(-1, Math.min(1, float32Array[i])); int16Array[i] = s < 0 ? s * 0x8000 : s * 0x7FFF; } return new Uint8Array(int16Array.buffer); }; // 音频重采样 const resampleAudio = (data, originalRate, targetRate) => { if (originalRate === targetRate) return data; const ratio = targetRate / originalRate; const newLength = Math.round(data.length * ratio); const result = new Float32Array(newLength); for (let i = 0; i < newLength; i++) { result[i] = data[Math.round(i / ratio)] || 0; } return result; }; // 开始录音 const startRecording = async () => { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { alert("浏览器不支持麦克风采集"); return; } try { // 先连接WebSocket if (!isWsConnected) { connectWebSocket(); await new Promise(resolve => setTimeout(resolve, 1000)); } // 检查 WebSocket 是否已连接 if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { alert("语音识别服务未连接,请重试"); return; } const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); audioContextRef.current = new (window.AudioContext || window.webkitAudioContext)(); inputNodeRef.current = audioContextRef.current.createMediaStreamSource(stream); scriptProcessorRef.current = audioContextRef.current.createScriptProcessor(2048, 1, 1); // 使用 ref 来检查录音状态,避免闭包问题 scriptProcessorRef.current.onaudioprocess = (event) => { if (!isRecordingRef.current) return; const inputData = event.inputBuffer.getChannelData(0); const resampledData = resampleAudio(inputData, audioContextRef.current.sampleRate, CONFIG.sampleRate); const pcmData = float32ToInt16(resampledData); if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { wsRef.current.send(pcmData); } }; inputNodeRef.current.connect(scriptProcessorRef.current); scriptProcessorRef.current.connect(audioContextRef.current.destination); // 关键:先设置 ref,再设置 state isRecordingRef.current = true; setIsRecording(true); setIsVoiceMode(true); setRecordingDuration(0); // 开始计时 recordingTimerRef.current = setInterval(() => { setRecordingDuration(prev => prev + 1); }, 1000); console.log("录音已开始"); } catch (e) { console.error("录音启动失败:", e); alert("录音启动失败:" + e.message); isRecordingRef.current = false; setIsRecording(false); } }; // 停止录音 const stopRecording = useCallback(() => { console.log("停止录音"); isRecordingRef.current = false; setIsRecording(false); setIsVoiceMode(false); if (recordingTimerRef.current) { clearInterval(recordingTimerRef.current); recordingTimerRef.current = null; } if (inputNodeRef.current) { inputNodeRef.current.disconnect(); inputNodeRef.current = null; } if (scriptProcessorRef.current) { scriptProcessorRef.current.disconnect(); scriptProcessorRef.current = null; } if (audioContextRef.current) { audioContextRef.current.close(); audioContextRef.current = null; } // 发送刷新指令获取最终结果 sendCommand("flush"); }, [sendCommand]); // 切换录音状态(点击按钮) const toggleRecording = useCallback(() => { if (isRecordingRef.current) { // 正在录音,停止 stopRecording(); } else { // 未录音,开始 startRecording(); } }, [stopRecording]); // 取消录音并清空 const cancelRecording = useCallback(() => { if (isRecordingRef.current) { stopRecording(); } setInputValue(''); setIsVoiceMode(false); }, [stopRecording]); // 格式化录音时长 const formatDuration = (seconds) => { const mins = Math.floor(seconds / 60).toString().padStart(2, '0'); const secs = (seconds % 60).toString().padStart(2, '0'); return `${mins}:${secs}`; }; // ==================== 初始化 ==================== useEffect(() => { setMessages([ { id: 'welcome', type: 'ai', content: '', time: getCurrentTime(), isWelcome: true } ]); const initSession = async () => { try { const initUrl = `${CONFIG.backendUrl}/api/v1/chat/init?sUserId=${sUserId}&sUserType=${sUserType}`; const response = await fetch(initUrl, { method: 'POST', headers: CONFIG.headers, body: JSON.stringify({}) }); const data = await response.json(); if (data.data) { setWelcomeContent(data.data); setMessages(prev => prev.map(msg => msg.id === 'welcome' ? { ...msg, content: data.data } : msg )); } } catch (error) { console.error('初始化失败:', error); } }; initSession(); inputRef.current?.focus(); const handleKeyDown = (e) => { if (e.ctrlKey && e.key === 'Enter') { handleSendMessage(); } if (e.key === 'Escape') { setInputValue(''); if (isRecordingRef.current) { stopRecording(); setIsVoiceMode(false); } } }; document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('keydown', handleKeyDown); disconnectWebSocket(); if (recordingTimerRef.current) { clearInterval(recordingTimerRef.current); } }; }, []); useEffect(() => { scrollToBottom(); }, [messages, isLoading]); // ==================== 消息处理 ==================== const addMessage = (content, type, isError = false) => { const newMessage = { id: `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, type, content, time: getCurrentTime(), isError }; setMessages(prev => [...prev, newMessage]); return newMessage.id; }; const handleSendMessage = async () => { const message = inputValue.trim(); if (!message || isLoading) return; // 如果正在录音,先停止 if (isRecordingRef.current) { stopRecording(); setIsVoiceMode(false); } let currentSessionId = sessionId; if (!currentSessionId) { currentSessionId = generateRandomString(20); setSessionId(currentSessionId); } setInputValue(''); setIsLoading(true); addMessage(message, 'user'); const newHistoryItem = { role: 'user', content: message, timestamp: Date.now() }; setChatHistory(prev => { const updated = [...prev, newHistoryItem]; return updated.length > CONFIG.maxHistory ? updated.slice(-CONFIG.maxHistory) : updated; }); try { const endpoint = currentModel === 'process' ? CONFIG.endpoints.process : CONFIG.endpoints.chat; const requestData = { message, modelType: currentModel, sUserId, sUserType, sessionId: currentSessionId }; const response = await fetch(`${CONFIG.backendUrl}${endpoint}`, { method: 'POST', headers: CONFIG.headers, body: JSON.stringify(requestData) }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); if (data.data) { addMessage(data.data, 'ai'); setChatHistory(prev => { const updated = [...prev, { role: 'assistant', content: data.data, timestamp: Date.now() }]; return updated.length > CONFIG.maxHistory ? updated.slice(-CONFIG.maxHistory) : updated; }); } } catch (error) { console.error('请求失败:', error); const errorMessage = ` 抱歉,请求出现错误:${error.message} **可能的原因:** 1. Spring Boot 后端服务未启动 2. API 接口路径不正确 3. 网络连接问题 **检查步骤:** 1. 确保后端服务在端口 8099 运行 2. 检查浏览器控制台查看详细错误 3. 刷新页面重试 `; addMessage(errorMessage, 'ai', true); } finally { setIsLoading(false); inputRef.current?.focus(); } }; const handleClearChat = () => { if (window.confirm('确定要清空当前对话吗?')) { setMessages([ { id: 'cleared', type: 'ai', content: '对话已清空,请开始新的对话。', time: getCurrentTime(), } ]); setChatHistory([]); setSessionId(''); setInputValue(''); if (isRecordingRef.current) { stopRecording(); setIsVoiceMode(false); } } }; const handleCopyMessage = (content) => { navigator.clipboard.writeText(content).then(() => { alert('已复制到剪贴板'); }); }; const handleRegenerateMessage = (messageId, content) => { setChatHistory(prev => prev.filter(item => !(item.role === 'assistant' && item.content === content) )); setMessages(prev => prev.filter(msg => msg.id !== messageId)); setInputValue(content); setTimeout(() => handleSendMessage(), 100); }; const handleModelChange = (e) => { setCurrentModel(e.target.value); }; // ==================== 渲染 ==================== return (
AI 印刷助手
{children}
) : (
{children}
)
)
}}
>
{msg.content || welcomeContent}