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:
2025-12-26 00:02:38 +01:00
parent fb2a8d0f7b
commit 8ccd600dcb
3 changed files with 100 additions and 40 deletions

View File

@@ -138,8 +138,14 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
if (scannerStatus === 'idle') {
// Don't change state on idle
} else if (scannerStatus === 'compressing' || scannerStatus === 'analyzing') {
} else if (scannerStatus === 'compressing') {
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') {
if (scanner.mergedResult) {
setBottleMetadata(scanner.mergedResult);
@@ -400,6 +406,18 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
exit={{ y: -50, opacity: 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 */}
{isOffline && (
<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 && (
<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">
{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">
<Clock size={10} className="text-orange-500" />
<span className="text-zinc-500">COMPRESS:</span>
<span className="text-orange-500 font-bold">{scanner.perf.compression.toFixed(0)}ms</span>
</div>
<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>
</div>
<div className="flex items-center gap-2">

View File

@@ -10,6 +10,7 @@ import { db } from '@/lib/db';
export type ScanStatus =
| 'idle'
| 'compressing'
| 'editor_ready' // NEW: Editor opens immediately with placeholder
| 'analyzing'
| 'complete'
| 'queued'
@@ -21,10 +22,13 @@ export interface ScanResult {
mergedResult: BottleMetadata | null;
processedImage: ProcessedImage | null;
error: string | null;
isAnalyzing: boolean; // NEW: Shows if AI is still working
perf: {
compression: number;
cloudVision: number;
total: number;
model?: string; // NEW: Which AI model was used
provider?: string; // NEW: Which provider (openrouter/gemini)
} | null;
}
@@ -34,14 +38,14 @@ export interface UseScannerOptions {
}
/**
* React hook for Gemini Vision scanning
* React hook for AI Vision scanning
*
* Flow:
* 1. Compress image (browser-side)
* 2. Run cloud vision (Gemini) → accurate results
* 3. Show editor for user refinement
* 2. IMMEDIATELY open editor with placeholder data
* 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 = {}) {
const { locale = 'en', onComplete } = options;
@@ -52,10 +56,10 @@ export function useScanner(options: UseScannerOptions = {}) {
mergedResult: null,
processedImage: null,
error: null,
isAnalyzing: false,
perf: null,
});
// Track which fields the user has manually edited
const dirtyFieldsRef = useRef<Set<string>>(new Set());
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 perfStart = performance.now();
let perfCompression = 0;
let perfCloudVision = 0;
let modelUsed = '';
let providerUsed = '';
clearDirtyFields();
setResult({
@@ -81,6 +87,7 @@ export function useScanner(options: UseScannerOptions = {}) {
mergedResult: null,
processedImage: null,
error: null,
isAnalyzing: false,
perf: null,
});
@@ -90,25 +97,27 @@ export function useScanner(options: UseScannerOptions = {}) {
const processedImage = await processImageForAI(file);
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) {
console.log('[useScanner] Offline → queuing for later');
const dummyMetadata = generateDummyMetadata(file);
await db.pending_scans.add({
temp_id: `temp_${Date.now()}`,
imageBase64: processedImage.base64,
timestamp: Date.now(),
locale,
metadata: dummyMetadata as any,
metadata: placeholder as any,
});
setResult({
status: 'queued',
cloudResult: null,
mergedResult: dummyMetadata,
mergedResult: placeholder,
processedImage,
error: null,
isAnalyzing: false,
perf: {
compression: perfCompression,
cloudVision: 0,
@@ -118,51 +127,77 @@ export function useScanner(options: UseScannerOptions = {}) {
return;
}
// Step 3: Run Gemini Vision
setResult(prev => ({
...prev,
status: 'analyzing',
// Step 4: IMMEDIATELY open editor with placeholder!
setResult({
status: 'editor_ready',
cloudResult: null,
mergedResult: placeholder,
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 cloudResponse = await analyzeLabelWithGemini(processedImage.base64);
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) {
const cloudResult = cloudResponse.data;
onComplete?.(cloudResult);
console.log('[useScanner] Gemini complete:', cloudResult);
console.log(`[useScanner] ${providerUsed} complete:`, cloudResult);
setResult({
// Merge AI results with user edits (preserve dirty fields)
setResult(prev => {
const merged = { ...cloudResult };
// Keep user-edited fields
for (const field of dirtyFieldsRef.current) {
if (prev.mergedResult && field in prev.mergedResult) {
(merged as any)[field] = (prev.mergedResult as any)[field];
}
}
return {
status: 'complete',
cloudResult,
mergedResult: cloudResult,
mergedResult: merged,
processedImage,
error: null,
isAnalyzing: false,
perf: {
compression: perfCompression,
cloudVision: perfCloudVision,
total: performance.now() - perfStart,
model: modelUsed,
provider: providerUsed,
},
};
});
} else {
// Gemini failed - show editor with dummy data
console.warn('[useScanner] Gemini failed:', cloudResponse.error);
const fallback = generateDummyMetadata(file);
// AI failed - keep placeholder, show error
console.warn(`[useScanner] ${providerUsed} failed:`, cloudResponse.error);
setResult({
setResult(prev => ({
...prev,
status: 'complete',
cloudResult: null,
mergedResult: fallback,
processedImage,
error: cloudResponse.error || null,
isAnalyzing: false,
perf: {
compression: perfCompression,
cloudVision: perfCloudVision,
total: performance.now() - perfStart,
model: modelUsed,
provider: providerUsed,
},
});
}));
}
} catch (error: any) {
@@ -171,6 +206,7 @@ export function useScanner(options: UseScannerOptions = {}) {
...prev,
status: 'error',
error: error.message || 'Scan failed',
isAnalyzing: false,
perf: {
compression: perfCompression,
cloudVision: perfCloudVision,
@@ -188,6 +224,7 @@ export function useScanner(options: UseScannerOptions = {}) {
mergedResult: null,
processedImage: null,
error: null,
isAnalyzing: false,
perf: null,
});
}, [clearDirtyFields]);
@@ -203,7 +240,6 @@ export function useScanner(options: UseScannerOptions = {}) {
}));
}, []);
// No cleanup needed without Tesseract
const cleanup = useCallback(() => { }, []);
return {

File diff suppressed because one or more lines are too long