From 7fbc42ab4171e6927f251415a28fd691ff456661 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paulo=20S=C3=A9rgio=20Dantas?= Date: Mon, 20 Jul 2026 20:32:48 -0300 Subject: [PATCH] feat(ai-create): review the generated copy before spending image credits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem AI creation is one shot: the wizard sends the prompt and the job writes the copy AND renders the images in a single pass. The user only sees the result once everything has been generated and the post exists. That is expensive and frustrating. Images are the costly part of a generation, so a weak headline, an off-tone caption or a carousel whose slides arrived in the wrong order all cost a full image render before the user can react — and fixing it means starting over and paying again. ## Change Generation is split into two phases with a review step in between. **Phase A** (`PreparePostContent`) writes only the structured text — the same generator and humanizer as before, no images — and stores it on a new `AiPostDraft`. **The review screen** shows that text as editable blocks: headline, body, caption, photo keywords, and for carousels a card per slide that can be reordered by dragging. Edits autosave. **Phase B** reuses `StreamPostCreation` with the approved structure, so it skips text generation and renders the images from exactly what the user signed off on. Nothing about the one-shot path changes: `StreamPostCreation` without `preparedStructured` behaves exactly as before. ## How - `PostContentComposer` — the "generate then humanize" step extracted from `StreamPostCreation` verbatim, so both phases share one implementation. Behaviour is unchanged; this is a pure extraction. - `AiPostDraft` + `DraftStatus` (`preparing/ready/generating/completed/failed`) hold the draft between phases. `generate` refuses a draft that is not `ready`, so a double click cannot create a duplicate post or bill images twice. - `PostContentPrepared` broadcasts on `user.{id}.ai-draft.{draftId}` when the text lands; the review screen also polls every 4s so a dropped websocket cannot strand the user on the skeleton. - `creationStatus` gives the loading screen the same fallback for phase B. ## Tests 15 tests covering the flow end to end: draft creation and job dispatch, the cross-workspace social account guard, rendering and authorization of the review screen, generate persisting the reviewed structure and dispatching phase B, the replay guard, autosave (persists while `ready`, no-op otherwise), validation, and `PreparePostContent` marking the draft ready while preserving the fields the humanizer does not return. The full suite passes (2638 tests). --- app/Broadcasting/UserAiDraftChannel.php | 15 + app/Enums/Ai/DraftStatus.php | 19 + app/Events/Ai/PostContentPrepared.php | 52 ++ .../App/PostAiCreateController.php | 180 ++++++ .../App/Ai/GenerateFromDraftRequest.php | 28 + app/Jobs/Ai/PreparePostContent.php | 92 +++ app/Jobs/Ai/StreamPostCreation.php | 143 ++--- app/Models/AiPostDraft.php | 73 +++ app/Providers/AppServiceProvider.php | 2 + app/Services/Ai/PostContentComposer.php | 149 +++++ database/factories/AiPostDraftFactory.php | 49 ++ ..._20_000000_create_ai_post_drafts_table.php | 38 ++ lang/ar/posts.php | 24 + lang/de/posts.php | 24 + lang/el/posts.php | 24 + lang/en/posts.php | 24 + lang/es/posts.php | 24 + lang/fr/posts.php | 24 + lang/it/posts.php | 24 + lang/ja/posts.php | 24 + lang/ko/posts.php | 24 + lang/nl/posts.php | 24 + lang/pl/posts.php | 24 + lang/pt-BR/posts.php | 24 + lang/ru/posts.php | 24 + lang/tr/posts.php | 24 + lang/zh/posts.php | 24 + package-lock.json | 2 +- .../components/posts/ai/ReviewBlockCard.vue | 143 +++++ .../components/posts/create/AiPostWizard.vue | 22 +- resources/js/pages/posts/ai/Review.vue | 562 ++++++++++++++++++ routes/app.php | 5 + routes/channels.php | 2 + tests/Feature/Ai/CreationStatusTest.php | 68 +++ tests/Feature/Ai/PostAiReviewFlowTest.php | 259 ++++++++ 35 files changed, 2139 insertions(+), 124 deletions(-) create mode 100644 app/Broadcasting/UserAiDraftChannel.php create mode 100644 app/Enums/Ai/DraftStatus.php create mode 100644 app/Events/Ai/PostContentPrepared.php create mode 100644 app/Http/Requests/App/Ai/GenerateFromDraftRequest.php create mode 100644 app/Jobs/Ai/PreparePostContent.php create mode 100644 app/Models/AiPostDraft.php create mode 100644 app/Services/Ai/PostContentComposer.php create mode 100644 database/factories/AiPostDraftFactory.php create mode 100644 database/migrations/2026_07_20_000000_create_ai_post_drafts_table.php create mode 100644 resources/js/components/posts/ai/ReviewBlockCard.vue create mode 100644 resources/js/pages/posts/ai/Review.vue create mode 100644 tests/Feature/Ai/CreationStatusTest.php create mode 100644 tests/Feature/Ai/PostAiReviewFlowTest.php diff --git a/app/Broadcasting/UserAiDraftChannel.php b/app/Broadcasting/UserAiDraftChannel.php new file mode 100644 index 000000000..30e4f2675 --- /dev/null +++ b/app/Broadcasting/UserAiDraftChannel.php @@ -0,0 +1,15 @@ +is($owner); + } +} diff --git a/app/Enums/Ai/DraftStatus.php b/app/Enums/Ai/DraftStatus.php new file mode 100644 index 000000000..72b55147d --- /dev/null +++ b/app/Enums/Ai/DraftStatus.php @@ -0,0 +1,19 @@ +userId}.ai-draft.{$this->draftId}"); + } + + /** + * @return array + */ + public function broadcastWith(): array + { + return [ + 'draft_id' => $this->draftId, + 'error' => $this->error, + ]; + } + + public function broadcastQueue(): string + { + return 'broadcasts'; + } +} diff --git a/app/Http/Controllers/App/PostAiCreateController.php b/app/Http/Controllers/App/PostAiCreateController.php index d9410758f..e7e0203bb 100644 --- a/app/Http/Controllers/App/PostAiCreateController.php +++ b/app/Http/Controllers/App/PostAiCreateController.php @@ -4,8 +4,13 @@ namespace App\Http\Controllers\App; +use App\Enums\Ai\DraftStatus; +use App\Enums\PostPlatform\ContentType; +use App\Http\Requests\App\Ai\GenerateFromDraftRequest; use App\Http\Requests\App\Ai\StartPostCreationRequest; +use App\Jobs\Ai\PreparePostContent; use App\Jobs\Ai\StreamPostCreation; +use App\Models\AiPostDraft; use App\Models\SocialAccount; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -71,4 +76,179 @@ public function loading(Request $request, string $creationId): InertiaResponse 'prompt' => (string) $request->query('prompt', ''), ]); } + + /** + * Phase A of the review flow: create a draft and pre-generate only its text + * (no images) for the user to review. Returns the draft id and its channel. + */ + public function prepare(StartPostCreationRequest $request): JsonResponse + { + $workspace = $request->user()->currentWorkspace; + + $this->authorize('createPost', $workspace); + + $gate = Gate::inspect('useAi', $workspace->account); + if ($gate->denied()) { + return response()->json(['message' => $gate->message()], Response::HTTP_PAYMENT_REQUIRED); + } + + $socialAccountId = $request->input('social_account_id'); + + if ($socialAccountId) { + $owned = SocialAccount::where('id', $socialAccountId) + ->where('workspace_id', $workspace->id) + ->exists(); + + if (! $owned) { + abort(Response::HTTP_FORBIDDEN); + } + } + + $draft = AiPostDraft::create([ + 'workspace_id' => $workspace->id, + 'user_id' => $request->user()->id, + 'social_account_id' => $socialAccountId, + 'format' => $request->string('format')->toString(), + 'template' => $request->input('template', 'image_card'), + 'image_count' => (int) $request->input('image_count', 0), + 'apply_brand_visuals' => $request->boolean('apply_brand_visuals', true), + 'scheduled_date' => $request->input('date'), + 'prompt' => $request->string('prompt')->toString(), + 'status' => DraftStatus::Preparing, + ]); + + PreparePostContent::dispatch($draft->id); + + return response()->json([ + 'draft_id' => $draft->id, + 'channel' => "user.{$request->user()->id}.ai-draft.{$draft->id}", + ], Response::HTTP_ACCEPTED); + } + + /** + * The review screen: editable blocks of the pre-generated structure. While + * the draft is still `preparing`, the page shows a skeleton and fills in on + * the `.ai.content.prepared` broadcast. + */ + public function review(Request $request, AiPostDraft $draft): InertiaResponse + { + $this->authorizeDraft($request, $draft); + + return Inertia::render('posts/ai/Review', [ + 'draft' => [ + 'id' => $draft->id, + 'status' => $draft->status->value, + 'format' => $draft->format, + 'template' => $draft->template, + 'is_carousel' => $draft->format === ContentType::CAROUSEL_FORMAT, + 'image_count' => $draft->image_count, + // Formats that publish no caption (stories and the like) hide the + // field instead of letting the user edit text that never ships. + 'supports_caption' => $draft->format === ContentType::CAROUSEL_FORMAT + || (ContentType::tryFrom((string) $draft->format)?->supportsCaption() ?? true), + 'structured' => $draft->structured, + 'error' => $draft->error, + 'channel' => "user.{$draft->user_id}.ai-draft.{$draft->id}", + ], + ]); + } + + /** + * Phase B: render the images from the REVIEWED structure and create the post. + * Reuses StreamPostCreation (with `preparedStructured`), so the loading + * screen and the completion event work unchanged. + */ + public function generate(GenerateFromDraftRequest $request, AiPostDraft $draft): JsonResponse + { + $this->authorizeDraft($request, $draft); + + // Only a `ready` draft may generate: this stops a replay (double click or + // a re-POST) from creating a duplicate post and billing images twice. + abort_unless($draft->status === DraftStatus::Ready, Response::HTTP_CONFLICT); + + $workspace = $request->user()->currentWorkspace; + + $gate = Gate::inspect('useAi', $workspace->account); + if ($gate->denied()) { + return response()->json(['message' => $gate->message()], Response::HTTP_PAYMENT_REQUIRED); + } + + $structured = $request->input('structured'); + + $draft->update([ + 'structured' => $structured, + 'status' => DraftStatus::Generating, + ]); + + StreamPostCreation::dispatch( + userId: $draft->user_id, + creationId: $draft->id, + workspaceId: $draft->workspace_id, + format: $draft->format, + socialAccountId: $draft->social_account_id, + imageCount: $draft->image_count, + prompt: $draft->prompt, + date: $draft->scheduled_date?->format('Y-m-d'), + template: $draft->template, + applyBrandVisuals: $draft->apply_brand_visuals, + preparedStructured: $structured, + draftId: $draft->id, + ); + + return response()->json([ + 'creation_id' => $draft->id, + 'channel' => "user.{$draft->user_id}.ai-creation.{$draft->id}", + ], Response::HTTP_ACCEPTED); + } + + /** + * Autosaves the review edits, and only while the draft is `ready`. It never + * triggers generation nor spends AI credit: it just persists the text. + */ + public function autosave(GenerateFromDraftRequest $request, AiPostDraft $draft): JsonResponse + { + $this->authorizeDraft($request, $draft); + + if ($draft->status === DraftStatus::Ready) { + $draft->update(['structured' => $request->input('structured')]); + } + + return response()->json(status: Response::HTTP_NO_CONTENT); + } + + /** + * Polling fallback for the loading screen: when the completion broadcast is + * missed (dropped or reconnecting websocket), the front end asks here and + * unblocks. In the review flow the creationId IS the draft id; in the + * one-shot flow there is no record to consult, so the status is 'unknown' + * and the channel stays the source of truth. + */ + public function creationStatus(Request $request, string $creationId): JsonResponse + { + $workspace = $request->user()->currentWorkspace; + + $draft = AiPostDraft::query() + ->where('workspace_id', $workspace?->id) + ->find($creationId); + + if (! $draft) { + return response()->json(['status' => 'unknown']); + } + + return response()->json(match ($draft->status) { + DraftStatus::Completed => ['status' => 'done', 'post_id' => $draft->post_id], + DraftStatus::Failed => ['status' => 'error', 'error' => $draft->error], + default => ['status' => 'pending'], + }); + } + + /** A draft belongs to the user who created it, inside their current workspace. */ + private function authorizeDraft(Request $request, AiPostDraft $draft): void + { + abort_unless( + $draft->user_id === $request->user()->id + && $draft->workspace_id === $request->user()->currentWorkspace?->id, + Response::HTTP_FORBIDDEN, + ); + } } diff --git a/app/Http/Requests/App/Ai/GenerateFromDraftRequest.php b/app/Http/Requests/App/Ai/GenerateFromDraftRequest.php new file mode 100644 index 000000000..9c7326cc3 --- /dev/null +++ b/app/Http/Requests/App/Ai/GenerateFromDraftRequest.php @@ -0,0 +1,28 @@ + + */ + public function rules(): array + { + return [ + // The reviewed structure the user approved. Shape mirrors the + // generator output (caption + slides, or content + image_* fields); + // downstream assemble() reads keys defensively via data_get(). + 'structured' => ['required', 'array'], + ]; + } +} diff --git a/app/Jobs/Ai/PreparePostContent.php b/app/Jobs/Ai/PreparePostContent.php new file mode 100644 index 000000000..3f51f9471 --- /dev/null +++ b/app/Jobs/Ai/PreparePostContent.php @@ -0,0 +1,92 @@ +onQueue('ai'); + } + + public function handle(): void + { + $draft = AiPostDraft::find($this->draftId); + + if (! $draft) { + return; + } + + $workspace = $draft->workspace; + $socialAccount = $draft->social_account_id ? SocialAccount::find($draft->social_account_id) : null; + $style = app(AiTemplateRegistry::class)->find($draft->template); + $isCarousel = $draft->format === ContentType::CAROUSEL_FORMAT; + + $context = new TemplateContext( + workspace: $workspace, + socialAccount: $socialAccount, + format: $draft->format, + imageCount: $draft->image_count, + isCarousel: $isCarousel, + applyBrandVisuals: $draft->apply_brand_visuals, + ); + + try { + $structured = app(PostContentComposer::class)->compose( + workspace: $workspace, + format: $draft->format, + imageCount: $draft->image_count, + prompt: $draft->prompt, + style: $style, + context: $context, + userId: $draft->user_id, + ); + + $draft->update([ + 'structured' => $structured, + 'status' => DraftStatus::Ready, + ]); + + PostContentPrepared::dispatch($draft->user_id, $draft->id); + } catch (\Throwable $e) { + Log::error('PreparePostContent failed', [ + 'draft_id' => $draft->id, + 'error' => $e->getMessage(), + ]); + + $draft->update([ + 'status' => DraftStatus::Failed, + 'error' => $e->getMessage(), + ]); + + PostContentPrepared::dispatch($draft->user_id, $draft->id, $e->getMessage()); + + throw $e; + } + } +} diff --git a/app/Jobs/Ai/StreamPostCreation.php b/app/Jobs/Ai/StreamPostCreation.php index f8c9eb144..a1d0ae660 100644 --- a/app/Jobs/Ai/StreamPostCreation.php +++ b/app/Jobs/Ai/StreamPostCreation.php @@ -5,23 +5,21 @@ namespace App\Jobs\Ai; use App\Actions\Post\CreatePost; -use App\Ai\Agents\PostContentGenerator; -use App\Ai\Agents\PostContentHumanizer; use App\Ai\Templates\AiTemplateRegistry; use App\Ai\Templates\GeneratedPost; use App\Ai\Templates\TemplateContext; -use App\Enums\Ai\ContentStyle; -use App\Enums\Ai\GeneratorFormat; +use App\Enums\Ai\DraftStatus; use App\Enums\Notification\Channel as NotificationChannel; use App\Enums\Notification\Type as NotificationType; use App\Enums\PostPlatform\ContentType; use App\Events\Ai\PostCreationReady; use App\Jobs\SendNotification; +use App\Models\AiPostDraft; use App\Models\Post; use App\Models\SocialAccount; use App\Models\User; use App\Models\Workspace; -use App\Services\Ai\RecordAiUsage; +use App\Services\Ai\PostContentComposer; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -44,6 +42,10 @@ public function __construct( public ?string $date = null, public string $template = 'image_card', public bool $applyBrandVisuals = true, + /** Review flow (phase B): when set, skip text generation and render this reviewed structure. */ + public ?array $preparedStructured = null, + /** Links this run to an AiPostDraft to mark completed/failed. */ + public ?string $draftId = null, ) { $this->onQueue('ai'); } @@ -56,8 +58,6 @@ public function handle(): void $style = app(AiTemplateRegistry::class)->find($this->template); $isCarousel = $this->format === ContentType::CAROUSEL_FORMAT; - $agentFormat = $isCarousel ? GeneratorFormat::Carousel : GeneratorFormat::Single; - $slideCount = $isCarousel && $this->imageCount > 0 ? $this->imageCount : 1; $context = new TemplateContext( workspace: $workspace, @@ -68,34 +68,30 @@ public function handle(): void applyBrandVisuals: $this->applyBrandVisuals, ); - $agent = new PostContentGenerator( - workspace: $workspace, - format: $agentFormat, - slideCount: $slideCount, - platformContext: $this->format, - template: $style, - templateContext: $context, - ); - try { - $response = $agent->prompt($this->prompt); - - RecordAiUsage::recordText( - workspace: $workspace, - promptTokens: $response->usage->promptTokens, - completionTokens: $response->usage->completionTokens, - provider: (string) config('ai.default'), - model: (string) config('ai.default_text_model'), - userId: $this->userId, - metadata: ['agent' => 'post_generator', 'format' => $this->format], - ); - - $structured = $response->structured ?? []; - - $structured = $this->humanize($workspace, $structured, $agentFormat, $style->style()); + // The review flow (phase B) passes the reviewed structure: skip text + // generation and render images from exactly what the user approved. + $structured = $this->preparedStructured + ?? app(PostContentComposer::class)->compose( + workspace: $workspace, + format: $this->format, + imageCount: $this->imageCount, + prompt: $this->prompt, + style: $style, + context: $context, + userId: $this->userId, + ); $generated = $style->assemble($structured, $context); $post = $this->createPostFromGenerated($workspace, $generated, $socialAccount); + + if ($this->draftId !== null) { + AiPostDraft::whereKey($this->draftId)->update([ + 'post_id' => $post->id, + 'status' => DraftStatus::Completed, + ]); + } + $this->notifyReady($workspace, $post); } catch (\Throwable $e) { Log::error('StreamPostCreation failed', [ @@ -103,90 +99,19 @@ public function handle(): void 'error' => $e->getMessage(), ]); + if ($this->draftId !== null) { + AiPostDraft::whereKey($this->draftId)->update([ + 'status' => DraftStatus::Failed, + 'error' => $e->getMessage(), + ]); + } + PostCreationReady::dispatch($this->userId, $this->creationId, null, $e->getMessage()); throw $e; } } - /** - * Run the structured generator output through the humanizer pass and merge - * the humanized text fields back over the original structure (preserving - * image_keywords and slide order/count). Failures are logged and the - * original structure is returned so generation never breaks because of the - * polish step. - * - * @param array $structured - * @return array - */ - private function humanize(Workspace $workspace, array $structured, GeneratorFormat $format, ContentStyle $style): array - { - if (! $style->humanizes()) { - return $structured; - } - - try { - $input = $format->isCarousel() - ? [ - 'caption' => data_get($structured, 'caption', ''), - 'slides' => array_map( - fn ($s) => [ - 'title' => data_get($s, 'title', ''), - 'body' => data_get($s, 'body', ''), - ], - data_get($structured, 'slides', []), - ), - ] - : [ - 'content' => data_get($structured, 'content', ''), - 'image_title' => data_get($structured, 'image_title', ''), - 'image_body' => data_get($structured, 'image_body', ''), - ]; - - $humanizer = new PostContentHumanizer($workspace, $format, platformContext: $this->format); - $response = $humanizer->prompt(json_encode($input, JSON_UNESCAPED_UNICODE)); - $humanized = $response->structured ?? []; - - RecordAiUsage::recordText( - workspace: $workspace, - promptTokens: $response->usage->promptTokens, - completionTokens: $response->usage->completionTokens, - provider: (string) config('ai.default'), - model: (string) config('ai.default_text_model'), - userId: $this->userId, - metadata: ['agent' => 'post_humanizer', 'format' => $format->value], - ); - } catch (\Throwable $e) { - Log::warning('PostContentHumanizer failed, using generator output as-is', [ - 'creation_id' => $this->creationId, - 'error' => $e->getMessage(), - ]); - - return $structured; - } - - if ($format->isCarousel()) { - $structured['caption'] = data_get($humanized, 'caption', $structured['caption'] ?? ''); - $originalSlides = $structured['slides'] ?? []; - $humanizedSlides = data_get($humanized, 'slides', []); - - foreach ($originalSlides as $i => $slide) { - if (isset($humanizedSlides[$i])) { - $originalSlides[$i]['title'] = data_get($humanizedSlides[$i], 'title', $slide['title'] ?? ''); - $originalSlides[$i]['body'] = data_get($humanizedSlides[$i], 'body', $slide['body'] ?? ''); - } - } - - $structured['slides'] = $originalSlides; - } else { - $structured['content'] = data_get($humanized, 'content', $structured['content'] ?? ''); - $structured['image_title'] = data_get($humanized, 'image_title', $structured['image_title'] ?? ''); - $structured['image_body'] = data_get($humanized, 'image_body', $structured['image_body'] ?? ''); - } - - return $structured; - } - private function createPostFromGenerated(Workspace $workspace, GeneratedPost $generated, ?SocialAccount $socialAccount): Post { $user = User::findOrFail($this->userId); diff --git a/app/Models/AiPostDraft.php b/app/Models/AiPostDraft.php new file mode 100644 index 000000000..ebf3a0296 --- /dev/null +++ b/app/Models/AiPostDraft.php @@ -0,0 +1,73 @@ + */ + use HasFactory, HasUuids; + + protected $fillable = [ + 'workspace_id', + 'user_id', + 'social_account_id', + 'format', + 'template', + 'image_count', + 'apply_brand_visuals', + 'scheduled_date', + 'prompt', + 'structured', + 'status', + 'post_id', + 'error', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'structured' => 'array', + 'status' => DraftStatus::class, + 'scheduled_date' => 'date', + 'image_count' => 'integer', + 'apply_brand_visuals' => 'boolean', + ]; + } + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function socialAccount(): BelongsTo + { + return $this->belongsTo(SocialAccount::class); + } + + public function post(): BelongsTo + { + return $this->belongsTo(Post::class); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 960c0244b..6a9805c63 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -7,6 +7,7 @@ use App\Listeners\StripeEventListener; use App\Models\AccessToken; use App\Models\Account; +use App\Models\AiPostDraft; use App\Models\AiUsageLog; use App\Models\Automation; use App\Models\AutomationNodeRun; @@ -114,6 +115,7 @@ protected function configureMorphMap(): void Relation::enforceMorphMap([ 'accessToken' => AccessToken::class, 'account' => Account::class, + 'aiPostDraft' => AiPostDraft::class, 'aiUsageLog' => AiUsageLog::class, 'automation' => Automation::class, 'automationNodeRun' => AutomationNodeRun::class, diff --git a/app/Services/Ai/PostContentComposer.php b/app/Services/Ai/PostContentComposer.php new file mode 100644 index 000000000..a5ce71444 --- /dev/null +++ b/app/Services/Ai/PostContentComposer.php @@ -0,0 +1,149 @@ + + */ + public function compose( + Workspace $workspace, + string $format, + int $imageCount, + string $prompt, + AiContentTemplate $style, + TemplateContext $context, + string $userId, + ): array { + $isCarousel = $format === ContentType::CAROUSEL_FORMAT; + $agentFormat = $isCarousel ? GeneratorFormat::Carousel : GeneratorFormat::Single; + $slideCount = $isCarousel && $imageCount > 0 ? $imageCount : 1; + + $agent = new PostContentGenerator( + workspace: $workspace, + format: $agentFormat, + slideCount: $slideCount, + platformContext: $format, + template: $style, + templateContext: $context, + ); + + $response = $agent->prompt($prompt); + + RecordAiUsage::recordText( + workspace: $workspace, + promptTokens: $response->usage->promptTokens, + completionTokens: $response->usage->completionTokens, + provider: (string) config('ai.default'), + model: (string) config('ai.default_text_model'), + userId: $userId, + metadata: ['agent' => 'post_generator', 'format' => $format], + ); + + return $this->humanize($workspace, $response->structured ?? [], $agentFormat, $style->style(), $userId, $format); + } + + /** + * Run the structured generator output through the humanizer pass and merge + * the humanized text fields back over the original structure (preserving + * image_keywords and slide order/count). Failures are logged and the + * original structure is returned so generation never breaks because of the + * polish step. + * + * @param array $structured + * @return array + */ + private function humanize( + Workspace $workspace, + array $structured, + GeneratorFormat $format, + ContentStyle $style, + string $userId, + string $platformFormat, + ): array { + if (! $style->humanizes()) { + return $structured; + } + + try { + $input = $format->isCarousel() + ? [ + 'caption' => data_get($structured, 'caption', ''), + 'slides' => array_map( + fn ($s) => [ + 'title' => data_get($s, 'title', ''), + 'body' => data_get($s, 'body', ''), + ], + data_get($structured, 'slides', []), + ), + ] + : [ + 'content' => data_get($structured, 'content', ''), + 'image_title' => data_get($structured, 'image_title', ''), + 'image_body' => data_get($structured, 'image_body', ''), + ]; + + $humanizer = new PostContentHumanizer($workspace, $format, platformContext: $platformFormat); + $response = $humanizer->prompt(json_encode($input, JSON_UNESCAPED_UNICODE)); + $humanized = $response->structured ?? []; + + RecordAiUsage::recordText( + workspace: $workspace, + promptTokens: $response->usage->promptTokens, + completionTokens: $response->usage->completionTokens, + provider: (string) config('ai.default'), + model: (string) config('ai.default_text_model'), + userId: $userId, + metadata: ['agent' => 'post_humanizer', 'format' => $format->value], + ); + } catch (\Throwable $e) { + Log::warning('PostContentHumanizer failed, using generator output as-is', [ + 'error' => $e->getMessage(), + ]); + + return $structured; + } + + if ($format->isCarousel()) { + $structured['caption'] = data_get($humanized, 'caption', $structured['caption'] ?? ''); + $originalSlides = $structured['slides'] ?? []; + $humanizedSlides = data_get($humanized, 'slides', []); + + foreach ($originalSlides as $i => $slide) { + if (isset($humanizedSlides[$i])) { + $originalSlides[$i]['title'] = data_get($humanizedSlides[$i], 'title', $slide['title'] ?? ''); + $originalSlides[$i]['body'] = data_get($humanizedSlides[$i], 'body', $slide['body'] ?? ''); + } + } + + $structured['slides'] = $originalSlides; + } else { + $structured['content'] = data_get($humanized, 'content', $structured['content'] ?? ''); + $structured['image_title'] = data_get($humanized, 'image_title', $structured['image_title'] ?? ''); + $structured['image_body'] = data_get($humanized, 'image_body', $structured['image_body'] ?? ''); + } + + return $structured; + } +} diff --git a/database/factories/AiPostDraftFactory.php b/database/factories/AiPostDraftFactory.php new file mode 100644 index 000000000..bf0c03933 --- /dev/null +++ b/database/factories/AiPostDraftFactory.php @@ -0,0 +1,49 @@ + + */ +class AiPostDraftFactory extends Factory +{ + protected $model = AiPostDraft::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'workspace_id' => Workspace::factory(), + 'user_id' => User::factory(), + 'social_account_id' => null, + 'format' => 'instagram_carousel', + 'template' => 'image_card', + 'image_count' => 3, + 'apply_brand_visuals' => true, + 'scheduled_date' => null, + 'prompt' => $this->faker->sentence(), + 'structured' => null, + 'status' => DraftStatus::Preparing, + 'post_id' => null, + 'error' => null, + ]; + } + + public function ready(array $structured): static + { + return $this->state(fn () => [ + 'status' => DraftStatus::Ready, + 'structured' => $structured, + ]); + } +} diff --git a/database/migrations/2026_07_20_000000_create_ai_post_drafts_table.php b/database/migrations/2026_07_20_000000_create_ai_post_drafts_table.php new file mode 100644 index 000000000..3046d4bf1 --- /dev/null +++ b/database/migrations/2026_07_20_000000_create_ai_post_drafts_table.php @@ -0,0 +1,38 @@ +uuid('id')->primary(); + $table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete(); + $table->foreignUuid('user_id')->constrained()->cascadeOnDelete(); + $table->uuid('social_account_id')->nullable(); + $table->string('format'); + $table->string('template')->default('image_card'); + $table->unsignedInteger('image_count')->default(0); + $table->boolean('apply_brand_visuals')->default(true); + $table->date('scheduled_date')->nullable(); + $table->text('prompt'); + $table->json('structured')->nullable(); + $table->string('status')->default('preparing'); + $table->uuid('post_id')->nullable(); + $table->text('error')->nullable(); + $table->timestamps(); + + $table->index(['workspace_id', 'user_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('ai_post_drafts'); + } +}; diff --git a/lang/ar/posts.php b/lang/ar/posts.php index f3e316bdd..237f18796 100644 --- a/lang/ar/posts.php +++ b/lang/ar/posts.php @@ -1,6 +1,30 @@ [ + 'page_title' => 'Review your post', + 'title' => 'Review before generating', + 'description' => 'Edit the copy the AI wrote. Images are only generated after you approve it.', + 'preparing' => 'Writing your post…', + 'failed' => 'We could not write this post. Try again.', + 'back' => 'Back', + 'generate' => 'Generate images', + 'saving' => 'Saving…', + 'saved' => 'Saved', + 'caption' => 'Caption', + 'caption_placeholder' => 'The caption published with the post', + 'no_caption_for_format' => 'This format publishes no caption, so there is nothing to edit here.', + 'field_tweet_text' => 'Tweet text', + 'field_title' => 'Title', + 'field_body' => 'Body', + 'field_image_title' => 'Image headline', + 'field_image_body' => 'Image body', + 'field_image_keywords' => 'Photo keywords', + 'field_image_keywords_hint' => 'Comma-separated terms used to search the photo for this card.', + 'field_image_keywords_placeholder' => 'office, team, meeting', + 'reorder_slide' => 'Reorder slide :number', + ], 'title' => 'المنشورات', 'search' => 'البحث في المنشورات...', 'all_posts' => 'جميع المنشورات', diff --git a/lang/de/posts.php b/lang/de/posts.php index 21bfbfae8..69ca875d4 100644 --- a/lang/de/posts.php +++ b/lang/de/posts.php @@ -3,6 +3,30 @@ declare(strict_types=1); return [ + + 'ai_review' => [ + 'page_title' => 'Review your post', + 'title' => 'Review before generating', + 'description' => 'Edit the copy the AI wrote. Images are only generated after you approve it.', + 'preparing' => 'Writing your post…', + 'failed' => 'We could not write this post. Try again.', + 'back' => 'Back', + 'generate' => 'Generate images', + 'saving' => 'Saving…', + 'saved' => 'Saved', + 'caption' => 'Caption', + 'caption_placeholder' => 'The caption published with the post', + 'no_caption_for_format' => 'This format publishes no caption, so there is nothing to edit here.', + 'field_tweet_text' => 'Tweet text', + 'field_title' => 'Title', + 'field_body' => 'Body', + 'field_image_title' => 'Image headline', + 'field_image_body' => 'Image body', + 'field_image_keywords' => 'Photo keywords', + 'field_image_keywords_hint' => 'Comma-separated terms used to search the photo for this card.', + 'field_image_keywords_placeholder' => 'office, team, meeting', + 'reorder_slide' => 'Reorder slide :number', + ], 'title' => 'Beiträge', 'search' => 'Beiträge suchen...', 'all_posts' => 'Alle Beiträge', diff --git a/lang/el/posts.php b/lang/el/posts.php index a4d6dce2a..7066ebec8 100644 --- a/lang/el/posts.php +++ b/lang/el/posts.php @@ -1,6 +1,30 @@ [ + 'page_title' => 'Review your post', + 'title' => 'Review before generating', + 'description' => 'Edit the copy the AI wrote. Images are only generated after you approve it.', + 'preparing' => 'Writing your post…', + 'failed' => 'We could not write this post. Try again.', + 'back' => 'Back', + 'generate' => 'Generate images', + 'saving' => 'Saving…', + 'saved' => 'Saved', + 'caption' => 'Caption', + 'caption_placeholder' => 'The caption published with the post', + 'no_caption_for_format' => 'This format publishes no caption, so there is nothing to edit here.', + 'field_tweet_text' => 'Tweet text', + 'field_title' => 'Title', + 'field_body' => 'Body', + 'field_image_title' => 'Image headline', + 'field_image_body' => 'Image body', + 'field_image_keywords' => 'Photo keywords', + 'field_image_keywords_hint' => 'Comma-separated terms used to search the photo for this card.', + 'field_image_keywords_placeholder' => 'office, team, meeting', + 'reorder_slide' => 'Reorder slide :number', + ], 'title' => 'Δημοσιεύσεις', 'search' => 'Αναζήτηση δημοσιεύσεων...', 'all_posts' => 'Όλες οι δημοσιεύσεις', diff --git a/lang/en/posts.php b/lang/en/posts.php index b0a647e5d..fd9a7445b 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -1,6 +1,30 @@ [ + 'page_title' => 'Review your post', + 'title' => 'Review before generating', + 'description' => 'Edit the copy the AI wrote. Images are only generated after you approve it.', + 'preparing' => 'Writing your post…', + 'failed' => 'We could not write this post. Try again.', + 'back' => 'Back', + 'generate' => 'Generate images', + 'saving' => 'Saving…', + 'saved' => 'Saved', + 'caption' => 'Caption', + 'caption_placeholder' => 'The caption published with the post', + 'no_caption_for_format' => 'This format publishes no caption, so there is nothing to edit here.', + 'field_tweet_text' => 'Tweet text', + 'field_title' => 'Title', + 'field_body' => 'Body', + 'field_image_title' => 'Image headline', + 'field_image_body' => 'Image body', + 'field_image_keywords' => 'Photo keywords', + 'field_image_keywords_hint' => 'Comma-separated terms used to search the photo for this card.', + 'field_image_keywords_placeholder' => 'office, team, meeting', + 'reorder_slide' => 'Reorder slide :number', + ], 'title' => 'Posts', 'search' => 'Search posts...', 'all_posts' => 'All Posts', diff --git a/lang/es/posts.php b/lang/es/posts.php index 0570a629c..6a1c44865 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -1,6 +1,30 @@ [ + 'page_title' => 'Review your post', + 'title' => 'Review before generating', + 'description' => 'Edit the copy the AI wrote. Images are only generated after you approve it.', + 'preparing' => 'Writing your post…', + 'failed' => 'We could not write this post. Try again.', + 'back' => 'Back', + 'generate' => 'Generate images', + 'saving' => 'Saving…', + 'saved' => 'Saved', + 'caption' => 'Caption', + 'caption_placeholder' => 'The caption published with the post', + 'no_caption_for_format' => 'This format publishes no caption, so there is nothing to edit here.', + 'field_tweet_text' => 'Tweet text', + 'field_title' => 'Title', + 'field_body' => 'Body', + 'field_image_title' => 'Image headline', + 'field_image_body' => 'Image body', + 'field_image_keywords' => 'Photo keywords', + 'field_image_keywords_hint' => 'Comma-separated terms used to search the photo for this card.', + 'field_image_keywords_placeholder' => 'office, team, meeting', + 'reorder_slide' => 'Reorder slide :number', + ], 'title' => 'Posts', 'search' => 'Buscar posts...', 'all_posts' => 'Todos los posts', diff --git a/lang/fr/posts.php b/lang/fr/posts.php index ae94b65ae..e8bf5d8ab 100644 --- a/lang/fr/posts.php +++ b/lang/fr/posts.php @@ -1,6 +1,30 @@ [ + 'page_title' => 'Review your post', + 'title' => 'Review before generating', + 'description' => 'Edit the copy the AI wrote. Images are only generated after you approve it.', + 'preparing' => 'Writing your post…', + 'failed' => 'We could not write this post. Try again.', + 'back' => 'Back', + 'generate' => 'Generate images', + 'saving' => 'Saving…', + 'saved' => 'Saved', + 'caption' => 'Caption', + 'caption_placeholder' => 'The caption published with the post', + 'no_caption_for_format' => 'This format publishes no caption, so there is nothing to edit here.', + 'field_tweet_text' => 'Tweet text', + 'field_title' => 'Title', + 'field_body' => 'Body', + 'field_image_title' => 'Image headline', + 'field_image_body' => 'Image body', + 'field_image_keywords' => 'Photo keywords', + 'field_image_keywords_hint' => 'Comma-separated terms used to search the photo for this card.', + 'field_image_keywords_placeholder' => 'office, team, meeting', + 'reorder_slide' => 'Reorder slide :number', + ], 'title' => 'Publications', 'search' => 'Rechercher des publications...', 'all_posts' => 'Toutes les publications', diff --git a/lang/it/posts.php b/lang/it/posts.php index 8e9d46706..9b164486c 100644 --- a/lang/it/posts.php +++ b/lang/it/posts.php @@ -1,6 +1,30 @@ [ + 'page_title' => 'Review your post', + 'title' => 'Review before generating', + 'description' => 'Edit the copy the AI wrote. Images are only generated after you approve it.', + 'preparing' => 'Writing your post…', + 'failed' => 'We could not write this post. Try again.', + 'back' => 'Back', + 'generate' => 'Generate images', + 'saving' => 'Saving…', + 'saved' => 'Saved', + 'caption' => 'Caption', + 'caption_placeholder' => 'The caption published with the post', + 'no_caption_for_format' => 'This format publishes no caption, so there is nothing to edit here.', + 'field_tweet_text' => 'Tweet text', + 'field_title' => 'Title', + 'field_body' => 'Body', + 'field_image_title' => 'Image headline', + 'field_image_body' => 'Image body', + 'field_image_keywords' => 'Photo keywords', + 'field_image_keywords_hint' => 'Comma-separated terms used to search the photo for this card.', + 'field_image_keywords_placeholder' => 'office, team, meeting', + 'reorder_slide' => 'Reorder slide :number', + ], 'title' => 'Post', 'search' => 'Cerca post...', 'all_posts' => 'Tutti i post', diff --git a/lang/ja/posts.php b/lang/ja/posts.php index 01423419e..d39b62c07 100644 --- a/lang/ja/posts.php +++ b/lang/ja/posts.php @@ -1,6 +1,30 @@ [ + 'page_title' => 'Review your post', + 'title' => 'Review before generating', + 'description' => 'Edit the copy the AI wrote. Images are only generated after you approve it.', + 'preparing' => 'Writing your post…', + 'failed' => 'We could not write this post. Try again.', + 'back' => 'Back', + 'generate' => 'Generate images', + 'saving' => 'Saving…', + 'saved' => 'Saved', + 'caption' => 'Caption', + 'caption_placeholder' => 'The caption published with the post', + 'no_caption_for_format' => 'This format publishes no caption, so there is nothing to edit here.', + 'field_tweet_text' => 'Tweet text', + 'field_title' => 'Title', + 'field_body' => 'Body', + 'field_image_title' => 'Image headline', + 'field_image_body' => 'Image body', + 'field_image_keywords' => 'Photo keywords', + 'field_image_keywords_hint' => 'Comma-separated terms used to search the photo for this card.', + 'field_image_keywords_placeholder' => 'office, team, meeting', + 'reorder_slide' => 'Reorder slide :number', + ], 'title' => '投稿', 'search' => '投稿を検索...', 'all_posts' => 'すべての投稿', diff --git a/lang/ko/posts.php b/lang/ko/posts.php index cccdc7fb1..9fb6e69cf 100644 --- a/lang/ko/posts.php +++ b/lang/ko/posts.php @@ -1,6 +1,30 @@ [ + 'page_title' => 'Review your post', + 'title' => 'Review before generating', + 'description' => 'Edit the copy the AI wrote. Images are only generated after you approve it.', + 'preparing' => 'Writing your post…', + 'failed' => 'We could not write this post. Try again.', + 'back' => 'Back', + 'generate' => 'Generate images', + 'saving' => 'Saving…', + 'saved' => 'Saved', + 'caption' => 'Caption', + 'caption_placeholder' => 'The caption published with the post', + 'no_caption_for_format' => 'This format publishes no caption, so there is nothing to edit here.', + 'field_tweet_text' => 'Tweet text', + 'field_title' => 'Title', + 'field_body' => 'Body', + 'field_image_title' => 'Image headline', + 'field_image_body' => 'Image body', + 'field_image_keywords' => 'Photo keywords', + 'field_image_keywords_hint' => 'Comma-separated terms used to search the photo for this card.', + 'field_image_keywords_placeholder' => 'office, team, meeting', + 'reorder_slide' => 'Reorder slide :number', + ], 'title' => '게시물', 'search' => '게시물 검색...', 'all_posts' => '모든 게시물', diff --git a/lang/nl/posts.php b/lang/nl/posts.php index ef6f2228c..342753d46 100644 --- a/lang/nl/posts.php +++ b/lang/nl/posts.php @@ -1,6 +1,30 @@ [ + 'page_title' => 'Review your post', + 'title' => 'Review before generating', + 'description' => 'Edit the copy the AI wrote. Images are only generated after you approve it.', + 'preparing' => 'Writing your post…', + 'failed' => 'We could not write this post. Try again.', + 'back' => 'Back', + 'generate' => 'Generate images', + 'saving' => 'Saving…', + 'saved' => 'Saved', + 'caption' => 'Caption', + 'caption_placeholder' => 'The caption published with the post', + 'no_caption_for_format' => 'This format publishes no caption, so there is nothing to edit here.', + 'field_tweet_text' => 'Tweet text', + 'field_title' => 'Title', + 'field_body' => 'Body', + 'field_image_title' => 'Image headline', + 'field_image_body' => 'Image body', + 'field_image_keywords' => 'Photo keywords', + 'field_image_keywords_hint' => 'Comma-separated terms used to search the photo for this card.', + 'field_image_keywords_placeholder' => 'office, team, meeting', + 'reorder_slide' => 'Reorder slide :number', + ], 'title' => 'Posts', 'search' => 'Posts zoeken...', 'all_posts' => 'Alle posts', diff --git a/lang/pl/posts.php b/lang/pl/posts.php index 694d521a4..e22e95a90 100644 --- a/lang/pl/posts.php +++ b/lang/pl/posts.php @@ -1,6 +1,30 @@ [ + 'page_title' => 'Review your post', + 'title' => 'Review before generating', + 'description' => 'Edit the copy the AI wrote. Images are only generated after you approve it.', + 'preparing' => 'Writing your post…', + 'failed' => 'We could not write this post. Try again.', + 'back' => 'Back', + 'generate' => 'Generate images', + 'saving' => 'Saving…', + 'saved' => 'Saved', + 'caption' => 'Caption', + 'caption_placeholder' => 'The caption published with the post', + 'no_caption_for_format' => 'This format publishes no caption, so there is nothing to edit here.', + 'field_tweet_text' => 'Tweet text', + 'field_title' => 'Title', + 'field_body' => 'Body', + 'field_image_title' => 'Image headline', + 'field_image_body' => 'Image body', + 'field_image_keywords' => 'Photo keywords', + 'field_image_keywords_hint' => 'Comma-separated terms used to search the photo for this card.', + 'field_image_keywords_placeholder' => 'office, team, meeting', + 'reorder_slide' => 'Reorder slide :number', + ], 'title' => 'Posty', 'search' => 'Szukaj postów...', 'all_posts' => 'Wszystkie posty', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index 588a674e8..1510aeb2f 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -1,6 +1,30 @@ [ + 'page_title' => 'Revise seu post', + 'title' => 'Revise antes de gerar', + 'description' => 'Edite o texto que a IA escreveu. As imagens só são geradas depois que você aprovar.', + 'preparing' => 'Escrevendo seu post…', + 'failed' => 'Não conseguimos escrever este post. Tente de novo.', + 'back' => 'Voltar', + 'generate' => 'Gerar imagens', + 'saving' => 'Salvando…', + 'saved' => 'Salvo', + 'caption' => 'Legenda', + 'caption_placeholder' => 'A legenda publicada junto com o post', + 'no_caption_for_format' => 'Este formato não publica legenda, então não há o que editar aqui.', + 'field_tweet_text' => 'Texto do tweet', + 'field_title' => 'Título', + 'field_body' => 'Descrição', + 'field_image_title' => 'Título da imagem', + 'field_image_body' => 'Texto da imagem', + 'field_image_keywords' => 'Palavras-chave da foto', + 'field_image_keywords_hint' => 'Termos separados por vírgula usados para buscar a foto deste card.', + 'field_image_keywords_placeholder' => 'cidade, equipe, reunião', + 'reorder_slide' => 'Reordenar slide :number', + ], 'title' => 'Posts', 'search' => 'Buscar posts...', 'all_posts' => 'Todos os Posts', diff --git a/lang/ru/posts.php b/lang/ru/posts.php index 7c346dd70..20edc0a08 100644 --- a/lang/ru/posts.php +++ b/lang/ru/posts.php @@ -1,6 +1,30 @@ [ + 'page_title' => 'Review your post', + 'title' => 'Review before generating', + 'description' => 'Edit the copy the AI wrote. Images are only generated after you approve it.', + 'preparing' => 'Writing your post…', + 'failed' => 'We could not write this post. Try again.', + 'back' => 'Back', + 'generate' => 'Generate images', + 'saving' => 'Saving…', + 'saved' => 'Saved', + 'caption' => 'Caption', + 'caption_placeholder' => 'The caption published with the post', + 'no_caption_for_format' => 'This format publishes no caption, so there is nothing to edit here.', + 'field_tweet_text' => 'Tweet text', + 'field_title' => 'Title', + 'field_body' => 'Body', + 'field_image_title' => 'Image headline', + 'field_image_body' => 'Image body', + 'field_image_keywords' => 'Photo keywords', + 'field_image_keywords_hint' => 'Comma-separated terms used to search the photo for this card.', + 'field_image_keywords_placeholder' => 'office, team, meeting', + 'reorder_slide' => 'Reorder slide :number', + ], 'title' => 'Посты', 'search' => 'Поиск постов...', 'all_posts' => 'Все посты', diff --git a/lang/tr/posts.php b/lang/tr/posts.php index 89a217d20..369d235b9 100644 --- a/lang/tr/posts.php +++ b/lang/tr/posts.php @@ -3,6 +3,30 @@ declare(strict_types=1); return [ + + 'ai_review' => [ + 'page_title' => 'Review your post', + 'title' => 'Review before generating', + 'description' => 'Edit the copy the AI wrote. Images are only generated after you approve it.', + 'preparing' => 'Writing your post…', + 'failed' => 'We could not write this post. Try again.', + 'back' => 'Back', + 'generate' => 'Generate images', + 'saving' => 'Saving…', + 'saved' => 'Saved', + 'caption' => 'Caption', + 'caption_placeholder' => 'The caption published with the post', + 'no_caption_for_format' => 'This format publishes no caption, so there is nothing to edit here.', + 'field_tweet_text' => 'Tweet text', + 'field_title' => 'Title', + 'field_body' => 'Body', + 'field_image_title' => 'Image headline', + 'field_image_body' => 'Image body', + 'field_image_keywords' => 'Photo keywords', + 'field_image_keywords_hint' => 'Comma-separated terms used to search the photo for this card.', + 'field_image_keywords_placeholder' => 'office, team, meeting', + 'reorder_slide' => 'Reorder slide :number', + ], 'title' => 'Gönderiler', 'search' => 'Gönderi ara...', 'all_posts' => 'Tüm Gönderiler', diff --git a/lang/zh/posts.php b/lang/zh/posts.php index 066eba912..c87ef7948 100644 --- a/lang/zh/posts.php +++ b/lang/zh/posts.php @@ -1,6 +1,30 @@ [ + 'page_title' => 'Review your post', + 'title' => 'Review before generating', + 'description' => 'Edit the copy the AI wrote. Images are only generated after you approve it.', + 'preparing' => 'Writing your post…', + 'failed' => 'We could not write this post. Try again.', + 'back' => 'Back', + 'generate' => 'Generate images', + 'saving' => 'Saving…', + 'saved' => 'Saved', + 'caption' => 'Caption', + 'caption_placeholder' => 'The caption published with the post', + 'no_caption_for_format' => 'This format publishes no caption, so there is nothing to edit here.', + 'field_tweet_text' => 'Tweet text', + 'field_title' => 'Title', + 'field_body' => 'Body', + 'field_image_title' => 'Image headline', + 'field_image_body' => 'Image body', + 'field_image_keywords' => 'Photo keywords', + 'field_image_keywords_hint' => 'Comma-separated terms used to search the photo for this card.', + 'field_image_keywords_placeholder' => 'office, team, meeting', + 'reorder_slide' => 'Reorder slide :number', + ], 'title' => '帖子', 'search' => '搜索帖子…', 'all_posts' => '所有帖子', diff --git a/package-lock.json b/package-lock.json index 51f067e32..a773e0e4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "trypost", + "name": "wt-review", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/resources/js/components/posts/ai/ReviewBlockCard.vue b/resources/js/components/posts/ai/ReviewBlockCard.vue new file mode 100644 index 000000000..ec5a9c806 --- /dev/null +++ b/resources/js/components/posts/ai/ReviewBlockCard.vue @@ -0,0 +1,143 @@ + + +