From 7acc07976a540ea403837f50250839e494073980 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 11 May 2026 21:08:43 -0300 Subject: [PATCH 1/4] chore: remove unused PlatformRules registry and rule classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole app/Ai/PlatformRules/ system was registered in AppServiceProvider but never read in production code. Confirmed via grep across app/, resources/, config/, database/, tests/ — only consumers were: - AppServiceProvider::configurePlatformRules() registering everything in the Registry (which nothing then called) - tests/Feature/Ai/PlatformRules/RegistryTest.php asserting the registrations it had just performed The actual AI text agent (PostContentGenerator) reads the per-platform caps straight from the Platform enum (maxContentLength / recommendedAiContentLength) and feeds them into the prompt template — bypassing this layer entirely. Removed: - app/Ai/PlatformRules/ (12 files: Contract, Registry, 10 Rule classes) - tests/Feature/Ai/PlatformRules/RegistryTest.php - 11 use imports + boot() call + configurePlatformRules() method in AppServiceProvider --- app/Ai/PlatformRules/BlueskyRules.php | 30 --------- app/Ai/PlatformRules/Contract.php | 19 ------ app/Ai/PlatformRules/FacebookRules.php | 39 ------------ app/Ai/PlatformRules/InstagramRules.php | 42 ------------- app/Ai/PlatformRules/LinkedInRules.php | 39 ------------ app/Ai/PlatformRules/MastodonRules.php | 30 --------- app/Ai/PlatformRules/PinterestRules.php | 40 ------------ app/Ai/PlatformRules/Registry.php | 53 ---------------- app/Ai/PlatformRules/ThreadsRules.php | 30 --------- app/Ai/PlatformRules/TikTokRules.php | 31 ---------- app/Ai/PlatformRules/XRules.php | 31 ---------- app/Ai/PlatformRules/YouTubeRules.php | 33 ---------- app/Providers/AppServiceProvider.php | 38 ------------ .../Feature/Ai/PlatformRules/RegistryTest.php | 61 ------------------- 14 files changed, 516 deletions(-) delete mode 100644 app/Ai/PlatformRules/BlueskyRules.php delete mode 100644 app/Ai/PlatformRules/Contract.php delete mode 100644 app/Ai/PlatformRules/FacebookRules.php delete mode 100644 app/Ai/PlatformRules/InstagramRules.php delete mode 100644 app/Ai/PlatformRules/LinkedInRules.php delete mode 100644 app/Ai/PlatformRules/MastodonRules.php delete mode 100644 app/Ai/PlatformRules/PinterestRules.php delete mode 100644 app/Ai/PlatformRules/Registry.php delete mode 100644 app/Ai/PlatformRules/ThreadsRules.php delete mode 100644 app/Ai/PlatformRules/TikTokRules.php delete mode 100644 app/Ai/PlatformRules/XRules.php delete mode 100644 app/Ai/PlatformRules/YouTubeRules.php delete mode 100644 tests/Feature/Ai/PlatformRules/RegistryTest.php diff --git a/app/Ai/PlatformRules/BlueskyRules.php b/app/Ai/PlatformRules/BlueskyRules.php deleted file mode 100644 index 22f5df4a..00000000 --- a/app/Ai/PlatformRules/BlueskyRules.php +++ /dev/null @@ -1,30 +0,0 @@ - 300, - 'max_images' => 4, - 'max_videos' => 1, - 'text_only_allowed' => true, - ]; - } - - public function summary(): string - { - return 'Bluesky: 300 chars max. Up to 4 images or 1 video. Similar to X but more concise.'; - } -} diff --git a/app/Ai/PlatformRules/Contract.php b/app/Ai/PlatformRules/Contract.php deleted file mode 100644 index 119047d1..00000000 --- a/app/Ai/PlatformRules/Contract.php +++ /dev/null @@ -1,19 +0,0 @@ - - */ - public function specs(): array; - - public function summary(): string; -} diff --git a/app/Ai/PlatformRules/FacebookRules.php b/app/Ai/PlatformRules/FacebookRules.php deleted file mode 100644 index 8e6bf1d0..00000000 --- a/app/Ai/PlatformRules/FacebookRules.php +++ /dev/null @@ -1,39 +0,0 @@ - 10000, - 'max_images' => 10, - 'text_only_allowed' => true, - 'formats' => [ - 'post' => ['max_media' => 10, 'text_only_allowed' => true], - 'reel' => ['aspect_ratio' => '9:16', 'video_only' => true, 'max_duration_seconds' => 90], - 'story' => ['aspect_ratio' => '9:16', 'ephemeral' => true], - ], - ]; - } - - public function summary(): string - { - return <<<'TXT' -Facebook: caption up to 10k chars but short posts perform better. Text-only allowed. -- Post: flexible aspect ratio, up to 10 images/videos. -- Reel: vertical 9:16 video only, up to 90s. -- Story: vertical 9:16, single image/video, ephemeral. -TXT; - } -} diff --git a/app/Ai/PlatformRules/InstagramRules.php b/app/Ai/PlatformRules/InstagramRules.php deleted file mode 100644 index dc844352..00000000 --- a/app/Ai/PlatformRules/InstagramRules.php +++ /dev/null @@ -1,42 +0,0 @@ - 2200, - 'max_images' => 10, - 'max_videos' => 10, - 'text_only_allowed' => false, - 'formats' => [ - 'feed' => ['aspect_ratio' => '1:1 or 4:5', 'media_limit' => 10], - 'carousel' => ['aspect_ratio' => '1:1 or 4:5', 'media_limit' => 10], - 'reel' => ['aspect_ratio' => '9:16', 'video_only' => true, 'max_duration_seconds' => 90], - 'story' => ['aspect_ratio' => '9:16', 'media_limit' => 1, 'ephemeral' => true], - ], - ]; - } - - public function summary(): string - { - return <<<'TXT' -Instagram: caption max 2200 chars. -- Feed: square (1:1) or portrait (4:5). Up to 10 images/videos. -- Carousel: 2-10 slides, all same aspect ratio. First slide is the hook. -- Reel: vertical 9:16 video only, up to 90s (30-60s performs best). Hook in first 1-3s. -- Story: vertical 9:16, single image/video, ephemeral (24h). -TXT; - } -} diff --git a/app/Ai/PlatformRules/LinkedInRules.php b/app/Ai/PlatformRules/LinkedInRules.php deleted file mode 100644 index 4c0c7f35..00000000 --- a/app/Ai/PlatformRules/LinkedInRules.php +++ /dev/null @@ -1,39 +0,0 @@ - 3000, - 'max_images' => 1, - 'max_videos' => 1, - 'text_only_allowed' => true, - 'formats' => [ - 'post' => ['max_media' => 1], - 'carousel' => ['max_slides' => 20, 'optimal_slides' => '8-12', 'images_only' => true, 'document_based' => true], - ], - ]; - } - - public function summary(): string - { - return <<<'TXT' -LinkedIn: 3000 chars max. First 2-3 lines visible before "see more" — make them compelling. -Professional tone. Educational/how-to content performs well. -- Post: 1 image or 1 video. -- Carousel: PDF-based document, up to 20 slides (8-12 optimal), images only. -TXT; - } -} diff --git a/app/Ai/PlatformRules/MastodonRules.php b/app/Ai/PlatformRules/MastodonRules.php deleted file mode 100644 index 99dd770d..00000000 --- a/app/Ai/PlatformRules/MastodonRules.php +++ /dev/null @@ -1,30 +0,0 @@ - 500, - 'max_images' => 4, - 'max_videos' => 1, - 'text_only_allowed' => true, - ]; - } - - public function summary(): string - { - return 'Mastodon: 500 chars max. Up to 4 images or 1 video. Community-focused, avoid commercial tone.'; - } -} diff --git a/app/Ai/PlatformRules/PinterestRules.php b/app/Ai/PlatformRules/PinterestRules.php deleted file mode 100644 index 11e60f43..00000000 --- a/app/Ai/PlatformRules/PinterestRules.php +++ /dev/null @@ -1,40 +0,0 @@ - 800, - 'max_images' => 5, - 'max_videos' => 1, - 'text_only_allowed' => false, - 'formats' => [ - 'pin' => ['aspect_ratio' => '2:3', 'media_limit' => 1, 'images_only' => true], - 'video_pin' => ['aspect_ratio' => '9:16 or 2:3', 'video_only' => true, 'min_duration_seconds' => 4, 'max_duration_seconds' => 900], - 'carousel' => ['aspect_ratio' => '2:3', 'max_slides' => 5, 'images_only' => true], - ], - ]; - } - - public function summary(): string - { - return <<<'TXT' -Pinterest: title max 100 chars, description max 500 chars. Text overlay on images helps discovery. -- Pin: portrait 2:3 (1000x1500px), single image. -- Video Pin: vertical 9:16 or portrait 2:3, 4s to 15 minutes. -- Carousel: portrait 2:3, up to 5 images. -TXT; - } -} diff --git a/app/Ai/PlatformRules/Registry.php b/app/Ai/PlatformRules/Registry.php deleted file mode 100644 index 11eac343..00000000 --- a/app/Ai/PlatformRules/Registry.php +++ /dev/null @@ -1,53 +0,0 @@ -> - */ - private static array $map = []; - - public static function for(Platform $platform): ?Contract - { - $class = self::$map[$platform->value] ?? null; - - return $class ? new $class : null; - } - - /** - * @param array $platforms - * @return array - */ - public static function forMany(array $platforms): array - { - $rules = []; - - foreach ($platforms as $platform) { - $rule = self::for($platform); - if ($rule) { - $rules[] = $rule; - } - } - - return $rules; - } - - /** - * @param class-string $ruleClass - */ - public static function register(Platform $platform, string $ruleClass): void - { - self::$map[$platform->value] = $ruleClass; - } - - public static function clear(): void - { - self::$map = []; - } -} diff --git a/app/Ai/PlatformRules/ThreadsRules.php b/app/Ai/PlatformRules/ThreadsRules.php deleted file mode 100644 index 9743a473..00000000 --- a/app/Ai/PlatformRules/ThreadsRules.php +++ /dev/null @@ -1,30 +0,0 @@ - 500, - 'max_images' => 10, - 'max_videos' => 10, - 'text_only_allowed' => true, - ]; - } - - public function summary(): string - { - return 'Threads: 500 chars max. Up to 10 images/videos. Conversational tone. Ask questions to drive replies.'; - } -} diff --git a/app/Ai/PlatformRules/TikTokRules.php b/app/Ai/PlatformRules/TikTokRules.php deleted file mode 100644 index f694171b..00000000 --- a/app/Ai/PlatformRules/TikTokRules.php +++ /dev/null @@ -1,31 +0,0 @@ - 2200, - 'max_videos' => 1, - 'text_only_allowed' => false, - 'aspect_ratio' => '9:16', - 'video_only' => true, - ]; - } - - public function summary(): string - { - return 'TikTok: vertical 9:16 video only. 15-60s performs best. Hook in first 1-2s. Trendy, authentic style. Caption max 2200 chars.'; - } -} diff --git a/app/Ai/PlatformRules/XRules.php b/app/Ai/PlatformRules/XRules.php deleted file mode 100644 index 380d5fee..00000000 --- a/app/Ai/PlatformRules/XRules.php +++ /dev/null @@ -1,31 +0,0 @@ - 280, - 'max_images' => 4, - 'max_videos' => 1, - 'text_only_allowed' => true, - 'best_aspect_ratio' => '16:9', - ]; - } - - public function summary(): string - { - return 'X (Twitter): 280 chars max. Up to 4 images OR 1 video. 16:9 landscape performs best. Concise, punchy copy.'; - } -} diff --git a/app/Ai/PlatformRules/YouTubeRules.php b/app/Ai/PlatformRules/YouTubeRules.php deleted file mode 100644 index 17d0ec34..00000000 --- a/app/Ai/PlatformRules/YouTubeRules.php +++ /dev/null @@ -1,33 +0,0 @@ - 5000, - 'max_videos' => 1, - 'text_only_allowed' => false, - 'aspect_ratio' => '9:16', - 'video_only' => true, - 'max_duration_seconds' => 60, - 'requires_title' => true, - ]; - } - - public function summary(): string - { - return 'YouTube Shorts: vertical 9:16 video only, up to 60s. Hook immediately. Loop-friendly content helps. Description max 5000 chars. Title required.'; - } -} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index dbe4bcea..88b3c30b 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,19 +4,7 @@ namespace App\Providers; -use App\Ai\PlatformRules\BlueskyRules; -use App\Ai\PlatformRules\FacebookRules; -use App\Ai\PlatformRules\InstagramRules; -use App\Ai\PlatformRules\LinkedInRules; -use App\Ai\PlatformRules\MastodonRules; -use App\Ai\PlatformRules\PinterestRules; -use App\Ai\PlatformRules\Registry; -use App\Ai\PlatformRules\ThreadsRules; -use App\Ai\PlatformRules\TikTokRules; -use App\Ai\PlatformRules\XRules; -use App\Ai\PlatformRules\YouTubeRules; use App\Ai\Providers\ExtendedGeminiProvider; -use App\Enums\SocialAccount\Platform; use App\Listeners\StripeEventListener; use App\Models\AccessToken; use App\Models\Account; @@ -97,7 +85,6 @@ public function boot(): void $this->configureDefaults(); $this->configureMorphMap(); $this->configureAi(); - $this->configurePlatformRules(); $this->configurePostHog(); $this->configureRateLimiting(); $this->configureSocialite(); @@ -136,31 +123,6 @@ protected function configureAi(): void }); } - protected function configurePlatformRules(): void - { - $map = [ - Platform::Instagram->value => InstagramRules::class, - Platform::InstagramFacebook->value => InstagramRules::class, - Platform::Facebook->value => FacebookRules::class, - Platform::X->value => XRules::class, - Platform::TikTok->value => TikTokRules::class, - Platform::YouTube->value => YouTubeRules::class, - Platform::LinkedIn->value => LinkedInRules::class, - Platform::LinkedInPage->value => LinkedInRules::class, - Platform::Threads->value => ThreadsRules::class, - Platform::Pinterest->value => PinterestRules::class, - Platform::Bluesky->value => BlueskyRules::class, - Platform::Mastodon->value => MastodonRules::class, - ]; - - foreach ($map as $value => $class) { - Registry::register( - Platform::from($value), - $class, - ); - } - } - protected function configureMorphMap(): void { Relation::enforceMorphMap([ diff --git a/tests/Feature/Ai/PlatformRules/RegistryTest.php b/tests/Feature/Ai/PlatformRules/RegistryTest.php deleted file mode 100644 index 14cc74bb..00000000 --- a/tests/Feature/Ai/PlatformRules/RegistryTest.php +++ /dev/null @@ -1,61 +0,0 @@ -toBeInstanceOf(Contract::class) - ->and(strlen($rules->summary()))->toBeGreaterThan(30) - ->and($rules->specs())->toBeArray(); -})->with([ - [Platform::Instagram], - [Platform::InstagramFacebook], - [Platform::Facebook], - [Platform::X], - [Platform::TikTok], - [Platform::YouTube], - [Platform::LinkedIn], - [Platform::LinkedInPage], - [Platform::Threads], - [Platform::Pinterest], - [Platform::Bluesky], - [Platform::Mastodon], -]); - -test('forMany returns rules for the given platforms in order', function () { - $rules = Registry::forMany([Platform::X, Platform::Instagram, Platform::TikTok]); - - expect($rules)->toHaveCount(3); - expect($rules[0]->platform())->toBe(Platform::X); - expect($rules[1]->platform())->toBe(Platform::Instagram); - expect($rules[2]->platform())->toBe(Platform::TikTok); -}); - -test('forMany skips platforms with no registered rules', function () { - Registry::clear(); - Registry::register(Platform::X, XRules::class); - - $rules = Registry::forMany([Platform::X, Platform::Instagram]); - - expect($rules)->toHaveCount(1); - expect($rules[0]->platform())->toBe(Platform::X); -}); - -test('X rules summary mentions 280 char limit', function () { - expect(Registry::for(Platform::X)->summary())->toContain('280'); -}); - -test('Instagram rules summary mentions 9:16 for Reel', function () { - expect(Registry::for(Platform::Instagram)->summary())->toContain('9:16'); -}); - -test('TikTok rules specs indicate video only', function () { - expect(Registry::for(Platform::TikTok)->specs()['video_only'])->toBeTrue(); -}); From 6f8cbf0d4cb365d9f344ee097358c9da52518576 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 11 May 2026 21:13:31 -0300 Subject: [PATCH 2/4] chore: drop unused isPublished computed in Edit.vue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Left over from the PostEditorHeader extraction in #26 — the header now derives its own isPublished from the post prop, and Edit.vue itself doesn't use it anywhere. ESLint caught it on CI. --- resources/js/pages/posts/Edit.vue | 1 - 1 file changed, 1 deletion(-) diff --git a/resources/js/pages/posts/Edit.vue b/resources/js/pages/posts/Edit.vue index b22bd0f3..27260d1a 100644 --- a/resources/js/pages/posts/Edit.vue +++ b/resources/js/pages/posts/Edit.vue @@ -99,7 +99,6 @@ const post = computed(() => props.post); // Terminal states the user cannot recover from — delete, navigate, but never edit. const isReadOnly = computed(() => ['publishing', 'published', 'partially_published'].includes(post.value.status)); const isPublishing = computed(() => post.value.status === 'publishing'); -const isPublished = computed(() => ['published', 'partially_published'].includes(post.value.status)); const isScheduled = computed(() => post.value.status === 'scheduled'); // Locked states — terminal + scheduled. Field edits and auto-save suppressed; // user must unschedule to re-enter draft and edit. From 740c921e126209c2acfd85a270136b121281c606 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 11 May 2026 21:24:11 -0300 Subject: [PATCH 3/4] chore: remove unused Gemini provider extension and debug middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more pieces in app/Ai/ that were registered but never reached by any production caller, same audit as the PlatformRules cleanup. DebugGeminiRequest middleware: zero references — not registered in the laravel/ai middleware pipeline, no agent imports it. Pure dev-time scaffolding that got left behind. ExtendedGeminiProvider: registered via Ai::extend('gemini', ...) so the class IS resolvable when AI_DEFAULT=gemini, but its only override — defaultImageOptions — is unreachable in practice: - All text agents (PostContentGenerator, PostContentHumanizer, PostContentReviewer, PostContentStreamer, BrandAnalyzer) only call text generation, never defaultImageOptions. - Image generation (AiImageClient) hardcodes OpenAI's `gpt-image-2` model, so the Gemini provider isn't on the image path either. Removed: - app/Ai/Middleware/DebugGeminiRequest.php - app/Ai/Providers/ExtendedGeminiProvider.php - tests/Unit/Ai/Providers/ExtendedGeminiProviderTest.php - Ai::extend('gemini', ...) wiring + configureAi() method and its four imports (ExtendedGeminiProvider, Ai, GeminiGateway, Dispatcher) in AppServiceProvider. --- app/Ai/Middleware/DebugGeminiRequest.php | 37 --------------- app/Ai/Providers/ExtendedGeminiProvider.php | 46 ------------------- app/Providers/AppServiceProvider.php | 16 ------- .../Providers/ExtendedGeminiProviderTest.php | 46 ------------------- 4 files changed, 145 deletions(-) delete mode 100644 app/Ai/Middleware/DebugGeminiRequest.php delete mode 100644 app/Ai/Providers/ExtendedGeminiProvider.php delete mode 100644 tests/Unit/Ai/Providers/ExtendedGeminiProviderTest.php diff --git a/app/Ai/Middleware/DebugGeminiRequest.php b/app/Ai/Middleware/DebugGeminiRequest.php deleted file mode 100644 index d8d0212b..00000000 --- a/app/Ai/Middleware/DebugGeminiRequest.php +++ /dev/null @@ -1,37 +0,0 @@ - get_class($prompt->agent), - 'model' => $prompt->model, - 'has_structured_output' => $prompt->agent instanceof HasStructuredOutput, - 'has_tools' => $prompt->agent instanceof HasTools, - 'tools_count' => $prompt->agent instanceof HasTools ? count(iterator_to_array($prompt->agent->tools())) : 0, - ]); - - return $next($prompt)->then(function (AgentResponse $response) { - Log::debug('Agent response debug', [ - 'response_class' => get_class($response), - 'text_length' => strlen($response->text ?? ''), - 'text_preview' => substr($response->text ?? '', 0, 200), - 'is_structured' => $response instanceof StructuredAgentResponse, - 'structured_data' => $response instanceof StructuredAgentResponse ? $response->structured : null, - ]); - }); - } -} diff --git a/app/Ai/Providers/ExtendedGeminiProvider.php b/app/Ai/Providers/ExtendedGeminiProvider.php deleted file mode 100644 index afbc5958..00000000 --- a/app/Ai/Providers/ExtendedGeminiProvider.php +++ /dev/null @@ -1,46 +0,0 @@ - match ($quality) { - 'low', '1K' => '1K', - 'medium', '2K' => '2K', - 'high', '4K' => '4K', - default => '1K', - }, - 'aspect_ratio' => match ($size) { - '1:1' => '1:1', - '2:3' => '2:3', - '3:2' => '3:2', - '9:16' => '9:16', - '16:9' => '16:9', - '4:3' => '4:3', - '3:4' => '3:4', - '4:5' => '4:5', - '5:4' => '5:4', - '21:9' => '21:9', - default => null, - }, - ]); - } -} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 88b3c30b..3888f6ee 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,7 +4,6 @@ namespace App\Providers; -use App\Ai\Providers\ExtendedGeminiProvider; use App\Listeners\StripeEventListener; use App\Models\AccessToken; use App\Models\Account; @@ -33,7 +32,6 @@ use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Auth\Notifications\VerifyEmail; use Illuminate\Cache\RateLimiting\Limit; -use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Http\Request; @@ -45,8 +43,6 @@ use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; -use Laravel\Ai\Ai; -use Laravel\Ai\Gateway\Gemini\GeminiGateway; use Laravel\Cashier\Cashier; use Laravel\Cashier\Events\WebhookReceived; use Laravel\Nightwatch\Facades\Nightwatch; @@ -84,7 +80,6 @@ public function boot(): void { $this->configureDefaults(); $this->configureMorphMap(); - $this->configureAi(); $this->configurePostHog(); $this->configureRateLimiting(); $this->configureSocialite(); @@ -112,17 +107,6 @@ protected function configurePassport(): void Passport::authorizationView('mcp.authorize'); } - protected function configureAi(): void - { - Ai::extend('gemini', function ($app, array $config) { - return new ExtendedGeminiProvider( - new GeminiGateway($app['events']), - $config, - $app->make(Dispatcher::class), - ); - }); - } - protected function configureMorphMap(): void { Relation::enforceMorphMap([ diff --git a/tests/Unit/Ai/Providers/ExtendedGeminiProviderTest.php b/tests/Unit/Ai/Providers/ExtendedGeminiProviderTest.php deleted file mode 100644 index 560d051c..00000000 --- a/tests/Unit/Ai/Providers/ExtendedGeminiProviderTest.php +++ /dev/null @@ -1,46 +0,0 @@ -provider = new ExtendedGeminiProvider( - new GeminiGateway(app(Dispatcher::class)), - ['name' => 'gemini', 'driver' => 'gemini'], - app(Dispatcher::class), - ); -}); - -test('supports social media vertical aspect ratio 9:16', function () { - $options = $this->provider->defaultImageOptions('9:16'); - - expect($options['aspect_ratio'])->toBe('9:16'); -}); - -test('supports social media horizontal aspect ratio 16:9', function () { - $options = $this->provider->defaultImageOptions('16:9'); - - expect($options['aspect_ratio'])->toBe('16:9'); -}); - -test('still supports the three original ratios from the base provider', function () { - expect($this->provider->defaultImageOptions('1:1')['aspect_ratio'])->toBe('1:1'); - expect($this->provider->defaultImageOptions('2:3')['aspect_ratio'])->toBe('2:3'); - expect($this->provider->defaultImageOptions('3:2')['aspect_ratio'])->toBe('3:2'); -}); - -test('supports additional Instagram-friendly ratios', function () { - expect($this->provider->defaultImageOptions('4:5')['aspect_ratio'])->toBe('4:5'); - expect($this->provider->defaultImageOptions('5:4')['aspect_ratio'])->toBe('5:4'); - expect($this->provider->defaultImageOptions('4:3')['aspect_ratio'])->toBe('4:3'); - expect($this->provider->defaultImageOptions('3:4')['aspect_ratio'])->toBe('3:4'); -}); - -test('drops unsupported aspect ratios silently', function () { - $options = $this->provider->defaultImageOptions('gibberish'); - - expect($options)->not->toHaveKey('aspect_ratio'); -}); From efdf902b8047e2e327a672f04fdecf985f230220 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 11 May 2026 21:42:50 -0300 Subject: [PATCH 4/4] fix(ai): wire up streaming generate dialog (event names + JSON extract + loading state) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline "Generate post with AI" dialog never showed any output, even after the queue job finished. Three independent issues stacked: 1. Event name mismatch in useAiStream. Listeners were CamelCased (.TextDelta, .StreamEnd, .Error) but laravel/ai broadcasts events with snake_case names via StreamEvent::type() (text_delta, stream_end, error). Echo never matched, the listener never fired, status stuck on 'streaming' forever. Renamed to snake_case. 2. The streamer agent shares the structured-output prompt template with PostContentGenerator, so the stream emits a JSON object like {"content":"...","image_title":"...","image_keywords":[...]}, not plain caption text. Added a previewText computed in AiGenerateDialog that JSON.parse's the accumulated text and grabs .content. Mid-stream the parse fails, so the preview stays empty (showing the existing '...' placeholder) until stream_end produces a complete JSON. 3. The Generate button used v-if="status === 'idle'" — it disappeared the instant generation started, leaving the user with no feedback. Swapped to v-if="status === 'idle' || status === 'streaming'" and used the Button component's built-in :loading prop so it stays visible with a spinner inside while the agent runs. --- .../components/posts/ai/AiGenerateDialog.vue | 27 +++++++++++++++---- resources/js/composables/echo/useAiStream.ts | 6 ++--- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/resources/js/components/posts/ai/AiGenerateDialog.vue b/resources/js/components/posts/ai/AiGenerateDialog.vue index 788190c1..bd3a1f6a 100644 --- a/resources/js/components/posts/ai/AiGenerateDialog.vue +++ b/resources/js/components/posts/ai/AiGenerateDialog.vue @@ -45,8 +45,24 @@ const startGeneration = async () => { } }; +// The streamer agent shares the structured-output prompt template with +// PostContentGenerator, so what comes through the stream is a JSON object +// like {"content": "...", "image_title": "...", ...}. We only want the +// content field — and only once the JSON is complete (mid-stream the parse +// fails and we show nothing, avoiding the typewriter-of-JSON effect). +const previewText = computed(() => { + if (! text.value) return ''; + try { + const parsed = JSON.parse(text.value); + if (parsed && typeof parsed.content === 'string') return parsed.content; + } catch { + // Mid-stream the JSON is incomplete — leave preview empty. + } + return ''; +}); + const apply = () => { - emit('apply', text.value); + emit('apply', previewText.value); open.value = false; }; @@ -56,7 +72,7 @@ const retry = () => { startGeneration(); }; -const canApply = computed(() => status.value === 'completed' && text.value.trim().length > 0); +const canApply = computed(() => status.value === 'completed' && previewText.value.trim().length > 0); const canRetry = computed(() => status.value === 'completed' || status.value === 'failed'); watch(open, () => { @@ -88,15 +104,16 @@ watch(open, () => {
-
{{ text || '...' }}
+
{{ previewText || '...' }}

{{ errorMessage }}