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/ 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/application/api/user/routes.py b/application/api/user/routes.py index 62addc26..a26ddc51 100644 --- a/application/api/user/routes.py +++ b/application/api/user/routes.py @@ -6,8 +6,9 @@ from urllib.parse import urlparse import requests from pymongo import MongoClient from bson.objectid import ObjectId +from bson.binary import Binary, UuidRepresentation from werkzeug.utils import secure_filename - +from bson.dbref import DBRef from application.api.user.tasks import ingest, ingest_remote from application.core.settings import settings @@ -20,6 +21,8 @@ vectors_collection = db["vectors"] prompts_collection = db["prompts"] feedback_collection = db["feedback"] api_key_collection = db["api_keys"] +shared_conversations_collections = db["shared_conversations"] + user = Blueprint("user", __name__) current_dir = os.path.dirname( @@ -491,3 +494,140 @@ def delete_api_key(): } ) return {"status": "ok"} + + +#route to share conversation +##isPromptable should be passed through queries +@user.route("/api/share",methods=["POST"]) +def share_conversation(): + try: + data = request.get_json() + 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, + "user":user + }) + 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 + }) + ## Identifier as route parameter in frontend + return jsonify({"success":True, "identifier":str(explicit_binary.as_uuid())}),201 + except Exception as err: + print (err) + return jsonify({"success":False,"error":str(err)}),400 + +#route to get publicly shared conversations +@user.route("/api/shared_conversation/",methods=["GET"]) +def get_publicly_shared_conversations(identifier : str): + try: + query_uuid = Binary.from_uuid(uuid.UUID(identifier), UuidRepresentation.STANDARD) + shared = shared_conversations_collections.find_one({"uuid":query_uuid}) + conversation_queries=[] + if shared and 'conversation_id' in shared and isinstance(shared['conversation_id'], DBRef): + # Resolve the DBRef + conversation_ref = shared['conversation_id'] + conversation = db.dereference(conversation_ref) + if(conversation is None): + return jsonify({"sucess":False,"error":"might have broken url or the conversation does not exist"}),404 + conversation_queries = conversation['queries'][:(shared["first_n_queries"])] + for query in conversation_queries: + query.pop("sources") ## avoid exposing sources + else: + return jsonify({"sucess":False,"error":"might have broken url or the conversation does not exist"}),404 + date = conversation["_id"].generation_time.isoformat() + res = { + "success":True, + "queries":conversation_queries, + "title":conversation["name"], + "timestamp":date + } + 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 diff --git a/application/vectorstore/base.py b/application/vectorstore/base.py index ec10519f..522ef4fa 100644 --- a/application/vectorstore/base.py +++ b/application/vectorstore/base.py @@ -14,7 +14,9 @@ class EmbeddingsSingleton: @staticmethod def get_instance(embeddings_name, *args, **kwargs): if embeddings_name not in EmbeddingsSingleton._instances: - EmbeddingsSingleton._instances[embeddings_name] = EmbeddingsSingleton._create_instance(embeddings_name, *args, **kwargs) + EmbeddingsSingleton._instances[embeddings_name] = EmbeddingsSingleton._create_instance( + embeddings_name, *args, **kwargs + ) return EmbeddingsSingleton._instances[embeddings_name] @staticmethod diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 83e0a930..e89a334c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@reduxjs/toolkit": "^1.9.2", "@vercel/analytics": "^0.1.10", "i18next": "^23.11.5", + "i18next-browser-languagedetector": "^8.0.0", "prop-types": "^15.8.1", "react": "^18.2.0", "react-copy-to-clipboard": "^5.1.0", @@ -1489,7 +1490,7 @@ "version": "18.0.10", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.0.10.tgz", "integrity": "sha512-E42GW/JA4Qv15wQdqJq8DL4JhNpB3prJgjgapN3qJT9K2zO5IIAQh4VXvCEDupoqAwnz0cY4RlXeC/ajX5SFHg==", - "dev": true, + "devOptional": true, "dependencies": { "@types/react": "*" } @@ -4194,6 +4195,15 @@ "@babel/runtime": "^7.23.2" } }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.0.0.tgz", + "integrity": "sha512-zhXdJXTTCoG39QsrOCiOabnWj2jecouOqbchu3EfhtSHxIB5Uugnm9JaizenOy39h7ne3+fLikIjeW88+rgszw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/ignore": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3083f1f3..9bad8724 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ import { Routes, Route } from 'react-router-dom'; +import { useEffect } from 'react'; import Navigation from './Navigation'; import Conversation from './conversation/Conversation'; import About from './About'; @@ -8,29 +9,53 @@ import { useMediaQuery } from './hooks'; import { useState } from 'react'; import Setting from './settings'; import './locale/i18n'; - +import { Outlet } from 'react-router-dom'; +import { SharedConversation } from './conversation/SharedConversation'; +import { useDarkTheme } from './hooks'; inject(); -export default function App() { +function MainLayout() { const { isMobile } = useMediaQuery(); const [navOpen, setNavOpen] = useState(!isMobile); return ( -
+
- - } /> - } /> - } /> - } /> - +
); } + +export default function App() { + const [isDarkTheme] = useDarkTheme(); + useEffect(() => { + localStorage.setItem('selectedTheme', isDarkTheme ? 'Dark' : 'Light'); + if (isDarkTheme) { + document + .getElementById('root') + ?.classList.add('dark', 'dark:bg-raisin-black'); + } else { + document.getElementById('root')?.classList.remove('dark'); + } + }, [isDarkTheme]); + return ( + <> + + }> + } /> + } /> + } /> + + } /> + } /> + + + ); +} diff --git a/frontend/src/Navigation.tsx b/frontend/src/Navigation.tsx index ebb3f88a..bd0d460c 100644 --- a/frontend/src/Navigation.tsx +++ b/frontend/src/Navigation.tsx @@ -1,46 +1,48 @@ import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; import { NavLink, useNavigate } from 'react-router-dom'; + +import conversationService from './api/services/conversationService'; +import userService from './api/services/userService'; +import Add from './assets/add.svg'; import DocsGPT3 from './assets/cute_docsgpt3.svg'; import Discord from './assets/discord.svg'; import Expand from './assets/expand.svg'; import Github from './assets/github.svg'; -import Hamburger from './assets/hamburger.svg'; import HamburgerDark from './assets/hamburger-dark.svg'; +import Hamburger from './assets/hamburger.svg'; import Info from './assets/info.svg'; import SettingGear from './assets/settingGear.svg'; import Twitter from './assets/TwitterX.svg'; -import Add from './assets/add.svg'; import UploadIcon from './assets/upload.svg'; -import { ActiveState } from './models/misc'; -import APIKeyModal from './preferences/APIKeyModal'; -import DeleteConvModal from './modals/DeleteConvModal'; - -import { - selectApiKeyStatus, - selectSelectedDocs, - selectSelectedDocsStatus, - selectSourceDocs, - setSelectedDocs, - selectConversations, - setConversations, - selectConversationId, - selectModalStateDeleteConv, - setModalStateDeleteConv, - setSourceDocs, -} from './preferences/preferenceSlice'; +import SourceDropdown from './components/SourceDropdown'; import { setConversation, updateConversationId, } from './conversation/conversationSlice'; -import { useMediaQuery, useOutsideAlerter } from './hooks'; -import Upload from './upload/Upload'; -import { Doc, getConversations, getDocs } from './preferences/preferenceApi'; -import SelectDocsModal from './preferences/SelectDocsModal'; import ConversationTile from './conversation/ConversationTile'; -import { useDarkTheme } from './hooks'; -import SourceDropdown from './components/SourceDropdown'; -import { useTranslation } from 'react-i18next'; +import { useDarkTheme, useMediaQuery, useOutsideAlerter } from './hooks'; +import DeleteConvModal from './modals/DeleteConvModal'; +import { ActiveState } from './models/misc'; +import APIKeyModal from './preferences/APIKeyModal'; +import { Doc, getConversations, getDocs } from './preferences/preferenceApi'; +import { + selectApiKeyStatus, + selectConversationId, + selectConversations, + selectModalStateDeleteConv, + selectSelectedDocs, + selectSelectedDocsStatus, + selectSourceDocs, + setConversations, + setModalStateDeleteConv, + setSelectedDocs, + setSourceDocs, +} from './preferences/preferenceSlice'; +import SelectDocsModal from './preferences/SelectDocsModal'; +import Upload from './upload/Upload'; + interface NavigationProps { navOpen: boolean; setNavOpen: React.Dispatch>; @@ -85,7 +87,6 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { useState('INACTIVE'); const navRef = useRef(null); - const apiHost = import.meta.env.VITE_API_HOST || 'https://docsapi.arc53.com'; const navigate = useNavigate(); @@ -106,9 +107,8 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { } const handleDeleteAllConversations = () => { - fetch(`${apiHost}/api/delete_all_conversations`, { - method: 'POST', - }) + conversationService + .deleteAll() .then(() => { fetchConversations(); }) @@ -116,9 +116,8 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { }; const handleDeleteConversation = (id: string) => { - fetch(`${apiHost}/api/delete_conversation?id=${id}`, { - method: 'POST', - }) + conversationService + .delete(id, {}) .then(() => { fetchConversations(); }) @@ -128,17 +127,9 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { const handleDeleteClick = (doc: Doc) => { const docPath = `indexes/local/${doc.name}`; - fetch(`${apiHost}/api/delete_old?path=${docPath}`, { - method: 'GET', - }) + userService + .deletePath(docPath) .then(() => { - // remove the image element from the DOM - // const imageElement = document.querySelector( - // `#img-${index}`, - // ) as HTMLElement; - // const parentElement = imageElement.parentNode as HTMLElement; - // parentElement.parentNode?.removeChild(parentElement); - return getDocs(); }) .then((updatedDocs) => { @@ -153,10 +144,8 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { }; const handleConversationClick = (index: string) => { - // fetch the conversation from the server and setConversation in the store - fetch(`${apiHost}/api/get_single_conversation?id=${index}`, { - method: 'GET', - }) + conversationService + .getConversation(index) .then((response) => response.json()) .then((data) => { navigate('/'); @@ -173,13 +162,8 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { name: string; id: string; }) { - await fetch(`${apiHost}/api/update_conversation_name`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(updatedConversation), - }) + await conversationService + .update(updatedConversation) .then((response) => response.json()) .then((data) => { if (data) { diff --git a/frontend/src/PageNotFound.tsx b/frontend/src/PageNotFound.tsx index eaea5cc9..0b86d7c1 100644 --- a/frontend/src/PageNotFound.tsx +++ b/frontend/src/PageNotFound.tsx @@ -2,11 +2,11 @@ import { Link } from 'react-router-dom'; export default function PageNotFound() { return ( -
-

+

+

404

The page you are looking for does not exist.

-

diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 00000000..21699721 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,69 @@ +const baseURL = import.meta.env.VITE_API_HOST || 'https://docsapi.arc53.com'; + +const defaultHeaders = { + 'Content-Type': 'application/json', +}; + +const apiClient = { + get: (url: string, headers = {}, signal?: AbortSignal): Promise => + fetch(`${baseURL}${url}`, { + method: 'GET', + headers: { + ...defaultHeaders, + ...headers, + }, + signal, + }).then((response) => { + return response; + }), + + post: ( + url: string, + data: any, + headers = {}, + signal?: AbortSignal, + ): Promise => + fetch(`${baseURL}${url}`, { + method: 'POST', + headers: { + ...defaultHeaders, + ...headers, + }, + body: JSON.stringify(data), + signal, + }).then((response) => { + return response; + }), + + put: ( + url: string, + data: any, + headers = {}, + signal?: AbortSignal, + ): Promise => + fetch(`${baseURL}${url}`, { + method: 'PUT', + headers: { + ...defaultHeaders, + ...headers, + }, + body: JSON.stringify(data), + signal, + }).then((response) => { + return response; + }), + + delete: (url: string, headers = {}, signal?: AbortSignal): Promise => + fetch(`${baseURL}${url}`, { + method: 'DELETE', + headers: { + ...defaultHeaders, + ...headers, + }, + signal, + }).then((response) => { + return response; + }), +}; + +export default apiClient; diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts new file mode 100644 index 00000000..af2fb920 --- /dev/null +++ b/frontend/src/api/endpoints.ts @@ -0,0 +1,33 @@ +const endpoints = { + USER: { + DOCS: '/api/combine', + DOCS_CHECK: '/api/docs_check', + API_KEYS: '/api/get_api_keys', + CREATE_API_KEY: '/api/create_api_key', + DELETE_API_KEY: '/api/delete_api_key', + PROMPTS: '/api/get_prompts', + CREATE_PROMPT: '/api/create_prompt', + DELETE_PROMPT: '/api/delete_prompt', + UPDATE_PROMPT: '/api/update_prompt', + SINGLE_PROMPT: (id: string) => `/api/get_single_prompt?id=${id}`, + DELETE_PATH: (docPath: string) => `/api/delete_old?path=${docPath}`, + TASK_STATUS: (task_id: string) => `/api/task_status?task_id=${task_id}`, + }, + CONVERSATION: { + ANSWER: '/api/answer', + ANSWER_STREAMING: '/stream', + SEARCH: '/api/search', + FEEDBACK: '/api/feedback', + CONVERSATION: (id: string) => `/api/get_single_conversation?id=${id}`, + CONVERSATIONS: '/api/get_conversations', + SHARE_CONVERSATION: (isPromptable: boolean) => + `/api/share?isPromptable=${isPromptable}`, + SHARED_CONVERSATION: (identifier: string) => + `/api/shared_conversation/${identifier}`, + DELETE: (id: string) => `/api/delete_conversation?id=${id}`, + DELETE_ALL: '/api/delete_all_conversations', + UPDATE: '/api/update_conversation_name', + }, +}; + +export default endpoints; diff --git a/frontend/src/api/services/conversationService.ts b/frontend/src/api/services/conversationService.ts new file mode 100644 index 00000000..aaf703de --- /dev/null +++ b/frontend/src/api/services/conversationService.ts @@ -0,0 +1,32 @@ +import apiClient from '../client'; +import endpoints from '../endpoints'; + +const conversationService = { + answer: (data: any, signal: AbortSignal): Promise => + apiClient.post(endpoints.CONVERSATION.ANSWER, data, {}, signal), + answerStream: (data: any, signal: AbortSignal): Promise => + apiClient.post(endpoints.CONVERSATION.ANSWER_STREAMING, data, {}, signal), + search: (data: any): Promise => + apiClient.post(endpoints.CONVERSATION.SEARCH, data), + feedback: (data: any): Promise => + apiClient.post(endpoints.CONVERSATION.FEEDBACK, data), + getConversation: (id: string): Promise => + apiClient.get(endpoints.CONVERSATION.CONVERSATION(id)), + getConversations: (): Promise => + apiClient.get(endpoints.CONVERSATION.CONVERSATIONS), + shareConversation: (isPromptable: boolean, data: any): Promise => + apiClient.post( + endpoints.CONVERSATION.SHARE_CONVERSATION(isPromptable), + data, + ), + getSharedConversation: (identifier: string): Promise => + apiClient.get(endpoints.CONVERSATION.SHARED_CONVERSATION(identifier)), + delete: (id: string, data: any): Promise => + apiClient.post(endpoints.CONVERSATION.DELETE(id), data), + deleteAll: (): Promise => + apiClient.get(endpoints.CONVERSATION.DELETE_ALL), + update: (data: any): Promise => + apiClient.post(endpoints.CONVERSATION.UPDATE, data), +}; + +export default conversationService; diff --git a/frontend/src/api/services/userService.ts b/frontend/src/api/services/userService.ts new file mode 100644 index 00000000..193fe6ad --- /dev/null +++ b/frontend/src/api/services/userService.ts @@ -0,0 +1,28 @@ +import apiClient from '../client'; +import endpoints from '../endpoints'; + +const userService = { + getDocs: (): Promise => apiClient.get(endpoints.USER.DOCS), + checkDocs: (data: any): Promise => + apiClient.post(endpoints.USER.DOCS_CHECK, data), + getAPIKeys: (): Promise => apiClient.get(endpoints.USER.API_KEYS), + createAPIKey: (data: any): Promise => + apiClient.post(endpoints.USER.CREATE_API_KEY, data), + deleteAPIKey: (data: any): Promise => + apiClient.post(endpoints.USER.DELETE_API_KEY, data), + getPrompts: (): Promise => apiClient.get(endpoints.USER.PROMPTS), + createPrompt: (data: any): Promise => + apiClient.post(endpoints.USER.CREATE_PROMPT, data), + deletePrompt: (data: any): Promise => + apiClient.post(endpoints.USER.DELETE_PROMPT, data), + updatePrompt: (data: any): Promise => + apiClient.post(endpoints.USER.UPDATE_PROMPT, data), + getSinglePrompt: (id: string): Promise => + apiClient.get(endpoints.USER.SINGLE_PROMPT(id)), + deletePath: (docPath: string): Promise => + apiClient.get(endpoints.USER.DELETE_PATH(docPath)), + getTaskStatus: (task_id: string): Promise => + apiClient.get(endpoints.USER.TASK_STATUS(task_id)), +}; + +export default userService; diff --git a/frontend/src/assets/red-trash.svg b/frontend/src/assets/red-trash.svg new file mode 100644 index 00000000..b3331d95 --- /dev/null +++ b/frontend/src/assets/red-trash.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/share.svg b/frontend/src/assets/share.svg new file mode 100644 index 00000000..4699e16b --- /dev/null +++ b/frontend/src/assets/share.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/three-dots.svg b/frontend/src/assets/three-dots.svg new file mode 100644 index 00000000..6462b942 --- /dev/null +++ b/frontend/src/assets/three-dots.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/components/Input.tsx b/frontend/src/components/Input.tsx new file mode 100644 index 00000000..56ca1d52 --- /dev/null +++ b/frontend/src/components/Input.tsx @@ -0,0 +1,43 @@ +import { InputProps } from './types'; + +const Input = ({ + id, + name, + type, + value, + isAutoFocused = false, + placeholder, + maxLength, + className, + colorVariant = 'silver', + children, + onChange, + onPaste, + onKeyDown, +}: InputProps) => { + const colorStyles = { + silver: 'border-silver dark:border-silver/40', + jet: 'border-jet', + gray: 'border-gray-5000 dark:text-silver', + }; + + return ( + + {children} + + ); +}; + +export default Input; 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/components/types/index.ts b/frontend/src/components/types/index.ts new file mode 100644 index 00000000..0d6172ab --- /dev/null +++ b/frontend/src/components/types/index.ts @@ -0,0 +1,21 @@ +export type InputProps = { + type: 'text' | 'number'; + value: string | string[] | number; + colorVariant?: 'silver' | 'jet' | 'gray'; + isAutoFocused?: boolean; + id?: string; + maxLength?: number; + name?: string; + placeholder?: string; + className?: string; + children?: React.ReactElement; + onChange: ( + e: React.ChangeEvent, + ) => void; + onPaste?: ( + e: React.ClipboardEvent, + ) => void; + onKeyDown?: ( + e: React.KeyboardEvent, + ) => void; +}; diff --git a/frontend/src/conversation/Conversation.tsx b/frontend/src/conversation/Conversation.tsx index 487e8ea7..1f57fee3 100644 --- a/frontend/src/conversation/Conversation.tsx +++ b/frontend/src/conversation/Conversation.tsx @@ -1,9 +1,22 @@ import { Fragment, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; -import { useDarkTheme } from '../hooks'; + +import ArrowDown from '../assets/arrow-down.svg'; +import Send from '../assets/send.svg'; +import SendDark from '../assets/send_dark.svg'; +import ShareIcon from '../assets/share.svg'; +import SpinnerDark from '../assets/spinner-dark.svg'; +import Spinner from '../assets/spinner.svg'; +import RetryIcon from '../components/RetryIcon'; import Hero from '../Hero'; +import { useDarkTheme } from '../hooks'; +import { ShareConversationModal } from '../modals/ShareConversationModal'; +import { selectConversationId } from '../preferences/preferenceSlice'; import { AppDispatch } from '../store'; import ConversationBubble from './ConversationBubble'; +import { handleSendFeedback } from './conversationHandlers'; +import { FEEDBACK, Query } from './conversationModels'; import { addQuery, fetchAnswer, @@ -11,18 +24,11 @@ import { selectStatus, updateQuery, } from './conversationSlice'; -import Send from './../assets/send.svg'; -import SendDark from './../assets/send_dark.svg'; -import Spinner from './../assets/spinner.svg'; -import SpinnerDark from './../assets/spinner-dark.svg'; -import { FEEDBACK, Query } from './conversationModels'; -import { sendFeedback } from './conversationApi'; -import { useTranslation } from 'react-i18next'; -import ArrowDown from './../assets/arrow-down.svg'; -import RetryIcon from '../components/RetryIcon'; + export default function Conversation() { const queries = useSelector(selectQueries); const status = useSelector(selectStatus); + const conversationId = useSelector(selectConversationId); const dispatch = useDispatch(); const endMessageRef = useRef(null); const inputRef = useRef(null); @@ -31,6 +37,7 @@ export default function Conversation() { const fetchStream = useRef(null); const [eventInterrupt, setEventInterrupt] = useState(false); const [lastQueryReturnedErr, setLastQueryReturnedErr] = useState(false); + const [isShareModalOpen, setShareModalState] = useState(false); const { t } = useTranslation(); const handleUserInterruption = () => { @@ -106,7 +113,7 @@ export default function Conversation() { const handleFeedback = (query: Query, feedback: FEEDBACK, index: number) => { const prevFeedback = query.feedback; dispatch(updateQuery({ index, query: { feedback } })); - sendFeedback(query.prompt, query.response!, feedback).catch(() => + handleSendFeedback(query.prompt, query.response!, feedback).catch(() => dispatch(updateQuery({ index, query: { feedback: prevFeedback } })), ); }; @@ -187,11 +194,36 @@ 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 (
+ {conversationId && ( + <> + + {isShareModalOpen && ( + { + setShareModalState(false); + }} + conversationId={conversationId} + /> + )} + + )}
) : ( -
+
diff --git a/frontend/src/conversation/ConversationBubble.tsx b/frontend/src/conversation/ConversationBubble.tsx index fc5d469e..4adfbf94 100644 --- a/frontend/src/conversation/ConversationBubble.tsx +++ b/frontend/src/conversation/ConversationBubble.tsx @@ -1,6 +1,6 @@ import { forwardRef, useState } from 'react'; import Avatar from '../components/Avatar'; -import CoppyButton from '../components/CopyButton'; +import CopyButton from '../components/CopyButton'; import remarkGfm from 'remark-gfm'; import { FEEDBACK, MESSAGE_TYPE } from './conversationModels'; import classes from './ConversationBubble.module.css'; @@ -103,7 +103,7 @@ const ConversationBubble = forwardRef< className={`absolute right-3 top-3 lg:invisible ${type !== 'ERROR' ? 'group-hover:lg:visible' : ''} `} > -
@@ -215,78 +215,82 @@ const ConversationBubble = forwardRef< ${type !== 'ERROR' ? 'group-hover:lg:visible' : ''}`} >
- +
-
-
+ {handleFeedback && ( + <>
- +
+ { - handleFeedback?.('LIKE'); - setIsLikeClicked(true); - setIsDislikeClicked(false); - }} - onMouseEnter={() => setIsLikeHovered(true)} - onMouseLeave={() => setIsLikeHovered(false)} - > + onClick={() => { + handleFeedback?.('LIKE'); + setIsLikeClicked(true); + setIsDislikeClicked(false); + }} + onMouseEnter={() => setIsLikeHovered(true)} + onMouseLeave={() => setIsLikeHovered(false)} + > +
+
-
-
-
-
- { - handleFeedback?.('DISLIKE'); - setIsDislikeClicked(true); - setIsLikeClicked(false); - }} - onMouseEnter={() => setIsDislikeHovered(true)} - onMouseLeave={() => setIsDislikeHovered(false)} - > +
+
+ { + handleFeedback?.('DISLIKE'); + setIsDislikeClicked(true); + setIsLikeClicked(false); + }} + onMouseEnter={() => setIsDislikeHovered(true)} + onMouseLeave={() => setIsDislikeHovered(false)} + > +
+
-
-
+ + )}
{sources && openSource !== null && sources[openSource] && ( diff --git a/frontend/src/conversation/ConversationTile.tsx b/frontend/src/conversation/ConversationTile.tsx index b9d2301d..6f93dbb4 100644 --- a/frontend/src/conversation/ConversationTile.tsx +++ b/frontend/src/conversation/ConversationTile.tsx @@ -1,14 +1,19 @@ -import { useEffect, useRef, useState } from 'react'; +import { SyntheticEvent, useEffect, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; import Edit from '../assets/edit.svg'; import Exit from '../assets/exit.svg'; import Message from '../assets/message.svg'; import MessageDark from '../assets/message-dark.svg'; import { useDarkTheme } from '../hooks'; +import ConfirmationModal from '../modals/ConfirmationModal'; import CheckMark2 from '../assets/checkMark2.svg'; -import Trash from '../assets/trash.svg'; - +import Trash from '../assets/red-trash.svg'; +import Share from '../assets/share.svg'; +import threeDots from '../assets/three-dots.svg'; import { selectConversationId } from '../preferences/preferenceSlice'; +import { ActiveState } from '../models/misc'; +import { ShareConversationModal } from '../modals/ShareConversationModal'; +import { useTranslation } from 'react-i18next'; interface ConversationProps { name: string; @@ -32,13 +37,20 @@ export default function ConversationTile({ const [isDarkTheme] = useDarkTheme(); const [isEdit, setIsEdit] = useState(false); const [conversationName, setConversationsName] = useState(''); - + const [isOpen, setOpen] = useState(false); + const [isShareModalOpen, setShareModalState] = useState(false); + const [isHovered, setIsHovered] = useState(false); + const [deleteModalState, setDeleteModalState] = + useState('INACTIVE'); + const menuRef = useRef(null); + const { t } = useTranslation(); useEffect(() => { setConversationsName(conversation.name); }, [conversation.name]); function handleEditConversation() { setIsEdit(true); + setOpen(false); } function handleSaveConversation(changedConversation: ConversationProps) { @@ -50,76 +62,172 @@ export default function ConversationTile({ } } + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setOpen(false); + } + }; + + useEffect(() => { + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); function onClear() { setConversationsName(conversation.name); setIsEdit(false); } return ( -
{ - 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 - ? 'bg-gray-100 dark:bg-[#28292E]' - : '' - }`} - > + <>
{ + setIsHovered(true); + }} + onMouseLeave={() => { + setIsHovered(false); + }} + onClick={() => { + 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 || isOpen || isHovered + ? 'bg-gray-100 dark:bg-[#28292E]' + : '' + }`} > - - {isEdit ? ( - setConversationsName(e.target.value)} +
+ - ) : ( -

- {conversationName} -

+ {isEdit ? ( + setConversationsName(e.target.value)} + /> + ) : ( +

+ {conversationName} +

+ )} +
+ {(conversationId === conversation.id || isHovered || isOpen) && ( +
+ {isEdit ? ( +
+ Edit { + event.stopPropagation(); + handleSaveConversation({ + id: conversation.id, + name: conversationName, + }); + }} + /> + Exit { + event.stopPropagation(); + onClear(); + }} + /> +
+ ) : ( + + )} + {isOpen && ( +
+ + + +
+ )} +
)}
- {conversationId === conversation.id && ( -
- Edit { - event.stopPropagation(); - isEdit - ? handleSaveConversation({ - id: conversationId, - name: conversationName, - }) - : handleEditConversation(); - }} - /> - Exit { - event.stopPropagation(); - isEdit ? onClear() : onDeleteConversation(conversation.id); - }} - /> -
+ onDeleteConversation(conversation.id)} + submitLabel={t('convTile.delete')} + /> + {isShareModalOpen && ( + { + setShareModalState(false); + isHovered && setIsHovered(false); + }} + conversationId={conversation.id} + /> )} -
+ ); } diff --git a/frontend/src/conversation/SharedConversation.tsx b/frontend/src/conversation/SharedConversation.tsx new file mode 100644 index 00000000..cbe80717 --- /dev/null +++ b/frontend/src/conversation/SharedConversation.tsx @@ -0,0 +1,288 @@ +import { Query } from './conversationModels'; +import { Fragment, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate, useParams } from 'react-router-dom'; + +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, + updateQuery, + addQuery, + fetchSharedAnswer, + selectStatus, +} from './sharedConversationSlice'; +import { setIdentifier, setFetchedData } from './sharedConversationSlice'; + +import { useDispatch } from 'react-redux'; +import { AppDispatch } from '../store'; + +import { + selectDate, + selectTitle, + selectQueries, +} from './sharedConversationSlice'; +import { useSelector } from 'react-redux'; +const apiHost = import.meta.env.VITE_API_HOST || 'https://docsapi.arc53.com'; + +export const SharedConversation = () => { + const navigate = useNavigate(); + 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(); + + 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); + + const monthNames = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'June', + 'July', + 'Aug', + 'Sept', + 'Oct', + 'Nov', + 'Dec', + ]; + + const month = monthNames[date.getMonth()]; + const day = date.getDate(); + const year = date.getFullYear(); + + let hours = date.getHours(); + const minutes = date.getMinutes(); + const ampm = hours >= 12 ? 'PM' : 'AM'; + + hours = hours % 12; + hours = hours ? hours : 12; + const minutesStr = minutes < 10 ? '0' + minutes : minutes; + const formattedDate = `Published ${month} ${day}, ${year} at ${hours}:${minutesStr} ${ampm}`; + return formattedDate; + } + 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 || '') + .then((res) => { + if (res.status === 404 || res.status === 400) + navigate('/pagenotfound'); + return res.json(); + }) + .then((data) => { + if (data.success) { + dispatch( + setFetchedData({ + queries: data.queries, + title: data.title, + date: data.date, + identifier, + }), + ); + data.api_key && dispatch(setClientApiKey(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) { + responseView = ( + + ); + } else if (query.error) { + responseView = ( + + ); + } + 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(() => { + fetchQueries(); + }, []); + + return ( +
+
+
+
+

+ {title} +

+

+ {t('sharedConv.subtitle')}{' '} + + DocsGPT + +

+

+ {date} +

+
+
+ {queries?.map((query, index) => { + return ( + + + + {prepResponseView(query, index)} + + ); + })} +
+
+
+ +
+ {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/conversationHandlers.ts similarity index 53% rename from frontend/src/conversation/conversationApi.ts rename to frontend/src/conversation/conversationHandlers.ts index e107abc8..90bbc0a9 100644 --- a/frontend/src/conversation/conversationApi.ts +++ b/frontend/src/conversation/conversationHandlers.ts @@ -1,11 +1,9 @@ -import { Answer, FEEDBACK } from './conversationModels'; +import conversationService from '../api/services/conversationService'; import { Doc } from '../preferences/preferenceApi'; - -const apiHost = import.meta.env.VITE_API_HOST || 'https://docsapi.arc53.com'; +import { Answer, FEEDBACK } from './conversationModels'; function getDocPath(selectedDocs: Doc | null): string { let docPath = 'default'; - if (selectedDocs) { let namePath = selectedDocs.name; if (selectedDocs.language === namePath) { @@ -27,10 +25,10 @@ function getDocPath(selectedDocs: Doc | null): string { docPath = selectedDocs.docLink; } } - return docPath; } -export function fetchAnswerApi( + +export function handleFetchAnswer( question: string, signal: AbortSignal, selectedDocs: Doc | null, @@ -57,27 +55,22 @@ export function fetchAnswerApi( } > { const docPath = getDocPath(selectedDocs); - //in history array remove all keys except prompt and response history = history.map((item) => { return { prompt: item.prompt, response: item.response }; }); - - return fetch(apiHost + '/api/answer', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - question: question, - history: history, - active_docs: docPath, - conversation_id: conversationId, - prompt_id: promptId, - chunks: chunks, - token_limit: token_limit, - }), - signal, - }) + return conversationService + .answer( + { + question: question, + history: history, + active_docs: docPath, + conversation_id: conversationId, + prompt_id: promptId, + chunks: chunks, + token_limit: token_limit, + }, + signal, + ) .then((response) => { if (response.ok) { return response.json(); @@ -97,7 +90,7 @@ export function fetchAnswerApi( }); } -export function fetchAnswerSteaming( +export function handleFetchAnswerSteaming( question: string, signal: AbortSignal, selectedDocs: Doc | null, @@ -109,29 +102,23 @@ export function fetchAnswerSteaming( onEvent: (event: MessageEvent) => void, ): Promise { const docPath = getDocPath(selectedDocs); - history = history.map((item) => { return { prompt: item.prompt, response: item.response }; }); - return new Promise((resolve, reject) => { - const body = { - question: question, - active_docs: docPath, - history: JSON.stringify(history), - conversation_id: conversationId, - prompt_id: promptId, - chunks: chunks, - token_limit: token_limit, - }; - fetch(apiHost + '/stream', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - signal, - }) + conversationService + .answerStream( + { + question: question, + active_docs: docPath, + history: JSON.stringify(history), + conversation_id: conversationId, + prompt_id: promptId, + chunks: chunks, + token_limit: token_limit, + }, + signal, + ) .then((response) => { if (!response.body) throw Error('No response body'); @@ -179,7 +166,8 @@ export function fetchAnswerSteaming( }); }); } -export function searchEndpoint( + +export function handleSearch( question: string, selectedDocs: Doc | null, conversation_id: string | null, @@ -188,48 +176,150 @@ export function searchEndpoint( token_limit: number, ) { const docPath = getDocPath(selectedDocs); - - const body = { - question: question, - active_docs: docPath, - conversation_id, - history, - chunks: chunks, - token_limit: token_limit, - }; - return fetch(`${apiHost}/api/search`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }) + return conversationService + .search({ + question: question, + active_docs: docPath, + conversation_id, + history, + chunks: chunks, + token_limit: token_limit, + }) .then((response) => response.json()) .then((data) => { return data; }) .catch((err) => console.log(err)); } -export function sendFeedback( + +export function handleSendFeedback( prompt: string, response: string, feedback: FEEDBACK, ) { - return fetch(`${apiHost}/api/feedback`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ + return conversationService + .feedback({ question: prompt, answer: response, feedback: feedback, - }), - }).then((response) => { - if (response.ok) { - return Promise.resolve(); - } else { - return Promise.reject(); - } + }) + .then((response) => { + if (response.ok) { + return Promise.resolve(); + } else { + return Promise.reject(); + } + }); +} + +export function handleFetchSharedAnswerStreaming( //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 payload = { + question: question, + history: JSON.stringify(history), + api_key: apiKey, + }; + conversationService + .answerStream(payload, 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); + }); }); } + +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/conversationSlice.ts b/frontend/src/conversation/conversationSlice.ts index 7ab9f8fe..75c457a9 100644 --- a/frontend/src/conversation/conversationSlice.ts +++ b/frontend/src/conversation/conversationSlice.ts @@ -1,10 +1,14 @@ import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; -import store from '../store'; -import { fetchAnswerApi, fetchAnswerSteaming } from './conversationApi'; -import { searchEndpoint } from './conversationApi'; -import { Answer, ConversationState, Query, Status } from './conversationModels'; + import { getConversations } from '../preferences/preferenceApi'; import { setConversations } from '../preferences/preferenceSlice'; +import store from '../store'; +import { + handleFetchAnswer, + handleFetchAnswerSteaming, + handleSearch, +} from './conversationHandlers'; +import { Answer, ConversationState, Query, Status } from './conversationModels'; const initialState: ConversationState = { queries: [], @@ -20,7 +24,7 @@ export const fetchAnswer = createAsyncThunk( const state = getState() as RootState; if (state.preference) { if (API_STREAMING) { - await fetchAnswerSteaming( + await handleFetchAnswerSteaming( question, signal, state.preference.selectedDocs!, @@ -45,7 +49,7 @@ export const fetchAnswer = createAsyncThunk( console.error('Failed to fetch conversations: ', error); }); - searchEndpoint( + handleSearch( //search for sources post streaming question, state.preference.selectedDocs!, @@ -89,7 +93,7 @@ export const fetchAnswer = createAsyncThunk( }, ); } else { - const answer = await fetchAnswerApi( + const answer = await handleFetchAnswer( question, signal, state.preference.selectedDocs!, diff --git a/frontend/src/conversation/sharedConversationSlice.ts b/frontend/src/conversation/sharedConversationSlice.ts new file mode 100644 index 00000000..6d9ec936 --- /dev/null +++ b/frontend/src/conversation/sharedConversationSlice.ts @@ -0,0 +1,229 @@ +import { createSlice } from '@reduxjs/toolkit'; +import type { PayloadAction } from '@reduxjs/toolkit'; +import store from '../store'; +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; + identifier: string; + status: Status; + date?: string; + title?: string; +} + +const initialState: SharedConversationsType = { + queries: [], + identifier: '', + status: 'idle', +}; + +export const fetchSharedAnswer = createAsyncThunk( + 'shared/fetchAnswer', + async ({ question }, { dispatch, getState, signal }) => { + 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')); + dispatch(saveToLocalStorage()); + } 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, + 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; + 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; + }, + setClientApiKey(state, action: PayloadAction) { + state.apiKey = action.payload; + }, + addQuery(state, action: PayloadAction) { + state.queries.push(action.payload); + }, + 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; + }, + 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 + .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, + updateQuery, + updateStreamingQuery, + addQuery, + saveToLocalStorage, +} = 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/hooks/index.ts b/frontend/src/hooks/index.ts index 6248b3f8..c8258ad2 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -77,21 +77,23 @@ export function useDarkTheme() { // Set dark mode based on local storage preference if (savedMode === 'Dark') { setIsDarkTheme(true); - document.documentElement.classList.add('dark'); - document.documentElement.classList.add('dark:bg-raisin-black'); + document + .getElementById('root') + ?.classList.add('dark', 'dark:bg-raisin-black'); } else { // If no preference found, set to default (light mode) setIsDarkTheme(false); - document.documentElement.classList.remove('dark'); + document.getElementById('root')?.classList.remove('dark'); } }, []); useEffect(() => { localStorage.setItem('selectedTheme', isDarkTheme ? 'Dark' : 'Light'); if (isDarkTheme) { - document.documentElement.classList.add('dark'); - document.documentElement.classList.add('dark:bg-raisin-black'); + document + .getElementById('root') + ?.classList.add('dark', 'dark:bg-raisin-black'); } else { - document.documentElement.classList.remove('dark'); + document.getElementById('root')?.classList.remove('dark'); } }, [isDarkTheme]); //method to toggle theme diff --git a/frontend/src/index.css b/frontend/src/index.css index c1d80714..37f946a3 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -22,6 +22,18 @@ background: #b1afaf; } +@layer utilities { + /* Chrome, Safari and Opera */ + .no-scrollbar::-webkit-scrollbar { + display: none; + } + + .no-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + } +} + /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ /* Document @@ -409,10 +421,6 @@ template { width: 0; } -/* .bottom-safe { - bottom: env(safe-area-inset-bottom, 0); -} */ - .inputbox-style[contenteditable] { padding-left: 36px; padding-right: 36px; diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index e59fbedc..c7651f8b 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -103,6 +103,23 @@ "deleteConv": { "confirm": "Are you sure you want to delete all the conversations?", "delete": "Delete" + }, + "shareConv": { + "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" } + }, + "sharedConv": { + "subtitle": "Created with", + "button": "Get Started with DocsGPT", + "meta": "DocsGPT uses GenAI, please review critical information using sources." + }, + "convTile": { + "share": "Share", + "delete": "Delete", + "rename": "Rename", + "deleteWarning": "Are you sure you want to delete this conversation?" } } diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 70966e4b..d368749f 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -103,6 +103,23 @@ "deleteConv": { "confirm": "¿Está seguro de que desea eliminar todas las conversaciones?", "delete": "Eliminar" + }, + "shareConv": { + "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." } + }, + "sharedConv": { + "subtitle": "Creado con", + "button": "Comienza con DocsGPT", + "meta": "DocsGPT utiliza GenAI, por favor revise la información crítica utilizando fuentes." + }, + "convTile": { + "share": "Compartir", + "delete": "Eliminar", + "rename": "Renombrar", + "deleteWarning": "¿Está seguro de que desea eliminar esta conversación?" } } diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index a025a17a..350faf09 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -103,6 +103,23 @@ "deleteConv": { "confirm": "すべての会話を削除してもよろしいですか?", "delete": "削除" + }, + "shareConv": { + "label": "共有ページを作成して共有する", + "note": "ソースドキュメント、個人情報、および以降の会話は非公開のままになります", + "create": "作成", + "option": "ユーザーがより多くのクエリを実行できるようにします。" } + }, + "sharedConv": { + "subtitle": "作成者", + "button": "DocsGPT を始める", + "meta": "DocsGPT は GenAI を使用しています、情報源を使用して重要情報を確認してください。" + }, + "convTile": { + "share": "共有", + "delete": "削除", + "rename": "名前変更", + "deleteWarning": "この会話を削除してもよろしいですか?" } } diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index f686ea20..db93dcfc 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -103,6 +103,23 @@ "deleteConv": { "confirm": "您确定要删除所有对话吗?", "delete": "删除" + }, + "shareConv": { + "label": "创建用于分享的公共页面", + "note": "源文档、个人信息和后续对话将保持私密", + "create": "创建", + "option": "允许用户进行更多查询。" } + }, + "sharedConv": { + "subtitle": "使用创建", + "button": "开始使用 DocsGPT", + "meta": "DocsGPT 使用 GenAI,请使用资源查看关键信息。" + }, + "convTile": { + "share": "分享", + "delete": "删除", + "rename": "重命名", + "deleteWarning": "您确定要删除此对话吗?" } } diff --git a/frontend/src/modals/CreateAPIKeyModal.tsx b/frontend/src/modals/CreateAPIKeyModal.tsx new file mode 100644 index 00000000..2f67d83b --- /dev/null +++ b/frontend/src/modals/CreateAPIKeyModal.tsx @@ -0,0 +1,162 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; + +import userService from '../api/services/userService'; +import Exit from '../assets/exit.svg'; +import Dropdown from '../components/Dropdown'; +import Input from '../components/Input'; +import { CreateAPIKeyModalProps, Doc } from '../models/misc'; +import { selectSourceDocs } from '../preferences/preferenceSlice'; + +const embeddingsName = + import.meta.env.VITE_EMBEDDINGS_NAME || + 'huggingface_sentence-transformers/all-mpnet-base-v2'; + +export default function CreateAPIKeyModal({ + close, + createAPIKey, +}: CreateAPIKeyModalProps) { + const { t } = useTranslation(); + const docs = useSelector(selectSourceDocs); + + const [APIKeyName, setAPIKeyName] = React.useState(''); + const [sourcePath, setSourcePath] = React.useState<{ + label: string; + value: string; + } | null>(null); + const [prompt, setPrompt] = React.useState<{ + name: string; + id: string; + type: string; + } | null>(null); + const [activePrompts, setActivePrompts] = React.useState< + { name: string; id: string; type: string }[] + >([]); + const [chunk, setChunk] = React.useState('2'); + const chunkOptions = ['0', '2', '4', '6', '8', '10']; + + const extractDocPaths = () => + 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, + }; + }) + : []; + + React.useEffect(() => { + const handleFetchPrompts = async () => { + try { + const response = await userService.getPrompts(); + if (!response.ok) { + throw new Error('Failed to fetch prompts'); + } + const promptsData = await response.json(); + setActivePrompts(promptsData); + } catch (error) { + console.error(error); + } + }; + handleFetchPrompts(); + }, []); + return ( +
+
+ +
+ + {t('modals.createAPIKey.label')} + +
+
+ + {t('modals.createAPIKey.apiKeyName')} + + setAPIKeyName(e.target.value)} + > +
+
+ + setSourcePath(selection) + } + options={extractDocPaths()} + size="w-full" + rounded="xl" + border="border" + /> +
+
+ + setPrompt(value) + } + size="w-full" + border="border" + /> +
+
+

+ {t('modals.createAPIKey.chunks')} +

+ setChunk(value)} + size="w-full" + border="border" + /> +
+ +
+
+ ); +} diff --git a/frontend/src/modals/SaveAPIKeyModal.tsx b/frontend/src/modals/SaveAPIKeyModal.tsx new file mode 100644 index 00000000..d91d0c2d --- /dev/null +++ b/frontend/src/modals/SaveAPIKeyModal.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import Exit from '../assets/exit.svg'; +import { SaveAPIKeyModalProps } from '../models/misc'; + +export default function SaveAPIKeyModal({ + apiKey, + close, +}: SaveAPIKeyModalProps) { + const { t } = useTranslation(); + const [isCopied, setIsCopied] = React.useState(false); + + const handleCopyKey = () => { + navigator.clipboard.writeText(apiKey); + setIsCopied(true); + }; + return ( +
+
+ +

+ {' '} + {t('modals.saveKey.note')} +

+

+ {t('modals.saveKey.disclaimer')} +

+
+
+

API Key

+ {apiKey} +
+ +
+ +
+
+ ); +} diff --git a/frontend/src/modals/ShareConversationModal.tsx b/frontend/src/modals/ShareConversationModal.tsx new file mode 100644 index 00000000..c7ef0ad6 --- /dev/null +++ b/frontend/src/modals/ShareConversationModal.tsx @@ -0,0 +1,201 @@ +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'; + +import conversationService from '../api/services/conversationService'; + +export const ShareConversationModal = ({ + close, + conversationId, +}: { + close: () => void; + conversationId: string; +}) => { + const { t } = useTranslation(); + + const domain = window.location.origin; + + const [identifier, setIdentifier] = useState(null); + const [isCopied, setIsCopied] = useState(false); + const [status, setStatus] = useState('idle'); + 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); + } + conversationService + .shareConversation(isPromptable, payload) + .then((res) => { + return res.json(); + }) + .then((data) => { + if (data.success && data.identifier) { + setIdentifier(data.identifier); + setStatus('fetched'); + } else setStatus('failed'); + }) + .catch((err) => setStatus('failed')); + }; + + return ( +
+
+ +
+

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

+

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

+
+ {t('modals.shareConv.option')} + +
+ {allowPrompt && ( +
+ + setSourcePath(selection) + } + options={extractDocPaths(sourceDocs ?? [])} + size="w-full" + rounded="xl" + /> +
+ )} +
+ + {`${domain}/share/${identifier ?? '....'}`} + + {status === 'fetched' ? ( + + ) : ( + + )} +
+
+
+
+ ); +}; diff --git a/frontend/src/models/misc.ts b/frontend/src/models/misc.ts index 52787932..ab8d6b85 100644 --- a/frontend/src/models/misc.ts +++ b/frontend/src/models/misc.ts @@ -21,7 +21,6 @@ export type PromptProps = { selectedPrompt: { name: string; id: string; type: string }; onSelectPrompt: (name: string, id: string, type: string) => void; setPrompts: (prompts: { name: string; id: string; type: string }[]) => void; - apiHost: string; }; export type DocumentsProps = { diff --git a/frontend/src/preferences/APIKeyModal.tsx b/frontend/src/preferences/APIKeyModal.tsx index dd7a1b88..166bd62b 100644 --- a/frontend/src/preferences/APIKeyModal.tsx +++ b/frontend/src/preferences/APIKeyModal.tsx @@ -4,6 +4,7 @@ import { ActiveState } from '../models/misc'; import { selectApiKey, setApiKey } from './preferenceSlice'; import { useMediaQuery, useOutsideAlerter } from './../hooks'; import Modal from '../modals'; +import Input from '../components/Input'; export default function APIKeyModal({ modalState, @@ -66,14 +67,15 @@ export default function APIKeyModal({ key for llm. Currently, we support only OpenAI but soon many more. You can find it here.

- setKey(e.target.value)} - /> + > ); }} diff --git a/frontend/src/preferences/PromptsModal.tsx b/frontend/src/preferences/PromptsModal.tsx index c16ddd2c..c207e0be 100644 --- a/frontend/src/preferences/PromptsModal.tsx +++ b/frontend/src/preferences/PromptsModal.tsx @@ -1,5 +1,6 @@ import { ActiveState } from '../models/misc'; import Exit from '../assets/exit.svg'; +import Input from '../components/Input'; function AddPrompt({ setModalState, @@ -34,13 +35,13 @@ function AddPrompt({ Add your custom prompt and save it to DocsGPT

- setNewPromptName(e.target.value)} - > + >
Prompt Name @@ -105,13 +106,13 @@ function EditPrompt({ Edit your custom prompt and save it to DocsGPT

- setEditPromptName(e.target.value)} - > + >
Prompt Name diff --git a/frontend/src/preferences/preferenceApi.ts b/frontend/src/preferences/preferenceApi.ts index 81fd3131..29a41645 100644 --- a/frontend/src/preferences/preferenceApi.ts +++ b/frontend/src/preferences/preferenceApi.ts @@ -1,3 +1,6 @@ +import conversationService from '../api/services/conversationService'; +import userService from '../api/services/userService'; + // not all properties in Doc are going to be present. Make some optional export type Doc = { location: string; @@ -14,10 +17,7 @@ export type Doc = { //Fetches all JSON objects from the source. We only use the objects with the "model" property in SelectDocsModal.tsx. Hopefully can clean up the source file later. export async function getDocs(): Promise { try { - const apiHost = - import.meta.env.VITE_API_HOST || 'https://docsapi.arc53.com'; - - const response = await fetch(apiHost + '/api/combine'); + const response = await userService.getDocs(); const data = await response.json(); const docs: Doc[] = []; @@ -37,10 +37,7 @@ export async function getConversations(): Promise< { name: string; id: string }[] | null > { try { - const apiHost = - import.meta.env.VITE_API_HOST || 'https://docsapi.arc53.com'; - - const response = await fetch(apiHost + '/api/get_conversations'); + const response = await conversationService.getConversations(); const data = await response.json(); const conversations: { name: string; id: string }[] = []; @@ -93,14 +90,9 @@ export function setLocalRecentDocs(doc: Doc): void { docPath = doc.language + '/' + namePath + '/' + doc.version + '/' + doc.model + '/'; } - const apiHost = import.meta.env.VITE_API_HOST || 'https://docsapi.arc53.com'; - fetch(apiHost + '/api/docs_check', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ + userService + .checkDocs({ docs: docPath, - }), - }).then((response) => response.json()); + }) + .then((response) => response.json()); } diff --git a/frontend/src/settings/APIKeys.tsx b/frontend/src/settings/APIKeys.tsx index 8264af08..18904d24 100644 --- a/frontend/src/settings/APIKeys.tsx +++ b/frontend/src/settings/APIKeys.tsx @@ -1,21 +1,12 @@ import React from 'react'; -import { useSelector } from 'react-redux'; -import Dropdown from '../components/Dropdown'; -import { - Doc, - CreateAPIKeyModalProps, - SaveAPIKeyModalProps, -} from '../models/misc'; -import { selectSourceDocs } from '../preferences/preferenceSlice'; -import Exit from '../assets/exit.svg'; -import Trash from '../assets/trash.svg'; import { useTranslation } from 'react-i18next'; -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'; -const APIKeys: React.FC = () => { +import userService from '../api/services/userService'; +import Trash from '../assets/trash.svg'; +import CreateAPIKeyModal from '../modals/CreateAPIKeyModal'; +import SaveAPIKeyModal from '../modals/SaveAPIKeyModal'; + +export default function APIKeys() { const { t } = useTranslation(); const [isCreateModalOpen, setCreateModal] = React.useState(false); const [isSaveKeyModalOpen, setSaveKeyModal] = React.useState(false); @@ -23,14 +14,23 @@ const APIKeys: React.FC = () => { const [apiKeys, setApiKeys] = React.useState< { name: string; key: string; source: string; id: string }[] >([]); + + const handleFetchKeys = async () => { + try { + const response = await userService.getAPIKeys(); + if (!response.ok) { + throw new Error('Failed to fetch API Keys'); + } + const apiKeys = await response.json(); + setApiKeys(apiKeys); + } catch (error) { + console.log(error); + } + }; + const handleDeleteKey = (id: string) => { - fetch(`${apiHost}/api/delete_api_key`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ id }), - }) + userService + .deleteAPIKey({ id }) .then((response) => { if (!response.ok) { throw new Error('Failed to delete API Key'); @@ -45,34 +45,15 @@ const APIKeys: React.FC = () => { console.error(error); }); }; - React.useEffect(() => { - fetchAPIKeys(); - }, []); - const fetchAPIKeys = async () => { - try { - const response = await fetch(`${apiHost}/api/get_api_keys`); - if (!response.ok) { - throw new Error('Failed to fetch API Keys'); - } - const apiKeys = await response.json(); - setApiKeys(apiKeys); - } catch (error) { - console.log(error); - } - }; - const createAPIKey = (payload: { + + const handleCreateKey = (payload: { name: string; source: string; prompt_id: string; chunks: string; }) => { - fetch(`${apiHost}/api/create_api_key`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }) + userService + .createAPIKey(payload) .then((response) => { if (!response.ok) { throw new Error('Failed to create API Key'); @@ -84,12 +65,16 @@ const APIKeys: React.FC = () => { setCreateModal(false); setNewKey(data.key); setSaveKeyModal(true); - fetchAPIKeys(); + handleFetchKeys(); }) .catch((error) => { console.error(error); }); }; + + React.useEffect(() => { + handleFetchKeys(); + }, []); return (
@@ -103,8 +88,8 @@ const APIKeys: React.FC = () => {
{isCreateModalOpen && ( setCreateModal(false)} - createAPIKey={createAPIKey} /> )} {isSaveKeyModalOpen && ( @@ -154,192 +139,4 @@ const APIKeys: React.FC = () => {
); -}; - -const CreateAPIKeyModal: React.FC = ({ - close, - createAPIKey, -}) => { - const [APIKeyName, setAPIKeyName] = React.useState(''); - const [sourcePath, setSourcePath] = React.useState<{ - label: string; - value: string; - } | null>(null); - - const chunkOptions = ['0', '2', '4', '6', '8', '10']; - const [chunk, setChunk] = React.useState('2'); - const [activePrompts, setActivePrompts] = React.useState< - { name: string; id: string; type: string }[] - >([]); - const [prompt, setPrompt] = React.useState<{ - name: string; - id: string; - type: string; - } | null>(null); - const docs = useSelector(selectSourceDocs); - React.useEffect(() => { - const fetchPrompts = async () => { - try { - const response = await fetch(`${apiHost}/api/get_prompts`); - if (!response.ok) { - throw new Error('Failed to fetch prompts'); - } - const promptsData = await response.json(); - setActivePrompts(promptsData); - } catch (error) { - console.error(error); - } - }; - fetchPrompts(); - }, []); - const extractDocPaths = () => - 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 { t } = useTranslation(); - return ( -
-
- -
- - {t('modals.createAPIKey.label')} - -
-
- - {t('modals.createAPIKey.apiKeyName')} - - setAPIKeyName(e.target.value)} - /> -
-
- - setSourcePath(selection) - } - options={extractDocPaths()} - size="w-full" - rounded="xl" - /> -
-
- - setPrompt(value) - } - size="w-full" - /> -
-
-

- {t('modals.createAPIKey.chunks')} -

- setChunk(value)} - size="w-full" - /> -
- -
-
- ); -}; - -const SaveAPIKeyModal: React.FC = ({ apiKey, close }) => { - const [isCopied, setIsCopied] = React.useState(false); - const { t } = useTranslation(); - const handleCopyKey = () => { - navigator.clipboard.writeText(apiKey); - setIsCopied(true); - }; - return ( -
-
- -

- {' '} - {t('modals.saveKey.note')} -

-

- {t('modals.saveKey.disclaimer')} -

-
-
-

API Key

- {apiKey} -
- -
- -
-
- ); -}; - -export default APIKeys; +} diff --git a/frontend/src/settings/General.tsx b/frontend/src/settings/General.tsx index bf917d91..2d0c466d 100644 --- a/frontend/src/settings/General.tsx +++ b/frontend/src/settings/General.tsx @@ -1,22 +1,22 @@ import React from 'react'; -import { useSelector, useDispatch } from 'react-redux'; -import Prompts from './Prompts'; -import { useDarkTheme } from '../hooks'; import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; + +import userService from '../api/services/userService'; import Dropdown from '../components/Dropdown'; +import { useDarkTheme } from '../hooks'; import { - selectPrompt, - setPrompt, - setChunks, selectChunks, - setTokenLimit, + selectPrompt, selectTokenLimit, + setChunks, setModalStateDeleteConv, + setPrompt, + setTokenLimit, } from '../preferences/preferenceSlice'; +import Prompts from './Prompts'; -const apiHost = import.meta.env.VITE_API_HOST || 'https://docsapi.arc53.com'; - -const General: React.FC = () => { +export default function General() { const { t, i18n: { changeLanguage, language }, @@ -69,9 +69,9 @@ const General: React.FC = () => { const selectedPrompt = useSelector(selectPrompt); React.useEffect(() => { - const fetchPrompts = async () => { + const handleFetchPrompts = async () => { try { - const response = await fetch(`${apiHost}/api/get_prompts`); + const response = await userService.getPrompts(); if (!response.ok) { throw new Error('Failed to fetch prompts'); } @@ -81,14 +81,13 @@ const General: React.FC = () => { console.error(error); } }; - fetchPrompts(); + handleFetchPrompts(); }, []); React.useEffect(() => { localStorage.setItem('docsgpt-locale', selectedLanguage?.value as string); changeLanguage(selectedLanguage?.value); }, [selectedLanguage, changeLanguage]); - return (
@@ -171,7 +170,6 @@ const General: React.FC = () => { dispatch(setPrompt({ name: name, id: id, type: type })) } setPrompts={setPrompts} - apiHost={apiHost} />
@@ -189,6 +187,4 @@ const General: React.FC = () => {
); -}; - -export default General; +} diff --git a/frontend/src/settings/Prompts.tsx b/frontend/src/settings/Prompts.tsx index 2bae07eb..3d041555 100644 --- a/frontend/src/settings/Prompts.tsx +++ b/frontend/src/settings/Prompts.tsx @@ -1,15 +1,17 @@ import React from 'react'; -import { PromptProps, ActiveState } from '../models/misc'; -import Dropdown from '../components/Dropdown'; -import PromptsModal from '../preferences/PromptsModal'; import { useTranslation } from 'react-i18next'; -const apiHost = import.meta.env.VITE_API_HOST || 'https://docsapi.arc53.com'; -const Prompts: React.FC = ({ + +import userService from '../api/services/userService'; +import Dropdown from '../components/Dropdown'; +import { ActiveState, PromptProps } from '../models/misc'; +import PromptsModal from '../preferences/PromptsModal'; + +export default function Prompts({ prompts, selectedPrompt, onSelectPrompt, setPrompts, -}) => { +}: PromptProps) { const handleSelectPrompt = ({ name, id, @@ -37,17 +39,12 @@ const Prompts: React.FC = ({ t, i18n: { changeLanguage, language }, } = useTranslation(); + const handleAddPrompt = async () => { try { - const response = await fetch(`${apiHost}/api/create_prompt`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - name: newPromptName, - content: newPromptContent, - }), + const response = await userService.createPrompt({ + name: newPromptName, + content: newPromptContent, }); if (!response.ok) { throw new Error('Failed to add prompt'); @@ -69,18 +66,12 @@ const Prompts: React.FC = ({ const handleDeletePrompt = (id: string) => { setPrompts(prompts.filter((prompt) => prompt.id !== id)); - fetch(`${apiHost}/api/delete_prompt`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ id: id }), - }) + userService + .deletePrompt({ id }) .then((response) => { if (!response.ok) { throw new Error('Failed to delete prompt'); } - // get 1st prompt and set it as selected if (prompts.length > 0) { onSelectPrompt(prompts[0].name, prompts[0].id, prompts[0].type); } @@ -90,18 +81,9 @@ const Prompts: React.FC = ({ }); }; - const fetchPromptContent = async (id: string) => { - console.log('fetching prompt content'); + const handleFetchPromptContent = async (id: string) => { try { - const response = await fetch( - `${apiHost}/api/get_single_prompt?id=${id}`, - { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }, - ); + const response = await userService.getSinglePrompt(id); if (!response.ok) { throw new Error('Failed to fetch prompt content'); } @@ -113,17 +95,12 @@ const Prompts: React.FC = ({ }; const handleSaveChanges = (id: string, type: string) => { - fetch(`${apiHost}/api/update_prompt`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ + userService + .updatePrompt({ id: id, name: editPromptName, content: editPromptContent, - }), - }) + }) .then((response) => { if (!response.ok) { throw new Error('Failed to update prompt'); @@ -154,7 +131,6 @@ const Prompts: React.FC = ({ console.error(error); }); }; - return ( <>
@@ -183,7 +159,7 @@ const Prompts: React.FC = ({ }) => { setModalType('EDIT'); setEditPromptName(name); - fetchPromptContent(id); + handleFetchPromptContent(id); setCurrentPromptEdit({ id: id, name: name, type: type }); setModalState('ACTIVE'); }} @@ -219,6 +195,4 @@ const Prompts: React.FC = ({ /> ); -}; - -export default Prompts; +} diff --git a/frontend/src/settings/index.tsx b/frontend/src/settings/index.tsx index 3969fce6..226ebb3b 100644 --- a/frontend/src/settings/index.tsx +++ b/frontend/src/settings/index.tsx @@ -1,22 +1,22 @@ import React from 'react'; -import { useSelector, useDispatch } from 'react-redux'; -import General from './General'; -import Documents from './Documents'; -import APIKeys from './APIKeys'; -import Widgets from './Widgets'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; + +import userService from '../api/services/userService'; +import ArrowLeft from '../assets/arrow-left.svg'; +import ArrowRight from '../assets/arrow-right.svg'; +import i18n from '../locale/i18n'; +import { Doc } from '../preferences/preferenceApi'; import { selectSourceDocs, setSourceDocs, } from '../preferences/preferenceSlice'; -import { Doc } from '../preferences/preferenceApi'; -import ArrowLeft from '../assets/arrow-left.svg'; -import ArrowRight from '../assets/arrow-right.svg'; -import { useTranslation } from 'react-i18next'; -import i18n from '../locale/i18n'; +import APIKeys from './APIKeys'; +import Documents from './Documents'; +import General from './General'; +import Widgets from './Widgets'; -const apiHost = import.meta.env.VITE_API_HOST || 'https://docsapi.arc53.com'; - -const Settings: React.FC = () => { +export default function Settings() { const dispatch = useDispatch(); const { t } = useTranslation(); const tabs = [ @@ -33,11 +33,11 @@ const Settings: React.FC = () => { const updateWidgetScreenshot = (screenshot: File | null) => { setWidgetScreenshot(screenshot); }; + const handleDeleteClick = (index: number, doc: Doc) => { const docPath = 'indexes/' + 'local' + '/' + doc.name; - fetch(`${apiHost}/api/delete_old?path=${docPath}`, { - method: 'GET', - }) + userService + .deletePath(docPath) .then((response) => { if (response.ok && documents) { const updatedDocuments = [ @@ -50,7 +50,6 @@ const Settings: React.FC = () => { .catch((error) => console.error(error)); }; - // persist active tab as the translated version of 'general' per language change React.useEffect(() => { setActiveTab(t('settings.general.label')); }, [i18n.language]); @@ -134,6 +133,4 @@ const Settings: React.FC = () => { return null; } } -}; - -export default Settings; +} 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), diff --git a/frontend/src/upload/Upload.tsx b/frontend/src/upload/Upload.tsx index 2a1b51a9..3bb3e7ae 100644 --- a/frontend/src/upload/Upload.tsx +++ b/frontend/src/upload/Upload.tsx @@ -1,12 +1,14 @@ -import React, { useRef } from 'react'; -import { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useDropzone } from 'react-dropzone'; +import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; + +import userService from '../api/services/userService'; +import Dropdown from '../components/Dropdown'; +import Input from '../components/Input'; import { ActiveState } from '../models/misc'; import { getDocs } from '../preferences/preferenceApi'; import { setSelectedDocs, setSourceDocs } from '../preferences/preferenceSlice'; -import Dropdown from '../components/Dropdown'; -import { useTranslation } from 'react-i18next'; function Upload({ modalState, @@ -94,20 +96,6 @@ function Upload({ {/* progress bar */} - -
); } @@ -124,8 +112,8 @@ function Upload({ if ((progress?.percentage ?? 0) < 100) { timeoutID = setTimeout(() => { - const apiHost = import.meta.env.VITE_API_HOST; - fetch(`${apiHost}/api/task_status?task_id=${progress?.taskId}`) + userService + .getTaskStatus(progress?.taskId as string) .then((data) => data.json()) .then((data) => { if (data.status == 'SUCCESS') { @@ -163,6 +151,10 @@ function Upload({ failed: false, }, ); + setDocName(''); + setfiles([]); + setProgress(undefined); + setModalState('INACTIVE'); } } else if (data.status == 'PROGRESS') { setProgress( @@ -272,7 +264,9 @@ function Upload({ }, }); - const handleChange = (e: React.ChangeEvent) => { + const handleChange = ( + e: React.ChangeEvent, + ) => { const { name, value } = e.target; if (name === 'search_queries' && value.length > 0) { setRedditData({ @@ -323,12 +317,12 @@ function Upload({ {activeTab === 'file' && ( <> - setDocName(e.target.value)} - > + >
{t('modals.uploadDoc.name')} @@ -373,25 +367,23 @@ function Upload({ /> {urlType.label !== 'Reddit' ? ( <> - setUrlName(e.target.value)} - > + >
{t('modals.uploadDoc.name')}
- setUrl(e.target.value)} - > + >
{t('modals.uploadDoc.link')} @@ -400,66 +392,61 @@ function Upload({ ) : ( <> - + >
{t('modals.uploadDoc.reddit.id')}
- + >
{t('modals.uploadDoc.reddit.secret')}
- + >
{t('modals.uploadDoc.reddit.agent')}
- + >
{t('modals.uploadDoc.reddit.searchQueries')}
- + >
{t('modals.uploadDoc.reddit.numberOfPosts')} diff --git a/frontend/tailwind.config.cjs b/frontend/tailwind.config.cjs index 63f99513..938eacee 100644 --- a/frontend/tailwind.config.cjs +++ b/frontend/tailwind.config.cjs @@ -48,6 +48,7 @@ module.exports = { 'soap':'#D8CCF1', 'independence':'#54546D', 'philippine-yellow':'#FFC700', + 'bright-gray':'#EBEBEB' }, }, }, diff --git a/scripts/requirements.txt b/scripts/requirements.txt index c4aed5b2..4a7b26a7 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -7,7 +7,7 @@ faiss_cpu==1.7.4 html2text==2020.1.16 javalang==0.13.0 langchain==0.1.4 -langchain_community==0.0.16 +langchain_community==0.2.9 langchain-openai==0.0.5 nltk==3.8.1 openapi3_parser==1.1.16