Easonwangzk's picture
Fix image path in LLM answers
290da9f
raw
history blame
18.8 kB
// 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 ? `<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');
// 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, '<br>') // Line breaks
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') // Bold text
.replace(/\*(.*?)\*/g, '<em>$1</em>'); // 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 `<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;
// 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 = '<div class="text-sm text-gray-500 text-center py-8">No products found.</div>';
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 = `
<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;
}
// Show last 6 items, reversed
const displayItems = this.searchHistory.slice(-6).reverse();
// Restore your original history styling
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');
// 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 = `
<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') {
// 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();
});