GenCN UI

GenUI Human Verification

GenUI-powered human verification component that uses AI to generate verification instructions and verify selfies through Chrome's LanguageModel API.

Usage

Loading preview...

Installation

npx shadcn@latest add https://gencn-ui.encatch.com/r/genui-human-verification.json
'use client';

import * as React from 'react';
import { 
  Dialog, 
  DialogContent, 
  DialogHeader, 
  DialogTitle, 
  DialogDescription 
} from '@/components/ui/dialog';
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { CheckCircle2, XCircle, Loader2, Camera, Info } from 'lucide-react';

// Type guard for browser environment
const isBrowser = typeof window !== 'undefined';

export interface GenUIHumanVerificationProps {
  /**
   * Custom instruction text for the user (if not provided, will be generated using Prompt API)
   * @default undefined (will be generated)
   */
  instruction?: string;
  
  /**
   * Prompt to generate instruction using Prompt API (LanguageModel)
   * @default "Write a very short, concise instruction for human verification through selfie, ask them to do some gesture in selfie, gesture should be static. Keep it brief, maximum 20 words."
   */
  instructionPrompt?: string;
  
  /**
   * Callback when verification succeeds
   */
  onVerified?: (confidence: number) => void;
  
  /**
   * Callback when verification fails
   */
  onVerificationFailed?: () => void;
  
  /**
   * Callback when an error occurs
   */
  onError?: (error: Error) => void;
  
  /**
   * Additional className for the container
   */
  className?: string;
  
  /**
   * Custom button text
   * @default "Verify you are human"
   */
  buttonText?: string;
}

type VerificationState = 
  | 'idle'               // Initial state, button visible
  | 'generating'         // Generating instruction
  | 'prompting'          // Dialog open, "Start Camera" button visible
  | 'starting-camera'    // "Start Camera" clicked, waiting for stream
  | 'countdown'          // Camera open, 5s timer running
  | 'verifying'          // Image captured, analysis in progress
  | 'failed-attempt'     // Verification failed, "Retry" button visible
  | 'success'            // Final success state, dialog closed
  | 'failed-final';      // Final failed state (max attempts), dialog closed

interface VerificationResult {
  success: boolean;
  confidence?: number;
  reason?: string;
}

const MAX_ATTEMPTS = 3;

