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; | |
| transition: opacity 0.3s ease; | |
| } | |
| .listing-draft img.hero.hidden { | |
| display: none; | |
| } | |
| .listing-draft .draft-image-container { | |
| position: relative; | |
| height: 200px; | |
| overflow: hidden; | |
| } | |
| .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: opacity 0.3s ease; | |
| } | |
| .search-card-hero.hidden { | |
| display: none; | |
| } | |
| .search-result-card:hover .search-card-hero { | |
| opacity: 0.95; | |
| } | |
| .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; | |
| transition: opacity 0.3s ease; | |
| } | |
| .my-listing-card-image img.hidden { | |
| display: none; | |
| } | |
| .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; | |
| } | |
| /* ============================================================ | |
| IMAGE CAROUSEL - DOT INDICATORS | |
| ============================================================ */ | |
| .carousel-dots { | |
| position: absolute; | |
| bottom: 10px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| display: flex; | |
| gap: 6px; | |
| z-index: 10; | |
| background: rgba(0, 0, 0, 0.3); | |
| padding: 6px 10px; | |
| border-radius: 20px; | |
| backdrop-filter: blur(4px); | |
| } | |
| .carousel-dot { | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| background: rgba(255, 255, 255, 0.5); | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| border: 1px solid rgba(255, 255, 255, 0.3); | |
| } | |
| .carousel-dot:hover { | |
| background: rgba(255, 255, 255, 0.8); | |
| transform: scale(1.2); | |
| } | |
| .carousel-dot.active { | |
| background: white; | |
| width: 24px; | |
| border-radius: 10px; | |
| border: 1px solid rgba(255, 255, 255, 0.8); | |
| } | |
| </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. [email protected]" | |
| value="[email protected]"> | |
| </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> | |
| <!-- Image Preview Area --> | |
| <div id="image-preview-container" | |
| style="display:none; padding: 10px 20px; background: #f0f9ff; border-top: 1px solid #e0f2fe;"> | |
| <div style="position: relative; display: inline-block;"> | |
| <img id="image-preview" src="" | |
| style="height: 80px; width: auto; border-radius: 8px; border: 2px solid #3b82f6; object-fit: cover;"> | |
| <button onclick="clearImage()" | |
| style="position: absolute; top: -10px; right: -10px; background: #ef4444; color: white; border: 2px solid white; border-radius: 50%; width: 24px; height: 24px; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: bold; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">✕</button> | |
| </div> | |
| <div | |
| style="display: inline-block; vertical-align: top; margin-left: 10px; margin-top: 25px; color: #3b82f6; font-size: 0.9rem; font-weight: 500;"> | |
| Image ready to send | |
| </div> | |
| </div> | |
| <div class="input-area"> | |
| <input type="file" id="file-input" accept="image/*" style="display: none;" | |
| onchange="handleFileSelect(this)"> | |
| <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="triggerFileSelect()" class="secondary" style="width: auto; background-color: #f59e0b;" | |
| title="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'; | |
| // CLOUDFLARE WORKER URL (Replace with your actual deployed Worker URL) | |
| const CF_WORKER_URL = "https://lojiz-worker.lojiz-uploadapi.workers.dev"; // UPDATE THIS! | |
| // Image Upload State | |
| let pendingUploadFile = null; | |
| // 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 Handling Functions | |
| function triggerFileSelect() { | |
| document.getElementById('file-input').click(); | |
| } | |
| function handleFileSelect(input) { | |
| if (input.files && input.files[0]) { | |
| const file = input.files[0]; | |
| pendingUploadFile = file; | |
| // Show preview | |
| const reader = new FileReader(); | |
| reader.onload = function (e) { | |
| document.getElementById('image-preview').src = e.target.result; | |
| document.getElementById('image-preview-container').style.display = 'block'; | |
| // Focus input so user can type message immediately | |
| document.getElementById('message-input').focus(); | |
| } | |
| reader.readAsDataURL(file); | |
| } | |
| } | |
| function clearImage() { | |
| pendingUploadFile = null; | |
| document.getElementById('file-input').value = ''; | |
| document.getElementById('image-preview-container').style.display = 'none'; | |
| } | |
| // 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, isHtml = false) { | |
| 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>`; | |
| if (isHtml) { | |
| html += `<div>${text}</div>`; | |
| } else { | |
| // 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 images = (draft.images && draft.images.length > 0) ? draft.images : ['https://via.placeholder.com/400x200?text=No+Image']; | |
| const cardId = 'draft-' + Date.now(); | |
| let amenitiesHtml = ''; | |
| if (draft.amenities && draft.amenities.length > 0) { | |
| amenitiesHtml = '<div class="amenities">' + draft.amenities.map(a => `<span class="pill">${a}</span>`).join('') + '</div>'; | |
| } | |
| // Build image carousel HTML | |
| const imagesHtml = images.map((img, idx) => | |
| `<img src="${img}" class="hero ${idx === 0 ? '' : 'hidden'}" alt="Listing Image ${idx + 1}" data-index="${idx}">` | |
| ).join(''); | |
| // Build dots HTML (only if more than 1 image) | |
| let dotsHtml = ''; | |
| if (images.length > 1) { | |
| const dots = images.map((_, idx) => | |
| `<span class="carousel-dot ${idx === 0 ? 'active' : ''}" onclick="switchImage('${cardId}', ${idx})" data-index="${idx}"></span>` | |
| ).join(''); | |
| dotsHtml = `<div class="carousel-dots">${dots}</div>`; | |
| } | |
| return ` | |
| <div class="listing-card-unit"> | |
| <div class="card-message">${formatMarkdown(message)}</div> | |
| <div class="listing-draft" id="${cardId}"> | |
| <div class="draft-image-container"> | |
| ${imagesHtml} | |
| ${dotsHtml} | |
| </div> | |
| <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, cardIdx) => { | |
| 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 all images or use placeholder | |
| const images = (listing.images && listing.images.length > 0) | |
| ? listing.images | |
| : ['https://images.unsplash.com/photo-1560518883-ce09059eeffa?w=400&h=200&fit=crop']; | |
| const cardId = 'search-card-' + Date.now() + '-' + cardIdx; | |
| // 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>`; | |
| } | |
| // Build image carousel HTML | |
| const imagesHtml = images.map((img, idx) => | |
| `<img src="${img}" class="search-card-hero ${idx === 0 ? '' : 'hidden'}" alt="${title}" data-index="${idx}" onerror="this.src='https://via.placeholder.com/400x180?text=Property'">` | |
| ).join(''); | |
| // Build dots HTML (only if more than 1 image) | |
| let dotsHtml = ''; | |
| if (images.length > 1) { | |
| const dots = images.map((_, idx) => | |
| `<span class="carousel-dot ${idx === 0 ? 'active' : ''}" onclick="switchImage('${cardId}', ${idx})" data-index="${idx}"></span>` | |
| ).join(''); | |
| dotsHtml = `<div class="carousel-dots">${dots}</div>`; | |
| } | |
| return ` | |
| <div class="search-result-card" id="${cardId}"> | |
| <div class="search-card-image-container"> | |
| ${imagesHtml} | |
| <span class="search-card-type">${listingType}</span> | |
| ${relevanceBadge} | |
| ${dotsHtml} | |
| </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, cardIdx) => { | |
| 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 && listing.images.length > 0) | |
| ? listing.images | |
| : ['https://via.placeholder.com/400x140?text=Property']; | |
| const cardId = 'my-listing-' + Date.now() + '-' + cardIdx; | |
| // Build image carousel HTML | |
| const imagesHtml = images.map((img, idx) => | |
| `<img src="${img}" alt="${title}" class="${idx === 0 ? '' : 'hidden'}" data-index="${idx}" onerror="this.src='https://via.placeholder.com/400x140?text=Property'">` | |
| ).join(''); | |
| // Build dots HTML (only if more than 1 image) | |
| let dotsHtml = ''; | |
| if (images.length > 1) { | |
| const dots = images.map((_, idx) => | |
| `<span class="carousel-dot ${idx === 0 ? 'active' : ''}" onclick="switchImage('${cardId}', ${idx})" data-index="${idx}"></span>` | |
| ).join(''); | |
| dotsHtml = `<div class="carousel-dots">${dots}</div>`; | |
| } | |
| return ` | |
| <div class="my-listing-card" id="${cardId}" data-listing-id="${id}"> | |
| <div class="my-listing-card-image"> | |
| ${imagesHtml} | |
| ${dotsHtml} | |
| </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; | |
| // Use the same session-id source as sendMessage for consistency | |
| const sessionId = document.getElementById('session-id').value || currentSessionId; | |
| // 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: sessionId, | |
| user_id: currentUserId, | |
| user_role: currentUserRole, | |
| user_name: userName, | |
| user_location: userLocation | |
| }) | |
| }); | |
| const data = await res.json(); | |
| // Sync session_id from response to keep edit context | |
| if (data.session_id) { | |
| currentSessionId = data.session_id; | |
| document.getElementById('session-id').value = data.session_id; | |
| console.log('Session ID synced from response:', data.session_id); | |
| } | |
| 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(); | |
| const hasImage = pendingUploadFile !== null; | |
| if (!text && !hasImage) return; | |
| // Use manual token if provided | |
| const manualToken = document.getElementById('manual-token').value.trim(); | |
| const effectiveToken = manualToken || authToken; | |
| input.value = ''; | |
| // Show user message immediately | |
| if (hasImage) { | |
| // Show text + "Uploading image..." status or similar | |
| let displayMsg = text; | |
| const previewSrc = document.getElementById('image-preview').src; | |
| displayMsg += `<br><img src="${previewSrc}" style="max-height:150px; border-radius:8px; margin-top:5px; opacity: 0.7;">`; | |
| displayMsg += `<div style="font-size:0.8em; color:#666;">Uploading image to Cloudflare...</div>`; | |
| addMessage('user', displayMsg, null, true); | |
| } else { | |
| addMessage('user', text); | |
| } | |
| // Get session info | |
| const sessionId = document.getElementById('session-id').value; | |
| const userId = document.getElementById('user-id').value || undefined; | |
| const userRole = document.getElementById('user-role').value; | |
| const userNameVal = userName || undefined; | |
| const userLocVal = userLocation || undefined; | |
| // Show typing indicator | |
| const typingEl = document.getElementById('typing-indicator'); | |
| // ... (rest of typing indicator logic handled in UI usually, but we'll adapt) | |
| // =================================== | |
| // PATH A: Image Upload Flow | |
| // =================================== | |
| if (hasImage) { | |
| try { | |
| // Prepare FormData for Cloudflare Worker | |
| const formData = new FormData(); | |
| formData.append('file', pendingUploadFile); | |
| formData.append('message', text); // Attach user command | |
| formData.append('user_id', userId || 'anon'); | |
| formData.append('session_id', sessionId); | |
| formData.append('operation', 'add'); // Default to add, can be improved later | |
| // Clear pending image | |
| const fileToUpload = pendingUploadFile; // Keep ref | |
| clearImage(); | |
| // Upload to Cloudflare Worker | |
| const cfRes = await fetch(CF_WORKER_URL, { | |
| method: 'POST', | |
| body: formData // No headers needed for FormData | |
| }); | |
| const cfData = await cfRes.json(); | |
| // Whether success or error, send result to AIDA to handle | |
| // The Worker returns {success: true/false, ...} which matches AIDA's expectation | |
| const aidaRes = await fetch(`${API_BASE}/ai/image-upload-result`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| ...(effectiveToken ? { 'Authorization': `Bearer ${effectiveToken}` } : {}) | |
| }, | |
| body: JSON.stringify(cfData) | |
| }); | |
| const aidaData = await aidaRes.json(); | |
| handleAidaResponse(aidaData); | |
| } catch (e) { | |
| console.error("Upload Error:", e); | |
| addMessage('assistant', `❌ Upload Error: ${e.message}. Is the Cloudflare Worker URL correct?`); | |
| } | |
| return; | |
| } | |
| // =================================== | |
| // PATH B: Standard Text Message Flow | |
| // =================================== | |
| const payload = { | |
| message: text, | |
| session_id: sessionId, | |
| user_id: userId, | |
| user_role: userRole, | |
| user_name: userNameVal, | |
| user_location: userLocVal | |
| }; | |
| const headers = { | |
| 'Content-Type': 'application/json' | |
| }; | |
| if (effectiveToken) { | |
| headers['Authorization'] = `Bearer ${effectiveToken}`; | |
| } | |
| updateDebug({ status: 'Sending...', payload }); | |
| 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'); | |
| if (data.draft_ui) { | |
| const existingCardUnit = document.querySelector('.listing-card-unit'); | |
| if (existingCardUnit) { | |
| const parentMessageDiv = existingCardUnit.closest('.message'); | |
| if (parentMessageDiv) { | |
| parentMessageDiv.remove(); | |
| } else { | |
| existingCardUnit.remove(); | |
| } | |
| } | |
| } | |
| } | |
| // Use the shared handler | |
| handleAidaResponse(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); | |
| } | |
| } | |
| // Shared Response Handler | |
| function handleAidaResponse(data) { | |
| // Remove any temp typing indicators | |
| const indicators = document.querySelectorAll('[id^="temp-typing-"]'); | |
| indicators.forEach(el => el.remove()); | |
| // Sync session_id from response to keep context (especially for edit mode) | |
| if (data.session_id) { | |
| currentSessionId = data.session_id; | |
| document.getElementById('session-id').value = data.session_id; | |
| } | |
| if (data.metadata && data.metadata.replace_last_message) { | |
| // Logic already handled in sendMessage mostly for removing, but let's ensure addMessage works | |
| // Just use addMessage | |
| const text = data.text || data.message || ''; | |
| addMessage('ai', text, data); | |
| } 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); | |
| } | |
| } | |
| } | |
| 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 }); | |
| } | |
| // Image Carousel Navigation | |
| function switchImage(cardId, targetIndex) { | |
| const card = document.getElementById(cardId); | |
| if (!card) return; | |
| // Find all images in this card | |
| const images = card.querySelectorAll('img[data-index]'); | |
| const dots = card.querySelectorAll('.carousel-dot'); | |
| // Hide all images and show the target one | |
| images.forEach((img, idx) => { | |
| if (idx === targetIndex) { | |
| img.classList.remove('hidden'); | |
| } else { | |
| img.classList.add('hidden'); | |
| } | |
| }); | |
| // Update dot states | |
| dots.forEach((dot, idx) => { | |
| if (idx === targetIndex) { | |
| dot.classList.add('active'); | |
| } else { | |
| dot.classList.remove('active'); | |
| } | |
| }); | |
| } | |
| </script> | |
| </body> | |
| </html> |