diff --git a/.gitignore b/.gitignore index 19262b956..8f3751fd1 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ client/public/main.js.LICENSE.txt # Deployed apps should consider commenting these lines out: # see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git node_modules/ +meili_data/ api/node_modules/ client/node_modules/ bower_components/ diff --git a/api/app/clients/chatgpt-browser.js b/api/app/clients/chatgpt-browser.js index a5bea2272..792f108e7 100644 --- a/api/app/clients/chatgpt-browser.js +++ b/api/app/clients/chatgpt-browser.js @@ -10,7 +10,7 @@ const clientOptions = { proxy: process.env.PROXY || null, }; -const browserClient = async ({ text, onProgress, convo }) => { +const browserClient = async ({ text, onProgress, convo, abortController }) => { const { ChatGPTBrowserClient } = await import('@waylaidwanderer/chatgpt-api'); const store = { @@ -18,7 +18,7 @@ const browserClient = async ({ text, onProgress, convo }) => { }; const client = new ChatGPTBrowserClient(clientOptions, store); - let options = { onProgress }; + let options = { onProgress, abortController }; if (!!convo.parentMessageId && !!convo.conversationId) { options = { ...options, ...convo }; diff --git a/api/app/clients/chatgpt-client.js b/api/app/clients/chatgpt-client.js index 350d1210c..04368e85b 100644 --- a/api/app/clients/chatgpt-client.js +++ b/api/app/clients/chatgpt-client.js @@ -9,14 +9,14 @@ const clientOptions = { debug: false }; -const askClient = async ({ text, onProgress, convo }) => { +const askClient = async ({ text, onProgress, convo, abortController }) => { const ChatGPTClient = (await import('@waylaidwanderer/chatgpt-api')).default; const store = { store: new KeyvFile({ filename: './data/cache.json' }) }; const client = new ChatGPTClient(process.env.OPENAI_KEY, clientOptions, store); - let options = { onProgress }; + let options = { onProgress, abortController }; if (!!convo.parentMessageId && !!convo.conversationId) { options = { ...options, ...convo }; diff --git a/api/app/clients/chatgpt-custom.js b/api/app/clients/chatgpt-custom.js index e7a0ee050..a1c797e31 100644 --- a/api/app/clients/chatgpt-custom.js +++ b/api/app/clients/chatgpt-custom.js @@ -9,7 +9,7 @@ const clientOptions = { debug: false }; -const customClient = async ({ text, onProgress, convo, promptPrefix, chatGptLabel }) => { +const customClient = async ({ text, onProgress, convo, promptPrefix, chatGptLabel, abortController }) => { const ChatGPTClient = (await import('@waylaidwanderer/chatgpt-api')).default; const store = { store: new KeyvFile({ filename: './data/cache.json' }) @@ -23,7 +23,7 @@ const customClient = async ({ text, onProgress, convo, promptPrefix, chatGptLabe const client = new ChatGPTClient(process.env.OPENAI_KEY, clientOptions, store); - let options = { onProgress }; + let options = { onProgress, abortController }; if (!!convo.parentMessageId && !!convo.conversationId) { options = { ...options, ...convo }; } diff --git a/api/package.json b/api/package.json index 3eb88a5dc..316603942 100644 --- a/api/package.json +++ b/api/package.json @@ -24,6 +24,7 @@ "@waylaidwanderer/chatgpt-api": "^1.28.2", "axios": "^1.3.4", "cors": "^2.8.5", + "crypto": "^1.0.1", "dotenv": "^16.0.3", "express": "^4.18.2", "express-session": "^1.17.3", diff --git a/api/server/routes/ask.js b/api/server/routes/ask.js index f016a49b3..3ce95ea1d 100644 --- a/api/server/routes/ask.js +++ b/api/server/routes/ask.js @@ -12,7 +12,7 @@ router.use('/bing', askBing); router.use('/sydney', askSydney); router.post('/', async (req, res) => { - let { model, text, parentMessageId, conversationId: oldConversationId, ...convo } = req.body; + let { model, text, overrideParentMessageId=null, parentMessageId, conversationId: oldConversationId, ...convo } = req.body; if (text.length === 0) { return handleError(res, { text: 'Prompt empty or too short' }); } @@ -36,51 +36,22 @@ router.post('/', async (req, res) => { ...convo }); - await saveMessage(userMessage); - await saveConvo(req?.session?.user?.username, { ...userMessage, model, ...convo }); + if (!overrideParentMessageId) { + await saveMessage(userMessage); + await saveConvo(req?.session?.user?.username, { ...userMessage, model, ...convo }); + } return await ask({ userMessage, model, convo, preSendRequest: true, + overrideParentMessageId, req, res }); }); -router.post('/regenerate', async (req, res) => { - const { model } = req.body; - - const oldUserMessage = await getMessages({ messageId: req.body }); - - if (oldUserMessage) { - const convo = await getConvo(userMessage?.conversationId); - - const userMessageId = crypto.randomUUID(); - - let userMessage = { - ...userMessage, - messageId: userMessageId - }; - - console.log('ask log for regeneration', { - model, - ...userMessage, - ...convo - }); - - return await ask({ - userMessage, - model, - convo, - preSendRequest: false, - req, - res - }); - } else return handleError(res, { text: 'Parent message not found' }); -}); - const ask = async ({ userMessage, overrideParentMessageId = null, @@ -119,6 +90,14 @@ const ask = async ({ try { const progressCallback = createOnProgress(); + + const abortController = new AbortController(); + res.on('close', () => { + console.log('The client has disconnected.'); + // 执行其他操作 + abortController.abort(); + }) + let gptResponse = await client({ text, onProgress: progressCallback.call(null, model, { res, text }), @@ -127,7 +106,8 @@ const ask = async ({ conversationId, ...convo }, - ...convo + ...convo, + abortController }); console.log('CLIENT RESPONSE', gptResponse); @@ -136,10 +116,10 @@ const ask = async ({ gptResponse.text = gptResponse.response; // gptResponse.id = gptResponse.messageId; gptResponse.parentMessageId = overrideParentMessageId || userMessageId; - userMessage.conversationId = conversationId - ? conversationId - : gptResponse.conversationId; - await saveMessage(userMessage); + // userMessage.conversationId = conversationId + // ? conversationId + // : gptResponse.conversationId; + // await saveMessage(userMessage); delete gptResponse.response; } diff --git a/api/server/routes/askBing.js b/api/server/routes/askBing.js index 0614faf86..ca23d8f6f 100644 --- a/api/server/routes/askBing.js +++ b/api/server/routes/askBing.js @@ -9,6 +9,7 @@ router.post('/', async (req, res) => { const { model, text, + overrideParentMessageId=null, parentMessageId, conversationId: oldConversationId, ...convo @@ -37,8 +38,10 @@ router.post('/', async (req, res) => { ...convo }); - await saveMessage(userMessage); - await saveConvo(req?.session?.user?.username, { ...userMessage, model, ...convo }); + if (!overrideParentMessageId) { + await saveMessage(userMessage); + await saveConvo(req?.session?.user?.username, { ...userMessage, model, ...convo }); + } return await ask({ isNewConversation, @@ -46,6 +49,7 @@ router.post('/', async (req, res) => { model, convo, preSendRequest: true, + overrideParentMessageId, req, res }); @@ -80,6 +84,14 @@ const ask = async ({ try { const progressCallback = createOnProgress(); + + const abortController = new AbortController(); + res.on('close', () => { + console.log('The client has disconnected.'); + // 执行其他操作 + abortController.abort(); + }) + let response = await askBing({ text, onProgress: progressCallback.call(null, model, { @@ -91,7 +103,8 @@ const ask = async ({ ...convo, parentMessageId: userParentMessageId, conversationId - } + }, + abortController }); console.log('BING RESPONSE', response); @@ -101,7 +114,8 @@ const ask = async ({ convo.conversationSignature || response.conversationSignature; userMessage.conversationId = response.conversationId || conversationId; userMessage.invocationId = response.invocationId; - await saveMessage(userMessage); + if (!overrideParentMessageId) + await saveMessage(userMessage); // Bing API will not use our conversationId at the first time, // so change the placeholder conversationId to the real one. @@ -117,8 +131,8 @@ const ask = async ({ ); conversationId = userMessage.conversationId; - response.text = response.response; - delete response.response; + response.text = response.response || response.details.spokenText || '**Bing refused to answer.**'; + // delete response.response; // response.id = response.details.messageId; response.suggestions = response.details.suggestedResponses && diff --git a/api/server/routes/askSydney.js b/api/server/routes/askSydney.js index a2017cf7d..49ebf3646 100644 --- a/api/server/routes/askSydney.js +++ b/api/server/routes/askSydney.js @@ -9,6 +9,7 @@ router.post('/', async (req, res) => { const { model, text, + overrideParentMessageId=null, parentMessageId, conversationId: oldConversationId, ...convo @@ -37,8 +38,10 @@ router.post('/', async (req, res) => { ...convo }); - await saveMessage(userMessage); - await saveConvo(req?.session?.user?.username, { ...userMessage, model, ...convo }); + if (!overrideParentMessageId) { + await saveMessage(userMessage); + await saveConvo(req?.session?.user?.username, { ...userMessage, model, ...convo }); + } return await ask({ isNewConversation, @@ -46,6 +49,7 @@ router.post('/', async (req, res) => { model, convo, preSendRequest: true, + overrideParentMessageId, req, res }); @@ -80,6 +84,14 @@ const ask = async ({ try { const progressCallback = createOnProgress(); + + const abortController = new AbortController(); + res.on('close', () => { + console.log('The client has disconnected.'); + // 执行其他操作 + abortController.abort(); + }) + let response = await askSydney({ text, onProgress: progressCallback.call(null, model, { @@ -91,7 +103,8 @@ const ask = async ({ parentMessageId: userParentMessageId, conversationId, ...convo - } + }, + abortController }); console.log('SYDNEY RESPONSE', response); @@ -102,7 +115,8 @@ const ask = async ({ userMessage.conversationId = response.conversationId || conversationId; userMessage.invocationId = response.invocationId; // Unlike gpt and bing, Sydney will never accept our given userMessage.messageId, it will generate its own one. - await saveMessage(userMessage); + if (!overrideParentMessageId) + await saveMessage(userMessage); // Save sydney response // response.id = response.messageId; @@ -111,8 +125,8 @@ const ask = async ({ response.conversationSignature = convo.conversationSignature ? convo.conversationSignature : crypto.randomUUID(); - response.text = response.response; - delete response.response; + response.text = response.response || response.details.spokenText || '**Bing refused to answer.**'; + // delete response.response; response.suggestions = response.details.suggestedResponses && response.details.suggestedResponses.map((s) => s.text); @@ -125,7 +139,8 @@ const ask = async ({ // Save user message userMessage.conversationId = response.conversationId || conversationId; - await saveMessage(userMessage); + if (!overrideParentMessageId) + await saveMessage(userMessage); // Bing API will not use our conversationId at the first time, // so change the placeholder conversationId to the real one. diff --git a/api/server/routes/handlers.js b/api/server/routes/handlers.js index 3ba4e8124..99a70e6e8 100644 --- a/api/server/routes/handlers.js +++ b/api/server/routes/handlers.js @@ -1,8 +1,6 @@ const _ = require('lodash'); -const sanitizeHtml = require('sanitize-html'); const citationRegex = /\[\^\d+?\^]/g; const { getCitations, citeText, detectCode } = require('../../app/'); -// const htmlTagRegex = /(<\/?\s*[a-zA-Z]*\s*(?:\s+[a-zA-Z]+\s*=\s*(?:"[^"]*"|'[^']*'))*\s*(?:\/?)>|<\s*[a-zA-Z]+\s*(?:\s+[a-zA-Z]+\s*=\s*(?:"[^"]*"|'[^']*'))*\s*(?:\/?>|<\/?>))/g; const handleError = (res, message) => { res.write(`event: error\ndata: ${JSON.stringify(message)}\n\n`); @@ -28,14 +26,6 @@ const createOnProgress = () => { tokens = tokens.replace(/^\n/, ''); } - // const htmlTags = tokens.match(htmlTagRegex); - // if (tokens.includes('```') && htmlTags && htmlTags.length > 0) { - // htmlTags.forEach((tag) => { - // const sanitizedTag = sanitizeHtml(tag); - // tokens = tokens.replaceAll(tag, sanitizedTag); - // }); - // } - if (bing) { tokens = citeText(tokens, true); } @@ -54,7 +44,7 @@ const createOnProgress = () => { const handleText = async (response, bing = false) => { let { text } = response; - text = await detectCode(text); + // text = await detectCode(text); response.text = text; if (bing) { @@ -66,14 +56,6 @@ const handleText = async (response, bing = false) => { text += links?.length > 0 ? `\n${links}` : ''; } - // const htmlTags = text.match(htmlTagRegex); - // if (text.includes('```') && htmlTags && htmlTags.length > 0) { - // htmlTags.forEach((tag) => { - // const sanitizedTag = sanitizeHtml(tag); - // text = text.replaceAll(tag, sanitizedTag); - // }); - // } - return text; }; diff --git a/client/public/index.html b/client/public/index.html index 476480f2f..8e8c205f6 100644 --- a/client/public/index.html +++ b/client/public/index.html @@ -2,6 +2,7 @@ + ChatGPT Clone +
+

