diff --git a/.idea/laravel-idea.xml b/.idea/laravel-idea.xml index 5a2c748..d4ca2cf 100644 --- a/.idea/laravel-idea.xml +++ b/.idea/laravel-idea.xml @@ -12,6 +12,7 @@ + diff --git a/backend/app/Actions/Chats/GetAllChatMessagesAction.php b/backend/app/Actions/Chats/GetAllChatMessagesAction.php new file mode 100644 index 0000000..d090182 --- /dev/null +++ b/backend/app/Actions/Chats/GetAllChatMessagesAction.php @@ -0,0 +1,18 @@ +messages()->oldest()->paginate(); + + return $messages->through(fn ($m) => MessageDto::fromModel($m)); + } +} diff --git a/backend/app/Actions/Chats/StoreChatMessageAction.php b/backend/app/Actions/Chats/StoreChatMessageAction.php deleted file mode 100644 index 53e8a89..0000000 --- a/backend/app/Actions/Chats/StoreChatMessageAction.php +++ /dev/null @@ -1,18 +0,0 @@ -messages()->create([ - 'role' => $role->value, - 'content' => $message, - ]); - } -} diff --git a/backend/app/Data/Chats/MessageDto.php b/backend/app/Data/Chats/MessageDto.php new file mode 100644 index 0000000..a31b283 --- /dev/null +++ b/backend/app/Data/Chats/MessageDto.php @@ -0,0 +1,67 @@ +id, + conversationId: $message->conversation_id, + role: $message->role, + content: $message->content, + agent: $message->agent, + attachments: $message->attachments, + toolCalls: $message->tool_calls, + toolResults: $message->tool_results, + usage: $message->usage, + meta: $message->meta, + createdAt: $message->created_at, + updatedAt: $message->updated_at, + ); + } + + public static function fromAgentResponse(AgentResponse|TextResponse $response, string $agent): MessageDto + { + /** @var AssistantMessage $message */ + $message = $response->messages->first(); + + return new self( + id: $response->invocationId, + conversationId: $response->conversationId, + role: $message->role->value, + content: $message->content, + agent: $agent, + attachments: [], + toolCalls: $response->toolCalls->toArray(), + toolResults: $response->toolResults->toArray(), + usage: $response->usage->toArray(), + meta: $response->meta->toArray(), + createdAt: now(), + updatedAt: now(), + ); + } +} diff --git a/backend/app/Http/Controllers/Chats/ChatController.php b/backend/app/Http/Controllers/Chats/ChatController.php index 2a68c31..8e2cea2 100644 --- a/backend/app/Http/Controllers/Chats/ChatController.php +++ b/backend/app/Http/Controllers/Chats/ChatController.php @@ -10,7 +10,7 @@ class ChatController extends Controller { - public function index(Request $request) {} + public function index(Chat $chat) {} public function store(CreateChatRequest $request, CreateChatAction $createChatAction) { diff --git a/backend/app/Http/Controllers/Chats/ChatMessageController.php b/backend/app/Http/Controllers/Chats/ChatMessageController.php index 5a62295..99bb418 100644 --- a/backend/app/Http/Controllers/Chats/ChatMessageController.php +++ b/backend/app/Http/Controllers/Chats/ChatMessageController.php @@ -2,22 +2,30 @@ namespace App\Http\Controllers\Chats; +use App\Actions\Chats\GetAllChatMessagesAction; use App\Http\Controllers\Controller; use App\Http\Requests\Chats\GeneratePostRequest; -use App\Http\Resources\Chats\GeneratedPostResource; +use App\Http\Resources\Chats\MessageResource; use App\Models\Chat; -use App\Services\SocialMediaService; -use Illuminate\Http\Request; +use App\Services\GeneratePostService; use Illuminate\Routing\Attributes\Controllers\Authorize; +use Illuminate\Routing\Attributes\Controllers\Middleware; +#[Middleware('auth:sanctum')] class ChatMessageController extends Controller { - #[Authorize('update', 'chat')] - public function store(GeneratePostRequest $request, Chat $chat, SocialMediaService $socialMediaService) + /** + * Get Chat History of a Chat + */ + #[Authorize('view', 'chat')] + public function index(Chat $chat, GetAllChatMessagesAction $getAllMessages) { - return new GeneratedPostResource( - $socialMediaService - ->generatePostWithImage($request->input('prompt'), $chat) - ); + return MessageResource::collection($getAllMessages->messages($chat)); + } + + #[Authorize('update', 'chat')] + public function store(GeneratePostRequest $request, Chat $chat, GeneratePostService $socialMediaService) + { + return new MessageResource($socialMediaService->generate($request->input('prompt'), $chat)); } } diff --git a/backend/app/Http/Resources/Chats/MessageCollection.php b/backend/app/Http/Resources/Chats/MessageCollection.php new file mode 100644 index 0000000..19a52bc --- /dev/null +++ b/backend/app/Http/Resources/Chats/MessageCollection.php @@ -0,0 +1,18 @@ + $this->collection, + ]; + } +} diff --git a/backend/app/Http/Resources/Chats/MessageResource.php b/backend/app/Http/Resources/Chats/MessageResource.php new file mode 100644 index 0000000..099be48 --- /dev/null +++ b/backend/app/Http/Resources/Chats/MessageResource.php @@ -0,0 +1,33 @@ +resource->id; + } + + public function toAttributes(Request $request): array + { + return [ + 'role' => $this->resource->role, + 'content' => $this->resource->content, + 'attachments' => $this->resource->attachments, + 'createdAt' => $this->resource->createdAt->toIso8601String(), + ]; + } +} diff --git a/backend/app/Models/ChatMessage.php b/backend/app/Models/ChatMessage.php index 83651c7..8f9512d 100644 --- a/backend/app/Models/ChatMessage.php +++ b/backend/app/Models/ChatMessage.php @@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -#[Fillable(['conversation_id', 'agent_id', 'role', 'content', 'attachment', 'tool_calls', 'tool_results', 'usage', 'meta', 'content', 'user_id'])] +#[Fillable(['conversation_id', 'agent_id', 'role', 'content', 'attachments', 'tool_calls', 'tool_results', 'usage', 'meta', 'content', 'user_id'])] #[Table('agent_conversation_messages')] class ChatMessage extends Model { @@ -18,4 +18,12 @@ public function chat(): BelongsTo { return $this->belongsTo(Chat::class, 'conversation_id', 'id'); } + + protected $casts = [ + 'attachments' => 'array', + 'meta' => 'array', + 'usage' => 'array', + 'tool_calls' => 'array', + 'tool_results' => 'array', + ]; } diff --git a/backend/app/Services/GeneratePostService.php b/backend/app/Services/GeneratePostService.php new file mode 100644 index 0000000..c99f96b --- /dev/null +++ b/backend/app/Services/GeneratePostService.php @@ -0,0 +1,28 @@ +contentWriterAgent + ->continue($chat->id, $chat->user) + ->prompt($prompt); + + return MessageDto::fromAgentResponse($socialMediaResponse, ContentWriterAgent::class); + } +} diff --git a/backend/app/Services/SocialMediaService.php b/backend/app/Services/SocialMediaService.php deleted file mode 100644 index 7b04172..0000000 --- a/backend/app/Services/SocialMediaService.php +++ /dev/null @@ -1,35 +0,0 @@ -contentWriterAgent->forUser($chat->user)->prompt($prompt); - $postText = $socialMediaResponse->text; - - // Generate image prompt via creative director agent - $imagePromptResponse = $this->creativeDirectorAgent->prompt($postText); - $imagePrompt = $imagePromptResponse->text; - - return new SocialMediaPostResponseDto($socialMediaResponse->conversationId, $postText, $imagePrompt, now()); - } -} diff --git a/frontend/src/app/chat/chat-service.ts b/frontend/src/app/chat/chat-service.ts index 396a443..6db514a 100644 --- a/frontend/src/app/chat/chat-service.ts +++ b/frontend/src/app/chat/chat-service.ts @@ -1,7 +1,7 @@ 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'; +import { MessageCollection, MessageResponse, NewChatResponse } from './chat.types'; @Injectable({ providedIn: 'root', @@ -19,4 +19,8 @@ export class ChatService { prompt: message, }); } + + public getMessages(id: string) { + return this.http.get(`${this.apiUrl}/chats/${id}/messages`); + } } diff --git a/frontend/src/app/chat/chat.html b/frontend/src/app/chat/chat.html index 60e21b9..68a17a4 100644 --- a/frontend/src/app/chat/chat.html +++ b/frontend/src/app/chat/chat.html @@ -11,16 +11,16 @@ @for (msg of chatStore.messages(); track msg.id) {
- {{ msg.content }} + {{ msg.attributes.content }}
- {{ msg.timestamp | date:'shortTime' }} + {{ msg.attributes.createdAt | date:'shortTime' }}
} diff --git a/frontend/src/app/chat/chat.store.ts b/frontend/src/app/chat/chat.store.ts index d47020c..eca5060 100644 --- a/frontend/src/app/chat/chat.store.ts +++ b/frontend/src/app/chat/chat.store.ts @@ -1,18 +1,23 @@ -import { signalStore, withState, withMethods, patchState } from '@ngrx/signals'; +import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; import { inject } from '@angular/core'; import { HttpErrorResponse } from '@angular/common/http'; import { ChatService } from './chat-service'; -import { ChatState, Message } from './chat.types'; +import { ChatState, Message, MessageResponse } from './chat.types'; import { lastValueFrom } from 'rxjs'; import { Router } from '@angular/router'; +import { JsonApiResource } from '../core/types/api'; const initialState: ChatState = { messages: [ { - id: 'welcome', - role: 'ai', - content: "What's you want to post today?", - timestamp: new Date(), + id: 'null', + type: 'messages', + attributes: { + role: 'assistant', + content: "What's you want to post today?", + attachments: [], + createdAt: new Date(), + }, }, ], isLoading: false, @@ -39,6 +44,21 @@ export const ChatStore = signalStore( throw error; } }; + const setErrorMessage = (message: string, role: 'assistant' | 'user' = 'user') => { + const errorMessage: JsonApiResource = { + id: Date.now().toString(), + type: 'messages', + attributes: { + role: role, + content: message, + attachments: [], + createdAt: new Date(), + }, + }; + patchState(store, (state) => ({ + messages: [...state.messages, errorMessage], + })); + }; return { newChat, @@ -51,25 +71,24 @@ export const ChatStore = signalStore( 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], - })); + setErrorMessage( + 'Failed to initialize a new chat session. Please try again.', + 'assistant', + ); return; } } // Add user message - const userMessage: Message = { + const userMessage: JsonApiResource = { id: Date.now().toString(), - role: 'user', - content, - timestamp: new Date(), + type: 'messages', + attributes: { + role: 'user', + content: content, + attachments: [], + createdAt: new Date(), + }, }; patchState(store, (state) => ({ @@ -77,19 +96,12 @@ export const ChatStore = signalStore( isLoading: true, })); try { - const response = await lastValueFrom( + const aiMessage: MessageResponse = await lastValueFrom( chatService.sendMessage(store.id(), content), ); - const aiMessage: Message = { - id: (Date.now() + 1).toString(), - role: 'ai', - content: response.data.attributes.post, - timestamp: new Date(response.data.attributes.createdAt), - }; - patchState(store, (state) => ({ - messages: [...state.messages, aiMessage], + messages: [...state.messages, aiMessage.data], isLoading: false, })); } catch (error: any) { @@ -106,20 +118,40 @@ export const ChatStore = signalStore( } } } - - const errorMessage: Message = { - id: (Date.now() + 1).toString(), - role: 'ai', - content: errorText, - timestamp: new Date(), - }; - - patchState(store, (state) => ({ - messages: [...state.messages, errorMessage], + setErrorMessage(errorText, 'assistant'); + patchState(store, () => ({ isLoading: false, })); } }, + fetchChatHistory: async () => { + if (!store.id()) { + setErrorMessage('Please create a new chat session first.', 'assistant'); + return; + } + patchState(store, { isLoading: true }); + + try { + const chatHistory = await lastValueFrom(chatService.getMessages(store.id())); + patchState(store, { messages: chatHistory.data, isLoading: false }); + } catch (error: any) { + let errorText = 'Failed to load chat history. Please try again.'; + + if (error instanceof HttpErrorResponse && error.status === 422) { + errorText = error.error?.message || 'Validation error.'; + + if (error.error?.errors) { + const firstErrorKey = Object.keys(error.error.errors)[0]; + if (firstErrorKey && error.error.errors[firstErrorKey].length > 0) { + errorText = error.error.errors[firstErrorKey][0]; + } + } + } + + setErrorMessage(errorText, 'assistant'); + patchState(store, { isLoading: false }); + } + }, }; }), ); diff --git a/frontend/src/app/chat/chat.ts b/frontend/src/app/chat/chat.ts index 34fa180..534f41a 100644 --- a/frontend/src/app/chat/chat.ts +++ b/frontend/src/app/chat/chat.ts @@ -38,6 +38,7 @@ export class Chat { if (urlId) { // If an ID exists in the URL, populate it in the store this.chatStore.setChatId(urlId); + this.chatStore.fetchChatHistory(); } }); } diff --git a/frontend/src/app/chat/chat.types.ts b/frontend/src/app/chat/chat.types.ts index 938959e..7faa03f 100644 --- a/frontend/src/app/chat/chat.types.ts +++ b/frontend/src/app/chat/chat.types.ts @@ -1,4 +1,4 @@ -import { JsonApiDocument, JsonApiResource } from '../core/types/api'; +import { JsonApiCollection, JsonApiDocument, JsonApiResource } from '../core/types/api'; export interface NewChatAttributes { id: string; @@ -9,22 +9,18 @@ export interface NewChatAttributes { 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'; +export interface Message { + role: 'assistant' | 'user'; content: string; - timestamp: Date; -}; + attachments: string[] | null; + createdAt: Date; +} + +export type MessageResponse = JsonApiDocument; +export type MessageCollection = JsonApiCollection; export type ChatState = { - messages: Message[]; + messages: JsonApiResource[]; isLoading: boolean; id: string | null; }; diff --git a/frontend/src/app/core/types/api.ts b/frontend/src/app/core/types/api.ts index 7e12782..e1d4938 100644 --- a/frontend/src/app/core/types/api.ts +++ b/frontend/src/app/core/types/api.ts @@ -7,3 +7,30 @@ export interface JsonApiResource { export interface JsonApiDocument { data: JsonApiResource; } + +export interface MetaLinks { + url: string | null; + label: string; + page: number | null; + active: boolean; +} + +export interface JsonApiCollection { + data: JsonApiResource[]; + links: { + first: string; + last: string; + prev: string | null; + next: string | null; + }; + meta: { + current_page: number; + from: number; + last_page: number; + links: MetaLinks[]; + path: string; + per_page: number; + to: number; + total: number; + }; +}