Spaces:
Sleeping
Sleeping
| {% extends "base.html" %} | |
| {% block title %}{{ contact.contact_name }} - PrepMate{% endblock %} | |
| {% block head %} | |
| <!-- Marked.js for Markdown rendering --> | |
| <script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script> | |
| <!-- Message Cache for Optimistic UI Updates (Feature: 012-realtime-message-display) --> | |
| <script src="{{ url_for('static', filename='js/message-cache.js') }}"></script> | |
| <style> | |
| /* Markdown styling for assistant messages */ | |
| .markdown-content { | |
| line-height: 1.6; | |
| } | |
| .markdown-content p { | |
| margin-bottom: 0.75rem; | |
| } | |
| .markdown-content p:last-child { | |
| margin-bottom: 0; | |
| } | |
| .markdown-content ul, .markdown-content ol { | |
| margin-bottom: 0.75rem; | |
| padding-left: 1.5rem; | |
| } | |
| .markdown-content code { | |
| background-color: rgba(0,0,0,0.05); | |
| padding: 0.2rem 0.4rem; | |
| border-radius: 0.25rem; | |
| font-size: 0.9em; | |
| } | |
| .markdown-content pre { | |
| background-color: rgba(0,0,0,0.05); | |
| padding: 0.75rem; | |
| border-radius: 0.25rem; | |
| overflow-x: auto; | |
| margin-bottom: 0.75rem; | |
| } | |
| .markdown-content pre code { | |
| background-color: transparent; | |
| padding: 0; | |
| } | |
| .markdown-content blockquote { | |
| border-left: 3px solid #dee2e6; | |
| padding-left: 1rem; | |
| margin-left: 0; | |
| color: #6c757d; | |
| } | |
| .markdown-content h1, .markdown-content h2, .markdown-content h3, | |
| .markdown-content h4, .markdown-content h5, .markdown-content h6 { | |
| margin-top: 1rem; | |
| margin-bottom: 0.5rem; | |
| font-weight: 600; | |
| } | |
| .markdown-content h1 { font-size: 1.5rem; } | |
| .markdown-content h2 { font-size: 1.3rem; } | |
| .markdown-content h3 { font-size: 1.1rem; } | |
| </style> | |
| {% endblock %} | |
| {% block content %} | |
| <div class="container mt-4"> | |
| <!-- Back Button and Header --> | |
| <div class="row mb-3"> | |
| <div class="col"> | |
| <a href="{{ url_for('contacts.list_contacts') }}" class="btn btn-sm btn-outline-secondary mb-2"> | |
| <i class="bi bi-arrow-left"></i> Back to Contacts | |
| </a> | |
| </div> | |
| </div> | |
| <!-- Contact Header --> | |
| <div class="row mb-4"> | |
| <div class="col-md-8"> | |
| <div class="d-flex align-items-start"> | |
| <i class="bi bi-person-circle text-primary me-3" style="font-size: 3rem;"></i> | |
| <div class="flex-grow-1"> | |
| <h1 class="h3 mb-1">{{ contact.contact_name }}</h1> | |
| {% if contact.contact_description %} | |
| <p class="text-muted mb-2">{{ contact.contact_description }}</p> | |
| {% endif %} | |
| <small class="text-muted"> | |
| <i class="bi bi-clock"></i> | |
| Last interaction: {{ contact.last_interaction.strftime('%Y-%m-%d %H:%M') }} | |
| </small> | |
| </div> | |
| <div class="dropdown"> | |
| <button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown"> | |
| <i class="bi bi-three-dots-vertical"></i> | |
| </button> | |
| <ul class="dropdown-menu dropdown-menu-end"> | |
| <li> | |
| <a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#editContactModal"> | |
| <i class="bi bi-pencil"></i> Edit Contact | |
| </a> | |
| </li> | |
| <li><hr class="dropdown-divider"></li> | |
| <li> | |
| <a class="dropdown-item text-danger" href="#" data-bs-toggle="modal" data-bs-target="#deleteContactModal"> | |
| <i class="bi bi-trash"></i> Delete Contact | |
| </a> | |
| </li> | |
| </ul> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- How it Works Info Box --> | |
| <div class="row mb-3"> | |
| <div class="col-12"> | |
| <div class="card"> | |
| <div class="card-header"> | |
| <h3 class="h6 mb-0"> | |
| <i class="bi bi-info-circle"></i> How it Works | |
| </h3> | |
| </div> | |
| <div class="card-body"> | |
| <p class="small mb-1"> | |
| <strong>Add Fact:</strong> Store information about {{ contact.contact_name }} (max 2,000 chars). Facts help build context for future conversations. | |
| </p> | |
| <p class="small mb-0"> | |
| <strong>Ask Question:</strong> Get AI responses using both your profile facts and {{ contact.contact_name }}'s facts as context. | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Contact Session Interface --> | |
| <div class="row"> | |
| <!-- Full Width Conversation Area --> | |
| <div class="col-12"> | |
| <div class="card" style="height: 600px; display: flex; flex-direction: column;"> | |
| <div class="card-header bg-primary text-white"> | |
| <h2 class="h6 mb-0"> | |
| <i class="bi bi-chat-dots"></i> Conversation with {{ contact.contact_name }} | |
| </h2> | |
| <small class="text-white-50">All messages and facts about this contact</small> | |
| </div> | |
| <!-- Message History (All Messages: Facts + Chat) --> | |
| <div class="card-body flex-grow-1" style="overflow-y: auto;" id="messageHistory"> | |
| {% set all_messages = facts + messages %} | |
| {% if all_messages %} | |
| {% for message in all_messages | sort(attribute='created_at') %} | |
| <div class="mb-3"> | |
| {% if message.get('mode') == 'memorize' %} | |
| <!-- Fact Message --> | |
| <div class="p-3 rounded bg-light border-start border-info border-3"> | |
| <div class="d-flex align-items-start"> | |
| <i class="bi bi-bookmark-fill text-info me-2"></i> | |
| <div class="flex-grow-1"> | |
| <p class="mb-1">{{ message.get('content', message.get('message', '')) }}</p> | |
| <small class="text-muted"> | |
| {{ message.get('created_at', '') }} | |
| </small> | |
| </div> | |
| </div> | |
| </div> | |
| {% else %} | |
| <!-- Chat Message - Different styling for user vs assistant --> | |
| {% if message.get('sender') == 'assistant' %} | |
| <!-- Assistant Message (AI response) - Right aligned, gray background --> | |
| <div class="d-flex justify-content-end"> | |
| <div class="bg-light border p-3 rounded" style="max-width: 75%;"> | |
| <div class="markdown-content text-dark" data-markdown-content>{{ message.get('content', message.get('message', '')) }}</div> | |
| <small class="text-muted"> | |
| <i class="bi bi-cpu"></i> AI · {{ message.get('created_at', '') }} | |
| </small> | |
| </div> | |
| </div> | |
| {% else %} | |
| <!-- User Message - Left aligned, blue background --> | |
| <div class="d-flex justify-content-start"> | |
| <div class="bg-primary text-white p-3 rounded" style="max-width: 75%;"> | |
| <p class="mb-1">{{ message.get('content', message.get('message', '')) }}</p> | |
| <small class="text-white-50"> | |
| {{ message.get('created_at', '') }} | |
| </small> | |
| </div> | |
| </div> | |
| {% endif %} | |
| {% endif %} | |
| </div> | |
| {% endfor %} | |
| {% else %} | |
| <!-- Feature: 001-contact-session-fixes - Empty state placeholder --> | |
| <div class="h-100 d-flex flex-column justify-content-center align-items-center p-4"> | |
| <div class="alert alert-info" role="status" style="max-width: 500px;"> | |
| <i class="bi bi-info-circle me-2"></i> | |
| <strong>No facts or messages yet.</strong> | |
| <p class="mb-0 mt-2 small">Add your first fact about {{ contact.contact_name }} using the "Add Fact" button below, or ask a question to start a conversation.</p> | |
| </div> | |
| </div> | |
| {% endif %} | |
| </div> | |
| <!-- Message/Fact Input --> | |
| <div class="card-footer bg-light"> | |
| <!-- Error Messages (T016: Feature 001-refine-memory-producer-logic) --> | |
| {% with messages = get_flashed_messages(with_categories=true) %} | |
| {% if messages %} | |
| {% for category, message in messages %} | |
| <div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show mb-2" role="alert"> | |
| {{ message }} | |
| <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> | |
| </div> | |
| {% endfor %} | |
| {% endif %} | |
| {% endwith %} | |
| <form method="POST" id="chatForm" onsubmit="return false;"> | |
| <div class="mb-2"> | |
| <textarea class="form-control" | |
| id="messageContent" | |
| name="content" | |
| rows="2" | |
| maxlength="10000" | |
| placeholder="Type a message or fact about {{ contact.contact_name }}..." | |
| required | |
| style="resize: none;">{{ preserved_content or '' }}</textarea> | |
| <small class="text-muted" id="charCounter">{{ (preserved_content|length) if preserved_content else 0 }} / 10000</small> | |
| </div> | |
| <div class="d-flex justify-content-between align-items-center"> | |
| <small class="text-muted"> | |
| <i class="bi bi-info-circle"></i> | |
| Questions use your profile as context | |
| </small> | |
| <div> | |
| <button type="button" class="btn btn-outline-secondary me-2" id="addFactBtn"> | |
| <i class="bi bi-bookmark-plus" id="factIcon"></i> | |
| <span id="factText">Add Fact</span> | |
| <span class="spinner-border spinner-border-sm d-none" id="factSpinner" role="status" aria-hidden="true"></span> | |
| </button> | |
| <button type="button" class="btn btn-primary" id="askBtn"> | |
| <i class="bi bi-send-fill" id="askIcon"></i> | |
| <span id="askText">Ask Question</span> | |
| <span class="spinner-border spinner-border-sm d-none" id="askSpinner" role="status" aria-hidden="true"></span> | |
| </button> | |
| </div> | |
| </div> | |
| </form> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Edit Contact Modal --> | |
| <div class="modal fade" id="editContactModal" tabindex="-1" aria-labelledby="editContactModalLabel" aria-hidden="true"> | |
| <div class="modal-dialog"> | |
| <div class="modal-content"> | |
| <form method="POST" action="{{ url_for('contacts.update_contact', session_id=contact.session_id) }}" id="editContactForm"> | |
| <input type="hidden" name="_method" value="PUT"> | |
| <div class="modal-header"> | |
| <h5 class="modal-title" id="editContactModalLabel"> | |
| <i class="bi bi-pencil"></i> Edit Contact | |
| </h5> | |
| <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> | |
| </div> | |
| <div class="modal-body"> | |
| <div class="mb-3"> | |
| <label for="editContactName" class="form-label"> | |
| Contact Name <span class="text-danger">*</span> | |
| </label> | |
| <input type="text" | |
| class="form-control" | |
| id="editContactName" | |
| name="contact_name" | |
| maxlength="255" | |
| value="{{ contact.contact_name }}" | |
| required | |
| autocomplete="off"> | |
| <div class="form-text"> | |
| <span id="editNameCounter">0 / 255</span> characters | |
| </div> | |
| </div> | |
| <div class="mb-3"> | |
| <label for="editContactDescription" class="form-label"> | |
| Description (Optional) | |
| </label> | |
| <textarea class="form-control" | |
| id="editContactDescription" | |
| name="contact_description" | |
| rows="3" | |
| maxlength="500">{{ contact.contact_description or '' }}</textarea> | |
| <div class="form-text"> | |
| <span id="editDescCounter">0 / 500</span> characters | |
| </div> | |
| </div> | |
| </div> | |
| <div class="modal-footer"> | |
| <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> | |
| <button type="submit" class="btn btn-primary"> | |
| <i class="bi bi-check-circle"></i> Save Changes | |
| </button> | |
| </div> | |
| </form> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Delete Contact Modal --> | |
| <div class="modal fade" id="deleteContactModal" tabindex="-1" aria-labelledby="deleteContactModalLabel" aria-hidden="true"> | |
| <div class="modal-dialog"> | |
| <div class="modal-content"> | |
| <form method="POST" action="{{ url_for('contacts.delete_contact', session_id=contact.session_id) }}"> | |
| <div class="modal-header"> | |
| <h5 class="modal-title" id="deleteContactModalLabel"> | |
| <i class="bi bi-exclamation-triangle text-danger"></i> Delete Contact | |
| </h5> | |
| <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> | |
| </div> | |
| <div class="modal-body"> | |
| <p>Are you sure you want to delete <strong>{{ contact.contact_name }}</strong>?</p> | |
| <div class="alert alert-warning" role="alert"> | |
| <i class="bi bi-exclamation-triangle"></i> | |
| This will remove the contact from your list but will <strong>not</strong> delete messages or facts from the backend. | |
| </div> | |
| </div> | |
| <div class="modal-footer"> | |
| <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> | |
| <button type="submit" class="btn btn-danger"> | |
| <i class="bi bi-trash"></i> Delete Contact | |
| </button> | |
| </div> | |
| </form> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // Character counters for edit form | |
| const editContactName = document.getElementById('editContactName'); | |
| const editContactDescription = document.getElementById('editContactDescription'); | |
| const editNameCounter = document.getElementById('editNameCounter'); | |
| const editDescCounter = document.getElementById('editDescCounter'); | |
| // Initialize counters on page load | |
| if (editContactName) { | |
| editNameCounter.textContent = editContactName.value.length + ' / 255'; | |
| editContactName.addEventListener('input', function() { | |
| editNameCounter.textContent = this.value.length + ' / 255'; | |
| if (this.value.length > 240) { | |
| editNameCounter.classList.add('text-warning'); | |
| } else { | |
| editNameCounter.classList.remove('text-warning'); | |
| } | |
| }); | |
| } | |
| if (editContactDescription) { | |
| editDescCounter.textContent = editContactDescription.value.length + ' / 500'; | |
| editContactDescription.addEventListener('input', function() { | |
| editDescCounter.textContent = this.value.length + ' / 500'; | |
| if (this.value.length > 450) { | |
| editDescCounter.classList.add('text-warning'); | |
| } else { | |
| editDescCounter.classList.remove('text-warning'); | |
| } | |
| }); | |
| } | |
| // Form validation for edit | |
| const editContactForm = document.getElementById('editContactForm'); | |
| if (editContactForm) { | |
| editContactForm.addEventListener('submit', function(e) { | |
| const name = editContactName.value.trim(); | |
| if (!name) { | |
| e.preventDefault(); | |
| alert('Contact name is required.'); | |
| editContactName.focus(); | |
| return false; | |
| } | |
| if (name.length > 255) { | |
| e.preventDefault(); | |
| alert('Contact name cannot exceed 255 characters.'); | |
| editContactName.focus(); | |
| return false; | |
| } | |
| const desc = editContactDescription.value.trim(); | |
| if (desc.length > 500) { | |
| e.preventDefault(); | |
| alert('Description cannot exceed 500 characters.'); | |
| editContactDescription.focus(); | |
| return false; | |
| } | |
| return true; | |
| }); | |
| } | |
| // Render Markdown in assistant messages | |
| document.addEventListener('DOMContentLoaded', function() { | |
| const markdownElements = document.querySelectorAll('[data-markdown-content]'); | |
| markdownElements.forEach(function(element) { | |
| const rawContent = element.textContent; | |
| const renderedMarkdown = marked.parse(rawContent); | |
| element.innerHTML = renderedMarkdown; | |
| }); | |
| }); | |
| // T029: Initialize message cache for contacts session (Feature: 012-realtime-message-display) | |
| const SESSION_ID = '{{ contact.session_id }}'; | |
| messageCache.init(SESSION_ID); | |
| // Declare variables at top level | |
| let messageHistory; | |
| let messageContent; | |
| let charCounter; | |
| let chatForm; | |
| let askBtn; | |
| let addFactBtn; | |
| // Initialize all elements after DOM is ready | |
| function initializeElements() { | |
| messageHistory = document.getElementById('messageHistory'); | |
| messageContent = document.getElementById('messageContent'); | |
| charCounter = document.getElementById('charCounter'); | |
| chatForm = document.getElementById('chatForm'); | |
| askBtn = document.getElementById('askBtn'); | |
| addFactBtn = document.getElementById('addFactBtn'); | |
| // Auto-scroll to bottom of message history | |
| if (messageHistory) { | |
| messageHistory.scrollTop = messageHistory.scrollHeight; | |
| } | |
| setupEventHandlers(); | |
| } | |
| function setupEventHandlers() { | |
| // Character counter with dynamic max length | |
| if (messageContent && charCounter) { | |
| messageContent.addEventListener('input', function() { | |
| const length = this.value.length; | |
| charCounter.textContent = length + ' / 10000'; | |
| if (length > 9000) { | |
| charCounter.classList.add('text-warning'); | |
| } else { | |
| charCounter.classList.remove('text-warning'); | |
| } | |
| }); | |
| } | |
| // Auto-resize message textarea | |
| if (messageContent) { | |
| messageContent.addEventListener('input', function() { | |
| this.style.height = 'auto'; | |
| this.style.height = Math.min(this.scrollHeight, 120) + 'px'; | |
| }); | |
| // Handle Enter key (submit question) vs Shift+Enter (new line) | |
| messageContent.addEventListener('keydown', function(e) { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| if (this.value.trim().length > 0) { | |
| askBtn.click(); | |
| } | |
| } | |
| }); | |
| } | |
| // T030-T032: Ask Question handler with optimistic updates (Feature: 012-realtime-message-display) | |
| if (askBtn) { | |
| askBtn.addEventListener('click', async function(e) { | |
| e.preventDefault(); | |
| const content = messageContent.value.trim(); | |
| if (!content) { | |
| alert('Please enter a message.'); | |
| return; | |
| } | |
| if (content.length > 10000) { | |
| alert('Message cannot exceed 10,000 characters.'); | |
| return; | |
| } | |
| if (askBtn.disabled) { | |
| return; | |
| } | |
| // Show loading state | |
| askBtn.disabled = true; | |
| addFactBtn.disabled = true; | |
| document.getElementById('askIcon').classList.add('d-none'); | |
| document.getElementById('askSpinner').classList.remove('d-none'); | |
| document.getElementById('askText').textContent = 'Sending...'; | |
| try { | |
| // T030: Optimistic update - question appears instantly | |
| await messageCache.addOptimisticMessage(content, 'user', 'question'); | |
| // Clear textarea | |
| messageContent.value = ''; | |
| charCounter.textContent = '0 / 10000'; | |
| messageContent.style.height = 'auto'; | |
| } catch (error) { | |
| console.error('Error sending question:', error); | |
| alert('Failed to send question. Please try again.'); | |
| } finally { | |
| // Re-enable buttons | |
| askBtn.disabled = false; | |
| addFactBtn.disabled = false; | |
| document.getElementById('askIcon').classList.remove('d-none'); | |
| document.getElementById('askSpinner').classList.add('d-none'); | |
| document.getElementById('askText').textContent = 'Ask Question'; | |
| } | |
| }); | |
| } | |
| // T034: Add Fact handler with optimistic updates (Feature: 012-realtime-message-display) | |
| if (addFactBtn) { | |
| addFactBtn.addEventListener('click', async function(e) { | |
| e.preventDefault(); | |
| const content = messageContent.value.trim(); | |
| if (!content) { | |
| alert('Please enter a fact.'); | |
| return; | |
| } | |
| if (content.length > 2000) { | |
| alert('Fact cannot exceed 2,000 characters.'); | |
| return; | |
| } | |
| if (addFactBtn.disabled) { | |
| return; | |
| } | |
| // Show loading state | |
| askBtn.disabled = true; | |
| addFactBtn.disabled = true; | |
| document.getElementById('factIcon').classList.add('d-none'); | |
| document.getElementById('factSpinner').classList.remove('d-none'); | |
| document.getElementById('factText').textContent = 'Adding...'; | |
| try { | |
| // T034: Optimistic update - fact appears instantly | |
| await messageCache.addOptimisticMessage(content, 'user', 'fact'); | |
| // Clear textarea | |
| messageContent.value = ''; | |
| charCounter.textContent = '0 / 10000'; | |
| messageContent.style.height = 'auto'; | |
| } catch (error) { | |
| console.error('Error adding fact:', error); | |
| alert('Failed to add fact. Please try again.'); | |
| } finally { | |
| // Re-enable buttons | |
| askBtn.disabled = false; | |
| addFactBtn.disabled = false; | |
| document.getElementById('factIcon').classList.remove('d-none'); | |
| document.getElementById('factSpinner').classList.add('d-none'); | |
| document.getElementById('factText').textContent = 'Add Fact'; | |
| } | |
| }); | |
| } | |
| } | |
| // T031-T032: Render messages with sync status indicators (Feature: 012-realtime-message-display) | |
| function renderMessages() { | |
| const messages = messageCache.getAllMessages(); | |
| const container = messageHistory; | |
| // Clear existing content | |
| const emptyState = container.querySelector('.alert-info'); | |
| container.innerHTML = ''; | |
| if (messages.length === 0) { | |
| if (emptyState) { | |
| container.appendChild(emptyState); | |
| } else { | |
| container.innerHTML = ` | |
| <div class="h-100 d-flex flex-column justify-content-center align-items-center p-4"> | |
| <div class="alert alert-info" role="status" style="max-width: 500px;"> | |
| <i class="bi bi-info-circle me-2"></i> | |
| <strong>No facts or messages yet.</strong> | |
| <p class="mb-0 mt-2 small">Add your first fact about {{ contact.contact_name }} using the "Add Fact" button below, or ask a question to start a conversation.</p> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| return; | |
| } | |
| // Render each message | |
| messages.forEach(message => { | |
| const messageDiv = document.createElement('div'); | |
| messageDiv.className = 'mb-3'; | |
| messageDiv.dataset.messageId = message.id || message.tempId; | |
| // Add pending/failed classes | |
| if (message.syncStatus === 'pending' || message.syncStatus === 'syncing') { | |
| messageDiv.classList.add('message-pending'); | |
| } else if (message.syncStatus === 'failed') { | |
| messageDiv.classList.add('message-failed'); | |
| } | |
| const timestamp = message.timestamp | |
| ? new Date(message.timestamp).toLocaleString() | |
| : new Date(message.clientTimestamp).toLocaleString(); | |
| // Render sync indicator | |
| let syncIndicatorHTML = ''; | |
| if (message.syncStatus === 'pending' || message.syncStatus === 'syncing') { | |
| syncIndicatorHTML = '<span class="sync-indicator pending ms-2" title="Syncing..." aria-label="Message syncing with server">⏳</span>'; | |
| } else if (message.syncStatus === 'failed') { | |
| syncIndicatorHTML = ` | |
| <button class="sync-indicator failed retry-btn ms-2" | |
| onclick="retryMessage('${message.tempId}')" | |
| title="Sync failed. Click to retry." | |
| aria-label="Sync failed. Click to retry sending message"> | |
| ⚠️ Retry | |
| </button> | |
| `; | |
| } else if (message.syncStatus === 'confirmed') { | |
| syncIndicatorHTML = '<span class="sync-indicator confirmed ms-2" title="Synced" aria-label="Message successfully synced">✓</span>'; | |
| } | |
| // Render based on message type | |
| if (message.type === 'fact') { | |
| // Fact message - info border style | |
| messageDiv.innerHTML = ` | |
| <div class="p-3 rounded bg-light border-start border-info border-3"> | |
| <div class="d-flex align-items-start"> | |
| <i class="bi bi-bookmark-fill text-info me-2"></i> | |
| <div class="flex-grow-1"> | |
| <p class="mb-1">${escapeHtml(message.content)}</p> | |
| <small class="text-muted"> | |
| ${timestamp}${syncIndicatorHTML} | |
| </small> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| } else if (message.role === 'assistant') { | |
| // Assistant message - right aligned, gray | |
| const renderedMarkdown = marked.parse(message.content); | |
| messageDiv.innerHTML = ` | |
| <div class="d-flex justify-content-end"> | |
| <div class="bg-light border p-3 rounded" style="max-width: 75%;"> | |
| <div class="markdown-content text-dark">${renderedMarkdown}</div> | |
| <small class="text-muted"> | |
| <i class="bi bi-cpu"></i> AI · ${timestamp}${syncIndicatorHTML} | |
| </small> | |
| </div> | |
| </div> | |
| `; | |
| } else { | |
| // User question - left aligned, blue | |
| messageDiv.innerHTML = ` | |
| <div class="d-flex justify-content-start"> | |
| <div class="bg-primary text-white p-3 rounded" style="max-width: 75%;"> | |
| <p class="mb-1">${escapeHtml(message.content)}</p> | |
| <small class="text-white-50"> | |
| ${timestamp}${syncIndicatorHTML} | |
| </small> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| container.appendChild(messageDiv); | |
| }); | |
| // Auto-scroll to bottom | |
| container.scrollTop = container.scrollHeight; | |
| } | |
| // Helper: Escape HTML to prevent XSS | |
| function escapeHtml(text) { | |
| const div = document.createElement('div'); | |
| div.textContent = text; | |
| return div.innerHTML; | |
| } | |
| // Retry failed message | |
| async function retryMessage(tempId) { | |
| try { | |
| await messageCache.retryFailedMessage(tempId); | |
| } catch (error) { | |
| console.error('Error retrying message:', error); | |
| alert('Failed to retry. Please try again.'); | |
| } | |
| } | |
| // T032: Listen to cache events and re-render (Feature: 012-realtime-message-display) | |
| document.addEventListener('cache:message-added', (event) => { | |
| console.log('Message added:', event.detail); | |
| renderMessages(); | |
| }); | |
| document.addEventListener('cache:message-confirmed', (event) => { | |
| console.log('Message confirmed:', event.detail); | |
| renderMessages(); | |
| }); | |
| document.addEventListener('cache:message-failed', (event) => { | |
| console.log('Message failed:', event.detail); | |
| renderMessages(); | |
| }); | |
| // Initialize: Load server-rendered messages into cache on first load | |
| function initializeContactMessages() { | |
| const cache = messageCache.getCache(); | |
| // Only load server messages if cache is empty (first visit) | |
| if (cache.confirmedMessages.length === 0) { | |
| {% set all_messages = facts + messages %} | |
| {% if all_messages %} | |
| const serverMessages = [ | |
| {% for message in all_messages | sort(attribute='created_at') %} | |
| { | |
| id: '{{ message.get("message_id", "") }}', | |
| content: {{ (message.get('content', message.get('message', '')))|tojson }}, | |
| role: {% if message.get('sender') == 'assistant' %}'assistant'{% else %}'user'{% endif %}, | |
| type: {% if message.get('mode') == 'memorize' %}'fact'{% else %}'question'{% endif %}, | |
| timestamp: '{{ message.get("created_at", "") }}', | |
| created_at: '{{ message.get("created_at", "") }}', | |
| syncStatus: 'confirmed' | |
| }{% if not loop.last %},{% endif %} | |
| {% endfor %} | |
| ]; | |
| // Add to cache | |
| cache.confirmedMessages = serverMessages; | |
| cache.lastFetchTimestamp = new Date().toISOString(); | |
| messageCache.saveCacheToBrowser(); | |
| {% endif %} | |
| } | |
| // Now render from cache | |
| renderMessages(); | |
| } | |
| // Wait for DOM ready and initialize everything | |
| function initializeAll() { | |
| initializeElements(); | |
| initializeContactMessages(); | |
| } | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', initializeAll); | |
| } else { | |
| initializeAll(); | |
| } | |
| </script> | |
| {% endblock %} | |