ChatGPT Clone diff --git a/client/src/components/Main/SubmitButton.jsx b/client/src/components/Main/SubmitButton.jsx index 1aa437e26..20553a933 100644 --- a/client/src/components/Main/SubmitButton.jsx +++ b/client/src/components/Main/SubmitButton.jsx @@ -1,8 +1,10 @@ import React from 'react'; import { useSelector } from 'react-redux'; -export default function SubmitButton({ submitMessage }) { - const { isSubmitting, disabled } = useSelector((state) => state.submit); +export default function SubmitButton({ submitMessage, disabled }) { + const { isSubmitting } = useSelector((state) => state.submit); + const { error, latestMessage } = useSelector((state) => state.convo); + const clickHandler = (e) => { e.preventDefault(); submitMessage(); @@ -28,7 +30,7 @@ export default function SubmitButton({ submitMessage }) { disabled={disabled} className="group absolute bottom-0 right-0 flex h-[100%] w-[50px] items-center justify-center bg-transparent p-1 text-gray-500" > -
+
state.user); @@ -24,17 +32,78 @@ export default function TextChat({ messages }) { const { isSubmitting, stopStream, submission, disabled, model, chatGptLabel, promptPrefix } = useSelector((state) => state.submit); const { text } = useSelector((state) => state.text); - const { error } = convo; + const { error, latestMessage } = convo; + const { ask, regenerate, stopGenerating } = useMessageHandler(); + + const isNotAppendable = latestMessage?.cancelled || latestMessage?.error; // auto focus to input, when enter a conversation. useEffect(() => { inputRef.current?.focus(); - }, [convo?.conversationId,]) + }, [convo?.conversationId]); const messageHandler = (data, currentState, currentMsg) => { - const { messages, _currentMsg, message, sender } = currentState; + const { messages, _currentMsg, message, sender, isRegenerate } = currentState; - dispatch(setMessages([...messages, currentMsg, { sender, text: data, parentMessageId: currentMsg?.messageId, messageId: currentMsg?.messageId + '_', submitting: true }])); + if (isRegenerate) + dispatch( + setMessages([ + ...messages, + { + sender, + text: data, + parentMessageId: message?.overrideParentMessageId, + messageId: message?.overrideParentMessageId + '_', + submitting: true + } + ]) + ); + else + dispatch( + setMessages([ + ...messages, + currentMsg, + { + sender, + text: data, + parentMessageId: currentMsg?.messageId, + messageId: currentMsg?.messageId + '_', + submitting: true + } + ]) + ); + }; + + const cancelHandler = (data, currentState, currentMsg) => { + const { messages, _currentMsg, message, sender, isRegenerate } = currentState; + + if (isRegenerate) + dispatch( + setMessages([ + ...messages, + { + sender, + text: data, + parentMessageId: message?.overrideParentMessageId, + messageId: message?.overrideParentMessageId + '_', + cancelled: true + } + ]) + ); + else + dispatch( + setMessages([ + ...messages, + currentMsg, + { + sender, + text: data, + parentMessageId: currentMsg?.messageId, + messageId: currentMsg?.messageId + '_', + cancelled: true + } + ]) + ); }; const createdHandler = (data, currentState, currentMsg) => { @@ -42,6 +111,7 @@ export default function TextChat({ messages }) { dispatch( setConversation({ conversationId, + latestMessage: null }) ); }; @@ -49,15 +119,16 @@ export default function TextChat({ messages }) { const convoHandler = (data, currentState, currentMsg) => { const { requestMessage, responseMessage } = data; const { conversationId } = requestMessage; - const { messages, _currentMsg, message, isCustomModel, sender } = + const { messages, _currentMsg, message, isCustomModel, sender, isRegenerate } = currentState; const { model, chatGptLabel, promptPrefix } = message; - dispatch( - setMessages([...messages, requestMessage, responseMessage,]) - ); + if (isRegenerate) dispatch(setMessages([...messages, responseMessage])); + else dispatch(setMessages([...messages, requestMessage, responseMessage])); + dispatch(setSubmitState(false)); const isBing = model === 'bingai' || model === 'sydney'; + // refresh title if (requestMessage.parentMessageId == '00000000-0000-0000-0000-000000000000') { setTimeout(() => { dispatch(refreshConversation()); @@ -82,15 +153,15 @@ export default function TextChat({ messages }) { clientId: null, invocationId: null, chatGptLabel: model === isCustomModel ? chatGptLabel : null, - promptPrefix: model === isCustomModel ? promptPrefix : null + promptPrefix: model === isCustomModel ? promptPrefix : null, + latestMessage: null }) ); - } else if ( - model === 'bingai' - ) { + } else if (model === 'bingai') { console.log('Bing data:', data); const { title } = data; - const { conversationSignature, clientId, conversationId, invocationId } = responseMessage; + const { conversationSignature, clientId, conversationId, invocationId } = + responseMessage; dispatch( setConversation({ title, @@ -98,7 +169,8 @@ export default function TextChat({ messages }) { conversationSignature, clientId, conversationId, - invocationId + invocationId, + latestMessage: null }) ); } else if (model === 'sydney') { @@ -119,12 +191,11 @@ export default function TextChat({ messages }) { conversationSignature, clientId, conversationId, - invocationId + invocationId, + latestMessage: null }) ); } - - dispatch(setSubmitState(false)); }; const errorHandler = (data, currentState, currentMsg) => { @@ -133,7 +204,7 @@ export default function TextChat({ messages }) { const errorResponse = { ...data, error: true, - parentMessageId: currentMsg?.messageId, + parentMessageId: currentMsg?.messageId }; setErrorMessage(data?.text); dispatch(setSubmitState(false)); @@ -142,51 +213,8 @@ export default function TextChat({ messages }) { dispatch(setError(true)); return; }; - const submitMessage = () => { - if (error) { - dispatch(setError(false)); - } - - if (!!isSubmitting || text.trim() === '') { - return; - } - - // this is not a real messageId, it is used as placeholder before real messageId returned - const fakeMessageId = crypto.randomUUID(); - const isCustomModel = model === 'chatgptCustom' || !initial[model]; - const message = text.trim(); - const sender = model === 'chatgptCustom' ? chatGptLabel : model; - let parentMessageId = convo.parentMessageId || '00000000-0000-0000-0000-000000000000'; - let currentMessages = messages; - if (resetConvo(currentMessages, sender)) { - parentMessageId = '00000000-0000-0000-0000-000000000000'; - dispatch(setNewConvo()); - currentMessages = []; - } - const currentMsg = { sender: 'User', text: message, current: true, isCreatedByUser: true, parentMessageId , messageId: fakeMessageId }; - const initialResponse = { sender, text: '', parentMessageId: fakeMessageId, submitting: true }; - - dispatch(setSubmitState(true)); - dispatch(setMessages([...currentMessages, currentMsg, initialResponse])); - dispatch(setText('')); - - const submission = { - convo, - isCustomModel, - message: { - ...currentMsg, - model, - chatGptLabel, - promptPrefix, - }, - messages: currentMessages, - currentMsg, - initialResponse, - sender, - }; - console.log('User Input:', message); - dispatch(setSubmission(submission)); + ask({ text }); }; useEffect(() => { @@ -196,7 +224,10 @@ export default function TextChat({ messages }) { } const currentState = submission; - let currentMsg = currentState.currentMsg; + + let currentMsg = { ...currentState.message }; + let latestResponseText = ''; + const { server, payload } = createPayload(submission); const onMessage = (e) => { if (stopStream) { @@ -205,19 +236,20 @@ export default function TextChat({ messages }) { const data = JSON.parse(e.data); - // if (data.message) { - // messageHandler(text, currentState); - // } - if (data.final) { convoHandler(data, currentState, currentMsg); console.log('final', data); - } if (data.created) { + } + if (data.created) { currentMsg = data.message; createdHandler(data, currentState, currentMsg); } else { let text = data.text || data.response; + if (data.initial) { + console.log(data); + } if (data.message) { + latestResponseText = text; messageHandler(text, currentState, currentMsg); } // console.log('dataStream', data); @@ -235,6 +267,10 @@ export default function TextChat({ messages }) { events.onmessage = onMessage; + events.oncancel = (e) => { + cancelHandler(latestResponseText, currentState, currentMsg); + }; + events.onerror = function (e) { console.log('error in opening conn.'); events.close(); @@ -248,18 +284,30 @@ export default function TextChat({ messages }) { return () => { events.removeEventListener('message', onMessage); + const isCancelled = events.readyState <= 1; events.close(); + if (isCancelled) { + const e = new Event('cancel'); + events.dispatchEvent(e); + } }; }, [submission]); + const handleRegenerate = () => { + if (latestMessage && !latestMessage?.isCreatedByUser) regenerate(latestMessage); + }; + + const handleStopGenerating = () => { + stopGenerating(); + }; + const handleKeyDown = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); } if (e.key === 'Enter' && !e.shiftKey) { - if (!isComposing.current) - submitMessage(); + if (!isComposing.current) submitMessage(); } }; @@ -272,21 +320,21 @@ export default function TextChat({ messages }) { return; } }; - + const handleCompositionStart = (e) => { - isComposing.current = true - } + isComposing.current = true; + }; const handleCompositionEnd = (e) => { isComposing.current = false; - } + }; const changeHandler = (e) => { const { value } = e.target; - if (isSubmitting && (value === '' || value === '\n')) { - return; - } + // if (isSubmitting && (value === '' || value === '\n')) { + // return; + // } dispatch(setText(value)); }; @@ -296,44 +344,65 @@ export default function TextChat({ messages }) { }; return ( -
-
+
+
-
- {error ? ( - + {isSubmitting ? ( + + ) : latestMessage && !latestMessage?.isCreatedByUser ? ( + + ) : null} + +
+ + - ) : ( -
- - - -
- )} + +