optimistic ui for message sending and submit state

This commit is contained in:
Danny Avila 2023-02-07 16:22:35 -05:00
parent 9d41ed4615
commit 6842ac880c
19 changed files with 430 additions and 92 deletions

View file

@ -1,5 +1,5 @@
const mongoose = require('mongoose');
const { getMessages } = require('./Message');
const { getMessages, deleteMessages } = require('./Message');
const convoSchema = mongoose.Schema({
conversationId: {
@ -26,7 +26,7 @@ const Conversation =
mongoose.models.Conversation || mongoose.model('Conversation', convoSchema);
module.exports = {
saveConversation: async ({ conversationId, parentMessageId, title }) => {
saveConvo: async ({ conversationId, parentMessageId, title }) => {
const messages = await getMessages({ conversationId });
const update = { parentMessageId, messages };
if (title) {
@ -39,5 +39,16 @@ module.exports = {
{ new: true, upsert: true }
).exec();
},
getConversations: async () => await Conversation.find({}).exec(),
getConvos: async () => await Conversation.find({}).exec(),
deleteConvos: async (filter) => {
// const filter = {};
// if (!!conversationId) {
// filter = conversationId;
// }
let deleteCount = await Conversation.deleteMany(filter).exec();
deleteCount.messages = await deleteMessages(filter);
return deleteCount;
}
};

View file

@ -1,8 +1,8 @@
const express = require('express');
const dbConnect = require('../models/dbConnect');
const { ask, titleConversation } = require('../app/chatgpt');
const { saveMessage, getMessages, deleteAllMessages } = require('../models/Message');
const { saveConversation, getConversations } = require('../models/Conversation');
const { saveMessage, getMessages } = require('../models/Message');
const { saveConvo, getConvos, deleteConvos } = require('../models/Conversation');
const crypto = require('crypto');
const path = require('path');
const cors = require('cors');
@ -22,7 +22,7 @@ app.get('/', function (req, res) {
});
app.get('/convos', async (req, res) => {
res.status(200).send(await getConversations());
res.status(200).send(await getConvos());
});
app.get('/messages/:conversationId', async (req, res) => {
@ -31,10 +31,20 @@ app.get('/messages/:conversationId', async (req, res) => {
});
app.post('/clear_convos', async (req, res) => {
const { conversationId } = req.body;
let filter = {};
const { conversationId } = req.body.arg;
console.log('conversationId', conversationId);
const filter = {};
res.status(201).send(await deleteAllMessages(filter));
if (!!conversationId) {
filter = { conversationId };
}
try {
const dbResponse = await deleteConvos(filter);
res.status(201).send(dbResponse);
} catch (error) {
console.error(error);
res.status(500).send(error);
}
});
app.post('/ask', async (req, res) => {
@ -51,6 +61,8 @@ app.post('/ask', async (req, res) => {
'X-Accel-Buffering': 'no'
});
// res.write(`event: message\ndata: ${JSON.stringify('')}\n\n`);
let i = 0;
const progressCallback = async (partial) => {
// console.log('partial', partial);
@ -74,7 +86,7 @@ app.post('/ask', async (req, res) => {
gptResponse.sender = 'GPT';
await saveMessage(gptResponse);
await saveConversation(gptResponse);
await saveConvo(gptResponse);
res.write(`event: message\ndata: ${JSON.stringify(gptResponse)}\n\n`);
res.end();

View file

@ -4,14 +4,14 @@ import Messages from './components/main/Messages';
import TextChat from './components/main/TextChat';
import Nav from './components/Nav';
import MobileNav from './components/Nav/MobileNav';
import useSWR from 'swr';
const fetcher = (url) => fetch(url).then((res) => res.json());
// const postRequest = async (url, { arg }) => await axios.post(url, { arg });
import { swr } from './utils/fetchers';
import useDidMountEffect from './hooks/useDidMountEffect';
const App = () => {
const messages = useSelector((state) => state.messages);
const { data, error, isLoading, mutate } = useSWR('http://localhost:3050/convos', fetcher);
const { messages } = useSelector((state) => state.messages);
const convo = useSelector((state) => state.convo);
const { data, error, isLoading, mutate } = swr('http://localhost:3050/convos');
useDidMountEffect(() => mutate(), [convo]);
return (
<div className="flex h-screen">
@ -22,7 +22,10 @@ const App = () => {
{/* <main className="relative h-full w-full transition-width flex flex-col overflow-hidden items-stretch flex-1"> */}
<MobileNav />
<Messages messages={messages} />
<TextChat messages={messages} reloadConvos={mutate} />
<TextChat
messages={messages}
reloadConvos={mutate}
/>
{/* </main> */}
</div>
</div>

View file

@ -4,32 +4,29 @@ import DeleteButton from './DeleteButton';
import { useSelector, useDispatch } from 'react-redux';
import { setConversation } from '~/store/convoSlice';
import { setMessages } from '~/store/messageSlice';
import useSWRMutation from 'swr/mutation';
const fetcher = (url) => fetch(url).then((res) => res.json());
import manualSWR from '~/utils/fetchers';
export default function Conversation({ id, parentMessageId, title = 'New conversation' }) {
const dispatch = useDispatch();
const conversationId = useSelector((state) => state.convo.conversationId);
const { trigger, isMutating } = useSWRMutation(
const { trigger, isMutating } = manualSWR(
`http://localhost:3050/messages/${id}`,
fetcher,
{
onSuccess: function (res) {
dispatch(setMessages(res));
}
}
'get',
(res) => dispatch(setMessages(res))
);
const onConvoClick = (id, parentMessageId) => {
const clickHandler = () => {
if (conversationId === id) {
return;
}
dispatch(setConversation({ conversationId: id, parentMessageId }));
trigger();
};
return (
<a
onClick={() => onConvoClick(id, parentMessageId)}
onClick={() => clickHandler()}
className="animate-flash group relative flex cursor-pointer items-center gap-3 break-all rounded-md bg-gray-800 py-3 px-3 pr-14 hover:bg-gray-800"
>
<svg
@ -50,8 +47,8 @@ export default function Conversation({ id, parentMessageId, title = 'New convers
{title}
</div>
<div className="visible absolute right-1 z-10 flex text-gray-300">
{id === conversationId && <RenameButton />}
{id === conversationId && <DeleteButton />}
{id === conversationId && <RenameButton conversationId={id} />}
{id === conversationId && <DeleteButton conversationId={id} />}
</div>
</a>
);

View file

@ -1,37 +1,29 @@
import React from 'react';
import TrashIcon from '../svg/TrashIcon';
import manualSWR from '~/utils/fetchers';
import { useDispatch } from 'react-redux';
import { setConversation } from '~/store/convoSlice';
import { setMessages } from '~/store/messageSlice';
export default function DeleteButton({ conversationId }) {
const dispatch = useDispatch();
const { trigger, isMutating } = manualSWR(
'http://localhost:3050/clear_convos',
'post',
() => {
dispatch(setMessages([]));
dispatch(setConversation({ conversationId: null, parentMessageId: null }));
}
);
const clickHandler = () => trigger({ conversationId });
export default function DeleteButton({ onClick, disabled }) {
return (
<button className="p-1 hover:text-white">
<TrashIcon />
{/* <svg
stroke="currentColor"
fill="none"
strokeWidth="2"
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
<button
className="p-1 hover:text-white"
onClick={clickHandler}
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<line
x1="10"
y1="11"
x2="10"
y2="17"
/>
<line
x1="14"
y1="11"
x2="14"
y2="17"
/>
</svg> */}
<TrashIcon />
</button>
);
}

View file

@ -1,10 +1,14 @@
import React from 'react';
export default function NavLink({ svg, text }) {
return (
<a className="flex cursor-pointer items-center gap-3 rounded-md py-3 px-3 text-sm text-white transition-colors duration-200 hover:bg-gray-500/10">
{svg()}
{text}
</a>
);
export default function NavLink({ svg, text, clickHandler}) {
// const props
// if (clickHandler) {
// }
// return (
// <a {clickHandler && onClick={clickHandler}} className="flex cursor-pointer items-center gap-3 rounded-md py-3 px-3 text-sm text-white transition-colors duration-200 hover:bg-gray-500/10">
// {svg()}
// {text}
// </a>
// );
}

View file

@ -3,13 +3,31 @@ import NavLink from './NavLink';
import TrashIcon from '../svg/TrashIcon';
import DarkModeIcon from '../svg/DarkModeIcon';
import LogOutIcon from '../svg/LogOutIcon';
import manualSWR from '~/utils/fetchers';
import { useDispatch } from 'react-redux';
import { setConversation } from '~/store/convoSlice';
import { setMessages } from '~/store/messageSlice';
export default function NavLinks() {
const dispatch = useDispatch();
const { trigger, isMutating } = manualSWR(
'http://localhost:3050/clear_convos',
'post',
() => {
dispatch(setMessages([]));
dispatch(setConversation({ conversationId: null, parentMessageId: null }));
}
);
const clickHandler = () => trigger({});
return (
<>
<NavLink
svg={TrashIcon}
text="Clear conversations"
onClick={clickHandler}
/>
<NavLink
svg={DarkModeIcon}

View file

@ -1,8 +1,21 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import { setConversation } from '~/store/convoSlice';
import { setMessages } from '~/store/messageSlice';
export default function NewChat() {
const dispatch = useDispatch();
const clickHandler = () => {
dispatch(setMessages([]));
dispatch(setConversation({ conversationId: null, parentMessageId: null }));
};
return (
<a className="mb-2 flex flex-shrink-0 cursor-pointer items-center gap-3 rounded-md border border-white/20 py-3 px-3 text-sm text-white transition-colors duration-200 hover:bg-gray-500/10">
<a
onClick={clickHandler}
className="mb-2 flex flex-shrink-0 cursor-pointer items-center gap-3 rounded-md border border-white/20 py-3 px-3 text-sm text-white transition-colors duration-200 hover:bg-gray-500/10"
>
<svg
stroke="currentColor"
fill="none"

View file

@ -0,0 +1,170 @@
import React from 'react';
export default function Landing() {
return (
<div className="flex h-full flex-col items-center text-sm dark:bg-gray-800">
<div className="w-full px-6 text-gray-800 dark:text-gray-100 md:flex md:max-w-2xl md:flex-col lg:max-w-3xl">
<h1 className="mt-6 ml-auto mr-auto mb-10 flex items-center justify-center gap-2 text-center text-4xl font-semibold sm:mt-[20vh] sm:mb-16">
ChatGPT Clone
</h1>
<div className="items-start gap-3.5 text-center md:flex">
<div className="mb-8 flex flex-1 flex-col gap-3.5 md:mb-auto">
<h2 className="m-auto flex items-center gap-3 text-lg font-normal md:flex-col md:gap-2">
<svg
stroke="currentColor"
fill="none"
strokeWidth="1.5"
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="h-6 w-6"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="12"
cy="12"
r="5"
></circle>
<line
x1="12"
y1="1"
x2="12"
y2="3"
/>
<line
x1="12"
y1="21"
x2="12"
y2="23"
/>
<line
x1="4.22"
y1="4.22"
x2="5.64"
y2="5.64"
/>
<line
x1="18.36"
y1="18.36"
x2="19.78"
y2="19.78"
/>
<line
x1="1"
y1="12"
x2="3"
y2="12"
/>
<line
x1="21"
y1="12"
x2="23"
y2="12"
/>
<line
x1="4.22"
y1="19.78"
x2="5.64"
y2="18.36"
/>
<line
x1="18.36"
y1="5.64"
x2="19.78"
y2="4.22"
/>
</svg>
Examples
</h2>
<ul className="m-auto flex w-full flex-col gap-3.5 sm:max-w-md">
<button className="w-full rounded-md bg-gray-50 p-3 hover:bg-gray-200 dark:bg-white/5 dark:hover:bg-gray-900">
"Explain quantum computing in simple terms"
</button>
<button className="w-full rounded-md bg-gray-50 p-3 hover:bg-gray-200 dark:bg-white/5 dark:hover:bg-gray-900">
"Got any creative ideas for a 10 year olds birthday?"
</button>
<button className="w-full rounded-md bg-gray-50 p-3 hover:bg-gray-200 dark:bg-white/5 dark:hover:bg-gray-900">
"How do I make an HTTP request in Javascript?"
</button>
</ul>
</div>
<div className="mb-8 flex flex-1 flex-col gap-3.5 md:mb-auto">
<h2 className="m-auto flex items-center gap-3 text-lg font-normal md:flex-col md:gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
aria-hidden="true"
className="h-6 w-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z"
/>
</svg>
Capabilities
</h2>
<ul className="m-auto flex w-full flex-col gap-3.5 sm:max-w-md">
<li className="w-full rounded-md bg-gray-50 p-3 dark:bg-white/5">
Remembers what user said earlier in the conversation
</li>
<li className="w-full rounded-md bg-gray-50 p-3 dark:bg-white/5">
Allows user to provide follow-up corrections
</li>
<li className="w-full rounded-md bg-gray-50 p-3 dark:bg-white/5">
Trained to decline inappropriate requests
</li>
</ul>
</div>
<div className="mb-8 flex flex-1 flex-col gap-3.5 md:mb-auto">
<h2 className="m-auto flex items-center gap-3 text-lg font-normal md:flex-col md:gap-2">
<svg
stroke="currentColor"
fill="none"
strokeWidth="1.5"
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="h-6 w-6"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line
x1="12"
y1="9"
x2="12"
y2="13"
/>
<line
x1="12"
y1="17"
x2="12.01"
y2="17"
/>
</svg>
Limitations
</h2>
<ul className="m-auto flex w-full flex-col gap-3.5 sm:max-w-md">
<li className="w-full rounded-md bg-gray-50 p-3 dark:bg-white/5">
May occasionally generate incorrect information
</li>
<li className="w-full rounded-md bg-gray-50 p-3 dark:bg-white/5">
May occasionally produce harmful instructions or biased content
</li>
<li className="w-full rounded-md bg-gray-50 p-3 dark:bg-white/5">
Limited knowledge of world and events after 2021
</li>
</ul>
</div>
</div>
</div>
</div>
);
}

View file

@ -1,6 +1,8 @@
import React from 'react';
import { useSelector } from 'react-redux';
export default function Message({ sender, text }) {
export default function Message({ sender, text, last = false}) {
const { isSubmitting } = useSelector((state) => state.submit);
const props = {
className:
'group w-full border-b border-black/10 text-gray-800 dark:border-gray-900/50 dark:bg-gray-800 dark:text-gray-100'
@ -16,7 +18,10 @@ export default function Message({ sender, text }) {
<div className="m-auto flex gap-4 p-4 text-base md:max-w-2xl md:gap-6 md:py-6 lg:max-w-2xl lg:px-0 xl:max-w-3xl">
<strong className="relative flex w-[30px] flex-col items-end">{sender}:</strong>
<div className="relative flex w-[calc(100%-50px)] flex-col gap-1 whitespace-pre-wrap md:gap-3 lg:w-[calc(100%-115px)]">
<span>
{text}
{isSubmitting && last && sender === 'GPT' && <span className="blink"></span>}
</span>
</div>
</div>
</div>

View file

@ -1,7 +1,13 @@
import React, { useEffect, useRef } from 'react';
import Message from './Message';
import Landing from './Landing';
export default function Messages({ messages }) {
if (messages.length === 0) {
return <Landing />
};
const messagesEndRef = useRef(null);
const scrollToBottom = () => {
@ -23,6 +29,7 @@ export default function Messages({ messages }) {
key={i}
sender={message.sender}
text={message.text}
last={i === messages.length - 1}
/>
))}
<div ref={messagesEndRef} />

View file

@ -1,8 +1,30 @@
import React from 'react';
import { useSelector } from 'react-redux';
import useDidMountEffect from '~/hooks/useDidMountEffect';
export default function SubmitButton({ onClick, disabled }) {
export default function SubmitButton({ submitMessage }) {
const { isSubmitting } = useSelector((state) => state.submit);
const clickHandler = (e) => {
e.preventDefault();
submitMessage();
};
if (isSubmitting) {
return (
<button onClick={onClick} className="absolute bottom-1.5 right-1 rounded-md p-1 text-gray-500 hover:bg-gray-100 disabled:hover:bg-transparent dark:hover:bg-gray-900 dark:hover:text-gray-400 dark:disabled:hover:bg-transparent md:bottom-2.5 md:right-2">
<button className="absolute bottom-1.5 right-1 rounded-md p-1 text-gray-500 hover:bg-gray-100 disabled:bottom-0.5 disabled:hover:bg-transparent dark:hover:bg-gray-900 dark:hover:text-gray-400 dark:disabled:hover:bg-transparent md:bottom-2.5 md:right-2 md:disabled:bottom-1">
<div className="text-2xl">
<span >·</span>
<span className="blink">·</span>
<span className="blink2">·</span>
</div>
</button>
);
}
return (
<button
onClick={clickHandler}
className="absolute bottom-1.5 right-1 rounded-md p-1 text-gray-500 hover:bg-gray-100 disabled:hover:bg-transparent dark:hover:bg-gray-900 dark:hover:text-gray-400 dark:disabled:hover:bg-transparent md:bottom-2.5 md:right-2"
>
<svg
stroke="currentColor"
fill="none"
@ -26,3 +48,7 @@ export default function SubmitButton({ onClick, disabled }) {
</button>
);
}
{
/* <div class="text-2xl"><span class="">·</span><span class="">·</span><span class="invisible">·</span></div> */
}

View file

@ -5,16 +5,23 @@ import handleSubmit from '~/utils/handleSubmit';
import { useSelector, useDispatch } from 'react-redux';
import { setConversation } from '~/store/convoSlice';
import { setMessages } from '~/store/messageSlice';
import { setSubmitState } from '~/store/submitSlice';
export default function TextChat({ messages, reloadConvos }) {
const [text, setText] = useState('');
const dispatch = useDispatch();
const convo = useSelector((state) => state.convo);
const { isSubmitting } = useSelector((state) => state.submit);
const submitMessage = () => {
if (!!isSubmitting || text.trim() === '') {
return;
}
dispatch(setSubmitState(true));
const payload = text.trim();
const currentMsg = { sender: 'user', text: payload, current: true };
dispatch(setMessages([...messages, currentMsg]));
const initialResponse = { sender: 'GPT', text: '' };
dispatch(setMessages([...messages, currentMsg, initialResponse]));
setText('');
const messageHandler = (data) => {
dispatch(setMessages([...messages, currentMsg, { sender: 'GPT', text: data }]));
@ -26,6 +33,7 @@ export default function TextChat({ messages, reloadConvos }) {
}
reloadConvos();
dispatch(setSubmitState(false));
};
console.log('User Input:', payload);
handleSubmit(payload, messageHandler, convo, convoHandler);
@ -37,18 +45,22 @@ export default function TextChat({ messages, reloadConvos }) {
}
if (e.key === 'Enter' && !e.shiftKey) {
if (!!isSubmitting) {
return;
}
submitMessage();
}
};
// <>
// <textarea
// className="m-10 h-16 p-4"
// value={text}
// onKeyUp={handleKeyPress}
// onChange={(e) => setText(e.target.value)}
// />
// </>
const changeHandler = (e) => {
// console.log('changeHandler', JSON.stringify(e.target.value));
const { value } = e.target;
if (isSubmitting && (value === '' || value === '\n')) {
return;
}
setText(value);
};
return (
<div className="md:bg-vert-light-gradient dark:md:bg-vert-dark-gradient w-full border-t bg-white dark:border-white/20 dark:bg-gray-800 md:border-t-0 md:border-transparent md:!bg-transparent md:dark:border-transparent">
<form className="stretch mx-2 flex flex-row gap-3 pt-2 last:mb-2 md:last:mb-6 lg:mx-auto lg:max-w-3xl lg:pt-6">
@ -61,11 +73,11 @@ export default function TextChat({ messages, reloadConvos }) {
rows="1"
value={text}
onKeyUp={handleKeyPress}
onChange={(e) => setText(e.target.value)}
onChange={changeHandler}
placeholder=""
className="m-0 h-auto max-h-52 resize-none overflow-auto border-0 bg-transparent p-0 pl-2 pr-7 leading-6 focus:outline-none focus:ring-0 focus-visible:ring-0 dark:bg-transparent md:pl-0"
/>
<SubmitButton onClick={() => submitMessage()} />
<SubmitButton submitMessage={submitMessage} />
</div>
</div>
</form>

View file

@ -11,9 +11,7 @@ const currentSlice = createSlice({
initialState,
reducers: {
setConversation: (state, action) => {
const { conversationId, parentMessageId } = action.payload;
state.conversationId = conversationId;
state.parentMessageId = parentMessageId;
return { ...state, ...action.payload };
},
}
});

View file

@ -2,10 +2,12 @@ import { configureStore } from '@reduxjs/toolkit';
import convoReducer from './convoSlice.js';
import messageReducer from './messageSlice.js'
import submitReducer from './submitSlice.js'
export const store = configureStore({
reducer: {
convo: convoReducer,
messages: messageReducer,
submit: submitReducer,
},
});

View file

@ -1,18 +1,19 @@
import { createSlice } from '@reduxjs/toolkit';
const initialState = [];
const initialState = {
messages: [],
};
const currentSlice = createSlice({
name: 'messages',
initialState,
reducers: {
setMessages: (state, action) => {
const { payload } = action;
return [...payload];
state.messages = [...action.payload];
},
}
});
export const { setMessages } = currentSlice.actions;
export const { setMessages, setSubmitState } = currentSlice.actions;
export default currentSlice.reducer;

19
src/store/submitSlice.js Normal file
View file

@ -0,0 +1,19 @@
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
isSubmitting: false,
};
const currentSlice = createSlice({
name: 'submit',
initialState,
reducers: {
setSubmitState: (state, action) => {
state.isSubmitting = action.payload;
},
}
});
export const { setSubmitState } = currentSlice.actions;
export default currentSlice.reducer;

View file

@ -6,3 +6,33 @@
box-sizing: border-box;
outline: 1px solid limegreen !important;
} */
.blink {
animation: blink 1s linear infinite;
}
@keyframes blink {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.blink2 {
animation: blink 1500ms linear infinite;
}
@keyframes blink2 {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
}

18
src/utils/fetchers.js Normal file
View file

@ -0,0 +1,18 @@
import axios from 'axios';
import useSWR from 'swr';
import useSWRMutation from 'swr/mutation';
const fetcher = (url) => fetch(url).then((res) => res.json());
const postRequest = async (url, { arg }) => {
return await axios.post(url, { arg });
};
export const swr = (path) => useSWR(path, fetcher);
export default function manualSWR(path, type, successCallback) {
const fetchFunction = type === 'get' ? fetcher : postRequest;
return useSWRMutation(path, fetchFunction, {
onSuccess: successCallback
});
};