diff --git a/backend/app/Actions/Chats/CreateChatAction.php b/backend/app/Actions/Chats/CreateChatAction.php new file mode 100644 index 0000000..a158d5e --- /dev/null +++ b/backend/app/Actions/Chats/CreateChatAction.php @@ -0,0 +1,16 @@ +chats()->create(['title' => $title]); + + return ChatResponseDto::fromModel($chat); + } +} diff --git a/backend/app/Actions/Chats/StoreChatMessageAction.php b/backend/app/Actions/Chats/StoreChatMessageAction.php new file mode 100644 index 0000000..53e8a89 --- /dev/null +++ b/backend/app/Actions/Chats/StoreChatMessageAction.php @@ -0,0 +1,18 @@ +messages()->create([ + 'role' => $role->value, + 'content' => $message, + ]); + } +} diff --git a/backend/app/Data/ChatResponseDto.php b/backend/app/Data/ChatResponseDto.php new file mode 100644 index 0000000..7823672 --- /dev/null +++ b/backend/app/Data/ChatResponseDto.php @@ -0,0 +1,28 @@ +id, + createdAt: $chat->created_at, + updatedAt: $chat->updated_at, + userId: $chat->user_id, + title: $chat->title ?? null, + ); + } +} diff --git a/backend/app/Data/SocialMediaPostResponseDto.php b/backend/app/Data/SocialMediaPostResponseDto.php index 952aaf2..920ef28 100644 --- a/backend/app/Data/SocialMediaPostResponseDto.php +++ b/backend/app/Data/SocialMediaPostResponseDto.php @@ -2,10 +2,14 @@ namespace App\Data; +use Carbon\CarbonInterface; + class SocialMediaPostResponseDto { public function __construct( + public string $id, public string $post, public string $image, + public CarbonInterface $createdAt ) {} } diff --git a/backend/app/Enums/Chats/ChatRoles.php b/backend/app/Enums/Chats/ChatRoles.php new file mode 100644 index 0000000..e3de5a8 --- /dev/null +++ b/backend/app/Enums/Chats/ChatRoles.php @@ -0,0 +1,9 @@ +create($request->user(), $request->input('title', null)) + ); + } +} diff --git a/backend/app/Http/Controllers/ChatMessageController.php b/backend/app/Http/Controllers/ChatMessageController.php new file mode 100644 index 0000000..ca664b0 --- /dev/null +++ b/backend/app/Http/Controllers/ChatMessageController.php @@ -0,0 +1,19 @@ +generatePostWithImage($request->input('prompt'), $chat) + ); + } +} diff --git a/backend/app/Http/Controllers/SocialMediaPostController.php b/backend/app/Http/Controllers/SocialMediaPostController.php deleted file mode 100644 index aa708a6..0000000 --- a/backend/app/Http/Controllers/SocialMediaPostController.php +++ /dev/null @@ -1,28 +0,0 @@ -input('prompt'); - $response = $socialMediaService->generatePostWithImage($prompt); - - return response()->json([ - 'post' => $response->post, - 'image_prompt' => $response->image, - ]); - } -} diff --git a/backend/app/Http/Requests/CreateChatRequest.php b/backend/app/Http/Requests/CreateChatRequest.php new file mode 100644 index 0000000..a5e07cf --- /dev/null +++ b/backend/app/Http/Requests/CreateChatRequest.php @@ -0,0 +1,20 @@ + 'nullable|string|max:255', + ]; + } + + public function authorize(): bool + { + return true; + } +} diff --git a/backend/app/Http/Requests/SocialMediaPostRequest.php b/backend/app/Http/Requests/GeneratePostRequest.php similarity index 93% rename from backend/app/Http/Requests/SocialMediaPostRequest.php rename to backend/app/Http/Requests/GeneratePostRequest.php index 5aa215f..0ba6d8b 100644 --- a/backend/app/Http/Requests/SocialMediaPostRequest.php +++ b/backend/app/Http/Requests/GeneratePostRequest.php @@ -4,7 +4,7 @@ use Illuminate\Foundation\Http\FormRequest; -class SocialMediaPostRequest extends FormRequest +class GeneratePostRequest extends FormRequest { public function rules(): array { diff --git a/backend/app/Http/Resources/ChatResponseResource.php b/backend/app/Http/Resources/ChatResponseResource.php new file mode 100644 index 0000000..3b3e2d3 --- /dev/null +++ b/backend/app/Http/Resources/ChatResponseResource.php @@ -0,0 +1,32 @@ +resource->id; + } + + public function toAttributes(Request $request): array + { + return [ + 'title' => $this->resource->title, + 'createdAt' => $this->resource->createdAt->toIso8601String(), + 'updatedAt' => $this->resource->updatedAt->toIso8601String(), + ]; + } +} diff --git a/backend/app/Http/Resources/GeneratedPostResource.php b/backend/app/Http/Resources/GeneratedPostResource.php new file mode 100644 index 0000000..d9f7b45 --- /dev/null +++ b/backend/app/Http/Resources/GeneratedPostResource.php @@ -0,0 +1,32 @@ +resource->id; + } + + public function toAttributes(Request $request): array + { + return [ + 'post' => $this->resource->post, + 'image' => $this->resource->image, + 'createdAt' => $this->resource->createdAt->toIso8601String(), + ]; + } +} diff --git a/backend/app/Models/Chat.php b/backend/app/Models/Chat.php new file mode 100644 index 0000000..cd344b1 --- /dev/null +++ b/backend/app/Models/Chat.php @@ -0,0 +1,22 @@ +belongsTo(User::class); + } + + public function messages(): HasMany + { + return $this->hasMany(ChatMessage::class); + } +} diff --git a/backend/app/Models/ChatMessage.php b/backend/app/Models/ChatMessage.php new file mode 100644 index 0000000..626a239 --- /dev/null +++ b/backend/app/Models/ChatMessage.php @@ -0,0 +1,16 @@ +belongsTo(Chat::class); + } +} diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index 4e71ebe..2d0857b 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; @@ -30,4 +31,9 @@ protected function casts(): array 'password' => 'hashed', ]; } + + public function chats(): HasMany + { + return $this->hasMany(Chat::class); + } } diff --git a/backend/app/Services/SocialMediaService.php b/backend/app/Services/SocialMediaService.php index 3d01943..8149fc3 100644 --- a/backend/app/Services/SocialMediaService.php +++ b/backend/app/Services/SocialMediaService.php @@ -2,25 +2,40 @@ namespace App\Services; +use App\Actions\Chats\StoreChatMessageAction; use App\Ai\Agents\ContentWriterAgent; use App\Ai\Agents\CreativeDirectorAgent; use App\Data\SocialMediaPostResponseDto; +use App\Enums\Chats\ChatRoles; +use App\Models\Chat; +use App\Models\ChatMessage; -class SocialMediaService +readonly class SocialMediaService { public function __construct( - private readonly ContentWriterAgent $contentWriterAgent, - private readonly CreativeDirectorAgent $creativeDirectorAgent, + private ContentWriterAgent $contentWriterAgent, + private CreativeDirectorAgent $creativeDirectorAgent, + private StoreChatMessageAction $chatMessage, ) {} - public function generatePostWithImage(string $prompt): SocialMediaPostResponseDto + /** + * Generate a social media post with an image. + * We are not using database transactions here because we do not want to delete the user prompt + * if any of the ai agents fails. + */ + public function generatePostWithImage(string $prompt, Chat $chat): SocialMediaPostResponseDto { + $this->chatMessage->store($chat, ChatRoles::USER, $prompt); + $socialMediaResponse = $this->contentWriterAgent->prompt($prompt); $postText = $socialMediaResponse->text; + /* @var ChatMessage $aiChat */ + $aiChat = $this->chatMessage->store($chat, ChatRoles::AI, $postText); + $imagePromptResponse = $this->creativeDirectorAgent->prompt($postText); $imagePrompt = $imagePromptResponse->text; - return new SocialMediaPostResponseDto($postText, $imagePrompt); + return new SocialMediaPostResponseDto($aiChat->id, $postText, $imagePrompt, now()); } } diff --git a/backend/database/migrations/2026_04_28_055031_create_chats_table.php b/backend/database/migrations/2026_04_28_055031_create_chats_table.php new file mode 100644 index 0000000..134b217 --- /dev/null +++ b/backend/database/migrations/2026_04_28_055031_create_chats_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignIdFor(User::class, 'user_id')->constrained()->cascadeOnDelete(); + $table->string('title')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('chats'); + } +}; diff --git a/backend/database/migrations/2026_04_28_055044_create_chat_messages_table.php b/backend/database/migrations/2026_04_28_055044_create_chat_messages_table.php new file mode 100644 index 0000000..90ed1ea --- /dev/null +++ b/backend/database/migrations/2026_04_28_055044_create_chat_messages_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignIdFor(Chat::class, 'chat_id')->constrained()->cascadeOnDelete(); + $table->string('role'); + $table->text('content'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('chat_messages'); + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php index 1a3929f..f78dced 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -1,6 +1,7 @@ user(); })->middleware('auth:sanctum'); -Route::post('/social-media/generate', [SocialMediaPostController::class, 'generate']); +Route::apiResource('chats', ChatController::class); +Route::apiResource('chats.messages', ChatMessageController::class); diff --git a/frontend/src/app/chat/chat-service.spec.ts b/frontend/src/app/chat/chat-service.spec.ts new file mode 100644 index 0000000..830afa2 --- /dev/null +++ b/frontend/src/app/chat/chat-service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { ChatService } from './chat-service'; + +describe('ChatService', () => { + let service: ChatService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(ChatService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/chat/chat-service.ts b/frontend/src/app/chat/chat-service.ts new file mode 100644 index 0000000..396a443 --- /dev/null +++ b/frontend/src/app/chat/chat-service.ts @@ -0,0 +1,22 @@ +import { inject, Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { API_URL } from '../core/tokens/api-urls'; +import { MessageResponse, NewChatResponse } from './chat.types'; + +@Injectable({ + providedIn: 'root', +}) +export class ChatService { + private http = inject(HttpClient); + private apiUrl = inject(API_URL); + + public newChat() { + return this.http.post(`${this.apiUrl}/chats`, {}); + } + + public sendMessage(id: string, message: string) { + return this.http.post(`${this.apiUrl}/chats/${id}/messages`, { + prompt: message, + }); + } +} diff --git a/frontend/src/app/chat/chat.store.ts b/frontend/src/app/chat/chat.store.ts index 1860b49..d9129fb 100644 --- a/frontend/src/app/chat/chat.store.ts +++ b/frontend/src/app/chat/chat.store.ts @@ -1,41 +1,62 @@ import { signalStore, withState, withMethods, patchState } from '@ngrx/signals'; - -export type Message = { - id: string; - role: 'user' | 'ai'; - content: string; - timestamp: Date; -}; - -type ChatState = { - messages: Message[]; - isLoading: boolean; -}; +import { inject } from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; +import { ChatService } from './chat-service'; +import { ChatState, Message } from './chat.types'; +import { lastValueFrom } from 'rxjs'; const initialState: ChatState = { messages: [ { id: 'welcome', role: 'ai', - content: "What\'s you want to post today ?", - timestamp: new Date() - } + content: "What's you want to post today?", + timestamp: new Date(), + }, ], isLoading: false, + id: null, }; -import { inject } from '@angular/core'; -import { HttpClient, HttpErrorResponse } from '@angular/common/http'; -import { lastValueFrom } from 'rxjs'; - export const ChatStore = signalStore( { providedIn: 'root' }, withState(initialState), withMethods((store) => { - const http = inject(HttpClient); + const chatService = inject(ChatService); + + const newChat = async () => { + try { + const response = await lastValueFrom(chatService.newChat()); + patchState(store, { id: response.data.id }); + return response.data.id; + } catch (error) { + console.error('Failed to create a new chat:', error); + throw error; + } + }; return { + newChat, + sendMessage: async (content: string) => { + if (!store.id()) { + try { + await newChat(); + } catch (error) { + // If chat creation fails, append an error message and stop execution + const errorMessage: Message = { + id: Date.now().toString(), + role: 'ai', + content: 'Failed to initialize a new chat session. Please try again.', + timestamp: new Date(), + }; + patchState(store, (state) => ({ + messages: [...state.messages, errorMessage], + })); + return; + } + } + // Add user message const userMessage: Message = { id: Date.now().toString(), @@ -46,31 +67,30 @@ export const ChatStore = signalStore( patchState(store, (state) => ({ messages: [...state.messages, userMessage], - isLoading: true + isLoading: true, })); - try { const response = await lastValueFrom( - http.post<{post: string, imagePrompt?: string}>('http://localhost:8000/api/social-media/generate', { prompt: content }) + chatService.sendMessage(store.id(), content), ); const aiMessage: Message = { id: (Date.now() + 1).toString(), role: 'ai', - content: response.post, - timestamp: new Date(), + content: response.data.attributes.post, + timestamp: new Date(response.data.attributes.createdAt), }; patchState(store, (state) => ({ messages: [...state.messages, aiMessage], - isLoading: false + isLoading: false, })); } catch (error: any) { let errorText = 'Sorry, I encountered an error while communicating with the server.'; if (error instanceof HttpErrorResponse && error.status === 422) { errorText = error.error?.message || 'Validation error.'; - + // Extract the first error message from the 'errors' object if available if (error.error?.errors) { const firstErrorKey = Object.keys(error.error.errors)[0]; @@ -89,10 +109,10 @@ export const ChatStore = signalStore( patchState(store, (state) => ({ messages: [...state.messages, errorMessage], - isLoading: false + isLoading: false, })); } - } + }, }; - }) + }), ); diff --git a/frontend/src/app/chat/chat.types.ts b/frontend/src/app/chat/chat.types.ts new file mode 100644 index 0000000..938959e --- /dev/null +++ b/frontend/src/app/chat/chat.types.ts @@ -0,0 +1,30 @@ +import { JsonApiDocument, JsonApiResource } from '../core/types/api'; + +export interface NewChatAttributes { + id: string; + title: string | null; + createdAt: string; + updatedAt: string; +} + +export type NewChatResponse = JsonApiDocument; + +export interface MessageAttributes { + post: string; + image: string; + createdAt: string; +} +export type MessageResponse = JsonApiDocument; + +export type Message = { + id: string; + role: 'user' | 'ai'; + content: string; + timestamp: Date; +}; + +export type ChatState = { + messages: Message[]; + isLoading: boolean; + id: string | null; +}; diff --git a/frontend/src/app/core/layout/header/header.html b/frontend/src/app/core/layout/header/header.html index 1ec8b7c..383b77f 100644 --- a/frontend/src/app/core/layout/header/header.html +++ b/frontend/src/app/core/layout/header/header.html @@ -20,18 +20,22 @@ Login + class="inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-semibold rounded-lg text-white bg-linear-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200 transform hover:-translate-y-0.5"> Register } @else{ -
+
+ {{ authStore.user()?.name }}
diff --git a/frontend/src/app/core/layout/header/header.ts b/frontend/src/app/core/layout/header/header.ts index eb4a806..557d802 100644 --- a/frontend/src/app/core/layout/header/header.ts +++ b/frontend/src/app/core/layout/header/header.ts @@ -1,6 +1,7 @@ -import { Component, inject} from '@angular/core'; +import { Component, inject } from '@angular/core'; import { RouterLink } from '@angular/router'; -import {AuthStore} from '../../../auth/auth.store'; +import { AuthStore } from '../../../auth/auth.store'; +import { ChatStore } from '../../../chat/chat.store'; @Component({ selector: 'app-header', @@ -10,5 +11,5 @@ import {AuthStore} from '../../../auth/auth.store'; }) export class Header { protected readonly authStore = inject(AuthStore); - + protected readonly chatStore = inject(ChatStore); } diff --git a/frontend/src/app/core/types/api.ts b/frontend/src/app/core/types/api.ts new file mode 100644 index 0000000..7e12782 --- /dev/null +++ b/frontend/src/app/core/types/api.ts @@ -0,0 +1,9 @@ +export interface JsonApiResource { + id: string; + type: string; + attributes: T; +} + +export interface JsonApiDocument { + data: JsonApiResource; +}