refactor(core): replace SocialMediaService with GeneratePostService, add message fetching and JSON:API resources

- Removed `SocialMediaService` and migrated core post generation logic to `GeneratePostService`.
- Added `GetAllChatMessagesAction` for fetching chat history.
- Introduced `MessageDto`, `MessageResource`, and `MessageCollection` for consistent backend API responses.
- Updated frontend state and services to support JSON:API-compliant chat messages and history retrieval.
- Improved typings and casting for chat message data.
This commit is contained in:
kushal-saha 2026-04-30 09:00:14 +00:00
parent cde80dbf08
commit 20a56d4adc
17 changed files with 309 additions and 121 deletions

View File

@ -12,6 +12,7 @@
<map> <map>
<entry key="createEloquentScope:namespace" value="Models\Scopes" /> <entry key="createEloquentScope:namespace" value="Models\Scopes" />
<entry key="createFormRequestDto:namespace" value="Data" /> <entry key="createFormRequestDto:namespace" value="Data" />
<entry key="createJsonResource:classSuffix" value="Resource" />
<entry key="createModel:namespace" value="Models" /> <entry key="createModel:namespace" value="Models" />
</map> </map>
</option> </option>

View File

@ -0,0 +1,18 @@
<?php
namespace App\Actions\Chats;
use App\Data\Chats\MessageDto;
use App\Models\Chat;
use Illuminate\Pagination\AbstractPaginator;
use Illuminate\Pagination\LengthAwarePaginator;
class GetAllChatMessagesAction
{
public function messages(Chat $chat): AbstractPaginator|LengthAwarePaginator
{
$messages = $chat->messages()->oldest()->paginate();
return $messages->through(fn ($m) => MessageDto::fromModel($m));
}
}

View File

