Critical fixes:
- Quota enforcement regressed when the regex flow was replaced with
tools. The LLM was trusted to respect [Session state] quota hints,
which is vulnerable to prompt injection / hallucination. Each tool
now checks Pennant feature limits at the top of handle() and returns
a short quota-exhausted string instead of calling the provider.
Audio shares the video quota (there is no AiAudioLimit by design).
- N+1 on aiMessages: combined the two separate ->get() scans that
produced imagesInThread and videosInThread into a single query.
- postPlatforms lazy load: controller now loadMissing('postPlatforms')
before constructing SocialMediaAssistant, which accesses the relation
in activePlatformRules().
Project conventions:
- \RuntimeException and \Throwable are now imported at the top of the
controller instead of inlined per CLAUDE.md.
- Tool handle() methods use data_get($request, 'prompt') instead of
direct array access, matching the data_get convention for service
classes.
- enrichContent() pluralizes attachment types via Str::plural so
'2 images' reads naturally instead of '2 image'.
Tests:
- Added Storage::fake('public') in tool tests so runs don't pollute
the local disk with fake-generated files.
- Restored quota enforcement coverage (deleted when the flow changed)
as three new tests — one per tool — that fill the monthly bucket
and assert the tool refuses and nothing is generated.
- Removed inline \App\ references in tests in favor of imports.
103 lines
3.4 KiB
PHP
103 lines
3.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Ai\Tools;
|
|
|
|
use App\Enums\Ai\UsageType;
|
|
use App\Features\AiVideosLimit;
|
|
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 Laravel\Pennant\Feature;
|
|
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
|
|
{
|
|
// Audio generation counts against the monthly video quota by design.
|
|
$limit = (int) Feature::for($this->workspace->account)->value(AiVideosLimit::class);
|
|
$used = AiUsageLog::monthlyCount($this->workspace->account_id, UsageType::Video)
|
|
+ AiUsageLog::monthlyCount($this->workspace->account_id, UsageType::Audio);
|
|
|
|
if ($used >= $limit) {
|
|
return "Audio and video share a monthly quota that is exhausted ({$used} of {$limit} used). Ask the user to upgrade their plan or wait until next month.";
|
|
}
|
|
|
|
$text = (string) data_get($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(),
|
|
];
|
|
}
|
|
}
|