Skip to content

Commit 7a59861

Browse files
Merge pull request #57 from trypostit/feat/ai-image-regenerate
feat: regenerate AI post images with brand palette
2 parents 666402c + 0e20434 commit 7a59861

67 files changed

Lines changed: 1848 additions & 171 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.cursor/rules/laravel-patterns.mdc

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,9 @@ If you hit `Illuminate\Foundation\ViteException: Unable to locate file in Vite m
6161
## Deployment
6262

6363
Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/) — the fastest path to production for Laravel apps.
64+
65+
## AI agents (`app/Ai/Agents`)
66+
67+
- Do not write prompts inside agent classes (no heredocs, no long strings in `instructions()`).
68+
- Store prompt copy in `resources/views/prompts/**/*.blade.php` and render with `view('prompts....', $vars)->render()`.
69+
- Follow `PostContentStreamer`, `PostContentReviewer`, or `BrandAnalyzer` for structure.

.cursor/rules/project-context.mdc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ This project has domain-specific skills in `.claude/skills/` (e.g. `pest-testing
2828
## Conventions
2929

3030
- Follow existing code conventions. Check sibling files for structure, approach and naming before creating or editing.
31+
- In Vue `<DialogFooter>`, primary action button first in markup, then cancel/secondary (see `vue-typescript.mdc` and `CLAUDE.md`).
32+
- AI agents: prompts live in `resources/views/prompts/`; `instructions()` uses `view(...)->render()` only — never heredocs or inline prompt strings in `app/Ai/Agents/` (see `CLAUDE.md`).
3133
- Use descriptive names (`isRegisteredForDiscounts`, not `discount()`).
3234
- Reuse existing components before writing new ones.
3335
- Stick to existing directory structure. Do not create new base folders without approval.

.cursor/rules/vue-typescript.mdc

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,21 @@ This project uses `@tabler/icons-vue` for all icons. NEVER use `lucide-vue-next`
4242
- Import routes from `@/routes/...` — e.g. `import { store } from '@/routes/login'`.
4343
- Import controller actions from `@/actions/...`.
4444

45+
## Dialogs
46+
47+
In `<DialogFooter>`, put the **primary action button first** in the markup, then secondary/cancel.
48+
49+
`DialogFooter` (`resources/js/components/ui/dialog/DialogFooter.vue`) uses `flex-col-reverse` on mobile and `sm:flex-row sm:justify-start` on desktop — the first child is the leftmost action on larger screens.
50+
51+
```vue
52+
<DialogFooter>
53+
<Button @click="submit">{{ $t('...submit') }}</Button>
54+
<Button variant="outline" @click="open = false">{{ $t('common.cancel') }}</Button>
55+
</DialogFooter>
56+
```
57+
58+
Check sibling dialogs in the same feature before inventing a new footer layout.
59+
4560
## Form validation
4661

4762
NEVER use HTML5 validation attributes (`required`, `minlength`, `pattern`, etc.) on form inputs. Always rely solely on backend validation.

CLAUDE.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,17 @@ Vue components must have a single root element.
230230

231231
- Always use arrow functions in Vue components and TypeScript files. Never use `function` declarations.
232232

233+
## Dialogs
234+
235+
- In `<DialogFooter>`, put the **primary action button first** in the markup, then secondary/cancel (e.g. Save → Cancel). `DialogFooter` uses `flex-col-reverse` on mobile and `sm:flex-row sm:justify-start` on desktop, so the first child is the leftmost action on larger screens.
236+
- Match sibling dialogs in the same feature area before inventing a new footer layout.
237+
238+
## AI agents (`app/Ai/Agents`)
239+
240+
- **Never** embed prompts in PHP (`<<<PROMPT`, heredocs, or long string literals in `instructions()`).
241+
- Put system/instruction text in Blade under `resources/views/prompts/` (e.g. `prompts.post_content.generator`, `prompts.post_image.regenerator`).
242+
- In `instructions()`, return `view('prompts....', [...])->render()` and pass only the variables the Blade file needs — same pattern as `PostContentStreamer`, `PostContentReviewer`, and `BrandAnalyzer`.
243+
233244
## Icons (@tabler/icons-vue)
234245

235246
- This project uses `@tabler/icons-vue` for all icons. NEVER use `lucide-vue-next`.

