Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>AIDA Agent Test Console</title> | |
| <style> | |
| :root { | |
| --primary: #2563eb; | |
| --bg: #f8fafc; | |
| --chat-bg: #ffffff; | |
| --user-msg: #eff6ff; | |
| --ai-msg: #f1f5f9; | |
| } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; | |
| margin: 0; | |
| padding: 20px; | |
| background-color: var(--bg); | |
| color: #1e293b; | |
| height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| box-sizing: border-box; | |
| } | |
| .container { | |
| max-width: 1200px; | |
| margin: 0 auto; | |
| display: grid; | |
| grid-template-columns: 300px 1fr; | |
| gap: 20px; | |
| height: 100%; | |
| } | |
| .sidebar { | |
| background: white; | |
| padding: 20px; | |
| border-radius: 12px; | |
| box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); | |
| display: flex; | |
| flex-direction: column; | |
| gap: 20px; | |
| overflow-y: auto; | |
| } | |
| .main-chat { | |
| background: white; | |
| border-radius: 12px; | |
| box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); | |
| display: flex; | |
| flex-direction: column; | |
| overflow: hidden; | |
| } | |
| h2, | |
| h3 { | |
| margin-top: 0; | |
| } | |
| /* Forms */ | |
| .form-group { | |
| margin-bottom: 12px; | |
| } | |
| label { | |
| display: block; | |
| font-size: 0.875rem; | |
| font-weight: 500; | |
| margin-bottom: 4px; | |
| } | |
| input, | |
| select, | |
| textarea { | |
| width: 100%; | |
| padding: 8px 12px; | |
| border: 1px solid #e2e8f0; | |
| border-radius: 6px; | |
| font-size: 0.875rem; | |
| box-sizing: border-box; | |
| } | |
| button { | |
| background-color: var(--primary); | |
| color: white; | |
| border: none; | |
| padding: 8px 16px; | |
| border-radius: 6px; | |
| font-weight: 500; | |
| cursor: pointer; | |
| width: 100%; | |
| } | |
| button:hover { | |
| opacity: 0.9; | |
| } | |
| button.secondary { | |
| background-color: #64748b; | |
| } | |
| /* Chat Area */ | |
| #chat-history { | |
| flex: 1; | |
| padding: 20px; | |
| overflow-y: auto; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 16px; | |
| } | |
| .message { | |
| max-width: 80%; | |
| padding: 12px 16px; | |
| border-radius: 12px; | |
| line-height: 1.5; | |
| position: relative; | |
| } | |
| .message.user { | |
| align-self: flex-end; | |
| background-color: var(--user-msg); | |
| color: #1e3a8a; | |
| border-bottom-right-radius: 4px; | |
| } | |
| .message.ai { | |
| align-self: flex-start; | |
| background-color: var(--ai-msg); | |
| color: #334155; | |
| border-bottom-left-radius: 4px; | |
| } | |
| .message .meta { | |
| font-size: 0.75rem; | |
| opacity: 0.7; | |
| margin-bottom: 4px; | |
| } | |
| .message pre { | |
| background: rgba(0, 0, 0, 0.05); | |
| padding: 8px; | |
| border-radius: 4px; | |
| overflow-x: auto; | |
| margin: 8px 0; | |
| } | |
| /* Input Area */ | |
| .input-area { | |
| padding: 20px; | |
| border-top: 1px solid #e2e8f0; | |
| display: flex; | |
| gap: 10px; | |
| } | |
| /* Status & Debug */ | |
| .status-badge { | |
| display: inline-block; | |
| padding: 4px 8px; | |
| border-radius: 4px; | |
| font-size: 0.75rem; | |
| font-weight: 500; | |
| } | |
| .status-badge.success { | |
| background: #dcfce7; | |
| color: #166534; | |
| } | |
| .status-badge.error { | |
| background: #fee2e2; | |
| color: #991b1b; | |
| } | |
| #json-output { | |
| font-family: monospace; | |
| font-size: 0.75rem; | |
| white-space: pre-wrap; | |
| background: #1e293b; | |
| color: #a5b4fc; | |
| padding: 10px; | |
| border-radius: 6px; | |
| max-height: 200px; | |
| overflow-y: auto; | |
| } | |
| .hidden { | |
| display: none; | |
| } | |
| /* Typing Indicator */ | |
| .typing-indicator { | |
| padding: 12px 16px; | |
| background-color: var(--ai-msg); | |
| color: #64748b; | |
| border-bottom-left-radius: 4px; | |
| border-radius: 12px; | |
| align-self: flex-start; | |
| font-size: 0.875rem; | |
| display: flex; | |
| align-items: center; | |
| gap: 4px; | |
| width: fit-content; | |
| } | |
| .typing-dot { | |
| width: 6px; | |
| height: 6px; | |
| background-color: #64748b; | |
| border-radius: 50%; | |
| animation: bounce 1.4s infinite ease-in-out both; | |
| } | |
| .typing-dot:nth-child(1) { | |
| animation-delay: -0.32s; | |
| } | |
| .typing-dot:nth-child(2) { | |
| animation-delay: -0.16s; | |
| } | |
| @keyframes bounce { | |
| 0%, | |
| 80%, | |
| 100% { | |
| transform: scale(0); | |
| } | |
| 40% { | |
| transform: scale(1); | |
| } | |
| } | |
| /* Listing Card Unit (Message + Card together) */ | |
| .listing-card-unit { | |
| margin-top: 10px; | |
| border-radius: 12px; | |
| overflow: hidden; | |
| background: #f8fafc; | |
| border: 1px solid #e2e8f0; | |
| } | |
| .listing-card-unit .card-message { | |
| padding: 12px 16px; | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| color: white; | |
| font-size: 14px; | |
| line-height: 1.5; | |
| } | |
| .listing-card-unit .card-message p { | |
| margin: 0; | |
| } | |
| .listing-card-unit .card-message strong { | |
| font-weight: 600; | |
| } | |
| /* Listing Draft Preview */ | |
| .listing-draft { | |
| margin-top: 0; | |
| border: none; | |
| border-radius: 0 0 8px 8px; | |
| overflow: hidden; | |
| background: white; | |
| box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); | |
| } | |
| .listing-draft img.hero { | |
| width: 100%; | |
| height: 200px; | |
| object-fit: cover; | |
| display: block; | |
| } | |
| .listing-draft .content { | |
| padding: 16px; | |
| } | |
| .listing-draft h3 { | |
| margin: 0 0 8px 0; | |
| font-size: 1.1rem; | |
| color: #1e293b; | |
| } | |
| .listing-draft p { | |
| margin: 0 0 12px 0; | |
| color: #64748b; | |
| font-size: 0.9rem; | |
| line-height: 1.5; | |
| } | |
| .listing-draft .details { | |
| display: flex; | |
| gap: 12px; | |
| margin-bottom: 12px; | |
| flex-wrap: wrap; | |
| } | |
| .listing-draft .pill { | |
| background: #f1f5f9; | |
| color: #475569; | |
| padding: 4px 8px; | |
| border-radius: 4px; | |
| font-size: 0.8rem; | |
| font-weight: 500; | |
| } | |
| .listing-draft .amenities { | |
| display: flex; | |
| gap: 8px; | |
| flex-wrap: wrap; | |
| margin-top: 12px; | |
| padding-top: 12px; | |
| border-top: 1px solid #f1f5f9; | |
| } | |
| /* Search Results Cards - With Hero Images */ | |
| .search-results-container { | |
| margin-top: 12px; | |
| width: 100%; | |
| } | |
| .search-results-scroll { | |
| display: flex; | |
| gap: 16px; | |
| overflow-x: auto; | |
| padding: 8px 4px 16px 4px; | |
| scroll-snap-type: x mandatory; | |
| scrollbar-width: thin; | |
| scrollbar-color: #cbd5e1 transparent; | |
| } | |
| .search-results-scroll::-webkit-scrollbar { | |
| height: 8px; | |
| } | |
| .search-results-scroll::-webkit-scrollbar-track { | |
| background: #f1f5f9; | |
| border-radius: 4px; | |
| } | |
| .search-results-scroll::-webkit-scrollbar-thumb { | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| border-radius: 4px; | |
| } | |
| .search-results-single { | |
| display: flex; | |
| justify-content: flex-start; | |
| } | |
| .search-result-card { | |
| min-width: 300px; | |
| max-width: 340px; | |
| border: 1px solid #e2e8f0; | |
| border-radius: 12px; | |
| overflow: hidden; | |
| background: white; | |
| box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); | |
| scroll-snap-align: start; | |
| flex-shrink: 0; | |
| transition: transform 0.3s ease, box-shadow 0.3s ease; | |
| } | |
| .search-result-card:hover { | |
| transform: translateY(-4px); | |
| box-shadow: 0 12px 24px -4px rgba(0, 0, 0, 0.15); | |
| } | |
| .search-card-image-container { | |
| position: relative; | |
| height: 160px; | |
| overflow: hidden; | |
| } | |
| .search-card-hero { | |
| width: 100%; | |
| height: 100%; | |
| object-fit: cover; | |
| transition: transform 0.3s ease; | |
| } | |
| .search-result-card:hover .search-card-hero { | |
| transform: scale(1.05); | |
| } | |
| .search-card-type { | |
| position: absolute; | |
| top: 12px; | |
| left: 12px; | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| color: white; | |
| padding: 4px 12px; | |
| border-radius: 20px; | |
| font-size: 0.75rem; | |
| font-weight: 600; | |
| text-transform: capitalize; | |
| } | |
| .search-relevance-badge { | |
| position: absolute; | |
| top: 12px; | |
| right: 12px; | |
| background: rgba(255, 255, 255, 0.95); | |
| color: #f59e0b; | |
| padding: 4px 10px; | |
| border-radius: 20px; | |
| font-size: 0.7rem; | |
| font-weight: 600; | |
| box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); | |
| } | |
| .search-card-content { | |
| padding: 16px; | |
| } | |
| .search-card-title { | |
| margin: 0 0 4px 0; | |
| font-size: 1.1rem; | |
| font-weight: 600; | |
| color: #1e293b; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| .search-card-location { | |
| margin: 0 0 8px 0; | |
| font-size: 0.85rem; | |
| color: #64748b; | |
| } | |
| .search-card-desc { | |
| margin: 0 0 12px 0; | |
| font-size: 0.875rem; | |
| color: #475569; | |
| line-height: 1.4; | |
| display: -webkit-box; | |
| -webkit-line-clamp: 2; | |
| line-clamp: 2; | |
| -webkit-box-orient: vertical; | |
| overflow: hidden; | |
| } | |
| .search-card-details { | |
| display: flex; | |
| gap: 16px; | |
| margin-bottom: 10px; | |
| } | |
| .search-detail { | |
| font-size: 0.875rem; | |
| color: #64748b; | |
| } | |
| .search-card-amenities { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 6px; | |
| margin-bottom: 12px; | |
| } | |
| .amenity-pill { | |
| background: #f1f5f9; | |
| color: #475569; | |
| padding: 3px 8px; | |
| border-radius: 4px; | |
| font-size: 0.72rem; | |
| } | |
| .search-card-footer { | |
| display: flex; | |
| align-items: baseline; | |
| padding-top: 12px; | |
| border-top: 1px solid #f1f5f9; | |
| } | |
| .search-card-price { | |
| font-size: 1.25rem; | |
| font-weight: 700; | |
| color: #059669; | |
| } | |
| .search-card-price-type { | |
| font-size: 0.85rem; | |
| color: #94a3b8; | |
| margin-left: 2px; | |
| } | |
| /* ============================================================ | |
| MY LISTINGS CARDS - With Edit/Delete Buttons | |
| ============================================================ */ | |
| .my-listings-container { | |
| margin-top: 12px; | |
| } | |
| .my-listings-scroll { | |
| display: flex; | |
| gap: 16px; | |
| overflow-x: auto; | |
| padding: 8px 0; | |
| scroll-snap-type: x mandatory; | |
| } | |
| .my-listing-card { | |
| min-width: 280px; | |
| max-width: 320px; | |
| background: white; | |
| border-radius: 12px; | |
| box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); | |
| overflow: hidden; | |
| scroll-snap-align: start; | |
| border: 1px solid #e2e8f0; | |
| } | |
| .my-listing-card-image { | |
| position: relative; | |
| height: 140px; | |
| overflow: hidden; | |
| } | |
| .my-listing-card-image img { | |
| width: 100%; | |
| height: 100%; | |
| object-fit: cover; | |
| } | |
| .my-listing-card-content { | |
| padding: 14px; | |
| } | |
| .my-listing-card-title { | |
| margin: 0 0 6px 0; | |
| font-size: 1rem; | |
| font-weight: 600; | |
| color: #1e293b; | |
| } | |
| .my-listing-card-location { | |
| margin: 0 0 10px 0; | |
| font-size: 0.85rem; | |
| color: #64748b; | |
| } | |
| .my-listing-card-price { | |
| font-size: 1.1rem; | |
| font-weight: 700; | |
| color: #059669; | |
| margin-bottom: 14px; | |
| } | |
| .my-listing-card-actions { | |
| display: flex; | |
| gap: 10px; | |
| padding-top: 12px; | |
| border-top: 1px solid #f1f5f9; | |
| } | |
| .my-listing-btn { | |
| flex: 1; | |
| padding: 8px 12px; | |
| border: none; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| font-size: 0.85rem; | |
| font-weight: 500; | |
| transition: all 0.2s ease; | |
| } | |
| .my-listing-btn-edit { | |
| background: #f0f9ff; | |
| color: #0369a1; | |
| border: 1px solid #bae6fd; | |
| } | |
| .my-listing-btn-edit:hover { | |
| background: #e0f2fe; | |
| } | |
| .my-listing-btn-delete { | |
| background: #fef2f2; | |
| color: #dc2626; | |
| border: 1px solid #fecaca; | |
| } | |
| .my-listing-btn-delete:hover { | |
| background: #fee2e2; | |
| } | |
| .my-listing-btn:disabled { | |
| opacity: 0.5; | |
| cursor: not-allowed; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <div class="sidebar"> | |
| <div> | |
| <h2>Authentication</h2> | |
| <!-- Login Form --> | |
| <div id="login-form"> | |
| <div class="form-group"> | |
| <label>Identifier (Email/Phone)</label> | |
| <input type="text" id="auth-identifier" placeholder="e.g. user@example.com" | |
| value="test@example.com"> | |
| </div> | |
| <div class="form-group"> | |
| <label>Password</label> | |
| <input type="password" id="auth-password" value="Password123!"> | |
| </div> | |
| <button onclick="login()">Login</button> | |
| </div> | |
| <!-- Authenticated State --> | |
| <div id="auth-success" class="hidden"> | |
| <div class="status-badge success" | |
| style="margin-bottom: 10px; width: 100%; box-sizing: border-box; text-align: center;">Logged In | |
| </div> | |
| <div class="form-group"> | |
| <label>User ID</label> | |
| <input type="text" id="user-id" readonly> | |
| </div> | |
| <div class="form-group"> | |
| <label>Access Token</label> | |
| <input type="text" id="access-token" readonly style="text-overflow: ellipsis;"> | |
| </div> | |
| <button class="secondary" onclick="logout()">Logout</button> | |
| </div> | |
| <!-- Fallback Token Input --> | |
| <div class="form-group" style="margin-top: 20px; border-top: 1px solid #eee; padding-top: 20px;"> | |
| <label>Manual Token Override</label> | |
| <textarea id="manual-token" rows="2" placeholder="Paste JWT here if login fails"></textarea> | |
| </div> | |
| </div> | |
| <div> | |
| <h3>Session Options</h3> | |
| <div class="form-group"> | |
| <label>Session ID</label> | |
| <input type="text" id="session-id" placeholder="Auto-generated"> | |
| </div> | |
| <div class="form-group"> | |
| <label>User Role</label> | |
| <select id="user-role"> | |
| <option value="renter">Renter</option> | |
| <option value="landlord">Landlord</option> | |
| <option value="admin">Admin</option> | |
| </select> | |
| </div> | |
| <button class="secondary" onclick="newConversation()">Start New Conversation</button> | |
| </div> | |
| <div style="flex: 1; display: flex; flex-direction: column;"> | |
| <h3>Debug Output</h3> | |
| <div id="json-output">Ready...</div> | |
| </div> | |
| </div> | |
| <div class="main-chat"> | |
| <div | |
| style="padding: 15px 20px; border-bottom: 1px solid #e2e8f0; display: flex; justify-content: space-between; align-items: center;"> | |
| <h2 style="margin:0; font-size: 1.25rem;">AIDA Chat</h2> | |
| <div id="connection-status" class="status-badge">Disconnected</div> | |
| </div> | |
| <div id="chat-history"> | |
| <!-- Messages will appear here --> | |
| <div class="message ai"> | |
| <div class="meta">System</div> | |
| Hello! I am AIDA. Please log in relative to the sidebar to start chatting. | |
| </div> | |
| </div> | |
| <div class="input-area"> | |
| <input type="text" id="message-input" placeholder="Type your message..." | |
| onkeypress="if(event.key === 'Enter') sendMessage()"> | |
| <button onclick="sendMessage()" style="width: auto;">Send</button> | |
| <button onclick="uploadImage()" class="secondary" style="width: auto; background-color: #f59e0b;">📷 | |
| Upload Image</button> | |
| </div> | |
| <!-- Typing Indicator (Hidden by default) --> | |
| <div id="typing-indicator" class="typing-indicator hidden" style="margin: 0 20px 20px 20px;"> | |
| <span>Thinking</span> | |
| <div class="typing-dot"></div> | |
| <div class="typing-dot"></div> | |
| <div class="typing-dot"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // State | |
| let authToken = ''; | |
| let currentSessionId = ''; | |
| // Use 127.0.0.1 to avoid localhost DNS/Av issues | |
| const API_BASE = 'http://127.0.0.1:8000'; | |
| // Init | |
| window.onload = function () { | |
| currentSessionId = generateUUID(); | |
| document.getElementById('session-id').value = currentSessionId; | |
| checkHealth(); | |
| }; | |
| // Global user personalization data | |
| let userName = ''; | |
| let userLocation = ''; | |
| // Utils | |
| function generateUUID() { | |
| return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { | |
| var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); | |
| return v.toString(16); | |
| }); | |
| } | |
| // Image URL Regex | |
| const IMG_URL_REGEX = /(https?:\/\/.*\.(?:png|jpg|jpeg|gif|webp|svg))/i; | |
| // Markdown to HTML formatter (Browser-compatible, no lookbehind) | |
| function formatMarkdown(text) { | |
| if (!text) return ''; | |
| // Escape HTML first to prevent XSS | |
| let formatted = text | |
| .replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>'); | |
| // Convert markdown to HTML | |
| // Bold: **text** (handle first to avoid conflict with single *) | |
| formatted = formatted.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>'); | |
| formatted = formatted.replace(/__([^_]+)__/g, '<strong>$1</strong>'); | |
| // Italic: *text* (simple version - works after bold is processed) | |
| formatted = formatted.replace(/\*([^*]+)\*/g, '<em>$1</em>'); | |
| formatted = formatted.replace(/_([^_]+)_/g, '<em>$1</em>'); | |
| // Headers: # Header | |
| formatted = formatted.replace(/^### (.+)$/gm, '<h4>$1</h4>'); | |
| formatted = formatted.replace(/^## (.+)$/gm, '<h3>$1</h3>'); | |
| formatted = formatted.replace(/^# (.+)$/gm, '<h2>$1</h2>'); | |
| // Line breaks | |
| formatted = formatted.replace(/\n/g, '<br>'); | |
| // Horizontal rule: --- | |
| formatted = formatted.replace(/---/g, '<hr style="border:none; border-top:1px solid #e2e8f0; margin:10px 0;">'); | |
| return formatted; | |
| } | |
| function updateDebug(data) { | |
| const el = document.getElementById('json-output'); | |
| el.textContent = JSON.stringify(data, null, 2); | |
| } | |
| function addMessage(role, text, debugInfo = null) { | |
| const history = document.getElementById('chat-history'); | |
| const div = document.createElement('div'); | |
| div.className = `message ${role}`; | |
| let html = `<div class="meta">${role === 'user' ? 'You' : 'AIDA'}</div>`; | |
| // Check for image URL to render it | |
| const imgMatch = text.match(IMG_URL_REGEX); | |
| if (imgMatch) { | |
| // If text contains an image URL, render the image + the text (if it's not JUST the url) | |
| const url = imgMatch[0]; | |
| const textWithoutUrl = text.replace(url, '').trim(); | |
| html += `<div style="margin-bottom:8px;"> | |
| <img src="${url}" style="max-width:100%; border-radius:8px; display:block;" alt="Uploaded Image" onerror="this.style.display='none'"> | |
| </div>`; | |
| if (textWithoutUrl) { | |
| html += `<div>${formatMarkdown(textWithoutUrl)}</div>`; | |
| } | |
| } else { | |
| // Always show the actual text from the response (personalized by LLM) | |
| html += `<div>${formatMarkdown(text)}</div>`; | |
| } | |
| // Handle draft_ui: Message + Card appear as ONE UNIT | |
| // When updating, the entire unit moves to bottom with new message | |
| if (debugInfo && debugInfo.draft_ui) { | |
| const isUpdate = debugInfo.replace_last_message === true || | |
| (debugInfo.metadata && debugInfo.metadata.replace_last_message === true); | |
| // Find and remove the ENTIRE message div that contains the card unit | |
| // This prevents empty leftover divs in chat history | |
| const existingCardUnit = document.querySelector('.listing-card-unit'); | |
| if (existingCardUnit) { | |
| // Find the parent message div (the one with class 'message') | |
| const parentMessageDiv = existingCardUnit.closest('.message'); | |
| if (parentMessageDiv) { | |
| parentMessageDiv.remove(); | |
| console.log('[UI] Entire old message div with card removed'); | |
| } else { | |
| existingCardUnit.remove(); | |
| console.log('[UI] Old card unit removed (no parent found)'); | |
| } | |
| } | |
| // For draft with card: Don't add text separately, it will be part of the card unit | |
| // Remove the text we just added - it will be inside the card unit instead | |
| html = `<div class="meta">${role === 'user' ? 'You' : 'AIDA'}</div>`; | |
| // Create combined Message + Card unit | |
| html += renderDraftWithMessage(debugInfo.draft_ui, text); | |
| console.log('[UI] Card unit created with message:', isUpdate ? 'UPDATE' : 'INITIAL'); | |
| } | |
| // Handle search results: Render as cards | |
| if (debugInfo && debugInfo.action === 'search_results' && debugInfo.search_results && debugInfo.search_results.length > 0) { | |
| html += renderSearchResults(debugInfo.search_results); | |
| console.log('[UI] Search results cards rendered:', debugInfo.search_results.length); | |
| } | |
| // Handle my listings: Render as cards with Edit/Delete buttons | |
| if (debugInfo && debugInfo.action === 'my_listings' && debugInfo.my_listings && debugInfo.my_listings.length > 0) { | |
| html += renderMyListings(debugInfo.my_listings); | |
| console.log('[UI] My listings cards rendered:', debugInfo.my_listings.length); | |
| } | |
| div.innerHTML = html; | |
| history.appendChild(div); | |
| history.scrollTop = history.scrollHeight; | |
| } | |
| // NEW: Render card WITH message as ONE unit | |
| function renderDraftWithMessage(draft, message) { | |
| const heroImage = (draft.images && draft.images.length > 0) ? draft.images[0] : 'https://via.placeholder.com/400x200?text=No+Image'; | |
| let amenitiesHtml = ''; | |
| if (draft.amenities && draft.amenities.length > 0) { | |
| amenitiesHtml = '<div class="amenities">' + draft.amenities.map(a => `<span class="pill">${a}</span>`).join('') + '</div>'; | |
| } | |
| return ` | |
| <div class="listing-card-unit"> | |
| <div class="card-message">${formatMarkdown(message)}</div> | |
| <div class="listing-draft" id="listing-draft-preview"> | |
| <img src="${heroImage}" class="hero" alt="Listing Image"> | |
| <div class="content"> | |
| <h3>${draft.title}</h3> | |
| <p>${draft.description}</p> | |
| <div class="details"> | |
| <span class="pill">📍 ${draft.details.location}</span> | |
| <span class="pill">💰 ${draft.details.price}</span> | |
| <span class="pill">🛏️ ${draft.details.bedrooms} Bedroom</span> | |
| <span class="pill">🚿 ${draft.details.bathrooms} Bathroom</span> | |
| <span class="pill">🏷️ ${draft.details.listing_type}</span> | |
| </div> | |
| ${amenitiesHtml} | |
| </div> | |
| </div> | |
| </div>`; | |
| } | |
| // Update an existing draft card in-place | |
| function updateDraftCard(cardElement, draft) { | |
| const heroImage = (draft.images && draft.images.length > 0) ? draft.images[0] : 'https://via.placeholder.com/400x200?text=No+Image'; | |
| // Update hero image | |
| const heroImg = cardElement.querySelector('img.hero'); | |
| if (heroImg) heroImg.src = heroImage; | |
| // Update title | |
| const titleEl = cardElement.querySelector('h3'); | |
| if (titleEl) titleEl.textContent = draft.title; | |
| // Update description | |
| const descEl = cardElement.querySelector('p'); | |
| if (descEl) descEl.textContent = draft.description; | |
| // Update details pills | |
| const detailsDiv = cardElement.querySelector('.details'); | |
| if (detailsDiv && draft.details) { | |
| detailsDiv.innerHTML = ` | |
| <span class="pill">📍 ${draft.details.location}</span> | |
| <span class="pill">💰 ${draft.details.price}</span> | |
| <span class="pill">🛏️ ${draft.details.bedrooms} Bedroom</span> | |
| <span class="pill">🚿 ${draft.details.bathrooms} Bathroom</span> | |
| <span class="pill">🏷️ ${draft.details.listing_type}</span> | |
| ${draft.status === 'published' ? '<span class="pill" style="background:#dcfce7; color:#166534; border:1px solid #86efac;">✅ Published</span>' : ''} | |
| `; | |
| } | |
| // Update amenities | |
| const amenitiesDiv = cardElement.querySelector('.amenities'); | |
| if (amenitiesDiv && draft.amenities) { | |
| amenitiesDiv.innerHTML = draft.amenities.map(a => `<span class="pill">${a}</span>`).join(''); | |
| } | |
| // Add a subtle highlight animation to show the update | |
| cardElement.style.transition = 'box-shadow 0.3s ease'; | |
| cardElement.style.boxShadow = '0 0 0 3px rgba(37, 99, 235, 0.5)'; | |
| setTimeout(() => { | |
| cardElement.style.boxShadow = '0 4px 6px -1px rgba(0, 0, 0, 0.1)'; | |
| }, 500); | |
| } | |
| function renderDraft(draft) { | |
| const heroImage = (draft.images && draft.images.length > 0) ? draft.images[0] : 'https://via.placeholder.com/400x200?text=No+Image'; | |
| let amenitiesHtml = ''; | |
| if (draft.amenities && draft.amenities.length > 0) { | |
| amenitiesHtml = '<div class="amenities">' + draft.amenities.map(a => `<span class="pill">${a}</span>`).join('') + '</div>'; | |
| } | |
| return ` | |
| <div class="listing-draft" id="listing-draft-preview"> | |
| <img src="${heroImage}" class="hero" alt="Listing Image"> | |
| <div class="content"> | |
| <h3>${draft.title}</h3> | |
| <p>${draft.description}</p> | |
| <div class="details"> | |
| <span class="pill">📍 ${draft.details.location}</span> | |
| <span class="pill">💰 ${draft.details.price}</span> | |
| <span class="pill">🛏️ ${draft.details.bedrooms} Bedroom</span> | |
| <span class="pill">🚿 ${draft.details.bathrooms} Bathroom</span> | |
| <span class="pill">🏷️ ${draft.details.listing_type}</span> | |
| </div> | |
| ${amenitiesHtml} | |
| </div> | |
| </div>`; | |
| } | |
| // Render search results as cards (with hero images like draft UI) | |
| function renderSearchResults(results) { | |
| if (!results || results.length === 0) return ''; | |
| const cards = results.map(listing => { | |
| const title = listing.title || 'Untitled Property'; | |
| const description = listing.description || 'A beautiful property waiting to be explored.'; | |
| const location = listing.location || 'Unknown'; | |
| const price = listing.price ? listing.price.toLocaleString() : 'N/A'; | |
| const currency = listing.currency || 'XOF'; | |
| const priceType = listing.price_type || 'monthly'; | |
| const bedrooms = listing.bedrooms || '?'; | |
| const bathrooms = listing.bathrooms || '?'; | |
| const listingType = listing.listing_type || 'rent'; | |
| const relevance = listing._relevance_score; | |
| const amenities = listing.amenities || []; | |
| // Get hero image (first image or placeholder) | |
| const images = listing.images || []; | |
| const heroImage = images.length > 0 | |
| ? images[0] | |
| : 'https://images.unsplash.com/photo-1560518883-ce09059eeffa?w=400&h=200&fit=crop'; | |
| // Truncate description to 2 lines | |
| const shortDesc = description.length > 100 | |
| ? description.substring(0, 100) + '...' | |
| : description; | |
| // Build amenities HTML (max 4) | |
| let amenitiesHtml = ''; | |
| if (amenities.length > 0) { | |
| const displayAmenities = amenities.slice(0, 4); | |
| amenitiesHtml = '<div class="search-card-amenities">' + | |
| displayAmenities.map(a => `<span class="amenity-pill">${a}</span>`).join('') + | |
| (amenities.length > 4 ? `<span class="amenity-pill">+${amenities.length - 4}</span>` : '') + | |
| '</div>'; | |
| } | |
| // Relevance badge for hybrid search results | |
| let relevanceBadge = ''; | |
| if (relevance) { | |
| const percent = Math.round(relevance * 100); | |
| relevanceBadge = `<span class="search-relevance-badge">⭐ ${percent}% match</span>`; | |
| } | |
| return ` | |
| <div class="search-result-card"> | |
| <div class="search-card-image-container"> | |
| <img src="${heroImage}" class="search-card-hero" alt="${title}" onerror="this.src='https://via.placeholder.com/400x180?text=Property'"> | |
| <span class="search-card-type">${listingType}</span> | |
| ${relevanceBadge} | |
| </div> | |
| <div class="search-card-content"> | |
| <h3 class="search-card-title">${title}</h3> | |
| <p class="search-card-location">📍 ${location}</p> | |
| <p class="search-card-desc">${shortDesc}</p> | |
| <div class="search-card-details"> | |
| <span class="search-detail">🛏️ ${bedrooms} bed</span> | |
| <span class="search-detail">🚿 ${bathrooms} bath</span> | |
| </div> | |
| ${amenitiesHtml} | |
| <div class="search-card-footer"> | |
| <span class="search-card-price">${currency} ${price}</span> | |
| <span class="search-card-price-type">/${priceType}</span> | |
| </div> | |
| </div> | |
| </div>`; | |
| }).join(''); | |
| // Use scroll container for multiple, simple flex for single | |
| const containerClass = results.length === 1 ? 'search-results-single' : 'search-results-scroll'; | |
| return ` | |
| <div class="search-results-container"> | |
| <div class="${containerClass}"> | |
| ${cards} | |
| </div> | |
| </div>`; | |
| } | |
| // Render My Listings Cards with Edit/Delete buttons | |
| function renderMyListings(listings) { | |
| if (!listings || listings.length === 0) { | |
| return ''; | |
| } | |
| const cards = listings.map(listing => { | |
| const id = listing._id || listing.id || ''; | |
| const title = listing.title || 'Untitled'; | |
| const location = listing.location || 'Unknown Location'; | |
| const price = (listing.price || 0).toLocaleString(); | |
| const currency = listing.currency || 'XOF'; | |
| const priceType = listing.price_type || 'monthly'; | |
| const images = listing.images || []; | |
| const heroImage = images.length > 0 ? images[0] : 'https://via.placeholder.com/400x140?text=Property'; | |
| return ` | |
| <div class="my-listing-card" data-listing-id="${id}"> | |
| <div class="my-listing-card-image"> | |
| <img src="${heroImage}" alt="${title}" onerror="this.src='https://via.placeholder.com/400x140?text=Property'"> | |
| </div> | |
| <div class="my-listing-card-content"> | |
| <h4 class="my-listing-card-title">${title}</h4> | |
| <p class="my-listing-card-location">📍 ${location}</p> | |
| <p class="my-listing-card-price">${currency} ${price}/${priceType}</p> | |
| <div class="my-listing-card-actions"> | |
| <button class="my-listing-btn my-listing-btn-edit" onclick="editListing('${id}')">✏️ Edit</button> | |
| <button class="my-listing-btn my-listing-btn-delete" onclick="deleteListing('${id}', this)">🗑️ Delete</button> | |
| </div> | |
| </div> | |
| </div>`; | |
| }).join(''); | |
| return ` | |
| <div class="my-listings-container"> | |
| <div class="my-listings-scroll"> | |
| ${cards} | |
| </div> | |
| </div>`; | |
| } | |
| // Delete listing function | |
| async function deleteListing(listingId, buttonElement) { | |
| if (!confirm('Are you sure you want to delete this listing?')) { | |
| return; | |
| } | |
| buttonElement.disabled = true; | |
| buttonElement.innerText = 'Deleting...'; | |
| try { | |
| const res = await fetch(`${API_BASE}/listings/${listingId}`, { | |
| method: 'DELETE', | |
| headers: { | |
| 'Authorization': `Bearer ${authToken}`, | |
| 'Content-Type': 'application/json' | |
| } | |
| }); | |
| if (res.ok) { | |
| // Remove the card from UI | |
| const card = buttonElement.closest('.my-listing-card'); | |
| if (card) { | |
| card.style.opacity = '0.5'; | |
| card.style.transform = 'scale(0.95)'; | |
| setTimeout(() => card.remove(), 300); | |
| } | |
| addMessage('assistant', '✅ Listing deleted successfully!'); | |
| } else { | |
| const error = await res.json(); | |
| addMessage('assistant', `❌ Failed to delete: ${error.detail || 'Unknown error'}`); | |
| buttonElement.disabled = false; | |
| buttonElement.innerText = '🗑️ Delete'; | |
| } | |
| } catch (error) { | |
| addMessage('assistant', `❌ Error: ${error.message}`); | |
| buttonElement.disabled = false; | |
| buttonElement.innerText = '🗑️ Delete'; | |
| } | |
| } | |
| // Edit listing function - triggers edit flow via chat | |
| async function editListing(listingId) { | |
| console.log('editListing called with ID:', listingId); | |
| console.log('authToken present:', !!authToken, 'length:', authToken?.length); | |
| if (!authToken) { | |
| addMessage('assistant', '🔒 Please log in to edit listings.'); | |
| return; | |
| } | |
| // Send the edit command to the chat API | |
| const editMessage = `edit listing ${listingId}`; | |
| console.log('Sending edit request with token:', authToken.substring(0, 20) + '...'); | |
| // Get user details from DOM inputs (set during login) | |
| const currentUserId = document.getElementById('user-id').value; | |
| const currentUserRole = document.getElementById('user-role').value; | |
| // Show user message | |
| addMessage('user', `✏️ Edit listing`); | |
| try { | |
| const res = await fetch(`${API_BASE}/ai/ask`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${authToken}` | |
| }, | |
| body: JSON.stringify({ | |
| message: editMessage, | |
| session_id: currentSessionId, | |
| user_id: currentUserId, | |
| user_role: currentUserRole, | |
| user_name: userName, | |
| user_location: userLocation | |
| }) | |
| }); | |
| const data = await res.json(); | |
| if (data.text) { | |
| addMessage('assistant', data.text, data); | |
| } else { | |
| addMessage('assistant', '❌ Failed to load listing for editing.'); | |
| } | |
| } catch (error) { | |
| addMessage('assistant', `❌ Error: ${error.message}`); | |
| } | |
| } | |
| // Auth Functions | |
| async function login() { | |
| // Visual feedback that the function started | |
| const loginBtn = document.querySelector('button[onclick="login()"]'); | |
| const originalText = loginBtn.innerText; | |
| loginBtn.innerText = "Logging in..."; | |
| loginBtn.disabled = true; | |
| const identifier = document.getElementById('auth-identifier').value; | |
| const password = document.getElementById('auth-password').value; | |
| updateDebug({ status: "Attempting login...", identifier: identifier }); | |
| try { | |
| const res = await fetch(`${API_BASE}/api/auth/login`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ identifier, password }) | |
| }); | |
| const data = await res.json(); | |
| updateDebug(data); | |
| if (res.ok && data.success && data.data && data.data.token) { | |
| const token = data.data.token; | |
| const user = data.data.user; | |
| const userId = user.id || user._id || 'unknown'; | |
| const userRole = user.role || 'renter'; | |
| // Extract personalization data | |
| const name = user.firstName || user.name || user.fullName || ''; | |
| const location = user.location || user.city || user.address?.city || ''; | |
| setAuth(token, userId, userRole, name, location); | |
| // alert("Login Successful!"); | |
| } else { | |
| alert('Login failed: ' + (data.message || 'Unknown error')); | |
| } | |
| } catch (e) { | |
| console.error("Login Error:", e); | |
| updateDebug({ error: e.message, hint: "Is the backend running on port 8000?" }); | |
| alert('Login error: ' + e.message + "\n\nIs the backend server running?"); | |
| } finally { | |
| loginBtn.innerText = originalText; | |
| loginBtn.disabled = false; | |
| } | |
| } | |
| function setAuth(token, userId, userRole, name = '', location = '') { | |
| authToken = token; | |
| userName = name; | |
| userLocation = location; | |
| document.getElementById('access-token').value = token; | |
| document.getElementById('user-id').value = userId; | |
| // Auto-fill the user role dropdown | |
| if (userRole) { | |
| document.getElementById('user-role').value = userRole; | |
| } | |
| // Log personalization data | |
| console.log('[Auth] User personalization:', { name: userName, location: userLocation }); | |
| document.getElementById('login-form').classList.add('hidden'); | |
| document.getElementById('auth-success').classList.remove('hidden'); | |
| } | |
| function logout() { | |
| authToken = ''; | |
| document.getElementById('login-form').classList.remove('hidden'); | |
| document.getElementById('auth-success').classList.add('hidden'); | |
| document.getElementById('access-token').value = ''; | |
| document.getElementById('user-id').value = ''; | |
| } | |
| // API Functions | |
| async function checkHealth() { | |
| try { | |
| const res = await fetch(`${API_BASE}/health`); | |
| const data = await res.json(); | |
| const statusEl = document.getElementById('connection-status'); | |
| if (res.ok) { | |
| statusEl.textContent = 'Connected'; | |
| statusEl.classList.add('success'); | |
| } else { | |
| statusEl.textContent = 'Error'; | |
| statusEl.classList.add('error'); | |
| } | |
| } catch (e) { | |
| document.getElementById('connection-status').textContent = 'Offline'; | |
| } | |
| } | |
| async function sendMessage() { | |
| const input = document.getElementById('message-input'); | |
| const text = input.value.trim(); | |
| if (!text) return; | |
| // Use manual token if provided | |
| const manualToken = document.getElementById('manual-token').value.trim(); | |
| const effectiveToken = manualToken || authToken; | |
| input.value = ''; | |
| addMessage('user', text); | |
| const payload = { | |
| message: text, | |
| session_id: document.getElementById('session-id').value, | |
| user_id: document.getElementById('user-id').value || undefined, | |
| user_role: document.getElementById('user-role').value, | |
| user_name: userName || undefined, | |
| user_location: userLocation || undefined | |
| }; | |
| const headers = { | |
| 'Content-Type': 'application/json' | |
| }; | |
| if (effectiveToken) { | |
| headers['Authorization'] = `Bearer ${effectiveToken}`; | |
| } | |
| updateDebug({ status: 'Sending...', payload }); | |
| // Show typing indicator | |
| const typingEl = document.getElementById('typing-indicator'); | |
| const chatHistory = document.getElementById('chat-history'); | |
| // Move typing indicator to inside chat history temporarily or just toggle it | |
| // Better to append a temporary element | |
| const tempTypingId = 'temp-typing-' + Date.now(); | |
| const typingHtml = ` | |
| <div id="${tempTypingId}" class="message ai" style="display:flex; align-items:center; gap:8px;"> | |
| <div class="meta">AIDA</div> | |
| <div style="display:flex; gap:4px; align-items:center;"> | |
| <span>Thinking</span> | |
| <div class="typing-dot"></div> | |
| <div class="typing-dot"></div> | |
| <div class="typing-dot"></div> | |
| </div> | |
| </div>`; | |
| // Append temporary typing indicator | |
| const tempDiv = document.createElement('div'); | |
| tempDiv.innerHTML = typingHtml; | |
| chatHistory.appendChild(tempDiv.firstElementChild); | |
| chatHistory.scrollTop = chatHistory.scrollHeight; | |
| try { | |
| const res = await fetch(`${API_BASE}/ai/ask`, { | |
| method: 'POST', | |
| headers: headers, | |
| body: JSON.stringify(payload) | |
| }); | |
| const data = await res.json(); | |
| updateDebug(data); | |
| // Remove typing indicator | |
| const typingNode = document.getElementById(tempTypingId); | |
| if (typingNode) typingNode.remove(); | |
| if (data.metadata && data.metadata.replace_last_message) { | |
| console.log('[UI] Replace last message signal received'); | |
| // For edit flow: Remove the old card-message unit entirely | |
| // Then use addMessage to create a new one at the bottom | |
| // This ensures both message AND card update correctly | |
| if (data.draft_ui) { | |
| // Find and remove the entire old card-message unit | |
| const existingCardUnit = document.querySelector('.listing-card-unit'); | |
| if (existingCardUnit) { | |
| const parentMessageDiv = existingCardUnit.closest('.message'); | |
| if (parentMessageDiv) { | |
| parentMessageDiv.remove(); | |
| console.log('[UI] Old card-message unit removed'); | |
| } else { | |
| existingCardUnit.remove(); | |
| } | |
| } | |
| } | |
| // Now use addMessage to create the proper response (with card if present) | |
| const text = data.text || data.message || ''; | |
| addMessage('ai', text, data); | |
| console.log('[UI] New message (with card if present) added at bottom'); | |
| } else { | |
| // Standard append | |
| if (data.text) { | |
| addMessage('ai', data.text, data); | |
| } else if (data.message) { | |
| addMessage('ai', data.message, data); | |
| } else { | |
| addMessage('ai', JSON.stringify(data), data); | |
| } | |
| } | |
| } catch (e) { | |
| // Remove typing indicator on error | |
| const typingNode = document.getElementById(tempTypingId); | |
| if (typingNode) typingNode.remove(); | |
| updateDebug({ error: e.message }); | |
| addMessage('ai', 'Error: ' + e.message); | |
| } | |
| } | |
| async function uploadImage() { | |
| // Trigger hidden file input | |
| let fileInput = document.getElementById('hidden-file-input'); | |
| if (!fileInput) { | |
| fileInput = document.createElement('input'); | |
| fileInput.type = 'file'; | |
| fileInput.id = 'hidden-file-input'; | |
| fileInput.accept = 'image/*'; | |
| fileInput.style.display = 'none'; | |
| document.body.appendChild(fileInput); | |
| fileInput.addEventListener('change', handleFileSelect); | |
| } | |
| fileInput.click(); | |
| } | |
| async function handleFileSelect(event) { | |
| const file = event.target.files[0]; | |
| if (!file) return; | |
| const history = document.getElementById('chat-history'); | |
| // Create local preview URL immediately | |
| const localPreviewUrl = URL.createObjectURL(file); | |
| // Create a unique ID for this upload message | |
| const uploadMsgId = 'upload-' + Date.now(); | |
| // Show user message with image preview + loading overlay | |
| const previewHtml = ` | |
| <div id="${uploadMsgId}" class="message user"> | |
| <div class="meta">You</div> | |
| <div style="position: relative; display: inline-block; max-width: 300px;"> | |
| <img id="${uploadMsgId}-img" src="${localPreviewUrl}" | |
| style="max-width: 100%; border-radius: 8px; display: block; opacity: 0.7;" | |
| alt="Uploading..."> | |
| <div id="${uploadMsgId}-overlay" style=" | |
| position: absolute; | |
| top: 0; left: 0; right: 0; bottom: 0; | |
| background: rgba(0,0,0,0.4); | |
| display: flex; align-items: center; justify-content: center; | |
| border-radius: 8px; | |
| color: white; font-weight: 500; | |
| "> | |
| <span>⏳ Uploading...</span> | |
| </div> | |
| </div> | |
| <div style="font-size: 0.85rem; color: #64748b; margin-top: 4px;"> | |
| 📷 Property image | |
| </div> | |
| </div>`; | |
| history.insertAdjacentHTML('beforeend', previewHtml); | |
| history.scrollTop = history.scrollHeight; | |
| const formData = new FormData(); | |
| formData.append('file', file); | |
| try { | |
| const response = await fetch('https://lojiz-worker.lojiz-uploadapi.workers.dev/', { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`Upload failed: ${response.status}`); | |
| } | |
| const result = await response.json(); | |
| console.log("Upload result:", result); | |
| let imageUrl = result.url || result.secure_url || result.data?.url || result.imageUrl; | |
| if (!imageUrl && typeof result === 'string' && result.startsWith('http')) { | |
| imageUrl = result; | |
| } | |
| if (imageUrl) { | |
| // Update the preview: remove overlay, set final image, full opacity | |
| const imgEl = document.getElementById(`${uploadMsgId}-img`); | |
| const overlayEl = document.getElementById(`${uploadMsgId}-overlay`); | |
| if (imgEl) { | |
| imgEl.src = imageUrl; | |
| imgEl.style.opacity = '1'; | |
| } | |
| if (overlayEl) { | |
| overlayEl.innerHTML = '✅'; | |
| setTimeout(() => overlayEl.remove(), 1000); | |
| } | |
| // Revoke the local URL to free memory | |
| URL.revokeObjectURL(localPreviewUrl); | |
| // Send the image URL to AIDA (hidden from user display since image is already shown) | |
| const message = `Here is the image of the property: ${imageUrl}`; | |
| // Don't show another user message, just send to backend | |
| await sendImageToAi(message); | |
| } else { | |
| throw new Error("Could not extract image URL from response"); | |
| } | |
| } catch (e) { | |
| console.error("Upload failed:", e); | |
| // Update overlay to show error | |
| const overlayEl = document.getElementById(`${uploadMsgId}-overlay`); | |
| if (overlayEl) { | |
| overlayEl.innerHTML = '❌ Failed'; | |
| overlayEl.style.background = 'rgba(220, 38, 38, 0.7)'; | |
| } | |
| URL.revokeObjectURL(localPreviewUrl); | |
| } | |
| // Clear file input | |
| event.target.value = ''; | |
| } | |
| // Send image URL to AI without showing duplicate user message | |
| async function sendImageToAi(message) { | |
| const manualToken = document.getElementById('manual-token').value.trim(); | |
| const effectiveToken = manualToken || authToken; | |
| const payload = { | |
| message: message, | |
| session_id: document.getElementById('session-id').value, | |
| user_id: document.getElementById('user-id').value || undefined, | |
| user_role: document.getElementById('user-role').value | |
| }; | |
| const headers = { 'Content-Type': 'application/json' }; | |
| if (effectiveToken) { | |
| headers['Authorization'] = `Bearer ${effectiveToken}`; | |
| } | |
| // Show typing indicator | |
| const history = document.getElementById('chat-history'); | |
| const tempTypingId = 'temp-typing-' + Date.now(); | |
| const typingHtml = ` | |
| <div id="${tempTypingId}" class="message ai" style="display:flex; align-items:center; gap:8px;"> | |
| <div class="meta">AIDA</div> | |
| <div style="display:flex; gap:4px; align-items:center;"> | |
| <span>Processing image</span> | |
| <div class="typing-dot"></div> | |
| <div class="typing-dot"></div> | |
| <div class="typing-dot"></div> | |
| </div> | |
| </div>`; | |
| history.insertAdjacentHTML('beforeend', typingHtml); | |
| history.scrollTop = history.scrollHeight; | |
| try { | |
| const res = await fetch(`${API_BASE}/ai/ask`, { | |
| method: 'POST', | |
| headers: headers, | |
| body: JSON.stringify(payload) | |
| }); | |
| const data = await res.json(); | |
| // Remove typing indicator | |
| const typingNode = document.getElementById(tempTypingId); | |
| if (typingNode) typingNode.remove(); | |
| if (data.text) { | |
| addMessage('ai', data.text, data); | |
| } else if (data.message) { | |
| addMessage('ai', data.message, data); | |
| } | |
| } catch (e) { | |
| const typingNode = document.getElementById(tempTypingId); | |
| if (typingNode) typingNode.remove(); | |
| addMessage('ai', 'Error processing image: ' + e.message); | |
| } | |
| } | |
| function newConversation() { | |
| currentSessionId = generateUUID(); | |
| document.getElementById('session-id').value = currentSessionId; | |
| // Clear chat history | |
| const history = document.getElementById('chat-history'); | |
| history.innerHTML = ` | |
| <div class="message ai"> | |
| <div class="meta">System</div> | |
| New conversation started! Session ID: ${currentSessionId} | |
| </div>`; | |
| updateDebug({ status: 'New conversation started', session_id: currentSessionId }); | |
| } | |
| </script> | |
| </body> | |
| </html> |