useGenerateExercises.ts
1 import { useState, useCallback, useEffect, useRef } from 'react' 2 import { useNavigate } from 'react-router-dom' 3 import { useAccount, useWriteContract, useWaitForTransactionReceipt, useReadContract } from 'wagmi' 4 import { litProtocolService } from '../lib/litProtocol' 5 import { useLitSession } from './useLitSession' 6 import { gunDBService } from '../services/database/gundb' 7 import { KARAOKE_CONTRACT_V5_ADDRESS } from '../constants/contracts' 8 import { KARAOKE_SCHOOL_V5_ABI } from '../contracts/abis/KaraokeSchoolV5' 9 import { usePGLiteSRS } from './usePGLiteSRS' 10 import { useGunDBSearch } from './useGunDBSearch' 11 12 // Lit Action code will be loaded at runtime 13 14 const FREE_GENERATION_LIMIT = 3 15 16 // Helper functions for tracking free generation usage 17 const getFreeGenerationCount = (): number => { 18 const today = new Date().toISOString().split('T')[0] 19 const key = `freeGenerations-${today}` 20 return parseInt(localStorage.getItem(key) || '0', 10) 21 } 22 23 const incrementFreeGenerationCount = (): void => { 24 const today = new Date().toISOString().split('T')[0] 25 const key = `freeGenerations-${today}` 26 const current = getFreeGenerationCount() 27 localStorage.setItem(key, String(current + 1)) 28 } 29 30 export interface GeneratedExercise { 31 referentId: number 32 fragment: string 33 questionType: 'trivia' | 'translation' 34 question: string 35 choices: { 36 A: string 37 B: string 38 C: string 39 D: string 40 } 41 correctAnswer: 'A' | 'B' | 'C' | 'D' 42 explanation: string 43 songId: string 44 language: string 45 generatedAt: string 46 } 47 48 // State machine for generation process 49 type GenerationState = 50 | { status: 'idle' } 51 | { status: 'checking_credits'; songId: string; language: string } 52 | { status: 'submitting_transaction'; songId: string; language: string } 53 | { status: 'waiting_confirmation'; songId: string; language: string; startTime: number } 54 | { status: 'generating_exercises'; songId: string; language: string; generationType: 'free' | 'paid' } 55 | { status: 'saving_results'; songId: string; language: string } 56 | { status: 'success'; exercises: GeneratedExercise[] } 57 | { status: 'error'; error: string } 58 | { status: 'quota_exceeded'; error: string } 59 60 interface UseGenerateExercisesReturn { 61 generateExercises: (songId: string, targetLanguage?: string) => Promise<GeneratedExercise[]> 62 checkExistingExercises: (songId: string, targetLanguage?: string) => Promise<GeneratedExercise[] | null> 63 isGenerating: boolean 64 error: string | null 65 progress: string 66 credits: number 67 canGenerate: boolean 68 isQuotaExceeded: boolean 69 shouldShowFreeButton: boolean 70 freeGenerationsRemaining: number 71 dismissQuotaAlert: () => void 72 navigateToPurchase: () => void 73 } 74 75 // Helper function to get progress message from state 76 function getProgressMessage(state: GenerationState): string { 77 switch (state.status) { 78 case 'checking_credits': return 'Checking credits...' 79 case 'submitting_transaction': return 'Processing payment...' 80 case 'waiting_confirmation': return 'Waiting for transaction confirmation...' 81 case 'generating_exercises': 82 return 'Generating...' 83 case 'saving_results': return 'Saving exercises...' 84 default: return '' 85 } 86 } 87 88 export function useGenerateExercises(): UseGenerateExercisesReturn { 89 const [generationState, setGenerationState] = useState<GenerationState>({ status: 'idle' }) 90 const generationInProgressRef = useRef<string | null>(null) 91 const pendingPromiseRef = useRef<{ 92 resolve: (exercises: GeneratedExercise[]) => void 93 reject: (error: Error) => void 94 } | null>(null) 95 96 const navigate = useNavigate() 97 const { sessionSigs, createSession } = useLitSession() 98 const { address } = useAccount() 99 const { createExerciseCard, isInitialized: isIDBInitialized } = usePGLiteSRS() 100 const { getSongMetadata } = useGunDBSearch() 101 102 // Read user credits 103 const { data: credits = 0n } = useReadContract({ 104 address: KARAOKE_CONTRACT_V5_ADDRESS, 105 abi: KARAOKE_SCHOOL_V5_ABI, 106 functionName: 'credits', 107 args: address ? [address] : undefined, 108 query: { 109 enabled: !!address 110 } 111 }) 112 113 // Write contract for generation payment 114 const { writeContract: startGeneration, data: generationHash, error: writeError, isError: hasWriteError } = useWriteContract() 115 116 // Wait for transaction 117 const { isSuccess: isGenerationTxSuccess } = useWaitForTransactionReceipt({ 118 hash: generationHash 119 }) 120 121 // Execute the actual generation (for both free and paid) 122 const executeGeneration = useCallback(async (songId: string, language: string, generationType: 'free' | 'paid') => { 123 try { 124 console.log('📚 Executing exercise generation...') 125 126 // Fetch user's public IP for analytics 127 let userIp = 'unknown'; 128 try { 129 const ipResponse = await fetch('https://api.ipify.org?format=json'); 130 if (ipResponse.ok) { 131 const ipData = await ipResponse.json(); 132 userIp = ipData.ip; 133 console.log('🌐 User IP for analytics:', userIp); 134 } 135 } catch (ipError) { 136 console.warn('Failed to fetch user IP:', ipError); 137 } 138 139 // Get user agent 140 const userAgent = navigator.userAgent; 141 142 // Get existing exercise IDs from localStorage to avoid duplicates 143 const existingExercisesKey = `exercises-${songId}-${language}` 144 const existingData = localStorage.getItem(existingExercisesKey) 145 const existingQuestionIds = existingData 146 ? JSON.parse(existingData).map((ex: GeneratedExercise) => ex.referentId) 147 : [] 148 149 // Ensure we have a valid session 150 let currentSessionSigs = sessionSigs 151 if (!currentSessionSigs) { 152 console.log('🔐 Creating new Lit session for exercise generation...') 153 currentSessionSigs = await createSession() 154 if (!currentSessionSigs) { 155 throw new Error('Failed to create Lit session') 156 } 157 } 158 159 console.log(`📚 Executing ${generationType} exercise generation Lit Action...`) 160 // Load the appropriate Lit Action code 161 const litActionPath = generationType === 'paid' 162 ? '/lit-actions/exercises/gen-exercises-paid.js' 163 : '/lit-actions/exercises/gen-exercises-free.js' 164 const exerciseGenCode = await fetch(litActionPath).then(r => r.text()) 165 166 const jsParams = generationType === 'paid' ? { 167 songId, 168 language, 169 existingQuestionIds: existingQuestionIds, 170 page: 1, 171 perPage: 50, 172 chainId: 'baseSepolia', 173 contractAddress: KARAOKE_CONTRACT_V5_ADDRESS, 174 userAddress: address, 175 userIp: userIp, 176 userAgent: userAgent 177 } : { 178 songId, 179 language, 180 existingQuestionIds: existingQuestionIds, 181 page: 1, 182 perPage: 50, 183 userAddress: address || undefined, 184 userIp: userIp, 185 userAgent: userAgent 186 } 187 188 const result = await litProtocolService.litNodeClient!.executeJs({ 189 code: exerciseGenCode, 190 sessionSigs: currentSessionSigs, 191 jsParams 192 }) 193 194 console.log('🔍 Lit Action raw result:', result) 195 const response = JSON.parse(result.response as string) 196 197 if (!response.success) { 198 console.error('❌ Lit Action error details:', response) 199 if (response.error === 'QUOTA_EXCEEDED' || response.error?.includes('rate limit')) { 200 throw new Error('QUOTA_EXCEEDED') 201 } 202 throw new Error(response.error || 'Failed to generate exercises') 203 } 204 205 // If free generation succeeded, increment the count 206 if (generationType === 'free') { 207 incrementFreeGenerationCount() 208 } 209 210 console.log(`✅ Generated ${response.questions.length} exercises`) 211 212 // Update state to saving 213 setGenerationState({ status: 'saving_results', songId, language }) 214 215 // Store exercises in localStorage 216 localStorage.setItem(existingExercisesKey, JSON.stringify(response.questions)) 217 console.log('💾 Saved exercises to localStorage') 218 219 // Cache to GunDB for other users 220 await gunDBService.saveExercises(songId, language, response) 221 console.log('🔫 Cached exercises to GunDB') 222 223 // Create SRS cards for all generated exercises 224 const exercises = response.questions as GeneratedExercise[] 225 226 // Get song metadata for SRS cards 227 const songData = await getSongMetadata(parseInt(songId)) 228 const songTitle = songData?.title || '' 229 const artistName = songData?.artist || '' 230 231 // Use the shared helper function to create cards 232 await createCardsForExercises(exercises, parseInt(songId), songTitle, artistName) 233 234 // Success! 235 setGenerationState({ status: 'success', exercises }) 236 generationInProgressRef.current = null 237 238 // Resolve the pending promise 239 if (pendingPromiseRef.current) { 240 pendingPromiseRef.current.resolve(exercises) 241 pendingPromiseRef.current = null 242 } 243 } catch (err) { 244 const errorMessage = err instanceof Error ? err.message : 'Failed to generate exercises' 245 console.error('❌ Exercise generation error:', errorMessage) 246 if (errorMessage === 'QUOTA_EXCEEDED') { 247 setGenerationState({ status: 'quota_exceeded', error: 'Free generation limit reached for today' }) 248 } else { 249 setGenerationState({ status: 'error', error: errorMessage }) 250 } 251 generationInProgressRef.current = null 252 253 // Reject the pending promise 254 if (pendingPromiseRef.current) { 255 pendingPromiseRef.current.reject(new Error(errorMessage)) 256 pendingPromiseRef.current = null 257 } 258 } 259 }, [sessionSigs, createSession, address, createExerciseCard, getSongMetadata, isIDBInitialized]) 260 261 // Handle write errors 262 useEffect(() => { 263 if (hasWriteError && writeError && generationState.status === 'submitting_transaction') { 264 console.error('Write contract error:', writeError) 265 setGenerationState({ status: 'error', error: writeError.message }) 266 generationInProgressRef.current = null 267 268 // Reject the pending promise 269 if (pendingPromiseRef.current) { 270 pendingPromiseRef.current.reject(new Error(writeError.message)) 271 pendingPromiseRef.current = null 272 } 273 } 274 }, [hasWriteError, writeError, generationState.status]) 275 276 // Handle transaction hash availability 277 useEffect(() => { 278 if (generationHash && generationState.status === 'submitting_transaction') { 279 console.log('Transaction hash received:', generationHash) 280 setGenerationState(prev => 281 prev.status === 'submitting_transaction' 282 ? { ...prev, status: 'waiting_confirmation', startTime: Date.now() } 283 : prev 284 ) 285 } 286 }, [generationHash, generationState.status]) 287 288 // Handle transaction confirmation 289 useEffect(() => { 290 if ( 291 isGenerationTxSuccess && 292 generationState.status === 'waiting_confirmation' && 293 generationInProgressRef.current === generationState.songId 294 ) { 295 console.log('✅ Transaction confirmed, proceeding with exercise generation') 296 297 // Immediately transition to prevent re-entry 298 setGenerationState(prev => 299 prev.status === 'waiting_confirmation' 300 ? { status: 'generating_exercises', songId: prev.songId, language: prev.language, generationType: 'paid' } 301 : prev 302 ) 303 304 // Execute the paid generation 305 executeGeneration(generationState.songId, generationState.language, 'paid') 306 } 307 }, [isGenerationTxSuccess, generationState, executeGeneration]) 308 309 // Monitor transaction timeout 310 useEffect(() => { 311 if (generationState.status === 'waiting_confirmation') { 312 const checkTimeout = setInterval(() => { 313 const elapsed = Date.now() - generationState.startTime 314 if (elapsed > 30000) { // 30 second timeout 315 console.error('Transaction timeout') 316 setGenerationState({ status: 'error', error: 'Transaction timeout. Please check your wallet.' }) 317 generationInProgressRef.current = null 318 319 if (pendingPromiseRef.current) { 320 pendingPromiseRef.current.reject(new Error('Transaction timeout')) 321 pendingPromiseRef.current = null 322 } 323 } 324 }, 1000) 325 326 return () => clearInterval(checkTimeout) 327 } 328 }, [generationState]) 329 330 // Main function to start exercise generation 331 const generateExercises = useCallback(async ( 332 songId: string, 333 targetLanguage?: string 334 ): Promise<GeneratedExercise[]> => { 335 // Check if IDB is initialized before starting 336 if (!isIDBInitialized) { 337 console.error('❌ Cannot generate exercises: IDB not initialized') 338 setGenerationState({ status: 'error', error: 'Database not ready. Please refresh the page.' }) 339 return [] 340 } 341 342 // Check if already generating for this song 343 if (generationInProgressRef.current === songId) { 344 console.log('⚠️ Generation already in progress for this song') 345 return [] 346 } 347 348 // Reset state 349 setGenerationState({ status: 'idle' }) 350 351 const language = targetLanguage || 'zh-CN' 352 console.log('🎯 Generating exercises for song:', songId, 'in language:', language) 353 354 // Check if user has sufficient credits for paid generation 355 const hasCredits = Number(credits) >= 2 356 const freeCount = getFreeGenerationCount() 357 const canUseFree = freeCount < FREE_GENERATION_LIMIT 358 359 if (!hasCredits && !canUseFree) { 360 const error = 'Free generation limit reached for today' 361 setGenerationState({ status: 'quota_exceeded', error }) 362 throw new Error('QUOTA_EXCEEDED') 363 } 364 365 const generationType = hasCredits ? 'paid' : 'free' 366 367 // Mark as in progress 368 generationInProgressRef.current = songId 369 370 return new Promise<GeneratedExercise[]>((resolve, reject) => { 371 // Store promise callbacks 372 pendingPromiseRef.current = { resolve, reject } 373 374 // Start the state machine 375 setGenerationState({ status: 'checking_credits', songId, language }) 376 377 // Small delay to ensure state is set 378 setTimeout(() => { 379 if (generationType === 'paid') { 380 setGenerationState({ status: 'submitting_transaction', songId, language }) 381 382 console.log('💳 Paying 2 credits for exercise generation') 383 console.log('Contract address:', KARAOKE_CONTRACT_V5_ADDRESS) 384 console.log('Current credits:', Number(credits)) 385 console.log('Song ID:', songId) 386 387 // Submit transaction 388 startGeneration({ 389 address: KARAOKE_CONTRACT_V5_ADDRESS, 390 abi: KARAOKE_SCHOOL_V5_ABI, 391 functionName: 'startGeneration', 392 args: [songId] 393 }) 394 } else { 395 // For free generation, skip transaction and go directly to generation 396 console.log('🆓 Using free generation') 397 setGenerationState({ 398 status: 'generating_exercises', 399 songId, 400 language, 401 generationType: 'free' 402 }) 403 404 // Execute free generation directly 405 executeGeneration(songId, language, 'free') 406 } 407 }, 100) 408 }) 409 }, [credits, startGeneration, executeGeneration, isIDBInitialized]) 410 411 // Helper function to create SRS cards for exercises 412 const createCardsForExercises = useCallback(async ( 413 exercises: GeneratedExercise[], 414 songId: number, 415 songTitle: string, 416 artistName: string 417 ) => { 418 if (!createExerciseCard || !isIDBInitialized) { 419 console.warn('⚠️ Cannot create cards: IDB not initialized or createExerciseCard not available') 420 return 421 } 422 423 console.log('📝 Creating SRS cards for exercises...') 424 console.log('🔍 Exercise referentIds:', exercises.map(e => e.referentId)) 425 426 // Check for duplicates 427 const referentIdSet = new Set(exercises.map(e => e.referentId)) 428 if (referentIdSet.size !== exercises.length) { 429 console.warn(`⚠️ Found duplicate referentIds! ${exercises.length} exercises but only ${referentIdSet.size} unique IDs`) 430 } 431 432 let createdCount = 0 433 for (const exercise of exercises) { 434 try { 435 await createExerciseCard({ 436 songId: songId, 437 referentId: exercise.referentId, 438 fragment: exercise.fragment, 439 songTitle: songTitle, 440 artistName: artistName 441 }) 442 createdCount++ 443 } catch (error) { 444 console.error(`Failed to create card for exercise ${exercise.referentId}:`, error) 445 } 446 } 447 448 console.log(`✅ Created ${createdCount} SRS cards for ${exercises.length} exercises`) 449 }, [createExerciseCard, isIDBInitialized]) 450 451 // Check if exercises already exist 452 const checkExistingExercises = useCallback(async ( 453 songId: string, 454 targetLanguage?: string 455 ): Promise<GeneratedExercise[] | null> => { 456 // Always use zh-CN for caching since exercises are generated for Chinese speakers 457 const language = targetLanguage || 'zh-CN' 458 459 // First check localStorage 460 const existingExercisesKey = `exercises-${songId}-${language}` 461 const localData = localStorage.getItem(existingExercisesKey) 462 if (localData) { 463 const exercises = JSON.parse(localData) 464 console.log(`📦 Found ${exercises.length} exercises in localStorage for song ${songId} (${language})`) 465 console.log('📝 Exercise types:', exercises.map((ex: GeneratedExercise) => ex.questionType)) 466 return exercises 467 } 468 469 // Then check GunDB 470 console.log(`🔍 Checking GunDB for exercises: song=${songId}, language=${language}`) 471 const gunData = await gunDBService.getExercises(songId, language) 472 if (gunData && gunData.questions) { 473 console.log(`📦 Found ${gunData.questions.length} exercises in GunDB for song ${songId} (${language})`) 474 console.log('📝 Exercise types:', gunData.questions.map((ex: GeneratedExercise) => ex.questionType)) 475 console.log('🕐 Generated at:', new Date(gunData.generatedAt).toLocaleString()) 476 console.log('📊 Total referents available:', gunData.totalReferents) 477 478 // Save to localStorage for faster access 479 localStorage.setItem(existingExercisesKey, JSON.stringify(gunData.questions)) 480 return gunData.questions 481 } 482 483 console.log(`❌ No exercises found in cache for song ${songId} (${language})`) 484 return null 485 }, []) 486 487 // Derive values from state machine 488 const isGenerating = generationState.status !== 'idle' && 489 generationState.status !== 'success' && 490 generationState.status !== 'error' && 491 generationState.status !== 'quota_exceeded' 492 493 const error = (generationState.status === 'error' || generationState.status === 'quota_exceeded') 494 ? generationState.error 495 : null 496 const progress = getProgressMessage(generationState) 497 const isQuotaExceeded = generationState.status === 'quota_exceeded' 498 499 // Calculate free generation state 500 const freeGenerationsRemaining = FREE_GENERATION_LIMIT - getFreeGenerationCount() 501 const shouldShowFreeButton = Number(credits) < 2 && freeGenerationsRemaining > 0 502 503 // Reset error state when needed 504 useEffect(() => { 505 if (generationState.status === 'error') { 506 const timer = setTimeout(() => { 507 setGenerationState({ status: 'idle' }) 508 }, 5000) // Clear error after 5 seconds 509 510 return () => clearTimeout(timer) 511 } 512 }, [generationState.status]) 513 514 const dismissQuotaAlert = useCallback(() => { 515 setGenerationState({ status: 'idle' }) 516 }, []) 517 518 const navigateToPurchase = useCallback(() => { 519 setGenerationState({ status: 'idle' }) 520 navigate('/pricing') 521 }, [navigate]) 522 523 return { 524 generateExercises, 525 checkExistingExercises, 526 isGenerating, 527 error, 528 progress, 529 credits: Number(credits), 530 canGenerate: Number(credits) >= 2 || freeGenerationsRemaining > 0, 531 isQuotaExceeded, 532 shouldShowFreeButton, 533 freeGenerationsRemaining, 534 dismissQuotaAlert, 535 navigateToPurchase 536 } 537 }