trypost/app/Ai/Tools/GenerateImage.php
Paulo Castellano 3c3b170b21 feat: @mentions in comments, AI Action layer + MCP tools, settings tabs
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.
2026-05-01 20:59:03 -03:00

89 lines
3.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Ai\Tools;
use App\Actions\Ai\GenerateImage as GenerateImageAction;
use App\Enums\Ai\Orientation;
use App\Enums\Media\Type as MediaType;
use App\Exceptions\Ai\QuotaExhaustedException;
use App\Models\Post;
use App\Models\Workspace;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Tool;
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:
- "square" (1:1) for LinkedIn, Facebook, or when user wants square
- "portrait" (4:5) for Instagram Feed, Threads
- "vertical" (9:16) for Instagram Reel/Story, Pinterest Pin, TikTok
- "horizontal" (16:9) for X/Twitter, YouTube thumbnail
Choose the orientation that best matches the target platform.
The image is generated, stored, 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) data_get($request, 'prompt', '');
$orientationString = (string) data_get($request, 'orientation', 'vertical');
$orientation = Orientation::tryFrom($orientationString) ?? Orientation::Portrait;
try {
$media = GenerateImageAction::execute(
workspace: $this->workspace,
prompt: $prompt,
orientation: $orientation,
userId: $this->userId,
postId: $this->post?->id,
);
} catch (QuotaExhaustedException $e) {
return "Image quota exhausted this month ({$e->used} of {$e->limit} used). Ask the user to upgrade their plan or wait until next month.";
}
$this->collector->push([
'id' => $media->id,
'path' => $media->path,
'url' => $media->url,
'mime_type' => 'image/png',
'type' => MediaType::Image->value,
]);
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(['square', 'portrait', 'vertical', 'horizontal'])
->description('"square" (1:1) for LinkedIn, Facebook. "portrait" (4:5) for Instagram Feed, Threads. "vertical" (9:16) for Instagram Reel/Story, Pinterest, TikTok. "horizontal" (16:9) for X/Twitter.')
->required(),
];
}
}