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>
<entry key="createEloquentScope:namespace" value="Models\Scopes" />
<entry key="createFormRequestDto:namespace" value="Data" />
<entry key="createJsonResource:classSuffix" value="Resource" />
<entry key="createModel:namespace" value="Models" />
</map>
</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
{
public function index(Request $request) {}
public function index(Chat $chat) {}
public function store(CreateChatRequest $request, CreateChatAction $createChatAction)
{

View File

@ -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));
}
}

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\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',
];
}

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 { 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<MessageCollection>(`${this.apiUrl}/chats/${id}/messages`);
}
}

View File

@ -11,16 +11,16 @@
@for (msg of chatStore.messages(); track msg.id) {
<div
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
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 class="text-xs text-slate-500 mt-2 px-1">
{{ msg.timestamp | date:'shortTime' }}
{{ msg.attributes.createdAt | date:'shortTime' }}
</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 { 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<Message> = {
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<Message> = {
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(<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) => ({
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(<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 an ID exists in the URL, populate it in the store
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 {
id: string;
@ -9,22 +9,18 @@ export interface NewChatAttributes {
export type NewChatResponse = JsonApiDocument<NewChatAttributes>;
export interface MessageAttributes {
post: string;
image: string;
createdAt: string;
}
export type MessageResponse = JsonApiDocument<MessageAttributes>;
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<Message>;
export type MessageCollection = JsonApiCollection<Message>;
export type ChatState = {
messages: Message[];
messages: JsonApiResource<Message>[];
isLoading: boolean;
id: string | null;
};

View File

@ -7,3 +7,30 @@ export interface JsonApiResource<T> {
export interface JsonApiDocument<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;
};
}