export const GenUIHumanVerification: React.FC<GenUIHumanVerificationProps> = ({
  instruction: propInstruction,
  instructionPrompt = 'Write a very short, concise instruction for human verification through selfie, ask them to do some gesture in selfie, gesture should be static. Keep it brief, maximum 20 words.',
  onVerified,
  onVerificationFailed,
  onError,
  className,
  buttonText = 'Verify you are human',
}) => {
  const [state, setState] = React.useState<VerificationState>('idle');
  const [isDialogOpen, setIsDialogOpen] = React.useState(false);
  const [statusMessage, setStatusMessage] = React.useState<string | null>(null);
  const [lastFailureReason, setLastFailureReason] = React.useState<string | null>(null);
  const [attemptCount, setAttemptCount] = React.useState(1);
  const [countdownValue, setCountdownValue] = React.useState<number | null>(null);
  
  const [isStreamReady, setIsStreamReady] = React.useState(false);
  const [generatedInstruction, setGeneratedInstruction] = React.useState<string | null>(null);
  const [isGeneratingInstruction, setIsGeneratingInstruction] = React.useState(false);
  
  // Use prop instruction if provided, otherwise use generated instruction
  const instruction = propInstruction || generatedInstruction || 'Take your selfie following the instructions below';
  
  // Keep instruction ref up to date
  React.useEffect(() => {
    instructionRef.current = instruction;
  }, [instruction]);
  
  const videoRef = React.useRef<HTMLVideoElement>(null);
  const canvasRef = React.useRef<HTMLCanvasElement>(null);
  const streamRef = React.useRef<MediaStream | null>(null);
  const sessionRef = React.useRef<any>(null);
  const instructionRef = React.useRef<string>('');
  const countdownTimerRef = React.useRef<NodeJS.Timeout | null>(null);

  // Cleanup function
  const fullCleanup = React.useCallback(() => {
    // Stop camera stream
    if (streamRef.current) {
      streamRef.current.getTracks().forEach(track => track.stop());
      streamRef.current = null;
    }
    setIsStreamReady(false);
    
    // Destroy language model session
    if (sessionRef.current) {
      try {
        const destroyResult = sessionRef.current.destroy();
        if (destroyResult && typeof destroyResult.catch === 'function') {
          destroyResult.catch(() => {});
        }
      } catch (err) {
        // Ignore destroy errors
      }
      sessionRef.current = null;
    }
    
    // Clear any pending timer
    if (countdownTimerRef.current) {
      clearInterval(countdownTimerRef.current);
      countdownTimerRef.current = null;
    }
    setCountdownValue(null);
  }, []);

  // Cleanup on unmount
  React.useEffect(() => {
    return () => {
      fullCleanup();
    };
  }, [fullCleanup]);
  
  // Ensure camera is closed when dialog is closed
  React.useEffect(() => {
    if (!isDialogOpen) {
      fullCleanup();
    }
  }, [isDialogOpen, fullCleanup]);
  
  // Handle dialog open/close
  const handleOpenChange = React.useCallback((open: boolean) => {
    if (!open) {
      fullCleanup();
      setIsDialogOpen(false);
      // Only reset to idle if not in a final success/fail state
      if (state !== 'success' && state !== 'failed-final') {
        setState('idle');
      }
    }
  }, [fullCleanup, state]);
  
  const closeDialogWithDelay = React.useCallback(() => {
     setTimeout(() => {
       setIsDialogOpen(false);
       // The onOpenChange handler will do the full cleanup
     }, 100);
  }, []);

  const startCamera = React.useCallback(async () => {
    if (streamRef.current) {
      // Camera is already running
      startCountdown();
      return;
    }
    
    try {
      setStatusMessage('Starting camera...');
      setState('starting-camera');
      
      if (!isBrowser || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
        throw new Error('Camera access is not available in this browser.');
      }
      
      const stream = await navigator.mediaDevices.getUserMedia({
        video: { 
          facingMode: 'user',
          width: { ideal: 1280 },
          height: { ideal: 720 }
        },
        audio: false,
      });
      
      streamRef.current = stream;
      setIsStreamReady(false);
      
      if (videoRef.current) {
        videoRef.current.srcObject = stream;
        
        await new Promise<void>((resolve, reject) => {
          if (!videoRef.current) return reject(new Error('Video ref lost'));
          const onLoadedMetadata = () => {
            videoRef.current?.removeEventListener('loadedmetadata', onLoadedMetadata);
            resolve();
          };
          videoRef.current.addEventListener('loadedmetadata', onLoadedMetadata);
          setTimeout(() => {
             videoRef.current?.removeEventListener('loadedmetadata', onLoadedMetadata);
             reject(new Error('Camera metadata timeout'));
          }, 3000);
        });
        
        await videoRef.current.play();
        setIsStreamReady(true);
        startCountdown();
      }
    } catch (err) {
      const error = err as Error;
      setStatusMessage(`Failed to access camera: ${error.message}`);
      setState('prompting'); // Go back to "Start Camera"
      setIsStreamReady(false);
      onError?.(error);
    }
  }, [onError]); // Removed startCountdown from deps, it's called internally

  const startCountdown = React.useCallback(() => {
    if (countdownTimerRef.current) {
      clearInterval(countdownTimerRef.current);
    }
    
    setState('countdown');
    setCountdownValue(5);
    setStatusMessage('Capturing in 5s...');

    countdownTimerRef.current = setInterval(() => {
      setCountdownValue(prev => {
        if (prev === null || prev <= 1) {
          clearInterval(countdownTimerRef.current!);
          countdownTimerRef.current = null;
          setStatusMessage('Capturing...');
          captureAndVerify();
          return null;
        }
        setStatusMessage(`Capturing in ${prev - 1}s...`);
        return prev - 1;
      });
    }, 1000);
  }, []); // captureAndVerify is stable as it uses refs/setters

  const captureAndVerify = React.useCallback(async () => {
    if (!videoRef.current || !canvasRef.current) {
      setStatusMessage('Capture failed: Missing video reference.');
      setState('failed-attempt');
      return;
    }

    try {
      setState('verifying');
      setStatusMessage('Verifying...');
      
      const video = videoRef.current;
      const canvas = canvasRef.current;
      
      canvas.width = video.videoWidth;
      canvas.height = video.videoHeight;
      
      const ctx = canvas.getContext('2d');
      if (!ctx) {
        throw new Error('Failed to get canvas context');
      }
      
      ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
      
      // Camera stream STAYS running
      
      await verifyImage(canvas);
    } catch (err) {
      const error = err as Error;
      setStatusMessage(`Capture error: ${error.message}`);
      setLastFailureReason(error.message);
      
      // Use updater function to get latest count
      setAttemptCount(prevAttemptCount => {
        if (prevAttemptCount >= MAX_ATTEMPTS) {
          setState('failed-final');
          closeDialogWithDelay();
          onVerificationFailed?.();
        } else {
          setState('failed-attempt');
        }
        return prevAttemptCount + 1;
      });
      onError?.(error);
    }
  }, [onError, onVerificationFailed, closeDialogWithDelay]); // Removed attemptCount, verifyImage

  const verifyImage = React.useCallback(async (imageCanvas: HTMLCanvasElement) => {
    try {
      if (!isBrowser) throw new Error('LanguageModel API is only available in browser.');

      const globalSelf = (typeof self !== 'undefined' ? self : window) as any;
      if (typeof globalSelf.LanguageModel === 'undefined') {
        throw new Error('Chrome LanguageModel API is not available.');
      }

      const availability = await globalSelf.LanguageModel.availability({
        expectedInputs: [{ type: 'image' }],
      });
      if (availability === 'unavailable') {
        throw new Error('LanguageModel is not available on this device.');
      }

      const session = await globalSelf.LanguageModel.create({
        expectedInputs: [{ type: 'image' }],
      });
      sessionRef.current = session;

      const currentInstruction = instructionRef.current;
      const promptText = `Does this photo show a person following this instruction: "${currentInstruction}"? Analyze the image carefully and respond with a JSON object containing:
- "verified" (true if the person is following the instruction correctly, false otherwise)
- "confidence" (a number between 0 and 1)
- "reason" (if verified is false, provide a brief reason why)`;

      const responseSchema = {
        type: 'object',
        properties: {
          verified: { type: 'boolean' },
          confidence: { type: 'number', minimum: 0, maximum: 1 },
          reason: { type: 'string' },
        },
        required: ['verified', 'confidence'],
      };

      const response = await session.prompt(
        [
          {
            role: 'user',
            content: [
              { type: 'text', value: promptText },
              { type: 'image', value: imageCanvas },
            ],
          },
        ],
        { responseConstraint: responseSchema }
      );

      let result: VerificationResult;
      try {
        const parsed = JSON.parse(response);
        result = {
          success: parsed.verified === true,
          confidence: parsed.confidence,
          reason: parsed.reason,
        };
      } catch (parseError) {
        // **FIX:** Don't re-throw. Handle parse failure as a verification failure.
        console.warn('[AIHumanVerification] Failed to parse JSON, treating as failure.');
        result = {
          success: false,
          confidence: 0,
          reason: 'Failed to parse verification response. Please try again.',
        };
      }
      
      // Cleanup session
      try {
        const destroyResult = session.destroy();
        if (destroyResult && typeof destroyResult.catch === 'function') {
          destroyResult.catch(() => {});
        }
      } catch (err) { /* Ignore */ }
      sessionRef.current = null;
      
      // --- Handle Result ---
      if (result.success) {
        console.log(`[AIHumanVerification] Success, Confidence: ${result.confidence}`);
        setState('success');
        onVerified?.(result.confidence || 0);
        closeDialogWithDelay();
      } else {
        const reason = result.reason || 'Verification failed. Please try again.';
        setLastFailureReason(reason);
        setStatusMessage(reason);
        
        // Use updater function to get latest count
        setAttemptCount(prevAttemptCount => {
          if (prevAttemptCount >= MAX_ATTEMPTS) {
            setState('failed-final');
            onVerificationFailed?.();
            closeDialogWithDelay();
          } else {
            setState('failed-attempt');
          }
          return prevAttemptCount + 1;
        });
      }

    } catch (err) {
      const error = err as Error;
      const reason = `Verification error: ${error.message}`;
      setLastFailureReason(reason);
      setStatusMessage(reason);
      
      // Use updater function to get latest count
      setAttemptCount(prevAttemptCount => {
        if (prevAttemptCount >= MAX_ATTEMPTS) {
          setState('failed-final');
          onVerificationFailed?.();
          closeDialogWithDelay();
        } else {
          setState('failed-attempt');
        }
        return prevAttemptCount + 1;
      });
      onError?.(error);
    }
  }, [onVerified, onVerificationFailed, onError, closeDialogWithDelay]); // Removed attemptCount

  // Generate instruction
  const generateInstruction = React.useCallback(async () => {
    if (propInstruction) return;

    try {
      setIsGeneratingInstruction(true);
      if (!isBrowser) throw new Error('LanguageModel API only available in browser.');
      
      const globalSelf = (typeof self !== 'undefined' ? self : window) as any;
      if (typeof globalSelf.LanguageModel === 'undefined') {
        throw new Error('Chrome LanguageModel API is not available.');
      }
      
      const availability = await globalSelf.LanguageModel.availability();
      if (availability === 'unavailable') {
        throw new Error('LanguageModel is not available on this device.');
      }

      const session = await globalSelf.LanguageModel.create();
      const fullPrompt = `${instructionPrompt}\n\nRespond with only the instruction text. Maximum 15 words.`;
      
      const generated = await session.prompt(fullPrompt);
      
      // **FIX:** Replaced simple cleanup with robust logic
      console.log('[AIHumanVerification] Raw generated instruction:', generated);
          
      // Remove markdown formatting
      let cleaned = generated.trim();
      cleaned = cleaned.replace(/\*\*/g, ''); // Remove bold
      cleaned = cleaned.replace(/^#+\s*/gm, ''); // Remove headings
      cleaned = cleaned.replace(/^\d+\.\s*/gm, ''); // Remove numbered lists
      cleaned = cleaned.replace(/^[-*]\s*/gm, ''); // Remove bullet points
      
      // Remove quotes if present at the start/end
      cleaned = cleaned.replace(/^["']|["']$/g, '');
      
      // Split into sentences
      const sentences = cleaned.split(/([.!?]+)/).filter((s: string) => s.trim().length > 0);
      
      // Reconstruct sentences with their punctuation
      let reconstructed: string[] = [];
      for (let i = 0; i < sentences.length; i += 2) {
        if (sentences[i]) {
          const sentence = sentences[i].trim();
          const punctuation = sentences[i + 1] || '';
          if (sentence.length > 0) {
            reconstructed.push(sentence + punctuation);
          }
        }
      }
      
      // Take up to 20 words total across sentences, but prefer keeping complete sentences
      let result = '';
      let wordCount = 0;
      const maxWords = 20;
      
      for (const sentence of reconstructed) {
        const sentenceWords = sentence.split(/\s+/).length;
        if (wordCount + sentenceWords <= maxWords) {
          result += (result ? ' ' : '') + sentence;
          wordCount += sentenceWords;
        } else {
          // If we can't fit the full sentence, truncate at word boundary
          const remainingWords = maxWords - wordCount;
          if (remainingWords > 0) {
            const words = sentence.split(/\s+/).slice(0, remainingWords);
            result += (result ? ' ' : '') + words.join(' ');
          }
          break;
        }
      }
      
      cleaned = result.trim();
      
      // If we got nothing, fall back to first 20 words of original
      if (!cleaned) {
        const words = generated.trim().split(/\s+/).slice(0, 20);
        cleaned = words.join(' ');
      }
      
      // Ensure it ends with proper punctuation or add period
      if (!cleaned.match(/[.!?]$/)) {
        cleaned = cleaned + '.';
      }
      
      console.log('[AIHumanVerification] Cleaned instruction:', cleaned);
      // **END FIX**

      setGeneratedInstruction(cleaned);
      
      try {
        const destroyResult = session.destroy();
        if (destroyResult && typeof destroyResult.catch === 'function') {
          destroyResult.catch(() => {});
        }
      } catch (err) { /* Ignore */ }
      
    } catch (err) {
      const error = err as Error;
      console.error('[AIHumanVerification] Failed to generate instruction:', error);
      setGeneratedInstruction('take your selfie with your hand covering your mouth.');
      onError?.(error);
    } finally {
      setIsGeneratingInstruction(false);
    }
  }, [propInstruction, instructionPrompt, onError]);

  // Main button click handler
  const handleStart = React.useCallback(async () => {
    setState('generating');
    setLastFailureReason(null);
    setStatusMessage(null);
    
    if (!propInstruction) {
      await generateInstruction();
    }
    
    setAttemptCount(1); // Reset attempt count
    setState('prompting');
    setIsDialogOpen(true);
  }, [propInstruction, generateInstruction]);

  // Retry button click handler
  const handleRetry = React.useCallback(() => {
    // Note: The logic in verifyImage/captureAndVerify already increments the count
    // We just need to start the countdown for the *next* attempt.
    startCountdown();
  }, [startCountdown]);

  // Create hidden canvas on mount
  React.useEffect(() => {
    if (!canvasRef.current) {
      const canvas = document.createElement('canvas');
      canvas.style.display = 'none';
      canvasRef.current = canvas;
    }
  }, []);
  
  // **FIX:** Simplified `useCallback` dependencies
  // By using `setAttemptCount(prev => ...)` inside the fail handlers,
  // we no longer need to pass `attemptCount` as a dependency to
  // `verifyImage` or `captureAndVerify`. This breaks the stale closure chain
  // and makes the callbacks more stable.
  React.useEffect(() => {
    startCountdownRef.current = () => {
      if (countdownTimerRef.current) {
        clearInterval(countdownTimerRef.current);
      }
      
      setState('countdown');
      setCountdownValue(5);
      setStatusMessage('Capturing in 5s...');
  
      countdownTimerRef.current = setInterval(() => {
        setCountdownValue(prev => {
          if (prev === null || prev <= 1) {
            clearInterval(countdownTimerRef.current!);
            countdownTimerRef.current = null;
            setStatusMessage('Capturing...');
            captureAndVerifyRef.current(); // Use ref
            return null;
          }
          setStatusMessage(`Capturing in ${prev - 1}s...`);
          return prev - 1;
        });
      }, 1000);
    };
  }, []); // Empty dep array

  const captureAndVerifyRef = React.useRef(captureAndVerify);
  const startCountdownRef = React.useRef(startCountdown);

  React.useEffect(() => {
    captureAndVerifyRef.current = captureAndVerify;
    startCountdownRef.current = startCountdown;
  });


  // --- Render Status Bar ---
  const renderStatus = () => {
    switch (state) {
      case 'starting-camera':
        return <><Loader2 className="w-4 h-4 mr-2 animate-spin" /> {statusMessage}</>;
      case 'countdown':
        return <><Loader2 className="w-4 h-4 mr-2 animate-spin" /> {statusMessage}</>;
      case 'verifying':
        return <><Loader2 className="w-4 h-4 mr-2 animate-spin" /> {statusMessage}</>;
      case 'failed-attempt':
        return <><XCircle className="w-4 h-4 mr-2 text-destructive" /> {statusMessage}</>;
      case 'prompting':
        return <><Info className="w-4 h-4 mr-2 text-blue-500" /> Ready to start verification.</>;
      default:
        return null;
    }
  };

  // --- Render Buttons ---
  const renderButtons = () => {
    switch (state) {
      case 'prompting':
        return (
          <>
            <Button onClick={() => setIsDialogOpen(false)} variant="outline">Cancel</Button>
            <Button onClick={startCamera} size="lg">
              <Camera className="w-4 h-4 mr-2" />
              Start Camera
            </Button>
          </>
        );
      case 'starting-camera':
        return (
          <>
            <Button onClick={() => setIsDialogOpen(false)} variant="outline">Cancel</Button>
            <Button disabled size="lg">
              <Loader2 className="w-4 h-4 mr-2 animate-spin" />
              Starting...
            </Button>
          </>
        );
      case 'failed-attempt':
        return (
          <>
            <Button onClick={() => setIsDialogOpen(false)} variant="outline">Cancel</Button>
            {/* Displaying the "next" attempt number.
              After attempt 1 fails, attemptCount is set to 2.
              Button shows "Retry Attempt (2/3)". This is correct.
            */}
            <Button onClick={handleRetry} size="lg">
              Retry
            </Button>
          </>
        );
      case 'countdown':
      case 'verifying':
        return (
          <p className="text-sm text-muted-foreground text-center w-full">
            Verification in progress...
          </p>
        );
      default:
        return null;
    }
  };

  return (
    <div className={className} style={{ width: '100%', maxWidth: '500px', margin: '0 auto' }}>
      {/* Hidden canvas for image capture */}
      <canvas ref={canvasRef} style={{ display: 'none' }} />

      {(state === 'idle' || state === 'generating') && (
        <div className="text-center space-y-4">
          <Button
            onClick={handleStart}
            disabled={state === 'generating'}
            size="lg"
            className="min-w-[200px]"
          >
            {state === 'generating' ? (
              <>
                <Loader2 className="w-4 h-4 mr-2 animate-spin" />
                Please wait...
              </>
            ) : (
              buttonText
            )}
          </Button>
        </div>
      )}

      {state === 'success' && (
        <Alert className="border-green-500 bg-green-50 dark:bg-green-950">
          <CheckCircle2 className="w-5 h-5 text-green-600 dark:text-green-400" />
          <AlertTitle className="text-green-800 dark:text-green-200 font-semibold text-lg">
            Verification Successful!
          </AlertTitle>
          <AlertDescription className="text-green-700 dark:text-green-300 mt-2">
            You have been successfully verified as human.
          </AlertDescription>
        </Alert>
      )}

      {state === 'failed-final' && (
        <Alert variant="destructive">
          <XCircle className="w-5 h-5" />
          <AlertTitle className="font-semibold text-lg">Verification Failed</AlertTitle>
          <AlertDescription className="mt-2">
            <p>We could not verify you</p>
            {lastFailureReason && (
              <p className="text-sm mt-2">
                <strong>Reason:</strong> {lastFailureReason}
              </p>
            )}
          </AlertDescription>
        </Alert>
      )}

      <Dialog open={isDialogOpen} onOpenChange={handleOpenChange}>
        <DialogContent className="p-0 gap-0" showCloseButton={true}>
          <DialogHeader className="pl-6 pr-6 pt-6 pb-4 text-left">
            <DialogTitle className="text-left">Human Verification</DialogTitle>
          </DialogHeader>
          
          <div className="space-y-4 pl-6 pr-6 pb-4 text-left">
            {/* Status Bar */}
            <div className="text-sm font-medium h-6 flex items-center text-muted-foreground">
              {renderStatus()}
            </div>
          
            {/* Instruction */}
            <Alert variant="default" className="bg-blue-50 border-blue-200 dark:bg-blue-950 dark:border-blue-800 w-full text-left *:text-left">
              <Info className="w-4 h-4 text-blue-600 dark:text-blue-400" />
              <AlertTitle className="font-semibold text-blue-800 dark:text-blue-200 text-left">
                Follow this instruction:
              </AlertTitle>
              <AlertDescription className="text-blue-700 dark:text-blue-300 text-left *:text-left">
                {instruction}
              </AlertDescription>
            </Alert>
          
            {/* Camera Viewport */}
            <div 
              className="relative w-full bg-black rounded-lg overflow-hidden"
              style={{ 
                aspectRatio: '16/9',
                display: 'flex',
                alignItems: 'center',
                justifyContent: 'center'
              }}
            >
              <video
                ref={videoRef}
                autoPlay
                playsInline
                muted
                className="w-full h-full object-cover block"
                style={{ display: isStreamReady ? 'block' : 'none' }}
              />
              {(!isStreamReady && state !== 'prompting') && (
                <div className="absolute inset-0 flex items-center justify-center">
                  <div className="flex flex-col items-center gap-3 text-white">
                    <Loader2 className="w-8 h-8 animate-spin" />
                    <span className="text-base">Loading camera...</span>
                  </div>
                </div>
              )}
            </div>
          </div>
          
          {/* Footer / Buttons */}
          <div className="flex gap-3 justify-end items-center pl-6 pr-6 pb-6 pt-4 bg-muted/50 border-t">
            {renderButtons()}
          </div>
        </DialogContent>
      </Dialog>
    </div>
  );
};

Props

Prop

Type

Dependencies

  • react
  • genui-provider
  • dialog
  • alert
  • button