Adds mock conversation api layer, adds reducers to handle various asyc

state and wires it with conversation UI
This commit is contained in:
ajaythapliyal
2023-02-20 09:04:24 +05:30
parent 63859a814b
commit a036a6b979
7 changed files with 122 additions and 34 deletions

View File

@@ -1,15 +1,32 @@
import { useEffect, useRef } from 'react';
import { useSelector } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import Hero from '../Hero';
import { AppDispatch } from '../store';
import ConversationBubble from './ConversationBubble';
import ConversationInput from './ConversationInput';
import { selectConversation } from './conversationSlice';
import {
addMessage,
fetchAnswer,
selectConversation,
selectStatus,
} from './conversationSlice';
import Send from './../assets/send.svg';
import Spinner from './../assets/spinner.svg';
export default function Conversation() {
const messages = useSelector(selectConversation);
const status = useSelector(selectStatus);
const dispatch = useDispatch<AppDispatch>();
const endMessageRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLDivElement>(null);
useEffect(() => endMessageRef?.current?.scrollIntoView());
useEffect(() =>
endMessageRef?.current?.scrollIntoView({ behavior: 'smooth' }),
);
const handleQuestion = (question: string) => {
dispatch(addMessage({ text: question, type: 'QUESTION' }));
dispatch(fetchAnswer({ question }));
};
return (
<div className="flex justify-center p-6">
@@ -27,7 +44,30 @@ export default function Conversation() {
})}
{messages.length === 0 && <Hero className="mt-24"></Hero>}
</div>
<ConversationInput className="fixed bottom-2 w-10/12 md:w-[50%]"></ConversationInput>
<div className="fixed bottom-2 flex w-10/12 md:w-[50%]">
<div
ref={inputRef}
contentEditable
className={`min-h-5 border-000000 overflow-x-hidden; max-h-24 w-full overflow-y-auto rounded-xl border bg-white p-2 pr-9 opacity-100 focus:border-2 focus:outline-none`}
></div>
{status === 'loading' ? (
<img
src={Spinner}
className="relative right-9 animate-spin cursor-pointer"
></img>
) : (
<img
onClick={() => {
if (inputRef.current?.textContent) {
handleQuestion(inputRef.current.textContent);
inputRef.current.textContent = '';
}
}}
src={Send}
className="relative right-9 cursor-pointer"
></img>
)}
</div>
</div>
);
}

View File

@@ -1,21 +0,0 @@
import Send from './../assets/send.svg';
export default function ConversationInput({
className,
}: {
className?: string;
}) {
return (
<div className={`${className} flex`}>
<div
contentEditable
className={`min-h-5 border-000000 overflow-x-hidden; max-h-24 w-full overflow-y-auto rounded-xl border bg-white p-2 pr-9 opacity-100 focus:border-2 focus:outline-none`}
></div>
<img
onClick={() => console.log('here')}
src={Send}
className="relative right-9"
></img>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { Answer } from './conversationModels';
export function fetchAnswerApi(
question: string,
apiKey: string,
): Promise<Answer> {
// a mock answer generator, this is going to be replaced with real http call
return new Promise((resolve) => {
setTimeout(() => {
let result = '';
const characters =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const charactersLength = characters.length;
let counter = 0;
while (counter < 5) {
result += characters.charAt(
Math.floor(Math.random() * charactersLength),
);
counter += 1;
}
resolve({ answer: result, query: question, result });
}, 3000);
});
}

View File

@@ -1,4 +1,5 @@
export type MESSAGE_TYPE = 'QUESTION' | 'ANSWER';
export type Status = 'idle' | 'loading' | 'failed';
export interface Message {
text: string;
@@ -7,4 +8,11 @@ export interface Message {
export interface ConversationState {
conversation: Message[];
status: Status;
}
export interface Answer {
answer: string;
query: string;
result: string;
}

View File

@@ -1,6 +1,7 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
import store from '../store';
import { ConversationState, Message } from './conversationModels';
import { fetchAnswerApi } from './conversationApi';
import { Answer, ConversationState, Message } from './conversationModels';
// harcoding the initial state just for demo
const initialState: ConversationState = {
@@ -24,13 +25,21 @@ const initialState: ConversationState = {
{ text: 'what is ChatGPT', type: 'QUESTION' },
{ text: 'ChatGPT is large learning model', type: 'ANSWER' },
{ text: 'what is ChatGPT', type: 'QUESTION' },
{
text: 'ChatGPT is large learning model',
type: 'ANSWER',
},
{ text: 'ChatGPT is large learning model', type: 'ANSWER' },
],
status: 'idle',
};
export const fetchAnswer = createAsyncThunk<
Answer,
{ question: string },
{ state: RootState }
>('fetchAnswer', async ({ question }, { getState }) => {
const state = getState();
const answer = await fetchAnswerApi(question, state.preference.apiKey);
return answer;
});
export const conversationSlice = createSlice({
name: 'conversation',
initialState,
@@ -39,12 +48,30 @@ export const conversationSlice = createSlice({
state.conversation.push(action.payload);
},
},
extraReducers(builder) {
builder
.addCase(fetchAnswer.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchAnswer.fulfilled, (state, action) => {
state.status = 'idle';
state.conversation.push({
text: action.payload.answer,
type: 'ANSWER',
});
})
.addCase(fetchAnswer.rejected, (state) => {
state.status = 'failed';
});
},
});
export const { addMessage } = conversationSlice.actions;
type RootState = ReturnType<typeof store.getState>;
export const selectConversation = (state: RootState) =>
state.conversation.conversation;
export const selectStatus = (state: RootState) => state.conversation.status;
export const { addMessage } = conversationSlice.actions;
export default conversationSlice.reducer;