This commit is contained in:
utin-francis-peter
2024-07-16 22:05:12 +01:00
31 changed files with 852 additions and 205 deletions

View File

@@ -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<AppDispatch>();
const endMessageRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLDivElement>(null);
@@ -31,6 +36,7 @@ export default function Conversation() {
const fetchStream = useRef<any>(null);
const [eventInterrupt, setEventInterrupt] = useState(false);
const [lastQueryReturnedErr, setLastQueryReturnedErr] = useState(false);
const [isShareModalOpen, setShareModalState] = useState<boolean>(false);
const { t } = useTranslation();
const handleUserInterruption = () => {
@@ -191,11 +197,36 @@ export default function Conversation() {
};
return (
<div className="flex h-screen flex-col gap-1">
<div className="flex h-screen flex-col gap-7 pb-2">
{conversationId && (
<>
<button
title="Share"
onClick={() => {
setShareModalState(true);
}}
className="fixed top-4 right-20 z-30 rounded-full hover:bg-bright-gray dark:hover:bg-[#28292E]"
>
<img
className="m-2 h-5 w-5 filter dark:invert"
alt="share"
src={ShareIcon}
/>
</button>
{isShareModalOpen && (
<ShareConversationModal
close={() => {
setShareModalState(false);
}}
conversationId={conversationId}
/>
)}
</>
)}
<div
onWheel={handleUserInterruption}
onTouchMove={handleUserInterruption}
className="flex h-[90%] w-full justify-center overflow-y-auto p-4 md:h-[83vh]"
className="flex h-[90%] w-full flex-1 justify-center overflow-y-auto p-4 md:h-[83vh]"
>
{queries.length > 0 && !hasScrolledToLast && (
<button
@@ -234,8 +265,8 @@ export default function Conversation() {
{queries.length === 0 && <Hero handleQuestion={handleQuestion} />}
</div>
<div className="bottom-safe fixed flex w-11/12 flex-col items-end self-center rounded-2xl bg-opacity-0 pb-1 sm:w-6/12">
<div className="flex h-full w-full items-center rounded-full border border-silver bg-white dark:bg-raisin-black">
<div className="flex w-11/12 flex-col items-end self-center rounded-2xl bg-opacity-0 pb-1 sm:w-6/12">
<div className="flex h-full w-full items-center rounded-[40px] border border-silver bg-white py-1 dark:bg-raisin-black">
<div
id="inputbox"
ref={inputRef}

View File

@@ -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';
@@ -42,7 +42,7 @@ const ConversationBubble = forwardRef<
bubble = (
<div ref={ref} className={`flex flex-row-reverse self-end ${className}`}>
<Avatar className="mt-2 text-2xl" avatar="🧑‍💻"></Avatar>
<div className="ml-10 mr-2 flex items-center rounded-3xl bg-purple-30 p-3.5 text-white">
<div className="ml-10 mr-2 flex items-center rounded-[28px] bg-purple-30 py-[14px] px-[19px] text-white">
<ReactMarkdown className="whitespace-pre-wrap break-normal leading-normal">
{message}
</ReactMarkdown>
@@ -53,7 +53,7 @@ const ConversationBubble = forwardRef<
bubble = (
<div
ref={ref}
className={`flex flex-wrap self-start ${className} group flex-col pr-20 dark:text-bright-gray`}
className={`flex flex-wrap self-start ${className} group flex-col dark:text-bright-gray`}
>
<div className="flex flex-wrap self-start lg:flex-nowrap">
<Avatar
@@ -68,7 +68,7 @@ const ConversationBubble = forwardRef<
/>
<div
className={`ml-2 mr-5 flex max-w-[90vw] rounded-3xl bg-gray-1000 p-3.5 dark:bg-gun-metal md:max-w-[70vw] lg:max-w-[50vw] ${
className={`ml-2 mr-5 flex max-w-[90vw] rounded-[28px] bg-gray-1000 py-[14px] px-7 dark:bg-gun-metal md:max-w-[70vw] lg:max-w-[50vw] ${
type === 'ERROR'
? 'relative flex-row items-center rounded-full border border-transparent bg-[#FFE7E7] p-2 py-5 text-sm font-normal text-red-3000 dark:border-red-2000 dark:text-white'
: 'flex-col rounded-3xl'
@@ -103,7 +103,7 @@ const ConversationBubble = forwardRef<
className={`absolute right-3 top-3 lg:invisible
${type !== 'ERROR' ? 'group-hover:lg:visible' : ''} `}
>
<CoppyButton
<CopyButton
text={String(children).replace(/\n$/, '')}
/>
</div>
@@ -208,85 +208,89 @@ const ConversationBubble = forwardRef<
</>
)}
</div>
<div className="flex justify-center">
<div
className={`relative mr-5 block items-center justify-center lg:invisible
</div>
<div className="my-2 flex justify-start lg:ml-12">
<div
className={`relative mr-5 block items-center justify-center lg:invisible
${type !== 'ERROR' ? 'group-hover:lg:visible' : ''}`}
>
<div className="absolute left-2 top-4">
<CoppyButton text={message} />
</div>
>
<div>
<CopyButton text={message} />
</div>
<div
className={`relative mr-5 flex items-center justify-center ${
!isLikeClicked ? 'lg:invisible' : ''
} ${
feedback === 'LIKE' || type !== 'ERROR'
? 'group-hover:lg:visible'
: ''
}`}
>
<div className="absolute left-6 top-4">
<div
className={`flex items-center justify-center rounded-full p-2 dark:bg-transparent ${
isLikeHovered
? 'bg-[#EEEEEE] dark:bg-purple-taupe'
: 'bg-[#ffffff] dark:bg-transparent'
}`}
>
<Like
className={`cursor-pointer
</div>
{handleFeedback && (
<>
<div
className={`relative mr-5 flex items-center justify-center ${
!isLikeClicked ? 'lg:invisible' : ''
} ${
feedback === 'LIKE' || type !== 'ERROR'
? 'group-hover:lg:visible'
: ''
}`}
>
<div>
<div
className={`flex items-center justify-center rounded-full p-2 dark:bg-transparent ${
isLikeHovered
? 'bg-[#EEEEEE] dark:bg-purple-taupe'
: 'bg-[#ffffff] dark:bg-transparent'
}`}
>
<Like
className={`cursor-pointer
${
isLikeClicked || feedback === 'LIKE'
? 'fill-white-3000 stroke-purple-30 dark:fill-transparent'
: 'fill-none stroke-gray-4000'
}`}
onClick={() => {
handleFeedback?.('LIKE');
setIsLikeClicked(true);
setIsDislikeClicked(false);
}}
onMouseEnter={() => setIsLikeHovered(true)}
onMouseLeave={() => setIsLikeHovered(false)}
></Like>
onClick={() => {
handleFeedback?.('LIKE');
setIsLikeClicked(true);
setIsDislikeClicked(false);
}}
onMouseEnter={() => setIsLikeHovered(true)}
onMouseLeave={() => setIsLikeHovered(false)}
></Like>
</div>
</div>
</div>
</div>
<div
className={`mr-13 relative flex items-center justify-center ${
!isDislikeClicked ? 'lg:invisible' : ''
} ${
feedback === 'DISLIKE' || type !== 'ERROR'
? 'group-hover:lg:visible'
: ''
}`}
>
<div className="absolute left-10 top-4">
<div
className={`flex items-center justify-center rounded-full p-2 ${
isDislikeHovered
? 'bg-[#EEEEEE] dark:bg-purple-taupe'
: 'bg-[#ffffff] dark:bg-transparent'
}`}
>
<Dislike
className={`cursor-pointer ${
isDislikeClicked || feedback === 'DISLIKE'
? 'fill-white-3000 stroke-red-2000 dark:fill-transparent'
: 'fill-none stroke-gray-4000'
<div
className={`mr-13 relative flex items-center justify-center ${
!isDislikeClicked ? 'lg:invisible' : ''
} ${
feedback === 'DISLIKE' || type !== 'ERROR'
? 'group-hover:lg:visible'
: ''
}`}
>
<div>
<div
className={`flex items-center justify-center rounded-full p-2 ${
isDislikeHovered
? 'bg-[#EEEEEE] dark:bg-purple-taupe'
: 'bg-[#ffffff] dark:bg-transparent'
}`}
onClick={() => {
handleFeedback?.('DISLIKE');
setIsDislikeClicked(true);
setIsLikeClicked(false);
}}
onMouseEnter={() => setIsDislikeHovered(true)}
onMouseLeave={() => setIsDislikeHovered(false)}
></Dislike>
>
<Dislike
className={`cursor-pointer ${
isDislikeClicked || feedback === 'DISLIKE'
? 'fill-white-3000 stroke-red-2000 dark:fill-transparent'
: 'fill-none stroke-gray-4000'
}`}
onClick={() => {
handleFeedback?.('DISLIKE');
setIsDislikeClicked(true);
setIsLikeClicked(false);
}}
onMouseEnter={() => setIsDislikeHovered(true)}
onMouseLeave={() => setIsDislikeHovered(false)}
></Dislike>
</div>
</div>
</div>
</div>
</div>
</>
)}
</div>
{sources && openSource !== null && sources[openSource] && (

View File

@@ -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<boolean>(false);
const [isShareModalOpen, setShareModalState] = useState<boolean>(false);
const [deleteModalState, setDeleteModalState] =
useState<ActiveState>('INACTIVE');
const menuRef = useRef<HTMLDivElement>(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({
<input
autoFocus
type="text"
className="h-6 w-full px-1 text-sm font-normal leading-6 outline-[#0075FF] focus:outline-1"
className="h-6 w-full bg-transparent px-1 text-sm font-normal leading-6 focus:outline-[#0075FF]"
value={conversationName}
onChange={(e) => setConversationsName(e.target.value)}
/>
@@ -90,36 +112,108 @@ export default function ConversationTile({
)}
</div>
{conversationId === conversation.id && (
<div className="flex text-white dark:text-[#949494]">
<img
src={isEdit ? CheckMark2 : Edit}
alt="Edit"
className="mr-2 h-4 w-4 cursor-pointer text-white hover:opacity-50"
id={`img-${conversation.id}`}
onClick={(event) => {
event.stopPropagation();
isEdit
? handleSaveConversation({
<div className="flex text-white dark:text-[#949494]" ref={menuRef}>
{isEdit ? (
<div className="flex gap-1">
<img
src={CheckMark2}
alt="Edit"
className="mr-2 h-4 w-4 cursor-pointer text-white hover:opacity-50"
id={`img-${conversation.id}`}
onClick={(event) => {
event.stopPropagation();
handleSaveConversation({
id: conversationId,
name: conversationName,
})
: handleEditConversation();
}}
/>
<img
src={isEdit ? Exit : Trash}
alt="Exit"
className={`mr-4 ${
isEdit ? 'h-3 w-3' : 'h-4 w-4'
}mt-px cursor-pointer hover:opacity-50`}
id={`img-${conversation.id}`}
onClick={(event) => {
event.stopPropagation();
isEdit ? onClear() : onDeleteConversation(conversation.id);
}}
/>
});
}}
/>
<img
src={isEdit ? Exit : Trash}
alt="Exit"
className={`mr-4 mt-px h-3 w-3 cursor-pointer hover:opacity-50`}
id={`img-${conversation.id}`}
onClick={(event) => {
event.stopPropagation();
onClear();
}}
/>
</div>
) : (
<button onClick={() => setOpen(!isOpen)}>
<img src={threeDots} className="mr-4 w-2" />
</button>
)}
{isOpen && (
<div className="flex-start absolute flex w-32 translate-x-1 translate-y-5 flex-col rounded-xl bg-stone-100 text-sm text-black shadow-xl dark:bg-chinese-black dark:text-chinese-silver md:w-36">
<button
onClick={() => {
setShareModalState(true);
setOpen(false);
}}
className="flex-start flex items-center gap-4 rounded-t-xl p-3 hover:bg-bright-gray dark:hover:bg-dark-charcoal"
>
<img
src={Share}
alt="Share"
width={14}
height={14}
className="cursor-pointer hover:opacity-50"
id={`img-${conversation.id}`}
/>
<span>{t('convTile.share')}</span>
</button>
<button
onClick={(event) => {
handleEditConversation();
}}
className="flex-start flex items-center gap-4 p-3 hover:bg-bright-gray dark:hover:bg-dark-charcoal"
>
<img
src={Edit}
alt="Edit"
width={16}
height={16}
className="cursor-pointer hover:opacity-50"
id={`img-${conversation.id}`}
/>
<span>{t('convTile.rename')}</span>
</button>
<button
onClick={(event) => {
setDeleteModalState('ACTIVE');
setOpen(false);
}}
className="flex-start flex items-center gap-3 rounded-b-xl p-2 text-red-700 hover:bg-bright-gray dark:hover:bg-dark-charcoal"
>
<img
src={Trash}
alt="Edit"
width={24}
height={24}
className="cursor-pointer hover:opacity-50"
/>
<span>{t('convTile.delete')}</span>
</button>
</div>
)}
</div>
)}
<ConfirmationModal
message={t('convTile.deleteWarning')}
modalState={deleteModalState}
setModalState={setDeleteModalState}
handleSubmit={() => onDeleteConversation(conversation.id)}
submitLabel={t('convTile.delete')}
/>
{isShareModalOpen && conversationId && (
<ShareConversationModal
close={() => {
setShareModalState(false);
}}
conversationId={conversationId}
/>
)}
</div>
);
}

View File

@@ -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<Query[]>([]);
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 = (
<ConversationBubble
className={`${index === queries.length - 1 ? 'mb-32' : 'mb-7'}`}
key={`${index}ANSWER`}
message={query.response}
type={'ANSWER'}
></ConversationBubble>
);
} else if (query.error) {
responseView = (
<ConversationBubble
className={`${index === queries.length - 1 ? 'mb-32' : 'mb-7'} `}
key={`${index}ERROR`}
message={query.error}
type="ERROR"
></ConversationBubble>
);
}
return responseView;
};
useEffect(() => {
fetchQueris();
}, []);
return (
<div className="flex h-full flex-col items-center justify-between gap-2 overflow-y-hidden dark:bg-raisin-black">
<div className="flex w-full justify-center overflow-auto">
<div className="mt-0 w-11/12 md:w-10/12 lg:w-6/12">
<div className="mb-2 w-full border-b pb-2">
<h1 className="font-semi-bold text-4xl text-chinese-black dark:text-chinese-silver">
{title}
</h1>
<h2 className="font-semi-bold text-base text-chinese-black dark:text-chinese-silver">
{t('sharedConv.subtitle')}{' '}
<a href="/" className="text-[#007DFF]">
DocsGPT
</a>
</h2>
<h2 className="font-semi-bold text-base text-chinese-black dark:text-chinese-silver">
{date}
</h2>
</div>
<div className="">
{queries?.map((query, index) => {
return (
<Fragment key={index}>
<ConversationBubble
className={'mb-1 last:mb-28 md:mb-7'}
key={`${index}QUESTION`}
message={query.prompt}
type="QUESTION"
sources={query.sources}
></ConversationBubble>
{prepResponseView(query, index)}
</Fragment>
);
})}
</div>
</div>
</div>
<div className=" flex flex-col items-center gap-4 pb-2">
<button
onClick={() => navigate('/')}
className="w-fit rounded-full bg-purple-30 p-4 text-white shadow-xl transition-colors duration-200 hover:bg-purple-taupe"
>
{t('sharedConv.button')}
</button>
<span className="hidden text-xs text-dark-charcoal dark:text-silver sm:inline">
{t('sharedConv.meta')}
</span>
</div>
</div>
);
};
export default SharedConversation;