├── .github ├── dependabot.yml └── workflows │ ├── action.yml │ └── pull_request.yml ├── .gitignore ├── FEATURES.md ├── LICENSE ├── README.md ├── cypress.config.ts ├── cypress ├── fixtures │ └── example.json └── support │ ├── commands.ts │ └── e2e.ts ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.cjs ├── public ├── favicon.ico ├── locales │ ├── de │ │ └── translation.json │ ├── el │ │ └── translation.json │ ├── en │ │ └── translation.json │ ├── es │ │ └── translation.json │ ├── fr │ │ └── translation.json │ ├── hi │ │ └── translation.json │ ├── id │ │ └── translation.json │ ├── it │ │ └── translation.json │ ├── ja │ │ └── translation.json │ ├── ko │ │ └── translation.json │ ├── pl │ │ └── translation.json │ ├── ru │ │ └── translation.json │ ├── sv │ │ └── translation.json │ ├── th │ │ └── translation.json │ ├── vi │ │ └── translation.json │ └── zh-CN │ │ └── translation.json ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── App.css ├── App.test.tsx ├── App.tsx ├── UserContext.tsx ├── components │ ├── AnchoredHint.tsx │ ├── AvatarFieldEditor.tsx │ ├── Button.css │ ├── Button.tsx │ ├── Chat.tsx │ ├── ChatBlock.tsx │ ├── ChatSettingDropdownMenu.tsx │ ├── ChatSettingsForm.tsx │ ├── ChatSettingsList.tsx │ ├── ChatShortcuts.tsx │ ├── ConfirmDialog.tsx │ ├── ConversationList.tsx │ ├── ConversationListItem.tsx │ ├── CopyButton.tsx │ ├── CustomChatEditor.tsx │ ├── CustomChatSplash.tsx │ ├── EditableField.tsx │ ├── EditableInstructions.tsx │ ├── ExploreCustomChats.css │ ├── ExploreCustomChats.tsx │ ├── FileDataPreview.css │ ├── FileDataPreview.tsx │ ├── FoldableTextSection.css │ ├── FoldableTextSection.tsx │ ├── FormLabel.tsx │ ├── MainPage.tsx │ ├── MarkdownBlock.css │ ├── MarkdownBlock.tsx │ ├── MessageBox.tsx │ ├── ModelSelect.css │ ├── ModelSelect.tsx │ ├── ScrollToBottomButton.tsx │ ├── SideBar.css │ ├── SideBar.tsx │ ├── SpeechSpeedSlider.tsx │ ├── SubmitButton.css │ ├── SubmitButton.tsx │ ├── TemperatureSlider.tsx │ ├── TextToSpeechButton.tsx │ ├── Tooltip.tsx │ ├── TopPSlider.tsx │ ├── UserContentBlock.tsx │ ├── UserSettingsModal.css │ └── UserSettingsModal.tsx ├── config.ts ├── constants │ ├── apiEndpoints.ts │ └── appConstants.ts ├── env.json ├── globalStyles.css ├── i18n.ts ├── index.css ├── index.tsx ├── logo.svg ├── models │ ├── ChatCompletion.ts │ ├── ChatSettings.ts │ ├── FileData.ts │ ├── SpeechSettings.ts │ └── model.ts ├── modelsContext.tsx ├── service │ ├── ChatService.ts │ ├── ChatSettingsDB.ts │ ├── ConversationService.ts │ ├── CustomError.ts │ ├── EventEmitter.ts │ ├── FileDataService.ts │ ├── NotificationService.ts │ ├── SpeechService.ts │ └── chatSettingsData.json ├── setupTests.ts ├── svg.tsx ├── utils │ └── ImageUtils.ts └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.json └── vite.config.ts /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | 8 | # For main webapp 9 | - package-ecosystem: "npm" 10 | directory: "/" 11 | open-pull-requests-limit: 15 12 | schedule: 13 | interval: "daily" 14 | -------------------------------------------------------------------------------- /.github/workflows/action.yml: -------------------------------------------------------------------------------- 1 | name: Check Markdown links 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | paths: 7 | - '**/*.md' 8 | pull_request: 9 | branches: [ "main" ] 10 | paths: 11 | - '**/*.md' 12 | 13 | jobs: 14 | markdown-link-check: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@main 18 | - uses: gaurav-nelson/github-action-markdown-link-check@v1 19 | -------------------------------------------------------------------------------- /.github/workflows/pull_request.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | paths-ignore: 6 | - 'README.md' 7 | - 'FEATURES.md' 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v4 18 | 19 | - name: Setup Node.js 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: '20' 23 | 24 | - name: Prepare Environment 25 | run: cp src/env.json src/local.env.json 26 | 27 | - name: Install dependencies 28 | run: npm install 29 | 30 | - name: Build CSS 31 | run: npm run build:css 32 | 33 | - name: Build 34 | run: npm run build 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /dist 13 | 14 | # tools 15 | .idea 16 | 17 | # local env 18 | local.env.json 19 | 20 | # misc 21 | .DS_Store 22 | .env.local 23 | .env.development.local 24 | .env.test.local 25 | .env.production.local 26 | 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | src/tailwind.css 31 | -------------------------------------------------------------------------------- /FEATURES.md: -------------------------------------------------------------------------------- 1 | ## Features 2 | - Uses OpenAI's [Completion API](https://api.openai.com/v1/chat/completions). 3 | - Responses are returned in [stream mode](https://platform.openai.com/docs/api-reference/chat/create#stream) as they are generated by OpenAI. 4 | - Conversation style is like ChatGPT. 5 | - Multiple languages supported: 6 | - Deutsch, 7 | Ελληνικά, 8 | English, 9 | Español, 10 | Français, 11 | हिन्दी, 12 | Bahasa Indonesia, 13 | Italiano, 14 | 日本語, 15 | 한국어, 16 | Polski, 17 | Русский, 18 | Svenska, 19 | ไทย, 20 | Tiếng Việt, 21 | 中文 22 | - Can select which model to use, like gpt-3.5-turbo, gpt-4. 23 | - Model context window and knowledge cutoff date are shown. 24 | * Note: If you don't see gpt-4 listed in the model selector, then you don't have access. GPT-4 API access is currently accessible to those who have made at least [one successful payment](https://help.openai.com/en/articles/7102672-how-can-i-access-gpt-4) through the OpenAI developer platform. 25 | - Markdown formatting 26 | - Syntax highlighting of code responses 27 | - Conversation History Sidebar 28 | - History of conversations is stored locally using the browser's IndexedDB API. 29 | - Group conversations by Today, Yesterday, Last Week, Last Month. 30 | - Search conversations by searching for substrings in title. 31 | - Edit conversation title. 32 | - Handles error conditions 33 | - Exceeding token limit for selected model. 34 | - System prompt to give instructions. 35 | - Set your OpenAI API key in local.env.json 36 | 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 elebitzero 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenAI React Chat Web Application 2 | 3 | This project provides a web frontend for the OpenAI chat API. This project is for developers or advanced users that are familiar with [OpenAI ChatGPT](https://chat.openai.com/) but want to customize the web interface. 4 | ## Goals 5 | * Provide the same features as [OpenAI ChatGPT](https://chat.openai.com/) and 6 | [OpenAI Playground](https://platform.openai.com/playground?mode=chat). 7 | * Use a modern web stack of React, Tailwind CSS, and Typescript. 8 | 9 | See [FEATURES.md](FEATURES.md) for details. 10 | 11 | ## Preview 12 | 13 | ![openai-react-chat-0812-demo](https://github.com/user-attachments/assets/4140d46c-cff2-481b-b606-d2ce869209f3) 14 | 15 | 16 | 17 | 18 | 19 | ## Requirements 20 | 21 | * [Node.JS](https://nodejs.dev/en/) 22 | * [npm](https://www.npmjs.com/) 23 | * [OpenAI API Account](https://openai.com/blog/openai-api) 24 | * Note: GPT-4 API access is currently accessible to those who have made at least [one successful payment](https://help.openai.com/en/articles/7102672-how-can-i-access-gpt-4) through the OpenAI developer platform. 25 | 26 | 27 | ## Setup 28 | 29 | 1. Clone the repository. 30 | ``` 31 | git clone https://github.com/elebitzero/openai-react-chat.git 32 | ``` 33 | 2. Copy [env.json](src/env.json) to `local.env.json` and change 'your-api-key-here' to your [OpenAI Key](https://platform.openai.com/account/api-keys) 34 | 3. Build & Run the web server 35 | ``` 36 | npm install 37 | npm run start 38 | ``` 39 | 40 | The local website [http://localhost:3000/](http://localhost:3000/) should open in your browser. 41 | 42 | ## Contributions 43 | 44 | All contributions are welcome. Feel free to open an issue or create a pull request. 45 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress"; 2 | 3 | export default defineConfig({ 4 | retries: { 5 | runMode: 3, 6 | openMode: 0, 7 | }, 8 | env: {}, 9 | e2e: { 10 | experimentalRunAllSpecs: true, 11 | // We've imported your old cypress plugins here. 12 | // You may want to clean this up later by importing these. 13 | setupNodeEvents(on, config) { 14 | // return require('./cypress/plugins/index.js')(on, config) 15 | }, 16 | specPattern: './cypress/e2e/**/*.cy.{js,jsx,ts,tsx}', 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************** 3 | // This example commands.ts shows you how to 4 | // create various custom commands and overwrite 5 | // existing commands. 6 | // 7 | // For more comprehensive examples of custom 8 | // commands please read more here: 9 | // https://on.cypress.io/custom-commands 10 | // *********************************************** 11 | // 12 | // 13 | // -- This is a parent command -- 14 | // Cypress.Commands.add('login', (email, password) => { ... }) 15 | // 16 | // 17 | // -- This is a child command -- 18 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 19 | // 20 | // 21 | // -- This is a dual command -- 22 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 23 | // 24 | // 25 | // -- This will overwrite an existing command -- 26 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 27 | // 28 | // declare global { 29 | // namespace Cypress { 30 | // interface Chainable { 31 | // login(email: string, password: string): Chainable 32 | // drag(subject: string, options?: Partial): Chainable 33 | // dismiss(subject: string, options?: Partial): Chainable 34 | // visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable 35 | // } 36 | // } 37 | // } -------------------------------------------------------------------------------- /cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 33 | 34 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "openai-react-chat", 3 | "version": "0.1.0", 4 | "license": "MIT", 5 | "private": true, 6 | "type": "module", 7 | "engines": { 8 | "node": ">=18.0.0" 9 | }, 10 | "dependencies": { 11 | "@headlessui/react": "^2.2.4", 12 | "@heroicons/react": "^2.2.0", 13 | "@radix-ui/react-tooltip": "^1.2.7", 14 | "@tailwindcss/postcss": "^4.1.8", 15 | "dexie": "^4.0.11", 16 | "github-markdown-css": "^5.8.1", 17 | "hast": "^1.0.0", 18 | "highlight.js": "^11.11.1", 19 | "i18next": "^25.2.1", 20 | "i18next-browser-languagedetector": "^8.1.0", 21 | "i18next-http-backend": "^3.0.2", 22 | "postcss": "^8.5.4", 23 | "postcss-cli": "^11.0.1", 24 | "rc-slider": "^11.1.8", 25 | "react": "^19.1.0", 26 | "react-dom": "^19.1.0", 27 | "react-i18next": "^15.5.2", 28 | "react-loader-spinner": "^6.1.6", 29 | "react-markdown": "^10.1.0", 30 | "react-router-dom": "^7.6.2", 31 | "react-select": "^5.10.1", 32 | "react-syntax-highlighter": "^15.6.1", 33 | "react-toastify": "^11.0.5", 34 | "rehype-katex": "^7.0.1", 35 | "remark-gfm": "^4.0.1", 36 | "remark-math": "^6.0.0", 37 | "tailwindcss": "^4.1.8", 38 | "typescript": "^5.8.3", 39 | "unist-util-visit": "^5.0.0" 40 | }, 41 | "overrides": { 42 | "typescript": "^5.8.3", 43 | "react-loader-spinner": { 44 | "react": "^18 || ^19", 45 | "react-dom": "^18 || ^19" 46 | } 47 | }, 48 | "scripts": { 49 | "build:css": "postcss src/index.css -o src/tailwind.css", 50 | "start": "npm run build:css && vite", 51 | "build": "npm run build:css && tsc && vite build", 52 | "serve": "vite preview" 53 | }, 54 | "eslintConfig": { 55 | "extends": [ 56 | "react-app", 57 | "react-app/jest" 58 | ] 59 | }, 60 | "browserslist": { 61 | "production": [ 62 | ">0.2%", 63 | "not dead", 64 | "not op_mini all" 65 | ], 66 | "development": [ 67 | "last 1 chrome version", 68 | "last 1 firefox version", 69 | "last 1 safari version" 70 | ] 71 | }, 72 | "devDependencies": { 73 | "@testing-library/jest-dom": "^6.6.3", 74 | "@testing-library/react": "^16.3.0", 75 | "@testing-library/user-event": "^14.6.1", 76 | "@types/hast": "^3.0.4", 77 | "@types/jest": "^29.5.14", 78 | "@types/node": "^22.15.29", 79 | "@types/react": "^19.1.6", 80 | "@types/react-dom": "^19.1.6", 81 | "@types/react-syntax-highlighter": "^15.5.13", 82 | "@vitejs/plugin-react": "^4.5.1", 83 | "cypress": "^14.4.1", 84 | "vite": "^6.3.5", 85 | "vite-plugin-svgr": "^4.3.0", 86 | "vite-tsconfig-paths": "^5.1.4" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | '@tailwindcss/postcss': {}, 4 | }, 5 | } 6 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elebitzero/openai-react-chat/2d56d3be113b7ee59931d9872d7c17c7033f4bb9/public/favicon.ico -------------------------------------------------------------------------------- /public/locales/de/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "system": "System:", 3 | "model": "Modell:", 4 | "non-applicable": "N.Z.", 5 | "default-label": "(Standard)", 6 | "deterministic-label": "Deterministisch", 7 | "creative-label": "Kreativ", 8 | "open-settings": "Einstellungen öffnen", 9 | "custom-chats-header": "Benutzerdefinierte Chats", 10 | "example-chats": "Beispiel Benutzerdefinierte Chats", 11 | "my-chats": "Meine Benutzerdefinierten Chats", 12 | "menu-about": "Über", 13 | "menu-edit": "Bearbeiten", 14 | "menu-duplicate": "Duplizieren", 15 | "menu-delete": "Löschen", 16 | "hide-sidebar": "In der Seitenleiste verbergen", 17 | "icon-header": "Symbol", 18 | "name-header": "Name", 19 | "enter-name-placeholder": "Name eingeben", 20 | "description-header": "Beschreibung", 21 | "instructions-header": "Anweisungen", 22 | "settings-header": "Einstellungen", 23 | "general-tab": "Allgemein", 24 | "instructions-tab": "Anweisungen", 25 | "speech-tab": "Sprache", 26 | "storage-tab": "Speicher", 27 | "storage-header": "Speicher", 28 | "delete-all-chats-button": "Alle Chats löschen", 29 | "theme-label": "Thema", 30 | "dark-option": "Dunkel", 31 | "light-option": "Hell", 32 | "system-option": "System", 33 | "model-header": "Modell", 34 | "seed-header": "Seed", 35 | "temperature-header": "Temperatur", 36 | "top-p-header": "Top P", 37 | "speed-header": "Geschwindigkeit", 38 | "slower-label": "Langsamer", 39 | "faster-label": "Schneller", 40 | "voice-header": "Stimme", 41 | "select-a-model": "Modell auswählen", 42 | "show-more-models": "Mehr Modelle anzeigen", 43 | "show-fewer-models": "Weniger Modelle anzeigen", 44 | "context-window": "Kontextfenster", 45 | "knowledge-cutoff": "Stichtag für Wissensstand", 46 | "new-chat": "Neuer Chat", 47 | "search": "Suchen…", 48 | "send-a-message": "Nachricht senden…", 49 | "previous-7-days": "Vorherige 7 Tage", 50 | "previous-30-days": "Vorherige 30 Tage", 51 | "yesterday": "Gestern", 52 | "today": "Heute", 53 | "copy-code": "Code kopieren", 54 | "copied": "Kopiert!", 55 | "expand": "Erweitern", 56 | "collapse": "Einklappen", 57 | "close-sidebar": "Seitenleiste schließen", 58 | "open-sidebar": "Seitenleiste öffnen", 59 | "send-message": "Nachricht senden", 60 | "cancel-output": "Abbrechen", 61 | "cancel-button": "Abbrechen", 62 | "save-button": "Speichern", 63 | "create-button": "Erstellen", 64 | "ok-button": "OK", 65 | "copy-button": "Kopieren", 66 | "change-button": "Ändern", 67 | "close-button": "Schließen", 68 | "read-aloud-button": "Vorlesen", 69 | "stop-read-aloud-button": "Abbrechen", 70 | "loading-tts-button": "Laden...", 71 | "model-does-not-support-images": "Das Modell unterstützt keine Bilder.", 72 | "tts-test-label": "Testen" 73 | } 74 | -------------------------------------------------------------------------------- /public/locales/el/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "system": "Σύστημα:", 3 | "model": "Μοντέλο:", 4 | "non-applicable": "Δ/Υ", 5 | "default-label": "(Προεπιλογή)", 6 | "deterministic-label": "Δετερμινιστικό", 7 | "creative-label": "Δημιουργικό", 8 | "open-settings": "Άνοιγμα ρυθμίσεων", 9 | "custom-chats-header": "Προσαρμοσμένες Συνομιλίες", 10 | "example-chats": "Παραδείγματα Προσαρμοσμένων Συνομιλιών", 11 | "my-chats": "Οι Προσαρμοσμένες Συνομιλίες μου", 12 | "menu-about": "Σχετικά", 13 | "menu-edit": "Επεξεργασία", 14 | "menu-duplicate": "Διπλότυπο", 15 | "menu-delete": "Διαγραφή", 16 | "hide-sidebar": "Απόκρυψη από την πλευρική στήλη", 17 | "icon-header": "Εικονίδιο", 18 | "name-header": "Όνομα", 19 | "enter-name-placeholder": "Εισάγετε όνομα", 20 | "description-header": "Περιγραφή", 21 | "instructions-header": "Οδηγίες", 22 | "settings-header": "Ρυθμίσεις", 23 | "general-tab": "Γενικά", 24 | "instructions-tab": "Οδηγίες", 25 | "speech-tab": "Ομιλία", 26 | "storage-tab": "Αποθήκευση", 27 | "storage-header": "Αποθήκευση", 28 | "delete-all-chats-button": "Διαγραφή όλων των συζητήσεων", 29 | "theme-label": "Θέμα", 30 | "dark-option": "Σκοτεινό", 31 | "light-option": "Φωτεινό", 32 | "system-option": "Σύστημα", 33 | "model-header": "Μοντέλο", 34 | "seed-header": "Σπόρος", 35 | "temperature-header": "Θερμοκρασία", 36 | "top-p-header": "Top P", 37 | "speed-header": "Ταχύτητα", 38 | "slower-label": "Πιο αργά", 39 | "faster-label": "Πιο γρήγορα", 40 | "voice-header": "Φωνή", 41 | "select-a-model": "Επιλέξτε ένα μοντέλο", 42 | "show-more-models": "Εμφάνιση περισσότερων μοντέλων", 43 | "show-fewer-models": "Εμφάνιση λιγότερων μοντέλων", 44 | "context-window": "Παράθυρο περιεχομένου", 45 | "knowledge-cutoff": "Ημερομηνία αποκοπής γνώσεων", 46 | "new-chat": "Νέα συνομιλία", 47 | "search": "Αναζήτηση…", 48 | "send-a-message": "Αποστολή μηνύματος…", 49 | "previous-7-days": "Προηγούμενες 7 ημέρες", 50 | "previous-30-days": "Προηγούμενες 30 ημέρες", 51 | "yesterday": "Χθες", 52 | "today": "Σήμερα", 53 | "copy-code": "Αντιγραφή κώδικα", 54 | "copied": "Αντιγράφηκε!", 55 | "expand": "Ανάπτυξη", 56 | "collapse": "Σύμπτυξη", 57 | "close-sidebar": "Κλείσιμο πλευρικής μπάρας", 58 | "open-sidebar": "Άνοιγμα πλευρικής μπάρας", 59 | "send-message": "Αποστολή μηνύματος", 60 | "cancel-output": "Άκυρο", 61 | "cancel-button": "Άκυρο", 62 | "save-button": "Αποθήκευση", 63 | "create-button": "Δημιουργία", 64 | "ok-button": "Εντάξει", 65 | "copy-button": "Αντιγραφή", 66 | "change-button": "Αλλαγή", 67 | "close-button": "Κλείσιμο", 68 | "read-aloud-button": "Διάβασε δυνατά", 69 | "stop-read-aloud-button": "Ακύρωση", 70 | "loading-tts-button": "Φόρτωση...", 71 | "model-does-not-support-images": "Το μοντέλο δεν υποστηρίζει εικόνες.", 72 | "tts-test-label": "Δοκιμή" 73 | } 74 | -------------------------------------------------------------------------------- /public/locales/en/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "system": "System:", 3 | "model": "Model:", 4 | "non-applicable": "N/A", 5 | "default-label": "(Default)", 6 | "deterministic-label": "Deterministic", 7 | "creative-label": "Creative", 8 | "open-settings": "Open settings", 9 | "custom-chats-header": "Custom Chats", 10 | "example-chats": "Example Custom Chats", 11 | "my-chats": "My Custom Chats", 12 | "menu-about": "About", 13 | "menu-edit": "Edit", 14 | "menu-duplicate": "Duplicate", 15 | "menu-delete": "Delete", 16 | "hide-sidebar": "Hide from sidebar", 17 | "show-sidebar": "Show in sidebar", 18 | "icon-header": "Icon", 19 | "name-header": "Name", 20 | "enter-name-placeholder": "Enter name", 21 | "description-header": "Description", 22 | "instructions-header": "Instructions", 23 | "settings-header": "Settings", 24 | "general-tab": "General", 25 | "instructions-tab": "Instructions", 26 | "speech-tab": "Speech", 27 | "storage-tab": "Storage", 28 | "storage-header": "Storage", 29 | "delete-all-chats-button": "Delete All Chats", 30 | "theme-label": "Theme", 31 | "dark-option": "Dark", 32 | "light-option": "Light", 33 | "system-option": "System", 34 | "model-header": "Model", 35 | "seed-header": "Seed", 36 | "temperature-header": "Temperature", 37 | "top-p-header": "Top P", 38 | "speed-header": "Speed", 39 | "slower-label": "Slower", 40 | "faster-label": "Faster", 41 | "voice-header": "Voice", 42 | "select-a-model": "Select a model", 43 | "show-more-models": "Show more models", 44 | "show-fewer-models": "Show fewer models", 45 | "context-window": "Context window", 46 | "knowledge-cutoff": "Knowledge cutoff date", 47 | "new-chat": "New chat", 48 | "search": "Search…", 49 | "send-a-message": "Send a message…", 50 | "previous-7-days": "Previous 7 days", 51 | "previous-30-days": "Previous 30 days", 52 | "yesterday": "Yesterday", 53 | "today": "Today", 54 | "copy-code": "Copy code", 55 | "copied": "Copied!", 56 | "expand": "Expand", 57 | "collapse": "Collapse", 58 | "close-sidebar": "Close sidebar", 59 | "open-sidebar": "Open sidebar", 60 | "send-message": "Send message", 61 | "cancel-output": "Cancel", 62 | "cancel-button": "Cancel", 63 | "save-button": "Save", 64 | "create-button": "Create", 65 | "ok-button": "OK", 66 | "copy-button": "Copy", 67 | "change-button": "Change", 68 | "close-button": "Close", 69 | "read-aloud-button": "Read Aloud", 70 | "stop-read-aloud-button": "Cancel", 71 | "loading-tts-button": "Loading...", 72 | "model-does-not-support-images": "Model does not support images.", 73 | "tts-test-label": "Test" 74 | } 75 | -------------------------------------------------------------------------------- /public/locales/es/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "system": "Sistema:", 3 | "model": "Modelo:", 4 | "non-applicable": "N/D", 5 | "default-label": "(Predeterminado)", 6 | "deterministic-label": "Determinista", 7 | "creative-label": "Creativo", 8 | "open-settings": "Abrir configuraciones", 9 | "custom-chats-header": "Chats Personalizados", 10 | "example-chats": "Ejemplo de Chats Personalizados", 11 | "my-chats": "Mis Chats Personalizados", 12 | "menu-about": "Acerca de", 13 | "menu-edit": "Editar", 14 | "menu-duplicate": "Duplicar", 15 | "menu-delete": "Eliminar", 16 | "hide-sidebar": "Ocultar de la barra lateral", 17 | "icon-header": "Icono", 18 | "name-header": "Nombre", 19 | "enter-name-placeholder": "Introducir nombre", 20 | "description-header": "Descripción", 21 | "instructions-header": "Instrucciones", 22 | "settings-header": "Configuración", 23 | "general-tab": "General", 24 | "instructions-tab": "Instrucciones", 25 | "speech-tab": "Discurso", 26 | "storage-tab": "Almacenamiento", 27 | "storage-header": "Almacenamiento", 28 | "delete-all-chats-button": "Eliminar todos los chats", 29 | "theme-label": "Tema", 30 | "dark-option": "Oscuro", 31 | "light-option": "Claro", 32 | "system-option": "Sistema", 33 | "model-header": "Modelo", 34 | "seed-header": "Semilla", 35 | "temperature-header": "Temperatura", 36 | "top-p-header": "Top P", 37 | "speed-header": "Velocidad", 38 | "slower-label": "Más lento", 39 | "faster-label": "Más rápido", 40 | "voice-header": "Voz", 41 | "select-a-model": "Seleccionar un modelo", 42 | "show-more-models": "Mostrar más modelos", 43 | "show-fewer-models": "Mostrar menos modelos", 44 | "context-window": "Ventana de contexto", 45 | "knowledge-cutoff": "Fecha de corte de conocimiento", 46 | "new-chat": "Nuevo chat", 47 | "search": "Buscar…", 48 | "send-a-message": "Enviar mensaje…", 49 | "previous-7-days": "Últimos 7 días", 50 | "previous-30-days": "Últimos 30 días", 51 | "yesterday": "Ayer", 52 | "today": "Hoy", 53 | "copy-code": "Copiar código", 54 | "copied": "¡Copiado!", 55 | "expand": "Expandir", 56 | "collapse": "Contraer", 57 | "close-sidebar": "Cerrar barra lateral", 58 | "open-sidebar": "Abrir barra lateral", 59 | "send-message": "Enviar mensaje", 60 | "cancel-output": "Cancelar", 61 | "cancel-button": "Cancelar", 62 | "save-button": "Guardar", 63 | "create-button": "Crear", 64 | "ok-button": "Aceptar", 65 | "copy-button": "Copiar", 66 | "change-button": "Cambiar", 67 | "close-button": "Cerrar", 68 | "read-aloud-button": "Leer en voz alta", 69 | "stop-read-aloud-button": "Cancelar", 70 | "loading-tts-button": "Cargando...", 71 | "model-does-not-support-images": "El modelo no admite imágenes.", 72 | "tts-test-label": "Probar" 73 | } 74 | -------------------------------------------------------------------------------- /public/locales/fr/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "system": "Système :", 3 | "model": "Modèle :", 4 | "non-applicable": "N/A", 5 | "default-label": "(Défaut)", 6 | "deterministic-label": "Déterministe", 7 | "creative-label": "Créatif", 8 | "open-settings": "Ouvrir les paramètres", 9 | "custom-chats-header": "Chats Personnalisés", 10 | "example-chats": "Exemples de Chats Personnalisés", 11 | "my-chats": "Mes Chats Personnalisés", 12 | "menu-about": "À propos", 13 | "menu-edit": "Modifier", 14 | "menu-duplicate": "Dupliquer", 15 | "menu-delete": "Supprimer", 16 | "hide-sidebar": "Masquer de la barre latérale", 17 | "icon-header": "Icône", 18 | "name-header": "Nom", 19 | "enter-name-placeholder": "Entrez le nom", 20 | "description-header": "Description", 21 | "instructions-header": "Instructions", 22 | "settings-header": "Paramètres", 23 | "general-tab": "Général", 24 | "instructions-tab": "Instructions", 25 | "speech-tab": "Discours", 26 | "storage-tab": "Stockage", 27 | "storage-header": "Stockage", 28 | "delete-all-chats-button": "Supprimer tous les chats", 29 | "theme-label": "Thème", 30 | "dark-option": "Sombre", 31 | "light-option": "Clair", 32 | "system-option": "Système", 33 | "model-header": "Modèle", 34 | "seed-header": "Graine", 35 | "temperature-header": "Température", 36 | "top-p-header": "Top P", 37 | "speed-header": "Vitesse", 38 | "slower-label": "Plus lent", 39 | "faster-label": "Plus rapide", 40 | "voice-header": "Voix", 41 | "select-a-model": "Sélectionner un modèle", 42 | "show-more-models": "Afficher plus de modèles", 43 | "show-fewer-models": "Afficher moins de modèles", 44 | "context-window": "Fenêtre de contexte", 45 | "knowledge-cutoff": "Date limite de connaissance", 46 | "new-chat": "Nouveau chat", 47 | "search": "Recherche…", 48 | "send-a-message": "Envoyer un message…", 49 | "previous-7-days": "7 jours précédents", 50 | "previous-30-days": "30 jours précédents", 51 | "yesterday": "Hier", 52 | "today": "Aujourd'hui", 53 | "copy-code": "Copier le code", 54 | "copied": "Copié !", 55 | "expand": "Développer", 56 | "collapse": "Réduire", 57 | "close-sidebar": "Fermer la barre latérale", 58 | "open-sidebar": "Ouvrir la barre latérale", 59 | "send-message": "Envoyer un message", 60 | "cancel-output": "Annuler", 61 | "cancel-button": "Annuler", 62 | "save-button": "Enregistrer", 63 | "create-button": "Créer", 64 | "ok-button": "OK", 65 | "copy-button": "Copier", 66 | "change-button": "Changer", 67 | "close-button": "Fermer", 68 | "read-aloud-button": "Lire à haute voix", 69 | "stop-read-aloud-button": "Annuler", 70 | "loading-tts-button": "Chargement...", 71 | "model-does-not-support-images": "Le modèle ne prend pas en charge les images.", 72 | "tts-test-label": "Tester" 73 | } 74 | -------------------------------------------------------------------------------- /public/locales/hi/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "system": "सिस्टम:", 3 | "model": "मॉडल:", 4 | "non-applicable": "लागू नहीं", 5 | "default-label": "(डिफ़ॉल्ट)", 6 | "deterministic-label": "नियतात्मक", 7 | "creative-label": "रचनात्मक", 8 | "open-settings": "सेटिंग्स खोलें", 9 | "custom-chats-header": "कस्टम चैट्स", 10 | "example-chats": "उदाहरण कस्टम चैट्स", 11 | "my-chats": "मेरी कस्टम चैट्स", 12 | "menu-about": "के बारे में", 13 | "menu-edit": "संपादित करें", 14 | "menu-duplicate": "डुप्लिकेट", 15 | "menu-delete": "हटाएं", 16 | "hide-sidebar": "साइडबार से छिपाएं", 17 | "icon-header": "आइकन", 18 | "name-header": "नाम", 19 | "enter-name-placeholder": "नाम दर्ज करें", 20 | "description-header": "विवरण", 21 | "instructions-header": "निर्देश", 22 | "settings-header": "सेटिंग्स", 23 | "general-tab": "सामान्य", 24 | "instructions-tab": "निर्देश", 25 | "speech-tab": "भाषण", 26 | "storage-tab": "भंडारण", 27 | "storage-header": "भंडारण", 28 | "delete-all-chats-button": "सभी चैट्स हटाएं", 29 | "theme-label": "थीम", 30 | "dark-option": "डार्क", 31 | "light-option": "लाइट", 32 | "system-option": "सिस्टम", 33 | "model-header": "मॉडल", 34 | "seed-header": "बीज", 35 | "temperature-header": "तापमान", 36 | "top-p-header": "टॉप P", 37 | "speed-header": "गति", 38 | "slower-label": "धीमा", 39 | "faster-label": "तेज़", 40 | "voice-header": "आवाज़", 41 | "select-a-model": "मॉडल चुनें", 42 | "show-more-models": "और मॉडल दिखाएं", 43 | "show-fewer-models": "कम मॉडल दिखाएं", 44 | "context-window": "संदर्भ विंडो", 45 | "knowledge-cutoff": "ज्ञान कटौती तिथि", 46 | "new-chat": "नई चैट", 47 | "search": "खोज…", 48 | "send-a-message": "संदेश भेजें…", 49 | "previous-7-days": "पिछले 7 दिन", 50 | "previous-30-days": "पिछले 30 दिन", 51 | "yesterday": "कल", 52 | "today": "आज", 53 | "copy-code": "कोड कॉपी करें", 54 | "copied": "कॉपी की गई!", 55 | "expand": "विस्तार करें", 56 | "collapse": "संकुचित करें", 57 | "close-sidebar": "साइडबार बंद करें", 58 | "open-sidebar": "साइडबार खोलें", 59 | "send-message": "संदेश भेजें", 60 | "cancel-output": "रद्द करें", 61 | "cancel-button": "रद्द करें", 62 | "save-button": "सहेजें", 63 | "create-button": "सृजन करें", 64 | "ok-button": "ठीक है", 65 | "copy-button": "कॉपी करें", 66 | "change-button": "परिवर्तन", 67 | "close-button": "बंद करें", 68 | "read-aloud-button": "जोर से पढ़ें", 69 | "stop-read-aloud-button": "रद्द करें", 70 | "loading-tts-button": "लोड हो रहा है...", 71 | "model-does-not-support-images": "मॉडल छवियों का समर्थन नहीं करता है।", 72 | "tts-test-label": "परीक्षण" 73 | } 74 | -------------------------------------------------------------------------------- /public/locales/id/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "system": "Sistem:", 3 | "model": "Model:", 4 | "non-applicable": "Tdk Berlaku", 5 | "default-label": "(Bawaan)", 6 | "deterministic-label": "Deterministik", 7 | "creative-label": "Kreatif", 8 | "open-settings": "Buka pengaturan", 9 | "custom-chats-header": "Obrolan Kustom", 10 | "example-chats": "Contoh Chat Kustom", 11 | "my-chats": "Chat Kustom Saya", 12 | "menu-about": "Tentang", 13 | "menu-edit": "Sunting", 14 | "menu-duplicate": "Duplikat", 15 | "menu-delete": "Hapus", 16 | "hide-sidebar": "Sembunyikan dari sidebar", 17 | "icon-header": "Ikon", 18 | "name-header": "Nama", 19 | "enter-name-placeholder": "Masukkan nama", 20 | "description-header": "Deskripsi", 21 | "instructions-header": "Instruksi", 22 | "settings-header": "Pengaturan", 23 | "general-tab": "Umum", 24 | "instructions-tab": "Instruksi", 25 | "speech-tab": "Pidato", 26 | "storage-tab": "Penyimpanan", 27 | "storage-header": "Penyimpanan", 28 | "delete-all-chats-button": "Hapus Semua Chat", 29 | "theme-label": "Tema", 30 | "dark-option": "Gelap", 31 | "light-option": "Terang", 32 | "system-option": "Sistem", 33 | "model-header": "Model", 34 | "seed-header": "Biji", 35 | "temperature-header": "Suhu", 36 | "top-p-header": "Top P", 37 | "speed-header": "Kecepatan", 38 | "slower-label": "Lebih lambat", 39 | "faster-label": "Lebih cepat", 40 | "voice-header": "Suara", 41 | "select-a-model": "Pilih model", 42 | "show-more-models": "Tampilkan lebih banyak model", 43 | "show-fewer-models": "Tampilkan lebih sedikit model", 44 | "context-window": "Jendela konteks", 45 | "knowledge-cutoff": "Tanggal batas pengetahuan", 46 | "new-chat": "Obrolan baru", 47 | "search": "Cari…", 48 | "send-a-message": "Kirim pesan…", 49 | "previous-7-days": "7 hari sebelumnya", 50 | "previous-30-days": "30 hari sebelumnya", 51 | "yesterday": "Kemarin", 52 | "today": "Hari ini", 53 | "copy-code": "Salin kode", 54 | "copied": "Disalin!", 55 | "expand": "Perluas", 56 | "collapse": "Lipat", 57 | "close-sidebar": "Tutup sidebar", 58 | "open-sidebar": "Buka sidebar", 59 | "send-message": "Kirim pesan", 60 | "cancel-output": "Batal", 61 | "cancel-button": "Batal", 62 | "save-button": "Simpan", 63 | "create-button": "Buat", 64 | "ok-button": "OK", 65 | "copy-button": "Salin", 66 | "change-button": "Ubah", 67 | "close-button": "Tutup", 68 | "read-aloud-button": "Bacakan", 69 | "stop-read-aloud-button": "Batal", 70 | "loading-tts-button": "Memuat...", 71 | "model-does-not-support-images": "Model tidak mendukung gambar.", 72 | "tts-test-label": "Tes" 73 | } 74 | -------------------------------------------------------------------------------- /public/locales/it/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "system": "Sistema:", 3 | "model": "Modello:", 4 | "non-applicable": "N/A", 5 | "default-label": "(Predefinito)", 6 | "deterministic-label": "Deterministico", 7 | "creative-label": "Creativo", 8 | "open-settings": "Apri impostazioni", 9 | "custom-chats-header": "Chat Personalizzate", 10 | "example-chats": "Esempio Chat Personalizzate", 11 | "my-chats": "Le Mie Chat Personalizzate", 12 | "menu-about": "Informazioni", 13 | "menu-edit": "Modifica", 14 | "menu-duplicate": "Duplica", 15 | "menu-delete": "Elimina", 16 | "hide-sidebar": "Nascondi dalla barra laterale", 17 | "icon-header": "Icona", 18 | "name-header": "Nome", 19 | "enter-name-placeholder": "Inserisci nome", 20 | "description-header": "Descrizione", 21 | "instructions-header": "Istruzioni", 22 | "settings-header": "Impostazioni", 23 | "general-tab": "Generale", 24 | "instructions-tab": "Istruzioni", 25 | "speech-tab": "Discorso", 26 | "storage-tab": "Archiviazione", 27 | "storage-header": "Archiviazione", 28 | "delete-all-chats-button": "Elimina tutte le chat", 29 | "theme-label": "Tema", 30 | "dark-option": "Scuro", 31 | "light-option": "Chiaro", 32 | "system-option": "Sistema", 33 | "model-header": "Modello", 34 | "seed-header": "Seme", 35 | "temperature-header": "Temperatura", 36 | "top-p-header": "Top P", 37 | "speed-header": "Velocità", 38 | "slower-label": "Più lento", 39 | "faster-label": "Più veloce", 40 | "voice-header": "Voce", 41 | "select-a-model": "Seleziona un modello", 42 | "show-more-models": "Mostra più modelli", 43 | "show-fewer-models": "Mostra meno modelli", 44 | "context-window": "Finestra di contesto", 45 | "knowledge-cutoff": "Data di taglio della conoscenza", 46 | "new-chat": "Nuova chat", 47 | "search": "Cerca…", 48 | "send-a-message": "Invia un messaggio…", 49 | "previous-7-days": "Ultimi 7 giorni", 50 | "previous-30-days": "Ultimi 30 giorni", 51 | "yesterday": "Ieri", 52 | "today": "Oggi", 53 | "copy-code": "Copia codice", 54 | "copied": "Copiato!", 55 | "expand": "Espandi", 56 | "collapse": "Comprimi", 57 | "close-sidebar": "Chiudi la barra laterale", 58 | "open-sidebar": "Apri la barra laterale", 59 | "send-message": "Invia messaggio", 60 | "cancel-output": "Annulla", 61 | "cancel-button": "Annulla", 62 | "save-button": "Salva", 63 | "create-button": "Crea", 64 | "ok-button": "OK", 65 | "copy-button": "Copia", 66 | "change-button": "Cambia", 67 | "close-button": "Chiudi", 68 | "read-aloud-button": "Leggi ad alta voce", 69 | "stop-read-aloud-button": "Annulla", 70 | "loading-tts-button": "Caricamento...", 71 | "model-does-not-support-images": "Il modello non supporta le immagini.", 72 | "tts-test-label": "Prova" 73 | } 74 | -------------------------------------------------------------------------------- /public/locales/ja/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "system": "システム:", 3 | "model": "モデル:", 4 | "non-applicable": "該当なし", 5 | "default-label": "(デフォルト)", 6 | "deterministic-label": "決定論的", 7 | "creative-label": "創造的", 8 | "open-settings": "設定を開く", 9 | "custom-chats-header": "カスタムチャット", 10 | "example-chats": "カスタムチャットの例", 11 | "my-chats": "私のカスタムチャット", 12 | "menu-about": "約", 13 | "menu-edit": "編集", 14 | "menu-duplicate": "複製", 15 | "menu-delete": "削除", 16 | "hide-sidebar": "サイドバーから隠す", 17 | "icon-header": "アイコン", 18 | "name-header": "名前", 19 | "enter-name-placeholder": "名前を入力", 20 | "description-header": "説明", 21 | "instructions-header": "指示", 22 | "settings-header": "設定", 23 | "general-tab": "一般", 24 | "instructions-tab": "指示", 25 | "speech-tab": "スピーチ", 26 | "storage-tab": "ストレージ", 27 | "storage-header": "ストレージ", 28 | "delete-all-chats-button": "すべてのチャットを削除する", 29 | "theme-label": "テーマ", 30 | "dark-option": "ダーク", 31 | "light-option": "ライト", 32 | "system-option": "システム", 33 | "model-header": "モデル", 34 | "seed-header": "シード", 35 | "temperature-header": "温度", 36 | "top-p-header": "トップP", 37 | "speed-header": "速度", 38 | "slower-label": "遅い", 39 | "faster-label": "速い", 40 | "voice-header": "声", 41 | "select-a-model": "モデルを選択", 42 | "show-more-models": "モデルをさらに表示", 43 | "show-fewer-models": "モデルを減らす", 44 | "context-window": "コンテキストウィンドウ", 45 | "knowledge-cutoff": "知識カットオフ日", 46 | "new-chat": "新しいチャット", 47 | "search": "検索…", 48 | "send-a-message": "メッセージを送る…", 49 | "previous-7-days": "過去7日間", 50 | "previous-30-days": "過去30日間", 51 | "yesterday": "昨日", 52 | "today": "今日", 53 | "copy-code": "コードをコピー", 54 | "copied": "コピーしました!", 55 | "expand": "展開", 56 | "collapse": "折りたたむ", 57 | "close-sidebar": "サイドバーを閉じる", 58 | "open-sidebar": "サイドバーを開く", 59 | "send-message": "メッセージを送信", 60 | "cancel-output": "キャンセル", 61 | "cancel-button": "キャンセル", 62 | "save-button": "保存", 63 | "create-button": "作成", 64 | "ok-button": "OK", 65 | "copy-button": "コピー", 66 | "change-button": "変更", 67 | "close-button": "閉じる", 68 | "read-aloud-button": "読み上げる", 69 | "stop-read-aloud-button": "キャンセル", 70 | "loading-tts-button": "読み込み中...", 71 | "model-does-not-support-images": "モデルは画像をサポートしていません。", 72 | "tts-test-label": "テスト" 73 | } 74 | -------------------------------------------------------------------------------- /public/locales/ko/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "system": "시스템:", 3 | "model": "모델:", 4 | "non-applicable": "해당 없음", 5 | "default-label": "(기본값)", 6 | "deterministic-label": "결정론적", 7 | "creative-label": "창의적", 8 | "open-settings": "설정 열기", 9 | "custom-chats-header": "사용자 정의 채팅", 10 | "example-chats": "예제 사용자 정의 채팅", 11 | "my-chats": "내 사용자 정의 채팅", 12 | "menu-about": "정보", 13 | "menu-edit": "편집", 14 | "menu-duplicate": "복제", 15 | "menu-delete": "삭제", 16 | "hide-sidebar": "사이드바에서 숨기기", 17 | "icon-header": "아이콘", 18 | "name-header": "이름", 19 | "enter-name-placeholder": "이름 입력", 20 | "description-header": "설명", 21 | "instructions-header": "지침", 22 | "settings-header": "설정", 23 | "general-tab": "일반", 24 | "instructions-tab": "지침", 25 | "speech-tab": "음성", 26 | "storage-tab": "저장", 27 | "storage-header": "저장소", 28 | "delete-all-chats-button": "모든 채팅 삭제", 29 | "theme-label": "테마", 30 | "dark-option": "다크", 31 | "light-option": "라이트", 32 | "system-option": "시스템", 33 | "model-header": "모델", 34 | "seed-header": "시드", 35 | "temperature-header": "온도", 36 | "top-p-header": "상위 P", 37 | "speed-header": "속도", 38 | "slower-label": "더 느리게", 39 | "faster-label": "더 빠르게", 40 | "voice-header": "목소리", 41 | "select-a-model": "모델 선택", 42 | "show-more-models": "더 많은 모델 표시", 43 | "show-fewer-models": "더 적은 모델 표시", 44 | "context-window": "컨텍스트 창", 45 | "knowledge-cutoff": "지식 컷오프 날짜", 46 | "new-chat": "새로운 채팅", 47 | "search": "검색…", 48 | "send-a-message": "메시지 보내기…", 49 | "previous-7-days": "지난 7일", 50 | "previous-30-days": "지난 30일", 51 | "yesterday": "어제", 52 | "today": "오늘", 53 | "copy-code": "코드 복사", 54 | "copied": "복사됨!", 55 | "expand": "확장", 56 | "collapse": "축소", 57 | "close-sidebar": "사이드바 닫기", 58 | "open-sidebar": "사이드바 열기", 59 | "send-message": "메시지 보내기", 60 | "cancel-output": "취소", 61 | "cancel-button": "취소", 62 | "save-button": "저장", 63 | "create-button": "생성", 64 | "ok-button": "확인", 65 | "copy-button": "복사", 66 | "change-button": "변경", 67 | "close-button": "닫기", 68 | "read-aloud-button": "읽어주기", 69 | "stop-read-aloud-button": "취소", 70 | "loading-tts-button": "로딩 중...", 71 | "model-does-not-support-images": "모델이 이미지를 지원하지 않습니다.", 72 | "tts-test-label": "테스트" 73 | } 74 | -------------------------------------------------------------------------------- /public/locales/pl/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "system": "System:", 3 | "model": "Model:", 4 | "non-applicable": "N/D", 5 | "default-label": "(Domyślnie)", 6 | "deterministic-label": "Deterministyczny", 7 | "creative-label": "Kreatywny", 8 | "open-settings": "Otwórz ustawienia", 9 | "custom-chats-header": "Niestandardowe Czaty", 10 | "example-chats": "Przykładowe Niestandardowe Czaty", 11 | "my-chats": "Moje Niestandardowe Czaty", 12 | "menu-about": "O", 13 | "menu-edit": "Edytuj", 14 | "menu-duplicate": "Duplikuj", 15 | "menu-delete": "Usuń", 16 | "hide-sidebar": "Ukryj z paska bocznego", 17 | "icon-header": "Ikona", 18 | "name-header": "Nazwa", 19 | "enter-name-placeholder": "Wpisz nazwę", 20 | "description-header": "Opis", 21 | "instructions-header": "Instrukcje", 22 | "settings-header": "Ustawienia", 23 | "general-tab": "Ogólne", 24 | "instructions-tab": "Instrukcje", 25 | "speech-tab": "Mowa", 26 | "storage-tab": "Przechowywanie", 27 | "storage-header": "Przechowywanie", 28 | "delete-all-chats-button": "Usuń wszystkie czaty", 29 | "theme-label": "Motyw", 30 | "dark-option": "Ciemny", 31 | "light-option": "Jasny", 32 | "system-option": "System", 33 | "model-header": "Model", 34 | "seed-header": "Ziarno", 35 | "temperature-header": "Temperatura", 36 | "top-p-header": "Top P", 37 | "speed-header": "Prędkość", 38 | "slower-label": "Wolniej", 39 | "faster-label": "Szybciej", 40 | "voice-header": "Głos", 41 | "select-a-model": "Wybierz model", 42 | "show-more-models": "Pokaż więcej modeli", 43 | "show-fewer-models": "Pokaż mniej modeli", 44 | "context-window": "Okno kontekstowe", 45 | "knowledge-cutoff": "Data zamknięcia wiedzy", 46 | "new-chat": "Nowy czat", 47 | "search": "Szukaj…", 48 | "send-a-message": "Wyślij wiadomość…", 49 | "previous-7-days": "Ostatnie 7 dni", 50 | "previous-30-days": "Ostatnie 30 dni", 51 | "yesterday": "Wczoraj", 52 | "today": "Dziś", 53 | "copy-code": "Skopiuj kod", 54 | "copied": "Skopiowano!", 55 | "expand": "Rozwiń", 56 | "collapse": "Zwiń", 57 | "close-sidebar": "Zamknij pasek boczny", 58 | "open-sidebar": "Otwórz pasek boczny", 59 | "send-message": "Wyślij wiadomość", 60 | "cancel-output": "Anuluj", 61 | "cancel-button": "Anuluj", 62 | "save-button": "Zapisz", 63 | "create-button": "Utwórz", 64 | "ok-button": "OK", 65 | "copy-button": "Kopiuj", 66 | "change-button": "Zmień", 67 | "close-button": "Zamknij", 68 | "read-aloud-button": "Czytaj na głos", 69 | "stop-read-aloud-button": "Anuluj", 70 | "loading-tts-button": "Ładowanie...", 71 | "model-does-not-support-images": "Model nie obsługuje obrazów.", 72 | "tts-test-label": "Testuj" 73 | } 74 | -------------------------------------------------------------------------------- /public/locales/ru/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "system": "Система:", 3 | "model": "Модель:", 4 | "non-applicable": "Н/П", 5 | "default-label": "(По умолчанию)", 6 | "deterministic-label": "Детерминированный", 7 | "creative-label": "Творческий", 8 | "open-settings": "Открыть настройки", 9 | "custom-chats-header": "Пользовательские Чаты", 10 | "example-chats": "Примеры Пользовательских Чатов", 11 | "my-chats": "Мои Пользовательские Чаты", 12 | "menu-about": "О программе", 13 | "menu-edit": "Редактировать", 14 | "menu-duplicate": "Дублировать", 15 | "menu-delete": "Удалить", 16 | "hide-sidebar": "Скрыть с боковой панели", 17 | "icon-header": "Иконка", 18 | "name-header": "Имя", 19 | "enter-name-placeholder": "Введите имя", 20 | "description-header": "Описание", 21 | "instructions-header": "Инструкции", 22 | "settings-header": "Настройки", 23 | "general-tab": "Общие", 24 | "instructions-tab": "Инструкции", 25 | "speech-tab": "Речь", 26 | "storage-tab": "Хранение", 27 | "storage-header": "Хранение", 28 | "delete-all-chats-button": "Удалить все чаты", 29 | "theme-label": "Тема", 30 | "dark-option": "Темная", 31 | "light-option": "Светлая", 32 | "system-option": "Система", 33 | "model-header": "Модель", 34 | "seed-header": "Сид", 35 | "temperature-header": "Температура", 36 | "top-p-header": "Топ P", 37 | "speed-header": "Скорость", 38 | "slower-label": "Медленнее", 39 | "faster-label": "Быстрее", 40 | "voice-header": "Голос", 41 | "select-a-model": "Выберите модель", 42 | "show-more-models": "Показать больше моделей", 43 | "show-fewer-models": "Показать меньше моделей", 44 | "context-window": "Контекстное окно", 45 | "knowledge-cutoff": "Дата окончания знаний", 46 | "new-chat": "Новый чат", 47 | "search": "Поиск…", 48 | "send-a-message": "Отправить сообщение…", 49 | "previous-7-days": "Предыдущие 7 дней", 50 | "previous-30-days": "Предыдущие 30 дней", 51 | "yesterday": "Вчера", 52 | "today": "Сегодня", 53 | "copy-code": "Копировать код", 54 | "copied": "Скопировано!", 55 | "expand": "Развернуть", 56 | "collapse": "Свернуть", 57 | "close-sidebar": "Закрыть боковую панель", 58 | "open-sidebar": "Открыть боковую панель", 59 | "send-message": "Отправить сообщение", 60 | "cancel-output": "Отмена", 61 | "cancel-button": "Отмена", 62 | "save-button": "Сохранить", 63 | "create-button": "Создать", 64 | "ok-button": "ОК", 65 | "copy-button": "Копировать", 66 | "change-button": "Изменить", 67 | "close-button": "Закрыть", 68 | "read-aloud-button": "Прочитать вслух", 69 | "stop-read-aloud-button": "Отмена", 70 | "loading-tts-button": "Загрузка...", 71 | "model-does-not-support-images": "Модель не поддерживает изображения.", 72 | "tts-test-label": "Тест" 73 | } 74 | -------------------------------------------------------------------------------- /public/locales/sv/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "system": "System:", 3 | "model": "Modell:", 4 | "non-applicable": "Ej tillämplig", 5 | "default-label": "(Standard)", 6 | "deterministic-label": "Deterministisk", 7 | "creative-label": "Kreativ", 8 | "open-settings": "Öppna inställningar", 9 | "custom-chats-header": "Anpassade Chattar", 10 | "example-chats": "Exempel på Anpassade Chattar", 11 | "my-chats": "Mina Anpassade Chattar", 12 | "menu-about": "Om", 13 | "menu-edit": "Redigera", 14 | "menu-duplicate": "Duplicera", 15 | "menu-delete": "Radera", 16 | "hide-sidebar": "Dölj från sidofältet", 17 | "icon-header": "Ikon", 18 | "name-header": "Namn", 19 | "enter-name-placeholder": "Ange namn", 20 | "description-header": "Beskrivning", 21 | "instructions-header": "Instruktioner", 22 | "settings-header": "Inställningar", 23 | "general-tab": "Allmänt", 24 | "instructions-tab": "Instruktioner", 25 | "speech-tab": "Tal", 26 | "storage-tab": "Lagring", 27 | "storage-header": "Förvaring", 28 | "delete-all-chats-button": "Radera alla chattar", 29 | "theme-label": "Tema", 30 | "dark-option": "Mörkt", 31 | "light-option": "Ljust", 32 | "system-option": "System", 33 | "model-header": "Modell", 34 | "seed-header": "Seed", 35 | "temperature-header": "Temperatur", 36 | "top-p-header": "Top P", 37 | "speed-header": "Hastighet", 38 | "slower-label": "Långsammare", 39 | "faster-label": "Snabbare", 40 | "voice-header": "Röst", 41 | "select-a-model": "Välj en modell", 42 | "show-more-models": "Visa fler modeller", 43 | "show-fewer-models": "Visa färre modeller", 44 | "context-window": "Kontextfönster", 45 | "knowledge-cutoff": "Kunskapsavstängningsdatum", 46 | "new-chat": "Ny chatt", 47 | "search": "Sök…", 48 | "send-a-message": "Skicka ett meddelande…", 49 | "previous-7-days": "Senaste 7 dagarna", 50 | "previous-30-days": "Senaste 30 dagarna", 51 | "yesterday": "Igår", 52 | "today": "Idag", 53 | "copy-code": "Kopiera kod", 54 | "copied": "Kopierat!", 55 | "expand": "Expandera", 56 | "collapse": "Kollapsa", 57 | "close-sidebar": "Stäng sidofältet", 58 | "open-sidebar": "Öppna sidofältet", 59 | "send-message": "Skicka meddelande", 60 | "cancel-output": "Avbryt", 61 | "cancel-button": "Avbryt", 62 | "save-button": "Spara", 63 | "create-button": "Skapa", 64 | "ok-button": "OK", 65 | "copy-button": "Kopiera", 66 | "change-button": "Ändra", 67 | "close-button": "Stäng", 68 | "read-aloud-button": "Läs högt", 69 | "stop-read-aloud-button": "Avbryt", 70 | "loading-tts-button": "Laddar...", 71 | "model-does-not-support-images": "Modellen stöder inte bilder.", 72 | "tts-test-label": "Testa" 73 | } 74 | -------------------------------------------------------------------------------- /public/locales/th/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "system": "ระบบ:", 3 | "model": "โมเดล:", 4 | "non-applicable": "ไม่มีข้อมูล", 5 | "default-label": "(ค่าเริ่มต้น)", 6 | "deterministic-label": "ลักษณะกำหนด", 7 | "creative-label": "สร้างสรรค์", 8 | "open-settings": "เปิดการตั้งค่า", 9 | "custom-chats-header": "แชทแบบกำหนดเอง", 10 | "example-chats": "ตัวอย่างแชทที่กำหนดเอง", 11 | "my-chats": "แชทที่กำหนดเองของฉัน", 12 | "menu-about": "เกี่ยวกับ", 13 | "menu-edit": "แก้ไข", 14 | "menu-duplicate": "ทำซ้ำ", 15 | "menu-delete": "ลบ", 16 | "hide-sidebar": "ซ่อนจากแถบด้านข้าง", 17 | "icon-header": "ไอคอน", 18 | "name-header": "ชื่อ", 19 | "enter-name-placeholder": "กรอกชื่อ", 20 | "description-header": "คำอธิบาย", 21 | "instructions-header": "คำแนะนำ", 22 | "settings-header": "การตั้งค่า", 23 | "general-tab": "ทั่วไป", 24 | "instructions-tab": "คำแนะนำ", 25 | "speech-tab": "คำพูด", 26 | "storage-tab": "การเก็บข้อมูล", 27 | "storage-header": "การเก็บข้อมูล", 28 | "delete-all-chats-button": "ลบการแชททั้งหมด", 29 | "theme-label": "ธีม", 30 | "dark-option": "มืด", 31 | "light-option": "สว่าง", 32 | "system-option": "ระบบ", 33 | "model-header": "โมเดล", 34 | "seed-header": "ซีด", 35 | "temperature-header": "อุณหภูมิ", 36 | "top-p-header": "ท็อป P", 37 | "speed-header": "ความเร็ว", 38 | "slower-label": "ช้าลง", 39 | "faster-label": "เร็วขึ้น", 40 | "voice-header": "เสียง", 41 | "select-a-model": "เลือกรุ่น", 42 | "show-more-models": "แสดงรุ่นเพิ่มเติม", 43 | "show-fewer-models": "แสดงรุ่นน้อยลง", 44 | "context-window": "หน้าต่างบริบท", 45 | "knowledge-cutoff": "วันที่ตัดองค์ความรู้", 46 | "new-chat": "แชทใหม่", 47 | "search": "ค้นหา…", 48 | "send-a-message": "ส่งข้อความ…", 49 | "previous-7-days": "ย้อนหลัง 7 วัน", 50 | "previous-30-days": "ย้อนหลัง 30 วัน", 51 | "yesterday": "เมื่อวาน", 52 | "today": "วันนี้", 53 | "copy-code": "คัดลอกรหัส", 54 | "copied": "คัดลอกแล้ว!", 55 | "expand": "ขยาย", 56 | "collapse": "ย่อ", 57 | "close-sidebar": "ปิดแถบข้าง", 58 | "open-sidebar": "เปิดแถบข้าง", 59 | "send-message": "ส่งข้อความ", 60 | "cancel-output": "ยกเลิก", 61 | "cancel-button": "ยกเลิก", 62 | "save-button": "บันทึก", 63 | "create-button": "สร้าง", 64 | "ok-button": "ตกลง", 65 | "copy-button": "คัดลอก", 66 | "change-button": "เปลี่ยน", 67 | "close-button": "ปิด", 68 | "read-aloud-button": "อ่านออกเสียง", 69 | "stop-read-aloud-button": "ยกเลิก", 70 | "loading-tts-button": "กำลังโหลด...", 71 | "model-does-not-support-images": "รุ่นไม่รองรับรูปภาพ", 72 | "tts-test-label": "ทดสอบ" 73 | } 74 | -------------------------------------------------------------------------------- /public/locales/vi/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "system": "Hệ thống:", 3 | "model": "Mô hình:", 4 | "non-applicable": "Không áp dụng", 5 | "default-label": "(Mặc định)", 6 | "deterministic-label": "Xác định", 7 | "creative-label": "Sáng tạo", 8 | "open-settings": "Mở cài đặt", 9 | "custom-chats-header": "Trò Chuyện Tùy Chỉnh", 10 | "example-chats": "Ví dụ về Trò chuyện Tùy chỉnh", 11 | "my-chats": "Trò chuyện Tùy chỉnh của Tôi", 12 | "menu-about": "Về", 13 | "menu-edit": "Chỉnh sửa", 14 | "menu-duplicate": "Nhân bản", 15 | "menu-delete": "Xóa", 16 | "hide-sidebar": "Ẩn khỏi thanh bên", 17 | "icon-header": "Biểu tượng", 18 | "name-header": "Tên", 19 | "enter-name-placeholder": "Nhập tên", 20 | "description-header": "Mô tả", 21 | "instructions-header": "Hướng dẫn", 22 | "settings-header": "Cài đặt", 23 | "general-tab": "Chung", 24 | "instructions-tab": "Hướng dẫn", 25 | "speech-tab": "Lời nói", 26 | "storage-tab": "Lưu trữ", 27 | "storage-header": "Lưu trữ", 28 | "delete-all-chats-button": "Xóa tất cả cuộc trò chuyện", 29 | "theme-label": "Chủ đề", 30 | "dark-option": "Tối", 31 | "light-option": "Sáng", 32 | "system-option": "Hệ thống", 33 | "model-header": "Mô hình", 34 | "seed-header": "Hạt giống", 35 | "temperature-header": "Nhiệt độ", 36 | "top-p-header": "Top P", 37 | "speed-header": "Tốc độ", 38 | "slower-label": "Chậm lại", 39 | "faster-label": "Nhanh hơn", 40 | "voice-header": "Giọng nói", 41 | "select-a-model": "Chọn một mô hình", 42 | "show-more-models": "Hiển thị thêm mô hình", 43 | "show-fewer-models": "Hiển thị ít mô hình hơn", 44 | "context-window": "Cửa sổ ngữ cảnh", 45 | "knowledge-cutoff": "Ngày cắt kiến thức", 46 | "new-chat": "Cuộc trò chuyện mới", 47 | "search": "Tìm kiếm…", 48 | "send-a-message": "Gửi tin nhắn…", 49 | "previous-7-days": "7 ngày trước", 50 | "previous-30-days": "30 ngày trước", 51 | "yesterday": "Hôm qua", 52 | "today": "Hôm nay", 53 | "copy-code": "Sao chép mã", 54 | "copied": "Đã sao chép!", 55 | "expand": "Mở rộng", 56 | "collapse": "Thu nhỏ", 57 | "close-sidebar": "Đóng thanh bên", 58 | "open-sidebar": "Mở thanh bên", 59 | "send-message": "Gửi tin nhắn", 60 | "cancel-output": "Hủy bỏ", 61 | "cancel-button": "Hủy bỏ", 62 | "save-button": "Lưu", 63 | "create-button": "Tạo", 64 | "ok-button": "OK", 65 | "copy-button": "Sao chép", 66 | "change-button": "Thay đổi", 67 | "close-button": "Đóng", 68 | "read-aloud-button": "Đọc to", 69 | "stop-read-aloud-button": "Hủy bỏ", 70 | "loading-tts-button": "Đang tải...", 71 | "model-does-not-support-images": "Mô hình không hỗ trợ hình ảnh.", 72 | "tts-test-label": "Kiểm tra" 73 | } 74 | -------------------------------------------------------------------------------- /public/locales/zh-CN/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "system": "系统:", 3 | "model": "模型:", 4 | "non-applicable": "不适用", 5 | "default-label": "(默认)", 6 | "deterministic-label": "决定性", 7 | "creative-label": "创造性", 8 | "open-settings": "打开设置", 9 | "custom-chats-header": "自定义聊天", 10 | "example-chats": "自定义聊天示例", 11 | "my-chats": "我的自定义聊天", 12 | "menu-about": "关于", 13 | "menu-edit": "编辑", 14 | "menu-duplicate": "复制", 15 | "menu-delete": "删除", 16 | "hide-sidebar": "从侧边栏隐藏", 17 | "icon-header": "图标", 18 | "name-header": "名称", 19 | "enter-name-placeholder": "输入名字", 20 | "description-header": "描述", 21 | "instructions-header": "说明", 22 | "settings-header": "设置", 23 | "general-tab": "通用", 24 | "instructions-tab": "说明", 25 | "speech-tab": "语音", 26 | "storage-tab": "存储", 27 | "storage-header": "存储", 28 | "delete-all-chats-button": "删除所有聊天", 29 | "theme-label": "主题", 30 | "dark-option": "深色", 31 | "light-option": "浅色", 32 | "system-option": "系统", 33 | "model-header": "模型", 34 | "seed-header": "种子", 35 | "temperature-header": "温度", 36 | "top-p-header": "Top P", 37 | "speed-header": "速度", 38 | "slower-label": "更慢", 39 | "faster-label": "更快", 40 | "voice-header": "声音", 41 | "select-a-model": "选择一个模型", 42 | "show-more-models": "显示更多模型", 43 | "show-fewer-models": "显示较少模型", 44 | "context-window": "上下文窗口", 45 | "knowledge-cutoff": "知识截止日期", 46 | "new-chat": "新聊天", 47 | "search": "搜索…", 48 | "send-a-message": "发送消息…", 49 | "previous-7-days": "过去7天", 50 | "previous-30-days": "过去30天", 51 | "yesterday": "昨天", 52 | "today": "今天", 53 | "copy-code": "复制代码", 54 | "copied": "已复制!", 55 | "expand": "展开", 56 | "collapse": "折叠", 57 | "close-sidebar": "关闭侧边栏", 58 | "open-sidebar": "打开侧边栏", 59 | "send-message": "发送消息", 60 | "cancel-output": "取消", 61 | "cancel-button": "取消", 62 | "save-button": "保存", 63 | "create-button": "创建", 64 | "ok-button": "确定", 65 | "copy-button": "复制", 66 | "change-button": "更改", 67 | "close-button": "关闭", 68 | "read-aloud-button": "朗读", 69 | "stop-read-aloud-button": "取消", 70 | "loading-tts-button": "正在加载...", 71 | "model-does-not-support-images": "模型不支持图像。", 72 | "tts-test-label": "测试" 73 | } 74 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elebitzero/openai-react-chat/2d56d3be113b7ee59931d9872d7c17c7033f4bb9/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elebitzero/openai-react-chat/2d56d3be113b7ee59931d9872d7c17c7033f4bb9/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {render, screen} from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | render(); 7 | const linkElement = screen.getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react'; 2 | import {BrowserRouter, Navigate, Route, Routes} from 'react-router-dom'; 3 | import {I18nextProvider} from 'react-i18next'; 4 | import i18n from './i18n'; 5 | import Sidebar from "./components/SideBar"; 6 | import MainPage from "./components/MainPage"; 7 | import './App.css'; 8 | import {ToastContainer} from "react-toastify"; 9 | import ExploreCustomChats from "./components/ExploreCustomChats"; 10 | import CustomChatEditor from './components/CustomChatEditor'; 11 | 12 | const App = () => { 13 | const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); 14 | 15 | const toggleSidebarCollapse = () => { 16 | setIsSidebarCollapsed(!isSidebarCollapsed); 17 | }; 18 | 19 | interface MainPageProps { 20 | className: string; 21 | isSidebarCollapsed: boolean; 22 | toggleSidebarCollapse: () => void; 23 | } 24 | 25 | const MainPageWithProps: React.FC> = (props) => ( 26 | 32 | ); 33 | return ( 34 | 35 | 36 |
37 | 38 |
39 | 44 |
45 | 46 | }/> 47 | }/> 48 | }/> 49 | // Use the wrapper for new routes 50 | }/> 51 | }/> 52 | }/> 53 | }/> 54 | }/> 55 | 56 |
57 |
58 |
59 |
60 |
61 | ); 62 | }; 63 | 64 | export default App; 65 | -------------------------------------------------------------------------------- /src/UserContext.tsx: -------------------------------------------------------------------------------- 1 | import React, {createContext, ReactNode, useEffect, useState} from 'react'; 2 | 3 | export type UserTheme = 'light' | 'dark' | 'system'; 4 | export type Theme = 'light' | 'dark'; 5 | 6 | interface UserSettings { 7 | userTheme: UserTheme; 8 | theme: Theme; 9 | model: string | null; 10 | instructions: string; 11 | speechModel: string | null; 12 | speechVoice: string | null; 13 | speechSpeed: number | null; 14 | } 15 | 16 | const defaultUserSettings: UserSettings = { 17 | userTheme: 'system', 18 | theme: 'light', 19 | model: null, 20 | instructions: '', 21 | speechModel: 'tts-1', 22 | speechVoice: 'echo', 23 | speechSpeed: 1.0 24 | }; 25 | 26 | const determineEffectiveTheme = (userTheme: UserTheme): Theme => { 27 | if (userTheme === 'system' || !userTheme) { 28 | return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; 29 | } 30 | return userTheme; 31 | }; 32 | 33 | export const UserContext = createContext<{ 34 | userSettings: UserSettings; 35 | setUserSettings: React.Dispatch>; 36 | }>({ 37 | userSettings: defaultUserSettings, 38 | setUserSettings: () => { 39 | }, 40 | }); 41 | 42 | interface UserProviderProps { 43 | children: ReactNode; 44 | } 45 | 46 | export const UserProvider = ({children}: UserProviderProps) => { 47 | const [userSettings, setUserSettings] = useState(() => { 48 | const storedUserTheme = localStorage.getItem('theme'); 49 | const userTheme: UserTheme = (storedUserTheme === 'light' || storedUserTheme === 'dark' || storedUserTheme === 'system') ? storedUserTheme : defaultUserSettings.userTheme; 50 | 51 | const model = localStorage.getItem('defaultModel') || defaultUserSettings.model; 52 | const instructions = localStorage.getItem('defaultInstructions') || defaultUserSettings.instructions; 53 | const speechModel = localStorage.getItem('defaultSpeechModel') || defaultUserSettings.speechModel; 54 | const speechVoice = localStorage.getItem('defaultSpeechVoice') || defaultUserSettings.speechVoice; 55 | 56 | const speechSpeedRaw = localStorage.getItem('defaultSpeechSpeed'); 57 | const speechSpeed = speechSpeedRaw !== null ? Number(speechSpeedRaw) : defaultUserSettings.speechSpeed; 58 | 59 | const effectiveTheme = determineEffectiveTheme(userTheme); 60 | 61 | return { 62 | userTheme: userTheme, 63 | theme: effectiveTheme, 64 | model, 65 | instructions, 66 | speechModel, 67 | speechVoice, 68 | speechSpeed 69 | }; 70 | }); 71 | 72 | useEffect(() => { 73 | const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); 74 | 75 | mediaQuery.addEventListener('change', mediaQueryChangeHandler); 76 | updateTheme(); 77 | 78 | return () => { 79 | mediaQuery.removeEventListener('change', mediaQueryChangeHandler); 80 | }; 81 | }, []); 82 | 83 | useEffect(() => { 84 | localStorage.setItem('theme', userSettings.userTheme); 85 | }, [userSettings.userTheme]); 86 | 87 | useEffect(() => { 88 | if (userSettings.model === null || userSettings.model === '') { 89 | localStorage.removeItem('defaultModel'); 90 | } else { 91 | localStorage.setItem('defaultModel', userSettings.model); 92 | } 93 | }, [userSettings.model]); 94 | 95 | useEffect(() => { 96 | if (userSettings.instructions === '') { 97 | localStorage.removeItem('defaultInstructions'); 98 | } else { 99 | localStorage.setItem('defaultInstructions', userSettings.instructions); 100 | } 101 | }, [userSettings.instructions]); 102 | 103 | useEffect(() => { 104 | const newEffectiveTheme = determineEffectiveTheme(userSettings.userTheme); 105 | setUserSettings(prevSettings => ({...prevSettings, theme: newEffectiveTheme})); 106 | 107 | if (newEffectiveTheme === 'dark') { 108 | document.body.classList.add('dark'); 109 | } else { 110 | document.body.classList.remove('dark'); 111 | } 112 | }, [userSettings.userTheme]); 113 | 114 | const mediaQueryChangeHandler = (e: MediaQueryListEvent) => { 115 | const newSystemTheme: Theme = e.matches ? 'dark' : 'light'; 116 | if (userSettings.userTheme === 'system') { 117 | setUserSettings((prevSettings) => ({ 118 | ...prevSettings, 119 | theme: newSystemTheme, 120 | })); 121 | } 122 | }; 123 | 124 | const updateTheme = () => { 125 | const newEffectiveTheme = determineEffectiveTheme(userSettings.userTheme || 'system'); 126 | if (newEffectiveTheme !== userSettings.theme) { 127 | setUserSettings((prevSettings) => ({...prevSettings, theme: newEffectiveTheme})); 128 | } 129 | if (newEffectiveTheme === 'dark') { 130 | document.body.classList.add('dark'); 131 | } else { 132 | document.body.classList.remove('dark'); 133 | } 134 | }; 135 | 136 | useEffect(() => { 137 | if (userSettings.speechModel === null || userSettings.speechModel === '') { 138 | localStorage.removeItem('defaultSpeechModel'); 139 | } else { 140 | localStorage.setItem('defaultSpeechModel', userSettings.speechModel); 141 | } 142 | }, [userSettings.speechModel]); 143 | 144 | useEffect(() => { 145 | if (userSettings.speechVoice === null || userSettings.speechVoice === '') { 146 | localStorage.removeItem('defaultSpeechVoice'); 147 | } else { 148 | localStorage.setItem('defaultSpeechVoice', userSettings.speechVoice); 149 | } 150 | }, [userSettings.speechVoice]); 151 | 152 | useEffect(() => { 153 | if (userSettings.speechSpeed === null || userSettings.speechSpeed === undefined || userSettings.speechSpeed < 0.25 || userSettings.speechSpeed > 4.0) { 154 | localStorage.removeItem('defaultSpeechSpeed'); 155 | } else { 156 | localStorage.setItem('defaultSpeechSpeed', String(userSettings.speechSpeed)); 157 | } 158 | }, [userSettings.speechSpeed]); 159 | 160 | return ( 161 | 162 | {children} 163 | 164 | ); 165 | }; 166 | 167 | // Usage hint 168 | // const { userSettings, setUserSettings } = useContext(UserContext); 169 | -------------------------------------------------------------------------------- /src/components/AnchoredHint.tsx: -------------------------------------------------------------------------------- 1 | // AnchoredHint.tsx 2 | import React, { useContext } from 'react'; 3 | import * as RadixTooltip from '@radix-ui/react-tooltip'; 4 | import { UserContext } from "../UserContext"; 5 | import {LightBulbIcon} from "@heroicons/react/24/outline"; 6 | 7 | interface AnchoredHintProps { 8 | content: React.ReactNode; 9 | children: React.ReactNode; 10 | side?: "top" | "right" | "bottom" | "left"; 11 | sideOffset?: number; 12 | open: boolean; 13 | close: () => void; // Function to dismiss the hint 14 | } 15 | 16 | const AnchoredHint: React.FC = ({ content, children, side = "top", sideOffset = 5, open, close }) => { 17 | const { userSettings } = useContext(UserContext); 18 | const arrowClassName = 19 | userSettings.theme === 'dark' 20 | ? "dark:text-gray-100" 21 | : "text-gray-900"; 22 | 23 | return ( 24 | 25 | 26 | 27 | {children} 28 | 29 | 30 | 36 | 37 | 40 | 41 | 42 | 43 | 44 | 45 | ); 46 | }; 47 | 48 | export default AnchoredHint; 49 | -------------------------------------------------------------------------------- /src/components/AvatarFieldEditor.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState} from 'react'; 2 | import FormLabel from "./FormLabel"; 3 | import {useTranslation} from 'react-i18next'; 4 | 5 | export interface ImageSource { 6 | data: string | null; 7 | type: string; // 'svg' | 'raster' 8 | } 9 | 10 | interface AvatarFieldEditorProps { 11 | image: ImageSource; 12 | onImageChange?: (newImage: ImageSource) => void; 13 | readOnly?: boolean; 14 | size?: number; 15 | } 16 | 17 | const AvatarFieldEditor: React.FC = ({ 18 | image, 19 | onImageChange, 20 | readOnly = false, 21 | size = 120 22 | }) => { 23 | const [imageSrc, setImageSrc] = useState(image); 24 | const {t} = useTranslation(); 25 | 26 | useEffect(() => { 27 | setImageSrc(image); 28 | }, [image, readOnly]); 29 | 30 | const handleImageUpload = (event: React.ChangeEvent) => { 31 | if (readOnly) { 32 | return; 33 | } 34 | const file = event.target.files ? event.target.files[0] : null; 35 | if (file) { 36 | const reader = new FileReader(); 37 | reader.onload = (e) => { 38 | const result = e.target!.result as string; 39 | if (file.type.startsWith('image/svg+xml')) { 40 | setImageSrc({data: result, type: 'svg'}); 41 | if (onImageChange) { 42 | onImageChange({data: result, type: 'svg'}); 43 | } 44 | } else if (file.type.startsWith('image/')) { 45 | const img = new Image(); 46 | img.src = result; 47 | img.onload = () => { 48 | const canvas = document.createElement('canvas'); 49 | canvas.width = size; 50 | canvas.height = size; 51 | 52 | const scale = Math.min(img.width / size, img.height / size); 53 | const scaledWidth = img.width / scale; 54 | const scaledHeight = img.height / scale; 55 | const xOffset = (scaledWidth - size) / 2; 56 | const yOffset = (scaledHeight - size) / 2; 57 | 58 | const ctx = canvas.getContext('2d'); 59 | if (ctx) { 60 | // Assuming img.width or img.height could be larger, find the smaller dimension 61 | const minDimension = Math.min(img.width, img.height); 62 | 63 | // Calculate the top left x,y position to start cropping to keep the crop centered 64 | const sx = (img.width - minDimension) / 2; 65 | const sy = (img.height - minDimension) / 2; 66 | 67 | // Draw the cropped and resized version of the image on the canvas 68 | // Here the source crop (sx, sy, minDimension, minDimension) is drawn onto the canvas at full size (size, size) 69 | ctx.drawImage(img, sx, sy, minDimension, minDimension, 0, 0, size, size); 70 | } 71 | 72 | const resizedImgDataURL = canvas.toDataURL('image/png'); 73 | setImageSrc({data: resizedImgDataURL, type: 'raster'}); 74 | if (onImageChange) { 75 | onImageChange({data: resizedImgDataURL, type: 'raster'}); 76 | } 77 | }; 78 | } 79 | }; 80 | reader.readAsDataURL(file); 81 | } 82 | }; 83 | 84 | return ( 85 |
86 | 87 |
88 | {imageSrc.data ? ( 89 | User Avatar 94 | ) : ( 95 |
106 | 117 | 118 | 119 |
120 | )} 121 | {!readOnly && ( // Conditionally render the input based on readOnly status 122 | 135 | )} 136 |
137 |
138 | ); 139 | }; 140 | 141 | export default AvatarFieldEditor; 142 | -------------------------------------------------------------------------------- /src/components/Button.css: -------------------------------------------------------------------------------- 1 | .chat-action-button:hover svg path, 2 | .chat-action-button:hover span { 3 | color: black; 4 | stroke: black; 5 | } 6 | 7 | .dark .chat-action-button:hover svg path, 8 | .dark .chat-action-button:hover span { 9 | color: white; 10 | stroke: white; 11 | } 12 | 13 | .chat-action-button.active svg path, 14 | .chat-action-button.active span { 15 | color: black; 16 | stroke: black; 17 | } 18 | 19 | .dark .chat-action-button.active svg path, 20 | .dark .chat-action-button.active span { 21 | color: white; 22 | stroke: white; 23 | } -------------------------------------------------------------------------------- /src/components/Button.tsx: -------------------------------------------------------------------------------- 1 | // Button.tsx 2 | import React, {FC} from 'react'; 3 | 4 | interface ButtonProps { 5 | onClick: () => void; 6 | children: React.ReactNode; 7 | variant?: 'primary' | 'secondary' | 'critical'; 8 | className?: string; 9 | disabled?: boolean; 10 | } 11 | 12 | const Button: FC = ({ 13 | onClick, 14 | children, 15 | variant = 'primary', 16 | className, 17 | disabled = false, 18 | }) => { 19 | const baseStyle = "py-2 px-4 rounded-sm font-medium cursor-pointer"; 20 | let variantStyle = ""; 21 | 22 | if (variant === 'primary') { 23 | variantStyle = disabled 24 | ? "bg-gray-200 dark:bg-gray-700 text-gray-400 dark:text-gray-600 border-gray-300" 25 | : "bg-white dark:bg-black text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-900 border border-gray-800 dark:border-gray-300"; 26 | } else if (variant === 'secondary') { 27 | variantStyle = disabled 28 | ? "bg-transparent text-gray-400 border-gray-300" 29 | : "bg-transparent text-gray-800 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 border border-gray-300 dark:border-gray-700"; 30 | } else if (variant === 'critical') { 31 | variantStyle = disabled 32 | ? "bg-gray-500 text-white hover:bg-gray-700" 33 | : "bg-red-700 text-white hover:bg-red-500"; 34 | } 35 | 36 | const styles = `${baseStyle} ${variantStyle} ${className || ''}`; 37 | 38 | const handleClick = (e: React.MouseEvent) => { 39 | if (!disabled) { 40 | e.stopPropagation(); 41 | e.preventDefault(); 42 | onClick(); 43 | } 44 | }; 45 | 46 | 47 | return ( 48 | 55 | ); 56 | }; 57 | 58 | export default Button; 59 | -------------------------------------------------------------------------------- /src/components/Chat.tsx: -------------------------------------------------------------------------------- 1 | import React, {useContext, useEffect, useRef, useState} from 'react'; 2 | import ChatBlock from "./ChatBlock"; 3 | import ModelSelect from "./ModelSelect"; 4 | import {OpenAIModel} from "../models/model"; 5 | import {ChatService} from "../service/ChatService"; 6 | import {ChatMessage} from "../models/ChatCompletion"; 7 | import {useTranslation} from 'react-i18next'; 8 | import Tooltip from "./Tooltip"; 9 | import {Conversation} from "../service/ConversationService"; 10 | import {OPENAI_DEFAULT_SYSTEM_PROMPT} from "../config"; 11 | import {DEFAULT_INSTRUCTIONS} from "../constants/appConstants"; 12 | import {UserContext} from '../UserContext'; 13 | import {InformationCircleIcon} from "@heroicons/react/24/outline"; 14 | import {NotificationService} from '../service/NotificationService'; 15 | 16 | interface Props { 17 | chatBlocks: ChatMessage[]; 18 | onChatScroll: (isAtBottom: boolean) => void; 19 | allowAutoScroll: boolean; 20 | model: string | null; 21 | onModelChange: (value: string | null) => void; 22 | conversation: Conversation | null; 23 | loading: boolean; 24 | } 25 | 26 | const Chat: React.FC = ({ 27 | chatBlocks, onChatScroll, allowAutoScroll, model, 28 | onModelChange, conversation, loading 29 | }) => { 30 | const {userSettings, setUserSettings} = useContext(UserContext); 31 | const {t} = useTranslation(); 32 | const [models, setModels] = useState([]); 33 | const chatDivRef = useRef(null); 34 | 35 | useEffect(() => { 36 | ChatService.getModels() 37 | .then(models => { 38 | setModels(models); 39 | }) 40 | .catch(err => { 41 | NotificationService.handleUnexpectedError(err, 'Failed to get list of models'); 42 | }); 43 | 44 | }, []); 45 | 46 | useEffect(() => { 47 | if (chatDivRef.current && allowAutoScroll) { 48 | chatDivRef.current.scrollTop = chatDivRef.current.scrollHeight; 49 | } 50 | }, [chatBlocks]); 51 | 52 | useEffect(() => { 53 | const chatContainer = chatDivRef.current; 54 | if (chatContainer) { 55 | const isAtBottom = 56 | chatContainer.scrollHeight - chatContainer.scrollTop === 57 | chatContainer.clientHeight; 58 | 59 | // Initially hide the button if chat is at the bottom 60 | onChatScroll(isAtBottom); 61 | } 62 | }, []); 63 | 64 | const findModelById = (id: string | null): OpenAIModel | undefined => { 65 | return models.find(model => model.id === id); 66 | }; 67 | 68 | const formatContextWindow = (context_window: number | undefined) => { 69 | if (context_window) { 70 | return Math.round(context_window / 1000) + 'k'; 71 | } 72 | return '?k'; 73 | } 74 | 75 | const handleScroll = () => { 76 | if (chatDivRef.current) { 77 | const scrollThreshold = 20; 78 | const isAtBottom = 79 | chatDivRef.current.scrollHeight - 80 | chatDivRef.current.scrollTop <= 81 | chatDivRef.current.clientHeight + scrollThreshold; 82 | 83 | // Notify parent component about the auto-scroll status 84 | onChatScroll(isAtBottom); 85 | 86 | // Disable auto-scroll if the user scrolls up 87 | if (!isAtBottom) { 88 | onChatScroll(false); 89 | } 90 | } 91 | }; 92 | 93 | return ( 94 |
95 |
96 |
98 |
99 | {!conversation ? '' : ( 100 | 103 | 104 | 105 | 106 | 107 | )} 108 | 109 | {t('model')} 110 | {conversation && ( 111 | 112 | 113 | {conversation.model} 114 | 115 | 116 | {formatContextWindow(findModelById(conversation.model)?.context_window)} 117 | 118 | 119 | 120 | 121 | {findModelById(conversation.model)?.knowledge_cutoff} 122 | 123 | 124 | 125 | ) 126 | } 127 | 128 | {!conversation && ( 129 | 130 | 131 | 132 | )} 133 |
134 |
135 | {chatBlocks.map((block, index) => ( 136 | 140 | ))} 141 |
142 |
143 |
144 | ); 145 | }; 146 | 147 | export default Chat; 148 | -------------------------------------------------------------------------------- /src/components/ChatBlock.tsx: -------------------------------------------------------------------------------- 1 | import React, {ChangeEvent, KeyboardEvent, useContext, useEffect, useRef, useState} from 'react'; 2 | import {SparklesIcon, UserCircleIcon} from "@heroicons/react/24/outline"; 3 | import MarkdownBlock from './MarkdownBlock'; 4 | import CopyButton, {CopyButtonMode} from "./CopyButton"; 5 | import {ChatMessage, MessageType} from "../models/ChatCompletion"; 6 | import UserContentBlock from "./UserContentBlock"; 7 | import { UserContext } from "../UserContext"; 8 | import TextToSpeechButton from "./TextToSpeechButton"; 9 | 10 | interface Props { 11 | block: ChatMessage; 12 | loading: boolean; 13 | isLastBlock: boolean; 14 | } 15 | 16 | const ChatBlock: React.FC = ({block, loading, isLastBlock}) => { 17 | const [isEdit, setIsEdit] = useState(false); 18 | const [editedBlockContent, setEditedBlockContent] = useState(''); 19 | const contentRef = useRef(null); 20 | const textareaRef = useRef(null); 21 | const [savedHeight, setSavedHeight] = useState(null); 22 | const { userSettings } = useContext(UserContext); 23 | 24 | const errorStyles = block.messageType === MessageType.Error ? { 25 | backgroundColor: userSettings.theme === 'dark' ? 'rgb(50, 36, 36)' : '#F5E6E6', 26 | borderColor: 'red', 27 | borderWidth: '1px', 28 | borderRadius: '8px', 29 | padding: '10px' 30 | } : {}; 31 | 32 | 33 | useEffect(() => { 34 | if (isEdit) { 35 | textareaRef.current?.focus(); 36 | textareaRef.current?.setSelectionRange(0, 0); 37 | } 38 | }, [isEdit]); 39 | 40 | 41 | const handleRegenerate = () => { 42 | } 43 | 44 | const handleEdit = () => { 45 | if (contentRef.current) { 46 | setSavedHeight(`${contentRef.current.offsetHeight}px`); 47 | } 48 | setIsEdit(true); 49 | setEditedBlockContent(block.content); 50 | } 51 | const handleEditSave = () => { 52 | // todo: notify main to change content block 53 | setIsEdit(false); 54 | } 55 | 56 | const handleEditCancel = () => { 57 | setIsEdit(false); 58 | } 59 | 60 | const checkForSpecialKey = (e: KeyboardEvent) => { 61 | const isEnter = (e.key === 'Enter'); 62 | const isEscape = (e.key === 'Escape'); 63 | 64 | if (isEnter) { 65 | e.preventDefault(); 66 | handleEditSave(); 67 | } else if (isEscape) { 68 | e.preventDefault(); 69 | handleEditCancel(); 70 | } 71 | }; 72 | 73 | const handleTextChange = (event: ChangeEvent) => { 74 | setEditedBlockContent(event.target.value); 75 | }; 76 | 77 | return ( 78 |
81 |
83 |
84 |
85 |
86 | {block.role === 'user' ? ( 87 | 88 | ) : block.role === 'assistant' ? ( 89 | 90 | ) : null} 91 |
92 |
93 |
94 |
96 |
98 | {isEdit ? ( 99 | 109 | ) 110 | : ( 111 |
113 | {block.role === 'user' ? ( 114 | 115 | ) : ( 116 | 118 | )} 119 |
)} 120 | 121 |
122 |
123 |
124 |
125 | {!(isLastBlock && loading) && ( 126 |
127 | {block.role === 'assistant' && ( 128 | 129 | )} 130 |
131 | 132 |
133 | {/* {block.role === 'assistant' && ( 134 |
135 | 138 |
139 | )} 140 |
141 | 144 |
*/} 145 |
146 | )} 147 |
148 |
149 | ); 150 | }; 151 | 152 | export default ChatBlock; 153 | -------------------------------------------------------------------------------- /src/components/ChatSettingsForm.tsx: -------------------------------------------------------------------------------- 1 | import React, {ChangeEvent, useEffect, useState} from 'react'; 2 | import AvatarFieldEditor, {ImageSource} from "./AvatarFieldEditor"; 3 | import 'rc-slider/assets/index.css'; 4 | import ModelSelect from './ModelSelect'; 5 | import TemperatureSlider from './TemperatureSlider'; 6 | import TopPSlider from './TopPSlider'; 7 | import {ChatSettings} from '../models/ChatSettings'; 8 | import {EditableField} from './EditableField'; 9 | import {useTranslation} from 'react-i18next'; 10 | import {NotificationService} from "../service/NotificationService"; 11 | import FormLabel from "./FormLabel"; 12 | import {DEFAULT_MODEL} from "../constants/appConstants"; 13 | 14 | interface ChatSettingsFormProps { 15 | chatSettings?: ChatSettings; 16 | readOnly?: boolean; 17 | onChange?: (updatedChatSettings: ChatSettings) => void; 18 | } 19 | 20 | const DUMMY_CHAT_SETTINGS: ChatSettings = { 21 | id: Date.now(), 22 | author: 'user', 23 | icon: null, 24 | name: '', 25 | description: '', 26 | instructions: 'You are a helpful assistant.', 27 | model: null, 28 | seed: null, 29 | temperature: null, 30 | top_p: null 31 | }; 32 | 33 | const ChatSettingsForm: React.FC = ({chatSettings, readOnly = false, onChange = undefined}) => { 34 | const [formData, setFormData] = useState(chatSettings || DUMMY_CHAT_SETTINGS); 35 | const {t} = useTranslation(); 36 | 37 | useEffect(() => { 38 | if (onChange) { 39 | onChange(formData); 40 | } 41 | }, [formData]); 42 | 43 | useEffect(() => { 44 | setFormData(chatSettings || DUMMY_CHAT_SETTINGS); 45 | }, [chatSettings]); 46 | 47 | const onImageChange = (image: ImageSource) => { 48 | setFormData({...formData, icon: image}); 49 | }; 50 | 51 | const handleInputChange = ( 52 | event: ChangeEvent 53 | ) => { 54 | const {name, value, type} = event.target; 55 | if (type === 'number') { 56 | setFormData({...formData, [name]: value ? parseFloat(value) : null}); 57 | } else { 58 | setFormData({...formData, [name]: value}); 59 | } 60 | }; 61 | 62 | const handleSubmit = (event: React.FormEvent) => { 63 | event.preventDefault(); 64 | NotificationService.handleSuccess('Form submitted successfully.'); 65 | }; 66 | 67 | return ( 68 |
69 |
70 | 73 |
74 | 76 | {readOnly ? 77 |

{formData.name || t('non-applicable')}

: 78 | } 89 |
90 |
91 | 93 | {readOnly ? 94 |

{formData.description || t('non-applicable')}

: 95 | } 102 |
103 |
104 | 106 | {readOnly ? 107 |

{formData.instructions || t('non-applicable')}

: 108 | } 115 |
116 |
117 | 118 | readOnly={readOnly} 119 | id="model" 120 | label={t('model-header')} 121 | value={formData.model} 122 | defaultValue={null} 123 | defaultValueLabel={DEFAULT_MODEL} 124 | editorComponent={(props) => 125 | } 129 | onValueChange={(value: string | null) => { 130 | setFormData({...formData, model: value}); 131 | }} 132 | /> 133 |
134 |
135 | 137 | {readOnly ? 138 |

{formData.seed || t('non-applicable')}

: 139 | } 146 |
147 | 148 | readOnly={readOnly} 149 | id="temperature" 150 | label={t('temperature-header')} 151 | value={formData.temperature} 152 | defaultValue={1.0} 153 | defaultValueLabel="1.0" 154 | editorComponent={TemperatureSlider} 155 | onValueChange={(value: number | null) => { 156 | setFormData({...formData, temperature: value}); 157 | }} 158 | /> 159 | 160 | readOnly={readOnly} 161 | id="top_p" 162 | label={t('top-p-header')} 163 | value={formData.top_p} 164 | defaultValue={1.0} 165 | defaultValueLabel="1.0" 166 | editorComponent={TopPSlider} 167 | onValueChange={(value: number | null) => { 168 | setFormData({...formData, top_p: value}); 169 | }} 170 | /> 171 | 172 |
173 | ); 174 | }; 175 | 176 | export default ChatSettingsForm; 177 | -------------------------------------------------------------------------------- /src/components/ChatSettingsList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {useNavigate} from 'react-router-dom'; 3 | import {ChatSettings} from '../models/ChatSettings'; 4 | import CubeIcon from '@heroicons/react/24/outline/CubeIcon'; 5 | import './ExploreCustomChats.css' 6 | import ChatSettingDropdownMenu from './ChatSettingDropdownMenu'; 7 | 8 | interface ChatSettingsListProps { 9 | chatSettings: ChatSettings[]; 10 | } 11 | 12 | const ChatSettingsList: React.FC = ({chatSettings}) => { 13 | const navigate = useNavigate(); 14 | 15 | const navigateToChatSetting = (id: number) => { 16 | navigate(`/g/${id}`, {state: {reset: Date.now()}}); 17 | }; 18 | 19 | return ( 20 |
21 | {chatSettings.map((setting) => ( 22 |
navigateToChatSetting(setting.id)} 25 | className="flex items-center gap-4 cursor-pointer p-3 bg-gray-100 hover:bg-gray-200 rounded-lg dark:bg-gray-700 dark:hover:bg-gray-600 relative" 26 | > 27 |
28 | 30 |
31 |
32 |
33 | {(setting.icon && setting.icon.data) ? ( 34 | 35 | ) : ( 36 | 37 | )} 38 |
39 |
40 |
41 | {setting.name} 43 | {setting.description} 45 |
46 |
47 | ))} 48 |
49 | ); 50 | }; 51 | export default ChatSettingsList; 52 | -------------------------------------------------------------------------------- /src/components/ChatShortcuts.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState} from 'react'; 2 | import {Link} from 'react-router-dom'; 3 | import chatSettingsDB, {chatSettingsEmitter} from '../service/ChatSettingsDB'; 4 | import {ChatSettings} from '../models/ChatSettings'; 5 | import CubeIcon from '@heroicons/react/24/outline/CubeIcon'; 6 | 7 | const ChatShortcuts: React.FC = () => { 8 | const [chatSettings, setChatSettings] = useState([]); 9 | 10 | const loadChatSettings = async () => { 11 | const filteredAndSortedChatSettings = await chatSettingsDB.chatSettings 12 | .where('showInSidebar').equals(1) 13 | .sortBy('name'); 14 | setChatSettings(filteredAndSortedChatSettings); 15 | }; 16 | 17 | const onDatabaseUpdate = (data: any) => { 18 | loadChatSettings(); 19 | }; 20 | 21 | useEffect(() => { 22 | chatSettingsEmitter.on('chatSettingsChanged', onDatabaseUpdate); 23 | loadChatSettings(); 24 | return () => { 25 | chatSettingsEmitter.off('chatSettingsChanged', onDatabaseUpdate); 26 | }; 27 | }, []); 28 | 29 | return ( 30 |
31 | {chatSettings.map((setting) => ( 32 | 34 |
35 | {setting.icon?.data ? ( 36 | 38 | ) : ( 39 | 40 | )} 41 |
42 | 44 | {setting.name} 45 | 46 | 47 | ))} 48 |
49 | ); 50 | }; 51 | 52 | export default ChatShortcuts; 53 | -------------------------------------------------------------------------------- /src/components/ConfirmDialog.tsx: -------------------------------------------------------------------------------- 1 | import React, {useCallback, useState} from 'react'; 2 | import Button from './Button'; 3 | import ReactDOM from 'react-dom'; 4 | 5 | interface DialogOptions { 6 | message: string; 7 | confirmText?: string; 8 | onConfirm: () => void; 9 | onCancel?: () => void; 10 | confirmButtonVariant?: 'primary' | 'secondary' | 'critical'; 11 | } 12 | 13 | interface ConfirmDialogProps { 14 | isOpen: boolean; 15 | message: string; 16 | confirmText: string; 17 | onConfirm: () => void; 18 | onCancel: () => void; 19 | confirmButtonVariant?: 'primary' | 'secondary' | 'critical'; 20 | } 21 | 22 | type UseDialogOptions = Omit; 23 | 24 | const ConfirmDialog: React.FC = ({ 25 | isOpen, 26 | message, 27 | confirmText, 28 | onConfirm, 29 | onCancel, 30 | confirmButtonVariant 31 | }) => { 32 | if (!isOpen) { 33 | return null; 34 | } 35 | 36 | return ReactDOM.createPortal( 37 | ( 38 |
39 |
40 |
41 |

{message}

42 |
43 | 49 | 55 |
56 |
57 |
58 |
59 | ), 60 | document.getElementById('modal-root')! 61 | ); 62 | }; 63 | 64 | export const useConfirmDialog = () => { 65 | const [isOpen, setIsOpen] = useState(false); 66 | const [dialogProps, setDialogProps] = useState({ 67 | message: '', 68 | confirmText: 'OK', 69 | onConfirm: () => { 70 | setIsOpen(false); 71 | }, 72 | onCancel: () => { 73 | setIsOpen(false); 74 | }, 75 | }); 76 | 77 | const showConfirmDialog = useCallback((options: DialogOptions) => { 78 | setDialogProps({ 79 | message: options.message, 80 | confirmText: options.confirmText || 'OK', 81 | onConfirm: options.onConfirm, 82 | onCancel: options.onCancel || (() => setIsOpen(false)), 83 | confirmButtonVariant: options.confirmButtonVariant, 84 | }); 85 | setIsOpen(true); 86 | }, []); 87 | 88 | const handleConfirm = useCallback(() => { 89 | dialogProps.onConfirm(); 90 | setIsOpen(false); 91 | }, [dialogProps]); 92 | 93 | const handleCancel = useCallback(() => { 94 | dialogProps.onCancel?.(); 95 | setIsOpen(false); 96 | }, [dialogProps]); 97 | 98 | // Return showDialog function and Dialog component 99 | return { 100 | showConfirmDialog, 101 | ConfirmDialog: isOpen ? ( 102 | 110 | ) : null, 111 | isOpen 112 | }; 113 | }; 114 | 115 | export default ConfirmDialog; 116 | -------------------------------------------------------------------------------- /src/components/ConversationListItem.tsx: -------------------------------------------------------------------------------- 1 | import React, {useRef, useState} from 'react'; 2 | import {useNavigate} from 'react-router-dom'; 3 | import {ChatBubbleLeftIcon, CheckIcon, PencilSquareIcon, TrashIcon, XMarkIcon} from "@heroicons/react/24/outline"; 4 | import ConversationService, {Conversation} from "../service/ConversationService"; 5 | import {iconProps} from "../svg"; 6 | import {MAX_TITLE_LENGTH} from "../constants/appConstants"; 7 | 8 | interface ConversationListItemProps { 9 | convo: Conversation; 10 | isSelected: boolean; 11 | loadConversations: () => void; 12 | setSelectedId: (id: number) => void; 13 | } 14 | 15 | const ConversationListItem: React.FC = ({ 16 | convo, 17 | isSelected, 18 | loadConversations, 19 | setSelectedId 20 | }) => { 21 | const [isEditingTitle, setIsEditingTitle] = useState(false); 22 | const [editedTitle, setEditedTitle] = useState(convo.title); 23 | const navigate = useNavigate(); 24 | const acceptButtonRef = useRef(null); 25 | 26 | const saveEditedTitle = () => { 27 | ConversationService.updateConversationPartial(convo, {title: editedTitle}) 28 | .then(() => { 29 | setIsEditingTitle(false); 30 | loadConversations(); // Reload conversations to reflect the updated title 31 | }) 32 | .catch((error) => { 33 | console.error('Error updating conversation title:', error); 34 | }); 35 | }; 36 | 37 | const deleteConversation = () => { 38 | ConversationService.deleteConversation(convo.id) 39 | .then(() => { 40 | loadConversations(); // Reload conversations to reflect the deletion 41 | }) 42 | .catch((error) => { 43 | console.error('Error deleting conversation:', error); 44 | }); 45 | }; 46 | 47 | const selectConversation = () => { 48 | if (isEditingTitle) { 49 | // If in edit mode, cancel edit mode and select the new conversation 50 | setIsEditingTitle(false); 51 | setEditedTitle(''); // Clear editedTitle 52 | } else { 53 | // If not in edit mode, simply select the conversation 54 | } 55 | setSelectedId(convo.id); 56 | if (!isEditingTitle) { 57 | const url = convo.gid ? `/g/${convo.gid}/c/${convo.id}` : `/c/${convo.id}`; 58 | navigate(url); 59 | } 60 | }; 61 | 62 | const toggleEditMode = (convo: Conversation) => { 63 | if (!isEditingTitle) { 64 | // Entering edit mode, initialize editedTitle with convo.title 65 | setEditedTitle(convo.title); 66 | } else { 67 | // Exiting edit mode, clear editedTitle 68 | setEditedTitle(''); 69 | } 70 | setIsEditingTitle(!isEditingTitle); 71 | }; 72 | 73 | const handleTitleInputKeyPress = (e: React.KeyboardEvent, conversation: Conversation) => { 74 | if (e.key === 'Enter') { 75 | // Save the edited title when Enter key is pressed 76 | saveEditedTitle(); 77 | } else if (e.key === 'Escape') { 78 | setIsEditingTitle(false); 79 | } 80 | }; 81 | 82 | const handleInputBlur = (e: React.FocusEvent, conversation: Conversation) => { 83 | if (acceptButtonRef.current) { 84 | saveEditedTitle(); 85 | } 86 | // Check if the blur event was not caused by pressing the Enter key 87 | // If in edit mode and the input loses focus, cancel the edit 88 | setEditedTitle(conversation.title); 89 | setIsEditingTitle(false); 90 | }; 91 | 92 | const handleContextMenu = (e: React.MouseEvent) => { 93 | setIsEditingTitle(false); 94 | }; 95 | 96 | if (isSelected) { 97 | return ( 98 |
  • 99 |
    103 | 104 | {isEditingTitle ? ( 105 |
    107 | setEditedTitle(e.target.value)} 112 | onKeyDown={(e) => handleTitleInputKeyPress(e, convo)} 113 | autoFocus={true} 114 | maxLength={MAX_TITLE_LENGTH} 115 | style={{width: "10em"}} 116 | onBlur={(e) => { 117 | if (isEditingTitle) { 118 | handleInputBlur(e, convo); 119 | } 120 | }} 121 | /> 122 |
    123 | ) : ( 124 |
    126 | {convo.title} 127 |
    128 | )} 129 |
    131 | {isEditingTitle ? ( 132 | <> 133 | 143 | 152 | 153 | ) : ( 154 | <> 155 | 161 | 167 | 168 | )} 169 |
    170 |
    171 |
  • 172 | ); 173 | } else { 174 | return ( 175 |
  • 176 | 187 |
  • 188 | ); 189 | } 190 | } 191 | 192 | export default ConversationListItem; 193 | -------------------------------------------------------------------------------- /src/components/CopyButton.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState} from "react"; 2 | import {CheckIcon, ClipboardIcon} from "@heroicons/react/24/outline"; 3 | import {iconProps} from "../svg"; 4 | import {useTranslation} from 'react-i18next'; 5 | import "./Button.css" 6 | import Tooltip from "./Tooltip"; 7 | 8 | export enum CopyButtonMode { 9 | Normal = "normal", 10 | Compact = "compact", 11 | } 12 | 13 | interface CopyButtonProps { 14 | text: string; 15 | mode?: CopyButtonMode; 16 | className?: string; 17 | } 18 | 19 | const CopyButton = ({text, mode = CopyButtonMode.Normal, className = ''}: CopyButtonProps) => { 20 | const {t} = useTranslation(); 21 | const [isCopied, setIsCopied] = useState(false); 22 | 23 | useEffect(() => { 24 | let timeoutId: NodeJS.Timeout | null = null; 25 | 26 | if (isCopied) { 27 | timeoutId = setTimeout(() => { 28 | setIsCopied(false); 29 | }, 2000); 30 | } 31 | 32 | return () => { 33 | if (timeoutId) { 34 | clearTimeout(timeoutId); 35 | } 36 | }; 37 | }, [isCopied]); 38 | 39 | const handleCopyClick = () => { 40 | navigator.clipboard.writeText(text); 41 | setIsCopied(true); 42 | 43 | if (mode === CopyButtonMode.Compact) { 44 | setTimeout(() => { 45 | setIsCopied(false); 46 | }, 2000); 47 | } 48 | }; 49 | 50 | const shouldWrapInTooltip = mode !== CopyButtonMode.Normal; 51 | const buttonContent = ( 52 | <> 53 | {isCopied ? ( 54 | <> 55 | 56 | {mode === CopyButtonMode.Normal && {t('copied')}} 57 | 58 | ) : ( 59 | <> 60 | 61 | {mode === CopyButtonMode.Normal && {t('copy-code')}} 62 | 63 | )} 64 | 65 | ); 66 | return shouldWrapInTooltip ? ( 67 | 68 | 73 | 74 | ) : ( 75 | 80 | ); 81 | }; 82 | 83 | export default CopyButton; 84 | -------------------------------------------------------------------------------- /src/components/CustomChatEditor.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState} from "react"; 2 | import {useLocation, useNavigate, useParams} from "react-router-dom"; 3 | import ChatSettingsForm from "./ChatSettingsForm"; 4 | import {ChatSettings} from "../models/ChatSettings"; 5 | import chatSettingsDB, {getChatSettingsById} from "../service/ChatSettingsDB"; 6 | import Button from "./Button"; 7 | import {useTranslation} from 'react-i18next'; 8 | 9 | const CustomChatEditor: React.FC = () => { 10 | const navigate = useNavigate(); 11 | const location = useLocation(); 12 | const {id} = useParams(); 13 | const isEditing = Boolean(id); 14 | const initialChatSettings: ChatSettings = { 15 | id: isEditing ? parseInt(id!) : Date.now(), 16 | author: 'user', 17 | icon: null, 18 | name: '', 19 | description: '', 20 | instructions: 'You are a helpful assistant.', 21 | model: null, 22 | seed: null, 23 | temperature: null, 24 | top_p: null 25 | }; 26 | const {t} = useTranslation(); 27 | const [chatSettings, setChatSettings] = useState(initialChatSettings); 28 | 29 | useEffect(() => { 30 | let stateChatSetting = location.state?.initialChatSetting as ChatSettings | undefined; 31 | if (stateChatSetting) { 32 | stateChatSetting.id = Date.now(); 33 | setChatSettings(stateChatSetting); 34 | } else if (isEditing && id) { 35 | const fetchChatSettings = async () => { 36 | const existingSettings = await getChatSettingsById(parseInt(id)); 37 | if (existingSettings) { 38 | setChatSettings(existingSettings); 39 | } 40 | }; 41 | fetchChatSettings(); 42 | } else { 43 | setChatSettings(initialChatSettings); 44 | } 45 | }, [id, isEditing, location.state]); 46 | 47 | const handleSave = async () => { 48 | if (isEditing) { 49 | await chatSettingsDB.chatSettings.update(chatSettings.id, chatSettings); 50 | } else { 51 | await chatSettingsDB.chatSettings.add(chatSettings); 52 | } 53 | navigate('/explore'); 54 | }; 55 | 56 | const handleCancel = () => { 57 | navigate('/explore'); 58 | }; 59 | 60 | const onChange = (updatedChatSettings: ChatSettings) => { 61 | setChatSettings(updatedChatSettings); 62 | }; 63 | 64 | return ( 65 |
    66 | 67 |
    68 | 75 | 82 |
    83 |
    84 | ); 85 | }; 86 | 87 | export default CustomChatEditor; 88 | -------------------------------------------------------------------------------- /src/components/CustomChatSplash.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {ChatSettings} from '../models/ChatSettings'; 3 | import CubeIcon from "@heroicons/react/24/outline/CubeIcon"; 4 | 5 | interface CustomChatSplashProps { 6 | chatSettings: ChatSettings; 7 | className?: string; 8 | } 9 | 10 | const CustomChatSplash: React.FC = ({chatSettings, className,}) => { 11 | 12 | return ( 13 |
    14 |
    15 |
    16 |
    17 | {(chatSettings.icon && chatSettings.icon.data) ? ( 18 | 19 | ) : ( 20 | 21 | )} 22 |
    23 |
    24 |
    25 |
    26 |
    {chatSettings.name}
    27 |
    {chatSettings.description}
    29 |
    30 |
    31 | ); 32 | }; 33 | 34 | export default CustomChatSplash; 35 | -------------------------------------------------------------------------------- /src/components/EditableField.tsx: -------------------------------------------------------------------------------- 1 | import React, {ReactElement, useState} from 'react'; 2 | import FormLabel from "./FormLabel"; 3 | import {useTranslation} from 'react-i18next'; 4 | 5 | export type EditorComponentProps = { 6 | id: string; 7 | onValueChange: (value: T) => void; 8 | value: T; 9 | }; 10 | 11 | export type EditableFieldProps = { 12 | id: string; 13 | label: string; 14 | value?: T | null; 15 | defaultValue: T; 16 | defaultValueLabel: string; 17 | editorComponent: React.ComponentType>; 18 | onValueChange: (value: T) => void; 19 | readOnly?: boolean; 20 | isModalLabel?: boolean; 21 | }; 22 | 23 | export function EditableField({ 24 | id, 25 | label, 26 | value, 27 | defaultValue, 28 | defaultValueLabel, 29 | editorComponent: EditorComponent, 30 | onValueChange, 31 | readOnly, 32 | isModalLabel, 33 | }: EditableFieldProps): ReactElement { 34 | const [isEditing, setIsEditing] = useState(false); 35 | const effectiveValue = value !== undefined && value !== null ? value : defaultValue; 36 | const [tempValue, setTempValue] = useState(effectiveValue); 37 | const {t} = useTranslation(); 38 | 39 | const isValueSet = (): boolean => { 40 | return value !== undefined && value !== null; 41 | }; 42 | 43 | const handleEdit = () => { 44 | setIsEditing(true); 45 | setTempValue(effectiveValue); 46 | }; 47 | 48 | const handleTempValueChange = (newValue: T) => { 49 | setTempValue(newValue); 50 | }; 51 | 52 | const handleCancel = () => { 53 | setIsEditing(false); 54 | setTempValue(effectiveValue); 55 | }; 56 | 57 | const handleOk = () => { 58 | onValueChange(tempValue); 59 | setIsEditing(false); 60 | }; 61 | 62 | function toStringRepresentation(value: T): string { 63 | if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { 64 | return String(value); 65 | } 66 | return JSON.stringify(value); 67 | } 68 | 69 | return ( 70 |
    71 | 73 | {!isEditing ? ( 74 |
    75 | 76 | {isValueSet() ? toStringRepresentation(effectiveValue) : `${defaultValueLabel} ${t('default-label')}`} 77 | 78 | {!readOnly && ( 79 | 82 | )} 83 |
    84 | ) : ( 85 | <> 86 | 87 |
    88 | 91 | {!readOnly && ( 92 | 95 | )} 96 |
    97 | 98 | )} 99 |
    100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /src/components/EditableInstructions.tsx: -------------------------------------------------------------------------------- 1 | import React, {forwardRef, useEffect, useImperativeHandle, useRef, useState} from 'react'; 2 | import {ArrowsPointingOutIcon} from "@heroicons/react/24/outline"; 3 | import {iconProps} from "../svg"; 4 | import Button from './Button'; 5 | import {useTranslation} from 'react-i18next'; 6 | 7 | interface EditableInstructionsProps { 8 | initialValue: string; 9 | placeholder: string; 10 | className?: string; 11 | onChange?: (value: string) => void; 12 | } 13 | 14 | // Use forwardRef to wrap your component 15 | const EditableInstructions = forwardRef(({ 16 | initialValue, 17 | placeholder, 18 | className = '', 19 | onChange, 20 | }: EditableInstructionsProps, ref) => { 21 | const {t} = useTranslation(); 22 | const textarea1Ref = useRef(null); 23 | const textarea2Ref = useRef(null); 24 | const currentValueRef = useRef(initialValue); 25 | const [isModalOpen, setIsModalOpen] = useState(false); 26 | 27 | useEffect(() => { 28 | const handleKeyDown = (event: KeyboardEvent) => { 29 | if (event.key === 'Escape') { 30 | setIsModalOpen(false); 31 | event.stopPropagation(); 32 | event.preventDefault(); 33 | } 34 | }; 35 | 36 | if (isModalOpen) { 37 | // When the modal is opened and textarea2 is mounted, update its value and focus 38 | if (textarea2Ref.current) { 39 | textarea2Ref.current.value = currentValueRef.current; 40 | textarea2Ref.current.focus(); 41 | } 42 | document.addEventListener('keydown', handleKeyDown, true); 43 | } 44 | 45 | return () => { 46 | document.removeEventListener('keydown', handleKeyDown, true); 47 | }; 48 | }, [isModalOpen]); 49 | 50 | 51 | // Use useImperativeHandle to expose specific properties to the parent 52 | useImperativeHandle(ref, () => ({ 53 | getCurrentValue: () => currentValueRef.current, 54 | })); 55 | 56 | const handleChange = (e: React.ChangeEvent) => { 57 | const newValue = e.target.value; 58 | currentValueRef.current = newValue; // Update the current value 59 | 60 | // Directly update the other textarea to stay in sync 61 | if (e.target === textarea1Ref.current && textarea2Ref.current) { 62 | textarea2Ref.current.value = newValue; 63 | } else if (e.target === textarea2Ref.current && textarea1Ref.current) { 64 | textarea1Ref.current.value = newValue; 65 | } 66 | 67 | if (onChange) { 68 | onChange(newValue); 69 | } 70 | }; 71 | 72 | 73 | const toggleModal = (): void => { 74 | if (isModalOpen && textarea2Ref.current) { 75 | handleChange({ 76 | target: textarea2Ref.current 77 | } as React.ChangeEvent); 78 | } 79 | setIsModalOpen(!isModalOpen); 80 | }; 81 | 82 | return ( 83 |
    84 | 96 | 103 | 104 | {isModalOpen && ( 105 |
    106 |
    107 | 116 |
    117 | 124 |
    125 |
    126 |
    127 | )} 128 |
    129 | ); 130 | }); 131 | 132 | export default EditableInstructions; 133 | -------------------------------------------------------------------------------- /src/components/ExploreCustomChats.css: -------------------------------------------------------------------------------- 1 | .chat-settings-grid { 2 | display: grid; 3 | grid-column-gap: 15px; 4 | grid-row-gap: 15px; 5 | grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); 6 | } 7 | -------------------------------------------------------------------------------- /src/components/ExploreCustomChats.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState} from 'react'; 2 | import {useNavigate} from 'react-router-dom'; 3 | import {PlusIcon} from '@heroicons/react/24/outline'; 4 | import ChatSettingsList from './ChatSettingsList'; 5 | import chatSettingsDB, {ChatSettingsChangeEvent, chatSettingsEmitter} from '../service/ChatSettingsDB'; 6 | import {ChatSettings} from '../models/ChatSettings'; 7 | import {useTranslation} from 'react-i18next'; 8 | 9 | const ExploreCustomChats: React.FC = () => { 10 | const [exampleChats, setExampleChats] = useState([]); 11 | const [myChats, setMyChats] = useState([]); 12 | const navigate = useNavigate(); 13 | const {t} = useTranslation(); 14 | 15 | const fetchChatSettings = async (event?: ChatSettingsChangeEvent) => { 16 | if (event) { 17 | const gid = event.gid; 18 | if (event.action === 'edit') { 19 | const updatedChat = await chatSettingsDB.chatSettings.get(gid); 20 | if (updatedChat) { 21 | if (updatedChat.author === 'system') { 22 | setExampleChats(prevChats => 23 | prevChats.map(chat => chat.id === gid ? updatedChat : chat) 24 | ); 25 | } else if (updatedChat.author === 'user') { 26 | setMyChats(prevChats => 27 | prevChats.map(chat => chat.id === gid ? updatedChat : chat) 28 | ); 29 | } 30 | } 31 | } else if (event.action === 'delete') { 32 | setExampleChats(prevChats => prevChats.filter(chat => chat.id !== gid)); 33 | setMyChats(prevChats => prevChats.filter(chat => chat.id !== gid)); 34 | } 35 | } else { 36 | const allChatSettings = await chatSettingsDB.chatSettings.orderBy('name').toArray(); 37 | setExampleChats(allChatSettings.filter(chat => chat.author === 'system')); 38 | setMyChats(allChatSettings.filter(chat => chat.author === 'user')); 39 | } 40 | }; 41 | 42 | useEffect(() => { 43 | fetchChatSettings(); 44 | 45 | const listener = (event: ChatSettingsChangeEvent) => { 46 | if (event?.gid) { 47 | fetchChatSettings(event); 48 | } else { 49 | fetchChatSettings(); 50 | } 51 | }; 52 | 53 | chatSettingsEmitter.on('chatSettingsChanged', listener); 54 | return () => { 55 | chatSettingsEmitter.off('chatSettingsChanged', listener); 56 | }; 57 | }, []); 58 | 59 | return ( 60 |
    62 |
    63 |

    {t('example-chats')}

    64 | 65 |

    {t('my-chats')}

    66 | 81 | 82 |
    83 |
    84 | ); 85 | }; 86 | 87 | export default ExploreCustomChats; 88 | -------------------------------------------------------------------------------- /src/components/FileDataPreview.css: -------------------------------------------------------------------------------- 1 | .file-data-tile .remove-file-button { 2 | opacity: 0; 3 | transition: opacity 0.2s; 4 | } 5 | 6 | .file-data-tile:hover .remove-file-button { 7 | opacity: 1; 8 | } 9 | -------------------------------------------------------------------------------- /src/components/FileDataPreview.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback, useEffect } from 'react'; 2 | import {createPortal} from 'react-dom'; 3 | import { NoSymbolIcon,XMarkIcon, ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline'; 4 | import { FileDataRef } from '../models/FileData'; 5 | import {useTranslation} from "react-i18next"; 6 | import Tooltip from './Tooltip'; 7 | import './FileDataPreview.css'; 8 | import {IMAGE_MAX_ZOOM} from "../constants/appConstants"; 9 | 10 | interface Props { 11 | fileDataRef: FileDataRef[]; 12 | removeFileData?: (index: number, file: FileDataRef) => void; 13 | readOnly?: boolean; 14 | allowImageAttachment?: boolean 15 | } 16 | 17 | const FileDataPreview: React.FC = ({ 18 | fileDataRef, 19 | removeFileData, 20 | readOnly = false, 21 | allowImageAttachment = true 22 | }) => { 23 | const {t} = useTranslation(); 24 | const [viewedFileIndex, setViewedFileIndex] = useState(null); 25 | const [imageStyle, setImageStyle] = useState({}); 26 | 27 | const determineAndSetImageStyle = (imgElement: HTMLImageElement) => { 28 | const naturalWidth = imgElement.naturalWidth; 29 | const naturalHeight = imgElement.naturalHeight; 30 | const maxWidth = window.innerWidth * 0.8; // 80vw 31 | const maxHeight = window.innerHeight * 0.8; // 80vh 32 | const maxZoomFactor = IMAGE_MAX_ZOOM; 33 | 34 | let width = naturalWidth; 35 | let height = naturalHeight; 36 | 37 | // Calculate the zoom factor needed to fit the image within 80vw or 80vh 38 | const widthZoomFactor = maxWidth / naturalWidth; 39 | const heightZoomFactor = maxHeight / naturalHeight; 40 | const zoomFactor = Math.min(widthZoomFactor, heightZoomFactor, maxZoomFactor); 41 | 42 | width = naturalWidth * zoomFactor; 43 | height = naturalHeight * zoomFactor; 44 | 45 | setImageStyle({width: `${width}px`, height: `${height}px`}); 46 | }; 47 | 48 | const handleRemoveFile = (event: React.MouseEvent, index: number, fileRef: FileDataRef) => { 49 | event.preventDefault(); 50 | event.stopPropagation(); 51 | if (removeFileData) { 52 | removeFileData(index, fileRef); 53 | } 54 | }; 55 | 56 | const toggleViewFile = (index: number) => { 57 | if (viewedFileIndex === index) { 58 | setImageStyle({}); 59 | setViewedFileIndex(null); 60 | } else { 61 | setViewedFileIndex(index); 62 | } 63 | }; 64 | 65 | const handleNextPrev = (direction: "next" | "prev") => { 66 | if (direction === "next" && viewedFileIndex !== null) { 67 | const nextIndex = viewedFileIndex + 1 < fileDataRef.length ? viewedFileIndex + 1 : 0; 68 | toggleViewFile(nextIndex); 69 | } else if (direction === "prev" && viewedFileIndex !== null) { 70 | const prevIndex = viewedFileIndex - 1 >= 0 ? viewedFileIndex - 1 : fileDataRef.length - 1; 71 | toggleViewFile(prevIndex); 72 | } 73 | }; 74 | 75 | const handleKeyDown = useCallback((event: KeyboardEvent) => { 76 | if (event.key === 'ArrowRight') { 77 | handleNextPrev("next"); 78 | } else if (event.key === 'ArrowLeft') { 79 | handleNextPrev("prev"); 80 | } else if (event.key === 'Escape') { 81 | // Close the full view when Escape is pressed 82 | setViewedFileIndex(null); 83 | } 84 | }, [viewedFileIndex, fileDataRef.length]); 85 | 86 | useEffect(() => { 87 | window.addEventListener('keydown', handleKeyDown); 88 | return () => { 89 | window.removeEventListener('keydown', handleKeyDown); 90 | }; 91 | }, [handleKeyDown]); 92 | 93 | const renderFileData = (fileRef: FileDataRef, index: number) => ( 94 |
    95 |
    96 |
    97 | 122 |
    123 |
    124 | {!readOnly && ( 125 | 126 | 133 | 134 | )} 135 |
    136 | ); 137 | 138 | const renderFullViewFile = () => viewedFileIndex !== null && createPortal( 139 |
    setViewedFileIndex(null)} 142 | style={{backgroundColor: 'rgba(0, 0, 0, 0.5)'}} 143 | > 144 |
    145 | {viewedFileIndex > 0 && ( 146 | 156 | )} 157 | determineAndSetImageStyle(e.currentTarget)} 160 | style={{ 161 | ...imageStyle, 162 | boxShadow: '0 4px 6px rgba(0, 0, 0, 0.5)' 163 | }} 164 | alt="Full view" 165 | /> 166 | {viewedFileIndex < fileDataRef.length - 1 && ( 167 | 177 | 178 | )} 179 |
    180 |
    , 181 | document.body 182 | ); 183 | 184 | return ( 185 |
    186 | {fileDataRef.map(renderFileData)} 187 | {renderFullViewFile()} 188 |
    189 | ); 190 | }; 191 | 192 | export default FileDataPreview; 193 | -------------------------------------------------------------------------------- /src/components/FoldableTextSection.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | display: flex; 3 | position: relative; 4 | padding-left: 0.5em; 5 | } 6 | 7 | .line::before { 8 | content: ''; 9 | position: absolute; 10 | left: -4px; 11 | top: 0; 12 | bottom: 0; 13 | width: 10px; 14 | background-color: transparent; 15 | cursor: pointer; 16 | box-sizing: border-box; 17 | } 18 | 19 | .line::before { 20 | background: linear-gradient(to right, transparent 4px, #E4E4E4 4px, #E4E4E4 6px, transparent 6px); 21 | } 22 | 23 | .line:hover::before { 24 | background: linear-gradient(to right, transparent 4px, #434343 4px, #434343 6px, transparent 6px); 25 | } 26 | 27 | .dark .line::before { 28 | background: linear-gradient(to right, transparent 4px, #404040 4px, #303030 6px, transparent 6px); 29 | } 30 | 31 | .dark .line:hover::before { 32 | background: linear-gradient(to right, transparent 4px, #ACACAC 4px, #ACACAC 6px, transparent 6px); 33 | } 34 | -------------------------------------------------------------------------------- /src/components/FoldableTextSection.tsx: -------------------------------------------------------------------------------- 1 | import React, {CSSProperties, useRef, useState} from "react"; 2 | import {useTranslation} from "react-i18next"; 3 | import {ChevronDownIcon, ChevronUpIcon} from "@heroicons/react/24/outline"; 4 | import "./FoldableTextSection.css"; // Make sure this is correctly imported 5 | 6 | interface FoldableTextSectionProps { 7 | content: string; 8 | } 9 | 10 | const FoldableTextSection: React.FC = ({content}) => { 11 | const {t} = useTranslation(); 12 | const [isExpanded, setIsExpanded] = useState(false); 13 | const topOfDivRef = useRef(null); // Create a ref for the button 14 | 15 | const toggleSection = () => { 16 | const scrollPositionBeforeToggle = window.scrollY; 17 | const rectBeforeToggle = topOfDivRef.current?.getBoundingClientRect(); 18 | 19 | setIsExpanded(!isExpanded); 20 | 21 | setTimeout(() => { 22 | if (rectBeforeToggle && topOfDivRef.current) { 23 | // Reference to the top of the component after expanding/collapsing 24 | const rectAfterToggle = topOfDivRef.current.getBoundingClientRect(); 25 | // Calculate the difference in position 26 | const positionDiff = rectAfterToggle.top - rectBeforeToggle.top; 27 | // Correct the scroll position to maintain the view 28 | window.scrollTo({ 29 | top: scrollPositionBeforeToggle + positionDiff, 30 | behavior: 'auto', 31 | }); 32 | } 33 | }, 0); 34 | }; 35 | 36 | const buttonStyles: CSSProperties = { 37 | color: 'var(--primary)', 38 | cursor: 'pointer', 39 | userSelect: 'none', 40 | backgroundColor: 'transparent', 41 | border: 'none', 42 | padding: 0, 43 | display: 'flex', 44 | alignItems: 'center', 45 | fontSize: '1rem', 46 | outline: 'none', 47 | }; 48 | 49 | const iconStyles: CSSProperties = { 50 | width: '1em', 51 | height: '1em', 52 | marginRight: '0.5em', 53 | }; 54 | 55 | const contentStyles: CSSProperties = { 56 | whiteSpace: 'pre-wrap', 57 | wordBreak: 'break-word', 58 | maxHeight: isExpanded ? 'none' : '4.5em', 59 | overflow: 'hidden', 60 | paddingLeft: '0.5em', // Space between the line and the content 61 | }; 62 | 63 | return ( 64 |
    65 |
    66 |
    67 | {/* Always render the line */} 68 |
    {content}
    69 |
    70 | 83 |
    84 | ); 85 | }; 86 | 87 | export default FoldableTextSection; 88 | -------------------------------------------------------------------------------- /src/components/FormLabel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface FormLabelProps { 4 | readOnly?: boolean; 5 | label: string; 6 | htmlFor?: string; 7 | value?: any; 8 | isModalLabel?: boolean; 9 | isEditing?: boolean; 10 | } 11 | 12 | const FormLabel: React.FC = ({readOnly, isEditing, label, htmlFor, value, isModalLabel}) => { 13 | const className = !isModalLabel ? "block text-gray-700 dark:text-gray-300 text-sm font-bold mb-2" : ""; 14 | 15 | return readOnly || !isEditing ? ( 16 | {label} 17 | ) : ( 18 | 19 | ); 20 | }; 21 | 22 | export default FormLabel; -------------------------------------------------------------------------------- /src/components/MarkdownBlock.tsx: -------------------------------------------------------------------------------- 1 | import React, {useContext} from 'react'; 2 | import ReactMarkdown from 'react-markdown'; 3 | import {visit} from 'unist-util-visit'; 4 | import "./MarkdownBlock.css"; 5 | 6 | // import SyntaxHighlighter from 'react-syntax-highlighter'; 7 | import {Prism as SyntaxHighlighter} from 'react-syntax-highlighter'; 8 | import CopyButton from "./CopyButton"; 9 | import {Root} from "hast"; 10 | import gfm from "remark-gfm"; 11 | import "github-markdown-css/github-markdown.css"; 12 | import remarkMath from 'remark-math' 13 | import rehypeKatex from 'rehype-katex' 14 | import 'katex/dist/katex.min.css' 15 | import {UserContext} from "../UserContext"; 16 | import {coldarkDark, oneLight} from 'react-syntax-highlighter/dist/esm/styles/prism' 17 | 18 | interface ChatBlockProps { 19 | markdown: string; 20 | role: string; 21 | loading: boolean; 22 | } 23 | 24 | function rehypeInlineCodeProperty() { 25 | return function (tree: Root): void { 26 | visit(tree, 'element', (node, index, parent) => { 27 | if (node.tagName === 'code') { 28 | const isInline = node.position && node.position.start.line === node.position.end.line; 29 | node.properties.dataInline = isInline; 30 | // console.log('Code element:', node); 31 | // console.log('Is inline:', isInline); 32 | } 33 | }); 34 | }; 35 | } 36 | 37 | const MarkdownBlock: React.FC = ({markdown, role, loading}) => { 38 | const {userSettings, setUserSettings} = useContext(UserContext); 39 | 40 | function inlineCodeBlock({value, language}: { value: string; language: string | undefined }) { 41 | return ( 42 | 43 | {value} 44 | 45 | ); 46 | } 47 | 48 | function codeBlock({node, className, children, ...props}: any) { 49 | if (!children) { 50 | return null; 51 | } 52 | const value = String(children).replace(/\n$/, ''); 53 | if (!value) { 54 | return null; 55 | } 56 | // Note: OpenAI does not always annotate the Markdown code block with the language 57 | // Note: In this case, we will fall back to plaintext 58 | const match = /language-(\w+)/.exec(className || ''); 59 | let language: string = match ? match[1] : 'plaintext'; 60 | const isInline = node.properties.dataInline; 61 | 62 | return isInline ? ( 63 | inlineCodeBlock({value: value, language}) 64 | ) : ( 65 |
    66 |
    68 | {language} 69 |
    70 |
    71 |
    72 | 73 |
    74 |
    75 |
    76 | 85 | {value} 86 | 87 | {/* 88 | {children} 89 | */} 90 |
    91 |
    92 | ); 93 | } 94 | 95 | function customPre({children, className, ...props}: any) { 96 | return ( 97 |
    102 |       {children}
    103 |     
    104 | ); 105 | } 106 | 107 | const renderers = { 108 | code: codeBlock, 109 | pre: customPre, 110 | }; 111 | 112 | return ( 113 |
    114 | 119 | {markdown} 120 | 121 | {loading && •••} 122 |
    123 | ); 124 | }; 125 | 126 | export default MarkdownBlock; 127 | -------------------------------------------------------------------------------- /src/components/ModelSelect.css: -------------------------------------------------------------------------------- 1 | .model-toggle { 2 | min-width: 20em; 3 | } -------------------------------------------------------------------------------- /src/components/ScrollToBottomButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {ArrowDownIcon} from '@heroicons/react/24/outline'; 3 | 4 | interface ScrollToBottomButtonProps { 5 | onClick: () => void; 6 | } 7 | 8 | export const ScrollToBottomButton: React.FC = ({onClick}) => { 9 | return ( 10 | 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /src/components/SideBar.css: -------------------------------------------------------------------------------- 1 | .icons-container { 2 | position: absolute; 3 | right: 1rem; 4 | top: 50%; 5 | transform: translateY(-50%); 6 | display: flex; 7 | gap: 0.5rem; 8 | } 9 | -------------------------------------------------------------------------------- /src/components/SideBar.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react'; 2 | import {Link, useNavigate} from 'react-router-dom'; 3 | import {Cog8ToothIcon, PlusIcon, Squares2X2Icon} from "@heroicons/react/24/outline"; 4 | import {CloseSideBarIcon, iconProps, OpenSideBarIcon} from "../svg"; 5 | import {useTranslation} from 'react-i18next'; 6 | import Tooltip from "./Tooltip"; 7 | import UserSettingsModal from './UserSettingsModal'; 8 | import ChatShortcuts from './ChatShortcuts'; 9 | import ConversationList from "./ConversationList"; 10 | 11 | interface SidebarProps { 12 | className: string; 13 | isSidebarCollapsed: boolean; 14 | toggleSidebarCollapse: () => void; 15 | } 16 | 17 | const Sidebar: React.FC = ({className, isSidebarCollapsed, toggleSidebarCollapse}) => { 18 | const {t} = useTranslation(); 19 | const navigate = useNavigate(); 20 | const [isSettingsModalVisible, setSettingsModalVisible] = useState(false); 21 | 22 | const openSettingsDialog = () => { 23 | setSettingsModalVisible(true); 24 | } 25 | 26 | const handleNewChat = () => { 27 | navigate('/', {state: {reset: Date.now()}}); 28 | } 29 | 30 | const handleOnClose = () => { 31 | setSettingsModalVisible(false); 32 | } 33 | 34 | return ( 35 |
    36 | {isSidebarCollapsed && ( 37 |
    38 | 39 | 46 | 47 |
    48 | )} 49 | 53 | {/* sidebar is always dark mode*/} 54 |
    56 |
    57 |
    58 |
    59 |

    Chat history

    60 | 101 |
    102 |
    103 |
    104 |
    105 |
    106 | ); 107 | } 108 | 109 | export default Sidebar; 110 | -------------------------------------------------------------------------------- /src/components/SpeechSpeedSlider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Slider from 'rc-slider'; 3 | import 'rc-slider/assets/index.css'; 4 | import {useTranslation} from 'react-i18next'; 5 | 6 | interface SpeechSpeedSliderProps { 7 | id: string; 8 | value: number | null; 9 | onValueChange: (value: number | null) => void; 10 | } 11 | 12 | const speechSpeedMarks = { 13 | 0.25: '0.25x', 14 | 1: '1x', 15 | 2: '2x', 16 | 3: '3x', 17 | 4: { 18 | label: 4x, 19 | }, 20 | }; 21 | 22 | const SpeechSpeedSlider: React.FC = ({value, onValueChange}) => { 23 | const {t} = useTranslation(); 24 | const handleChange = (value: number | number[] | null) => { 25 | if (value === null) { 26 | onValueChange(null); 27 | } 28 | if (typeof value === 'number') { 29 | onValueChange(value); 30 | } else { 31 | console.warn("Unexpected value type", value); 32 | } 33 | }; 34 | 35 | return ( 36 |
    37 |

    Adjust the speech speed to your preference. Lower values will slow down the speech, 38 | while higher values will speed it up.

    39 |
    40 | {t('slower-label')} 42 | {t('faster-label')} 44 |
    45 | 55 |
    56 | ); 57 | }; 58 | 59 | export default SpeechSpeedSlider; 60 | -------------------------------------------------------------------------------- /src/components/SubmitButton.css: -------------------------------------------------------------------------------- 1 | /* SubmitButton.module.css */ 2 | @keyframes ellipsis-pulse { 3 | 0%, 100% { 4 | transform: translateY(0); 5 | } 6 | 25% { 7 | transform: translateY(-4px); 8 | } 9 | 75% { 10 | transform: translateY(4px); 11 | } 12 | } 13 | 14 | .animate-ellipsis-pulse { 15 | animation: ellipsis-pulse 1s linear infinite; 16 | } 17 | -------------------------------------------------------------------------------- /src/components/SubmitButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {EllipsisHorizontalIcon, PaperAirplaneIcon} from '@heroicons/react/24/outline'; 3 | import './SubmitButton.css'; 4 | import Tooltip from "./Tooltip"; 5 | import {useTranslation} from 'react-i18next'; 6 | 7 | interface SubmitButtonProps { 8 | loading: boolean; 9 | disabled: boolean; 10 | name?: string; 11 | } 12 | 13 | export const SubmitButton: React.FC = ({loading, disabled, name}) => { 14 | const {t} = useTranslation(); 15 | const strokeColor = disabled ? 'currentColor' : 'white'; 16 | 17 | return ( 18 | 19 | 32 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /src/components/TemperatureSlider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Slider from 'rc-slider'; 3 | import 'rc-slider/assets/index.css'; 4 | import {useTranslation} from 'react-i18next'; 5 | 6 | interface TemperatureSliderProps { 7 | id: string; 8 | value: number | null; 9 | onValueChange: (value: number | null) => void; 10 | } 11 | 12 | const temperatureMarks = { 13 | '0': { 14 | label: 0, 15 | }, 16 | 0.5: '0.5', 17 | 1.0: '1.0', 18 | 1.5: '1.5', 19 | 2: { 20 | label: 2, 21 | }, 22 | }; 23 | 24 | const TemperatureSlider: React.FC = ({value, onValueChange}) => { 25 | 26 | const {t} = useTranslation(); 27 | const handleChange = (value: number | number[] | null) => { 28 | // Since your application expects a single number, ensure only a number is handled 29 | if (value === null) { 30 | onValueChange(null); 31 | } 32 | if (typeof value === 'number') { 33 | onValueChange(value); 34 | } else { 35 | // This branch should not be hit based on your current usage, 36 | // but it's here to satisfy TypeScript's checks and handle possible future range slider use cases. 37 | // Handle appropriately or log a warning/error as needed. 38 | console.warn("Unexpected value type", value); 39 | } 40 | }; 41 | 42 | return ( 43 |
    44 |

    Higher values like 0.8 will make the output more random, while lower values like 0.2 45 | will make it more focused and deterministic. 46 | We recommend altering this or top_p but not both.

    47 |
    48 | {t('deterministic-label')} 50 | {t('creative-label')} 52 |
    53 | 63 |
    64 | ); 65 | }; 66 | 67 | export default TemperatureSlider; 68 | -------------------------------------------------------------------------------- /src/components/TextToSpeechButton.tsx: -------------------------------------------------------------------------------- 1 | import React, {useContext, useEffect, useRef, useState} from 'react'; 2 | import {SpeakerWaveIcon, StopCircleIcon} from '@heroicons/react/24/outline'; 3 | import {SpeechSettings} from '../models/SpeechSettings'; 4 | import {SpeechService} from '../service/SpeechService'; 5 | import {RotatingLines} from 'react-loader-spinner'; 6 | import {UserContext} from '../UserContext'; 7 | import {iconProps} from "../svg"; 8 | import {useTranslation} from "react-i18next"; 9 | import "./Button.css"; 10 | import Tooltip from './Tooltip'; 11 | 12 | interface TextToSpeechButtonProps { 13 | content: string; 14 | } 15 | 16 | const simpleChecksum = (s: string) => { 17 | let checksum = 0; 18 | for (let i = 0; i < s.length; i++) { 19 | checksum = (checksum + s.charCodeAt(i) * (i + 1)) % 65535; 20 | } 21 | return checksum; 22 | }; 23 | 24 | const generateIdentifier = (content: string, settings: SpeechSettings) => { 25 | return `${simpleChecksum(content)}-${settings.id}-${settings.voice}-${settings.speed}`; 26 | }; 27 | 28 | const TextToSpeechButton: React.FC = ({content}) => { 29 | const {t} = useTranslation(); 30 | const [isLoading, setIsLoading] = useState(false); 31 | const [isPlaying, setIsPlaying] = useState(false); 32 | const [audioUrl, setAudioUrl] = useState(''); 33 | const [lastIdentifier, setLastIdentifier] = useState(''); 34 | const audioRef = useRef(new Audio()); 35 | const {userSettings} = useContext(UserContext); 36 | 37 | const speechSettings: SpeechSettings = { 38 | id: userSettings.speechModel || 'tts-1', 39 | voice: userSettings.speechVoice || 'alloy', 40 | speed: userSettings.speechSpeed || 1.0, 41 | }; 42 | 43 | const currentIdentifier = generateIdentifier(content, speechSettings); 44 | 45 | const preprocessContent = (content: string) => { 46 | content = content.replace(/```[\s\S]*?```/g, ''); // Simple preprocessing to remove code blocks 47 | return content; 48 | }; 49 | 50 | 51 | const fetchAudio = async () => { 52 | if (currentIdentifier !== lastIdentifier) { 53 | setIsLoading(true); 54 | try { 55 | const processedContent = preprocessContent(content); 56 | const url = await SpeechService.textToSpeech(processedContent, speechSettings); 57 | audioRef.current.src = url; 58 | setAudioUrl(url); 59 | setLastIdentifier(currentIdentifier); 60 | 61 | audioRef.current.onloadeddata = () => { 62 | audioRef.current.play(); 63 | setIsPlaying(true); 64 | }; 65 | } catch (error) { 66 | console.error('Error fetching audio:', error); 67 | } finally { 68 | setIsLoading(false); 69 | } 70 | } else if (audioUrl) { 71 | audioRef.current.play(); 72 | setIsPlaying(true); 73 | } 74 | }; 75 | 76 | const handleClick = () => { 77 | if (isPlaying) { 78 | audioRef.current.pause(); 79 | audioRef.current.currentTime = 0; 80 | setIsPlaying(false); 81 | } else if (!isLoading) { 82 | fetchAudio(); 83 | } 84 | }; 85 | 86 | useEffect(() => { 87 | audioRef.current.onended = () => setIsPlaying(false); 88 | }, []); 89 | 90 | return ( 91 | 120 | ); 121 | }; 122 | 123 | export default TextToSpeechButton; 124 | -------------------------------------------------------------------------------- /src/components/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | // Tooltip.tsx 2 | import React, {useContext} from 'react'; 3 | import * as RadixTooltip from '@radix-ui/react-tooltip'; 4 | import {UserContext} from "../UserContext"; 5 | 6 | interface TooltipProps { 7 | title: string; 8 | children: React.ReactNode; 9 | side: "top" | "right" | "bottom" | "left"; 10 | sideOffset: number; 11 | } 12 | 13 | const Tooltip: React.FC = ({title, children, side, sideOffset}) => { 14 | const {userSettings, setUserSettings} = useContext(UserContext); 15 | 16 | 17 | return ( 18 | 19 | 20 | 21 | {children} 22 | 23 | 24 | 29 | 31 | {title} 32 | 33 | 34 | 35 | 36 | 37 | ); 38 | }; 39 | 40 | export default Tooltip; 41 | -------------------------------------------------------------------------------- /src/components/TopPSlider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Slider from 'rc-slider'; 3 | import 'rc-slider/assets/index.css'; 4 | 5 | interface TopPSliderProps { 6 | id: string; 7 | value: number | null; 8 | onValueChange: (value: number | null) => void; 9 | } 10 | 11 | const topPMarks = { 12 | '0': { 13 | label: 0%, 14 | }, 15 | 0.25: '25%', 16 | 0.5: '50%', 17 | 0.75: '75%', 18 | 1: { 19 | label: 100%, 20 | }, 21 | }; 22 | 23 | 24 | const TopPSlider: React.FC = ({value, onValueChange}) => { 25 | const handleChange = (value: number | number[] | null) => { 26 | // Since your application expects a single number, ensure only a number is handled 27 | if (value === null) { 28 | onValueChange(null); 29 | } 30 | if (typeof value === 'number') { 31 | onValueChange(value); 32 | } else { 33 | // This branch should not be hit based on your current usage, 34 | // but it's here to satisfy TypeScript's checks and handle possible future range slider use cases. 35 | // Handle appropriately or log a warning/error as needed. 36 | console.warn("Unexpected value type", value); 37 | } 38 | }; 39 | 40 | return ( 41 |
    42 |

    43 | An alternative to sampling with temperature, called nucleus sampling, where the model considers the 44 | results of 45 | the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability 46 | mass are 47 | considered. 48 | We generally recommend altering this or temperature but not both. 49 |

    50 | 60 |
    61 | ); 62 | }; 63 | 64 | export default TopPSlider; 65 | -------------------------------------------------------------------------------- /src/components/UserContentBlock.tsx: -------------------------------------------------------------------------------- 1 | import React, { type JSX } from 'react'; 2 | import {SNIPPET_MARKERS} from "../constants/appConstants"; 3 | import FoldableTextSection from './FoldableTextSection'; 4 | import { FileData, FileDataRef } from '../models/FileData'; 5 | import FileDataPreview from './FileDataPreview'; 6 | 7 | interface UserContentBlockProps { 8 | text: string; 9 | fileDataRef: FileDataRef[]; 10 | } 11 | 12 | const UserContentBlock: React.FC = ({text, fileDataRef}) => { 13 | const preformattedTextStyles: React.CSSProperties = { 14 | whiteSpace: 'pre-wrap', 15 | wordBreak: 'break-word', 16 | }; 17 | 18 | const processText = (inputText: string): JSX.Element[] => { 19 | const sections: JSX.Element[] = []; 20 | inputText.split(SNIPPET_MARKERS.begin).forEach((section, index) => { 21 | if (index === 0 && !section.includes(SNIPPET_MARKERS.end)) { 22 | sections.push(
    {section}
    ); 23 | return; 24 | } 25 | 26 | const endSnippetIndex = section.indexOf(SNIPPET_MARKERS.end); 27 | if (endSnippetIndex !== -1) { 28 | const snippet = section.substring(0, endSnippetIndex); 29 | sections.push( 30 | 31 | ); 32 | 33 | const remainingText = section.substring(endSnippetIndex + SNIPPET_MARKERS.end.length); 34 | if (remainingText) { 35 | sections.push(
    {remainingText}
    ); 37 | } 38 | } else { 39 | sections.push(
    {section}
    ); 40 | } 41 | }); 42 | 43 | return sections; 44 | }; 45 | 46 | const content = processText(text); 47 | 48 | return ( 49 |
    50 | {fileDataRef && fileDataRef.length > 0 && 51 | } 52 |
    {content}
    53 |
    54 | ); 55 | }; 56 | 57 | export default UserContentBlock; 58 | -------------------------------------------------------------------------------- /src/components/UserSettingsModal.css: -------------------------------------------------------------------------------- 1 | .setting-panel { 2 | padding: 10px; 3 | margin-bottom: 15px; 4 | border-bottom: 1px solid #eee; 5 | } 6 | 7 | .setting-panel:last-child { 8 | border-bottom: none; 9 | } 10 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import env from './local.env.json'; 2 | 3 | export const OPENAI_API_KEY = (env as any).openapi_key; 4 | export const OPENAI_DEFAULT_MODEL: string = (env as any).default_model; 5 | export const OPENAI_DEFAULT_SYSTEM_PROMPT: string = (env as any).default_system_prompt; 6 | -------------------------------------------------------------------------------- /src/constants/apiEndpoints.ts: -------------------------------------------------------------------------------- 1 | export const OPENAI_ENDPOINT = 'https://api.openai.com'; 2 | export const TTS_ENDPOINT = `${OPENAI_ENDPOINT}/v1/audio/speech`; 3 | export const CHAT_COMPLETIONS_ENDPOINT = `${OPENAI_ENDPOINT}/v1/chat/completions`; 4 | export const MODELS_ENDPOINT = `${OPENAI_ENDPOINT}/v1/models`; 5 | -------------------------------------------------------------------------------- /src/constants/appConstants.ts: -------------------------------------------------------------------------------- 1 | export const SNIPPET_MARKERS = { 2 | begin: '----BEGIN-SNIPPET----', 3 | end: '----END-SNIPPET----', 4 | }; 5 | 6 | export const MAX_ROWS = 20; 7 | export const MAX_TITLE_LENGTH = 128; 8 | 9 | export const CHAT_STREAM_DEBOUNCE_TIME = 250; 10 | export const DEFAULT_MODEL = 'gpt-4o'; 11 | export const DEFAULT_INSTRUCTIONS = 'You are a helpful assistant.'; 12 | 13 | 14 | export const CONVERSATION_NOT_FOUND = 'conversation-not-found'; 15 | 16 | export const IMAGE_MAX_ZOOM = 2; // 200% 17 | export const MAX_IMAGE_ATTACHMENTS_PER_MESSAGE = 10; 18 | export const IMAGE_MIME_TYPES = [ 19 | 'image/jpeg', 20 | 'image/png', 21 | 'image/gif', 22 | 'image/webp', 23 | 'image/svg+xml' 24 | ]; 25 | 26 | export const TEXT_MIME_TYPES = [ 27 | 'text/plain', 28 | 'text/csv', 29 | 'text/html', 30 | 'text/css', 31 | 'text/javascript', 32 | 'text/xml', 33 | 'application/json', 34 | 'text/markdown' 35 | ]; 36 | -------------------------------------------------------------------------------- /src/env.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi_key": "your-api-key-here", 3 | "default_model": "gpt-3.5-turbo", 4 | "default_system_prompt": "You are a helpful assistant." 5 | } 6 | -------------------------------------------------------------------------------- /src/globalStyles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --primary: #10a37f; 3 | --primary-50: #ebfaeb; 4 | --primary-100: #d2f4d3; 5 | --primary-200: #b9eebc; 6 | --primary-300: #93e69c; 7 | --primary-400: #68de7a; 8 | --primary-500: #19c37d; 9 | --primary-600: #10a37f; 10 | --primary-700: #1a7f64; 11 | --primary-800: #1b5d4a; 12 | --primary-900: #183d31; 13 | --primary-50a: rgba(16, 163, 127, .2); 14 | --primary-100a: rgba(16, 163, 127, .3); 15 | --secondary: #5436da; 16 | --secondary-50: #ecebf9; 17 | --secondary-100: #d2cff2; 18 | --secondary-200: #b9b4ec; 19 | --secondary-300: #a198e6; 20 | --secondary-400: #897ce2; 21 | --secondary-500: #715fde; 22 | --secondary-600: #5436da; 23 | --secondary-700: #482da8; 24 | --secondary-800: #3b2479; 25 | --secondary-900: #281852; 26 | --secondary-100a: rgba(84, 54, 218, .3); 27 | --green-50: #ebfaeb; 28 | --green-100: #d2f4d3; 29 | --green-200: #b9eebc; 30 | --green-300: #93e69c; 31 | --green-400: #68de7a; 32 | --green-500: #19c37d; 33 | --green-600: #10a37f; 34 | --green-700: #1a7f64; 35 | --green-800: #1b5d4a; 36 | --green-900: #183d31; 37 | --green-100a: rgba(16, 163, 127, .3); 38 | --red-50: #fdebeb; 39 | --red-100: #f9cfcf; 40 | --red-200: #f6b2b3; 41 | --red-300: #f49394; 42 | --red-400: #f17173; 43 | --red-500: #ef4146; 44 | --red-600: #c23539; 45 | --red-700: #9d2b2e; 46 | --red-800: #7b2124; 47 | --red-900: #59181a; 48 | --red-100a: rgba(239, 65, 70, .3); 49 | --gray-50: #f7f7f8; 50 | --gray-100: #ececf1; 51 | --gray-200: #d9d9e3; 52 | --gray-300: #c5c5d2; 53 | --gray-400: #acacbe; 54 | --gray-500: #8e8ea0; 55 | --gray-600: #6e6e80; 56 | --gray-700: #565869; 57 | --gray-800: #353740; 58 | --gray-900: #202123; 59 | --medium-wash: #eff7f8; 60 | --bg-color: #fff; 61 | --text-primary: #202123; 62 | --text-default: #353740; 63 | --text-secondary: #6e6e80; 64 | --text-disabled: #acacbe; 65 | --text-error: #ef4146; 66 | --font-size-small: 16px; 67 | --input-border: var(--gray-300); 68 | --input-border-focus: var(--primary-600); 69 | --input-focus-ring: 0px 0px 0px 1px #10a37f; 70 | --icon-warning-color: #f4ac36; 71 | --heading-margin-top: 30px; 72 | --heading-margin-bottom: 16px; 73 | --content-width: 760px; 74 | --content-v-padding: 40px; 75 | --content-h-padding: 56px; 76 | --border-radius: 4px; 77 | --sans-serif: "ColfaxAI", helvetica, sans-serif; 78 | --monospace: "Roboto Mono", sfmono-regular, consolas, liberation mono, menlo, courier, monospace; 79 | } 80 | -------------------------------------------------------------------------------- /src/i18n.ts: -------------------------------------------------------------------------------- 1 | import i18n from 'i18next'; 2 | import {initReactI18next} from 'react-i18next'; 3 | import Backend from 'i18next-http-backend'; 4 | import LanguageDetector from 'i18next-browser-languagedetector'; 5 | 6 | i18n 7 | .use(Backend) 8 | .use(initReactI18next) 9 | .use(LanguageDetector) 10 | .init({ 11 | fallbackLng: 'en', 12 | detection: { 13 | order: ['navigator', 'querystring', 'cookie', 'localStorage', 'htmlTag', 'path'], 14 | }, 15 | //debug: true, 16 | interpolation: { 17 | escapeValue: false, 18 | }, 19 | backend: { 20 | loadPath: '/locales/{{lng}}/{{ns}}.json', 21 | }, 22 | react: { 23 | useSuspense: false, 24 | }, 25 | }); 26 | 27 | export default i18n; 28 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | @config '../tailwind.config.js'; 4 | 5 | /* 6 | The default border color has changed to `currentColor` in Tailwind CSS v4, 7 | so we've added these compatibility styles to make sure everything still 8 | looks the same as it did with Tailwind CSS v3. 9 | 10 | If we ever want to remove these styles, we need to add an explicit border 11 | color utility to any element that depends on these defaults. 12 | */ 13 | @layer base { 14 | *, 15 | ::after, 16 | ::before, 17 | ::backdrop, 18 | ::file-selector-button { 19 | border-color: var(--color-gray-200, currentColor); 20 | } 21 | } 22 | 23 | html, body, #root { 24 | height: 100%; 25 | } 26 | 27 | .bg-custom-gray { 28 | background-color: rgb(247, 247, 248); 29 | } 30 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './tailwind.css'; 4 | import './globalStyles.css'; 5 | import 'react-toastify/dist/ReactToastify.css'; 6 | import {UserProvider} from "./UserContext"; 7 | import App from "./App"; 8 | import './i18n'; // sideEffects: true 9 | 10 | const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); 11 | 12 | root.render( 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/models/ChatCompletion.ts: -------------------------------------------------------------------------------- 1 | import { FileDataRef } from "./FileData"; 2 | 3 | // Ref: https://platform.openai.com/docs/api-reference/chat/create 4 | export enum Role { 5 | System = 'system', 6 | User = 'user', 7 | Assistant = 'assistant', 8 | } 9 | 10 | export interface ChatMessagePart { 11 | type: string, 12 | text?: string; 13 | image_url?: { 14 | url: string 15 | } 16 | } 17 | 18 | export interface ChatCompletionMessage { 19 | role: Role, 20 | content: ChatMessagePart[]; 21 | } 22 | 23 | export interface ChatCompletionRequest { 24 | messages: ChatCompletionMessage[]; 25 | model: string; 26 | frequency_penalty?: number | null; 27 | presence_penalty?: number | null; 28 | logit_bias?: { [token: string]: number } | null; 29 | logprobs?: boolean | null; 30 | top_logprobs?: number | null; 31 | max_tokens?: number | null; 32 | n?: number | null; 33 | response_format?: { 34 | type: 'json_object'; 35 | } | null; 36 | seed?: number | null; 37 | stop?: string | string[] | null; 38 | stream?: boolean | null; 39 | temperature?: number | null; 40 | top_p?: number | null; 41 | tools?: any[]; 42 | tool_choice?: 'none' | 'auto' | { 43 | type: 'function'; 44 | function: { 45 | name: string; 46 | }; 47 | } | null; 48 | user?: string; 49 | } 50 | 51 | export interface ChatCompletion { 52 | id: string; 53 | object: string; 54 | created: number; 55 | model: string; 56 | usage: { 57 | prompt_tokens: number; 58 | completion_tokens: number; 59 | total_tokens: number; 60 | }; 61 | choices: ChatCompletionChoice[]; 62 | } 63 | 64 | export interface ChatMessage { 65 | id?: number; 66 | role: Role; 67 | messageType: MessageType; 68 | content: string; 69 | name?: string; 70 | fileDataRef?: FileDataRef[]; 71 | } 72 | 73 | export interface ChatCompletionChoice { 74 | message: ChatMessage; 75 | finish_reason: string; 76 | index: number; 77 | } 78 | 79 | export function getRole(roleString: string): Role { 80 | return Role[roleString as keyof typeof Role]; 81 | } 82 | 83 | export enum MessageType { 84 | Normal = 'normal', 85 | Error = 'error', 86 | } 87 | 88 | export function getMessageType(messageTypeString: string): MessageType { 89 | return MessageType[messageTypeString as keyof typeof MessageType]; 90 | } 91 | -------------------------------------------------------------------------------- /src/models/ChatSettings.ts: -------------------------------------------------------------------------------- 1 | import {ImageSource} from "../components/AvatarFieldEditor"; 2 | 3 | export interface ChatSettings { 4 | id: number; 5 | author: string; 6 | icon?: ImageSource | null; 7 | name: string; 8 | description?: string; 9 | instructions?: string; 10 | model: string | null; 11 | seed?: number | null; 12 | temperature?: number | null; 13 | top_p?: number | null; 14 | showInSidebar?: number; 15 | } 16 | -------------------------------------------------------------------------------- /src/models/FileData.ts: -------------------------------------------------------------------------------- 1 | export interface FileData { 2 | id?: number; 3 | data: string | null; 4 | type: string; // mime type e.g. 'image/svg', 'image/gif', 'text/csv' etc. 5 | source: 'filename' | 'pasted'; 6 | filename?: string; // if source is 'filename' 7 | } 8 | 9 | export interface FileDataRef { 10 | id: number; 11 | fileData: FileData | null; 12 | } -------------------------------------------------------------------------------- /src/models/SpeechSettings.ts: -------------------------------------------------------------------------------- 1 | export interface SpeechSettings { 2 | id: string; 3 | voice: string; 4 | speed: number; 5 | } 6 | -------------------------------------------------------------------------------- /src/models/model.ts: -------------------------------------------------------------------------------- 1 | export interface ModelPermission { 2 | id: string; 3 | object: string; 4 | created: number; 5 | allow_create_engine: boolean; 6 | allow_sampling: boolean; 7 | allow_logprobs: boolean; 8 | allow_search_indices: boolean; 9 | allow_view: boolean; 10 | allow_fine_tuning: boolean; 11 | organization: string; 12 | group: null | string; 13 | is_blocking: boolean; 14 | } 15 | 16 | export interface OpenAIModel { 17 | id: string; 18 | object: string; 19 | owned_by: string; 20 | permission: ModelPermission[]; 21 | context_window: number; 22 | knowledge_cutoff: string; 23 | image_support: boolean; 24 | preferred: boolean; 25 | deprecated: boolean; 26 | } 27 | 28 | export interface OpenAIModelListResponse { 29 | data: OpenAIModel[]; 30 | object: string; 31 | } 32 | 33 | export const modelDetails: { [modelId: string]: { contextWindowSize: number, knowledgeCutoffDate: string, imageSupport: boolean, preferred: boolean, deprecated: boolean } } = { 34 | "chatgpt-4o-latest": {contextWindowSize: 128000, knowledgeCutoffDate: "10/2023", imageSupport: true, preferred: true, deprecated: false}, 35 | "o1-preview": {contextWindowSize: 128000, knowledgeCutoffDate: "10/2023", imageSupport: false, preferred: false, deprecated: true}, 36 | "o1-preview-2024-09-12": {contextWindowSize: 128000, knowledgeCutoffDate: "10/2023", imageSupport: false, preferred: false, deprecated: true}, 37 | "o1-mini": {contextWindowSize: 128000, knowledgeCutoffDate: "10/2023", imageSupport: false, preferred: false, deprecated: true}, 38 | "o1-mini-2024-09-12": {contextWindowSize: 128000, knowledgeCutoffDate: "10/2023", imageSupport: false, preferred: false, deprecated: true}, 39 | "gpt-4o": {contextWindowSize: 128000, knowledgeCutoffDate: "10/2023", imageSupport: true, preferred: true, deprecated: false}, 40 | "gpt-4o-mini-audio-preview": {contextWindowSize: 128000, knowledgeCutoffDate: "10/2023", imageSupport: false, preferred: false, deprecated: true}, 41 | "gpt-4o-mini-audio-preview-2024-12-17": {contextWindowSize: 128000, knowledgeCutoffDate: "10/2023", imageSupport: false, preferred: false, deprecated: true}, 42 | "gpt-4o-audio-preview": {contextWindowSize: 128000, knowledgeCutoffDate: "10/2023", imageSupport: false, preferred: false, deprecated: true}, 43 | "gpt-4o-audio-preview-2024-10-01": {contextWindowSize: 128000, knowledgeCutoffDate: "10/2023", imageSupport: false, preferred: false, deprecated: true}, 44 | "gpt-4o-audio-preview-2024-12-17": {contextWindowSize: 128000, knowledgeCutoffDate: "10/2023", imageSupport: false, preferred: false, deprecated: true}, 45 | "gpt-4o-2024-05-13": {contextWindowSize: 128000, knowledgeCutoffDate: "10/2023", imageSupport: true, preferred: false, deprecated: false}, 46 | "gpt-4o-2024-08-06": {contextWindowSize: 128000, knowledgeCutoffDate: "10/2023", imageSupport: true, preferred: false, deprecated: false}, 47 | "gpt-4o-2024-11-20": {contextWindowSize: 128000, knowledgeCutoffDate: "10/2023", imageSupport: true, preferred: false, deprecated: false}, 48 | "gpt-4o-mini": {contextWindowSize: 128000, knowledgeCutoffDate: "10/2023", imageSupport: true, preferred: true, deprecated: false}, 49 | "gpt-4o-mini-2024-07-18": {contextWindowSize: 128000, knowledgeCutoffDate: "10/2023", imageSupport: true, preferred: false, deprecated: false}, 50 | "gpt-4o-mini-realtime-preview": {contextWindowSize: 128000, knowledgeCutoffDate: "10/2023", imageSupport: false, preferred: false, deprecated: true}, 51 | "gpt-4o-mini-realtime-preview-2024-12-17": {contextWindowSize: 128000, knowledgeCutoffDate: "10/2023", imageSupport: false, preferred: false, deprecated: true}, 52 | "gpt-4o-realtime-preview": {contextWindowSize: 128000, knowledgeCutoffDate: "10/2023", imageSupport: false, preferred: false, deprecated: true}, 53 | "gpt-4o-realtime-preview-2024-10-01": {contextWindowSize: 128000, knowledgeCutoffDate: "10/2023", imageSupport: false, preferred: false, deprecated: true}, 54 | "gpt-4o-realtime-preview-2024-12-17": {contextWindowSize: 128000, knowledgeCutoffDate: "10/2023", imageSupport: false, preferred: false, deprecated: true}, 55 | "gpt-4-turbo": {contextWindowSize: 128000, knowledgeCutoffDate: "12/2023", imageSupport: true, preferred: false, deprecated: true}, 56 | "gpt-4-turbo-2024-04-09": {contextWindowSize: 128000, knowledgeCutoffDate: "12/2023", imageSupport: true, preferred: false, deprecated: false}, 57 | "gpt-4-turbo-preview": {contextWindowSize: 128000, knowledgeCutoffDate: "12/2023", imageSupport: false, preferred: false, deprecated: false}, 58 | "gpt-4-0125-preview": {contextWindowSize: 128000, knowledgeCutoffDate: "12/2023", imageSupport: false, preferred: false, deprecated: true}, 59 | "gpt-4-1106-vision-preview": {contextWindowSize: 128000, knowledgeCutoffDate: "4/2023", imageSupport: true, preferred: false, deprecated: true}, 60 | "gpt-4-1106-preview": {contextWindowSize: 128000, knowledgeCutoffDate: "4/2023", imageSupport: false, preferred: false, deprecated: true}, 61 | "gpt-4-vision-preview": {contextWindowSize: 128000, knowledgeCutoffDate: "4/2023", imageSupport: true, preferred: false, deprecated: true}, 62 | "gpt-4": {contextWindowSize: 8192, knowledgeCutoffDate: "9/2021", imageSupport: false, preferred: false, deprecated: false}, 63 | "gpt-4-0613": {contextWindowSize: 8192, knowledgeCutoffDate: "9/2021", imageSupport: false, preferred: false, deprecated: false}, 64 | "gpt-4-32k": {contextWindowSize: 32768, knowledgeCutoffDate: "9/2021", imageSupport: false, preferred: false, deprecated: true}, 65 | "gpt-4-32k-0613": {contextWindowSize: 32768, knowledgeCutoffDate: "9/2021", imageSupport: false, preferred: false, deprecated: true}, 66 | "gpt-3.5-turbo": {contextWindowSize: 4096, knowledgeCutoffDate: "9/2021", imageSupport: false, preferred: false, deprecated: false}, 67 | "gpt-3.5-turbo-0125": {contextWindowSize: 16385, knowledgeCutoffDate: "9/2021", imageSupport: false, preferred: false, deprecated: false}, 68 | "gpt-3.5-turbo-0301": {contextWindowSize: 4096, knowledgeCutoffDate: "9/2021", imageSupport: false, preferred: false, deprecated: true}, 69 | "gpt-3.5-turbo-1106": {contextWindowSize: 16385, knowledgeCutoffDate: "9/2021", imageSupport: false, preferred: false, deprecated: false}, 70 | "gpt-3.5-turbo-instruct": {contextWindowSize: 4096, knowledgeCutoffDate: "9/2021", imageSupport: false, preferred: false, deprecated: false}, 71 | "gpt-3.5-turbo-instruct-0914": {contextWindowSize: 4096, knowledgeCutoffDate: "9/2021", imageSupport: false, preferred: false, deprecated: true}, 72 | "gpt-3.5-turbo-16k": {contextWindowSize: 16385, knowledgeCutoffDate: "9/2021", imageSupport: false, preferred: false, deprecated: true}, 73 | "gpt-3.5-turbo-0613": {contextWindowSize: 4096, knowledgeCutoffDate: "9/2021", imageSupport: false, preferred: false, deprecated: true}, 74 | "gpt-3.5-turbo-16k-0613": {contextWindowSize: 16385, knowledgeCutoffDate: "9/2021", imageSupport: false, preferred: false, deprecated: true} 75 | }; 76 | -------------------------------------------------------------------------------- /src/modelsContext.tsx: -------------------------------------------------------------------------------- 1 | import {createContext, useContext} from 'react'; 2 | import {OpenAIModel} from "./models/model"; 3 | 4 | interface ModelsContextState { 5 | models: OpenAIModel[]; 6 | setModels: (models: OpenAIModel[]) => void; 7 | } 8 | 9 | const ModelsContext = createContext({ 10 | models: [], 11 | setModels: () => { 12 | }, 13 | }); 14 | 15 | export const useModelsContext = () => useContext(ModelsContext); 16 | 17 | export default ModelsContext; 18 | -------------------------------------------------------------------------------- /src/service/ChatSettingsDB.ts: -------------------------------------------------------------------------------- 1 | import Dexie from 'dexie'; 2 | import {ChatSettings} from '../models/ChatSettings'; 3 | import initialData from './chatSettingsData.json'; 4 | import {EventEmitter} from "./EventEmitter"; 5 | 6 | export interface ChatSettingsChangeEvent { 7 | action: 'edit' | 'delete', 8 | gid: number 9 | } 10 | 11 | export const chatSettingsEmitter = new EventEmitter(); 12 | 13 | class ChatSettingsDB extends Dexie { 14 | chatSettings: Dexie.Table; 15 | 16 | constructor() { 17 | super("chatSettingsDB"); 18 | this.version(1).stores({ 19 | chatSettings: '&id, name, description, instructions, model, seed, temperature, top_p, icon' 20 | }); 21 | this.version(2).stores({ 22 | chatSettings: '&id, name, description, instructions, model, seed, temperature, top_p, icon, showInSidebar' 23 | }).upgrade(tx => { 24 | return tx.table('chatSettings').toCollection().modify(chatSetting => { 25 | chatSetting.showInSidebar = false; 26 | }); 27 | }); 28 | this.version(3).stores({ 29 | chatSettings: '&id, name, description, instructions, model, seed, temperature, top_p, icon, showInSidebar' 30 | }).upgrade(tx => { 31 | return tx.table('chatSettings').toCollection().modify(chatSetting => { 32 | chatSetting.showInSidebar = chatSetting.showInSidebar ? 1 : 0; 33 | }); 34 | }); 35 | this.version(4).stores({ 36 | chatSettings: '&id, name, description, model, showInSidebar' 37 | }) 38 | this.chatSettings = this.table("chatSettings"); 39 | 40 | this.on('populate', () => { 41 | this.chatSettings.bulkAdd(initialData); 42 | }); 43 | } 44 | } 45 | 46 | export async function getChatSettingsById(id: number): Promise { 47 | const db: ChatSettingsDB = new ChatSettingsDB(); 48 | return db.chatSettings.get(id); 49 | } 50 | 51 | export async function updateShowInSidebar(id: number, showInSidebar: number) { 52 | try { 53 | await chatSettingsDB.chatSettings.update(id, {showInSidebar}); 54 | let event: ChatSettingsChangeEvent = {action: 'edit', gid: id}; 55 | chatSettingsEmitter.emit('chatSettingsChanged', event); 56 | } catch (error) { 57 | console.error('Failed to update:', error); 58 | } 59 | } 60 | 61 | export async function deleteChatSetting(id: number) { 62 | try { 63 | await chatSettingsDB.chatSettings.delete(id); 64 | let event: ChatSettingsChangeEvent = {action: 'delete', gid: id}; 65 | chatSettingsEmitter.emit('chatSettingsChanged', event); 66 | } catch (error) { 67 | console.error('Failed to update:', error); 68 | } 69 | } 70 | 71 | const chatSettingsDB = new ChatSettingsDB(); 72 | 73 | export default chatSettingsDB; 74 | -------------------------------------------------------------------------------- /src/service/ConversationService.ts: -------------------------------------------------------------------------------- 1 | import Dexie from 'dexie'; 2 | import {EventEmitter} from "./EventEmitter"; 3 | import FileDataService from './FileDataService'; 4 | import { ChatMessage } from '../models/ChatCompletion'; 5 | 6 | export interface Conversation { 7 | id: number; 8 | gid: number; 9 | timestamp: number; 10 | title: string; 11 | model: string | null, 12 | systemPrompt: string, 13 | messages: string; // stringified ChatMessage[] 14 | marker?: boolean; 15 | } 16 | 17 | export interface ConversationChangeEvent { 18 | action: 'add' | 'edit' | 'delete', 19 | id: number, 20 | conversation?: Conversation, // not set on delete 21 | } 22 | 23 | 24 | class ConversationDB extends Dexie { 25 | conversations: Dexie.Table; 26 | 27 | constructor() { 28 | super("conversationsDB"); 29 | this.version(1).stores({ 30 | conversations: '&id, gid, timestamp, title, model' 31 | }); 32 | this.conversations = this.table("conversations"); 33 | } 34 | } 35 | 36 | const db = new ConversationDB(); 37 | const NUM_INITIAL_CONVERSATIONS = 200; 38 | 39 | class ConversationService { 40 | 41 | static async getConversationById(id: number): Promise { 42 | return db.conversations.get(id); 43 | } 44 | 45 | static async getChatMessages(conversation: Conversation): Promise { 46 | const messages: ChatMessage[] = JSON.parse(conversation.messages); 47 | 48 | const messagesWithFileDataPromises = messages.map(async (message) => { 49 | if (!message.fileDataRef) { 50 | return message; 51 | } 52 | const fileDataRefsPromises = (message.fileDataRef || []).map(async (fileDataRef) => { 53 | fileDataRef.fileData = await FileDataService.getFileData(fileDataRef.id) || null; 54 | return fileDataRef; 55 | }); 56 | 57 | message.fileDataRef = await Promise.all(fileDataRefsPromises); 58 | return message; 59 | }); 60 | 61 | // Wait for all messages to have their fileDataRefs loaded 62 | return Promise.all(messagesWithFileDataPromises); 63 | } 64 | 65 | static async searchConversationsByTitle(searchString: string): Promise { 66 | searchString = searchString.toLowerCase(); 67 | return db.conversations 68 | .filter(conversation => conversation.title.toLowerCase().includes(searchString)) 69 | .toArray(); 70 | } 71 | 72 | 73 | // todo: Currently we are not indexing messages since it is expensive 74 | static async searchWithinConversations(searchString: string): Promise { 75 | return db.conversations 76 | .filter(conversation => conversation.messages.includes(searchString)) 77 | .toArray(); 78 | } 79 | 80 | 81 | // This is adding a new conversation object with empty messages "[]" 82 | static async addConversation(conversation: Conversation): Promise { 83 | await db.conversations.add(conversation); 84 | let event: ConversationChangeEvent = {action: 'add', id: conversation.id, conversation: conversation}; 85 | conversationsEmitter.emit('conversationChangeEvent', event); 86 | } 87 | 88 | static deepCopyChatMessages(messages: ChatMessage[]): ChatMessage[] { 89 | return messages.map(msg => ({ 90 | ...msg, 91 | fileDataRef: msg.fileDataRef?.map(fileRef => ({ 92 | ...fileRef, 93 | fileData: fileRef.fileData ? { ...fileRef.fileData } : null, 94 | })) 95 | })); 96 | } 97 | 98 | static async updateConversation(conversation: Conversation, messages: ChatMessage[]): Promise { 99 | const messagesCopy = ConversationService.deepCopyChatMessages(messages); 100 | 101 | for (let i = 0; i < messagesCopy.length; i++) { 102 | const fileDataRefs = messagesCopy[i].fileDataRef; 103 | if (fileDataRefs) { 104 | for (let j = 0; j < fileDataRefs.length; j++) { 105 | const fileRef = fileDataRefs[j]; 106 | if (fileRef.id === 0 && fileRef.fileData) { 107 | const fileId = await FileDataService.addFileData(fileRef.fileData); 108 | // Update the ID in both messagesCopy and the original messages array 109 | fileDataRefs[j].id = fileId; 110 | messages[i].fileDataRef![j].id = fileId; 111 | } 112 | // Set the fileData to null after processing 113 | fileDataRefs[j].fileData = null; 114 | } 115 | } 116 | } 117 | 118 | conversation.messages = JSON.stringify(messagesCopy); 119 | await db.conversations.put(conversation); 120 | let event: ConversationChangeEvent = {action: 'edit', id: conversation.id, conversation: conversation}; 121 | conversationsEmitter.emit('conversationChangeEvent', event); 122 | } 123 | 124 | static async updateConversationPartial(conversation: Conversation, changes: any): Promise { 125 | // todo: currently not emitting event for this case 126 | return db.conversations 127 | .update(conversation.id, changes) 128 | } 129 | 130 | static async deleteConversation(id: number): Promise { 131 | const conversation = await db.conversations.get(id); 132 | if (conversation) { 133 | const messages: ChatMessage[] = JSON.parse(conversation.messages); 134 | 135 | for (let message of messages) { 136 | if (message.fileDataRef && message.fileDataRef.length > 0) { 137 | await Promise.all(message.fileDataRef.map(async (fileRef) => { 138 | if (fileRef.id) { 139 | await FileDataService.deleteFileData(fileRef.id); 140 | } 141 | })); 142 | } 143 | } 144 | await db.conversations.delete(id); 145 | 146 | let event: ConversationChangeEvent = {action: 'delete', id: id}; 147 | conversationsEmitter.emit('conversationChangeEvent', event); 148 | } else { 149 | console.log(`Conversation with ID ${id} not found.`); 150 | } 151 | } 152 | 153 | static async deleteAllConversations(): Promise { 154 | await db.conversations.clear(); 155 | await FileDataService.deleteAllFileData(); 156 | let event: ConversationChangeEvent = {action: 'delete', id: 0}; 157 | conversationsEmitter.emit('conversationChangeEvent', event); 158 | } 159 | 160 | static async loadRecentConversationsTitleOnly(): Promise { 161 | try { 162 | const conversations = await db.conversations 163 | .orderBy('timestamp') 164 | .reverse() 165 | .limit(NUM_INITIAL_CONVERSATIONS) 166 | .toArray(conversations => conversations.map(conversation => { 167 | const conversationWithEmptyMessages = {...conversation, messages: "[]"}; 168 | return conversationWithEmptyMessages; 169 | })); 170 | return conversations; 171 | } catch (error) { 172 | console.error("Error loading recent conversations:", error); 173 | throw error; 174 | } 175 | } 176 | 177 | 178 | static async countConversationsByGid(id: number): Promise { 179 | return db.conversations 180 | .where('gid').equals(id) 181 | .count(); 182 | } 183 | 184 | static async deleteConversationsByGid(gid: number): Promise { 185 | const conversationsToDelete = await db.conversations 186 | .where('gid').equals(gid).toArray(); 187 | for (const conversation of conversationsToDelete) { 188 | await ConversationService.deleteConversation(conversation.id); 189 | } 190 | let event: ConversationChangeEvent = {action: 'delete', id: 0}; 191 | conversationsEmitter.emit('conversationChangeEvent', event); 192 | } 193 | 194 | } 195 | 196 | export const conversationsEmitter = new EventEmitter(); 197 | export default ConversationService; 198 | -------------------------------------------------------------------------------- /src/service/CustomError.ts: -------------------------------------------------------------------------------- 1 | export class CustomError extends Error { 2 | responseJson: any; 3 | 4 | constructor(message: string, responseJson: any) { 5 | super(message); 6 | this.responseJson = responseJson; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/service/EventEmitter.ts: -------------------------------------------------------------------------------- 1 | 2 | export class EventEmitter { 3 | events: { [key: string]: ((data: T) => void)[] } = {}; 4 | 5 | on(eventName: string, listener: (data: T) => void) { 6 | if (!this.events[eventName]) { 7 | this.events[eventName] = []; 8 | } 9 | this.events[eventName].push(listener); 10 | } 11 | 12 | off(eventName: string, listener: (data: T) => void) { 13 | if (this.events[eventName]) { 14 | this.events[eventName] = this.events[eventName].filter(l => l !== listener); 15 | } 16 | } 17 | 18 | emit(eventName: string, data: T) { // Removed the optional modifier from `data` 19 | if (this.events[eventName]) { 20 | this.events[eventName].forEach(listener => listener(data)); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/service/FileDataService.ts: -------------------------------------------------------------------------------- 1 | import Dexie from 'dexie'; 2 | import {FileData} from '../models/FileData'; 3 | 4 | class FileDB extends Dexie { 5 | fileData: Dexie.Table; 6 | 7 | constructor() { 8 | super("FileDB"); 9 | this.version(1).stores({ 10 | fileData: '++id' 11 | }); 12 | this.fileData = this.table("fileData"); 13 | } 14 | } 15 | 16 | const db = new FileDB(); 17 | 18 | class FileDataService { 19 | static async getFileData(id: number): Promise { 20 | return db.fileData.get(id); 21 | } 22 | 23 | static async addFileData(fileData: FileData): Promise { 24 | return db.fileData.add(fileData); 25 | } 26 | 27 | static async updateFileData(id: number, changes: Partial): Promise { 28 | return db.fileData.update(id, changes); 29 | } 30 | 31 | static async deleteFileData(id: number): Promise { 32 | await db.fileData.delete(id); 33 | } 34 | 35 | static async deleteAllFileData(): Promise { 36 | await db.fileData.clear(); 37 | } 38 | } 39 | 40 | export default FileDataService; 41 | -------------------------------------------------------------------------------- /src/service/NotificationService.ts: -------------------------------------------------------------------------------- 1 | import {toast} from "react-toastify"; 2 | import {Theme, UserTheme} from "../UserContext"; 3 | 4 | export class NotificationService { 5 | 6 | private static getEffectiveTheme = (): Theme => { 7 | let userTheme: UserTheme | null = localStorage.getItem('theme') as UserTheme; 8 | if (!userTheme) { 9 | userTheme = 'system'; 10 | } 11 | if (userTheme === 'system') { 12 | return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; 13 | } 14 | return userTheme; 15 | }; 16 | 17 | static handleUnexpectedError(err: Error = new Error(), title: string = '', toastId?: string) { 18 | const messagePrefix = title ? `${title}: ` : 'Unexpected error: '; 19 | const message = `${messagePrefix}${err.message || 'No error message provided'}`; 20 | 21 | toast.error(message, { 22 | toastId: toastId || 'error', 23 | position: "top-center", 24 | autoClose: 5000, 25 | hideProgressBar: true, 26 | closeOnClick: true, 27 | pauseOnHover: true, 28 | draggable: true, 29 | progress: undefined, 30 | theme: NotificationService.getEffectiveTheme(), 31 | }); 32 | } 33 | 34 | static handleError(title: string = '', toastId?: string) { 35 | toast.error(title, { 36 | toastId: toastId || 'error', 37 | position: "top-center", 38 | autoClose: 5000, 39 | hideProgressBar: true, 40 | closeOnClick: true, 41 | pauseOnHover: true, 42 | draggable: true, 43 | progress: undefined, 44 | theme: NotificationService.getEffectiveTheme(), 45 | }); 46 | } 47 | 48 | static handleSuccess(title: string, toastId?: string) { 49 | toast.success(title, { 50 | toastId: toastId || 'success', 51 | position: "top-center", 52 | autoClose: 5000, 53 | hideProgressBar: true, 54 | closeOnClick: true, 55 | pauseOnHover: true, 56 | draggable: true, 57 | progress: undefined, 58 | theme: NotificationService.getEffectiveTheme(), 59 | }); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/service/SpeechService.ts: -------------------------------------------------------------------------------- 1 | import {OpenAIModel} from "../models/model"; 2 | import {OPENAI_API_KEY} from "../config"; 3 | import {CustomError} from "./CustomError"; 4 | import {MODELS_ENDPOINT, TTS_ENDPOINT} from "../constants/apiEndpoints"; 5 | import {SpeechSettings} from "../models/SpeechSettings"; // Adjust the path as necessary 6 | 7 | export class SpeechService { 8 | private static models: Promise | null = null; 9 | 10 | static async textToSpeech(text: string, settings: SpeechSettings): Promise { 11 | const endpoint = TTS_ENDPOINT; 12 | const headers = { 13 | "Content-Type": "application/json", 14 | "Authorization": `Bearer ${OPENAI_API_KEY}`, 15 | }; 16 | 17 | if (text.length > 4096) { 18 | throw new Error("Input text exceeds the maximum length of 4096 characters."); 19 | } 20 | 21 | if (settings.speed < 0.25 || settings.speed > 4.0) { 22 | throw new Error("Speed must be between 0.25 and 4.0."); 23 | } 24 | 25 | const requestBody = { 26 | model: settings.id, 27 | voice: settings.voice, 28 | input: text, 29 | speed: settings.speed, 30 | response_format: "mp3", 31 | }; 32 | 33 | const response = await fetch(endpoint, { 34 | method: "POST", 35 | headers: headers, 36 | body: JSON.stringify(requestBody), 37 | }); 38 | 39 | if (!response.ok) { 40 | const err = await response.json(); 41 | throw new CustomError(err.error.message, err); 42 | } 43 | 44 | const blob = await response.blob(); 45 | return URL.createObjectURL(blob); 46 | } 47 | 48 | static getModels = (): Promise => { 49 | return SpeechService.fetchModels(); 50 | }; 51 | 52 | static async fetchModels(): Promise { 53 | if (this.models !== null) { 54 | return this.models; 55 | } 56 | 57 | try { 58 | const response = await fetch(MODELS_ENDPOINT, { 59 | headers: { 60 | "Authorization": `Bearer ${OPENAI_API_KEY}`, 61 | }, 62 | }); 63 | 64 | if (!response.ok) { 65 | const err = await response.json(); 66 | throw new Error(err.error.message); 67 | } 68 | 69 | const data = await response.json(); 70 | const models: OpenAIModel[] = data.data.filter((model: OpenAIModel) => model.id.includes("tts")); 71 | this.models = Promise.resolve(models); 72 | return models; 73 | } catch (err: any) { 74 | throw new Error(err.message || err); 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/service/chatSettingsData.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "author": "system", 5 | "name": "React UI Developer", 6 | "description": "Focused on React/Typescript/TailwindCSS development.", 7 | "instructions": "You are an experienced front end developer familiar with react, angular, typescript, tailwindcss, bootstrap, material, next.js. When providing code, preserve existing comments and do not add new comments. Code should wrapped at 80 characters if possible. Be as brief as possible.", 8 | "model": null, 9 | "seed": null, 10 | "temperature": 0.2, 11 | "top_p": null, 12 | "icon": { 13 | "data": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiB2aWV3Qm94PSItMTAuNSAtOS40NSAyMSAxOC45IiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGNsYXNzPSJ0ZXh0LXNtIG1lLTAgdy0xMCBoLTEwIHRleHQtbGluayBkYXJrOnRleHQtbGluay1kYXJrIGZsZXggb3JpZ2luLWNlbnRlciB0cmFuc2l0aW9uLWFsbCBlYXNlLWluLW91dCIgc3R5bGU9ImNvbG9yOiAjMkI3REExOyI+DQogIDwhLS0gQmFja2dyb3VuZCAtLT4NCiAgPHJlY3QgeD0iLTEwLjUiIHk9Ii05LjQ1IiB3aWR0aD0iMjEiIGhlaWdodD0iMTguOSIgZmlsbD0iI0YzRjRGNiI+PC9yZWN0Pg0KICA8IS0tIENvbnRlbnQgKHNjYWxlZCBkb3duKSAtLT4NCiAgPGNpcmNsZSBjeD0iMCIgY3k9IjAiIHI9IjEuNiIgZmlsbD0iIzJCN0RBMSI+PC9jaXJjbGU+DQogIDxnIHN0cm9rZT0iY3VycmVudENvbG9yIiBzdHJva2Utd2lkdGg9IjEiIGZpbGw9Im5vbmUiPg0KICAgIDxlbGxpcHNlIGN4PSIwIiBjeT0iMCIgcng9IjgiIHJ5PSIzLjYiPjwvZWxsaXBzZT4NCiAgICA8ZWxsaXBzZSBjeD0iMCIgY3k9IjAiIHJ4PSI4IiByeT0iMy42IiB0cmFuc2Zvcm09InJvdGF0ZSg2MCkiPjwvZWxsaXBzZT4NCiAgICA8ZWxsaXBzZSBjeD0iMCIgY3k9IjAiIHJ4PSI4IiByeT0iMy42IiB0cmFuc2Zvcm09InJvdGF0ZSgxMjApIj48L2VsbGlwc2U+DQogIDwvZz4NCjwvc3ZnPg0K", 14 | "type": "svg" 15 | } 16 | }, 17 | { 18 | "id": 2, 19 | "author": "system", 20 | "name": "Math Tutor", 21 | "description": "Focused on solving math problems.", 22 | "instructions": "All math equations or multi-line math expressions should be enclosed with $$..$$. Shorter math expressions should be enclosed with $..$.", 23 | "model": null, 24 | "seed": null, 25 | "temperature": 0.2, 26 | "top_p": null, 27 | "icon": { 28 | "data": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22800%22%20height%3D%22800%22%20viewBox%3D%220%200%20100%20100%22%20xml%3Aspace%3D%22preserve%22%3E%3Cpath%20d%3D%22M55.3%2021.2c-1.1-.7-2.4-1.1-4.1-1.1-.4%200-.8%200-1.2.1-4.4.7-7.3%204.7-9.1%208.5-.8%201.8-1.5%203.7-2.2%205.5-.3.9-.7%201.7-1%202.6%200%20.1-.5%201.8-.6%201.8h-4.5c-.6%200-1.1.5-1.1%201.1s.5%201.1%201.1%201.1h3.9l-2.2%209.4c-2.2%2010.5-5.2%2022.5-5.9%2024.6-.7%202.2-1.7%203.3-3.1%203.3-.3%200-.5-.1-.7-.2s-.3-.3-.3-.5.1-.5.4-.9.4-.9.4-1.3c0-.8-.3-1.5-.8-2-.6-.5-1.2-.7-1.9-.7s-1.4.3-2%20.8c-.1.7-.4%201.4-.4%202.3%200%201.2.5%202.2%201.5%203.1s2.3%201.3%204%201.3c2.7%200%205.1-1.2%206.9-3.3%201.1-1.3%201.9-2.8%202.6-4.4%202.2-4.9%203.4-10.3%204.7-15.5q1.95-7.95%203.6-15.9h4.3c.6%200%201.1-.5%201.1-1.1s-.5-1.1-1.1-1.1h-4c2.2-8.3%204.7-14%205.2-14.8q1.2-2.1%202.7-2.1c.4%200%20.6.1.7.3s.2.4.2.5-.1.4-.4.9-.4%201-.4%201.5c0%20.7.3%201.3.8%201.8s1.2.8%201.9.8%201.4-.3%201.9-.8.8-1.2.8-2.1c-.1-1.7-.6-2.8-1.7-3.5m20%2025.2c1.6%200%204.7-1.3%204.7-5.5s-3-4.4-4-4.4c-1.8%200-3.7%201.3-5.3%204.1s-3.4%206-3.4%206h-.1c-.4-2-.7-3.6-.9-4.4-.3-1.7-2.3-5.5-6.5-5.5s-8%202.4-8%202.4c-.7.4-1.2%201.2-1.2%202.1%200%201.4%201.1%202.5%202.5%202.5.4%200%20.8-.1%201.1-.3%200%200%203.2-1.8%203.8%200%20.2.5.4%201.1.6%201.7.8%202.7%201.5%205.9%202.2%208.8L58.3%2058s-3.1-1.1-4.7-1.1-4.7%201.3-4.7%205.5%203%204.4%204%204.4c1.8%200%203.7-1.3%205.3-4.1s3.4-6%203.4-6c.5%202.6%201%204.7%201.3%205.5%201%203%203.4%204.7%206.6%204.7%200%200%203.3%200%207.1-2.2.9-.4%201.6-1.3%201.6-2.3%200-1.4-1.1-2.5-2.5-2.5-.4%200-.8.1-1.1.3%200%200-2.7%201.5-3.7.3-.7-1.3-1.2-3-1.7-5.1-.4-1.9-.9-4.1-1.3-6.2l2.8-4c-.1.1%203%201.2%204.6%201.2%22%2F%3E%3C%2Fsvg%3E", 29 | "type": "svg" 30 | } 31 | } 32 | ] 33 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /src/svg.tsx: -------------------------------------------------------------------------------- 1 | import React, {SVGProps} from 'react'; 2 | 3 | export interface IconProps { 4 | className: string; 5 | height: string; 6 | width: string; 7 | stroke: string; 8 | strokeWidth: string; 9 | viewBox: string; 10 | strokeLinecap: 'round' | 'butt' | 'square' | 'inherit'; 11 | strokeLinejoin: 'round' | 'miter' | 'bevel' | 'inherit'; 12 | } 13 | 14 | export const iconProps: IconProps = { 15 | className: 'h-4 w-4', 16 | height: '1em', 17 | width: '1em', 18 | stroke: 'currentColor', 19 | strokeWidth: '2', 20 | viewBox: '0 0 24 24', 21 | strokeLinecap: 'round', 22 | strokeLinejoin: 'round', 23 | }; 24 | 25 | export const CloseSideBarIcon: React.FC> = (props) => ( 26 | 38 | 39 | 40 | 41 | ); 42 | 43 | export const OpenSideBarIcon: React.FC> = (props) => ( 44 | 56 | 57 | 58 | 59 | ); 60 | 61 | export const SubmitChatIcon: React.FC> = (props) => ( 62 | 71 | 72 | 73 | ); 74 | -------------------------------------------------------------------------------- /src/utils/ImageUtils.ts: -------------------------------------------------------------------------------- 1 | export const preprocessImage = async (file: File, callback: (base64Data: string, file: File) => void) => { 2 | if (!file) return; 3 | 4 | const imageBitmap = await createImageBitmap(file); 5 | const canvas = document.createElement('canvas'); 6 | const ctx = canvas.getContext('2d'); 7 | 8 | const maxLongerDimension = 2000; 9 | const maxSmallerDimension = 768; 10 | 11 | const originalWidth = imageBitmap.width; 12 | const originalHeight = imageBitmap.height; 13 | 14 | // Log original size 15 | // console.log(`Original Image Size: ${originalWidth}x${originalHeight}`); 16 | 17 | // Determine the longer and smaller dimensions 18 | const isWidthLonger = originalWidth >= originalHeight; 19 | const longerDimension = isWidthLonger ? originalWidth : originalHeight; 20 | const smallerDimension = isWidthLonger ? originalHeight : originalWidth; 21 | 22 | // Calculate the scaling factor 23 | const longerDimensionScale = longerDimension > maxLongerDimension ? maxLongerDimension / longerDimension : 1; 24 | const smallerDimensionScale = smallerDimension > maxSmallerDimension ? maxSmallerDimension / smallerDimension : 1; 25 | 26 | // Choose the smaller scaling factor to ensure both dimensions are within limits 27 | const scaleFactor = Math.min(longerDimensionScale, smallerDimensionScale); 28 | 29 | // Calculate new dimensions and round them down to the nearest integer 30 | const newWidth = Math.floor(originalWidth * scaleFactor); 31 | const newHeight = Math.floor(originalHeight * scaleFactor); 32 | 33 | // Ensure canvas dimensions are set correctly for the aspect ratio 34 | canvas.width = newWidth; 35 | canvas.height = newHeight; 36 | 37 | // Draw the image to the canvas with new dimensions 38 | ctx?.drawImage(imageBitmap, 0, 0, newWidth, newHeight); 39 | 40 | // Log new size 41 | // console.log(`Resized Image Size: ${newWidth}x${newHeight}`); 42 | 43 | canvas.toBlob((blob) => { 44 | if (!blob) return; 45 | const reader = new FileReader(); 46 | reader.onload = (loadEvent) => { 47 | const base64Data = loadEvent.target?.result as string; 48 | callback(base64Data, file); 49 | }; 50 | reader.readAsDataURL(blob); 51 | }, file.type); 52 | }; 53 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: 'class', 4 | content: ['./src/**/*.{js,jsx,ts,tsx}'], 5 | theme: { 6 | extend: { 7 | screens: { 8 | 'md': '768px', 9 | 'lg': '1024px', 10 | 'xl': '1280px', 11 | '2xl': '1536px', 12 | '3xl': '1920px', 13 | '4xl': '2560px', 14 | '5xl': '3840px', 15 | }, 16 | colors: { 17 | gray: { 18 | 500: 'rgba(142, 142, 160, var(--tw-text-opacity))', 19 | }, 20 | }, 21 | }, 22 | }, 23 | variants: { 24 | extend: {}, 25 | }, 26 | plugins: [], 27 | } 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "types": [ 10 | "node", 11 | "jest", 12 | "vite/client", 13 | "vite-plugin-svgr/client" 14 | ], 15 | "allowJs": true, 16 | "skipLibCheck": true, 17 | "esModuleInterop": true, 18 | "allowSyntheticDefaultImports": true, 19 | "strict": true, 20 | "forceConsistentCasingInFileNames": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "module": "esnext", 23 | "moduleResolution": "node", 24 | "resolveJsonModule": true, 25 | "isolatedModules": true, 26 | "noEmit": true, 27 | "jsx": "react-jsx" 28 | }, 29 | "include": [ 30 | "src" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | import viteTsconfigPaths from 'vite-tsconfig-paths'; 4 | import svgrPlugin from 'vite-plugin-svgr'; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | plugins: [react(), viteTsconfigPaths(), svgrPlugin()], 9 | server: { 10 | open: true, 11 | port: 3000, 12 | }, 13 | build: { 14 | chunkSizeWarningLimit: 2000, // in kilobytes 15 | }, 16 | }); 17 | --------------------------------------------------------------------------------