feat: add GenerateImage, GenerateVideo, GenerateAudio SDK tools
Each tool implements Laravel\Ai\Contracts\Tool with description(), handle(Request), and schema(JsonSchema). They share a common pattern: - Constructor receives Workspace, optional Post, userId, and an optional AttachmentCollector (resolves from container if not injected). - handle() does the work (Image::of(), Audio::of(), or calls our custom VideoGenerationService for Veo), persists media + usage log, pushes the full attachment shape into the collector, and returns a short text summary to the LLM. - schema() exposes typed parameters with enum constraints and doc strings so the LLM selects valid inputs. Also fix the Ai::extend closure signature — MultipleInstanceManager passes (app, config) not just (config) to custom creators.
This commit is contained in:
parent
93a84f8f95
commit
fb5d2e4f02
7 changed files with 502 additions and 3 deletions
92
app/Ai/Tools/GenerateAudio.php
Normal file
92
app/Ai/Tools/GenerateAudio.php
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Ai\Tools;
|
||||
|
||||
use App\Enums\Ai\UsageType;
|
||||
use App\Models\AiUsageLog;
|
||||
use App\Models\Post;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Ai\Audio;
|
||||
use Laravel\Ai\Contracts\Tool;
|
||||
use Laravel\Ai\Tools\Request;
|
||||
use Stringable;
|
||||
|
||||
class GenerateAudio implements Tool
|
||||
{
|
||||
public function __construct(
|
||||
public Workspace $workspace,
|
||||
public ?Post $post = null,
|
||||
public ?string $userId = null,
|
||||
public ?AttachmentCollector $collector = null,
|
||||
) {
|
||||
$this->collector ??= app(AttachmentCollector::class);
|
||||
}
|
||||
|
||||
public function description(): Stringable|string
|
||||
{
|
||||
return <<<'TXT'
|
||||
Generate an AI voiceover or narration audio from text.
|
||||
|
||||
Call this when the user asks for a voiceover, narration, TTS, podcast clip,
|
||||
or any voice content. The output language will match the language of the
|
||||
input text — so pass the text in the target language.
|
||||
|
||||
Audio is generated via ElevenLabs with the configured default voice.
|
||||
TXT;
|
||||
}
|
||||
|
||||
public function handle(Request $request): Stringable|string
|
||||
{
|
||||
$text = (string) $request['text'];
|
||||
$voice = config('services.elevenlabs.default_voice', 'EXAVITQu4vr4xnSDxMaL');
|
||||
|
||||
$response = Audio::of($text)->voice($voice)->generate();
|
||||
|
||||
$storedPath = $response->store('medias', 'public');
|
||||
|
||||
$media = $this->workspace->media()->create([
|
||||
'group_id' => Str::uuid()->toString(),
|
||||
'collection' => 'assets',
|
||||
'type' => 'video',
|
||||
'path' => $storedPath,
|
||||
'original_filename' => 'ai-generated.mp3',
|
||||
'mime_type' => 'audio/mpeg',
|
||||
'size' => Storage::disk('public')->size($storedPath),
|
||||
'order' => 0,
|
||||
'meta' => ['ai_generated' => true, 'text' => Str::limit($text, 200)],
|
||||
]);
|
||||
|
||||
AiUsageLog::create([
|
||||
'account_id' => $this->workspace->account_id,
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->userId,
|
||||
'post_id' => $this->post?->id,
|
||||
'type' => UsageType::Audio,
|
||||
'provider' => 'elevenlabs',
|
||||
]);
|
||||
|
||||
$this->collector->push([
|
||||
'id' => $media->id,
|
||||
'path' => $media->path,
|
||||
'url' => $media->url,
|
||||
'mime_type' => 'audio/mpeg',
|
||||
'type' => 'audio',
|
||||
]);
|
||||
|
||||
return "Generated audio (id: {$media->id}) and attached it to the post.";
|
||||
}
|
||||
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'text' => $schema->string()
|
||||
->description('The text to convert into speech. Write it in the target language — the output voice language will match.')
|
||||
->required(),
|
||||
];
|
||||
}
|
||||
}
|
||||
112
app/Ai/Tools/GenerateImage.php
Normal file
112
app/Ai/Tools/GenerateImage.php
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Ai\Tools;
|
||||
|
||||
use App\Enums\Ai\Orientation;
|
||||
use App\Enums\Ai\UsageType;
|
||||
use App\Models\AiUsageLog;
|
||||
use App\Models\Post;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Ai\Contracts\Tool;
|
||||
use Laravel\Ai\Image;
|
||||
use Laravel\Ai\Tools\Request;
|
||||
use Stringable;
|
||||
|
||||
class GenerateImage implements Tool
|
||||
{
|
||||
public function __construct(
|
||||
public Workspace $workspace,
|
||||
public ?Post $post = null,
|
||||
public ?string $userId = null,
|
||||
public ?AttachmentCollector $collector = null,
|
||||
) {
|
||||
$this->collector ??= app(AttachmentCollector::class);
|
||||
}
|
||||
|
||||
public function description(): Stringable|string
|
||||
{
|
||||
return <<<'TXT'
|
||||
Generate an AI image and attach it to the current post.
|
||||
|
||||
Call this when the user asks for an image, photo, carousel slide, or any visual content.
|
||||
Pass a detailed visual prompt describing what to generate, and an orientation:
|
||||
- "vertical" (9:16) for Instagram Reel/Story, Pinterest, TikTok, YouTube Shorts
|
||||
- "horizontal" (16:9) for X/Twitter, LinkedIn, Facebook
|
||||
|
||||
The image is generated, stored on the public disk, registered in the workspace's
|
||||
media library, logged in monthly usage tracking, and attached to the assistant's
|
||||
response message.
|
||||
TXT;
|
||||
}
|
||||
|
||||
public function handle(Request $request): Stringable|string
|
||||
{
|
||||
$prompt = (string) $request['prompt'];
|
||||
$orientationString = (string) ($request['orientation'] ?? 'vertical');
|
||||
$orientationEnum = Orientation::tryFrom($orientationString) ?? Orientation::Vertical;
|
||||
$aspectRatio = $orientationEnum->aspectRatio();
|
||||
|
||||
$renderedPrompt = view('prompts.assistant.image', [
|
||||
'prompt' => $prompt,
|
||||
'brand_name' => $this->workspace->name ?? '',
|
||||
'tone' => $this->workspace->brand_tone ?? 'professional',
|
||||
'aspect_ratio' => $aspectRatio,
|
||||
])->render();
|
||||
|
||||
$response = Image::of($renderedPrompt)
|
||||
->size($aspectRatio)
|
||||
->quality('high')
|
||||
->generate();
|
||||
|
||||
$storedPath = $response->store('medias', 'public');
|
||||
|
||||
$media = $this->workspace->media()->create([
|
||||
'group_id' => Str::uuid()->toString(),
|
||||
'collection' => 'assets',
|
||||
'type' => 'image',
|
||||
'path' => $storedPath,
|
||||
'original_filename' => 'ai-generated.png',
|
||||
'mime_type' => 'image/png',
|
||||
'size' => Storage::disk('public')->size($storedPath),
|
||||
'order' => 0,
|
||||
'meta' => ['ai_generated' => true, 'prompt' => Str::limit($prompt, 200)],
|
||||
]);
|
||||
|
||||
AiUsageLog::create([
|
||||
'account_id' => $this->workspace->account_id,
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->userId,
|
||||
'post_id' => $this->post?->id,
|
||||
'type' => UsageType::Image,
|
||||
'provider' => 'gemini',
|
||||
]);
|
||||
|
||||
$this->collector->push([
|
||||
'id' => $media->id,
|
||||
'path' => $media->path,
|
||||
'url' => $media->url,
|
||||
'mime_type' => 'image/png',
|
||||
'type' => 'image',
|
||||
]);
|
||||
|
||||
return "Generated a {$orientationString} image (id: {$media->id}) and attached it to the post.";
|
||||
}
|
||||
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'prompt' => $schema->string()
|
||||
->description('A detailed visual description of the image to generate. Include subject, style, composition, mood, and any text that should appear in the image.')
|
||||
->required(),
|
||||
'orientation' => $schema->string()
|
||||
->enum(['vertical', 'horizontal'])
|
||||
->description('"vertical" for 9:16 (Instagram Reel/Story, TikTok, YouTube Shorts, Pinterest). "horizontal" for 16:9 (X/Twitter, LinkedIn, Facebook).')
|
||||
->required(),
|
||||
];
|
||||
}
|
||||
}
|
||||
77
app/Ai/Tools/GenerateVideo.php
Normal file
77
app/Ai/Tools/GenerateVideo.php
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Ai\Tools;
|
||||
|
||||
use App\Enums\Ai\Orientation;
|
||||
use App\Models\Post;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Ai\VideoGenerationService;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Ai\Contracts\Tool;
|
||||
use Laravel\Ai\Tools\Request;
|
||||
use Stringable;
|
||||
|
||||
class GenerateVideo implements Tool
|
||||
{
|
||||
public function __construct(
|
||||
public Workspace $workspace,
|
||||
public ?Post $post = null,
|
||||
public ?string $userId = null,
|
||||
public ?AttachmentCollector $collector = null,
|
||||
) {
|
||||
$this->collector ??= app(AttachmentCollector::class);
|
||||
}
|
||||
|
||||
public function description(): Stringable|string
|
||||
{
|
||||
return <<<'TXT'
|
||||
Generate a short AI video and attach it to the current post.
|
||||
|
||||
Call this when the user asks for a video, Reel, TikTok, YouTube Short,
|
||||
Facebook Reel, or any motion content. Pass a detailed visual description
|
||||
and an orientation:
|
||||
- "vertical" (9:16) for TikTok, Instagram Reel, YouTube Shorts, Facebook Reel, Pinterest Video Pin
|
||||
- "horizontal" (16:9) for X/Twitter video, LinkedIn video
|
||||
|
||||
Videos are generated via Veo 3.1 (not yet supported by the SDK's Image/Audio
|
||||
entry points), which this tool wraps internally.
|
||||
TXT;
|
||||
}
|
||||
|
||||
public function handle(Request $request): Stringable|string
|
||||
{
|
||||
$prompt = (string) $request['prompt'];
|
||||
$orientationString = (string) ($request['orientation'] ?? 'vertical');
|
||||
$orientationEnum = Orientation::tryFrom($orientationString) ?? Orientation::Vertical;
|
||||
|
||||
/** @var VideoGenerationService $service */
|
||||
$service = app(VideoGenerationService::class);
|
||||
|
||||
$attachment = $service->generate(
|
||||
prompt: $prompt,
|
||||
workspace: $this->workspace,
|
||||
userId: $this->userId,
|
||||
postId: $this->post?->id,
|
||||
orientation: $orientationEnum,
|
||||
);
|
||||
|
||||
$this->collector->push($attachment);
|
||||
|
||||
return "Generated a {$orientationString} video (id: {$attachment['id']}) and attached it to the post.";
|
||||
}
|
||||
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'prompt' => $schema->string()
|
||||
->description('A detailed visual description of the video to generate. Include subject, motion, style, mood, and any key moments.')
|
||||
->required(),
|
||||
'orientation' => $schema->string()
|
||||
->enum(['vertical', 'horizontal'])
|
||||
->description('"vertical" for 9:16 (TikTok, Reels, Shorts, Stories). "horizontal" for 16:9 (X, LinkedIn, Facebook).')
|
||||
->required(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -109,11 +109,11 @@ public function boot(): void
|
|||
|
||||
protected function configureAi(): void
|
||||
{
|
||||
Ai::extend('gemini', function (array $config) {
|
||||
Ai::extend('gemini', function ($app, array $config) {
|
||||
return new ExtendedGeminiProvider(
|
||||
new GeminiGateway($this->app['events']),
|
||||
new GeminiGateway($app['events']),
|
||||
$config,
|
||||
$this->app->make(Dispatcher::class),
|
||||
$app->make(Dispatcher::class),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
65
tests/Feature/Ai/Tools/GenerateAudioTest.php
Normal file
65
tests/Feature/Ai/Tools/GenerateAudioTest.php
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Ai\Tools\AttachmentCollector;
|
||||
use App\Ai\Tools\GenerateAudio;
|
||||
use App\Enums\Ai\UsageType;
|
||||
use App\Enums\User\Setup;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\JsonSchema\JsonSchemaTypeFactory;
|
||||
use Laravel\Ai\Audio;
|
||||
use Laravel\Ai\Tools\Request as ToolRequest;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create(['setup' => Setup::Completed]);
|
||||
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
|
||||
$this->post = Post::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
app(AttachmentCollector::class)->clear();
|
||||
});
|
||||
|
||||
test('tool generates audio, pushes attachment to collector, creates usage log', function () {
|
||||
Audio::fake();
|
||||
|
||||
$tool = new GenerateAudio(
|
||||
workspace: $this->workspace,
|
||||
post: $this->post,
|
||||
userId: $this->user->id,
|
||||
);
|
||||
|
||||
$summary = $tool->handle(new ToolRequest([
|
||||
'text' => 'Welcome to my channel',
|
||||
]));
|
||||
|
||||
expect((string) $summary)->toContain('audio');
|
||||
expect(app(AttachmentCollector::class)->all())->toHaveCount(1);
|
||||
expect(app(AttachmentCollector::class)->all()[0]['type'])->toBe('audio');
|
||||
expect(app(AttachmentCollector::class)->all()[0]['mime_type'])->toBe('audio/mpeg');
|
||||
|
||||
Audio::assertGenerated(fn ($prompt) => true);
|
||||
|
||||
$this->assertDatabaseHas('workspace_ai_usages', [
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'type' => UsageType::Audio->value,
|
||||
'provider' => 'elevenlabs',
|
||||
]);
|
||||
});
|
||||
|
||||
test('tool exposes schema with text parameter', function () {
|
||||
$tool = new GenerateAudio(
|
||||
workspace: $this->workspace,
|
||||
post: $this->post,
|
||||
userId: $this->user->id,
|
||||
);
|
||||
|
||||
$schema = $tool->schema(new JsonSchemaTypeFactory);
|
||||
|
||||
expect($schema)->toHaveKey('text');
|
||||
});
|
||||
88
tests/Feature/Ai/Tools/GenerateImageTest.php
Normal file
88
tests/Feature/Ai/Tools/GenerateImageTest.php
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Ai\Tools\AttachmentCollector;
|
||||
use App\Ai\Tools\GenerateImage;
|
||||
use App\Enums\Ai\UsageType;
|
||||
use App\Enums\User\Setup;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\JsonSchema\JsonSchemaTypeFactory;
|
||||
use Laravel\Ai\Image;
|
||||
use Laravel\Ai\Tools\Request as ToolRequest;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create(['setup' => Setup::Completed]);
|
||||
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
|
||||
$this->post = Post::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
app(AttachmentCollector::class)->clear();
|
||||
});
|
||||
|
||||
test('tool generates image, pushes attachment to collector, creates usage log', function () {
|
||||
Image::fake();
|
||||
|
||||
$tool = new GenerateImage(
|
||||
workspace: $this->workspace,
|
||||
post: $this->post,
|
||||
userId: $this->user->id,
|
||||
);
|
||||
|
||||
$summary = $tool->handle(new ToolRequest([
|
||||
'prompt' => 'A sunset beach',
|
||||
'orientation' => 'vertical',
|
||||
]));
|
||||
|
||||
expect((string) $summary)->toContain('image');
|
||||
|
||||
$collector = app(AttachmentCollector::class);
|
||||
expect($collector->all())->toHaveCount(1);
|
||||
|
||||
$attachment = $collector->all()[0];
|
||||
expect($attachment)->toHaveKeys(['id', 'path', 'url', 'mime_type', 'type']);
|
||||
expect($attachment['type'])->toBe('image');
|
||||
expect($attachment['mime_type'])->toBe('image/png');
|
||||
|
||||
Image::assertGenerated(fn ($prompt) => true);
|
||||
|
||||
$this->assertDatabaseHas('workspace_ai_usages', [
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'type' => UsageType::Image->value,
|
||||
'provider' => 'gemini',
|
||||
]);
|
||||
});
|
||||
|
||||
test('tool defaults to vertical when orientation is unknown', function () {
|
||||
Image::fake();
|
||||
|
||||
$tool = new GenerateImage(
|
||||
workspace: $this->workspace,
|
||||
post: $this->post,
|
||||
userId: $this->user->id,
|
||||
);
|
||||
|
||||
$tool->handle(new ToolRequest([
|
||||
'prompt' => 'A forest',
|
||||
'orientation' => 'gibberish',
|
||||
]));
|
||||
|
||||
expect(app(AttachmentCollector::class)->all()[0]['type'])->toBe('image');
|
||||
});
|
||||
|
||||
test('tool exposes schema with prompt and orientation parameters', function () {
|
||||
$tool = new GenerateImage(
|
||||
workspace: $this->workspace,
|
||||
post: $this->post,
|
||||
userId: $this->user->id,
|
||||
);
|
||||
|
||||
$schema = $tool->schema(new JsonSchemaTypeFactory);
|
||||
|
||||
expect($schema)->toHaveKeys(['prompt', 'orientation']);
|
||||
});
|
||||
65
tests/Feature/Ai/Tools/GenerateVideoTest.php
Normal file
65
tests/Feature/Ai/Tools/GenerateVideoTest.php
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Ai\Tools\AttachmentCollector;
|
||||
use App\Ai\Tools\GenerateVideo;
|
||||
use App\Enums\User\Setup;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Ai\VideoGenerationService;
|
||||
use Illuminate\JsonSchema\JsonSchemaTypeFactory;
|
||||
use Laravel\Ai\Tools\Request as ToolRequest;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create(['setup' => Setup::Completed]);
|
||||
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
|
||||
$this->post = Post::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
app(AttachmentCollector::class)->clear();
|
||||
});
|
||||
|
||||
test('tool delegates to VideoGenerationService and pushes attachment to collector', function () {
|
||||
$expected = [
|
||||
'id' => 'video-uuid',
|
||||
'path' => 'medias/video.mp4',
|
||||
'url' => 'https://example.com/medias/video.mp4',
|
||||
'mime_type' => 'video/mp4',
|
||||
'type' => 'video',
|
||||
];
|
||||
|
||||
$mock = $this->mock(VideoGenerationService::class);
|
||||
$mock->shouldReceive('generate')->once()->andReturn($expected);
|
||||
|
||||
$tool = new GenerateVideo(
|
||||
workspace: $this->workspace,
|
||||
post: $this->post,
|
||||
userId: $this->user->id,
|
||||
);
|
||||
|
||||
$summary = $tool->handle(new ToolRequest([
|
||||
'prompt' => 'A cat dancing',
|
||||
'orientation' => 'vertical',
|
||||
]));
|
||||
|
||||
expect((string) $summary)->toContain('video');
|
||||
expect(app(AttachmentCollector::class)->all())->toHaveCount(1);
|
||||
expect(app(AttachmentCollector::class)->all()[0])->toBe($expected);
|
||||
});
|
||||
|
||||
test('tool exposes schema with prompt and orientation parameters', function () {
|
||||
$tool = new GenerateVideo(
|
||||
workspace: $this->workspace,
|
||||
post: $this->post,
|
||||
userId: $this->user->id,
|
||||
);
|
||||
|
||||
$schema = $tool->schema(new JsonSchemaTypeFactory);
|
||||
|
||||
expect($schema)->toHaveKeys(['prompt', 'orientation']);
|
||||
});
|
||||
Loading…
Reference in a new issue