feat: add AI generation services and usage tracking

This commit is contained in:
Paulo Castellano 2026-04-15 21:06:06 -03:00
parent 91e0930182
commit 762fa77583
12 changed files with 611 additions and 2 deletions

View file

@ -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),
],

46
app/Models/AiUsageLog.php Normal file
View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Database\Factories\AiUsageLogFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AiUsageLog extends Model
{
/** @use HasFactory<AiUsageLogFactory> */
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();
}
}

View file

@ -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,

View file

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Services\Ai;
use App\Models\AiUsageLog;
use App\Models\Workspace;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class AudioGenerationService
{
private string $apiKey;
private string $baseUrl = 'https://api.elevenlabs.io/v1';
public function __construct()
{
$this->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',
];
}
}

View file

@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Services\Ai;
use App\Models\AiUsageLog;
use App\Models\Workspace;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class ImageGenerationService
{
private string $apiKey;
public function __construct()
{
$this->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.');
}
}

View file

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Services\Ai;
class IntentDetector
{
public function detect(string $prompt): string
{
$lower = mb_strtolower($prompt);
$videoKeywords = ['video', 'clip', 'reel', 'animation', 'animate', 'footage'];
$imageKeywords = ['image', 'photo', 'picture', 'illustration', 'draw', 'design', 'visual', 'graphic'];
$audioKeywords = ['audio', 'voice', 'narrate', 'speak', 'tts', 'voiceover', 'text to speech'];
foreach ($videoKeywords as $keyword) {
if (str_contains($lower, $keyword)) {
return 'video';
}
}
foreach ($imageKeywords as $keyword) {
if (str_contains($lower, $keyword)) {
return 'image';
}
}
foreach ($audioKeywords as $keyword) {
if (str_contains($lower, $keyword)) {
return 'audio';
}
}
return 'text';
}
}

View file

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Services\Ai;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class TextGenerationService
{
private string $apiKey;
private string $baseUrl = 'https://api.openai.com/v1';
public function __construct()
{
$this->apiKey = config('ai.providers.openai.api_key', '');
}
/**
* @param array<int, array{role: string, content: string}> $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', '');
}
}

View file

@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace App\Services\Ai;
use App\Models\AiUsageLog;
use App\Models\Workspace;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class VideoGenerationService
{
private string $apiKey;
public function __construct()
{
$this->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',
];
}
}

View file

@ -122,4 +122,8 @@
'api_key' => env('GIPHY_API_KEY'),
],
'elevenlabs' => [
'api_key' => env('ELEVENLABS_API_KEY'),
],
];

View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Account;
use App\Models\AiUsageLog;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<AiUsageLog> */
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',
]);
}
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('ai_usage_logs', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
use App\Models\Account;
use App\Models\AiUsageLog;
use App\Models\Workspace;
use App\Services\Ai\IntentDetector;
test('monthly count returns correct count for account and type', function () {
$account = Account::factory()->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');
});