// Amazon Multimodal Assistant - Main JavaScript // Connected to Python Backend via FastAPI class MultimodalAssistant { constructor() { this.searchHistory = []; // Local session history this.isSearching = false; this.currentUploadFile = null; // Configuration: Point to your local FastAPI server this.API_ENDPOINT = '/api/search'; this.initializeEventListeners(); this.initializeAnimations(); } initializeEventListeners() { // Search functionality const searchBtn = document.getElementById('search-button'); const searchText = document.getElementById('search-text'); if (searchBtn) searchBtn.addEventListener('click', () => this.handleSearch()); if (searchText) searchText.addEventListener('keypress', (e) => { if (e.key === 'Enter' && !e.shiftKey) { // Prevent default enter behavior in textarea usually needs shift e.preventDefault(); this.handleSearch(); } }); // Image upload functionality this.initializeImageUpload(); // Clear history const clearBtn = document.getElementById('clear-history'); if (clearBtn) clearBtn.addEventListener('click', () => this.clearHistory()); // Search mode radio buttons document.querySelectorAll('input[name="search-mode"]').forEach(radio => { radio.addEventListener('change', (e) => this.updateSearchMode(e.target.value)); }); } initializeImageUpload() { const uploadArea = document.getElementById('upload-area'); const imageInput = document.getElementById('image-input'); const removeButton = document.getElementById('remove-image'); if (!uploadArea || !imageInput) return; // Click to upload uploadArea.addEventListener('click', (e) => { if (e.target !== removeButton && !e.target.closest('#remove-image')) { imageInput.click(); } }); // Drag and drop visuals uploadArea.addEventListener('dragover', (e) => { e.preventDefault(); uploadArea.classList.add('dragover'); }); uploadArea.addEventListener('dragleave', () => { uploadArea.classList.remove('dragover'); }); uploadArea.addEventListener('drop', (e) => { e.preventDefault(); uploadArea.classList.remove('dragover'); if (e.dataTransfer.files.length > 0) { this.handleImageUpload(e.dataTransfer.files[0]); } }); // File input change imageInput.addEventListener('change', (e) => { if (e.target.files.length > 0) { this.handleImageUpload(e.target.files[0]); } }); // Remove image if (removeButton) { removeButton.addEventListener('click', (e) => { e.stopPropagation(); this.removeImage(); }); } } handleImageUpload(file) { if (!file.type.startsWith('image/')) { this.showNotification('Please select a valid image file.', 'error'); return; } // Save file object to send to API later this.currentUploadFile = file; const reader = new FileReader(); reader.onload = (e) => { this.displayImagePreview(e.target.result); // Auto switch to multimodal if image uploaded const multiRadio = document.querySelector('input[name="search-mode"][value="multimodal"]'); if(multiRadio) multiRadio.checked = true; this.showNotification('Image uploaded successfully', 'success'); }; reader.readAsDataURL(file); } displayImagePreview(src) { const uploadContent = document.getElementById('upload-content'); const imagePreview = document.getElementById('image-preview'); const previewImg = document.getElementById('preview-img'); if (uploadContent) uploadContent.classList.add('hidden'); if (imagePreview) imagePreview.classList.remove('hidden'); if (previewImg) previewImg.src = src; } removeImage() { this.currentUploadFile = null; document.getElementById('upload-content').classList.remove('hidden'); document.getElementById('image-preview').classList.add('hidden'); document.getElementById('image-input').value = ''; } async handleSearch() { if (this.isSearching) return; const textInput = document.getElementById('search-text'); const textQuery = textInput ? textInput.value.trim() : ""; // Get Search Mode const modeEl = document.querySelector('input[name="search-mode"]:checked'); const searchMode = modeEl ? modeEl.value : "multimodal"; const hasImage = !!this.currentUploadFile; // Validation if (!textQuery && !hasImage) { this.showNotification('Please enter text or upload an image.', 'warning'); return; } this.isSearching = true; this.showLoadingState(true); // 1. Prepare Form Data for Backend const formData = new FormData(); formData.append('query', textQuery); formData.append('mode', searchMode); // Pass history so LLM knows context const historyPayload = this.searchHistory.map(h => ({ role: h.role, content: h.content })); formData.append('history', JSON.stringify(historyPayload)); if (this.currentUploadFile) { formData.append('image', this.currentUploadFile); } try { // 2. Call API const response = await fetch(this.API_ENDPOINT, { method: 'POST', body: formData }); if (!response.ok) { throw new Error(`Server error: ${response.statusText}`); } const data = await response.json(); // 3. Process Response & Update History this.addToHistory({ role: 'user', content: textQuery || '[Image Query]', timestamp: new Date() }); this.addToHistory({ role: 'assistant', content: data.answer, timestamp: new Date() }); // 4. Update UI this.displayQuery({ text: textQuery, image: hasImage ? document.getElementById('preview-img').src : null, mode: data.retrieval_method }); this.displayResults(data.products); this.displayAnswer(data.answer, data.retrieval_method); if (data.products && data.products.length > 0) { this.highlightEvidence(data.products[0]); } else { document.getElementById('evidence-card').classList.add('hidden'); } // Optional: clear text input // if (textInput) textInput.value = ''; } catch (error) { console.error('Search failed:', error); this.showNotification('Search failed: ' + error.message, 'error'); } finally { this.isSearching = false; this.showLoadingState(false); } } displayQuery(query) { const queryCard = document.getElementById('query-card'); const queryContent = document.getElementById('query-content'); const queryImage = document.getElementById('query-image'); const retrievalMethod = document.getElementById('retrieval-method'); const methodLabels = { 'text_only': 'Text Search', 'image_only': 'Image Search', 'multimodal_fusion': 'Multimodal Fusion', 'multimodal': 'Multimodal' }; if (retrievalMethod) retrievalMethod.textContent = methodLabels[query.mode] || query.mode; if (queryContent) { queryContent.innerHTML = query.text ? `Text: "${query.text}"` : 'Image-only query'; } if (query.image && queryImage) { const img = queryImage.querySelector('img'); if(img) img.src = query.image; queryImage.classList.remove('hidden'); } else if (queryImage) { queryImage.classList.add('hidden'); } if (queryCard) { queryCard.classList.remove('hidden'); // Re-trigger animation if possible queryCard.classList.remove('fade-in'); void queryCard.offsetWidth; // trigger reflow queryCard.classList.add('fade-in'); } } displayAnswer(content, mode) { const answerCard = document.getElementById('answer-card'); const answerContent = document.getElementById('answer-content'); // Convert Markdown-like formatting to HTML let formattedContent = content .replace(/\n/g, '
') // Line breaks .replace(/\*\*(.*?)\*\*/g, '$1') // Bold text .replace(/\*(.*?)\*/g, '$1'); // Italic text // Convert Markdown image syntax to HTML img tags // Pattern: ![alt text](image_url) formattedContent = formattedContent.replace( /!\[(.*?)\]\((.*?)\)/g, (match, alt, url) => { // 如果是 images/ 开头的路径,转换为 /product_images/ let correctedUrl = url; if (url.startsWith('images/')) { correctedUrl = url.replace('images/', '/product_images/'); } return `
${alt}
`; } ); if (answerContent) answerContent.innerHTML = formattedContent; if (answerCard) answerCard.classList.remove('hidden'); } highlightEvidence(product) { const evidenceCard = document.getElementById('evidence-card'); const evidenceImage = document.getElementById('evidence-image'); const evidenceName = document.getElementById('evidence-name'); const evidenceCategory = document.getElementById('evidence-category'); const evidenceSimilarity = document.getElementById('evidence-similarity'); if (!evidenceCard) return; // Use backend URL or fallback const imgSrc = product.image || 'https://via.placeholder.com/150?text=No+Img'; if (evidenceImage) { evidenceImage.src = imgSrc; evidenceImage.onerror = () => { evidenceImage.src = 'https://via.placeholder.com/150?text=Error'; }; } if (evidenceName) evidenceName.textContent = product.name; if (evidenceCategory) evidenceCategory.textContent = product.category; if (evidenceSimilarity) evidenceSimilarity.textContent = `${Math.round(product.similarity * 100)}% match`; evidenceCard.classList.remove('hidden'); } displayResults(products) { const resultsContainer = document.getElementById('results-container'); const resultsCount = document.getElementById('results-count'); if (resultsCount) resultsCount.textContent = `${products.length} items`; if (!resultsContainer) return; resultsContainer.innerHTML = ''; if (!products || products.length === 0) { resultsContainer.innerHTML = '
No products found.
'; return; } // Restore the detailed card design you liked products.forEach((product, index) => { const card = document.createElement('div'); // Using your original classes card.className = 'product-card bg-white rounded-lg p-3 border border-gray-200 mb-3 hover:shadow-md transition-shadow'; const similarityPercentage = Math.round(product.similarity * 100); const imgSrc = product.image || 'https://via.placeholder.com/64?text=No+Img'; card.innerHTML = `
${product.name}

