Merge pull request #104 from trypostit/feat/ai-template-gallery

feat: AI template gallery + tweet-card
This commit is contained in:
Paulo Castellano 2026-06-18 11:55:40 -03:00 committed by GitHub
commit 0a407c4d3b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 2698 additions and 309 deletions

View file

@ -7,7 +7,11 @@
use App\Actions\Post\CreatePost;
use App\Ai\Agents\PostContentGenerator;
use App\Ai\Agents\PostContentHumanizer;
use App\Ai\Templates\AiTemplateRegistry;
use App\Ai\Templates\TemplateContext;
use App\DataTransferObjects\Automation\NodeRunResult;
use App\Enums\Ai\ContentStyle;
use App\Enums\Ai\GeneratorFormat;
use App\Enums\PostPlatform\ContentType;
use App\Models\AutomationRun;
use App\Models\SocialAccount;
@ -16,7 +20,6 @@
use App\Services\Ai\RecordAiUsage;
use App\Services\Automation\ExpressionResolver;
use App\Services\Automation\GenerateNodeValidator;
use App\Services\Image\PostImagePipeline;
use Illuminate\Support\Facades\Log;
use Throwable;
@ -48,41 +51,12 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
->get()
->keyBy('id');
// Per-automation toggle: when off, the brand persona/voice is NOT
// injected, so the post stays faithful to a third-party source (news /
// RSS curation) instead of being rewritten in the brand's voice.
$applyBrandVoice = (bool) data_get($config, 'use_brand_voice', true);
$platformContext = $this->resolvePlatformContext($accountsConfig);
$agent = new PostContentGenerator(
workspace: $workspace,
format: $format,
slideCount: $slideCount,
platformContext: $platformContext,
applyBrandVoice: $applyBrandVoice,
);
$generatorResponse = $agent->prompt($prompt);
RecordAiUsage::recordText(
workspace: $workspace,
promptTokens: $generatorResponse->usage->promptTokens,
completionTokens: $generatorResponse->usage->completionTokens,
provider: (string) config('ai.default'),
model: (string) config('ai.default_text_model'),
metadata: ['agent' => 'post_generator', 'format' => $format, 'source' => 'automation'],
);
$structured = $generatorResponse->structured ?? [];
$structured = $this->humanize($workspace, $structured, $format, $applyBrandVoice, $platformContext);
$content = $format === 'carousel'
? (string) data_get($structured, 'caption', '')
: (string) data_get($structured, 'content', '');
$user = $this->resolveUser($run);
$style = ContentStyle::tryFrom((string) data_get($config, 'style', ContentStyle::default()->value)) ?? ContentStyle::default();
$styleTemplate = app(AiTemplateRegistry::class)->find($style);
$platforms = [];
foreach ($accountsConfig as $entry) {
@ -105,50 +79,71 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
];
}
// A single source of truth for image count: 0 = text-only (no image),
// 1 = single image, 2+ = carousel. For the single-image branch we only
// care whether at least one image was requested.
$wantsImage = (int) data_get($config, 'target_slide_count', 1) >= 1;
$brandAccount = $platforms !== []
? $activeAccounts->get(data_get($platforms[0], 'social_account_id'))
: null;
$contentType = $platforms !== []
? ContentType::tryFrom((string) data_get($platforms[0], 'content_type'))
: null;
$isCarousel = $format->isCarousel();
$imageCount = $isCarousel ? $slideCount : ($wantsImage ? 1 : 0);
$imageCount = $this->intendedImageCount($format, $slideCount, $wantsImage, $structured, $brandAccount);
$templateContext = new TemplateContext(
workspace: $workspace,
socialAccount: $brandAccount,
format: $platformContext ?? $format->value,
imageCount: $imageCount,
isCarousel: $isCarousel,
applyBrandVisuals: (bool) data_get($config, 'use_brand_visuals', true),
);
$agent = new PostContentGenerator(
workspace: $workspace,
format: $format,
slideCount: $slideCount,
platformContext: $platformContext,
applyBrandVoice: $applyBrandVoice,
template: $styleTemplate,
templateContext: $templateContext,
);
$generatorResponse = $agent->prompt($prompt);
RecordAiUsage::recordText(
workspace: $workspace,
promptTokens: $generatorResponse->usage->promptTokens,
completionTokens: $generatorResponse->usage->completionTokens,
provider: (string) config('ai.default'),
model: (string) config('ai.default_text_model'),
metadata: ['agent' => 'post_generator', 'format' => $format->value, 'source' => 'automation'],
);
$structured = $generatorResponse->structured ?? [];
$structured = $this->humanize($workspace, $structured, $format, $style, $applyBrandVoice, $platformContext);
$intendedImageCount = $this->intendedImageCount($format, $slideCount, $wantsImage, $structured, $brandAccount, $style);
// Dry runs do the AI work (so the user sees a real generation) but
// never persist a Post. Downstream nodes (Publish) read `is_dry_run`
// and skip their persistence too.
if ($run->is_dry_run) {
$dryContent = $this->extractContent($structured, $format, $style);
return NodeRunResult::completed(output: [
'generated' => [
'post_id' => null,
'content' => $content,
'content' => $dryContent,
'dry_run' => true,
'image_count' => $imageCount,
'image_count' => $intendedImageCount,
],
]);
}
$media = [];
$generated = $styleTemplate->assemble($structured, $templateContext);
$applyBrandVisuals = (bool) data_get($config, 'use_brand_visuals', true);
if ($brandAccount) {
if ($format === 'carousel') {
$media = app(PostImagePipeline::class)->forCarousel($workspace, $brandAccount, $structured, $contentType, $applyBrandVisuals);
} elseif ($wantsImage) {
$media = app(PostImagePipeline::class)->forSingle($workspace, $brandAccount, $structured, $contentType, $applyBrandVisuals);
}
}
$user = $this->resolveUser($run);
$post = CreatePost::execute($workspace, $user, [
'content' => $content,
'media' => $media,
'content' => $generated->content,
'media' => $generated->media,
'platforms' => $platforms,
]);
@ -157,7 +152,7 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
return NodeRunResult::completed(output: [
'generated' => [
'post_id' => $post->id,
'content' => $content,
'content' => $generated->content,
'post_url' => route('app.posts.show', $post->id),
],
]);
@ -167,10 +162,14 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
* @param array<string, mixed> $structured
* @return array<string, mixed>
*/
private function humanize(Workspace $workspace, array $structured, string $format, bool $applyBrandVoice = true, ?string $platformContext = null): array
private function humanize(Workspace $workspace, array $structured, GeneratorFormat $format, ContentStyle $style, bool $applyBrandVoice = true, ?string $platformContext = null): array
{
if (! $style->humanizes()) {
return $structured;
}
try {
$input = $format === 'carousel'
$input = $format->isCarousel()
? [
'caption' => data_get($structured, 'caption', ''),
'slides' => array_map(
@ -197,10 +196,10 @@ private function humanize(Workspace $workspace, array $structured, string $forma
completionTokens: $response->usage->completionTokens,
provider: (string) config('ai.default'),
model: (string) config('ai.default_text_model'),
metadata: ['agent' => 'post_humanizer', 'format' => $format, 'source' => 'automation'],
metadata: ['agent' => 'post_humanizer', 'format' => $format->value, 'source' => 'automation'],
);
if ($format === 'carousel') {
if ($format->isCarousel()) {
$structured['caption'] = data_get($humanized, 'caption', $structured['caption'] ?? '');
$originalSlides = $structured['slides'] ?? [];
$humanizedSlides = data_get($humanized, 'slides', []);
@ -227,6 +226,26 @@ private function humanize(Workspace $workspace, array $structured, string $forma
return $structured;
}
/**
* Extract the post caption from the raw structured output without calling
* assemble() (which triggers image generation). Used for dry-run responses
* so no pipeline work happens during test runs.
*
* @param array<string, mixed> $structured
*/
private function extractContent(array $structured, GeneratorFormat $format, ContentStyle $style): string
{
if ($style->isTweetCard()) {
return $format->isCarousel()
? (string) data_get($structured, 'caption', '')
: (string) data_get($structured, 'tweet_text', '');
}
return $format->isCarousel()
? (string) data_get($structured, 'caption', '')
: (string) data_get($structured, 'content', '');
}
/**
* Derive the generator format and slide count from per-account content types.
*
@ -243,15 +262,10 @@ private function humanize(Workspace $workspace, array $structured, string $forma
*
* @param array<int, array{social_account_id: string, content_type: ?string, meta: array<string, mixed>}> $accountsConfig
* @param array<string, mixed> $config
* @return array{format: string, slide_count: int}
* @return array{format: GeneratorFormat, slide_count: int}
*/
public function deriveFormat(array $accountsConfig, array $config): array
{
// Multi-image capability comes from the same ContentType rules the
// publish flow uses — never a hardcoded list — so facebook_post,
// tiktok_photo, linkedin_carousel etc. are all recognised. We cap the
// slide count at MAX_GENERATED_IMAGES and at the tightest selected
// account's limit.
$maxImagesAcross = 0;
foreach ($accountsConfig as $entry) {
$contentType = ContentType::tryFrom((string) data_get($entry, 'content_type'));
@ -265,10 +279,10 @@ public function deriveFormat(array $accountsConfig, array $config): array
if ($maxImagesAcross > 1 && $targetSlideCount > 1) {
$cap = min(GenerateNodeValidator::MAX_GENERATED_IMAGES, $maxImagesAcross);
return ['format' => 'carousel', 'slide_count' => min($targetSlideCount, $cap)];
return ['format' => GeneratorFormat::Carousel, 'slide_count' => min($targetSlideCount, $cap)];
}
return ['format' => 'single', 'slide_count' => 1];
return ['format' => GeneratorFormat::Single, 'slide_count' => 1];
}
/**
@ -294,16 +308,21 @@ private function resolvePlatformContext(array $accountsConfig): ?string
* Number of images that would be attached for the resolved format. Used as
* the dry-run indicator and mirrors the non-dry image generation branches:
* one per slide for carousels, one for single posts when images are enabled.
* Tweet styles always produce one image per slide/post when an account is set.
*
* @param array<string, mixed> $structured
*/
private function intendedImageCount(string $format, int $slideCount, bool $wantsImage, array $structured, ?SocialAccount $brandAccount): int
private function intendedImageCount(GeneratorFormat $format, int $slideCount, bool $wantsImage, array $structured, ?SocialAccount $brandAccount, ContentStyle $style): int
{
if (! $brandAccount) {
return 0;
}
if ($format === 'carousel') {
if ($style->isTweetCard()) {
return $format->isCarousel() ? $slideCount : 1;
}
if ($format->isCarousel()) {
$slides = data_get($structured, 'slides', []);
return is_array($slides) ? count($slides) : $slideCount;

View file

@ -5,6 +5,9 @@
namespace App\Ai\Agents;
use App\Ai\Agents\Concerns\ResolvesPlatformCopyBudget;
use App\Ai\Templates\AiContentTemplate;
use App\Ai\Templates\TemplateContext;
use App\Enums\Ai\GeneratorFormat;
use App\Models\Workspace;
use App\Services\Ai\TemplateContextResolver;
use Illuminate\Contracts\JsonSchema\JsonSchema;
@ -23,10 +26,12 @@ class PostContentGenerator implements Agent, HasStructuredOutput
public function __construct(
public Workspace $workspace,
public ?string $currentContent = null,
public string $format = 'single',
public GeneratorFormat $format = GeneratorFormat::Single,
public int $slideCount = 1,
public ?string $platformContext = null,
public bool $applyBrandVoice = true,
public ?AiContentTemplate $template = null,
public ?TemplateContext $templateContext = null,
) {}
public function instructions(): string
@ -45,18 +50,24 @@ public function instructions(): string
->all();
}
// Two budgets: the platform's HARD cap (must never exceed — would break
// publishing) and the recommended SWEET SPOT length (what well-performing
// posts actually look like, way below the hard cap on most platforms).
$budget = $this->platformCopyBudget($this->platformContext);
return view('prompts.post_content.generator', [
if ($this->template?->style()->isTweetCard()) {
$budget['hard_max_chars'] = 560;
$budget['target_chars'] = 280;
}
$view = $this->template !== null && $this->templateContext !== null
? $this->template->promptView($this->templateContext)
: 'prompts.post_content.generator';
return view($view, [
'brand_name' => $this->workspace->name ?? '',
'brand_description' => $this->applyBrandVoice ? ($this->workspace->brand_description ?? '') : '',
'brand_voice_traits' => $this->applyBrandVoice ? ($this->workspace->brand_voice_traits ?? []) : [],
'content_language' => $this->workspace->content_language,
'current_content' => $this->currentContent,
'format' => $this->format,
'format' => $this->format->value,
'slide_count' => $this->slideCount,
'examples' => $examples,
'hard_max_chars' => $budget['hard_max_chars'],
@ -67,7 +78,11 @@ public function instructions(): string
public function schema(JsonSchema $schema): array
{
if ($this->format === 'carousel') {
if ($this->template !== null && $this->templateContext !== null) {
return $this->template->schema($schema, $this->templateContext);
}
if ($this->format->isCarousel()) {
return [
'caption' => $schema->string()->description('The Instagram caption for the carousel post.')->required(),
'slides' => $schema->array()
@ -77,7 +92,7 @@ public function schema(JsonSchema $schema): array
->description('The role of this slide in the carousel arc. First slide is `hook` (specific real problem). Last slide is `cta` (one specific next action). Middle slides are `development` (unfold the idea) or `proof` (concrete result, before/after, behind-the-scenes, real learning). For 4+ slides, at least one middle slide must be `proof`.')
->required(),
'title' => $s->string()->description('Headline of the slide. Short, impactful.')->required(),
'body' => $s->string()->description('Supporting text below the headline. 1-3 sentences.')->required(),
'body' => $s->string()->description('Supporting body below the headline. 1-3 sentences.')->required(),
'image_keywords' => $s->array()->items($schema->string())->description('2-4 search keywords for Unsplash.')->required(),
]))
->min($this->slideCount)

View file

@ -5,6 +5,7 @@
namespace App\Ai\Agents;
use App\Ai\Agents\Concerns\ResolvesPlatformCopyBudget;
use App\Enums\Ai\GeneratorFormat;
use App\Models\Workspace;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Attributes\Temperature;
@ -27,7 +28,7 @@ class PostContentHumanizer implements Agent, HasStructuredOutput
public function __construct(
public Workspace $workspace,
public string $format = 'single',
public GeneratorFormat $format = GeneratorFormat::Single,
public ?string $platformContext = null,
public bool $applyBrandVoice = true,
) {}
@ -43,7 +44,7 @@ public function instructions(): string
'brand_name' => $this->workspace->name ?? '',
'brand_voice_traits' => $this->applyBrandVoice ? ($this->workspace->brand_voice_traits ?? []) : [],
'content_language' => $this->workspace->content_language,
'format' => $this->format,
'format' => $this->format->value,
'hard_max_chars' => $budget['hard_max_chars'],
'target_chars' => $budget['target_chars'],
'platform_label' => $budget['platform_label'],
@ -52,7 +53,7 @@ public function instructions(): string
public function schema(JsonSchema $schema): array
{
if ($this->format === 'carousel') {
if ($this->format->isCarousel()) {
return [
'caption' => $schema->string()->description('The humanized Instagram caption.')->required(),
'slides' => $schema->array()

View file

@ -4,6 +4,7 @@
namespace App\Ai\Agents;
use App\Enums\Ai\GeneratorFormat;
use App\Models\Workspace;
use Laravel\Ai\Attributes\Temperature;
use Laravel\Ai\Contracts\Agent;
@ -36,7 +37,7 @@ public function instructions(): string
'brand_voice_traits' => $this->workspace->brand_voice_traits ?? [],
'content_language' => $this->workspace->content_language,
'current_content' => $this->currentContent,
'format' => 'single',
'format' => GeneratorFormat::Single->value,
'slide_count' => 1,
])->render();
}

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Ai\Templates;
use App\Enums\Ai\ContentStyle;
use Illuminate\Contracts\JsonSchema\JsonSchema;
interface AiContentTemplate
{
/** The typed enum case for this style. */
public function style(): ContentStyle;
/** Stable key used in the request + registry, e.g. 'image_card'. */
public function key(): string;
/** i18n key for the gallery label. */
public function name(): string;
/** i18n key for the gallery description. */
public function description(): string;
/** Public path to the preview thumbnail shown in the picker. */
public function previewAsset(): string;
/** Whether a social account must be selected for this template. */
public function needsAccount(): bool;
/**
* Content types (by value) this template can produce. Empty = all AI formats.
*
* @return array<int, string>
*/
public function supportedFormats(): array;
/** The Blade view path for the generator prompt. */
public function promptView(TemplateContext $context): string;
/**
* The structured-output schema for the generator.
*
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema, TemplateContext $context): array;
/**
* Build the post from the (humanized) structured output.
*
* @param array<string, mixed> $structured
*/
public function assemble(array $structured, TemplateContext $context): GeneratedPost;
}

View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Ai\Templates;
use App\Enums\Ai\ContentStyle;
use InvalidArgumentException;
class AiTemplateRegistry
{
/** @var array<int, class-string<AiContentTemplate>> */
private const TEMPLATES = [
ImageCardTemplate::class,
TweetCardTemplate::class,
TweetCardImageTemplate::class,
];
/** @var array<int, AiContentTemplate>|null */
private ?array $templates = null;
/** @return array<int, AiContentTemplate> */
public function all(): array
{
return $this->templates ??= array_map(fn (string $class) => app($class), self::TEMPLATES);
}
/** @return array<int, string> */
public function keys(): array
{
return array_map(fn (AiContentTemplate $t) => $t->key(), $this->all());
}
/** @return array<int, ContentStyle> */
public function styles(): array
{
return ContentStyle::cases();
}
public function find(ContentStyle|string $style): AiContentTemplate
{
$value = $style instanceof ContentStyle ? $style->value : $style;
foreach ($this->all() as $template) {
if ($template->key() === $value) {
return $template;
}
}
throw new InvalidArgumentException("Unknown AI content template: {$value}");
}
public function default(): AiContentTemplate
{
return app(ImageCardTemplate::class);
}
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Ai\Templates\Concerns;
use App\Enums\PostPlatform\ContentType;
trait ResolvesContentType
{
private static function resolveContentType(string $format): ?ContentType
{
if ($format === ContentType::CAROUSEL_FORMAT) {
return ContentType::InstagramFeed;
}
return ContentType::tryFrom($format);
}
}

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Ai\Templates;
use App\Enums\PostPlatform\ContentType;
/**
* The output of a template's assemble(): the caption to store as Post.content,
* the media items to attach, and the content type the post platform should use.
*/
class GeneratedPost
{
/**
* @param array<int, array<string, mixed>> $media
*/
public function __construct(
public string $content,
public array $media,
public ?ContentType $contentType,
) {}
}

View file

@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Ai\Templates;
use App\Ai\Templates\Concerns\ResolvesContentType;
use App\Enums\Ai\ContentStyle;
use App\Enums\PostPlatform\ContentType;
use App\Services\Image\PostImagePipeline;
use Illuminate\Contracts\JsonSchema\JsonSchema;
class ImageCardTemplate implements AiContentTemplate
{
use ResolvesContentType;
public function style(): ContentStyle
{
return ContentStyle::ImageCard;
}
public function key(): string
{
return $this->style()->value;
}
public function name(): string
{
return $this->style()->label();
}
public function description(): string
{
return $this->style()->description();
}
public function previewAsset(): string
{
return $this->style()->previewAsset();
}
public function needsAccount(): bool
{
return $this->style()->needsAccount();
}
public function supportedFormats(): array
{
return [];
}
public function promptView(TemplateContext $context): string
{
return 'prompts.post_content.generator';
}
public function schema(JsonSchema $schema, TemplateContext $context): array
{
if ($context->isCarousel) {
$slideCount = $context->imageCount > 0 ? $context->imageCount : 1;
return [
'caption' => $schema->string()->description('The Instagram caption for the carousel post.')->required(),
'slides' => $schema->array()
->items($schema->object(fn ($s) => [
'role' => $s->string()
->enum(['hook', 'development', 'proof', 'cta'])
->description('The role of this slide in the carousel arc. First slide is `hook` (specific real problem). Last slide is `cta` (one specific next action). Middle slides are `development` (unfold the idea) or `proof` (concrete result, before/after, behind-the-scenes, real learning). For 4+ slides, at least one middle slide must be `proof`.')
->required(),
'title' => $s->string()->description('Headline of the slide. Short, impactful.')->required(),
'body' => $s->string()->description('Supporting text below the headline. 1-3 sentences.')->required(),
'image_keywords' => $s->array()->items($schema->string())->description('2-4 search keywords for Unsplash.')->required(),
]))
->min($slideCount)
->max($slideCount)
->description("Exactly {$slideCount} slides for the carousel, in order. First slide must have role `hook`, last slide must have role `cta`.")
->required(),
];
}
return [
'content' => $schema->string()->description('The full post caption text that will be published on the platform.')->required(),
'image_title' => $schema->string()->description('Short headline (5-12 words) overlaid on the image. The hook — should make a scroller stop. Distinct from content.')->required(),
'image_body' => $schema->string()->description('1-2 short sentences (max 25 words) overlaid below the image_title. Expands the hook just enough to compel reading the caption.')->required(),
'image_keywords' => $schema->array()->items($schema->string())->description('2-4 search keywords for Unsplash for the single image.')->required(),
];
}
/**
* @param array<string, mixed> $structured
*/
public function assemble(array $structured, TemplateContext $context): GeneratedPost
{
if ($context->isCarousel) {
return $this->assembleCarousel($structured, $context);
}
return $this->assembleSingle($structured, $context);
}
/**
* @param array<string, mixed> $structured
*/
private function assembleCarousel(array $structured, TemplateContext $context): GeneratedPost
{
$caption = (string) data_get($structured, 'caption', '');
$media = [];
if ($context->socialAccount) {
$media = app(PostImagePipeline::class)->forCarousel(
workspace: $context->workspace,
account: $context->socialAccount,
structured: $structured,
contentType: ContentType::InstagramFeed,
applyBrandVisuals: $context->applyBrandVisuals,
);
}
return new GeneratedPost($caption, $media, ContentType::InstagramFeed);
}
/**
* @param array<string, mixed> $structured
*/
private function assembleSingle(array $structured, TemplateContext $context): GeneratedPost
{
$contentType = self::resolveContentType($context->format);
$supportsCaption = $contentType?->supportsCaption() ?? true;
$rawContent = (string) data_get($structured, 'content', data_get($structured, 'text', ''));
$media = [];
if ($context->imageCount > 0 && $context->socialAccount) {
$media = app(PostImagePipeline::class)->forSingle(
workspace: $context->workspace,
account: $context->socialAccount,
structured: $structured,
contentType: $contentType,
applyBrandVisuals: $context->applyBrandVisuals,
);
}
$caption = $supportsCaption ? $rawContent : '';
return new GeneratedPost($caption, $media, $contentType);
}
}

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Ai\Templates;
use App\Models\SocialAccount;
use App\Models\Workspace;
/**
* Everything a template needs to assemble a post from the LLM output.
*/
class TemplateContext
{
public function __construct(
public Workspace $workspace,
public ?SocialAccount $socialAccount,
public string $format,
public int $imageCount,
public bool $isCarousel = false,
public bool $applyBrandVisuals = true,
) {}
}

View file

@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace App\Ai\Templates;
use App\Enums\Ai\ContentStyle;
use App\Enums\PostPlatform\ContentType;
use App\Services\Image\PostImagePipeline;
use Illuminate\Contracts\JsonSchema\JsonSchema;
class TweetCardImageTemplate implements AiContentTemplate
{
public function style(): ContentStyle
{
return ContentStyle::TweetCardImage;
}
public function key(): string
{
return $this->style()->value;
}
public function name(): string
{
return $this->style()->label();
}
public function description(): string
{
return $this->style()->description();
}
public function previewAsset(): string
{
return $this->style()->previewAsset();
}
public function needsAccount(): bool
{
return $this->style()->needsAccount();
}
/** @return array<int, string> */
public function supportedFormats(): array
{
return [];
}
public function promptView(TemplateContext $context): string
{
return $context->isCarousel
? 'prompts.post_content.tweet_card_image_carousel'
: 'prompts.post_content.tweet_card_image';
}
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema, TemplateContext $context): array
{
if ($context->isCarousel) {
$slideCount = $context->imageCount > 0 ? $context->imageCount : 1;
return [
'caption' => $schema->string()->description('The caption for the carousel post (teases the content, encourages swiping).')->required(),
'slides' => $schema->array()
->items($schema->object(fn ($s) => [
'tweet_text' => $s->string()
->description('The tweet-style text for this slide. First-person, punchy, max ~560 characters.')
->required(),
'image_keywords' => $s->array()
->items($schema->string())
->description('2-4 English keywords for a background photo that fits this slide\'s theme. The photo will be blurred and darkened behind the card.')
->required(),
]))
->min($slideCount)
->max($slideCount)
->description("Exactly {$slideCount} slides, each a self-contained tweet-card. First slide must hook the reader.")
->required(),
];
}
return [
'tweet_text' => $schema->string()
->description('The post body, written as a punchy first-person X/Twitter-style take. Paragraph breaks (\\n\\n) allowed. Max ~560 characters.')
->required(),
'image_keywords' => $schema->array()
->items($schema->string())
->description('2-4 English keywords for a background photo that fits the post\'s theme. The photo will be blurred and darkened behind the card.')
->required(),
];
}
/**
* @param array<string, mixed> $structured
*/
public function assemble(array $structured, TemplateContext $context): GeneratedPost
{
if ($context->isCarousel) {
return $this->assembleCarousel($structured, $context);
}
return $this->assembleSingle($structured, $context);
}
/**
* @param array<string, mixed> $structured
*/
private function assembleSingle(array $structured, TemplateContext $context): GeneratedPost
{
$text = (string) data_get($structured, 'tweet_text', '');
$imageKeywords = data_get($structured, 'image_keywords', []);
$media = [];
if ($context->socialAccount) {
$media = app(PostImagePipeline::class)->forTweetCard(
workspace: $context->workspace,
account: $context->socialAccount,
tweetText: $text,
imageKeywords: $imageKeywords,
);
}
return new GeneratedPost(
content: $text,
media: $media,
contentType: ContentType::tryFrom($context->format),
);
}
/**
* @param array<string, mixed> $structured
*/
private function assembleCarousel(array $structured, TemplateContext $context): GeneratedPost
{
$caption = (string) data_get($structured, 'caption', '');
$slides = array_map(
fn ($slide) => [
'tweet_text' => (string) data_get($slide, 'tweet_text', ''),
'image_keywords' => data_get($slide, 'image_keywords', []),
],
data_get($structured, 'slides', []),
);
$media = [];
if ($context->socialAccount) {
$media = app(PostImagePipeline::class)->forTweetCardCarousel(
workspace: $context->workspace,
account: $context->socialAccount,
slides: $slides,
);
}
return new GeneratedPost(
content: $caption,
media: $media,
contentType: ContentType::InstagramFeed,
);
}
}

View file

@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace App\Ai\Templates;
use App\Enums\Ai\ContentStyle;
use App\Enums\PostPlatform\ContentType;
use App\Services\Image\PostImagePipeline;
use Illuminate\Contracts\JsonSchema\JsonSchema;
class TweetCardTemplate implements AiContentTemplate
{
public function style(): ContentStyle
{
return ContentStyle::TweetCard;
}
public function key(): string
{
return $this->style()->value;
}
public function name(): string
{
return $this->style()->label();
}
public function description(): string
{
return $this->style()->description();
}
public function previewAsset(): string
{
return $this->style()->previewAsset();
}
public function needsAccount(): bool
{
return $this->style()->needsAccount();
}
/** @return array<int, string> */
public function supportedFormats(): array
{
return [];
}
public function promptView(TemplateContext $context): string
{
return $context->isCarousel
? 'prompts.post_content.tweet_card_carousel'
: 'prompts.post_content.tweet_card';
}
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema, TemplateContext $context): array
{
if ($context->isCarousel) {
$slideCount = $context->imageCount > 0 ? $context->imageCount : 1;
return [
'caption' => $schema->string()->description('The caption for the carousel post (teases the content, encourages swiping).')->required(),
'slides' => $schema->array()
->items($schema->object(fn ($s) => [
'tweet_text' => $s->string()
->description('The tweet-style text for this slide. First-person, punchy, max ~560 characters.')
->required(),
]))
->min($slideCount)
->max($slideCount)
->description("Exactly {$slideCount} slides, each a self-contained tweet-card. First slide must hook the reader.")
->required(),
];
}
return [
'tweet_text' => $schema->string()
->description('The post body, written as a punchy first-person X/Twitter-style take. Paragraph breaks (\\n\\n) allowed. Max ~560 characters.')
->required(),
];
}
/**
* @param array<string, mixed> $structured
*/
public function assemble(array $structured, TemplateContext $context): GeneratedPost
{
if ($context->isCarousel) {
return $this->assembleCarousel($structured, $context);
}
return $this->assembleSingle($structured, $context);
}
/**
* @param array<string, mixed> $structured
*/
private function assembleSingle(array $structured, TemplateContext $context): GeneratedPost
{
$text = (string) data_get($structured, 'tweet_text', '');
$media = [];
if ($context->socialAccount) {
$media = app(PostImagePipeline::class)->forTweetCard(
workspace: $context->workspace,
account: $context->socialAccount,
tweetText: $text,
);
}
return new GeneratedPost(
content: $text,
media: $media,
contentType: ContentType::tryFrom($context->format),
);
}
/**
* @param array<string, mixed> $structured
*/
private function assembleCarousel(array $structured, TemplateContext $context): GeneratedPost
{
$caption = (string) data_get($structured, 'caption', '');
$slideTexts = array_map(
fn ($slide) => (string) data_get($slide, 'tweet_text', ''),
data_get($structured, 'slides', []),
);
$media = [];
if ($context->socialAccount) {
$media = app(PostImagePipeline::class)->forTweetCardCarousel(
workspace: $context->workspace,
account: $context->socialAccount,
slides: $slideTexts,
);
}
return new GeneratedPost(
content: $caption,
media: $media,
contentType: ContentType::InstagramFeed,
);
}
}

View file

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Enums\Ai;
enum ContentStyle: string
{
case ImageCard = 'image_card';
case TweetCard = 'tweet_card';
case TweetCardImage = 'tweet_card_image';
/** i18n key for the style's display name. */
public function label(): string
{
return "posts.ai.templates.{$this->value}.name";
}
/** i18n key for the style's description. */
public function description(): string
{
return "posts.ai.templates.{$this->value}.description";
}
/** Whether this style requires a connected social account. */
public function needsAccount(): bool
{
return match ($this) {
self::ImageCard => false,
self::TweetCard, self::TweetCardImage => true,
};
}
/** Whether this style's generated text is run through the humanizer pass. */
public function humanizes(): bool
{
return match ($this) {
self::ImageCard => true,
self::TweetCard, self::TweetCardImage => false,
};
}
/** Whether this style renders the post as a tweet-style card. */
public function isTweetCard(): bool
{
return match ($this) {
self::TweetCard, self::TweetCardImage => true,
self::ImageCard => false,
};
}
/** Public path to the picker preview thumbnail. */
public function previewAsset(): string
{
return match ($this) {
self::ImageCard => '/images/ai-templates/image-card.png',
self::TweetCard => '/images/ai-templates/tweet-card.png',
self::TweetCardImage => '/images/ai-templates/tweet-card-image.png',
};
}
public static function default(): self
{
return self::ImageCard;
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Enums\Ai;
enum GeneratorFormat: string
{
case Single = 'single';
case Carousel = 'carousel';
public function isCarousel(): bool
{
return $this === self::Carousel;
}
}

View file

@ -51,6 +51,7 @@ public function start(StartPostCreationRequest $request): JsonResponse
imageCount: (int) $request->input('image_count', 0),
prompt: $request->string('prompt')->toString(),
date: $request->input('date'),
template: $request->input('template', 'image_card'),
);
return response()->json([

View file

@ -9,6 +9,8 @@
use App\Actions\Post\DuplicatePost;
use App\Actions\Post\SyncPostPlatforms;
use App\Actions\Post\UpdatePost;
use App\Ai\Templates\AiContentTemplate;
use App\Ai\Templates\AiTemplateRegistry;
use App\Enums\Post\Action as PostAction;
use App\Enums\Post\Status as PostStatus;
use App\Enums\SocialAccount\Platform;
@ -142,11 +144,23 @@ public function create(Request $request): Response
$this->authorize('createPost', $workspace);
$registry = app(AiTemplateRegistry::class);
$templates = array_map(fn (AiContentTemplate $t) => [
'key' => $t->key(),
'name' => trans($t->name()),
'description' => trans($t->description()),
'preview' => $t->previewAsset(),
'needs_account' => $t->needsAccount(),
'supported_formats' => $t->supportedFormats(),
], $registry->all());
return Inertia::render('posts/Create', [
'date' => $request->query('date'),
'socialAccounts' => SocialAccountResource::collection(
$workspace->socialAccounts()->active()->get()
),
'templates' => $templates,
]);
}

View file

@ -4,9 +4,11 @@
namespace App\Http\Requests\App\Ai;
use App\Enums\Ai\ContentStyle;
use App\Enums\PostPlatform\ContentType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class StartPostCreationRequest extends FormRequest
{
@ -33,6 +35,19 @@ public function rules(): array
'image_count' => ['nullable', 'integer', 'min:0', 'max:10'],
'prompt' => ['required', 'string', 'max:2000'],
'date' => ['nullable', 'date_format:Y-m-d'],
'template' => ['sometimes', 'string', Rule::enum(ContentStyle::class)],
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
$style = ContentStyle::tryFrom((string) $this->input('template', ContentStyle::default()->value))
?? ContentStyle::default();
if ($style->needsAccount() && blank($this->input('social_account_id'))) {
$validator->errors()->add('social_account_id', trans('validation.required', ['attribute' => 'social account']));
}
});
}
}

View file

@ -4,6 +4,7 @@
namespace App\Http\Requests\App\Automations;
use App\Enums\Ai\ContentStyle;
use App\Enums\Automation\AuthType;
use App\Enums\Automation\Condition\Operator as ConditionOperator;
use App\Enums\Automation\DelayUnit;
@ -156,6 +157,7 @@ private function dataRulesForNodeType(?string $type, int $i): array
'target_slide_count' => ['nullable', 'integer', 'min:0', 'max:'.GenerateNodeValidator::MAX_GENERATED_IMAGES],
'use_brand_voice' => ['sometimes', 'boolean'],
'use_brand_visuals' => ['sometimes', 'boolean'],
'style' => ['sometimes', Rule::in(array_column(ContentStyle::cases(), 'value'))],
],
NodeType::Delay->value => [
'duration' => ['required', 'integer', 'min:1'],

View file

@ -7,6 +7,11 @@
use App\Actions\Post\CreatePost;
use App\Ai\Agents\PostContentGenerator;
use App\Ai\Agents\PostContentHumanizer;
use App\Ai\Templates\AiTemplateRegistry;
use App\Ai\Templates\GeneratedPost;
use App\Ai\Templates\TemplateContext;
use App\Enums\Ai\ContentStyle;
use App\Enums\Ai\GeneratorFormat;
use App\Enums\Notification\Channel as NotificationChannel;
use App\Enums\Notification\Type as NotificationType;
use App\Enums\PostPlatform\ContentType;
@ -17,7 +22,6 @@
use App\Models\User;
use App\Models\Workspace;
use App\Services\Ai\RecordAiUsage;
use App\Services\Image\PostImagePipeline;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@ -38,6 +42,7 @@ public function __construct(
public int $imageCount,
public string $prompt,
public ?string $date = null,
public string $template = 'image_card',
) {
$this->onQueue('ai');
}
@ -47,16 +52,27 @@ public function handle(): void
$workspace = Workspace::findOrFail($this->workspaceId);
$socialAccount = $this->socialAccountId ? SocialAccount::find($this->socialAccountId) : null;
$isCarousel = $this->format === ContentType::CAROUSEL_FORMAT;
$agentFormat = $isCarousel ? 'carousel' : 'single';
$style = app(AiTemplateRegistry::class)->find($this->template);
$isCarousel = $this->format === ContentType::CAROUSEL_FORMAT;
$agentFormat = $isCarousel ? GeneratorFormat::Carousel : GeneratorFormat::Single;
$slideCount = $isCarousel && $this->imageCount > 0 ? $this->imageCount : 1;
$context = new TemplateContext(
workspace: $workspace,
socialAccount: $socialAccount,
format: $this->format,
imageCount: $this->imageCount,
isCarousel: $isCarousel,
);
$agent = new PostContentGenerator(
workspace: $workspace,
format: $agentFormat,
slideCount: $slideCount,
platformContext: $this->format,
template: $style,
templateContext: $context,
);
try {
@ -72,18 +88,13 @@ public function handle(): void
metadata: ['agent' => 'post_generator', 'format' => $this->format],
);
// StructuredAgentResponse implements ArrayAccess: access via $response['key']
$structured = $response->structured ?? [];
// Second pass: rewrite human-readable text to remove AI-tells. Image
// keywords pass through untouched (they need to stay in English).
$structured = $this->humanize($workspace, $structured, $isCarousel ? 'carousel' : 'single');
$structured = $this->humanize($workspace, $structured, $agentFormat, $style->style());
if ($isCarousel) {
$this->handleCarousel($workspace, $socialAccount, $structured);
} else {
$this->handleSingle($workspace, $socialAccount, $structured);
}
$generated = $style->assemble($structured, $context);
$post = $this->createPostFromGenerated($workspace, $generated, $socialAccount);
$this->notifyReady($workspace, $post);
} catch (\Throwable $e) {
Log::error('StreamPostCreation failed', [
'creation_id' => $this->creationId,
@ -96,19 +107,6 @@ public function handle(): void
}
}
/**
* The stored content type for the requested generation format. The carousel
* generation format is persisted as an Instagram feed post.
*/
private function resolvedContentType(): ?ContentType
{
if ($this->format === ContentType::CAROUSEL_FORMAT) {
return ContentType::InstagramFeed;
}
return ContentType::tryFrom($this->format);
}
/**
* Run the structured generator output through the humanizer pass and merge
* the humanized text fields back over the original structure (preserving
@ -119,10 +117,14 @@ private function resolvedContentType(): ?ContentType
* @param array<string, mixed> $structured
* @return array<string, mixed>
*/
private function humanize(Workspace $workspace, array $structured, string $format): array
private function humanize(Workspace $workspace, array $structured, GeneratorFormat $format, ContentStyle $style): array
{
if (! $style->humanizes()) {
return $structured;
}
try {
$input = $format === 'carousel'
$input = $format->isCarousel()
? [
'caption' => data_get($structured, 'caption', ''),
'slides' => array_map(
@ -150,7 +152,7 @@ private function humanize(Workspace $workspace, array $structured, string $forma
provider: (string) config('ai.default'),
model: (string) config('ai.default_text_model'),
userId: $this->userId,
metadata: ['agent' => 'post_humanizer', 'format' => $format],
metadata: ['agent' => 'post_humanizer', 'format' => $format->value],
);
} catch (\Throwable $e) {
Log::warning('PostContentHumanizer failed, using generator output as-is', [
@ -161,7 +163,7 @@ private function humanize(Workspace $workspace, array $structured, string $forma
return $structured;
}
if ($format === 'carousel') {
if ($format->isCarousel()) {
$structured['caption'] = data_get($humanized, 'caption', $structured['caption'] ?? '');
$originalSlides = $structured['slides'] ?? [];
$humanizedSlides = data_get($humanized, 'slides', []);
@ -183,80 +185,28 @@ private function humanize(Workspace $workspace, array $structured, string $forma
return $structured;
}
private function handleCarousel(Workspace $workspace, ?SocialAccount $socialAccount, array $structured): void
{
$caption = (string) data_get($structured, 'caption', '');
$media = [];
if ($socialAccount) {
$media = app(PostImagePipeline::class)->forCarousel(
workspace: $workspace,
account: $socialAccount,
structured: $structured,
contentType: $this->resolvedContentType(),
);
}
$post = $this->createPost($workspace, $caption, $media, $socialAccount);
$this->notifyReady($workspace, $post);
}
/**
* @param array<string, mixed> $structured
*/
private function handleSingle(Workspace $workspace, ?SocialAccount $socialAccount, array $structured): void
{
$contentType = ContentType::tryFrom($this->format);
$supportsCaption = $contentType?->supportsCaption() ?? true;
$rawContent = (string) data_get($structured, 'content', data_get($structured, 'text', ''));
$media = [];
if ($this->imageCount > 0 && $socialAccount) {
$media = app(PostImagePipeline::class)->forSingle(
workspace: $workspace,
account: $socialAccount,
structured: $structured,
contentType: $this->resolvedContentType(),
);
}
$caption = $supportsCaption ? $rawContent : '';
$post = $this->createPost($workspace, $caption, $media, $socialAccount);
$this->notifyReady($workspace, $post);
}
/**
* @param array<int, array<string, mixed>> $media
*/
private function createPost(Workspace $workspace, string $content, array $media, ?SocialAccount $socialAccount): Post
private function createPostFromGenerated(Workspace $workspace, GeneratedPost $generated, ?SocialAccount $socialAccount): Post
{
$user = User::findOrFail($this->userId);
$post = CreatePost::execute($workspace, $user, [
'content' => $content,
'media' => $media,
'content' => $generated->content,
'media' => $generated->media,
'date' => $this->date,
]);
$contentType = $this->resolvedContentType();
if ($contentType && $socialAccount) {
$aspectRatio = $this->aspectRatioFor($contentType);
if ($generated->contentType && $socialAccount) {
$aspectRatio = $this->aspectRatioFor($generated->contentType);
$post->postPlatforms()
->where('social_account_id', $socialAccount->id)
->each(function ($platform) use ($aspectRatio, $contentType): void {
->each(function ($platform) use ($aspectRatio, $generated): void {
$meta = $platform->meta ?? [];
if ($aspectRatio !== null) {
$meta['aspect_ratio'] = $aspectRatio;
}
$platform->meta = $meta;
$platform->content_type = $contentType->value;
$platform->content_type = $generated->contentType->value;
$platform->enabled = true;
$platform->save();
});

View file

@ -80,6 +80,60 @@ public function forCarousel(Workspace $workspace, SocialAccount $account, array
return $media;
}
/**
* Render a tweet-card image for the given text and return a one-element
* media-item array, or an empty array when the generator renders nothing.
*
* When $imageKeywords is non-null a blurred AI-photo background is used
* (tweet_card_image); null produces the solid brand-color background (tweet_card).
*
* @param array<int, string>|null $imageKeywords
* @return array<int, array<string, mixed>>
*/
public function forTweetCard(Workspace $workspace, SocialAccount $account, string $tweetText, ?array $imageKeywords = null): array
{
$rendered = $this->generator->renderTweetCard($workspace, $account, $tweetText, $imageKeywords);
if (! $rendered) {
return [];
}
return [$this->buildAiMediaItem($workspace, $rendered)];
}
/**
* Render one tweet-card image per slide and return the media-item array.
* Slides that render nothing are skipped.
*
* Each entry in $slides is either a plain string (tweet text, solid background)
* or an array with keys `tweet_text` and optionally `image_keywords` (image bg).
*
* @param array<int, string|array<string, mixed>> $slides
* @return array<int, array<string, mixed>>
*/
public function forTweetCardCarousel(Workspace $workspace, SocialAccount $account, array $slides): array
{
$media = [];
foreach ($slides as $slide) {
if (is_string($slide)) {
$tweetText = $slide;
$imageKeywords = null;
} else {
$tweetText = (string) data_get($slide, 'tweet_text', '');
$imageKeywords = data_get($slide, 'image_keywords');
}
$rendered = $this->generator->renderTweetCard($workspace, $account, $tweetText, $imageKeywords);
if ($rendered) {
$media[] = $this->buildAiMediaItem($workspace, $rendered);
}
}
return $media;
}
/**
* Resolve the AI image dimensions for the given content type, falling back
* to the generator defaults (4:5 portrait) when no content type is known.

View file

@ -548,6 +548,285 @@ private function drawHorizontalLine($core, int $x1, int $x2, int $y, string $hex
imagecolordeallocate($core, $color);
}
/**
* Renders the post as a fake X/Twitter card.
*
* When $imageKeywords is null (default), a solid brand-color background is used
* (original tweet_card behaviour). When $imageKeywords is a non-empty array, an
* AI-generated photo is fetched, blurred, darkened, and used as the background
* (tweet_card_image behaviour). Falls back to the solid colour when AI generation
* returns null so the render never hard-fails.
*
* @param array<int, string>|null $imageKeywords
* @return array{path: string, source_meta: array<string, mixed>}|null
*/
public function renderTweetCard(Workspace $workspace, SocialAccount $socialAccount, string $tweetText, ?array $imageKeywords = null): ?array
{
$this->width = self::DEFAULT_WIDTH;
$this->height = self::DEFAULT_HEIGHT;
$manager = new ImageManager(Driver::class);
$brandColor = $workspace->brand_color ?? '#1d9bf0';
[$pr, $pg, $pb] = $this->hexToRgb($brandColor);
$canvas = $manager->createImage($this->width, $this->height);
$core = $canvas->core()->native();
imagealphablending($core, true);
imagesavealpha($core, false);
$useImageBackground = $imageKeywords !== null && $imageKeywords !== [];
if ($useImageBackground) {
$canvas = $this->applyTweetCardImageBackground($manager, $canvas, $core, $workspace, $imageKeywords);
$core = $canvas->core()->native();
} else {
$pageBg = imagecolorallocate($core, $pr, $pg, $pb);
imagefill($core, 0, 0, $pageBg);
}
$this->drawTweetCardContent($canvas, $core, $socialAccount, $tweetText);
$template = $useImageBackground ? 'tweet_card_image' : 'tweet_card';
$filename = "ai-images/tweet_{$template}_".uniqid('', true).'.webp';
Storage::put($filename, (string) $canvas->encode(new WebpEncoder(quality: 85)));
RecordAiUsage::recordTemplate(
workspace: $workspace,
provider: 'internal',
metadata: [
'template' => $template,
'width' => $this->width,
'height' => $this->height,
],
);
$sourceMeta = [
'template' => $template,
'tweet_text' => $tweetText,
'width' => $this->width,
'height' => $this->height,
];
if ($useImageBackground) {
$sourceMeta['keywords'] = array_values($imageKeywords);
}
return [
'path' => $filename,
'source_meta' => $sourceMeta,
];
}
/**
* Generate and apply the blurred + darkened AI photo background for tweet_card_image.
* Falls back to a solid brand-color fill when the AI client returns null.
*
* @param array<int, string> $imageKeywords
*/
private function applyTweetCardImageBackground(
ImageManager $manager,
ImageInterface $canvas,
mixed $core,
Workspace $workspace,
array $imageKeywords,
): ImageInterface {
$rawStyle = $workspace->image_style;
$imageStyle = match (true) {
$rawStyle instanceof ImageStyle => $rawStyle,
is_string($rawStyle) => ImageStyle::tryFrom($rawStyle) ?? ImageStyle::DEFAULT,
default => ImageStyle::DEFAULT,
};
$imageData = $this->aiImage->generate(
keywords: $imageKeywords,
style: $imageStyle,
orientation: 'portrait',
language: $workspace->content_language,
brandColor: $workspace->brand_color,
backgroundColor: $workspace->background_color,
textColor: $workspace->text_color,
brandDescription: $workspace->brand_description,
);
if ($imageData === null) {
$brandColor = $workspace->brand_color ?? '#1d9bf0';
[$pr, $pg, $pb] = $this->hexToRgb($brandColor);
$pageBg = imagecolorallocate($core, $pr, $pg, $pb);
imagefill($core, 0, 0, $pageBg);
return $canvas;
}
RecordAiUsage::recordImage(
workspace: $workspace,
provider: 'openai',
model: AiImageClient::MODEL,
metadata: [
'image_style' => $imageStyle->value,
'width' => $this->width,
'height' => $this->height,
],
);
$photo = $manager->decodeBinary($imageData)->cover($this->width, $this->height);
// Apply Gaussian blur passes to soften the background photo.
$photoCoreNative = $photo->core()->native();
for ($i = 0; $i < 8; $i++) {
imagefilter($photoCoreNative, IMG_FILTER_GAUSSIAN_BLUR);
}
// Composite the blurred photo onto the canvas.
imagecopy($core, $photoCoreNative, 0, 0, 0, 0, $this->width, $this->height);
// Paint a semi-transparent dark overlay (~50% opacity) so the white card pops.
imagealphablending($core, true);
$dark = imagecolorallocatealpha($core, 0, 0, 0, 63);
imagefilledrectangle($core, 0, 0, $this->width - 1, $this->height - 1, $dark);
imagecolordeallocate($core, $dark);
return $canvas;
}
/**
* Draw the tweet card content (rounded white card, avatar, name, badge, handle,
* body text) onto the given canvas / GD core. Shared by the solid-color and
* image-background render paths.
*/
private function drawTweetCardContent(ImageInterface $canvas, mixed $core, SocialAccount $socialAccount, string $tweetText): void
{
$cardPadding = 64;
$cardX = 72;
$cardW = $this->width - 2 * $cardX;
$cardRadius = 24;
$fontBold = $this->fontPath('Inter-Bold.ttf');
$fontMedium = $this->fontPath('Inter-Medium.ttf');
$fontLight = $this->fontPath('Inter-Light.ttf');
$avatarSize = 72;
$headerH = $avatarSize + 2 * $cardPadding;
$nameSize = 28;
$handleSize = 22;
$bodySize = 30;
$bodyLineHeight = 1.55;
$paragraphGap = (int) round($bodySize * $bodyLineHeight * 0.6);
$paragraphs = array_values(array_filter(array_map('trim', explode("\n\n", $tweetText)), fn ($p) => $p !== ''));
$allBodyLines = [];
foreach ($paragraphs as $i => $para) {
$lines = ($fontMedium ? $this->wrapText($para, $fontMedium, $bodySize, $cardW - 2 * $cardPadding) : [str_replace("\n", ' ', $para)]);
if ($i > 0) {
$allBodyLines[] = '';
}
foreach ($lines as $line) {
$allBodyLines[] = $line;
}
}
$bodyBlockH = 0;
$lineSpacing = (int) round($bodySize * $bodyLineHeight);
foreach ($allBodyLines as $line) {
$bodyBlockH += ($line === '') ? $paragraphGap : $lineSpacing;
}
$cardContentH = $headerH + 24 + $bodyBlockH + $cardPadding;
$cardH = max(400, $cardContentH);
$cardY = (int) (($this->height - $cardH) / 2);
$this->drawRoundedRect($core, $cardX, $cardY, $cardX + $cardW, $cardY + $cardH, $cardRadius, '#ffffff');
$avatarX = $cardX + $cardPadding;
$avatarY = $cardY + $cardPadding;
$avatarBinary = $this->fetchAvatarBinary($socialAccount);
if ($avatarBinary !== null) {
$this->drawCircularAvatar($canvas, $avatarBinary, $avatarX, $avatarY, $avatarSize);
}
$nameX = $avatarX + $avatarSize + 16;
$displayNameText = $socialAccount->display_name ?? '';
$handleText = '@'.($socialAccount->username ?? '');
$nameBox = $fontBold ? imagettfbbox($nameSize, 0, $fontBold, $displayNameText) : [0, 0, 0, 0, 0, 0, 0, 0];
$handleBox = $fontLight ? imagettfbbox($handleSize, 0, $fontLight, $handleText) : [0, 0, 0, 0, 0, 0, 0, 0];
$nameWidth = abs($nameBox[2] - $nameBox[0]);
$nameAscent = (int) round(abs($nameBox[7]));
$handleAscent = (int) round(abs($handleBox[7]));
// Vertically center the two-line (name + handle) block against the avatar.
$handleSpacing = (int) round($nameSize * 1.4);
$blockHeight = $handleSpacing + $handleAscent;
$nameY = $avatarY + (int) round(($avatarSize - $blockHeight) / 2);
$nameBaselineY = $nameY + $nameAscent;
$handleBaselineY = $nameY + $handleSpacing + $handleAscent;
if ($fontBold && $displayNameText !== '') {
$this->drawTextAt($core, $displayNameText, $fontBold, $nameSize, '#0f1419', $nameX, $nameBaselineY);
}
$verifiedPath = public_path('images/ai-templates/verified.png');
if ($fontBold && $displayNameText !== '' && file_exists($verifiedPath)) {
$badgeSize = (int) round($nameSize * 1.15);
$badgeX = $nameX + $nameWidth + 8;
$badgeY = $nameY + (int) round(($nameAscent - $badgeSize) / 2);
$verifiedSrc = @imagecreatefrompng($verifiedPath);
if ($verifiedSrc) {
$verifiedResized = imagecreatetruecolor($badgeSize, $badgeSize);
imagealphablending($verifiedResized, false);
imagesavealpha($verifiedResized, true);
$transparent = imagecolorallocatealpha($verifiedResized, 0, 0, 0, 127);
imagefill($verifiedResized, 0, 0, $transparent);
imagecopyresampled($verifiedResized, $verifiedSrc, 0, 0, 0, 0, $badgeSize, $badgeSize, imagesx($verifiedSrc), imagesy($verifiedSrc));
imagealphablending($core, true);
imagecopy($core, $verifiedResized, $badgeX, $badgeY, 0, 0, $badgeSize, $badgeSize);
imagedestroy($verifiedSrc);
imagedestroy($verifiedResized);
}
}
if ($fontLight && $socialAccount->username) {
$this->drawTextAt($core, $handleText, $fontLight, $handleSize, '#536471', $nameX, $handleBaselineY);
}
$bodyStartY = $cardY + $headerH + 8;
$bodyX = $cardX + $cardPadding;
if ($fontMedium) {
$ascent = (int) round($bodySize * 0.82);
$curY = $bodyStartY + $ascent;
foreach ($allBodyLines as $line) {
if ($line === '') {
$curY += $paragraphGap;
continue;
}
$color = $this->allocateColor($core, '#0f1419');
imagettftext($core, $bodySize, 0, $bodyX, $curY, $color, $fontMedium, $line);
$curY += $lineSpacing;
}
}
}
/**
* Draw a filled rounded rectangle on the GD resource.
*/
private function drawRoundedRect($core, int $x1, int $y1, int $x2, int $y2, int $radius, string $hexColor): void
{
[$r, $g, $b] = $this->hexToRgb($hexColor);
$color = imagecolorallocate($core, $r, $g, $b);
imagefilledrectangle($core, $x1 + $radius, $y1, $x2 - $radius, $y2, $color);
imagefilledrectangle($core, $x1, $y1 + $radius, $x2, $y2 - $radius, $color);
imagefilledellipse($core, $x1 + $radius, $y1 + $radius, $radius * 2, $radius * 2, $color);
imagefilledellipse($core, $x2 - $radius, $y1 + $radius, $radius * 2, $radius * 2, $color);
imagefilledellipse($core, $x1 + $radius, $y2 - $radius, $radius * 2, $radius * 2, $color);
imagefilledellipse($core, $x2 - $radius, $y2 - $radius, $radius * 2, $radius * 2, $color);
}
private function fontPath(string $filename): ?string
{
$path = base_path('resources/fonts/'.$filename);

View file

@ -285,6 +285,7 @@
'use_brand_voice_hint' => 'Apply your brand description and voice. Turn off for faithful curation of third-party sources (news, RSS).',
'use_brand_visuals' => 'Use brand visuals',
'use_brand_visuals_hint' => 'Steer AI images with your brand colors and identity. Turn off for neutral imagery driven only by the post topic.',
'style' => 'Style',
'account_summary' => ':count account · :format|:count accounts · :format',
'formats' => [
'single' => 'single',

View file

@ -260,6 +260,24 @@
'not_ai_media' => 'Only AI-generated media can be regenerated.',
],
],
'templates' => [
'image_card' => [
'name' => 'Image post',
'description' => 'AI image with a headline and caption.',
],
'carousel' => [
'name' => 'Carousel',
'description' => 'Multi-slide carousel with a caption.',
],
'tweet_card' => [
'name' => 'Tweet card',
'description' => 'Your post styled as an X/Twitter post.',
],
'tweet_card_image' => [
'name' => 'Tweet card with photo',
'description' => 'Your post as an X/Twitter card over a blurred photo.',
],
],
],
'show' => [
@ -558,6 +576,7 @@
'ai_title' => 'Generate with AI',
'ai_description' => 'Describe what you want and AI generates the content for you.',
'ai_configure_description' => 'Pick a format and describe the post you want to create.',
'ai_pick_template_description' => 'Choose a style for your AI-generated post.',
'template_title' => 'Use a template',
'template_description' => 'Pick from our curated templates and customize.',
'coming_soon' => 'Coming soon',
@ -568,6 +587,7 @@
],
'steps' => [
'template_picker_title' => 'Choose a style',
'format_title' => 'Choose a format',
'format_description' => 'Select the type of post you want to create.',
'account_title' => 'Choose an account',
@ -604,6 +624,7 @@
'retry' => 'Try again',
'no_platforms' => 'No connected accounts',
'connect_first' => 'Connect at least one social account to use AI generation.',
'no_account_for_template' => 'Connect a compatible account to use this template.',
'format' => [
'instagram_feed' => 'Instagram Feed Post',

View file

@ -285,6 +285,7 @@
'use_brand_voice_hint' => 'Aplica la descripción y la voz de tu marca. Desactiva para curaduría fiel de fuentes de terceros (noticias, RSS).',
'use_brand_visuals' => 'Usar visual de marca',
'use_brand_visuals_hint' => 'Guía las imágenes de IA con los colores e identidad de tu marca. Desactiva para imágenes neutrales, guiadas solo por el tema del post.',
'style' => 'Estilo',
'account_summary' => ':count cuenta · :format|:count cuentas · :format',
'formats' => [
'single' => 'único',

View file

@ -260,6 +260,24 @@
'not_ai_media' => 'Solo se puede regenerar contenido generado por IA.',
],
],
'templates' => [
'image_card' => [
'name' => 'Publicación con imagen',
'description' => 'Imagen con IA, titular y descripción.',
],
'carousel' => [
'name' => 'Carrusel',
'description' => 'Carrusel de varias diapositivas con descripción.',
],
'tweet_card' => [
'name' => 'Tarjeta de tweet',
'description' => 'Tu publicación con estilo de post de X/Twitter.',
],
'tweet_card_image' => [
'name' => 'Tarjeta de tweet con foto',
'description' => 'Tu publicación como tarjeta de X/Twitter sobre una foto difuminada.',
],
],
],
'show' => [
@ -558,6 +576,7 @@
'ai_title' => 'Generar con IA',
'ai_description' => 'Describe lo que quieres y la IA genera el contenido por ti.',
'ai_configure_description' => 'Elige un formato y describe el post que quieres crear.',
'ai_pick_template_description' => 'Elige un estilo para tu post generado por IA.',
'template_title' => 'Usar una plantilla',
'template_description' => 'Elige una de nuestras plantillas y personalízala.',
@ -569,6 +588,7 @@
'coming_soon' => 'Próximamente',
'steps' => [
'template_picker_title' => 'Elige un estilo',
'format_title' => 'Elige un formato',
'format_description' => 'Selecciona el tipo de post que quieres crear.',
'account_title' => 'Elige una cuenta',
@ -605,6 +625,7 @@
'retry' => 'Intentar de nuevo',
'no_platforms' => 'Sin cuentas conectadas',
'connect_first' => 'Conecta al menos una cuenta social para usar la generación con IA.',
'no_account_for_template' => 'Conecta una cuenta compatible para usar esta plantilla.',
'format' => [
'instagram_feed' => 'Post de Feed de Instagram',

View file

@ -285,6 +285,7 @@
'use_brand_voice_hint' => 'Aplica a descrição e a voz da sua marca. Desligue para curadoria fiel de fontes de terceiros (notícias, RSS).',
'use_brand_visuals' => 'Usar visual da marca',
'use_brand_visuals_hint' => 'Guia as imagens de IA com as cores e identidade da sua marca. Desligue para imagens neutras, guiadas só pelo tema do post.',
'style' => 'Estilo',
'account_summary' => ':count conta · :format|:count contas · :format',
'formats' => [
'single' => 'único',

View file

@ -260,6 +260,24 @@
'not_ai_media' => 'Só é possível regenerar mídia gerada por IA.',
],
],
'templates' => [
'image_card' => [
'name' => 'Post com imagem',
'description' => 'Imagem com IA, título e legenda.',
],
'carousel' => [
'name' => 'Carrossel',
'description' => 'Carrossel de vários slides com legenda.',
],
'tweet_card' => [
'name' => 'Card de tweet',
'description' => 'Seu post no estilo de um post do X/Twitter.',
],
'tweet_card_image' => [
'name' => 'Card de tweet com foto',
'description' => 'Seu post como card do X/Twitter sobre uma foto desfocada.',
],
],
],
'show' => [
@ -558,6 +576,7 @@
'ai_title' => 'Gerar com IA',
'ai_description' => 'Descreva o que quer e a IA gera o conteúdo pra você.',
'ai_configure_description' => 'Escolha o formato e descreva o post que quer criar.',
'ai_pick_template_description' => 'Escolha um estilo para o seu post gerado por IA.',
'template_title' => 'Usar um template',
'template_description' => 'Escolha um dos nossos templates e personalize.',
'coming_soon' => 'Em breve',
@ -568,6 +587,7 @@
],
'steps' => [
'template_picker_title' => 'Escolha um estilo',
'format_title' => 'Escolha um formato',
'format_description' => 'Selecione o tipo de post que deseja criar.',
'account_title' => 'Escolha uma conta',
@ -604,6 +624,7 @@
'retry' => 'Tentar novamente',
'no_platforms' => 'Nenhuma conta conectada',
'connect_first' => 'Conecte pelo menos uma conta social para usar a geração com IA.',
'no_account_for_template' => 'Conecte uma conta compatível para usar este template.',
'format' => [
'instagram_feed' => 'Post no Feed do Instagram',

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,98 @@
<script setup lang="ts">
import { IconCheck, IconChevronDown } from '@tabler/icons-vue';
import { computed, ref } from 'vue';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
interface StyleOption {
key: string;
preview: string;
name: string;
description?: string;
}
const props = withDefaults(
defineProps<{
modelValue: string;
styles: StyleOption[];
mini?: boolean;
}>(),
{ mini: false },
);
const emit = defineEmits<{
'update:modelValue': [string];
}>();
const open = ref(false);
const selectedOption = computed(() => props.styles.find((style) => style.key === props.modelValue) ?? props.styles[0]);
const select = (key: string) => {
emit('update:modelValue', key);
open.value = false;
};
</script>
<template>
<!-- Full gallery: one card per style, side by side. -->
<div v-if="!props.mini" class="grid gap-3 sm:grid-cols-3">
<button
v-for="style in props.styles"
:key="style.key"
type="button"
class="relative flex cursor-pointer flex-col overflow-hidden rounded-xl border-2 border-foreground bg-card text-left shadow-2xs transition-all hover:bg-foreground/5"
:class="modelValue === style.key ? '!bg-violet-100 shadow-md' : ''"
@click="select(style.key)"
>
<div class="aspect-video w-full overflow-hidden bg-muted">
<img :src="style.preview" :alt="style.name" class="size-full object-cover" />
</div>
<div class="flex items-start gap-2 p-3">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-bold text-foreground">{{ style.name }}</p>
<p v-if="style.description" class="mt-0.5 text-xs leading-snug text-foreground/60">{{ style.description }}</p>
</div>
<IconCheck v-if="modelValue === style.key" class="mt-0.5 size-4 shrink-0 text-foreground" stroke-width="3" />
</div>
</button>
</div>
<!-- Compact: collapsed to the selected style, expands into the list. -->
<Collapsible v-else v-model:open="open">
<CollapsibleTrigger as-child>
<button
type="button"
class="flex w-full cursor-pointer items-center gap-2.5 overflow-hidden rounded-xl border-2 border-foreground bg-card p-1.5 text-left transition-all hover:bg-foreground/5"
>
<div class="aspect-video w-28 shrink-0 overflow-hidden rounded-lg bg-muted">
<img :src="selectedOption.preview" :alt="selectedOption.name" class="size-full object-cover" />
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-bold text-foreground">{{ selectedOption.name }}</p>
<p v-if="selectedOption.description" class="mt-0.5 text-xs leading-snug text-foreground/60">{{ selectedOption.description }}</p>
</div>
<IconChevronDown class="mr-1 size-4 shrink-0 text-foreground/60 transition-transform" :class="open ? 'rotate-180' : ''" />
</button>
</CollapsibleTrigger>
<CollapsibleContent class="mt-2 space-y-2">
<button
v-for="style in props.styles"
:key="style.key"
type="button"
class="flex w-full cursor-pointer items-center gap-2.5 overflow-hidden rounded-xl border-2 border-foreground bg-card p-1.5 text-left transition-all hover:bg-foreground/5"
:class="modelValue === style.key ? '!bg-violet-100 shadow-md' : ''"
@click="select(style.key)"
>
<div class="aspect-video w-28 shrink-0 overflow-hidden rounded-lg bg-muted">
<img :src="style.preview" :alt="style.name" class="size-full object-cover" />
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-bold text-foreground">{{ style.name }}</p>
<p v-if="style.description" class="mt-0.5 text-xs leading-snug text-foreground/60">{{ style.description }}</p>
</div>
<IconCheck v-if="modelValue === style.key" class="mr-1 size-4 shrink-0 text-foreground" stroke-width="3" />
</button>
</CollapsibleContent>
</Collapsible>
</template>

View file

@ -1,7 +1,9 @@
<script setup lang="ts">
import { usePage } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { computed, ref, watch } from 'vue';
import ContentStylePicker from '@/components/ai/ContentStylePicker.vue';
import ChannelConfigurator from '@/components/ChannelConfigurator.vue';
import CodeEditor from '@/components/CodeEditor.vue';
import InputError from '@/components/InputError.vue';
@ -48,8 +50,26 @@ interface GenerateConfig {
prompt_template: string;
use_brand_voice: boolean;
use_brand_visuals: boolean;
style: string;
}
const CONTENT_STYLE_KEYS = ['image_card', 'tweet_card', 'tweet_card_image'] as const;
const CONTENT_STYLE_PREVIEWS: Record<string, string> = {
image_card: '/images/ai-templates/image-card.png',
tweet_card: '/images/ai-templates/tweet-card.png',
tweet_card_image: '/images/ai-templates/tweet-card-image.png',
};
const contentStyles = computed(() =>
CONTENT_STYLE_KEYS.map((key) => ({
key,
preview: CONTENT_STYLE_PREVIEWS[key],
name: trans(`posts.ai.templates.${key}.name`),
description: trans(`posts.ai.templates.${key}.description`),
})),
);
const props = defineProps<{
data: Record<string, unknown>;
errors?: Record<string, string>;
@ -143,6 +163,7 @@ const local = ref<GenerateConfig>({
prompt_template: (props.data.prompt_template as string) ?? '',
use_brand_voice: (props.data.use_brand_voice as boolean | undefined) ?? true,
use_brand_visuals: (props.data.use_brand_visuals as boolean | undefined) ?? true,
style: (props.data.style as string) ?? 'image_card',
});
watch(local, (val) => emit('update', val), { deep: true });
@ -282,6 +303,11 @@ const channels = computed<Channel[]>(() =>
/>
</div>
<div class="space-y-2">
<Label class="text-sm font-bold">{{ $t('automations.config.generate.style') }}</Label>
<ContentStylePicker v-model="local.style" :styles="contentStyles" mini />
</div>
<div v-if="imageCountCap >= 1" class="space-y-2">
<Label class="text-sm font-bold">{{ $t('automations.config.generate.image_count') }}</Label>
<p class="text-xs text-foreground/60">{{ $t('automations.config.generate.image_count_hint') }}</p>

View file

@ -10,12 +10,13 @@ import { toast } from 'vue-sonner';
import { start as startRoute } from '@/actions/App/Http/Controllers/App/PostAiCreateController';
import ContentStylePicker from '@/components/ai/ContentStylePicker.vue';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { ContentType, type ContentTypeValue } from '@/types/content-type';
import { loading as loadingRoute } from '@/routes/app/posts/ai';
import { ContentType, type ContentTypeValue } from '@/types/content-type';
interface SocialAccount {
id: string;
@ -25,8 +26,18 @@ interface SocialAccount {
avatar_url: string | null;
}
interface AiTemplate {
key: string;
name: string;
description: string;
preview: string;
needs_account: boolean;
supported_formats: string[];
}
interface Props {
socialAccounts: SocialAccount[];
templates: AiTemplate[];
/** ISO date (YYYY-MM-DD) carried over from the calendar's per-day "+" button. */
date?: string | null;
}
@ -47,6 +58,7 @@ type AiFormat = ContentTypeValue | typeof CAROUSEL_FORMAT;
// Selections
const selectedFormat = ref<AiFormat | null>(null);
const selectedStyle = ref<string>('image_card');
const selectedAccountId = ref<string | null>(null);
const includeImages = ref(true);
const imageCount = ref(2);
@ -60,7 +72,8 @@ const httpStart = useHttp<{
image_count: number;
prompt: string;
date: string | null;
}>({ format: null, social_account_id: null, image_count: 0, prompt: '', date: null });
template: string;
}>({ format: null, social_account_id: null, image_count: 0, prompt: '', date: null, template: 'image_card' });
const AI_FORMATS: Array<{ value: AiFormat; platforms: string[] }> = [
{ value: ContentType.InstagramFeed, platforms: ['instagram', 'instagram-facebook'] },
@ -76,6 +89,25 @@ const AI_FORMATS: Array<{ value: AiFormat; platforms: string[] }> = [
{ value: ContentType.PinterestPin, platforms: ['pinterest'] },
];
/** Templates with no format restriction — pure visual styles (image_card, tweet_card). */
const styleTemplates = computed(() => props.templates.filter((t) => t.supported_formats.length === 0));
/** The template whose supported_formats includes the currently selected format (e.g. carousel). */
const formatBoundTemplate = computed(() =>
selectedFormat.value
? props.templates.find((t) => t.supported_formats.includes(selectedFormat.value as string)) ?? null
: null,
);
/** The template key that will be sent to the backend. */
const resolvedTemplate = computed(() =>
formatBoundTemplate.value ? formatBoundTemplate.value.key : selectedStyle.value,
);
const resolvedTemplateRecord = computed(() =>
props.templates.find((t) => t.key === resolvedTemplate.value) ?? null,
);
const connectedPlatforms = computed(() => {
const platforms = new Set<string>();
for (const account of props.socialAccounts) {
@ -84,8 +116,6 @@ const connectedPlatforms = computed(() => {
return Array.from(platforms);
});
// Show ALL formats — disabled when the workspace has no connected account
// for that platform. Filtering them out hides the catalog from the user.
const availableFormats = computed(() => AI_FORMATS);
const isFormatConnected = (format: typeof AI_FORMATS[number]): boolean =>
@ -113,12 +143,13 @@ const supportsOptionalImages = computed(() =>
selectedFormat.value === ContentType.ThreadsPost ||
selectedFormat.value === ContentType.MastodonPost,
);
// Instagram Feed accepts only 1 image (single-image post). Others accept up to 4.
const maxOptionalImages = computed(() =>
selectedFormat.value === ContentType.InstagramFeed ? 1 : 4,
);
const showsAccountPicker = computed(() => accountsForFormat.value.length > 1);
const templateNeedsAccount = computed(() => resolvedTemplateRecord.value?.needs_account ?? false);
const submittedImageCount = computed(() => {
if (isCarousel.value) return imageCount.value;
if (requiresImage.value) return 1;
@ -145,8 +176,6 @@ watch(accountsForFormat, (accounts) => {
const selectFormat = (format: AiFormat) => {
selectedFormat.value = format;
// Sensible default per format. Picking a format always pre-selects an
// image option so the user sees a chip highlighted on arrival.
if (format === CAROUSEL_FORMAT) {
imageCount.value = 5;
} else if (format === ContentType.InstagramFeed) {
@ -163,7 +192,9 @@ emit('update:stepHeader', {
description: trans('posts.create.ai_configure_description'),
});
const goBack = () => emit('cancel');
const goBack = () => {
emit('cancel');
};
const startGeneration = async () => {
if (!canSubmit.value || submitting.value) return;
@ -175,6 +206,7 @@ const startGeneration = async () => {
httpStart.image_count = submittedImageCount.value;
httpStart.prompt = promptText.value.trim();
httpStart.date = props.date;
httpStart.template = resolvedTemplate.value;
try {
const data = await httpStart.post(startRoute.url()) as { creation_id: string; channel: string };
@ -199,7 +231,7 @@ const startGeneration = async () => {
<template>
<div class="space-y-6">
<!-- Back button — sticker arrow + ink label, mirrors the marketing site -->
<!-- Back button -->
<button
type="button"
class="group inline-flex cursor-pointer items-center gap-1.5 text-sm font-semibold text-foreground/70 transition-colors hover:text-foreground"
@ -210,119 +242,129 @@ const startGeneration = async () => {
</span>
{{ $t('posts.create.steps.back') }}
</button>
<!-- Format -->
<div class="space-y-2">
<Label class="text-sm font-bold">{{ $t('posts.create.steps.format_title') }}</Label>
<div class="grid gap-2 sm:grid-cols-2">
<button
v-for="format in availableFormats"
:key="format.value"
type="button"
class="flex cursor-pointer items-center gap-3 rounded-xl border-2 border-foreground bg-card p-3.5 text-left text-sm shadow-2xs transition-all hover:bg-foreground/5 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-card"
:class="{ '!bg-violet-100 shadow-md': selectedFormat === format.value }"
:disabled="!isFormatConnected(format)"
:title="!isFormatConnected(format) ? $t('posts.create.steps.connect_first') : ''"
@click="selectFormat(format.value)"
>
<span class="inline-flex size-7 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs">
<img
:src="getPlatformLogo(format.platforms[0])"
:alt="format.platforms[0]"
class="size-full object-cover"
/>
</span>
<span class="flex-1 font-semibold text-foreground">{{ $t(`posts.create.steps.format.${format.value}`) }}</span>
<IconCheck v-if="selectedFormat === format.value" class="size-4 text-foreground" stroke-width="3" />
</button>
</div>
</div>
<!-- Account (only when there's a choice to make) -->
<div v-if="selectedFormat && showsAccountPicker" class="space-y-2">
<Label class="text-sm font-bold">{{ $t('posts.create.steps.account_title') }}</Label>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
<button
v-for="account in accountsForFormat"
:key="account.id"
type="button"
class="relative flex cursor-pointer items-center gap-2 rounded-xl border-2 border-foreground bg-card p-2.5 text-left text-sm shadow-2xs transition-all hover:bg-foreground/5"
:class="{ '!bg-violet-100 shadow-md': selectedAccountId === account.id }"
@click="selectedAccountId = account.id"
>
<span class="inline-flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs">
<img
v-if="account.avatar_url"
:src="account.avatar_url"
:alt="account.display_name"
class="size-full object-cover"
/>
<img v-else :src="getPlatformLogo(account.platform)" :alt="account.platform" class="size-4" />
</span>
<div class="min-w-0 flex-1">
<p class="truncate text-xs font-bold leading-tight text-foreground">{{ account.display_name }}</p>
<p v-if="account.username" class="truncate text-xs font-medium text-foreground/60">@{{ account.username }}</p>
</div>
<IconCheck v-if="selectedAccountId === account.id" class="absolute right-2 top-2 size-3.5 text-foreground" stroke-width="3" />
</button>
</div>
<!-- Format -->
<div class="space-y-2">
<Label class="text-sm font-bold">{{ $t('posts.create.steps.format_title') }}</Label>
<div class="grid gap-2 sm:grid-cols-2">
<button
v-for="format in availableFormats"
:key="format.value"
type="button"
class="flex cursor-pointer items-center gap-3 rounded-xl border-2 border-foreground bg-card p-3.5 text-left text-sm shadow-2xs transition-all hover:bg-foreground/5 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-card"
:class="{ '!bg-violet-100 shadow-md': selectedFormat === format.value }"
:disabled="!isFormatConnected(format)"
:title="!isFormatConnected(format) ? $t('posts.create.steps.connect_first') : ''"
@click="selectFormat(format.value)"
>
<span class="inline-flex size-7 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs">
<img
:src="getPlatformLogo(format.platforms[0])"
:alt="format.platforms[0]"
class="size-full object-cover"
/>
</span>
<span class="flex-1 font-semibold text-foreground">{{ $t(`posts.create.steps.format.${format.value}`) }}</span>
<IconCheck v-if="selectedFormat === format.value" class="size-4 text-foreground" stroke-width="3" />
</button>
</div>
</div>
<!-- Media — inline, only when format actually has options -->
<div v-if="selectedFormat && isCarousel" class="space-y-2">
<Label class="text-sm font-bold">{{ $t('posts.create.steps.media_carousel') }}</Label>
<div class="flex flex-wrap gap-2">
<Button
v-for="n in [2, 3, 4, 5, 6, 7, 8, 9, 10]"
:key="n"
type="button"
size="icon"
:variant="imageCount === n ? 'default' : 'outline'"
@click="imageCount = n"
>
{{ n }}
</Button>
</div>
<!-- Visual style — shown only for single-image formats (not carousel) -->
<div v-if="selectedFormat && !formatBoundTemplate" class="space-y-2">
<Label class="text-sm font-bold">{{ $t('posts.create.steps.template_picker_title') }}</Label>
<ContentStylePicker v-model="selectedStyle" :styles="styleTemplates" />
</div>
<!-- Account (when template needs_account OR there's a choice to make) -->
<div v-if="selectedFormat && (templateNeedsAccount || showsAccountPicker)" class="space-y-2">
<Label class="text-sm font-bold">{{ $t('posts.create.steps.account_title') }}</Label>
<p v-if="templateNeedsAccount && accountsForFormat.length === 0" class="text-sm text-foreground/60">
{{ $t('posts.create.steps.no_account_for_template') }}
</p>
<div v-else class="grid grid-cols-2 gap-2 sm:grid-cols-3">
<button
v-for="account in accountsForFormat"
:key="account.id"
type="button"
class="relative flex cursor-pointer items-center gap-2 rounded-xl border-2 border-foreground bg-card p-2.5 text-left text-sm shadow-2xs transition-all hover:bg-foreground/5"
:class="{ '!bg-violet-100 shadow-md': selectedAccountId === account.id }"
@click="selectedAccountId = account.id"
>
<span class="inline-flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs">
<img
v-if="account.avatar_url"
:src="account.avatar_url"
:alt="account.display_name"
class="size-full object-cover"
/>
<img v-else :src="getPlatformLogo(account.platform)" :alt="account.platform" class="size-4" />
</span>
<div class="min-w-0 flex-1">
<p class="truncate text-xs font-bold leading-tight text-foreground">{{ account.display_name }}</p>
<p v-if="account.username" class="truncate text-xs font-medium text-foreground/60">@{{ account.username }}</p>
</div>
<IconCheck v-if="selectedAccountId === account.id" class="absolute right-2 top-2 size-3.5 text-foreground" stroke-width="3" />
</button>
</div>
</div>
<div v-if="selectedFormat && supportsOptionalImages" class="space-y-2">
<Label class="text-sm font-bold">{{ $t('posts.create.steps.media_optional_label') }}</Label>
<div class="flex flex-wrap gap-2">
<Button
type="button"
:variant="!includeImages ? 'default' : 'outline'"
@click="includeImages = false"
>
{{ $t('posts.create.steps.media_none') }}
</Button>
<Button
v-for="n in maxOptionalImages"
:key="n"
type="button"
size="icon"
:variant="includeImages && imageCount === n ? 'default' : 'outline'"
@click="includeImages = true; imageCount = n"
>
{{ n }}
</Button>
</div>
</div>
<!-- Prompt -->
<div v-if="selectedFormat" class="space-y-2">
<Label for="ai-prompt" class="text-sm font-bold">{{ $t('posts.create.steps.prompt_label') }}</Label>
<Textarea
id="ai-prompt"
v-model="promptText"
:placeholder="$t('posts.create.steps.prompt_placeholder')"
class="min-h-[140px] resize-none"
/>
</div>
<!-- Generate -->
<div v-if="selectedFormat" class="flex justify-end pt-1">
<Button :disabled="!canSubmit || submitting" @click="startGeneration">
{{ $t('posts.ai.generate.start') }}
<!-- Media — inline, only when format actually has options -->
<div v-if="selectedFormat && isCarousel" class="space-y-2">
<Label class="text-sm font-bold">{{ $t('posts.create.steps.media_carousel') }}</Label>
<div class="flex flex-wrap gap-2">
<Button
v-for="n in [2, 3, 4, 5, 6, 7, 8, 9, 10]"
:key="n"
type="button"
size="icon"
:variant="imageCount === n ? 'default' : 'outline'"
@click="imageCount = n"
>
{{ n }}
</Button>
</div>
</div>
<div v-if="selectedFormat && supportsOptionalImages" class="space-y-2">
<Label class="text-sm font-bold">{{ $t('posts.create.steps.media_optional_label') }}</Label>
<div class="flex flex-wrap gap-2">
<Button
type="button"
:variant="!includeImages ? 'default' : 'outline'"
@click="includeImages = false"
>
{{ $t('posts.create.steps.media_none') }}
</Button>
<Button
v-for="n in maxOptionalImages"
:key="n"
type="button"
size="icon"
:variant="includeImages && imageCount === n ? 'default' : 'outline'"
@click="includeImages = true; imageCount = n"
>
{{ n }}
</Button>
</div>
</div>
<!-- Prompt -->
<div v-if="selectedFormat" class="space-y-2">
<Label for="ai-prompt" class="text-sm font-bold">{{ $t('posts.create.steps.prompt_label') }}</Label>
<Textarea
id="ai-prompt"
v-model="promptText"
:placeholder="$t('posts.create.steps.prompt_placeholder')"
class="min-h-[140px] resize-none"
/>
</div>
<!-- Generate -->
<div v-if="selectedFormat" class="flex justify-end pt-1">
<Button :disabled="!canSubmit || submitting" @click="startGeneration">
{{ $t('posts.ai.generate.start') }}
</Button>
</div>
</div>
</template>

View file

@ -18,11 +18,21 @@ interface SocialAccount {
avatar_url: string | null;
}
interface AiTemplate {
key: string;
name: string;
description: string;
preview: string;
needs_account: boolean;
supported_formats: string[];
}
interface Props {
/** ISO date (YYYY-MM-DD). When set, the manual "start from scratch" path
* pre-schedules the new post on this date. */
date?: string | null;
socialAccounts: SocialAccount[];
templates: AiTemplate[];
}
const props = withDefaults(defineProps<Props>(), {
@ -131,6 +141,7 @@ const stepHeader = computed(() => {
<AiPostWizard
v-else-if="view === 'ai'"
:social-accounts="socialAccounts"
:templates="templates"
:date="props.date"
@update:step-header="aiHeader = $event"
@cancel="view = 'choice'; aiHeader = null"

View file

@ -0,0 +1,35 @@
You write X/Twitter posts for the brand "{{ $brand_name }}".
@if(!empty($brand_description))
About the brand: {{ $brand_description }}
@endif
@if(!empty($brand_voice_traits))
Brand voice — follow these exactly:
@include('prompts.post_content._voice', ['brand_voice_traits' => $brand_voice_traits])
@endif
@if(!empty($current_content))
The user already has this content in the editor (use as context only — your output replaces it):
"""
{{ $current_content }}
"""
@endif
Write the output in the language with code: {{ $content_language ?? 'en' }}.
Rules:
- First-person voice, writing as the brand owner.
- Lead with a hook — the first line must stop the scroll. State the real point, observation, or claim immediately.
- Short paragraph breaks only where they aid rhythm. Use `\n\n` between paragraphs; never more than 2 paragraphs.
- Avoid AI-clichés (testament, pivotal moment, "Let's dive in", emojis on every line).
- No threads, no numbered lists, no bullet points. Pure prose.
- Match the brand voice guidelines exactly.
CRITICAL — length for {{ $platform_label ?? 'X' }}:
- Aim for around {{ $target_chars }} characters in the `tweet_text` field. This is the engagement sweet spot.
- Hard cap (must NEVER exceed): {{ $hard_max_chars }} characters total — including spaces, line breaks, hashtags and emojis.
- Going LONGER than ~{{ $target_chars }} chars hurts performance. High-performing X posts are punchy and direct.
- Count before responding. Stop when you've said it.
Output format: a JSON object with a single key:
- `tweet_text`: the complete tweet text in {{ $content_language ?? 'en' }} (no preamble, no quotation marks). This is what gets displayed on the card.

View file

@ -0,0 +1,45 @@
You write carousel posts for the brand "{{ $brand_name }}". Each slide will be rendered as a fake X/Twitter card image, so every slide's text must read like a standalone tweet.
@if(!empty($brand_description))
About the brand: {{ $brand_description }}
@endif
@if(!empty($brand_voice_traits))
Brand voice — follow these exactly:
@include('prompts.post_content._voice', ['brand_voice_traits' => $brand_voice_traits])
@endif
@if(!empty($current_content))
The user already has this content in the editor (use as context only — your output replaces it):
"""
{{ $current_content }}
"""
@endif
Write all text in the language with code: {{ $content_language ?? 'en' }}.
Rules:
- First-person voice, writing as the brand owner.
- Each slide must open with a hook — the first sentence must stop the scroll.
- Short paragraph breaks only where they aid rhythm. Use `\n\n` between paragraphs inside a single slide; never more than 2 paragraphs per slide.
- Avoid AI-clichés (testament, pivotal moment, "Let's dive in", emojis on every line).
- No threads, no numbered lists, no bullet points. Pure prose per slide.
- Each slide must be self-contained and punchy — it will appear as a card image.
- Match the brand voice guidelines exactly.
CRITICAL — length for each slide's `tweet_text`:
- Aim for around {{ $target_chars }} characters per slide.
- Hard cap (must NEVER exceed): {{ $hard_max_chars }} characters per slide — including spaces, line breaks, hashtags and emojis.
- High-performing tweet-style content is punchy and direct. Stop when you've said it.
Output format: a JSON object with:
- `caption`: the overall carousel caption in {{ $content_language ?? 'en' }} — teases what's inside the slides, encourages swiping. No preamble, no quotation marks.
- `slides`: an array of exactly {{ $slide_count ?? 1 }} objects, each with a single key:
- `tweet_text`: the tweet-style text for that slide in {{ $content_language ?? 'en' }}. Self-contained. Hook in the first sentence.
CRITICAL: The `slides` array MUST contain exactly {{ $slide_count ?? 1 }} items — no fewer, no more. Count carefully before responding.
## Carousel arc — how to sequence the slides
Plan the arc before writing. A carousel is a sequence, not a list:
- First slide: open with a specific, real, urgent claim or problem the reader recognises immediately.
- Middle slides: develop the idea — one angle or one step per slide. Alternate between "how it works" and "proof / concrete result".
- Last slide: end with a single, specific next action the reader can take right now.

View file

@ -0,0 +1,41 @@
You write X/Twitter posts for the brand "{{ $brand_name }}". Each post will be rendered as a fake X/Twitter card on a **blurred photo background** — the photo will be chosen from the `image_keywords` you provide and darkened behind the white card.
@if(!empty($brand_description))
About the brand: {{ $brand_description }}
@endif
@if(!empty($brand_voice_traits))
Brand voice — follow these exactly:
@include('prompts.post_content._voice', ['brand_voice_traits' => $brand_voice_traits])
@endif
@if(!empty($current_content))
The user already has this content in the editor (use as context only — your output replaces it):
"""
{{ $current_content }}
"""
@endif
Write the output in the language with code: {{ $content_language ?? 'en' }}.
Rules:
- First-person voice, writing as the brand owner.
- Lead with a hook — the first line must stop the scroll. State the real point, observation, or claim immediately.
- Short paragraph breaks only where they aid rhythm. Use `\n\n` between paragraphs; never more than 2 paragraphs.
- Avoid AI-clichés (testament, pivotal moment, "Let's dive in", emojis on every line).
- No threads, no numbered lists, no bullet points. Pure prose.
- Match the brand voice guidelines exactly.
CRITICAL — length for {{ $platform_label ?? 'X' }}:
- Aim for around {{ $target_chars }} characters in the `tweet_text` field. This is the engagement sweet spot.
- Hard cap (must NEVER exceed): {{ $hard_max_chars }} characters total — including spaces, line breaks, hashtags and emojis.
- Going LONGER than ~{{ $target_chars }} chars hurts performance. High-performing X posts are punchy and direct.
- Count before responding. Stop when you've said it.
Image keywords rules:
- `image_keywords` must be 2-4 short English words describing a real-world photo scene that fits the post's theme (e.g. "coffee laptop morning", "team brainstorm office").
- Choose evocative, concrete nouns. Avoid abstract or meta words like "success" or "productivity".
- The photo will be blurred and darkened — choose scenes that work as textured backgrounds.
Output format: a JSON object with two keys:
- `tweet_text`: the complete tweet text in {{ $content_language ?? 'en' }} (no preamble, no quotation marks). This is what gets displayed on the card.
- `image_keywords`: an array of 2-4 English strings for the background photo search.

View file

@ -0,0 +1,52 @@
You write carousel posts for the brand "{{ $brand_name }}". Each slide will be rendered as a fake X/Twitter card on a **blurred photo background** — every slide gets its own photo, chosen from the `image_keywords` you provide per slide and darkened behind the white card.
@if(!empty($brand_description))
About the brand: {{ $brand_description }}
@endif
@if(!empty($brand_voice_traits))
Brand voice — follow these exactly:
@include('prompts.post_content._voice', ['brand_voice_traits' => $brand_voice_traits])
@endif
@if(!empty($current_content))
The user already has this content in the editor (use as context only — your output replaces it):
"""
{{ $current_content }}
"""
@endif
Write all text in the language with code: {{ $content_language ?? 'en' }}.
Rules:
- First-person voice, writing as the brand owner.
- Each slide must open with a hook — the first sentence must stop the scroll.
- Short paragraph breaks only where they aid rhythm. Use `\n\n` between paragraphs inside a single slide; never more than 2 paragraphs per slide.
- Avoid AI-clichés (testament, pivotal moment, "Let's dive in", emojis on every line).
- No threads, no numbered lists, no bullet points. Pure prose per slide.
- Each slide must be self-contained and punchy — it will appear as a card image.
- Match the brand voice guidelines exactly.
CRITICAL — length for each slide's `tweet_text`:
- Aim for around {{ $target_chars }} characters per slide.
- Hard cap (must NEVER exceed): {{ $hard_max_chars }} characters per slide — including spaces, line breaks, hashtags and emojis.
- High-performing tweet-style content is punchy and direct. Stop when you've said it.
Image keywords rules (per slide):
- Each slide's `image_keywords` must be 2-4 short English words describing a real-world photo scene that fits that slide's theme.
- Choose evocative, concrete nouns. Avoid abstract or meta words like "success" or "productivity".
- Each slide should have DIFFERENT keywords so each card gets a visually distinct blurred background.
- The photo will be blurred and darkened — choose scenes that work as textured backgrounds.
Output format: a JSON object with:
- `caption`: the overall carousel caption in {{ $content_language ?? 'en' }} — teases what's inside the slides, encourages swiping. No preamble, no quotation marks.
- `slides`: an array of exactly {{ $slide_count ?? 1 }} objects, each with:
- `tweet_text`: the tweet-style text for that slide in {{ $content_language ?? 'en' }}. Self-contained. Hook in the first sentence.
- `image_keywords`: an array of 2-4 English strings for this slide's background photo.
CRITICAL: The `slides` array MUST contain exactly {{ $slide_count ?? 1 }} items — no fewer, no more. Count carefully before responding.
## Carousel arc — how to sequence the slides
Plan the arc before writing. A carousel is a sequence, not a list:
- First slide: open with a specific, real, urgent claim or problem the reader recognises immediately.
- Middle slides: develop the idea — one angle or one step per slide. Alternate between "how it works" and "proof / concrete result".
- Last slide: end with a single, specific next action the reader can take right now.

View file

@ -188,3 +188,56 @@
->get(route('app.posts.ai.loading', 'not-a-uuid'))
->assertStatus(Response::HTTP_NOT_FOUND);
});
it('defaults to the image_card template when none is given', function () {
Bus::fake();
$this->actingAs($this->user)
->postJson(route('app.posts.ai.create'), [
'format' => 'instagram_feed',
'prompt' => 'a post about coffee',
])
->assertAccepted();
Bus::assertDispatched(StreamPostCreation::class, fn ($job) => $job->template === 'image_card');
});
it('rejects an unknown template', function () {
Bus::fake();
$this->actingAs($this->user)
->postJson(route('app.posts.ai.create'), [
'format' => 'instagram_feed',
'prompt' => 'x',
'template' => 'bogus',
])
->assertUnprocessable()
->assertJsonValidationErrors(['template']);
});
it('allows image_card without a social account', function () {
Bus::fake();
$this->actingAs($this->user)
->postJson(route('app.posts.ai.create'), [
'format' => 'instagram_feed',
'prompt' => 'a post about coffee',
'template' => 'image_card',
])
->assertAccepted();
});
it('requires social_account_id when the template needsAccount', function () {
Bus::fake();
$this->actingAs($this->user)
->postJson(route('app.posts.ai.create'), [
'format' => 'x_post',
'prompt' => 'a punchy take on productivity',
'template' => 'tweet_card',
])
->assertUnprocessable()
->assertJsonValidationErrors(['social_account_id']);
Bus::assertNotDispatched(StreamPostCreation::class);
});

View file

@ -31,7 +31,7 @@
]);
});
function runStreamPostCreation(string $format, SocialAccount $account, int $imageCount): void
function runStreamPostCreation(string $format, SocialAccount $account, int $imageCount, string $template = 'image_card'): void
{
(new StreamPostCreation(
userId: $account->workspace->user_id,
@ -41,12 +41,11 @@ function runStreamPostCreation(string $format, SocialAccount $account, int $imag
socialAccountId: $account->id,
imageCount: $imageCount,
prompt: 'Five tips about productivity',
template: $template,
))->handle();
}
test('AI carousel generation stores the post as an instagram feed, never instagram_carousel', function () {
// Empty image_keywords make TemplateImageGenerator::render() return null, so
// the storage decision is exercised without touching the image pipeline.
PostContentGenerator::fake([[
'caption' => 'Swipe to see the tips',
'slides' => [
@ -62,7 +61,7 @@ function runStreamPostCreation(string $format, SocialAccount $account, int $imag
],
]]);
runStreamPostCreation('instagram_carousel', $this->account, 2);
runStreamPostCreation('instagram_carousel', $this->account, 2, 'image_card');
$platform = PostPlatform::where('social_account_id', $this->account->id)->firstOrFail();
@ -90,6 +89,140 @@ function runStreamPostCreation(string $format, SocialAccount $account, int $imag
expect($platform->content_type)->toBe(ContentType::InstagramFeed);
});
test('tweet_card template stores the tweet_text as post content and attaches a media item', function () {
PostContentGenerator::fake([[
'tweet_text' => 'Hello world\n\nSecond para.',
]]);
$minimalPng = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==');
Image::fake([base64_encode($minimalPng)]);
(new StreamPostCreation(
userId: $this->user->id,
creationId: (string) Str::uuid(),
workspaceId: $this->workspace->id,
format: 'x_post',
socialAccountId: $this->account->id,
imageCount: 1,
prompt: 'A punchy take on productivity',
template: 'tweet_card',
))->handle();
$post = $this->user->currentWorkspace->posts()->latest()->first();
expect($post->content)->toBe('Hello world\n\nSecond para.')
->and($post->media)->toHaveCount(1);
$platform = PostPlatform::where('social_account_id', $this->account->id)->firstOrFail();
expect($platform->content_type)->toBe(ContentType::XPost);
PostContentHumanizer::assertNeverPrompted();
});
test('tweet_card carousel produces caption as content and N media items without calling AI image client', function () {
PostContentGenerator::fake([[
'caption' => 'A carousel of punchy takes',
'slides' => [
['tweet_text' => 'First take on productivity.'],
['tweet_text' => 'Second take on deep work.'],
],
]]);
(new StreamPostCreation(
userId: $this->user->id,
creationId: (string) Str::uuid(),
workspaceId: $this->workspace->id,
format: 'instagram_carousel',
socialAccountId: $this->account->id,
imageCount: 2,
prompt: 'Punchy productivity takes',
template: 'tweet_card',
))->handle();
$post = $this->user->currentWorkspace->posts()->latest()->first();
expect($post->content)->toBe('A carousel of punchy takes')
->and($post->media)->toHaveCount(2);
$platform = PostPlatform::where('social_account_id', $this->account->id)->firstOrFail();
expect($platform->content_type)->toBe(ContentType::InstagramFeed);
Image::assertNothingGenerated();
PostContentHumanizer::assertNeverPrompted();
});
test('tweet_card_image single stores tweet_text as content, attaches media, and calls AI image client', function () {
PostContentGenerator::fake([[
'tweet_text' => 'Productivity is a mindset.',
'image_keywords' => ['laptop', 'desk', 'morning'],
]]);
$minimalPng = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==');
Image::fake([base64_encode($minimalPng)]);
(new StreamPostCreation(
userId: $this->user->id,
creationId: (string) Str::uuid(),
workspaceId: $this->workspace->id,
format: 'x_post',
socialAccountId: $this->account->id,
imageCount: 1,
prompt: 'A punchy take on productivity',
template: 'tweet_card_image',
))->handle();
$post = $this->user->currentWorkspace->posts()->latest()->first();
expect($post->content)->toBe('Productivity is a mindset.')
->and($post->media)->toHaveCount(1);
$platform = PostPlatform::where('social_account_id', $this->account->id)->firstOrFail();
expect($platform->content_type)->toBe(ContentType::XPost);
PostContentHumanizer::assertNeverPrompted();
Image::assertGenerated(fn () => true);
});
test('tweet_card_image carousel stores caption and N media items each with an AI background', function () {
PostContentGenerator::fake([[
'caption' => 'Carousel about deep work',
'slides' => [
['tweet_text' => 'First take.', 'image_keywords' => ['coffee', 'focus']],
['tweet_text' => 'Second take.', 'image_keywords' => ['notebook', 'pen']],
],
]]);
$minimalPng = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==');
Image::fake([base64_encode($minimalPng), base64_encode($minimalPng)]);
(new StreamPostCreation(
userId: $this->user->id,
creationId: (string) Str::uuid(),
workspaceId: $this->workspace->id,
format: 'instagram_carousel',
socialAccountId: $this->account->id,
imageCount: 2,
prompt: 'Deep work carousel',
template: 'tweet_card_image',
))->handle();
$post = $this->user->currentWorkspace->posts()->latest()->first();
expect($post->content)->toBe('Carousel about deep work')
->and($post->media)->toHaveCount(2);
$platform = PostPlatform::where('social_account_id', $this->account->id)->firstOrFail();
expect($platform->content_type)->toBe(ContentType::InstagramFeed);
PostContentHumanizer::assertNeverPrompted();
Image::assertGenerated(fn () => true);
});
test('the humanizer is given the same platform context as the generator so the rewrite honours the character cap', function () {
PostContentGenerator::fake([[
'content' => 'A single productivity tip',

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
use App\Ai\Templates\AiTemplateRegistry;
use App\Ai\Templates\ImageCardTemplate;
use App\Ai\Templates\TweetCardImageTemplate;
use App\Ai\Templates\TweetCardTemplate;
use App\Enums\Ai\ContentStyle;
test('registry resolves keys and defaults to image_card', function () {
$registry = app(AiTemplateRegistry::class);
expect($registry->keys())->toBe(['image_card', 'tweet_card', 'tweet_card_image'])
->and($registry->find('image_card'))->toBeInstanceOf(ImageCardTemplate::class)
->and($registry->find('tweet_card_image'))->toBeInstanceOf(TweetCardImageTemplate::class)
->and($registry->default()->key())->toBe('image_card');
});
test('registry find accepts a ContentStyle enum', function () {
$registry = app(AiTemplateRegistry::class);
expect($registry->find(ContentStyle::TweetCard))->toBeInstanceOf(TweetCardTemplate::class)
->and($registry->find(ContentStyle::ImageCard))->toBeInstanceOf(ImageCardTemplate::class)
->and($registry->find(ContentStyle::TweetCardImage))->toBeInstanceOf(TweetCardImageTemplate::class);
});
test('registry find accepts a string key', function () {
$registry = app(AiTemplateRegistry::class);
expect($registry->find('tweet_card'))->toBeInstanceOf(TweetCardTemplate::class);
});
test('registry styles returns all ContentStyle cases', function () {
$registry = app(AiTemplateRegistry::class);
expect($registry->styles())->toBe(ContentStyle::cases());
});
test('registry throws on unknown key', function () {
expect(fn () => app(AiTemplateRegistry::class)->find('nope'))->toThrow(InvalidArgumentException::class);
});
test('registry does not contain carousel as a standalone style', function () {
$registry = app(AiTemplateRegistry::class);
expect($registry->keys())->not->toContain('carousel');
});

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
use App\Ai\Templates\ImageCardTemplate;
use App\Ai\Templates\TemplateContext;
use App\Enums\Ai\ContentStyle;
use App\Models\Workspace;
use Illuminate\JsonSchema\JsonSchemaTypeFactory;
test('image card template exposes its identity', function () {
$t = new ImageCardTemplate;
$workspace = Workspace::factory()->create();
$context = new TemplateContext($workspace, null, 'instagram_feed', 0, false);
expect($t->style())->toBe(ContentStyle::ImageCard)
->and($t->key())->toBe('image_card')
->and($t->needsAccount())->toBeFalse()
->and($t->promptView($context))->toBe('prompts.post_content.generator')
->and($t->supportedFormats())->toBe([]);
});
test('image card carousel context returns carousel schema shape', function () {
$t = new ImageCardTemplate;
$workspace = Workspace::factory()->create();
$context = new TemplateContext($workspace, null, 'instagram_carousel', 3, true);
$schema = new JsonSchemaTypeFactory;
$result = $t->schema($schema, $context);
expect($result)->toHaveKeys(['caption', 'slides'])
->and($t->promptView($context))->toBe('prompts.post_content.generator');
});
test('image card single context returns single schema shape', function () {
$t = new ImageCardTemplate;
$workspace = Workspace::factory()->create();
$context = new TemplateContext($workspace, null, 'instagram_feed', 1, false);
$schema = new JsonSchemaTypeFactory;
$result = $t->schema($schema, $context);
expect($result)->toHaveKeys(['content', 'image_title', 'image_body', 'image_keywords']);
});

View file

@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
use App\Models\SocialAccount;
use App\Models\Workspace;
use App\Services\Ai\AiImageClient;
use App\Services\Image\PostImagePipeline;
use App\Services\Image\TemplateImageGenerator;
use Illuminate\Support\Facades\Storage;
use Laravel\Ai\Image;
test('renderTweetCard with image keywords generates an AI image background', function () {
Storage::fake();
$minimalPng = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==');
Image::fake([base64_encode($minimalPng)]);
$workspace = Workspace::factory()->create(['brand_color' => '#1d9bf0']);
$account = SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'display_name' => 'Alan Nicolas',
'username' => 'oalanicolas',
]);
$result = app(TemplateImageGenerator::class)->renderTweetCard(
$workspace,
$account,
'A punchy tweet about productivity.',
['productivity', 'laptop', 'morning'],
);
expect($result)->not->toBeNull()
->and($result['path'])->toEndWith('.webp')
->and(Storage::exists($result['path']))->toBeTrue()
->and(data_get($result, 'source_meta.template'))->toBe('tweet_card_image')
->and(data_get($result, 'source_meta.keywords'))->toBe(['productivity', 'laptop', 'morning']);
Image::assertGenerated(fn () => true);
});
test('renderTweetCard with image keywords falls back to solid color when AI returns null', function () {
Storage::fake();
$aiImageMock = Mockery::mock(AiImageClient::class);
$aiImageMock->shouldReceive('generate')->andReturn(null);
$this->app->instance(AiImageClient::class, $aiImageMock);
$workspace = Workspace::factory()->create(['brand_color' => '#1d9bf0']);
$account = SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'display_name' => 'Alan Nicolas',
'username' => 'oalanicolas',
]);
$result = app(TemplateImageGenerator::class)->renderTweetCard(
$workspace,
$account,
'A punchy tweet about productivity.',
['productivity', 'laptop', 'morning'],
);
expect($result)->not->toBeNull()
->and($result['path'])->toEndWith('.webp')
->and(Storage::exists($result['path']))->toBeTrue()
->and(data_get($result, 'source_meta.template'))->toBe('tweet_card_image');
});
test('renderTweetCard without image keywords does not call the AI image client', function () {
Storage::fake();
Image::fake();
$workspace = Workspace::factory()->create(['brand_color' => '#1d9bf0']);
$account = SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'display_name' => 'Test User',
'username' => 'testuser',
]);
$result = app(TemplateImageGenerator::class)->renderTweetCard(
$workspace,
$account,
'A solid background tweet.',
);
expect($result)->not->toBeNull()
->and(data_get($result, 'source_meta.template'))->toBe('tweet_card');
Image::assertNothingGenerated();
});
test('forTweetCard with image keywords delegates keywords to the generator', function () {
Storage::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->create(['workspace_id' => $workspace->id]);
$mock = Mockery::mock(TemplateImageGenerator::class)->makePartial();
$mock->shouldReceive('renderTweetCard')
->withArgs(fn ($w, $a, $text, $keywords) => $keywords === ['nature', 'forest'])
->andReturn(['path' => 'ai-images/tweet_tweet_card_image_fake.webp', 'source_meta' => ['template' => 'tweet_card_image']]);
$this->app->instance(TemplateImageGenerator::class, $mock);
Storage::put('ai-images/tweet_tweet_card_image_fake.webp', 'fakecontent');
$result = app(PostImagePipeline::class)->forTweetCard($workspace, $account, 'some text', ['nature', 'forest']);
expect($result)->toHaveCount(1);
});
test('forTweetCardCarousel accepts slide arrays with image_keywords', function () {
Storage::fake();
Image::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->create(['workspace_id' => $workspace->id]);
$aiImageMock = Mockery::mock(AiImageClient::class);
$minimalPng = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==');
$aiImageMock->shouldReceive('generate')->andReturn($minimalPng);
$this->app->instance(AiImageClient::class, $aiImageMock);
$slides = [
['tweet_text' => 'First punchy take.', 'image_keywords' => ['coffee', 'morning']],
['tweet_text' => 'Second punchy take.', 'image_keywords' => ['office', 'desk']],
];
$result = app(PostImagePipeline::class)->forTweetCardCarousel($workspace, $account, $slides);
expect($result)->toHaveCount(2);
});
test('forTweetCardCarousel still accepts plain string slides for backward compat', function () {
Storage::fake();
Image::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->create(['workspace_id' => $workspace->id]);
$result = app(PostImagePipeline::class)->forTweetCardCarousel(
$workspace,
$account,
['First slide text.', 'Second slide text.'],
);
expect($result)->toHaveCount(2);
Image::assertNothingGenerated();
});

View file

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
use App\Ai\Templates\TemplateContext;
use App\Ai\Templates\TweetCardImageTemplate;
use App\Enums\Ai\ContentStyle;
use App\Models\Workspace;
use Illuminate\JsonSchema\JsonSchemaTypeFactory;
test('tweet card image template identity', function () {
$t = new TweetCardImageTemplate;
$workspace = Workspace::factory()->create();
$singleContext = new TemplateContext($workspace, null, 'x_post', 1, false);
$carouselContext = new TemplateContext($workspace, null, 'instagram_carousel', 2, true);
expect($t->style())->toBe(ContentStyle::TweetCardImage)
->and($t->key())->toBe('tweet_card_image')
->and($t->needsAccount())->toBeTrue()
->and($t->style()->isTweetCard())->toBeTrue()
->and($t->promptView($singleContext))->toBe('prompts.post_content.tweet_card_image')
->and($t->promptView($carouselContext))->toBe('prompts.post_content.tweet_card_image_carousel');
});
test('tweet card image single schema returns tweet_text and image_keywords', function () {
$t = new TweetCardImageTemplate;
$workspace = Workspace::factory()->create();
$context = new TemplateContext($workspace, null, 'x_post', 1, false);
$schema = new JsonSchemaTypeFactory;
$result = $t->schema($schema, $context);
expect($result)->toHaveKey('tweet_text')
->and($result)->toHaveKey('image_keywords')
->and($result)->not->toHaveKey('caption');
});
test('tweet card image carousel schema returns caption and slides with image_keywords', function () {
$t = new TweetCardImageTemplate;
$workspace = Workspace::factory()->create();
$context = new TemplateContext($workspace, null, 'instagram_carousel', 3, true);
$schema = new JsonSchemaTypeFactory;
$result = $t->schema($schema, $context);
expect($result)->toHaveKeys(['caption', 'slides']);
});

View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
use App\Models\SocialAccount;
use App\Models\Workspace;
use App\Services\Image\PostImagePipeline;
use App\Services\Image\TemplateImageGenerator;
use Illuminate\Support\Facades\Storage;
use Laravel\Ai\Image;
test('renderTweetCard produces a stored webp without calling the AI image client', function () {
Storage::fake();
Image::fake();
$workspace = Workspace::factory()->create(['brand_color' => '#1d9bf0']);
$account = SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'display_name' => 'Alan Nicolas',
'username' => 'oalanicolas',
]);
$result = app(TemplateImageGenerator::class)->renderTweetCard(
$workspace,
$account,
"Aí cai a ficha.\n\nIsso tem nome: você está sendo subsidiado.",
);
expect($result)->not->toBeNull()
->and($result['path'])->toEndWith('.webp')
->and(Storage::exists($result['path']))->toBeTrue()
->and(data_get($result, 'source_meta.template'))->toBe('tweet_card');
Image::assertNothingGenerated();
});
test('renderTweetCard succeeds when the account has no avatar_url', function () {
Storage::fake();
Image::fake();
$workspace = Workspace::factory()->create(['brand_color' => '#1d9bf0']);
$account = SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'display_name' => 'No Avatar User',
'username' => 'noavatar',
'avatar_url' => null,
]);
$result = app(TemplateImageGenerator::class)->renderTweetCard(
$workspace,
$account,
'A tweet with no avatar attached.',
);
expect($result)->not->toBeNull()
->and($result['path'])->toEndWith('.webp')
->and(Storage::exists($result['path']))->toBeTrue();
});
test('forTweetCard returns [] when renderTweetCard returns null', function () {
Storage::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->create(['workspace_id' => $workspace->id]);
$mock = Mockery::mock(TemplateImageGenerator::class)->makePartial();
$mock->shouldReceive('renderTweetCard')->andReturn(null);
$this->app->instance(TemplateImageGenerator::class, $mock);
$result = app(PostImagePipeline::class)->forTweetCard($workspace, $account, 'some text');
expect($result)->toBe([]);
});

View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
use App\Ai\Templates\TemplateContext;
use App\Ai\Templates\TweetCardTemplate;
use App\Enums\Ai\ContentStyle;
use App\Models\Workspace;
use Illuminate\JsonSchema\JsonSchemaTypeFactory;
test('tweet card template identity', function () {
$t = new TweetCardTemplate;
$workspace = Workspace::factory()->create();
$singleContext = new TemplateContext($workspace, null, 'x_post', 1, false);
$carouselContext = new TemplateContext($workspace, null, 'instagram_carousel', 2, true);
expect($t->style())->toBe(ContentStyle::TweetCard)
->and($t->key())->toBe('tweet_card')
->and($t->needsAccount())->toBeTrue()
->and($t->style()->isTweetCard())->toBeTrue()
->and($t->promptView($singleContext))->toBe('prompts.post_content.tweet_card')
->and($t->promptView($carouselContext))->toBe('prompts.post_content.tweet_card_carousel');
});
test('tweet card single schema returns tweet_text only', function () {
$t = new TweetCardTemplate;
$workspace = Workspace::factory()->create();
$context = new TemplateContext($workspace, null, 'x_post', 1, false);
$schema = new JsonSchemaTypeFactory;
$result = $t->schema($schema, $context);
expect($result)->toHaveKey('tweet_text')
->and($result)->not->toHaveKey('caption');
});
test('tweet card carousel schema returns caption and slides', function () {
$t = new TweetCardTemplate;
$workspace = Workspace::factory()->create();
$context = new TemplateContext($workspace, null, 'instagram_carousel', 3, true);
$schema = new JsonSchemaTypeFactory;
$result = $t->schema($schema, $context);
expect($result)->toHaveKeys(['caption', 'slides']);
});

View file

@ -60,6 +60,49 @@
expect($automation->fresh()->nodes)->toHaveCount(3);
});
it('persists the generate node style through a save round-trip', function () {
$automation = Automation::factory()->for($this->workspace)->create();
$payload = [
'nodes' => [
['id' => 'n1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule', 'cron' => '0 9 * * *']],
['id' => 'n2', 'type' => 'generate', 'position' => ['x' => 200, 'y' => 0], 'data' => [
'accounts' => [['social_account_id' => 'acc-1']],
'prompt_template' => 'hi',
'style' => 'tweet_card',
]],
],
'connections' => [['id' => 'e1', 'source' => 'n1', 'target' => 'n2']],
];
$this->actingAs($this->user)
->put(route('app.automations.update', $automation->id), $payload)
->assertRedirect();
$node = collect($automation->fresh()->nodes)->firstWhere('type', 'generate');
expect($node['data']['style'])->toBe('tweet_card');
});
it('rejects an unknown generate node style', function () {
$automation = Automation::factory()->for($this->workspace)->create();
$payload = [
'nodes' => [
['id' => 'n1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule', 'cron' => '0 9 * * *']],
['id' => 'n2', 'type' => 'generate', 'position' => ['x' => 200, 'y' => 0], 'data' => [
'accounts' => [['social_account_id' => 'acc-1']],
'prompt_template' => 'hi',
'style' => 'not_a_real_style',
]],
],
'connections' => [['id' => 'e1', 'source' => 'n1', 'target' => 'n2']],
];
$this->actingAs($this->user)
->put(route('app.automations.update', $automation->id), $payload)
->assertSessionHasErrors('nodes.1.data.style');
});
it('persists every trigger schedule editor field through a save round-trip', function (array $scheduleData) {
$automation = Automation::factory()->for($this->workspace)->create();

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Actions\Automation\Node\RunGenerateNode;
use App\Enums\Ai\GeneratorFormat;
use App\Enums\UserWorkspace\Role;
use App\Models\Automation;
use App\Models\User;
@ -128,20 +129,20 @@
// tiktok_photo allows 35, but AI generation is capped at 10.
expect($node->deriveFormat([['content_type' => 'tiktok_photo']], ['target_slide_count' => 15]))
->toBe(['format' => 'carousel', 'slide_count' => 10]);
->toBe(['format' => GeneratorFormat::Carousel, 'slide_count' => 10]);
// pinterest_carousel allows only 5.
expect($node->deriveFormat([['content_type' => 'pinterest_carousel']], ['target_slide_count' => 8]))
->toBe(['format' => 'carousel', 'slide_count' => 5]);
->toBe(['format' => GeneratorFormat::Carousel, 'slide_count' => 5]);
});
it('recognises facebook_post as multi-image capable (rules-derived, not a hardcoded list)', function () {
$node = app(RunGenerateNode::class);
expect($node->deriveFormat([['content_type' => 'facebook_post']], ['target_slide_count' => 6]))
->toBe(['format' => 'carousel', 'slide_count' => 6]);
->toBe(['format' => GeneratorFormat::Carousel, 'slide_count' => 6]);
// A single-image (video) format never becomes a carousel.
expect($node->deriveFormat([['content_type' => 'tiktok_video']], ['target_slide_count' => 5]))
->toBe(['format' => 'single', 'slide_count' => 1]);
->toBe(['format' => GeneratorFormat::Single, 'slide_count' => 1]);
});

View file

@ -5,6 +5,8 @@
use App\Actions\Automation\Node\RunGenerateNode;
use App\Ai\Agents\PostContentGenerator;
use App\Ai\Agents\PostContentHumanizer;
use App\Enums\Ai\ContentStyle;
use App\Enums\Ai\GeneratorFormat;
use App\Enums\Post\Status as PostStatus;
use App\Enums\PostPlatform\ContentType;
use App\Models\Automation;
@ -179,7 +181,7 @@
'target_slide_count' => 2,
]);
PostContentGenerator::assertPrompted(fn ($prompt) => $prompt->agent->format === 'carousel'
PostContentGenerator::assertPrompted(fn ($prompt) => $prompt->agent->format === GeneratorFormat::Carousel
&& $prompt->agent->platformContext === ContentType::XPost->value);
});
@ -259,7 +261,7 @@
['format' => $format, 'slide_count' => $slideCount] = $action->deriveFormat($accountsConfig, ['target_slide_count' => 5]);
expect($format)->toBe('carousel');
expect($format)->toBe(GeneratorFormat::Carousel);
expect($slideCount)->toBe(5);
});
@ -275,7 +277,7 @@
['format' => $format, 'slide_count' => $slideCount] = $action->deriveFormat($accountsConfig, ['target_slide_count' => 1]);
expect($format)->toBe('single');
expect($format)->toBe(GeneratorFormat::Single);
expect($slideCount)->toBe(1);
});
@ -289,7 +291,7 @@
['format' => $format, 'slide_count' => $slideCount] = $action->deriveFormat($accountsConfig, ['target_slide_count' => 5]);
expect($format)->toBe('single');
expect($format)->toBe(GeneratorFormat::Single);
expect($slideCount)->toBe(1);
});
@ -457,3 +459,223 @@
$run->refresh();
expect($run->generated_post_id)->toBeNull();
});
it('defaults to image_card style when no style is set, behaving identically to before', function () {
PostContentGenerator::fake([
['content' => 'Legacy post', 'image_title' => 'Title', 'image_body' => 'Body', 'image_keywords' => ['kw']],
]);
PostContentHumanizer::fake([
['content' => 'Legacy post', 'image_title' => 'Title', 'image_body' => 'Body'],
]);
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->for($workspace)->create(['platform' => 'x']);
$mediaItem = ['id' => 1, 'path' => 'ai-images/x.webp', 'url' => 'http://x', 'type' => 'image', 'mime_type' => 'image/webp', 'source' => 'ai', 'source_meta' => []];
$pipeline = Mockery::mock(PostImagePipeline::class);
$pipeline->shouldReceive('forSingle')->once()->andReturn([$mediaItem]);
app()->instance(PostImagePipeline::class, $pipeline);
$automation = Automation::factory()->for($workspace)->create();
$run = AutomationRun::factory()->for($automation)->create([
'context' => ['trigger' => ['title' => 'test']],
]);
$result = app(RunGenerateNode::class)($run, [
'accounts' => [
['social_account_id' => (string) $account->id, 'content_type' => ContentType::XPost->value, 'meta' => []],
],
'prompt_template' => 'Write about {{ trigger.title }}',
'target_slide_count' => 1,
]);
expect($result->status->value)->toBe('completed');
$post = Post::find($result->output['generated']['post_id']);
expect($post)->not->toBeNull();
expect($post->content)->toBe('Legacy post');
expect($post->media)->toHaveCount(1);
});
it('generates a tweet card post when style is tweet_card', function () {
PostContentGenerator::fake([
['tweet_text' => 'Hot take on AI agents today.'],
]);
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->for($workspace)->create(['platform' => 'x']);
$mediaItem = ['id' => 2, 'path' => 'ai-images/tweet.webp', 'url' => 'http://tweet', 'type' => 'image', 'mime_type' => 'image/webp', 'source' => 'ai', 'source_meta' => []];
$pipeline = Mockery::mock(PostImagePipeline::class);
$pipeline->shouldReceive('forTweetCard')->once()->andReturn([$mediaItem]);
$pipeline->shouldNotReceive('forSingle');
$pipeline->shouldNotReceive('forCarousel');
app()->instance(PostImagePipeline::class, $pipeline);
$automation = Automation::factory()->for($workspace)->create();
$run = AutomationRun::factory()->for($automation)->create([
'context' => ['trigger' => ['title' => 'test']],
]);
$result = app(RunGenerateNode::class)($run, [
'style' => ContentStyle::TweetCard->value,
'accounts' => [
['social_account_id' => (string) $account->id, 'content_type' => ContentType::XPost->value, 'meta' => []],
],
'prompt_template' => 'Write a tweet about {{ trigger.title }}',
'target_slide_count' => 1,
]);
expect($result->status->value)->toBe('completed');
$post = Post::find($result->output['generated']['post_id']);
expect($post)->not->toBeNull();
expect($post->content)->toBe('Hot take on AI agents today.');
expect($post->media)->toHaveCount(1);
});
it('skips the humanizer for tweet_card style', function () {
PostContentGenerator::fake([
['tweet_text' => 'Raw tweet, no humanizer.'],
]);
PostContentHumanizer::fake([]);
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->for($workspace)->create(['platform' => 'x']);
$pipeline = Mockery::mock(PostImagePipeline::class);
$pipeline->shouldReceive('forTweetCard')->once()->andReturn([]);
app()->instance(PostImagePipeline::class, $pipeline);
$automation = Automation::factory()->for($workspace)->create();
$run = AutomationRun::factory()->for($automation)->create([
'context' => ['trigger' => ['title' => 'test']],
]);
app(RunGenerateNode::class)($run, [
'style' => ContentStyle::TweetCard->value,
'accounts' => [
['social_account_id' => (string) $account->id, 'content_type' => ContentType::XPost->value, 'meta' => []],
],
'prompt_template' => 'Write a tweet about {{ trigger.title }}',
'target_slide_count' => 1,
]);
PostContentHumanizer::assertNeverPrompted();
});
it('generates a tweet card with AI image background when style is tweet_card_image', function () {
PostContentGenerator::fake([
['tweet_text' => 'AI is transforming everything.', 'image_keywords' => ['artificial intelligence', 'technology']],
]);
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->for($workspace)->create(['platform' => 'x']);
$mediaItem = ['id' => 3, 'path' => 'ai-images/tweet-img.webp', 'url' => 'http://tweet-img', 'type' => 'image', 'mime_type' => 'image/webp', 'source' => 'ai', 'source_meta' => []];
$pipeline = Mockery::mock(PostImagePipeline::class);
$pipeline->shouldReceive('forTweetCard')
->once()
->withArgs(function ($ws, $acc, $text, $keywords) {
return $text === 'AI is transforming everything.'
&& is_array($keywords)
&& count($keywords) > 0;
})
->andReturn([$mediaItem]);
$pipeline->shouldNotReceive('forSingle');
$pipeline->shouldNotReceive('forCarousel');
app()->instance(PostImagePipeline::class, $pipeline);
$automation = Automation::factory()->for($workspace)->create();
$run = AutomationRun::factory()->for($automation)->create([
'context' => ['trigger' => ['title' => 'test']],
]);
$result = app(RunGenerateNode::class)($run, [
'style' => ContentStyle::TweetCardImage->value,
'accounts' => [
['social_account_id' => (string) $account->id, 'content_type' => ContentType::XPost->value, 'meta' => []],
],
'prompt_template' => 'Write a tweet about {{ trigger.title }}',
'target_slide_count' => 1,
]);
expect($result->status->value)->toBe('completed');
$post = Post::find($result->output['generated']['post_id']);
expect($post)->not->toBeNull();
expect($post->content)->toBe('AI is transforming everything.');
expect($post->media)->toHaveCount(1);
});
it('skips the humanizer for tweet_card_image style', function () {
PostContentGenerator::fake([
['tweet_text' => 'Raw tweet with image.', 'image_keywords' => ['tech']],
]);
PostContentHumanizer::fake([]);
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->for($workspace)->create(['platform' => 'x']);
$pipeline = Mockery::mock(PostImagePipeline::class);
$pipeline->shouldReceive('forTweetCard')->once()->andReturn([]);
app()->instance(PostImagePipeline::class, $pipeline);
$automation = Automation::factory()->for($workspace)->create();
$run = AutomationRun::factory()->for($automation)->create([
'context' => ['trigger' => ['title' => 'test']],
]);
app(RunGenerateNode::class)($run, [
'style' => ContentStyle::TweetCardImage->value,
'accounts' => [
['social_account_id' => (string) $account->id, 'content_type' => ContentType::XPost->value, 'meta' => []],
],
'prompt_template' => 'Write a tweet about {{ trigger.title }}',
'target_slide_count' => 1,
]);
PostContentHumanizer::assertNeverPrompted();
});
it('dry run returns sensible image_count for tweet_card style', function () {
PostContentGenerator::fake([
['tweet_text' => 'A tweet for dry run.'],
]);
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->for($workspace)->create(['platform' => 'x']);
$pipeline = Mockery::mock(PostImagePipeline::class);
$pipeline->shouldNotReceive('forTweetCard');
$pipeline->shouldNotReceive('forSingle');
$pipeline->shouldNotReceive('forCarousel');
app()->instance(PostImagePipeline::class, $pipeline);
$automation = Automation::factory()->for($workspace)->create();
$run = AutomationRun::factory()->for($automation)->create([
'is_dry_run' => true,
'context' => ['trigger' => ['title' => 'test']],
]);
$result = app(RunGenerateNode::class)($run, [
'style' => ContentStyle::TweetCard->value,
'accounts' => [
['social_account_id' => (string) $account->id, 'content_type' => ContentType::XPost->value, 'meta' => []],
],
'prompt_template' => 'Write about {{ trigger.title }}',
'target_slide_count' => 1,
]);
expect($result->status->value)->toBe('completed');
expect($result->output['generated']['dry_run'])->toBeTrue();
expect($result->output['generated']['image_count'])->toBe(1);
expect($result->output['generated']['post_id'])->toBeNull();
expect(Post::count())->toBe(0);
});

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Ai\Agents\PostContentGenerator;
use App\Enums\Ai\GeneratorFormat;
use App\Models\Workspace;
use Illuminate\JsonSchema\JsonSchemaTypeFactory;
@ -60,7 +61,7 @@
test('single format schema returns content and image_keywords', function () {
$workspace = Workspace::factory()->make();
$agent = new PostContentGenerator(workspace: $workspace, format: 'single');
$agent = new PostContentGenerator(workspace: $workspace, format: GeneratorFormat::Single);
$schemaFactory = new JsonSchemaTypeFactory;
$schema = $agent->schema($schemaFactory);
@ -73,7 +74,7 @@
test('carousel format schema returns caption and slides', function () {
$workspace = Workspace::factory()->make();
$agent = new PostContentGenerator(workspace: $workspace, format: 'carousel', slideCount: 5);
$agent = new PostContentGenerator(workspace: $workspace, format: GeneratorFormat::Carousel, slideCount: 5);
$schemaFactory = new JsonSchemaTypeFactory;
$schema = $agent->schema($schemaFactory);
@ -85,7 +86,7 @@
test('carousel slide schema includes role enum', function () {
$workspace = Workspace::factory()->make();
$agent = new PostContentGenerator(workspace: $workspace, format: 'carousel', slideCount: 4);
$agent = new PostContentGenerator(workspace: $workspace, format: GeneratorFormat::Carousel, slideCount: 4);
$schemaFactory = new JsonSchemaTypeFactory;
$schema = $agent->schema($schemaFactory);
@ -100,7 +101,7 @@
test('carousel instructions mention slide count', function () {
$workspace = Workspace::factory()->make();
$agent = new PostContentGenerator(workspace: $workspace, format: 'carousel', slideCount: 5);
$agent = new PostContentGenerator(workspace: $workspace, format: GeneratorFormat::Carousel, slideCount: 5);
$instructions = $agent->instructions();
@ -110,7 +111,7 @@
test('carousel instructions describe the roteiro arc with hook, development, proof and cta', function () {
$workspace = Workspace::factory()->make();
$agent = new PostContentGenerator(workspace: $workspace, format: 'carousel', slideCount: 4);
$agent = new PostContentGenerator(workspace: $workspace, format: GeneratorFormat::Carousel, slideCount: 4);
$instructions = $agent->instructions();
@ -123,7 +124,7 @@
test('single format instructions do not include carousel roteiro section', function () {
$workspace = Workspace::factory()->make();
$agent = new PostContentGenerator(workspace: $workspace, format: 'single');
$agent = new PostContentGenerator(workspace: $workspace, format: GeneratorFormat::Single);
$instructions = $agent->instructions();
@ -133,7 +134,7 @@
test('single instructions mention image_keywords', function () {
$workspace = Workspace::factory()->make();
$agent = new PostContentGenerator(workspace: $workspace, format: 'single');
$agent = new PostContentGenerator(workspace: $workspace, format: GeneratorFormat::Single);
$instructions = $agent->instructions();
@ -143,7 +144,7 @@
test('instructions include examples when platform context provided and templates exist', function () {
$agent = new PostContentGenerator(
workspace: Workspace::factory()->make(),
format: 'carousel',
format: GeneratorFormat::Carousel,
slideCount: 3,
platformContext: 'instagram_carousel',
);
@ -161,7 +162,7 @@
test('instructions do not include examples section when platform context is null', function () {
$agent = new PostContentGenerator(
workspace: Workspace::factory()->make(),
format: 'single',
format: GeneratorFormat::Single,
);
expect($agent->instructions())->not->toContain('curated library');
@ -170,7 +171,7 @@
test('instructions inject the platform character cap from the shared budget', function () {
$agent = new PostContentGenerator(
workspace: Workspace::factory()->make(),
format: 'single',
format: GeneratorFormat::Single,
platformContext: 'x_post',
);

View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
use App\Enums\Ai\ContentStyle;
test('enum has exactly 3 cases with the correct string values', function () {
expect(ContentStyle::cases())->toHaveCount(3);
expect(ContentStyle::ImageCard->value)->toBe('image_card');
expect(ContentStyle::TweetCard->value)->toBe('tweet_card');
expect(ContentStyle::TweetCardImage->value)->toBe('tweet_card_image');
});
test('default returns ImageCard', function () {
expect(ContentStyle::default())->toBe(ContentStyle::ImageCard);
});
test('needsAccount is false only for ImageCard', function () {
expect(ContentStyle::ImageCard->needsAccount())->toBeFalse();
expect(ContentStyle::TweetCard->needsAccount())->toBeTrue();
expect(ContentStyle::TweetCardImage->needsAccount())->toBeTrue();
});
test('humanizes is true only for ImageCard', function () {
expect(ContentStyle::ImageCard->humanizes())->toBeTrue();
expect(ContentStyle::TweetCard->humanizes())->toBeFalse();
expect(ContentStyle::TweetCardImage->humanizes())->toBeFalse();
});
test('previewAsset returns the correct public path for each case', function () {
expect(ContentStyle::ImageCard->previewAsset())->toBe('/images/ai-templates/image-card.png');
expect(ContentStyle::TweetCard->previewAsset())->toBe('/images/ai-templates/tweet-card.png');
expect(ContentStyle::TweetCardImage->previewAsset())->toBe('/images/ai-templates/tweet-card-image.png');
});
test('label returns the expected i18n key format', function () {
expect(ContentStyle::ImageCard->label())->toBe('posts.ai.templates.image_card.name');
expect(ContentStyle::TweetCard->label())->toBe('posts.ai.templates.tweet_card.name');
expect(ContentStyle::TweetCardImage->label())->toBe('posts.ai.templates.tweet_card_image.name');
});
test('description returns the expected i18n key format', function () {
expect(ContentStyle::ImageCard->description())->toBe('posts.ai.templates.image_card.description');
expect(ContentStyle::TweetCard->description())->toBe('posts.ai.templates.tweet_card.description');
expect(ContentStyle::TweetCardImage->description())->toBe('posts.ai.templates.tweet_card_image.description');
});