blob: 640ad33bd45a8127424da8b087ff802870571118 [file]
<!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>