feat: add workspace content_language setting for AI generation

Users can now pick the language AI uses for captions, hashtags,
descriptions, AND on-image / on-screen text — independent of the
app UI locale. A user with pt-BR UI who writes for an English
audience gets English content, and vice versa.

- New content_language column on workspaces (added to the existing
  create_workspaces_table migration since we're pre-production),
  defaulting to 'en'. Fillable on the model.
- Dropdown in Settings → Workspace → Brand, side-by-side with Tone.
  Options: English, Português (Brasil), Español. Short helper copy
  explains what the setting controls.
- Validation: sometimes|string|in:en,pt-BR,es — optional on update
  so existing callers that don't post the field keep the current
  value (DB default handles fresh workspaces).
- System prompt now uses $content_language (sourced from the
  workspace) instead of app()->getLocale(). The rule was also
  strengthened: the AI must always write in this language regardless
  of what language the user types instructions in, and must instruct
  image/video tools to render on-media text in this language too.
- Image and video Blade prompts received a new line forcing any text
  rendered inside the generated media to use $content_language.
- GenerateImage tool and VideoGenerationService pass the workspace's
  content_language into their respective prompt templates.

Previously the caption would be in Portuguese (since it followed the
user's input language) but the image could render with English text
because there was no explicit constraint. Now the whole pipeline is
aligned to one per-workspace language.
This commit is contained in:
Paulo Castellano 2026-04-16 10:37:03 -03:00
parent 5f7768ca3a
commit aaa4db1dbb
14 changed files with 61 additions and 25 deletions

View file

@ -40,11 +40,16 @@ public function instructions(): string
'brand_website' => $this->workspace->brand_website ?? '',
'tone' => $this->workspace->brand_tone ?? 'professional',
'voice_notes' => $this->workspace->brand_voice_notes ?? '',
'locale' => app()->getLocale(),
'content_language' => $this->workspace->content_language ?? 'en',
'platform_rules' => $this->activePlatformRules(),
])->render();
}
public function contentLanguage(): string
{
return $this->workspace->content_language ?? 'en';
}
/**
* @return array<int, Contract>
*/

View file

@ -65,6 +65,7 @@ public function handle(Request $request): Stringable|string
'brand_name' => $this->workspace->name ?? '',
'tone' => $this->workspace->brand_tone ?? 'professional',
'aspect_ratio' => $aspectRatio,
'content_language' => $this->workspace->content_language ?? 'en',
])->render();
$response = Image::of($renderedPrompt)

View file

@ -23,6 +23,7 @@ public function rules(): array
'brand_description' => ['nullable', 'string', 'max:2000'],
'brand_tone' => ['nullable', 'string', 'in:professional,casual,friendly,bold,inspirational,humorous,educational'],
'brand_voice_notes' => ['nullable', 'string', 'max:2000'],
'content_language' => ['sometimes', 'string', 'in:en,pt-BR,es'],
];
}

View file

@ -28,6 +28,7 @@ class Workspace extends Model
'brand_description',
'brand_tone',
'brand_voice_notes',
'content_language',
];
protected $appends = ['has_logo', 'logo_url'];

View file

@ -46,6 +46,7 @@ public function generate(string $prompt, Workspace $workspace, ?string $userId =
'prompt' => $prompt,
'brand_name' => $workspace->name ?? '',
'tone' => $workspace->brand_tone ?? 'professional',
'content_language' => $workspace->content_language ?? 'en',
])->render();
$operationName = $this->startGeneration($fullPrompt, $aspectRatio);

View file

@ -23,6 +23,7 @@ public function up(): void
$table->text('brand_description')->nullable();
$table->string('brand_tone')->default('professional');
$table->text('brand_voice_notes')->nullable();
$table->string('content_language', 10)->default('en');
$table->timestamps();
$table->foreign('user_id')->references('id')->on('users')->nullOnDelete();

View file

