├── .eslintignore ├── .eslintrc.cjs ├── .github └── workflows │ └── build.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .nvmrc ├── .prettierignore ├── .vscode └── extensions.json ├── README.md ├── docs └── images │ └── gif.gif ├── electron-builder.json5 ├── electron ├── electron-env.d.ts ├── main │ └── index.ts └── preload │ └── index.ts ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.cjs ├── prettier.config.js ├── src ├── App.tsx ├── components │ ├── Button.tsx │ ├── ChatSelection.tsx │ ├── IconButton.tsx │ ├── Toast.tsx │ └── chat │ │ ├── Chat.tsx │ │ ├── ChatInput.tsx │ │ └── ChatMessage.tsx ├── features │ ├── chat │ │ ├── index.ts │ │ ├── selectors.ts │ │ ├── thunks.ts │ │ └── types.ts │ ├── settings │ │ ├── index.ts │ │ ├── selectors.ts │ │ └── types.ts │ └── toasts │ │ ├── index.ts │ │ ├── thunks.ts │ │ └── types.ts ├── index.css ├── lib │ ├── api │ │ ├── gpt-encoder.d.ts │ │ ├── gpt-encoder.js │ │ └── openai.ts │ ├── constants │ │ └── openai.ts │ ├── hooks │ │ └── redux.ts │ ├── storage │ │ └── index.ts │ └── util │ │ └── index.ts ├── main.tsx ├── pages │ ├── Chat.tsx │ └── Settings.tsx ├── store.ts ├── styles │ ├── grid.css │ └── markdown.css ├── types │ └── index.d.ts └── vite-env.d.ts ├── tailwind.config.cjs ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | .eslintrc.cjs 2 | tailwind.config.cjs 3 | postcss.config.cjs 4 | vite.config.ts 5 | prettier.config.js 6 | dist/ -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | }, 6 | extends: [ 7 | "eslint:recommended", 8 | "plugin:react/recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "plugin:import/recommended", 11 | "plugin:import/errors", 12 | "plugin:import/warnings", 13 | "plugin:import/typescript", 14 | "plugin:react-hooks/recommended", 15 | ], 16 | settings: { 17 | "import/parsers": { 18 | "@typescript-eslint/parser": [".ts", ".tsx"], 19 | }, 20 | "import/resolver": { 21 | typescript: { 22 | alwaysTryTypes: true, 23 | project: "./tsconfig.json", 24 | }, 25 | }, 26 | }, 27 | overrides: [], 28 | parser: "@typescript-eslint/parser", 29 | parserOptions: { 30 | ecmaVersion: "latest", 31 | sourceType: "module", 32 | }, 33 | plugins: ["react", "@typescript-eslint", "import", "react-hooks"], 34 | rules: { 35 | "no-constant-condition": [ 36 | "error", 37 | { 38 | checkLoops: false, 39 | }, 40 | ], 41 | "linebreak-style": ["error", "unix"], 42 | quotes: ["error", "double"], 43 | semi: ["error", "always"], 44 | "react/react-in-jsx-scope": "off", 45 | "import/default": "off", 46 | "import/no-default-export": "error", 47 | }, 48 | }; 49 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build/release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | permissions: write-all 9 | 10 | env: 11 | SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_TOKEN }} 12 | 13 | jobs: 14 | release: 15 | runs-on: ${{ matrix.os }} 16 | 17 | strategy: 18 | matrix: 19 | os: [macos-latest, ubuntu-latest, windows-latest] 20 | 21 | steps: 22 | - name: Check out Git repository 23 | uses: actions/checkout@v3 24 | 25 | - name: Install Node.js, NPM and Yarn 26 | uses: actions/setup-node@v3 27 | with: 28 | node-version: 18 29 | - name: Install Snapcraft 30 | uses: samuelmeuli/action-snapcraft@v1 31 | # Only install Snapcraft on Ubuntu 32 | if: startsWith(matrix.os, 'ubuntu') 33 | - name: Build/release Electron app 34 | uses: samuelmeuli/action-electron-builder@v1 35 | with: 36 | github_token: ${{ secrets.github_token }} 37 | release: true 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | dist-ssr 14 | dist-electron/ 15 | release/ 16 | *.local 17 | chats 18 | # Editor directories and files 19 | .vscode/* 20 | !.vscode/extensions.json 21 | .idea 22 | .DS_Store 23 | *.suo 24 | *.ntvs* 25 | *.njsproj 26 | *.sln 27 | *.sw? 28 | 29 | .eslintcache 30 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.13.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | 2 | dist/ -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "mrmlnc.vscode-json5" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MyChatGPT 2 | 3 | This is a OSS standalone ChatGPT client. It is based on [ChatGPT](https://openai.com/blog/chatgpt). 4 | 5 | The client works almost just like the original ChatGPT websites but it includes some additional features. 6 | 7 | Screenshot of the App 8 | 9 | ## Hosted Version 10 | 11 | You can use the hosted version of this client [here](https://my-chat-gpt-lake.vercel.app/). 12 | 13 | ## Installation 14 | 15 | Go to the [realease page](https://github.com/Loeffeldude/my-chat-gpt/releases) and download and install the latest release for your platform. 16 | 17 | ## Setup 18 | 19 | 1. Head over to the settings and enter your OpenAI API key. You can get one [here](https://platform.openai.com/account/api-keys). 20 | 21 | 2. Choose a preamble. A basic default preamble is already set. You can change it to something more specific to your use case. 22 | 23 | 3. Start Chatting! 24 | 25 | --- 26 | 27 | ## Why? 28 | 29 | I wanted to use ChatGPT but I didn't want to pay a fixed price if I have days where I barely use it. So I created this client that almost works like the original. 30 | 31 | The 20 dollar price tag on ChatGPT is a bit steep for me. I don't want to pay for a service I don't use. I also don't want to pay for a service that I use only a few times a month. Even with relatively high usage this client is much cheaper. 32 | 33 | ### Pricing Comparison: 34 | 35 | A ChatGPT conversation can hold 4096 tokens (about 1000 words). The ChatGPT API charges `0.002$` per 1k tokens. 36 | 37 | Every message needs the entire conversation context. So if you have a long conversation with ChatGPT you pay about `0.008$` per message. ChatGPT needs to send 2500 (messages with full conversation context) a month to pay the same as the ChatGPT subscription. 38 | 39 | You can delete previous messages with this client if they are no longer needed for the context. So you can have a lot more messages for the same price. 40 | 41 | ## Features 42 | 43 | ### Pay as you go. 44 | 45 | You use your own API key and pay for the usage this turns out to be much cheaper than the original ChatGPT website with moderate usage. 46 | 47 | ### More customization. 48 | 49 | You can change the models behaior by changing the preamble. You can use this to create more customized chatbots. 50 | 51 | ### Edit the chat history. 52 | 53 | You can edit the chat history and the model will continue the conversation from the edited point. 54 | 55 | ### Mark messages as important. 56 | 57 | You can mark messages as important. Important messages will never be dropped from the conversation. (The model still has a token limit all not important messages will be dropped if the conversation gets too long.) 58 | 59 | ### No annoying login flow. 60 | 61 | You can use the client without having to login to OpenAI. 62 | 63 | --- 64 | 65 | ## Development 66 | 67 | This is just a React app with an Electron wrapper. 68 | 69 | Building is done with Vite. 70 | 71 | Fork this repo and start hacking. Feel free to open a PR if you want to contribute. 72 | 73 | ### Setup 74 | 75 | ```bash 76 | npm install 77 | ``` 78 | 79 | ### Run 80 | 81 | ```bash 82 | npm run dev 83 | ``` 84 | 85 | ### Build 86 | 87 | Build the electron app: 88 | 89 | ```bash 90 | npm run build:electron 91 | ``` 92 | 93 | Just build React app: 94 | 95 | ```bash 96 | npm run build:client 97 | ``` 98 | -------------------------------------------------------------------------------- /docs/images/gif.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Loeffeldude/my-chat-gpt/fff727f14c00737c69788699acef32878806cfb6/docs/images/gif.gif -------------------------------------------------------------------------------- /electron-builder.json5: -------------------------------------------------------------------------------- 1 | /** 2 | * @see https://www.electron.build/configuration/configuration 3 | */ 4 | { 5 | appId: "com.loeffeldude.mychatgpt", 6 | productName: "MyChatGPT", 7 | asar: true, 8 | directories: { 9 | output: "release/${version}", 10 | }, 11 | files: ["dist-electron", "dist"], 12 | mac: { 13 | publish: ["github"], 14 | artifactName: "${productName}_${version}.${ext}", 15 | target: ["dmg"], 16 | }, 17 | win: { 18 | publish: ["github"], 19 | target: [ 20 | { 21 | target: "nsis", 22 | arch: ["x64"], 23 | }, 24 | ], 25 | artifactName: "${productName}_${version}.${ext}", 26 | }, 27 | publish: ["github"], 28 | nsis: { 29 | publish: ["github"], 30 | oneClick: false, 31 | perMachine: false, 32 | allowToChangeInstallationDirectory: true, 33 | deleteAppDataOnUninstall: false, 34 | }, 35 | } 36 | -------------------------------------------------------------------------------- /electron/electron-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare namespace NodeJS { 4 | interface ProcessEnv { 5 | VSCODE_DEBUG?: "true"; 6 | DIST_ELECTRON: string; 7 | DIST: string; 8 | /** /dist/ or /public/ */ 9 | PUBLIC: string; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /electron/main/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | app, 3 | BrowserWindow, 4 | shell, 5 | ipcMain, 6 | safeStorage, 7 | dialog, 8 | } from "electron"; 9 | import { release } from "node:os"; 10 | import { join } from "node:path"; 11 | import Store from "electron-store"; 12 | import fs from "fs/promises"; 13 | import type { Chat } from "../../src/features/chat/types"; 14 | // The built directory structure 15 | // 16 | process.env.DIST_ELECTRON = join(__dirname, "../"); 17 | process.env.DIST = join(process.env.DIST_ELECTRON, "../dist"); 18 | process.env.PUBLIC = process.env.VITE_DEV_SERVER_URL 19 | ? join(process.env.DIST_ELECTRON, "../public") 20 | : process.env.DIST; 21 | 22 | // Disable GPU Acceleration for Windows 7 23 | if (release().startsWith("6.1")) app.disableHardwareAcceleration(); 24 | 25 | // Set application name for Windows 10+ notifications 26 | if (process.platform === "win32") app.setAppUserModelId(app.getName()); 27 | 28 | if (!app.requestSingleInstanceLock()) { 29 | app.quit(); 30 | process.exit(0); 31 | } 32 | 33 | let win: BrowserWindow | null = null; 34 | // Here, you can also use other preload 35 | const preload = join(__dirname, "../preload/index.js"); 36 | const url = process.env.VITE_DEV_SERVER_URL; 37 | const indexHtml = join(process.env.DIST, "index.html"); 38 | 39 | const store = new Store(); 40 | 41 | async function createWindow() { 42 | win = new BrowserWindow({ 43 | title: "Main window", 44 | icon: join(process.env.PUBLIC!, "favicon.ico"), 45 | webPreferences: { 46 | preload, 47 | nodeIntegration: true, 48 | contextIsolation: true, 49 | }, 50 | }); 51 | win.removeMenu(); 52 | if (process.env.VITE_DEV_SERVER_URL) { 53 | win.loadURL(url!); 54 | win.webContents.openDevTools(); 55 | } else { 56 | win.loadFile(indexHtml); 57 | } 58 | 59 | // Make all links open with the browser, not with the application 60 | win.webContents.setWindowOpenHandler(({ url }) => { 61 | if (url.startsWith("https:")) shell.openExternal(url); 62 | return { action: "deny" }; 63 | }); 64 | } 65 | 66 | app.whenReady().then(createWindow); 67 | 68 | app.on("window-all-closed", () => { 69 | win = null; 70 | if (process.platform !== "darwin") app.quit(); 71 | }); 72 | 73 | app.on("activate", () => { 74 | const allWindows = BrowserWindow.getAllWindows(); 75 | if (allWindows.length) { 76 | allWindows[0].focus(); 77 | } else { 78 | createWindow(); 79 | } 80 | }); 81 | 82 | ipcMain.handle( 83 | "chat:save", 84 | async (event: Electron.IpcMainInvokeEvent, id: string, chat: Chat) => { 85 | const dir = app.getPath("userData"); 86 | const path = join(dir, "chats", `${id}.json`); 87 | 88 | // Create the directory if it doesn't exist 89 | 90 | await fs.mkdir(join(dir, "chats"), { recursive: true }); 91 | await fs.writeFile(path, JSON.stringify(chat)); 92 | } 93 | ); 94 | 95 | ipcMain.handle("chat:getAll", async () => { 96 | const appDir = app.getPath("userData"); 97 | const path = join(appDir, "chats"); 98 | await fs.mkdir(path, { recursive: true }); 99 | const chatDir = await fs.readdir(path); 100 | 101 | const chats: Promise[] = chatDir.map(async (file) => { 102 | const stat = await fs.stat(join(path, file)); 103 | 104 | if (!file.endsWith(".json") || !stat.isFile()) { 105 | return null; 106 | } 107 | 108 | const fileContent = fs.readFile(join(path, file), "utf8"); 109 | const chat = JSON.parse(await fileContent); 110 | 111 | return chat; 112 | }); 113 | 114 | return (await Promise.allSettled(chats)) 115 | .map((result) => (result.status === "fulfilled" ? result.value : null)) 116 | .filter((chat) => chat !== null); 117 | }); 118 | 119 | ipcMain.handle("chat:delete", async (_, id: string) => { 120 | const dir = app.getPath("userData"); 121 | const path = join(dir, "chats", `${id}.json`); 122 | 123 | const fileExists = await fs 124 | .stat(path) 125 | .then((stat) => stat.isFile()) 126 | .catch(() => false); 127 | 128 | if (!fileExists) return; 129 | 130 | await fs.rm(path, { force: true }); 131 | }); 132 | 133 | ipcMain.handle("apikey:get", () => { 134 | const encrypedKey: string | null = store.get("apiKey", null) as string | null; 135 | 136 | if (!encrypedKey) return null; 137 | 138 | if (!safeStorage.isEncryptionAvailable()) { 139 | return encrypedKey; 140 | } 141 | 142 | return safeStorage.decryptString(Buffer.from(encrypedKey, "utf-8")); 143 | }); 144 | 145 | ipcMain.handle("apikey:set", (_, key: string) => { 146 | const encryptionAvailable = safeStorage.isEncryptionAvailable(); 147 | 148 | const encrypedKey = encryptionAvailable 149 | ? safeStorage.encryptString(key) 150 | : key; 151 | 152 | store.set("apiKey", encrypedKey); 153 | }); 154 | 155 | ipcMain.handle("messagebox:confirm", (_, message: string) => { 156 | return new Promise((resolve) => { 157 | const options = { 158 | type: "question", 159 | buttons: ["Yes", "No"], 160 | defaultId: 1, 161 | title: "Confirm", 162 | message, 163 | }; 164 | 165 | if (!win) return resolve(false); 166 | 167 | dialog.showMessageBox(win, options).then((result) => { 168 | resolve(result.response === 0); 169 | }); 170 | }); 171 | }); 172 | -------------------------------------------------------------------------------- /electron/preload/index.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge, ipcRenderer } from "electron"; 2 | import type { Chat } from "../../src/features/chat/types"; 3 | 4 | const electronAPI = { 5 | saveChat: (id: string, chat: Chat) => 6 | ipcRenderer.invoke("chat:save", id, chat), 7 | getChats: () => ipcRenderer.invoke("chat:getAll") as Promise, 8 | deleteChat: (id: string) => ipcRenderer.invoke("chat:delete", id), 9 | getApiKey: () => ipcRenderer.invoke("apikey:get") as Promise, 10 | setApiKey: (key: string) => 11 | ipcRenderer.invoke("apikey:set", key) as Promise, 12 | confirm: (message: string) => 13 | ipcRenderer.invoke("messagebox:confirm", message) as Promise, 14 | }; 15 | 16 | contextBridge.exposeInMainWorld("electronAPI", electronAPI); 17 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | MyChatGPT 8 | 9 | 10 | 11 |
12 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-chat-gpt", 3 | "private": true, 4 | "version": "0.3.0", 5 | "main": "dist-electron/main/index.js", 6 | "author": { 7 | "name": "Nico Krätschmer", 8 | "email": "kraetschmerni@gmail.com" 9 | }, 10 | "description": "An opensource ChatGPT client for Windows, Mac and Linux", 11 | "scripts": { 12 | "dev": "vite", 13 | "build:client": "tsc && vite build", 14 | "build:electron": "npm run build:client && electron-builder", 15 | "build": "npm run build:client", 16 | "preview": "vite preview", 17 | "prepare": "husky install", 18 | "release": "npm run build:client && electron-builder --publish always" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "git@github.com:Loeffeldude/self-chat-gpt.git" 23 | }, 24 | "debug": { 25 | "env": { 26 | "VITE_DEV_SERVER_URL": "http://127.0.0.1:7777/" 27 | } 28 | }, 29 | "lint-staged": { 30 | "**/*": "prettier --write --ignore-unknown", 31 | "*.js": "eslint --cache --fix" 32 | }, 33 | "dependencies": { 34 | "@headlessui/react": "^1.7.13", 35 | "@reduxjs/toolkit": "^1.9.3", 36 | "@uiw/react-md-editor": "^3.20.5", 37 | "axios": "^1.3.4", 38 | "classnames": "^2.3.2", 39 | "electron-store": "^8.1.0", 40 | "gpt-3-encoder": "^1.1.4", 41 | "markdown-it": "^13.0.1", 42 | "openai": "^3.2.1", 43 | "react": "^18.2.0", 44 | "react-dom": "^18.2.0", 45 | "react-icons": "^4.8.0", 46 | "react-redux": "^8.0.5", 47 | "react-router-dom": "^6.8.2", 48 | "rehype-sanitize": "^5.0.1", 49 | "uuid": "^9.0.0", 50 | "zod": "^3.21.2" 51 | }, 52 | "devDependencies": { 53 | "@types/react": "^18.0.27", 54 | "@types/react-dom": "^18.0.10", 55 | "@types/uuid": "^9.0.1", 56 | "@typescript-eslint/eslint-plugin": "^5.54.0", 57 | "@typescript-eslint/parser": "^5.54.0", 58 | "@vitejs/plugin-react": "^3.1.0", 59 | "autoprefixer": "^10.4.13", 60 | "electron": "^23.1.2", 61 | "electron-builder": "^24.2.1", 62 | "eslint": "^8.35.0", 63 | "eslint-import-resolver-typescript": "^3.5.3", 64 | "eslint-plugin-import": "^2.27.5", 65 | "eslint-plugin-react": "^7.32.2", 66 | "eslint-plugin-react-hooks": "^4.6.0", 67 | "husky": "^8.0.3", 68 | "install": "^0.13.0", 69 | "lint-staged": "^13.1.2", 70 | "postcss": "^8.4.21", 71 | "postcss-import": "^15.1.0", 72 | "prettier": "^2.8.4", 73 | "prettier-plugin-tailwindcss": "^0.2.4", 74 | "tailwindcss": "^3.2.7", 75 | "typescript": "^4.9.3", 76 | "vite": "^4.1.5", 77 | "vite-electron-plugin": "^0.8.2", 78 | "vite-plugin-electron": "^0.11.1", 79 | "vite-plugin-electron-renderer": "^0.12.1", 80 | "vite-plugin-top-level-await": "^1.3.0", 81 | "vite-tsconfig-paths": "^4.0.5" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "postcss-import": {}, 4 | "tailwindcss/nesting": {}, 5 | tailwindcss: {}, 6 | autoprefixer: {}, 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [require("prettier-plugin-tailwindcss")], 3 | }; 4 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Link, Outlet, useLocation, useNavigate } from "react-router-dom"; 2 | import { ChatSelection } from "./components/ChatSelection"; 3 | import { useAppDispatch, useAppSelector } from "./lib/hooks/redux"; 4 | import { useEffect, useState } from "react"; 5 | import { FiSettings, FiX, FiMenu } from "react-icons/fi"; 6 | import classNames from "classnames"; 7 | import { IconButton } from "./components/IconButton"; 8 | import { createChat, switchChat } from "./features/chat"; 9 | 10 | function SideMenu() { 11 | const [isOpen, setIsOpen] = useState(false); 12 | 13 | const classes = classNames( 14 | { 15 | "translate-x-0": isOpen, 16 | "-translate-x-full ": !isOpen, 17 | }, 18 | "fixed left-0 top-0 bottom-0 w-72 transition-transform z-20 border-r-2 border-mirage-700 bg-mirage-800" 19 | ); 20 | 21 | return ( 22 | <> 23 |
24 | setIsOpen(!isOpen)} 26 | className=" p-2" 27 | aria-label={`${isOpen ? "Close" : "Open"} side menu`} 28 | > 29 | {isOpen ? : } 30 | 31 |
32 | 44 | 45 | ); 46 | } 47 | 48 | function App() { 49 | const state = useAppSelector((state) => state); 50 | const location = useLocation(); 51 | const navigate = useNavigate(); 52 | const dispatch = useAppDispatch(); 53 | 54 | useEffect(() => { 55 | if (!state.chats.activeId) { 56 | const firstChat = Object.values(state.chats.chats)[0]; 57 | 58 | if (!firstChat) { 59 | dispatch(createChat({ preamble: state.settings.preamble })); 60 | } else { 61 | dispatch(switchChat({ id: firstChat.id })); 62 | } 63 | return; 64 | } 65 | 66 | if (!location.pathname.includes(state.chats.activeId)) { 67 | navigate(`/${state.chats.activeId}`, { replace: true }); 68 | } 69 | }, [ 70 | dispatch, 71 | location.pathname, 72 | navigate, 73 | state.chats.activeId, 74 | state.chats.chats, 75 | state.settings.preamble, 76 | ]); 77 | 78 | return ( 79 |
80 | 81 |
82 | 83 |
84 |
85 | ); 86 | } 87 | 88 | export { App }; 89 | -------------------------------------------------------------------------------- /src/components/Button.tsx: -------------------------------------------------------------------------------- 1 | export type ButtonProps = { 2 | children: React.ReactNode; 3 | className?: string; 4 | } & React.ButtonHTMLAttributes; 5 | 6 | export function Button({ children, className, ...rest }: ButtonProps) { 7 | return ( 8 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/components/ChatSelection.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | clearChats, 3 | createChat, 4 | deleteChat, 5 | editSummary, 6 | switchChat, 7 | } from "@src/features/chat"; 8 | import { useAppDispatch, useAppSelector } from "@src/lib/hooks/redux"; 9 | import classNames from "classnames"; 10 | import { IconButton } from "./IconButton"; 11 | import { FiCheck, FiEdit, FiTrash } from "react-icons/fi"; 12 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; 13 | import { localConfirm } from "@src/lib/util"; 14 | 15 | const SUMMARY_TYPING_SPEED = 50; 16 | interface ChatSummaryTextProps { 17 | summary: string; 18 | } 19 | 20 | const ChatSummaryText = memo(({ summary }: ChatSummaryTextProps) => { 21 | const [displaySummary, setDisplaySummary] = useState(summary); 22 | const prevSummary = useRef(summary); 23 | // Type the new summary into the text 24 | useEffect(() => { 25 | let interval: NodeJS.Timer | null = null; 26 | if (prevSummary.current === summary) { 27 | return; 28 | } 29 | prevSummary.current = summary; 30 | setDisplaySummary(""); 31 | const typeText = () => { 32 | setDisplaySummary((prev) => { 33 | if (prev.length === summary.length) { 34 | interval && clearInterval(interval); 35 | return prev; 36 | } 37 | return summary.slice(0, prev.length + 1); 38 | }); 39 | }; 40 | 41 | interval = setInterval(typeText, SUMMARY_TYPING_SPEED); 42 | 43 | return () => { 44 | interval && clearInterval(interval); 45 | }; 46 | }, [summary]); 47 | 48 | return ( 49 |

50 | {displaySummary} 51 |

52 | ); 53 | }); 54 | ChatSummaryText.displayName = "ChatSummaryText"; 55 | 56 | interface ChatSelectionButtonProps { 57 | id: string; 58 | active: boolean; 59 | summary: string; 60 | onClick?: (id: string) => void; 61 | onDelete?: (id: string) => void; 62 | onEdit?: (id: string, summary: string) => void; 63 | } 64 | 65 | export function ChatSelectionButton({ 66 | active, 67 | summary, 68 | id, 69 | onClick, 70 | onDelete, 71 | onEdit, 72 | }: ChatSelectionButtonProps) { 73 | const [isEditing, setIsEditing] = useState(false); 74 | const [editedSummary, setEditedSummary] = useState(summary); 75 | 76 | const inputRef = useRef(null); 77 | 78 | const classes = classNames( 79 | { 80 | "bg-mirage-700": active, 81 | "transition-colors focus-within:bg-mirage-600 hover:bg-mirage-600 active:bg-mirage-700": 82 | !active, 83 | }, 84 | " p-2 rounded-lg my-1 transition-colors relative" 85 | ); 86 | 87 | const handleClick = () => { 88 | onClick && onClick(id); 89 | }; 90 | 91 | const handleDelete = () => { 92 | onDelete && onDelete(id); 93 | }; 94 | 95 | const handleEdit = () => { 96 | setIsEditing(true); 97 | }; 98 | 99 | const handleSave = useCallback(() => { 100 | setIsEditing(false); 101 | onEdit && onEdit(id, editedSummary); 102 | }, [editedSummary, id, onEdit]); 103 | 104 | useEffect(() => { 105 | setEditedSummary(summary); 106 | }, [summary]); 107 | // When the user clicks the edit button, focus the input 108 | // And when the user presses enter, save the summary 109 | useEffect(() => { 110 | if (isEditing) { 111 | inputRef.current?.focus(); 112 | 113 | inputRef.current?.addEventListener("keydown", (e) => { 114 | if (e.key === "Enter") { 115 | handleSave(); 116 | } 117 | }); 118 | } 119 | }, [handleSave, isEditing]); 120 | 121 | const chatSummaryText = useMemo(() => { 122 | return ; 123 | }, [summary]); 124 | 125 | return ( 126 |
127 | {active ? ( 128 | isEditing ? ( 129 | { 134 | setEditedSummary(e.target.value); 135 | }} 136 | /> 137 | ) : ( 138 | chatSummaryText 139 | ) 140 | ) : ( 141 | 144 | )} 145 | {active && ( 146 |
147 | {!isEditing && ( 148 | 149 | 150 | 151 | )} 152 | 156 | {isEditing ? : } 157 | 158 |
159 | )} 160 |
161 | ); 162 | } 163 | 164 | export function ChatSelection() { 165 | const chats = useAppSelector((state) => state.chats.chats); 166 | const preamble = useAppSelector((state) => state.settings.preamble); 167 | const activeChatId = useAppSelector((state) => state.chats.activeId); 168 | 169 | const dispatch = useAppDispatch(); 170 | 171 | const handleCreateChat = () => { 172 | dispatch(createChat({ preamble })); 173 | }; 174 | const handleSwitchChat = (id: string) => { 175 | dispatch(switchChat({ id })); 176 | }; 177 | 178 | const handleEditChat = (id: string, summary: string) => { 179 | dispatch(editSummary({ id, summary })); 180 | }; 181 | 182 | const handleDeleteChat = (id: string) => { 183 | dispatch(deleteChat({ id })); 184 | }; 185 | const handleClearChats = async () => { 186 | (await localConfirm("Are you sure you want to delete all chats?")) && 187 | dispatch(clearChats()); 188 | }; 189 | 190 | return ( 191 |
192 |
193 | 199 | 205 |
206 |
207 | {Object.entries(chats).map(([id, chat]) => { 208 | return ( 209 | 218 | ); 219 | })} 220 |
221 |
222 | ); 223 | } 224 | -------------------------------------------------------------------------------- /src/components/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import { DetailedHTMLProps } from "react"; 2 | 3 | interface IconButtonProps 4 | extends DetailedHTMLProps< 5 | React.ButtonHTMLAttributes, 6 | HTMLButtonElement 7 | > { 8 | children: React.ReactNode; 9 | className?: string; 10 | } 11 | 12 | export function IconButton({ children, className, ...rest }: IconButtonProps) { 13 | return ( 14 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/components/Toast.tsx: -------------------------------------------------------------------------------- 1 | import { Transition } from "@headlessui/react"; 2 | import { removeToast } from "@src/features/toasts/thunks"; 3 | import { Toast, ToastType } from "@src/features/toasts/types"; 4 | import { useAppDispatch, useAppSelector } from "@src/lib/hooks/redux"; 5 | import classNames from "classnames"; 6 | import { useEffect, useMemo, useRef, useState } from "react"; 7 | import { createPortal } from "react-dom"; 8 | import { FiCheck, FiX } from "react-icons/fi"; 9 | 10 | export type ToastProps = { 11 | toast: Toast; 12 | }; 13 | const getToastContainer = () => document.getElementById("toasts")!; 14 | 15 | interface ToastCloseIconProps { 16 | type: ToastType; 17 | } 18 | 19 | const ToastCloseIcon = ({ type }: ToastCloseIconProps) => { 20 | switch (type) { 21 | case "success": 22 | return ; 23 | default: 24 | return ; 25 | } 26 | }; 27 | 28 | function ToastComponent({ 29 | toast: { id, message, type, _showing }, 30 | }: ToastProps) { 31 | const dispatch = useAppDispatch(); 32 | 33 | const toastClasses = classNames( 34 | { 35 | "bg-green-700": type === "success", 36 | "bg-red-700": type === "error", 37 | "bg-yellow-800": type === "warning", 38 | "bg-blue-700": type === "info", 39 | }, 40 | "rounded-lg my-1 transition-colors flex flex-row text-sm items-center" 41 | ); 42 | 43 | const closeClasses = classNames( 44 | { 45 | "bg-green-700 hover:bg-green-800 active:bg-green-900": 46 | type === "success", 47 | "bg-red-700 hover:bg-red-800 active:bg-red-900": type === "error", 48 | "bg-yellow-800 hover:bg-yellow-900 active:bg-yellow-900": 49 | type === "warning", 50 | "bg-blue-700 hover:bg-blue-800 active:bg-blue-900": type === "info", 51 | }, 52 | "ml-1 flex flex-row items-center justify-center border-l border-l-white border-opacity-75 p-2 rounded-r-lg" 53 | ); 54 | 55 | const close = () => { 56 | dispatch(removeToast(id)); 57 | }; 58 | 59 | return ( 60 | 69 |
70 |

{message}

71 | 78 |
79 |
80 | ); 81 | } 82 | 83 | export function ToastContainer() { 84 | const toasts = useAppSelector((state) => state.toasts.toasts); 85 | const containerRef = useRef(null); 86 | 87 | return createPortal( 88 |
92 | {Object.values(toasts).map((toast) => ( 93 | 94 | ))} 95 |
, 96 | getToastContainer() 97 | ); 98 | } 99 | -------------------------------------------------------------------------------- /src/components/chat/Chat.tsx: -------------------------------------------------------------------------------- 1 | import { useAppDispatch, useAppSelector } from "@src/lib/hooks/redux"; 2 | 3 | import { ChatCompletionResponseMessageRoleEnum } from "openai"; 4 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 5 | import { ChatInput, ChatInputProps } from "./ChatInput"; 6 | import { ChatMessage } from "./ChatMessage"; 7 | import { 8 | updateDraft, 9 | deleteMessage, 10 | editMessage, 11 | abortCompletion, 12 | setImportant, 13 | } from "@src/features/chat"; 14 | import { pushHistory, streamCompletion } from "@src/features/chat/thunks"; 15 | import { Chat } from "@src/features/chat/types"; 16 | import { Button } from "../Button"; 17 | 18 | export type ChatViewProps = { 19 | chat: Chat; 20 | }; 21 | 22 | export function ChatView({ chat }: ChatViewProps) { 23 | const dispatch = useAppDispatch(); 24 | 25 | const [sendAsRole, setSendAsRole] = 26 | useState("user"); 27 | 28 | const scrollRef = useRef(null); 29 | const isScrolledToBottomRef = useRef(true); 30 | 31 | const botTyping = useAppSelector( 32 | (state) => state.chats.chats[chat.id].botTyping 33 | ); 34 | const botTypingMessage = useAppSelector( 35 | (state) => state.chats.chats[chat.id].botTypingMessage 36 | ); 37 | const showPreamble = useAppSelector((state) => state.settings.showPreamble); 38 | 39 | const isHistoryEmpty = 40 | Object.values(chat.history).filter((message) => !message.isPreamble) 41 | .length === 0; 42 | 43 | const isLastMessageBot = useMemo(() => { 44 | const lastMessage = Object.values(chat.history).pop(); 45 | if (!lastMessage) return false; 46 | return lastMessage.role === "assistant"; 47 | }, [chat.history]); 48 | 49 | const handleChatInput = useCallback>( 50 | ({ draft, role }) => { 51 | if (!chat) return; 52 | dispatch(updateDraft({ id: chat.id, draft: draft })); 53 | 54 | setSendAsRole(role); 55 | }, 56 | [chat, dispatch] 57 | ); 58 | const handleChatSubmit = useCallback>( 59 | ({ draft, role }) => { 60 | if (!chat) return; 61 | 62 | dispatch(pushHistory({ content: draft, role: role })); 63 | dispatch(updateDraft({ id: chat.id, draft: "" })); 64 | }, 65 | [chat, dispatch] 66 | ); 67 | const handleChatAbort = useCallback(() => { 68 | if (!chat) return; 69 | 70 | dispatch(abortCompletion({ id: chat.id })); 71 | }, [chat, dispatch]); 72 | 73 | const handleGenerateResponse = useCallback(() => { 74 | if (!chat) return; 75 | 76 | if (isLastMessageBot) { 77 | // Remove the last message by the bot and generate a new one 78 | const lastMessage = Object.values(chat.history).pop(); 79 | if (lastMessage) { 80 | dispatch(deleteMessage({ chatId: chat.id, messageId: lastMessage.id })); 81 | } 82 | dispatch(streamCompletion(chat.id)); 83 | return; 84 | } 85 | 86 | dispatch(streamCompletion(chat.id)); 87 | }, [chat, dispatch, isLastMessageBot]); 88 | 89 | useEffect(() => { 90 | const scrollElement = scrollRef.current; 91 | 92 | if (!scrollElement) return; 93 | 94 | const handleScroll = () => { 95 | const bottomThreshold = 10; 96 | isScrolledToBottomRef.current = 97 | scrollElement.scrollHeight - 98 | scrollElement.scrollTop - 99 | bottomThreshold <= 100 | scrollElement.clientHeight; 101 | }; 102 | 103 | scrollElement.addEventListener("scroll", handleScroll); 104 | 105 | return () => scrollElement.removeEventListener("scroll", handleScroll); 106 | }); 107 | useEffect(() => { 108 | const scrollElement = scrollRef.current; 109 | if (!scrollElement) return; 110 | 111 | if (isScrolledToBottomRef.current) { 112 | scrollElement.scrollTop = scrollElement.scrollHeight; 113 | } 114 | }); 115 | 116 | const shouldRenderTmpMessage = 117 | botTyping && botTypingMessage?.role && botTypingMessage?.content; 118 | 119 | const historyMessages = useMemo(() => { 120 | if (!chat.id) return; 121 | 122 | const handleDelete = (id: string) => { 123 | dispatch(deleteMessage({ chatId: chat.id, messageId: id })); 124 | }; 125 | const handleEdit = (content: string, id: string) => { 126 | dispatch(editMessage({ content, chatId: chat.id, messageId: id })); 127 | }; 128 | return Object.values(chat.history).map((message, i) => { 129 | if (message.isPreamble && !showPreamble) { 130 | return null; 131 | } 132 | return ( 133 | { 136 | dispatch( 137 | setImportant({ 138 | chatId: chat.id, 139 | messageId: message.id, 140 | important: !message.isImportant, 141 | }) 142 | ); 143 | }} 144 | onDelete={() => { 145 | handleDelete(message.id); 146 | }} 147 | onEdit={(content) => { 148 | handleEdit(content, message.id); 149 | }} 150 | key={i} 151 | content={message.content} 152 | role={message.role} 153 | /> 154 | ); 155 | }); 156 | }, [chat.history, chat.id, dispatch, showPreamble]); 157 | 158 | return ( 159 |
160 |
161 |
162 | {historyMessages} 163 | {shouldRenderTmpMessage && ( 164 | 168 | )} 169 |
170 | {shouldRenderTmpMessage && ( 171 |
172 | 173 |
174 | )} 175 | {!botTyping && !isHistoryEmpty && ( 176 |
177 | 180 |
181 | )} 182 |
183 | 190 |
191 |
192 |
193 | ); 194 | } 195 | -------------------------------------------------------------------------------- /src/components/chat/ChatInput.tsx: -------------------------------------------------------------------------------- 1 | import { capitilize } from "@src/lib/util"; 2 | import { ChatCompletionResponseMessageRoleEnum } from "openai"; 3 | import { useCallback, ChangeEvent, useState, FormEvent } from "react"; 4 | import { IconButton } from "../IconButton"; 5 | 6 | import { FiSend } from "react-icons/fi"; 7 | import { useAppSelector } from "@src/lib/hooks/redux"; 8 | export type ChatInputValue = { 9 | role: ChatCompletionResponseMessageRoleEnum; 10 | draft: string; 11 | }; 12 | export type ChatInputProps = { 13 | onChange?: (values: ChatInputValue) => void; 14 | onSubmit?: (values: ChatInputValue) => void; 15 | disabled?: boolean; 16 | draft?: string; 17 | sendAsRole: ChatCompletionResponseMessageRoleEnum; 18 | }; 19 | 20 | export function ChatInput({ 21 | onChange, 22 | draft, 23 | sendAsRole, 24 | onSubmit, 25 | disabled, 26 | }: ChatInputProps) { 27 | const sendWithShiftEnter = useAppSelector( 28 | (state) => state.settings.shiftSend 29 | ); 30 | const roleOptions = ["user", "system", "assistant"]; 31 | 32 | const handleDraftChange = useCallback( 33 | (e: ChangeEvent) => { 34 | onChange && onChange({ draft: e.target.value, role: sendAsRole }); 35 | }, 36 | [onChange, sendAsRole] 37 | ); 38 | 39 | const handleSendAsRoleChange = useCallback( 40 | (e: ChangeEvent) => { 41 | onChange && 42 | onChange({ 43 | draft: draft ?? "", 44 | role: e.target.value as ChatCompletionResponseMessageRoleEnum, 45 | }); 46 | }, 47 | [onChange, draft] 48 | ); 49 | 50 | const handleSubmit = useCallback( 51 | (e: FormEvent) => { 52 | e.preventDefault(); 53 | if (disabled) return; 54 | if (!draft) return; 55 | 56 | onSubmit && onSubmit({ draft, role: sendAsRole }); 57 | }, 58 | [disabled, draft, onSubmit, sendAsRole] 59 | ); 60 | 61 | const handleResize = (event: FormEvent) => { 62 | const el = event.currentTarget; 63 | 64 | el.style.height = "auto"; 65 | el.style.height = `${el.scrollHeight}px`; 66 | }; 67 | 68 | const handleKeyDown = (e: React.KeyboardEvent) => { 69 | if (e.key !== "Enter") { 70 | return; 71 | } 72 | 73 | if (e.shiftKey && !sendWithShiftEnter) { 74 | e.preventDefault(); 75 | const textarea = e.target as HTMLTextAreaElement; 76 | const start = textarea.selectionStart; 77 | const end = textarea.selectionEnd; 78 | const value = textarea.value; 79 | const before = value.substring(0, start); 80 | const after = value.substring(end, value.length); 81 | const newValue = before + "\n" + after; 82 | textarea.selectionStart = start + 1; 83 | textarea.selectionEnd = start + 1; 84 | 85 | onChange && onChange({ draft: newValue, role: sendAsRole }); 86 | handleResize(e as any); 87 | return; 88 | } 89 | 90 | e.preventDefault(); 91 | handleSubmit(e as any); 92 | }; 93 | 94 | return ( 95 |
99 |
100 | 113 |
114 |
115 | 131 |
132 | 137 | {disabled ? ( 138 |
139 |
140 |
141 |
142 |
143 | ) : ( 144 | 145 | )} 146 |
147 |
148 |
149 |
150 | ); 151 | } 152 | -------------------------------------------------------------------------------- /src/components/chat/ChatMessage.tsx: -------------------------------------------------------------------------------- 1 | import MDEditor from "@uiw/react-md-editor"; 2 | import { ChatCompletionResponseMessageRoleEnum } from "openai"; 3 | import { useState, useEffect } from "react"; 4 | import { IconButton } from "../IconButton"; 5 | import { 6 | FiCopy, 7 | FiEdit, 8 | FiCheck, 9 | FiTrash, 10 | FiSave, 11 | FiFlag, 12 | } from "react-icons/fi"; 13 | import { capitilize } from "@src/lib/util"; 14 | import classNames from "classnames"; 15 | 16 | export type ChatMessageProps = { 17 | content: string; 18 | role: ChatCompletionResponseMessageRoleEnum; 19 | isImportant?: boolean; 20 | onDelete?: () => void; 21 | onEdit?: (content: string) => void; 22 | onToggleImportant?: () => void; 23 | }; 24 | 25 | export function ChatMessage({ 26 | content, 27 | role, 28 | isImportant, 29 | onDelete, 30 | onEdit, 31 | onToggleImportant, 32 | }: ChatMessageProps) { 33 | const [isCopied, setIsCopied] = useState(false); 34 | const [isEditing, setIsEditing] = useState(false); 35 | const [editedContent, setEditedContent] = useState(content); 36 | 37 | const nameClasses = classNames( 38 | { 39 | "text-white": role === "user", 40 | "text-mirage-300 italic": role === "system", 41 | "text-green-500": role === "assistant", 42 | }, 43 | "text-sm" 44 | ); 45 | 46 | const nameDisplay = role === "user" ? "You" : capitilize(role); 47 | 48 | useEffect(() => { 49 | setEditedContent(content); 50 | }, [content]); 51 | 52 | return ( 53 |
54 |
55 | {isEditing ? ( 56 | { 59 | setIsEditing(false); 60 | onEdit && onEdit(editedContent); 61 | }} 62 | aria-label="Save Edited Message" 63 | > 64 | 65 | 66 | ) : ( 67 | { 70 | setIsEditing(true); 71 | }} 72 | aria-label="Edit Message" 73 | > 74 | 75 | 76 | )} 77 | { 85 | onToggleImportant && onToggleImportant(); 86 | }} 87 | > 88 | 89 | 90 | { 93 | onDelete && onDelete(); 94 | }} 95 | aria-label="Delete Message" 96 | > 97 | 98 | 99 | { 102 | setIsCopied(true); 103 | navigator.clipboard.writeText(content); 104 | }} 105 | onMouseLeave={() => { 106 | setIsCopied(false); 107 | }} 108 | onBlur={() => { 109 | setIsCopied(false); 110 | }} 111 | aria-label="Copy Message to Clipboard" 112 | > 113 | {isCopied ? : } 114 | 115 |
116 |
{nameDisplay}:
117 | {isEditing ? ( 118 | 174 | 175 | 176 | 177 |
178 | 179 | 186 |
187 |
188 | 189 |
190 | 191 | 198 |
199 |
200 | 201 | 202 |
203 | ); 204 | } 205 | -------------------------------------------------------------------------------- /src/store.ts: -------------------------------------------------------------------------------- 1 | import { Middleware, configureStore } from "@reduxjs/toolkit"; 2 | import { INITIAL_SETTINGS_STATE, settingSlice } from "./features/settings"; 3 | import { API_KEY } from "./lib/api/openai"; 4 | import { chatsSlice } from "./features/chat"; 5 | import { Chat, chatSchema } from "./features/chat/types"; 6 | import { toastSlice } from "./features/toasts"; 7 | import { SettingsState } from "./features/settings/types"; 8 | import { getStorage } from "./lib/storage"; 9 | 10 | const LS_STATE_KEY = "state"; 11 | 12 | type LocalStorageState = { 13 | settings: Omit; 14 | }; 15 | 16 | const stateToLocalState = (state: RootState): LocalStorageState => { 17 | return { 18 | settings: { 19 | maxTokens: state.settings.maxTokens, 20 | preamble: state.settings.preamble, 21 | shiftSend: state.settings.shiftSend, 22 | showPreamble: state.settings.showPreamble, 23 | model: state.settings.model, 24 | }, 25 | }; 26 | }; 27 | 28 | const storageMiddleware: Middleware = (store) => (next) => (action) => { 29 | const result: RootState = store.getState(); 30 | 31 | if (action.type.startsWith("settings/")) { 32 | const localState = stateToLocalState(result); 33 | localStorage.setItem(LS_STATE_KEY, JSON.stringify(localState)); 34 | } 35 | return next(action); 36 | }; 37 | 38 | const getInitalState = async (): Promise => { 39 | try { 40 | const localStateJson = localStorage.getItem(LS_STATE_KEY); 41 | 42 | let localState: LocalStorageState = { 43 | settings: { 44 | ...INITIAL_SETTINGS_STATE, 45 | }, 46 | }; 47 | 48 | if (localStateJson) { 49 | localState = JSON.parse(localStateJson); 50 | } 51 | 52 | const chats: Chat[] = await getStorage().getChats(); 53 | const chatRecord = chats.reduce>((acc, chat) => { 54 | const parse = chatSchema.safeParse(chat); 55 | if (parse.success) { 56 | acc[chat.id] = parse.data; 57 | } else { 58 | console.error("Error parsing chat:", parse.error); 59 | } 60 | 61 | return acc; 62 | }, {}); 63 | 64 | const result: RootState = { 65 | chats: { 66 | chats: chatRecord, 67 | activeId: chats.length > 0 ? chats[0].id : null, 68 | }, 69 | toasts: { 70 | toasts: {}, 71 | }, 72 | settings: { ...localState.settings, apiKey: API_KEY }, 73 | }; 74 | 75 | return result; 76 | } catch (e) { 77 | console.error("Error loading state from local storage:", e); 78 | } 79 | return undefined; 80 | }; 81 | // We use any to avoid recursive type errors 82 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 83 | const initalState: any = await getInitalState(); 84 | 85 | export const STORE = configureStore({ 86 | reducer: { 87 | chats: chatsSlice.reducer, 88 | settings: settingSlice.reducer, 89 | toasts: toastSlice.reducer, 90 | }, 91 | preloadedState: initalState, 92 | middleware: (getDefaultMiddleware) => 93 | getDefaultMiddleware({ 94 | thunk: { 95 | extraArgument: {}, 96 | }, 97 | }).concat(storageMiddleware), 98 | }); 99 | export type RootState = ReturnType; 100 | export type AppDispatch = typeof STORE.dispatch; 101 | -------------------------------------------------------------------------------- /src/styles/grid.css: -------------------------------------------------------------------------------- 1 | .app { 2 | display: grid; 3 | width: 100%; 4 | height: 100%; 5 | 6 | grid-template-columns: 1fr; 7 | grid-template-rows: 1fr; 8 | 9 | grid-template-areas: "main"; 10 | } 11 | 12 | main { 13 | grid-area: main; 14 | } 15 | -------------------------------------------------------------------------------- /src/styles/markdown.css: -------------------------------------------------------------------------------- 1 | .wmde-markdown { 2 | background-color: transparent !important; 3 | } 4 | .wmde-markdown pre { 5 | @apply !bg-mirage-900; 6 | } 7 | -------------------------------------------------------------------------------- /src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { ElectronAPI } from "../../electron/preload"; 2 | 3 | declare global { 4 | interface Window { 5 | electronAPI: ElectronAPI; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./**/*.html", "./src/**/*.{ts,tsx}"], 4 | theme: { 5 | extend: { 6 | colors: { 7 | mirage: { 8 | 50: "#f6f6f7", 9 | 100: "#e1e3e6", 10 | 200: "#c3c7cc", 11 | 300: "#9da2ab", 12 | 400: "#787d89", 13 | 500: "#5e636e", 14 | 600: "#4a4d57", 15 | 700: "#3e4047", 16 | 800: "#34353b", 17 | 900: "#202124", 18 | }, 19 | }, 20 | }, 21 | }, 22 | plugins: [], 23 | }; 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "noUncheckedIndexedAccess": false, 19 | "baseUrl": "./", 20 | "paths": { 21 | "@src/*": ["src/*"], 22 | "@components/*": ["src/components/*"], 23 | "@lib/*": ["src/lib/*"], 24 | "@root/*": ["./*"], 25 | "@assets/*": ["src/assets/*"] 26 | }, 27 | "typeRoots": ["src/types", "./node_modules/@types"] 28 | }, 29 | "include": ["src/**/*"], 30 | "references": [{ "path": "./tsconfig.node.json" }] 31 | } 32 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | 3 | import tsconfigPaths from "vite-tsconfig-paths"; 4 | 5 | import react from "@vitejs/plugin-react"; 6 | import electron from "vite-electron-plugin"; 7 | import { customStart, loadViteEnv } from "vite-electron-plugin/plugin"; 8 | import renderer from "vite-plugin-electron-renderer"; 9 | import pkg from "./package.json"; 10 | import { rmSync } from "fs"; 11 | import topLevelAwait from "vite-plugin-top-level-await"; 12 | 13 | // https://vitejs.dev/config/ 14 | export default defineConfig(({ command }) => { 15 | rmSync("dist-electron", { recursive: true, force: true }); 16 | 17 | const sourcemap = command === "serve" || !!process.env.VSCODE_DEBUG; 18 | 19 | return { 20 | plugins: [ 21 | tsconfigPaths(), 22 | react(), 23 | electron({ 24 | include: ["electron"], 25 | transformOptions: { 26 | sourcemap, 27 | }, 28 | plugins: [ 29 | ...(!!process.env.VSCODE_DEBUG 30 | ? [customStart(() => console.log("[startup] Electron App"))] 31 | : []), 32 | loadViteEnv(), 33 | ], 34 | }), 35 | renderer({ 36 | nodeIntegration: false, 37 | }), 38 | ], 39 | build: { 40 | target: ["chrome89"], 41 | }, 42 | server: !!process.env.VSCODE_DEBUG 43 | ? (() => { 44 | const url = new URL(pkg.debug.env.VITE_DEV_SERVER_URL); 45 | return { 46 | host: url.hostname, 47 | port: +url.port, 48 | }; 49 | })() 50 | : undefined, 51 | clearScreen: false, 52 | }; 53 | }); 54 | --------------------------------------------------------------------------------