feat: add SocialMediaAssistant agent with Conversational interface

The agent implements Laravel AI SDK's Agent + Conversational contracts:
- instructions() renders the existing Blade system prompt with workspace
  brand context (name, description, website, tone, voice notes, locale)
- messages() reads directly from the AiMessage model scoped to the post,
  so our existing conversation persistence stays the single source of
  truth — no duplicate SDK-managed storage.
- Assistant messages with attachments are enriched inline with a summary
  like [This assistant message attached: 2 image] so the model tracks
  progress through carousel-style multi-image generations.
- provider() maps the trypost.ai.text_provider config to Lab::Gemini or
  Lab::OpenAI, preserving the existing provider-switching behavior.
This commit is contained in:
Paulo Castellano 2026-04-16 09:51:56 -03:00
parent a962124097
commit 6007aec17d
2 changed files with 188 additions and 0 deletions

View file

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Ai\Agents;
use App\Models\AiMessage;
use App\Models\Post;
use App\Models\Workspace;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Messages\Message;
use Laravel\Ai\Promptable;
class SocialMediaAssistant implements Agent, Conversational
{
use Promptable;
public function __construct(
public Workspace $workspace,
public ?Post $post = null,
public ?string $userId = null,
) {}
public function instructions(): string
{
return view('prompts.assistant.system', [
'brand_name' => $this->workspace->name ?? '',
'brand_description' => $this->workspace->brand_description ?? '',
'brand_website' => $this->workspace->brand_website ?? '',
'tone' => $this->workspace->brand_tone ?? 'professional',
'voice_notes' => $this->workspace->brand_voice_notes ?? '',
'locale' => app()->getLocale(),
])->render();
}
/**
* @return iterable<Message>
*/
public function messages(): iterable
{
if (! $this->post) {
return [];
}
return AiMessage::query()
->where('post_id', $this->post->id)
->whereIn('role', ['user', 'assistant'])
->oldest()
->limit(20)
->get()
->map(fn (AiMessage $m) => new Message($m->role, $this->enrichContent($m)))
->all();
}
public function provider(): Lab
{
return match (config('trypost.ai.text_provider')) {
'openai' => Lab::OpenAI,
default => Lab::Gemini,
};
}
private function enrichContent(AiMessage $m): string
{
$content = $m->content;
if ($m->role === 'assistant' && ! empty($m->attachments)) {
$counts = collect($m->attachments)
->groupBy('type')
->map(fn ($group, $type) => count($group)." {$type}")
->implode(', ');
$content .= "\n\n[This assistant message attached: {$counts}]";
}
return $content;
}
}

View file

@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
use App\Ai\Agents\SocialMediaAssistant;
use App\Enums\User\Setup;
use App\Enums\UserWorkspace\Role;
use App\Models\AiMessage;
use App\Models\Post;
use App\Models\User;
use App\Models\Workspace;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Messages\Message;
beforeEach(function () {
$this->user = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace = Workspace::factory()->create([
'user_id' => $this->user->id,
'name' => 'Paulo Coffee',
'brand_tone' => 'friendly',
]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
$this->post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
});
test('instructions include brand context from workspace', function () {
$agent = new SocialMediaAssistant($this->workspace, $this->post);
$instructions = $agent->instructions();
expect($instructions)
->toContain('Paulo Coffee')
->toContain('friendly');
});
test('messages returns AiMessage rows as SDK messages', function () {
AiMessage::factory()->create([
'post_id' => $this->post->id,
'role' => 'user',
'content' => 'Write a caption',
]);
AiMessage::factory()->create([
'post_id' => $this->post->id,
'role' => 'assistant',
'content' => 'Here it is!',
]);
$agent = new SocialMediaAssistant($this->workspace, $this->post);
$messages = collect($agent->messages())->all();
expect($messages)->toHaveCount(2);
expect($messages[0])->toBeInstanceOf(Message::class);
expect($messages[0]->role->value)->toBe('user');
expect($messages[0]->content)->toBe('Write a caption');
expect($messages[1]->role->value)->toBe('assistant');
expect($messages[1]->content)->toBe('Here it is!');
});
test('messages is empty when no post is provided', function () {
$agent = new SocialMediaAssistant($this->workspace);
$messages = collect($agent->messages())->all();
expect($messages)->toBeEmpty();
});
test('messages is empty when post has no AiMessages', function () {
$agent = new SocialMediaAssistant($this->workspace, $this->post);
$messages = collect($agent->messages())->all();
expect($messages)->toBeEmpty();
});
test('messages enriches assistant content with attachment summary', function () {
AiMessage::factory()->create([
'post_id' => $this->post->id,
'role' => 'assistant',
'content' => 'Here is your image',
'attachments' => [
['id' => 'a', 'type' => 'image'],
['id' => 'b', 'type' => 'image'],
],
]);
$agent = new SocialMediaAssistant($this->workspace, $this->post);
$messages = collect($agent->messages())->all();
expect($messages[0]->content)->toContain('Here is your image');
expect($messages[0]->content)->toContain('[This assistant message attached: 2 image]');
});
test('provider honors trypost.ai.text_provider config', function () {
config()->set('trypost.ai.text_provider', 'openai');
expect((new SocialMediaAssistant($this->workspace))->provider())->toBe(Lab::OpenAI);
config()->set('trypost.ai.text_provider', 'gemini');
expect((new SocialMediaAssistant($this->workspace))->provider())->toBe(Lab::Gemini);
});