diff --git a/app/Http/Controllers/App/Settings/UsageController.php b/app/Http/Controllers/App/Settings/UsageController.php index e53a8342..474a4af0 100644 --- a/app/Http/Controllers/App/Settings/UsageController.php +++ b/app/Http/Controllers/App/Settings/UsageController.php @@ -11,6 +11,7 @@ use App\Features\SocialAccountLimit; use App\Features\WorkspaceLimit; use App\Http\Controllers\App\Controller; +use App\Models\AiUsageLog; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; @@ -46,9 +47,9 @@ public function index(Request $request): Response|RedirectResponse 'socialAccountLimit' => Feature::for($account)->value(SocialAccountLimit::class), 'memberCount' => $totalMembers, 'memberLimit' => Feature::for($account)->value(MemberLimit::class), - 'aiImagesUsed' => 0, + 'aiImagesUsed' => AiUsageLog::monthlyCount($account->id, 'image'), 'aiImagesLimit' => Feature::for($account)->value(AiImagesLimit::class), - 'aiVideosUsed' => 0, + 'aiVideosUsed' => AiUsageLog::monthlyCount($account->id, 'video'), 'aiVideosLimit' => Feature::for($account)->value(AiVideosLimit::class), 'dataRetentionDays' => Feature::for($account)->value(DataRetentionDays::class), ], diff --git a/app/Models/AiUsageLog.php b/app/Models/AiUsageLog.php new file mode 100644 index 00000000..8707a619 --- /dev/null +++ b/app/Models/AiUsageLog.php @@ -0,0 +1,46 @@ + */ + use HasFactory, HasUuids; + + protected $fillable = [ + 'account_id', + 'workspace_id', + 'user_id', + 'post_id', + 'type', + 'provider', + 'metadata', + ]; + + protected function casts(): array + { + return ['metadata' => 'array']; + } + + public function account(): BelongsTo + { + return $this->belongsTo(Account::class); + } + + public static function monthlyCount(string $accountId, string $type): int + { + return static::where('account_id', $accountId) + ->where('type', $type) + ->whereMonth('created_at', now()->month) + ->whereYear('created_at', now()->year) + ->count(); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 44ce5fc1..804fbbd6 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -7,6 +7,7 @@ use App\Listeners\StripeEventListener; use App\Models\Account; use App\Models\AiMessage; +use App\Models\AiUsageLog; use App\Models\Invite; use App\Models\Media; use App\Models\Notification; @@ -92,6 +93,7 @@ protected function configureMorphMap(): void Relation::enforceMorphMap([ 'account' => Account::class, 'aiMessage' => AiMessage::class, + 'aiUsageLog' => AiUsageLog::class, 'invite' => Invite::class, 'media' => Media::class, 'notification' => Notification::class, diff --git a/app/Services/Ai/AudioGenerationService.php b/app/Services/Ai/AudioGenerationService.php new file mode 100644 index 00000000..74374bd3 --- /dev/null +++ b/app/Services/Ai/AudioGenerationService.php @@ -0,0 +1,85 @@ +apiKey = config('services.elevenlabs.api_key', ''); + } + + /** + * @return array{id: string, path: string, url: string, mime_type: string, type: string} + */ + public function generate(string $text, Workspace $workspace, ?string $userId = null, ?string $postId = null, string $voiceId = 'EXAVITQu4vr4xnSDxMaL'): array + { + $response = Http::timeout(120) + ->withHeaders([ + 'xi-api-key' => $this->apiKey, + 'Content-Type' => 'application/json', + 'Accept' => 'audio/mpeg', + ]) + ->post("{$this->baseUrl}/text-to-speech/{$voiceId}", [ + 'text' => $text, + 'model_id' => 'eleven_multilingual_v2', + 'voice_settings' => [ + 'stability' => 0.5, + 'similarity_boost' => 0.75, + ], + ]); + + if ($response->failed()) { + Log::error('AudioGenerationService failed', ['body' => $response->body()]); + + throw new \RuntimeException('Failed to generate audio. Please try again.'); + } + + $filename = Str::uuid().'.mp3'; + $path = 'medias/'.$filename; + + Storage::put($path, $response->body()); + + $media = $workspace->media()->create([ + 'group_id' => Str::uuid()->toString(), + 'collection' => 'assets', + 'type' => 'document', + 'path' => $path, + 'original_filename' => 'ai-generated.mp3', + 'mime_type' => 'audio/mpeg', + 'size' => strlen($response->body()), + 'order' => 0, + 'meta' => ['ai_generated' => true, 'text' => Str::limit($text, 200)], + ]); + + AiUsageLog::create([ + 'account_id' => $workspace->account_id, + 'workspace_id' => $workspace->id, + 'user_id' => $userId, + 'post_id' => $postId, + 'type' => 'audio', + 'provider' => 'elevenlabs', + ]); + + return [ + 'id' => $media->id, + 'path' => $media->path, + 'url' => $media->url, + 'mime_type' => 'audio/mpeg', + 'type' => 'audio', + ]; + } +} diff --git a/app/Services/Ai/ImageGenerationService.php b/app/Services/Ai/ImageGenerationService.php new file mode 100644 index 00000000..83b459dd --- /dev/null +++ b/app/Services/Ai/ImageGenerationService.php @@ -0,0 +1,91 @@ +apiKey = config('ai.providers.gemini.api_key', ''); + } + + /** + * @return array{id: string, path: string, url: string, mime_type: string, type: string} + */ + public function generate(string $prompt, Workspace $workspace, ?string $userId = null, ?string $postId = null): array + { + $response = Http::timeout(120) + ->withHeaders(['Content-Type' => 'application/json']) + ->post("https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key={$this->apiKey}", [ + 'contents' => [['parts' => [['text' => $prompt]]]], + 'generationConfig' => ['responseModalities' => ['TEXT', 'IMAGE']], + ]); + + if ($response->failed()) { + Log::error('ImageGenerationService failed', ['body' => $response->body()]); + + throw new \RuntimeException('Failed to generate image. Please try again.'); + } + + $parts = data_get($response->json(), 'candidates.0.content.parts', []); + + foreach ($parts as $part) { + if (data_get($part, 'inlineData')) { + $imageData = base64_decode(data_get($part, 'inlineData.data')); + $mimeType = data_get($part, 'inlineData.mimeType', 'image/png'); + $extension = match ($mimeType) { + 'image/jpeg' => 'jpg', + 'image/webp' => 'webp', + default => 'png', + }; + + $filename = Str::uuid().'.'.$extension; + $path = 'medias/'.$filename; + + Storage::put($path, $imageData); + + $media = $workspace->media()->create([ + 'group_id' => Str::uuid()->toString(), + 'collection' => 'assets', + 'type' => 'image', + 'path' => $path, + 'original_filename' => 'ai-generated.'.$extension, + 'mime_type' => $mimeType, + 'size' => strlen($imageData), + 'order' => 0, + 'meta' => ['ai_generated' => true, 'prompt' => Str::limit($prompt, 200)], + ]); + + AiUsageLog::create([ + 'account_id' => $workspace->account_id, + 'workspace_id' => $workspace->id, + 'user_id' => $userId, + 'post_id' => $postId, + 'type' => 'image', + 'provider' => 'gemini', + ]); + + return [ + 'id' => $media->id, + 'path' => $media->path, + 'url' => $media->url, + 'mime_type' => $mimeType, + 'type' => 'image', + ]; + } + } + + throw new \RuntimeException('No image was generated. Try a different prompt.'); + } +} diff --git a/app/Services/Ai/IntentDetector.php b/app/Services/Ai/IntentDetector.php new file mode 100644 index 00000000..ad0c2067 --- /dev/null +++ b/app/Services/Ai/IntentDetector.php @@ -0,0 +1,37 @@ +apiKey = config('ai.providers.openai.api_key', ''); + } + + /** + * @param array $history + */ + public function generate(string $prompt, array $history = []): string + { + $messages = [ + [ + 'role' => 'system', + 'content' => 'You are a social media content expert. Help users write engaging captions, hashtags, and post content. Be creative, concise, and on-brand. Respond in the same language the user writes in.', + ], + ...$history, + ['role' => 'user', 'content' => $prompt], + ]; + + $response = Http::timeout(60) + ->withHeaders([ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ]) + ->post("{$this->baseUrl}/chat/completions", [ + 'model' => 'gpt-4o', + 'messages' => $messages, + 'max_tokens' => 2048, + 'temperature' => 0.7, + ]); + + if ($response->failed()) { + Log::error('TextGenerationService failed', ['body' => $response->body()]); + + throw new \RuntimeException('Failed to generate text. Please try again.'); + } + + return data_get($response->json(), 'choices.0.message.content', ''); + } +} diff --git a/app/Services/Ai/VideoGenerationService.php b/app/Services/Ai/VideoGenerationService.php new file mode 100644 index 00000000..3daa04d3 --- /dev/null +++ b/app/Services/Ai/VideoGenerationService.php @@ -0,0 +1,112 @@ +apiKey = config('ai.providers.gemini.api_key', ''); + } + + /** + * @return array{id: string, path: string, url: string, mime_type: string, type: string} + */ + public function generate(string $prompt, Workspace $workspace, ?string $userId = null, ?string $postId = null): array + { + $response = Http::timeout(30) + ->withHeaders(['Content-Type' => 'application/json']) + ->post("https://generativelanguage.googleapis.com/v1beta/models/veo-3.0-generate-preview:predictLongRunning?key={$this->apiKey}", [ + 'instances' => [['prompt' => $prompt]], + 'parameters' => [ + 'aspectRatio' => '9:16', + 'sampleCount' => 1, + 'durationSeconds' => 8, + 'generateAudio' => true, + ], + ]); + + if ($response->failed()) { + Log::error('VideoGenerationService start failed', ['body' => $response->body()]); + + throw new \RuntimeException('Failed to start video generation. Please try again.'); + } + + $operationName = data_get($response->json(), 'name'); + + if (! $operationName) { + throw new \RuntimeException('No operation returned from video generation API.'); + } + + $maxAttempts = 30; + $videoData = null; + + for ($i = 0; $i < $maxAttempts; $i++) { + sleep(10); + + $statusResponse = Http::timeout(30) + ->get("https://generativelanguage.googleapis.com/v1beta/{$operationName}?key={$this->apiKey}"); + + if ($statusResponse->failed()) { + continue; + } + + $status = $statusResponse->json(); + + if (data_get($status, 'done')) { + $videoData = data_get($status, 'response.predictions.0.bytesBase64Encoded'); + break; + } + } + + if (! $videoData) { + throw new \RuntimeException('Video generation timed out. Please try again.'); + } + + $decoded = base64_decode($videoData); + $filename = Str::uuid().'.mp4'; + $path = 'medias/'.$filename; + + Storage::put($path, $decoded); + + $media = $workspace->media()->create([ + 'group_id' => Str::uuid()->toString(), + 'collection' => 'assets', + 'type' => 'video', + 'path' => $path, + 'original_filename' => 'ai-generated.mp4', + 'mime_type' => 'video/mp4', + 'size' => strlen($decoded), + 'order' => 0, + 'meta' => ['ai_generated' => true, 'prompt' => Str::limit($prompt, 200)], + ]); + + AiUsageLog::create([ + 'account_id' => $workspace->account_id, + 'workspace_id' => $workspace->id, + 'user_id' => $userId, + 'post_id' => $postId, + 'type' => 'video', + 'provider' => 'veo', + ]); + + return [ + 'id' => $media->id, + 'path' => $media->path, + 'url' => $media->url, + 'mime_type' => 'video/mp4', + 'type' => 'video', + ]; + } +} diff --git a/config/services.php b/config/services.php index 3baddf3a..b14cbd77 100644 --- a/config/services.php +++ b/config/services.php @@ -122,4 +122,8 @@ 'api_key' => env('GIPHY_API_KEY'), ], + 'elevenlabs' => [ + 'api_key' => env('ELEVENLABS_API_KEY'), + ], + ]; diff --git a/database/factories/AiUsageLogFactory.php b/database/factories/AiUsageLogFactory.php new file mode 100644 index 00000000..fead0e65 --- /dev/null +++ b/database/factories/AiUsageLogFactory.php @@ -0,0 +1,48 @@ + */ +class AiUsageLogFactory extends Factory +{ + public function definition(): array + { + return [ + 'account_id' => Account::factory(), + 'workspace_id' => Workspace::factory(), + 'type' => fake()->randomElement(['image', 'video', 'audio']), + 'provider' => fake()->randomElement(['gemini', 'veo', 'elevenlabs']), + ]; + } + + public function image(): static + { + return $this->state(fn () => [ + 'type' => 'image', + 'provider' => 'gemini', + ]); + } + + public function video(): static + { + return $this->state(fn () => [ + 'type' => 'video', + 'provider' => 'veo', + ]); + } + + public function audio(): static + { + return $this->state(fn () => [ + 'type' => 'audio', + 'provider' => 'elevenlabs', + ]); + } +} diff --git a/database/migrations/2026_04_15_000003_create_ai_usage_logs_table.php b/database/migrations/2026_04_15_000003_create_ai_usage_logs_table.php new file mode 100644 index 00000000..ef68821f --- /dev/null +++ b/database/migrations/2026_04_15_000003_create_ai_usage_logs_table.php @@ -0,0 +1,33 @@ +uuid('id')->primary(); + $table->uuid('account_id'); + $table->uuid('workspace_id'); + $table->uuid('user_id')->nullable(); + $table->uuid('post_id')->nullable(); + $table->string('type'); // image, video, audio + $table->string('provider')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->foreign('account_id')->references('id')->on('accounts')->cascadeOnDelete(); + $table->index(['account_id', 'type', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('ai_usage_logs'); + } +}; diff --git a/tests/Feature/AiUsageLogTest.php b/tests/Feature/AiUsageLogTest.php new file mode 100644 index 00000000..84e868c5 --- /dev/null +++ b/tests/Feature/AiUsageLogTest.php @@ -0,0 +1,95 @@ +create(); + $workspace = Workspace::factory()->create(['account_id' => $account->id]); + + AiUsageLog::factory()->image()->count(3)->create([ + 'account_id' => $account->id, + 'workspace_id' => $workspace->id, + ]); + + AiUsageLog::factory()->video()->count(2)->create([ + 'account_id' => $account->id, + 'workspace_id' => $workspace->id, + ]); + + expect(AiUsageLog::monthlyCount($account->id, 'image'))->toBe(3); + expect(AiUsageLog::monthlyCount($account->id, 'video'))->toBe(2); + expect(AiUsageLog::monthlyCount($account->id, 'audio'))->toBe(0); +}); + +test('monthly count excludes logs from other months', function () { + $account = Account::factory()->create(); + $workspace = Workspace::factory()->create(['account_id' => $account->id]); + + AiUsageLog::factory()->image()->create([ + 'account_id' => $account->id, + 'workspace_id' => $workspace->id, + ]); + + AiUsageLog::factory()->image()->create([ + 'account_id' => $account->id, + 'workspace_id' => $workspace->id, + 'created_at' => now()->subMonth(), + ]); + + expect(AiUsageLog::monthlyCount($account->id, 'image'))->toBe(1); +}); + +test('monthly count excludes logs from other accounts', function () { + $account = Account::factory()->create(); + $otherAccount = Account::factory()->create(); + $workspace = Workspace::factory()->create(['account_id' => $account->id]); + $otherWorkspace = Workspace::factory()->create(['account_id' => $otherAccount->id]); + + AiUsageLog::factory()->image()->create([ + 'account_id' => $account->id, + 'workspace_id' => $workspace->id, + ]); + + AiUsageLog::factory()->image()->create([ + 'account_id' => $otherAccount->id, + 'workspace_id' => $otherWorkspace->id, + ]); + + expect(AiUsageLog::monthlyCount($account->id, 'image'))->toBe(1); +}); + +test('intent detector detects video intent', function () { + $detector = new IntentDetector; + + expect($detector->detect('Create a video for my product'))->toBe('video'); + expect($detector->detect('Make a reel about coffee'))->toBe('video'); + expect($detector->detect('Animate this logo'))->toBe('video'); +}); + +test('intent detector detects image intent', function () { + $detector = new IntentDetector; + + expect($detector->detect('Generate an image of a sunset'))->toBe('image'); + expect($detector->detect('Draw me a logo'))->toBe('image'); + expect($detector->detect('Create a visual for my post'))->toBe('image'); +}); + +test('intent detector detects audio intent', function () { + $detector = new IntentDetector; + + expect($detector->detect('Create a voiceover for this text'))->toBe('audio'); + expect($detector->detect('Convert this to audio narration'))->toBe('audio'); + expect($detector->detect('Generate TTS for my caption'))->toBe('audio'); +}); + +test('intent detector defaults to text', function () { + $detector = new IntentDetector; + + expect($detector->detect('Write a caption for my post'))->toBe('text'); + expect($detector->detect('Help me with hashtags'))->toBe('text'); +});