Merge pull request #27 from trypostit/chore/remove-unused-platform-rules
chore: remove unused PlatformRules registry and rule classes
This commit is contained in:
commit
f8f5cba3d6
20 changed files with 25 additions and 670 deletions
|
|
@ -1,37 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Ai\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Laravel\Ai\Contracts\HasStructuredOutput;
|
||||
use Laravel\Ai\Contracts\HasTools;
|
||||
use Laravel\Ai\Prompts\AgentPrompt;
|
||||
use Laravel\Ai\Responses\AgentResponse;
|
||||
use Laravel\Ai\Responses\StructuredAgentResponse;
|
||||
|
||||
class DebugGeminiRequest
|
||||
{
|
||||
public function handle(AgentPrompt $prompt, Closure $next)
|
||||
{
|
||||
Log::debug('Agent prompt debug', [
|
||||
'agent' => 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,
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Ai\PlatformRules;
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
|
||||
class BlueskyRules implements Contract
|
||||
{
|
||||
public function platform(): Platform
|
||||
{
|
||||
return Platform::Bluesky;
|
||||
}
|
||||
|
||||
public function specs(): array
|
||||
{
|
||||
return [
|
||||
'max_content_length' => 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.';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Ai\PlatformRules;
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
|
||||
interface Contract
|
||||
{
|
||||
public function platform(): Platform;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function specs(): array;
|
||||
|
||||
public function summary(): string;
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Ai\PlatformRules;
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
|
||||
class FacebookRules implements Contract
|
||||
{
|
||||
public function platform(): Platform
|
||||
{
|
||||
return Platform::Facebook;
|
||||
}
|
||||
|
||||
public function specs(): array
|
||||
{
|
||||
return [
|
||||
'max_content_length' => 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Ai\PlatformRules;
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
|
||||
class InstagramRules implements Contract
|
||||
{
|
||||
public function platform(): Platform
|
||||
{
|
||||
return Platform::Instagram;
|
||||
}
|
||||
|
||||
public function specs(): array
|
||||
{
|
||||
return [
|
||||
'max_content_length' => 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Ai\PlatformRules;
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
|
||||
class LinkedInRules implements Contract
|
||||
{
|
||||
public function platform(): Platform
|
||||
{
|
||||
return Platform::LinkedIn;
|
||||
}
|
||||
|
||||
public function specs(): array
|
||||
{
|
||||
return [
|
||||
'max_content_length' => 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Ai\PlatformRules;
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
|
||||
class MastodonRules implements Contract
|
||||
{
|
||||
public function platform(): Platform
|
||||
{
|
||||
return Platform::Mastodon;
|
||||
}
|
||||
|
||||
public function specs(): array
|
||||
{
|
||||
return [
|
||||
'max_content_length' => 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.';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Ai\PlatformRules;
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
|
||||
class PinterestRules implements Contract
|
||||
{
|
||||
public function platform(): Platform
|
||||
{
|
||||
return Platform::Pinterest;
|
||||
}
|
||||
|
||||
public function specs(): array
|
||||
{
|
||||
return [
|
||||
'max_content_length' => 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Ai\PlatformRules;
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
|
||||
class Registry
|
||||
{
|
||||
/**
|
||||
* @var array<string, class-string<Contract>>
|
||||
*/
|
||||
private static array $map = [];
|
||||
|
||||
public static function for(Platform $platform): ?Contract
|
||||
{
|
||||
$class = self::$map[$platform->value] ?? null;
|
||||
|
||||
return $class ? new $class : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, Platform> $platforms
|
||||
* @return array<int, Contract>
|
||||
*/
|
||||
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<Contract> $ruleClass
|
||||
*/
|
||||
public static function register(Platform $platform, string $ruleClass): void
|
||||
{
|
||||
self::$map[$platform->value] = $ruleClass;
|
||||
}
|
||||
|
||||
public static function clear(): void
|
||||
{
|
||||
self::$map = [];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Ai\PlatformRules;
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
|
||||
class ThreadsRules implements Contract
|
||||
{
|
||||
public function platform(): Platform
|
||||
{
|
||||
return Platform::Threads;
|
||||
}
|
||||
|
||||
public function specs(): array
|
||||
{
|
||||
return [
|
||||
'max_content_length' => 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.';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Ai\PlatformRules;
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
|
||||
class TikTokRules implements Contract
|
||||
{
|
||||
public function platform(): Platform
|
||||
{
|
||||
return Platform::TikTok;
|
||||
}
|
||||
|
||||
public function specs(): array
|
||||
{
|
||||
return [
|
||||
'max_content_length' => 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.';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Ai\PlatformRules;
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
|
||||
class XRules implements Contract
|
||||
{
|
||||
public function platform(): Platform
|
||||
{
|
||||
return Platform::X;
|
||||
}
|
||||
|
||||
public function specs(): array
|
||||
{
|
||||
return [
|
||||
'max_content_length' => 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.';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Ai\PlatformRules;
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
|
||||
class YouTubeRules implements Contract
|
||||
{
|
||||
public function platform(): Platform
|
||||
{
|
||||
return Platform::YouTube;
|
||||
}
|
||||
|
||||
public function specs(): array
|
||||
{
|
||||
return [
|
||||
'max_content_length' => 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.';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Ai\Providers;
|
||||
|
||||
use Laravel\Ai\Providers\GeminiProvider;
|
||||
|
||||
/**
|
||||
* Extends the base Gemini provider with social-media-friendly aspect ratios.
|
||||
*
|
||||
* The stock provider only exposes 1:1, 2:3, 3:2. Social posts need 9:16
|
||||
* (Reels/Stories/TikTok/Shorts) and 16:9 (X/LinkedIn/Facebook video).
|
||||
* Gemini's native API supports both, but the SDK's match statement silently
|
||||
* drops anything outside its three documented ratios. We re-expose them here.
|
||||
*/
|
||||
class ExtendedGeminiProvider extends GeminiProvider
|
||||
{
|
||||
/**
|
||||
* @param 'low'|'medium'|'high'|'1K'|'2K'|'4K'|null $quality
|
||||
*/
|
||||
public function defaultImageOptions(?string $size = null, $quality = null): array
|
||||
{
|
||||
return array_filter([
|
||||
'image_size' => 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,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,19 +4,6 @@
|
|||
|
||||
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;
|
||||
|
|
@ -45,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;
|
||||
|
|
@ -57,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;
|
||||
|
|
@ -96,8 +80,6 @@ public function boot(): void
|
|||
{
|
||||
$this->configureDefaults();
|
||||
$this->configureMorphMap();
|
||||
$this->configureAi();
|
||||
$this->configurePlatformRules();
|
||||
$this->configurePostHog();
|
||||
$this->configureRateLimiting();
|
||||
$this->configureSocialite();
|
||||
|
|
@ -125,42 +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 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([
|
||||
|
|
|
|||
|
|
@ -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, () => {
|
|||
|
||||
<div v-if="status !== 'idle'" class="grid gap-2">
|
||||
<Label class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.ai.generate.preview_label') }}</Label>
|
||||
<div class="min-h-[120px] whitespace-pre-wrap rounded-lg border-2 border-foreground bg-card px-3 py-2 text-sm font-medium text-foreground shadow-2xs">{{ text || '...' }}</div>
|
||||
<div class="min-h-[120px] whitespace-pre-wrap rounded-lg border-2 border-foreground bg-card px-3 py-2 text-sm font-medium text-foreground shadow-2xs">{{ previewText || '...' }}</div>
|
||||
<p v-if="status === 'failed'" class="text-xs font-semibold text-rose-700">{{ errorMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
v-if="status === 'idle'"
|
||||
:disabled="! prompt.trim() || dispatching"
|
||||
v-if="status === 'idle' || status === 'streaming'"
|
||||
:loading="dispatching || status === 'streaming'"
|
||||
:disabled="! prompt.trim()"
|
||||
@click="startGeneration"
|
||||
>
|
||||
{{ $t('posts.ai.generate.start') }}
|
||||
|
|
|
|||
|
|
@ -42,13 +42,13 @@ export const useAiStream = () => {
|
|||
subscribedName = channelName;
|
||||
|
||||
echo().private(channelName)
|
||||
.listen('.TextDelta', (e: TextDeltaEvent) => {
|
||||
.listen('.text_delta', (e: TextDeltaEvent) => {
|
||||
text.value += e.delta ?? '';
|
||||
})
|
||||
.listen('.StreamEnd', () => {
|
||||
.listen('.stream_end', () => {
|
||||
status.value = 'completed';
|
||||
})
|
||||
.listen('.Error', (e: ErrorEvent) => {
|
||||
.listen('.error', (e: ErrorEvent) => {
|
||||
status.value = 'failed';
|
||||
errorMessage.value = e?.message ?? 'AI generation failed';
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Ai\PlatformRules\Contract;
|
||||
use App\Ai\PlatformRules\Registry;
|
||||
use App\Ai\PlatformRules\XRules;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
|
||||
test('registry returns rule instance for every supported platform', function (Platform $platform) {
|
||||
$rules = Registry::for($platform);
|
||||
|
||||
expect($rules)
|
||||
->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();
|
||||
});
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Ai\Providers\ExtendedGeminiProvider;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
use Laravel\Ai\Gateway\Gemini\GeminiGateway;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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');
|
||||
});
|
||||
Loading…
Reference in a new issue