diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml new file mode 100644 index 0000000..40f53ee --- /dev/null +++ b/.github/workflows/review.yml @@ -0,0 +1,20 @@ +name: Code Review with ChatGPT + +on: + pull_request: + types: [opened] + +jobs: + add-auto-review-comment: + if: contains(github.event.pull_request.labels.*.name, 'review') + runs-on: ubuntu-latest + name: Code Review with ChatGPT + steps: + - uses: Jonghakseo/gpt-pr-github-actions@v1 + with: + openai_api_key: ${{ secrets.openai_api_key }} # Get the OpenAI API key from repository secrets + github_token: ${{ secrets.GITHUB_TOKEN }} # Get the Github Token from repository secrets + github_pr_id: ${{ github.event.number }} # Get the Github Pull Request ID from the Github event + openai_model: "gpt-3.5-turbo" # Optional: specify the OpenAI engine to use. [gpt-3.5-turbo, text-davinci-002, text-babbage-001, text-curie-001, text-ada-001'] Default is 'gpt-3.5-turbo' + openai_temperature: 0.5 # Optional: Default is 0.7 + openai_top_p: 0.5 # Optional: Default 0.8 diff --git a/package.json b/package.json index 818d8dc..5b6a5d2 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "drag-gpt", - "version": "1.3.11", + "version": "1.4.0", "description": "Drag GPT chrome extension", "license": "MIT", "repository": { @@ -24,11 +24,8 @@ "@emotion/cache": "11.10.5", "@emotion/react": "11.10.6", "@emotion/styled": "11.10.6", - "@vespaiach/axios-fetch-adapter": "0.3.1", "@xstate/react": "3.2.1", - "axios": "0.26.0", "framer-motion": "10.0.1", - "openai": "3.2.1", "react": "18.2.0", "react-dom": "18.2.0", "react-draggable": "4.4.5", @@ -54,6 +51,7 @@ "eslint-plugin-prettier": "4.2.1", "eslint-plugin-react": "7.31.8", "fs-extra": "10.1.0", + "openai": "3.2.1", "jest": "29.0.3", "jest-environment-jsdom": "29.0.3", "npm-run-all": "^4.1.5", diff --git a/public/_locales/en/messages.json b/public/_locales/en/messages.json index 5fc4c43..4097f08 100644 --- a/public/_locales/en/messages.json +++ b/public/_locales/en/messages.json @@ -38,6 +38,9 @@ "quickChattingPage_sendButtonText": { "message": "SEND" }, + "quickChattingPage_stopButtonText": { + "message": "STOP" + }, "quickChattingPage_chattingPlaceholder": { "message": "ex. Hello!" }, @@ -110,6 +113,9 @@ "responseMessageBox_sendButtonText": { "message": "SEND" }, + "responseMessageBox_stopButtonText": { + "message": "STOP" + }, "responseMessageBox_messageInputPlacepolder": { "message": "ex. Summarize!" }, diff --git a/public/_locales/ja/messages.json b/public/_locales/ja/messages.json index 794b0ec..f9a7b87 100644 --- a/public/_locales/ja/messages.json +++ b/public/_locales/ja/messages.json @@ -38,6 +38,9 @@ "quickChattingPage_sendButtonText": { "message": "送信" }, + "quickChattingPage_stopButtonText": { + "message": "回答中断" + }, "quickChattingPage_chattingPlaceholder": { "message": "例:こんにちは!" }, @@ -110,6 +113,9 @@ "responseMessageBox_sendButtonText": { "message": "送信" }, + "responseMessageBox_stopButtonText": { + "message": "回答中断" + }, "responseMessageBox_messageInputPlacepolder": { "message": "例:要約!" }, diff --git a/public/_locales/ko/messages.json b/public/_locales/ko/messages.json index e00bd6b..9a233e3 100644 --- a/public/_locales/ko/messages.json +++ b/public/_locales/ko/messages.json @@ -38,6 +38,9 @@ "quickChattingPage_sendButtonText": { "message": "전송" }, + "quickChattingPage_stopButtonText": { + "message": "답변 중단" + }, "quickChattingPage_chattingPlaceholder": { "message": "ex. 안녕!" }, @@ -110,6 +113,9 @@ "responseMessageBox_sendButtonText": { "message": "전송" }, + "responseMessageBox_stopButtonText": { + "message": "답변 중단" + }, "responseMessageBox_messageInputPlacepolder": { "message": "ex. 위 내용을 알기 쉽게 요약해줘" }, diff --git a/public/_locales/zh_CN/messages.json b/public/_locales/zh_CN/messages.json index 367fb60..6912e05 100644 --- a/public/_locales/zh_CN/messages.json +++ b/public/_locales/zh_CN/messages.json @@ -38,6 +38,9 @@ "quickChattingPage_sendButtonText": { "message": "发送" }, + "quickChattingPage_stopButtonText": { + "message": "回答中止" + }, "quickChattingPage_chattingPlaceholder": { "message": "例如:你好!" }, @@ -110,6 +113,9 @@ "responseMessageBox_sendButtonText": { "message": "发送" }, + "responseMessageBox_stopButtonText": { + "message": "回答中止" + }, "responseMessageBox_messageInputPlacepolder": { "message": "例如:总结!" }, diff --git a/src/chrome/message.ts b/src/chrome/message.ts index 1ac563e..0c0006b 100644 --- a/src/chrome/message.ts +++ b/src/chrome/message.ts @@ -1,5 +1,3 @@ -import { AxiosError } from "axios"; - type GetDataType = Exclude< Extract< Message, @@ -51,6 +49,10 @@ export function sendMessageToBackground({ } catch (error) { console.log(error); } + const disconnect = () => { + port.disconnect(); + }; + return { disconnect }; } export function sendMessageToClient( @@ -68,19 +70,14 @@ export function sendErrorMessageToClient( port: chrome.runtime.Port, error: unknown ) { - if (!(error instanceof Error)) { - const unknownError = new Error(); - unknownError.name = "Unknown Error"; - sendMessageToClient(port, { type: "Error", error: unknownError }); - return; - } - if ((error as AxiosError).isAxiosError) { - const axiosError = error as AxiosError; - const customError = new Error(); - customError.message = axiosError.response?.data?.error?.message; - customError.name = axiosError.response?.data?.error?.code ?? error.name; - sendMessageToClient(port, { type: "Error", error: customError }); - } else { - sendMessageToClient(port, { type: "Error", error }); + const sendError = new Error(); + sendError.name = "Unknown Error"; + + if (error instanceof Error) { + error.name && (sendError.name = error.name); + sendError.message = error.message; } + + sendMessageToClient(port, { type: "Error", error: sendError }); + return; } diff --git a/src/global.d.ts b/src/global.d.ts index d417a3b..45885dc 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -79,22 +79,27 @@ declare global { type RequestOnetimeChatGPTMessage = { type: "RequestOnetimeChatGPT"; input: string; - data?: { result: string; tokenUsage: number }; + data?: { result: string }; }; type RequestGenerateChatGPTPromptMessage = { type: "RequestGenerateChatGPTPrompt"; input: string; - data?: { result: string; tokenUsage: number }; + data?: { result: string }; }; type RequestOngoingChatGPTMessage = { type: "RequestOngoingChatGPT"; input: ChatCompletionRequestMessage[]; - data?: { result: string; tokenUsage: number }; + data?: { result: string }; + }; + type RequestInitialDragGPTMessage = { + type: "RequestInitialDragGPTStream"; + input?: string; + data?: { result: string; chunk?: string; isDone?: boolean }; }; type RequestQuickChatGPTMessage = { - type: "RequestQuickChatGPT"; + type: "RequestChatGPTStream"; input?: ChatCompletionRequestMessage[]; - data?: { result: string; tokenUsage: number }; + data?: { result: string; chunk?: string; isDone?: boolean }; }; type SaveAPIKeyMessage = { type: "SaveAPIKey"; @@ -133,6 +138,7 @@ declare global { }; type Message = + | RequestInitialDragGPTMessage | RequestQuickChatGPTMessage | RequestOngoingChatGPTMessage | ResetQuickChatHistoryMessage diff --git a/src/pages/background/index.ts b/src/pages/background/index.ts index 1bb6c45..799df2d 100644 --- a/src/pages/background/index.ts +++ b/src/pages/background/index.ts @@ -22,7 +22,9 @@ type RequiredDataNullableInput = { }; chrome.runtime.onConnect.addListener((port) => { - port.onDisconnect.addListener(() => console.log("Port disconnected")); + port.onDisconnect.addListener(() => { + console.log("Port disconnected"); + }); port.onMessage.addListener(async (message: Message) => { Logger.receive(message); @@ -92,6 +94,32 @@ chrome.runtime.onConnect.addListener((port) => { await ApiKeyStorage.setApiKey(null); sendResponse({ type: "ResetAPIKey", data: "success" }); break; + case "RequestInitialDragGPTStream": { + const slot = await SlotStorage.getSelectedSlot(); + const apiKey = await ApiKeyStorage.getApiKey(); + const response = await chatGPT({ + input: message.input, + slot, + apiKey, + onDelta: (chunk) => { + sendResponse({ + type: "RequestInitialDragGPTStream", + data: { + result: "", + chunk, + }, + }); + }, + }); + sendResponse({ + type: "RequestInitialDragGPTStream", + data: { + isDone: true, + result: response.result, + }, + }); + break; + } case "RequestOnetimeChatGPT": { const selectedSlot = await SlotStorage.getSelectedSlot(); const apiKey = await ApiKeyStorage.getApiKey(); @@ -106,7 +134,7 @@ chrome.runtime.onConnect.addListener((port) => { }); break; } - case "RequestQuickChatGPT": { + case "RequestChatGPTStream": { await QuickChatHistoryStorage.pushChatHistories({ role: "user", content: message.input?.at(-1)?.content ?? "", @@ -116,12 +144,24 @@ chrome.runtime.onConnect.addListener((port) => { chats: message.input, slot: { type: "ChatGPT" }, apiKey, + onDelta: (chunk) => { + sendResponse({ + type: "RequestChatGPTStream", + data: { + result: "", + chunk, + }, + }); + }, }); await QuickChatHistoryStorage.pushChatHistories({ role: "assistant", content: response.result, }); - sendResponse({ type: "RequestQuickChatGPT", data: response }); + sendResponse({ + type: "RequestChatGPTStream", + data: { result: response.result, isDone: true }, + }); break; } case "RequestOngoingChatGPT": { diff --git a/src/pages/background/lib/infra/chatGPT.ts b/src/pages/background/lib/infra/chatGPT.ts index e1bccda..b1d19a9 100644 --- a/src/pages/background/lib/infra/chatGPT.ts +++ b/src/pages/background/lib/infra/chatGPT.ts @@ -1,51 +1,21 @@ -import { ChatCompletionRequestMessage, Configuration, OpenAIApi } from "openai"; -import axios, { AxiosInstance } from "axios"; -import fetchAdapter from "@vespaiach/axios-fetch-adapter"; - -let openAiApiInstance: OpenAIApi | null = null; -let axiosInstance: AxiosInstance | null = null; -let configuration: Configuration | null = null; - -function checkIsSameApiKey(apiKey: string): boolean { - return configuration?.apiKey === apiKey; -} - -function resetAuthInfoInstance(): void { - configuration = null; - openAiApiInstance = null; -} - -function createInstancesIfNotExists(apiKey: string) { - axiosInstance ??= axios.create({ - adapter: fetchAdapter, - }); - configuration ??= new Configuration({ - apiKey, - }); - openAiApiInstance ??= new OpenAIApi(configuration, undefined, axiosInstance); - - return { - openAiApiInstance, - }; -} +import type { + ChatCompletionRequestMessage, + CreateChatCompletionRequest, +} from "openai"; export async function chatGPT({ input, slot, chats, apiKey, + onDelta, }: { slot: ChatGPTSlot; chats?: ChatCompletionRequestMessage[]; input?: string; apiKey: string; -}): Promise<{ result: string; tokenUsage: number }> { - if (!checkIsSameApiKey(apiKey)) { - resetAuthInfoInstance(); - } - - const { openAiApiInstance } = createInstancesIfNotExists(apiKey); - + onDelta?: (chunk: string) => unknown; +}): Promise<{ result: string }> { const messages: ChatCompletionRequestMessage[] = []; if (slot.system) { @@ -61,23 +31,60 @@ export async function chatGPT({ messages.push({ role: "user", content: input }); } - const completion = await openAiApiInstance.createChatCompletion({ - model: "gpt-3.5-turbo", - max_tokens: slot.maxTokens, - messages, - temperature: slot.temperature, - top_p: slot.topP, - frequency_penalty: slot.frequencyPenalty, - presence_penalty: slot.presencePenalty, + const response = await fetch("https://api.openai.com/v1/chat/completions", { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + method: "POST", + body: JSON.stringify({ + model: "gpt-3.5-turbo", + max_tokens: slot.maxTokens, + messages, + stream: true, + temperature: slot.temperature, + top_p: slot.topP, + frequency_penalty: slot.frequencyPenalty, + presence_penalty: slot.presencePenalty, + } as CreateChatCompletionRequest), }); - const result = - completion.data.choices.at(0)?.message?.content ?? "Unknown Response"; - const tokenUsage = completion.data.usage?.total_tokens ?? 0; + if (response.status !== 200) { + const jsonBody = await response.json(); + const error = new Error(); + error.name = jsonBody.error.type; + error.message = jsonBody.error.code + jsonBody.error.message ?? ""; + throw error; + } + + const reader = response.body + ?.pipeThrough(new TextDecoderStream()) + .getReader(); + + let result = ""; + while (reader) { + const { value, done } = await reader.read(); + if (done) { + break; + } + if (value.includes("data: [DONE]")) { + break; + } + const lines = value.split("\n\n").filter(Boolean); + const chunks = lines + .map((line) => line.substring(5).trim()) + .map((line) => JSON.parse(line)) + .map((data) => data.choices.at(0).delta.content) + .filter(Boolean); + + chunks.forEach((chunk) => { + result += chunk; + onDelta?.(chunk); + }); + } return { result, - tokenUsage, }; } diff --git a/src/pages/content/src/ContentScriptApp/DragGPT.tsx b/src/pages/content/src/ContentScriptApp/DragGPT.tsx index 7d6911c..f7f320f 100644 --- a/src/pages/content/src/ContentScriptApp/DragGPT.tsx +++ b/src/pages/content/src/ContentScriptApp/DragGPT.tsx @@ -9,10 +9,14 @@ import ErrorMessageBox from "@pages/content/src/ContentScriptApp/components/mess import { useMachine } from "@xstate/react"; import delayPromise from "@pages/content/src/ContentScriptApp/utils/delayPromise"; import dragStateMachine from "@pages/content/src/ContentScriptApp/xState/dragStateMachine"; -import { sendMessageToBackgroundAsync } from "@src/chrome/message"; +import { sendMessageToBackground } from "@src/chrome/message"; import styled from "@emotion/styled"; import { getPositionOnScreen } from "@pages/content/src/ContentScriptApp/utils/getPositionOnScreen"; import useSelectedSlot from "@pages/content/src/ContentScriptApp/hooks/useSelectedSlot"; +import ChatText from "@src/shared/component/ChatText"; +import AssistantChat from "@src/shared/component/AssistantChat"; +import MessageBox from "@pages/content/src/ContentScriptApp/components/messageBox/MessageBox"; +import { t } from "@src/chrome/i18n"; const Container = styled.div` * { @@ -22,10 +26,30 @@ const Container = styled.div` const skipLoopCycleOnce = async () => await delayPromise(1); -async function getGPTResponse(userInput: string) { - return sendMessageToBackgroundAsync({ - type: "RequestOnetimeChatGPT", - input: userInput, +async function getGPTResponseAsStream({ + input, + onDelta, + onFinish, +}: { + input: string; + onDelta: (chunk: string) => unknown; + onFinish: (result: string) => unknown; +}) { + return new Promise<{ firstChunk: string }>((resolve, reject) => { + sendMessageToBackground({ + message: { + type: "RequestInitialDragGPTStream", + input, + }, + handleSuccess: (response) => { + if (response.isDone || !response.chunk) { + return onFinish(response.result); + } + resolve({ firstChunk: response.chunk }); + onDelta(response.chunk); + }, + handleError: reject, + }); }); } @@ -44,10 +68,17 @@ export default function DragGPT() { }, }, services: { - getGPTResponse: (context) => getGPTResponse(context.selectedText), + getGPTResponse: (context) => + getGPTResponseAsStream({ + input: context.selectedText, + onDelta: (chunk) => send("RECEIVE_ING", { data: chunk }), + onFinish: () => send("RECEIVE_END"), + }), }, }); + console.log(state.context.error); + useEffect(() => { const onMouseUp = async (event: MouseEvent) => { /** Selection 이벤트 호출을 기다리는 해키한 코드 */ @@ -90,6 +121,22 @@ export default function DragGPT() { selectedSlot={selectedSlot} /> )} + {state.matches("temp_response_message_box") && ( + + {state.context.chats.at(-1)?.content} + + } + width={480} + onClose={() => send("RECEIVE_CANCEL")} + anchorTop={state.context.anchorNodePosition.top} + anchorCenter={state.context.anchorNodePosition.center} + anchorBottom={state.context.anchorNodePosition.bottom} + positionOnScreen={state.context.positionOnScreen} + /> + )} {state.hasTag("showResponseMessages") && ( Promise.resolve(initialChats), getGPTResponse: (context) => { - const chatsWithoutError = context.chats.filter( - (chat) => chat.role !== "error" - ); - return getGPTResponse( - chatsWithoutError as ChatCompletionRequestMessage[] - ); + return getGPTResponseAsStream({ + messages: context.chats.filter( + (chat) => chat.role !== "error" + ) as ChatCompletionRequestMessage[], + onDelta: (chunk) => { + send("RECEIVE_ING", { data: chunk }); + }, + onFinish: (result) => send("RECEIVE_DONE", { data: result }), + }); }, }, actions: { @@ -57,12 +52,17 @@ export default function ResponseMessageBox({ /** 첫 번째 질문 숨김처리 (드래깅으로 질문) */ const [, ...chats] = state.context.chats; const isLoading = state.matches("loading"); + const isReceiving = state.matches("receiving"); - const { scrollDownRef } = useScrollDownEffect([chats.length]); + const { scrollDownRef } = useScrollDownEffect([chats.at(-1)?.content]); const { isCopied, copy } = useCopyClipboard([ chats.filter(({ role }) => role === "assistant").length, ]); + const onClickStopButton = () => { + send("RECEIVE_CANCEL"); + }; + const onClickCopy = async () => { const lastResponseText = findLastResponseChat(chats); if (lastResponseText) { @@ -127,6 +127,11 @@ export default function ResponseMessageBox({ ? t("responseMessageBox_copyButtonText_copied") : t("responseMessageBox_copyButtonText_copy")} + {isReceiving && ( + + {t("responseMessageBox_stopButtonText")} + + )} e.stopPropagation()} /> - + + {t("responseMessageBox_sendButtonText")} @@ -173,11 +179,11 @@ const ChatBox = ({ if (chat.role === "assistant") { return ( - - - {chat.content} - - + // + + {chat.content} + + // ); } diff --git a/src/pages/content/src/ContentScriptApp/xState/dragStateMachine.ts b/src/pages/content/src/ContentScriptApp/xState/dragStateMachine.ts index 29d9126..0029d91 100644 --- a/src/pages/content/src/ContentScriptApp/xState/dragStateMachine.ts +++ b/src/pages/content/src/ContentScriptApp/xState/dragStateMachine.ts @@ -18,7 +18,10 @@ type TextSelectedEvent = { }; }; -type Events = TextSelectedEvent | { type: "CLOSE_MESSAGE_BOX" | "REQUEST" }; +type Events = + | TextSelectedEvent + | { type: "CLOSE_MESSAGE_BOX" | "REQUEST" | "RECEIVE_END" | "RECEIVE_CANCEL" } + | { type: "RECEIVE_ING"; data: string }; interface Context { chats: Chat[]; @@ -32,7 +35,7 @@ interface Context { type Services = { getGPTResponse: { - data: { result: string; tokenUsage: number }; + data: { firstChunk: string }; }; }; @@ -92,8 +95,8 @@ const dragStateMachine = createMachine( invoke: { src: "getGPTResponse", onDone: { - target: "response_message_box", - actions: "addResponseChat", + target: "temp_response_message_box", + actions: "addInitialResponseChat", }, onError: { target: "error_message_box", @@ -103,6 +106,15 @@ const dragStateMachine = createMachine( }, }, }, + temp_response_message_box: { + on: { + RECEIVE_ING: { + actions: "addResponseChatChunk", + }, + RECEIVE_END: "response_message_box", + RECEIVE_CANCEL: "idle", + }, + }, response_message_box: { tags: "showResponseMessages", on: { @@ -140,13 +152,24 @@ const dragStateMachine = createMachine( chats: (context) => context.chats.concat({ role: "user", content: context.selectedText }), }), - addResponseChat: assign({ + addInitialResponseChat: assign({ chats: (context, event) => context.chats.concat({ role: "assistant", - content: event.data.result, + content: event.data.firstChunk, }), }), + addResponseChatChunk: assign({ + chats: ({ chats }, event) => { + const lastChat = chats.at(-1); + if (!lastChat) { + return chats; + } + return chats + .slice(0, chats.length - 1) + .concat({ ...lastChat, content: lastChat.content + event.data }); + }, + }), }, guards: { isValidTextSelectedEvent: (_, event) => { diff --git a/src/pages/content/src/ContentScriptApp/xState/dragStateMachine.typegen.ts b/src/pages/content/src/ContentScriptApp/xState/dragStateMachine.typegen.ts index 9a43985..a6b1272 100644 --- a/src/pages/content/src/ContentScriptApp/xState/dragStateMachine.typegen.ts +++ b/src/pages/content/src/ContentScriptApp/xState/dragStateMachine.typegen.ts @@ -25,10 +25,15 @@ export interface Typegen0 { services: "getGPTResponse"; }; eventsCausingActions: { + addInitialResponseChat: "done.invoke.drag-state.loading:invocation[0]"; addRequestChat: "REQUEST"; - addResponseChat: "done.invoke.drag-state.loading:invocation[0]"; + addResponseChatChunk: "RECEIVE_ING"; readyRequestButton: "TEXT_SELECTED"; - resetAll: "CLOSE_MESSAGE_BOX" | "TEXT_SELECTED" | "xstate.init"; + resetAll: + | "CLOSE_MESSAGE_BOX" + | "RECEIVE_CANCEL" + | "TEXT_SELECTED" + | "xstate.init"; setAnchorNodePosition: "REQUEST"; setPositionOnScreen: | "done.invoke.drag-state.loading:invocation[0]" @@ -48,6 +53,7 @@ export interface Typegen0 { | "idle" | "loading" | "request_button" - | "response_message_box"; + | "response_message_box" + | "temp_response_message_box"; tags: "showRequestButton" | "showResponseMessages"; } diff --git a/src/pages/popup/pages/NoApiKeyPage.tsx b/src/pages/popup/pages/NoApiKeyPage.tsx index d39996c..438305f 100644 --- a/src/pages/popup/pages/NoApiKeyPage.tsx +++ b/src/pages/popup/pages/NoApiKeyPage.tsx @@ -49,7 +49,6 @@ export const NoApiKeyPage = ({ { - const chatsWithoutError = context.chats.filter( - (chat) => chat.role !== "error" - ); - return getGPTResponse( - chatsWithoutError as ChatCompletionRequestMessage[] - ); + return getGPTResponseAsStream({ + messages: context.chats.filter( + (chat) => chat.role !== "error" + ) as ChatCompletionRequestMessage[], + onDelta: (chunk) => { + send("RECEIVE_ING", { data: chunk }); + }, + onFinish: (result) => send("RECEIVE_DONE", { data: result }), + }); }, }, actions: { @@ -62,7 +60,9 @@ export default function QuickChattingPage({ }, }); - const { scrollDownRef } = useScrollDownEffect([state.context.chats.length]); + const { scrollDownRef } = useScrollDownEffect([ + state.context.chats.at(-1)?.content, + ]); const { isCopied, copy } = useCopyClipboard([ state.context.chats.filter(({ role }) => role === "assistant").length, ]); @@ -75,6 +75,7 @@ export default function QuickChattingPage({ }; const isLoading = state.matches("loading"); + const isReceiving = state.matches("receiving"); const onChatSubmit: FormEventHandler = (event) => { event.preventDefault(); @@ -85,6 +86,10 @@ export default function QuickChattingPage({ send("RESET"); }; + const onClickStopButton = () => { + send("RECEIVE_CANCEL"); + }; + const onChatInputKeyDown: KeyboardEventHandler = ( event ) => { @@ -166,9 +171,20 @@ export default function QuickChattingPage({ ? t("quickChattingPage_copyButtonText_copied") : t("quickChattingPage_copyButtonText_copy")} - - {t("quickChattingPage_sendButtonText")} - + + {isReceiving && ( + + {t("quickChattingPage_stopButtonText")} + + )} + + {t("quickChattingPage_sendButtonText")} + + diff --git a/src/shared/services/getGPTResponseAsStream.ts b/src/shared/services/getGPTResponseAsStream.ts new file mode 100644 index 0000000..76a50c1 --- /dev/null +++ b/src/shared/services/getGPTResponseAsStream.ts @@ -0,0 +1,31 @@ +import { ChatCompletionRequestMessage } from "openai"; +import { sendMessageToBackground } from "@src/chrome/message"; + +export async function getGPTResponseAsStream({ + messages, + onDelta, + onFinish, +}: { + messages: ChatCompletionRequestMessage[]; + onDelta: (chunk: string) => unknown; + onFinish: (result: string) => unknown; +}) { + return new Promise<{ cancel: () => unknown; firstChunk: string }>( + (resolve, reject) => { + const { disconnect } = sendMessageToBackground({ + message: { + type: "RequestChatGPTStream", + input: messages, + }, + handleSuccess: (response) => { + if (response.isDone || !response.chunk) { + return onFinish(response.result); + } + resolve({ cancel: disconnect, firstChunk: response.chunk }); + onDelta(response.chunk); + }, + handleError: reject, + }); + } + ); +} diff --git a/src/shared/xState/chatStateMachine.ts b/src/shared/xState/chatStateMachine.ts index 35cf702..a259c54 100644 --- a/src/shared/xState/chatStateMachine.ts +++ b/src/shared/xState/chatStateMachine.ts @@ -7,24 +7,21 @@ type Events = interface Context { inputText: string; chats: Chat[]; - leftToken: number; error?: Error; } type Services = { getGPTResponse: { - data: { result: string; tokenUsage: number }; + data: { result: string }; }; getChatHistoryFromBackground: { data: Chat[]; }; }; -const MAX_TOKEN = 4096 as const; const initialContext: Context = { inputText: "", chats: [], - leftToken: MAX_TOKEN, }; const chatStateMachine = createMachine( @@ -99,7 +96,6 @@ const chatStateMachine = createMachine( role: "assistant", content: event.data.result, }), - leftToken: (_, event) => MAX_TOKEN - event.data.tokenUsage, }), addErrorChat: assign({ chats: (context, event) => { diff --git a/src/shared/xState/streamChatStateMachine.ts b/src/shared/xState/streamChatStateMachine.ts new file mode 100644 index 0000000..89c9263 --- /dev/null +++ b/src/shared/xState/streamChatStateMachine.ts @@ -0,0 +1,168 @@ +import { assign, createMachine } from "xstate"; + +type Events = + | { type: "EXIT" | "QUERY" | "RESET" | "RECEIVE_CANCEL" } + | { type: "CHANGE_TEXT"; data: string } + | { type: "RECEIVE_ING"; data: string } + | { type: "RECEIVE_DONE"; data: string }; + +interface Context { + inputText: string; + chats: Chat[]; + tempResponse: string; + error?: Error; + cancelReceive?: () => unknown; +} + +type Services = { + getGPTResponse: { + data: { cancel: Context["cancelReceive"]; firstChunk: string }; + }; + getChatHistoryFromBackground: { + data: Chat[]; + }; +}; + +const initialContext: Context = { + inputText: "", + chats: [], + tempResponse: "", +}; + +const streamChatStateMachine = createMachine( + { + id: "stream-chat-state", + initial: "init", + predictableActionArguments: true, + context: initialContext, + schema: { + context: {} as Context, + events: {} as Events, + services: {} as Services, + }, + tsTypes: {} as import("./streamChatStateMachine.typegen").Typegen0, + states: { + init: { + invoke: { + src: "getChatHistoryFromBackground", + onDone: { target: "idle", actions: "setChats" }, + onError: { target: "idle" }, + }, + }, + idle: { + on: { + QUERY: { + target: "loading", + actions: ["addUserChat", "resetChatText"], + cond: "isValidText", + }, + EXIT: "finish", + RESET: { actions: "resetChatData" }, + CHANGE_TEXT: { + actions: "updateChatText", + }, + }, + }, + loading: { + invoke: { + src: "getGPTResponse", + onDone: { + target: "receiving", + actions: ["addInitialAssistantChat", "setCancelReceive"], + }, + onError: { target: "idle", actions: "addErrorChat" }, + }, + on: { + EXIT: "finish", + RESET: { actions: "resetChatData" }, + CHANGE_TEXT: { + actions: "updateChatText", + }, + }, + }, + receiving: { + on: { + RECEIVE_ING: { target: "receiving", actions: "addResponseToken" }, + RECEIVE_DONE: { target: "idle", actions: "replaceLastResponse" }, + RECEIVE_CANCEL: { target: "idle", actions: "execCancelReceive" }, + CHANGE_TEXT: { + actions: "updateChatText", + }, + }, + }, + finish: { + type: "final", + entry: "exitChatting", + }, + }, + }, + { + actions: { + setChats: assign({ + chats: (_, event) => event.data, + }), + addUserChat: assign({ + chats: (context) => + context.chats.concat({ + role: "user", + content: context.inputText, + }), + }), + addInitialAssistantChat: assign({ + chats: (context, event) => + context.chats.concat({ + role: "assistant", + content: event.data.firstChunk, + }), + }), + addResponseToken: assign({ + chats: (context, event) => { + return context.chats.map((chat, index) => { + // if last index + if (index === context.chats.length - 1) { + return { ...chat, content: chat.content + event.data }; + } + return chat; + }); + }, + }), + replaceLastResponse: assign({ + chats: (context, event) => { + return context.chats.map((chat, index) => { + if (index === context.chats.length - 1) { + return { ...chat, content: event.data }; + } + return chat; + }); + }, + }), + setCancelReceive: assign({ + cancelReceive: (_, event) => event.data.cancel, + }), + execCancelReceive: (context) => { + context.cancelReceive?.(); + }, + addErrorChat: assign({ + chats: (context, event) => { + const error: Error = event.data as Error; + return context.chats.concat({ + role: "error", + content: `${error?.name}\n${error?.message}`, + }); + }, + }), + updateChatText: assign({ + inputText: (_, event) => event.data, + }), + resetChatText: assign({ + inputText: () => "", + }), + resetChatData: assign({ chats: () => [] }), + }, + guards: { + isValidText: (context) => context.inputText.length > 0, + }, + } +); + +export default streamChatStateMachine; diff --git a/src/shared/xState/streamChatStateMachine.typegen.ts b/src/shared/xState/streamChatStateMachine.typegen.ts new file mode 100644 index 0000000..5a85939 --- /dev/null +++ b/src/shared/xState/streamChatStateMachine.typegen.ts @@ -0,0 +1,56 @@ +// This file was automatically generated. Edits will be overwritten + +export interface Typegen0 { + "@@xstate/typegen": true; + internalEvents: { + "done.invoke.stream-chat-state.init:invocation[0]": { + type: "done.invoke.stream-chat-state.init:invocation[0]"; + data: unknown; + __tip: "See the XState TS docs to learn how to strongly type this."; + }; + "done.invoke.stream-chat-state.loading:invocation[0]": { + type: "done.invoke.stream-chat-state.loading:invocation[0]"; + data: unknown; + __tip: "See the XState TS docs to learn how to strongly type this."; + }; + "error.platform.stream-chat-state.loading:invocation[0]": { + type: "error.platform.stream-chat-state.loading:invocation[0]"; + data: unknown; + }; + "xstate.init": { type: "xstate.init" }; + }; + invokeSrcNameMap: { + getChatHistoryFromBackground: "done.invoke.stream-chat-state.init:invocation[0]"; + getGPTResponse: "done.invoke.stream-chat-state.loading:invocation[0]"; + }; + missingImplementations: { + actions: "exitChatting"; + delays: never; + guards: never; + services: "getChatHistoryFromBackground" | "getGPTResponse"; + }; + eventsCausingActions: { + addErrorChat: "error.platform.stream-chat-state.loading:invocation[0]"; + addInitialAssistantChat: "done.invoke.stream-chat-state.loading:invocation[0]"; + addResponseToken: "RECEIVE_ING"; + addUserChat: "QUERY"; + execCancelReceive: "RECEIVE_CANCEL"; + exitChatting: "EXIT"; + replaceLastResponse: "RECEIVE_DONE"; + resetChatData: "RESET"; + resetChatText: "QUERY"; + setCancelReceive: "done.invoke.stream-chat-state.loading:invocation[0]"; + setChats: "done.invoke.stream-chat-state.init:invocation[0]"; + updateChatText: "CHANGE_TEXT"; + }; + eventsCausingDelays: {}; + eventsCausingGuards: { + isValidText: "QUERY"; + }; + eventsCausingServices: { + getChatHistoryFromBackground: "xstate.init"; + getGPTResponse: "QUERY"; + }; + matchesStates: "finish" | "idle" | "init" | "loading" | "receiving"; + tags: never; +} diff --git a/yarn.lock b/yarn.lock index 82ef229..e8c38c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2255,11 +2255,6 @@ "@typescript-eslint/types" "5.38.1" eslint-visitor-keys "^3.3.0" -"@vespaiach/axios-fetch-adapter@0.3.1": - version "0.3.1" - resolved "https://registry.yarnpkg.com/@vespaiach/axios-fetch-adapter/-/axios-fetch-adapter-0.3.1.tgz#b0c08167bec9cc558f578a1b9ccff52ead1cf1cb" - integrity sha512-+1F52VWXmQHSRFSv4/H0wtnxfvjRMPK5531e880MIjypPdUSX6QZuoDgEVeCE1vjhzDdxCVX7rOqkub7StEUwQ== - "@vitejs/plugin-react@2.1.0": version "2.1.0" resolved "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-2.1.0.tgz#4c99df15e71d2630601bd3018093bdc787d40e55" @@ -2487,13 +2482,6 @@ asynckit@^0.4.0: resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== -axios@0.26.0: - version "0.26.0" - resolved "https://registry.yarnpkg.com/axios/-/axios-0.26.0.tgz#9a318f1c69ec108f8cd5f3c3d390366635e13928" - integrity sha512-lKoGLMYtHvFrPVt3r+RBMp9nh34N0M8zEfCWqdWZx6phynIEhQqAdydpyBAAG211zlhX9Rgu08cOamy6XjE5Og== - dependencies: - follow-redirects "^1.14.8" - axios@^0.26.0: version "0.26.1" resolved "https://registry.yarnpkg.com/axios/-/axios-0.26.1.tgz#1ede41c51fcf51bbbd6fd43669caaa4f0495aaa9" @@ -5846,11 +5834,16 @@ tslib@^1.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.4.0: +tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0: version "2.5.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.0.tgz#42bfed86f5787aeb41d031866c8f402429e0fddf" integrity sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg== +tslib@^2.4.0: + version "2.5.2" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.2.tgz#1b6f07185c881557b0ffa84b111a0106989e8338" + integrity sha512-5svOrSA2w3iGFDs1HibEVBGbDrAY82bFQ3HZ3ixB+88nsbsWQoKqDRb5UBYAUPEzbBn6dAp5gRNXglySbx1MlA== + tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"