diff --git a/application/api/user/routes.py b/application/api/user/routes.py index 62addc26..e67eb992 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,73 @@ 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(hasattr(data,"user")): + user = 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"]) + pre_existing = shared_conversations_collections.find_one({ + "conversation_id":DBRef("conversations",ObjectId(conversation_id)), + "isPromptable":isPromptable, + "first_n_queries":current_n_queries + }) + print("pre_existing",pre_existing) + if(pre_existing is not None): + explicit_binary = pre_existing["uuid"] + return jsonify({"success":True, "identifier":str(explicit_binary.as_uuid())}),200 + else: + explicit_binary = Binary.from_uuid(uuid.uuid4(), UuidRepresentation.STANDARD) + 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: + 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() + return jsonify({ + "success":True, + "queries":conversation_queries, + "title":conversation["name"], + "timestamp":date + }), 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..2b868da2 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", @@ -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..05792187 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/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/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/conversation/Conversation.tsx b/frontend/src/conversation/Conversation.tsx index 487e8ea7..115abcd8 100644 --- a/frontend/src/conversation/Conversation.tsx +++ b/frontend/src/conversation/Conversation.tsx @@ -11,6 +11,7 @@ import { selectStatus, updateQuery, } from './conversationSlice'; +import { selectConversationId } from '../preferences/preferenceSlice'; import Send from './../assets/send.svg'; import SendDark from './../assets/send_dark.svg'; import Spinner from './../assets/spinner.svg'; @@ -20,9 +21,13 @@ import { sendFeedback } from './conversationApi'; import { useTranslation } from 'react-i18next'; import ArrowDown from './../assets/arrow-down.svg'; import RetryIcon from '../components/RetryIcon'; +import ShareIcon from '../assets/share.svg'; +import { ShareConversationModal } from '../modals/ShareConversationModal'; + 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 +36,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 = () => { @@ -192,6 +198,31 @@ export default function Conversation() { return (
+ {conversationId && ( + <> + + {isShareModalOpen && ( + { + setShareModalState(false); + }} + conversationId={conversationId} + /> + )} + + )}
-
@@ -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..0fdc3567 100644 --- a/frontend/src/conversation/ConversationTile.tsx +++ b/frontend/src/conversation/ConversationTile.tsx @@ -5,11 +5,15 @@ 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; id: string; @@ -32,13 +36,19 @@ 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 [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,6 +60,18 @@ 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); @@ -79,7 +101,7 @@ export default function ConversationTile({ setConversationsName(e.target.value)} /> @@ -90,36 +112,108 @@ export default function ConversationTile({ )}
{conversationId === conversation.id && ( -
- Edit { - event.stopPropagation(); - isEdit - ? handleSaveConversation({ +
+ {isEdit ? ( +
+ Edit { + event.stopPropagation(); + handleSaveConversation({ id: conversationId, name: conversationName, - }) - : handleEditConversation(); - }} - /> - Exit { - event.stopPropagation(); - isEdit ? onClear() : onDeleteConversation(conversation.id); - }} - /> + }); + }} + /> + Exit { + event.stopPropagation(); + onClear(); + }} + /> +
+ ) : ( + + )} + {isOpen && ( +
+ + + +
+ )}
)} + onDeleteConversation(conversation.id)} + submitLabel={t('convTile.delete')} + /> + {isShareModalOpen && conversationId && ( + { + setShareModalState(false); + }} + conversationId={conversationId} + /> + )}
); } diff --git a/frontend/src/conversation/SharedConversation.tsx b/frontend/src/conversation/SharedConversation.tsx new file mode 100644 index 00000000..e365c6f0 --- /dev/null +++ b/frontend/src/conversation/SharedConversation.tsx @@ -0,0 +1,144 @@ +import { useState, useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; +import { Query } from './conversationModels'; +import { useTranslation } from 'react-i18next'; +import ConversationBubble from './ConversationBubble'; +import { Fragment } from 'react'; +const apiHost = import.meta.env.VITE_API_HOST || 'https://docsapi.arc53.com'; +const SharedConversation = () => { + const params = useParams(); + const navigate = useNavigate(); + const { identifier } = params; //identifier is a uuid, not conversationId + const [queries, setQueries] = useState([]); + const [title, setTitle] = useState(''); + const [date, setDate] = useState(''); + const { t } = useTranslation(); + 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; + } + const fetchQueris = () => { + fetch(`${apiHost}/api/shared_conversation/${identifier}`) + .then((res) => { + if (res.status === 404 || res.status === 400) navigate('/pagenotfound'); + return res.json(); + }) + .then((data) => { + if (data.success) { + setQueries(data.queries); + setTitle(data.title); + setDate(formatISODate(data.timestamp)); + } + }); + }; + + const prepResponseView = (query: Query, index: number) => { + let responseView; + if (query.response) { + responseView = ( + + ); + } else if (query.error) { + responseView = ( + + ); + } + return responseView; + }; + useEffect(() => { + fetchQueris(); + }, []); + + return ( +
+
+
+
+

+ {title} +

+

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

+

+ {date} +

+
+
+ {queries?.map((query, index) => { + return ( + + + + {prepResponseView(query, index)} + + ); + })} +
+
+
+ +
+ + + {t('sharedConv.meta')} + +
+
+ ); +}; + +export default SharedConversation; 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..7e0b3bda 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 diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index e59fbedc..0f5aa708 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -103,6 +103,22 @@ "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" } + }, + "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..b91b67e9 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -103,6 +103,22 @@ "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" } + }, + "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..75e62589 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -103,6 +103,22 @@ "deleteConv": { "confirm": "すべての会話を削除してもよろしいですか?", "delete": "削除" + }, + "shareConv": { + "label": "共有ページを作成して共有する", + "note": "ソースドキュメント、個人情報、および以降の会話は非公開のままになります", + "create": "作成" } + }, + "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..e46fe254 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -103,6 +103,22 @@ "deleteConv": { "confirm": "您确定要删除所有对话吗?", "delete": "删除" + }, + "shareConv": { + "label": "创建用于分享的公共页面", + "note": "源文档、个人信息和后续对话将保持私密", + "create": "创建" } + }, + "sharedConv": { + "subtitle": "使用创建", + "button": "开始使用 DocsGPT", + "meta": "DocsGPT 使用 GenAI,请使用资源查看关键信息。" + }, + "convTile": { + "share": "分享", + "delete": "删除", + "rename": "重命名", + "deleteWarning": "您确定要删除此对话吗?" } } diff --git a/frontend/src/modals/ShareConversationModal.tsx b/frontend/src/modals/ShareConversationModal.tsx new file mode 100644 index 00000000..37da934d --- /dev/null +++ b/frontend/src/modals/ShareConversationModal.tsx @@ -0,0 +1,90 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import Spinner from '../assets/spinner.svg'; +import Exit from '../assets/exit.svg'; +const apiHost = import.meta.env.VITE_API_HOST || 'https://docsapi.arc53.com'; + +export const ShareConversationModal = ({ + close, + conversationId, +}: { + close: () => void; + conversationId: string; +}) => { + const [identifier, setIdentifier] = useState(null); + const [isCopied, setIsCopied] = useState(false); + type StatusType = 'loading' | 'idle' | 'fetched' | 'failed'; + const [status, setStatus] = useState('idle'); + const { t } = useTranslation(); + const domain = window.location.origin; + const handleCopyKey = (url: string) => { + navigator.clipboard.writeText(url); + setIsCopied(true); + }; + const shareCoversationPublicly: (isPromptable: boolean) => void = ( + isPromptable = false, + ) => { + setStatus('loading'); + fetch(`${apiHost}/api/share?isPromptable=${isPromptable}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ conversation_id: conversationId }), + }) + .then((res) => { + console.log(res.status); + 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')}

+
+ {`${domain}/share/${ + identifier ?? '....' + }`} + {status === 'fetched' ? ( + + ) : ( + + )} +
+
+
+
+ ); +}; 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' }, }, },