import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals'; import { computed, inject } from '@angular/core'; import { HttpErrorResponse } from '@angular/common/http'; import { ChatService } from './chat-service'; import { MessageState, Message, MessageResponse, ChatResponse, ChatState, Chat, } from './chat.types'; import { debounceTime, distinctUntilChanged, lastValueFrom, of, pipe, switchMap } from 'rxjs'; import { Router } from '@angular/router'; import { JsonApiResource } from '../core/types/api'; import { environment } from '../../environments/environment.development'; import { rxMethod } from '@ngrx/signals/rxjs-interop'; const initialMessageState: MessageState = { messages: [ { id: 'null', type: 'messages', attributes: { role: 'assistant', content: "What's you want to post today?", attachments: [], createdAt: new Date(), }, }, ], isLoading: true, isAgentThinking: false, isLoadingPaginated: false, currentPage: 1, lastPage: 1, id: null, }; const initalChatState: ChatState = { _chats: [], _serverSearchResults: [], _filteredChats: [], isLoading: true, searchQuery: null, }; export const MessageStore = signalStore( { providedIn: 'root' }, withState(initialMessageState), withMethods((store) => { const chatService = inject(ChatService); const router = inject(Router); const newChat = async () => { patchState(store, { isLoading: true }); try { const response = await lastValueFrom(chatService.newChat()); const chatId = response.data.id; patchState(store, { id: chatId, isLoading: false }); await router.navigate(['/', chatId], { replaceUrl: true }); return chatId; } catch (error) { patchState(store, { isLoading: false }); console.error('Failed to create a new chat:', error); 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, setChatId: (chatId: string) => { patchState(store, { id: chatId }); }, sendMessage: async (content: string) => { if (!store.id()) { try { await newChat(); } catch (error) { // If chat creation fails, append an error message and stop execution setErrorMessage( 'Failed to initialize a new chat session. Please try again.', 'assistant', ); return; } } // Add user message const userMessage: JsonApiResource = { id: Date.now().toString(), type: 'messages', attributes: { role: 'user', content: content, attachments: [], createdAt: new Date(), }, }; patchState(store, (state) => ({ messages: [...state.messages, userMessage], isAgentThinking: true, })); try { const aiMessage: MessageResponse = await lastValueFrom( chatService.sendMessage(store.id(), content), ); patchState(store, (state) => ({ messages: [...state.messages, aiMessage.data], isAgentThinking: 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]; if (firstErrorKey && error.error.errors[firstErrorKey].length > 0) { errorText = error.error.errors[firstErrorKey][0]; } } } setErrorMessage(errorText, 'assistant'); patchState(store, () => ({ isAgentThinking: false, })); } }, fetchChatHistory: async (chatId: string | null = null) => { if (!store.id() && !chatId) { setErrorMessage('Please create a new chat session first.', 'assistant'); return; } if (chatId) { patchState(store, { id: chatId }); } patchState(store, { isLoading: true }); try { const chatHistory = await lastValueFrom(chatService.getMessages(store.id())); if (chatHistory.data.length !== 0) { patchState(store, { messages: chatHistory.data, currentPage: chatHistory.meta.current_page, lastPage: chatHistory.meta.last_page, }); } else { patchState(store, { messages: initialMessageState.messages }); } patchState(store, { isLoading: false }); if (chatId) { await router.navigate(['/', chatId], { replaceUrl: true }); } } 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 }); } }, loadOlderMessages: async () => { if ( store.isLoadingPaginated() || store.isLoading() || store.currentPage() >= store.lastPage() || !store.id() ) { return; } patchState(store, { isLoadingPaginated: true }); const nextPage = store.currentPage() + 1; try { const chatHistory = await lastValueFrom( chatService.getMessages(store.id(), nextPage), ); patchState(store, (state) => ({ messages: [...chatHistory.data, ...state.messages], currentPage: chatHistory.meta.current_page, lastPage: chatHistory.meta.last_page, isLoadingPaginated: false, })); } catch (err) { if (!environment.production) console.error(err); patchState(store, { isLoadingPaginated: false }); } }, }; }), ); export const ChatStore = signalStore( { providedIn: 'root' }, withState(initalChatState), withComputed((store) => ({ displayedChats: computed(() => { if (!store.searchQuery()) return store._chats(); return store._filteredChats(); }), })), withMethods((store) => { const chatService = inject(ChatService); return { fetchChats: async () => { patchState(store, { isLoading: true }); try { const chats = await lastValueFrom(chatService.getChats()); patchState(store, { isLoading: false, _chats: chats.data, }); } catch (error: any) { patchState(store, { isLoading: false }); } }, // This method first searches local chats which are loaded on init. // If not hit, then it calls backend for results as chats come paginated. performSearch: rxMethod( pipe( debounceTime(300), distinctUntilChanged(), switchMap(async (query) => { patchState(store, { isLoading: true }); if (!query) { patchState(store, { searchQuery: null, _filteredChats: [], isLoading: false }); return of(null); } patchState(store, { searchQuery: query }); const localResults = store._chats().filter((chat) => { return chat.attributes.title?.toLowerCase().includes(query.toLowerCase()); }); if (localResults.length > 0) { patchState(store, { _filteredChats: localResults, }); } else { const serverResults = await lastValueFrom(chatService.searchChat(query)); patchState(store, { _serverSearchResults: serverResults.data }); } patchState(store, { isLoading: false }); return of(null); }), ), ), }; }), );