Spaces:
Sleeping
Sleeping
| <html lang="en" class="dark"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Enterprise AI Search</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script> | |
| tailwind.config = { | |
| darkMode: 'class', | |
| theme: { | |
| extend: { | |
| colors: { | |
| darkbg: '#121212', | |
| sidebar: '#18181b', | |
| card: '#27272a', | |
| accent: '#ff2d55' | |
| } | |
| } | |
| } | |
| } | |
| </script> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
| <style> | |
| .no-scrollbar::-webkit-scrollbar { display: none; } | |
| .no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } | |
| .log-scroll::-webkit-scrollbar { width: 6px; } | |
| .log-scroll::-webkit-scrollbar-track { background: #121212; } | |
| .log-scroll::-webkit-scrollbar-thumb { background: #3f3f46; border-radius: 10px; } | |
| .truncate-1-line { | |
| display: -webkit-box; | |
| -webkit-line-clamp: 1; | |
| -webkit-box-orient: vertical; | |
| overflow: hidden; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-darkbg text-gray-200 flex h-screen overflow-hidden font-sans"> | |
| <!-- GPT-STYLE SMART COLLAPSIBLE SIDEBAR --> | |
| <!-- Added: ID 'app-sidebar', w-64, overflow-hidden, and smooth width transitions --> | |
| <aside id="app-sidebar" class="w-64 bg-sidebar flex flex-col border-r border-gray-800 flex-shrink-0 h-screen transition-all duration-300 overflow-hidden"> | |
| <!-- TOP: Branding & Sidebar Toggle --> | |
| <div class="p-4 flex items-center justify-between"> | |
| <div class="flex items-center gap-3 font-bold text-xl tracking-wider text-white px-1 whitespace-nowrap"> | |
| <i class="fa-solid fa-layer-group text-accent"></i> | |
| <span>Multimodal AI</span> | |
| </div> | |
| <!-- Added: The Collapse Button --> | |
| <button onclick="toggleSidebar()" class="text-gray-400 hover:text-white hover:bg-gray-800 w-8 h-8 rounded-md flex items-center justify-center transition flex-shrink-0" title="Close sidebar"> | |
| <i class="fa-solid fa-bars-staggered"></i> | |
| </button> | |
| </div> | |
| <div class="px-4 py-2"> | |
| <button onclick="resetHome()" class="w-full bg-accent hover:bg-pink-600 text-white py-2.5 rounded-lg font-semibold flex items-center gap-3 px-4 transition shadow-lg shadow-accent/20 whitespace-nowrap"> | |
| <i class="fa-solid fa-plus text-sm"></i> New Search | |
| </button> | |
| </div> | |
| <!-- MIDDLE: Scrollable Search History --> | |
| <div class="flex-1 overflow-y-auto no-scrollbar px-3 py-2 mt-2"> | |
| <div class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3 px-3 whitespace-nowrap">Recent Searches</div> | |
| <div id="searchHistoryList" class="space-y-1 w-full"> | |
| <div class="text-gray-600 text-xs text-center mt-4">No recent searches</div> | |
| </div> | |
| </div> | |
| <!-- BOTTOM: Fixed Tools & Settings --> | |
| <div class="p-3 border-t border-gray-800 space-y-1 bg-sidebar w-full whitespace-nowrap"> | |
| <a href="#" onclick="resetHome()" class="flex items-center gap-3 px-3 py-2.5 hover:bg-gray-800 rounded-lg text-gray-300 transition text-sm font-medium"><i class="fa-solid fa-house w-4"></i> Home</a> | |
| <a href="#" onclick="document.getElementById('imageInput').click()" class="flex items-center gap-3 px-3 py-2.5 hover:bg-gray-800 rounded-lg text-gray-300 transition text-sm font-medium"><i class="fa-solid fa-camera w-4"></i> Visual Search</a> | |
| <a href="#" onclick="triggerDashboard()" class="flex items-center gap-3 px-3 py-2.5 hover:bg-gray-800 rounded-lg text-gray-300 transition text-sm font-medium"><i class="fa-solid fa-shield-halved w-4"></i> Admin Panel</a> | |
| </div> | |
| </aside> | |
| <!-- MAIN APP CANVAS --> | |
| <main class="flex-1 flex flex-col h-full relative overflow-hidden"> | |
| <!-- HEADER --> | |
| <header class="p-4 border-b border-gray-800 flex items-center bg-darkbg z-10 gap-4"> | |
| <!-- Added: Hamburger Menu (Shows only when sidebar is closed) --> | |
| <button id="headerMenuBtn" onclick="toggleSidebar()" class="hidden text-gray-400 hover:text-white hover:bg-gray-800 w-10 h-10 rounded-md flex items-center justify-center transition flex-shrink-0" title="Open sidebar"> | |
| <i class="fa-solid fa-bars text-lg"></i> | |
| </button> | |
| <div class="relative w-full max-w-3xl mx-auto flex-1"> | |
| <i class="fa-solid fa-magnifying-glass absolute left-5 top-1/2 transform -translate-y-1/2 text-gray-400"></i> | |
| <input type="text" id="searchInput" autocomplete="off" spellcheck="false" placeholder="Search 80,000 products... (e.g., a ceramic coffee mug)" class="w-full bg-card text-white rounded-full pl-12 pr-28 py-3.5 focus:outline-none focus:ring-2 focus:ring-gray-600 transition shadow-lg text-sm border border-gray-700/50"> | |
| <input type="file" id="imageInput" accept="image/*" class="hidden"> | |
| <button onclick="document.getElementById('imageInput').click()" class="absolute right-28 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-white transition px-2"><i class="fa-solid fa-camera text-lg"></i></button> | |
| <button onclick="performSearch()" class="absolute right-2 top-1/2 transform -translate-y-1/2 bg-gray-700 hover:bg-gray-600 px-6 py-2 rounded-full text-sm font-medium transition text-white">Search</button> | |
| </div> | |
| <!-- Invisible spacer to keep the search bar perfectly centered --> | |
| <div id="headerSpacer" class="w-10 hidden flex-shrink-0"></div> | |
| </header> | |
| <div class="flex-1 overflow-y-auto p-8 relative"> | |
| <!-- HOME SCREEN --> | |
| <div id="homeState" class="flex flex-col items-center justify-center mt-10 text-center animate-fade-in"> | |
| <div class="w-20 h-20 bg-card rounded-2xl flex items-center justify-center mb-6 shadow-lg border border-gray-700"> | |
| <i class="fa-solid fa-wand-magic-sparkles text-3xl text-accent"></i> | |
| </div> | |
| <h1 class="text-3xl font-bold text-white mb-3">What are you looking for?</h1> | |
| <p class="text-gray-400 max-w-lg mx-auto mb-8">Type a complex semantic query, apply filters, or upload a photo to test the multimodal vision engine.</p> | |
| </div> | |
| <!-- LOADING SPINNER --> | |
| <div id="loadingIndicator" class="hidden flex-col items-center justify-center mt-20 text-gray-400"> | |
| <i class="fa-solid fa-circle-notch fa-spin text-4xl mb-4 text-accent"></i> | |
| <p id="loadingText" class="text-lg font-medium">AI is scanning the database...</p> | |
| </div> | |
| <!-- ADMIN DASHBOARD --> | |
| <div id="dashboardState" class="hidden flex-col h-full max-w-7xl mx-auto"> | |
| <div class="flex items-center justify-between border-b border-gray-800 pb-4 mb-6"> | |
| <h2 class="text-2xl font-bold text-white flex items-center gap-2"><i class="fa-solid fa-shield-halved text-accent"></i> Secure Admin Analytics Panel</h2> | |
| <button onclick="resetHome()" class="text-xs bg-gray-800 hover:bg-gray-700 px-3 py-1.5 rounded-md text-gray-400 font-medium transition">Exit Admin Mode</button> | |
| </div> | |
| <div class="flex flex-col lg:flex-row gap-6 h-[calc(100vh-200px)]"> | |
| <div class="flex-1 overflow-y-auto no-scrollbar space-y-6"> | |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-6"> | |
| <div class="bg-card border border-gray-800 p-6 rounded-xl shadow-lg"> | |
| <div class="flex justify-between items-start mb-4"> | |
| <div class="p-3 bg-blue-500/10 rounded-lg text-blue-400 text-xl"><i class="fa-solid fa-database"></i></div> | |
| <span id="qdrantStatus" class="text-[10px] uppercase font-bold tracking-wider px-2 py-0.5 rounded bg-green-500/10 text-green-400 border border-green-500/20">UNKNOWN</span> | |
| </div> | |
| <p class="text-gray-400 text-xs uppercase font-semibold tracking-wider mb-1">Qdrant Vectors</p> | |
| <h3 id="qdrantCount" class="text-3xl font-bold text-white tracking-tight">0</h3> | |
| </div> | |
| <div class="bg-card border border-gray-800 p-6 rounded-xl shadow-lg"> | |
| <div class="flex justify-between items-start mb-4"> | |
| <div class="p-3 bg-purple-500/10 rounded-lg text-purple-400 text-xl"><i class="fa-solid fa-stopwatch"></i></div> | |
| <span class="text-[10px] uppercase font-bold tracking-wider px-2 py-0.5 rounded bg-gray-700 text-gray-300 border border-gray-600">LIFETIME</span> | |
| </div> | |
| <p class="text-gray-400 text-xs uppercase font-semibold tracking-wider mb-1">Avg Search Latency</p> | |
| <h3 id="avgLatency" class="text-3xl font-bold text-white tracking-tight">0.00 ms</h3> | |
| </div> | |
| <div class="bg-card border border-gray-800 p-6 rounded-xl shadow-lg"> | |
| <div class="flex justify-between items-start mb-4"> | |
| <div class="p-3 bg-pink-500/10 rounded-lg text-accent text-xl"><i class="fa-solid fa-bolt"></i></div> | |
| <span id="redisStatus" class="text-[10px] uppercase font-bold tracking-wider px-2 py-0.5 rounded bg-green-500/10 text-green-400 border border-green-500/20">UNKNOWN</span> | |
| </div> | |
| <p class="text-gray-400 text-xs uppercase font-semibold tracking-wider mb-1">Redis Cache Hits</p> | |
| <h3 id="redisHits" class="text-3xl font-bold text-white tracking-tight">0</h3> | |
| </div> | |
| <div class="bg-card border border-gray-800 p-6 rounded-xl shadow-lg"> | |
| <div class="flex justify-between items-start mb-4"> | |
| <div class="p-3 bg-amber-500/10 rounded-lg text-amber-400 text-xl"><i class="fa-solid fa-gauge-high"></i></div> | |
| <span class="text-[10px] text-gray-400 font-semibold uppercase tracking-wider">Sub-ms Tier</span> | |
| </div> | |
| <p class="text-gray-400 text-xs uppercase font-semibold tracking-wider mb-1">Redis Cache Misses</p> | |
| <h3 id="redisMisses" class="text-3xl font-bold text-white tracking-tight">0</h3> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="w-full lg:w-96 bg-card border border-gray-800 rounded-xl flex flex-col shadow-lg overflow-hidden flex-shrink-0"> | |
| <div class="p-4 bg-gray-900 border-b border-gray-800 flex justify-between items-center"> | |
| <span class="text-xs font-bold text-gray-400 uppercase tracking-widest"><i class="fa-solid fa-terminal mr-2"></i> Live Event Logs</span> | |
| <span class="flex h-2 w-2 relative"> | |
| <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-accent opacity-75"></span> | |
| <span class="relative inline-flex rounded-full h-2 w-2 bg-accent"></span> | |
| </span> | |
| </div> | |
| <div id="logContainer" class="flex-1 p-4 overflow-y-auto log-scroll space-y-3"> | |
| <div class="text-gray-500 text-center text-sm mt-10">Awaiting system events...</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- SEARCH RESULTS GRID --> | |
| <div id="resultsGrid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 pb-20 w-full hidden"></div> | |
| </div> | |
| </main> | |
| <script> | |
| const searchInput = document.getElementById('searchInput'); | |
| const resultsGrid = document.getElementById('resultsGrid'); | |
| const homeState = document.getElementById('homeState'); | |
| const dashboardState = document.getElementById('dashboardState'); | |
| const loadingIndicator = document.getElementById('loadingIndicator'); | |
| const loadingText = document.getElementById('loadingText'); | |
| const imageInput = document.getElementById('imageInput'); | |
| let currentImageFile = null; | |
| // --- NEW: SIDEBAR TOGGLE LOGIC --- | |
| function toggleSidebar() { | |
| const sidebar = document.getElementById('app-sidebar'); | |
| const headerBtn = document.getElementById('headerMenuBtn'); | |
| const headerSpacer = document.getElementById('headerSpacer'); | |
| if (sidebar.classList.contains('w-64')) { | |
| // Collapse the sidebar to 0 width | |
| sidebar.classList.remove('w-64'); | |
| sidebar.classList.add('w-0', 'border-transparent'); | |
| // Show hamburger menu in the main header | |
| headerBtn.classList.remove('hidden'); | |
| headerSpacer.classList.remove('hidden'); | |
| } else { | |
| // Expand the sidebar back to normal | |
| sidebar.classList.add('w-64'); | |
| sidebar.classList.remove('w-0', 'border-transparent'); | |
| // Hide the hamburger menu | |
| headerBtn.classList.add('hidden'); | |
| headerSpacer.classList.add('hidden'); | |
| } | |
| } | |
| // --- LOCAL STORAGE HISTORY ENGINE --- | |
| function loadHistory() { | |
| const history = JSON.parse(localStorage.getItem('search_history') || '[]'); | |
| const listEl = document.getElementById('searchHistoryList'); | |
| if (history.length === 0) { | |
| listEl.innerHTML = '<div class="text-gray-600 text-xs text-center mt-4">No recent searches</div>'; | |
| return; | |
| } | |
| listEl.innerHTML = history.map(item => ` | |
| <button onclick="triggerPastSearch('${item.query.replace(/'/g, "\\'")}')" class="w-full text-left px-3 py-2.5 rounded-lg hover:bg-gray-800 text-gray-300 flex items-center gap-3 transition group"> | |
| <i class="fa-regular ${item.type === 'image' ? 'fa-image' : 'fa-message'} text-gray-500 group-hover:text-accent transition flex-shrink-0"></i> | |
| <span class="text-sm truncate-1-line">${item.query}</span> | |
| </button> | |
| `).join(''); | |
| } | |
| function saveToHistory(query, type) { | |
| let history = JSON.parse(localStorage.getItem('search_history') || '[]'); | |
| history = history.filter(item => item.query !== query); | |
| history.unshift({ query, type }); | |
| if (history.length > 15) history.pop(); | |
| localStorage.setItem('search_history', JSON.stringify(history)); | |
| loadHistory(); | |
| } | |
| function triggerPastSearch(query) { | |
| resetHome(); | |
| searchInput.value = query; | |
| executeUnifiedSearch(); | |
| } | |
| // Initialize history on page load | |
| window.addEventListener('DOMContentLoaded', () => { | |
| loadHistory(); | |
| const params = new URLSearchParams(window.location.search); | |
| if (params.get('admin') === 'true') { | |
| triggerDashboard(); | |
| } | |
| }); | |
| // --- INPUT EVENT LISTENERS --- | |
| searchInput.addEventListener('keypress', function (e) { | |
| if (e.key === 'Enter') executeUnifiedSearch(); | |
| }); | |
| imageInput.addEventListener('change', function(e) { | |
| const file = e.target.files[0]; | |
| if (!file) return; | |
| currentImageFile = file; | |
| searchInput.placeholder = `📸 Image attached. Add text to modify it`; | |
| searchInput.value = ''; | |
| searchInput.focus(); | |
| setTimeout(() => { | |
| if(searchInput.value === '') executeUnifiedSearch(); | |
| }, 500); | |
| }); | |
| // --- APP NAVIGATION --- | |
| function resetHome() { | |
| window.history.replaceState({}, document.title, window.location.pathname); | |
| searchInput.value = ''; | |
| searchInput.placeholder = "Search 80,000 products... (e.g., a ceramic coffee mug)"; | |
| currentImageFile = null; | |
| imageInput.value = ''; | |
| resultsGrid.innerHTML = ''; | |
| resultsGrid.classList.add('hidden'); | |
| loadingIndicator.classList.add('hidden'); | |
| dashboardState.classList.add('hidden'); | |
| homeState.classList.remove('hidden'); | |
| } | |
| function triggerDashboard() { | |
| showDashboard(); | |
| } | |
| async function showDashboard() { | |
| homeState.classList.add('hidden'); | |
| resultsGrid.classList.add('hidden'); | |
| dashboardState.classList.add('hidden'); | |
| loadingIndicator.classList.remove('hidden'); | |
| loadingText.innerText = "Authenticating secure handshake..."; | |
| const adminKey = prompt("Enter Admin Security Token:"); | |
| if (!adminKey) { resetHome(); return; } | |
| try { | |
| const response = await fetch('/api/stats', { | |
| headers: { 'x-admin-token': adminKey } | |
| }); | |
| if (response.status === 403) { | |
| showError("Access Denied: Invalid Security Header."); | |
| return; | |
| } | |
| const data = await response.json(); | |
| if (data.status === "success") { | |
| document.getElementById('qdrantCount').innerText = data.qdrant.points_count.toLocaleString(); | |
| document.getElementById('qdrantStatus').innerText = data.qdrant.status.toUpperCase(); | |
| document.getElementById('redisStatus').innerText = data.redis.status; | |
| document.getElementById('redisHits').innerText = data.redis.hits.toLocaleString(); | |
| document.getElementById('redisMisses').innerText = data.redis.misses.toLocaleString(); | |
| document.getElementById('avgLatency').innerText = `${data.performance.avg_latency_ms} ms`; | |
| const logContainer = document.getElementById('logContainer'); | |
| if (data.performance.recent_logs.length > 0) { | |
| logContainer.innerHTML = data.performance.recent_logs.map(log => ` | |
| <div class="bg-darkbg p-3 rounded-lg border border-gray-800 text-xs font-mono shadow-inner"> | |
| <div class="flex justify-between items-center mb-1.5"> | |
| <span class="text-accent font-bold">${log.type}</span> | |
| <span class="text-gray-500">${log.time}</span> | |
| </div> | |
| <div class="text-gray-300 truncate" title="${log.details}">${log.details}</div> | |
| ${log.latency_ms > 0 ? `<div class="text-gray-500 mt-1"><i class="fa-solid fa-stopwatch text-[10px] mr-1"></i>${log.latency_ms} ms</div>` : ''} | |
| </div> | |
| `).join(''); | |
| } else { | |
| logContainer.innerHTML = '<div class="text-gray-500 text-center text-sm mt-10">No recent system activity recorded.</div>'; | |
| } | |
| loadingIndicator.classList.add('hidden'); | |
| dashboardState.classList.remove('hidden'); | |
| dashboardState.style.display = 'flex'; | |
| } | |
| } catch (error) { | |
| showError("Could not capture cloud resource statistics."); | |
| } | |
| } | |
| // --- CORE SEARCH EXECUTION --- | |
| async function executeUnifiedSearch() { | |
| const textQuery = searchInput.value.trim(); | |
| if (!textQuery && !currentImageFile) return; | |
| if (textQuery) { | |
| saveToHistory(textQuery, currentImageFile ? 'hybrid' : 'text'); | |
| } else if (currentImageFile) { | |
| saveToHistory(`📸 Visual Search: ${currentImageFile.name}`, 'image'); | |
| } | |
| homeState.classList.add('hidden'); | |
| dashboardState.classList.add('hidden'); | |
| resultsGrid.innerHTML = ''; | |
| resultsGrid.classList.add('hidden'); | |
| loadingIndicator.classList.remove('hidden'); | |
| try { | |
| let response, data; | |
| if (currentImageFile && textQuery) { | |
| loadingText.innerText = "VLM is composing your search..."; | |
| const formData = new FormData(); | |
| formData.append("file", currentImageFile); | |
| formData.append("query", textQuery); | |
| formData.append("top_k", 6); | |
| response = await fetch('/api/search/composed', { method: 'POST', body: formData }); | |
| data = await response.json(); | |
| } else if (currentImageFile && !textQuery) { | |
| loadingText.innerText = "Analyzing visual embeddings..."; | |
| const formData = new FormData(); | |
| formData.append("file", currentImageFile); | |
| formData.append("top_k", 6); | |
| response = await fetch('/api/search/image', { method: 'POST', body: formData }); | |
| data = await response.json(); | |
| } else { | |
| loadingText.innerText = "Querying semantic vectors..."; | |
| response = await fetch('/api/search', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ query: textQuery, top_k: 6 }) | |
| }); | |
| data = await response.json(); | |
| } | |
| processResults(data); | |
| } catch (error) { | |
| showError("Error connecting to AI backend."); | |
| } | |
| } | |
| function performSearch() { executeUnifiedSearch(); } | |
| function processResults(data) { | |
| loadingIndicator.classList.add('hidden'); | |
| resultsGrid.classList.remove('hidden'); | |
| if (data.results && data.results.length > 0) { | |
| renderCards(data.results); | |
| } else { | |
| resultsGrid.innerHTML = '<div class="w-full text-center mt-10 text-gray-500 col-span-full">No products found matching your criteria.</div>'; | |
| } | |
| } | |
| function renderCards(results) { | |
| resultsGrid.innerHTML = results.map(item => ` | |
| <div class="mb-6 bg-card rounded-xl overflow-hidden hover:ring-2 hover:ring-gray-500 transition duration-300 shadow-lg group cursor-pointer border border-gray-800 flex flex-col justify-between"> | |
| <div class="relative bg-gray-900"> | |
| <img src="${item.image_url}" alt="Product" class="w-full h-64 object-cover opacity-90 group-hover:opacity-100 transition duration-300"> | |
| <div class="absolute top-3 right-3 bg-black/80 px-2.5 py-1 rounded-md text-xs font-bold text-accent backdrop-blur-sm shadow-md border border-gray-700/50">Match: ${(item.hybrid_sort_score * 100).toFixed(1)}%</div> | |
| <span>Match: ${(item.hybrid_sort_score * 100).toFixed(1)}%</span> | |
| </div> | |
| </div> | |
| <div class="p-4 flex-1 flex flex-col justify-between"> | |
| <div> | |
| <p class="text-[10px] text-gray-400 mb-1.5 uppercase tracking-widest font-semibold">${item.category || 'Product'}</p> | |
| <h3 class="font-medium text-gray-200 text-sm mb-3 leading-snug line-clamp-2">${item.title}</h3> | |
| </div> | |
| <div class="flex justify-between items-center pt-2"> | |
| <span class="text-lg font-bold text-white">$${item.price || 'N/A'}</span> | |
| <button class="text-gray-500 hover:text-accent transition"><i class="fa-solid fa-bookmark"></i></button> | |
| </div> | |
| </div> | |
| </div> | |
| `).join(''); | |
| } | |
| function showError(msg) { | |
| loadingIndicator.classList.add('hidden'); | |
| dashboardState.classList.add('hidden'); | |
| resultsGrid.classList.remove('hidden'); | |
| resultsGrid.innerHTML = `<div class="w-full text-center mt-10 text-red-400 font-medium">${msg}</div>`; | |
| } | |
| </script> | |
| </body> | |
| </html> |