This commit is contained in:
ct
2025-07-04 12:53:08 +08:00
parent 81c38dd9e3
commit b5ac848ba2
6 changed files with 298 additions and 32 deletions

View File

@@ -3,10 +3,12 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Helpers\FirstParty\Credits\CreditsService; use App\Helpers\FirstParty\Credits\CreditsService;
use App\Helpers\FirstParty\Meme\MemeGenerator; use App\Jobs\GenerateMemeJob;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Models\Category; use App\Models\Category;
use Auth; use Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
class UserAIController extends Controller class UserAIController extends Controller
{ {
@@ -14,7 +16,6 @@ public function generateMeme(Request $request)
{ {
$user = Auth::user(); $user = Auth::user();
if (!CreditsService::canSpend($user->id, 2)) { if (!CreditsService::canSpend($user->id, 2)) {
return response()->json([ return response()->json([
'error' => [ 'error' => [
@@ -25,24 +26,69 @@ public function generateMeme(Request $request)
CreditsService::spend($user->id, 2); CreditsService::spend($user->id, 2);
$meme = MemeGenerator::generateMemeByKeyword($request->prompt); $jobId = Str::uuid()->toString();
$meme_media = MemeGenerator::getSuitableMemeMedia($meme, 2); Cache::put("meme_job_status_{$jobId}", 'pending', 300);
$job = new GenerateMemeJob($user->id, $request->prompt, $jobId);
dispatch($job);
return response()->json([ return response()->json([
'success' => [ 'success' => [
'data' => [ 'data' => [
'generate' => [ 'job_id' => $jobId,
'info' => $meme, 'status' => 'pending',
'caption' => $meme->caption,
'meme' => $meme_media,
'background' => $meme->background_media,
],
], ],
], ],
]); ]);
} }
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() public function aiHints()
{ {
$categories = Category::whereNotNull('keywords') $categories = Category::whereNotNull('keywords')

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Jobs;
use App\Helpers\FirstParty\Meme\MemeGenerator;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
class GenerateMemeJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $timeout = 30;
public $tries = 1;
protected $userId;
protected $prompt;
protected $jobId;
public function __construct(int $userId, string $prompt, string $jobId = null)
{
$this->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;
}
}

View File

@@ -211,6 +211,20 @@
'nice' => 0, 'nice' => 0,
'rest' => 2, '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' => [ 'supervisor-media' => [
'connection' => 'redis', 'connection' => 'redis',
'queue' => ['media'], 'queue' => ['media'],
@@ -256,6 +270,20 @@
'nice' => 0, 'nice' => 0,
'rest' => 2, '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' => [ 'supervisor-media' => [
'connection' => 'redis', 'connection' => 'redis',
'queue' => ['media'], 'queue' => ['media'],

View File

@@ -10,13 +10,17 @@ import CoinIcon from '@/reusables/coin-icon';
import useMediaStore from '@/stores/MediaStore'; import useMediaStore from '@/stores/MediaStore';
import useUserStore from '@/stores/UserStore'; import useUserStore from '@/stores/UserStore';
import { usePage } from '@inertiajs/react'; import { usePage } from '@inertiajs/react';
import { useEffect, useState } from 'react'; import { useEffect, useState, useRef } from 'react';
import { toast } from 'sonner';
const EditorAISheet = () => { const EditorAISheet = () => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [prompt, setPrompt] = useState(''); const [prompt, setPrompt] = useState('');
const emitter = useMitt(); 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 { credits } = useUserStore();
const { auth } = usePage().props; const { auth } = usePage().props;
@@ -36,14 +40,99 @@ const EditorAISheet = () => {
const handleOpenChange = (open) => { const handleOpenChange = (open) => {
setIsOpen(open); setIsOpen(open);
};
const handleSend = () => { // If sheet is being closed while generating, stop polling
if (prompt.trim()) { if (!open && isGeneratingMeme) {
generateMeme(prompt); 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 ( return (
<Sheet open={isOpen} onOpenChange={handleOpenChange}> <Sheet open={isOpen} onOpenChange={handleOpenChange}>
<SheetContent <SheetContent
@@ -116,7 +205,26 @@ const EditorAISheet = () => {
</> </>
) : ( ) : (
<> <>
<div className="text-muted-foreground text-center text-xs">Login / Signup to use AI features.</div> <div className="text-muted-foreground text-center text-xs flex items-center justify-center gap-1">
<Button
onClick={() => emitter.emit('login')}
variant="link"
size="sm"
className="text-primary h-auto p-0 text-xs"
>
Login
</Button>
<span>/</span>
<Button
onClick={() => emitter.emit('join')}
variant="link"
size="sm"
className="text-primary h-auto p-0 text-xs"
>
Signup
</Button>
<span>to use AI features.</span>
</div>
</> </>
)} )}
</div> </div>

View File

@@ -93,28 +93,44 @@ const useMediaStore = create(
generateMeme: async (prompt) => { generateMeme: async (prompt) => {
set({ isGeneratingMeme: true }); set({ isGeneratingMeme: true });
try { 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) { if (response?.data?.success?.data?.job_id) {
set({ return response.data;
currentCaption: response.data.success.data.generate.caption,
selectedMeme: response.data.success.data.generate.meme,
selectedBackground: response.data.success.data.generate.background,
});
} else { } else {
throw 'Invalid API response'; throw 'Invalid API response';
} }
return response.data;
} catch (error) { } catch (error) {
console.error(route('api.app.generate_meme'));
console.error('Error generating meme:', error); console.error('Error generating meme:', error);
toast.error('Failed to generate meme'); toast.error('Failed to generate meme');
} finally {
set({ isGeneratingMeme: false }); 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) // Fetch memes (overlays)
fetchMemes: async () => { fetchMemes: async () => {
set({ isFetchingMemes: true }); set({ isFetchingMemes: true });
@@ -175,6 +191,7 @@ const useMediaStore = create(
isFetchingBackgrounds: false, isFetchingBackgrounds: false,
selectedMeme: null, selectedMeme: null,
selectedBackground: null, selectedBackground: null,
isGeneratingMeme: false,
}); });
}, },
})), })),

View File

@@ -35,7 +35,10 @@
Route::post('/premium-export/complete', [UserExportController::class, 'premiumExportComplete'])->name('api.user.premium_export.complete'); 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');
});
}); });
}); });