From b2fffb2e232641afba4fa4747a818095f1dfa80a Mon Sep 17 00:00:00 2001 From: ManishMadan2882 Date: Fri, 19 Jul 2024 03:24:35 +0530 Subject: [PATCH 01/10] feat(share): enable route to share promptable conv --- application/api/user/routes.py | 85 ++++++++++++++++++++++++++++++---- 1 file changed, 75 insertions(+), 10 deletions(-) diff --git a/application/api/user/routes.py b/application/api/user/routes.py index e67eb992..0f9f5829 100644 --- a/application/api/user/routes.py +++ b/application/api/user/routes.py @@ -502,24 +502,88 @@ def delete_api_key(): def share_conversation(): try: data = request.get_json() - user = "local" - if(hasattr(data,"user")): - user = data["user"] + user = "local" if "user" not in data else data["user"] conversation_id = data["conversation_id"] isPromptable = request.args.get("isPromptable").lower() == "true" + conversation = conversations_collection.find_one({"_id": ObjectId(conversation_id)}) current_n_queries = len(conversation["queries"]) + + ##generate binary representation of uuid + explicit_binary = Binary.from_uuid(uuid.uuid4(), UuidRepresentation.STANDARD) + + if(isPromptable): + source = "default" if "source" not in data else data["source"] + prompt_id = "default" if "prompt_id" not in data else data["prompt_id"] + chunks = "2" if "chunks" not in data else data["chunks"] + + name = conversation["name"]+"(shared)" + pre_existing_api_document = api_key_collection.find_one({ + "prompt_id":prompt_id, + "chunks":chunks, + "source":source, + "user":user + }) + api_uuid = str(uuid.uuid4()) + if(pre_existing_api_document): + api_uuid = pre_existing_api_document["key"] + pre_existing = shared_conversations_collections.find_one({ + "conversation_id":DBRef("conversations",ObjectId(conversation_id)), + "isPromptable":isPromptable, + "first_n_queries":current_n_queries, + "user":user, + "api_key":api_uuid + }) + if(pre_existing is not None): + return jsonify({"success":True, "identifier":str(pre_existing["uuid"].as_uuid())}),200 + else: + shared_conversations_collections.insert_one({ + "uuid":explicit_binary, + "conversation_id": { + "$ref":"conversations", + "$id":ObjectId(conversation_id) + } , + "isPromptable":isPromptable, + "first_n_queries":current_n_queries, + "user":user, + "api_key":api_uuid + }) + return jsonify({"success":True,"identifier":str(explicit_binary.as_uuid())}) + else: + api_key_collection.insert_one( + { + "name": name, + "key": api_uuid, + "source": source, + "user": user, + "prompt_id": prompt_id, + "chunks": chunks, + } + ) + shared_conversations_collections.insert_one({ + "uuid":explicit_binary, + "conversation_id": { + "$ref":"conversations", + "$id":ObjectId(conversation_id) + } , + "isPromptable":isPromptable, + "first_n_queries":current_n_queries, + "user":user, + "api_key":api_uuid + }) + ## Identifier as route parameter in frontend + return jsonify({"success":True, "identifier":str(explicit_binary.as_uuid())}),201 + + ##isPromptable = False pre_existing = shared_conversations_collections.find_one({ "conversation_id":DBRef("conversations",ObjectId(conversation_id)), "isPromptable":isPromptable, - "first_n_queries":current_n_queries + "first_n_queries":current_n_queries, + "user":user }) - print("pre_existing",pre_existing) if(pre_existing is not None): - explicit_binary = pre_existing["uuid"] - return jsonify({"success":True, "identifier":str(explicit_binary.as_uuid())}),200 - else: - explicit_binary = Binary.from_uuid(uuid.uuid4(), UuidRepresentation.STANDARD) + return jsonify({"success":True, "identifier":str(pre_existing["uuid"].as_uuid())}),200 + else: shared_conversations_collections.insert_one({ "uuid":explicit_binary, "conversation_id": { @@ -532,7 +596,8 @@ def share_conversation(): }) ## Identifier as route parameter in frontend return jsonify({"success":True, "identifier":str(explicit_binary.as_uuid())}),201 - except Exception as err: + except ArithmeticError as err: + print (err) return jsonify({"success":False,"error":str(err)}),400 #route to get publicly shared conversations From 0cf86d3bbc7eb985cd47ebb7aec16b2ca5188ee2 Mon Sep 17 00:00:00 2001 From: ManishMadan2882 Date: Sat, 20 Jul 2024 03:34:34 +0530 Subject: [PATCH 02/10] share api key through response --- application/api/user/routes.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/application/api/user/routes.py b/application/api/user/routes.py index 0f9f5829..a26ddc51 100644 --- a/application/api/user/routes.py +++ b/application/api/user/routes.py @@ -596,7 +596,7 @@ def share_conversation(): }) ## Identifier as route parameter in frontend return jsonify({"success":True, "identifier":str(explicit_binary.as_uuid())}),201 - except ArithmeticError as err: + except Exception as err: print (err) return jsonify({"success":False,"error":str(err)}),400 @@ -619,13 +619,15 @@ def get_publicly_shared_conversations(identifier : str): else: return jsonify({"sucess":False,"error":"might have broken url or the conversation does not exist"}),404 date = conversation["_id"].generation_time.isoformat() - return jsonify({ + res = { "success":True, "queries":conversation_queries, "title":conversation["name"], "timestamp":date - }), 200 + } + if(shared["isPromptable"] and "api_key" in shared): + res["api_key"] = shared["api_key"] + return jsonify(res), 200 except Exception as err: print (err) - return jsonify({"success":False,"error":str(err)}),400 - \ No newline at end of file + return jsonify({"success":False,"error":str(err)}),400 \ No newline at end of file From 052669a0b06d8accea9bd57615831fdb93d04ff5 Mon Sep 17 00:00:00 2001 From: ManishMadan2882 Date: Wed, 24 Jul 2024 02:01:32 +0530 Subject: [PATCH 03/10] complete_stream: no storing for api key req, fe:update shared conv --- application/api/answer/routes.py | 15 +- frontend/src/components/SourceDropdown.tsx | 2 +- frontend/src/conversation/Conversation.tsx | 2 +- .../src/conversation/SharedConversation.tsx | 68 +++++++-- frontend/src/conversation/conversationApi.ts | 73 ++++++++++ .../conversation/sharedConversationSlice.ts | 0 .../src/modals/ShareConversationModal.tsx | 135 ++++++++++++++++-- 7 files changed, 264 insertions(+), 31 deletions(-) create mode 100644 frontend/src/conversation/sharedConversationSlice.ts diff --git a/application/api/answer/routes.py b/application/api/answer/routes.py index 9559133c..7eed8434 100644 --- a/application/api/answer/routes.py +++ b/application/api/answer/routes.py @@ -189,13 +189,14 @@ def complete_stream(question, retriever, conversation_id, user_api_key): llm = LLMCreator.create_llm( settings.LLM_NAME, api_key=settings.API_KEY, user_api_key=user_api_key ) - conversation_id = save_conversation( - conversation_id, question, response_full, source_log_docs, llm - ) - - # send data.type = "end" to indicate that the stream has ended as json - data = json.dumps({"type": "id", "id": str(conversation_id)}) - yield f"data: {data}\n\n" + if(user_api_key is None): + conversation_id = save_conversation( + conversation_id, question, response_full, source_log_docs, llm + ) + # send data.type = "end" to indicate that the stream has ended as json + data = json.dumps({"type": "id", "id": str(conversation_id)}) + yield f"data: {data}\n\n" + data = json.dumps({"type": "end"}) yield f"data: {data}\n\n" except Exception as e: diff --git a/frontend/src/components/SourceDropdown.tsx b/frontend/src/components/SourceDropdown.tsx index 6983fe76..ce130b4d 100644 --- a/frontend/src/components/SourceDropdown.tsx +++ b/frontend/src/components/SourceDropdown.tsx @@ -77,7 +77,7 @@ function SourceDropdown({ /> {isDocsListOpen && ( -
+
{options ? ( options.map((option: any, index: number) => { if (option.model === embeddingsName) { diff --git a/frontend/src/conversation/Conversation.tsx b/frontend/src/conversation/Conversation.tsx index 1b53ab2f..dddd945e 100644 --- a/frontend/src/conversation/Conversation.tsx +++ b/frontend/src/conversation/Conversation.tsx @@ -193,7 +193,7 @@ export default function Conversation() { const handlePaste = (e: React.ClipboardEvent) => { e.preventDefault(); const text = e.clipboardData.getData('text/plain'); - document.execCommand('insertText', false, text); + inputRef.current && (inputRef.current.innerText = text); }; return ( diff --git a/frontend/src/conversation/SharedConversation.tsx b/frontend/src/conversation/SharedConversation.tsx index e365c6f0..9a2d8d16 100644 --- a/frontend/src/conversation/SharedConversation.tsx +++ b/frontend/src/conversation/SharedConversation.tsx @@ -1,9 +1,12 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useParams } from 'react-router-dom'; import { useNavigate } from 'react-router-dom'; import { Query } from './conversationModels'; import { useTranslation } from 'react-i18next'; import ConversationBubble from './ConversationBubble'; +import Send from '../assets/send.svg'; +import Spinner from '../assets/spinner.svg'; + import { Fragment } from 'react'; const apiHost = import.meta.env.VITE_API_HOST || 'https://docsapi.arc53.com'; const SharedConversation = () => { @@ -13,6 +16,8 @@ const SharedConversation = () => { const [queries, setQueries] = useState([]); const [title, setTitle] = useState(''); const [date, setDate] = useState(''); + const [apiKey, setAPIKey] = useState(null); + const inputRef = useRef(null); const { t } = useTranslation(); function formatISODate(isoDateStr: string) { const date = new Date(isoDateStr); @@ -57,10 +62,15 @@ const SharedConversation = () => { setQueries(data.queries); setTitle(data.title); setDate(formatISODate(data.timestamp)); + data.api_key && setAPIKey(data.api_key); } }); }; - + const handlePaste = (e: React.ClipboardEvent) => { + e.preventDefault(); + const text = e.clipboardData.getData('text/plain'); + inputRef.current && (inputRef.current.innerText = text); + }; const prepResponseView = (query: Query, index: number) => { let responseView; if (query.response) { @@ -126,17 +136,51 @@ const SharedConversation = () => {
-
- - - {t('sharedConv.meta')} - +
+ {apiKey ? ( +
+
{ + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + //handleQuestionSubmission(); + } + }} + >
+ {status === 'loading' ? ( + + ) : ( +
+ +
+ )} +
+ ) : ( + + )}
+ + {t('sharedConv.meta')} +
); }; diff --git a/frontend/src/conversation/conversationApi.ts b/frontend/src/conversation/conversationApi.ts index e107abc8..3010a63d 100644 --- a/frontend/src/conversation/conversationApi.ts +++ b/frontend/src/conversation/conversationApi.ts @@ -233,3 +233,76 @@ export function sendFeedback( } }); } + +export function fetchSharedAnswerSteaming( //for shared conversations + question: string, + signal: AbortSignal, + apiKey: string, + history: Array = [], + onEvent: (event: MessageEvent) => void, +): Promise { + history = history.map((item) => { + return { prompt: item.prompt, response: item.response }; + }); + + return new Promise((resolve, reject) => { + const body = { + question: question, + history: JSON.stringify(history), + apiKey: apiKey, + }; + fetch(apiHost + '/stream', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + signal, + }) + .then((response) => { + if (!response.body) throw Error('No response body'); + + const reader = response.body.getReader(); + const decoder = new TextDecoder('utf-8'); + let counterrr = 0; + const processStream = ({ + done, + value, + }: ReadableStreamReadResult) => { + if (done) { + console.log(counterrr); + return; + } + + counterrr += 1; + + const chunk = decoder.decode(value); + + const lines = chunk.split('\n'); + + for (let line of lines) { + if (line.trim() == '') { + continue; + } + if (line.startsWith('data:')) { + line = line.substring(5); + } + + const messageEvent: MessageEvent = new MessageEvent('message', { + data: line, + }); + + onEvent(messageEvent); // handle each message + } + + reader.read().then(processStream).catch(reject); + }; + + reader.read().then(processStream).catch(reject); + }) + .catch((error) => { + console.error('Connection failed:', error); + reject(error); + }); + }); +} diff --git a/frontend/src/conversation/sharedConversationSlice.ts b/frontend/src/conversation/sharedConversationSlice.ts new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/modals/ShareConversationModal.tsx b/frontend/src/modals/ShareConversationModal.tsx index 37da934d..cffe477b 100644 --- a/frontend/src/modals/ShareConversationModal.tsx +++ b/frontend/src/modals/ShareConversationModal.tsx @@ -1,8 +1,22 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; +import { + selectSourceDocs, + selectSelectedDocs, + selectChunks, + selectPrompt, +} from '../preferences/preferenceSlice'; +import Dropdown from '../components/Dropdown'; +import { Doc } from '../models/misc'; import Spinner from '../assets/spinner.svg'; import Exit from '../assets/exit.svg'; const apiHost = import.meta.env.VITE_API_HOST || 'https://docsapi.arc53.com'; +const embeddingsName = + import.meta.env.VITE_EMBEDDINGS_NAME || + 'huggingface_sentence-transformers/all-mpnet-base-v2'; + +type StatusType = 'loading' | 'idle' | 'fetched' | 'failed'; export const ShareConversationModal = ({ close, @@ -11,26 +25,87 @@ export const ShareConversationModal = ({ close: () => void; conversationId: string; }) => { + const { t } = useTranslation(); + + const domain = window.location.origin; + const [identifier, setIdentifier] = useState(null); const [isCopied, setIsCopied] = useState(false); - type StatusType = 'loading' | 'idle' | 'fetched' | 'failed'; const [status, setStatus] = useState('idle'); - const { t } = useTranslation(); - const domain = window.location.origin; + const [allowPrompt, setAllowPrompt] = useState(false); + + const sourceDocs = useSelector(selectSourceDocs); + const preSelectedDoc = useSelector(selectSelectedDocs); + const selectedPrompt = useSelector(selectPrompt); + const selectedChunk = useSelector(selectChunks); + + const extractDocPaths = (docs: Doc[]) => + docs + ? docs + .filter((doc) => doc.model === embeddingsName) + .map((doc: Doc) => { + let namePath = doc.name; + if (doc.language === namePath) { + namePath = '.project'; + } + let docPath = 'default'; + if (doc.location === 'local') { + docPath = 'local' + '/' + doc.name + '/'; + } else if (doc.location === 'remote') { + docPath = + doc.language + + '/' + + namePath + + '/' + + doc.version + + '/' + + doc.model + + '/'; + } + return { + label: doc.name, + value: docPath, + }; + }) + : []; + + const [sourcePath, setSourcePath] = useState<{ + label: string; + value: string; + } | null>(preSelectedDoc ? extractDocPaths([preSelectedDoc])[0] : null); + const handleCopyKey = (url: string) => { navigator.clipboard.writeText(url); setIsCopied(true); }; + + const togglePromptPermission = () => { + setAllowPrompt(!allowPrompt); + setStatus('idle'); + setIdentifier(null); + }; + const shareCoversationPublicly: (isPromptable: boolean) => void = ( isPromptable = false, ) => { setStatus('loading'); + const payload: { + conversation_id: string; + chunks?: string; + prompt_id?: string; + source?: string; + } = { conversation_id: conversationId }; + if (isPromptable) { + payload.chunks = selectedChunk; + payload.prompt_id = selectedPrompt.id; + sourcePath && (payload.source = sourcePath.value); + } fetch(`${apiHost}/api/share?isPromptable=${isPromptable}`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ conversation_id: conversationId }), + body: JSON.stringify(payload), }) .then((res) => { console.log(res.status); @@ -44,6 +119,7 @@ export const ShareConversationModal = ({ }) .catch((err) => setStatus('failed')); }; + return (
@@ -53,13 +129,52 @@ export const ShareConversationModal = ({

{t('modals.shareConv.label')}

{t('modals.shareConv.note')}

+
+ Allow users to prompt further + +
+ {allowPrompt && ( +
+ + setSourcePath(selection) + } + options={extractDocPaths(sourceDocs ?? [])} + size="w-full" + rounded="xl" + /> +
+ )}
- {`${domain}/share/${ - identifier ?? '....' - }`} + + {`${domain}/share/${identifier ?? '....'}`} + {status === 'fetched' ? ( ) : (
@@ -163,7 +180,6 @@ const SharedConversation = () => {
diff --git a/frontend/src/conversation/sharedConversationSlice.ts b/frontend/src/conversation/sharedConversationSlice.ts index e69de29b..0e7bd4bd 100644 --- a/frontend/src/conversation/sharedConversationSlice.ts +++ b/frontend/src/conversation/sharedConversationSlice.ts @@ -0,0 +1,66 @@ +import { createSlice } from '@reduxjs/toolkit'; +import type { PayloadAction } from '@reduxjs/toolkit'; +import store from '../store'; +import { Query, Status } from '../conversation/conversationModels'; + +interface SharedConversationsType { + queries: Query[]; + apiKey?: string; + identifier: string | null; + status: Status; + date?: string; + title?: string; +} + +const initialState: SharedConversationsType = { + queries: [], + identifier: null, + status: 'idle', +}; + +export const sharedConversationSlice = createSlice({ + name: 'sharedConversation', + initialState, + reducers: { + setStatus(state, action: PayloadAction) { + state.status = action.payload; + }, + setIdentifier(state, action: PayloadAction) { + state.identifier = action.payload; + }, + setFetchedData( + state, + action: PayloadAction<{ + queries: Query[]; + title: string; + date: string; + identifier: string; + }>, + ) { + const { queries, title, identifier, date } = action.payload; + state.queries = queries; + state.title = title; + state.date = date; + state.identifier = identifier; + }, + setClientApiKey(state, action: PayloadAction) { + state.apiKey = action.payload; + }, + }, +}); + +export const { setStatus, setIdentifier, setFetchedData, setClientApiKey } = + sharedConversationSlice.actions; + +export const selectStatus = (state: RootState) => state.conversation.status; +export const selectClientAPIKey = (state: RootState) => + state.sharedConversation.apiKey; +export const selectQueries = (state: RootState) => + state.sharedConversation.queries; +export const selectTitle = (state: RootState) => state.sharedConversation.title; +export const selectDate = (state: RootState) => state.sharedConversation.date; + +type RootState = ReturnType; + +sharedConversationSlice; +export default sharedConversationSlice.reducer; diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 5b7e7ea1..3d1408b3 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -1,5 +1,6 @@ import { configureStore } from '@reduxjs/toolkit'; import { conversationSlice } from './conversation/conversationSlice'; +import { sharedConversationSlice } from './conversation/sharedConversationSlice'; import { prefListenerMiddleware, prefSlice, @@ -42,6 +43,7 @@ const store = configureStore({ reducer: { preference: prefSlice.reducer, conversation: conversationSlice.reducer, + sharedConversation: sharedConversationSlice.reducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(prefListenerMiddleware.middleware), From db7c001076111af28fcae56c90903f5c2b6be435 Mon Sep 17 00:00:00 2001 From: ManishMadan2882 Date: Fri, 26 Jul 2024 15:47:51 +0530 Subject: [PATCH 05/10] exclude the models only in root --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index ac5ff190..f5480882 100644 --- a/.gitignore +++ b/.gitignore @@ -172,5 +172,5 @@ application/vectors/ node_modules/ .vscode/settings.json -models/ +/models/ model/ From a0dd8f8e0fdb909e511535d7c35e3aa36714d099 Mon Sep 17 00:00:00 2001 From: ManishMadan2882 Date: Sat, 27 Jul 2024 02:42:33 +0530 Subject: [PATCH 06/10] 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) => From 360d790282dd17d59fdf7604168024f7ff8e455b Mon Sep 17 00:00:00 2001 From: ManishMadan2882 Date: Sun, 28 Jul 2024 00:58:55 +0530 Subject: [PATCH 07/10] save further queries to localstorage --- .../src/conversation/SharedConversation.tsx | 5 ++- .../conversation/sharedConversationSlice.ts | 41 +++++++++---------- .../src/modals/ShareConversationModal.tsx | 1 - 3 files changed, 24 insertions(+), 23 deletions(-) diff --git a/frontend/src/conversation/SharedConversation.tsx b/frontend/src/conversation/SharedConversation.tsx index 4b73579b..78d49a79 100644 --- a/frontend/src/conversation/SharedConversation.tsx +++ b/frontend/src/conversation/SharedConversation.tsx @@ -139,6 +139,7 @@ export const SharedConversation = () => { if (query.response) { responseView = ( { } else if (query.error) { responseView = ( { if (question === '') return; setEventInterrupt(false); !isRetry && dispatch(addQuery({ prompt: question })); //dispatch only new queries - dispatch(fetchSharedAnswer({ question: '' })); + dispatch(fetchSharedAnswer({ question })); }; useEffect(() => { fetchQueries(); @@ -220,6 +222,7 @@ export const SharedConversation = () => { return ( ( '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( @@ -43,6 +43,7 @@ export const fetchSharedAnswer = createAsyncThunk( if (data.type === 'end') { // set status to 'idle' dispatch(sharedConversationSlice.actions.setStatus('idle')); + dispatch(saveToLocalStorage()); } else if (data.type === 'error') { // set status to 'failed' dispatch(sharedConversationSlice.actions.setStatus('failed')); @@ -137,24 +138,6 @@ export const sharedConversationSlice = createSlice({ }, 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, @@ -188,6 +171,21 @@ export const sharedConversationSlice = createSlice({ const { index, message } = action.payload; state.queries[index].error = message; }, + saveToLocalStorage(state) { + const previousQueriesStr = localStorage.getItem(state.identifier); + previousQueriesStr + ? localStorage.setItem( + state.identifier, + JSON.stringify([ + ...JSON.parse(previousQueriesStr), + state.queries[state.queries.length - 1], + ]), + ) + : localStorage.setItem( + state.identifier, + JSON.stringify([state.queries[state.queries.length - 1]]), + ); + }, }, extraReducers(builder) { builder @@ -214,6 +212,7 @@ export const { updateQuery, updateStreamingQuery, addQuery, + saveToLocalStorage, } = sharedConversationSlice.actions; export const selectStatus = (state: RootState) => state.conversation.status; diff --git a/frontend/src/modals/ShareConversationModal.tsx b/frontend/src/modals/ShareConversationModal.tsx index 0015402e..870c032c 100644 --- a/frontend/src/modals/ShareConversationModal.tsx +++ b/frontend/src/modals/ShareConversationModal.tsx @@ -105,7 +105,6 @@ export const ShareConversationModal = ({ conversationService .shareConversation(isPromptable, payload) .then((res) => { - console.log(res.status); return res.json(); }) .then((data) => { From d63b5d71a1af4d2e8f3ec107ea6b54b27c50583b Mon Sep 17 00:00:00 2001 From: ManishMadan2882 Date: Sun, 28 Jul 2024 19:48:48 +0530 Subject: [PATCH 08/10] prevent event bubble, open menu onhover --- frontend/src/conversation/Conversation.tsx | 2 +- .../src/conversation/ConversationTile.tsx | 61 +++++++++++-------- .../src/conversation/SharedConversation.tsx | 2 +- frontend/src/modals/ConfirmationModal.tsx | 2 + .../src/modals/ShareConversationModal.tsx | 7 ++- 5 files changed, 46 insertions(+), 28 deletions(-) diff --git a/frontend/src/conversation/Conversation.tsx b/frontend/src/conversation/Conversation.tsx index 4ceddbff..d9defb9e 100644 --- a/frontend/src/conversation/Conversation.tsx +++ b/frontend/src/conversation/Conversation.tsx @@ -289,7 +289,7 @@ export default function Conversation() { className="relative right-[38px] bottom-[24px] -mr-[30px] animate-spin cursor-pointer self-end bg-transparent" > ) : ( -
+
(false); const [isShareModalOpen, setShareModalState] = useState(false); + const [isHovered, setIsHovered] = useState(false); const [deleteModalState, setDeleteModalState] = useState('INACTIVE'); const menuRef = useRef(null); @@ -79,20 +81,24 @@ export default function ConversationTile({ return (
{ + setOpen(false); + setIsHovered(true); + }} + onMouseLeave={() => { + setIsHovered(false); + }} onClick={() => { - selectConversation(conversation.id); + conversationId !== conversation.id && + selectConversation(conversation.id); }} className={`my-auto mx-4 mt-4 flex h-9 cursor-pointer items-center justify-between gap-4 rounded-3xl hover:bg-gray-100 dark:hover:bg-[#28292E] ${ - conversationId === conversation.id + conversationId === conversation.id || isOpen ? 'bg-gray-100 dark:bg-[#28292E]' : '' }`} > -
+
)}
- {conversationId === conversation.id && ( + {(conversationId === conversation.id || isHovered || isOpen) && (
{isEdit ? (
@@ -120,34 +126,41 @@ export default function ConversationTile({ alt="Edit" className="mr-2 h-4 w-4 cursor-pointer text-white hover:opacity-50" id={`img-${conversation.id}`} - onClick={(event) => { + onClick={(event: SyntheticEvent) => { event.stopPropagation(); handleSaveConversation({ - id: conversationId, + id: conversation.id, name: conversationName, }); }} /> Exit { + onClick={(event: SyntheticEvent) => { event.stopPropagation(); onClear(); }} />
) : ( - )} {isOpen && ( -
+
diff --git a/frontend/src/conversation/SharedConversation.tsx b/frontend/src/conversation/SharedConversation.tsx index 78d49a79..cbe80717 100644 --- a/frontend/src/conversation/SharedConversation.tsx +++ b/frontend/src/conversation/SharedConversation.tsx @@ -262,7 +262,7 @@ export const SharedConversation = () => { className="relative right-[38px] bottom-[24px] -mr-[30px] animate-spin cursor-pointer self-end bg-transparent filter dark:invert" > ) : ( -
+
event.stopPropagation()} className={`${ modalState === 'ACTIVE' ? 'visible' : 'hidden' } fixed top-0 left-0 z-30 h-screen w-screen bg-gray-alpha`} diff --git a/frontend/src/modals/ShareConversationModal.tsx b/frontend/src/modals/ShareConversationModal.tsx index 870c032c..e26cbd38 100644 --- a/frontend/src/modals/ShareConversationModal.tsx +++ b/frontend/src/modals/ShareConversationModal.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { SyntheticEvent, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; import { @@ -117,7 +117,10 @@ export const ShareConversationModal = ({ }; return ( -
+
event.stopPropagation()} + className="z-100 fixed top-0 left-0 flex h-screen w-screen items-center justify-center bg-gray-alpha bg-opacity-50 text-chinese-black dark:text-silver" + >
- )} - {isOpen && ( -
- - - -
+

+ {conversationName} +

)}
- )} + {(conversationId === conversation.id || isHovered || isOpen) && ( +
+ {isEdit ? ( +
+ Edit { + event.stopPropagation(); + handleSaveConversation({ + id: conversation.id, + name: conversationName, + }); + }} + /> + Exit { + event.stopPropagation(); + onClear(); + }} + /> +
+ ) : ( + + )} + {isOpen && ( +
+ + + +
+ )} +
+ )} +
)} -
+ ); } diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index ce44b713..c7651f8b 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -108,7 +108,7 @@ "label": "Create a public page to share", "note": "Source document, personal information and further conversation will remain private", "create": "Create", - "option":"Allow users to prompt further" + "option": "Allow users to prompt further" } }, "sharedConv": { diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index a68d440c..d368749f 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -108,7 +108,7 @@ "label": "Crear una página pública para compartir", "note": "El documento original, la información personal y las conversaciones posteriores permanecerán privadas", "create": "Crear", - "option":"Permitir a los usuarios realizar más consultas." + "option": "Permitir a los usuarios realizar más consultas." } }, "sharedConv": { diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index f4e2980f..350faf09 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -108,7 +108,7 @@ "label": "共有ページを作成して共有する", "note": "ソースドキュメント、個人情報、および以降の会話は非公開のままになります", "create": "作成", - "option":"ユーザーがより多くのクエリを実行できるようにします。" + "option": "ユーザーがより多くのクエリを実行できるようにします。" } }, "sharedConv": { diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index a3ff940b..db93dcfc 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -108,7 +108,7 @@ "label": "创建用于分享的公共页面", "note": "源文档、个人信息和后续对话将保持私密", "create": "创建", - "option":"允许用户进行更多查询。" + "option": "允许用户进行更多查询。" } }, "sharedConv": { diff --git a/frontend/src/modals/ConfirmationModal.tsx b/frontend/src/modals/ConfirmationModal.tsx index f1bb4549..0b39440b 100644 --- a/frontend/src/modals/ConfirmationModal.tsx +++ b/frontend/src/modals/ConfirmationModal.tsx @@ -1,4 +1,3 @@ -import { SyntheticEvent } from 'react'; import Exit from '../assets/exit.svg'; import { ActiveState } from '../models/misc'; import { useTranslation } from 'react-i18next'; @@ -22,7 +21,6 @@ function ConfirmationModal({ const { t } = useTranslation(); return (
event.stopPropagation()} className={`${ modalState === 'ACTIVE' ? 'visible' : 'hidden' } fixed top-0 left-0 z-30 h-screen w-screen bg-gray-alpha`} diff --git a/frontend/src/modals/ShareConversationModal.tsx b/frontend/src/modals/ShareConversationModal.tsx index 939a4efc..c7ef0ad6 100644 --- a/frontend/src/modals/ShareConversationModal.tsx +++ b/frontend/src/modals/ShareConversationModal.tsx @@ -1,4 +1,4 @@ -import { SyntheticEvent, useState } from 'react'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; import { @@ -117,10 +117,7 @@ export const ShareConversationModal = ({ }; return ( -
event.stopPropagation()} - className="z-100 fixed top-0 left-0 flex h-screen w-screen items-center justify-center bg-gray-alpha bg-opacity-50 text-chinese-black dark:text-silver" - > +
) : (