Merge pull request #128 from dantaspaulo/feat/ai-brand-colors-toggle
feat(ai-create): let users choose brand colors or free AI colors for images
This commit is contained in:
commit
7b37190358
26 changed files with 112 additions and 1 deletions
|
|
@ -27,6 +27,13 @@ public function previewAsset(): string;
|
|||
/** Whether a social account must be selected for this template. */
|
||||
public function needsAccount(): bool;
|
||||
|
||||
/**
|
||||
* Whether this template's generated image honors the workspace brand
|
||||
* palette (so the "Brand colors" choice is meaningful). Tweet-card styles
|
||||
* are brand-colored by design and ignore the flag.
|
||||
*/
|
||||
public function appliesBrandVisuals(): bool;
|
||||
|
||||
/**
|
||||
* Content types (by value) this template can produce. Empty = all AI formats.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -44,6 +44,11 @@ public function needsAccount(): bool
|
|||
return $this->style()->needsAccount();
|
||||
}
|
||||
|
||||
public function appliesBrandVisuals(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function supportedFormats(): array
|
||||
{
|
||||
return [];
|
||||
|
|
|
|||
|
|
@ -41,6 +41,11 @@ public function needsAccount(): bool
|
|||
return $this->style()->needsAccount();
|
||||
}
|
||||
|
||||
public function appliesBrandVisuals(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @return array<int, string> */
|
||||
public function supportedFormats(): array
|
||||
{
|
||||
|
|
|
|||
|
|
@ -41,6 +41,11 @@ public function needsAccount(): bool
|
|||
return $this->style()->needsAccount();
|
||||
}
|
||||
|
||||
public function appliesBrandVisuals(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @return array<int, string> */
|
||||
public function supportedFormats(): array
|
||||
{
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ public function start(StartPostCreationRequest $request): JsonResponse
|
|||
prompt: $request->string('prompt')->toString(),
|
||||
date: $request->input('date'),
|
||||
template: $request->input('template', 'image_card'),
|
||||
applyBrandVisuals: $request->boolean('apply_brand_visuals', true),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
|
|
|
|||
|
|
@ -153,6 +153,7 @@ public function create(Request $request): Response
|
|||
'preview' => $t->previewAsset(),
|
||||
'needs_account' => $t->needsAccount(),
|
||||
'supported_formats' => $t->supportedFormats(),
|
||||
'applies_brand_visuals' => $t->appliesBrandVisuals(),
|
||||
], $registry->all());
|
||||
|
||||
return Inertia::render('posts/Create', [
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ public function rules(): array
|
|||
'prompt' => AiPromptRules::wizardPromptRule(),
|
||||
'date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'template' => ['sometimes', 'string', Rule::enum(ContentStyle::class)],
|
||||
'apply_brand_visuals' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ public function __construct(
|
|||
public string $prompt,
|
||||
public ?string $date = null,
|
||||
public string $template = 'image_card',
|
||||
public bool $applyBrandVisuals = true,
|
||||
) {
|
||||
$this->onQueue('ai');
|
||||
}
|
||||
|
|
@ -64,6 +65,7 @@ public function handle(): void
|
|||
format: $this->format,
|
||||
imageCount: $this->imageCount,
|
||||
isCarousel: $isCarousel,
|
||||
applyBrandVisuals: $this->applyBrandVisuals,
|
||||
);
|
||||
|
||||
$agent = new PostContentGenerator(
|
||||
|
|
|
|||
|
|
@ -606,6 +606,8 @@
|
|||
'media_optional_label' => 'كم عدد الصور؟',
|
||||
'media_none' => 'بلا',
|
||||
'media_count_label' => 'عدد الصور',
|
||||
'brand_colors_label' => 'ألوان العلامة التجارية',
|
||||
'brand_colors_description' => 'طبّق لوحة ألوان علامتك التجارية على الصور المُنشأة. عطّله لترك الذكاء الاصطناعي يختار الألوان التي تناسب المحتوى.',
|
||||
'prompt_title' => 'صِف منشورك',
|
||||
'prompt_label' => 'ما موضوع هذا المنشور؟',
|
||||
'prompt_placeholder' => 'مثال: الإعلان عن ميزة العرض الدائري الجديدة لدينا على Instagram',
|
||||
|
|
|
|||
|
|
@ -608,6 +608,8 @@
|
|||
'media_optional_label' => 'Wie viele Bilder?',
|
||||
'media_none' => 'Keine',
|
||||
'media_count_label' => 'Anzahl der Bilder',
|
||||
'brand_colors_label' => 'Markenfarben',
|
||||
'brand_colors_description' => 'Wende die Markenpalette deines Workspace auf die generierten Bilder an. Deaktiviere die Option, damit die KI passende Farben zum Inhalt wählt.',
|
||||
'prompt_title' => 'Beschreibe deinen Beitrag',
|
||||
'prompt_label' => 'Worum geht es in diesem Beitrag?',
|
||||
'prompt_placeholder' => 'z. B. Unser neues Karussell-Feature für Instagram ankündigen',
|
||||
|
|
|
|||
|
|
@ -606,6 +606,8 @@
|
|||
'media_optional_label' => 'Πόσες εικόνες;',
|
||||
'media_none' => 'Καμία',
|
||||
'media_count_label' => 'Αριθμός εικόνων',
|
||||
'brand_colors_label' => 'Χρώματα επωνυμίας',
|
||||
'brand_colors_description' => 'Εφάρμοσε την παλέτα της επωνυμίας σου στις εικόνες που δημιουργούνται. Απενεργοποίησέ το για να επιλέξει το AI χρώματα που ταιριάζουν στο περιεχόμενο.',
|
||||
'prompt_title' => 'Περιγράψτε τη δημοσίευσή σας',
|
||||
'prompt_label' => 'Ποιο είναι το θέμα αυτής της δημοσίευσης;',
|
||||
'prompt_placeholder' => 'π.χ. Ανακοινώστε τη νέα μας λειτουργία carousel για το Instagram',
|
||||
|
|
|
|||
|
|
@ -606,6 +606,8 @@
|
|||
'media_optional_label' => 'How many images?',
|
||||
'media_none' => 'None',
|
||||
'media_count_label' => 'Number of images',
|
||||
'brand_colors_label' => 'Brand colors',
|
||||
'brand_colors_description' => 'Apply your workspace brand palette to the generated images. Turn it off to let the AI pick colors that fit the content.',
|
||||
'prompt_title' => 'Describe your post',
|
||||
'prompt_label' => 'What is this post about?',
|
||||
'prompt_placeholder' => 'e.g. Announce our new carousel feature for Instagram',
|
||||
|
|
|
|||
|
|
@ -607,6 +607,8 @@
|
|||
'media_optional_label' => '¿Cuántas imágenes?',
|
||||
'media_none' => 'Ninguna',
|
||||
'media_count_label' => 'Número de imágenes',
|
||||
'brand_colors_label' => 'Colores de marca',
|
||||
'brand_colors_description' => 'Aplica la paleta de tu marca a las imágenes generadas. Desactívalo para que la IA elija los colores que mejor encajen con el contenido.',
|
||||
'prompt_title' => 'Describe tu post',
|
||||
'prompt_label' => '¿De qué trata este post?',
|
||||
'prompt_placeholder' => 'Ej. Anuncia nuestra nueva función de carrusel para Instagram',
|
||||
|
|
|
|||
|
|
@ -606,6 +606,8 @@
|
|||
'media_optional_label' => 'Combien d\'images ?',
|
||||
'media_none' => 'Aucune',
|
||||
'media_count_label' => 'Nombre d\'images',
|
||||
'brand_colors_label' => 'Couleurs de la marque',
|
||||
'brand_colors_description' => 'Appliquez la palette de votre marque aux images générées. Désactivez pour laisser l’IA choisir des couleurs adaptées au contenu.',
|
||||
'prompt_title' => 'Décrivez votre publication',
|
||||
'prompt_label' => 'De quoi parle cette publication ?',
|
||||
'prompt_placeholder' => 'par ex. Annoncer notre nouvelle fonctionnalité de carrousel pour Instagram',
|
||||
|
|
|
|||
|
|
@ -606,6 +606,8 @@
|
|||
'media_optional_label' => 'Quante immagini?',
|
||||
'media_none' => 'Nessuna',
|
||||
'media_count_label' => 'Numero di immagini',
|
||||
'brand_colors_label' => 'Colori del brand',
|
||||
'brand_colors_description' => 'Applica la palette del tuo brand alle immagini generate. Disattivalo per lasciare che l’IA scelga i colori più adatti al contenuto.',
|
||||
'prompt_title' => 'Descrivi il tuo post',
|
||||
'prompt_label' => 'Di cosa parla questo post?',
|
||||
'prompt_placeholder' => 'es. Annuncia la nostra nuova funzione carosello per Instagram',
|
||||
|
|
|
|||
|
|
@ -606,6 +606,8 @@
|
|||
'media_optional_label' => '画像は何枚にしますか?',
|
||||
'media_none' => 'なし',
|
||||
'media_count_label' => '画像の枚数',
|
||||
'brand_colors_label' => 'ブランドカラー',
|
||||
'brand_colors_description' => '生成される画像にワークスペースのブランドカラーを適用します。オフにすると、AI がコンテンツに合った色を選びます。',
|
||||
'prompt_title' => '投稿を説明',
|
||||
'prompt_label' => 'この投稿は何についてですか?',
|
||||
'prompt_placeholder' => '例: Instagram 向けの新しいカルーセル機能を発表',
|
||||
|
|
|
|||
|
|
@ -606,6 +606,8 @@
|
|||
'media_optional_label' => '이미지는 몇 장인가요?',
|
||||
'media_none' => '없음',
|
||||
'media_count_label' => '이미지 수',
|
||||
'brand_colors_label' => '브랜드 색상',
|
||||
'brand_colors_description' => '생성되는 이미지에 워크스페이스 브랜드 색상을 적용합니다. 끄면 AI가 콘텐츠에 어울리는 색상을 선택합니다.',
|
||||
'prompt_title' => '게시물을 설명하세요',
|
||||
'prompt_label' => '이 게시물은 무엇에 관한 것인가요?',
|
||||
'prompt_placeholder' => '예: Instagram용 새 캐러셀 기능 발표',
|
||||
|
|
|
|||
|
|
@ -606,6 +606,8 @@
|
|||
'media_optional_label' => 'Hoeveel afbeeldingen?',
|
||||
'media_none' => 'Geen',
|
||||
'media_count_label' => 'Aantal afbeeldingen',
|
||||
'brand_colors_label' => 'Merkkleuren',
|
||||
'brand_colors_description' => 'Pas het merkkleurenpalet van je workspace toe op de gegenereerde afbeeldingen. Zet het uit om de AI kleuren te laten kiezen die bij de inhoud passen.',
|
||||
'prompt_title' => 'Beschrijf je post',
|
||||
'prompt_label' => 'Waar gaat deze post over?',
|
||||
'prompt_placeholder' => 'bijv. Kondig onze nieuwe carrouselfunctie voor Instagram aan',
|
||||
|
|
|
|||
|
|
@ -606,6 +606,8 @@
|
|||
'media_optional_label' => 'Ile obrazów?',
|
||||
'media_none' => 'Brak',
|
||||
'media_count_label' => 'Liczba obrazów',
|
||||
'brand_colors_label' => 'Kolory marki',
|
||||
'brand_colors_description' => 'Zastosuj paletę marki Twojego obszaru roboczego do generowanych obrazów. Wyłącz, aby AI dobrała kolory pasujące do treści.',
|
||||
'prompt_title' => 'Opisz swój post',
|
||||
'prompt_label' => 'O czym jest ten post?',
|
||||
'prompt_placeholder' => 'np. Ogłoś naszą nową funkcję karuzeli na Instagramie',
|
||||
|
|
|
|||
|
|
@ -606,6 +606,8 @@
|
|||
'media_optional_label' => 'Quantas imagens?',
|
||||
'media_none' => 'Nenhuma',
|
||||
'media_count_label' => 'Número de imagens',
|
||||
'brand_colors_label' => 'Cores da marca',
|
||||
'brand_colors_description' => 'Aplica a paleta da sua marca nas imagens geradas. Desative para deixar a IA escolher as cores que combinam com o conteúdo.',
|
||||
'prompt_title' => 'Descreva seu post',
|
||||
'prompt_label' => 'Sobre o que é este post?',
|
||||
'prompt_placeholder' => 'Ex. Anunciar nossa nova função de carrossel para o Instagram',
|
||||
|
|
|
|||
|
|
@ -606,6 +606,8 @@
|
|||
'media_optional_label' => 'Сколько изображений?',
|
||||
'media_none' => 'Нет',
|
||||
'media_count_label' => 'Количество изображений',
|
||||
'brand_colors_label' => 'Цвета бренда',
|
||||
'brand_colors_description' => 'Применяйте фирменную палитру вашего рабочего пространства к создаваемым изображениям. Отключите, чтобы ИИ подобрал цвета под содержание.',
|
||||
'prompt_title' => 'Опишите свой пост',
|
||||
'prompt_label' => 'О чём этот пост?',
|
||||
'prompt_placeholder' => 'например, Анонсировать нашу новую функцию карусели для Instagram',
|
||||
|
|
|
|||
|
|
@ -608,6 +608,8 @@
|
|||
'media_optional_label' => 'Kaç görsel?',
|
||||
'media_none' => 'Hiçbiri',
|
||||
'media_count_label' => 'Görsel sayısı',
|
||||
'brand_colors_label' => 'Marka renkleri',
|
||||
'brand_colors_description' => 'Oluşturulan görsellere çalışma alanınızın marka paletini uygular. Yapay zekânın içeriğe uygun renkleri seçmesi için kapatın.',
|
||||
'prompt_title' => 'Gönderinizi açıklayın',
|
||||
'prompt_label' => 'Bu gönderi ne hakkında?',
|
||||
'prompt_placeholder' => 'örn. Instagram için yeni karusel özelliğimizi duyur',
|
||||
|
|
|
|||
|
|
@ -606,6 +606,8 @@
|
|||
'media_optional_label' => '要多少张图片?',
|
||||
'media_none' => '无',
|
||||
'media_count_label' => '图片数量',
|
||||
'brand_colors_label' => '品牌颜色',
|
||||
'brand_colors_description' => '将工作区的品牌配色应用到生成的图片。关闭后由 AI 选择适合内容的颜色。',
|
||||
'prompt_title' => '描述你的帖子',
|
||||
'prompt_label' => '这条帖子讲的是什么?',
|
||||
'prompt_placeholder' => '例如 为 Instagram 宣布我们全新的轮播功能',
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { start as startRoute } from '@/actions/App/Http/Controllers/App/PostAiCr
|
|||
import ContentStylePicker from '@/components/ai/ContentStylePicker.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { getPlatformLogo } from '@/composables/usePlatformLogo';
|
||||
import { loading as loadingRoute } from '@/routes/app/posts/ai';
|
||||
|
|
@ -33,6 +34,7 @@ interface AiTemplate {
|
|||
preview: string;
|
||||
needs_account: boolean;
|
||||
supported_formats: string[];
|
||||
applies_brand_visuals: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
|
|
@ -63,6 +65,8 @@ const selectedAccountId = ref<string | null>(null);
|
|||
const includeImages = ref(true);
|
||||
const imageCount = ref(2);
|
||||
const promptText = ref('');
|
||||
// true = images use the workspace brand palette; false = the AI picks colors freely.
|
||||
const useBrandColors = ref(true);
|
||||
const PROMPT_MIN = 3;
|
||||
const PROMPT_MAX = 2000;
|
||||
|
||||
|
|
@ -75,7 +79,8 @@ const httpStart = useHttp<{
|
|||
prompt: string;
|
||||
date: string | null;
|
||||
template: string;
|
||||
}>({ format: null, social_account_id: null, image_count: 0, prompt: '', date: null, template: 'image_card' });
|
||||
apply_brand_visuals: boolean;
|
||||
}>({ format: null, social_account_id: null, image_count: 0, prompt: '', date: null, template: 'image_card', apply_brand_visuals: true });
|
||||
|
||||
const AI_FORMATS: Array<{ value: AiFormat; platforms: string[] }> = [
|
||||
{ value: ContentType.InstagramFeed, platforms: ['instagram', 'instagram-facebook'] },
|
||||
|
|
@ -212,6 +217,7 @@ const startGeneration = async () => {
|
|||
httpStart.prompt = promptText.value.trim();
|
||||
httpStart.date = props.date;
|
||||
httpStart.template = resolvedTemplate.value;
|
||||
httpStart.apply_brand_visuals = useBrandColors.value;
|
||||
|
||||
try {
|
||||
const data = await httpStart.post(startRoute.url()) as { creation_id: string; channel: string };
|
||||
|
|
@ -354,6 +360,19 @@ const startGeneration = async () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Brand colors: apply the workspace palette or let the AI decide. Only
|
||||
for image templates that honor it (tweet cards are always branded). -->
|
||||
<div
|
||||
v-if="selectedFormat && submittedImageCount > 0 && resolvedTemplateRecord?.applies_brand_visuals"
|
||||
class="flex items-center justify-between gap-4 rounded-xl border-2 border-foreground bg-card p-4 shadow-2xs"
|
||||
>
|
||||
<div class="space-y-0.5">
|
||||
<Label for="apply-brand-visuals" class="text-sm font-bold">{{ $t('posts.create.steps.brand_colors_label') }}</Label>
|
||||
<p class="text-sm text-foreground/70">{{ $t('posts.create.steps.brand_colors_description') }}</p>
|
||||
</div>
|
||||
<Switch id="apply-brand-visuals" v-model="useBrandColors" />
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -200,6 +200,33 @@
|
|||
Bus::assertDispatched(StreamPostCreation::class, fn ($job) => $job->date === '2026-06-15');
|
||||
});
|
||||
|
||||
test('start carries apply_brand_visuals=false when the user lets the AI decide colors', function () {
|
||||
Bus::fake();
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('app.posts.ai.create'), [
|
||||
'prompt' => 'hello',
|
||||
'format' => 'x_post',
|
||||
'apply_brand_visuals' => false,
|
||||
])
|
||||
->assertAccepted();
|
||||
|
||||
Bus::assertDispatched(StreamPostCreation::class, fn ($job) => $job->applyBrandVisuals === false);
|
||||
});
|
||||
|
||||
test('start defaults apply_brand_visuals to true when omitted', function () {
|
||||
Bus::fake();
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('app.posts.ai.create'), [
|
||||
'prompt' => 'hello',
|
||||
'format' => 'x_post',
|
||||
])
|
||||
->assertAccepted();
|
||||
|
||||
Bus::assertDispatched(StreamPostCreation::class, fn ($job) => $job->applyBrandVisuals === true);
|
||||
});
|
||||
|
||||
test('start rejects invalid date format', function () {
|
||||
Bus::fake();
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
use App\Ai\Templates\ImageCardTemplate;
|
||||
use App\Ai\Templates\TemplateContext;
|
||||
use App\Ai\Templates\TweetCardImageTemplate;
|
||||
use App\Ai\Templates\TweetCardTemplate;
|
||||
use App\Enums\Ai\ContentStyle;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\JsonSchema\JsonSchemaTypeFactory;
|
||||
|
|
@ -16,10 +18,16 @@
|
|||
expect($t->style())->toBe(ContentStyle::ImageCard)
|
||||
->and($t->key())->toBe('image_card')
|
||||
->and($t->needsAccount())->toBeFalse()
|
||||
->and($t->appliesBrandVisuals())->toBeTrue()
|
||||
->and($t->promptView($context))->toBe('prompts.post_content.generator')
|
||||
->and($t->supportedFormats())->toBe([]);
|
||||
});
|
||||
|
||||
test('tweet-card templates do not apply brand visuals', function () {
|
||||
expect((new TweetCardTemplate)->appliesBrandVisuals())->toBeFalse()
|
||||
->and((new TweetCardImageTemplate)->appliesBrandVisuals())->toBeFalse();
|
||||
});
|
||||
|
||||
test('image card carousel context returns carousel schema shape', function () {
|
||||
$t = new ImageCardTemplate;
|
||||
$workspace = Workspace::factory()->create();
|
||||
|
|
|
|||
Loading…
Reference in a new issue