├── .nvmrc ├── src ├── pages │ ├── options │ │ ├── index.css │ │ ├── index.html │ │ ├── src │ │ │ ├── components │ │ │ │ ├── EmptySession.tsx │ │ │ │ ├── layout │ │ │ │ │ ├── ConditionalRender.tsx │ │ │ │ │ └── ChatHistoryMainLayout.tsx │ │ │ │ ├── PleaseSelectSession.tsx │ │ │ │ ├── ChatSessionGroupHeader.tsx │ │ │ │ ├── ChatHistoryHeader.tsx │ │ │ │ ├── ChatSessionGroup.tsx │ │ │ │ └── ChatHistory.tsx │ │ │ ├── App.tsx │ │ │ └── pages │ │ │ │ └── Main.tsx │ │ └── index.tsx │ ├── content │ │ ├── style.scss │ │ ├── index.ts │ │ └── src │ │ │ └── ContentScriptApp │ │ │ ├── utils │ │ │ ├── getSafePixel.ts │ │ │ ├── delayPromise.ts │ │ │ ├── selection.ts │ │ │ ├── getSafePixel.test.ts │ │ │ ├── getPositionOnScreen.ts │ │ │ └── getPositionOnScreen.test.ts │ │ │ ├── constant │ │ │ └── elementId.ts │ │ │ ├── components │ │ │ ├── DraggableBox.tsx │ │ │ ├── GPTRequestButton.test.tsx │ │ │ ├── messageBox │ │ │ │ ├── ErrorMessageBox.tsx │ │ │ │ ├── ErrorMessageBox.test.tsx │ │ │ │ ├── MessageBox.tsx │ │ │ │ └── ResponseMessageBox.tsx │ │ │ └── GPTRequestButton.tsx │ │ │ ├── emotion │ │ │ ├── FontProvider.tsx │ │ │ ├── EmotionCacheProvider.tsx │ │ │ └── ResetStyleProvider.tsx │ │ │ ├── index.tsx │ │ │ ├── hooks │ │ │ ├── useSelectedSlot.ts │ │ │ └── useRootOutsideClick.ts │ │ │ ├── App.tsx │ │ │ ├── xState │ │ │ ├── dragStateMachine.typegen.ts │ │ │ └── dragStateMachine.ts │ │ │ └── DragGPT.tsx │ ├── popup │ │ ├── App.tsx │ │ ├── components │ │ │ ├── StyledButton.tsx │ │ │ ├── layout │ │ │ │ ├── Footer.tsx │ │ │ │ └── MainLayout.tsx │ │ │ ├── SlotListItem.tsx │ │ │ └── SlotDetail.tsx │ │ ├── index.tsx │ │ ├── index.html │ │ ├── xState │ │ │ ├── popupStateMachine.typegen.ts │ │ │ ├── slotListPageStateMachine.typegen.ts │ │ │ ├── popupStateMachine.ts │ │ │ └── slotListPageStateMachine.ts │ │ ├── Popup.tsx │ │ └── pages │ │ │ ├── NoApiKeyPage.tsx │ │ │ └── SlotListPage.tsx │ └── background │ │ └── lib │ │ ├── storage │ │ ├── apiKeyStorage.ts │ │ ├── onOffStorage.ts │ │ ├── apiKeyStorage.test.ts │ │ ├── quickChatHistoryStorage.ts │ │ ├── slotStorage.ts │ │ ├── quickChatHistoryStorage.test.ts │ │ ├── chatHistoryStorage.ts │ │ └── slotStorage.test.ts │ │ ├── utils │ │ └── logger.ts │ │ ├── service │ │ ├── slotsManipulatorService.ts │ │ └── slotsManipulatorService.test.ts │ │ └── infra │ │ └── chatGPT.ts ├── vite-env.d.ts ├── assets │ └── style │ │ └── theme.scss ├── shared │ ├── utils │ │ └── generateId.ts │ ├── ts-utils │ │ └── exhaustiveMatchingGuard.ts │ ├── component │ │ ├── StyleProvider.tsx │ │ ├── UserChat.tsx │ │ ├── AssistantChat.tsx │ │ ├── FontProvider.tsx │ │ ├── ChatText.tsx │ │ ├── ResetStyleProvider.tsx │ │ └── ChatCollapse.tsx │ ├── slot │ │ └── createNewChatGPTSlot.ts │ ├── hook │ │ ├── useGeneratedId.ts │ │ ├── useCopyClipboard.ts │ │ ├── useScrollDownEffect.ts │ │ └── useBackgroundMessage.tsx │ ├── services │ │ └── getGPTResponseAsStream.ts │ └── xState │ │ ├── chatStateMachine.typegen.ts │ │ ├── streamChatStateMachine.typegen.ts │ │ ├── chatStateMachine.ts │ │ └── streamChatStateMachine.ts ├── constant │ ├── style.ts │ └── promptGeneratePrompt.ts ├── chrome │ ├── i18n.ts │ ├── localStorage.ts │ └── message.ts └── global.d.ts ├── public ├── icon-128.png ├── icon-34.png ├── logo-dark.png └── _locales │ ├── zh_CN │ └── messages.json │ ├── ja │ └── messages.json │ ├── ko │ └── messages.json │ └── en │ └── messages.json ├── .prettierrc ├── test-utils └── jest.setup.ts ├── .github ├── workflows │ ├── auto_assign.yml │ ├── test.yml │ ├── build.yml │ └── review.yml └── auto_assign.yml ├── .gitignore ├── utils ├── reload │ ├── constant.ts │ ├── utils.ts │ ├── injections │ │ ├── script.ts │ │ └── view.ts │ ├── interpreter │ │ ├── index.ts │ │ └── types.ts │ ├── rollup.config.ts │ ├── initReloadClient.ts │ └── initReloadServer.ts ├── manifest-parser │ └── index.ts ├── plugins │ ├── custom-dynamic-import.ts │ ├── make-manifest.ts │ └── add-hmr.ts └── log.ts ├── .eslintrc ├── LICENSE ├── manifest.ts ├── tsconfig.json ├── README.zh-CN.md ├── README.ja.md ├── README.ko.md ├── CONTRIBUTING.md ├── README.md ├── package.json ├── vite.config.ts └── CODE_OF_CONDUCT.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 18.12.0 2 | -------------------------------------------------------------------------------- /src/pages/options/index.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/pages/content/style.scss: -------------------------------------------------------------------------------- 1 | @import "@assets/style/theme.scss"; 2 | -------------------------------------------------------------------------------- /src/assets/style/theme.scss: -------------------------------------------------------------------------------- 1 | .crx-class { 2 | color: pink; 3 | } 4 | -------------------------------------------------------------------------------- /public/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jonghakseo/drag-gpt-extension/HEAD/public/icon-128.png -------------------------------------------------------------------------------- /public/icon-34.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jonghakseo/drag-gpt-extension/HEAD/public/icon-34.png -------------------------------------------------------------------------------- /public/logo-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jonghakseo/drag-gpt-extension/HEAD/public/logo-dark.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "trailingComma": "es5", 4 | "arrowParens": "always" 5 | } 6 | -------------------------------------------------------------------------------- /src/pages/content/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @description 3 | * Chrome extensions don't support modules in content scripts. 4 | */ 5 | import("./src/ContentScriptApp"); 6 | -------------------------------------------------------------------------------- /src/pages/content/src/ContentScriptApp/utils/getSafePixel.ts: -------------------------------------------------------------------------------- 1 | export default function getSafePixel(pixelNumber: number) { 2 | return `${Math.max(pixelNumber, 0)}px`; 3 | } 4 | -------------------------------------------------------------------------------- /src/shared/utils/generateId.ts: -------------------------------------------------------------------------------- 1 | export default function generateId() { 2 | return Number( 3 | String(new Date().getTime() + Math.random()).replace(".", "") 4 | ).toString(36); 5 | } 6 | -------------------------------------------------------------------------------- /test-utils/jest.setup.ts: -------------------------------------------------------------------------------- 1 | // Do what you need to set up your test 2 | 3 | jest.mock("@src/chrome/i18n", () => { 4 | const t = (key: string) => key; 5 | return { 6 | t, 7 | }; 8 | }); 9 | -------------------------------------------------------------------------------- /src/pages/content/src/ContentScriptApp/constant/elementId.ts: -------------------------------------------------------------------------------- 1 | export const ROOT_ID = 2 | "chrome-extension-boilerplate-react-vite-content-view-root"; 3 | export const SHADOW_ROOT_ID = "shadow-root"; 4 | -------------------------------------------------------------------------------- /src/shared/ts-utils/exhaustiveMatchingGuard.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 2 | export function exhaustiveMatchingGuard(_: never) { 3 | throw new Error("should not here"); 4 | } 5 | -------------------------------------------------------------------------------- /src/pages/content/src/ContentScriptApp/utils/delayPromise.ts: -------------------------------------------------------------------------------- 1 | export default async function delayPromise(ms: number) { 2 | return new Promise((resolve) => 3 | setTimeout(() => { 4 | resolve(ms); 5 | }, ms) 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /.github/workflows/auto_assign.yml: -------------------------------------------------------------------------------- 1 | name: 'Auto Assign' 2 | on: 3 | pull_request: 4 | types: [opened] 5 | 6 | jobs: 7 | add-reviews: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: kentaro-m/auto-assign-action@v1.2.5 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | 4 | # testing 5 | /coverage 6 | 7 | # build 8 | /dist 9 | 10 | # etc 11 | .DS_Store 12 | .env.local 13 | .idea 14 | 15 | # compiled 16 | utils/reload/*.js 17 | utils/reload/injections/*.js 18 | 19 | public/manifest.json 20 | -------------------------------------------------------------------------------- /src/pages/popup/App.tsx: -------------------------------------------------------------------------------- 1 | import Popup from "@pages/popup/Popup"; 2 | import StyleProvider from "@src/shared/component/StyleProvider"; 3 | 4 | export default function App() { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/shared/component/StyleProvider.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import { ChakraProvider } from "@chakra-ui/react"; 3 | 4 | export default function StyleProvider({ children }: { children: ReactNode }) { 5 | return {children}; 6 | } 7 | -------------------------------------------------------------------------------- /src/constant/style.ts: -------------------------------------------------------------------------------- 1 | export const COLORS = { 2 | CONTENT_BACKGROUND: "#2a4365", 3 | POPUP_BACKGROUND: "#282c34", // index css 4 | PRIMARY: "#3F75E5FF", 5 | WHITE: "#f5f5f5", 6 | RED: "#e35353", 7 | }; 8 | 9 | export const Z_INDEX = { 10 | ROOT: 2147483647, 11 | }; 12 | -------------------------------------------------------------------------------- /src/pages/options/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Options 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /utils/reload/constant.ts: -------------------------------------------------------------------------------- 1 | export const LOCAL_RELOAD_SOCKET_PORT = 8081; 2 | export const LOCAL_RELOAD_SOCKET_URL = `ws://localhost:${LOCAL_RELOAD_SOCKET_PORT}`; 3 | export const UPDATE_PENDING_MESSAGE = "wait_update"; 4 | export const UPDATE_REQUEST_MESSAGE = "do_update"; 5 | export const UPDATE_COMPLETE_MESSAGE = "done_update"; 6 | -------------------------------------------------------------------------------- /src/shared/slot/createNewChatGPTSlot.ts: -------------------------------------------------------------------------------- 1 | export function createNewChatGPTSlot(config?: Partial): Slot { 2 | return { 3 | type: "gpt-4o", 4 | isSelected: false, 5 | id: generateId(), 6 | name: "", 7 | ...config, 8 | }; 9 | } 10 | 11 | function generateId(): string { 12 | return `${Date.now()}${Math.random()}`; 13 | } 14 | -------------------------------------------------------------------------------- /utils/reload/utils.ts: -------------------------------------------------------------------------------- 1 | import { clearTimeout } from "timers"; 2 | 3 | export function debounce( 4 | callback: (...args: A) => void, 5 | delay: number 6 | ) { 7 | let timer: NodeJS.Timeout; 8 | return function (...args: A) { 9 | clearTimeout(timer); 10 | timer = setTimeout(() => callback(...args), delay); 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /src/pages/options/src/components/EmptySession.tsx: -------------------------------------------------------------------------------- 1 | import { Text, VStack } from "@chakra-ui/react"; 2 | import { InfoIcon } from "@chakra-ui/icons"; 3 | 4 | export default function EmptySession() { 5 | return ( 6 | 7 | 8 | Empty 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /utils/manifest-parser/index.ts: -------------------------------------------------------------------------------- 1 | type Manifest = chrome.runtime.ManifestV3; 2 | 3 | class ManifestParser { 4 | // eslint-disable-next-line @typescript-eslint/no-empty-function 5 | private constructor() {} 6 | 7 | static convertManifestToString(manifest: Manifest): string { 8 | return JSON.stringify(manifest, null, 2); 9 | } 10 | } 11 | 12 | export default ManifestParser; 13 | -------------------------------------------------------------------------------- /utils/reload/injections/script.ts: -------------------------------------------------------------------------------- 1 | import initReloadClient from "../initReloadClient"; 2 | 3 | export default function addHmrIntoScript(watchPath: string) { 4 | initReloadClient({ 5 | watchPath, 6 | onUpdate: () => { 7 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 8 | // @ts-ignore 9 | chrome.runtime.reload(); 10 | }, 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /src/pages/options/src/components/layout/ConditionalRender.tsx: -------------------------------------------------------------------------------- 1 | type ConditionalRenderProps = { 2 | isRender: boolean; 3 | children: React.ReactNode; 4 | fallback?: React.ReactNode; 5 | }; 6 | 7 | export default function ConditionalRender({ 8 | isRender, 9 | children, 10 | fallback, 11 | }: ConditionalRenderProps) { 12 | return isRender ? <>{children} : <>{fallback}; 13 | } 14 | -------------------------------------------------------------------------------- /src/pages/options/src/components/PleaseSelectSession.tsx: -------------------------------------------------------------------------------- 1 | import { Text, VStack } from "@chakra-ui/react"; 2 | import { InfoIcon } from "@chakra-ui/icons"; 3 | 4 | export default function PleaseSelectSession() { 5 | return ( 6 | 7 | 8 | Please select session 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/pages/content/src/ContentScriptApp/utils/selection.ts: -------------------------------------------------------------------------------- 1 | export function getSelectionText(): string { 2 | return window.getSelection()?.toString() ?? ""; 3 | } 4 | 5 | export function getSelectionNodeRect(): DOMRect | undefined { 6 | try { 7 | return ( 8 | window.getSelection()?.getRangeAt(0)?.getBoundingClientRect() ?? undefined 9 | ); 10 | } catch { 11 | return undefined; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/shared/hook/useGeneratedId.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from "react"; 2 | import generateId from "@src/shared/utils/generateId"; 3 | 4 | export default function useGeneratedId(prefix?: string) { 5 | const idRef = useRef(prefix + generateId()); 6 | 7 | const regenerate = () => { 8 | idRef.current = generateId(); 9 | }; 10 | 11 | return { 12 | id: idRef.current, 13 | regenerate, 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /utils/plugins/custom-dynamic-import.ts: -------------------------------------------------------------------------------- 1 | import { PluginOption } from "vite"; 2 | 3 | export default function customDynamicImport(): PluginOption { 4 | return { 5 | name: "custom-dynamic-import", 6 | renderDynamicImport() { 7 | return { 8 | left: ` 9 | { 10 | const dynamicImport = (path) => import(path); 11 | dynamicImport( 12 | `, 13 | right: ")}", 14 | }; 15 | }, 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /src/shared/component/UserChat.tsx: -------------------------------------------------------------------------------- 1 | import { BoxProps, Box } from "@chakra-ui/react"; 2 | import { PropsWithChildren } from "react"; 3 | 4 | type UserChatProps = PropsWithChildren & Omit; 5 | 6 | export default function UserChat({ ...restProps }: UserChatProps) { 7 | return ( 8 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/shared/component/AssistantChat.tsx: -------------------------------------------------------------------------------- 1 | import { BoxProps, Box } from "@chakra-ui/react"; 2 | import { PropsWithChildren } from "react"; 3 | 4 | type AssistantChatProps = PropsWithChildren & Omit; 5 | 6 | export default function AssistantChat({ ...restProps }: AssistantChatProps) { 7 | return ( 8 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /utils/reload/interpreter/index.ts: -------------------------------------------------------------------------------- 1 | import { ReloadMessage, SerializedMessage } from "./types"; 2 | 3 | export default class MessageInterpreter { 4 | // eslint-disable-next-line @typescript-eslint/no-empty-function 5 | private constructor() {} 6 | 7 | static send(message: ReloadMessage): SerializedMessage { 8 | return JSON.stringify(message); 9 | } 10 | static receive(serializedMessage: SerializedMessage): ReloadMessage { 11 | return JSON.parse(serializedMessage); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/pages/popup/components/StyledButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button, ButtonProps } from "@chakra-ui/react"; 2 | 3 | type StyledButtonProps = ButtonProps; 4 | 5 | const StyledButton = ({ ...restProps }: StyledButtonProps) => { 6 | return ( 7 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/pages/popup/components/layout/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { Link, Spacer, Text } from "@chakra-ui/react"; 2 | import React from "react"; 3 | import { t } from "@src/chrome/i18n"; 4 | 5 | export default function Footer() { 6 | return ( 7 | <> 8 | 9 | 16 | 17 | {t("footer_EmailText")} 18 | 19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/pages/content/src/ContentScriptApp/components/DraggableBox.tsx: -------------------------------------------------------------------------------- 1 | import Draggable from "react-draggable"; 2 | import { ReactNode } from "react"; 3 | 4 | type DraggableBoxProps = { 5 | defaultX: number; 6 | defaultY: number; 7 | children: ReactNode; 8 | }; 9 | 10 | export default function DraggableBox({ 11 | defaultY, 12 | defaultX, 13 | children, 14 | }: DraggableBoxProps) { 15 | return ( 16 | 20 | {children} 21 | 22 | ); 23 | } 24 | 25 | DraggableBox.handlerClassName = "drag_gpt_handle"; 26 | -------------------------------------------------------------------------------- /src/shared/hook/useCopyClipboard.ts: -------------------------------------------------------------------------------- 1 | import { DependencyList, useEffect, useState } from "react"; 2 | 3 | export function useCopyClipboard(copiedResetEffectDeps?: DependencyList) { 4 | const [isCopied, setIsCopied] = useState(false); 5 | 6 | useEffect(() => { 7 | setIsCopied(false); 8 | }, copiedResetEffectDeps); 9 | 10 | const copy = async (lastResponseText: string) => { 11 | await copyToClipboard(lastResponseText); 12 | setIsCopied(true); 13 | }; 14 | 15 | return { 16 | isCopied, 17 | copy, 18 | }; 19 | } 20 | async function copyToClipboard(text: string) { 21 | await navigator.clipboard.writeText(text); 22 | } 23 | -------------------------------------------------------------------------------- /src/pages/options/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Spinner } from "@chakra-ui/react"; 2 | import { FC, Suspense } from "react"; 3 | import FontProvider from "@src/shared/component/FontProvider"; 4 | import OptionMainPage from "@pages/options/src/pages/Main"; 5 | import StyleProvider from "@src/shared/component/StyleProvider"; 6 | 7 | const App: FC = () => { 8 | return ( 9 | 10 | 11 | {/* TODO router */} 12 | }> 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default App; 21 | -------------------------------------------------------------------------------- /src/pages/background/lib/storage/onOffStorage.ts: -------------------------------------------------------------------------------- 1 | import { ILocalStorage, LocalStorage } from "@src/chrome/localStorage"; 2 | 3 | export class OnOffStorage { 4 | private static KEY = "ON_OFF"; 5 | static storage: ILocalStorage = new LocalStorage(); 6 | 7 | static async getOnOff(): Promise { 8 | try { 9 | const onOff = await this.storage.load(this.KEY); 10 | return Boolean(onOff); 11 | } catch { 12 | await this.storage.save(this.KEY, false); 13 | return false; 14 | } 15 | } 16 | 17 | static async toggle() { 18 | const onOff = await this.storage.load(this.KEY); 19 | await this.storage.save(this.KEY, !onOff); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/pages/options/src/components/ChatHistoryHeader.tsx: -------------------------------------------------------------------------------- 1 | import { Button, HStack, StackProps } from "@chakra-ui/react"; 2 | import { DeleteIcon } from "@chakra-ui/icons"; 3 | 4 | type ChatHistoryHeaderProps = { 5 | deleteSelectedSession: () => void; 6 | } & StackProps; 7 | 8 | export default function ChatHistoryHeader({ 9 | deleteSelectedSession, 10 | ...restProps 11 | }: ChatHistoryHeaderProps) { 12 | return ( 13 | 14 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/shared/component/FontProvider.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useEffect } from "react"; 2 | 3 | export default function FontProvider({ children }: { children: ReactNode }) { 4 | useEffect(() => { 5 | const linkNode = document.createElement("link"); 6 | linkNode.type = "text/css"; 7 | linkNode.rel = "stylesheet"; 8 | linkNode.href = 9 | "https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;700&display=swap"; 10 | document.head.appendChild(linkNode); 11 | }, []); 12 | 13 | return ( 14 | <> 15 | 20 | {children} 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/pages/content/src/ContentScriptApp/emotion/FontProvider.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useEffect } from "react"; 2 | 3 | export default function FontProvider({ children }: { children: ReactNode }) { 4 | useEffect(() => { 5 | const linkNode = document.createElement("link"); 6 | linkNode.type = "text/css"; 7 | linkNode.rel = "stylesheet"; 8 | linkNode.href = 9 | "https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;700&display=swap"; 10 | document.head.appendChild(linkNode); 11 | }, []); 12 | 13 | return ( 14 | <> 15 | 20 | {children} 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /utils/reload/rollup.config.ts: -------------------------------------------------------------------------------- 1 | import typescript from "@rollup/plugin-typescript"; 2 | 3 | const plugins = [typescript()]; 4 | 5 | export default [ 6 | { 7 | plugins, 8 | input: "utils/reload/initReloadServer.ts", 9 | output: { 10 | file: "utils/reload/initReloadServer.js", 11 | }, 12 | external: ["ws", "chokidar", "timers"], 13 | }, 14 | { 15 | plugins, 16 | input: "utils/reload/injections/script.ts", 17 | output: { 18 | file: "utils/reload/injections/script.js", 19 | }, 20 | }, 21 | { 22 | plugins, 23 | input: "utils/reload/injections/view.ts", 24 | output: { 25 | file: "utils/reload/injections/view.js", 26 | }, 27 | }, 28 | ]; 29 | -------------------------------------------------------------------------------- /src/pages/content/src/ContentScriptApp/utils/getSafePixel.test.ts: -------------------------------------------------------------------------------- 1 | import getSafePixel from "@pages/content/src/ContentScriptApp/utils/getSafePixel"; 2 | 3 | describe("getSafePixel test", () => { 4 | test("양수 값을 넣으면 `$숫자}px`이 반환된다", async () => { 5 | // given 6 | const positiveNumber = 10; 7 | 8 | // when 9 | const result = getSafePixel(positiveNumber); 10 | 11 | // then 12 | expect(result).toEqual(`${positiveNumber}px`); 13 | }); 14 | test("음수 값을 넣으면 0px이 반환된다", async () => { 15 | // given 16 | const negativeNumber = -10; 17 | 18 | // when 19 | const result = getSafePixel(negativeNumber); 20 | 21 | // then 22 | expect(result).toEqual("0px"); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/pages/content/src/ContentScriptApp/components/GPTRequestButton.test.tsx: -------------------------------------------------------------------------------- 1 | import GPTRequestButton from "@pages/content/src/ContentScriptApp/components/GPTRequestButton"; 2 | import { render, screen } from "@testing-library/react"; 3 | 4 | describe("GPTRequestButton test", () => { 5 | test("request 버튼이 노출된다", () => { 6 | // when 7 | render(); 8 | 9 | // then 10 | screen.getByRole("button", { name: "request" }); 11 | }); 12 | test("로딩 상태에서 로딩중이라는 사실을 알 수 있다", () => { 13 | // when 14 | render(); 15 | 16 | // then 17 | screen.getByRole("button", { name: "Loading..." }); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build And Zip Extension 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | 8 | jobs: 9 | build: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v3 18 | with: 19 | cache: 'yarn' 20 | 21 | - uses: actions/cache@v3 22 | with: 23 | path: node_modules 24 | key: ${{ runner.OS }}-build-${{ hashFiles('**/package-lock.json') }} 25 | 26 | - run: yarn install 27 | 28 | - run: yarn typegen 29 | 30 | - run: yarn build 31 | 32 | - uses: actions/upload-artifact@v4 33 | with: 34 | path: dist/* 35 | -------------------------------------------------------------------------------- /src/pages/content/src/ContentScriptApp/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client"; 2 | import App from "@src/pages/content/src/ContentScriptApp/App"; 3 | import refreshOnUpdate from "virtual:reload-on-update-in-view"; 4 | import { 5 | ROOT_ID, 6 | SHADOW_ROOT_ID, 7 | } from "@pages/content/src/ContentScriptApp/constant/elementId"; 8 | 9 | refreshOnUpdate("pages/content/src/ContentScriptApp"); 10 | 11 | const root = document.createElement("div"); 12 | root.id = ROOT_ID; 13 | 14 | document.body.append(root); 15 | 16 | const renderIn = document.createElement("div"); 17 | renderIn.id = SHADOW_ROOT_ID; 18 | 19 | const shadow = root.attachShadow({ mode: "open" }); 20 | shadow.appendChild(renderIn); 21 | 22 | createRoot(renderIn).render(); 23 | -------------------------------------------------------------------------------- /src/constant/promptGeneratePrompt.ts: -------------------------------------------------------------------------------- 1 | export const PROMPT_GENERATE_PROMPT = 2 | 'Act as a prompt generator for ChatGPT. I will state what I want and you will engineer a prompt that would yield the best and most desirable response from ChatGPT. Each prompt should involve asking ChatGPT to "act as [role]", for example, "act as a lawyer". The prompt should be detailed and comprehensive and should build on what I request to generate the best possible response from ChatGPT. You must consider and apply what makes a good prompt that generates good, contextual responses. Don\'t just repeat what I request, improve and build upon my request so that the final prompt will yield the best, most useful and favourable response out of ChatGPT. Place any variables in square brackets\n' + 3 | "Here is the prompt I want"; 4 | -------------------------------------------------------------------------------- /utils/reload/injections/view.ts: -------------------------------------------------------------------------------- 1 | import initReloadClient from "../initReloadClient"; 2 | 3 | export default function addHmrIntoView(watchPath: string) { 4 | let pendingReload = false; 5 | 6 | initReloadClient({ 7 | watchPath, 8 | onUpdate: () => { 9 | // disable reload when tab is hidden 10 | if (document.hidden) { 11 | pendingReload = true; 12 | return; 13 | } 14 | reload(); 15 | }, 16 | }); 17 | 18 | // reload 19 | function reload(): void { 20 | pendingReload = false; 21 | window.location.reload(); 22 | } 23 | 24 | // reload when tab is visible 25 | function reloadWhenTabIsVisible(): void { 26 | !document.hidden && pendingReload && reload(); 27 | } 28 | document.addEventListener("visibilitychange", reloadWhenTabIsVisible); 29 | } 30 | -------------------------------------------------------------------------------- /src/pages/content/src/ContentScriptApp/utils/getPositionOnScreen.ts: -------------------------------------------------------------------------------- 1 | export type PositionOnScreen = 2 | | "topLeft" 3 | | "topRight" 4 | | "bottomLeft" 5 | | "bottomRight"; 6 | 7 | export function getPositionOnScreen(position: { 8 | horizontalCenter: number; 9 | verticalCenter: number; 10 | }): PositionOnScreen { 11 | const viewportWidth = window.innerWidth; 12 | const viewportHeight = window.innerHeight; 13 | const isLeft = viewportWidth / 2 > position.verticalCenter; 14 | const isTop = viewportHeight / 2 > position.horizontalCenter; 15 | 16 | if (isTop && isLeft) { 17 | return "topLeft"; 18 | } 19 | if (isTop && !isLeft) { 20 | return "topRight"; 21 | } 22 | if (!isTop && isLeft) { 23 | return "bottomLeft"; 24 | } 25 | // !isTop && !isLeft 26 | return "bottomRight"; 27 | } 28 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:react/recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "plugin:prettier/recommended" 12 | ], 13 | "parser": "@typescript-eslint/parser", 14 | "parserOptions": { 15 | "ecmaFeatures": { 16 | "jsx": true 17 | }, 18 | "ecmaVersion": "latest", 19 | "sourceType": "module" 20 | }, 21 | "plugins": [ 22 | "react", 23 | "@typescript-eslint" 24 | ], 25 | "rules": { 26 | "react/react-in-jsx-scope": "off", 27 | "react/jsx-curly-brace-presence": [ 28 | "warn", 29 | { 30 | "props": "never", 31 | "children": "never" 32 | } 33 | ] 34 | }, 35 | "globals": { 36 | "chrome": "readonly" 37 | }, 38 | "ignorePatterns": [ 39 | "watch.js", 40 | "dist/**" 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /src/shared/component/ChatText.tsx: -------------------------------------------------------------------------------- 1 | import { Text } from "@chakra-ui/react"; 2 | import { PropsWithChildren } from "react"; 3 | 4 | type ChatTextProps = PropsWithChildren<{ 5 | isError?: boolean; 6 | bold?: boolean; 7 | padding?: number; 8 | border?: boolean; 9 | }>; 10 | 11 | export default function ChatText({ 12 | isError, 13 | bold, 14 | padding = 6, 15 | border = true, 16 | children, 17 | ...restProps 18 | }: ChatTextProps) { 19 | return ( 20 | 32 | {typeof children === "string" ? children.trim() : children} 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/pages/background/lib/storage/apiKeyStorage.test.ts: -------------------------------------------------------------------------------- 1 | import { ApiKeyStorage } from "@pages/background/lib/storage/apiKeyStorage"; 2 | 3 | describe("ApiKeyStorage test", () => { 4 | test("getApiKey test", async () => { 5 | // given 6 | const MOCK_API_KEY = "KEY"; 7 | jest 8 | .spyOn(ApiKeyStorage.storage, "load") 9 | .mockImplementationOnce(() => Promise.resolve(MOCK_API_KEY)); 10 | 11 | // when 12 | const apiKey = await ApiKeyStorage.getApiKey(); 13 | 14 | // then 15 | expect(apiKey).toEqual(MOCK_API_KEY); 16 | }); 17 | test("setApiKey test", async () => { 18 | // given 19 | const MOCK_API_KEY = "KEY"; 20 | const mockSave = jest 21 | .spyOn(ApiKeyStorage.storage, "save") 22 | .mockImplementationOnce(() => Promise.resolve()); 23 | 24 | // when 25 | await ApiKeyStorage.setApiKey(MOCK_API_KEY); 26 | 27 | // then 28 | expect(mockSave).toBeCalled(); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/pages/options/src/components/layout/ChatHistoryMainLayout.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | 3 | const Container = styled.main` 4 | display: grid; 5 | gap: 12px; 6 | grid-template-columns: 300px auto; 7 | `; 8 | 9 | const AsideWrapper = styled.aside` 10 | padding: 12px; 11 | max-height: 100vh; 12 | overflow-y: scroll; 13 | `; 14 | 15 | const SectionWrapper = styled.section` 16 | display: grid; 17 | grid-template-rows: auto 1fr; 18 | gap: 12px; 19 | padding: 12px 20px; 20 | width: 100%; 21 | height: 100vh; 22 | `; 23 | 24 | type ChatHistoryMainLayoutProps = { 25 | Aside: React.ReactNode; 26 | ChatHistory: React.ReactNode; 27 | }; 28 | 29 | export default function ChatHistoryMainLayout({ 30 | Aside, 31 | ChatHistory, 32 | }: ChatHistoryMainLayoutProps) { 33 | return ( 34 | 35 | {Aside} 36 | {ChatHistory} 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/pages/popup/components/layout/MainLayout.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from "react"; 2 | import styled from "@emotion/styled"; 3 | import { COLORS } from "@src/constant/style"; 4 | 5 | const Container = styled.div` 6 | position: relative; 7 | width: fit-content; 8 | height: fit-content; 9 | min-width: 440px; 10 | min-height: 300px; 11 | 12 | display: flex; 13 | gap: 8px; 14 | flex-direction: column; 15 | align-items: center; 16 | 17 | text-align: center; 18 | padding: 24px; 19 | background-color: ${COLORS.POPUP_BACKGROUND}; 20 | 21 | p { 22 | margin: 0; 23 | } 24 | `; 25 | 26 | type MainLayoutProps = { 27 | children: ReactNode; 28 | }; 29 | 30 | export default function MainLayout({ children }: MainLayoutProps) { 31 | return ( 32 | 33 | logo 38 | {children} 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/pages/background/lib/service/slotsManipulatorService.ts: -------------------------------------------------------------------------------- 1 | export class SlotsManipulatorService { 2 | static getSelectedSlot(slots: Slot[]): Slot | undefined { 3 | return slots.find(({ isSelected }) => isSelected); 4 | } 5 | 6 | static getSelectedSlotIndex(slots: Slot[]): number | undefined { 7 | const index = slots.findIndex(({ isSelected }) => isSelected); 8 | return index >= 0 ? index : undefined; 9 | } 10 | 11 | static addSlot(slots: Slot[], slot: Slot): Slot[] { 12 | return [...slots, slot]; 13 | } 14 | 15 | static updateSlot(slots: Slot[], slot: Slot): Slot[] { 16 | return slots.reduce((previousValue, currentValue) => { 17 | if (currentValue.id === slot.id) { 18 | return previousValue.concat(slot); 19 | } 20 | return previousValue.concat(currentValue); 21 | }, []); 22 | } 23 | 24 | static deleteSlot(slots: Slot[], slotId: string): Slot[] { 25 | return slots.filter((slot) => slot.id !== slotId); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/pages/content/src/ContentScriptApp/hooks/useSelectedSlot.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { sendMessageToBackgroundAsync } from "@src/chrome/message"; 3 | import { SlotsManipulatorService } from "@pages/background/lib/service/slotsManipulatorService"; 4 | import { useInterval } from "@chakra-ui/react"; 5 | 6 | export default function useSelectedSlot(pollIntervalMs = 1500) { 7 | const [selectedSlot, setSelectedSlot] = useState(); 8 | 9 | const getSelectedSlot = async (): Promise => { 10 | if (window.document.hidden) { 11 | return; 12 | } 13 | 14 | try { 15 | const slots = await sendMessageToBackgroundAsync({ type: "GetSlots" }); 16 | return SlotsManipulatorService.getSelectedSlot(slots); 17 | } catch (e) { 18 | return undefined; 19 | } 20 | }; 21 | 22 | useInterval(() => { 23 | getSelectedSlot().then(setSelectedSlot); 24 | }, pollIntervalMs); 25 | 26 | return selectedSlot; 27 | } 28 | -------------------------------------------------------------------------------- /src/pages/background/lib/storage/quickChatHistoryStorage.ts: -------------------------------------------------------------------------------- 1 | import { ILocalStorage, LocalStorage } from "@src/chrome/localStorage"; 2 | 3 | export class QuickChatHistoryStorage { 4 | private static QUICK_CHAT_HISTORY = "QUICK_CHAT_HISTORY"; 5 | static storage: ILocalStorage = new LocalStorage(); 6 | 7 | static async getChatHistories(): Promise { 8 | try { 9 | const chatHistories = await this.storage.load(this.QUICK_CHAT_HISTORY); 10 | if (Array.isArray(chatHistories)) { 11 | return chatHistories as Chat[]; 12 | } 13 | } catch (e) { 14 | return []; 15 | } 16 | return []; 17 | } 18 | 19 | static async resetChatHistories(): Promise { 20 | await this.storage.save(this.QUICK_CHAT_HISTORY, []); 21 | } 22 | 23 | static async pushChatHistories(chatOrChats: Chat | Chat[]): Promise { 24 | const chats = await this.getChatHistories(); 25 | await this.storage.save(this.QUICK_CHAT_HISTORY, chats.concat(chatOrChats)); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/review.yml: -------------------------------------------------------------------------------- 1 | name: Code Review with ChatGPT 2 | 3 | on: 4 | pull_request: 5 | types: [opened] 6 | 7 | jobs: 8 | add-auto-review-comment: 9 | if: contains(github.event.pull_request.labels.*.name, 'review') 10 | runs-on: ubuntu-latest 11 | name: Code Review with ChatGPT 12 | steps: 13 | - uses: Jonghakseo/gpt-pr-github-actions@v1 14 | with: 15 | openai_api_key: ${{ secrets.openai_api_key }} # Get the OpenAI API key from repository secrets 16 | github_token: ${{ secrets.GITHUB_TOKEN }} # Get the Github Token from repository secrets 17 | github_pr_id: ${{ github.event.number }} # Get the Github Pull Request ID from the Github event 18 | 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' 19 | openai_temperature: 0.5 # Optional: Default is 0.7 20 | openai_top_p: 0.5 # Optional: Default 0.8 21 | -------------------------------------------------------------------------------- /src/pages/content/src/ContentScriptApp/components/messageBox/ErrorMessageBox.tsx: -------------------------------------------------------------------------------- 1 | import MessageBox, { 2 | MessageBoxProps, 3 | } from "@pages/content/src/ContentScriptApp/components/messageBox/MessageBox"; 4 | import { Text } from "@chakra-ui/react"; 5 | import styled from "@emotion/styled"; 6 | import { t } from "@src/chrome/i18n"; 7 | 8 | const ErrorHeaderText = styled(Text)` 9 | font-weight: bold; 10 | color: #ea3737; 11 | `; 12 | 13 | type ErrorMessageBoxProps = Omit< 14 | MessageBoxProps, 15 | "header" | "content" | "width" 16 | > & { 17 | error?: Error; 18 | }; 19 | 20 | export default function ErrorMessageBox({ 21 | error, 22 | ...restProps 23 | }: ErrorMessageBoxProps) { 24 | return ( 25 | {`${t("errorMessageBox_errorTitle")}: ${ 28 | error?.name ?? t("errorMessageBox_unknownError") 29 | }`} 30 | } 31 | width={400} 32 | content={error?.message ?? t("errorMessageBox_unknownError")} 33 | {...restProps} 34 | /> 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/chrome/localStorage.ts: -------------------------------------------------------------------------------- 1 | export interface ILocalStorage { 2 | save(key: string, value: unknown): Promise; 3 | load(key: string): Promise; 4 | resetAll(): Promise; 5 | } 6 | 7 | export class LocalStorage implements ILocalStorage { 8 | async save(key: string, value: unknown) { 9 | return chrome.storage.local.set({ [key]: value }); 10 | } 11 | 12 | async load(key: string): Promise { 13 | return new Promise((resolve, reject) => { 14 | chrome.storage.local.get([key], (result) => { 15 | const value = result[key]; 16 | if (value === null || value === undefined) { 17 | const notFoundError = new Error(); 18 | notFoundError.name = "Not Found Storage Value"; 19 | notFoundError.message = `The [${key}] key could not be found. Register or change your key value`; 20 | 21 | reject(notFoundError); 22 | } else { 23 | resolve(value); 24 | } 25 | }); 26 | }); 27 | } 28 | 29 | async resetAll(): Promise { 30 | await chrome.storage.local.clear(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Seo Jong Hak 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/pages/content/src/ContentScriptApp/hooks/useRootOutsideClick.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, RefObject } from "react"; 2 | import { ROOT_ID } from "@pages/content/src/ContentScriptApp/constant/elementId"; 3 | 4 | type UseOutsideClickArgs = { 5 | ref: RefObject; 6 | handler: (event?: MouseEvent) => void; 7 | isDisabled?: boolean; 8 | }; 9 | 10 | export default function useRootOutsideClick({ 11 | ref, 12 | handler, 13 | isDisabled, 14 | }: UseOutsideClickArgs) { 15 | useEffect(() => { 16 | if (!ref.current) { 17 | return; 18 | } 19 | 20 | const root = ref.current.getRootNode(); 21 | const onClick = (event: MouseEvent) => { 22 | if (isDisabled) { 23 | return; 24 | } 25 | if ((event.target as HTMLElement).id === ROOT_ID) { 26 | return; 27 | } 28 | if (root.contains(event.target as HTMLElement)) { 29 | return; 30 | } 31 | handler(event); 32 | }; 33 | 34 | window.addEventListener("click", onClick); 35 | 36 | return () => { 37 | window.removeEventListener("click", onClick); 38 | }; 39 | }, [ref.current, handler, isDisabled]); 40 | } 41 | -------------------------------------------------------------------------------- /utils/plugins/make-manifest.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | import colorLog from "../log"; 4 | import { PluginOption } from "vite"; 5 | import ManifestParser from "../manifest-parser"; 6 | 7 | const { resolve } = path; 8 | 9 | const distDir = resolve(__dirname, "..", "..", "dist"); 10 | const publicDir = resolve(__dirname, "..", "..", "public"); 11 | 12 | export default function makeManifest( 13 | manifest: chrome.runtime.ManifestV3, 14 | config: { isDev: boolean } 15 | ): PluginOption { 16 | function makeManifest(to: string) { 17 | if (!fs.existsSync(to)) { 18 | fs.mkdirSync(to); 19 | } 20 | const manifestPath = resolve(to, "manifest.json"); 21 | 22 | fs.writeFileSync( 23 | manifestPath, 24 | ManifestParser.convertManifestToString(manifest) 25 | ); 26 | 27 | colorLog(`Manifest file copy complete: ${manifestPath}`, "success"); 28 | } 29 | 30 | return { 31 | name: "make-manifest", 32 | buildStart() { 33 | if (config.isDev) { 34 | makeManifest(distDir); 35 | } 36 | }, 37 | buildEnd() { 38 | if (config.isDev) { 39 | return; 40 | } 41 | makeManifest(publicDir); 42 | }, 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /src/pages/content/src/ContentScriptApp/App.tsx: -------------------------------------------------------------------------------- 1 | import DragGPT from "@pages/content/src/ContentScriptApp/DragGPT"; 2 | import EmotionCacheProvider from "@pages/content/src/ContentScriptApp/emotion/EmotionCacheProvider"; 3 | import ResetStyleProvider from "@pages/content/src/ContentScriptApp/emotion/ResetStyleProvider"; 4 | import FontProvider from "@pages/content/src/ContentScriptApp/emotion/FontProvider"; 5 | import { CSSReset, theme, ThemeProvider } from "@chakra-ui/react"; 6 | 7 | theme.space; 8 | export default function App() { 9 | return ( 10 | 11 | 12 | 13 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /utils/log.ts: -------------------------------------------------------------------------------- 1 | type ColorType = "success" | "info" | "error" | "warning" | keyof typeof COLORS; 2 | 3 | export default function colorLog(message: string, type?: ColorType) { 4 | let color: string = type || COLORS.FgBlack; 5 | 6 | switch (type) { 7 | case "success": 8 | color = COLORS.FgGreen; 9 | break; 10 | case "info": 11 | color = COLORS.FgBlue; 12 | break; 13 | case "error": 14 | color = COLORS.FgRed; 15 | break; 16 | case "warning": 17 | color = COLORS.FgYellow; 18 | break; 19 | } 20 | 21 | console.log(color, message); 22 | } 23 | 24 | const COLORS = { 25 | Reset: "\x1b[0m", 26 | Bright: "\x1b[1m", 27 | Dim: "\x1b[2m", 28 | Underscore: "\x1b[4m", 29 | Blink: "\x1b[5m", 30 | Reverse: "\x1b[7m", 31 | Hidden: "\x1b[8m", 32 | FgBlack: "\x1b[30m", 33 | FgRed: "\x1b[31m", 34 | FgGreen: "\x1b[32m", 35 | FgYellow: "\x1b[33m", 36 | FgBlue: "\x1b[34m", 37 | FgMagenta: "\x1b[35m", 38 | FgCyan: "\x1b[36m", 39 | FgWhite: "\x1b[37m", 40 | BgBlack: "\x1b[40m", 41 | BgRed: "\x1b[41m", 42 | BgGreen: "\x1b[42m", 43 | BgYellow: "\x1b[43m", 44 | BgBlue: "\x1b[44m", 45 | BgMagenta: "\x1b[45m", 46 | BgCyan: "\x1b[46m", 47 | BgWhite: "\x1b[47m", 48 | } as const; 49 | -------------------------------------------------------------------------------- /manifest.ts: -------------------------------------------------------------------------------- 1 | import packageJson from "./package.json"; 2 | 3 | /** 4 | * After changing, please reload the extension at `chrome://extensions` 5 | */ 6 | const manifest: chrome.runtime.ManifestV3 = { 7 | manifest_version: 3, 8 | name: "__MSG_extensionName__", 9 | default_locale: "ko", 10 | version: packageJson.version, 11 | description: "__MSG_extensionDescription__", 12 | options_page: "src/pages/options/index.html", 13 | background: { 14 | service_worker: "src/pages/background/index.js", 15 | type: "module", 16 | }, 17 | action: { 18 | default_popup: "src/pages/popup/index.html", 19 | default_icon: "icon-34.png", 20 | }, 21 | permissions: ["storage"], 22 | icons: { 23 | "128": "icon-128.png", 24 | }, 25 | content_scripts: [ 26 | { 27 | matches: ["http://*/*", "https://*/*", ""], 28 | js: ["src/pages/content/index.js"], 29 | css: ["assets/css/contentStyle.chunk.css"], 30 | }, 31 | ], 32 | web_accessible_resources: [ 33 | { 34 | resources: [ 35 | "assets/js/*.js", 36 | "assets/css/*.css", 37 | "logo-dark.png", 38 | "icon-128.png", 39 | "icon-34.png", 40 | ], 41 | matches: ["*://*/*"], 42 | }, 43 | ], 44 | }; 45 | 46 | export default manifest; 47 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "baseUrl": ".", 5 | "allowJs": false, 6 | "target": "esnext", 7 | "module": "esnext", 8 | "strict": true, 9 | "jsx": "react-jsx", 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "resolveJsonModule": true, 13 | "moduleResolution": "node", 14 | "types": [ 15 | "vite/client", 16 | "node" 17 | ], 18 | "noFallthroughCasesInSwitch": true, 19 | "allowSyntheticDefaultImports": true, 20 | "lib": [ 21 | "dom", 22 | "dom.iterable", 23 | "esnext" 24 | ], 25 | "forceConsistentCasingInFileNames": true, 26 | "typeRoots": [ 27 | "./src/global.d.ts" 28 | ], 29 | "paths": { 30 | "@src/*": [ 31 | "src/*" 32 | ], 33 | "@assets/*": [ 34 | "src/assets/*" 35 | ], 36 | "@pages/*": [ 37 | "src/pages/*" 38 | ], 39 | "virtual:reload-on-update-in-background-script": [ 40 | "./src/global.d.ts" 41 | ], 42 | "virtual:reload-on-update-in-view": [ 43 | "./src/global.d.ts" 44 | ] 45 | } 46 | }, 47 | "include": [ 48 | "src", 49 | "utils", 50 | "vite.config.ts", 51 | "node_modules/@types", 52 | "test-utils" 53 | ], 54 | "exclude": [ 55 | "node_modules/ts-jest" 56 | ] 57 | } 58 | -------------------------------------------------------------------------------- /src/pages/content/src/ContentScriptApp/emotion/EmotionCacheProvider.tsx: -------------------------------------------------------------------------------- 1 | import createCache from "@emotion/cache"; 2 | import { ReactNode, useEffect, useRef, useState } from "react"; 3 | import { CacheProvider, EmotionCache } from "@emotion/react"; 4 | import { ROOT_ID } from "@pages/content/src/ContentScriptApp/constant/elementId"; 5 | 6 | export default function EmotionCacheProvider({ 7 | children, 8 | }: { 9 | children: ReactNode; 10 | }) { 11 | const shadowRootRef = useRef(null); 12 | const [emotionCache, setEmotionCache] = useState(); 13 | 14 | useEffect(() => { 15 | const root = document.getElementById(ROOT_ID); 16 | if (root && root.shadowRoot) { 17 | setEmotionStyles(root); 18 | } 19 | }, [shadowRootRef.current?.shadowRoot]); 20 | 21 | function setEmotionStyles(ref: HTMLElement) { 22 | if (!ref?.shadowRoot) { 23 | return; 24 | } 25 | 26 | if (ref && !emotionCache) { 27 | const createdInflabEmotionWithRef = createCache({ 28 | key: "drag-gpt-key", 29 | container: ref.shadowRoot, 30 | }); 31 | 32 | setEmotionCache(createdInflabEmotionWithRef); 33 | } 34 | } 35 | 36 | return ( 37 |
38 | {emotionCache && ( 39 | {children} 40 | )} 41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/shared/hook/useScrollDownEffect.ts: -------------------------------------------------------------------------------- 1 | import { DependencyList, useEffect, useRef } from "react"; 2 | 3 | export function useScrollDownEffect(deps?: DependencyList) { 4 | const scrollDownRef = useRef(null); 5 | const isActivate = useRef(true); 6 | 7 | useEffect(() => { 8 | if (!scrollDownRef.current) { 9 | return; 10 | } 11 | if (!isActivate.current) { 12 | return; 13 | } 14 | scrollDownRef.current.scrollTo({ 15 | top: scrollDownRef.current.scrollHeight, 16 | }); 17 | }, deps); 18 | 19 | useEffect(() => { 20 | if (!scrollDownRef.current) { 21 | return; 22 | } 23 | let lastScroll = scrollDownRef.current.scrollTop; 24 | const onScroll = () => { 25 | if (!scrollDownRef.current) { 26 | return; 27 | } 28 | const isScrollDownNow = scrollDownRef.current.scrollTop > lastScroll; 29 | isScrollDownNow ? on() : off(); 30 | lastScroll = scrollDownRef.current.scrollTop; 31 | }; 32 | 33 | scrollDownRef.current.addEventListener("scroll", onScroll); 34 | 35 | return () => { 36 | scrollDownRef.current?.removeEventListener("scroll", onScroll); 37 | }; 38 | }, []); 39 | 40 | const off = () => { 41 | isActivate.current = false; 42 | }; 43 | 44 | const on = () => { 45 | isActivate.current = true; 46 | }; 47 | 48 | return { scrollDownRef }; 49 | } 50 | -------------------------------------------------------------------------------- /utils/reload/initReloadClient.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LOCAL_RELOAD_SOCKET_URL, 3 | UPDATE_COMPLETE_MESSAGE, 4 | UPDATE_PENDING_MESSAGE, 5 | UPDATE_REQUEST_MESSAGE, 6 | } from "./constant"; 7 | import MessageInterpreter from "./interpreter"; 8 | 9 | let needToUpdate = false; 10 | 11 | export default function initReloadClient({ 12 | watchPath, 13 | onUpdate, 14 | }: { 15 | watchPath: string; 16 | onUpdate: () => void; 17 | }): WebSocket { 18 | const socket = new WebSocket(LOCAL_RELOAD_SOCKET_URL); 19 | 20 | function sendUpdateCompleteMessage() { 21 | socket.send(MessageInterpreter.send({ type: UPDATE_COMPLETE_MESSAGE })); 22 | } 23 | 24 | socket.addEventListener("message", (event) => { 25 | const message = MessageInterpreter.receive(String(event.data)); 26 | 27 | switch (message.type) { 28 | case UPDATE_REQUEST_MESSAGE: { 29 | if (needToUpdate) { 30 | sendUpdateCompleteMessage(); 31 | needToUpdate = false; 32 | onUpdate(); 33 | } 34 | return; 35 | } 36 | case UPDATE_PENDING_MESSAGE: { 37 | if (!needToUpdate) { 38 | needToUpdate = message.path.includes(watchPath); 39 | } 40 | return; 41 | } 42 | } 43 | }); 44 | 45 | socket.addEventListener("close", () => { 46 | console.log("Reload server disconnected."); 47 | }); 48 | 49 | return socket; 50 | } 51 | -------------------------------------------------------------------------------- /README.zh-CN.md: -------------------------------------------------------------------------------- 1 | # DragGPT 2 | 3 | Translation Versions: [ENGLISH](./README.md) | [中文简体](./README.zh-CN.md) | [にほんご](./README.ja.md) | [한국어](./README.ko.md) 4 | 5 | 你可以轻松地拖动并点击按钮来向ChatGPT请求所选内容! 6 | 7 | 由[chrome-extension-boilerplate-react-vite](https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite)制作 8 | 9 | [安装扩展程序](chrome.google.com/webstore/detail/draggpt-easy-start-with-d/akgdgnhlglhelinkmnmiakgccdkghjbh) 10 | 11 | --- 12 | 13 | ## 目录 14 | 15 | - [简介](#intro) 16 | - [功能](#features) 17 | - [安装](#installation) 18 | - [步骤](#procedures) 19 | 20 | ## 简介
21 | 22 | 此扩展程序旨在帮助用户以友好的方式在Web环境中使用ChatGPT以实现各种目的。 23 | 24 | 它使用openai API,目前,您可以通过简单的提示请求指示要执行的操作,但我们计划在未来添加一个功能来微调参数并选择模型。 25 | 26 | ## 功能 27 | - 通过拖动文本轻松调用ChatGPT 28 | - 可以通过预设提示接收所需的响应 29 | - 可以在需要时创建和使用多个提示槽 30 | - 通过扩展弹出窗口快速聊天而无需预设提示 31 | - 创建适合角色的提示生成器的功能(例如“用于SNS营销所需的短文本的提示生成器”) 32 | 33 | ### 待办事项 34 | - [x] 微调提示参数 35 | - [x] 选择文本模型(目前固定为gpt-3.5-turbo) 36 | - [ ] 图像输入/输出功能(适用于GPT-4) 37 | - [x] 添加查看对话历史记录的功能 38 | 39 | ## 安装 40 | 41 | 请前往[安装链接](https://chrome.google.com/webstore/detail/draggpt-easy-start-with-d/akgdgnhlglhelinkmnmiakgccdkghjbh)安装并使用。 42 | 43 | ### 步骤 44 | 45 | [获取openai API密钥](https://platform.openai.com/account/api-keys) 46 | 47 | --- 48 | 49 | 50 | [CONTRIBUTING](./CONTRIBUTING.md) 51 | 52 | [LICENSE](./LICENSE) 53 | -------------------------------------------------------------------------------- /src/pages/content/src/ContentScriptApp/components/messageBox/ErrorMessageBox.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import ErrorMessageBox from "@pages/content/src/ContentScriptApp/components/messageBox/ErrorMessageBox"; 3 | import { t } from "@src/chrome/i18n"; 4 | 5 | describe("ErrorMessageBox", () => { 6 | test("에러가 없으면 알 수 없는 에러 이름과 메시지가 노출된다", async () => { 7 | // when 8 | render( 9 | 16 | ); 17 | 18 | // then 19 | screen.getByText(new RegExp(t("errorMessageBox_errorTitle"))); 20 | screen.getByText(t("errorMessageBox_unknownError")); 21 | }); 22 | test("에러가 있으면 에러 이름과 에러 메시지가 노출된다", async () => { 23 | // given 24 | const customError = new Error(); 25 | customError.name = "custom error name"; 26 | customError.message = "custom error message"; 27 | 28 | // when 29 | render( 30 | 38 | ); 39 | 40 | // then 41 | screen.getByText(new RegExp(customError.name)); 42 | screen.getByText(customError.message); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/shared/hook/useBackgroundMessage.tsx: -------------------------------------------------------------------------------- 1 | import { GetDataType, sendMessageToBackgroundAsync } from "@src/chrome/message"; 2 | 3 | type WrappedPromise = ReturnType; 4 | 5 | const wrappedPromiseRecord: Map = new Map(); 6 | 7 | export default function useBackgroundMessage(message: M) { 8 | const messageKey = JSON.stringify(message); 9 | const wrappedPromise = wrappedPromiseRecord.get(messageKey); 10 | if (!wrappedPromise) { 11 | wrappedPromiseRecord.set( 12 | messageKey, 13 | wrapPromise(sendMessageToBackgroundAsync(message)) 14 | ); 15 | } 16 | return { 17 | data: wrappedPromiseRecord.get(messageKey)!.read() as GetDataType< 18 | M["type"] 19 | >, 20 | refetch: () => { 21 | wrappedPromiseRecord.delete(messageKey); 22 | }, 23 | }; 24 | } 25 | 26 | function wrapPromise(promise: Promise) { 27 | let status = "pending"; // 최초의 상태 28 | let result: R; 29 | const suspender = promise.then( 30 | (r) => { 31 | status = "success"; 32 | result = r; 33 | }, 34 | (e) => { 35 | status = "error"; 36 | result = e; 37 | } 38 | ); 39 | 40 | return { 41 | read() { 42 | if (status === "pending") { 43 | throw suspender; 44 | } else if (status === "error") { 45 | throw result; 46 | } else if (status === "success") { 47 | return result; 48 | } 49 | }, 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /src/pages/popup/xState/popupStateMachine.typegen.ts: -------------------------------------------------------------------------------- 1 | // This file was automatically generated. Edits will be overwritten 2 | 3 | export interface Typegen0 { 4 | "@@xstate/typegen": true; 5 | internalEvents: { 6 | "done.invoke.popup-state.init:invocation[0]": { 7 | type: "done.invoke.popup-state.init:invocation[0]"; 8 | data: unknown; 9 | __tip: "See the XState TS docs to learn how to strongly type this."; 10 | }; 11 | "xstate.init": { type: "xstate.init" }; 12 | }; 13 | invokeSrcNameMap: { 14 | getApiKeyFromBackground: "done.invoke.popup-state.init:invocation[0]"; 15 | saveApiKeyToBackground: "done.invoke.popup-state.checking_api_key:invocation[0]"; 16 | }; 17 | missingImplementations: { 18 | actions: "resetApiKeyFromBackground"; 19 | delays: never; 20 | guards: never; 21 | services: "getApiKeyFromBackground" | "saveApiKeyToBackground"; 22 | }; 23 | eventsCausingActions: { 24 | resetApiKeyFromBackground: "RESET_API_KEY"; 25 | resetOpenAiApiKey: "RESET_API_KEY"; 26 | setApiKey: "CHECK_API_KEY" | "done.invoke.popup-state.init:invocation[0]"; 27 | }; 28 | eventsCausingDelays: {}; 29 | eventsCausingGuards: {}; 30 | eventsCausingServices: { 31 | getApiKeyFromBackground: "xstate.init"; 32 | saveApiKeyToBackground: "CHECK_API_KEY"; 33 | }; 34 | matchesStates: 35 | | "checking_api_key" 36 | | "init" 37 | | "no_api_key" 38 | | "quick_chat" 39 | | "slot_list_page"; 40 | tags: "noApiKeyPage"; 41 | } 42 | -------------------------------------------------------------------------------- /utils/plugins/add-hmr.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import { PluginOption } from "vite"; 3 | import { readFileSync } from "fs"; 4 | 5 | const isDev = process.env.__DEV__ === "true"; 6 | 7 | const DUMMY_CODE = `export default function(){};`; 8 | 9 | function getInjectionCode(fileName: string): string { 10 | return readFileSync( 11 | path.resolve(__dirname, "..", "reload", "injections", fileName), 12 | { encoding: "utf8" } 13 | ); 14 | } 15 | 16 | type Config = { 17 | background?: boolean; 18 | view?: boolean; 19 | }; 20 | 21 | export default function addHmr(config?: Config): PluginOption { 22 | const { background = false, view = true } = config || {}; 23 | const idInBackgroundScript = "virtual:reload-on-update-in-background-script"; 24 | const idInView = "virtual:reload-on-update-in-view"; 25 | 26 | const scriptHmrCode = isDev ? getInjectionCode("script.js") : DUMMY_CODE; 27 | const viewHmrCode = isDev ? getInjectionCode("view.js") : DUMMY_CODE; 28 | 29 | return { 30 | name: "add-hmr", 31 | resolveId(id) { 32 | if (id === idInBackgroundScript || id === idInView) { 33 | return getResolvedId(id); 34 | } 35 | }, 36 | load(id) { 37 | if (id === getResolvedId(idInBackgroundScript)) { 38 | return background ? scriptHmrCode : DUMMY_CODE; 39 | } 40 | 41 | if (id === getResolvedId(idInView)) { 42 | return view ? viewHmrCode : DUMMY_CODE; 43 | } 44 | }, 45 | }; 46 | } 47 | 48 | function getResolvedId(id: string) { 49 | return "\0" + id; 50 | } 51 | -------------------------------------------------------------------------------- /README.ja.md: -------------------------------------------------------------------------------- 1 | # DragGPT 2 | 3 | Translation Versions: [ENGLISH](./README.md) | [中文简体](./README.zh-CN.md) | [にほんご](./README.ja.md) | [한국어](./README.ko.md) 4 | 5 | ドラッグ&クリックで選択したコンテンツをChatGPTに簡単に要求できます! 6 | 7 | [chrome-extension-boilerplate-react-vite](https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite) によって作成されました。 8 | 9 | [拡張機能をインストールする](chrome.google.com/webstore/detail/draggpt-easy-start-with-d/akgdgnhlglhelinkmnmiakgccdkghjbh) 10 | 11 | --- 12 | 13 | ## 目次 14 | 15 | - [概要](#intro) 16 | - [特徴](#features) 17 | - [インストール方法](#installation) 18 | - [手順](#procedures) 19 | 20 | ## 概要 21 | 22 | この拡張プログラムは、Web環境でさまざまな目的にChatGPTを使いたいユーザーのために、親しみやすい方法でヘルプすることを目的としています。 23 | 24 | オープンAI APIを使用し、現在は簡単なプロンプトリクエストで何らかのアクションを指示できますが、将来的にはパラメータを微調整してモデルを選択する機能を追加する予定です。 25 | 26 | ## 特徴 27 | - テキストをドラッグしてChatGPTを簡単に呼び出すことができます。 28 | - 事前に設定されたプロンプトを介して所望の応答を受信できます。 29 | - 複数のプロンプトスロットを作成して、必要に応じて使用できます。 30 | - 拡張機能のポップアップウィンドウを介したプリセットプロンプトなしのクイックチャット 31 | - 役割に適したプロンプトを作成する機能(例:「SNSマーケティングに必要な短文を作成するプロンプトジェネレータ」) 32 | 33 | ### 今後の予定 34 | - [x] プロンプトパラメータの微調整 35 | - [x] テキストモデルの選択(現在はgpt-3.5-turboに固定されています) 36 | - [ ] 画像入出力機能(GPT-4用) 37 | - [x] 会話履歴の表示機能の追加 38 | 39 | ## インストール方法 40 | 41 | [インストールリンク](https://chrome.google.com/webstore/detail/draggpt-easy-start-with-d/akgdgnhlglhelinkmnmiakgccdkghjbh)にアクセスして、インストールして使用してください。 42 | 43 | ### 手順 44 | 45 | [openai APIキーを取得する](https://platform.openai.com/account/api-keys) 46 | 47 | --- 48 | 49 | [CONTRIBUTING](./CONTRIBUTING.md) 50 | 51 | [LICENSE](./LICENSE) 52 | -------------------------------------------------------------------------------- /README.ko.md: -------------------------------------------------------------------------------- 1 | # DragGPT 2 | 3 | Translation Versions: [ENGLISH](./README.md) | [中文简体](./README.zh-CN.md) | [にほんご](./README.ja.md) | [한국어](./README.ko.md) 4 | 5 | 드래그 후 버튼 클릭만으로 간단하게 선택한 내용을 ChatGPT에게 물어보거나 요청할 수 있어요! 6 | 7 | 이 익스텐션은 [chrome-extension-boilerplate-react-vite](https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite) 로 쉽고 빠르게 개발 할 수 있었습니다. 8 | 9 | [Install Extension](chrome.google.com/webstore/detail/draggpt-easy-start-with-d/akgdgnhlglhelinkmnmiakgccdkghjbh) 10 | 11 | 12 | --- 13 | 14 | ## Table of Contents 15 | 16 | - [Intro](#intro) 17 | - [Features](#features) 18 | - [Installation](#installation) 19 | - [Procedures](#procedures) 20 | 21 | ## Intro 22 | 23 | 이 확장 프로그램은 웹 환경에서 친숙하게 여러 용도로 ChatGPT를 사용하길 원하는 유저들을 돕기 위해 만들어졌습니다. 24 | 25 | openai API를 사용하고 있으며, 현재는 간단한 프롬프트 요청으로 어떤 동작을 할지 지시 할 수 있지만 추후 파라미터의 미세조정과 모델 선택 기능도 추가할 예정입니다. 26 | 27 | ## Features 28 | - 텍스트 드래그를 통해 간단하게 ChatGPT 호출 29 | - 사전 설정한 프롬프트를 통해 원하는 응답을 받을 수 있음 30 | - 여러개의 프롬프트 슬롯을 만들어두고 원할 때 선택해서 사용 가능 31 | - 익스텐션 팝업창을 통해 사전 프롬프트 없이 빠른 채팅 가능 32 | - 사전 프롬프트를 역할에 맞게 생성해주는 기능 탑재 (ex. 프롬프트 생성기에 'SNS 마케팅에 필요한 짧은 텍스트를 생성하는 프롬프트' 입력) 33 | 34 | ### TODO 35 | - [x] 프롬프트 파라미터 미세 조정 36 | - [x] 텍스트 모델 선택 (현재는 gpt-3.5-turbo 고정) 37 | - [ ] 이미지 입-출력 기능 (GPT-4 대응) 38 | - [x] 대화 내역 히스토리에서 볼 수 있는 기능 추가 39 | 40 | ## Installation 41 | 42 | [설치 링크](chrome.google.com/webstore/detail/draggpt-easy-start-with-d/akgdgnhlglhelinkmnmiakgccdkghjbh)로 이동 후 설치해서 사용 43 | 44 | ### Procedures 45 | 46 | [openai api key 발급](https://platform.openai.com/account/api-keys) 47 | 48 | --- 49 | 50 | 51 | [CONTRIBUTING](./CONTRIBUTING.md) 52 | 53 | [LICENSE](./LICENSE) 54 | -------------------------------------------------------------------------------- /src/pages/popup/xState/slotListPageStateMachine.typegen.ts: -------------------------------------------------------------------------------- 1 | // This file was automatically generated. Edits will be overwritten 2 | 3 | export interface Typegen0 { 4 | "@@xstate/typegen": true; 5 | internalEvents: { 6 | "done.invoke.slot_list_page.init:invocation[0]": { 7 | type: "done.invoke.slot_list_page.init:invocation[0]"; 8 | data: unknown; 9 | __tip: "See the XState TS docs to learn how to strongly type this."; 10 | }; 11 | "xstate.init": { type: "xstate.init" }; 12 | }; 13 | invokeSrcNameMap: { 14 | getAllSlotsFromBackground: "done.invoke.slot_list_page.init:invocation[0]"; 15 | }; 16 | missingImplementations: { 17 | actions: 18 | | "addSlotMessageSendToBackground" 19 | | "deleteSlotMessageSendToBackground" 20 | | "exitPage" 21 | | "selectSlotMessageSendToBackground" 22 | | "updateSlotMessageSendToBackground"; 23 | delays: never; 24 | guards: never; 25 | services: "getAllSlotsFromBackground"; 26 | }; 27 | eventsCausingActions: { 28 | addSlot: "ADD_SLOT"; 29 | addSlotMessageSendToBackground: "ADD_SLOT"; 30 | deleteSlot: "DELETE_SLOT"; 31 | deleteSlotMessageSendToBackground: "DELETE_SLOT"; 32 | exitPage: "CHANGE_API_KEY"; 33 | selectSlot: "SELECT_SLOT"; 34 | selectSlotMessageSendToBackground: "SELECT_SLOT"; 35 | setSlots: "done.invoke.slot_list_page.init:invocation[0]"; 36 | updateSlot: "UPDATE_SLOT"; 37 | updateSlotMessageSendToBackground: "UPDATE_SLOT"; 38 | }; 39 | eventsCausingDelays: {}; 40 | eventsCausingGuards: {}; 41 | eventsCausingServices: { 42 | getAllSlotsFromBackground: "CHANGE_API_KEY" | "xstate.init"; 43 | }; 44 | matchesStates: "init" | "slot_detail" | "slot_list"; 45 | tags: never; 46 | } 47 | -------------------------------------------------------------------------------- /src/shared/component/ResetStyleProvider.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | export default function ResetStyleProvider({ 4 | children, 5 | }: { 6 | children: ReactNode; 7 | }) { 8 | return ( 9 | <> 10 | 72 | {children} 73 | 74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /src/pages/content/src/ContentScriptApp/emotion/ResetStyleProvider.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | export default function ResetStyleProvider({ 4 | children, 5 | }: { 6 | children: ReactNode; 7 | }) { 8 | return ( 9 | <> 10 | 72 | {children} 73 | 74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /src/pages/background/lib/infra/chatGPT.ts: -------------------------------------------------------------------------------- 1 | import { OpenAI } from "openai"; 2 | import { ChatCompletionMessageParam } from "openai/resources"; 3 | 4 | export async function chatGPT({ 5 | input, 6 | slot, 7 | chats, 8 | apiKey, 9 | onDelta, 10 | }: { 11 | slot: ChatGPTSlot; 12 | chats?: Chat[]; 13 | input?: string; 14 | apiKey: string; 15 | onDelta?: (chunk: string) => unknown; 16 | }): Promise<{ result: string }> { 17 | const messages: ChatCompletionMessageParam[] = []; 18 | 19 | if (slot.system) { 20 | messages.push({ 21 | role: "system", 22 | content: slot.system, 23 | }); 24 | } 25 | if (hasChats(chats)) { 26 | messages.push(...convertChatsToMessages(chats)); 27 | } 28 | if (input) { 29 | messages.push({ role: "user", content: input }); 30 | } 31 | 32 | const client = new OpenAI({ apiKey }); 33 | 34 | const stream = client.beta.chat.completions 35 | .stream({ 36 | messages, 37 | model: slot.type, 38 | max_tokens: slot.maxTokens, 39 | temperature: slot.temperature, 40 | top_p: slot.topP, 41 | frequency_penalty: slot.frequencyPenalty, 42 | presence_penalty: slot.presencePenalty, 43 | stream: true, 44 | }) 45 | .on("content", (content) => { 46 | onDelta?.(content); 47 | }); 48 | 49 | const result = await stream.finalChatCompletion(); 50 | return { result: result.choices.at(0)?.message.content ?? "" }; 51 | } 52 | 53 | function hasChats(chats?: Chat[]): chats is Chat[] { 54 | return chats !== undefined && chats.length > 0; 55 | } 56 | 57 | function convertChatsToMessages(chats: Chat[]) { 58 | return chats 59 | .filter((chat) => chat.role !== "error") 60 | .map((chat) => { 61 | return { 62 | role: chat.role === "user" ? "user" : "assistant", 63 | content: chat.content, 64 | } as const; 65 | }); 66 | } 67 | -------------------------------------------------------------------------------- /src/pages/popup/components/SlotListItem.tsx: -------------------------------------------------------------------------------- 1 | import { Box, HStack, Text } from "@chakra-ui/react"; 2 | import StyledButton from "@pages/popup/components/StyledButton"; 3 | import { COLORS } from "@src/constant/style"; 4 | import { t } from "@src/chrome/i18n"; 5 | 6 | type SlotListItemProps = { 7 | slotName: string; 8 | isSelected: boolean; 9 | onSelect: () => void; 10 | onDetail: () => void; 11 | onDelete: () => void; 12 | }; 13 | 14 | export default function SlotListItem({ 15 | slotName, 16 | isSelected, 17 | onDetail, 18 | onSelect, 19 | onDelete, 20 | }: SlotListItemProps) { 21 | return ( 22 | 30 | 31 | 36 | {slotName} 37 | 38 | 39 | { 42 | event.stopPropagation(); 43 | onDetail(); 44 | }} 45 | > 46 | {t("slotListItem_editButtonText")} 47 | 48 | { 52 | event.stopPropagation(); 53 | onDelete(); 54 | }} 55 | > 56 | {t("slotListItem_deleteButtonText")} 57 | 58 | 59 | 60 | 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /src/shared/services/getGPTResponseAsStream.ts: -------------------------------------------------------------------------------- 1 | import { sendMessageToBackground } from "@src/chrome/message"; 2 | 3 | export async function getQuickGPTResponseAsStream({ 4 | messages, 5 | model, 6 | onDelta, 7 | onFinish, 8 | }: { 9 | messages: Chat[]; 10 | model: Model; 11 | onDelta: (chunk: string) => unknown; 12 | onFinish: (result: string) => unknown; 13 | }) { 14 | return new Promise<{ cancel: () => unknown; firstChunk: string }>( 15 | (resolve, reject) => { 16 | const { disconnect } = sendMessageToBackground({ 17 | message: { 18 | type: "RequestQuickChatGPTStream", 19 | input: { messages, model }, 20 | }, 21 | handleSuccess: (response) => { 22 | if (response.isDone || !response.chunk) { 23 | return onFinish(response.result); 24 | } 25 | resolve({ cancel: disconnect, firstChunk: response.chunk }); 26 | onDelta(response.chunk); 27 | }, 28 | handleError: reject, 29 | }); 30 | } 31 | ); 32 | } 33 | 34 | export async function getDragGPTResponseAsStream({ 35 | input, 36 | onDelta, 37 | onFinish, 38 | }: { 39 | input: { chats: Chat[]; sessionId: string }; 40 | onDelta: (chunk: string) => unknown; 41 | onFinish: (result: string) => unknown; 42 | }) { 43 | return new Promise<{ cancel: () => unknown; firstChunk: string }>( 44 | (resolve, reject) => { 45 | const { disconnect } = sendMessageToBackground({ 46 | message: { 47 | type: "RequestDragGPTStream", 48 | input, 49 | }, 50 | handleSuccess: (response) => { 51 | if (response.isDone || !response.chunk) { 52 | return onFinish(response.result); 53 | } 54 | resolve({ cancel: disconnect, firstChunk: response.chunk }); 55 | onDelta(response.chunk); 56 | }, 57 | handleError: reject, 58 | }); 59 | } 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /src/pages/options/src/components/ChatSessionGroup.tsx: -------------------------------------------------------------------------------- 1 | import { SessionHistories } from "@pages/background/lib/storage/chatHistoryStorage"; 2 | import { Menu, MenuGroup, MenuItem, Text } from "@chakra-ui/react"; 3 | 4 | type ChatSessionGroupProps = { 5 | selectedSessionId?: string; 6 | onSelectSession: (sessionId: string) => unknown; 7 | chatSessions: [string, SessionHistories][]; 8 | }; 9 | 10 | export default function ChatSessionGroup({ 11 | selectedSessionId, 12 | chatSessions, 13 | onSelectSession, 14 | }: ChatSessionGroupProps) { 15 | const dragChatHistories = chatSessions.filter( 16 | ([, chat]) => chat.type === "Drag" 17 | ); 18 | const quickChatHistories = chatSessions.filter( 19 | ([, chat]) => chat.type === "Quick" 20 | ); 21 | 22 | return ( 23 | 24 | {quickChatHistories.length > 0 && ( 25 | 26 | {quickChatHistories.map(([id, chats]) => ( 27 | onSelectSession(id)} 33 | > 34 | {new Date(chats.createdAt).toLocaleString()} 35 | 36 | ))} 37 | 38 | )} 39 | {dragChatHistories.length > 0 && ( 40 | 41 | {dragChatHistories.map(([id, chats]) => ( 42 | onSelectSession(id)} 48 | > 49 | {new Date(chats.createdAt).toLocaleString()} 50 | 51 | ))} 52 | 53 | )} 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /utils/reload/initReloadServer.ts: -------------------------------------------------------------------------------- 1 | import { WebSocket, WebSocketServer } from "ws"; 2 | import chokidar from "chokidar"; 3 | import { debounce } from "./utils"; 4 | import { 5 | LOCAL_RELOAD_SOCKET_PORT, 6 | LOCAL_RELOAD_SOCKET_URL, 7 | UPDATE_COMPLETE_MESSAGE, 8 | UPDATE_PENDING_MESSAGE, 9 | UPDATE_REQUEST_MESSAGE, 10 | } from "./constant"; 11 | import MessageInterpreter from "./interpreter"; 12 | 13 | const clientsThatNeedToUpdate: Set = new Set(); 14 | 15 | function initReloadServer() { 16 | const wss = new WebSocketServer({ port: LOCAL_RELOAD_SOCKET_PORT }); 17 | 18 | wss.on("listening", () => 19 | console.log(`[HRS] Server listening at ${LOCAL_RELOAD_SOCKET_URL}`) 20 | ); 21 | 22 | wss.on("connection", (ws) => { 23 | clientsThatNeedToUpdate.add(ws); 24 | 25 | ws.addEventListener("close", () => clientsThatNeedToUpdate.delete(ws)); 26 | ws.addEventListener("message", (event) => { 27 | const message = MessageInterpreter.receive(String(event.data)); 28 | if (message.type === UPDATE_COMPLETE_MESSAGE) { 29 | ws.close(); 30 | } 31 | }); 32 | }); 33 | } 34 | 35 | /** CHECK:: src file was updated **/ 36 | const debounceSrc = debounce(function (path: string) { 37 | // Normalize path on Windows 38 | const pathConverted = path.replace(/\\/g, "/"); 39 | clientsThatNeedToUpdate.forEach((ws: WebSocket) => 40 | ws.send( 41 | MessageInterpreter.send({ 42 | type: UPDATE_PENDING_MESSAGE, 43 | path: pathConverted, 44 | }) 45 | ) 46 | ); 47 | }, 200); 48 | chokidar.watch("src").on("all", (event, path) => debounceSrc(path)); 49 | 50 | /** CHECK:: build was completed **/ 51 | const debounceDist = debounce(() => { 52 | clientsThatNeedToUpdate.forEach((ws: WebSocket) => { 53 | ws.send(MessageInterpreter.send({ type: UPDATE_REQUEST_MESSAGE })); 54 | }); 55 | }, 200); 56 | chokidar.watch("dist").on("all", () => debounceDist()); 57 | 58 | initReloadServer(); 59 | -------------------------------------------------------------------------------- /src/shared/xState/chatStateMachine.typegen.ts: -------------------------------------------------------------------------------- 1 | // This file was automatically generated. Edits will be overwritten 2 | 3 | export interface Typegen0 { 4 | "@@xstate/typegen": true; 5 | internalEvents: { 6 | "done.invoke.chat-state.init:invocation[0]": { 7 | type: "done.invoke.chat-state.init:invocation[0]"; 8 | data: unknown; 9 | __tip: "See the XState TS docs to learn how to strongly type this."; 10 | }; 11 | "done.invoke.chat-state.loading:invocation[0]": { 12 | type: "done.invoke.chat-state.loading:invocation[0]"; 13 | data: unknown; 14 | __tip: "See the XState TS docs to learn how to strongly type this."; 15 | }; 16 | "error.platform.chat-state.loading:invocation[0]": { 17 | type: "error.platform.chat-state.loading:invocation[0]"; 18 | data: unknown; 19 | }; 20 | "xstate.init": { type: "xstate.init" }; 21 | }; 22 | invokeSrcNameMap: { 23 | getChatHistoryFromBackground: "done.invoke.chat-state.init:invocation[0]"; 24 | getGPTResponse: "done.invoke.chat-state.loading:invocation[0]"; 25 | }; 26 | missingImplementations: { 27 | actions: "exitChatting"; 28 | delays: never; 29 | guards: never; 30 | services: "getChatHistoryFromBackground" | "getGPTResponse"; 31 | }; 32 | eventsCausingActions: { 33 | addAssistantChat: "done.invoke.chat-state.loading:invocation[0]"; 34 | addErrorChat: "error.platform.chat-state.loading:invocation[0]"; 35 | addUserChat: "QUERY"; 36 | exitChatting: "EXIT"; 37 | resetChatData: "RESET"; 38 | resetChatText: "QUERY"; 39 | setChats: "done.invoke.chat-state.init:invocation[0]"; 40 | updateChatText: "CHANGE_TEXT"; 41 | }; 42 | eventsCausingDelays: {}; 43 | eventsCausingGuards: { 44 | isValidText: "QUERY"; 45 | }; 46 | eventsCausingServices: { 47 | getChatHistoryFromBackground: "xstate.init"; 48 | getGPTResponse: "QUERY"; 49 | }; 50 | matchesStates: "finish" | "idle" | "init" | "loading"; 51 | tags: never; 52 | } 53 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | [code-of-conduct]: CODE_OF_CONDUCT.md 2 | 3 | ## Contributing 4 | 5 | Hi there! We're thrilled that you'd like to contribute to this project. Your help is essential for keeping it great. 6 | 7 | Please note that this project is released with a [Contributor Code of Conduct][code-of-conduct]. By participating in this project you agree to abide by its terms. 8 | 9 | ## Issues and PRs 10 | 11 | If you have suggestions for how this project could be improved, or want to report a bug, open an issue! We'd love all and any contributions. If you have questions, too, we'd love to hear them. 12 | 13 | We'd also love PRs. If you're thinking of a large PR, we advise opening up an issue first to talk about it, though! Look at the links below if you're not sure how to open a PR. 14 | 15 | ## Submitting a pull request 16 | 17 | 1. Fork and clone the repository. 18 | 1. Configure and install the dependencies: `yarn`. 19 | 1. Make sure the tests pass on your machine: `yarn test`, note: these tests also apply the linter, so there's no need to lint separately. 20 | 1. Create a new branch: `git checkout -b my-branch-name`. 21 | 1. Make your change, add tests, and make sure the tests still pass. 22 | 1. Push to your fork and submit a pull request. 23 | 1. Pat your self on the back and wait for your pull request to be reviewed and merged. 24 | 25 | Here are a few things you can do that will increase the likelihood of your pull request being accepted: 26 | 27 | - Write and update tests. 28 | - Keep your changes as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests. 29 | 30 | Work in Progress pull requests are also welcome to get feedback early on, or if there is something blocked you. 31 | 32 | ## Resources 33 | 34 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 35 | - [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) 36 | - [GitHub Help](https://help.github.com) 37 | -------------------------------------------------------------------------------- /src/shared/component/ChatCollapse.tsx: -------------------------------------------------------------------------------- 1 | import { Box, BoxProps, Collapse, StatUpArrow, VStack } from "@chakra-ui/react"; 2 | import { 3 | CSSProperties, 4 | MouseEventHandler, 5 | PropsWithChildren, 6 | useState, 7 | } from "react"; 8 | import styled from "@emotion/styled"; 9 | import { css } from "@emotion/react"; 10 | import { COLORS } from "@src/constant/style"; 11 | 12 | type ChatCollapseProps = BoxProps & 13 | PropsWithChildren<{ 14 | arrowColor?: CSSProperties["color"]; 15 | }> & 16 | Omit; 17 | 18 | export default function ChatCollapse({ 19 | children, 20 | onClick, 21 | arrowColor = "white", 22 | ...restProps 23 | }: ChatCollapseProps) { 24 | const [show, setShow] = useState(false); 25 | 26 | const closeCollapse = () => setShow(false); 27 | const openCollapse: MouseEventHandler = (event) => { 28 | setShow(true); 29 | onClick?.(event); 30 | }; 31 | 32 | return ( 33 | 34 | 35 | {children} 36 | { 41 | closeCollapse(); 42 | e.stopPropagation(); 43 | }} 44 | > 45 | 46 | 47 | 48 | 49 | ); 50 | } 51 | 52 | const CollapseBox = styled(Box)<{ isShow: boolean }>` 53 | align-self: start; 54 | position: relative; 55 | ${(p) => 56 | p.isShow || 57 | css` 58 | cursor: pointer; 59 | &:before { 60 | content: ""; 61 | position: absolute; 62 | top: 0; 63 | right: 0; 64 | left: 0; 65 | height: 100%; 66 | 67 | background-image: linear-gradient( 68 | 0deg, 69 | ${COLORS.CONTENT_BACKGROUND} 0%, 70 | rgba(0, 0, 0, 0) 100% 71 | ); 72 | } 73 | `} 74 | `; 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DragGPT 2 | 3 | Translation Versions: [ENGLISH](./README.md) | [中文简体](./README.zh-CN.md) | [にほんご](./README.ja.md) | [한국어](./README.ko.md) 4 | 5 | You can easily ask or request the selected content to ChatGPT by dragging and clicking the button! 6 | 7 | Made by [chrome-extension-boilerplate-react-vite](https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite) 8 | 9 | [Install Extension](https://chrome.google.com/webstore/detail/akgdgnhlglhelinkmnmiakgccdkghjbh) 10 | 11 | --- 12 | 13 | ## Table of Contents 14 | 15 | - [Intro](#intro) 16 | - [Features](#features) 17 | - [Installation](#installation) 18 | - [Procedures](#procedures) 19 | 20 | ## Intro 21 | 22 | This extension program is designed to help users who want to use ChatGPT for various purposes in a friendly way in the web environment. 23 | 24 | It uses the openai API, and currently, you can instruct what action to take with a simple prompt request, but we plan to add a feature to fine-tune parameters and select models in the future. 25 | 26 | ## Features 27 | - Easily call ChatGPT by dragging text 28 | - Can receive a desired response through a pre-set prompt 29 | - Multiple prompt slots can be created and used when desired 30 | - Quick chat without a pre-set prompt through the extension pop-up window 31 | - Feature to create prompt suitable for role (e.g. 'Prompt generator to create short text required for SNS marketing') 32 | 33 | ### TODO 34 | - [x] Fine-tune prompt parameters 35 | - [x] Select text model (currently fixed to gpt-3.5-turbo) 36 | - [ ] Image input/output feature (for GPT-4) 37 | - [x] Add a feature to view conversation history 38 | 39 | ## Installation 40 | 41 | Go to the [installation link](https://chrome.google.com/webstore/detail/draggpt-easy-start-with-d/akgdgnhlglhelinkmnmiakgccdkghjbh) to install and use. 42 | 43 | ### Procedures 44 | 45 | [Get your openai API key](https://platform.openai.com/account/api-keys) 46 | 47 | --- 48 | 49 | [CONTRIBUTING](./CONTRIBUTING.md) 50 | 51 | [LICENSE](./LICENSE) 52 | -------------------------------------------------------------------------------- /src/pages/content/src/ContentScriptApp/utils/getPositionOnScreen.test.ts: -------------------------------------------------------------------------------- 1 | import { getPositionOnScreen } from "@pages/content/src/ContentScriptApp/utils/getPositionOnScreen"; 2 | 3 | declare let window: { 4 | innerWidth: number; 5 | innerHeight: number; 6 | }; 7 | 8 | const originWidth = window.innerWidth; 9 | const originHeight = window.innerHeight; 10 | 11 | afterEach(() => { 12 | window.innerWidth = originWidth; 13 | window.innerHeight = originHeight; 14 | }); 15 | 16 | describe("getPositionOnScreen test", () => { 17 | test("수평의 중간값이 화면의 중앙보다 위에 가깝고, 수직 중앙값이 화면의 중간보다 왼쪽에 가까우면 > 좌측 상단이다", () => { 18 | // given 19 | window.innerWidth = 1000; 20 | window.innerHeight = 1000; 21 | 22 | // when 23 | const position = getPositionOnScreen({ 24 | horizontalCenter: 490, 25 | verticalCenter: 490, 26 | }); 27 | 28 | // then 29 | expect(position).toEqual("topLeft"); 30 | }); 31 | test("수평의 중간값이 화면의 중앙보다 위에 가깝고, 수직 중앙값이 화면의 중간보다 오른쪽에 가까우면 > 우측 상단이다", () => { 32 | // given 33 | window.innerWidth = 1000; 34 | window.innerHeight = 1000; 35 | 36 | // when 37 | const position = getPositionOnScreen({ 38 | horizontalCenter: 490, 39 | verticalCenter: 510, 40 | }); 41 | 42 | // then 43 | expect(position).toEqual("topRight"); 44 | }); 45 | test("수평의 중간값이 화면의 중앙보다 아래에 가깝고, 수직 중앙값이 화면의 중간보다 왼쪽에 가까우면 > 좌측 하단이다", () => { 46 | // given 47 | window.innerWidth = 1000; 48 | window.innerHeight = 1000; 49 | 50 | // when 51 | const position = getPositionOnScreen({ 52 | horizontalCenter: 510, 53 | verticalCenter: 490, 54 | }); 55 | 56 | // then 57 | expect(position).toEqual("bottomLeft"); 58 | }); 59 | test("수평의 중간값이 화면의 중앙보다 위에 가깝고, 수직 중앙값이 화면의 중간보다 왼쪽에 가까우면 > 좌측 상단이다", () => { 60 | // given 61 | window.innerWidth = 1000; 62 | window.innerHeight = 1000; 63 | 64 | // when 65 | const position = getPositionOnScreen({ 66 | horizontalCenter: 510, 67 | verticalCenter: 510, 68 | }); 69 | 70 | // then 71 | expect(position).toEqual("bottomRight"); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /src/pages/background/lib/storage/slotStorage.ts: -------------------------------------------------------------------------------- 1 | import { SlotsManipulatorService } from "@pages/background/lib/service/slotsManipulatorService"; 2 | import { ILocalStorage, LocalStorage } from "@src/chrome/localStorage"; 3 | 4 | export class SlotStorage { 5 | private static SLOTS = "SLOTS"; 6 | static storage: ILocalStorage = new LocalStorage(); 7 | 8 | static async getAllSlots(): Promise { 9 | try { 10 | const slots = await this.storage.load(this.SLOTS); 11 | if (Array.isArray(slots)) { 12 | return slots as Slot[]; 13 | } 14 | } catch (e) { 15 | return []; 16 | } 17 | return []; 18 | } 19 | 20 | static async setAllSlots(slots: Slot[]): Promise { 21 | await this.storage.save(this.SLOTS, slots); 22 | return slots; 23 | } 24 | 25 | static async getSelectedSlot(): Promise { 26 | const slots = await this.getAllSlots(); 27 | const selectedSlot = SlotsManipulatorService.getSelectedSlot(slots); 28 | if (selectedSlot) { 29 | return selectedSlot; 30 | } 31 | const notFoundError = new Error(); 32 | notFoundError.name = "Not found selected slot"; 33 | notFoundError.message = "Check selected slot."; 34 | throw notFoundError; 35 | } 36 | 37 | static async addSlot(slot: Slot): Promise { 38 | const slots: Slot[] = await this.getAllSlots(); 39 | const newSlot: Slot = { ...slot, isSelected: slots.length === 0 }; 40 | const addedSlots = SlotsManipulatorService.addSlot(slots, newSlot); 41 | await this.storage.save(this.SLOTS, addedSlots); 42 | return addedSlots; 43 | } 44 | 45 | static async updateSlot(slot: Slot): Promise { 46 | const slots = await this.getAllSlots(); 47 | const updatedSlots = SlotsManipulatorService.updateSlot(slots, slot); 48 | await this.storage.save(this.SLOTS, updatedSlots); 49 | return updatedSlots; 50 | } 51 | 52 | static async deleteSlot(slotId: string): Promise { 53 | const slots = await this.getAllSlots(); 54 | const deletedSlots = SlotsManipulatorService.deleteSlot(slots, slotId); 55 | await this.storage.save(this.SLOTS, deletedSlots); 56 | return deletedSlots; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/chrome/message.ts: -------------------------------------------------------------------------------- 1 | export type GetDataType = Exclude< 2 | Extract< 3 | Message, 4 | { 5 | type: T; 6 | data?: unknown; 7 | input?: unknown; 8 | } 9 | >["data"], 10 | undefined 11 | >; 12 | 13 | export async function sendMessageToBackgroundAsync( 14 | message: M 15 | ) { 16 | return new Promise>((resolve, reject) => { 17 | try { 18 | sendMessageToBackground({ 19 | message, 20 | handleSuccess: resolve, 21 | handleError: reject, 22 | }); 23 | } catch (error) { 24 | reject(error); 25 | } 26 | }); 27 | } 28 | 29 | export function sendMessageToBackground({ 30 | message, 31 | handleSuccess, 32 | handleError, 33 | }: { 34 | message: M; 35 | handleSuccess?: (data: GetDataType) => void; 36 | handleError?: (error: Error) => void; 37 | }) { 38 | const port = chrome.runtime.connect(); 39 | port.onMessage.addListener((responseMessage: M | ErrorMessage) => { 40 | if (responseMessage.type === "Error") { 41 | handleError?.(responseMessage.error); 42 | } else { 43 | handleSuccess?.(responseMessage.data as GetDataType); 44 | } 45 | }); 46 | port.onDisconnect.addListener(() => console.log("Port disconnected")); 47 | try { 48 | port.postMessage(message); 49 | } catch (error) { 50 | console.log(error); 51 | } 52 | const disconnect = () => { 53 | port.disconnect(); 54 | }; 55 | return { disconnect }; 56 | } 57 | 58 | export function sendMessageToClient( 59 | port: chrome.runtime.Port, 60 | message: { type: Message["type"]; data: Message["data"] } | ErrorMessage 61 | ) { 62 | try { 63 | port.postMessage(message); 64 | } catch (error) { 65 | console.log(error); 66 | } 67 | } 68 | 69 | export function sendErrorMessageToClient( 70 | port: chrome.runtime.Port, 71 | error: unknown 72 | ) { 73 | const sendError = new Error(); 74 | sendError.name = "Unknown Error"; 75 | 76 | if (error instanceof Error) { 77 | error.name && (sendError.name = error.name); 78 | sendError.message = error.message; 79 | } 80 | 81 | sendMessageToClient(port, { type: "Error", error: sendError }); 82 | } 83 | -------------------------------------------------------------------------------- /src/pages/popup/Popup.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { NoApiKeyPage } from "@pages/popup/pages/NoApiKeyPage"; 3 | import SlotListPage from "@pages/popup/pages/SlotListPage"; 4 | import { useMachine } from "@xstate/react"; 5 | import popupStateMachine from "@pages/popup/xState/popupStateMachine"; 6 | import { 7 | sendMessageToBackground, 8 | sendMessageToBackgroundAsync, 9 | } from "@src/chrome/message"; 10 | import MainLayout from "@pages/popup/components/layout/MainLayout"; 11 | import QuickChattingPage from "@pages/popup/pages/QuickChattingPage"; 12 | 13 | const saveApiKeyToBackground = async (apiKey: string) => { 14 | await sendMessageToBackgroundAsync({ 15 | type: "SaveAPIKey", 16 | input: apiKey, 17 | }); 18 | }; 19 | 20 | const getApiKeyFromBackground = async () => { 21 | return sendMessageToBackgroundAsync({ 22 | type: "GetAPIKey", 23 | }); 24 | }; 25 | 26 | const resetApiKeyFromBackground = () => { 27 | sendMessageToBackground({ 28 | message: { 29 | type: "ResetAPIKey", 30 | }, 31 | }); 32 | }; 33 | 34 | export default function Popup() { 35 | const [state, send] = useMachine(popupStateMachine, { 36 | services: { 37 | saveApiKeyToBackground: (context) => { 38 | return saveApiKeyToBackground(context.openAiApiKey ?? ""); 39 | }, 40 | getApiKeyFromBackground, 41 | }, 42 | actions: { 43 | resetApiKeyFromBackground, 44 | }, 45 | }); 46 | 47 | const checkApiKey = (apiKey: string) => { 48 | send({ type: "CHECK_API_KEY", data: apiKey }); 49 | }; 50 | 51 | return ( 52 | 53 | {state.matches("slot_list_page") && ( 54 | send("RESET_API_KEY")} 56 | onClickQuickChatButton={() => send("GO_TO_QUICK_CHAT")} 57 | /> 58 | )} 59 | {state.hasTag("noApiKeyPage") && ( 60 | 65 | )} 66 | {state.matches("quick_chat") && ( 67 | send("EXIT_QUICK_CHAT")} /> 68 | )} 69 | 70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "drag-gpt", 3 | "version": "1.5.2", 4 | "description": "Drag GPT chrome extension", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite.git" 9 | }, 10 | "scripts": { 11 | "build": "tsc --noEmit && vite build", 12 | "build:watch": "__DEV__=true vite build --watch", 13 | "build:hmr": "rollup --config utils/reload/rollup.config.ts", 14 | "typegen": "xstate typegen \"src/**/*.ts?(x)\"", 15 | "wss": "node utils/reload/initReloadServer.js", 16 | "dev": "npm run build:hmr && (run-p wss build:watch)", 17 | "test": "jest", 18 | "patch": "npm version patch -m \"version %s\"" 19 | }, 20 | "type": "module", 21 | "dependencies": { 22 | "@chakra-ui/icons": "2.2.4", 23 | "@chakra-ui/react": "2.10.4", 24 | "@emotion/cache": "11.10.5", 25 | "@emotion/react": "11.10.6", 26 | "@emotion/styled": "11.10.6", 27 | "@xstate/react": "3.2.1", 28 | "framer-motion": "10.0.1", 29 | "react": "18.2.0", 30 | "react-dom": "18.2.0", 31 | "react-draggable": "4.4.5", 32 | "regenerator-runtime": "0.13.11", 33 | "xstate": "4.37.0" 34 | }, 35 | "devDependencies": { 36 | "@rollup/plugin-typescript": "^8.5.0", 37 | "@testing-library/react": "13.4.0", 38 | "@types/chrome": "0.0.197", 39 | "@types/jest": "29.0.3", 40 | "@types/node": "18.7.23", 41 | "@types/react": "18.0.21", 42 | "@types/react-dom": "18.0.6", 43 | "@types/ws": "^8.5.3", 44 | "@typescript-eslint/eslint-plugin": "5.38.1", 45 | "@typescript-eslint/parser": "5.38.1", 46 | "@vitejs/plugin-react": "2.1.0", 47 | "@xstate/cli": "^0.4.2", 48 | "chokidar": "^3.5.3", 49 | "eslint": "8.24.0", 50 | "eslint-config-prettier": "^8.5.0", 51 | "eslint-plugin-prettier": "4.2.1", 52 | "eslint-plugin-react": "7.31.8", 53 | "fs-extra": "10.1.0", 54 | "openai": "4.55.4", 55 | "jest": "29.0.3", 56 | "jest-environment-jsdom": "29.0.3", 57 | "npm-run-all": "^4.1.5", 58 | "prettier": "2.7.1", 59 | "rollup": "2.79.1", 60 | "sass": "1.55.0", 61 | "ts-jest": "29.0.2", 62 | "ts-loader": "9.4.1", 63 | "typescript": "4.8.3", 64 | "vite": "3.2.10", 65 | "ws": "8.9.0" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/shared/xState/streamChatStateMachine.typegen.ts: -------------------------------------------------------------------------------- 1 | // This file was automatically generated. Edits will be overwritten 2 | 3 | export interface Typegen0 { 4 | "@@xstate/typegen": true; 5 | internalEvents: { 6 | "done.invoke.stream-chat-state.init:invocation[0]": { 7 | type: "done.invoke.stream-chat-state.init:invocation[0]"; 8 | data: unknown; 9 | __tip: "See the XState TS docs to learn how to strongly type this."; 10 | }; 11 | "done.invoke.stream-chat-state.loading:invocation[0]": { 12 | type: "done.invoke.stream-chat-state.loading:invocation[0]"; 13 | data: unknown; 14 | __tip: "See the XState TS docs to learn how to strongly type this."; 15 | }; 16 | "error.platform.stream-chat-state.loading:invocation[0]": { 17 | type: "error.platform.stream-chat-state.loading:invocation[0]"; 18 | data: unknown; 19 | }; 20 | "xstate.init": { type: "xstate.init" }; 21 | }; 22 | invokeSrcNameMap: { 23 | getChatHistoryFromBackground: "done.invoke.stream-chat-state.init:invocation[0]"; 24 | getGPTResponse: "done.invoke.stream-chat-state.loading:invocation[0]"; 25 | }; 26 | missingImplementations: { 27 | actions: "exitChatting"; 28 | delays: never; 29 | guards: never; 30 | services: "getChatHistoryFromBackground" | "getGPTResponse"; 31 | }; 32 | eventsCausingActions: { 33 | addErrorChat: "error.platform.stream-chat-state.loading:invocation[0]"; 34 | addInitialAssistantChat: "done.invoke.stream-chat-state.loading:invocation[0]"; 35 | addResponseToken: "RECEIVE_ING"; 36 | addUserChat: "QUERY"; 37 | execCancelReceive: "RECEIVE_CANCEL"; 38 | exitChatting: "EXIT"; 39 | replaceLastResponse: "RECEIVE_DONE"; 40 | resetChatData: "RESET"; 41 | resetChatText: "QUERY"; 42 | selectGptModel: "SELECT_GPT_MODEL"; 43 | setCancelReceive: "done.invoke.stream-chat-state.loading:invocation[0]"; 44 | setChats: "done.invoke.stream-chat-state.init:invocation[0]"; 45 | updateChatText: "CHANGE_TEXT"; 46 | }; 47 | eventsCausingDelays: {}; 48 | eventsCausingGuards: { 49 | isValidText: "QUERY"; 50 | }; 51 | eventsCausingServices: { 52 | getChatHistoryFromBackground: "xstate.init"; 53 | getGPTResponse: "QUERY"; 54 | }; 55 | matchesStates: "finish" | "idle" | "init" | "loading" | "receiving"; 56 | tags: never; 57 | } 58 | -------------------------------------------------------------------------------- /src/pages/content/src/ContentScriptApp/components/GPTRequestButton.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentPropsWithRef, CSSProperties } from "react"; 2 | import styled from "@emotion/styled"; 3 | import { Spinner, Text, Tooltip } from "@chakra-ui/react"; 4 | import { COLORS, Z_INDEX } from "@src/constant/style"; 5 | import { ChatIcon } from "@chakra-ui/icons"; 6 | 7 | const GAP = 4; 8 | 9 | const StyledRequestButton = styled.button` 10 | border: none; 11 | padding: 0; 12 | position: absolute; 13 | z-index: ${Z_INDEX.ROOT}; 14 | width: 20px; 15 | height: 20px; 16 | background: ${COLORS.CONTENT_BACKGROUND}; 17 | border-radius: 4px; 18 | display: flex; 19 | align-items: center; 20 | justify-content: center; 21 | cursor: pointer; 22 | 23 | outline: none; 24 | box-shadow: none; 25 | 26 | &:hover { 27 | border: 1px solid #ffffff; 28 | } 29 | 30 | &:active { 31 | transform: scale(0.9); 32 | transition: all ease-in-out 100ms; 33 | } 34 | `; 35 | 36 | const labelTextInlineStyle: CSSProperties = { 37 | display: "block", 38 | fontSize: "13px", 39 | lineHeight: 1, 40 | margin: 0, 41 | maxWidth: "160px", 42 | overflow: "hidden", 43 | whiteSpace: "nowrap", 44 | textOverflow: "ellipsis", 45 | fontFamily: "Noto Sans KR, sans-serif", 46 | }; 47 | 48 | type GPTRequestButtonProps = { 49 | top: number; 50 | left: number; 51 | loading: boolean; 52 | selectedSlot?: Slot; 53 | } & ComponentPropsWithRef<"button">; 54 | 55 | export default function GPTRequestButton({ 56 | top, 57 | left, 58 | loading, 59 | style, 60 | selectedSlot, 61 | ...restProps 62 | }: GPTRequestButtonProps) { 63 | return ( 64 | {selectedSlot.name} 68 | ) 69 | } 70 | > 71 | 81 | {loading ? ( 82 | 83 | ) : ( 84 | 85 | )} 86 | 87 | 88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /src/pages/content/src/ContentScriptApp/xState/dragStateMachine.typegen.ts: -------------------------------------------------------------------------------- 1 | // This file was automatically generated. Edits will be overwritten 2 | 3 | export interface Typegen0 { 4 | "@@xstate/typegen": true; 5 | internalEvents: { 6 | "done.invoke.drag-state.loading:invocation[0]": { 7 | type: "done.invoke.drag-state.loading:invocation[0]"; 8 | data: unknown; 9 | __tip: "See the XState TS docs to learn how to strongly type this."; 10 | }; 11 | "error.platform.drag-state.loading:invocation[0]": { 12 | type: "error.platform.drag-state.loading:invocation[0]"; 13 | data: unknown; 14 | }; 15 | "error.platform.drag-state.on_off_check:invocation[0]": { 16 | type: "error.platform.drag-state.on_off_check:invocation[0]"; 17 | data: unknown; 18 | }; 19 | "xstate.init": { type: "xstate.init" }; 20 | "xstate.stop": { type: "xstate.stop" }; 21 | }; 22 | invokeSrcNameMap: { 23 | checkOnOffState: "done.invoke.drag-state.on_off_check:invocation[0]"; 24 | getGPTResponse: "done.invoke.drag-state.loading:invocation[0]"; 25 | }; 26 | missingImplementations: { 27 | actions: "setPositionOnScreen"; 28 | delays: never; 29 | guards: never; 30 | services: "checkOnOffState" | "getGPTResponse"; 31 | }; 32 | eventsCausingActions: { 33 | addInitialResponseChat: "done.invoke.drag-state.loading:invocation[0]"; 34 | addRequestChat: "REQUEST"; 35 | addResponseChatChunk: "RECEIVE_ING"; 36 | readyRequestButton: "TEXT_SELECTED"; 37 | resetAll: 38 | | "CLOSE_MESSAGE_BOX" 39 | | "RECEIVE_CANCEL" 40 | | "TEXT_SELECTED" 41 | | "error.platform.drag-state.on_off_check:invocation[0]" 42 | | "xstate.init"; 43 | setAnchorNodePosition: "REQUEST"; 44 | setPositionOnScreen: 45 | | "done.invoke.drag-state.loading:invocation[0]" 46 | | "error.platform.drag-state.loading:invocation[0]" 47 | | "xstate.stop"; 48 | }; 49 | eventsCausingDelays: {}; 50 | eventsCausingGuards: { 51 | isInvalidTextSelectedEvent: "TEXT_SELECTED"; 52 | isValidTextSelectedEvent: "TEXT_SELECTED"; 53 | }; 54 | eventsCausingServices: { 55 | checkOnOffState: "TEXT_SELECTED"; 56 | getGPTResponse: "REQUEST"; 57 | }; 58 | matchesStates: 59 | | "error_message_box" 60 | | "idle" 61 | | "loading" 62 | | "on_off_check" 63 | | "request_button" 64 | | "response_message_box" 65 | | "temp_response_message_box"; 66 | tags: "showRequestButton" | "showResponseMessages"; 67 | } 68 | -------------------------------------------------------------------------------- /src/pages/background/lib/storage/quickChatHistoryStorage.test.ts: -------------------------------------------------------------------------------- 1 | import { QuickChatHistoryStorage } from "@pages/background/lib/storage/quickChatHistoryStorage"; 2 | 3 | describe("QuickChatHistoryStorage test", () => { 4 | describe("getChatHistories", () => { 5 | test("채팅 데이터가 있는 경우 정상적으로 반환한다.", async () => { 6 | // given 7 | const savedChats: Chat[] = []; 8 | jest 9 | .spyOn(QuickChatHistoryStorage.storage, "load") 10 | .mockImplementationOnce(() => Promise.resolve(savedChats)); 11 | 12 | // when 13 | const chats = await QuickChatHistoryStorage.getChatHistories(); 14 | 15 | // then 16 | expect(chats).toEqual(savedChats); 17 | }); 18 | test("채팅 데이터가 배열이 아닌 경우 빈 배열을 반환한다.", async () => { 19 | // given 20 | jest 21 | .spyOn(QuickChatHistoryStorage.storage, "load") 22 | .mockImplementationOnce(() => Promise.resolve("this is not array")); 23 | 24 | // when 25 | const chats = await QuickChatHistoryStorage.getChatHistories(); 26 | 27 | // then 28 | expect(chats).toEqual([]); 29 | }); 30 | test("채팅 데이터를 가져오는 과정에서 에러가 날 경우 빈 배열을 반환한다.", async () => { 31 | // given 32 | jest 33 | .spyOn(QuickChatHistoryStorage.storage, "load") 34 | .mockImplementationOnce(() => Promise.reject(Error("unknown"))); 35 | 36 | // when 37 | const chats = await QuickChatHistoryStorage.getChatHistories(); 38 | 39 | // then 40 | expect(chats).toEqual([]); 41 | }); 42 | }); 43 | test("resetChatHistories", async () => { 44 | // given 45 | const storageSaveFunction = jest 46 | .spyOn(QuickChatHistoryStorage.storage, "save") 47 | .mockImplementationOnce(() => Promise.resolve()); 48 | 49 | // when 50 | await QuickChatHistoryStorage.resetChatHistories(); 51 | 52 | // then 53 | expect(storageSaveFunction).toBeCalledWith("QUICK_CHAT_HISTORY", []); 54 | }); 55 | test("pushChatHistories", async () => { 56 | // given 57 | const chat: Chat = { 58 | role: "user", 59 | content: "content", 60 | }; 61 | jest 62 | .spyOn(QuickChatHistoryStorage, "getChatHistories") 63 | .mockImplementationOnce(() => Promise.resolve([])); 64 | const storageSaveFunction = jest 65 | .spyOn(QuickChatHistoryStorage.storage, "save") 66 | .mockImplementationOnce(() => Promise.resolve()); 67 | 68 | // when 69 | await QuickChatHistoryStorage.pushChatHistories(chat); 70 | 71 | // then 72 | expect(storageSaveFunction).toBeCalledWith("QUICK_CHAT_HISTORY", [chat]); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import path, { resolve } from "path"; 4 | import makeManifest from "./utils/plugins/make-manifest"; 5 | import customDynamicImport from "./utils/plugins/custom-dynamic-import"; 6 | import addHmr from "./utils/plugins/add-hmr"; 7 | import manifest from "./manifest"; 8 | 9 | const root = resolve(__dirname, "src"); 10 | const pagesDir = resolve(root, "pages"); 11 | const assetsDir = resolve(root, "assets"); 12 | const outDir = resolve(__dirname, "dist"); 13 | const publicDir = resolve(__dirname, "public"); 14 | 15 | const isDev = process.env.__DEV__ === "true"; 16 | const isProduction = !isDev; 17 | 18 | // ENABLE HMR IN BACKGROUND SCRIPT 19 | const enableHmrInBackgroundScript = true; 20 | 21 | export default defineConfig({ 22 | resolve: { 23 | alias: { 24 | "@src": root, 25 | "@assets": assetsDir, 26 | "@pages": pagesDir, 27 | }, 28 | }, 29 | plugins: [ 30 | react(), 31 | makeManifest(manifest, { isDev }), 32 | customDynamicImport(), 33 | addHmr({ background: enableHmrInBackgroundScript, view: true }), 34 | ], 35 | publicDir, 36 | build: { 37 | outDir, 38 | /** Can slowDown build speed. */ 39 | // sourcemap: !isDev, 40 | minify: isProduction, 41 | reportCompressedSize: isProduction, 42 | rollupOptions: { 43 | input: { 44 | content: resolve(pagesDir, "content", "index.ts"), 45 | background: resolve(pagesDir, "background", "index.ts"), 46 | contentStyle: resolve(pagesDir, "content", "style.scss"), 47 | popup: resolve(pagesDir, "popup", "index.html"), 48 | options: resolve(pagesDir, "options", "index.html"), 49 | }, 50 | watch: { 51 | include: ["src/**", "vite.config.ts"], 52 | exclude: ["node_modules/**", "src/**/*.test.ts"], 53 | }, 54 | output: { 55 | entryFileNames: "src/pages/[name]/index.js", 56 | chunkFileNames: isDev 57 | ? "assets/js/[name].js" 58 | : "assets/js/[name].[hash].js", 59 | assetFileNames: (assetInfo) => { 60 | const { dir, name: _name } = path.parse(assetInfo.name ?? ""); 61 | const assetFolder = dir.split("/").at(-1); 62 | const name = assetFolder + firstUpperCase(_name); 63 | return `assets/[ext]/${name}.chunk.[ext]`; 64 | }, 65 | }, 66 | }, 67 | }, 68 | }); 69 | 70 | function firstUpperCase(str: string) { 71 | const firstAlphabet = new RegExp(/( |^)[a-z]/, "g"); 72 | return str.toLowerCase().replace(firstAlphabet, (L) => L.toUpperCase()); 73 | } 74 | -------------------------------------------------------------------------------- /src/pages/background/lib/storage/chatHistoryStorage.ts: -------------------------------------------------------------------------------- 1 | import { ILocalStorage, LocalStorage } from "@src/chrome/localStorage"; 2 | 3 | type SessionId = string; 4 | export type SessionHistories = { 5 | history: Chat[]; 6 | updatedAt: number; 7 | createdAt: number; 8 | type?: "Quick" | "Drag"; 9 | }; 10 | export type ChatHistories = Record; 11 | 12 | const EMPTY_CHAT_HISTORIES: ChatHistories = {}; 13 | 14 | export class ChatHistoryStorage { 15 | private static CHAT_HISTORY_KEY = "CHAT_HISTORY"; 16 | static storage: ILocalStorage = new LocalStorage(); 17 | 18 | static async getChatHistories(): Promise { 19 | try { 20 | return (await this.storage.load(this.CHAT_HISTORY_KEY)) as ChatHistories; 21 | } catch (e) { 22 | return EMPTY_CHAT_HISTORIES; 23 | } 24 | } 25 | 26 | static async getChatHistory(sessionId: string): Promise { 27 | const chatHistories = await this.getChatHistories(); 28 | return ( 29 | chatHistories[sessionId] || { 30 | history: [], 31 | updatedAt: 0, 32 | createdAt: Date.now(), 33 | } 34 | ); 35 | } 36 | 37 | static async resetChatHistories(): Promise { 38 | await this.storage.save(this.CHAT_HISTORY_KEY, EMPTY_CHAT_HISTORIES); 39 | } 40 | 41 | static async saveChatHistories( 42 | sessionId: string, 43 | chatOrChats: Chat | Chat[], 44 | type: "Quick" | "Drag" 45 | ): Promise { 46 | const chatHistories = await this.getChatHistories(); 47 | await this.storage.save(this.CHAT_HISTORY_KEY, { 48 | ...chatHistories, 49 | [sessionId]: { 50 | type, 51 | history: chatOrChats, 52 | createdAt: Date.now(), 53 | updatedAt: Date.now(), 54 | }, 55 | }); 56 | } 57 | 58 | static async deleteChatHistory(sessionId: string): Promise { 59 | const chatHistories = await this.getChatHistories(); 60 | delete chatHistories[sessionId]; 61 | await this.storage.save(this.CHAT_HISTORY_KEY, chatHistories); 62 | } 63 | 64 | static async pushChatHistories( 65 | sessionId: string, 66 | chatOrChats: Chat | Chat[], 67 | type?: "Quick" | "Drag" 68 | ): Promise { 69 | const chatHistories = await this.getChatHistories(); 70 | const sessionHistories = await this.getChatHistory(sessionId); 71 | await this.storage.save(this.CHAT_HISTORY_KEY, { 72 | ...chatHistories, 73 | [sessionId]: { 74 | ...sessionHistories, 75 | history: sessionHistories.history.concat(chatOrChats), 76 | updatedAt: Date.now(), 77 | type: type ? type : sessionHistories.type, 78 | }, 79 | }); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/pages/popup/xState/popupStateMachine.ts: -------------------------------------------------------------------------------- 1 | import { assign, createMachine } from "xstate"; 2 | 3 | type Events = 4 | | { 5 | type: "CHECK_API_KEY"; 6 | data: string; 7 | } 8 | | { 9 | type: "RESET_API_KEY" | "GO_TO_QUICK_CHAT" | "EXIT_QUICK_CHAT"; 10 | }; 11 | 12 | interface Context { 13 | openAiApiKey: string | null; 14 | apiKeyCheckError?: Error; 15 | } 16 | 17 | type Services = { 18 | getApiKeyFromBackground: { 19 | data: string; 20 | }; 21 | saveApiKeyToBackground: { 22 | data: void; 23 | }; 24 | }; 25 | 26 | const popupStateMachine = createMachine( 27 | { 28 | id: "popup-state", 29 | initial: "init", 30 | predictableActionArguments: true, 31 | schema: { 32 | context: {} as Context, 33 | events: {} as Events, 34 | services: {} as Services, 35 | }, 36 | context: { 37 | openAiApiKey: null, 38 | }, 39 | tsTypes: {} as import("./popupStateMachine.typegen").Typegen0, 40 | states: { 41 | init: { 42 | invoke: { 43 | src: "getApiKeyFromBackground", 44 | onDone: { 45 | target: "slot_list_page", 46 | actions: "setApiKey", 47 | }, 48 | onError: { 49 | target: "no_api_key", 50 | }, 51 | }, 52 | }, 53 | slot_list_page: { 54 | on: { 55 | RESET_API_KEY: { 56 | target: "no_api_key", 57 | actions: ["resetOpenAiApiKey", "resetApiKeyFromBackground"], 58 | }, 59 | GO_TO_QUICK_CHAT: "quick_chat", 60 | }, 61 | }, 62 | no_api_key: { 63 | tags: "noApiKeyPage", 64 | on: { 65 | CHECK_API_KEY: { 66 | target: "checking_api_key", 67 | actions: "setApiKey", 68 | }, 69 | }, 70 | }, 71 | quick_chat: { 72 | on: { 73 | EXIT_QUICK_CHAT: "slot_list_page", 74 | }, 75 | }, 76 | checking_api_key: { 77 | tags: "noApiKeyPage", 78 | invoke: { 79 | src: "saveApiKeyToBackground", 80 | onDone: { target: "slot_list_page" }, 81 | onError: { 82 | target: "no_api_key", 83 | actions: assign({ apiKeyCheckError: (_, event) => event.data }), 84 | }, 85 | }, 86 | }, 87 | }, 88 | }, 89 | { 90 | actions: { 91 | setApiKey: assign({ 92 | openAiApiKey: (_, event) => event.data, 93 | }), 94 | resetOpenAiApiKey: assign({ 95 | openAiApiKey: null, 96 | }), 97 | }, 98 | } 99 | ); 100 | 101 | export default popupStateMachine; 102 | -------------------------------------------------------------------------------- /src/pages/background/lib/service/slotsManipulatorService.test.ts: -------------------------------------------------------------------------------- 1 | import { SlotsManipulatorService } from "@pages/background/lib/service/slotsManipulatorService"; 2 | 3 | const defaultSlot: Slot = { 4 | id: "1", 5 | name: "name", 6 | isSelected: false, 7 | type: "gpt-4o", 8 | }; 9 | 10 | describe("SlotsManipulator test", () => { 11 | test("getSelectedSlot test", () => { 12 | // given 13 | const MOCK_SELECTED_SLOT: Slot = { ...defaultSlot, isSelected: true }; 14 | const MOCK_SLOTS: Slot[] = [ 15 | defaultSlot, 16 | defaultSlot, 17 | defaultSlot, 18 | MOCK_SELECTED_SLOT, 19 | ]; 20 | 21 | // when 22 | const foundedSlot = SlotsManipulatorService.getSelectedSlot(MOCK_SLOTS); 23 | 24 | // then 25 | expect(foundedSlot).toEqual(MOCK_SELECTED_SLOT); 26 | }); 27 | describe("getSelectedSlotIndex test", () => { 28 | test("선택된 슬롯의 index를 반환한다.", () => { 29 | // given 30 | const MOCK_SELECTED_SLOT: Slot = { ...defaultSlot, isSelected: true }; 31 | const MOCK_SLOTS: Slot[] = [ 32 | defaultSlot, 33 | defaultSlot, 34 | defaultSlot, 35 | MOCK_SELECTED_SLOT, 36 | ]; 37 | 38 | // when 39 | const selectedSlotIndex = 40 | SlotsManipulatorService.getSelectedSlotIndex(MOCK_SLOTS); 41 | 42 | // then 43 | expect(selectedSlotIndex).toEqual(3); 44 | }); 45 | test("선택된 슬롯이 없으면 undefined를 반환한다", () => { 46 | // given 47 | const MOCK_SLOTS: Slot[] = [defaultSlot, defaultSlot, defaultSlot]; 48 | 49 | // when 50 | const selectedSlotIndex = 51 | SlotsManipulatorService.getSelectedSlotIndex(MOCK_SLOTS); 52 | 53 | // then 54 | expect(selectedSlotIndex).toEqual(undefined); 55 | }); 56 | }); 57 | 58 | test("addSlot test", () => { 59 | // given 60 | const MOCK_SLOT: Slot = { ...defaultSlot }; 61 | const MOCK_SLOTS: Slot[] = []; 62 | 63 | // when 64 | const slots = SlotsManipulatorService.addSlot(MOCK_SLOTS, MOCK_SLOT); 65 | 66 | // then 67 | expect(slots).toEqual(MOCK_SLOTS.concat(MOCK_SLOT)); 68 | }); 69 | test("updateSlot test", () => { 70 | // given 71 | const MOCK_SLOT_ID = "update"; 72 | const MOCK_SLOT: Slot = { 73 | ...defaultSlot, 74 | id: MOCK_SLOT_ID, 75 | isSelected: true, 76 | }; 77 | const UPDATED_SLOT: Slot = { ...MOCK_SLOT, isSelected: false }; 78 | const MOCK_SLOTS: Slot[] = [ 79 | defaultSlot, 80 | defaultSlot, 81 | defaultSlot, 82 | MOCK_SLOT, 83 | ]; 84 | 85 | // when 86 | const updatedSlots = SlotsManipulatorService.updateSlot( 87 | MOCK_SLOTS, 88 | UPDATED_SLOT 89 | ); 90 | 91 | // then 92 | const updatedSlot = updatedSlots.find(({ id }) => id === MOCK_SLOT_ID); 93 | expect(updatedSlot).toEqual(UPDATED_SLOT); 94 | }); 95 | test("deleteSlot test", () => { 96 | // given 97 | const DELETED_SLOT_ID = "deleted"; 98 | const MOCK_SLOTS: Slot[] = [ 99 | defaultSlot, 100 | { ...defaultSlot, id: DELETED_SLOT_ID }, 101 | ]; 102 | 103 | // when 104 | const deletedSlots = SlotsManipulatorService.deleteSlot( 105 | MOCK_SLOTS, 106 | DELETED_SLOT_ID 107 | ); 108 | 109 | // then 110 | expect(deletedSlots).toHaveLength(1); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /src/pages/options/src/pages/Main.tsx: -------------------------------------------------------------------------------- 1 | import useBackgroundMessage from "@src/shared/hook/useBackgroundMessage"; 2 | import ChatHistoryMainLayout from "@pages/options/src/components/layout/ChatHistoryMainLayout"; 3 | import { Suspense, useState } from "react"; 4 | import ChatHistory from "@pages/options/src/components/ChatHistory"; 5 | import ChatSessionGroup from "@pages/options/src/components/ChatSessionGroup"; 6 | import { sendMessageToBackgroundAsync } from "@src/chrome/message"; 7 | import PleaseSelectSession from "@pages/options/src/components/PleaseSelectSession"; 8 | import ConditionalRender from "@pages/options/src/components/layout/ConditionalRender"; 9 | import ChatHistoryHeader from "@pages/options/src/components/ChatHistoryHeader"; 10 | import ChatSessionGroupHeader from "@pages/options/src/components/ChatSessionGroupHeader"; 11 | import EmptySession from "@pages/options/src/components/EmptySession"; 12 | import { Spinner } from "@chakra-ui/react"; 13 | 14 | export default function OptionMainPage() { 15 | const { data: chatHistories, refetch } = useBackgroundMessage({ 16 | type: "GetAllChatHistory", 17 | }); 18 | 19 | const hasChatHistories = Object.keys(chatHistories).length > 0; 20 | const sortedChatHistories = [ 21 | ...Object.entries(chatHistories).sort( 22 | ([, a], [, b]) => a.createdAt - b.createdAt 23 | ), 24 | ]; 25 | 26 | const lastUpdatedSessionId = [ 27 | ...Object.entries(chatHistories).sort( 28 | ([, a], [, b]) => b.updatedAt - a.updatedAt 29 | ), 30 | ].at(0)?.[0]; 31 | 32 | const [selectedSessionId, setSelectedSessionId] = 33 | useState(lastUpdatedSessionId); 34 | 35 | const onSelectSession = (sessionId: string) => { 36 | setSelectedSessionId(sessionId); 37 | }; 38 | 39 | const afterDeleteSession = () => { 40 | setSelectedSessionId(undefined); 41 | refetch(); 42 | }; 43 | 44 | const deleteSelectedSession = async () => { 45 | if (!selectedSessionId) return; 46 | await sendMessageToBackgroundAsync({ 47 | type: "DeleteChatHistorySession", 48 | input: selectedSessionId, 49 | }); 50 | afterDeleteSession(); 51 | }; 52 | 53 | const deleteAllSession = async () => { 54 | await sendMessageToBackgroundAsync({ 55 | type: "DeleteAllChatHistory", 56 | }); 57 | afterDeleteSession(); 58 | }; 59 | 60 | return ( 61 | } 66 | > 67 | 68 | 73 | 74 | } 75 | ChatHistory={ 76 | } 79 | > 80 | 81 | }> 82 | 87 | 88 | 89 | } 90 | /> 91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | education, socio-economic status, nationality, personal appearance, race, 10 | religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at . All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | -------------------------------------------------------------------------------- /src/shared/xState/chatStateMachine.ts: -------------------------------------------------------------------------------- 1 | import { assign, createMachine } from "xstate"; 2 | 3 | type Events = 4 | | { type: "EXIT" | "QUERY" | "RESET" } 5 | | { type: "CHANGE_TEXT"; data: string }; 6 | 7 | interface Context { 8 | inputText: string; 9 | chats: Chat[]; 10 | error?: Error; 11 | } 12 | 13 | type Services = { 14 | getGPTResponse: { 15 | data: { result: string }; 16 | }; 17 | getChatHistoryFromBackground: { 18 | data: Chat[]; 19 | }; 20 | }; 21 | 22 | const initialContext: Context = { 23 | inputText: "", 24 | chats: [], 25 | }; 26 | 27 | const chatStateMachine = createMachine( 28 | { 29 | id: "chat-state", 30 | initial: "init", 31 | predictableActionArguments: true, 32 | context: initialContext, 33 | schema: { 34 | context: {} as Context, 35 | events: {} as Events, 36 | services: {} as Services, 37 | }, 38 | tsTypes: {} as import("./chatStateMachine.typegen").Typegen0, 39 | states: { 40 | init: { 41 | invoke: { 42 | src: "getChatHistoryFromBackground", 43 | onDone: { target: "idle", actions: "setChats" }, 44 | onError: { target: "idle" }, 45 | }, 46 | }, 47 | idle: { 48 | on: { 49 | QUERY: { 50 | target: "loading", 51 | actions: ["addUserChat", "resetChatText"], 52 | cond: "isValidText", 53 | }, 54 | EXIT: "finish", 55 | RESET: { actions: "resetChatData" }, 56 | CHANGE_TEXT: { 57 | actions: "updateChatText", 58 | }, 59 | }, 60 | }, 61 | loading: { 62 | invoke: { 63 | src: "getGPTResponse", 64 | onDone: { target: "idle", actions: "addAssistantChat" }, 65 | onError: { target: "idle", actions: "addErrorChat" }, 66 | }, 67 | on: { 68 | EXIT: "finish", 69 | RESET: { actions: "resetChatData" }, 70 | CHANGE_TEXT: { 71 | actions: "updateChatText", 72 | }, 73 | }, 74 | }, 75 | finish: { 76 | type: "final", 77 | entry: "exitChatting", 78 | }, 79 | }, 80 | }, 81 | { 82 | actions: { 83 | setChats: assign({ 84 | chats: (_, event) => event.data, 85 | }), 86 | addUserChat: assign({ 87 | chats: (context) => 88 | context.chats.concat({ 89 | role: "user", 90 | content: context.inputText, 91 | }), 92 | }), 93 | addAssistantChat: assign({ 94 | chats: (context, event) => 95 | context.chats.concat({ 96 | role: "assistant", 97 | content: event.data.result, 98 | }), 99 | }), 100 | addErrorChat: assign({ 101 | chats: (context, event) => { 102 | const error: Error = event.data as Error; 103 | return context.chats.concat({ 104 | role: "error", 105 | content: `${error?.name}\n${error?.message}`, 106 | }); 107 | }, 108 | }), 109 | updateChatText: assign({ 110 | inputText: (_, event) => event.data, 111 | }), 112 | resetChatText: assign({ 113 | inputText: () => "", 114 | }), 115 | resetChatData: assign({ chats: () => [] }), 116 | }, 117 | guards: { 118 | isValidText: (context) => context.inputText.length > 0, 119 | }, 120 | } 121 | ); 122 | 123 | export default chatStateMachine; 124 | -------------------------------------------------------------------------------- /src/pages/popup/xState/slotListPageStateMachine.ts: -------------------------------------------------------------------------------- 1 | import { assign, createMachine } from "xstate"; 2 | 3 | type Events = 4 | | { 5 | type: "EDIT_SLOT" | "SELECT_SLOT" | "DELETE_SLOT"; 6 | data: string; 7 | } 8 | | { 9 | type: "ADD_SLOT" | "UPDATE_SLOT"; 10 | data: Slot; 11 | } 12 | | { 13 | type: "CHANGE_API_KEY" | "BACK_TO_LIST"; 14 | }; 15 | 16 | interface Context { 17 | slots: Slot[]; 18 | editingSlot?: Slot; 19 | } 20 | 21 | type Services = { 22 | getAllSlotsFromBackground: { 23 | data: Slot[]; 24 | }; 25 | }; 26 | 27 | const slotListPageStateMachine = createMachine( 28 | { 29 | id: "slot_list_page", 30 | initial: "init", 31 | predictableActionArguments: true, 32 | schema: { 33 | context: {} as Context, 34 | events: {} as Events, 35 | services: {} as Services, 36 | }, 37 | context: { 38 | slots: [], 39 | }, 40 | tsTypes: {} as import("./slotListPageStateMachine.typegen").Typegen0, 41 | states: { 42 | init: { 43 | invoke: { 44 | src: "getAllSlotsFromBackground", 45 | onDone: { 46 | target: "slot_list", 47 | actions: "setSlots", 48 | }, 49 | onError: { 50 | target: "slot_list", 51 | }, 52 | }, 53 | }, 54 | slot_list: { 55 | on: { 56 | EDIT_SLOT: { 57 | target: "slot_detail", 58 | actions: assign({ 59 | editingSlot: (context, event) => 60 | context.slots.find((slot) => slot.id === event.data), 61 | }), 62 | }, 63 | ADD_SLOT: { 64 | actions: ["addSlot", "addSlotMessageSendToBackground"], 65 | }, 66 | CHANGE_API_KEY: { 67 | target: "init", 68 | actions: "exitPage", 69 | }, 70 | DELETE_SLOT: { 71 | actions: ["deleteSlot", "deleteSlotMessageSendToBackground"], 72 | }, 73 | SELECT_SLOT: { 74 | actions: ["selectSlot", "selectSlotMessageSendToBackground"], 75 | }, 76 | }, 77 | }, 78 | slot_detail: { 79 | on: { 80 | BACK_TO_LIST: { 81 | target: "slot_list", 82 | actions: assign({ 83 | editingSlot: () => undefined, 84 | }), 85 | }, 86 | UPDATE_SLOT: { 87 | actions: ["updateSlot", "updateSlotMessageSendToBackground"], 88 | }, 89 | }, 90 | }, 91 | }, 92 | }, 93 | { 94 | actions: { 95 | setSlots: assign({ slots: (_, event) => event.data }), 96 | addSlot: assign({ 97 | slots: (context, event) => context.slots.concat(event.data), 98 | }), 99 | updateSlot: assign({ 100 | slots: (context, event) => { 101 | return context.slots.reduce((total, slot) => { 102 | const isUpdateTargetSlot = slot.id === event.data.id; 103 | if (isUpdateTargetSlot) { 104 | return [...total, event.data]; 105 | } 106 | return [...total, slot]; 107 | }, []); 108 | }, 109 | }), 110 | deleteSlot: assign({ 111 | slots: (context, event) => 112 | context.slots.filter((slot) => slot.id !== event.data), 113 | }), 114 | selectSlot: assign({ 115 | slots: (context, event) => 116 | context.slots.map((slot) => ({ 117 | ...slot, 118 | isSelected: slot.id === event.data, 119 | })), 120 | }), 121 | }, 122 | } 123 | ); 124 | 125 | export default slotListPageStateMachine; 126 | -------------------------------------------------------------------------------- /public/_locales/zh_CN/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extensionName": { 3 | "message": "拖拽 GPT - 轻松开始人工智能之旅!" 4 | }, 5 | "extensionDescription": { 6 | "message": "只需拖拽并点击按钮,就可以轻松向ChatGPT提问或发出请求!" 7 | }, 8 | "dragGPT": { 9 | "message": "拖拽 GPT" 10 | }, 11 | "noApiKeyPage_openAIApiKey_placeholder": { 12 | "message": "OpenAI API 密钥" 13 | }, 14 | "noApiKeyPage_checkingApiKey": { 15 | "message": "正在检查 API 密钥...\n发送测试请求中..." 16 | }, 17 | "noApiKeyPage_howToGetApiKey": { 18 | "message": "如何获取 OpenAI 的 API 密钥?" 19 | }, 20 | "noApiKeyPage_howToGetApiKeyDetail1": { 21 | "message": "如果没有 OpenAI 帐号,需要进行{注册}。" 22 | }, 23 | "noApiKeyPage_howToGetApiKeyDetail2": { 24 | "message": "前往{API 密钥页面}。" 25 | }, 26 | "noApiKeyPage_howToGetApiKeyDetail3": { 27 | "message": "点击“创建新的密钥”按钮以生成 API 密钥。" 28 | }, 29 | "noApiKeyPage_howToGetApiKeyDetail4": { 30 | "message": "复制密钥并粘贴到输入框,然后点击保存按钮即可开始使用!" 31 | }, 32 | "footer_EmailText": { 33 | "message": "功能建议 / 错误报告" 34 | }, 35 | "noApiKeyPage_saveButtonText": { 36 | "message": "保存" 37 | }, 38 | "quickChattingPage_sendButtonText": { 39 | "message": "发送" 40 | }, 41 | "quickChattingPage_stopButtonText": { 42 | "message": "停止回答" 43 | }, 44 | "quickChattingPage_chattingPlaceholder": { 45 | "message": "例如:你好!" 46 | }, 47 | "quickChattingPage_backButtonText": { 48 | "message": "返回" 49 | }, 50 | "quickChattingPage_resetButtonText": { 51 | "message": "重置对话" 52 | }, 53 | "quickChattingPage_copyButtonText_copy": { 54 | "message": "复制最后的回答" 55 | }, 56 | "quickChattingPage_copyButtonText_copied": { 57 | "message": "已复制!" 58 | }, 59 | "slotListPage_newSlotButtonText": { 60 | "message": "添加插槽" 61 | }, 62 | "slogListPage_showChatHistoryButtonText": { 63 | "message": "대화 기록 보기" 64 | }, 65 | "slotListPage_quickChatButtonText": { 66 | "message": "快速对话" 67 | }, 68 | "slotListPage_resetApiKeyButtonText": { 69 | "message": "重置 API 密钥" 70 | }, 71 | "slotListPage_promptSlotsTitle": { 72 | "message": "提示插槽" 73 | }, 74 | "slotListPage_resetApiKeyConfirmMessage": { 75 | "message": "确定要重置 API 密钥吗?" 76 | }, 77 | "slotListItem_deleteButtonText": { 78 | "message": "删除" 79 | }, 80 | "slotListItem_editButtonText": { 81 | "message": "编辑" 82 | }, 83 | "slotDetail_promptSlotName": { 84 | "message": "提示插槽名称" 85 | }, 86 | "slotDetail_promptSlotName_placeholder": { 87 | "message": "例如:用于英译汉" 88 | }, 89 | "slotDetail_writePromptTitle": { 90 | "message": "请填写用于ChatGPT请求的预设提示内容(最多2000个字符)" 91 | }, 92 | "slotDetail_promptInputPlaceholder": { 93 | "message": "例如:告诉我英语句子或单词的中文意思" 94 | }, 95 | "slotDetail_temperatureTitle": { 96 | "message": "随机性(温度:0~2)" 97 | }, 98 | "slotDetail_isGpt4Turbo": { 99 | "message": "使用 GPT4Turbo(默认值:gpt4o)" 100 | }, 101 | "slotDetail_saveButtonText": { 102 | "message": "保存" 103 | }, 104 | "slotDetail_cancelButtonText": { 105 | "message": "取消" 106 | }, 107 | "responseMessageBox_responseTitle": { 108 | "message": "回答" 109 | }, 110 | "responseMessageBox_copyButtonText_copy": { 111 | "message": "复制最后的回答" 112 | }, 113 | "responseMessageBox_copyButtonText_copied": { 114 | "message": "已复制!" 115 | }, 116 | "responseMessageBox_sendButtonText": { 117 | "message": "发送" 118 | }, 119 | "responseMessageBox_stopButtonText": { 120 | "message": "停止回答" 121 | }, 122 | "responseMessageBox_messageInputPlacepolder": { 123 | "message": "例如:将上述内容简单总结一下" 124 | }, 125 | "errorMessageBox_errorTitle": { 126 | "message": "错误" 127 | }, 128 | "errorMessageBox_unknownError": { 129 | "message": "未知错误" 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /public/_locales/ja/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extensionName": { 3 | "message": "ドラッグGPT - 簡単にAIを始めてみよう!" 4 | }, 5 | "extensionDescription": { 6 | "message": "ドラッグしてボタンをクリックするだけで、簡単に選択した内容をChatGPTに質問したり要求したりすることができます!" 7 | }, 8 | "dragGPT": { 9 | "message": "ドラッグGPT" 10 | }, 11 | "noApiKeyPage_openAIApiKey_placeholder": { 12 | "message": "OpenAI APIキー" 13 | }, 14 | "noApiKeyPage_checkingApiKey": { 15 | "message": "APIキーを確認するために\nテストリクエストを送信中..." 16 | }, 17 | "noApiKeyPage_howToGetApiKey": { 18 | "message": "OpenAIのAPIキーの取得方法は?" 19 | }, 20 | "noApiKeyPage_howToGetApiKeyDetail1": { 21 | "message": "OpenAIアカウントがない場合、{登録}が必要です。" 22 | }, 23 | "noApiKeyPage_howToGetApiKeyDetail2": { 24 | "message": "{APIキーページ}に移動してください。" 25 | }, 26 | "noApiKeyPage_howToGetApiKeyDetail3": { 27 | "message": "APIキーの作成には、新しいシークレットキーを作成するボタンをクリックしてください。" 28 | }, 29 | "noApiKeyPage_howToGetApiKeyDetail4": { 30 | "message": "キーをコピーして入力し、保存ボタンをクリックすると、開始できます!" 31 | }, 32 | "footer_EmailText": { 33 | "message": "機能の提案/バグの報告" 34 | }, 35 | "noApiKeyPage_saveButtonText": { 36 | "message": "保存" 37 | }, 38 | "quickChattingPage_sendButtonText": { 39 | "message": "送信" 40 | }, 41 | "quickChattingPage_stopButtonText": { 42 | "message": "回答停止" 43 | }, 44 | "quickChattingPage_chattingPlaceholder": { 45 | "message": "例:こんにちは!" 46 | }, 47 | "quickChattingPage_backButtonText": { 48 | "message": "戻る" 49 | }, 50 | "quickChattingPage_resetButtonText": { 51 | "message": "会話のリセット" 52 | }, 53 | "quickChattingPage_copyButtonText_copy": { 54 | "message": "最後の応答のコピー" 55 | }, 56 | "quickChattingPage_copyButtonText_copied": { 57 | "message": "コピー済み!" 58 | }, 59 | "slotListPage_newSlotButtonText": { 60 | "message": "スロットを追加" 61 | }, 62 | "slogListPage_showChatHistoryButtonText": { 63 | "message": "대화 기록 보기" 64 | }, 65 | "slotListPage_quickChatButtonText": { 66 | "message": "クイックチャット" 67 | }, 68 | "slotListPage_resetApiKeyButtonText": { 69 | "message": "APIキーのリセット" 70 | }, 71 | "slotListPage_promptSlotsTitle": { 72 | "message": "プロンプトスロット" 73 | }, 74 | "slotListPage_resetApiKeyConfirmMessage": { 75 | "message": "本当にAPIキーをリセットしますか?" 76 | }, 77 | "slotListItem_deleteButtonText": { 78 | "message": "削除" 79 | }, 80 | "slotListItem_editButtonText": { 81 | "message": "編集" 82 | }, 83 | "slotDetail_promptSlotName": { 84 | "message": "プロンプトスロット名" 85 | }, 86 | "slotDetail_promptSlotName_placeholder": { 87 | "message": "例:英語から日本語への翻訳用" 88 | }, 89 | "slotDetail_writePromptTitle": { 90 | "message": "ChatGPTの要求に使用するプロンプトを作成してください(最大2000文字)" 91 | }, 92 | "slotDetail_promptInputPlaceholder": { 93 | "message": "例:英文または単語の日本語訳を教えてください" 94 | }, 95 | "slotDetail_temperatureTitle": { 96 | "message": "ランダム性(temperature:0~2)" 97 | }, 98 | "slotDetail_isGpt4Turbo": { 99 | "message": "gpt4Turboの使用(デフォルト:gpt4o)" 100 | }, 101 | "slotDetail_saveButtonText": { 102 | "message": "保存" 103 | }, 104 | "slotDetail_cancelButtonText": { 105 | "message": "キャンセル" 106 | }, 107 | "responseMessageBox_responseTitle": { 108 | "message": "回答" 109 | }, 110 | "responseMessageBox_copyButtonText_copy": { 111 | "message": "最後の応答のコピー" 112 | }, 113 | "responseMessageBox_copyButtonText_copied": { 114 | "message": "コピー済み!" 115 | }, 116 | "responseMessageBox_sendButtonText": { 117 | "message": "送信" 118 | }, 119 | "responseMessageBox_stopButtonText": { 120 | "message": "回答停止" 121 | }, 122 | "responseMessageBox_messageInputPlacepolder": { 123 | "message": "例:上記の内容を簡潔に要約してください" 124 | }, 125 | "errorMessageBox_errorTitle": { 126 | "message": "エラー" 127 | }, 128 | "errorMessageBox_unknownError": { 129 | "message": "未知のエラー" 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /public/_locales/ko/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extensionName": { 3 | "message": "드래그 GPT - 드래그로 쉽게 AI를 시작해보세요!" 4 | }, 5 | "extensionDescription": { 6 | "message": "드래그 후 버튼 클릭만으로 간단하게 선택한 내용을 ChatGPT에게 물어보거나 요청할 수 있어요!" 7 | }, 8 | "dragGPT": { 9 | "message": "드래그 GPT" 10 | }, 11 | "noApiKeyPage_openAIApiKey_placeholder": { 12 | "message": "OpenAI API 키" 13 | }, 14 | "noApiKeyPage_checkingApiKey": { 15 | "message": "API 키 확인을 위해\n테스트 요청을 날려보는 중..." 16 | }, 17 | "noApiKeyPage_howToGetApiKey": { 18 | "message": "OpenAI의 API 키는 어떻게 얻나요?" 19 | }, 20 | "noApiKeyPage_howToGetApiKeyDetail1": { 21 | "message": "OpenAI 계정이 없다면 {회원가입}이 필요합니다." 22 | }, 23 | "noApiKeyPage_howToGetApiKeyDetail2": { 24 | "message": "{API Key 페이지}로 이동하세요." 25 | }, 26 | "noApiKeyPage_howToGetApiKeyDetail3": { 27 | "message": "API key 생성을 위해 Create a new secret key 버튼을 눌러주세요." 28 | }, 29 | "noApiKeyPage_howToGetApiKeyDetail4": { 30 | "message": "키를 복사하고 입력 후 저장 버튼을 누르면 시작할 수 있습니다!" 31 | }, 32 | "footer_EmailText": { 33 | "message": "기능 제안 / 버그 제보" 34 | }, 35 | "noApiKeyPage_saveButtonText": { 36 | "message": "저장" 37 | }, 38 | "quickChattingPage_sendButtonText": { 39 | "message": "전송" 40 | }, 41 | "quickChattingPage_stopButtonText": { 42 | "message": "답변 중단" 43 | }, 44 | "quickChattingPage_chattingPlaceholder": { 45 | "message": "ex. 안녕!" 46 | }, 47 | "quickChattingPage_backButtonText": { 48 | "message": "뒤로가기" 49 | }, 50 | "quickChattingPage_resetButtonText": { 51 | "message": "대화 초기화" 52 | }, 53 | "quickChattingPage_copyButtonText_copy": { 54 | "message": "마지막 응답 복사" 55 | }, 56 | "quickChattingPage_copyButtonText_copied": { 57 | "message": "복사됨!" 58 | }, 59 | "slotListPage_newSlotButtonText": { 60 | "message": "슬롯 추가" 61 | }, 62 | "slogListPage_showChatHistoryButtonText": { 63 | "message": "대화 기록 보기" 64 | }, 65 | "slotListPage_quickChatButtonText": { 66 | "message": "빠른 대화" 67 | }, 68 | "slotListPage_resetApiKeyButtonText": { 69 | "message": "API키 초기화" 70 | }, 71 | "slotListPage_promptSlotsTitle": { 72 | "message": "프롬프트 슬롯" 73 | }, 74 | "slotListPage_resetApiKeyConfirmMessage": { 75 | "message": "정말 API 키를 초기화 하시겠어요?" 76 | }, 77 | "slotListItem_deleteButtonText": { 78 | "message": "삭제" 79 | }, 80 | "slotListItem_editButtonText": { 81 | "message": "수정" 82 | }, 83 | "slotDetail_promptSlotName": { 84 | "message": "프롬프트 슬롯 이름" 85 | }, 86 | "slotDetail_promptSlotName_placeholder": { 87 | "message": "ex. 영-한 번역용" 88 | }, 89 | "slotDetail_writePromptTitle": { 90 | "message": "ChatGPT 요청에 사용할 사전 프롬프트를\n작성해주세요.(최대 2000자)" 91 | }, 92 | "slotDetail_promptInputPlaceholder": { 93 | "message": "ex. 영어 문장 혹은 단어의 한국어 뜻을 알려줘." 94 | }, 95 | "slotDetail_temperatureTitle": { 96 | "message": "무작위성 (temperature: 0~2)" 97 | }, 98 | "slotDetail_isGpt4Turbo": { 99 | "message": "GPT4Turbo 사용 (기본값: gpt4o)" 100 | }, 101 | "slotDetail_saveButtonText": { 102 | "message": "저장" 103 | }, 104 | "slotDetail_cancelButtonText": { 105 | "message": "취소" 106 | }, 107 | "responseMessageBox_responseTitle": { 108 | "message": "답변" 109 | }, 110 | "responseMessageBox_copyButtonText_copy": { 111 | "message": "마지막 응답 복사" 112 | }, 113 | "responseMessageBox_copyButtonText_copied": { 114 | "message": "복사됨!" 115 | }, 116 | "responseMessageBox_sendButtonText": { 117 | "message": "전송" 118 | }, 119 | "responseMessageBox_stopButtonText": { 120 | "message": "답변 중단" 121 | }, 122 | "responseMessageBox_messageInputPlacepolder": { 123 | "message": "ex. 위 내용을 알기 쉽게 요약해줘" 124 | }, 125 | "errorMessageBox_errorTitle": { 126 | "message": "에러" 127 | }, 128 | "errorMessageBox_unknownError": { 129 | "message": "알 수 없는 에러" 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/pages/popup/pages/NoApiKeyPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { ChangeEventHandler, useState } from "react"; 2 | import { 3 | Button, 4 | HStack, 5 | Input, 6 | Link, 7 | OrderedList, 8 | Spinner, 9 | Text, 10 | VStack, 11 | } from "@chakra-ui/react"; 12 | import Footer from "@pages/popup/components/layout/Footer"; 13 | import StyledButton from "@pages/popup/components/StyledButton"; 14 | import { COLORS } from "@src/constant/style"; 15 | import { t } from "@src/chrome/i18n"; 16 | 17 | type NoApiKeyPageProps = { 18 | checkApiKey: (key: string) => void; 19 | apiKeyError?: Error; 20 | loading: boolean; 21 | }; 22 | export const NoApiKeyPage = ({ 23 | loading, 24 | checkApiKey, 25 | apiKeyError, 26 | }: NoApiKeyPageProps) => { 27 | const [apiKey, setApiKey] = useState(""); 28 | 29 | const handleChange: ChangeEventHandler = (event) => { 30 | setApiKey(event.target.value); 31 | }; 32 | 33 | const onClickSaveButton = () => { 34 | checkApiKey(apiKey); 35 | }; 36 | 37 | return ( 38 | <> 39 | 40 | {loading ? ( 41 | 42 | 43 | 44 | {t("noApiKeyPage_checkingApiKey")} 45 | 46 | 47 | ) : ( 48 | <> 49 | 50 | 59 | 60 | {t("noApiKeyPage_saveButtonText")} 61 | 62 | 63 | 64 | 71 | {t("noApiKeyPage_howToGetApiKey")} 72 | 73 | 81 |
  • 82 | {separateI18nAndAddLink( 83 | t("noApiKeyPage_howToGetApiKeyDetail1"), 84 | "https://platform.openai.com/signup" 85 | )} 86 |
  • 87 |
  • 88 | {separateI18nAndAddLink( 89 | t("noApiKeyPage_howToGetApiKeyDetail2"), 90 | "https://platform.openai.com/account/api-keys" 91 | )} 92 |
  • 93 |
  • {t("noApiKeyPage_howToGetApiKeyDetail3")}
  • 94 |
  • {t("noApiKeyPage_howToGetApiKeyDetail4")}
  • 95 |
    96 | 97 | )} 98 | {apiKeyError && ( 99 | 100 | 101 | {apiKeyError.name} 102 | 103 | 104 | {apiKeyError.message} 105 | 106 | 107 | )} 108 |
    109 |