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') { 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">

View File

@@ -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