@ -1,18 +0,0 @@
<?php
namespace App\Actions\Chats;
use App\Enums\Chats\ChatRoles;
use App\Models\Chat;
use Illuminate\Database\Eloquent\Model;
final readonly class StoreChatMessageAction
{
public function store(Chat $chat, ChatRoles $role, string $message): Model
{
return $chat->messages()->create([
'role' => $role->value,
'content' => $message,
]);
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace App\Data\Chats;
use App\Models\ChatMessage;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Model;
use Laravel\Ai\Messages\AssistantMessage;
use Laravel\Ai\Responses\AgentResponse;
use Laravel\Ai\Responses\TextResponse;
final readonly class MessageDto
{
public function __construct(
public string $id,
public string $conversationId,
public string $role,
public string $content,
public string $agent,
public array $attachments,
public array $toolCalls,
public array $toolResults,
public array $usage,
public array $meta,
public CarbonInterface $createdAt,
public CarbonInterface $updatedAt,
) {}
public static function fromModel(ChatMessage|Model $message): MessageDto
{
return new self(
id: $message->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(),
);
}
}

View File

@ -10,7 +10,7 @@
class ChatController extends Controller class ChatController extends Controller
{ {
public function index(Request $request) {} public function index(Chat $chat) {}
public function store(CreateChatRequest $request, CreateChatAction $createChatAction) public function store(CreateChatRequest $request, CreateChatAction $createChatAction)
{ {

View File

@ -2,22 +2,30 @@
namespace App\Http\Controllers\Chats; namespace App\Http\Controllers\Chats;
use App\Actions\Chats\GetAllChatMessagesAction;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Chats\GeneratePostRequest; use App\Http\Requests\Chats\GeneratePostRequest;
use App\Http\Resources\Chats\GeneratedPostResource; use App\Http\Resources\Chats\MessageResource;
use App\Models\Chat; use App\Models\Chat;
use App\Services\SocialMediaService; use App\Services\GeneratePostService;
use Illuminate\Http\Request;
use Illuminate\Routing\Attributes\Controllers\Authorize; use Illuminate\Routing\Attributes\Controllers\Authorize;
use Illuminate\Routing\Attributes\Controllers\Middleware;
#[Middleware('auth:sanctum')]
class ChatMessageController extends Controller 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( return MessageResource::collection($getAllMessages->messages($chat));
$socialMediaService }
->generatePostWithImage($request->input('prompt'), $chat)
); #[Authorize('update', 'chat')]
public function store(GeneratePostRequest $request, Chat $chat, GeneratePostService $socialMediaService)
{
return new MessageResource($socialMediaService->generate($request->input('prompt'), $chat));
} }
} }

View File

@ -0,0 +1,18 @@
<?php
namespace App\Http\Resources\Chats;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Attributes\Collects;
use Illuminate\Http\Resources\Json\ResourceCollection;
#[Collects(MessageResource::class)]
class MessageCollection extends ResourceCollection
{
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
];
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Http\Resources\Chats;
use App\Data\Chats\MessageDto;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\JsonApi\JsonApiResource;
/**
* @property MessageDto $resource
*/
class MessageResource extends JsonApiResource
{
public function type(): string
{
return 'messages';
}
public function resolveResourceIdentifier($request): string
{
return $this->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(),
];
}
}

View File

@ -8,7 +8,7 @@
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; 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')] #[Table('agent_conversation_messages')]
class ChatMessage extends Model class ChatMessage extends Model
{ {
@ -18,4 +18,12 @@ public function chat(): BelongsTo
{ {
return $this->belongsTo(Chat::class, 'conversation_id', 'id'); return $this->belongsTo(Chat::class, 'conversation_id', 'id');
} }
protected $casts = [
'attachments' => 'array',
'meta' => 'array',
'usage' => 'array',
'tool_calls' => 'array',
'tool_results' => 'array',
];
} }

View File

@ -0,0 +1,28 @@
<?php
namespace App\Services;
use App\Ai\Agents\ContentWriterAgent;
use App\Data\Chats\MessageDto;
use App\Models\Chat;
class GeneratePostService
{
public function __construct(
private ContentWriterAgent $contentWriterAgent,
) {}
/**
* 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 generate(string $prompt, Chat $chat): MessageDto
{
$socialMediaResponse = $this->contentWriterAgent
->continue($chat->id, $chat->user)
->prompt($prompt);
return MessageDto::fromAgentResponse($socialMediaResponse, ContentWriterAgent::class);
}
}

View File

@ -1,35 +0,0 @@
<?php
namespace App\Services;
use App\Actions\Chats\StoreChatMessageAction;
use App\Ai\Agents\ContentWriterAgent;
use App\Ai\Agents\CreativeDirectorAgent;
use App\Data\Chats\SocialMediaPostResponseDto;
use App\Models\Chat;
readonly class SocialMediaService
{
public function __construct(
private ContentWriterAgent $contentWriterAgent,
private CreativeDirectorAgent $creativeDirectorAgent,
private StoreChatMessageAction $chatMessage,
) {}
/**
* 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
{
$socialMediaResponse = $this->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());
}
}

View File

@ -1,7 +1,7 @@
import { inject, Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { API_URL } from '../core/tokens/api-urls'; import { API_URL } from '../core/tokens/api-urls';
import { MessageResponse, NewChatResponse } from './chat.types'; import { MessageCollection, MessageResponse, NewChatResponse } from './chat.types';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@ -19,4 +19,8 @@ export class ChatService {
prompt: message, prompt: message,
}); });
} }
public getMessages(id: string) {
return this.http.get<MessageCollection>(`${this.apiUrl}/chats/${id}/messages`);
}
} }

View File

@ -11,16 +11,16 @@
@for (msg of chatStore.messages(); track msg.id) { @for (msg of chatStore.messages(); track msg.id) {
<div <div
class="flex flex-col max-w-[80%] animate-[messageAppear_0.5s_cubic-bezier(0.16,1,0.3,1)]" class="flex flex-col max-w-[80%] animate-[messageAppear_0.5s_cubic-bezier(0.16,1,0.3,1)]"
[ngClass]="msg.role === 'user' ? 'self-end items-end' : 'self-start items-start'" [ngClass]="msg.attributes.role === 'user' ? 'self-end items-end' : 'self-start items-start'"
> >
<div <div
class="px-5 py-4 rounded-2xl text-base leading-relaxed shadow-[0_4px_15px_rgba(0,0,0,0.1)] relative whitespace-pre-wrap" class="px-5 py-4 rounded-2xl text-base leading-relaxed shadow-[0_4px_15px_rgba(0,0,0,0.1)] relative whitespace-pre-wrap"
[ngClass]="msg.role === 'user' ? 'bg-gradient-to-br from-indigo-500 to-purple-600 text-white rounded-br-sm' : 'bg-white/10 border border-white/5 text-slate-200 rounded-bl-sm backdrop-blur-md'" [ngClass]="msg.attributes.role === 'user' ? 'bg-gradient-to-br from-indigo-500 to-purple-600 text-white rounded-br-sm' : 'bg-white/10 border border-white/5 text-slate-200 rounded-bl-sm backdrop-blur-md'"
> >
{{ msg.content }} {{ msg.attributes.content }}
</div> </div>
<div class="text-xs text-slate-500 mt-2 px-1"> <div class="text-xs text-slate-500 mt-2 px-1">
{{ msg.timestamp | date:'shortTime' }} {{ msg.attributes.createdAt | date:'shortTime' }}
</div> </div>
</div> </div>
} }

View File

@ -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 { inject } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http'; import { HttpErrorResponse } from '@angular/common/http';
import { ChatService } from './chat-service'; import { ChatService } from './chat-service';
import { ChatState, Message } from './chat.types'; import { ChatState, Message, MessageResponse } from './chat.types';
import { lastValueFrom } from 'rxjs'; import { lastValueFrom } from 'rxjs';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { JsonApiResource } from '../core/types/api';
const initialState: ChatState = { const initialState: ChatState = {
messages: [ messages: [
{ {
id: 'welcome', id: 'null',
role: 'ai', type: 'messages',
attributes: {
role: 'assistant',
content: "What's you want to post today?", content: "What's you want to post today?",
timestamp: new Date(), attachments: [],
createdAt: new Date(),
},
}, },
], ],
isLoading: false, isLoading: false,
@ -39,6 +44,21 @@ export const ChatStore = signalStore(
throw error; throw error;
} }
}; };
const setErrorMessage = (message: string, role: 'assistant' | 'user' = 'user') => {
const errorMessage: JsonApiResource<Message> = {
id: Date.now().toString(),
type: 'messages',
attributes: {
role: role,
content: message,
attachments: [],
createdAt: new Date(),
},
};
patchState(store, (state) => ({
messages: [...state.messages, errorMessage],
}));
};
return { return {
newChat, newChat,
@ -51,25 +71,24 @@ export const ChatStore = signalStore(
await newChat(); await newChat();
} catch (error) { } catch (error) {
// If chat creation fails, append an error message and stop execution // If chat creation fails, append an error message and stop execution
const errorMessage: Message = { setErrorMessage(
id: Date.now().toString(), 'Failed to initialize a new chat session. Please try again.',
role: 'ai', 'assistant',
content: 'Failed to initialize a new chat session. Please try again.', );
timestamp: new Date(),
};
patchState(store, (state) => ({
messages: [...state.messages, errorMessage],
}));
return; return;
} }
} }
// Add user message // Add user message
const userMessage: Message = { const userMessage: JsonApiResource<Message> = {
id: Date.now().toString(), id: Date.now().toString(),
type: 'messages',
attributes: {
role: 'user', role: 'user',
content, content: content,
timestamp: new Date(), attachments: [],
createdAt: new Date(),
},
}; };
patchState(store, (state) => ({ patchState(store, (state) => ({
@ -77,19 +96,12 @@ export const ChatStore = signalStore(
isLoading: true, isLoading: true,
})); }));
try { try {
const response = await lastValueFrom( const aiMessage: MessageResponse = await lastValueFrom(
chatService.sendMessage(<string>store.id(), content), chatService.sendMessage(<string>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) => ({ patchState(store, (state) => ({
messages: [...state.messages, aiMessage], messages: [...state.messages, aiMessage.data],
isLoading: false, isLoading: false,
})); }));
} catch (error: any) { } catch (error: any) {
@ -106,20 +118,40 @@ export const ChatStore = signalStore(
} }
} }
} }
setErrorMessage(errorText, 'assistant');
const errorMessage: Message = { patchState(store, () => ({
id: (Date.now() + 1).toString(),
role: 'ai',
content: errorText,
timestamp: new Date(),
};
patchState(store, (state) => ({
messages: [...state.messages, errorMessage],
isLoading: false, 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(<string>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 });
}
},
}; };
}), }),
); );

View File

@ -38,6 +38,7 @@ export class Chat {
if (urlId) { if (urlId) {
// If an ID exists in the URL, populate it in the store // If an ID exists in the URL, populate it in the store
this.chatStore.setChatId(urlId); this.chatStore.setChatId(urlId);
this.chatStore.fetchChatHistory();
} }
}); });
} }

View File

@ -1,4 +1,4 @@
import { JsonApiDocument, JsonApiResource } from '../core/types/api'; import { JsonApiCollection, JsonApiDocument, JsonApiResource } from '../core/types/api';
export interface NewChatAttributes { export interface NewChatAttributes {
id: string; id: string;
@ -9,22 +9,18 @@ export interface NewChatAttributes {
export type NewChatResponse = JsonApiDocument<NewChatAttributes>; export type NewChatResponse = JsonApiDocument<NewChatAttributes>;
export interface MessageAttributes { export interface Message {
post: string; role: 'assistant' | 'user';
image: string;
createdAt: string;
}
export type MessageResponse = JsonApiDocument<MessageAttributes>;
export type Message = {
id: string;
role: 'user' | 'ai';
content: string; content: string;
timestamp: Date; attachments: string[] | null;
}; createdAt: Date;
}
export type MessageResponse = JsonApiDocument<Message>;
export type MessageCollection = JsonApiCollection<Message>;
export type ChatState = { export type ChatState = {
messages: Message[]; messages: JsonApiResource<Message>[];
isLoading: boolean; isLoading: boolean;
id: string | null; id: string | null;
}; };

View File

@ -7,3 +7,30 @@ export interface JsonApiResource<T> {
export interface JsonApiDocument<T> { export interface JsonApiDocument<T> {
data: JsonApiResource<T>; data: JsonApiResource<T>;
} }
export interface MetaLinks {
url: string | null;
label: string;
page: number | null;
active: boolean;
}
export interface JsonApiCollection<T> {
data: JsonApiResource<T>[];
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;
};
}