Merge pull request #104 from trypostit/feat/ai-template-gallery
feat: AI template gallery + tweet-card
This commit is contained in:
commit
0a407c4d3b
52 changed files with 2698 additions and 309 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
53
app/Ai/Templates/AiContentTemplate.php
Normal file
53
app/Ai/Templates/AiContentTemplate.php
Normal 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;
|
||||
}
|
||||
57
app/Ai/Templates/AiTemplateRegistry.php
Normal file
57
app/Ai/Templates/AiTemplateRegistry.php
Normal 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);
|
||||
}
|
||||
}
|
||||
19
app/Ai/Templates/Concerns/ResolvesContentType.php
Normal file
19
app/Ai/Templates/Concerns/ResolvesContentType.php
Normal 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);
|
||||
}
|
||||
}
|
||||
23
app/Ai/Templates/GeneratedPost.php
Normal file
23
app/Ai/Templates/GeneratedPost.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
149
app/Ai/Templates/ImageCardTemplate.php
Normal file
149
app/Ai/Templates/ImageCardTemplate.php
Normal 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);
|
||||
}
|
||||
}
|
||||
23
app/Ai/Templates/TemplateContext.php
Normal file
23
app/Ai/Templates/TemplateContext.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
164
app/Ai/Templates/TweetCardImageTemplate.php
Normal file
164
app/Ai/Templates/TweetCardImageTemplate.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
151
app/Ai/Templates/TweetCardTemplate.php
Normal file
151
app/Ai/Templates/TweetCardTemplate.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
66
app/Enums/Ai/ContentStyle.php
Normal file
66
app/Enums/Ai/ContentStyle.php
Normal 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;
|
||||
}
|
||||
}
|
||||
16
app/Enums/Ai/GeneratorFormat.php
Normal file
16
app/Enums/Ai/GeneratorFormat.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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']));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
BIN
public/images/ai-templates/image-card.png
Normal file
BIN
public/images/ai-templates/image-card.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 497 KiB |
BIN
public/images/ai-templates/tweet-card-image.png
Normal file
BIN
public/images/ai-templates/tweet-card-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 285 KiB |
BIN
public/images/ai-templates/tweet-card.png
Normal file
BIN
public/images/ai-templates/tweet-card.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 158 KiB |
BIN
public/images/ai-templates/verified.png
Normal file
BIN
public/images/ai-templates/verified.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
98
resources/js/components/ai/ContentStylePicker.vue
Normal file
98
resources/js/components/ai/ContentStylePicker.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
35
resources/views/prompts/post_content/tweet_card.blade.php
Normal file
35
resources/views/prompts/post_content/tweet_card.blade.php
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
43
tests/Feature/Ai/Templates/AiTemplateRegistryTest.php
Normal file
43
tests/Feature/Ai/Templates/AiTemplateRegistryTest.php
Normal 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');
|
||||
});
|
||||
44
tests/Feature/Ai/Templates/TemplateContractTest.php
Normal file
44
tests/Feature/Ai/Templates/TemplateContractTest.php
Normal 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']);
|
||||
});
|
||||
150
tests/Feature/Ai/Templates/TweetCardImageRenderTest.php
Normal file
150
tests/Feature/Ai/Templates/TweetCardImageRenderTest.php
Normal 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();
|
||||
});
|
||||
47
tests/Feature/Ai/Templates/TweetCardImageTemplateTest.php
Normal file
47
tests/Feature/Ai/Templates/TweetCardImageTemplateTest.php
Normal 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']);
|
||||
});
|
||||
72
tests/Feature/Ai/Templates/TweetCardRenderTest.php
Normal file
72
tests/Feature/Ai/Templates/TweetCardRenderTest.php
Normal 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([]);
|
||||
});
|
||||
46
tests/Feature/Ai/Templates/TweetCardTemplateTest.php
Normal file
46
tests/Feature/Ai/Templates/TweetCardTemplateTest.php
Normal 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']);
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
|
||||
|
|
|
|||
46
tests/Unit/Enums/ContentStyleTest.php
Normal file
46
tests/Unit/Enums/ContentStyleTest.php
Normal 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');
|
||||
});
|
||||
Loading…
Reference in a new issue