@ -101,6 +101,8 @@
'tone_educational' => 'Educational',
'voice_notes' => 'Voice notes',
'voice_notes_placeholder' => 'Additional writing guidelines, words to avoid, style preferences...',
'content_language' => 'Content language',
'content_language_description' => 'Language used for AI-generated captions, hashtags, and any text inside generated images or videos.',
],
'members' => [

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -53,6 +53,7 @@ interface Workspace {
brand_description: string | null;
brand_tone: string;
brand_voice_notes: string | null;
content_language: string;
}
interface Member {
@ -77,6 +78,7 @@ const props = defineProps<{
const timezone = ref(props.workspace.timezone);
const brandTone = ref(props.workspace.brand_tone ?? 'professional');
const contentLanguage = ref(props.workspace.content_language ?? 'en');
const inviteDialogOpen = ref(false);
const removeMemberModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
@ -187,26 +189,48 @@ const changeRole = (member: Member, role: string) => {
<InputError :message="errors.brand_description" />
</div>
<div class="grid gap-2">
<Label for="brand_tone">{{ $t('settings.brand.tone') }}</Label>
<Select v-model="brandTone" name="brand_tone">
<SelectTrigger id="brand_tone">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="professional">{{ $t('settings.brand.tone_professional') }}</SelectItem>
<SelectItem value="casual">{{ $t('settings.brand.tone_casual') }}</SelectItem>
<SelectItem value="friendly">{{ $t('settings.brand.tone_friendly') }}</SelectItem>
<SelectItem value="bold">{{ $t('settings.brand.tone_bold') }}</SelectItem>
<SelectItem value="inspirational">{{ $t('settings.brand.tone_inspirational') }}</SelectItem>
<SelectItem value="humorous">{{ $t('settings.brand.tone_humorous') }}</SelectItem>
<SelectItem value="educational">{{ $t('settings.brand.tone_educational') }}</SelectItem>
</SelectContent>
</Select>
<input type="hidden" name="brand_tone" :value="brandTone" />
<InputError :message="errors.brand_tone" />
<div class="grid gap-4 sm:grid-cols-2">
<div class="grid gap-2">
<Label for="brand_tone">{{ $t('settings.brand.tone') }}</Label>
<Select v-model="brandTone" name="brand_tone">
<SelectTrigger id="brand_tone">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="professional">{{ $t('settings.brand.tone_professional') }}</SelectItem>
<SelectItem value="casual">{{ $t('settings.brand.tone_casual') }}</SelectItem>
<SelectItem value="friendly">{{ $t('settings.brand.tone_friendly') }}</SelectItem>
<SelectItem value="bold">{{ $t('settings.brand.tone_bold') }}</SelectItem>
<SelectItem value="inspirational">{{ $t('settings.brand.tone_inspirational') }}</SelectItem>
<SelectItem value="humorous">{{ $t('settings.brand.tone_humorous') }}</SelectItem>
<SelectItem value="educational">{{ $t('settings.brand.tone_educational') }}</SelectItem>
</SelectContent>
</Select>
<input type="hidden" name="brand_tone" :value="brandTone" />
<InputError :message="errors.brand_tone" />
</div>
<div class="grid gap-2">
<Label for="content_language">{{ $t('settings.brand.content_language') }}</Label>
<Select v-model="contentLanguage" name="content_language">
<SelectTrigger id="content_language">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="pt-BR">Português (Brasil)</SelectItem>
<SelectItem value="es">Español</SelectItem>
</SelectContent>
</Select>
<input type="hidden" name="content_language" :value="contentLanguage" />
<InputError :message="errors.content_language" />
</div>
</div>
<p class="-mt-4 text-xs text-muted-foreground">
{{ $t('settings.brand.content_language_description') }}
</p>
<div class="grid gap-2">
<Label for="brand_voice_notes">{{ $t('settings.brand.voice_notes') }}</Label>
<Textarea

View file

@ -5,6 +5,7 @@
- Eye-catching, professional quality suitable for social media
- Modern design with vibrant colors and clean composition
- If including text on the image, keep it minimal, bold, and highly readable
- ANY text that appears on the image MUST be written in {{ $content_language }}. Do not use any other language for on-image text.
- No watermarks, no borders, no mockup frames
- Do NOT include any offensive, violent, sexual, or inappropriate content
@if($brand_name)

View file

@ -21,9 +21,10 @@
- NEVER say "I don't have access to the latest news" create the best engaging content you can.
- NEVER leave blanks or TODOs for the user to fill in.
LANGUAGE RULES:
- The user's system language is: {{ $locale }}.
- ALWAYS respond in the same language the user writes in.
LANGUAGE RULES (CRITICAL):
- The workspace's configured content language is: {{ $content_language }}.
- ALWAYS write captions, hashtags, descriptions, and ALL your responses in this language regardless of the language the user writes their instructions in.
- When calling generate_image or generate_video, the prompt you pass MUST instruct the image/video generation model to render any on-image text in {{ $content_language }}. For example, prepend your image prompt with "All visible text in the image must be in {{ $content_language }}." Never let the image show text in a different language than the post itself.
SCOPE RULES:
- You ONLY help with social media content creation.

View file

@ -5,6 +5,7 @@
- Professional quality suitable for social media platforms
- Modern aesthetic with good lighting and composition
- Hook the viewer in the first 1-2 seconds
- ANY text that appears on screen or any spoken narration MUST be in {{ $content_language }}. Do not use any other language for on-screen text or audio.
- No watermarks, no borders
- Do NOT include any offensive, violent, sexual, or inappropriate content
@if($brand_name)