|
<!DOCTYPE html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<title>Vision AI</title> |
|
<link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@300;400;500;600&display=swap" rel="stylesheet"> |
|
<script src="https://cdn.tailwindcss.com"></script> |
|
<script> |
|
tailwind.config = { |
|
theme: { |
|
extend: { |
|
fontFamily: { |
|
'inter-tight': ['Inter Tight', 'system-ui', 'sans-serif'] |
|
} |
|
} |
|
} |
|
} |
|
</script> |
|
<style> |
|
.loading-dots::after { |
|
content: ''; |
|
display: inline-block; |
|
animation: loading 1.5s infinite; |
|
} |
|
|
|
@keyframes loading { |
|
0% { content: ''; } |
|
25% { content: '.'; } |
|
50% { content: '..'; } |
|
75% { content: '...'; } |
|
100% { content: ''; } |
|
} |
|
|
|
.drag-over { |
|
@apply border-gray-900 bg-gray-50; |
|
} |
|
|
|
@keyframes slideIn { |
|
from { |
|
transform: translateX(100%); |
|
opacity: 0; |
|
} |
|
to { |
|
transform: translateX(0); |
|
opacity: 1; |
|
} |
|
} |
|
|
|
.notification { |
|
animation: slideIn 0.3s ease; |
|
} |
|
|
|
textarea { |
|
text-align: left !important; |
|
direction: ltr !important; |
|
} |
|
|
|
#questionInput { |
|
text-align: left !important; |
|
} |
|
</style> |
|
</head> |
|
<body class="min-h-screen bg-white text-gray-900 font-inter-tight antialiased"> |
|
<div class="max-w-2xl mx-auto px-6 py-12"> |
|
|
|
<header class="mb-16"> |
|
<div class="flex items-center justify-between mb-8"> |
|
<div> |
|
<h1 class="text-2xl font-semibold text-gray-900 tracking-tight">Vision AI</h1> |
|
<p class="text-gray-600 text-sm mt-1">AI-powered image analysis</p> |
|
</div> |
|
<div class="w-2 h-2 bg-gray-900 rounded-full"></div> |
|
</div> |
|
</header> |
|
|
|
|
|
<div class="mb-12"> |
|
|
|
<div id="dropZone" class="border-2 border-dashed border-gray-300 rounded-xl p-12 text-center cursor-pointer transition-all duration-200 hover:border-gray-900 hover:bg-gray-50 bg-gray-25 h-[300px] relative flex items-center justify-center"> |
|
<div id="dropContent" class="space-y-4"> |
|
<div class="mx-auto w-12 h-12 text-gray-400"> |
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="w-full h-full"> |
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/> |
|
</svg> |
|
</div> |
|
<div> |
|
<p class="text-gray-700 text-base font-medium mb-1">Drop your image here or click to browse</p> |
|
<p class="text-gray-500 text-sm">Supports JPG, PNG, GIF up to 10MB</p> |
|
</div> |
|
</div> |
|
|
|
|
|
<div id="imagePreview" class="hidden absolute inset-0 flex-col"> |
|
|
|
<img id="previewImg" class="w-full h-full object-contain object-center" alt="Preview"> |
|
|
|
|
|
<button id="removeImage" class="absolute top-3 right-3 p-1.5 rounded-full bg-white/70 hover:bg-white/100 shadow-sm border border-gray-300 text-gray-700 hover:text-gray-900 transition-colors duration-200" aria-label="Remove image"> |
|
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5"> |
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> |
|
</svg> |
|
</button> |
|
</div> |
|
</div> |
|
|
|
<input type="file" id="fileInput" accept="image/*" class="hidden"> |
|
</div> |
|
|
|
|
|
<div class="mb-12 space-y-6"> |
|
<div> |
|
<textarea |
|
id="questionInput" |
|
rows="4" |
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg resize-none focus:ring-2 focus:ring-gray-900 focus:border-transparent text-base placeholder-gray-500 transition-all duration-200 text-left" |
|
placeholder="What would you like to know about this image?" |
|
></textarea> |
|
</div> |
|
|
|
<button id="analyzeBtn" disabled class="w-full bg-gray-900 text-white px-6 py-4 rounded-lg font-medium text-base hover:bg-gray-800 disabled:bg-gray-300 disabled:text-gray-500 disabled:cursor-not-allowed transition-all duration-200 focus:ring-2 focus:ring-gray-900 focus:ring-offset-2"> |
|
<span id="analyzeText">Analyze Image</span> |
|
</button> |
|
</div> |
|
|
|
|
|
<div id="resultsSection" class="hidden mb-12"> |
|
<div class="border border-gray-200 rounded-xl p-8 bg-gray-50"> |
|
<div class="space-y-6"> |
|
<div> |
|
<div class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Question</div> |
|
<div class="text-gray-900 leading-relaxed" id="questionDisplay"></div> |
|
</div> |
|
|
|
<div> |
|
<div class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Answer</div> |
|
<div class="text-gray-900 leading-relaxed text-base" id="answerDisplay"></div> |
|
</div> |
|
|
|
<div class="flex justify-between items-center pt-4 border-t border-gray-200 text-xs text-gray-500"> |
|
<span>ID: <span id="requestId" class="font-mono"></span></span> |
|
<span id="responseTime" class="font-medium"></span> |
|
</div> |
|
</div> |
|
</div> |
|
</div> |
|
|
|
|
|
<footer class="pt-12 border-t border-gray-100"> |
|
<div class="flex items-center justify-between"> |
|
<p class="text-sm text-gray-500"> |
|
Powered by <a href="https://moondream.ai" target="_blank" class="text-gray-700 hover:text-gray-900 font-medium transition-colors duration-200 underline decoration-1 underline-offset-2">Moondream</a> |
|
</p> |
|
<div class="text-xs text-gray-400 font-mono"> |
|
v1.0.0 |
|
</div> |
|
</div> |
|
</footer> |
|
</div> |
|
|
|
<script> |
|
class VisionApp { |
|
constructor() { |
|
|
|
|
|
this.apiKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXlfaWQiOiJkMjc1N2QwNS04OGRjLTQ4YjUtOGVjNC05Y2M1ODYxYjBmOGMiLCJvcmdfaWQiOiIyUlJuZUVNMGVEblVRWFdjbDZKa3M3Y01vaWdTMmJ2aCIsImlhdCI6MTc1ODYyNzkzNywidmVyIjoxfQ.-Wqw1RXeev4ERwl18R9fefMzvOvSBVMvbVWiR3E-BOE'; |
|
this.currentImage = null; |
|
this.initializeElements(); |
|
this.bindEvents(); |
|
} |
|
|
|
initializeElements() { |
|
this.elements = { |
|
dropZone: document.getElementById('dropZone'), |
|
fileInput: document.getElementById('fileInput'), |
|
dropContent: document.getElementById('dropContent'), |
|
imagePreview: document.getElementById('imagePreview'), |
|
previewImg: document.getElementById('previewImg'), |
|
removeImageBtn: document.getElementById('removeImage'), |
|
questionInput: document.getElementById('questionInput'), |
|
analyzeBtn: document.getElementById('analyzeBtn'), |
|
analyzeText: document.getElementById('analyzeText'), |
|
resultsSection: document.getElementById('resultsSection'), |
|
questionDisplay: document.getElementById('questionDisplay'), |
|
answerDisplay: document.getElementById('answerDisplay'), |
|
requestId: document.getElementById('requestId'), |
|
responseTime: document.getElementById('responseTime') |
|
}; |
|
} |
|
|
|
bindEvents() { |
|
|
|
this.elements.dropZone.addEventListener('click', () => this.elements.fileInput.click()); |
|
this.elements.dropZone.addEventListener('dragover', this.handleDragOver.bind(this)); |
|
this.elements.dropZone.addEventListener('dragleave', this.handleDragLeave.bind(this)); |
|
this.elements.dropZone.addEventListener('drop', this.handleDrop.bind(this)); |
|
this.elements.fileInput.addEventListener('change', this.handleFileSelect.bind(this)); |
|
this.elements.removeImageBtn.addEventListener('click', (e) => { |
|
e.stopPropagation(); |
|
this.removeImage(); |
|
}); |
|
|
|
|
|
this.elements.questionInput.addEventListener('input', () => this.updateAnalyzeButton()); |
|
this.elements.analyzeBtn.addEventListener('click', () => this.analyzeImage()); |
|
} |
|
|
|
handleDragOver(e) { |
|
e.preventDefault(); |
|
this.elements.dropZone.classList.add('drag-over'); |
|
} |
|
|
|
handleDragLeave(e) { |
|
e.preventDefault(); |
|
if (!this.elements.dropZone.contains(e.relatedTarget)) { |
|
this.elements.dropZone.classList.remove('drag-over'); |
|
} |
|
} |
|
|
|
handleDrop(e) { |
|
e.preventDefault(); |
|
this.elements.dropZone.classList.remove('drag-over'); |
|
const files = e.dataTransfer.files; |
|
if (files.length > 0) { |
|
this.processFile(files[0]); |
|
} |
|
} |
|
|
|
handleFileSelect(e) { |
|
if (e.target.files.length > 0) { |
|
this.processFile(e.target.files[0]); |
|
} |
|
} |
|
|
|
processFile(file) { |
|
if (!file.type.startsWith('image/')) { |
|
this.showNotification('Please select a valid image file', 'error'); |
|
return; |
|
} |
|
|
|
if (file.size > 10 * 1024 * 1024) { |
|
this.showNotification('File size should be less than 10MB', 'error'); |
|
return; |
|
} |
|
|
|
const reader = new FileReader(); |
|
reader.onload = (e) => { |
|
this.currentImage = e.target.result; |
|
this.showImagePreview(file); |
|
}; |
|
reader.readAsDataURL(file); |
|
} |
|
|
|
showImagePreview(file) { |
|
this.elements.previewImg.src = this.currentImage; |
|
|
|
this.elements.dropContent.classList.add('hidden'); |
|
this.elements.imagePreview.classList.remove('hidden'); |
|
this.updateAnalyzeButton(); |
|
} |
|
|
|
removeImage() { |
|
this.currentImage = null; |
|
this.elements.dropContent.classList.remove('hidden'); |
|
this.elements.imagePreview.classList.add('hidden'); |
|
this.elements.fileInput.value = ''; |
|
this.updateAnalyzeButton(); |
|
} |
|
|
|
updateAnalyzeButton() { |
|
const hasImage = this.currentImage !== null; |
|
const hasQuestion = this.elements.questionInput.value.trim().length > 0; |
|
|
|
this.elements.analyzeBtn.disabled = !(hasImage && hasQuestion); |
|
} |
|
|
|
async analyzeImage() { |
|
if (!this.currentImage || !this.elements.questionInput.value.trim()) { |
|
this.showNotification('Please provide an image and question', 'error'); |
|
return; |
|
} |
|
|
|
const question = this.elements.questionInput.value.trim(); |
|
this.setLoading(true); |
|
|
|
try { |
|
const startTime = Date.now(); |
|
const result = await this.queryMoondream(this.currentImage, question); |
|
const endTime = Date.now(); |
|
const responseTime = ((endTime - startTime) / 1000).toFixed(1); |
|
|
|
if (!result || !result.answer) { |
|
throw new Error('Invalid response from API'); |
|
} |
|
|
|
this.showResults(question, result.answer, result.request_id, responseTime); |
|
this.showNotification('Analysis completed successfully', 'success'); |
|
|
|
} catch (error) { |
|
console.error('Analysis error:', error); |
|
this.showNotification(`Analysis failed: ${error.message}`, 'error'); |
|
} finally { |
|
this.setLoading(false); |
|
} |
|
} |
|
|
|
async queryMoondream(imageDataUrl, question) { |
|
try { |
|
const requestBody = { |
|
image_url: imageDataUrl, |
|
question: question, |
|
stream: false |
|
}; |
|
|
|
|
|
const response = await fetch('https://api.moondream.ai/v1/query', { |
|
method: 'POST', |
|
headers: { |
|
'X-Moondream-Auth': this.apiKey, |
|
'Content-Type': 'application/json', |
|
}, |
|
body: JSON.stringify(requestBody) |
|
}); |
|
|
|
if (!response.ok) { |
|
const errorText = await response.text(); |
|
let errorMessage = `HTTP ${response.status}: ${response.statusText}`; |
|
try { |
|
const errorData = JSON.parse(errorText); |
|
errorMessage = errorData.message || errorData.error || errorMessage; |
|
} catch (e) { |
|
errorMessage = errorText || errorMessage; |
|
} |
|
throw new Error(errorMessage); |
|
} |
|
|
|
const result = await response.json(); |
|
return { |
|
answer: result.result || result.answer || 'No answer received', |
|
request_id: result.request_id || result.id || `req_${Date.now()}` |
|
}; |
|
|
|
} catch (error) { |
|
throw new Error(`API request failed: ${error.message}`); |
|
} |
|
} |
|
|
|
setLoading(isLoading) { |
|
if (isLoading) { |
|
this.elements.analyzeText.innerHTML = '<span class="loading-dots">Analyzing</span>'; |
|
this.elements.analyzeBtn.disabled = true; |
|
} else { |
|
this.elements.analyzeText.textContent = 'Analyze Image'; |
|
this.updateAnalyzeButton(); |
|
} |
|
} |
|
|
|
showResults(question, answer, requestId, responseTime) { |
|
this.elements.questionDisplay.textContent = question; |
|
this.elements.answerDisplay.textContent = answer; |
|
this.elements.requestId.textContent = requestId; |
|
this.elements.responseTime.textContent = `${responseTime}s`; |
|
this.elements.resultsSection.classList.remove('hidden'); |
|
|
|
|
|
this.elements.resultsSection.scrollIntoView({ |
|
behavior: 'smooth', |
|
block: 'nearest' |
|
}); |
|
} |
|
|
|
showNotification(message, type = 'info') { |
|
const notification = document.createElement('div'); |
|
const bgColor = type === 'error' ? 'bg-red-600' : type === 'success' ? 'bg-green-600' : 'bg-gray-900'; |
|
|
|
notification.className = `notification fixed top-4 right-4 ${bgColor} text-white px-4 py-3 rounded-lg shadow-lg z-50 text-sm font-medium`; |
|
notification.textContent = message; |
|
|
|
document.body.appendChild(notification); |
|
|
|
setTimeout(() => { |
|
notification.remove(); |
|
}, 4000); |
|
} |
|
} |
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => { |
|
new VisionApp(); |
|
}); |
|
</script> |
|
</body> |
|
</html> |