feat: Instant editor opening with background AI analysis
- Editor opens immediately with placeholder data - AI analyzes label in background while user can already edit - Blue 'KI analysiert...' banner shows when AI is still working - User edits preserved when AI results arrive (dirty field tracking) - Model/provider shown in admin perf overlay - Renamed 'CLOUD' to 'AI' in perf display
This commit is contained in:
@@ -138,8 +138,14 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
|||||||
|
|
||||||
if (scannerStatus === 'idle') {
|
if (scannerStatus === 'idle') {
|
||||||
// Don't change state on idle
|
// Don't change state on idle
|
||||||
} else if (scannerStatus === 'compressing' || scannerStatus === 'analyzing') {
|
} else if (scannerStatus === 'compressing') {
|
||||||
setState('SCANNING');
|
setState('SCANNING');
|
||||||
|
} else if (scannerStatus === 'editor_ready' || scannerStatus === 'analyzing') {
|
||||||
|
// NEW: Open editor immediately when image is ready!
|
||||||
|
if (scanner.mergedResult) {
|
||||||
|
setBottleMetadata(scanner.mergedResult);
|
||||||
|
}
|
||||||
|
setState('EDITOR');
|
||||||
} else if (scannerStatus === 'complete' || scannerStatus === 'queued') {
|
} else if (scannerStatus === 'complete' || scannerStatus === 'queued') {
|
||||||
if (scanner.mergedResult) {
|
if (scanner.mergedResult) {
|
||||||
setBottleMetadata(scanner.mergedResult);
|
setBottleMetadata(scanner.mergedResult);
|
||||||
@@ -400,6 +406,18 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
|||||||
exit={{ y: -50, opacity: 0 }}
|
exit={{ y: -50, opacity: 0 }}
|
||||||
className="flex-1 w-full h-full flex flex-col min-h-0"
|
className="flex-1 w-full h-full flex flex-col min-h-0"
|
||||||
>
|
>
|
||||||
|
{/* AI Analyzing Banner - shows when AI is still processing */}
|
||||||
|
{scanner.isAnalyzing && (
|
||||||
|
<div className="bg-blue-500/10 border-b border-blue-500/20 p-3">
|
||||||
|
<div className="max-w-2xl mx-auto flex items-center gap-3">
|
||||||
|
<Loader2 size={14} className="animate-spin text-blue-500" />
|
||||||
|
<p className="text-xs font-bold text-blue-500 uppercase tracking-wider">
|
||||||
|
KI analysiert Etikett...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Status banners */}
|
{/* Status banners */}
|
||||||
{isOffline && (
|
{isOffline && (
|
||||||
<div className="bg-orange-500/10 border-b border-orange-500/20 p-4">
|
<div className="bg-orange-500/10 border-b border-orange-500/20 p-4">
|
||||||
@@ -429,13 +447,19 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
|||||||
{isAdmin && scanner.perf && (
|
{isAdmin && scanner.perf && (
|
||||||
<div className="fixed bottom-24 left-6 right-6 z-50 p-3 bg-zinc-950/80 backdrop-blur-md rounded-2xl border border-orange-500/20 text-[9px] font-mono text-white/90 shadow-xl overflow-x-auto">
|
<div className="fixed bottom-24 left-6 right-6 z-50 p-3 bg-zinc-950/80 backdrop-blur-md rounded-2xl border border-orange-500/20 text-[9px] font-mono text-white/90 shadow-xl overflow-x-auto">
|
||||||
<div className="flex items-center justify-between gap-4 whitespace-nowrap">
|
<div className="flex items-center justify-between gap-4 whitespace-nowrap">
|
||||||
|
{scanner.perf.model && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-zinc-500">MODEL:</span>
|
||||||
|
<span className="text-purple-400 font-bold">{scanner.perf.model}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Clock size={10} className="text-orange-500" />
|
<Clock size={10} className="text-orange-500" />
|
||||||
<span className="text-zinc-500">COMPRESS:</span>
|
<span className="text-zinc-500">COMPRESS:</span>
|
||||||
<span className="text-orange-500 font-bold">{scanner.perf.compression.toFixed(0)}ms</span>
|
<span className="text-orange-500 font-bold">{scanner.perf.compression.toFixed(0)}ms</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-zinc-500">CLOUD:</span>
|
<span className="text-zinc-500">AI:</span>
|
||||||
<span className="text-green-500 font-bold">{scanner.perf.cloudVision.toFixed(0)}ms</span>
|
<span className="text-green-500 font-bold">{scanner.perf.cloudVision.toFixed(0)}ms</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { db } from '@/lib/db';
|
|||||||
export type ScanStatus =
|
export type ScanStatus =
|
||||||
| 'idle'
|
| 'idle'
|
||||||
| 'compressing'
|
| 'compressing'
|
||||||
|
| 'editor_ready' // NEW: Editor opens immediately with placeholder
|
||||||
| 'analyzing'
|
| 'analyzing'
|
||||||
| 'complete'
|
| 'complete'
|
||||||
| 'queued'
|
| 'queued'
|
||||||
@@ -21,10 +22,13 @@ export interface ScanResult {
|
|||||||
mergedResult: BottleMetadata | null;
|
mergedResult: BottleMetadata | null;
|
||||||
processedImage: ProcessedImage | null;
|
processedImage: ProcessedImage | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
isAnalyzing: boolean; // NEW: Shows if AI is still working
|
||||||
perf: {
|
perf: {
|
||||||
compression: number;
|
compression: number;
|
||||||
cloudVision: number;
|
cloudVision: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
model?: string; // NEW: Which AI model was used
|
||||||
|
provider?: string; // NEW: Which provider (openrouter/gemini)
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,14 +38,14 @@ export interface UseScannerOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* React hook for Gemini Vision scanning
|
* React hook for AI Vision scanning
|
||||||
*
|
*
|
||||||
* Flow:
|
* Flow:
|
||||||
* 1. Compress image (browser-side)
|
* 1. Compress image (browser-side)
|
||||||
* 2. Run cloud vision (Gemini) → accurate results
|
* 2. IMMEDIATELY open editor with placeholder data
|
||||||
* 3. Show editor for user refinement
|
* 3. Run AI Vision in background → update fields when ready
|
||||||
*
|
*
|
||||||
* No local OCR - saves ~45MB of Tesseract files
|
* User doesn't wait - editor opens instantly!
|
||||||
*/
|
*/
|
||||||
export function useScanner(options: UseScannerOptions = {}) {
|
export function useScanner(options: UseScannerOptions = {}) {
|
||||||
const { locale = 'en', onComplete } = options;
|
const { locale = 'en', onComplete } = options;
|
||||||
@@ -52,10 +56,10 @@ export function useScanner(options: UseScannerOptions = {}) {
|
|||||||
mergedResult: null,
|
mergedResult: null,
|
||||||
processedImage: null,
|
processedImage: null,
|
||||||
error: null,
|
error: null,
|
||||||
|
isAnalyzing: false,
|
||||||
perf: null,
|
perf: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track which fields the user has manually edited
|
|
||||||
const dirtyFieldsRef = useRef<Set<string>>(new Set());
|
const dirtyFieldsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
const markFieldDirty = useCallback((field: string) => {
|
const markFieldDirty = useCallback((field: string) => {
|
||||||
@@ -67,12 +71,14 @@ export function useScanner(options: UseScannerOptions = {}) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main scan handler
|
* Main scan handler - Opens editor immediately!
|
||||||
*/
|
*/
|
||||||
const handleScan = useCallback(async (file: File) => {
|
const handleScan = useCallback(async (file: File) => {
|
||||||
const perfStart = performance.now();
|
const perfStart = performance.now();
|
||||||
let perfCompression = 0;
|
let perfCompression = 0;
|
||||||
let perfCloudVision = 0;
|
let perfCloudVision = 0;
|
||||||
|
let modelUsed = '';
|
||||||
|
let providerUsed = '';
|
||||||
|
|
||||||
clearDirtyFields();
|
clearDirtyFields();
|
||||||
setResult({
|
setResult({
|
||||||
@@ -81,6 +87,7 @@ export function useScanner(options: UseScannerOptions = {}) {
|
|||||||
mergedResult: null,
|
mergedResult: null,
|
||||||
processedImage: null,
|
processedImage: null,
|
||||||
error: null,
|
error: null,
|
||||||
|
isAnalyzing: false,
|
||||||
perf: null,
|
perf: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,25 +97,27 @@ export function useScanner(options: UseScannerOptions = {}) {
|
|||||||
const processedImage = await processImageForAI(file);
|
const processedImage = await processImageForAI(file);
|
||||||
perfCompression = performance.now() - compressStart;
|
perfCompression = performance.now() - compressStart;
|
||||||
|
|
||||||
// Step 2: Check if offline
|
// Step 2: Generate placeholder data
|
||||||
|
const placeholder = generateDummyMetadata(file);
|
||||||
|
|
||||||
|
// Step 3: Check if offline → queue
|
||||||
if (!navigator.onLine) {
|
if (!navigator.onLine) {
|
||||||
console.log('[useScanner] Offline → queuing for later');
|
console.log('[useScanner] Offline → queuing for later');
|
||||||
const dummyMetadata = generateDummyMetadata(file);
|
|
||||||
|
|
||||||
await db.pending_scans.add({
|
await db.pending_scans.add({
|
||||||
temp_id: `temp_${Date.now()}`,
|
temp_id: `temp_${Date.now()}`,
|
||||||
imageBase64: processedImage.base64,
|
imageBase64: processedImage.base64,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
locale,
|
locale,
|
||||||
metadata: dummyMetadata as any,
|
metadata: placeholder as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
setResult({
|
setResult({
|
||||||
status: 'queued',
|
status: 'queued',
|
||||||
cloudResult: null,
|
cloudResult: null,
|
||||||
mergedResult: dummyMetadata,
|
mergedResult: placeholder,
|
||||||
processedImage,
|
processedImage,
|
||||||
error: null,
|
error: null,
|
||||||
|
isAnalyzing: false,
|
||||||
perf: {
|
perf: {
|
||||||
compression: perfCompression,
|
compression: perfCompression,
|
||||||
cloudVision: 0,
|
cloudVision: 0,
|
||||||
@@ -118,51 +127,77 @@ export function useScanner(options: UseScannerOptions = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Run Gemini Vision
|
// Step 4: IMMEDIATELY open editor with placeholder!
|
||||||
setResult(prev => ({
|
setResult({
|
||||||
...prev,
|
status: 'editor_ready',
|
||||||
status: 'analyzing',
|
cloudResult: null,
|
||||||
|
mergedResult: placeholder,
|
||||||
processedImage,
|
processedImage,
|
||||||
}));
|
error: null,
|
||||||
|
isAnalyzing: true, // Show loading indicator
|
||||||
|
perf: {
|
||||||
|
compression: perfCompression,
|
||||||
|
cloudVision: 0,
|
||||||
|
total: performance.now() - perfStart,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 5: Run AI in background (user is already in editor!)
|
||||||
const cloudStart = performance.now();
|
const cloudStart = performance.now();
|
||||||
const cloudResponse = await analyzeLabelWithGemini(processedImage.base64);
|
const cloudResponse = await analyzeLabelWithGemini(processedImage.base64);
|
||||||
perfCloudVision = performance.now() - cloudStart;
|
perfCloudVision = performance.now() - cloudStart;
|
||||||
|
|
||||||
|
// Store provider/model info
|
||||||
|
providerUsed = cloudResponse.provider || 'unknown';
|
||||||
|
modelUsed = providerUsed === 'openrouter' ? 'gemma-3-27b-it' : 'gemini-2.5-flash';
|
||||||
|
|
||||||
if (cloudResponse.success && cloudResponse.data) {
|
if (cloudResponse.success && cloudResponse.data) {
|
||||||
const cloudResult = cloudResponse.data;
|
const cloudResult = cloudResponse.data;
|
||||||
onComplete?.(cloudResult);
|
onComplete?.(cloudResult);
|
||||||
console.log('[useScanner] Gemini complete:', cloudResult);
|
console.log(`[useScanner] ${providerUsed} complete:`, cloudResult);
|
||||||
|
|
||||||
setResult({
|
// Merge AI results with user edits (preserve dirty fields)
|
||||||
status: 'complete',
|
setResult(prev => {
|
||||||
cloudResult,
|
const merged = { ...cloudResult };
|
||||||
mergedResult: cloudResult,
|
// Keep user-edited fields
|
||||||
processedImage,
|
for (const field of dirtyFieldsRef.current) {
|
||||||
error: null,
|
if (prev.mergedResult && field in prev.mergedResult) {
|
||||||
perf: {
|
(merged as any)[field] = (prev.mergedResult as any)[field];
|
||||||
compression: perfCompression,
|
}
|
||||||
cloudVision: perfCloudVision,
|
}
|
||||||
total: performance.now() - perfStart,
|
return {
|
||||||
},
|
status: 'complete',
|
||||||
|
cloudResult,
|
||||||
|
mergedResult: merged,
|
||||||
|
processedImage,
|
||||||
|
error: null,
|
||||||
|
isAnalyzing: false,
|
||||||
|
perf: {
|
||||||
|
compression: perfCompression,
|
||||||
|
cloudVision: perfCloudVision,
|
||||||
|
total: performance.now() - perfStart,
|
||||||
|
model: modelUsed,
|
||||||
|
provider: providerUsed,
|
||||||
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Gemini failed - show editor with dummy data
|
// AI failed - keep placeholder, show error
|
||||||
console.warn('[useScanner] Gemini failed:', cloudResponse.error);
|
console.warn(`[useScanner] ${providerUsed} failed:`, cloudResponse.error);
|
||||||
const fallback = generateDummyMetadata(file);
|
|
||||||
|
|
||||||
setResult({
|
setResult(prev => ({
|
||||||
|
...prev,
|
||||||
status: 'complete',
|
status: 'complete',
|
||||||
cloudResult: null,
|
|
||||||
mergedResult: fallback,
|
|
||||||
processedImage,
|
|
||||||
error: cloudResponse.error || null,
|
error: cloudResponse.error || null,
|
||||||
|
isAnalyzing: false,
|
||||||
perf: {
|
perf: {
|
||||||
compression: perfCompression,
|
compression: perfCompression,
|
||||||
cloudVision: perfCloudVision,
|
cloudVision: perfCloudVision,
|
||||||
total: performance.now() - perfStart,
|
total: performance.now() - perfStart,
|
||||||
|
model: modelUsed,
|
||||||
|
provider: providerUsed,
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -171,6 +206,7 @@ export function useScanner(options: UseScannerOptions = {}) {
|
|||||||
...prev,
|
...prev,
|
||||||
status: 'error',
|
status: 'error',
|
||||||
error: error.message || 'Scan failed',
|
error: error.message || 'Scan failed',
|
||||||
|
isAnalyzing: false,
|
||||||
perf: {
|
perf: {
|
||||||
compression: perfCompression,
|
compression: perfCompression,
|
||||||
cloudVision: perfCloudVision,
|
cloudVision: perfCloudVision,
|
||||||
@@ -188,6 +224,7 @@ export function useScanner(options: UseScannerOptions = {}) {
|
|||||||
mergedResult: null,
|
mergedResult: null,
|
||||||
processedImage: null,
|
processedImage: null,
|
||||||
error: null,
|
error: null,
|
||||||
|
isAnalyzing: false,
|
||||||
perf: null,
|
perf: null,
|
||||||
});
|
});
|
||||||
}, [clearDirtyFields]);
|
}, [clearDirtyFields]);
|
||||||
@@ -203,7 +240,6 @@ export function useScanner(options: UseScannerOptions = {}) {
|
|||||||
}));
|
}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// No cleanup needed without Tesseract
|
|
||||||
const cleanup = useCallback(() => { }, []);
|
const cleanup = useCallback(() => { }, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user