app/Actions/Post/UpdatePost.php

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use App\Jobs\PublishPost;
1010
use App\Models\Post;
1111
use App\Models\Workspace;
12+
use App\Support\PostStatusRules;
1213
use Carbon\Carbon;
1314
use Illuminate\Support\Arr;
1415
use Illuminate\Support\Facades\DB;
@@ -20,9 +21,7 @@ class UpdatePost
2021
*/
2122
public static function execute(Workspace $workspace, Post $post, array $data): array
2223
{
23-
$terminalStatuses = [PostStatus::Published, PostStatus::PartiallyPublished, PostStatus::Failed, PostStatus::Publishing];
24-
25-
if (in_array($post->status, $terminalStatuses, true)) {
24+
if (PostStatusRules::blocksEditing($post)) {
2625
return ['post' => $post, 'action' => PostAction::Finalized];
2726
}
2827

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Ai\Agents;
6+
7+
use App\Models\Workspace;
8+
use Illuminate\Contracts\JsonSchema\JsonSchema;
9+
use Laravel\Ai\Attributes\Temperature;
10+
use Laravel\Ai\Contracts\Agent;
11+
use Laravel\Ai\Contracts\HasStructuredOutput;
12+
use Laravel\Ai\Enums\Lab;
13+
use Laravel\Ai\Promptable;
14+
15+
#[Temperature(0.25)]
16+
class PostImageRegenerator implements Agent, HasStructuredOutput
17+
{
18+
use Promptable;
19+
20+
public function __construct(
21+
public Workspace $workspace,
22+
) {}
23+
24+
public function instructions(): string
25+
{
26+
return view('prompts.post_image.regenerator', [
27+
'content_language' => $this->workspace->content_language ?: 'en',
28+
])->render();
29+
}
30+
31+
public function schema(JsonSchema $schema): array
32+
{
33+
return [
34+
'title' => $schema->string()
35+
->description('Updated short title for the image (max ~120 chars).')
36+
->required(),
37+
'body' => $schema->string()
38+
->description('Updated supporting text for the image (max ~240 chars).')
39+
->required(),
40+
'keywords' => $schema->array()
41+
->items($schema->string())
42+
->description('3-10 short keywords for image generation context.')
43+
->required(),
44+
'change_mode' => $schema->string()
45+
->enum(['image_only', 'text_only', 'both'])
46+
->description('Set to image_only (change visual only), text_only (change text only), or both (change visual and text).')
47+
->required(),
48+
];
49+
}
50+
51+
public function provider(): Lab
52+
{
53+
return match (config('ai.default')) {
54+
'openai' => Lab::OpenAI,
55+
'anthropic' => Lab::Anthropic,
56+
default => Lab::Gemini,
57+
};
58+
}
59+
60+
public function model(): string
61+
{
62+
return config('ai.default_text_model');
63+
}
64+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Broadcasting;
6+
7+
use App\Models\User;
8+
9+
class UserAiMediaRegenerationChannel
10+
{
11+
public function join(User $user, User $owner, string $regenerationId): bool
12+
{
13+
return $user->is($owner);
14+
}
15+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Events\Ai;
6+
7+
use Illuminate\Broadcasting\InteractsWithSockets;
8+
use Illuminate\Broadcasting\PrivateChannel;
9+
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
10+
use Illuminate\Foundation\Events\Dispatchable;
11+
use Illuminate\Queue\SerializesModels;
12+
13+
class PostMediaRegenerated implements ShouldBroadcast
14+
{
15+
use Dispatchable, InteractsWithSockets, SerializesModels;
16+
17+
/**
18+
* @param array<string, mixed>|null $media
19+
*/
20+
public function __construct(
21+
public string $userId,
22+
public string $regenerationId,
23+
public string $postId,
24+
public ?array $media = null,
25+
public ?string $error = null,
26+
) {}
27+
28+
public function broadcastAs(): string
29+
{
30+
return 'ai.media.regenerated';
31+
}
32+
33+
public function broadcastOn(): PrivateChannel
34+
{
35+
return new PrivateChannel("user.{$this->userId}.ai-media.{$this->regenerationId}");
36+
}
37+
38+
/**
39+
* @return array<string, mixed>
40+
*/
41+
public function broadcastWith(): array
42+
{
43+
return [
44+
'regeneration_id' => $this->regenerationId,
45+
'post_id' => $this->postId,
46+
'media' => $this->media,
47+
'error' => $this->error,
48+
];
49+
}
50+
51+
public function broadcastQueue(): string
52+
{
53+
return 'broadcasts';
54+
}
55+
}

app/Http/Controllers/Api/PostController.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use App\Http\Resources\Api\PostResource;
2020
use App\Models\Post;
2121
use App\Services\Post\MediaAttacher;
22+
use App\Support\PostStatusRules;
2223
use Illuminate\Http\JsonResponse;
2324
use Illuminate\Http\Request;
2425
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -69,7 +70,7 @@ public function update(UpdatePostRequest $request, Post $post): PostResource|Jso
6970

7071
if (data_get($result, 'action') === PostAction::Finalized) {
7172
return response()->json(
72-
['message' => 'Cannot edit a post in a terminal state.'],
73+
['message' => PostStatusRules::editBlockedMessage()],
7374
Response::HTTP_UNPROCESSABLE_ENTITY
7475
);
7576
}

app/Http/Controllers/App/PostAiGenerateController.php

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,7 @@ public function generate(GeneratePostContentRequest $request, Post $post): JsonR
1818
{
1919
$workspace = $request->user()->currentWorkspace;
2020

21-
if ($post->workspace_id !== $workspace->id) {
22-
abort(Response::HTTP_FORBIDDEN);
23-
}
21+
$this->authorize('update', $post);
2422

2523
$gate = Gate::inspect('useAi', $workspace->account);
2624
if ($gate->denied()) {

0 commit comments

Comments
 (0)