feat: add brand config to workspace and Blade prompt system for AI

This commit is contained in:
Paulo Castellano 2026-04-15 21:28:40 -03:00
parent baf310112e
commit 5493871b2d
10 changed files with 162 additions and 4 deletions

View file

@ -125,7 +125,7 @@ public function store(
->map(fn (AiMessage $m) => ['role' => $m->role, 'content' => $m->content])
->all();
$responseContent = $textService->generate($prompt, $history);
$responseContent = $textService->generate($prompt, $history, $workspace);
}
$assistantMessage = $post->aiMessages()->create([

View file

@ -19,6 +19,10 @@ public function rules(): array
return [
'name' => ['required', 'string', 'max:255'],
'timezone' => ['required', 'string', new Timezone],
'brand_website' => ['nullable', 'url', 'max:255'],
'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'],
];
}

View file

@ -24,6 +24,10 @@ class Workspace extends Model
'user_id',
'name',
'timezone',
'brand_website',
'brand_description',
'brand_tone',
'brand_voice_notes',
];
protected $appends = ['has_logo', 'logo_url'];

View file

@ -4,6 +4,7 @@
namespace App\Services\Ai;
use App\Models\Workspace;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
@ -21,12 +22,20 @@ public function __construct()
/**
* @param array<int, array{role: string, content: string}> $history
*/
public function generate(string $prompt, array $history = []): string
public function generate(string $prompt, array $history = [], ?Workspace $workspace = null): string
{
$systemPrompt = view('prompts.assistant.system', [
'brandName' => $workspace?->name ?? '',
'brandDescription' => $workspace?->brand_description ?? '',
'brandWebsite' => $workspace?->brand_website ?? '',
'tone' => $workspace?->brand_tone ?? 'professional',
'voiceNotes' => $workspace?->brand_voice_notes ?? '',
])->render();
$messages = [
[
'role' => 'system',
'content' => 'You are a social media content expert. Help users write engaging captions, hashtags, and post content. Be creative, concise, and on-brand. Respond in the same language the user writes in.',
'content' => $systemPrompt,
],
...$history,
['role' => 'user', 'content' => $prompt],

View file

@ -19,6 +19,10 @@ public function up(): void
$table->uuid('user_id')->nullable();
$table->string('name');
$table->string('timezone');
$table->string('brand_website')->nullable();
$table->text('brand_description')->nullable();
$table->string('brand_tone')->default('professional');
$table->text('brand_voice_notes')->nullable();
$table->timestamps();
$table->foreign('user_id')->references('id')->on('users')->nullOnDelete();

View file

@ -84,6 +84,25 @@
'save' => 'Save',
],
'brand' => [
'title' => 'Brand',
'description' => 'Configure your brand identity for AI-generated content.',
'website' => 'Website',
'website_placeholder' => 'https://yourbrand.com',
'brand_description' => 'Description',
'brand_description_placeholder' => 'Tell us about your brand, what you do, and who your audience is...',
'tone' => 'Tone of voice',
'tone_professional' => 'Professional',
'tone_casual' => 'Casual',
'tone_friendly' => 'Friendly',
'tone_bold' => 'Bold',
'tone_inspirational' => 'Inspirational',
'tone_humorous' => 'Humorous',
'tone_educational' => 'Educational',
'voice_notes' => 'Voice notes',
'voice_notes_placeholder' => 'Additional writing guidelines, words to avoid, style preferences...',
],
'members' => [
'title' => 'Members',
'heading' => 'Team members',

File diff suppressed because one or more lines are too long

View file

@ -22,6 +22,13 @@ import {
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import {
Table,
@ -31,6 +38,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Textarea } from '@/components/ui/textarea';
import AppLayout from '@/layouts/AppLayout.vue';
import { destroy as destroyInvite } from '@/routes/app/invites';
import { remove as removeMemberRoute, updateRole } from '@/routes/app/members';
@ -41,6 +49,10 @@ interface Workspace {
timezone: string;
has_logo: boolean;
logo_url: string | null;
brand_website: string | null;
brand_description: string | null;
brand_tone: string;
brand_voice_notes: string | null;
}
interface Member {
@ -64,6 +76,7 @@ const props = defineProps<{
}>();
const timezone = ref(props.workspace.timezone);
const brandTone = ref(props.workspace.brand_tone ?? 'professional');
const inviteDialogOpen = ref(false);
const removeMemberModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
@ -136,6 +149,82 @@ const changeRole = (member: Member, role: string) => {
<Separator />
<div class="flex flex-col space-y-6">
<HeadingSmall
:title="$t('settings.brand.title')"
:description="$t('settings.brand.description')"
/>
<Form
v-bind="WorkspaceController.updateSettings.form()"
class="space-y-6"
v-slot="{ errors, processing }"
>
<input type="hidden" name="name" :value="workspace.name" />
<input type="hidden" name="timezone" :value="workspace.timezone" />
<div class="grid gap-2">
<Label for="brand_website">{{ $t('settings.brand.website') }}</Label>
<Input
id="brand_website"
name="brand_website"
type="url"
:default-value="workspace.brand_website ?? ''"
:placeholder="trans('settings.brand.website_placeholder')"
/>
<InputError :message="errors.brand_website" />
</div>
<div class="grid gap-2">
<Label for="brand_description">{{ $t('settings.brand.brand_description') }}</Label>
<Textarea
id="brand_description"
name="brand_description"
:default-value="workspace.brand_description ?? ''"
:placeholder="trans('settings.brand.brand_description_placeholder')"
rows="3"
/>
<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>
<div class="grid gap-2">
<Label for="brand_voice_notes">{{ $t('settings.brand.voice_notes') }}</Label>
<Textarea
id="brand_voice_notes"
name="brand_voice_notes"
:default-value="workspace.brand_voice_notes ?? ''"
:placeholder="trans('settings.brand.voice_notes_placeholder')"
rows="3"
/>
<InputError :message="errors.brand_voice_notes" />
</div>
<Button :disabled="processing">{{ $t('settings.workspace.save') }}</Button>
</Form>
</div>
<Separator />
<div class="flex flex-col space-y-6">
<div class="flex items-center justify-between">
<HeadingSmall

View file

@ -0,0 +1,9 @@
Create a social media image.
@if($brandName)
Brand: {{ $brandName }}
@endif
@if($tone)
Style should match a {{ $tone }} tone.
@endif
User request: {{ $prompt }}

View file

@ -0,0 +1,20 @@
You are a social media content expert.
@if($brandName)
You are creating content for {{ $brandName }}.
@endif
@if($brandDescription)
About the brand: {{ $brandDescription }}
@endif
@if($brandWebsite)
Brand website: {{ $brandWebsite }}
@endif
Tone of voice: {{ $tone }}
@if($voiceNotes)
Additional guidelines: {{ $voiceNotes }}
@endif
Help users write engaging captions, hashtags, and post content. Be creative, concise, and on-brand. Respond in the same language the user writes in.