diff --git a/app/Http/Controllers/UserAIController.php b/app/Http/Controllers/UserAIController.php index 1e06d6b..1256f8d 100644 --- a/app/Http/Controllers/UserAIController.php +++ b/app/Http/Controllers/UserAIController.php @@ -3,10 +3,12 @@ namespace App\Http\Controllers; use App\Helpers\FirstParty\Credits\CreditsService; -use App\Helpers\FirstParty\Meme\MemeGenerator; +use App\Jobs\GenerateMemeJob; use Illuminate\Http\Request; use App\Models\Category; use Auth; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Str; class UserAIController extends Controller { @@ -14,7 +16,6 @@ public function generateMeme(Request $request) { $user = Auth::user(); - if (!CreditsService::canSpend($user->id, 2)) { return response()->json([ 'error' => [ @@ -25,24 +26,69 @@ public function generateMeme(Request $request) CreditsService::spend($user->id, 2); - $meme = MemeGenerator::generateMemeByKeyword($request->prompt); - - $meme_media = MemeGenerator::getSuitableMemeMedia($meme, 2); + $jobId = Str::uuid()->toString(); + + Cache::put("meme_job_status_{$jobId}", 'pending', 300); + + $job = new GenerateMemeJob($user->id, $request->prompt, $jobId); + dispatch($job); return response()->json([ 'success' => [ 'data' => [ - 'generate' => [ - 'info' => $meme, - 'caption' => $meme->caption, - 'meme' => $meme_media, - 'background' => $meme->background_media, - ], + 'job_id' => $jobId, + 'status' => 'pending', ], ], ]); } + public function checkMemeJobStatus(Request $request) + { + $jobId = $request->job_id; + + if (!$jobId) { + return response()->json([ + 'error' => [ + 'message' => 'Job ID is required.', + ], + ], 400); + } + + $status = Cache::get("meme_job_status_{$jobId}"); + + if (!$status) { + return response()->json([ + 'error' => [ + 'message' => 'Job not found or expired.', + ], + ], 404); + } + + $response = [ + 'success' => [ + 'data' => [ + 'job_id' => $jobId, + 'status' => $status, + ], + ], + ]; + + if ($status === 'completed') { + $result = Cache::get("meme_job_result_{$jobId}"); + if ($result) { + $response['success']['data']['result'] = $result; + } + } elseif ($status === 'failed') { + $error = Cache::get("meme_job_error_{$jobId}"); + if ($error) { + $response['success']['data']['error'] = $error; + } + } + + return response()->json($response); + } + public function aiHints() { $categories = Category::whereNotNull('keywords') diff --git a/app/Jobs/GenerateMemeJob.php b/app/Jobs/GenerateMemeJob.php new file mode 100644 index 0000000..61e0608 --- /dev/null +++ b/app/Jobs/GenerateMemeJob.php @@ -0,0 +1,64 @@ +userId = $userId; + $this->prompt = $prompt; + $this->jobId = $jobId ?: Str::uuid()->toString(); + $this->queue = 'ai'; + } + + public function handle(): void + { + try { + Cache::put("meme_job_status_{$this->jobId}", 'processing', 300); + + $meme = MemeGenerator::generateMemeByKeyword($this->prompt); + $meme_media = MemeGenerator::getSuitableMemeMedia($meme, 2); + + $result = [ + 'generate' => [ + 'info' => $meme, + 'caption' => $meme->caption, + 'meme' => $meme_media, + 'background' => $meme->background_media, + ], + ]; + + Cache::put("meme_job_result_{$this->jobId}", $result, 300); + Cache::put("meme_job_status_{$this->jobId}", 'completed', 300); + } catch (\Exception $e) { + Cache::put("meme_job_status_{$this->jobId}", 'failed', 300); + Cache::put("meme_job_error_{$this->jobId}", $e->getMessage(), 300); + throw $e; + } + } + + public function getJobId(): string + { + return $this->jobId; + } +} \ No newline at end of file diff --git a/config/horizon.php b/config/horizon.php index aeedb39..486f03f 100644 --- a/config/horizon.php +++ b/config/horizon.php @@ -56,7 +56,7 @@ 'prefix' => env( 'HORIZON_PREFIX', - Str::slug(env('APP_NAME', 'laravel'), '_').'_horizon:' + Str::slug(env('APP_NAME', 'laravel'), '_') . '_horizon:' ), /* @@ -211,6 +211,20 @@ 'nice' => 0, 'rest' => 2, ], + 'supervisor-ai' => [ + 'connection' => 'redis', + 'queue' => ['ai'], + 'balance' => 'auto', + 'autoScalingStrategy' => 'time', + 'maxProcesses' => 1, + 'maxTime' => 0, + 'maxJobs' => 0, + 'memory' => 1024, + 'tries' => 1, + 'timeout' => 30, + 'nice' => 0, + 'rest' => 2, + ], 'supervisor-media' => [ 'connection' => 'redis', 'queue' => ['media'], @@ -256,6 +270,20 @@ 'nice' => 0, 'rest' => 2, ], + 'supervisor-ai' => [ + 'connection' => 'redis', + 'queue' => ['ai'], + 'balance' => 'auto', + 'autoScalingStrategy' => 'time', + 'maxProcesses' => 1, + 'maxTime' => 0, + 'maxJobs' => 0, + 'memory' => 1024, + 'tries' => 1, + 'timeout' => 30, + 'nice' => 0, + 'rest' => 2, + ], 'supervisor-media' => [ 'connection' => 'redis', 'queue' => ['media'], diff --git a/resources/js/modules/editor/partials/editor-ai-sheet.jsx b/resources/js/modules/editor/partials/editor-ai-sheet.jsx index b98a27c..d28361b 100644 --- a/resources/js/modules/editor/partials/editor-ai-sheet.jsx +++ b/resources/js/modules/editor/partials/editor-ai-sheet.jsx @@ -10,13 +10,17 @@ import CoinIcon from '@/reusables/coin-icon'; import useMediaStore from '@/stores/MediaStore'; import useUserStore from '@/stores/UserStore'; import { usePage } from '@inertiajs/react'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef } from 'react'; +import { toast } from 'sonner'; const EditorAISheet = () => { const [isOpen, setIsOpen] = useState(false); const [prompt, setPrompt] = useState(''); const emitter = useMitt(); - const { generateMeme, isGeneratingMeme, keywords, isLoadingAIHints, fetchAIHints } = useMediaStore(); + const { generateMeme, isGeneratingMeme, keywords, isLoadingAIHints, fetchAIHints, checkMemeJobStatus, updateMemeResult, setGeneratingMeme } = useMediaStore(); + + const pollingIntervalRef = useRef(null); + const currentJobIdRef = useRef(null); const { credits } = useUserStore(); const { auth } = usePage().props; @@ -36,14 +40,99 @@ const EditorAISheet = () => { const handleOpenChange = (open) => { setIsOpen(open); - }; - - const handleSend = () => { - if (prompt.trim()) { - generateMeme(prompt); + + // If sheet is being closed while generating, stop polling + if (!open && isGeneratingMeme) { + stopPolling(); } }; + const startPolling = (jobId) => { + currentJobIdRef.current = jobId; + + // Clear existing interval if any + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + } + + const checkJobStatus = async () => { + try { + const response = await checkMemeJobStatus(jobId); + + if (response?.success?.data) { + const { status, result, error } = response.success.data; + + if (status === 'completed') { + // Job completed successfully + if (result?.generate) { + updateMemeResult(result); + stopPolling(); + toast.success('Meme generated successfully!'); + } + } else if (status === 'failed') { + // Job failed + stopPolling(); + setGeneratingMeme(false); + toast.error(error || 'Failed to generate meme'); + } + // If status is 'pending' or 'processing', continue polling + } + } catch (error) { + console.error('Error checking job status:', error); + stopPolling(); + setGeneratingMeme(false); + toast.error('Error checking meme generation status'); + } + }; + + // Start polling every 5 seconds + pollingIntervalRef.current = setInterval(checkJobStatus, 5000); + + // Also check immediately + checkJobStatus(); + }; + + const stopPolling = () => { + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + currentJobIdRef.current = null; + }; + + const handleSend = async () => { + if (prompt.trim()) { + try { + const response = await generateMeme(prompt); + if (response?.success?.data?.job_id) { + startPolling(response.success.data.job_id); + } + } catch (error) { + // Error already handled in store + } + } + }; + + // Close sheet when generation is complete + useEffect(() => { + if (!isGeneratingMeme && isOpen && currentJobIdRef.current) { + // Small delay to let user see the success message + const timer = setTimeout(() => { + setIsOpen(false); + setPrompt(''); + }, 1000); + + return () => clearTimeout(timer); + } + }, [isGeneratingMeme, isOpen]); + + // Cleanup polling on unmount + useEffect(() => { + return () => { + stopPolling(); + }; + }, []); + return ( { ) : ( <> -
Login / Signup to use AI features.
+
+ + / + + to use AI features. +
)} diff --git a/resources/js/stores/MediaStore.js b/resources/js/stores/MediaStore.js index 58087d7..a5d8833 100644 --- a/resources/js/stores/MediaStore.js +++ b/resources/js/stores/MediaStore.js @@ -93,28 +93,44 @@ const useMediaStore = create( generateMeme: async (prompt) => { set({ isGeneratingMeme: true }); try { - const response = await axiosInstance.post(route('api.user.generate_meme', { prompt: prompt })); + const response = await axiosInstance.post(route('api.user.generate_meme'), { prompt: prompt }); - if (response?.data?.success?.data?.generate) { - set({ - currentCaption: response.data.success.data.generate.caption, - selectedMeme: response.data.success.data.generate.meme, - selectedBackground: response.data.success.data.generate.background, - }); + if (response?.data?.success?.data?.job_id) { + return response.data; } else { throw 'Invalid API response'; } - - return response.data; } catch (error) { - console.error(route('api.app.generate_meme')); console.error('Error generating meme:', error); toast.error('Failed to generate meme'); - } finally { set({ isGeneratingMeme: false }); + throw error; } }, + checkMemeJobStatus: async (jobId) => { + try { + const response = await axiosInstance.post(route('api.user.check_meme_job_status'), { job_id: jobId }); + return response.data; + } catch (error) { + console.error('Error checking job status:', error); + throw error; + } + }, + + updateMemeResult: (result) => { + set({ + currentCaption: result.generate.caption, + selectedMeme: result.generate.meme, + selectedBackground: result.generate.background, + isGeneratingMeme: false, + }); + }, + + setGeneratingMeme: (isGenerating) => { + set({ isGeneratingMeme: isGenerating }); + }, + // Fetch memes (overlays) fetchMemes: async () => { set({ isFetchingMemes: true }); @@ -175,6 +191,7 @@ const useMediaStore = create( isFetchingBackgrounds: false, selectedMeme: null, selectedBackground: null, + isGeneratingMeme: false, }); }, })), diff --git a/routes/api.php b/routes/api.php index e131369..1b86ef3 100644 --- a/routes/api.php +++ b/routes/api.php @@ -35,7 +35,10 @@ Route::post('/premium-export/complete', [UserExportController::class, 'premiumExportComplete'])->name('api.user.premium_export.complete'); - Route::post('generate_meme', [UserAIController::class, 'generateMeme'])->name('api.user.generate_meme'); + Route::group(['prefix' => 'generate_meme'], function () { + Route::post('/', [UserAIController::class, 'generateMeme'])->name('api.user.generate_meme'); + Route::post('/status', [UserAIController::class, 'checkMemeJobStatus'])->name('api.user.check_meme_job_status'); + }); }); });