| <!DOCTYPE html> |
| <html lang="zh"> |
| <head> |
| <meta charset="UTF-8"> |
| <title>AI Agent Chat - Streaming</title> |
| <style> |
| body { |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
| max-width: 900px; |
| margin: 0 auto; |
| padding: 20px; |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
| min-height: 100vh; |
| } |
| .header { |
| text-align: center; |
| margin-bottom: 20px; |
| } |
| h2 { |
| color: white; |
| text-shadow: 0 2px 4px rgba(0,0,0,0.2); |
| margin: 0 0 8px 0; |
| } |
| .session-info { |
| display: inline-block; |
| background: rgba(255, 255, 255, 0.2); |
| padding: 4px 12px; |
| border-radius: 12px; |
| backdrop-filter: blur(10px); |
| } |
| .session-label { |
| font-size: 0.75em; |
| color: rgba(255, 255, 255, 0.9); |
| font-weight: 500; |
| } |
| .session-id { |
| font-family: 'Courier New', monospace; |
| font-size: 0.7em; |
| color: white; |
| margin-left: 6px; |
| opacity: 0.9; |
| } |
| .connection-status { |
| text-align: center; |
| padding: 6px 12px; |
| border-radius: 8px; |
| margin-bottom: 12px; |
| font-size: 0.85em; |
| font-weight: 500; |
| backdrop-filter: blur(10px); |
| } |
| .status-connected { |
| background: rgba(81, 207, 102, 0.2); |
| color: white; |
| border: 1px solid rgba(81, 207, 102, 0.5); |
| } |
| .status-disconnected { |
| background: rgba(255, 107, 107, 0.2); |
| color: white; |
| border: 1px solid rgba(255, 107, 107, 0.5); |
| } |
| .status-connecting { |
| background: rgba(255, 193, 7, 0.2); |
| color: white; |
| border: 1px solid rgba(255, 193, 7, 0.5); |
| } |
| #chat-box { |
| background: white; |
| height: 600px; |
| overflow-y: auto; |
| padding: 20px; |
| border-radius: 12px; |
| box-shadow: 0 4px 20px rgba(0,0,0,0.15); |
| margin-bottom: 20px; |
| } |
| .msg { |
| margin: 12px 0; |
| padding: 12px 18px; |
| border-radius: 18px; |
| max-width: 85%; |
| line-height: 1.6; |
| animation: fadeIn 0.3s ease-in; |
| } |
| @keyframes fadeIn { |
| from { opacity: 0; transform: translateY(10px); } |
| to { opacity: 1; transform: translateY(0); } |
| } |
| .user { |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
| color: white; |
| margin-left: auto; |
| text-align: right; |
| box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3); |
| } |
| .agent { |
| background: #f8f9fa; |
| color: #333; |
| margin-right: auto; |
| border: 1px solid #e9ecef; |
| } |
| .status { |
| text-align: center; |
| color: #6c757d; |
| font-size: 0.9em; |
| margin: 8px 0; |
| font-style: italic; |
| padding: 8px; |
| background: #fff3cd; |
| border-radius: 8px; |
| border-left: 4px solid #ffc107; |
| } |
| .error { |
| background: #f8d7da; |
| border-left: 4px solid #dc3545; |
| color: #721c24; |
| } |
| .success { |
| background: #d4edda; |
| border-left: 4px solid #28a745; |
| color: #155724; |
| } |
| .agent-label { |
| font-size: 0.85em; |
| color: #667eea; |
| margin-bottom: 6px; |
| display: block; |
| font-weight: 600; |
| text-transform: uppercase; |
| letter-spacing: 0.5px; |
| } |
| .agent-content { |
| white-space: pre-wrap; |
| word-wrap: break-word; |
| } |
| .typing-cursor { |
| display: inline-block; |
| width: 2px; |
| height: 1em; |
| background: #667eea; |
| animation: blink 1s infinite; |
| margin-left: 2px; |
| vertical-align: text-bottom; |
| } |
| @keyframes blink { |
| 0%, 50% { opacity: 1; } |
| 51%, 100% { opacity: 0; } |
| } |
| .loading-dots { |
| display: inline-flex; |
| align-items: center; |
| gap: 4px; |
| margin-left: 8px; |
| } |
| .loading-dots span { |
| width: 8px; |
| height: 8px; |
| border-radius: 50%; |
| background: #667eea; |
| animation: bounce 1.4s infinite ease-in-out both; |
| } |
| .loading-dots span:nth-child(1) { |
| animation-delay: -0.32s; |
| } |
| .loading-dots span:nth-child(2) { |
| animation-delay: -0.16s; |
| } |
| @keyframes bounce { |
| 0%, 80%, 100% { |
| transform: scale(0); |
| opacity: 0.5; |
| } |
| 40% { |
| transform: scale(1); |
| opacity: 1; |
| } |
| } |
| .input-area { |
| display: flex; |
| gap: 12px; |
| background: white; |
| padding: 15px; |
| border-radius: 12px; |
| box-shadow: 0 4px 20px rgba(0,0,0,0.15); |
| align-items: center; |
| } |
| input { |
| flex: 1; |
| padding: 12px 16px; |
| border: 2px solid #e9ecef; |
| border-radius: 8px; |
| font-size: 15px; |
| transition: border-color 0.3s; |
| } |
| input:focus { |
| outline: none; |
| border-color: #667eea; |
| } |
| .btn-primary { |
| padding: 12px 28px; |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
| color: white; |
| border: none; |
| border-radius: 8px; |
| cursor: pointer; |
| font-size: 15px; |
| font-weight: 600; |
| transition: transform 0.2s, box-shadow 0.2s; |
| white-space: nowrap; |
| } |
| .btn-primary:hover { |
| transform: translateY(-2px); |
| box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); |
| } |
| .btn-primary:active { |
| transform: translateY(0); |
| } |
| .btn-primary:disabled { |
| background: #ccc; |
| cursor: not-allowed; |
| transform: none; |
| } |
| .btn-secondary { |
| padding: 12px 20px; |
| background: rgba(255, 255, 255, 0.9); |
| color: #667eea; |
| border: 2px solid #667eea; |
| border-radius: 8px; |
| cursor: pointer; |
| font-size: 14px; |
| font-weight: 600; |
| transition: all 0.2s; |
| white-space: nowrap; |
| } |
| .btn-secondary:hover:not(:disabled) { |
| background: #667eea; |
| color: white; |
| transform: translateY(-2px); |
| box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); |
| } |
| .btn-secondary:disabled { |
| opacity: 0.5; |
| cursor: not-allowed; |
| transform: none; |
| } |
| .btn-danger { |
| color: #ff6b6b; |
| border-color: #ff6b6b; |
| } |
| .btn-danger:hover:not(:disabled) { |
| background: #ff6b6b; |
| color: white; |
| box-shadow: 0 4px 12px rgba(255, 107, 107, 0.3); |
| } |
| .btn-success { |
| color: #51cf66; |
| border-color: #51cf66; |
| } |
| .btn-success:hover:not(:disabled) { |
| background: #51cf66; |
| color: white; |
| box-shadow: 0 4px 12px rgba(81, 207, 102, 0.3); |
| } |
| .trace-info { |
| font-size: 0.75em; |
| color: #999; |
| margin-top: 4px; |
| text-align: right; |
| } |
| .toast { |
| position: fixed; |
| top: 20px; |
| left: 50%; |
| transform: translateX(-50%) translateY(-100px); |
| background: rgba(0, 0, 0, 0.85); |
| color: white; |
| padding: 12px 24px; |
| border-radius: 8px; |
| font-size: 14px; |
| font-weight: 500; |
| z-index: 10000; |
| opacity: 0; |
| transition: all 0.3s ease; |
| backdrop-filter: blur(10px); |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); |
| } |
| .toast.show { |
| transform: translateX(-50%) translateY(0); |
| opacity: 1; |
| } |
| .toast.success { |
| background: rgba(81, 207, 102, 0.9); |
| } |
| .toast.error { |
| background: rgba(255, 107, 107, 0.9); |
| } |
| .toast.info { |
| background: rgba(102, 126, 234, 0.9); |
| } |
| </style> |
| </head> |
| <body> |
| <div class="header"> |
| <h2>🤖 LangGraph Supervisor + RocketMQ Streaming</h2> |
| <div class="session-info"> |
| <span class="session-label">Session:</span> |
| <span class="session-id" id="session-id-display"></span> |
| </div> |
| </div> |
| |
| <div class="connection-status status-connected" id="connection-status"> |
| ✅ 已连接 |
| </div> |
| |
| <div id="chat-box"></div> |
| <div class="input-area"> |
| <input type="text" id="user-input" placeholder="例如:杭州明天天气? 或 帮我做杭州下周六自驾游规划" onkeypress="if(event.key==='Enter') sendMessage()"> |
| <button id="send-btn" class="btn-primary" onclick="sendMessage()">发送</button> |
| <button class="btn-secondary btn-danger" id="disconnect-btn" onclick="disconnectStream()"> |
| 🔌 断开 |
| </button> |
| <button class="btn-secondary btn-success" id="reconnect-btn" onclick="reconnectStream()"> |
| 🔄 重连 |
| </button> |
| </div> |
| |
| <div id="toast-container"></div> |
| |
| <script> |
| const chatBox = document.getElementById('chat-box'); |
| const sendBtn = document.getElementById('send-btn'); |
| const disconnectBtn = document.getElementById('disconnect-btn'); |
| const reconnectBtn = document.getElementById('reconnect-btn'); |
| const connectionStatus = document.getElementById('connection-status'); |
| |
| // Generate session ID when page loads |
| const sessionId = generateUUID(); |
| |
| // Display session ID on page |
| document.getElementById('session-id-display').textContent = sessionId; |
| |
| // Function to generate UUID v4 |
| function generateUUID() { |
| return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { |
| const r = Math.random() * 16 | 0; |
| const v = c === 'x' ? r : (r & 0x3 | 0x8); |
| return v.toString(16); |
| }); |
| } |
| |
| // Track active message elements for streaming updates |
| let activeMessageElements = {}; |
| let isProcessing = false; |
| let statusDiv = null; |
| let lastActiveAgentKey = null; |
| |
| // Connection state management |
| let currentReader = null; |
| let isConnected = true; |
| let isReconnecting = false; |
| |
| // State preservation for reconnection |
| let preservedMessageElements = {}; |
| let preservedLastAgentKey = null; |
| |
| // Track whether the last task completed with [DONE] |
| let lastTaskCompleted = false; |
| |
| // Show toast notification |
| function showToast(message, type = 'info', duration = 3000) { |
| const container = document.getElementById('toast-container'); |
| const toast = document.createElement('div'); |
| toast.className = `toast ${type}`; |
| toast.textContent = message; |
| |
| container.appendChild(toast); |
| |
| setTimeout(() => { |
| toast.classList.add('show'); |
| }, 10); |
| |
| setTimeout(() => { |
| toast.classList.remove('show'); |
| setTimeout(() => { |
| toast.remove(); |
| }, 300); |
| }, duration); |
| } |
| |
| // Update connection status display |
| function updateConnectionStatus(status, message) { |
| const statusClasses = { |
| 'connected': 'status-connected', |
| 'disconnected': 'status-disconnected', |
| 'connecting': 'status-connecting' |
| }; |
| |
| connectionStatus.className = `connection-status ${statusClasses[status] || ''}`; |
| |
| const icons = { |
| 'connected': '✅', |
| 'disconnected': '❌', |
| 'connecting': '⏳' |
| }; |
| |
| connectionStatus.innerHTML = `${icons[status] || ''} ${message}`; |
| |
| disconnectBtn.disabled = status !== 'connected'; |
| reconnectBtn.disabled = status === 'connecting' || status === 'connected'; |
| } |
| |
| function appendMessage(role, content, label = null, isStatus = false, isError = false, isSuccess = false) { |
| if (isStatus) { |
| const div = document.createElement('div'); |
| div.className = `status ${isError ? 'error' : isSuccess ? 'success' : ''}`; |
| div.innerHTML = content; |
| chatBox.appendChild(div); |
| chatBox.scrollTop = chatBox.scrollHeight; |
| return div; |
| } |
| |
| const div = document.createElement('div'); |
| div.className = `msg ${role}`; |
| |
| if (label) { |
| const span = document.createElement('span'); |
| span.className = 'agent-label'; |
| span.innerText = label; |
| div.appendChild(span); |
| } |
| |
| const contentDiv = document.createElement('div'); |
| contentDiv.className = 'agent-content'; |
| contentDiv.innerHTML = content.replace(/\n/g, '<br>'); |
| div.appendChild(contentDiv); |
| |
| chatBox.appendChild(div); |
| chatBox.scrollTop = chatBox.scrollHeight; |
| return contentDiv; |
| } |
| |
| function updateMessageContent(contentDiv, newContent) { |
| if (contentDiv) { |
| contentDiv.innerHTML = newContent.replace(/\n/g, '<br>'); |
| chatBox.scrollTop = chatBox.scrollHeight; |
| } |
| } |
| |
| function showLoading(message = '⏳ 智能体正在思考中') { |
| if (statusDiv) { |
| statusDiv.remove(); |
| } |
| statusDiv = appendMessage('', |
| `${message}<div class="loading-dots"><span></span><span></span><span></span></div>`, |
| null, true); |
| } |
| |
| function hideLoading() { |
| if (statusDiv) { |
| statusDiv.remove(); |
| statusDiv = null; |
| } |
| } |
| |
| async function sendMessage() { |
| const input = document.getElementById('user-input'); |
| const text = input.value.trim(); |
| if (!text) return; |
| |
| if (!isConnected) { |
| appendMessage('agent', '❌ 连接已断开,请先重新连接', 'System', false, true); |
| return; |
| } |
| |
| input.disabled = true; |
| sendBtn.disabled = true; |
| isProcessing = true; |
| lastTaskCompleted = false; |
| |
| activeMessageElements = {}; |
| lastActiveAgentKey = null; |
| statusDiv = null; |
| |
| appendMessage('user', text); |
| input.value = ''; |
| |
| let accumulatedContent = {}; |
| let hasReceivedResponse = false; |
| let lastAgentRole = null; |
| |
| try { |
| showLoading('⏳ 智能体正在思考中'); |
| console.log('[UI] Loading shown'); |
| |
| const response = await fetch('/chat', { |
| method: 'POST', |
| headers: {'Content-Type': 'application/json'}, |
| body: JSON.stringify({ |
| message: text, |
| session_id: sessionId |
| }) |
| }); |
| |
| const reader = response.body.getReader(); |
| currentReader = reader; |
| const decoder = new TextDecoder(); |
| |
| while (true) { |
| const { done, value } = await reader.read(); |
| if (done) break; |
| |
| const chunk = decoder.decode(value); |
| const lines = chunk.split('\n'); |
| |
| for (const line of lines) { |
| if (line.startsWith('data: ')) { |
| const data = line.slice(6).trim(); |
| |
| if (data === '[DONE]') { |
| console.log('[UI] Received [DONE]'); |
| lastTaskCompleted = true; |
| hideLoading(); |
| |
| Object.values(activeMessageElements).forEach(div => { |
| const cursor = div.querySelector('.typing-cursor'); |
| if (cursor) cursor.remove(); |
| }); |
| |
| if (hasReceivedResponse) { |
| appendMessage('', '✅ 回复完毕', null, true, false, true); |
| } |
| continue; |
| } |
| |
| try { |
| const obj = JSON.parse(data); |
| |
| if (obj.type === 'start') { |
| console.log('[UI] Start event:', obj.trace_id); |
| |
| } else if (obj.type === 'chunk') { |
| hasReceivedResponse = true; |
| |
| const labels = { |
| 'weather': '🌤️ Weather Agent', |
| 'travel': '✈️ Travel Agent', |
| 'assistant': '🤖 Assistant', |
| 'supervisor': '👮 Supervisor' |
| }; |
| |
| const agentKey = `${obj.role}_${obj.sub_trace_id || 'default'}`; |
| |
| if (lastAgentRole && lastAgentRole !== obj.role && !activeMessageElements[agentKey]) { |
| console.log(`[UI] Agent transition: ${lastAgentRole} -> ${obj.role}`); |
| showLoading(`⏳ ${labels[obj.role] || obj.role} 正在生成回复`); |
| } |
| |
| if (!activeMessageElements[agentKey]) { |
| hideLoading(); |
| |
| const label = labels[obj.role] || obj.role; |
| const currentAgentDiv = appendMessage('agent', '', label); |
| activeMessageElements[agentKey] = currentAgentDiv; |
| accumulatedContent[agentKey] = ''; |
| lastActiveAgentKey = agentKey; |
| |
| const cursor = document.createElement('span'); |
| cursor.className = 'typing-cursor'; |
| currentAgentDiv.appendChild(cursor); |
| |
| console.log(`[UI] Created message for ${label}`); |
| } else { |
| lastActiveAgentKey = agentKey; |
| } |
| |
| lastAgentRole = obj.role; |
| |
| accumulatedContent[agentKey] += obj.content; |
| |
| updateMessageContent( |
| activeMessageElements[agentKey], |
| accumulatedContent[agentKey] |
| ); |
| |
| } else if (obj.type === 'error') { |
| console.error('[UI] Error event:', obj); |
| hideLoading(); |
| |
| const errorDiv = appendMessage( |
| 'agent', |
| `❌ 错误: ${obj.content}`, |
| obj.role ? `Error from ${obj.role}` : 'Error', |
| false, |
| true |
| ); |
| } |
| |
| } catch (e) { |
| console.error('[UI] Parse error:', e, 'Data:', data); |
| } |
| } |
| } |
| } |
| |
| } catch (error) { |
| console.error('[UI] Request failed:', error); |
| hideLoading(); |
| |
| appendMessage('agent', '⚠️ 连接已断开', 'System', false, false); |
| } finally { |
| input.disabled = false; |
| sendBtn.disabled = false; |
| isProcessing = false; |
| input.focus(); |
| |
| activeMessageElements = {}; |
| lastActiveAgentKey = null; |
| accumulatedContent = {}; |
| statusDiv = null; |
| currentReader = null; |
| console.log('[UI] Cleanup done - state cleared'); |
| } |
| } |
| |
| // Disconnect stream function |
| async function disconnectStream() { |
| console.log('[UI] Disconnecting stream...'); |
| |
| try { |
| // 🔑 CRITICAL: Save state BEFORE canceling reader |
| preservedMessageElements = {...activeMessageElements}; |
| preservedLastAgentKey = lastActiveAgentKey; |
| console.log('[UI] State saved for reconnection - preserved elements:', Object.keys(preservedMessageElements)); |
| console.log('[UI] State saved - preserved lastActiveAgentKey:', preservedLastAgentKey); |
| |
| // Cancel current reader if exists |
| if (currentReader) { |
| await currentReader.cancel(); |
| currentReader = null; |
| } |
| |
| const response = await fetch('/disconnect', { |
| method: 'POST', |
| headers: {'Content-Type': 'application/json'}, |
| body: JSON.stringify({ |
| session_id: sessionId |
| }) |
| }); |
| |
| if (!response.ok) { |
| throw new Error(`HTTP error! status: ${response.status}`); |
| } |
| |
| isConnected = false; |
| updateConnectionStatus('disconnected', '已断开连接'); |
| |
| showToast('🔌 连接已断开', 'info'); |
| console.log('[UI] Stream disconnected successfully'); |
| |
| } catch (error) { |
| console.error('[UI] Disconnect error:', error); |
| showToast('❌ 断开连接失败: ' + error.message, 'error'); |
| } |
| } |
| |
| // Reconnect stream function |
| async function reconnectStream() { |
| if (isReconnecting) { |
| console.log('[UI] Already reconnecting...'); |
| return; |
| } |
| |
| console.log('[UI] Reconnecting stream with session_id:', sessionId); |
| console.log('[UI] >>> lastTaskCompleted =', lastTaskCompleted); |
| |
| // 🔑 If last task already completed with [DONE], no need to actually reconnect |
| // Just reset state locally so the user can start a new conversation. |
| if (lastTaskCompleted) { |
| console.log('[UI] Last task already completed, skipping backend reconnect'); |
| isConnected = true; |
| updateConnectionStatus('connected', '已连接'); |
| showToast('✅ 已就绪,可以开始新对话', 'success'); |
| preservedMessageElements = {}; |
| preservedLastAgentKey = null; |
| return; |
| } |
| |
| // 🔑 CRITICAL: Restore preserved state |
| activeMessageElements = {...preservedMessageElements}; |
| lastActiveAgentKey = preservedLastAgentKey; |
| |
| console.log('[UI] Restored state - Active elements:', Object.keys(activeMessageElements)); |
| console.log('[UI] Restored state - Last active agent key:', lastActiveAgentKey); |
| |
| isReconnecting = true; |
| isProcessing = true; |
| updateConnectionStatus('connecting', '正在重新连接...'); |
| |
| // Disable input and send button during reconnection |
| const input = document.getElementById('user-input'); |
| input.disabled = true; |
| sendBtn.disabled = true; |
| |
| try { |
| const response = await fetch('/reconnect', { |
| method: 'POST', |
| headers: {'Content-Type': 'application/json'}, |
| body: JSON.stringify({ |
| session_id: sessionId |
| }) |
| }); |
| |
| if (!response.ok) { |
| // Special case: 404 means no active session exists yet |
| // (e.g. user clicked disconnect before any chat). Treat as a |
| // graceful reset back to "ready" state so the user can start chatting. |
| if (response.status === 404) { |
| console.log('[UI] No active session on backend, resetting to ready state'); |
| isConnected = true; |
| isReconnecting = false; |
| isProcessing = false; |
| input.disabled = false; |
| sendBtn.disabled = false; |
| updateConnectionStatus('connected', '已连接'); |
| showToast('✅ 已就绪,可以开始新对话', 'success'); |
| // Clear any preserved state since there is nothing to resume |
| preservedMessageElements = {}; |
| preservedLastAgentKey = null; |
| input.focus(); |
| return; |
| } |
| throw new Error(`HTTP error! status: ${response.status}`); |
| } |
| |
| const reader = response.body.getReader(); |
| currentReader = reader; |
| const decoder = new TextDecoder(); |
| |
| let hasReceivedConfirmation = false; |
| let lastAgentRole = null; |
| |
| console.log('[UI] Reconnect - Current active elements:', Object.keys(activeMessageElements)); |
| |
| while (true) { |
| const { done, value } = await reader.read(); |
| if (done) break; |
| |
| const chunk = decoder.decode(value); |
| const lines = chunk.split('\n'); |
| |
| for (const line of lines) { |
| if (line.startsWith('data: ')) { |
| const data = line.slice(6).trim(); |
| |
| if (data === '[DONE]') { |
| console.log('[UI] Reconnect stream completed'); |
| lastTaskCompleted = true; |
| |
| Object.values(activeMessageElements).forEach(div => { |
| const cursor = div.querySelector('.typing-cursor'); |
| if (cursor) cursor.remove(); |
| }); |
| |
| appendMessage('', '✅ 回复完毕', null, true, false, true); |
| |
| // Clear preserved state after successful reconnection |
| preservedMessageElements = {}; |
| preservedLastAgentKey = null; |
| |
| // Re-enable input after reconnection completes |
| isConnected = true; |
| isReconnecting = false; |
| isProcessing = false; |
| input.disabled = false; |
| sendBtn.disabled = false; |
| updateConnectionStatus('connected', '已连接'); |
| input.focus(); |
| |
| break; |
| } |
| |
| try { |
| const obj = JSON.parse(data); |
| |
| if (obj.type === 'reconnected') { |
| hasReceivedConfirmation = true; |
| isConnected = true; |
| isReconnecting = false; |
| updateConnectionStatus('connected', '已连接'); |
| showToast('✅ 重新连接成功', 'success'); |
| console.log('[UI] Stream reconnected successfully'); |
| } |
| |
| if (obj.type === 'chunk') { |
| const labels = { |
| 'weather': '🌤️ Weather Agent', |
| 'travel': '✈️ Travel Agent', |
| 'assistant': '🤖 Assistant', |
| 'supervisor': '👮 Supervisor' |
| }; |
| |
| const agentKey = `${obj.role}_${obj.sub_trace_id || 'default'}`; |
| console.log(`[UI] Reconnect chunk - role: ${obj.role}, sub_trace_id: ${obj.sub_trace_id}, agentKey: ${agentKey}`); |
| |
| // 🔑 KEY FIX: Find existing message box by role first |
| let targetAgentKey = null; |
| |
| // Step 1: Check if exact agentKey already exists |
| if (activeMessageElements[agentKey]) { |
| targetAgentKey = agentKey; |
| console.log(`[UI] ✅ Found exact match: ${targetAgentKey}`); |
| } |
| // Step 2: If not found, try to find ANY existing message box with same role |
| else { |
| const existingKeys = Object.keys(activeMessageElements); |
| for (const key of existingKeys) { |
| const existingRole = key.split('_')[0]; |
| if (existingRole === obj.role) { |
| targetAgentKey = key; |
| console.log(`[UI] ✅ Found existing message box with same role: ${targetAgentKey} (role: ${obj.role})`); |
| break; |
| } |
| } |
| } |
| // Step 3: If still not found, use lastActiveAgentKey as fallback |
| if (!targetAgentKey && lastActiveAgentKey) { |
| const lastAgentRoleFromKey = lastActiveAgentKey.split('_')[0]; |
| if (lastAgentRoleFromKey === obj.role) { |
| targetAgentKey = lastActiveAgentKey; |
| console.log(`[UI] ✅ Using lastActiveAgentKey fallback: ${targetAgentKey}`); |
| } |
| } |
| |
| // Step 4: If no existing box found, create new one |
| if (!targetAgentKey) { |
| console.log(`[UI] No existing message box found, will create new one`); |
| targetAgentKey = agentKey; |
| } |
| |
| // Show transition loading if switching to different agent |
| if (lastAgentRole && lastAgentRole !== obj.role && !activeMessageElements[targetAgentKey]) { |
| console.log(`[UI] Agent transition: ${lastAgentRole} -> ${obj.role}`); |
| showLoading(`⏳ ${labels[obj.role] || obj.role} 正在生成回复`); |
| } |
| |
| // Create new message element only if doesn't exist |
| if (!activeMessageElements[targetAgentKey]) { |
| hideLoading(); |
| |
| const label = labels[obj.role] || obj.role; |
| const currentAgentDiv = appendMessage('agent', '', label); |
| activeMessageElements[targetAgentKey] = currentAgentDiv; |
| lastActiveAgentKey = targetAgentKey; |
| |
| // Add typing cursor when creating new message box |
| const cursor = document.createElement('span'); |
| cursor.className = 'typing-cursor'; |
| currentAgentDiv.appendChild(cursor); |
| |
| console.log(`[UI] Created NEW message element for ${label}, key: ${targetAgentKey}`); |
| } else { |
| lastActiveAgentKey = targetAgentKey; |
| console.log(`[UI] Using EXISTING message element for key: ${targetAgentKey}`); |
| } |
| |
| lastAgentRole = obj.role; |
| |
| // 🔑 KEY FIX: Directly append content without manipulating cursor DOM |
| const contentDiv = activeMessageElements[targetAgentKey]; |
| |
| // Temporarily hide cursor, append content, then show cursor again |
| const cursor = contentDiv.querySelector('.typing-cursor'); |
| if (cursor) { |
| cursor.style.display = 'none'; |
| } |
| |
| // Append new content directly |
| contentDiv.innerHTML += obj.content.replace(/\n/g, '<br>'); |
| |
| // Show cursor again |
| if (cursor) { |
| cursor.style.display = 'inline-block'; |
| } |
| |
| chatBox.scrollTop = chatBox.scrollHeight; |
| console.log(`[UI] Content appended to ${targetAgentKey}`); |
| } |
| |
| if (obj.type === 'error') { |
| console.error('[UI] Error during reconnection:', obj); |
| hideLoading(); |
| showToast('❌ 错误: ' + obj.content, 'error'); |
| |
| // Re-enable input on error |
| isConnected = true; |
| isReconnecting = false; |
| isProcessing = false; |
| input.disabled = false; |
| sendBtn.disabled = false; |
| } |
| |
| } catch (e) { |
| console.error('[UI] Parse error during reconnect:', e, 'Data:', data); |
| } |
| } |
| } |
| } |
| |
| // Fallback: if loop exits without [DONE], ensure input is re-enabled |
| if (isProcessing) { |
| isConnected = true; |
| isReconnecting = false; |
| isProcessing = false; |
| input.disabled = false; |
| sendBtn.disabled = false; |
| updateConnectionStatus('connected', '已连接'); |
| if (!hasReceivedConfirmation) { |
| showToast('✅ 重新连接成功', 'success'); |
| } |
| } |
| |
| } catch (error) { |
| console.error('[UI] Reconnect error:', error); |
| isReconnecting = false; |
| isProcessing = false; |
| // Restore connected state so the user can still start a new chat. |
| // The /chat endpoint creates a fresh session anyway. |
| isConnected = true; |
| updateConnectionStatus('connected', '已连接'); |
| showToast('⚠️ 重新连接失败,可直接开始新对话', 'error'); |
| |
| // Re-enable input on error |
| input.disabled = false; |
| sendBtn.disabled = false; |
| } finally { |
| currentReader = null; |
| console.log('[UI] Reconnect finally complete'); |
| } |
| } |
| |
| // Initialize connection status |
| updateConnectionStatus('connected', '已连接'); |
| </script> |
| </body> |
| </html> |