fix: add limit checks, rate limiting, error styling, and test fixes for AI assistant
This commit is contained in:
parent
8894d9ced6
commit
baf310112e
10 changed files with 353 additions and 6 deletions
159
app/Http/Controllers/App/PostAssistantController.php
Normal file
159
app/Http/Controllers/App/PostAssistantController.php
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Features\AiImagesLimit;
|
||||
use App\Features\AiVideosLimit;
|
||||
use App\Models\AiMessage;
|
||||
use App\Models\AiUsageLog;
|
||||
use App\Models\Post;
|
||||
use App\Services\Ai\AudioGenerationService;
|
||||
use App\Services\Ai\ImageGenerationService;
|
||||
use App\Services\Ai\IntentDetector;
|
||||
use App\Services\Ai\TextGenerationService;
|
||||
use App\Services\Ai\VideoGenerationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Laravel\Pennant\Feature;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PostAssistantController extends Controller
|
||||
{
|
||||
public function index(Request $request, Post $post): JsonResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
if ($post->workspace_id !== $workspace->id) {
|
||||
abort(Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
$messages = $post->aiMessages()
|
||||
->with('user')
|
||||
->oldest()
|
||||
->get();
|
||||
|
||||
return response()->json(['messages' => $messages]);
|
||||
}
|
||||
|
||||
public function store(
|
||||
Request $request,
|
||||
Post $post,
|
||||
IntentDetector $intentDetector,
|
||||
TextGenerationService $textService,
|
||||
ImageGenerationService $imageService,
|
||||
AudioGenerationService $audioService,
|
||||
VideoGenerationService $videoService,
|
||||
): JsonResponse {
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
if ($post->workspace_id !== $workspace->id) {
|
||||
abort(Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'body' => ['required', 'string', 'max:2000'],
|
||||
]);
|
||||
|
||||
$prompt = data_get($validated, 'body');
|
||||
|
||||
$userMessage = $post->aiMessages()->create([
|
||||
'user_id' => $request->user()->id,
|
||||
'role' => 'user',
|
||||
'content' => $prompt,
|
||||
]);
|
||||
|
||||
$userMessage->load('user');
|
||||
|
||||
$intent = $intentDetector->detect($prompt);
|
||||
|
||||
try {
|
||||
if ($intent === 'image') {
|
||||
$limit = Feature::for($workspace->account)->value(AiImagesLimit::class);
|
||||
$used = AiUsageLog::monthlyCount($workspace->account_id, 'image');
|
||||
|
||||
if ($used >= $limit) {
|
||||
$assistantMessage = $post->aiMessages()->create([
|
||||
'role' => 'assistant',
|
||||
'content' => __('assistant.limit_reached_images'),
|
||||
'metadata' => ['intent' => $intent, 'limit_reached' => true],
|
||||
]);
|
||||
|
||||
return response()->json(['user_message' => $userMessage, 'assistant_message' => $assistantMessage], Response::HTTP_CREATED);
|
||||
}
|
||||
}
|
||||
|
||||
if ($intent === 'video') {
|
||||
$limit = Feature::for($workspace->account)->value(AiVideosLimit::class);
|
||||
$used = AiUsageLog::monthlyCount($workspace->account_id, 'video');
|
||||
|
||||
if ($used >= $limit) {
|
||||
$assistantMessage = $post->aiMessages()->create([
|
||||
'role' => 'assistant',
|
||||
'content' => __('assistant.limit_reached_videos'),
|
||||
'metadata' => ['intent' => $intent, 'limit_reached' => true],
|
||||
]);
|
||||
|
||||
return response()->json(['user_message' => $userMessage, 'assistant_message' => $assistantMessage], Response::HTTP_CREATED);
|
||||
}
|
||||
}
|
||||
|
||||
$responseContent = '';
|
||||
$attachments = [];
|
||||
|
||||
if ($intent === 'image') {
|
||||
$result = $imageService->generate($prompt, $workspace, $request->user()->id, $post->id);
|
||||
$responseContent = __('assistant.image_generated');
|
||||
$attachments = [$result];
|
||||
} elseif ($intent === 'video') {
|
||||
$result = $videoService->generate($prompt, $workspace, $request->user()->id, $post->id);
|
||||
$responseContent = __('assistant.video_generated');
|
||||
$attachments = [$result];
|
||||
} elseif ($intent === 'audio') {
|
||||
$result = $audioService->generate($prompt, $workspace, $request->user()->id, $post->id);
|
||||
$responseContent = __('assistant.audio_generated');
|
||||
$attachments = [$result];
|
||||
} else {
|
||||
$history = $post->aiMessages()
|
||||
->whereIn('role', ['user', 'assistant'])
|
||||
->where('id', '!=', $userMessage->id)
|
||||
->oldest()
|
||||
->limit(20)
|
||||
->get()
|
||||
->map(fn (AiMessage $m) => ['role' => $m->role, 'content' => $m->content])
|
||||
->all();
|
||||
|
||||
$responseContent = $textService->generate($prompt, $history);
|
||||
}
|
||||
|
||||
$assistantMessage = $post->aiMessages()->create([
|
||||
'role' => 'assistant',
|
||||
'content' => $responseContent,
|
||||
'attachments' => $attachments,
|
||||
'metadata' => ['intent' => $intent],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'user_message' => $userMessage,
|
||||
'assistant_message' => $assistantMessage,
|
||||
], Response::HTTP_CREATED);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('PostAssistantController error', ['error' => $e->getMessage()]);
|
||||
|
||||
$errorMessage = $e instanceof \RuntimeException ? $e->getMessage() : __('assistant.error');
|
||||
|
||||
$assistantMessage = $post->aiMessages()->create([
|
||||
'role' => 'assistant',
|
||||
'content' => $errorMessage,
|
||||
'metadata' => ['intent' => $intent, 'error' => true],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'user_message' => $userMessage,
|
||||
'assistant_message' => $assistantMessage,
|
||||
], Response::HTTP_CREATED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,8 @@ class AiUsageLog extends Model
|
|||
/** @use HasFactory<AiUsageLogFactory> */
|
||||
use HasFactory, HasUuids;
|
||||
|
||||
protected $table = 'workspace_ai_usages';
|
||||
|
||||
protected $fillable = [
|
||||
'account_id',
|
||||
'workspace_id',
|
||||
|
|
|
|||
|
|
@ -25,8 +25,10 @@ public function __construct()
|
|||
/**
|
||||
* @return array{id: string, path: string, url: string, mime_type: string, type: string}
|
||||
*/
|
||||
public function generate(string $text, Workspace $workspace, ?string $userId = null, ?string $postId = null, string $voiceId = 'EXAVITQu4vr4xnSDxMaL'): array
|
||||
public function generate(string $text, Workspace $workspace, ?string $userId = null, ?string $postId = null, ?string $voiceId = null): array
|
||||
{
|
||||
$voiceId ??= config('services.elevenlabs.default_voice', 'EXAVITQu4vr4xnSDxMaL');
|
||||
|
||||
$response = Http::timeout(120)
|
||||
->withHeaders([
|
||||
'xi-api-key' => $this->apiKey,
|
||||
|
|
@ -56,7 +58,7 @@ public function generate(string $text, Workspace $workspace, ?string $userId = n
|
|||
$media = $workspace->media()->create([
|
||||
'group_id' => Str::uuid()->toString(),
|
||||
'collection' => 'assets',
|
||||
'type' => 'document',
|
||||
'type' => 'video',
|
||||
'path' => $path,
|
||||
'original_filename' => 'ai-generated.mp3',
|
||||
'mime_type' => 'audio/mpeg',
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@
|
|||
|
||||
'elevenlabs' => [
|
||||
'api_key' => env('ELEVENLABS_API_KEY'),
|
||||
'default_voice' => env('ELEVENLABS_DEFAULT_VOICE', 'EXAVITQu4vr4xnSDxMaL'),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('ai_usage_logs', function (Blueprint $table) {
|
||||
Schema::create('workspace_ai_usages', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->uuid('account_id');
|
||||
$table->uuid('workspace_id');
|
||||
|
|
@ -28,6 +28,6 @@ public function up(): void
|
|||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('ai_usage_logs');
|
||||
Schema::dropIfExists('workspace_ai_usages');
|
||||
}
|
||||
};
|
||||
15
lang/en/assistant.php
Normal file
15
lang/en/assistant.php
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'placeholder' => 'Ask me to write a caption, generate an image, create audio...',
|
||||
'thinking' => 'Thinking...',
|
||||
'add_to_post' => 'Add to post',
|
||||
'added' => 'Added',
|
||||
'error' => 'Something went wrong. Please try again.',
|
||||
'image_generated' => 'Here is the generated image:',
|
||||
'video_generated' => 'Here is the generated video:',
|
||||
'audio_generated' => 'Here is the generated audio:',
|
||||
'empty' => 'Ask me anything. I can write captions, generate images, create audio, and produce videos.',
|
||||
'limit_reached_images' => 'You have reached your monthly image generation limit.',
|
||||
'limit_reached_videos' => 'You have reached your monthly video generation limit.',
|
||||
];
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -21,6 +21,11 @@ interface AiMessage {
|
|||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
attachments?: Attachment[];
|
||||
metadata?: {
|
||||
intent?: string;
|
||||
error?: boolean;
|
||||
limit_reached?: boolean;
|
||||
};
|
||||
created_at: string;
|
||||
user?: {
|
||||
id: string;
|
||||
|
|
@ -169,7 +174,7 @@ onMounted(() => {
|
|||
<IconSparkles class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<div class="max-w-[85%]">
|
||||
<div class="rounded-lg bg-muted px-3 py-2">
|
||||
<div :class="['rounded-lg px-3 py-2', message.metadata?.error ? 'bg-destructive/10 text-destructive' : 'bg-muted']">
|
||||
<p class="whitespace-pre-wrap text-sm">{{ message.content }}</p>
|
||||
|
||||
<!-- Attachments -->
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
use App\Http\Controllers\App\MediaController;
|
||||
use App\Http\Controllers\App\NotificationController;
|
||||
use App\Http\Controllers\App\OnboardingController;
|
||||
use App\Http\Controllers\App\PostAssistantController;
|
||||
use App\Http\Controllers\App\PostCommentController;
|
||||
use App\Http\Controllers\App\PostController;
|
||||
use App\Http\Controllers\App\Settings\AccountController;
|
||||
|
|
@ -149,6 +150,12 @@
|
|||
Route::delete('posts/{post}/comments/{comment}', [PostCommentController::class, 'destroy'])->name('app.posts.comments.destroy');
|
||||
Route::post('posts/{post}/comments/{comment}/react', [PostCommentController::class, 'react'])->name('app.posts.comments.react');
|
||||
|
||||
// Post AI Assistant
|
||||
Route::middleware('throttle:10,1')->group(function () {
|
||||
Route::get('posts/{post}/assistant', [PostAssistantController::class, 'index'])->name('app.posts.assistant.index');
|
||||
Route::post('posts/{post}/assistant', [PostAssistantController::class, 'store'])->name('app.posts.assistant.store');
|
||||
});
|
||||
|
||||
// Media
|
||||
Route::post('medias', [MediaController::class, 'store'])->name('app.medias.store');
|
||||
Route::post('medias/chunked', [MediaController::class, 'storeChunked'])->name('app.medias.store-chunked');
|
||||
|
|
|
|||
156
tests/Feature/PostAssistantTest.php
Normal file
156
tests/Feature/PostAssistantTest.php
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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 App\Services\Ai\AudioGenerationService;
|
||||
use App\Services\Ai\ImageGenerationService;
|
||||
use App\Services\Ai\TextGenerationService;
|
||||
use App\Services\Ai\VideoGenerationService;
|
||||
|
||||
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->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
|
||||
$this->post = Post::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
$this->mock(AudioGenerationService::class);
|
||||
$this->mock(VideoGenerationService::class);
|
||||
});
|
||||
|
||||
test('index returns ai messages for a post', function () {
|
||||
AiMessage::factory()->create([
|
||||
'post_id' => $this->post->id,
|
||||
'user_id' => $this->user->id,
|
||||
'role' => 'user',
|
||||
'content' => 'Write a caption',
|
||||
]);
|
||||
|
||||
AiMessage::factory()->create([
|
||||
'post_id' => $this->post->id,
|
||||
'user_id' => null,
|
||||
'role' => 'assistant',
|
||||
'content' => 'Here is your caption!',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->getJson(route('app.posts.assistant.index', $this->post));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(2, 'messages');
|
||||
});
|
||||
|
||||
test('index rejects access to post from other workspace', function () {
|
||||
$otherUser = User::factory()->create(['setup' => Setup::Completed]);
|
||||
$otherWorkspace = Workspace::factory()->create(['user_id' => $otherUser->id]);
|
||||
$otherWorkspace->members()->attach($otherUser->id, ['role' => Role::Member->value]);
|
||||
$otherUser->update(['current_workspace_id' => $otherWorkspace->id]);
|
||||
|
||||
$response = $this->actingAs($otherUser)
|
||||
->getJson(route('app.posts.assistant.index', $this->post));
|
||||
|
||||
$response->assertForbidden();
|
||||
});
|
||||
|
||||
test('store creates user and assistant messages for text intent', function () {
|
||||
$this->mock(TextGenerationService::class)
|
||||
->shouldReceive('generate')
|
||||
->once()
|
||||
->andReturn('Here is a great caption for your post!');
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->postJson(route('app.posts.assistant.store', $this->post), [
|
||||
'body' => 'Write me a caption about summer vibes',
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
$response->assertJsonPath('user_message.content', 'Write me a caption about summer vibes');
|
||||
$response->assertJsonPath('user_message.role', 'user');
|
||||
$response->assertJsonPath('assistant_message.content', 'Here is a great caption for your post!');
|
||||
$response->assertJsonPath('assistant_message.role', 'assistant');
|
||||
|
||||
$this->assertDatabaseHas('ai_messages', [
|
||||
'post_id' => $this->post->id,
|
||||
'role' => 'user',
|
||||
'content' => 'Write me a caption about summer vibes',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('ai_messages', [
|
||||
'post_id' => $this->post->id,
|
||||
'role' => 'assistant',
|
||||
'content' => 'Here is a great caption for your post!',
|
||||
]);
|
||||
});
|
||||
|
||||
test('store creates user and assistant messages for image intent', function () {
|
||||
$this->mock(ImageGenerationService::class)
|
||||
->shouldReceive('generate')
|
||||
->once()
|
||||
->andReturn([
|
||||
'id' => 'test-media-id',
|
||||
'path' => 'medias/test.png',
|
||||
'url' => 'https://example.com/medias/test.png',
|
||||
'mime_type' => 'image/png',
|
||||
'type' => 'image',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->postJson(route('app.posts.assistant.store', $this->post), [
|
||||
'body' => 'Generate an image of a sunset on the beach',
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
$response->assertJsonPath('user_message.role', 'user');
|
||||
$response->assertJsonPath('assistant_message.role', 'assistant');
|
||||
$response->assertJsonPath('assistant_message.attachments.0.id', 'test-media-id');
|
||||
$response->assertJsonPath('assistant_message.attachments.0.type', 'image');
|
||||
});
|
||||
|
||||
test('store validates body is required', function () {
|
||||
$response = $this->actingAs($this->user)
|
||||
->postJson(route('app.posts.assistant.store', $this->post), [
|
||||
'body' => '',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonValidationErrors('body');
|
||||
});
|
||||
|
||||
test('store rejects access to post from other workspace', function () {
|
||||
$otherUser = User::factory()->create(['setup' => Setup::Completed]);
|
||||
$otherWorkspace = Workspace::factory()->create(['user_id' => $otherUser->id]);
|
||||
$otherWorkspace->members()->attach($otherUser->id, ['role' => Role::Member->value]);
|
||||
$otherUser->update(['current_workspace_id' => $otherWorkspace->id]);
|
||||
|
||||
$response = $this->actingAs($otherUser)
|
||||
->postJson(route('app.posts.assistant.store', $this->post), [
|
||||
'body' => 'Write me a caption',
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
});
|
||||
|
||||
test('store handles service exception gracefully', function () {
|
||||
$this->mock(TextGenerationService::class)
|
||||
->shouldReceive('generate')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('API quota exceeded'));
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->postJson(route('app.posts.assistant.store', $this->post), [
|
||||
'body' => 'Write me a caption about coffee',
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
$response->assertJsonPath('assistant_message.content', 'API quota exceeded');
|
||||
});
|
||||
Loading…
Reference in a new issue