${index + 1}. ${product.name}

${product.category}

Score: ${(product.similarity).toFixed(3)}
${similarityPercentage}%
`; resultsContainer.appendChild(card); }); } addToHistory(item) { this.searchHistory.push(item); this.updateHistoryDisplay(); } updateHistoryDisplay() { const container = document.getElementById('history-container'); if (!container) return; if (this.searchHistory.length === 0) { container.innerHTML = '
Start by entering a search query above
'; return; } // Show last 6 items, reversed const displayItems = this.searchHistory.slice(-6).reverse(); // Restore your original history styling container.innerHTML = displayItems.map(item => `
${item.role === 'user' ? 'YOU' : 'AI'} ${item.timestamp.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
${item.content}
`).join(''); } clearHistory() { this.searchHistory = []; this.updateHistoryDisplay(); this.showNotification('History cleared', 'success'); } showLoadingState(isLoading) { const loadingState = document.getElementById('loading-state'); const resultsContainer = document.getElementById('results-container'); const btnText = document.getElementById('btn-text'); // Button Spinner logic if (loadingState) { if (isLoading) { loadingState.classList.remove('hidden'); if (btnText) btnText.textContent = 'Searching...'; } else { loadingState.classList.add('hidden'); if (btnText) btnText.textContent = 'Search Products'; } } // Optional: Skeleton loading in results area (restoring your logic) if (resultsContainer && isLoading) { resultsContainer.innerHTML = `
`; } } showNotification(message, type = 'info') { // Remove existing notifications const existing = document.querySelectorAll('.fixed.top-4.right-4'); existing.forEach(el => el.remove()); const notification = document.createElement('div'); const bgColor = type === 'error' ? 'bg-red-500' : (type === 'success' ? 'bg-green-500' : 'bg-blue-500'); notification.className = `fixed top-4 right-4 z-50 px-6 py-3 rounded-lg shadow-lg text-white font-medium fade-in ${bgColor}`; notification.textContent = message; document.body.appendChild(notification); setTimeout(() => { notification.style.opacity = '0'; notification.style.transform = 'translateY(-20px)'; notification.style.transition = 'all 0.5s ease'; setTimeout(() => notification.remove(), 500); }, 3000); } // UI Helpers updateSearchMode(mode) { // Just logic to highlight or log change if needed // The radio button state is handled natively by HTML } initializeAnimations() { // Restoring Anime.js hover effects if library is loaded if (typeof anime !== 'undefined') { document.querySelectorAll('.hover-lift').forEach(element => { element.addEventListener('mouseenter', () => { anime({ targets: element, translateY: -2, boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.1)', duration: 200, easing: 'easeOutQuad' }); }); element.addEventListener('mouseleave', () => { anime({ targets: element, translateY: 0, boxShadow: 'none', // or original shadow duration: 200, easing: 'easeOutQuad' }); }); }); } } } document.addEventListener('DOMContentLoaded', () => { new MultimodalAssistant(); });