diff --git a/application/api/answer/routes.py b/application/api/answer/routes.py index 7eed8434..893edd3a 100644 --- a/application/api/answer/routes.py +++ b/application/api/answer/routes.py @@ -74,7 +74,7 @@ def run_async_chain(chain, question, chat_history): def get_data_from_api_key(api_key): data = api_key_collection.find_one({"key": api_key}) - + # # Raise custom exception if the API key is not found if data is None: raise Exception("Invalid API Key, please generate new key", 401) @@ -129,10 +129,10 @@ def save_conversation(conversation_id, question, response, source_log_docs, llm) "content": "Summarise following conversation in no more than 3 " "words, respond ONLY with the summary, use the same " "language as the system \n\nUser: " - +question - +"\n\n" - +"AI: " - +response, + + question + + "\n\n" + + "AI: " + + response, }, { "role": "user", @@ -172,7 +172,9 @@ def get_prompt(prompt_id): return prompt -def complete_stream(question, retriever, conversation_id, user_api_key): +def complete_stream( + question, retriever, conversation_id, user_api_key, isNoneDoc=False +): try: response_full = "" @@ -186,126 +188,136 @@ def complete_stream(question, retriever, conversation_id, user_api_key): elif "source" in line: source_log_docs.append(line["source"]) + if isNoneDoc: + for doc in source_log_docs: + doc["source"] = "None" + llm = LLMCreator.create_llm( settings.LLM_NAME, api_key=settings.API_KEY, user_api_key=user_api_key - ) - if(user_api_key is None): + ) + 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: print("\033[91merr", str(e), file=sys.stderr) - data = json.dumps({"type": "error","error":"Please try again later. We apologize for any inconvenience.", - "error_exception": str(e)}) + data = json.dumps( + { + "type": "error", + "error": "Please try again later. We apologize for any inconvenience.", + "error_exception": str(e), + } + ) yield f"data: {data}\n\n" - return + return + @answer.route("/stream", methods=["POST"]) def stream(): - try: - data = request.get_json() - # get parameter from url question - question = data["question"] - if "history" not in data: - history = [] - else: - history = data["history"] - history = json.loads(history) - if "conversation_id" not in data: - conversation_id = None - else: - conversation_id = data["conversation_id"] - if "prompt_id" in data: - prompt_id = data["prompt_id"] - else: - prompt_id = "default" - if "selectedDocs" in data and data["selectedDocs"] is None: - chunks = 0 - elif "chunks" in data: - chunks = int(data["chunks"]) - else: - chunks = 2 - if "token_limit" in data: - token_limit = data["token_limit"] - else: - token_limit = settings.DEFAULT_MAX_HISTORY + try: + data = request.get_json() + question = data["question"] + if "history" not in data: + history = [] + else: + history = data["history"] + history = json.loads(history) + if "conversation_id" not in data: + conversation_id = None + else: + conversation_id = data["conversation_id"] + if "prompt_id" in data: + prompt_id = data["prompt_id"] + else: + prompt_id = "default" + if "selectedDocs" in data and data["selectedDocs"] is None: + chunks = 0 + elif "chunks" in data: + chunks = int(data["chunks"]) + else: + chunks = 2 + if "token_limit" in data: + token_limit = data["token_limit"] + else: + token_limit = settings.DEFAULT_MAX_HISTORY - # check if active_docs or api_key is set + # check if active_docs or api_key is set - if "api_key" in data: - data_key = get_data_from_api_key(data["api_key"]) - chunks = int(data_key["chunks"]) - prompt_id = data_key["prompt_id"] - source = {"active_docs": data_key["source"]} - user_api_key = data["api_key"] - elif "active_docs" in data: - source = {"active_docs": data["active_docs"]} - user_api_key = None - else: - source = {} - user_api_key = None + if "api_key" in data: + data_key = get_data_from_api_key(data["api_key"]) + chunks = int(data_key["chunks"]) + prompt_id = data_key["prompt_id"] + source = {"active_docs": data_key["source"]} + user_api_key = data["api_key"] + elif "active_docs" in data: + source = {"active_docs": data["active_docs"]} + user_api_key = None + else: + source = {} + user_api_key = None - if ( - source["active_docs"].split("/")[0] == "default" - or source["active_docs"].split("/")[0] == "local" - ): - retriever_name = "classic" - else: - retriever_name = source["active_docs"] + if source["active_docs"].split("/")[0] in ["default", "local"]: + retriever_name = "classic" + else: + retriever_name = source["active_docs"] - prompt = get_prompt(prompt_id) + prompt = get_prompt(prompt_id) - retriever = RetrieverCreator.create_retriever( - retriever_name, - question=question, - source=source, - chat_history=history, - prompt=prompt, - chunks=chunks, - token_limit=token_limit, - gpt_model=gpt_model, - user_api_key=user_api_key, - ) - - return Response( - complete_stream( + retriever = RetrieverCreator.create_retriever( + retriever_name, question=question, - retriever=retriever, - conversation_id=conversation_id, + source=source, + chat_history=history, + prompt=prompt, + chunks=chunks, + token_limit=token_limit, + gpt_model=gpt_model, user_api_key=user_api_key, - ), - mimetype="text/event-stream", - ) - - except ValueError: - message = "Malformed request body" - print("\033[91merr", str(message), file=sys.stderr) - return Response( - error_stream_generate(message), - status=400, - mimetype="text/event-stream", - ) - except Exception as e: + ) + + return Response( + complete_stream( + question=question, + retriever=retriever, + conversation_id=conversation_id, + user_api_key=user_api_key, + isNoneDoc=data.get("isNoneDoc"), + ), + mimetype="text/event-stream", + ) + + except ValueError: + message = "Malformed request body" + print("\033[91merr", str(message), file=sys.stderr) + return Response( + error_stream_generate(message), + status=400, + mimetype="text/event-stream", + ) + except Exception as e: print("\033[91merr", str(e), file=sys.stderr) message = e.args[0] status_code = 400 # # Custom exceptions with two arguments, index 1 as status code - if(len(e.args) >= 2): + if len(e.args) >= 2: status_code = e.args[1] return Response( - error_stream_generate(message), - status=status_code, - mimetype="text/event-stream", - ) + error_stream_generate(message), + status=status_code, + mimetype="text/event-stream", + ) + + def error_stream_generate(err_response): - data = json.dumps({"type": "error", "error":err_response}) - yield f"data: {data}\n\n" + data = json.dumps({"type": "error", "error": err_response}) + yield f"data: {data}\n\n" + @answer.route("/api/answer", methods=["POST"]) def api_answer(): @@ -346,10 +358,7 @@ def api_answer(): source = data user_api_key = None - if ( - source["active_docs"].split("/")[0] == "default" - or source["active_docs"].split("/")[0] == "local" - ): + if source["active_docs"].split("/")[0] in ["default", "local"]: retriever_name = "classic" else: retriever_name = source["active_docs"] @@ -375,6 +384,10 @@ def api_answer(): elif "answer" in line: response_full += line["answer"] + if data.get("isNoneDoc"): + for doc in source_log_docs: + doc["source"] = "None" + llm = LLMCreator.create_llm( settings.LLM_NAME, api_key=settings.API_KEY, user_api_key=user_api_key ) @@ -395,7 +408,6 @@ def api_answer(): @answer.route("/api/search", methods=["POST"]) def api_search(): data = request.get_json() - # get parameter from url question question = data["question"] if "chunks" in data: chunks = int(data["chunks"]) @@ -413,10 +425,7 @@ def api_search(): source = {} user_api_key = None - if ( - source["active_docs"].split("/")[0] == "default" - or source["active_docs"].split("/")[0] == "local" - ): + if source["active_docs"].split("/")[0] in ["default", "local"]: retriever_name = "classic" else: retriever_name = source["active_docs"] @@ -437,4 +446,9 @@ def api_search(): user_api_key=user_api_key, ) docs = retriever.search() + + if data.get("isNoneDoc"): + for doc in docs: + doc["source"] = "None" + return docs diff --git a/frontend/src/assets/document.svg b/frontend/src/assets/document.svg new file mode 100644 index 00000000..bf9504c8 --- /dev/null +++ b/frontend/src/assets/document.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/sources.svg b/frontend/src/assets/sources.svg new file mode 100644 index 00000000..4bb7d30b --- /dev/null +++ b/frontend/src/assets/sources.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx new file mode 100644 index 00000000..f0f4878b --- /dev/null +++ b/frontend/src/components/Sidebar.tsx @@ -0,0 +1,54 @@ +import React from 'react'; + +import Exit from '../assets/exit.svg'; + +type SidebarProps = { + isOpen: boolean; + toggleState: (arg0: boolean) => void; + children: React.ReactNode; +}; + +export default function Sidebar({ + isOpen, + toggleState, + children, +}: SidebarProps) { + const sidebarRef = React.useRef(null); + + const handleClickOutside = (event: MouseEvent) => { + if ( + sidebarRef.current && + !sidebarRef.current.contains(event.target as Node) + ) { + toggleState(false); + } + }; + + React.useEffect(() => { + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + return ( +
+
+
+ +
+
+ {children} +
+
+
+ ); +} diff --git a/frontend/src/conversation/ConversationBubble.tsx b/frontend/src/conversation/ConversationBubble.tsx index 4adfbf94..6ad877c3 100644 --- a/frontend/src/conversation/ConversationBubble.tsx +++ b/frontend/src/conversation/ConversationBubble.tsx @@ -1,17 +1,27 @@ import { forwardRef, useState } from 'react'; -import Avatar from '../components/Avatar'; -import CopyButton from '../components/CopyButton'; -import remarkGfm from 'remark-gfm'; -import { FEEDBACK, MESSAGE_TYPE } from './conversationModels'; -import classes from './ConversationBubble.module.css'; -import Alert from './../assets/alert.svg'; -import Like from './../assets/like.svg?react'; -import Dislike from './../assets/dislike.svg?react'; - import ReactMarkdown from 'react-markdown'; +import { useSelector } from 'react-redux'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { vscDarkPlus } from 'react-syntax-highlighter/dist/cjs/styles/prism'; +import remarkGfm from 'remark-gfm'; + +import Alert from '../assets/alert.svg'; import DocsGPT3 from '../assets/cute_docsgpt3.svg'; +import Dislike from '../assets/dislike.svg?react'; +import Document from '../assets/document.svg'; +import Like from '../assets/like.svg?react'; +import Link from '../assets/link.svg'; +import Sources from '../assets/sources.svg'; +import Avatar from '../components/Avatar'; +import CopyButton from '../components/CopyButton'; +import Sidebar from '../components/Sidebar'; +import { + selectChunks, + selectSelectedDocs, +} from '../preferences/preferenceSlice'; +import classes from './ConversationBubble.module.css'; +import { FEEDBACK, MESSAGE_TYPE } from './conversationModels'; + const DisableSourceFE = import.meta.env.VITE_DISABLE_SOURCE_FE || false; const ConversationBubble = forwardRef< @@ -29,15 +39,16 @@ const ConversationBubble = forwardRef< { message, type, className, feedback, handleFeedback, sources, retryBtn }, ref, ) { - const [openSource, setOpenSource] = useState(null); - + const chunks = useSelector(selectChunks); + const selectedDocs = useSelector(selectSelectedDocs); const [isLikeHovered, setIsLikeHovered] = useState(false); const [isDislikeHovered, setIsDislikeHovered] = useState(false); const [isLikeClicked, setIsLikeClicked] = useState(false); const [isDislikeClicked, setIsDislikeClicked] = useState(false); + const [activeTooltip, setActiveTooltip] = useState(null); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); let bubble; - if (type === 'QUESTION') { bubble = (
@@ -55,18 +66,147 @@ const ConversationBubble = forwardRef< ref={ref} className={`flex flex-wrap self-start ${className} group flex-col dark:text-bright-gray`} > -
- source.source === 'None') ? null : !sources && + chunks !== '0' && + selectedDocs ? ( +
+
+ + } /> - } - /> - +

