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