Merge pull request #45 from danny-avila/submit-state

fixes: allow simultaneous convos & minor improvements
This commit is contained in:
Danny Avila 2023-03-11 23:28:33 -05:00 committed by GitHub
commit a705e907ff
19 changed files with 290 additions and 128 deletions

View file

@ -8,6 +8,18 @@ https://user-images.githubusercontent.com/110412045/223754183-8b7f45ce-6517-4bd5
## Updates
<details open>
<summary><strong>2023-03-12</strong></summary>
Really thankful for all the issues reported and contributions made, the project's features and improvements have accelerated as result. Honorable mention is [wtlyu](https://github.com/wtlyu) for contributing a lot of mindful code, namely hostname configuration and mobile styling. I will upload images on next release for faster docker setup, and starting updating them simultaneously with this repo.
Many improvements across the board, the biggest is being able to start conversations simultaneously (again thanks to [wtlyu](https://github.com/wtlyu) for bringing it to my attention), as you can switch conversations or start a new chat without any response streaming from a prior one, as the backend will still process/save client responses. Just watch out for any rate limiting from OpenAI/Microsoft if this is done excessively.
Adding support for conversation search is next! Thank you [mysticaltech](https://github.com/mysticaltech) for bringing up a method I can use for this.
</details>
<details>
<details>
<summary><strong>2023-03-09</strong></summary>
Released v.0.0.2
@ -16,8 +28,6 @@ Adds Sydney (jailbroken Bing AI) to the model menu. Thank you [DavesDevFails](ht
I've re-enabled the ChatGPT browser client (free version) since it might be working for most people, it no longer works for me. Sydney is the best free route anyway.
</details>
<details>
<details>
<summary><strong>2023-03-07</strong></summary>
Due to increased interest in the repo, I've dockerized the app as of this update for quick setup! See setup instructions below. I realize this still takes some time with installing docker dependencies, so it's on the roadmap to have a deployed demo. Besides this, I've made major improvements for a lot of the existing features across the board, mainly UI/UX.
@ -95,6 +105,7 @@ Here are my recently completed and planned features:
- [x] Customize prompt prefix/label (custom ChatGPT using official API)
- [x] Server convo pagination (limit fetch and load more with 'show more' button)
- [x] Config file for easy startup (docker compose)
- [x] Mobile styling (thanks to [wtlyu](https://github.com/wtlyu))
- [ ] Bing AI Styling (for suggested responses, convo end, etc.) - **In progress**
- [ ] Add warning before clearing convos
- [ ] Build test suite for CI/CD
@ -104,7 +115,6 @@ Here are my recently completed and planned features:
- [ ] Prompt Templates/Search
- [ ] Refactor/clean up code (tech debt)
- [ ] Optional use of local storage for credentials
- [ ] Mobile styling (half-finished)
- [ ] Deploy demo
### Features

View file

@ -3,7 +3,7 @@ const { KeyvFile } = require('keyv-file');
const clientOptions = {
// Warning: This will expose your access token to a third party. Consider the risks before using this.
reverseProxyUrl: 'https://chatgpt.duti.tech/api/conversation',
reverseProxyUrl: 'https://bypass.duti.tech/api/conversation',
// Access token from https://chat.openai.com/api/auth/session
accessToken: process.env.CHATGPT_TOKEN,
// debug: true

View file

@ -2,7 +2,7 @@ const { ModelOperations } = require('@vscode/vscode-languagedetection');
const languages = require('../utils/languages.js');
const codeRegex = /(```[\s\S]*?```)/g;
// const languageMatch = /```(\w+)/;
const replaceRegex = /```\w+/g;
const replaceRegex = /```\w+\n/g;
const detectCode = async (input) => {
try {
@ -22,7 +22,7 @@ const detectCode = async (input) => {
}
console.log('[detectCode.js] replacing', match, 'with', '```shell');
text = text.replace(match, '```shell');
text = text.replace(match, '```shell\n');
});
return text;

View file

@ -77,6 +77,10 @@ router.post('/', async (req, res) => {
sendMessage(res, { ...partial, message: true });
} else {
tokens += partial === text ? '' : partial;
if (tokens.match(/^\n/)) {
tokens = tokens.replace(/^\n/, '');
}
if (tokens.includes('[DONE]')) {
tokens = tokens.replace('[DONE]', '');
}

View file

@ -0,0 +1,11 @@
module.exports = (req, res, next) => {
let { stopStream } = req.body;
if (stopStream) {
console.log('stopStream');
res.write('event: stop\ndata:\n\n');
res.end();
return;
} else {
next();
}
};

View file

@ -3,8 +3,8 @@ import RenameButton from './RenameButton';
import DeleteButton from './DeleteButton';
import { useSelector, useDispatch } from 'react-redux';
import { setConversation } from '~/store/convoSlice';
import { setCustomGpt, setModel, setCustomModel } from '~/store/submitSlice';
import { setMessages } from '~/store/messageSlice';
import { setSubmission, setStopStream, setCustomGpt, setModel, setCustomModel } from '~/store/submitSlice';
import { setMessages, setEmptyMessage } from '~/store/messageSlice';
import { setText } from '~/store/textSlice';
import manualSWR from '~/utils/fetchers';
import ConvoIcon from '../svg/ConvoIcon';
@ -14,13 +14,15 @@ export default function Conversation({
parentMessageId,
conversationId,
title = 'New conversation',
bingData,
chatGptLabel = null,
promptPrefix = null
promptPrefix = null,
bingData,
retainView,
}) {
const [renaming, setRenaming] = useState(false);
const [titleInput, setTitleInput] = useState(title);
const { modelMap } = useSelector((state) => state.models);
const { stopStream } = useSelector((state) => state.submit);
const inputRef = useRef(null);
const dispatch = useDispatch();
const { trigger } = manualSWR(`/api/messages/${id}`, 'get');
@ -31,6 +33,12 @@ export default function Conversation({
return;
}
if (!stopStream) {
dispatch(setStopStream(true));
dispatch(setSubmission({}));
}
dispatch(setEmptyMessage());
const convo = { title, error: false, conversationId: id, chatGptLabel, promptPrefix };
if (bingData) {
@ -81,6 +89,7 @@ export default function Conversation({
dispatch(setMessages(data));
dispatch(setCustomGpt(convo));
dispatch(setText(''));
dispatch(setStopStream(false));
};
const renameHandler = (e) => {
@ -154,6 +163,7 @@ export default function Conversation({
conversationId={id}
renaming={renaming}
cancelHandler={cancelHandler}
retainView={retainView}
/>
</div>
) : (

View file

@ -5,8 +5,9 @@ import manualSWR from '~/utils/fetchers';
import { useDispatch } from 'react-redux';
import { setNewConvo, removeConvo } from '~/store/convoSlice';
import { setMessages } from '~/store/messageSlice';
import { setSubmission } from '~/store/submitSlice';
export default function DeleteButton({ conversationId, renaming, cancelHandler }) {
export default function DeleteButton({ conversationId, renaming, cancelHandler, retainView }) {
const dispatch = useDispatch();
const { trigger } = manualSWR(
`/api/convos/clear`,
@ -15,6 +16,8 @@ export default function DeleteButton({ conversationId, renaming, cancelHandler }
dispatch(setMessages([]));
dispatch(removeConvo(conversationId));
dispatch(setNewConvo());
dispatch(setSubmission({}));
retainView();
}
);

View file

@ -32,10 +32,11 @@ export default function Conversations({ conversations, conversationId, showMore
chatGptLabel={convo.chatGptLabel}
promptPrefix={convo.promptPrefix}
bingData={bingData}
retainView={showMore.bind(null, false)}
/>
);
})}
{conversations && conversations.length >= 12 && conversations.length % 12 === 0 && (
{conversations?.length >= 12 && (
<button
onClick={clickHandler}
className="btn btn-dark btn-small m-auto mb-2 flex justify-center gap-2"

View file

@ -1,4 +1,5 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { SSE } from '~/utils/sse';
import SubmitButton from './SubmitButton';
import Regenerate from './Regenerate';
import ModelMenu from '../Models/ModelMenu';
@ -8,7 +9,7 @@ import handleSubmit from '~/utils/handleSubmit';
import { useSelector, useDispatch } from 'react-redux';
import { setConversation, setError } from '~/store/convoSlice';
import { setMessages } from '~/store/messageSlice';
import { setSubmitState } from '~/store/submitSlice';
import { setSubmitState, setSubmission } from '~/store/submitSlice';
import { setText } from '~/store/textSlice';
export default function TextChat({ messages }) {
@ -16,12 +17,98 @@ export default function TextChat({ messages }) {
const dispatch = useDispatch();
const convo = useSelector((state) => state.convo);
const { initial } = useSelector((state) => state.models);
const { isSubmitting, disabled, model, chatGptLabel, promptPrefix } = useSelector(
(state) => state.submit
);
const { isSubmitting, stopStream, submission, disabled, model, chatGptLabel, promptPrefix } =
useSelector((state) => state.submit);
const { text } = useSelector((state) => state.text);
const { error } = convo;
const isCustomModel = model === 'chatgptCustom' || !initial[model];
const messageHandler = (data, currentState) => {
const { messages, currentMsg, sender } = currentState;
dispatch(setMessages([...messages, currentMsg, { sender, text: data }]));
};
const convoHandler = (data, currentState) => {
const { messages, currentMsg, sender, isCustomModel, model, chatGptLabel, promptPrefix } =
currentState;
dispatch(
setMessages([...messages, currentMsg, { sender, text: data.text || data.response }])
);
const isBing = model === 'bingai' || model === 'sydney';
if (!isBing && convo.conversationId === null && convo.parentMessageId === null) {
const { title, conversationId, id } = data;
dispatch(
setConversation({
title,
conversationId,
parentMessageId: id,
jailbreakConversationId: null,
conversationSignature: null,
clientId: null,
invocationId: null,
chatGptLabel: model === isCustomModel ? chatGptLabel : null,
promptPrefix: model === isCustomModel ? promptPrefix : null
})
);
} else if (
model === 'bingai' &&
convo.conversationId === null &&
convo.invocationId === null
) {
console.log('Bing data:', data);
const { title, conversationSignature, clientId, conversationId, invocationId } = data;
dispatch(
setConversation({
title,
parentMessageId: null,
conversationSignature,
clientId,
conversationId,
invocationId
})
);
} else if (model === 'sydney') {
const {
title,
jailbreakConversationId,
parentMessageId,
conversationSignature,
clientId,
conversationId,
invocationId
} = data;
dispatch(
setConversation({
title,
jailbreakConversationId,
parentMessageId,
conversationSignature,
clientId,
conversationId,
invocationId
})
);
}
dispatch(setSubmitState(false));
};
const errorHandler = (event, currentState) => {
const { initialResponse, messages, currentMsg, message } = currentState;
console.log('Error:', event);
const errorResponse = {
...initialResponse,
text: `An error occurred. Please try again in a few moments.\n\nError message: ${event.data}`,
error: true
};
setErrorMessage(event.data);
dispatch(setSubmitState(false));
dispatch(setMessages([...messages.slice(0, -2), currentMsg, errorResponse]));
dispatch(setText(message));
dispatch(setError(true));
return;
};
const submitMessage = () => {
if (error) {
@ -31,119 +118,115 @@ export default function TextChat({ messages }) {
if (!!isSubmitting || text.trim() === '') {
return;
}
dispatch(setSubmitState(true));
const isCustomModel = model === 'chatgptCustom' || !initial[model];
const message = text.trim();
const currentMsg = { sender: 'User', text: message, current: true };
const sender = model === 'chatgptCustom' ? chatGptLabel : model;
const initialResponse = { sender, text: '' };
dispatch(setSubmitState(true));
dispatch(setMessages([...messages, currentMsg, initialResponse]));
dispatch(setText(''));
const messageHandler = (data) => {
dispatch(setMessages([...messages, currentMsg, { sender, text: data }]));
};
const convoHandler = (data) => {
dispatch(
setMessages([...messages, currentMsg, { sender, text: data.text || data.response }])
);
const isBing = model === 'bingai' || model === 'sydney';
if (
!isBing &&
convo.conversationId === null &&
convo.parentMessageId === null
) {
const { title, conversationId, id } = data;
dispatch(
setConversation({
title,
conversationId,
parentMessageId: id,
jailbreakConversationId: null,
conversationSignature: null,
clientId: null,
invocationId: null,
chatGptLabel: model === isCustomModel ? chatGptLabel : null,
promptPrefix: model === isCustomModel ? promptPrefix : null
})
);
} else if (
model === 'bingai' &&
convo.conversationId === null &&
convo.invocationId === null
) {
console.log('Bing data:', data)
const {
title,
conversationSignature,
clientId,
conversationId,
invocationId
} = data;
dispatch(
setConversation({
title,
parentMessageId: null,
conversationSignature,
clientId,
conversationId,
invocationId,
})
);
} else if (model === 'sydney') {
const {
title,
jailbreakConversationId,
parentMessageId,
conversationSignature,
clientId,
conversationId,
invocationId
} = data;
dispatch(
setConversation({
title,
jailbreakConversationId,
parentMessageId,
conversationSignature,
clientId,
conversationId,
invocationId,
})
);
}
dispatch(setSubmitState(false));
};
const errorHandler = (event) => {
console.log('Error:', event);
const errorResponse = {
...initialResponse,
text: `An error occurred. Please try again in a few moments.\n\nError message: ${event.data}`,
error: true
};
setErrorMessage(event.data);
dispatch(setSubmitState(false));
dispatch(setMessages([...messages.slice(0, -2), currentMsg, errorResponse]));
dispatch(setText(message));
dispatch(setError(true));
return;
};
const submission = {
model,
text: message,
convo,
messageHandler,
convoHandler,
errorHandler,
chatGptLabel,
promptPrefix
promptPrefix,
isCustomModel,
message,
messages,
currentMsg,
sender,
initialResponse
};
console.log('User Input:', message);
handleSubmit(submission);
// handleSubmit(submission);
dispatch(setSubmission(submission));
};
const createPayload = ({ model, text, convo, chatGptLabel, promptPrefix }) => {
const endpoint = `/api/ask`;
let payload = { model, text, chatGptLabel, promptPrefix };
if (convo.conversationId && convo.parentMessageId) {
payload = {
...payload,
conversationId: convo.conversationId,
parentMessageId: convo.parentMessageId
};
}
const isBing = model === 'bingai' || model === 'sydney';
if (isBing && convo.conversationId) {
payload = {
...payload,
jailbreakConversationId: convo.jailbreakConversationId,
conversationId: convo.conversationId,
conversationSignature: convo.conversationSignature,
clientId: convo.clientId,
invocationId: convo.invocationId
};
}
let server = endpoint;
server = model === 'bingai' ? server + '/bing' : server;
server = model === 'sydney' ? server + '/sydney' : server;
return { server, payload };
};
useEffect(() => {
if (Object.keys(submission).length === 0) {
return;
}
const currentState = submission;
const { server, payload } = createPayload(submission);
const onMessage = (e) => {
if (stopStream) {
return;
}
const data = JSON.parse(e.data);
let text = data.text || data.response;
if (data.message) {
messageHandler(text, currentState);
}
if (data.final) {
convoHandler(data, currentState);
console.log('final', data);
} else {
// console.log('dataStream', data);
}
};
const events = new SSE(server, {
payload: JSON.stringify(payload),
headers: { 'Content-Type': 'application/json' }
});
events.onopen = function () {
console.log('connection is opened');
};
events.onmessage = onMessage;
events.onerror = function (e) {
console.log('error in opening conn.');
events.close();
errorHandler(e, currentState);
};
events.stream();
return () => {
events.removeEventListener('message', onMessage);
events.close();
};
}, [submission]);
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();

View file

@ -4,6 +4,7 @@ import { useSelector } from 'react-redux';
import GPTIcon from '../svg/GPTIcon';
import BingIcon from '../svg/BingIcon';
import HoverButtons from './HoverButtons';
import Spinner from '../svg/Spinner';
export default function Message({
sender,
@ -22,6 +23,10 @@ export default function Message({
scrollToBottom();
}
}, [isSubmitting, text, blinker, scrollToBottom, abortScroll]);
if (sender === '') {
return <Spinner />;
}
const handleWheel = () => {
if (blinker) {

View file

@ -1,7 +1,7 @@
import React, { useState, useRef } from 'react';
import TextareaAutosize from 'react-textarea-autosize';
import { useSelector, useDispatch } from 'react-redux';
import { setModel, setCustomGpt } from '~/store/submitSlice';
import { setSubmission, setModel, setCustomGpt } from '~/store/submitSlice';
import { setNewConvo } from '~/store/convoSlice';
import manualSWR from '~/utils/fetchers';
import { Button } from '../ui/Button.tsx';
@ -39,6 +39,7 @@ export default function ModelDialog({ mutate, setModelSave, handleSaveState }) {
handleSaveState(chatGptLabel.toLowerCase());
// Set new conversation
dispatch(setNewConvo());
dispatch(setSubmission({}));
};
const saveHandler = (e) => {

View file

@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { setModel, setDisabled, setCustomGpt, setCustomModel } from '~/store/submitSlice';
import { setSubmission, setModel, setDisabled, setCustomGpt, setCustomModel } from '~/store/submitSlice';
import { setNewConvo } from '~/store/convoSlice';
import ModelDialog from './ModelDialog';
import MenuItems from './MenuItems';
@ -77,6 +77,7 @@ export default function ModelMenu() {
// Set new conversation
dispatch(setNewConvo());
dispatch(setSubmission({}));
};
const onOpenChange = (open) => {

View file

@ -13,6 +13,7 @@ export default function ClearConvos() {
const { trigger } = manualSWR(`/api/convos/clear`, 'post', () => {
dispatch(setMessages([]));
dispatch(setNewConvo());
dispatch(setSubmission({}));
mutate(`/api/convos`);
});

View file

@ -18,6 +18,7 @@ export default function MobileNav({ setNavVisible }) {
dispatch(setText(''));
dispatch(setMessages([]));
dispatch(setNewConvo());
dispatch(setSubmission({}));
}
const title = convos?.find(element => element?.conversationId == conversationId)?.title || 'New Chat';

View file

@ -2,6 +2,7 @@ import React from 'react';
import { useDispatch } from 'react-redux';
import { setNewConvo } from '~/store/convoSlice';
import { setMessages } from '~/store/messageSlice';
import { setSubmission } from '~/store/submitSlice';
import { setText } from '~/store/textSlice';
export default function NewChat() {
@ -11,6 +12,7 @@ export default function NewChat() {
dispatch(setText(''));
dispatch(setMessages([]));
dispatch(setNewConvo());
dispatch(setSubmission({}));
};
return (

View file

@ -24,14 +24,15 @@ export default function Nav({ navVisible, setNavVisible }) {
const scrollPositionRef = useRef(null);
const showMore = async (increment = true) => {
if (increment) {
const container = containerRef.current;
if (container) {
scrollPositionRef.current = container.scrollTop;
}
dispatch(incrementPage());
const container = containerRef.current;
if (container) {
scrollPositionRef.current = container.scrollTop;
}
if (increment) {
dispatch(incrementPage());
await mutate();
}
await mutate();
};
useDidMountEffect(() => mutate(), [conversationId]);

View file

@ -11,9 +11,20 @@ const currentSlice = createSlice({
setMessages: (state, action) => {
state.messages = action.payload;
},
setEmptyMessage: (state) => {
state.messages = [
{
id: '1',
conversationId: '1',
parentMessageId: '1',
sender: '',
text: ''
}
]
},
}
});
export const { setMessages, setSubmitState } = currentSlice.actions;
export const { setMessages, setEmptyMessage } = currentSlice.actions;
export default currentSlice.reducer;

View file

@ -2,11 +2,13 @@ import { createSlice } from '@reduxjs/toolkit';
const initialState = {
isSubmitting: false,
submission: {},
stopStream: false,
disabled: false,
model: 'chatgpt',
promptPrefix: '',
chatGptLabel: '',
customModel: null
customModel: null,
};
const currentSlice = createSlice({
@ -16,6 +18,15 @@ const currentSlice = createSlice({
setSubmitState: (state, action) => {
state.isSubmitting = action.payload;
},
setSubmission: (state, action) => {
state.submission = action.payload;
if (Object.keys(action.payload).length === 0) {
state.isSubmitting = false;
}
},
setStopStream: (state, action) => {
state.stopStream = action.payload;
},
setDisabled: (state, action) => {
state.disabled = action.payload;
},
@ -32,7 +43,7 @@ const currentSlice = createSlice({
}
});
export const { setSubmitState, setDisabled, setModel, setCustomGpt, setCustomModel } =
export const { setSubmitState, setSubmission, setStopStream, setDisabled, setModel, setCustomGpt, setCustomModel } =
currentSlice.actions;
export default currentSlice.reducer;

View file

@ -51,7 +51,7 @@ export default function handleSubmit({
const data = JSON.parse(e.data);
let text = data.text || data.response;
if (data.message) {
messageHandler(text);
messageHandler(text, events);
}
if (data.final) {
@ -68,5 +68,11 @@ export default function handleSubmit({
errorHandler(e);
};
events.addEventListener('stop', () => {
// Close the SSE stream
console.log('stop event received');
events.close();
});
events.stream();
}