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