| |
| |
| |
| |
|
|
| 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) |
|
|
| |
| const onDrop = useCallback((acceptedFiles) => { |
| const f = acceptedFiles[0] |
| if (!f) return |
| setFile(f) |
| setResult(null) |
| const url = URL.createObjectURL(f) |
| setPreview(url) |
| |
| 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) |
| } |
|
|
| |
| const analyze = async () => { |
| if (!file) return |
| setLoading(true) |
| try { |
| const data = await imageAPI.analyzeImage(file, question || null, activeSessionId) |
| setResult(data) |
| |
| setPendingImage({ file, preview, caption: data.caption }) |
| |
| addImageResult( |
| data.session_id, |
| question || 'Analyze this image', |
| data, |
| ) |
| toast.success('Image analyzed!') |
| } catch (err) { |
| toast.error(err.message || 'Analysis failed') |
| } finally { |
| setLoading(false) |
| } |
| } |
|
|
| |
| 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> |
| ) |
| } |
|
|