Sources

+
+
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ + + + + + +
+ ))} +
+
+ ) : ( + sources && ( +
+
+ + } + /> +

Sources

+
+
+
+ {sources?.slice(0, 3)?.map((source, index) => ( +
+
setActiveTooltip(index)} + onMouseOut={() => setActiveTooltip(null)} + > +

+ {source.text} +

+
+ source.source && source.source !== 'local' + ? window.open( + source.source, + '_blank', + 'noopener, noreferrer', + ) + : null + } + > + Document +

+ {source.source && source.source !== 'local' + ? source.source + : source.title} +

+
+
+ {activeTooltip === index && ( +
setActiveTooltip(index)} + onMouseOut={() => setActiveTooltip(null)} + > +

+ {source.text} +

+
+ )} +
+ ))} + {(sources?.length ?? 0) > 3 && ( +
setIsSidebarOpen(true)} + > +

{`View ${ + sources?.length ? sources.length - 3 : 0 + } more`}

+
+ )} +
+
+
+ ) + )} +
+
+ + } + /> +

Answer

+
{message} - {DisableSourceFE || - type === 'ERROR' || - !sources || - sources.length === 0 ? null : ( - <> - -
-
Sources:
-
- {sources?.map((source, index) => ( -
- source.source !== 'local' - ? window.open( - source.source, - '_blank', - 'noopener, noreferrer', - ) - : setOpenSource(openSource === index ? null : index) - } - > -

- {index + 1}. {source.title.substring(0, 45)} -

-
- ))} -
-
- - )}
-
+
)}
- - {sources && openSource !== null && sources[openSource] && ( -
-

- Source: {sources[openSource].title} -

- -
-

- {sources[openSource].text} -

-
-
+ {sources && ( + { + setIsSidebarOpen(state); + }} + children={} + /> )}
); @@ -312,4 +405,49 @@ const ConversationBubble = forwardRef< return bubble; }); +type AllSourcesProps = { + sources: { title: string; text: string; source: string }[]; +}; + +function AllSources(sources: AllSourcesProps) { + return ( +
+
+

{`${sources.sources.length} Sources`}

+
+
+
+ {sources.sources.map((source, index) => ( +
+ +

+ {`${index + 1}. ${source.title}`} +

+ {source.source && source.source !== 'local' ? ( + Link + window.open(source.source, '_blank', 'noopener, noreferrer') + } + > + ) : null} +
+

+ {source.text} +

+
+ ))} +
+
+ ); +} + export default ConversationBubble; diff --git a/frontend/src/conversation/conversationHandlers.ts b/frontend/src/conversation/conversationHandlers.ts index 90bbc0a9..4e87678b 100644 --- a/frontend/src/conversation/conversationHandlers.ts +++ b/frontend/src/conversation/conversationHandlers.ts @@ -116,6 +116,7 @@ export function handleFetchAnswerSteaming( prompt_id: promptId, chunks: chunks, token_limit: token_limit, + isNoneDoc: selectedDocs === null, }, signal, ) @@ -184,6 +185,7 @@ export function handleSearch( history, chunks: chunks, token_limit: token_limit, + isNoneDoc: selectedDocs === null, }) .then((response) => response.json()) .then((data) => { diff --git a/frontend/src/conversation/conversationSlice.ts b/frontend/src/conversation/conversationSlice.ts index 75c457a9..23962a28 100644 --- a/frontend/src/conversation/conversationSlice.ts +++ b/frontend/src/conversation/conversationSlice.ts @@ -62,7 +62,7 @@ export const fetchAnswer = createAsyncThunk( dispatch( updateStreamingSource({ index: state.conversation.queries.length - 1, - query: { sources }, + query: { sources: sources ?? [] }, }), ); }); diff --git a/frontend/src/index.css b/frontend/src/index.css index cf90289f..6ae4b762 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -433,3 +433,11 @@ template { .bottom-safe { bottom: env(safe-area-inset-bottom, 0); } + +.ellipsis-text { + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + text-overflow: ellipsis; +} diff --git a/frontend/src/preferences/preferenceApi.ts b/frontend/src/preferences/preferenceApi.ts index 29a41645..10494862 100644 --- a/frontend/src/preferences/preferenceApi.ts +++ b/frontend/src/preferences/preferenceApi.ts @@ -76,17 +76,17 @@ export function setLocalPrompt(prompt: string): void { localStorage.setItem('DocsGPTPrompt', prompt); } -export function setLocalRecentDocs(doc: Doc): void { +export function setLocalRecentDocs(doc: Doc | null): void { localStorage.setItem('DocsGPTRecentDocs', JSON.stringify(doc)); - let namePath = doc.name; - if (doc.language === namePath) { + let namePath = doc?.name; + if (doc?.language === namePath) { namePath = '.project'; } let docPath = 'default'; - if (doc.location === 'local') { + if (doc?.location === 'local') { docPath = 'local' + '/' + doc.name + '/'; - } else if (doc.location === 'remote') { + } else if (doc?.location === 'remote') { docPath = doc.language + '/' + namePath + '/' + doc.version + '/' + doc.model + '/'; } diff --git a/frontend/src/preferences/preferenceSlice.ts b/frontend/src/preferences/preferenceSlice.ts index 370f260e..aaf7fc2d 100644 --- a/frontend/src/preferences/preferenceSlice.ts +++ b/frontend/src/preferences/preferenceSlice.ts @@ -95,8 +95,7 @@ prefListenerMiddleware.startListening({ matcher: isAnyOf(setSelectedDocs), effect: (action, listenerApi) => { setLocalRecentDocs( - (listenerApi.getState() as RootState).preference.selectedDocs ?? - ([] as unknown as Doc), + (listenerApi.getState() as RootState).preference.selectedDocs ?? null, ); }, });