From a0dd8f8e0fdb909e511535d7c35e3aa36714d099 Mon Sep 17 00:00:00 2001 From: ManishMadan2882 Date: Sat, 27 Jul 2024 02:42:33 +0530 Subject: [PATCH] integrated stream for shared(prompt) conv --- frontend/src/App.tsx | 2 +- .../src/conversation/SharedConversation.tsx | 100 +++++++++- .../src/conversation/conversationHandlers.ts | 61 +++++-- .../conversation/sharedConversationSlice.ts | 172 +++++++++++++++++- 4 files changed, 310 insertions(+), 25 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 05792187..9bad8724 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,7 +10,7 @@ import { useState } from 'react'; import Setting from './settings'; import './locale/i18n'; import { Outlet } from 'react-router-dom'; -import SharedConversation from './conversation/SharedConversation'; +import { SharedConversation } from './conversation/SharedConversation'; import { useDarkTheme } from './hooks'; inject(); diff --git a/frontend/src/conversation/SharedConversation.tsx b/frontend/src/conversation/SharedConversation.tsx index bd484c4b..4b73579b 100644 --- a/frontend/src/conversation/SharedConversation.tsx +++ b/frontend/src/conversation/SharedConversation.tsx @@ -1,5 +1,5 @@ import { Query } from './conversationModels'; -import { Fragment, useEffect, useRef } from 'react'; +import { Fragment, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useParams } from 'react-router-dom'; @@ -7,9 +7,19 @@ import conversationService from '../api/services/conversationService'; import ConversationBubble from './ConversationBubble'; import Send from '../assets/send.svg'; import Spinner from '../assets/spinner.svg'; -import { selectClientAPIKey, setClientApiKey } from './sharedConversationSlice'; +import { + selectClientAPIKey, + setClientApiKey, + updateQuery, + addQuery, + fetchSharedAnswer, + selectStatus, +} from './sharedConversationSlice'; import { setIdentifier, setFetchedData } from './sharedConversationSlice'; + import { useDispatch } from 'react-redux'; +import { AppDispatch } from '../store'; + import { selectDate, selectTitle, @@ -17,19 +27,39 @@ import { } from './sharedConversationSlice'; import { useSelector } from 'react-redux'; const apiHost = import.meta.env.VITE_API_HOST || 'https://docsapi.arc53.com'; -const SharedConversation = () => { - const params = useParams(); + +export const SharedConversation = () => { const navigate = useNavigate(); - const { identifier } = params; //identifier is a uuid, not conversationId + const { identifier } = useParams(); //identifier is a uuid, not conversationId const queries = useSelector(selectQueries); const title = useSelector(selectTitle); const date = useSelector(selectDate); const apiKey = useSelector(selectClientAPIKey); + const status = useSelector(selectStatus); + const inputRef = useRef(null); const { t } = useTranslation(); - const dispatch = useDispatch(); - identifier && dispatch(setIdentifier(identifier)); + const dispatch = useDispatch(); + + const [lastQueryReturnedErr, setLastQueryReturnedErr] = useState(false); + const [eventInterrupt, setEventInterrupt] = useState(false); + const endMessageRef = useRef(null); + const handleUserInterruption = () => { + if (!eventInterrupt && status === 'loading') setEventInterrupt(true); + }; + useEffect(() => { + !eventInterrupt && scrollIntoView(); + }, [queries.length, queries[queries.length - 1]]); + + useEffect(() => { + identifier && dispatch(setIdentifier(identifier)); + const element = document.getElementById('inputbox') as HTMLInputElement; + if (element) { + element.focus(); + } + }, []); + function formatISODate(isoDateStr: string) { const date = new Date(isoDateStr); @@ -62,7 +92,21 @@ const SharedConversation = () => { const formattedDate = `Published ${month} ${day}, ${year} at ${hours}:${minutesStr} ${ampm}`; return formattedDate; } - const fetchQueris = () => { + useEffect(() => { + if (queries.length) { + queries[queries.length - 1].error && setLastQueryReturnedErr(true); + queries[queries.length - 1].response && setLastQueryReturnedErr(false); //considering a query that initially returned error can later include a response property on retry + } + }, [queries[queries.length - 1]]); + + const scrollIntoView = () => { + endMessageRef?.current?.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + }; + + const fetchQueries = () => { identifier && conversationService .getSharedConversation(identifier || '') @@ -113,8 +157,44 @@ const SharedConversation = () => { } return responseView; }; + const handleQuestionSubmission = () => { + if (inputRef.current?.textContent && status !== 'loading') { + if (lastQueryReturnedErr) { + // update last failed query with new prompt + dispatch( + updateQuery({ + index: queries.length - 1, + query: { + prompt: inputRef.current.textContent, + }, + }), + ); + handleQuestion({ + question: queries[queries.length - 1].prompt, + isRetry: true, + }); + } else { + handleQuestion({ question: inputRef.current.textContent }); + } + inputRef.current.textContent = ''; + } + }; + + const handleQuestion = ({ + question, + isRetry = false, + }: { + question: string; + isRetry?: boolean; + }) => { + question = question.trim(); + if (question === '') return; + setEventInterrupt(false); + !isRetry && dispatch(addQuery({ prompt: question })); //dispatch only new queries + dispatch(fetchSharedAnswer({ question: '' })); + }; useEffect(() => { - fetchQueris(); + fetchQueries(); }, []); return ( @@ -169,6 +249,7 @@ const SharedConversation = () => { onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); + handleQuestionSubmission(); } }} > @@ -180,6 +261,7 @@ const SharedConversation = () => { ) : (
diff --git a/frontend/src/conversation/conversationHandlers.ts b/frontend/src/conversation/conversationHandlers.ts index 05c2db0d..90bbc0a9 100644 --- a/frontend/src/conversation/conversationHandlers.ts +++ b/frontend/src/conversation/conversationHandlers.ts @@ -212,7 +212,7 @@ export function handleSendFeedback( }); } -export function fetchSharedAnswerSteaming( //for shared conversations +export function handleFetchSharedAnswerStreaming( //for shared conversations question: string, signal: AbortSignal, apiKey: string, @@ -224,19 +224,13 @@ export function fetchSharedAnswerSteaming( //for shared conversations }); return new Promise((resolve, reject) => { - const body = { + const payload = { question: question, history: JSON.stringify(history), - apiKey: apiKey, + api_key: apiKey, }; - fetch(apiHost + '/stream', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - signal, - }) + conversationService + .answerStream(payload, signal) .then((response) => { if (!response.body) throw Error('No response body'); @@ -284,3 +278,48 @@ export function fetchSharedAnswerSteaming( //for shared conversations }); }); } + +export function handleFetchSharedAnswer( + question: string, + signal: AbortSignal, + apiKey: string, +): Promise< + | { + result: any; + answer: any; + sources: any; + query: string; + } + | { + result: any; + answer: any; + sources: any; + query: string; + title: any; + } +> { + return conversationService + .answer( + { + question: question, + api_key: apiKey, + }, + signal, + ) + .then((response) => { + if (response.ok) { + return response.json(); + } else { + return Promise.reject(new Error(response.statusText)); + } + }) + .then((data) => { + const result = data.answer; + return { + answer: result, + query: question, + result, + sources: data.sources, + }; + }); +} diff --git a/frontend/src/conversation/sharedConversationSlice.ts b/frontend/src/conversation/sharedConversationSlice.ts index 0e7bd4bd..ecc45cc6 100644 --- a/frontend/src/conversation/sharedConversationSlice.ts +++ b/frontend/src/conversation/sharedConversationSlice.ts @@ -1,8 +1,14 @@ import { createSlice } from '@reduxjs/toolkit'; import type { PayloadAction } from '@reduxjs/toolkit'; import store from '../store'; -import { Query, Status } from '../conversation/conversationModels'; +import { Query, Status, Answer } from '../conversation/conversationModels'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { + handleFetchSharedAnswer, + handleFetchSharedAnswerStreaming, +} from './conversationHandlers'; +const API_STREAMING = import.meta.env.VITE_API_STREAMING === 'true'; interface SharedConversationsType { queries: Query[]; apiKey?: string; @@ -18,6 +24,85 @@ const initialState: SharedConversationsType = { status: 'idle', }; +export const fetchSharedAnswer = createAsyncThunk( + 'shared/fetchAnswer', + async ({ question }, { dispatch, getState, signal }) => { + console.log('bulaya sahab ji ?'); + const state = getState() as RootState; + if (state.preference && state.sharedConversation.apiKey) { + if (API_STREAMING) { + await handleFetchSharedAnswerStreaming( + question, + signal, + state.sharedConversation.apiKey, + state.sharedConversation.queries, + + (event) => { + const data = JSON.parse(event.data); + // check if the 'end' event has been received + if (data.type === 'end') { + // set status to 'idle' + dispatch(sharedConversationSlice.actions.setStatus('idle')); + } else if (data.type === 'error') { + // set status to 'failed' + dispatch(sharedConversationSlice.actions.setStatus('failed')); + dispatch( + sharedConversationSlice.actions.raiseError({ + index: state.conversation.queries.length - 1, + message: data.error, + }), + ); + } else { + const result = data.answer; + dispatch( + updateStreamingQuery({ + index: state.sharedConversation.queries.length - 1, + query: { response: result }, + }), + ); + } + }, + ); + } else { + const answer = await handleFetchSharedAnswer( + question, + signal, + state.sharedConversation.apiKey, + ); + if (answer) { + let sourcesPrepped = []; + sourcesPrepped = answer.sources.map((source: { title: string }) => { + if (source && source.title) { + const titleParts = source.title.split('/'); + return { + ...source, + title: titleParts[titleParts.length - 1], + }; + } + return source; + }); + + dispatch( + updateQuery({ + index: state.sharedConversation.queries.length - 1, + query: { response: answer.answer, sources: sourcesPrepped }, + }), + ); + dispatch(sharedConversationSlice.actions.setStatus('idle')); + } + } + } + return { + conversationId: null, + title: null, + answer: '', + query: question, + result: '', + sources: [], + }; + }, +); + export const sharedConversationSlice = createSlice({ name: 'sharedConversation', initialState, @@ -38,7 +123,11 @@ export const sharedConversationSlice = createSlice({ }>, ) { const { queries, title, identifier, date } = action.payload; - state.queries = queries; + const previousQueriesStr = localStorage.getItem(identifier); + const localySavedQueries: Query[] = previousQueriesStr + ? JSON.parse(previousQueriesStr) + : []; + state.queries = [...queries, ...localySavedQueries]; state.title = title; state.date = date; state.identifier = identifier; @@ -46,11 +135,86 @@ export const sharedConversationSlice = createSlice({ setClientApiKey(state, action: PayloadAction) { state.apiKey = action.payload; }, + addQuery(state, action: PayloadAction) { + state.queries.push(action.payload); + if (state.identifier) { + const previousQueriesStr = localStorage.getItem(state.identifier); + previousQueriesStr + ? localStorage.setItem( + state.identifier, + JSON.stringify([ + ...JSON.parse(previousQueriesStr), + action.payload, + ]), + ) + : localStorage.setItem( + state.identifier, + JSON.stringify([action.payload]), + ); + if (action.payload.prompt) { + fetchSharedAnswer({ question: action.payload.prompt }); + } + } + }, + updateStreamingQuery( + state, + action: PayloadAction<{ index: number; query: Partial }>, + ) { + const { index, query } = action.payload; + if (query.response != undefined) { + state.queries[index].response = + (state.queries[index].response || '') + query.response; + } else { + state.queries[index] = { + ...state.queries[index], + ...query, + }; + } + }, + updateQuery( + state, + action: PayloadAction<{ index: number; query: Partial }>, + ) { + const { index, query } = action.payload; + state.queries[index] = { + ...state.queries[index], + ...query, + }; + }, + raiseError( + state, + action: PayloadAction<{ index: number; message: string }>, + ) { + const { index, message } = action.payload; + state.queries[index].error = message; + }, + }, + extraReducers(builder) { + builder + .addCase(fetchSharedAnswer.pending, (state) => { + state.status = 'loading'; + }) + .addCase(fetchSharedAnswer.rejected, (state, action) => { + if (action.meta.aborted) { + state.status = 'idle'; + return state; + } + state.status = 'failed'; + state.queries[state.queries.length - 1].error = + 'Something went wrong. Please check your internet connection.'; + }); }, }); -export const { setStatus, setIdentifier, setFetchedData, setClientApiKey } = - sharedConversationSlice.actions; +export const { + setStatus, + setIdentifier, + setFetchedData, + setClientApiKey, + updateQuery, + updateStreamingQuery, + addQuery, +} = sharedConversationSlice.actions; export const selectStatus = (state: RootState) => state.conversation.status; export const selectClientAPIKey = (state: RootState) =>