ZARAI / frontend /src /components /image /ImagePanel.jsx
Srajal2579
Deploy JARVIS
3e646d9
Raw
History Blame Contribute Delete
9.58 kB
/**
* JARVIS — Image Panel
* Drag-and-drop image upload, visual analysis with BLIP + CLIP, then chat.
*/
import React, { useState, useCallback } from 'react'
import { useDropzone } from 'react-dropzone'
import { motion, AnimatePresence } from 'framer-motion'
import {
Upload, Image, X, Loader2, Eye, Tag,
ChevronDown, ChevronUp, Sparkles,
} from 'lucide-react'
import clsx from 'clsx'
import toast from 'react-hot-toast'
import useAppStore from '../../stores/appStore'
import { imageAPI } from '../../services/api'
export default function ImagePanel() {
const { activeSessionId, addImageResult, setPendingImage, setInputMode } = useAppStore()
const [file, setFile] = useState(null)
const [preview, setPreview] = useState(null)
const [question, setQuestion] = useState('')
const [result, setResult] = useState(null)
const [loading, setLoading] = useState(false)
const [expanded, setExpanded] = useState(true)
// ── Dropzone ───────────────────────────────────
const onDrop = useCallback((acceptedFiles) => {
const f = acceptedFiles[0]
if (!f) return
setFile(f)
setResult(null)
const url = URL.createObjectURL(f)
setPreview(url)
// Auto-set pending image for chat context
setPendingImage({ file: f, preview: url, caption: null })
}, [setPendingImage])
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: { 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'] },
maxFiles: 1,
maxSize: 50 * 1024 * 1024,
})
const clearImage = () => {
setFile(null)
setPreview(null)
setResult(null)
setQuestion('')
setPendingImage(null)
}
// ── Analyze ────────────────────────────────────
const analyze = async () => {
if (!file) return
setLoading(true)
try {
const data = await imageAPI.analyzeImage(file, question || null, activeSessionId)
setResult(data)
// Update pending image caption for chat context
setPendingImage({ file, preview, caption: data.caption })
// Add to message feed
addImageResult(
data.session_id,
question || 'Analyze this image',
data,
)
toast.success('Image analyzed!')
} catch (err) {
toast.error(err.message || 'Analysis failed')
} finally {
setLoading(false)
}
}
// ── Use in chat ────────────────────────────────
const useInChat = () => {
setInputMode('text')
toast('Image context attached to chat', { icon: '📎' })
}
return (
<div className="bg-jarvis-surface border-t border-jarvis-border">
{/* Header */}
<div
className="flex items-center justify-between px-4 py-2.5 cursor-pointer
hover:bg-jarvis-panel/30 transition-colors"
onClick={() => setExpanded((v) => !v)}
>
<div className="flex items-center gap-2">
<Eye size={13} className="text-jarvis-accent" />
<span className="text-xs font-medium text-jarvis-text">Vision Analysis</span>
<span className="text-[10px] font-mono text-jarvis-dim">BLIP + CLIP</span>
</div>
{expanded ? <ChevronDown size={13} className="text-jarvis-dim" /> :
<ChevronUp size={13} className="text-jarvis-dim" />}
</div>
<AnimatePresence>
{expanded && (
<motion.div
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
className="overflow-hidden"
>
<div className="px-4 pb-4 space-y-3">
{!file ? (
// ── Drop zone ────────────────────────
<div
{...getRootProps()}
className={clsx(
'border-2 border-dashed rounded-xl p-6 text-center cursor-pointer',
'transition-all duration-200',
isDragActive
? 'border-jarvis-accent bg-jarvis-accent/10'
: 'border-jarvis-border hover:border-jarvis-accent/50 hover:bg-jarvis-panel/30',
)}
>
<input {...getInputProps()} />
<Upload size={24} className={clsx('mx-auto mb-2', isDragActive ? 'text-jarvis-accent' : 'text-jarvis-dim')} />
<p className="text-xs text-jarvis-muted">
{isDragActive ? 'Drop image here…' : 'Drag & drop or click to upload'}
</p>
<p className="text-[10px] text-jarvis-dim mt-1">PNG, JPG, WEBP · max 50MB</p>
</div>
) : (
// ── Image preview + controls ─────────
<div className="flex gap-3">
{/* Preview */}
<div className="relative flex-shrink-0">
<img
src={preview}
alt="Preview"
className="w-28 h-28 object-cover rounded-lg border border-jarvis-border"
/>
<button
onClick={clearImage}
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full
bg-jarvis-error text-white flex items-center justify-center
hover:brightness-110 transition-all"
>
<X size={10} />
</button>
</div>
<div className="flex-1 space-y-2">
{/* Question input */}
<input
type="text"
value={question}
onChange={(e) => setQuestion(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && analyze()}
placeholder="Ask about this image… (optional)"
className="w-full input-field text-xs py-2"
/>
<div className="flex gap-2">
<button
onClick={analyze}
disabled={loading}
className="flex items-center gap-1.5 btn-accent text-xs py-1.5 flex-1"
>
{loading
? <Loader2 size={12} className="animate-spin" />
: <Sparkles size={12} />}
{loading ? 'Analyzing…' : 'Analyze'}
</button>
{result && (
<button
onClick={useInChat}
className="btn-ghost text-xs py-1.5 flex items-center gap-1"
>
Use in Chat
</button>
)}
</div>
</div>
</div>
)}
{/* Results */}
<AnimatePresence>
{result && (
<motion.div
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-2"
>
{/* Caption */}
<div className="panel p-3">
<p className="text-[10px] font-mono text-jarvis-dim uppercase tracking-widest mb-1">Caption</p>
<p className="text-xs text-jarvis-text">{result.caption}</p>
</div>
{/* Categories */}
{result.top_categories?.length > 0 && (
<div className="panel p-3">
<p className="text-[10px] font-mono text-jarvis-dim uppercase tracking-widest mb-2">
CLIP Categories
</p>
<div className="space-y-1.5">
{result.top_categories.map((cat) => (
<div key={cat.label} className="flex items-center gap-2">
<div
className="h-1 rounded-full bg-jarvis-accent/30"
style={{ width: '100%' }}
>
<div
className="h-1 rounded-full bg-jarvis-accent transition-all"
style={{ width: `${(cat.score * 100).toFixed(0)}%` }}
/>
</div>
<span className="text-[10px] text-jarvis-muted whitespace-nowrap font-mono">
{(cat.score * 100).toFixed(0)}%
</span>
<span className="text-[10px] text-jarvis-dim whitespace-nowrap truncate">
{cat.label}
</span>
</div>
))}
</div>
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}