Mentions in post comments
- @mention autocomplete (workspace members, current user excluded) with
marker syntax @[uuid] persisted, display names rendered via CommentBody
chips; live edit replaces markers with names and converts back on save.
- NotifyMentions action with workspace-scoped membership check, dedupes
same user, only newly-added mentions on update.
- Email + in-app via SendNotification job, respecting per-user
notification_preferences.mentioned_in_comment.
- Heartbeat-based presence (Cache, 60s TTL, 30s ping) so online recipients
get only the in-app notification — no email noise.
- Real-time bell on workspace.{id}.user.{id} private channel
(NotificationCreated event), scoped channel name avoids client-side
filtering and lays out a convention for future workspace channels.
- Mailable localized via lang/{en,es,pt-BR}/mail.php; Maizzle source
template for the email is committed and built into resources/views/mail.
AI generation refactor (Action layer + MCP)
- Extracted Actions/Ai/Generate{Image,Video} with QuotaExhaustedException
so agent tools and MCP tools share a single domain entry point.
- Mcp/Tools/Ai/Generate{Image,Video}Tool registered in TryPostServer; both
return MediaResource payloads.
- Orientation::imageApiSize maps non-OpenAI ratios to 1:1/2:3/3:2.
- config/ai.php is now the single source of truth driven by env, removing
the trypost.ai shim. Default text/image providers flipped to OpenAI.
Settings/UX
- /settings/workspace split into shadcn Tabs (Workspace / Brand / Users)
with three components.
- /assets and the in-editor MediaPicker open the ImagePreviewDialog
lightbox on image click while preserving action button behaviour.
- Comments tab landed via ?tab=comments&comment=<id> from notification
click (scroll-to + temporary highlight).
- Mention autocomplete popover flips above when near the viewport bottom.
- Real social platform PNGs replace Tabler brand glyphs in schedule
pills and post list, with hover tooltip carrying display_name + handle.
Bug fixes
- AcceptInvite: controller now passes workspace + role payload that the
Vue page expects; login/register CTAs preselect the invite email.
- WorkspaceInvite mailable: stopped referencing nonexistent
$invite->workspace and $invite->role; column added to the migration,
Invite model casts role to WorkspaceRole, CreateInvite persists it.
- PostCommentCreated: added broadcastAs so .PostCommentCreated actually
matches the Echo listener; payload now includes mentioned_users so
receivers render the chip correctly without a refetch.
- Preview components for X/Pinterest/Threads/Bluesky/LinkedIn/Mastodon/
TikTok/YouTube switched from item.type === 'image' to
!isVideoMedia(item) so media without a persisted type still renders.
- UpdatePostRequest now accepts media.*.{type,mime_type,size,...} so the
posts.media JSON keeps the metadata that the previews need.
- Removed throttle:6,1 from social connect routes (was 429ing legitimate
OAuth retries).
- Used MediaType enum cases instead of literal 'image'/'video' strings
when creating media rows.
Tests
- MentionParser unit tests, NotifyMentions feature tests including
online/offline channel selection and preference gating, MCP AI tool
happy paths, MentionedInComment mailable rendering, AcceptInvite +
search-members + index mentioned_users path. 1229 passing.
143 lines
4.6 KiB
PHP
143 lines
4.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Ai;
|
|
|
|
use App\Enums\Ai\Orientation;
|
|
use App\Enums\Ai\UsageType;
|
|
use App\Enums\Media\Type as MediaType;
|
|
use App\Models\AiUsageLog;
|
|
use App\Models\Media;
|
|
use App\Models\Workspace;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use RuntimeException;
|
|
|
|
class VideoGenerationService
|
|
{
|
|
private string $baseUrl = 'https://generativelanguage.googleapis.com/v1beta';
|
|
|
|
private string $model = 'veo-3.1-generate-preview';
|
|
|
|
private int $maxPollAttempts = 60;
|
|
|
|
private int $pollIntervalSeconds = 10;
|
|
|
|
private string $apiKey;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->apiKey = config('services.gemini.api_key') ?? '';
|
|
}
|
|
|
|
public function generate(string $prompt, Workspace $workspace, ?string $userId = null, ?string $postId = null, Orientation $orientation = Orientation::Vertical): Media
|
|
{
|
|
if (empty($this->apiKey)) {
|
|
throw new RuntimeException('Gemini API key is not configured. Please set GEMINI_API_KEY in your .env file.');
|
|
}
|
|
|
|
$aspectRatio = $orientation->aspectRatio();
|
|
|
|
$fullPrompt = view('prompts.assistant.video', [
|
|
'prompt' => $prompt,
|
|
'brand_name' => $workspace->name ?? '',
|
|
'tone' => $workspace->brand_tone ?? 'professional',
|
|
'content_language' => $workspace->content_language ?? 'en',
|
|
])->render();
|
|
|
|
$operationName = $this->startGeneration($fullPrompt, $aspectRatio);
|
|
$videoData = $this->pollForCompletion($operationName);
|
|
|
|
$decoded = base64_decode($videoData);
|
|
|
|
return DB::transaction(function () use ($decoded, $prompt, $workspace, $userId, $postId) {
|
|
$filename = Str::uuid().'.mp4';
|
|
$path = 'medias/'.$filename;
|
|
|
|
Storage::put($path, $decoded);
|
|
|
|
$media = $workspace->media()->create([
|
|
'group_id' => Str::uuid()->toString(),
|
|
'collection' => 'assets',
|
|
'type' => MediaType::Video,
|
|
'path' => $path,
|
|
'original_filename' => 'ai-generated.mp4',
|
|
'mime_type' => 'video/mp4',
|
|
'size' => strlen($decoded),
|
|
'order' => 0,
|
|
'meta' => ['ai_generated' => true, 'prompt' => Str::limit($prompt, 200)],
|
|
]);
|
|
|
|
AiUsageLog::create([
|
|
'account_id' => $workspace->account_id,
|
|
'workspace_id' => $workspace->id,
|
|
'user_id' => $userId,
|
|
'post_id' => $postId,
|
|
'type' => UsageType::Video,
|
|
'provider' => 'veo',
|
|
]);
|
|
|
|
return $media;
|
|
});
|
|
}
|
|
|
|
private function startGeneration(string $prompt, string $aspectRatio = '9:16'): string
|
|
{
|
|
$response = Http::timeout(30)
|
|
->withHeaders(['x-goog-api-key' => $this->apiKey])
|
|
->post("{$this->baseUrl}/models/{$this->model}:predictLongRunning", [
|
|
'instances' => [['prompt' => $prompt]],
|
|
'parameters' => [
|
|
'aspectRatio' => $aspectRatio,
|
|
'durationSeconds' => 8,
|
|
'resolution' => '720p',
|
|
'personGeneration' => 'allow_all',
|
|
],
|
|
]);
|
|
|
|
if ($response->failed()) {
|
|
Log::error('VideoGenerationService start failed', ['body' => $response->body()]);
|
|
|
|
throw new RuntimeException('Failed to start video generation. Please try again.');
|
|
}
|
|
|
|
$operationName = data_get($response->json(), 'name');
|
|
|
|
if (! $operationName) {
|
|
throw new RuntimeException('No operation returned from video generation API.');
|
|
}
|
|
|
|
return $operationName;
|
|
}
|
|
|
|
private function pollForCompletion(string $operationName): string
|
|
{
|
|
for ($i = 0; $i < $this->maxPollAttempts; $i++) {
|
|
sleep($this->pollIntervalSeconds);
|
|
|
|
$response = Http::timeout(30)
|
|
->withHeaders(['x-goog-api-key' => $this->apiKey])
|
|
->get("{$this->baseUrl}/{$operationName}");
|
|
|
|
if ($response->failed()) {
|
|
continue;
|
|
}
|
|
|
|
$status = $response->json();
|
|
|
|
if (data_get($status, 'done')) {
|
|
$videoData = data_get($status, 'response.predictions.0.bytesBase64Encoded');
|
|
|
|
if ($videoData) {
|
|
return $videoData;
|
|
}
|
|
}
|
|
}
|
|
|
|
throw new RuntimeException('Video generation timed out. Please try again.');
|
|
}
|
|
}
|