From ebd9a2ac0f356ef643443111ec71939f5ed269fb Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 17:03:42 -0300 Subject: [PATCH 01/31] feat(ai-templates): content template contract + value objects --- app/Ai/Templates/AiContentTemplate.php | 60 ++++++++++++++++++++++++++ app/Ai/Templates/GeneratedPost.php | 23 ++++++++++ app/Ai/Templates/TemplateContext.php | 21 +++++++++ 3 files changed, 104 insertions(+) create mode 100644 app/Ai/Templates/AiContentTemplate.php create mode 100644 app/Ai/Templates/GeneratedPost.php create mode 100644 app/Ai/Templates/TemplateContext.php diff --git a/app/Ai/Templates/AiContentTemplate.php b/app/Ai/Templates/AiContentTemplate.php new file mode 100644 index 00000000..8ec3d62a --- /dev/null +++ b/app/Ai/Templates/AiContentTemplate.php @@ -0,0 +1,60 @@ + + */ + public function supportedFormats(): array; + + /** The 'format' string passed to the generator agent (e.g. 'single'). */ + public function generatorFormat(): string; + + /** The Blade view path for the generator prompt. */ + public function promptView(): string; + + /** + * The structured-output schema for the generator. + * + * @return array + */ + public function schema(JsonSchema $schema, TemplateContext $context): array; + + /** + * Text fields (by structured-output key) the humanizer should rewrite. + * Empty = skip humanization. + * + * @return array + */ + public function humanizableFields(): array; + + /** + * Build the post from the (humanized) structured output. + * + * @param array $structured + */ + public function assemble(array $structured, TemplateContext $context): GeneratedPost; +} diff --git a/app/Ai/Templates/GeneratedPost.php b/app/Ai/Templates/GeneratedPost.php new file mode 100644 index 00000000..690807e6 --- /dev/null +++ b/app/Ai/Templates/GeneratedPost.php @@ -0,0 +1,23 @@ +> $media + */ + public function __construct( + public string $content, + public array $media, + public ?ContentType $contentType, + ) {} +} diff --git a/app/Ai/Templates/TemplateContext.php b/app/Ai/Templates/TemplateContext.php new file mode 100644 index 00000000..9a0cb16b --- /dev/null +++ b/app/Ai/Templates/TemplateContext.php @@ -0,0 +1,21 @@ + Date: Wed, 17 Jun 2026 17:05:53 -0300 Subject: [PATCH 02/31] feat(ai-templates): extract image-card + carousel templates --- app/Ai/Templates/CarouselTemplate.php | 104 ++++++++++++++++++ .../Concerns/ResolvesContentType.php | 19 ++++ app/Ai/Templates/ImageCardTemplate.php | 99 +++++++++++++++++ public/images/ai-templates/carousel.png | Bin 0 -> 17438 bytes public/images/ai-templates/image-card.png | Bin 0 -> 17438 bytes .../Ai/Templates/TemplateContractTest.php | 23 ++++ 6 files changed, 245 insertions(+) create mode 100644 app/Ai/Templates/CarouselTemplate.php create mode 100644 app/Ai/Templates/Concerns/ResolvesContentType.php create mode 100644 app/Ai/Templates/ImageCardTemplate.php create mode 100644 public/images/ai-templates/carousel.png create mode 100644 public/images/ai-templates/image-card.png create mode 100644 tests/Feature/Ai/Templates/TemplateContractTest.php diff --git a/app/Ai/Templates/CarouselTemplate.php b/app/Ai/Templates/CarouselTemplate.php new file mode 100644 index 00000000..9bdf3fcf --- /dev/null +++ b/app/Ai/Templates/CarouselTemplate.php @@ -0,0 +1,104 @@ +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(), + ]; + } + + public function humanizableFields(): array + { + return ['caption' => 'caption']; + } + + /** + * @param array $structured + */ + public function assemble(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, + ); + } + + return new GeneratedPost($caption, $media, ContentType::InstagramFeed); + } +} diff --git a/app/Ai/Templates/Concerns/ResolvesContentType.php b/app/Ai/Templates/Concerns/ResolvesContentType.php new file mode 100644 index 00000000..20087553 --- /dev/null +++ b/app/Ai/Templates/Concerns/ResolvesContentType.php @@ -0,0 +1,19 @@ + $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(), + ]; + } + + public function humanizableFields(): array + { + return [ + 'content' => 'content', + 'image_title' => 'image_title', + 'image_body' => 'image_body', + ]; + } + + /** + * @param array $structured + */ + public function assemble(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, + ); + } + + $caption = $supportsCaption ? $rawContent : ''; + + return new GeneratedPost($caption, $media, $contentType); + } +} diff --git a/public/images/ai-templates/carousel.png b/public/images/ai-templates/carousel.png new file mode 100644 index 0000000000000000000000000000000000000000..389d7eb5efbddeee1f0a26a9de0de70e9db0a322 GIT binary patch literal 17438 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 v0LDj))a5JG^^NkWs}K|xUI2qGd)Q4qmGM-Wh& zR1u{~RZxobb^?~SeD6K~Z|2@PXYS0KnUH7itlidLd;QjL?GS&~Scj5~l?(&|QR?Yx zn&3YrkKQCi_`jSgaTOqtGzN)Kw>bKA@o~35s>XjhASJyJAX*Sj7zk7fI+g8Ba9n|x zGW;oJ^h3)1uTlsHPkWSt55_~m#|Q18AY+d}+MjZi6qB=;MTtq`WgQS=2q`%S zS$QdhybK(`j|i^(*{Sf4P7&Du7+M^G#iB4cw5JCa-vBiHvw^^I1A9-*KQuu8mM%f! zCGI#Oe$<5Db^P(koUH8&?u48G`&MjGKKfxs{8{?BCz{XZ|u8G&^ccR=8d#6f{a z3%lon@<+P)V2^YYo{z%f5EvW_x1$@%!wKhnLOR631L2PHIO^FE=8o_{JK{x<2sbB3 ztTRGVM%Lk|M+?|CaoNE@oWk4T_skQh&_0eGE!CW?MLK>@^TjtbvZ?M!FNA~c(U?D8 z5b2*&zK)d6`AtXoBEOvH4D&WvKH1q0(^mq4CKp&ZtqazY7Ee98ql(M%MlLj0d#ZEcc zJBk60(vAR1&R$wdTF&!$R9pNm9u47%@t6IvmuHttHyi-cYw)y)j zmp{xuA|L64aq}1ayX+ql631Bc$@h#!3&*A0ML-Vo)Q6v#lqs;Dk!7@cGf3htLG!lh{^8m1;uL3d>PzvM_L$TSdcX#C0XKqLOTOc=oVTMeXO zCfcJRJiV~0h$FWqjyF#r0?hMgRe=dX1QB3r5Wb!a5&;H-zV(BOf@74@)LvE!iyC^g z+FQP3XO?_k;z6BVclOy@!=#%L@@nKu#3Pk+XXun@Cob0b6fPY|6z|d?_8jPP0Pcs8 z<_IcBrp5r~(O7{YjpZ$?-48e8qflJQjt&bvA#Jv0b{_qNoF-aaAOqZVRVI?Zw|i|c)_ z>E(8n3XD z^kDEI6as(%s-qJ1Q3*-_Ar8aq8eiszG6Rf1l`a7WQxK7e5`hT`i3y-U__ZSe7&JTv z2#*B9!+uLa2mmbL{Yw%80ZtM~ADr`%rAQzVwsHs=q!a=`%1OcDa?*e-!odM9F9nyh zN5buql2VQmhWI&PaT+Fu;`r8p+^-dZ!B9~^7!dqb2EZ|Fe;UaXiS_#PPypA_ym+B3 z08<#l1>wt*Mx6)KGRBQueAsTR>HTq+KU*yRa1%s8z#wkq;NGbHx4YCKB4##-YW5lHH9pqoRq?GicHjs&np`*8lrb9w(>P7nbAR)8TNLs5R=80%E ziX}Iv6Hz8g)&+~C6kHqZisE$YcZ(aP=|IpA$yFzrcy5mAsw0#vm?U^|3!!kn*H=Hc zvpCU~ZY1AO56&}qZ57H#c#=J{QB`B_G^UElQlfAZbvb%_!?OI0Z8e;?M-;&LA;$Vwo9aELdo;jFh+Ot;nW>d+>t3WyWtK3~6Zu1E>t%1po zB=)J2gUj^~M9qz{k5_J;k$q@cI&@7U_^q3o;V!$raq@~odntGO_qhQRkts`3;owhD zszldac!#$0#+Stjm~t<1qPNV9pJ<+zT1eJw8@J{u?$P}4-ZwQ<9Upbz^{f5#!MmS` zBI{Jw8x)4kJK`UlboegZzE4{i7ir=cHhPYjOewVIl3hwh(?ai-lTBff6Etr63r<Jv=1b0V1IBKOuplxY{f*ezuP& z-lywocKp=etOjuVtFQe30uoLD%y`qqLjuhY&u!v~KfM}iFdfMwB!J@~UsC=z5=i4= zLGsiuB>2AsP!W*-^#70}{|~|YfBdm}$|YV`M_OsE>vlj9`Oppg`J6w0L6~xUQN`mp zhxZ%tzTw;39BZk2{uzlG*LtSEvY%I`#9H}vSI`wR`lAffR*6r1YvE`Pf3-qjGh%AZ ziHBIO^fP^|AswH;4==yOxPQ;zOi!lN$XwrQc6pQpwdk8kACq;PI^+wHY?MyOFSac0 z)8y7-U^QbTybXb@S}#^8`1Hz?=Ooc17hIp%s)L6^og zyoJo%=UZDZ-D(P@X(xC;_^B<@%w0-tcuQ%k<+2==VN9;W0p(9&a}B&>BXx7lc*RKA$3WXq??>)u$)R$Vl&E zLpGbOOV*b0_jnK1fhASBQU;cd5USg*Y+UEp&rsKh$rUW;h0P>SL`~8v^9b){2W?(1 zZZL0|eeB>u%owRvdW}m-mL>~I&Dg=_m%pvu&XXf!lOo|eH@m4-A+K0kQnKM#&|};8 z{QSF?*d2*SwOZJ$DxWN6y@!~omO-g*|AW4PF+JmO^~x^(cS=!DS!t~8u77H7@Rp&S zlL-x`zJrUb%uf_#3fV=z;_yQ&9&)MCHwb6NCfueB0PZn4m2c=pD_6I;3Rnr4u1hpFcH9T)D1tB5+MH#i(q2l!ci>@dKNGS z41d?-;Zg-q`UNHsFcbV}L@b_$`T6<%X-Ic87Ab*;GKr(8$` z5K581iN!j%-ty@OHX=pKNd6tE8lKfNo;BRo=1l9)J0cl(Q>tVp?!Yl8Zk-&G3HTZp zMdyorx#rMfCM+00rbVkvNe!*I+p~IoPl1bzD`u*)pJeiyR>CFwr?{aDJhP16wMFc; z@rrsxgT&T9A-!nURRakg&K61<7AkHU1 z55ot;I?^x`Q?;YB3NzJJ!>2G{W@vYmDGuT8h4yg5YM8!gTWovRpJPirETHj3G-RDOs(39e1BX*10D~_CSGxw><_G z7Ge_BD8}VezrP#T?;>jJ-*Lk`Bx2QACVIZfb5sABp^BFtay)# zcX|K`5s_0k1sKm|@ILp*q<$F7pS{`(rV)3eXbG&aZ9m+ahWzRQBLYONzLo2J(an0{0YJ3UBk`}WSR!!5-Vran;e@U&^4t1EZC zgcmyN9tzkevKu=VU+ZwFfa}_cGGFN-YrS}HG*G)-mOjPiR(oqV&#N1imONqwtA1}Y z!qr2ROuYyY@{7@x!H$}ZL)XXm-b;+6O4JQIIJL4gkSW!&y)jgr%K$lPHtM5&L6yGh z4bdZcxnJpx;yT8&NzQmo_7)F)xGNP< zo5e`7bW`4gO?a=$hX$wX-g{7{2Q$qVb8{|KZh4*6OYdwe6`qOFxKWkkIOKJOy%Y#Vgj1g0hcT%1L4Y4<7=+hua6q_F|_$7`H_6= zww%7?fvLe0k%xF9lJ?UT63fax8lJa{g@GaPDKqjE`Peez*ALBaaz~j`Xo7lmw(CrT*}Qa`>X;FHiV?#2x=n zWaCwg43S=ClM+Vn-1#pryP~fPhTKtC~mkXDd72e%-9x%f7q)VA$x>Pm z&axFZIp9=_8ZoUejJYbREQ|sUhLf;2ADon*x~Ds|Tn?u%xgeY-D83$Bm}KnBcwWFO zLm)JdRR^Z=Ndp-c=;I;CpPZp^inelNBX?Hu;$rw155q`=NZXBuys=Np`PiK~E-SZ` zRg+X&Q45@^(0!~%_qhHYcGpv_!fUTf)3EB!x2v@)a}@3l5==BK=bwJ9{((g|ltH?u z&+z`YY^4LLXAUAeK0}Tw;c6M&VE0)CaY(371nq1f=qyD@tT%hEpMX5<_E;s z%>((y?Z?19!{LPteGBD&g1Cdqr2b0=zI5kqb|g?JUcACn+$2D<%|Js(5Nyj^hU4N+DswL~903OvTR0_gt|Vbp%L0w6ImzYy=gK$xQwsw0g@Hyohx136Cv3c#s6**xhO$zT0o z-2V5-`MVRh{{rCuni@YwtYFJ)jW?@3GfFghz#)!9@3_?`%UQ%}jOcp+)9wbA-L|tf zQwGAjURNkfh0%NHrXCLB1!cSTcjPnH7l++rccNhY(r~A)YPvMfma~TT926uG#Y}Z2 z<~@wqL@*D3cPd&%$8Q&dV;DZXf9A@`%lD(ZG;Ejbs!PvYcg#JRJ8|W~%zi?xgFvj? zjk=tmUN5qsZ^gAfXI@xsme4%5SvV9ji(ekR;_Zui-?@yr%e&Jk7@_$vQ;_XGAE!Xm z(=m}R8@Uw$nc;WMWA2k_b&R(p=FMg7?iS`?9<{VJHMw|?yrK^yZK=N96&uS}bLOtz zb3e!HE0|jg1`v}XxsaQt4-`2_^r%z1WqCEn0DWsn&I-S~Z)STebHo zJzv)c4u6H|+_D*I?;FDcuG6Qm_Y1*i>Qof{%M8nQTT%m;$ZVzLW7YgGpIW#z`H^>@ zL#V%PdwwOuc31RW$f|aZ&q+?flLDijjDegduF5{UIWW7*oN4*mYgw)#12hg}IwQDi z=frf!@x{mUjS9jsntCrIZR5O|C|B4{<0_Tt%+PL$geIf@ z96c4ssN5;mJE9v)LqU7+2=G(_LLb<-F_`bWN5}Y@M362~^;>TP7e_;UC1~ehCV7}W6 zak6G{Pvp)Xbo9;Y%jL6@G&OU*;*}Kc?o780^XOfAc&5ehMxv3AZ}BoB;Ij*Jp&HR0I%K zLJ*XpW*o~OILe#fW*l!bzDFBfoP8Di&qk-s2b8rj zfwVoZ$#~v)vC4VmUW+nk<=E!=YV7*lMjN)Uww1iE2?jlGrc0o+X-JQmfHL%!kokG~ zn3mW+$=@1u1@Yzy6HRKa*HC`2Ex+JqDj^lc`l%lD(d*0;L7Pj{>0uqO)he#VHc+x= zJ;i97_(xGT(9Owsy3BX0zqmNAw{gRHpuYY6<*fF})%1nzYI^G7?Y+vImT?T{^8|<( z6aya{@=F-crDg+Nd(h`j-j4ey)r0 zTBct2thkjABiLk|*qGEU?J$zlqgU3Ru#3EA;hS$7;p`RY2R=M$B;|KuZCm0N?A)+< z)<Oo*Qp7Bcmz{Dhky6Q;DylzouV4Wxjsc8_Zm!PK+VR zJV!oSINhZo03)!eDNa?|v87uV4jqNYPzYgn0;C0Qtv9ywD}5@I9bwJOiy+*`rx~{K z?{)m$@5dkUHG|Dzvl}qTAZ2vua!`JON#myHVdmds?Eg#Y^&j`YQj++iBqJ>&b#(uW zzc<61_>UrR`LCh>e{~0e_ZvTw5lTOD5NH4M-WhgA!}RPQ*$CKC;zkUR5|aeX;HMO1 zq!sW}!RKoJ_o@F5iCb>%ITeeQthp)GjPoI9b(HTIA&Z}gN@>7vH5JHp-d>|F z;|?Q~x+^NW)kZkewm!o5On0DlIy1hTg#&DsJlC+w9gieugEh` z)PbIb}`-NxC=GmrNKbr3w{h4TyeG znWUgz$@+p>yk>beXnVw#_Wh};AfHYL#27vAh=2r9TDydX(wOU$eXBujeg|F6=!_R^ z3L#xN^Oh&t*i)y5R;X*jb3?XW_%P;9jjZP$=8nZt&5J&H&dJb~Q}CUH=oz7whaY#r zYpWNx5Nad&3mN-4Y47s2tzEc+++2*y>x~XEDNAsd{cpL2McZ!!Jr!4kOfHX*`&&Gh z{Q8FY#97xOa;gYjt6QJr65@q(+vsM(>Gl@~XSXJC4EnD6sL#VojUEOSTAs3g?o-2a-)kLa#62r9TJzff$r(i( zO|oWb_j*&S-5=4oKj8bPgwH?W#s7R=`uh~Fqh2!-GioBsd+ zX`;1`2boj4eYfW0pQk*bSu9DU{>Dj2MKL;S@z~_UZB1QQPL)hAeR=wKxz}>Yn0a3; zag2MOG|MdttoHUGBYR13r^6MHJozrH3s+aFeu>P3oUFo~VASZlEX}*PkJqlAs|Q*IP7uv+at4dlt3n=jRe< zNi?NcnNlx*Hn4hZV|N`%RMFyC`%d6cwf!~8JdIoIcha>^_q2Xp#&oPY`bFqzx75Bt z?JGi3r)24IpFcn9pR1H(9j)huKaCl9N~tACZI)`(U?J>OZ^mlUUvH~-0~H?@(h(Fn z;?fOcLf2CMk-|K%zQxAhvyz9OBp2M4Exrjsz$BIr@)Bkc#c@CPK;wtg_ zGkcP`CU4pYciYkk0cs2LedP=EIacW64+{&U!~;6XZWp9N^k4=zSuY=wR78E{ zNrEvwKX86QOY@Ooun;;P<;gd2^!QN6W3{E@0^dXFm! zV}cEhcoma34cW$ML|fio^?SgZ5PODv-~@l2A|}oqZvIx3CePqvxdkkLNa+A`_)10h z=*MT4WF7e51pWK^R|EfQ;9m{=tAT$t@UI5`)xf_R_*Vn}|I@&RB^Y$wu!$qa&!*N+ z+sr*CDzoid)akM(r#5W8E6fLN$tvq)o6#3uD};_qzvK#~yf;0$E$f^pVDR8XPvOvM zwr44dWw)+uH-yO8T2tN_tl5Xg8Zv4YzbmwLW7j`?%yDfHawqtju|e||lK3mgs zkZ-4>Y3r8A;TpkJ%^+32Br_yuFZKExZ5k8%Dgz<$h6e?T&#Dh!QMNrv`h2-`tro6T zox0zgI`VotIXeVJx=cA(Xg#P~;8M-R{0btdwX) zZGd3y05a|RhMmMoF4;e$AkgB=?Z%7?^o!l^ydP_koN|H9Omgcs(yKcr2+HAij?_!n z68|qfO#DZ0u$~FuG6+O%qNk~5<{Vy?6_|Ycd_bE@PvGuHU%70D$`L*`iVRH_2oE%$ z9}t;B5fMLNEMh3sTAV*c_@vs@4V@{eP-YtSb_GJla6i)I&XCYOh64BGI~E>9B++~s zY#przIp-(;O}=OIxysyAj4AIqrW4o54a|kY@3)Bc z>b^beP1kDLYQ$*En3U4ko%_CrZz*(6bW13_@L9oD0kk-v>`Kx|53LplSd~MUAeQBh z)HK$u_;pCbtB1UdRz{WyHV$yu-6ElnOokjxaok?1pw{#OAbQGG-R=a%+0%-Cjjh|{GFvMpiA z=$vhoOonHm`H=4=oP~wpmp+I#=~!CVYtf*UUCH)n3bvL67u3hGo|U~}e1*6V7)w&x&u!XNn=J$&To zHya69R7}w-9FJ=~a=p#G!RZXtUb~Lh39xqyKrO(nU0t=vXqj%vtQ1Hu{G94*Rfl?Q zU9~)g{+Y*qi*pAYf+t`vKPC(5(+r1sfvAz@4@DjhCS(g4-=I0A|A`@e2hCYkC4L~e z>=JR2aG1CaOxwyxlWVz_%e>b8Q8U(4^ zrCH1Zds|HKo}e*_FmI2UF>UwjSy*V7s@w{n$V+SF-=rm_PrGG{*xNveWY-cV>}hK& zs(D_f++P)sE1xxo-jEM@U0*r;DN&OG+;CRO}23dv_IXy5v~=Mm%yo%AQx0? z(52_acsH@$GIMwk8)P078bBJQJkDF^N)-Gck^M?u8BGa1QI5SMpRYbYgiO@h!Ibf# zRrKBl%hs!jX?o8HdiU==q2L>&1t86o_np$Kt9=@*ov)tR-?)j)871V?V;QSwJ&drT znz|~e>v8%@%ZYwSgFxAd!cP#;nw5T@I!y9nofE@ceOLK_TvCr#!M6~G+)QgTUK4LG zqEB%#uLfl9+^M)1rA-INRbL=lxkq0h$F61%Hx{~f-6RHePM;}RH{ttun=17y(d9I? zUKY*~gPpH!7oxOBbPelct zc_>3RpLCLWIxG2xxqUCqa}SR0uYQw@LCmX5*v=C@Tw0!EZ^!kN>uI?)PiD}eqfF1x zmu3xXdZdN5s8usPS?Q%Pe9G)`18$bER;vA&)#|h5<8N0dUWv9FvE7*p<89YD6LD2X zm0`a_;?9W~Oh8-Q+e-^U1fAHvfvvL@(!C7E)H_tfd51WLd4?z4YmUTb6eMlYPlv8! ziLU!7Z#Aya78D@m16V)ej=diag%HN)_`Csn87Mjm~I zjVrKie>_i~A(2=CdX84wJ7C>vzd7GeKVNiPJ#?9sLcSeIoX2b>P=kUYeG=s5Q+U%S z;-3h|YO@)AHkk7UP-Mcq2kgmY1JJnxzvkVx*GZjPxOB z87Y|5OixUEvssuhKJ!xjxEJ9kdP_`aV_57HGFAJ#v_{GLr_XHltIqBb!n~=scUn<2 zZ7=4#dh%41!`MHo-fFf3%_Z^N{s?6*gMT3kz7^Ob_;FR^6xl0FvfAVnnZD%*+Saf6 zgGu9p&zh{gCpTzj%o<7}yfHed6bH=(NYA=aS1x#hchQ(M#)UN5EH846G)esJ@SJzNlR@u|5%fVPe^>en0w?Ij?0`D%vrG+iFpul1i*q>c-Uv`#J*l zXbNLX-sT6C4bgC#NT*868sa77E5U&wyU*`mwaeTtac3HUJM9OlSik5v-B7B==_Pr< z8;I?5;@L$y^}or#M#z&1%rpP-qR!@7iPYiMU za&N#^RD%tMqz^tnUcI!b6z?yro3PO(AWWcF*;8kz=1W4QM*lgpV6@jcdsxkqSK*x| zTN=@#b#{#6p@8l5i{+e0FHeYaD?ytW24?f#L;aW(+F-Y0E~*f5T@F0>pyC!AAE})| z$4i%;r$KfJtapnQb|ZX6jPO*<3VRr0AWOS~n2Q8aj&^SBHqiK<$<+Rs`vn%W{BZb5~o(jZmFO(&L$cjq~VdG8N5&TfuYy_vv5>3Xv^|U7K8BePHmW0^%w7i%r zCy>-gu-VM9+1wbDL37s}DyJ5K{rD(lD=x42(m*&YOoN*>5sR8#SVWqz9fnm*B*Q~vGLlvwy6rE_)O z`RHpY!6xjuR+x!&S^BjMjor*~eG}``A|>DnaPCI9*B(mOemT))W+^?thBHQ+Hje4$ zeX!(2mio8(%K0XzwGAH8oLbP(P_HqGyV@Ru2x(_=-TWJVv7(dFs6=aHmEcx(kMv?I zTW|K$l-4cO*hB}?=NvC{m%Mg}JH?$YL6CCl3Iq?svRQAw?z|t$>bRy55X?+aF)g=T z$7yD=%(%`#YLd;#Fbmiza9dZOAS&G+QyC>x`XL79Jx1+t=2g(m z$ttf7BLS8Hy8e?xgJ-+CeO?a;byC(if=u10)(xF6^=ZXk20p;02V}Qr_b9zy#$vZW zY(E_p~_Ab zXuWH=vxlP(bMUlOd+H&P%&4mS0R`rD>+)x%`8&7O=U{wdy1ZvcRU8mcdMh@X{9U32 zW^1nyB#I;?zHGPjYMLou&#zSiZM|=3?y-%2o%}YcFaVwj36HGBwc5_3d2NAxufpq{ z7lKsj9QL)mMl>cm?^j7jFGIfxl%oVxMgv_S6;v6>QbP1j^fL&vx$v8LCyp_3k@YF4WlTXVndQ*LL4(`#MJX zz;^ZNL`_`Lb4SSPa6M9-dSi@|_>hyR4Y4d;oC8grnHWgOjPtB?=Y}{8mt654g7znC zapSCn+CS2k##|RrnC&wgz?=2wnR^5=gRZ2G`TKk?=DtbRuDNsx> zbi%jRPBuw1gf@Q7sR0q1D^y zHQ`kDxHFvBg4H06^rna0G)2xb+NE$a_w$@Ox-@sL*CofPE4b$aL_{G>wPMf)lmzzP)o#Z25ir9}$TMfBNq8-$DM(m|9D<4w^;nYU* zvyOq!H@CaztJwYJC03aC<%*0h+D=3m97MgPJiKOh!+KSVt20GB9%B{Yg zn9XHBv%^7gtxgxU49y)^<2yN^P_hmd3+s4@K8bR2v{Z)D!9nVZeuPU3y+ljUf-Xe! v0key())->toBe('image_card') + ->and($t->needsAccount())->toBeFalse() + ->and($t->generatorFormat())->toBe('single') + ->and($t->promptView())->toBe('prompts.post_content.generator') + ->and($t->supportedFormats())->toBe([]); +}); + +test('carousel template exposes its identity', function () { + $t = new CarouselTemplate; + expect($t->key())->toBe('carousel') + ->and($t->generatorFormat())->toBe('carousel') + ->and($t->supportedFormats())->toContain(ContentType::CAROUSEL_FORMAT); +}); From 9dba90129f91b4a3f4de3ac9e0cb5812e9bdd7c2 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 17:23:33 -0300 Subject: [PATCH 03/31] feat(ai-templates): task 3 - template-driven generator + job - Add AiTemplateRegistry (resolves, keys, default) - Add template/templateContext params to PostContentGenerator; schema() delegates to template when both are set; instructions() uses template.promptView() - Refactor StreamPostCreation.handle() to resolve template via registry, build TemplateContext, delegate assemble() to template; add createPostFromGenerated() - Remove handleSingle(), handleCarousel(), resolvedContentType() (no other callers) - PostAiCreateController passes template param from request (default image_card) - All existing AI tests green; registry test added --- app/Ai/Agents/PostContentGenerator.php | 17 ++- app/Ai/Templates/AiTemplateRegistry.php | 44 ++++++++ .../App/PostAiCreateController.php | 1 + app/Jobs/Ai/StreamPostCreation.php | 106 ++++-------------- .../Ai/Templates/AiTemplateRegistryTest.php | 17 +++ 5 files changed, 95 insertions(+), 90 deletions(-) create mode 100644 app/Ai/Templates/AiTemplateRegistry.php create mode 100644 tests/Feature/Ai/Templates/AiTemplateRegistryTest.php diff --git a/app/Ai/Agents/PostContentGenerator.php b/app/Ai/Agents/PostContentGenerator.php index cb9c09b2..09e1751f 100644 --- a/app/Ai/Agents/PostContentGenerator.php +++ b/app/Ai/Agents/PostContentGenerator.php @@ -5,6 +5,8 @@ namespace App\Ai\Agents; use App\Ai\Agents\Concerns\ResolvesPlatformCopyBudget; +use App\Ai\Templates\AiContentTemplate; +use App\Ai\Templates\TemplateContext; use App\Models\Workspace; use App\Services\Ai\TemplateContextResolver; use Illuminate\Contracts\JsonSchema\JsonSchema; @@ -27,6 +29,8 @@ public function __construct( 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,12 +49,11 @@ 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', [ + $view = $this->template?->promptView() ?? '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 ?? []) : [], @@ -67,6 +70,10 @@ public function instructions(): string public function schema(JsonSchema $schema): array { + if ($this->template !== null && $this->templateContext !== null) { + return $this->template->schema($schema, $this->templateContext); + } + if ($this->format === 'carousel') { return [ 'caption' => $schema->string()->description('The Instagram caption for the carousel post.')->required(), @@ -77,7 +84,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) diff --git a/app/Ai/Templates/AiTemplateRegistry.php b/app/Ai/Templates/AiTemplateRegistry.php new file mode 100644 index 00000000..b01e4691 --- /dev/null +++ b/app/Ai/Templates/AiTemplateRegistry.php @@ -0,0 +1,44 @@ +> */ + private const TEMPLATES = [ + ImageCardTemplate::class, + CarouselTemplate::class, + ]; + + /** @return array */ + public function all(): array + { + return array_map(fn (string $class) => app($class), self::TEMPLATES); + } + + /** @return array */ + public function keys(): array + { + return array_map(fn (AiContentTemplate $t) => $t->key(), $this->all()); + } + + public function find(string $key): AiContentTemplate + { + foreach ($this->all() as $template) { + if ($template->key() === $key) { + return $template; + } + } + + throw new InvalidArgumentException("Unknown AI content template: {$key}"); + } + + public function default(): AiContentTemplate + { + return app(ImageCardTemplate::class); + } +} diff --git a/app/Http/Controllers/App/PostAiCreateController.php b/app/Http/Controllers/App/PostAiCreateController.php index 93f39d7b..1d40db64 100644 --- a/app/Http/Controllers/App/PostAiCreateController.php +++ b/app/Http/Controllers/App/PostAiCreateController.php @@ -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([ diff --git a/app/Jobs/Ai/StreamPostCreation.php b/app/Jobs/Ai/StreamPostCreation.php index 7d45f776..e02a09c5 100644 --- a/app/Jobs/Ai/StreamPostCreation.php +++ b/app/Jobs/Ai/StreamPostCreation.php @@ -7,6 +7,9 @@ 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\Notification\Channel as NotificationChannel; use App\Enums\Notification\Type as NotificationType; use App\Enums\PostPlatform\ContentType; @@ -17,7 +20,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 +40,7 @@ public function __construct( public int $imageCount, public string $prompt, public ?string $date = null, + public string $template = 'image_card', ) { $this->onQueue('ai'); } @@ -47,16 +50,19 @@ 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'; + $template = app(AiTemplateRegistry::class)->find($this->template); + $context = new TemplateContext($workspace, $socialAccount, $this->format, $this->imageCount); + $isCarousel = $template->generatorFormat() === 'carousel'; $slideCount = $isCarousel && $this->imageCount > 0 ? $this->imageCount : 1; $agent = new PostContentGenerator( workspace: $workspace, - format: $agentFormat, + format: $template->generatorFormat(), slideCount: $slideCount, platformContext: $this->format, + template: $template, + templateContext: $context, ); try { @@ -72,18 +78,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, $template->generatorFormat()); - if ($isCarousel) { - $this->handleCarousel($workspace, $socialAccount, $structured); - } else { - $this->handleSingle($workspace, $socialAccount, $structured); - } + $generated = $template->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 +97,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 @@ -183,80 +171,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 $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> $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(); }); diff --git a/tests/Feature/Ai/Templates/AiTemplateRegistryTest.php b/tests/Feature/Ai/Templates/AiTemplateRegistryTest.php new file mode 100644 index 00000000..225ccb54 --- /dev/null +++ b/tests/Feature/Ai/Templates/AiTemplateRegistryTest.php @@ -0,0 +1,17 @@ +keys())->toContain('image_card', 'carousel') + ->and($registry->find('image_card'))->toBeInstanceOf(ImageCardTemplate::class) + ->and($registry->default()->key())->toBe('image_card'); +}); + +test('registry throws on unknown key', function () { + expect(fn () => app(AiTemplateRegistry::class)->find('nope'))->toThrow(InvalidArgumentException::class); +}); From 8c72719f92e2d626ed211753b4b7129a94075ed4 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 17:27:58 -0300 Subject: [PATCH 04/31] feat(ai-templates): validate template key + require account when needed --- .../App/Ai/StartPostCreationRequest.php | 19 ++++++++++ tests/Feature/Ai/PostAiCreateTest.php | 38 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/app/Http/Requests/App/Ai/StartPostCreationRequest.php b/app/Http/Requests/App/Ai/StartPostCreationRequest.php index 40fe56a4..258ad4a5 100644 --- a/app/Http/Requests/App/Ai/StartPostCreationRequest.php +++ b/app/Http/Requests/App/Ai/StartPostCreationRequest.php @@ -4,9 +4,11 @@ namespace App\Http\Requests\App\Ai; +use App\Ai\Templates\AiTemplateRegistry; 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,23 @@ 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::in(app(AiTemplateRegistry::class)->keys())], ]; } + + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + $key = (string) $this->input('template', 'image_card'); + $registry = app(AiTemplateRegistry::class); + + if (! in_array($key, $registry->keys(), true)) { + return; + } + + if ($registry->find($key)->needsAccount() && blank($this->input('social_account_id'))) { + $validator->errors()->add('social_account_id', trans('validation.required', ['attribute' => 'social account'])); + } + }); + } } diff --git a/tests/Feature/Ai/PostAiCreateTest.php b/tests/Feature/Ai/PostAiCreateTest.php index 297da2d7..90919810 100644 --- a/tests/Feature/Ai/PostAiCreateTest.php +++ b/tests/Feature/Ai/PostAiCreateTest.php @@ -188,3 +188,41 @@ ->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('requires social_account_id when the template needsAccount', 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(); +}); From 8dfdb2427a83bf9f411b48777f1b6c602116b2e2 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 17:29:28 -0300 Subject: [PATCH 05/31] feat(tweet-card): prompt + verified badge + thumbnail assets --- public/images/ai-templates/tweet-card.png | Bin 0 -> 17438 bytes public/images/ai-templates/verified.png | Bin 0 -> 402 bytes .../prompts/post_content/tweet_card.blade.php | 35 ++++++++++++++++++ 3 files changed, 35 insertions(+) create mode 100644 public/images/ai-templates/tweet-card.png create mode 100644 public/images/ai-templates/verified.png create mode 100644 resources/views/prompts/post_content/tweet_card.blade.php diff --git a/public/images/ai-templates/tweet-card.png b/public/images/ai-templates/tweet-card.png new file mode 100644 index 0000000000000000000000000000000000000000..389d7eb5efbddeee1f0a26a9de0de70e9db0a322 GIT binary patch literal 17438 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 literal 0 HcmV?d00001 diff --git a/resources/views/prompts/post_content/tweet_card.blade.php b/resources/views/prompts/post_content/tweet_card.blade.php new file mode 100644 index 00000000..ea1e42b7 --- /dev/null +++ b/resources/views/prompts/post_content/tweet_card.blade.php @@ -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. From 0d4f3b90d75e5874aa7b39df6279437bc28be2bc Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 17:32:10 -0300 Subject: [PATCH 06/31] feat(tweet-card): solid-background tweet card image layout --- app/Services/Image/PostImagePipeline.php | 17 ++ app/Services/Image/TemplateImageGenerator.php | 180 ++++++++++++++++++ .../Ai/Templates/TweetCardRenderTest.php | 29 +++ 3 files changed, 226 insertions(+) create mode 100644 tests/Feature/Ai/Templates/TweetCardRenderTest.php diff --git a/app/Services/Image/PostImagePipeline.php b/app/Services/Image/PostImagePipeline.php index eb5b4336..e266528e 100644 --- a/app/Services/Image/PostImagePipeline.php +++ b/app/Services/Image/PostImagePipeline.php @@ -80,6 +80,23 @@ 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. + * + * @return array> + */ + public function forTweetCard(Workspace $workspace, SocialAccount $account, string $tweetText): array + { + $rendered = $this->generator->renderTweetCard($workspace, $account, $tweetText); + + if (! $rendered) { + return []; + } + + return [$this->buildAiMediaItem($workspace, $rendered)]; + } + /** * 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. diff --git a/app/Services/Image/TemplateImageGenerator.php b/app/Services/Image/TemplateImageGenerator.php index 4f5d0fbd..be13923b 100644 --- a/app/Services/Image/TemplateImageGenerator.php +++ b/app/Services/Image/TemplateImageGenerator.php @@ -548,6 +548,186 @@ private function drawHorizontalLine($core, int $x1, int $x2, int $y, string $hex imagecolordeallocate($core, $color); } + /** + * Renders the post as a fake X/Twitter card on a solid brand-color background. + * Text + identity composition only — never calls the AI image client. + * + * @return array{path: string, source_meta: array}|null + */ + public function renderTweetCard(Workspace $workspace, SocialAccount $socialAccount, string $tweetText): ?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); + + $pageBg = imagecolorallocate($core, $pr, $pg, $pb); + imagefill($core, 0, 0, $pageBg); + + $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 = 56; + $headerH = $avatarSize + 2 * $cardPadding; + $nameSize = 28; + $handleSize = 22; + $bodySize = 30; + $bodyLineHeight = 1.55; + $paragraphGap = (int) round($bodySize * $bodyLineHeight * 0.6); + + $textMaxWidth = $cardW - 2 * $cardPadding - $avatarSize - 24; + + $paragraphs = 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; + $nameY = $avatarY + 4; + + if ($fontBold) { + $canvas->text($socialAccount->display_name ?? '', $nameX, $nameY, function (FontFactory $font) use ($fontBold, $nameSize) { + $font->filename($fontBold); + $font->size($nameSize); + $font->color('#0f1419'); + $font->align('left', 'top'); + }); + } + + $verifiedPath = public_path('images/ai-templates/verified.png'); + if ($fontBold && file_exists($verifiedPath)) { + $displayNameText = $socialAccount->display_name ?? ''; + $nameBox = $fontBold ? imagettfbbox($nameSize, 0, $fontBold, $displayNameText) : [0, 0, 0, 0, 0, 0, 0, 0]; + $nameWidth = abs($nameBox[2] - $nameBox[0]); + $badgeSize = (int) round($nameSize * 1.1); + $badgeX = $nameX + $nameWidth + 6; + $badgeY = $nameY + (int) round(($nameSize - $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); + } + } + + $handleY = $nameY + (int) round($nameSize * 1.35); + if ($fontLight) { + $canvas->text('@'.($socialAccount->username ?? ''), $nameX, $handleY, function (FontFactory $font) use ($fontLight, $handleSize) { + $font->filename($fontLight); + $font->size($handleSize); + $font->color('#536471'); + $font->align('left', 'top'); + }); + } + + $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; + } + } + + $filename = 'ai-images/tweet_'.uniqid('', true).'.webp'; + Storage::put($filename, (string) $canvas->encode(new WebpEncoder(quality: 85))); + + RecordAiUsage::recordTemplate( + workspace: $workspace, + provider: 'internal', + metadata: [ + 'template' => 'tweet_card', + 'width' => $this->width, + 'height' => $this->height, + ], + ); + + return [ + 'path' => $filename, + 'source_meta' => [ + 'template' => 'tweet_card', + 'tweet_text' => $tweetText, + 'width' => $this->width, + 'height' => $this->height, + ], + ]; + } + + /** + * 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); diff --git a/tests/Feature/Ai/Templates/TweetCardRenderTest.php b/tests/Feature/Ai/Templates/TweetCardRenderTest.php new file mode 100644 index 00000000..8d28c800 --- /dev/null +++ b/tests/Feature/Ai/Templates/TweetCardRenderTest.php @@ -0,0 +1,29 @@ +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'); +}); From 9d8f772a3e02ac6eb899deee9c615ca9b208fbb6 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 17:35:18 -0300 Subject: [PATCH 07/31] feat(tweet-card): tweet_card template wired end-to-end --- app/Ai/Agents/PostContentGenerator.php | 5 + app/Ai/Templates/AiTemplateRegistry.php | 1 + app/Ai/Templates/TweetCardTemplate.php | 95 +++++++++++++++++++ app/Jobs/Ai/StreamPostCreation.php | 4 + tests/Feature/Ai/StreamPostCreationTest.php | 25 +++++ .../Ai/Templates/TweetCardTemplateTest.php | 13 +++ 6 files changed, 143 insertions(+) create mode 100644 app/Ai/Templates/TweetCardTemplate.php create mode 100644 tests/Feature/Ai/Templates/TweetCardTemplateTest.php diff --git a/app/Ai/Agents/PostContentGenerator.php b/app/Ai/Agents/PostContentGenerator.php index 09e1751f..08a95efd 100644 --- a/app/Ai/Agents/PostContentGenerator.php +++ b/app/Ai/Agents/PostContentGenerator.php @@ -51,6 +51,11 @@ public function instructions(): string $budget = $this->platformCopyBudget($this->platformContext); + if ($this->template?->generatorFormat() === 'tweet_card') { + $budget['hard_max_chars'] = 560; + $budget['target_chars'] = 280; + } + $view = $this->template?->promptView() ?? 'prompts.post_content.generator'; return view($view, [ diff --git a/app/Ai/Templates/AiTemplateRegistry.php b/app/Ai/Templates/AiTemplateRegistry.php index b01e4691..f1be810a 100644 --- a/app/Ai/Templates/AiTemplateRegistry.php +++ b/app/Ai/Templates/AiTemplateRegistry.php @@ -12,6 +12,7 @@ class AiTemplateRegistry private const TEMPLATES = [ ImageCardTemplate::class, CarouselTemplate::class, + TweetCardTemplate::class, ]; /** @return array */ diff --git a/app/Ai/Templates/TweetCardTemplate.php b/app/Ai/Templates/TweetCardTemplate.php new file mode 100644 index 00000000..bcd08568 --- /dev/null +++ b/app/Ai/Templates/TweetCardTemplate.php @@ -0,0 +1,95 @@ + */ + public function supportedFormats(): array + { + return []; + } + + public function generatorFormat(): string + { + return 'tweet_card'; + } + + public function promptView(): string + { + return 'prompts.post_content.tweet_card'; + } + + /** + * @return array + */ + public function schema(JsonSchema $schema, TemplateContext $context): array + { + 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(), + ]; + } + + /** @return array */ + public function humanizableFields(): array + { + return ['tweet_text' => 'tweet_text']; + } + + /** + * @param array $structured + */ + public function assemble(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), + ); + } +} diff --git a/app/Jobs/Ai/StreamPostCreation.php b/app/Jobs/Ai/StreamPostCreation.php index e02a09c5..203a6f76 100644 --- a/app/Jobs/Ai/StreamPostCreation.php +++ b/app/Jobs/Ai/StreamPostCreation.php @@ -109,6 +109,10 @@ public function handle(): void */ private function humanize(Workspace $workspace, array $structured, string $format): array { + if ($format === 'tweet_card') { + return $structured; + } + try { $input = $format === 'carousel' ? [ diff --git a/tests/Feature/Ai/StreamPostCreationTest.php b/tests/Feature/Ai/StreamPostCreationTest.php index 78343bcc..8cd73f2e 100644 --- a/tests/Feature/Ai/StreamPostCreationTest.php +++ b/tests/Feature/Ai/StreamPostCreationTest.php @@ -90,6 +90,31 @@ 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_twitter', + 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); +}); + 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', diff --git a/tests/Feature/Ai/Templates/TweetCardTemplateTest.php b/tests/Feature/Ai/Templates/TweetCardTemplateTest.php new file mode 100644 index 00000000..8894c1dd --- /dev/null +++ b/tests/Feature/Ai/Templates/TweetCardTemplateTest.php @@ -0,0 +1,13 @@ +key())->toBe('tweet_card') + ->and($t->needsAccount())->toBeTrue() + ->and($t->generatorFormat())->toBe('tweet_card') + ->and($t->promptView())->toBe('prompts.post_content.tweet_card'); +}); From 4656cb52c63f967ebff92458c7b75b3f9afb5eba Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 17:37:51 -0300 Subject: [PATCH 08/31] feat(ai-templates): i18n template names (en, es, pt-BR) --- lang/en/posts.php | 14 ++++++++++++++ lang/es/posts.php | 14 ++++++++++++++ lang/pt-BR/posts.php | 14 ++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/lang/en/posts.php b/lang/en/posts.php index 6fe57b73..96a13447 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -260,6 +260,20 @@ '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.', + ], + ], ], 'show' => [ diff --git a/lang/es/posts.php b/lang/es/posts.php index c1da9111..dad61d9a 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -260,6 +260,20 @@ '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.', + ], + ], ], 'show' => [ diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index 831b745e..cd6b1906 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -260,6 +260,20 @@ '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.', + ], + ], ], 'show' => [ From 799a1513216bbbb9ff441174df7bd49526dc13fd Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 17:42:23 -0300 Subject: [PATCH 09/31] feat(ai-templates): template picker in the AI wizard --- app/Http/Controllers/App/PostController.php | 13 ++ lang/en/posts.php | 2 + lang/es/posts.php | 2 + lang/pt-BR/posts.php | 2 + .../components/posts/create/AiPostWizard.vue | 116 ++++++++++++++++-- resources/js/pages/posts/Create.vue | 11 ++ 6 files changed, 137 insertions(+), 9 deletions(-) diff --git a/app/Http/Controllers/App/PostController.php b/app/Http/Controllers/App/PostController.php index 8635b4e5..5c7f9c7f 100644 --- a/app/Http/Controllers/App/PostController.php +++ b/app/Http/Controllers/App/PostController.php @@ -9,6 +9,7 @@ use App\Actions\Post\DuplicatePost; use App\Actions\Post\SyncPostPlatforms; use App\Actions\Post\UpdatePost; +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 +143,23 @@ public function create(Request $request): Response $this->authorize('createPost', $workspace); + $registry = app(AiTemplateRegistry::class); + + $templates = array_map(fn ($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, ]); } diff --git a/lang/en/posts.php b/lang/en/posts.php index 96a13447..b966ee45 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -572,6 +572,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', @@ -582,6 +583,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', diff --git a/lang/es/posts.php b/lang/es/posts.php index dad61d9a..6926f152 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -572,6 +572,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.', @@ -583,6 +584,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', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index cd6b1906..c19e0b2a 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -572,6 +572,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', @@ -582,6 +583,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', diff --git a/resources/js/components/posts/create/AiPostWizard.vue b/resources/js/components/posts/create/AiPostWizard.vue index 8d5bb037..566e0229 100644 --- a/resources/js/components/posts/create/AiPostWizard.vue +++ b/resources/js/components/posts/create/AiPostWizard.vue @@ -14,8 +14,8 @@ 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 +25,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; } @@ -45,7 +55,12 @@ const emit = defineEmits<{ const CAROUSEL_FORMAT = 'instagram_carousel' as const; type AiFormat = ContentTypeValue | typeof CAROUSEL_FORMAT; +// Wizard steps +type WizardStep = 'template' | 'configure'; +const wizardStep = ref('template'); + // Selections +const selectedTemplate = ref(props.templates[0]?.key ?? 'image_card'); const selectedFormat = ref(null); const selectedAccountId = ref(null); const includeImages = ref(true); @@ -60,7 +75,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 +92,10 @@ const AI_FORMATS: Array<{ value: AiFormat; platforms: string[] }> = [ { value: ContentType.PinterestPin, platforms: ['pinterest'] }, ]; +const activeTemplate = computed(() => + props.templates.find((t) => t.key === selectedTemplate.value) ?? null, +); + const connectedPlatforms = computed(() => { const platforms = new Set(); for (const account of props.socialAccounts) { @@ -84,9 +104,12 @@ 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); +// When the active template restricts formats, only show those; otherwise show all. +const availableFormats = computed(() => { + const supported = activeTemplate.value?.supported_formats ?? []; + if (supported.length === 0) return AI_FORMATS; + return AI_FORMATS.filter((f) => supported.includes(f.value)); +}); const isFormatConnected = (format: typeof AI_FORMATS[number]): boolean => format.platforms.some((p) => connectedPlatforms.value.includes(p)); @@ -119,6 +142,10 @@ const maxOptionalImages = computed(() => ); const showsAccountPicker = computed(() => accountsForFormat.value.length > 1); +// When the template requires an account, we need one even if the format only maps +// to a single account that gets auto-selected — the gating is purely on needsAccount. +const templateNeedsAccount = computed(() => activeTemplate.value?.needs_account ?? false); + const submittedImageCount = computed(() => { if (isCarousel.value) return imageCount.value; if (requiresImage.value) return 1; @@ -143,6 +170,16 @@ watch(accountsForFormat, (accounts) => { } }); +// When the template changes, reset format/account selections in case the new +// template's supported_formats set doesn't include the previously-selected one. +watch(selectedTemplate, () => { + const supported = activeTemplate.value?.supported_formats ?? []; + if (supported.length > 0 && selectedFormat.value && !supported.includes(selectedFormat.value)) { + selectedFormat.value = null; + selectedAccountId.value = null; + } +}); + const selectFormat = (format: AiFormat) => { selectedFormat.value = format; // Sensible default per format. Picking a format always pre-selects an @@ -158,12 +195,30 @@ const selectFormat = (format: AiFormat) => { } }; +const proceedToConfig = () => { + wizardStep.value = 'configure'; + emit('update:stepHeader', { + title: trans('posts.create.ai_title'), + description: trans('posts.create.ai_configure_description'), + }); +}; + emit('update:stepHeader', { title: trans('posts.create.ai_title'), - description: trans('posts.create.ai_configure_description'), + description: trans('posts.create.ai_pick_template_description'), }); -const goBack = () => emit('cancel'); +const goBack = () => { + if (wizardStep.value === 'configure') { + wizardStep.value = 'template'; + emit('update:stepHeader', { + title: trans('posts.create.ai_title'), + description: trans('posts.create.ai_pick_template_description'), + }); + } else { + emit('cancel'); + } +}; const startGeneration = async () => { if (!canSubmit.value || submitting.value) return; @@ -175,6 +230,7 @@ const startGeneration = async () => { httpStart.image_count = submittedImageCount.value; httpStart.prompt = promptText.value.trim(); httpStart.date = props.date; + httpStart.template = selectedTemplate.value; try { const data = await httpStart.post(startRoute.url()) as { creation_id: string; channel: string }; @@ -210,6 +266,47 @@ const startGeneration = async () => { {{ $t('posts.create.steps.back') }} + + + + + + diff --git a/resources/js/pages/posts/Create.vue b/resources/js/pages/posts/Create.vue index 32b2dda3..be61608a 100644 --- a/resources/js/pages/posts/Create.vue +++ b/resources/js/pages/posts/Create.vue @@ -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(), { @@ -131,6 +141,7 @@ const stepHeader = computed(() => { Date: Wed, 17 Jun 2026 17:56:52 -0300 Subject: [PATCH 10/31] fix(ai-templates): real needsAccount + content_type test coverage; drop unwired humanizableFields; cleanup --- app/Ai/Templates/AiContentTemplate.php | 8 -------- app/Ai/Templates/CarouselTemplate.php | 8 -------- app/Ai/Templates/ImageCardTemplate.php | 9 --------- app/Ai/Templates/TweetCardTemplate.php | 6 ------ app/Services/Image/TemplateImageGenerator.php | 2 -- tests/Feature/Ai/PostAiCreateTest.php | 17 ++++++++++++++++- tests/Feature/Ai/StreamPostCreationTest.php | 6 +++++- 7 files changed, 21 insertions(+), 35 deletions(-) diff --git a/app/Ai/Templates/AiContentTemplate.php b/app/Ai/Templates/AiContentTemplate.php index 8ec3d62a..2600d48f 100644 --- a/app/Ai/Templates/AiContentTemplate.php +++ b/app/Ai/Templates/AiContentTemplate.php @@ -43,14 +43,6 @@ public function promptView(): string; */ public function schema(JsonSchema $schema, TemplateContext $context): array; - /** - * Text fields (by structured-output key) the humanizer should rewrite. - * Empty = skip humanization. - * - * @return array - */ - public function humanizableFields(): array; - /** * Build the post from the (humanized) structured output. * diff --git a/app/Ai/Templates/CarouselTemplate.php b/app/Ai/Templates/CarouselTemplate.php index 9bdf3fcf..e28b8d27 100644 --- a/app/Ai/Templates/CarouselTemplate.php +++ b/app/Ai/Templates/CarouselTemplate.php @@ -4,15 +4,12 @@ namespace App\Ai\Templates; -use App\Ai\Templates\Concerns\ResolvesContentType; use App\Enums\PostPlatform\ContentType; use App\Services\Image\PostImagePipeline; use Illuminate\Contracts\JsonSchema\JsonSchema; class CarouselTemplate implements AiContentTemplate { - use ResolvesContentType; - public function key(): string { return 'carousel'; @@ -76,11 +73,6 @@ public function schema(JsonSchema $schema, TemplateContext $context): array ]; } - public function humanizableFields(): array - { - return ['caption' => 'caption']; - } - /** * @param array $structured */ diff --git a/app/Ai/Templates/ImageCardTemplate.php b/app/Ai/Templates/ImageCardTemplate.php index 4213a739..15ffa976 100644 --- a/app/Ai/Templates/ImageCardTemplate.php +++ b/app/Ai/Templates/ImageCardTemplate.php @@ -62,15 +62,6 @@ public function schema(JsonSchema $schema, TemplateContext $context): array ]; } - public function humanizableFields(): array - { - return [ - 'content' => 'content', - 'image_title' => 'image_title', - 'image_body' => 'image_body', - ]; - } - /** * @param array $structured */ diff --git a/app/Ai/Templates/TweetCardTemplate.php b/app/Ai/Templates/TweetCardTemplate.php index bcd08568..dd7fcd79 100644 --- a/app/Ai/Templates/TweetCardTemplate.php +++ b/app/Ai/Templates/TweetCardTemplate.php @@ -63,12 +63,6 @@ public function schema(JsonSchema $schema, TemplateContext $context): array ]; } - /** @return array */ - public function humanizableFields(): array - { - return ['tweet_text' => 'tweet_text']; - } - /** * @param array $structured */ diff --git a/app/Services/Image/TemplateImageGenerator.php b/app/Services/Image/TemplateImageGenerator.php index be13923b..dbd76322 100644 --- a/app/Services/Image/TemplateImageGenerator.php +++ b/app/Services/Image/TemplateImageGenerator.php @@ -590,8 +590,6 @@ public function renderTweetCard(Workspace $workspace, SocialAccount $socialAccou $bodyLineHeight = 1.55; $paragraphGap = (int) round($bodySize * $bodyLineHeight * 0.6); - $textMaxWidth = $cardW - 2 * $cardPadding - $avatarSize - 24; - $paragraphs = array_filter(array_map('trim', explode("\n\n", $tweetText)), fn ($p) => $p !== ''); $allBodyLines = []; foreach ($paragraphs as $i => $para) { diff --git a/tests/Feature/Ai/PostAiCreateTest.php b/tests/Feature/Ai/PostAiCreateTest.php index 90919810..8f588452 100644 --- a/tests/Feature/Ai/PostAiCreateTest.php +++ b/tests/Feature/Ai/PostAiCreateTest.php @@ -215,7 +215,7 @@ ->assertJsonValidationErrors(['template']); }); -it('requires social_account_id when the template needsAccount', function () { +it('allows image_card without a social account', function () { Bus::fake(); $this->actingAs($this->user) @@ -226,3 +226,18 @@ ]) ->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); +}); diff --git a/tests/Feature/Ai/StreamPostCreationTest.php b/tests/Feature/Ai/StreamPostCreationTest.php index 8cd73f2e..fa16a161 100644 --- a/tests/Feature/Ai/StreamPostCreationTest.php +++ b/tests/Feature/Ai/StreamPostCreationTest.php @@ -102,7 +102,7 @@ function runStreamPostCreation(string $format, SocialAccount $account, int $imag userId: $this->user->id, creationId: (string) Str::uuid(), workspaceId: $this->workspace->id, - format: 'x_twitter', + format: 'x_post', socialAccountId: $this->account->id, imageCount: 1, prompt: 'A punchy take on productivity', @@ -113,6 +113,10 @@ function runStreamPostCreation(string $format, SocialAccount $account, int $imag 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); }); test('the humanizer is given the same platform context as the generator so the rewrite honours the character cap', function () { From bb55e1442371018108daa5d5b1317934b735d69f Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 18:09:02 -0300 Subject: [PATCH 11/31] fix(ai-templates): paragraph spacing bug, registry cache, coverage gaps, empty-account UX --- app/Ai/Templates/AiTemplateRegistry.php | 5 ++- app/Http/Controllers/App/PostController.php | 3 +- app/Services/Image/TemplateImageGenerator.php | 2 +- lang/en/posts.php | 1 + lang/es/posts.php | 1 + lang/pt-BR/posts.php | 1 + .../components/posts/create/AiPostWizard.vue | 5 ++- tests/Feature/Ai/StreamPostCreationTest.php | 2 + .../Ai/Templates/AiTemplateRegistryTest.php | 2 +- .../Ai/Templates/TweetCardRenderTest.php | 43 +++++++++++++++++++ 10 files changed, 60 insertions(+), 5 deletions(-) diff --git a/app/Ai/Templates/AiTemplateRegistry.php b/app/Ai/Templates/AiTemplateRegistry.php index f1be810a..c81716e0 100644 --- a/app/Ai/Templates/AiTemplateRegistry.php +++ b/app/Ai/Templates/AiTemplateRegistry.php @@ -15,10 +15,13 @@ class AiTemplateRegistry TweetCardTemplate::class, ]; + /** @var array|null */ + private ?array $templates = null; + /** @return array */ public function all(): array { - return array_map(fn (string $class) => app($class), self::TEMPLATES); + return $this->templates ??= array_map(fn (string $class) => app($class), self::TEMPLATES); } /** @return array */ diff --git a/app/Http/Controllers/App/PostController.php b/app/Http/Controllers/App/PostController.php index 5c7f9c7f..65576b9c 100644 --- a/app/Http/Controllers/App/PostController.php +++ b/app/Http/Controllers/App/PostController.php @@ -9,6 +9,7 @@ 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; @@ -145,7 +146,7 @@ public function create(Request $request): Response $registry = app(AiTemplateRegistry::class); - $templates = array_map(fn ($t) => [ + $templates = array_map(fn (AiContentTemplate $t) => [ 'key' => $t->key(), 'name' => trans($t->name()), 'description' => trans($t->description()), diff --git a/app/Services/Image/TemplateImageGenerator.php b/app/Services/Image/TemplateImageGenerator.php index dbd76322..a4be6670 100644 --- a/app/Services/Image/TemplateImageGenerator.php +++ b/app/Services/Image/TemplateImageGenerator.php @@ -590,7 +590,7 @@ public function renderTweetCard(Workspace $workspace, SocialAccount $socialAccou $bodyLineHeight = 1.55; $paragraphGap = (int) round($bodySize * $bodyLineHeight * 0.6); - $paragraphs = array_filter(array_map('trim', explode("\n\n", $tweetText)), fn ($p) => $p !== ''); + $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)]); diff --git a/lang/en/posts.php b/lang/en/posts.php index b966ee45..252754f5 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -620,6 +620,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', diff --git a/lang/es/posts.php b/lang/es/posts.php index 6926f152..4dd580e6 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -621,6 +621,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', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index c19e0b2a..c9fdc07c 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -620,6 +620,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', diff --git a/resources/js/components/posts/create/AiPostWizard.vue b/resources/js/components/posts/create/AiPostWizard.vue index 566e0229..d4592bf2 100644 --- a/resources/js/components/posts/create/AiPostWizard.vue +++ b/resources/js/components/posts/create/AiPostWizard.vue @@ -337,7 +337,10 @@ const startGeneration = async () => {
-
+

+ {{ $t('posts.create.steps.no_account_for_template') }} +

+