|
|
|
|
|
|
|
|
|
|
|
class MultimodalAssistant { |
|
|
constructor() { |
|
|
this.searchHistory = []; |
|
|
this.isSearching = false; |
|
|
this.currentUploadFile = null; |
|
|
|
|
|
|
|
|
this.API_ENDPOINT = '/api/search'; |
|
|
|
|
|
this.initializeEventListeners(); |
|
|
this.initializeAnimations(); |
|
|
} |
|
|
|
|
|
initializeEventListeners() { |
|
|
|
|
|
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) { |
|
|
e.preventDefault(); |
|
|
this.handleSearch(); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
this.initializeImageUpload(); |
|
|
|
|
|
|
|
|
const clearBtn = document.getElementById('clear-history'); |
|
|
if (clearBtn) clearBtn.addEventListener('click', () => this.clearHistory()); |
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
uploadArea.addEventListener('click', (e) => { |
|
|
if (e.target !== removeButton && !e.target.closest('#remove-image')) { |
|
|
imageInput.click(); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
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]); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
imageInput.addEventListener('change', (e) => { |
|
|
if (e.target.files.length > 0) { |
|
|
this.handleImageUpload(e.target.files[0]); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
this.currentUploadFile = file; |
|
|
|
|
|
const reader = new FileReader(); |
|
|
reader.onload = (e) => { |
|
|
this.displayImagePreview(e.target.result); |
|
|
|
|
|
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() : ""; |
|
|
|
|
|
|
|
|
const modeEl = document.querySelector('input[name="search-mode"]:checked'); |
|
|
const searchMode = modeEl ? modeEl.value : "multimodal"; |
|
|
|
|
|
const hasImage = !!this.currentUploadFile; |
|
|
|
|
|
|
|
|
if (!textQuery && !hasImage) { |
|
|
this.showNotification('Please enter text or upload an image.', 'warning'); |
|
|
return; |
|
|
} |
|
|
|
|
|
this.isSearching = true; |
|
|
this.showLoadingState(true); |
|
|
|
|
|
|
|
|
const formData = new FormData(); |
|
|
formData.append('query', textQuery); |
|
|
formData.append('mode', searchMode); |
|
|
|
|
|
|
|
|
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 { |
|
|
|
|
|
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(); |
|
|
|
|
|
|
|
|
this.addToHistory({ |
|
|
role: 'user', |
|
|
content: textQuery || '[Image Query]', |
|
|
timestamp: new Date() |
|
|
}); |
|
|
|
|
|
this.addToHistory({ |
|
|
role: 'assistant', |
|
|
content: data.answer, |
|
|
timestamp: new Date() |
|
|
}); |
|
|
|
|
|
|
|
|
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'); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
} 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 ? `<strong>Text:</strong> "${query.text}"` : '<em>Image-only query</em>'; |
|
|
} |
|
|
|
|
|
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'); |
|
|
|
|
|
queryCard.classList.remove('fade-in'); |
|
|
void queryCard.offsetWidth; |
|
|
queryCard.classList.add('fade-in'); |
|
|
} |
|
|
} |
|
|
|
|
|
displayAnswer(content, mode) { |
|
|
const answerCard = document.getElementById('answer-card'); |
|
|
const answerContent = document.getElementById('answer-content'); |
|
|
|
|
|
|
|
|
let formattedContent = content |
|
|
.replace(/\n/g, '<br>') |
|
|
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') |
|
|
.replace(/\*(.*?)\*/g, '<em>$1</em>'); |
|
|
|
|
|
|
|
|
|
|
|
formattedContent = formattedContent.replace( |
|
|
/!\[(.*?)\]\((.*?)\)/g, |
|
|
(match, alt, url) => { |
|
|
|
|
|
let correctedUrl = url; |
|
|
if (url.startsWith('images/')) { |
|
|
correctedUrl = url.replace('images/', '/product_images/'); |
|
|
} |
|
|
return `<br><img src="${correctedUrl}" alt="${alt}" class="max-w-md rounded-lg shadow-md my-4" onerror="this.src='https://via.placeholder.com/300?text=Image+Not+Found'" /><br>`; |
|
|
} |
|
|
); |
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
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 = '<div class="text-sm text-gray-500 text-center py-8">No products found.</div>'; |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
products.forEach((product, index) => { |
|
|
const card = document.createElement('div'); |
|
|
|
|
|
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 = ` |
|
|
<div class="flex items-start space-x-3"> |
|
|
<div class="flex-shrink-0 w-12 h-12 bg-white rounded-lg overflow-hidden border border-gray-100 flex items-center justify-center"> |
|
|
<img src="${imgSrc}" alt="${product.name}" |
|
|
class="w-full h-full object-contain" |
|
|
onerror="this.src='https://via.placeholder.com/64?text=Err'"> |
|
|
</div> |
|
|
<div class="flex-1 min-w-0"> |
|
|
<h4 class="text-sm font-semibold text-gray-800 truncate" title="${product.name}">${index + 1}. ${product.name}</h4> |
|
|
<p class="text-xs text-gray-600 mb-1">${product.category}</p> |
|
|
<div class="flex items-center justify-between"> |
|
|
<span class="text-xs text-gray-500">Score: ${(product.similarity).toFixed(3)}</span> |
|
|
<div class="flex items-center space-x-2"> |
|
|
<div class="w-16 h-1.5 bg-gray-100 rounded-full overflow-hidden"> |
|
|
<div class="similarity-bar h-full bg-orange-500" style="width: ${similarityPercentage}%"></div> |
|
|
</div> |
|
|
<span class="text-xs font-bold text-gray-700">${similarityPercentage}%</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
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 = '<div class="text-sm text-gray-500 text-center py-8">Start by entering a search query above</div>'; |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
const displayItems = this.searchHistory.slice(-6).reverse(); |
|
|
|
|
|
|
|
|
container.innerHTML = displayItems.map(item => ` |
|
|
<div class="bg-gray-50 rounded-lg p-3 border border-gray-200 mb-2"> |
|
|
<div class="flex items-center justify-between mb-1"> |
|
|
<span class="text-xs font-bold ${item.role === 'user' ? 'text-blue-600' : 'text-orange-600'}"> |
|
|
${item.role === 'user' ? 'YOU' : 'AI'} |
|
|
</span> |
|
|
<span class="text-xs text-gray-400"> |
|
|
${item.timestamp.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} |
|
|
</span> |
|
|
</div> |
|
|
<div class="text-sm text-gray-700 line-clamp-2"> |
|
|
${item.content} |
|
|
</div> |
|
|
</div> |
|
|
`).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'); |
|
|
|
|
|
|
|
|
if (loadingState) { |
|
|
if (isLoading) { |
|
|
loadingState.classList.remove('hidden'); |
|
|
if (btnText) btnText.textContent = 'Searching...'; |
|
|
} else { |
|
|
loadingState.classList.add('hidden'); |
|
|
if (btnText) btnText.textContent = 'Search Products'; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (resultsContainer && isLoading) { |
|
|
resultsContainer.innerHTML = ` |
|
|
<div class="space-y-4 animate-pulse"> |
|
|
<div class="h-20 bg-gray-100 rounded-lg"></div> |
|
|
<div class="h-20 bg-gray-100 rounded-lg"></div> |
|
|
<div class="h-20 bg-gray-100 rounded-lg"></div> |
|
|
</div> |
|
|
`; |
|
|
} |
|
|
} |
|
|
|
|
|
showNotification(message, type = 'info') { |
|
|
|
|
|
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); |
|
|
} |
|
|
|
|
|
|
|
|
updateSearchMode(mode) { |
|
|
|
|
|
|
|
|
} |
|
|
|
|
|
initializeAnimations() { |
|
|
|
|
|
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', |
|
|
duration: 200, |
|
|
easing: 'easeOutQuad' |
|
|
}); |
|
|
}); |
|
|
}); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => { |
|
|
new MultimodalAssistant(); |
|
|
}); |