├── .dockerignore
├── .env
├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.yml
│ └── feature_request.yml
└── workflows
│ └── build-docker-image.yml
├── .gitignore
├── .prettierignore
├── .prettierrc
├── CHANGELOG.md
├── Dockerfile
├── LICENSE
├── README.md
├── assets
├── demo-en.png
├── demo-zh.png
├── screenshots
│ ├── screenshot-1-en.png
│ ├── screenshot-1-zh.png
│ ├── screenshot-2-en.png
│ ├── screenshot-2-zh.png
│ ├── screenshot-3-en.png
│ └── screenshot-3-zh.png
└── speechgpt-icon-text.svg
├── docs
├── README.zh.md
├── developer-guide.md
└── developer-guide.zh.md
├── index.html
├── nginx.conf
├── package.json
├── postcss.config.cjs
├── public
└── speechgpt.svg
├── src
├── App.tsx
├── apis
│ ├── amazonPolly.ts
│ ├── azureTTS.ts
│ ├── azureToken.ts
│ └── openai.ts
├── assets
│ └── arrow-down.svg
├── components
│ ├── AboutDialog.tsx
│ ├── AppearanceSelector.tsx
│ ├── AzureSpeechToText.tsx
│ ├── BrowserSpeechToText.tsx
│ ├── ButtonGroup.tsx
│ ├── Content.tsx
│ ├── ConversationPanel.tsx
│ ├── Conversations
│ │ ├── ConversationIcons.tsx
│ │ ├── ConversationItem.tsx
│ │ └── Sidebar.tsx
│ ├── EllipsisMenu.tsx
│ ├── Header.tsx
│ ├── Icons
│ │ ├── AboutIcon.tsx
│ │ ├── BlubIcon.tsx
│ │ ├── ChatIcon.tsx
│ │ ├── HelpIcon.tsx
│ │ ├── MicrophoneIcon.tsx
│ │ ├── OKCircleIcon.tsx
│ │ ├── RightTriangleIcon.tsx
│ │ ├── SpeakerIcon.tsx
│ │ ├── SpeechGPTIcon.tsx
│ │ ├── SpinnerIcon.tsx
│ │ ├── StartCircleIcon.tsx
│ │ ├── StopCircleIcon.tsx
│ │ ├── WarningIcon.tsx
│ │ └── XIcon.tsx
│ ├── InputPanel.tsx
│ ├── LocaleSelector.tsx
│ ├── MobileSettingsSelector.tsx
│ ├── Notification.tsx
│ ├── Record.tsx
│ ├── Settings
│ │ ├── ChatSection.tsx
│ │ ├── PollyVoice.tsx
│ │ ├── RecognitionSection.tsx
│ │ ├── SettingContent.tsx
│ │ ├── SettingDialog.tsx
│ │ ├── SettingSelector.tsx
│ │ ├── SynthesisSection.tsx
│ │ ├── azureTtsVoice.tsx
│ │ └── base
│ │ │ ├── SettingCheckText.tsx
│ │ │ ├── SettingDivider.tsx
│ │ │ ├── SettingGroup.tsx
│ │ │ ├── SettingInput.tsx
│ │ │ ├── SettingSelect.tsx
│ │ │ ├── SettingSlider.tsx
│ │ │ ├── SettingSubtitle.tsx
│ │ │ ├── SettingSwitch.tsx
│ │ │ ├── SettingTextArea.tsx
│ │ │ ├── SettingTitle.tsx
│ │ │ └── SettingWarningText.tsx
│ ├── Tips.tsx
│ └── base
│ │ ├── Button.tsx
│ │ ├── Dialog.tsx
│ │ ├── DropdownMenu.tsx
│ │ ├── Input.tsx
│ │ ├── RangeSlider.tsx
│ │ ├── Select.tsx
│ │ ├── Textarea.tsx
│ │ ├── TippyButton.tsx
│ │ └── Toggle.tsx
├── constants
│ └── data.ts
├── css
│ └── index.css
├── db
│ ├── chat.ts
│ └── index.ts
├── helpers
│ ├── markdown
│ │ ├── index.tsx
│ │ ├── matcher.ts
│ │ └── parser
│ │ │ ├── Blockquote.tsx
│ │ │ ├── Bold.tsx
│ │ │ ├── BoldEmphasis.tsx
│ │ │ ├── Br.tsx
│ │ │ ├── CodeBlock.tsx
│ │ │ ├── DoneList.tsx
│ │ │ ├── Emphasis.tsx
│ │ │ ├── Heading.tsx
│ │ │ ├── HorizontalRules.tsx
│ │ │ ├── Image.tsx
│ │ │ ├── InlineCode.tsx
│ │ │ ├── Link.tsx
│ │ │ ├── OrderedList.tsx
│ │ │ ├── Paragraph.tsx
│ │ │ ├── PlainLink.tsx
│ │ │ ├── PlainText.tsx
│ │ │ ├── Strikethrough.tsx
│ │ │ ├── Table.tsx
│ │ │ ├── Tag.tsx
│ │ │ ├── TodoList.tsx
│ │ │ ├── UnorderedList.tsx
│ │ │ └── index.ts
│ └── utils.ts
├── i18n.ts
├── locales
│ ├── en.json
│ ├── es.json
│ └── zh-CN.json
├── main.tsx
├── pages
│ ├── Home.tsx
│ └── NotFound.tsx
├── store
│ ├── index.ts
│ ├── module
│ │ ├── global.ts
│ │ ├── index.ts
│ │ └── session.ts
│ └── reducer
│ │ ├── global.ts
│ │ └── session.ts
├── typings
│ ├── chat.d.ts
│ ├── env.d.ts
│ ├── global.d.ts
│ └── session.d.ts
├── utils
│ ├── speechSynthesis.ts
│ └── version.ts
└── vite-env.d.ts
├── tailwind.config.cjs
├── tsconfig.json
├── tsconfig.node.json
├── vite.config.ts
└── yarn.lock
/.dockerignore:
--------------------------------------------------------------------------------
1 | **/.dockerignore
2 | **/.git
3 | **/.gitignore
4 | **/.settings
5 | **/.vscode
6 | **/.idea
7 | **/dist
8 | **/Dockerfile*
9 | **/node_modules
10 | **/npm-debug.log
11 | **/yarn-error.log
12 | LICENSE
13 | README.md
14 |
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
1 | VITE_ACCESS_CODE=REPLACE_WITH_YOUR_OWN
2 | VITE_OPENAI_API_KEY=REPLACE_WITH_YOUR_OWN
3 | VITE_OPENAI_HOST=REPLACE_WITH_YOUR_OWN
4 | VITE_OPENAI_MODEL=REPLACE_WITH_YOUR_OWN
5 | VITE_AWS_REGION=REPLACE_WITH_YOUR_OWN
6 | VITE_AWS_ACCESS_KEY_ID=REPLACE_WITH_YOUR_OWN
7 | VITE_AWS_ACCESS_KEY=REPLACE_WITH_YOUR_OWN
8 | VITE_AZURE_REGION=REPLACE_WITH_YOUR_OWN
9 | VITE_AZURE_KEY=REPLACE_WITH_YOUR_OWN
10 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.yml:
--------------------------------------------------------------------------------
1 | name: Bug Report
2 | description: Create a report to help us improve
3 | labels: [bug]
4 | body:
5 | - type: markdown
6 | attributes:
7 | value: |
8 | If you are reporting a new issue, make sure that we do not have any duplicates already open. You can ensure this by searching the issue list for this repository. If there is a duplicate, please close your issue and add a comment to the existing issue instead.
9 | - type: textarea
10 | attributes:
11 | label: Describe the bug
12 | description: |
13 | Briefly describe the problem you are having in a few paragraphs.
14 | validations:
15 | required: true
16 | - type: textarea
17 | attributes:
18 | label: Steps to reproduce
19 | description: |
20 | Provide the steps to reproduce the issue.
21 | placeholder: |
22 | 1. Go to '...'
23 | 2. Click on '....'
24 | 3. See error
25 | validations:
26 | required: true
27 | - type: textarea
28 | attributes:
29 | label: Screenshots or additional context
30 | description: |
31 | Add screenshots or any other context about the problem.
32 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.yml:
--------------------------------------------------------------------------------
1 | name: Feature Request
2 | description: Suggest an idea for this project
3 | labels: [enhancement]
4 | body:
5 | - type: markdown
6 | attributes:
7 | value: |
8 | Thanks for taking the time to suggest an idea for Memos!
9 | - type: textarea
10 | attributes:
11 | label: Is your feature request related to a problem?
12 | description: |
13 | A clear and concise description of what the problem is.
14 | placeholder: |
15 | I'm always frustrated when [...]
16 | validations:
17 | required: true
18 | - type: textarea
19 | attributes:
20 | label: Describe the solution you'd like
21 | description: |
22 | A clear and concise description of what you want to happen.
23 | validations:
24 | required: true
25 | - type: textarea
26 | attributes:
27 | label: Additional context
28 | description: Add any other context or screenshots about the feature request.
29 |
--------------------------------------------------------------------------------
/.github/workflows/build-docker-image.yml:
--------------------------------------------------------------------------------
1 | name: Docker Image CI
2 |
3 | on:
4 | push:
5 | branches: [ "main" ]
6 | pull_request:
7 | branches: [ "main" ]
8 |
9 | env:
10 | IMAGE_NAME: speechgpt
11 | DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
12 | DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
13 |
14 | jobs:
15 | build-and-push-image:
16 | runs-on: ubuntu-latest
17 | steps:
18 | - name: Checkout code
19 | uses: actions/checkout@v2
20 |
21 | - name: Login to Docker Hub
22 | uses: docker/login-action@v1
23 | with:
24 | username: ${{ env.DOCKER_USERNAME }}
25 | password: ${{ env.DOCKER_PASSWORD }}
26 |
27 | - name: Build and push Docker image
28 | uses: docker/build-push-action@v2
29 | with:
30 | context: .
31 | push: true
32 | tags: ${{ env.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:latest
33 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | /dist
3 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 100,
3 | "tabWidth": 2,
4 | "useTabs": false,
5 | "semi": true,
6 | "singleQuote": true,
7 | "trailingComma": "es5",
8 | "bracketSpacing": true,
9 | "jsxBracketSameLine": false,
10 | "arrowParens": "avoid",
11 | "proseWrap": "preserve"
12 | }
13 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # 📝 Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
6 |
7 | ## [0.5.1] - 2023-05-15
8 | ### 🛠️ Changed
9 | - Retain disable speaker and disable microphone states after refresh
10 | - Disable send button when the input is empty
11 | - Hide record button when the microphone is disabled
12 | - Hide replay button when the speaker is disabled
13 | - Reset conversation confirmation
14 |
15 | ### 🐞 Fixed
16 | - Reset confirmation disappear problem
17 |
18 | ## [0.5.0] - 2023-05-12
19 | ### 🛠️ Changed
20 | - Brand-new UI design
21 | - New about page design
22 | - New color theme
23 | - New icon library
24 |
25 | ### ✨ Added
26 | - Allow to change conversation color
27 | - Allow to clear all conversations
28 | - Delete requires confirmations
29 |
30 | ### 🐞 Fixed
31 | - Delete multiple conversations at once error
32 |
33 | ## [0.4.3] - 2023-04-26
34 | ### 🛠️ Changed
35 | - Improve website SEO
36 | - Update `README.md`
37 |
38 | ### ✨ Added
39 | - Add developer guide
40 |
41 | ### 🐞 Fixed
42 | - Typo in the settings page
43 |
44 | ## [0.4.2] - 2023-04-22
45 | ### 🛠️ Changed
46 | - Update settings about page link icons
47 | - Improve the stability of the Azure speech synthesis service
48 | - i18n of the input panel
49 |
50 | ## [0.4.1] - 2023-04-19
51 |
52 | ### ✨ Added
53 | - Allow user to set access code to protect the app (#61)
54 | - Add change log
55 |
56 | ## [0.4.0] - 2023-04-17
57 | ### ✨ Added
58 | - Support multiple conversions
59 | - Allow to change OpenAI model
60 |
61 | ### 🐞 Fixed
62 | - Hide button when text is selected
63 |
64 | ## [0.3.0] - 2023-04-10
65 | ### ✨ Added
66 | - Add replay button
67 | - Supports saving chat records locally
68 |
69 | ### 🐞 Fixed
70 | - Hide keyboard on mobile when user send a message
71 | - Fix bug when using environment variables to set OpenAI Key
72 | - Cannot delete message error
73 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Use the ARM64 Node.js image as the builder stage
2 | FROM node:18-bullseye-slim AS builder
3 |
4 | ARG VITE_OPENAI_API_KEY=REPLACE_WITH_YOUR_OWN
5 | ARG VITE_OPENAI_HOST=REPLACE_WITH_YOUR_OWN
6 | ARG VITE_AWS_REGION=REPLACE_WITH_YOUR_OWN
7 | ARG VITE_AWS_ACCESS_KEY_ID=REPLACE_WITH_YOUR_OWN
8 | ARG VITE_AWS_ACCESS_KEY=REPLACE_WITH_YOUR_OWN
9 | ARG VITE_AZURE_REGION=REPLACE_WITH_YOUR_OWN
10 | ARG VITE_AZURE_KEY=REPLACE_WITH_YOUR_OWN
11 |
12 | ENV VITE_OPENAI_API_KEY=$VITE_OPENAI_API_KEY \
13 | VITE_OPENAI_HOST=$VITE_OPENAI_HOST \
14 | VITE_AWS_REGION=$VITE_AWS_REGION \
15 | VITE_AWS_ACCESS_KEY_ID=$VITE_AWS_ACCESS_KEY_ID \
16 | VITE_AWS_ACCESS_KEY=$VITE_AWS_ACCESS_KEY \
17 | VITE_AZURE_REGION=$VITE_AZURE_REGION \
18 | VITE_AZURE_KEY=$VITE_AZURE_KEY
19 |
20 | WORKDIR /app
21 | COPY package.json yarn.lock ./
22 | RUN yarn install
23 | COPY . .
24 | RUN yarn build
25 |
26 | # Use a smaller ARM64-compatible base image for the final stage
27 | FROM arm64v8/nginx:alpine
28 |
29 | COPY nginx.conf /etc/nginx/nginx.conf
30 | WORKDIR /usr/share/nginx/html
31 | COPY --from=builder /app/dist .
32 |
33 | ENTRYPOINT ["nginx", "-g", "daemon off;"]
34 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 SpeechGPT
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 |
2 |
3 |
4 |
5 |
6 | Website •
7 | [中文]
8 |
9 |
10 |
11 |
12 |
13 |
14 | ## 🌟 Introduction
15 | SpeechGPT is a web application that enables you to converse with ChatGPT.
16 | You can utilize this app to improve your language speaking skills or simply have fun chatting with ChatGPT.
17 |
18 | ## 🚀 Features
19 | - 📖 **Open source and free**: Anyone can use, modify it without cost.
20 | - 🔒 **Privacy First**: All data is stored locally.
21 | - 📱 **Mobile friendly**: Designed to be accessible and usable on mobile devices.
22 | - 📚 **Support for multiple languages**: Supports over 100 languages.
23 | - 🎙 **Speech Recognition**: Includes both built-in speech recognition and integration with Azure Speech Services.
24 | - 🔊 **Speech Synthesis**: Includes built-in speech synthesis, as well as integration with Amazon Polly and Azure Speech Services.
25 |
26 | ## 📸 Screenshots
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | ## 📖 Tutorial
36 | 1. Set the OpenAI API Key
37 | - Go to Settings and navigate to the Chat section.
38 | - Set the OpenAI API Key.
39 | - If you don't have an OpenAI API Key, follow this tutorial on [how to get an OpenAI API Key](https://www.windowscentral.com/software-apps/how-to-get-an-openai-api-key).
40 | 2. Set up Azure Speech Services (optional)
41 | - Go to Settings and navigate to the Synthesis section.
42 | - Change the Speech Synthesis Service to Azure TTS.
43 | - Set the Azure Region and Azure Access Key.
44 | 3. Set up Amazon Polly (optional)
45 | - Go to Settings and navigate to the Synthesis section.
46 | - Change the Speech Synthesis Service to Amazon Polly.
47 | - Set the AWS Region, AWS Access Key ID, and Secret Access Key (the Access Key should have the AmazonPollyFullAccess policy).
48 | - If you don't have an AWS Access Key, follow this tutorial on [how to create an IAM user in AWS](https://www.techtarget.com/searchcloudcomputing/tutorial/Step-by-step-guide-on-how-to-create-an-IAM-user-in-AWS).
49 |
50 | ## 💻 Development Guide and Changelog
51 | - For more information on setting up your development environment, please see our [Development Guide](./docs/developer-guide.md).
52 | - To view the project's history of notable changes, please check the [Changelog](./CHANGELOG.md).
53 |
54 | ## 🚢 Deployment
55 |
56 | ### Deploying with Vercel
57 | [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fhahahumble%2Fspeechgpt&env=VITE_OPENAI_API_KEY,VITE_OPENAI_HOST,VITE_AWS_REGION,VITE_AWS_ACCESS_KEY_ID,VITE_AWS_ACCESS_KEY,VITE_AZURE_REGION,VITE_AZURE_KEY&envDescription=If%20you%20do%20not%20want%20to%20provide%20a%20value%2C%20use%20REPLACE_WITH_YOUR_OWN.&project-name=speechgpt&repository-name=speechgpt)
58 |
59 | ### Deploying with Docker
60 | 1. Pull the Docker image:arm64.
61 | ```bash
62 | docker pull hahahumble/speechgpt
63 | ```
64 |
65 | 2. Run the Docker container.
66 | ```bash
67 | docker run -d -p 8080:8080 --name speechgpt hahahumble/speechgpt
68 | ```
69 |
70 | 3. Visit `http://localhost:8080/` to access the application.
71 |
72 | ### Building and running the Docker image
73 | 1. Build the Docker image.
74 | ```bash
75 | docker build -t speechgpt:arm64 -f Dockerfile .
76 | ```
77 |
78 | 2. Run the Docker container.
79 | ```bash
80 | docker run -d -p 8080:8080 --name=speechgpt speechgpt
81 | ```
82 |
83 | 3. Visit `http://localhost:8080/` to access the application.
84 |
85 | ## 📄 License
86 | This project is licensed under the terms of the [MIT license](/LICENSE).
87 |
--------------------------------------------------------------------------------
/assets/demo-en.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hahahumble/speechgpt/215e3a07a65eafede2ba3257939a4e59cfd01ae6/assets/demo-en.png
--------------------------------------------------------------------------------
/assets/demo-zh.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hahahumble/speechgpt/215e3a07a65eafede2ba3257939a4e59cfd01ae6/assets/demo-zh.png
--------------------------------------------------------------------------------
/assets/screenshots/screenshot-1-en.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hahahumble/speechgpt/215e3a07a65eafede2ba3257939a4e59cfd01ae6/assets/screenshots/screenshot-1-en.png
--------------------------------------------------------------------------------
/assets/screenshots/screenshot-1-zh.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hahahumble/speechgpt/215e3a07a65eafede2ba3257939a4e59cfd01ae6/assets/screenshots/screenshot-1-zh.png
--------------------------------------------------------------------------------
/assets/screenshots/screenshot-2-en.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hahahumble/speechgpt/215e3a07a65eafede2ba3257939a4e59cfd01ae6/assets/screenshots/screenshot-2-en.png
--------------------------------------------------------------------------------
/assets/screenshots/screenshot-2-zh.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hahahumble/speechgpt/215e3a07a65eafede2ba3257939a4e59cfd01ae6/assets/screenshots/screenshot-2-zh.png
--------------------------------------------------------------------------------
/assets/screenshots/screenshot-3-en.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hahahumble/speechgpt/215e3a07a65eafede2ba3257939a4e59cfd01ae6/assets/screenshots/screenshot-3-en.png
--------------------------------------------------------------------------------
/assets/screenshots/screenshot-3-zh.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hahahumble/speechgpt/215e3a07a65eafede2ba3257939a4e59cfd01ae6/assets/screenshots/screenshot-3-zh.png
--------------------------------------------------------------------------------
/docs/README.zh.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | 网站
7 |
8 |
9 |
10 |
11 |
12 |
13 | ## 🌟 简介
14 | SpeechGPT 是一个让你与 ChatGPT 聊天的网站。
15 | 你可以使用 SpeechGPT 来练习你的口语,或者只是和 ChatGPT 闲聊。
16 |
17 | ## 🚀 特点
18 | - 📖 **开源免费**: 任何人都可以免费使用、修改。
19 | - 🔒 **隐私至上**: 所有数据都存储在本地,保护用户隐私。
20 | - 📱 **移动端友好**: 具有响应式设计。
21 | - 📚 **支持多种语言**: 支持超过 100 种语言。
22 | - 🎙 **语音识别**: 包括浏览器内置的语音识别功能和与 Azure 语音服务的集成。
23 | - 🔊 **语音合成**: 包括浏览器内置的语音合成功能,以及与 Amazon Polly 和 Azure 语音服务的集成。
24 |
25 | ## 📸 屏幕截图
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | ## 📖 教程
35 | 1. 设置 OpenAI API Key
36 | - 进入设置,进入对话部分。
37 | - 设置 OpenAI API Key。
38 | - 如果您没有 OpenAI API Key,请按照[如何获取 OpenAI API Key 的教程](https://www.windowscentral.com/software-apps/how-to-get-an-openai-api-key)进行操作。
39 | 2. 设置 Azure 语音服务(可选)
40 | - 进入设置,进入语音合成部分。
41 | - 将语音合成服务更改为 Azure TTS。
42 | - 设置 Azure 区域和 Azure 访问密钥。
43 | 3. 设置 Amazon Polly(可选)
44 | - 进入设置,进入语音合成部分。
45 | - 将语音合成服务更改为 Amazon Polly。
46 | - 设置 AWS 区域、AWS 访问密钥 ID 和密钥访问密钥(访问密钥应具有 AmazonPollyFullAccess 策略)。
47 | - 如果您没有 AWS Access Key,请按照[如何在 AWS 中创建 IAM 用户的教程](https://www.techtarget.com/searchcloudcomputing/tutorial/Step-by-step-guide-on-how-to-create-an-IAM-user-in-AWS)进行操作。
48 |
49 | ## 💻 开发指南和更新日志
50 | - 如需了解有关设置开发环境的更多信息,请查看我们的[开发指南](./developer-guide.zh.md)。
51 | - 若要查看项目的重要更改历史,请查阅[更新日志](../CHANGELOG.md)。
52 |
53 | ## 🚢 部署
54 | ### 使用 Vercel 部署
55 | [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fhahahumble%2Fspeechgpt&env=VITE_OPENAI_API_KEY,VITE_OPENAI_HOST,VITE_AWS_REGION,VITE_AWS_ACCESS_KEY_ID,VITE_AWS_ACCESS_KEY,VITE_AZURE_REGION,VITE_AZURE_KEY&envDescription=If%20you%20do%20not%20want%20to%20provide%20a%20value%2C%20use%20REPLACE_WITH_YOUR_OWN.&project-name=speechgpt&repository-name=speechgpt)
56 |
57 | ### 使用 Docker 部署
58 | 1. 拉取 Docker 镜像
59 | ```bash
60 | docker pull hahahumble/speechgpt
61 | ```
62 |
63 | 2. 运行 Docker 容器
64 | ```bash
65 | docker run -d -p 8080:8080 --name speechgpt hahahumble/speechgpt
66 | ```
67 |
68 | 3. 访问 `http://localhost:8080/`
69 |
70 | ### 构建和运行 Docker 镜像
71 | 1. 构建 Docker 镜像
72 | ```bash
73 | docker build -t speechgpt .
74 | ```
75 |
76 | 2. 运行 Docker 容器
77 | ```bash
78 | docker run -d -p 8080:8080 --name=speechgpt speechgpt
79 | ```
80 |
81 | 3. 访问 `http://localhost:8080/`
82 |
83 | ## 📄 许可
84 | 本项目根据 [MIT 许可证](/LICENSE) 的条款进行许可。
85 |
--------------------------------------------------------------------------------
/docs/developer-guide.md:
--------------------------------------------------------------------------------
1 | # Development Guide for SpeechGPT
2 | This development guide provides an overview of the SpeechGPT project, its technology stack, and the file structure. Follow the instructions below to get started with development.
3 |
4 | ## Technology Stack
5 | SpeechGPT uses the following technology stack:
6 |
7 | - [React 18](https://reactjs.org/)
8 | - [Vite](https://vitejs.dev/)
9 | - [TypeScript](https://www.typescriptlang.org/)
10 | - [Redux Toolkit](https://redux-toolkit.js.org/)
11 | - [TailwindCSS](https://tailwindcss.com/)
12 |
13 | ## File Structure
14 | The project is organized as follows:
15 |
16 | ```text
17 | .
18 | ├── .github
19 | ├── assets
20 | ├── docs
21 | ├── public
22 | └── src
23 | ├── apis
24 | ├── assets
25 | ├── components
26 | ├── constants
27 | ├── css
28 | ├── db
29 | ├── helpers
30 | ├── locales
31 | ├── pages
32 | ├── store
33 | ├── typings
34 | └── utils
35 | ```
36 |
37 | ## Directory Descriptions
38 | - `.github`: Contains GitHub Actions workflows and configurations.
39 | - `assets`: Contains project-specific assets, such as images or other media files.
40 | - `docs`: Holds documentation files for the project.
41 | - `public`: Includes static files and assets used in the project.
42 | - `src`: The main source code directory for the application.
43 | - `apis`: Contains API-related functions and configurations.
44 | - `components`: Holds React components used throughout the application.
45 | - `constants`: Includes constant values and configurations.
46 | - `css`: Holds CSS files and styles for the project.
47 | - `db`: Contains database configurations and related files.
48 | - `helpers`: Stores helper functions for specific tasks and logic.
49 | - `locales`: Stores localization and internationalization files.
50 | - `pages`: Contains the main page components for the application.
51 | - `store`: Contains Redux store configurations and related files.
52 | - `typings`: Contains TypeScript type definitions and interfaces.
53 | - `utils`: Stores utility functions and helper methods.
54 |
55 | ## Git Commit Guidelines
56 | When committing changes to the repository, follow these commit message conventions to maintain a consistent and readable Git history:
57 |
58 | - `feat`: New features or modifications to existing features.
59 | - `fix`: Bug fixes.
60 | - `docs`: Changes to documentation files.
61 | - `style`: Formatting changes that do not affect code execution (e.g., whitespace, formatting, missing semicolons, etc.).
62 | - `refactor`: Code changes that are neither new features nor bug fixes but improve code quality.
63 | - `perf`: Performance-enhancing code changes.
64 | - `test`: Adding missing tests or updating existing tests.
65 | - `chore`: Changes to build processes or auxiliary tools.
66 | - `revert`: Reverting a previous commit (e.g., revert: type(scope): subject (revert to version: xxxx)).
67 |
68 | ## Getting Started
69 | 1. Clone the repository.
70 | ```shell
71 | git clone https://github.com/hahahumble/speechgpt.git
72 | ```
73 |
74 | 2. Install the required dependencies.
75 | ```shell
76 | yarn install
77 | ```
78 |
79 | 3. Start the development server. The application should be accessible at http://localhost:5173.
80 | ```shell
81 | yarn dev
82 | ```
83 |
84 | 4. Build the application for production. The output files will be in the dist directory.
85 | ```shell
86 | yarn build
87 | ```
88 |
89 | 5. Run the application in production mode. The application should be accessible at http://localhost:4173.
90 | ```shell
91 | yarn serve
92 | ```
93 |
94 | Code formatting(Using Prettier).
95 | ```shell
96 | yarn format
97 | ```
98 |
99 | 🚀 Start your SpeechGPT development journey! If you encounter any problems during the development process, please feel free to check out the documentation or submit a question on GitHub. Have fun developing! 🎉
100 |
--------------------------------------------------------------------------------
/docs/developer-guide.zh.md:
--------------------------------------------------------------------------------
1 | # SpeechGPT 开发指南
2 | 本开发指南概述了 SpeechGPT 项目、技术栈和文件结构。按照以下说明开始开发。
3 |
4 | ## 技术栈
5 | SpeechGPT 使用以下技术栈:
6 |
7 | - [React 18](https://reactjs.org/)
8 | - [Vite](https://vitejs.dev/)
9 | - [TypeScript](https://www.typescriptlang.org/)
10 | - [Redux Toolkit](https://redux-toolkit.js.org/)
11 | - [TailwindCSS](https://tailwindcss.com/)
12 |
13 | ## 文件结构
14 | 项目组织如下:
15 |
16 | ```text
17 | .
18 | ├── .github
19 | ├── assets
20 | ├── docs
21 | ├── public
22 | └── src
23 | ├── apis
24 | ├── assets
25 | ├── components
26 | ├── constants
27 | ├── css
28 | ├── db
29 | ├── helpers
30 | ├── locales
31 | ├── pages
32 | ├── store
33 | ├── typings
34 | └── utils
35 | ```
36 |
37 | ## 目录描述
38 | - `.github`: 包含 GitHub Actions 工作流和配置。
39 | - `assets`: 包含项目特定的资源,如图像或其他媒体文件。
40 | - `docs`: 存放项目文档文件。
41 | - `public`: 包含项目中使用的静态文件和资源。
42 | - `src`: 应用程序的主要源代码目录。
43 | - `apis`: 包含 API 相关的函数和配置。
44 | - `components`: 存放应用程序中使用的 React 组件。
45 | - `constants`: 包含常量值和配置。
46 | - `css`: 存放项目的 CSS 文件和样式。
47 | - `db`: 包含数据库配置和相关文件。
48 | - `helpers`: 存储特定任务和逻辑的辅助函数。
49 | - `locales`: 存放本地化和国际化文件。
50 | - `pages`: 包含应用程序的主要页面组件。
51 | - `store`: 包含 Redux 存储配置和相关文件。
52 | - `typings`: 包含 TypeScript 类型定义和接口。
53 | - `utils`: 存储实用函数和辅助方法。
54 |
55 | ## Git 提交指南
56 | 在将更改提交到仓库时,请遵循这些提交消息约定,以保持一致且可读的 Git 历史记录:
57 |
58 | - `feat`:新增功能或对现有功能的修改。
59 | - `fix`:修复 bug。
60 | - `docs`:文档文件的更改。
61 | - `style`:不影响代码执行的格式更改(例如,空格、格式化、缺失的分号等)。
62 | - `refactor`:既非新功能,也非 bug 修复,但能提高代码质量的更改。
63 | - `perf`:提高性能的代码更改。
64 | - `test`:添加缺失的测试或更新现有测试。
65 | - `revert`:撤销以前的提交(例如,revert: type(scope): subject(回滚到版本:xxxx))。
66 |
67 | ## 开始
68 | 1. 克隆仓库。
69 | ```shell
70 | git clone https://github.com/hahahumble/speechgpt.git
71 | ```
72 |
73 | 2. 安装所需的依赖项。
74 | ```shell
75 | yarn install
76 | ```
77 |
78 | 3. 启动开发服务器。应用程序应可在 http://localhost:5173 上访问。
79 | ```shell
80 | yarn dev
81 | ```
82 |
83 | 4. 为生产环境构建应用程序。输出文件将位于 dist 目录中。
84 | ```shell
85 | yarn build
86 | ```
87 |
88 | 5. 以生产模式运行应用程序。应用程序应可在 http://localhost:4173 上访问。
89 | ```shell
90 | yarn serve
91 | ```
92 |
93 | 代码格式化(使用 Prettier)。
94 | ```shell
95 | yarn format
96 | ```
97 |
98 | 🚀 开始您的 SpeechGPT 开发之旅吧!如果您在开发过程中遇到任何问题,请随时查阅相关文档或在 GitHub 上提交问题。祝您开发愉快!🎉
99 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | SpeechGPT
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/nginx.conf:
--------------------------------------------------------------------------------
1 | worker_processes 1;
2 | events {
3 | worker_connections 1024;
4 | }
5 | http {
6 | include mime.types;
7 | default_type application/octet-stream;
8 | sendfile on;
9 | keepalive_timeout 65;
10 | gzip on;
11 | server {
12 | listen 8080;
13 | server_name localhost;
14 | location / {
15 | root /usr/share/nginx/html;
16 | index index.html index.htm;
17 | try_files $uri $uri/ /index.html;
18 | }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "speechgpt",
3 | "private": true,
4 | "version": "0.5.1",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc && vite build",
9 | "preview": "vite preview",
10 | "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,scss,md}\""
11 | },
12 | "dependencies": {
13 | "@aws-sdk/client-polly": "^3.303.0",
14 | "@aws-sdk/client-transcribe-streaming": "^3.303.0",
15 | "@aws-sdk/polly-request-presigner": "^3.303.0",
16 | "@headlessui/react": "^1.7.13",
17 | "@heroicons/react": "^2.0.18",
18 | "@reduxjs/toolkit": "^1.9.3",
19 | "@tabler/icons-react": "^2.18.0",
20 | "@tippyjs/react": "^4.2.6",
21 | "@types/i18next": "^13.0.0",
22 | "@types/react-speech-recognition": "^3.9.1",
23 | "@types/react-transition-group": "^4.4.6",
24 | "@types/uuid": "^9.0.1",
25 | "aws-sdk": "^2.1348.0",
26 | "axios": "^1.3.4",
27 | "classnames": "^2.3.2",
28 | "dexie": "^3.2.3",
29 | "dexie-react-hooks": "^1.1.3",
30 | "highlight.js": "^11.7.0",
31 | "i18next": "^22.4.13",
32 | "microsoft-cognitiveservices-speech-sdk": "^1.26.0",
33 | "openai": "^3.2.1",
34 | "react": "^18.2.0",
35 | "react-cool-onclickoutside": "^1.7.0",
36 | "react-device-detect": "^2.2.3",
37 | "react-dom": "^18.2.0",
38 | "react-hook-speech-to-text": "^0.8.0",
39 | "react-hot-toast": "^2.4.0",
40 | "react-i18next": "^12.2.0",
41 | "react-markdown": "^8.0.6",
42 | "react-redux": "^8.0.5",
43 | "react-router-dom": "^6.10.0",
44 | "react-scroll": "^1.8.9",
45 | "react-textarea-autosize": "^8.4.1",
46 | "react-transition-group": "^4.4.5",
47 | "uuid": "^9.0.0"
48 | },
49 | "devDependencies": {
50 | "@types/react": "^18.0.31",
51 | "@types/react-dom": "^18.0.11",
52 | "@types/react-scroll": "^1.8.6",
53 | "@vitejs/plugin-react": "^3.1.0",
54 | "autoprefixer": "^10.4.14",
55 | "postcss": "^8.4.23",
56 | "prettier": "2.8.7",
57 | "tailwindcss": "^3.3.1",
58 | "typescript": "^5.0.3",
59 | "vite": "^4.2.1"
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/public/speechgpt.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
2 | import React from 'react';
3 | import Home from './pages/Home';
4 | import { initialGlobalState, initialSessionState } from './store/module';
5 | import NotFound from './pages/NotFound';
6 | import 'highlight.js/styles/github.css';
7 |
8 | function App() {
9 | initialGlobalState();
10 | initialSessionState();
11 | return (
12 |
13 |
14 | } />
15 | } />
16 |
17 |
18 | );
19 | }
20 |
21 | export default App;
22 |
--------------------------------------------------------------------------------
/src/apis/amazonPolly.ts:
--------------------------------------------------------------------------------
1 | import { Polly } from '@aws-sdk/client-polly';
2 | import { getSynthesizeSpeechUrl } from '@aws-sdk/polly-request-presigner';
3 |
4 | const speechParams = {
5 | OutputFormat: 'mp3',
6 | SampleRate: '16000',
7 | VoiceId: 'Matthew',
8 | TextType: 'text',
9 | Text: '',
10 | Engine: 'neural',
11 | };
12 |
13 | export default async function speechSynthesizeWithPolly(
14 | text: string,
15 | voiceId: string = 'Matthew',
16 | engine: string = 'neural',
17 | aws_region: string,
18 | aws_id: string,
19 | aws_key: string
20 | ) {
21 | speechParams.Text = text;
22 | speechParams.VoiceId = voiceId;
23 | speechParams.Engine = engine;
24 |
25 | try {
26 | const polly = new Polly({
27 | region: aws_region,
28 | credentials: {
29 | accessKeyId: aws_id,
30 | secretAccessKey: aws_key,
31 | },
32 | });
33 | // https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/Polly.html#synthesizeSpeech-property
34 | return getSynthesizeSpeechUrl({ client: polly, params: speechParams });
35 | } catch (err) {
36 | console.log('Error', err);
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/apis/azureTTS.ts:
--------------------------------------------------------------------------------
1 | import * as sdk from 'microsoft-cognitiveservices-speech-sdk';
2 | import { azureSynthesisErrorNotify } from '../components/Notification';
3 |
4 | const speechSynthesizeWithAzure = async (
5 | subscriptionKey: string,
6 | region: string,
7 | text: string,
8 | voiceName: string,
9 | language: string
10 | ) => {
11 | console.time('Azure speech synthesis');
12 | const speechConfig = sdk.SpeechConfig.fromSubscription(subscriptionKey, region);
13 | speechConfig.speechRecognitionLanguage = language;
14 | speechConfig.speechSynthesisVoiceName = voiceName;
15 | const player = new sdk.SpeakerAudioDestination();
16 | const audioConfig = sdk.AudioConfig.fromSpeakerOutput(player);
17 | const speechSynthesizer = new sdk.SpeechSynthesizer(speechConfig, audioConfig);
18 | speechSynthesizer.speakTextAsync(
19 | text,
20 | result => {
21 | console.timeEnd('Azure speech synthesis');
22 | speechSynthesizer.close();
23 | },
24 | error => {
25 | console.log(error);
26 | azureSynthesisErrorNotify();
27 | speechSynthesizer.close();
28 | }
29 | );
30 | return player;
31 | };
32 | export default speechSynthesizeWithAzure;
33 |
--------------------------------------------------------------------------------
/src/apis/azureToken.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 |
3 | export async function getAzureToken(subscriptionKey: string, region: string): Promise {
4 | const url = `https://${region}.api.cognitive.microsoft.com/sts/v1.0/issueToken`;
5 |
6 | try {
7 | const response = await axios.post(url, null, {
8 | headers: {
9 | 'Ocp-Apim-Subscription-Key': subscriptionKey,
10 | 'Content-Type': 'application/x-www-form-urlencoded',
11 | },
12 | });
13 |
14 | return response.data;
15 | } catch (error) {
16 | throw new Error(`Error getting token: ${error}`);
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/apis/openai.ts:
--------------------------------------------------------------------------------
1 | export default async function sendRequest(
2 | messages: string[],
3 | openaiApiKey: string,
4 | openaiHost: string,
5 | openaiModel: string,
6 | callback: (data: any) => void
7 | ) {
8 | const requestOptions = {
9 | method: 'POST',
10 | headers: {
11 | 'Content-Type': 'application/json',
12 | Authorization: 'Bearer ' + openaiApiKey,
13 | },
14 | body: JSON.stringify({
15 | model: openaiModel || 'gpt-3.5-turbo',
16 | messages: messages,
17 | }),
18 | };
19 |
20 | const openaiHostAddress = openaiHost || 'api.openai.com';
21 |
22 | fetch('https://' + openaiHostAddress + '/v1/chat/completions', requestOptions)
23 | .then(response => response.json())
24 | .then(data => {
25 | callback(data);
26 | })
27 | .catch(err => {
28 | return err;
29 | });
30 | }
31 |
--------------------------------------------------------------------------------
/src/assets/arrow-down.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/AboutDialog.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useTranslation } from 'react-i18next';
3 | import BaseDialog from './base/Dialog';
4 | import TippyButton from './base/TippyButton';
5 |
6 | import { IconX, IconInfoCircle, IconWorldWww, IconBrandGithub } from '@tabler/icons-react';
7 | import SettingGroup from './Settings/base/SettingGroup';
8 | import { getVersion } from '../utils/version';
9 |
10 | interface AboutDialogProps {
11 | open: boolean;
12 | onClose: () => void;
13 | notify: any;
14 | }
15 |
16 | function AboutDialog({ open, onClose, notify }: AboutDialogProps) {
17 | const { i18n } = useTranslation();
18 |
19 | return (
20 |
21 |
22 | }
26 | style="hover:bg-gray-200 active:bg-gray-300"
27 | />
28 |
29 |
30 |
31 |
32 |
33 |
34 | {i18n.t('common.about')}
35 |
36 |
37 |
38 |
39 | {i18n.t('setting.about.intro')}
40 |
41 |
42 |
51 |
52 |
53 |
54 | {i18n.t('setting.about.link') as string}
55 |
56 |
57 |
58 |
74 |
75 |
76 |
77 | {i18n.t('setting.about.version') as string}
78 |
79 |
80 |
81 |
82 | Version: {getVersion()}
83 |
84 |
85 |
86 |
87 |
88 | );
89 | }
90 |
91 | export default AboutDialog;
92 |
--------------------------------------------------------------------------------
/src/components/AppearanceSelector.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState, useRef } from 'react';
2 | import { MoonIcon, SunIcon, ComputerDesktopIcon, CheckIcon } from '@heroicons/react/20/solid';
3 | import { Menu } from '@headlessui/react';
4 | import DropdownMenu from './base/DropdownMenu';
5 | import Tippy from '@tippyjs/react';
6 | import 'tippy.js/dist/tippy.css';
7 | import { useTranslation } from 'react-i18next';
8 | import { useGlobalStore } from '../store/module';
9 |
10 | interface AppearanceOption {
11 | key: 'light' | 'dark' | 'system';
12 | label: any;
13 | icon: JSX.Element;
14 | }
15 |
16 | function AppearanceSelector() {
17 | const { i18n } = useTranslation();
18 |
19 | const { appearance, setAppearance } = useGlobalStore();
20 |
21 | const isMount = useRef(true);
22 |
23 | useEffect(() => {
24 | if (isMount.current) {
25 | const storedAppearance = localStorage.getItem('appearance');
26 | if (storedAppearance) {
27 | setAppearance(storedAppearance);
28 | }
29 | isMount.current = false;
30 | } else {
31 | switch (appearance) {
32 | case 'light':
33 | document.documentElement.classList.remove('dark');
34 | break;
35 | case 'dark':
36 | document.documentElement.classList.add('dark');
37 | break;
38 | case 'system':
39 | const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
40 | if (darkModeMediaQuery.matches) {
41 | document.documentElement.classList.add('dark');
42 | } else {
43 | document.documentElement.classList.remove('dark');
44 | }
45 | break;
46 | }
47 | localStorage.setItem('appearance', appearance);
48 | }
49 | }, [appearance, setAppearance]);
50 |
51 | const appearanceOptions: AppearanceOption[] = [
52 | { key: 'light', label: 'Light', icon: },
53 | { key: 'dark', label: 'Dark', icon: },
54 | {
55 | key: 'system',
56 | label: 'System',
57 | icon: ,
58 | },
59 | ];
60 |
61 | const button = (
62 |
70 |
71 | {appearanceOptions.find(option => option.key === appearance)?.icon}
72 |
73 |
74 | );
75 |
76 | return (
77 |
78 | {appearanceOptions.map(option => (
79 |
80 | {({ active }) => (
81 | setAppearance(option.key)}
86 | >
87 | {option.icon}
88 | {option.label}
89 | {appearance === option.key && (
90 |
94 | )}
95 |
96 | )}
97 |
98 | ))}
99 |
100 | );
101 | }
102 |
103 | export default AppearanceSelector;
104 |
--------------------------------------------------------------------------------
/src/components/AzureSpeechToText.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import * as sdk from 'microsoft-cognitiveservices-speech-sdk';
3 | import { existEnvironmentVariable, getEnvironmentVariable } from '../helpers/utils';
4 |
5 | interface AzureSpeechToTextProps {
6 | subscriptionKey: string;
7 | region: string;
8 | isListening: boolean;
9 | language?: string;
10 | setTranscript: (update: ((prevTranscript: string) => string) | string) => void;
11 | setIsListening: (update: ((prevIsListening: boolean) => boolean) | boolean) => void;
12 | setWaiting: (update: ((prevWaiting: boolean) => boolean) | boolean) => void;
13 | notify: any;
14 | accessCode: string;
15 | }
16 |
17 | const AzureSpeechToText: React.FC = ({
18 | subscriptionKey,
19 | region,
20 | isListening,
21 | language = 'en-US',
22 | setIsListening,
23 | setTranscript,
24 | setWaiting,
25 | notify,
26 | accessCode,
27 | }) => {
28 | const [recognizer, setRecognizer] = useState(null);
29 |
30 | React.useEffect(() => {
31 | if (isListening) {
32 | startSpeechRecognition();
33 | } else {
34 | if (recognizer) {
35 | recognizer.stopContinuousRecognitionAsync();
36 | } else {
37 | // console.log('Recognizer is null');
38 | }
39 | }
40 | }, [isListening]);
41 |
42 | const startSpeechRecognition = () => {
43 | if (accessCode !== getEnvironmentVariable('ACCESS_CODE')) {
44 | notify.invalidAccessCodeNotify();
45 | setIsListening(false);
46 | setWaiting(false);
47 | return;
48 | }
49 |
50 | setWaiting(true);
51 |
52 | if (subscriptionKey === '' || region === '') {
53 | notify.emptyAzureKeyNotify();
54 | setIsListening(false);
55 | setWaiting(false);
56 | return;
57 | }
58 |
59 | const speechConfig = sdk.SpeechConfig.fromSubscription(subscriptionKey, region);
60 | speechConfig.speechRecognitionLanguage = language;
61 |
62 | const audioConfig = sdk.AudioConfig.fromDefaultMicrophoneInput();
63 | const newRecognizer = new sdk.SpeechRecognizer(speechConfig, audioConfig);
64 |
65 | newRecognizer.recognizing = (s, e) => {
66 | console.log(`Recognizing: ${e.result.text}`);
67 | };
68 |
69 | newRecognizer.recognized = (s, e) => {
70 | console.log(`Recognized: ${e.result.text}`);
71 | if (e.result.text !== undefined) {
72 | setTranscript(e.result.text);
73 | }
74 | };
75 |
76 | newRecognizer.canceled = (s, e) => {
77 | // @ts-ignore
78 | if (e.errorCode === sdk.CancellationErrorCode.ErrorAPIKey) {
79 | console.error('Invalid or incorrect subscription key');
80 | } else {
81 | console.log(`Canceled: ${e.errorDetails}`);
82 | notify.azureRecognitionErrorNotify();
83 | }
84 | setIsListening(false);
85 | setWaiting(false);
86 | };
87 |
88 | newRecognizer.sessionStopped = (s, e) => {
89 | console.log('Session stopped');
90 | newRecognizer.stopContinuousRecognitionAsync();
91 | setIsListening(false);
92 | setWaiting(false);
93 | };
94 |
95 | newRecognizer.startContinuousRecognitionAsync(
96 | () => {
97 | setWaiting(false);
98 | console.log('Listening...');
99 | },
100 | error => {
101 | console.log(`Error: ${error}`);
102 | notify.azureRecognitionErrorNotify();
103 | newRecognizer.stopContinuousRecognitionAsync();
104 | setIsListening(false);
105 | }
106 | );
107 |
108 | setRecognizer(newRecognizer);
109 | };
110 |
111 | return null;
112 | };
113 |
114 | export default AzureSpeechToText;
115 |
--------------------------------------------------------------------------------
/src/components/BrowserSpeechToText.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import {} from './Notification';
3 |
4 | interface BrowserSpeechToTextProps {
5 | isListening: boolean;
6 | language: string;
7 | setIsListening: (update: ((prevIsListening: boolean) => boolean) | boolean) => void;
8 | setTranscript: (update: ((prevTranscript: string) => string) | string) => void;
9 | notify: any;
10 | }
11 |
12 | const SpeechRecognition =
13 | ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window) &&
14 | ((window as any).SpeechRecognition || (window as any).webkitSpeechRecognition);
15 |
16 | const globalRecognition = SpeechRecognition ? new SpeechRecognition() : null;
17 |
18 | const BrowserSpeechToText: React.FC = ({
19 | isListening,
20 | language,
21 | setIsListening,
22 | setTranscript,
23 | notify,
24 | }) => {
25 | const [recognition, setRecognition] = useState(globalRecognition);
26 |
27 | useEffect(() => {
28 | if (recognition) {
29 | recognition.interimResults = true;
30 | recognition.continuous = true;
31 | recognition.lang = language;
32 |
33 | recognition.onresult = (event: SpeechRecognitionEvent) => {
34 | let currentTranscript = '';
35 |
36 | for (let i = event.resultIndex; i < event.results.length; i++) {
37 | const result = event.results[i];
38 | const text = result[0].transcript;
39 |
40 | if (result.isFinal) {
41 | setTranscript(text);
42 | } else {
43 | currentTranscript += text;
44 | }
45 | }
46 | };
47 |
48 | // @ts-ignore
49 | recognition.onerror = (event: SpeechRecognitionError) => {
50 | console.log('Error:', event.error);
51 | notify.errorBuiltinSpeechRecognitionNotify();
52 | setIsListening(false);
53 | };
54 | } else {
55 | console.log('SpeechRecognition API is not supported in this browser');
56 | notify.errorBuiltinSpeechRecognitionNotify();
57 | }
58 |
59 | return () => {
60 | if (recognition) {
61 | recognition.stop();
62 | }
63 | };
64 | }, [isListening, language, recognition]);
65 |
66 | useEffect(() => {
67 | if (isListening) {
68 | if (recognition) {
69 | recognition.start();
70 | }
71 | } else {
72 | if (recognition) {
73 | recognition.stop();
74 | }
75 | }
76 | }, [isListening, recognition]);
77 |
78 | return null;
79 | };
80 |
81 | export default BrowserSpeechToText;
82 |
--------------------------------------------------------------------------------
/src/components/ButtonGroup.tsx:
--------------------------------------------------------------------------------
1 | import TippyButton from './base/TippyButton';
2 | import React, { useRef, useState } from 'react';
3 | import { useTranslation } from 'react-i18next';
4 |
5 | import {
6 | IconVolume,
7 | IconVolume3,
8 | IconMicrophone,
9 | IconMicrophoneOff,
10 | IconMessages,
11 | IconRotateClockwise,
12 | IconPlayerPause,
13 | IconPlayerPlay,
14 | IconBackspace,
15 | IconCheck,
16 | } from '@tabler/icons-react';
17 | import { useGlobalStore } from '../store/module';
18 | import { isMobile } from 'react-device-detect';
19 |
20 | interface ButtonGroupProps {
21 | disableMicrophone: boolean;
22 | onClickDisableMicrophone: () => void;
23 | disableSpeaker: boolean;
24 | onClickDisableSpeaker: () => void;
25 | stopSpeaking: () => void;
26 | clearConversation: () => void;
27 | clearUserInput: () => void;
28 | notify: any;
29 | status: string;
30 | finished: boolean;
31 | }
32 |
33 | function ButtonGroup({
34 | disableMicrophone,
35 | onClickDisableMicrophone,
36 | disableSpeaker,
37 | onClickDisableSpeaker,
38 | stopSpeaking,
39 | clearConversation,
40 | clearUserInput,
41 | notify,
42 | status,
43 | finished,
44 | }: ButtonGroupProps) {
45 | const { i18n } = useTranslation();
46 | const [isConfirmingReset, setIsConfirmingReset] = useState(false);
47 |
48 | const resetTimeoutRef = useRef(null);
49 |
50 | function handleResetClick() {
51 | if (!isConfirmingReset) {
52 | setIsConfirmingReset(true);
53 |
54 | if (resetTimeoutRef.current) {
55 | clearTimeout(resetTimeoutRef.current);
56 | }
57 |
58 | resetTimeoutRef.current = setTimeout(() => {
59 | setIsConfirmingReset(false);
60 | }, 6000);
61 | } else {
62 | clearConversation();
63 | setIsConfirmingReset(false);
64 |
65 | if (resetTimeoutRef.current) {
66 | clearTimeout(resetTimeoutRef.current);
67 | }
68 | }
69 | }
70 |
71 | function MicrophoneButton() {
72 | if (!disableMicrophone) {
73 | return (
74 | }
78 | style="hover:bg-slate-200 active:bg-slate-300"
79 | />
80 | );
81 | } else {
82 | return (
83 | }
87 | style="hover:bg-slate-200 active:bg-slate-300"
88 | />
89 | );
90 | }
91 | }
92 |
93 | function SpeakerButton() {
94 | if (!disableSpeaker) {
95 | return (
96 | }
100 | style="hover:bg-slate-200 active:bg-slate-300"
101 | />
102 | );
103 | } else {
104 | return (
105 | }
109 | style="hover:bg-slate-200 active:bg-slate-300"
110 | />
111 | );
112 | }
113 | }
114 |
115 | return (
116 |
117 |
118 |
119 |
120 |
121 |
122 | {!disableSpeaker && (
123 | <>
124 | {status === 'speaking' && !finished && (
125 | }
129 | style="hover:bg-slate-200 active:bg-slate-300"
130 | />
131 | )}
132 | {status !== 'speaking' && !finished && (
133 | }
137 | style="hover:bg-slate-200 active:bg-slate-300"
138 | />
139 | )}
140 | >
141 | )}
142 | {isConfirmingReset ? (
143 | }
147 | style="hover:bg-slate-200 active:bg-slate-300"
148 | />
149 | ) : (
150 | }
154 | style="hover:bg-slate-200 active:bg-slate-300"
155 | />
156 | )}
157 | {
159 | clearUserInput();
160 | notify.clearedNotify();
161 | }}
162 | tooltip={i18n.t('common.clear-input') as string}
163 | icon={ }
164 | style="hover:bg-slate-200 active:bg-slate-300"
165 | />
166 |
167 |
168 | );
169 | }
170 |
171 | export default ButtonGroup;
172 |
--------------------------------------------------------------------------------
/src/components/ConversationPanel.tsx:
--------------------------------------------------------------------------------
1 | import Tips from './Tips';
2 | import TippyButton from './base/TippyButton';
3 | import { Element } from 'react-scroll';
4 | import React, { useState } from 'react';
5 | import { marked } from '../helpers/markdown';
6 | import { Chat } from '../db/chat';
7 | import { useTranslation } from 'react-i18next';
8 | import { useGlobalStore, useSessionStore } from '../store/module';
9 | import { IconCheck, IconCopy, IconTrash, IconVolume } from '@tabler/icons-react';
10 | import { isMobile } from 'react-device-detect';
11 |
12 | interface ConversationPanelProps {
13 | conversations: Chat[];
14 | deleteContent: (index: any) => void;
15 | copyContentToClipboard: (content: string) => void;
16 | generateSpeech: (content: string) => void;
17 | }
18 |
19 | function ConversationPanel({
20 | conversations,
21 | deleteContent,
22 | copyContentToClipboard,
23 | generateSpeech,
24 | }: ConversationPanelProps) {
25 | const { i18n } = useTranslation();
26 | const { disableSpeaker } = useGlobalStore();
27 |
28 | const [isHidden, setIsHidden] = useState(false);
29 | const [isConfirmingDelete, setIsConfirmingDelete] = useState(false);
30 |
31 | const handleMouseUp = () => {
32 | setIsHidden(window.getSelection()?.toString().length !== 0);
33 | };
34 |
35 | const handleMouseDown = () => {
36 | setIsHidden(true);
37 | };
38 |
39 | const { currentSessionId } = useSessionStore();
40 |
41 | function handleDeleteClick(id: number | undefined) {
42 | if (!isConfirmingDelete) {
43 | setIsConfirmingDelete(true);
44 |
45 | setTimeout(() => {
46 | // Automatically reset isConfirmingDelete state after 10 seconds
47 | setIsConfirmingDelete(false);
48 | }, 6000);
49 | } else {
50 | deleteContent(id);
51 | setIsConfirmingDelete(false);
52 | }
53 | }
54 |
55 | function ChatIcon({ role }: { role: 'user' | 'assistant' | 'system' }) {
56 | if (role === 'user') {
57 | return (
58 |
59 | );
60 | } else {
61 | return (
62 |
63 | );
64 | }
65 | }
66 |
67 | function isConversationEmpty() {
68 | return (
69 | conversations.length === 0 ||
70 | conversations.filter(conversation => conversation.sessionId === currentSessionId).length === 0
71 | );
72 | }
73 |
74 | return (
75 |
76 | {isConversationEmpty() && }
77 | {conversations
78 | .filter(conversation => conversation.sessionId === currentSessionId)
79 | .map((conversation, index) => (
80 |
84 |
85 |
92 | {marked(conversation.content ?? '')}
93 |
94 |
99 | {!disableSpeaker && (
100 | {
102 | generateSpeech(conversation.content);
103 | }}
104 | tooltip={i18n.t('common.replay') as string}
105 | icon={ }
106 | style="bg-slate-100 active:bg-slate-300 rounded-sm"
107 | />
108 | )}
109 | {isMobile ? (
110 | {
112 | deleteContent(conversation.id);
113 | }}
114 | tooltip={i18n.t('common.delete') as string}
115 | icon={ }
116 | style="bg-slate-100 active:bg-slate-300 rounded-sm"
117 | />
118 | ) : (
119 | {
121 | handleDeleteClick(conversation.id);
122 | }}
123 | tooltip={
124 | isConfirmingDelete
125 | ? (i18n.t('common.confirm') as string)
126 | : (i18n.t('common.delete') as string)
127 | }
128 | icon={
129 | isConfirmingDelete ? (
130 |
131 | ) : (
132 |
133 | )
134 | }
135 | style="bg-slate-100 active:bg-slate-300 rounded-sm"
136 | />
137 | )}
138 | {
140 | copyContentToClipboard(conversation.content);
141 | }}
142 | tooltip={i18n.t('common.copy') as string}
143 | icon={ }
144 | style="bg-slate-100 active:bg-slate-300 rounded-sm"
145 | />
146 |
147 |
148 | ))}
149 |
150 | );
151 | }
152 |
153 | export default ConversationPanel;
154 |
--------------------------------------------------------------------------------
/src/components/Conversations/ConversationIcons.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export const BlueCircle: React.FC = () => {
4 | return (
5 |
6 |
7 |
8 | );
9 | };
10 |
11 | export const RedCircle: React.FC = () => {
12 | return (
13 |
14 |
15 |
16 | );
17 | };
18 |
19 | export const GreenCircle: React.FC = () => {
20 | return (
21 |
22 |
23 |
24 | );
25 | };
26 |
27 | export const OrangeCircle: React.FC = () => {
28 | return (
29 |
30 |
31 |
32 | );
33 | };
34 |
35 | export const PurpleCircle: React.FC = () => {
36 | return (
37 |
38 |
39 |
40 | );
41 | };
42 |
43 | export const VioletCircle: React.FC = () => {
44 | return (
45 |
46 |
47 |
48 | );
49 | };
50 |
51 | export const CyanCircle: React.FC = () => {
52 | return (
53 |
54 |
55 |
56 | );
57 | };
58 |
--------------------------------------------------------------------------------
/src/components/EllipsisMenu.tsx:
--------------------------------------------------------------------------------
1 | import React, { Fragment } from 'react';
2 | import { Popover, Transition } from '@headlessui/react';
3 | import { EllipsisVerticalIcon } from '@heroicons/react/24/outline';
4 | import { IconInfoCircle, IconMessage2, IconSettings } from '@tabler/icons-react';
5 | import { useTranslation } from 'react-i18next';
6 |
7 | interface EllipsisMenuProps {
8 | setOpenSetting: (open: boolean) => void;
9 | setOpenAbout: (open: boolean) => void;
10 | }
11 |
12 | function EllipsisMenu({ setOpenSetting, setOpenAbout }: EllipsisMenuProps) {
13 | const { i18n } = useTranslation();
14 |
15 | function handleFeedback() {
16 | window.open('https://github.com/hahahumble/speechgpt/issues');
17 | }
18 |
19 | function handleAbout() {
20 | setOpenAbout(true);
21 | }
22 |
23 | const buttons = [
24 | { name: i18n.t('common.setting'), icon: IconSettings, onClick: () => setOpenSetting(true) },
25 | { name: i18n.t('common.feedback'), icon: IconMessage2, onClick: handleFeedback },
26 | { name: i18n.t('common.about'), icon: IconInfoCircle, onClick: handleAbout },
27 | ];
28 |
29 | return (
30 |
31 |
32 |
33 |
34 |
35 |
44 |
45 |
46 |
47 | {buttons.map(item => (
48 |
63 | ))}
64 |
65 |
66 |
67 | © {new Date().getFullYear()} SpeechGPT
68 |
69 |
70 |
71 |
72 |
73 |
74 | );
75 | }
76 |
77 | export default EllipsisMenu;
78 |
--------------------------------------------------------------------------------
/src/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import LanguageSelector from './LocaleSelector';
2 | import AppearanceSelector from './AppearanceSelector';
3 | import React from 'react';
4 | import SpeechGPTIcon from './Icons/SpeechGPTIcon';
5 |
6 | function Header() {
7 | return (
8 |
9 |
10 |
11 |
12 |
13 | SpeechGPT
14 |
15 |
16 |
20 |
21 |
22 | );
23 | }
24 |
25 | export default Header;
26 |
--------------------------------------------------------------------------------
/src/components/Icons/AboutIcon.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import classNames from 'classnames';
3 |
4 | interface AboutIconProps {
5 | className?: string;
6 | strokeWidth?: string;
7 | }
8 |
9 | const AboutIcon: React.FC = ({ className, strokeWidth }) => {
10 | return (
11 |
22 |
23 |
24 | );
25 | };
26 |
27 | export default AboutIcon;
28 |
--------------------------------------------------------------------------------
/src/components/Icons/BlubIcon.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import classNames from 'classnames';
3 |
4 | interface BlubIconProps {
5 | className?: string;
6 | strokeWidth?: string;
7 | }
8 |
9 | const BlubIcon: React.FC = ({ className, strokeWidth }) => {
10 | return (
11 |
22 |
23 |
24 | );
25 | };
26 |
27 | export default BlubIcon;
28 |
--------------------------------------------------------------------------------
/src/components/Icons/ChatIcon.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import classNames from 'classnames';
3 |
4 | interface ChatIconProps {
5 | className?: string;
6 | strokeWidth?: string;
7 | }
8 |
9 | const ChatIcon: React.FC = ({ className, strokeWidth }) => {
10 | return (
11 |
22 |
23 |
24 | );
25 | };
26 |
27 | export default ChatIcon;
28 |
--------------------------------------------------------------------------------
/src/components/Icons/HelpIcon.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import classNames from 'classnames';
3 |
4 | interface HelpIconProps {
5 | className?: string;
6 | strokeWidth?: string;
7 | }
8 |
9 | const HelpIcon: React.FC = ({ className, strokeWidth }) => {
10 | return (
11 |
22 |
23 |
24 |
25 |
26 |
27 | );
28 | };
29 |
30 | export default HelpIcon;
31 |
--------------------------------------------------------------------------------
/src/components/Icons/MicrophoneIcon.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import classNames from 'classnames';
3 |
4 | interface MicrophoneIconProps {
5 | className?: string;
6 | strokeWidth?: string;
7 | }
8 |
9 | const MicrophoneIcon: React.FC = ({ className, strokeWidth }) => {
10 | return (
11 |
22 |
23 |
24 |
25 |
26 |
27 | );
28 | };
29 |
30 | export default MicrophoneIcon;
31 |
--------------------------------------------------------------------------------
/src/components/Icons/OKCircleIcon.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import classNames from 'classnames';
3 |
4 | interface OKCircleIconProps {
5 | className?: string;
6 | strokeWidth?: string;
7 | }
8 |
9 | const OKCircleIcon: React.FC = ({ className, strokeWidth }) => {
10 | return (
11 |
22 |
28 |
29 | );
30 | };
31 |
32 | export default OKCircleIcon;
33 |
--------------------------------------------------------------------------------
/src/components/Icons/RightTriangleIcon.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import classNames from 'classnames';
3 |
4 | interface RightTriangleIconProps {
5 | className?: string;
6 | strokeWidth?: string;
7 | }
8 |
9 | const RightTriangleIcon: React.FC = ({ className, strokeWidth }) => {
10 | return (
11 |
22 |
23 |
24 | );
25 | };
26 |
27 | export default RightTriangleIcon;
28 |
--------------------------------------------------------------------------------
/src/components/Icons/SpeakerIcon.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import classNames from 'classnames';
3 |
4 | interface SpeakerIconProps {
5 | className?: string;
6 | strokeWidth?: string;
7 | }
8 |
9 | const SpeakerIcon: React.FC = ({ className, strokeWidth }) => {
10 | return (
11 |
22 |
23 |
24 | );
25 | };
26 |
27 | export default SpeakerIcon;
28 |
--------------------------------------------------------------------------------
/src/components/Icons/SpeechGPTIcon.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import classNames from 'classnames';
3 |
4 | interface SpeechGPTIconProps {
5 | className?: string;
6 | strokeWidth?: string;
7 | }
8 |
9 | const SpeechGPTIcon: React.FC = ({ className, strokeWidth }) => {
10 | return (
11 |
19 |
23 |
27 |
31 |
35 |
39 |
40 |
48 |
49 |
50 |
51 |
52 |
60 |
61 |
62 |
63 |
64 |
72 |
73 |
74 |
75 |
76 |
84 |
85 |
86 |
87 |
88 |
96 |
97 |
98 |
99 |
100 |
101 |
102 | );
103 | };
104 |
105 | export default SpeechGPTIcon;
106 |
--------------------------------------------------------------------------------
/src/components/Icons/SpinnerIcon.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import classNames from 'classnames';
3 |
4 | interface SpinnerIconProps {
5 | className?: string;
6 | strokeWidth?: string;
7 | }
8 |
9 | const SpinnerIcon: React.FC = ({ className, strokeWidth }) => {
10 | return (
11 |
22 |
23 |
28 |
29 |
30 | );
31 | };
32 |
33 | export default SpinnerIcon;
34 |
--------------------------------------------------------------------------------
/src/components/Icons/StartCircleIcon.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import classNames from 'classnames';
3 |
4 | interface StartCircleIconProps {
5 | className?: string;
6 | strokeWidth?: string;
7 | }
8 |
9 | const StartCircleIcon: React.FC = ({ className, strokeWidth }) => {
10 | return (
11 |
22 |
23 |
24 |
25 | );
26 | };
27 |
28 | export default StartCircleIcon;
29 |
--------------------------------------------------------------------------------
/src/components/Icons/StopCircleIcon.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import classNames from 'classnames';
3 |
4 | interface StopCircleIconProps {
5 | className?: string;
6 | strokeWidth?: string;
7 | }
8 |
9 | const StopCircleIcon: React.FC = ({ className, strokeWidth }) => {
10 | return (
11 |
22 |
23 |
24 |
25 | );
26 | };
27 |
28 | export default StopCircleIcon;
29 |
--------------------------------------------------------------------------------
/src/components/Icons/WarningIcon.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import classNames from 'classnames';
3 |
4 | interface WarningIconProps {
5 | className?: string;
6 | strokeWidth?: string;
7 | }
8 |
9 | const WarningIcon: React.FC = ({ className, strokeWidth }) => {
10 | return (
11 |
22 | {' '}
23 |
24 |
25 | );
26 | };
27 |
28 | export default WarningIcon;
29 |
--------------------------------------------------------------------------------
/src/components/Icons/XIcon.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import classNames from 'classnames';
3 |
4 | interface XIconProps {
5 | className?: string;
6 | strokeWidth?: string;
7 | }
8 |
9 | const XIcon: React.FC = ({ className, strokeWidth }) => {
10 | return (
11 |
22 |
23 |
24 | );
25 | };
26 |
27 | export default XIcon;
28 |
--------------------------------------------------------------------------------
/src/components/InputPanel.tsx:
--------------------------------------------------------------------------------
1 | import TextareaAutosize from 'react-textarea-autosize';
2 | import React from 'react';
3 | import StartCircleIcon from './Icons/StartCircleIcon';
4 | import StopCircleIcon from './Icons/StopCircleIcon';
5 | import SpinnerIcon from './Icons/SpinnerIcon';
6 |
7 | import { useTranslation } from 'react-i18next';
8 |
9 | import { IconSend } from '@tabler/icons-react';
10 |
11 | interface InputPanelProps {
12 | status: string;
13 | disableMicrophone: boolean;
14 | startRecording: () => void;
15 | stopRecording: () => void;
16 | handleSend: () => void;
17 | inputRef: React.RefObject;
18 | userInput: string;
19 | setUserInput: (value: string) => void;
20 | handleInputKeyDown: (event: React.KeyboardEvent) => void;
21 | waiting: boolean;
22 | notify: any;
23 | }
24 |
25 | function InputPanel({
26 | status,
27 | disableMicrophone,
28 | startRecording,
29 | stopRecording,
30 | handleSend,
31 | inputRef,
32 | userInput,
33 | setUserInput,
34 | handleInputKeyDown,
35 | waiting,
36 | notify,
37 | }: InputPanelProps) {
38 | const { i18n } = useTranslation();
39 |
40 | function RecordButton() {
41 | if (status === 'idle' && !disableMicrophone) {
42 | return (
43 |
48 |
49 | {i18n.t('common.record')}
50 |
51 | );
52 | } else if (status === 'speaking' || status === 'waiting' || disableMicrophone) {
53 | return (
54 |
58 |
59 | {i18n.t('common.record')}
60 |
61 | );
62 | } else if (status === 'recording' && !disableMicrophone && waiting) {
63 | return (
64 |
68 |
69 | {i18n.t('common.connecting')}
70 |
71 | );
72 | } else if (status === 'recording' && !disableMicrophone && !waiting) {
73 | return (
74 |
79 |
80 | {i18n.t('common.stop')}
81 |
82 | );
83 | } else {
84 | return (
85 |
89 |
90 | {i18n.t('common.record')}
91 |
92 | );
93 | }
94 | }
95 |
96 | function SendButton() {
97 | if (
98 | (status === 'idle' || status === 'recording' || status === 'connecting') &&
99 | userInput.length > 0
100 | ) {
101 | return (
102 |
107 |
108 | {i18n.t('common.send')}
109 |
110 | );
111 | } else if (
112 | (status === 'idle' || status === 'recording' || status === 'connecting') &&
113 | userInput.length == 0
114 | ) {
115 | return (
116 |
121 |
122 | {i18n.t('common.send')}
123 |
124 | );
125 | } else {
126 | return (
127 |
132 |
133 | {i18n.t('common.waiting')}
134 |
135 | );
136 | }
137 | }
138 |
139 | return (
140 |
141 |
setUserInput(event.target.value)}
148 | onKeyDown={handleInputKeyDown}
149 | maxRows={5}
150 | />
151 |
152 | {/*
{i18n.t('common.status')}: {status}
*/}
153 | {!disableMicrophone &&
}
154 |
155 |
156 |
157 | );
158 | }
159 |
160 | export default InputPanel;
161 |
--------------------------------------------------------------------------------
/src/components/LocaleSelector.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from 'react';
2 | import { useTranslation } from 'react-i18next';
3 | import 'tippy.js/dist/tippy.css';
4 | import { useGlobalStore } from '../store/module';
5 |
6 | interface LanguageOption {
7 | code: string;
8 | name: string;
9 | }
10 |
11 | function LanguageSelector() {
12 | const { i18n } = useTranslation();
13 | const { locale, setLocale } = useGlobalStore();
14 | const selectRef = useRef(null);
15 | const hiddenSpanRef = useRef(null);
16 |
17 | useEffect(() => {
18 | if (locale === '') {
19 | const browserLanguage = navigator.language.split(/[-_]/)[0];
20 | setLocale(browserLanguage);
21 | }
22 | setTimeout(() => {
23 | i18n.changeLanguage(locale);
24 | }, 100);
25 | }, [locale]);
26 |
27 | const languages: LanguageOption[] = [
28 | { code: 'en', name: 'English' },
29 | { code: 'es', name: 'Español' },
30 | { code: 'zh', name: '中文' },
31 | ];
32 |
33 | useEffect(() => {
34 | if (selectRef.current && hiddenSpanRef.current) {
35 | const width = hiddenSpanRef.current.offsetWidth + 25;
36 | selectRef.current.style.width = width + 'px';
37 | }
38 | }, [locale]);
39 |
40 | const handleLanguageChange = (e: React.ChangeEvent) => {
41 | setLocale(e.target.value);
42 | };
43 |
44 | const selectedLanguage = languages.find(lang => lang.code === locale);
45 |
46 | return (
47 |
48 |
49 | {selectedLanguage?.name}
50 |
51 | handleLanguageChange(e)}
54 | value={locale}
55 | className="mr-0.5 w-auto bg-slate-100 text-gray-700 border border-slate-300 rounded-md py-2 px-3 focus:outline-none hover:bg-slate-200 transition duration-100 ease-in-out"
56 | >
57 | {languages.map(language => (
58 |
59 | {language.name}
60 |
61 | ))}
62 |
63 |
64 | );
65 | }
66 |
67 | export default LanguageSelector;
68 |
--------------------------------------------------------------------------------
/src/components/MobileSettingsSelector.tsx:
--------------------------------------------------------------------------------
1 | import { Fragment } from 'react';
2 | import { Listbox, Transition } from '@headlessui/react';
3 | import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/20/solid';
4 |
5 | interface MobileSettingsSelectorProps {
6 | catalogItems: string[];
7 | selected: string | null;
8 | setSelected: (item: string) => void;
9 | }
10 |
11 | export default function MobileSettingsSelector({
12 | catalogItems,
13 | selected,
14 | setSelected,
15 | }: MobileSettingsSelectorProps) {
16 | return (
17 |
18 |
19 |
20 |
21 | {selected}
22 |
23 |
24 |
25 |
26 |
32 |
33 | {catalogItems.map((item, itemIdx) => (
34 |
37 | `hover:cursor-pointer text-left relative cursor-default select-none pl-3 py-1.5 ${
38 | active ? 'bg-gray-100 text-gray-900' : 'text-gray-900'
39 | }`
40 | }
41 | value={item}
42 | >
43 | {({ selected }) => (
44 | <>
45 |
48 | {item}
49 |
50 | {selected ? (
51 |
52 |
53 |
54 | ) : null}
55 | >
56 | )}
57 |
58 | ))}
59 |
60 |
61 |
62 |
63 |
64 | );
65 | }
66 |
--------------------------------------------------------------------------------
/src/components/Notification.tsx:
--------------------------------------------------------------------------------
1 | import toast from 'react-hot-toast';
2 | import i18next from 'i18next';
3 |
4 | const notificationStyle = {
5 | borderRadius: '24px',
6 | padding: '8px 16px', // x, y
7 | };
8 |
9 | export const clearedNotify = () => {
10 | toast.success(i18next.t('notification.cleared') as string, {
11 | style: notificationStyle,
12 | });
13 | };
14 |
15 | export const copiedNotify = () => {
16 | toast.success(i18next.t('notification.copied') as string, {
17 | style: notificationStyle,
18 | });
19 | };
20 |
21 | export const resetNotify = () => {
22 | toast.success(i18next.t('notification.reset') as string, {
23 | style: notificationStyle,
24 | });
25 | };
26 |
27 | export const invalidAccessCodeNotify = () => {
28 | toast.error(i18next.t('notification.invalid-access-code') as string, {
29 | style: notificationStyle,
30 | });
31 | };
32 |
33 | // OpenAI
34 | export const invalidOpenAiKeyNotify = () => {
35 | toast.error(i18next.t('notification.invalid-openai-key') as string, {
36 | style: notificationStyle,
37 | });
38 | };
39 |
40 | export const invalidOpenAiRequestNotify = () => {
41 | toast.error(i18next.t('notification.invalid-openai-request') as string, {
42 | style: notificationStyle,
43 | });
44 | };
45 |
46 | export const invalidOpenAiModelNotify = () => {
47 | toast.error(i18next.t('notification.invalid-openai-model') as string, {
48 | style: notificationStyle,
49 | });
50 | };
51 |
52 | export const openAiErrorNotify = () => {
53 | toast.error(i18next.t('notification.openai-error') as string, {
54 | style: notificationStyle,
55 | });
56 | };
57 |
58 | export const emptyOpenAiKeyNotify = () => {
59 | toast.error(i18next.t('notification.empty-openai-key') as string, {
60 | style: notificationStyle,
61 | });
62 | };
63 |
64 | export const networkErrorNotify = () => {
65 | toast.error(i18next.t('notification.network-error') as string, {
66 | style: notificationStyle,
67 | });
68 | };
69 |
70 | export const deletedNotify = () => {
71 | toast.success(i18next.t('notification.deleted') as string, {
72 | style: notificationStyle,
73 | });
74 | };
75 |
76 | export const cannotBeEmptyNotify = () => {
77 | toast.error(i18next.t('notification.cannot-be-empty') as string, {
78 | style: notificationStyle,
79 | });
80 | };
81 |
82 | // Builtin services
83 | export const errorBuiltinSpeechRecognitionNotify = () => {
84 | toast.error(i18next.t('notification.builtin-recognition-error') as string, {
85 | style: notificationStyle,
86 | });
87 | };
88 |
89 | export const errorBuiltinSpeechSynthesisNotify = () => {
90 | toast.error(i18next.t('notification.builtin-synthesis-error') as string, {
91 | style: notificationStyle,
92 | });
93 | };
94 |
95 | // Azure
96 | export const emptyAzureKeyNotify = () => {
97 | toast.error(i18next.t('notification.empty-azure-key') as string, {
98 | style: notificationStyle,
99 | });
100 | };
101 |
102 | export const azureRecognitionErrorNotify = () => {
103 | toast.error(i18next.t('notification.azure-recognition-error') as string, {
104 | style: notificationStyle,
105 | });
106 | };
107 |
108 | export const azureSynthesisErrorNotify = () => {
109 | toast.error(i18next.t('notification.azure-synthesis-error') as string, {
110 | style: notificationStyle,
111 | });
112 | };
113 |
114 | export const invalidAzureKeyNotify = () => {
115 | toast.error(i18next.t('notification.invalid-azure-key') as string, {
116 | style: notificationStyle,
117 | });
118 | };
119 |
120 | // AWS
121 | export const awsErrorNotify = () => {
122 | toast.error(i18next.t('notification.polly-synthesis-error') as string, {
123 | style: notificationStyle,
124 | });
125 | };
126 |
127 | export const allConversationClearNotify = () => {
128 | toast.success(i18next.t('notification.all-conversations-clear') as string, {
129 | style: notificationStyle,
130 | });
131 | };
132 |
--------------------------------------------------------------------------------
/src/components/Record.tsx:
--------------------------------------------------------------------------------
1 | import useSpeechToText, { ResultType } from 'react-hook-speech-to-text';
2 | import { useEffect } from 'react';
3 |
4 | type baseStatus = 'idle' | 'waiting' | 'speaking' | 'recording';
5 |
6 | interface RecordProps {
7 | setResults: (results: string) => void;
8 | setInterimResult: (interimResult: string) => void;
9 | recording: boolean;
10 | setRecording: (recording: boolean) => void;
11 | status: string;
12 | setStatus: (status: baseStatus) => void;
13 | language: string;
14 | }
15 |
16 | function Record({
17 | setResults,
18 | setInterimResult,
19 | recording,
20 | setRecording,
21 | status,
22 | setStatus,
23 | language,
24 | }: RecordProps) {
25 | // https://github.com/Riley-Brown/react-speech-to-text
26 | const { error, interimResult, isRecording, results, startSpeechToText, stopSpeechToText } =
27 | useSpeechToText({
28 | continuous: true,
29 | useLegacyResults: false,
30 | speechRecognitionProperties: {
31 | lang: language,
32 | },
33 | }) as {
34 | error: string;
35 | interimResult: string | undefined;
36 | isRecording: boolean;
37 | results: ResultType[];
38 | setResults: import('react').Dispatch>;
39 | startSpeechToText: () => Promise;
40 | stopSpeechToText: () => void;
41 | };
42 |
43 | useEffect(() => {
44 | console.log('error', error);
45 | }, [error]);
46 |
47 | useEffect(() => {
48 | if (results.length > 0) {
49 | // console.log(results);
50 | setResults(results[results.length - 1].transcript);
51 | // setResults(results.map((result) => result.transcript).join(' '));
52 | }
53 | }, [results]);
54 |
55 | // useEffect(() => {
56 | // if (interimResult) {
57 | // console.log('interimResult', interimResult);
58 | // // setInterimResult(interimResult);
59 | // }
60 | // }, [interimResult]);
61 |
62 | useEffect(() => {
63 | if (interimResult) {
64 | console.log('interimResult', interimResult);
65 | }
66 | }, [interimResult]);
67 |
68 | // start recognition when the user clicks the record button
69 | useEffect(() => {
70 | if (recording) {
71 | console.log('start: startSpeechToText()');
72 | startSpeechToText();
73 | } else {
74 | console.log('stop');
75 | stopSpeechToText();
76 | }
77 | }, [recording]);
78 |
79 | useEffect(() => {
80 | if (!isRecording) {
81 | console.log('isrecording false');
82 | setRecording(false);
83 | }
84 | }, [isRecording]);
85 |
86 | useEffect(() => {
87 | if (status == 'speaking' || status == 'waiting') {
88 | console.log('stop');
89 | stopSpeechToText();
90 | }
91 | }, [status]);
92 |
93 | return null;
94 | }
95 |
96 | export default Record;
97 |
--------------------------------------------------------------------------------
/src/components/Settings/ChatSection.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import SettingTextArea from './base/SettingTextArea';
3 | import SettingDivider from './base/SettingDivider';
4 | import SettingTitle from './base/SettingTitle';
5 | import SettingInput from './base/SettingInput';
6 | import SettingSwitch from './base/SettingSwitch';
7 | import SettingSlider from './base/SettingSlider';
8 | import SettingGroup from './base/SettingGroup';
9 | import SettingCheckText from './base/SettingCheckText';
10 |
11 | import { useGlobalStore } from '../../store/module';
12 | import { useTranslation } from 'react-i18next';
13 | import { existEnvironmentVariable } from '../../helpers/utils';
14 | import { azureRegions } from '../../constants/data';
15 | import SettingSelect from './base/SettingSelect';
16 |
17 | interface ChatSectionProps {}
18 |
19 | const openaiModels = [
20 | 'gpt-3.5-turbo',
21 | 'gpt-3.5-turbo-0301', // deprecated on June 1st, 2023
22 | 'gpt-4',
23 | 'gpt-4-0314', // deprecated on June 14th, 2023
24 | 'gpt-4-32k',
25 | 'gpt-4-32k-0314', // deprecated on June 14th, 2023
26 | ];
27 |
28 | const ChatSection: React.FC = ({}) => {
29 | const { key, setKey, chat, setChat } = useGlobalStore();
30 |
31 | const { i18n } = useTranslation();
32 |
33 | return (
34 |
35 |
36 |
37 | {existEnvironmentVariable('OPENAI_API_KEY') ? (
38 |
41 | ) : (
42 | <>
43 | setKey({ ...key, openaiApiKey: e })}
49 | placeholder={i18n.t('setting.chat.api-key') as string}
50 | className={''}
51 | />
52 | setKey({ ...key, openaiHost: e })}
59 | placeholder={i18n.t('setting.chat.default-host-address') as string}
60 | className={''}
61 | />
62 | setKey({ ...key, openaiModel: e })}
69 | />
70 | >
71 | )}
72 | {existEnvironmentVariable('ACCESS_CODE') ? (
73 | setKey({ ...key, accessCode: e })}
80 | placeholder={i18n.t('setting.chat.code') as string}
81 | className={''}
82 | />
83 | ) : (
84 | <>>
85 | )}
86 |
87 |
88 |
89 |
90 |
91 | setChat({ ...chat, systemRole: e })}
97 | placeholder={i18n.t('setting.chat.type-something') as string}
98 | maxRows={4}
99 | />
100 | setChat({ ...chat, defaultPrompt: e })}
105 | placeholder={i18n.t('setting.chat.type-something') as string}
106 | maxRows={4}
107 | />
108 |
109 |
110 |
111 |
112 |
113 | setChat({ ...chat, useAssistant: e })}
118 | />
119 | setChat({ ...chat, temperature: e })}
125 | min={'0'}
126 | max={'2'}
127 | step={'0.1'}
128 | />
129 | setChat({ ...chat, maxMessages: e })}
135 | min={'1'}
136 | max={'100'}
137 | step={'1'}
138 | />
139 |
140 |
141 | );
142 | };
143 |
144 | export default ChatSection;
145 |
--------------------------------------------------------------------------------
/src/components/Settings/PollyVoice.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import SettingSelect from './base/SettingSelect';
3 |
4 | import {
5 | pollyStandardSupportedLanguages,
6 | pollyStandardVoices,
7 | pollyNeuralVoices,
8 | pollyNeuralSupportedLanguages,
9 | } from '../../constants/data';
10 | import { useGlobalStore } from '../../store/module';
11 | import { useTranslation } from 'react-i18next';
12 |
13 | const PollyVoice = () => {
14 | const { i18n } = useTranslation();
15 |
16 | const { speech, setSpeech } = useGlobalStore();
17 |
18 | const languageCode = speech.pollyLanguage;
19 |
20 | useEffect(() => {
21 | if (
22 | pollyStandardSupportedLanguages.includes(languageCode) &&
23 | speech.pollyEngine === 'Standard'
24 | ) {
25 | if (!pollyStandardVoices[languageCode].includes(speech.pollyVoice)) {
26 | setSpeech({
27 | ...speech,
28 | pollyVoice: pollyStandardVoices[languageCode][0],
29 | });
30 | }
31 | } else if (
32 | pollyNeuralSupportedLanguages.includes(languageCode) &&
33 | speech.pollyEngine === 'Neural'
34 | ) {
35 | if (!pollyNeuralVoices[languageCode].includes(speech.pollyVoice)) {
36 | setSpeech({ ...speech, pollyVoice: pollyNeuralVoices[languageCode][0] });
37 | }
38 | }
39 | }, [languageCode]);
40 |
41 | switch (speech.pollyEngine) {
42 | case 'Standard':
43 | if (pollyStandardSupportedLanguages.includes(languageCode)) {
44 | return (
45 | setSpeech({ ...speech, pollyVoice: e })}
52 | />
53 | );
54 | } else {
55 | return (
56 |
57 | {i18n.t('setting.synthesis.polly-standard-not-supported') as string}
58 |
59 | );
60 | }
61 | case 'Neural':
62 | if (pollyNeuralSupportedLanguages.includes(languageCode)) {
63 | return (
64 | setSpeech({ ...speech, pollyVoice: e })}
71 | />
72 | );
73 | } else {
74 | return (
75 |
76 | {i18n.t('setting.synthesis.polly-neural-not-supported') as string}
77 |
78 | );
79 | }
80 | default:
81 | return null;
82 | }
83 | };
84 |
85 | export default PollyVoice;
86 |
--------------------------------------------------------------------------------
/src/components/Settings/RecognitionSection.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import SettingTitle from './base/SettingTitle';
3 | import SettingDivider from './base/SettingDivider';
4 | import SettingSelect from './base/SettingSelect';
5 | import SettingGroup from './base/SettingGroup';
6 | import SettingInput from './base/SettingInput';
7 | import SettingSlider from './base/SettingSlider';
8 | import SettingSwitch from './base/SettingSwitch';
9 | import SettingCheckText from './base/SettingCheckText';
10 | import SettingWarningText from './base/SettingWarningText';
11 |
12 | import {
13 | awsRegions,
14 | azureRegions,
15 | azureSpeechRecognitionLanguagesLocale,
16 | speechRecognitionSystemLanguagesLocale,
17 | } from '../../constants/data';
18 |
19 | import { browserName, isMobile } from 'react-device-detect';
20 | import { useGlobalStore } from '../../store/module';
21 | import { useTranslation } from 'react-i18next';
22 | import { existEnvironmentVariable } from '../../helpers/utils';
23 |
24 | interface RecognitionSectionProps {}
25 |
26 | const RecognitionSection: React.FC = ({}) => {
27 | const { i18n } = useTranslation();
28 | const { key, setKey, voice, setVoice } = useGlobalStore();
29 | const voiceServices =
30 | browserName !== 'Chrome' || isMobile
31 | ? ['Azure Speech to Text']
32 | : ['System', 'Azure Speech to Text'];
33 |
34 | useEffect(() => {
35 | if (!key.azureRegion) {
36 | setKey({ ...key, azureRegion: azureRegions[0] });
37 | }
38 | }, [key.azureRegion]);
39 |
40 | useEffect(() => {
41 | if (!key.awsRegion) {
42 | setKey({ ...key, awsRegion: awsRegions[0] });
43 | }
44 | }, [key.awsRegion]);
45 |
46 | function getSystemLanguageCode(language: string) {
47 | return Object.keys(speechRecognitionSystemLanguagesLocale).find(
48 | key => speechRecognitionSystemLanguagesLocale[key] === language
49 | );
50 | }
51 |
52 | function getAzureLanguageCode(language: string) {
53 | return Object.keys(azureSpeechRecognitionLanguagesLocale).find(
54 | key => azureSpeechRecognitionLanguagesLocale[key] === language
55 | );
56 | }
57 |
58 | return (
59 |
60 |
61 |
62 | {browserName !== 'Chrome' && !isMobile && (
63 |
66 | )}
67 | {isMobile && (
68 |
69 | )}
70 | setVoice({ ...voice, service: e })}
78 | />
79 | {voice.service === 'Azure Speech to Text' && (
80 | <>
81 | {existEnvironmentVariable('AZURE_REGION') && existEnvironmentVariable('AZURE_KEY') ? (
82 |
87 | ) : (
88 | <>
89 | setKey({ ...key, azureRegion: e })}
98 | />
99 | setKey({ ...key, azureKey: e })}
106 | />
107 | >
108 | )}
109 | >
110 | )}
111 |
112 |
113 |
114 |
115 |
116 | {voice.service === 'System' && (
117 | setVoice({ ...voice, systemLanguage: getSystemLanguageCode(e) })}
124 | />
125 | )}
126 | {voice.service === 'Azure Speech to Text' && (
127 | setVoice({ ...voice, azureLanguage: getAzureLanguageCode(e) })}
134 | />
135 | )}
136 | setVoice({ ...voice, autoStart: e })}
141 | />
142 | {voice.autoStart && (
143 | setVoice({ ...voice, startTime: e })}
149 | min={'0'}
150 | max={'5'}
151 | step={'0.2'}
152 | />
153 | )}
154 |
155 |
156 | );
157 | };
158 |
159 | export default RecognitionSection;
160 |
--------------------------------------------------------------------------------
/src/components/Settings/SettingContent.tsx:
--------------------------------------------------------------------------------
1 | import ChatSection from './ChatSection';
2 | import SynthesisSection from './SynthesisSection';
3 | import RecognitionSection from './RecognitionSection';
4 | import React from 'react';
5 | import { useTranslation } from 'react-i18next';
6 |
7 | interface SettingContentProps {
8 | selected: string | null;
9 | }
10 |
11 | function SettingContent({ selected }: SettingContentProps) {
12 | const { i18n } = useTranslation();
13 |
14 | switch (selected) {
15 | case i18n.t('setting.chat.title') as string:
16 | return ;
17 | case i18n.t('setting.synthesis.title') as string:
18 | return ;
19 | case i18n.t('setting.recognition.title') as string:
20 | return ;
21 | // case i18n.t('setting.about.title') as string:
22 | // return ;
23 | default:
24 | return ;
25 | }
26 | }
27 |
28 | export default SettingContent;
29 |
--------------------------------------------------------------------------------
/src/components/Settings/SettingDialog.tsx:
--------------------------------------------------------------------------------
1 | import BaseDialog from '../base/Dialog';
2 | import React, { useEffect, useState } from 'react';
3 | import XIcon from '../Icons/XIcon';
4 | import SettingSelector from './SettingSelector';
5 | import TippyButton from '../base/TippyButton';
6 | import SettingContent from './SettingContent';
7 | import MobileSettingsSelector from '../MobileSettingsSelector';
8 | import ChatIcon from '../Icons/ChatIcon';
9 | import SpeakerIcon from '../Icons/SpeakerIcon';
10 | import MicrophoneIcon from '../Icons/MicrophoneIcon';
11 | import AboutIcon from '../Icons/AboutIcon';
12 | import { useTranslation } from 'react-i18next';
13 |
14 | interface SettingDialogProps {
15 | open: boolean;
16 | onClose: () => void;
17 | }
18 |
19 | function SettingDialog({ open, onClose }: SettingDialogProps) {
20 | const { i18n } = useTranslation();
21 |
22 | const [selected, setSelected] = useState(i18n.t('setting.chat.title') as string);
23 |
24 | useEffect(() => {
25 | setSelected(i18n.t('setting.chat.title') as string);
26 | }, [i18n.t('setting.chat.title')]);
27 |
28 | const catalogItems = [
29 | i18n.t('setting.chat.title') as string,
30 | i18n.t('setting.synthesis.title') as string,
31 | i18n.t('setting.recognition.title') as string,
32 | // i18n.t('setting.about.title') as string,
33 | ];
34 |
35 | const catalogIcons = [
36 | ,
37 | ,
38 | ,
39 | // ,
40 | ];
41 |
42 | return (
43 |
44 |
45 | }
49 | style="hover:bg-slate-200 active:bg-slate-300"
50 | />
51 |
52 |
53 |
54 |
59 |
60 |
61 |
67 |
68 |
69 |
70 |
71 |
72 |
73 | );
74 | }
75 |
76 | export default SettingDialog;
77 |
--------------------------------------------------------------------------------
/src/components/Settings/SettingSelector.tsx:
--------------------------------------------------------------------------------
1 | interface SettingSelectorProps {
2 | selected: string | null;
3 | onSelect: (item: string) => void;
4 | catalogItems: string[];
5 | catalogIcons: JSX.Element[];
6 | }
7 |
8 | function SettingSelector({ selected, onSelect, catalogItems, catalogIcons }: SettingSelectorProps) {
9 | return (
10 |
11 | {catalogItems.map((item, key) => (
12 |
onSelect(item)}
18 | >
19 |
{catalogIcons[key]}
20 |
{item}
21 |
22 | ))}
23 |
24 | );
25 | }
26 |
27 | export default SettingSelector;
28 |
--------------------------------------------------------------------------------
/src/components/Settings/azureTtsVoice.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import SettingSelect from './base/SettingSelect';
3 |
4 | import { azureSpeechSynthesisVoices } from '../../constants/data';
5 | import { useGlobalStore } from '../../store/module';
6 | import { useTranslation } from 'react-i18next';
7 |
8 | const azureTtsVoice = () => {
9 | const { i18n } = useTranslation();
10 |
11 | const { speech, setSpeech } = useGlobalStore();
12 |
13 | const languageCode = speech.azureLanguage;
14 |
15 | useEffect(() => {
16 | if (!azureSpeechSynthesisVoices[languageCode].includes(speech.azureVoice)) {
17 | setSpeech({
18 | ...speech,
19 | azureVoice: azureSpeechSynthesisVoices[languageCode][0],
20 | });
21 | }
22 | }, [languageCode]);
23 |
24 | return (
25 | setSpeech({ ...speech, azureVoice: e })}
33 | />
34 | );
35 | };
36 |
37 | export default azureTtsVoice;
38 |
--------------------------------------------------------------------------------
/src/components/Settings/base/SettingCheckText.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import OKCircleIcon from '../../Icons/OKCircleIcon';
3 |
4 | interface SettingCheckTextProps {
5 | text: string;
6 | }
7 |
8 | function SettingCheckText({ text }: SettingCheckTextProps) {
9 | return (
10 |
14 | );
15 | }
16 |
17 | export default SettingCheckText;
18 |
--------------------------------------------------------------------------------
/src/components/Settings/base/SettingDivider.tsx:
--------------------------------------------------------------------------------
1 | function SettingDivider() {
2 | return
;
3 | }
4 |
5 | export default SettingDivider;
6 |
--------------------------------------------------------------------------------
/src/components/Settings/base/SettingGroup.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | interface SettingGroupProps {
4 | children: React.ReactNode;
5 | }
6 | function SettingGroup({ children }: SettingGroupProps) {
7 | return {children}
;
8 | }
9 |
10 | export default SettingGroup;
11 |
--------------------------------------------------------------------------------
/src/components/Settings/base/SettingInput.tsx:
--------------------------------------------------------------------------------
1 | import SettingSubtitle from './SettingSubtitle';
2 | import Input from '../../base/Input';
3 |
4 | interface SettingInputProps {
5 | text: string;
6 | helpText?: string;
7 | id: string;
8 | type: string;
9 | className?: string;
10 | value: string;
11 | placeholder: string;
12 | onChange: (value: string) => void;
13 | }
14 |
15 | function SettingInput({
16 | text,
17 | helpText,
18 | id,
19 | type,
20 | className,
21 | value,
22 | placeholder,
23 | onChange,
24 | }: SettingInputProps) {
25 | return (
26 |
27 |
28 | onChange(e.target.value)}
33 | className={`h-9 mx-0.5 ${className}`}
34 | placeholder={placeholder}
35 | />
36 |
37 | );
38 | }
39 |
40 | export default SettingInput;
41 |
--------------------------------------------------------------------------------
/src/components/Settings/base/SettingSelect.tsx:
--------------------------------------------------------------------------------
1 | import SettingSubtitle from './SettingSubtitle';
2 | import Select from '../../base/Select';
3 |
4 | interface SettingSelectProps {
5 | text: string;
6 | helpText?: string;
7 | options: string[];
8 | value: string;
9 | onChange: (value: string) => void;
10 | className?: string;
11 | selectClassName?: string;
12 | }
13 |
14 | function SettingSelect({
15 | text,
16 | helpText,
17 | options,
18 | value,
19 | onChange,
20 | className,
21 | selectClassName,
22 | }: SettingSelectProps) {
23 | return (
24 |
25 |
26 |
32 |
33 | );
34 | }
35 |
36 | export default SettingSelect;
37 |
--------------------------------------------------------------------------------
/src/components/Settings/base/SettingSlider.tsx:
--------------------------------------------------------------------------------
1 | import SettingSubtitle from './SettingSubtitle';
2 | import Input from '../../base/Input';
3 | import RangeSlider from '../../base/RangeSlider';
4 | import { useEffect } from 'react';
5 |
6 | interface SettingSliderProps {
7 | text: string;
8 | helpText?: string;
9 | value: number;
10 | id: string;
11 | min: string;
12 | max: string;
13 | step: string;
14 | inputClassName?: string;
15 | sliderClassName?: string;
16 | onChange: (value: number) => void;
17 | }
18 |
19 | function SettingSlider({
20 | text,
21 | helpText,
22 | value,
23 | id,
24 | min,
25 | max,
26 | step,
27 | inputClassName,
28 | sliderClassName,
29 | onChange,
30 | }: SettingSliderProps) {
31 | useEffect(() => {
32 | if (value < parseFloat(min)) {
33 | onChange(parseFloat(min));
34 | } else if (value > parseFloat(max)) {
35 | onChange(parseFloat(max));
36 | }
37 | }, [value, min, max, onChange]);
38 |
39 | return (
40 |
41 |
42 |
43 | onChange(parseFloat(e.target.value))}
50 | step={step}
51 | />
52 |
61 |
62 |
63 | );
64 | }
65 |
66 | export default SettingSlider;
67 |
--------------------------------------------------------------------------------
/src/components/Settings/base/SettingSubtitle.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Tippy from '@tippyjs/react';
3 | import HelpIcon from '../../Icons/HelpIcon';
4 |
5 | interface SettingSubtitleProps {
6 | text: string;
7 | helpText?: string;
8 | }
9 |
10 | function SettingSubtitle({ text, helpText }: SettingSubtitleProps) {
11 | return (
12 |
13 | {text}
14 | {helpText && (
15 |
16 |
24 |
25 |
26 |
27 |
28 |
29 | )}
30 |
31 | );
32 | }
33 |
34 | export default SettingSubtitle;
35 |
--------------------------------------------------------------------------------
/src/components/Settings/base/SettingSwitch.tsx:
--------------------------------------------------------------------------------
1 | import SettingSubtitle from './SettingSubtitle';
2 | import Toggle from '../../base/Toggle';
3 |
4 | interface SettingSwitchProps {
5 | text: string;
6 | helpText?: string;
7 | checked: boolean;
8 | onChange: (checked: boolean) => void;
9 | className?: string;
10 | }
11 |
12 | function SettingSwitch({ text, helpText, checked, onChange, className }: SettingSwitchProps) {
13 | return (
14 |
15 |
16 |
17 |
18 | );
19 | }
20 |
21 | export default SettingSwitch;
22 |
--------------------------------------------------------------------------------
/src/components/Settings/base/SettingTextArea.tsx:
--------------------------------------------------------------------------------
1 | import Textarea from '../../base/Textarea';
2 | import SettingSubtitle from './SettingSubtitle';
3 |
4 | interface SettingTextAreaProps {
5 | text: string;
6 | helpText?: string;
7 | value: string;
8 | onChange: (value: string) => void;
9 | className?: string;
10 | placeholder?: string;
11 | maxRows?: number;
12 | }
13 |
14 | function SettingTextArea({
15 | text,
16 | helpText,
17 | value,
18 | onChange,
19 | className,
20 | placeholder,
21 | maxRows,
22 | }: SettingTextAreaProps) {
23 | return (
24 |
25 |
26 |
34 | );
35 | }
36 |
37 | export default SettingTextArea;
38 |
--------------------------------------------------------------------------------
/src/components/Settings/base/SettingTitle.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | interface SettingTitleProps {
4 | text: string;
5 | }
6 |
7 | function SettingTitle({ text }: SettingTitleProps) {
8 | return {text}
;
9 | }
10 |
11 | export default SettingTitle;
12 |
--------------------------------------------------------------------------------
/src/components/Settings/base/SettingWarningText.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import WarningIcon from '../../Icons/WarningIcon';
3 |
4 | interface SettingWarningTextProps {
5 | text: string;
6 | }
7 |
8 | function SettingWarningText({ text }: SettingWarningTextProps) {
9 | return (
10 |
14 | );
15 | }
16 |
17 | export default SettingWarningText;
18 |
--------------------------------------------------------------------------------
/src/components/Tips.tsx:
--------------------------------------------------------------------------------
1 | import BlubIcon from './Icons/BlubIcon';
2 | import RightTriangleIcon from './Icons/RightTriangleIcon';
3 | import React from 'react';
4 | import { useTranslation } from 'react-i18next';
5 |
6 | function Tips() {
7 | const { i18n } = useTranslation();
8 |
9 | return (
10 |
11 |
12 |
13 |
14 |
{i18n.t('common.tips') as string}
15 |
16 |
26 |
34 |
35 |
36 |
37 |
38 | Shift
39 | {' '}
40 | +
41 |
42 | Enter
43 |
44 | {i18n.t('tips.tips3a') as string}
45 |
46 | Enter
47 |
48 | {i18n.t('tips.tips3b') as string}
49 |
50 |
51 |
52 |
53 | );
54 | }
55 |
56 | export default Tips;
57 |
--------------------------------------------------------------------------------
/src/components/base/Button.tsx:
--------------------------------------------------------------------------------
1 | interface ButtonProps {
2 | onClick?: () => void;
3 | className?: string;
4 | text?: string;
5 | }
6 |
7 | function Button({ onClick, className, text }: ButtonProps) {
8 | return (
9 |
13 | {text}
14 |
15 | );
16 | }
17 |
18 | export default Button;
19 |
--------------------------------------------------------------------------------
/src/components/base/Dialog.tsx:
--------------------------------------------------------------------------------
1 | import { Dialog, Transition } from '@headlessui/react';
2 | import { Fragment } from 'react';
3 |
4 | interface DialogProps {
5 | open: boolean;
6 | onClose: () => void;
7 | children: React.ReactNode;
8 | className?: string;
9 | }
10 |
11 | function BaseDialog({ open, onClose, children, className }: DialogProps) {
12 | return (
13 |
14 |
15 |
24 |
25 |
26 |
27 |
28 |
29 |
38 | ${className}`}
40 | >
41 | {children}
42 |
43 |
44 |
45 |
46 |
47 |
48 | );
49 | }
50 |
51 | export default BaseDialog;
52 |
--------------------------------------------------------------------------------
/src/components/base/DropdownMenu.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Menu, Transition } from '@headlessui/react';
3 |
4 | interface DropdownMenuProps {
5 | button: React.ReactNode;
6 | children: React.ReactNode;
7 | }
8 |
9 | const DropdownMenu: React.FC = ({ button, children }) => {
10 | return (
11 |
12 | {button}
13 |
22 |
23 | {children}
24 |
25 |
26 |
27 | );
28 | };
29 |
30 | export default DropdownMenu;
31 |
--------------------------------------------------------------------------------
/src/components/base/Input.tsx:
--------------------------------------------------------------------------------
1 | interface InputProps {
2 | id: string;
3 | type: string;
4 | className?: string;
5 | value: string;
6 | placeholder: string;
7 | onChange: (e: React.ChangeEvent) => void;
8 | step?: string;
9 | }
10 |
11 | function Input({ id, type, className, value, placeholder, onChange, step }: InputProps) {
12 | return (
13 |
23 | );
24 | }
25 |
26 | export default Input;
27 |
--------------------------------------------------------------------------------
/src/components/base/RangeSlider.tsx:
--------------------------------------------------------------------------------
1 | interface RangeSliderProps {
2 | id?: string;
3 | value: number;
4 | onChange: (value: number) => void;
5 | className?: string;
6 | min?: number | string;
7 | max?: number | string;
8 | step?: string | number;
9 | }
10 |
11 | function RangeSlider({ id, value, onChange, className, min, max, step }: RangeSliderProps) {
12 | return (
13 | onChange(parseFloat(e.target.value))}
19 | className={`h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700 accent-indigo-500 hover:accent-indigo-500 active:accent-indigo-800 ${className}`}
20 | min={min}
21 | max={max}
22 | step={step}
23 | />
24 | );
25 | }
26 |
27 | export default RangeSlider;
28 |
--------------------------------------------------------------------------------
/src/components/base/Select.tsx:
--------------------------------------------------------------------------------
1 | interface SelectProps {
2 | options: string[];
3 | value: string;
4 | onChange: (value: string) => void;
5 | className?: string;
6 | }
7 |
8 | function Select({ options, value, onChange, className }: SelectProps) {
9 | return (
10 | onChange(e.target.value)}
14 | >
15 | {options.map(option => (
16 |
17 | {option}
18 |
19 | ))}
20 |
21 | );
22 | }
23 |
24 | export default Select;
25 |
--------------------------------------------------------------------------------
/src/components/base/Textarea.tsx:
--------------------------------------------------------------------------------
1 | import TextareaAutosize from 'react-textarea-autosize';
2 | import { useState } from 'react';
3 |
4 | type TextareaProps = {
5 | value?: string;
6 | className?: string;
7 | onChange?: (event: React.ChangeEvent) => void;
8 | placeholder?: string;
9 | onKeyDown?: any;
10 | maxRows?: number;
11 | };
12 |
13 | export default function Textarea({
14 | value,
15 | className,
16 | onChange,
17 | placeholder,
18 | onKeyDown,
19 | maxRows,
20 | }: TextareaProps) {
21 | const [inputValue, setInputValue] = useState(value || '');
22 |
23 | const handleChange = (event: React.ChangeEvent) => {
24 | setInputValue(event.target.value);
25 |
26 | if (onChange) {
27 | onChange(event);
28 | }
29 | };
30 |
31 | return (
32 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/src/components/base/TippyButton.tsx:
--------------------------------------------------------------------------------
1 | import Tippy from '@tippyjs/react';
2 | import 'tippy.js/dist/tippy.css';
3 |
4 | interface TippyButtonProps {
5 | onClick?: (event: React.MouseEvent) => void;
6 | style?: string;
7 | text?: string;
8 | icon?: React.ReactNode;
9 | tooltip?: string;
10 | }
11 |
12 | function TippyButton(props: TippyButtonProps) {
13 | return (
14 | <>
15 | {props.tooltip && (
16 |
25 |
29 | {props.icon}
30 | {props.text}
31 |
32 |
33 | )}
34 | {!props.tooltip && (
35 |
39 | {props.icon}
40 | {props.text}
41 |
42 | )}
43 | >
44 | );
45 | }
46 |
47 | export default TippyButton;
48 |
--------------------------------------------------------------------------------
/src/components/base/Toggle.tsx:
--------------------------------------------------------------------------------
1 | import { Switch } from '@headlessui/react';
2 |
3 | interface ToggleProps {
4 | checked: boolean;
5 | onChange: (checked: boolean) => void;
6 | className?: string;
7 | description?: string;
8 | }
9 |
10 | function Toggle({ checked, onChange, className, description }: ToggleProps) {
11 | return (
12 |
19 | {description}
20 |
25 |
26 | );
27 | }
28 |
29 | export default Toggle;
30 |
--------------------------------------------------------------------------------
/src/css/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | html,
6 | body {
7 | height: 100%;
8 | margin: 0;
9 | padding: 0;
10 | }
11 |
12 | #root {
13 | height: 100%;
14 | }
15 |
16 | /* Vertical scrollbar */
17 | ::-webkit-scrollbar {
18 | width: 6px;
19 | height: 6px;
20 | }
21 |
22 | ::-webkit-scrollbar-track {
23 | background: transparent;
24 | }
25 |
26 | ::-webkit-scrollbar-thumb {
27 | background: #e2e8f0;
28 | border-radius: 0.25em;
29 | }
30 |
31 | /* Change the color of Tippy */
32 | .tippy-box[data-theme~='light'] {
33 | background-color: #4b5563;
34 | /*border-radius: 20px;*/
35 | /*color: #1f2937;*/
36 | /* font medium */
37 | font-weight: 500;
38 | }
39 |
40 | /* Change the color of arrow */
41 | .tippy-box[data-theme~='light'][data-placement^='bottom'] > .tippy-arrow::before {
42 | border-bottom-color: #4b5563;
43 | }
44 |
45 | select {
46 | appearance: none; /* remove default arrow */
47 | padding-right: 1.5em; /* add space for the custom arrow */
48 | background: url('../assets/arrow-down.svg') no-repeat right 0.5em center/1em auto;
49 | background-size: 1.2em; /* adjust arrow size */
50 | }
51 |
52 | /* Markdown */
53 | .markdown-content {
54 | }
55 |
56 | .markdown-content {
57 | width: 100%;
58 | max-width: 100%;
59 | word-break: break-word;
60 | font-size: 1rem;
61 | line-height: 1.5;
62 | }
63 |
64 | .markdown-content > h1 {
65 | font-size: 3rem;
66 | line-height: 1.2;
67 | font-weight: bold;
68 | }
69 |
70 | .markdown-content > h2 {
71 | font-size: 1.875rem;
72 | line-height: 1.2;
73 | font-weight: 500;
74 | }
75 |
76 | .markdown-content > h3 {
77 | font-size: 1.25rem;
78 | line-height: 1.2;
79 | font-weight: 500;
80 | }
81 |
82 | .markdown-content > h4 {
83 | font-size: 1.125rem;
84 | }
85 |
86 | .markdown-content > p {
87 | width: 100%;
88 | height: auto;
89 | margin-bottom: 0.25rem;
90 | }
91 |
92 | .markdown-content > p:last-child {
93 | margin-bottom: 0;
94 | }
95 |
96 | .markdown-content > .li-container {
97 | width: 100%;
98 | display: flex;
99 | flex-direction: row;
100 | flex-wrap: nowrap;
101 | }
102 |
103 | .markdown-content .img {
104 | display: block;
105 | max-width: 100%;
106 | border-radius: 0.25rem;
107 | cursor: pointer;
108 | transition: box-shadow 0.15s ease-in-out;
109 | }
110 |
111 | .markdown-content .img:hover {
112 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
113 | }
114 |
115 | .markdown-content .tag-span {
116 | display: inline-block;
117 | width: auto;
118 | color: #1e88e5;
119 | cursor: pointer;
120 | }
121 |
122 | .markdown-content .link {
123 | color: #1e88e5;
124 | cursor: pointer;
125 | text-decoration: underline;
126 | word-break: break-all;
127 | transition: opacity 0.15s ease-in-out;
128 | }
129 |
130 | .markdown-content .link:hover {
131 | opacity: 0.8;
132 | }
133 |
134 | .markdown-content .link code {
135 | text-decoration: underline;
136 | }
137 |
138 | .markdown-content .ol-block,
139 | .markdown-content .ul-block,
140 | .markdown-content .todo-block {
141 | flex-shrink: 0;
142 | display: inline-block;
143 | box-sizing: border-box;
144 | text-align: right;
145 | width: 1.75rem;
146 | margin-right: 0.0625rem;
147 | font-family: monospace;
148 | font-size: 0.875rem;
149 | line-height: 1.5;
150 | user-select: none;
151 | white-space: nowrap;
152 | }
153 |
154 | .markdown-content .ol-block {
155 | opacity: 0.8;
156 | padding-right: 0.25rem;
157 | margin-top: 0.0625rem;
158 | }
159 |
160 | .markdown-content .ul-block {
161 | text-align: center;
162 | margin-top: 0.0625rem;
163 | }
164 |
165 | .markdown-content .todo-block {
166 | width: 1rem;
167 | height: 1rem;
168 | line-height: 1rem;
169 | margin-left: 0.25rem;
170 | margin-right: 0.5rem;
171 | margin-top: 0.25rem;
172 | border: 1px solid currentColor;
173 | border-radius: 0.25rem;
174 | box-sizing: border-box;
175 | font-size: 1.125rem;
176 | cursor: pointer;
177 | box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.06), inset 0 0 0 1px rgba(0, 0, 0, 0.04);
178 | transition: opacity 0.15s ease-in-out;
179 | }
180 |
181 | .markdown-content .todo-block:hover {
182 | opacity: 0.8;
183 | }
184 |
185 | .markdown-content pre {
186 | width: 100%;
187 | margin-top: 0.25rem;
188 | margin-bottom: 0.25rem;
189 | padding: 0.75rem;
190 | border-radius: 0.25rem;
191 | background-color: #f5f5f5;
192 | white-space: pre-wrap;
193 | position: relative;
194 | }
195 |
196 | .markdown-content pre code {
197 | display: block;
198 | }
199 |
200 | .markdown-content pre:hover .codeblock-copy-btn {
201 | display: flex;
202 | }
203 |
204 | .markdown-content .codeblock-copy-btn {
205 | appearance: none;
206 | display: none;
207 | position: absolute;
208 | top: 0.5rem;
209 | right: 0.5rem;
210 | border: 2px solid currentColor;
211 | border-radius: 0.25rem;
212 | background-color: transparent;
213 | color: inherit;
214 | cursor: pointer;
215 | font-family: inherit;
216 | font-size: 100%;
217 | line-height: 1.15;
218 | margin: 0;
219 | overflow: visible;
220 | text-transform: none;
221 | transition: background-color 0.15s ease-in-out, color 0.15s ease-in-out;
222 | user-select: none;
223 | vertical-align: middle;
224 | white-space: nowrap;
225 | }
226 |
227 | .markdown-content code {
228 | word-break: break-all;
229 | background-color: #f5f5f5;
230 | padding-left: 0.25rem;
231 | padding-right: 0.25rem;
232 | border-radius: 0.125rem;
233 | font-size: 0.875rem;
234 | font-family: monospace;
235 | line-height: 1.5;
236 | display: inline-block;
237 | }
238 |
239 | .markdown-content table {
240 | margin-top: 0.25rem;
241 | margin-bottom: 0.25rem;
242 | border-collapse: collapse;
243 | width: 100%;
244 | border: 1px solid #d1d5db;
245 | }
246 |
247 | .markdown-content table th {
248 | padding: 0.25rem 1rem;
249 | text-align: center;
250 | border: 1px solid #d1d5db;
251 | }
252 |
253 | .markdown-content table td {
254 | padding: 0.25rem 1rem;
255 | text-align: center;
256 | border: 1px solid #d1d5db;
257 | }
258 |
259 | .markdown-content blockquote {
260 | border-left: 4px solid currentColor;
261 | padding-left: 0.5rem;
262 | color: #6b7280;
263 | }
264 |
265 | .markdown-content hr {
266 | margin-top: 0.25rem;
267 | margin-bottom: 0.25rem;
268 | border: 1px solid #d1d5db;
269 | }
270 |
--------------------------------------------------------------------------------
/src/db/chat.ts:
--------------------------------------------------------------------------------
1 | export interface Chat {
2 | id?: number;
3 | sessionId: string;
4 | role: any;
5 | content?: any;
6 | }
7 |
--------------------------------------------------------------------------------
/src/db/index.ts:
--------------------------------------------------------------------------------
1 | import Dexie, { Table } from 'dexie';
2 | import { Chat } from './chat';
3 |
4 | class ChatDB extends Dexie {
5 | chat!: Table;
6 |
7 | constructor() {
8 | super('chatDB');
9 | this.version(2).stores({
10 | chat: `
11 | ++id
12 | `,
13 | });
14 |
15 | this.version(3).stores({
16 | chat: `
17 | ++id,
18 | sessionId
19 | `,
20 | });
21 | }
22 |
23 | async deleteChatsBySessionId(sessionId: string): Promise {
24 | await this.chat.where('sessionId').equals(sessionId).delete();
25 | }
26 |
27 | async clearChats(): Promise {
28 | await this.chat.clear();
29 | }
30 | }
31 |
32 | export const chatDB = new ChatDB();
33 |
--------------------------------------------------------------------------------
/src/helpers/markdown/index.tsx:
--------------------------------------------------------------------------------
1 | import { matcher } from './matcher';
2 | import { blockElementParserList, inlineElementParserList } from './parser';
3 |
4 | type Parser = {
5 | name: string;
6 | regexp: RegExp;
7 | renderer: (rawStr: string) => JSX.Element | string;
8 | };
9 |
10 | const findMatchingParser = (parsers: Parser[], markdownStr: string): Parser | undefined => {
11 | let matchedParser = undefined;
12 | let matchedIndex = -1;
13 |
14 | for (const parser of parsers) {
15 | const matchResult = matcher(markdownStr, parser.regexp);
16 | if (!matchResult) {
17 | continue;
18 | }
19 |
20 | if (parser.name === 'plain text' && matchedParser !== undefined) {
21 | continue;
22 | }
23 |
24 | const startIndex = matchResult.index as number;
25 | if (matchedParser === undefined || matchedIndex > startIndex) {
26 | matchedParser = parser;
27 | matchedIndex = startIndex;
28 | }
29 | }
30 |
31 | return matchedParser;
32 | };
33 |
34 | export const marked = (
35 | markdownStr: string,
36 | blockParsers = blockElementParserList,
37 | inlineParsers = inlineElementParserList
38 | ): string | JSX.Element => {
39 | const matchedBlockParser = findMatchingParser(blockParsers, markdownStr);
40 | if (matchedBlockParser) {
41 | const matchResult = matcher(markdownStr, matchedBlockParser.regexp);
42 | if (matchResult) {
43 | const matchedStr = matchResult[0];
44 | const retainContent = markdownStr.slice(matchedStr.length);
45 |
46 | if (matchedBlockParser.name === 'br') {
47 | return (
48 | <>
49 | {matchedBlockParser.renderer(matchedStr)}
50 | {marked(retainContent, blockParsers, inlineParsers)}
51 | >
52 | );
53 | } else {
54 | if (retainContent === '') {
55 | return matchedBlockParser.renderer(matchedStr);
56 | } else if (retainContent.startsWith('\n')) {
57 | return (
58 | <>
59 | {matchedBlockParser.renderer(matchedStr)}
60 | {marked(retainContent.slice(1), blockParsers, inlineParsers)}
61 | >
62 | );
63 | }
64 | }
65 | }
66 | }
67 |
68 | const matchedInlineParser = findMatchingParser(inlineParsers, markdownStr);
69 | if (matchedInlineParser) {
70 | const matchResult = matcher(markdownStr, matchedInlineParser.regexp);
71 | if (matchResult) {
72 | const matchedStr = matchResult[0];
73 | const matchedLength = matchedStr.length;
74 | const mIndex = matchResult.index || 0;
75 | const prefixStr = markdownStr.slice(0, mIndex);
76 | const suffixStr = markdownStr.slice(mIndex + matchedLength);
77 | return (
78 | <>
79 | {marked(prefixStr, [], inlineParsers)}
80 | {matchedInlineParser.renderer(matchedStr)}
81 | {marked(suffixStr, [], inlineParsers)}
82 | >
83 | );
84 | }
85 | }
86 |
87 | return <>{markdownStr}>;
88 | };
89 |
90 | interface MatchedNode {
91 | parserName: string;
92 | matchedContent: string;
93 | }
94 |
95 | export const getMatchedNodes = (markdownStr: string): MatchedNode[] => {
96 | const matchedNodeList: MatchedNode[] = [];
97 |
98 | const walkthrough = (
99 | markdownStr: string,
100 | blockParsers = blockElementParserList,
101 | inlineParsers = inlineElementParserList
102 | ): string => {
103 | const matchedBlockParser = findMatchingParser(blockParsers, markdownStr);
104 | if (matchedBlockParser) {
105 | const matchResult = matcher(markdownStr, matchedBlockParser.regexp);
106 | if (matchResult) {
107 | const matchedStr = matchResult[0];
108 | const retainContent = markdownStr.slice(matchedStr.length);
109 | matchedNodeList.push({
110 | parserName: matchedBlockParser.name,
111 | matchedContent: matchedStr,
112 | });
113 |
114 | if (matchedBlockParser.name === 'br') {
115 | return walkthrough(retainContent, blockParsers, inlineParsers);
116 | } else {
117 | if (matchedBlockParser.name !== 'code block') {
118 | walkthrough(matchedStr, [], inlineParsers);
119 | }
120 | if (retainContent.startsWith('\n')) {
121 | return walkthrough(retainContent.slice(1), blockParsers, inlineParsers);
122 | }
123 | }
124 | return '';
125 | }
126 | }
127 |
128 | const matchedInlineParser = findMatchingParser(inlineParsers, markdownStr);
129 | if (matchedInlineParser) {
130 | const matchResult = matcher(markdownStr, matchedInlineParser.regexp);
131 | if (matchResult) {
132 | const matchedStr = matchResult[0];
133 | const matchedLength = matchedStr.length;
134 | const mIndex = matchResult.index || 0;
135 | const suffixStr = markdownStr.slice(mIndex + matchedLength);
136 | matchedNodeList.push({
137 | parserName: matchedInlineParser.name,
138 | matchedContent: matchedStr,
139 | });
140 | return walkthrough(suffixStr, [], inlineParsers);
141 | }
142 | }
143 |
144 | return markdownStr;
145 | };
146 |
147 | walkthrough(markdownStr);
148 |
149 | return matchedNodeList;
150 | };
151 |
--------------------------------------------------------------------------------
/src/helpers/markdown/matcher.ts:
--------------------------------------------------------------------------------
1 | export const matcher = (rawStr: string, regexp: RegExp) => {
2 | const matchResult = rawStr.match(regexp);
3 | return matchResult;
4 | };
5 |
--------------------------------------------------------------------------------
/src/helpers/markdown/parser/Blockquote.tsx:
--------------------------------------------------------------------------------
1 | import { inlineElementParserList } from '.';
2 | import { marked } from '..';
3 | import { matcher } from '../matcher';
4 |
5 | export const BLOCKQUOTE_REG = /^> ([^\n]+)/;
6 |
7 | const renderer = (rawStr: string) => {
8 | const matchResult = matcher(rawStr, BLOCKQUOTE_REG);
9 | if (!matchResult) {
10 | return <>{rawStr}>;
11 | }
12 |
13 | const parsedContent = marked(matchResult[1], [], inlineElementParserList);
14 | return {parsedContent} ;
15 | };
16 |
17 | export default {
18 | name: 'blockquote',
19 | regexp: BLOCKQUOTE_REG,
20 | renderer,
21 | };
22 |
--------------------------------------------------------------------------------
/src/helpers/markdown/parser/Bold.tsx:
--------------------------------------------------------------------------------
1 | import { marked } from '..';
2 | import { matcher } from '../matcher';
3 | import Link from './Link';
4 | import PlainText from './PlainText';
5 |
6 | export const BOLD_REG = /\*\*(.+?)\*\*/;
7 |
8 | const renderer = (rawStr: string) => {
9 | const matchResult = matcher(rawStr, BOLD_REG);
10 | if (!matchResult) {
11 | return <>{rawStr}>;
12 | }
13 |
14 | const parsedContent = marked(matchResult[1], [], [Link, PlainText]);
15 | return {parsedContent} ;
16 | };
17 |
18 | export default {
19 | name: 'bold',
20 | regexp: BOLD_REG,
21 | renderer,
22 | };
23 |
--------------------------------------------------------------------------------
/src/helpers/markdown/parser/BoldEmphasis.tsx:
--------------------------------------------------------------------------------
1 | import { marked } from '..';
2 | import { matcher } from '../matcher';
3 | import Link from './Link';
4 | import PlainText from './PlainText';
5 |
6 | export const BOLD_EMPHASIS_REG = /\*\*\*(.+?)\*\*\*/;
7 |
8 | const renderer = (rawStr: string) => {
9 | const matchResult = matcher(rawStr, BOLD_EMPHASIS_REG);
10 | if (!matchResult) {
11 | return rawStr;
12 | }
13 |
14 | const parsedContent = marked(matchResult[1], [], [Link, PlainText]);
15 | return (
16 |
17 | {parsedContent}
18 |
19 | );
20 | };
21 |
22 | export default {
23 | name: 'bold emphasis',
24 | regexp: BOLD_EMPHASIS_REG,
25 | renderer,
26 | };
27 |
--------------------------------------------------------------------------------
/src/helpers/markdown/parser/Br.tsx:
--------------------------------------------------------------------------------
1 | export const BR_REG = /^(\n+)/;
2 |
3 | const renderer = (rawStr: string) => {
4 | const length = rawStr.split('\n').length - 1;
5 | const brList = [];
6 | for (let i = 0; i < length; i++) {
7 | brList.push( );
8 | }
9 | return <>{...brList}>;
10 | };
11 |
12 | export default {
13 | name: 'br',
14 | regexp: BR_REG,
15 | renderer,
16 | };
17 |
--------------------------------------------------------------------------------
/src/helpers/markdown/parser/CodeBlock.tsx:
--------------------------------------------------------------------------------
1 | import hljs from 'highlight.js';
2 | import { toast } from 'react-hot-toast';
3 | import { matcher } from '../matcher';
4 |
5 | export const CODE_BLOCK_REG = /^```(\S*?)\s([\s\S]*?)```/;
6 |
7 | const renderer = (rawStr: string) => {
8 | const matchResult = matcher(rawStr, CODE_BLOCK_REG);
9 | if (!matchResult) {
10 | return <>{rawStr}>;
11 | }
12 |
13 | const language = matchResult[1] || 'plaintext';
14 | let highlightedCode = hljs.highlightAuto(matchResult[2]).value;
15 |
16 | try {
17 | const temp = hljs.highlight(matchResult[2], {
18 | language,
19 | }).value;
20 | highlightedCode = temp;
21 | } catch (error) {
22 | // do nth
23 | }
24 |
25 | return (
26 |
27 |
31 |
32 | );
33 | };
34 |
35 | export default {
36 | name: 'code block',
37 | regexp: CODE_BLOCK_REG,
38 | renderer,
39 | };
40 |
--------------------------------------------------------------------------------
/src/helpers/markdown/parser/DoneList.tsx:
--------------------------------------------------------------------------------
1 | import { inlineElementParserList } from '.';
2 | import { marked } from '..';
3 | import { matcher } from '../matcher';
4 |
5 | export const DONE_LIST_REG = /^( *)- \[[xX]\] ([^\n]+)/;
6 |
7 | const renderer = (rawStr: string) => {
8 | const matchResult = matcher(rawStr, DONE_LIST_REG);
9 | if (!matchResult) {
10 | return rawStr;
11 | }
12 | const space = matchResult[1];
13 | const parsedContent = marked(matchResult[2], [], inlineElementParserList);
14 | return (
15 |
16 | {space}
17 |
18 | ✓
19 |
20 | {parsedContent}
21 |
22 | );
23 | };
24 |
25 | export default {
26 | name: 'done list',
27 | regexp: DONE_LIST_REG,
28 | renderer,
29 | };
30 |
--------------------------------------------------------------------------------
/src/helpers/markdown/parser/Emphasis.tsx:
--------------------------------------------------------------------------------
1 | import { marked } from '..';
2 | import { matcher } from '../matcher';
3 | import Link from './Link';
4 | import PlainLink from './PlainLink';
5 | import PlainText from './PlainText';
6 |
7 | export const EMPHASIS_REG = /\*(.+?)\*/;
8 |
9 | const renderer = (rawStr: string) => {
10 | const matchResult = matcher(rawStr, EMPHASIS_REG);
11 | if (!matchResult) {
12 | return rawStr;
13 | }
14 |
15 | const parsedContent = marked(matchResult[1], [], [Link, PlainLink, PlainText]);
16 | return {parsedContent} ;
17 | };
18 |
19 | export default {
20 | name: 'emphasis',
21 | regexp: EMPHASIS_REG,
22 | renderer,
23 | };
24 |
--------------------------------------------------------------------------------
/src/helpers/markdown/parser/Heading.tsx:
--------------------------------------------------------------------------------
1 | import { marked } from '..';
2 | import { matcher } from '../matcher';
3 | import Link from './Link';
4 | import PlainLink from './PlainLink';
5 | import PlainText from './PlainText';
6 |
7 | export const HEADING_REG = /^(#+) ([^\n]+)/;
8 |
9 | const renderer = (rawStr: string) => {
10 | const matchResult = matcher(rawStr, HEADING_REG);
11 | if (!matchResult) {
12 | return rawStr;
13 | }
14 |
15 | const level = matchResult[1].length;
16 | const parsedContent = marked(matchResult[2], [], [Link, PlainLink, PlainText]);
17 | if (level === 1) {
18 | return {parsedContent} ;
19 | } else if (level === 2) {
20 | return {parsedContent} ;
21 | } else if (level === 3) {
22 | return {parsedContent} ;
23 | } else if (level === 4) {
24 | return {parsedContent} ;
25 | }
26 | return {parsedContent} ;
27 | };
28 |
29 | export default {
30 | name: 'heading',
31 | regexp: HEADING_REG,
32 | renderer,
33 | };
34 |
--------------------------------------------------------------------------------
/src/helpers/markdown/parser/HorizontalRules.tsx:
--------------------------------------------------------------------------------
1 | export const HORIZONTAL_RULES_REG = /^_{3}|^-{3}|^\*{3}/;
2 |
3 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
4 | export const renderer = (rawStr: string) => {
5 | return ;
6 | };
7 |
8 | export default {
9 | name: 'horizontal rules',
10 | regexp: HORIZONTAL_RULES_REG,
11 | renderer,
12 | };
13 |
--------------------------------------------------------------------------------
/src/helpers/markdown/parser/Image.tsx:
--------------------------------------------------------------------------------
1 | import { absolutifyLink } from '../../utils';
2 | import { matcher } from '../matcher';
3 |
4 | export const IMAGE_REG = /!\[.*?\]\((.+?)\)/;
5 |
6 | const renderer = (rawStr: string) => {
7 | const matchResult = matcher(rawStr, IMAGE_REG);
8 | if (!matchResult) {
9 | return rawStr;
10 | }
11 |
12 | const imageUrl = absolutifyLink(matchResult[1]);
13 | return ;
14 | };
15 |
16 | export default {
17 | name: 'image',
18 | regexp: IMAGE_REG,
19 | renderer,
20 | };
21 |
--------------------------------------------------------------------------------
/src/helpers/markdown/parser/InlineCode.tsx:
--------------------------------------------------------------------------------
1 | import { matcher } from '../matcher';
2 |
3 | export const INLINE_CODE_REG = /`(.+?)`/;
4 |
5 | const renderer = (rawStr: string) => {
6 | const matchResult = matcher(rawStr, INLINE_CODE_REG);
7 | if (!matchResult) {
8 | return rawStr;
9 | }
10 |
11 | return {matchResult[1]}
;
12 | };
13 |
14 | export default {
15 | name: 'inline code',
16 | regexp: INLINE_CODE_REG,
17 | renderer,
18 | };
19 |
--------------------------------------------------------------------------------
/src/helpers/markdown/parser/Link.tsx:
--------------------------------------------------------------------------------
1 | import Emphasis from './Emphasis';
2 | import Bold from './Bold';
3 | import { marked } from '..';
4 | import InlineCode from './InlineCode';
5 | import BoldEmphasis from './BoldEmphasis';
6 | import PlainText from './PlainText';
7 | import { matcher } from '../matcher';
8 |
9 | export const LINK_REG = /\[([^\]]+)\]\(([^)]+)\)/;
10 |
11 | const renderer = (rawStr: string) => {
12 | const matchResult = matcher(rawStr, LINK_REG);
13 | if (!matchResult) {
14 | return rawStr;
15 | }
16 | const parsedContent = marked(
17 | matchResult[1],
18 | [],
19 | [InlineCode, BoldEmphasis, Emphasis, Bold, PlainText]
20 | );
21 | return (
22 |
23 | {parsedContent}
24 |
25 | );
26 | };
27 |
28 | export default {
29 | name: 'link',
30 | regexp: LINK_REG,
31 | renderer,
32 | };
33 |
--------------------------------------------------------------------------------
/src/helpers/markdown/parser/OrderedList.tsx:
--------------------------------------------------------------------------------
1 | import { inlineElementParserList } from '.';
2 | import { marked } from '..';
3 | import { matcher } from '../matcher';
4 |
5 | export const ORDERED_LIST_REG = /^( *)(\d+)\. (.+)/;
6 |
7 | const renderer = (rawStr: string) => {
8 | const matchResult = matcher(rawStr, ORDERED_LIST_REG);
9 | if (!matchResult) {
10 | return rawStr;
11 | }
12 | const space = matchResult[1];
13 | const parsedContent = marked(matchResult[3], [], inlineElementParserList);
14 | return (
15 |
16 | {space}
17 | {matchResult[2]}.
18 | {parsedContent}
19 |
20 | );
21 | };
22 |
23 | export default {
24 | name: 'ordered list',
25 | regexp: ORDERED_LIST_REG,
26 | renderer,
27 | };
28 |
--------------------------------------------------------------------------------
/src/helpers/markdown/parser/Paragraph.tsx:
--------------------------------------------------------------------------------
1 | import { inlineElementParserList } from '.';
2 | import { marked } from '..';
3 |
4 | export const PARAGRAPH_REG = /^([^\n]+)/;
5 |
6 | const renderer = (rawStr: string) => {
7 | const parsedContent = marked(rawStr, [], inlineElementParserList);
8 | return {parsedContent}
;
9 | };
10 |
11 | export default {
12 | name: 'paragraph',
13 | regexp: PARAGRAPH_REG,
14 | renderer,
15 | };
16 |
--------------------------------------------------------------------------------
/src/helpers/markdown/parser/PlainLink.tsx:
--------------------------------------------------------------------------------
1 | import { matcher } from '../matcher';
2 |
3 | export const PLAIN_LINK_REG = /((?:https?|chrome|edge):\/\/[^ ]+)/;
4 |
5 | const renderer = (rawStr: string) => {
6 | const matchResult = matcher(rawStr, PLAIN_LINK_REG);
7 | if (!matchResult) {
8 | return rawStr;
9 | }
10 |
11 | return (
12 |
13 | {matchResult[1]}
14 |
15 | );
16 | };
17 |
18 | export default {
19 | name: 'plain link',
20 | regexp: PLAIN_LINK_REG,
21 | renderer,
22 | };
23 |
--------------------------------------------------------------------------------
/src/helpers/markdown/parser/PlainText.tsx:
--------------------------------------------------------------------------------
1 | import { matcher } from '../matcher';
2 |
3 | export const PLAIN_TEXT_REG = /(.+)/;
4 |
5 | const renderer = (rawStr: string): string => {
6 | const matchResult = matcher(rawStr, PLAIN_TEXT_REG);
7 | if (!matchResult) {
8 | return rawStr;
9 | }
10 |
11 | return matchResult[1];
12 | };
13 |
14 | export default {
15 | name: 'plain text',
16 | regexp: PLAIN_TEXT_REG,
17 | renderer,
18 | };
19 |
--------------------------------------------------------------------------------
/src/helpers/markdown/parser/Strikethrough.tsx:
--------------------------------------------------------------------------------
1 | import { matcher } from '../matcher';
2 |
3 | export const STRIKETHROUGH_REG = /~~(.+?)~~/;
4 |
5 | const renderer = (rawStr: string) => {
6 | const matchResult = matcher(rawStr, STRIKETHROUGH_REG);
7 | if (!matchResult) {
8 | return rawStr;
9 | }
10 |
11 | return {matchResult[1]};
12 | };
13 |
14 | export default {
15 | name: 'Strikethrough',
16 | regexp: STRIKETHROUGH_REG,
17 | renderer,
18 | };
19 |
--------------------------------------------------------------------------------
/src/helpers/markdown/parser/Table.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { marked } from '..';
3 | import { inlineElementParserList } from '.';
4 |
5 | export const TABLE_REG = /^ *\|(.+)\n *\|( *[-:]+ *\|)+( *\n *\|(.+))*\|? *$/;
6 |
7 | const parseTableContent = (content: string, delimiter: RegExp) => {
8 | return content.split(delimiter).map(cell => cell.trim());
9 | };
10 |
11 | const renderer = (rawStr: string) => {
12 | const matchResult = rawStr.match(TABLE_REG);
13 | if (!matchResult) {
14 | return rawStr;
15 | }
16 |
17 | const tableRows = matchResult[0].split('\n');
18 | const headerRow = tableRows[0];
19 | const alignmentRow = tableRows[1];
20 | const dataRows = tableRows.slice(2);
21 |
22 | const headerCells = parseTableContent(headerRow, /\|/).filter(cell => cell !== '');
23 | const alignments = parseTableContent(alignmentRow, /\|/).filter(cell => cell !== '');
24 |
25 | const header = (
26 |
27 |
28 | {headerCells.map((cell, index) => (
29 | {marked(cell, [], inlineElementParserList)}
30 | ))}
31 |
32 |
33 | );
34 |
35 | const body = (
36 |
37 | {dataRows.map((row, rowIndex) => (
38 |
39 | {parseTableContent(row, /\|/)
40 | .filter(cell => cell !== '')
41 | .map((cell, cellIndex) => (
42 | {marked(cell, [], inlineElementParserList)}
43 | ))}
44 |
45 | ))}
46 |
47 | );
48 |
49 | return (
50 |
51 | {header}
52 | {body}
53 |
54 | );
55 | };
56 |
57 | export default {
58 | name: 'table',
59 | regexp: TABLE_REG,
60 | renderer,
61 | };
62 |
--------------------------------------------------------------------------------
/src/helpers/markdown/parser/Tag.tsx:
--------------------------------------------------------------------------------
1 | import { matcher } from '../matcher';
2 |
3 | export const TAG_REG = /#([^\s#]+)/;
4 |
5 | const renderer = (rawStr: string) => {
6 | const matchResult = matcher(rawStr, TAG_REG);
7 | if (!matchResult) {
8 | return rawStr;
9 | }
10 |
11 | return #{matchResult[1]} ;
12 | };
13 |
14 | export default {
15 | name: 'tag',
16 | regexp: TAG_REG,
17 | renderer,
18 | };
19 |
--------------------------------------------------------------------------------
/src/helpers/markdown/parser/TodoList.tsx:
--------------------------------------------------------------------------------
1 | import { inlineElementParserList } from '.';
2 | import { marked } from '..';
3 | import { matcher } from '../matcher';
4 |
5 | export const TODO_LIST_REG = /^( *)- \[ \] ([^\n]+)/;
6 |
7 | const renderer = (rawStr: string) => {
8 | const matchResult = matcher(rawStr, TODO_LIST_REG);
9 | if (!matchResult) {
10 | return rawStr;
11 | }
12 | const space = matchResult[1];
13 | const parsedContent = marked(matchResult[2], [], inlineElementParserList);
14 | return (
15 |
16 | {space}
17 |
18 | {parsedContent}
19 |
20 | );
21 | };
22 |
23 | export default {
24 | name: 'todo list',
25 | regexp: TODO_LIST_REG,
26 | renderer,
27 | };
28 |
--------------------------------------------------------------------------------
/src/helpers/markdown/parser/UnorderedList.tsx:
--------------------------------------------------------------------------------
1 | import { inlineElementParserList } from '.';
2 | import { marked } from '..';
3 | import { matcher } from '../matcher';
4 |
5 | export const UNORDERED_LIST_REG = /^( *)[*-] ([^\n]+)/;
6 |
7 | const renderer = (rawStr: string) => {
8 | const matchResult = matcher(rawStr, UNORDERED_LIST_REG);
9 | if (!matchResult) {
10 | return rawStr;
11 | }
12 | const space = matchResult[1];
13 | const parsedContent = marked(matchResult[2], [], inlineElementParserList);
14 | return (
15 |
16 | {space}
17 | •
18 | {parsedContent}
19 |
20 | );
21 | };
22 |
23 | export default {
24 | name: 'unordered list',
25 | regexp: UNORDERED_LIST_REG,
26 | renderer,
27 | };
28 |
--------------------------------------------------------------------------------
/src/helpers/markdown/parser/index.ts:
--------------------------------------------------------------------------------
1 | import CodeBlock from './CodeBlock';
2 | import TodoList from './TodoList';
3 | import DoneList from './DoneList';
4 | import OrderedList from './OrderedList';
5 | import UnorderedList from './UnorderedList';
6 | import Paragraph from './Paragraph';
7 | import Br from './Br';
8 | import Tag from './Tag';
9 | import Image from './Image';
10 | import Link from './Link';
11 | import Bold from './Bold';
12 | import Emphasis from './Emphasis';
13 | import PlainLink from './PlainLink';
14 | import InlineCode from './InlineCode';
15 | import PlainText from './PlainText';
16 | import BoldEmphasis from './BoldEmphasis';
17 | import Blockquote from './Blockquote';
18 | import HorizontalRules from './HorizontalRules';
19 | import Strikethrough from './Strikethrough';
20 | import Heading from './Heading';
21 | import Table from './Table';
22 |
23 | export { TAG_REG } from './Tag';
24 | export { LINK_REG } from './Link';
25 |
26 | // The order determines the order of execution.
27 | export const blockElementParserList = [
28 | Table,
29 | Br,
30 | CodeBlock,
31 | Blockquote,
32 | Heading,
33 | TodoList,
34 | DoneList,
35 | OrderedList,
36 | UnorderedList,
37 | HorizontalRules,
38 | Paragraph,
39 | ];
40 |
41 | export const inlineElementParserList = [
42 | Image,
43 | BoldEmphasis,
44 | Bold,
45 | Emphasis,
46 | Link,
47 | InlineCode,
48 | PlainLink,
49 | Strikethrough,
50 | Tag,
51 | PlainText,
52 | ];
53 |
--------------------------------------------------------------------------------
/src/helpers/utils.ts:
--------------------------------------------------------------------------------
1 | export function absolutifyLink(rel: string): string {
2 | const anchor = document.createElement('a');
3 | anchor.setAttribute('href', rel);
4 | return anchor.href;
5 | }
6 |
7 | export function getEnvironmentVariable(name: string): string {
8 | const environment_variable_name = 'VITE_' + name;
9 | return import.meta.env[environment_variable_name];
10 | }
11 |
12 | export function existEnvironmentVariable(name: string): boolean {
13 | const environment_variable_name = 'VITE_' + name;
14 | return import.meta.env[environment_variable_name] !== 'REPLACE_WITH_YOUR_OWN';
15 | }
16 |
17 | export function getFormatDateTime(isoDateTime: string): any {
18 | const date = new Date(isoDateTime);
19 |
20 | const year = date.getFullYear();
21 | const month = String(date.getMonth() + 1).padStart(2, '0');
22 | const day = String(date.getDate()).padStart(2, '0');
23 |
24 | const hours = String(date.getHours()).padStart(2, '0');
25 | const minutes = String(date.getMinutes()).padStart(2, '0');
26 | const seconds = String(date.getSeconds()).padStart(2, '0');
27 |
28 | return `${day}/${month}/${year}, ${hours}:${minutes}:${seconds}`;
29 | }
30 |
--------------------------------------------------------------------------------
/src/i18n.ts:
--------------------------------------------------------------------------------
1 | import i18n from 'i18next';
2 | import { initReactI18next } from 'react-i18next';
3 |
4 | import enLocale from './locales/en.json';
5 | import esLocale from './locales/es.json';
6 | import zhLocale from './locales/zh-CN.json';
7 |
8 | function getLocaleFromLocalStorage() {
9 | const storedData = localStorage.getItem('globalState');
10 |
11 | if (storedData) {
12 | const parsedData = JSON.parse(storedData);
13 | return parsedData.locale || 'en';
14 | }
15 |
16 | return 'en';
17 | }
18 |
19 | i18n.use(initReactI18next).init({
20 | resources: {
21 | en: {
22 | translation: enLocale,
23 | },
24 | zh: {
25 | translation: zhLocale,
26 | },
27 | es: {
28 | translation: esLocale,
29 | },
30 | },
31 | fallbackLng: getLocaleFromLocalStorage() || 'en',
32 | });
33 |
34 | export default i18n;
35 |
--------------------------------------------------------------------------------
/src/locales/zh-CN.json:
--------------------------------------------------------------------------------
1 | {
2 | "common": {
3 | "about": "关于",
4 | "tips": "小提示",
5 | "setting": "设置",
6 | "home": "主页",
7 | "disable-microphone": "禁用麦克风",
8 | "enable-microphone": "启用麦克风",
9 | "disable-speaker": "禁用扬声器",
10 | "enable-speaker": "启用扬声器",
11 | "conversations": "对话",
12 | "conversations-list": "对话列表",
13 | "language": "语言",
14 | "reset-conversation": "重置对话",
15 | "clear-input": "清空输入",
16 | "appearance": "外观",
17 | "record": "录音",
18 | "stop": "停止",
19 | "connecting": "连接中",
20 | "send": "发送",
21 | "speaking": "正在说话",
22 | "status": "状态",
23 | "waiting": "等待中",
24 | "pause-speaking": "暂停播放",
25 | "resume-speaking": "继续播放",
26 | "copy": "复制",
27 | "delete": "删除",
28 | "replay": "重放",
29 | "type-your-message": "输入消息",
30 | "confirm": "确认",
31 | "feedback": "反馈",
32 | "about": "关于"
33 | },
34 | "appearance": {
35 | "light": "浅色主题",
36 | "dark": "深色主题",
37 | "system": "设备默认"
38 | },
39 | "language": {
40 | "en": "English",
41 | "es": "Español",
42 | "zh": "简体中文"
43 | },
44 | "conversations": {
45 | "new": "新的对话",
46 | "new-conversation": "New Conversation",
47 | "no-match": "没有找到匹配的对话",
48 | "search": "搜索",
49 | "message": "条消息",
50 | "messages": "条消息",
51 | "delete": "删除",
52 | "confirm": "确认",
53 | "like": "星标",
54 | "unlike": "取消星标",
55 | "edit": "编辑",
56 | "save": "保存",
57 | "clear-all-conversations": "清空所有对话",
58 | "actions": "操作",
59 | "change-icon": "更改图标"
60 | },
61 | "setting": {
62 | "chat": {
63 | "title": "对话",
64 | "access-code": "访问密码",
65 | "access-code-tooltip": "输入访问密码以使用应用",
66 | "code": "Code",
67 | "api-key": "API Key",
68 | "default-host-address": "chat.openai.com",
69 | "openai": "OpenAI",
70 | "openai-api-key": "OpenAI API 密钥",
71 | "openai-host": "OpenAI API 地址",
72 | "openai-host-tooltip": "用于连接 OpenAI API 的地址",
73 | "openai-model": "OpenAI 模型",
74 | "default-value": "默认值",
75 | "system-role": "系统角色",
76 | "system-role-tooltip": "系统在对话中的角色",
77 | "default-prompt": "默认输入",
78 | "default-prompt-tooltip": "这将作为对话默认的输入",
79 | "type-something": "输入一些内容...",
80 | "others": "其他",
81 | "use-assistant": "使用助手的回复",
82 | "use-assistant-tooltip": "这将提高回复的准确性,但会使用更多的 Token",
83 | "temperature": "思维发散程度",
84 | "temperature-tooltip": "思维发散程度越高,回复越多样化,但可能不太相关",
85 | "maximum-messages": "连续对话次数",
86 | "maximum-messages-tooltip": "这将提高回复的准确性,但会使用更多的 Token",
87 | "already-set-environment-variable": "已在环境变量中设置 OpenAI API 密钥"
88 | },
89 | "synthesis": {
90 | "title": "语音合成",
91 | "service": "服务",
92 | "synthesis-service": "语音合成服务",
93 | "synthesis-service-tooltip": "合成语音使用的服务",
94 | "aws-region": "AWS 区域",
95 | "aws-access-key-id": "AWS Access Key ID",
96 | "aws-access-key-id-placeholder": "AWS Access Key ID",
97 | "aws-secret-access-key": "AWS Secret Access Key",
98 | "aws-secret-access-key-placeholder": "AWS Secret Access Key",
99 | "azure-region": "Azure 区域",
100 | "azure-access-key": "Azure Access Key",
101 | "azure-access-key-placeholder": "Azure Access Key",
102 | "properties": "属性",
103 | "language": "语言",
104 | "voice": "语音",
105 | "speech-rate": "语速",
106 | "pitch": "音调",
107 | "engine": "语音合成引擎",
108 | "engine-tooltip": "Neural 引擎价格更高但更加逼真",
109 | "voice-id": "语音 ID",
110 | "voice-id-tooltip": "用于指定特定的语音",
111 | "polly-standard-not-supported": "Polly 标准版不支持此语言",
112 | "polly-neural-not-supported": "Polly 神经版不支持此语言",
113 | "mobile-not-supported": "目前,移动端不支持浏览器语音合成",
114 | "azure-already-set-environment-variable": "已在环境变量中设置 Azure 密钥",
115 | "polly-already-set-environment-variable": "已在环境变量中设置 AWS 密钥"
116 | },
117 | "recognition": {
118 | "title": "语音识别",
119 | "browser-not-supported": "目前,只有 Chrome 支持浏览器语音识别",
120 | "mobile-not-supported": "目前,移动端不支持浏览器语音识别",
121 | "service": "服务",
122 | "recognition-service": "语音识别服务",
123 | "recognition-service-tooltip": "识别语音使用的服务",
124 | "azure-region": "Azure 区域",
125 | "azure-access-key": "Azure Access Key",
126 | "azure-access-key-placeholder": "Azure Access Key",
127 | "properties": "属性",
128 | "language": "语言",
129 | "continuous": "持续识别",
130 | "continuous-tooltip": "系统回复后,继续识别用户的输入",
131 | "start-time": "开始时间",
132 | "start-time-tooltip": "系统回复后,开始识别的时间",
133 | "azure-already-set-environment-variable": "已在环境变量中设置 Azure 密钥"
134 | },
135 | "about": {
136 | "title": "关于",
137 | "intro": "简介",
138 | "link": "链接",
139 | "version": "版本",
140 | "about-text": "SpeechGPT 是一个让你与 ChatGPT 聊天的网站。 你可以使用 SpeechGPT 来练习你的口语,或者只是和 ChatGPT 闲聊。"
141 | }
142 | },
143 | "tips": {
144 | "tips1": "使用前,请先提供 OpenAI API Key 。",
145 | "tips2": "若要使用 Azure 语音服务 或是 Amazon Polly ,需要提供相关访问密钥。",
146 | "tips3a": "换行。",
147 | "tips3b": "发送消息。"
148 | },
149 | "notification": {
150 | "reset": "对话已重置",
151 | "cleared": "输入已清除",
152 | "copied": "已复制到剪贴板",
153 | "deleted": "已删除",
154 | "invalid-openai-key": "无效的 OpenAI API Key",
155 | "invalid-openai-model": "无效的 OpenAI 模型",
156 | "invalid-openai-request": "无效的 OpenAI 请求",
157 | "openai-error": "OpenAI 错误",
158 | "empty-openai-key": "OpenAI API Key 不能为空",
159 | "network-error": "网络错误",
160 | "builtin-recognition-error": "浏览器语音识别错误",
161 | "builtin-synthesis-error": "浏览器语音合成错误",
162 | "azure-error": "Azure 错误",
163 | "empty-azure-key": "Azure 访问密钥不能为空",
164 | "azure-synthesis-error": "Azure 语音合成错误",
165 | "azure-recognition-error": "Azure 语音识别错误",
166 | "polly-synthesis-error": "Amazon Polly 语音合成错误",
167 | "invalid-azure-key": "无效的 Azure Key 或 Region",
168 | "cannot-be-empty": "不能为空",
169 | "invalid-access-code": "无效的访问密码",
170 | "all-conversations-clear": "已清除所有对话"
171 | }
172 | }
173 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import App from './App';
4 | import { Provider } from 'react-redux';
5 | import store from './store';
6 | import './css/index.css';
7 |
8 | import { I18nextProvider } from 'react-i18next';
9 | import i18n from './i18n';
10 |
11 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | );
20 |
--------------------------------------------------------------------------------
/src/pages/Home.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import Content from '../components/Content';
3 | import { Toaster } from 'react-hot-toast';
4 | import * as Notify from '../components/Notification';
5 | import { browserName, isMobile } from 'react-device-detect';
6 | import { useGlobalStore, useSessionStore } from '../store/module';
7 | import { v4 as uuidv4 } from 'uuid';
8 | import { useTranslation } from 'react-i18next';
9 |
10 | import SettingDialog from '../components/Settings/SettingDialog';
11 | import EllipsisMenu from '../components/EllipsisMenu';
12 |
13 | import { IconMenu2 } from '@tabler/icons-react';
14 | import AboutDialog from '../components/AboutDialog';
15 | import Sidebar from '../components/Conversations/Sidebar';
16 |
17 | function Home() {
18 | const { i18n } = useTranslation();
19 | const [sidebarOpen, setSidebarOpen] = useState(false);
20 |
21 | const { speech, setSpeech, voice, setVoice } = useGlobalStore();
22 | const { sessions, addSession, setCurrentSessionId, currentSessionId } = useSessionStore();
23 |
24 | const [openSetting, setOpenSetting] = useState(false);
25 | const [openAbout, setOpenAbout] = useState(false);
26 |
27 | const notifyDict = {
28 | clearedNotify: Notify.clearedNotify,
29 | copiedNotify: Notify.copiedNotify,
30 | resetNotify: Notify.resetNotify,
31 | invalidOpenAiKeyNotify: Notify.invalidOpenAiKeyNotify,
32 | openAiErrorNotify: Notify.openAiErrorNotify,
33 | networkErrorNotify: Notify.networkErrorNotify,
34 | invalidOpenAiRequestNotify: Notify.invalidOpenAiKeyNotify,
35 | invalidOpenAiModelNotify: Notify.invalidOpenAiModelNotify,
36 | emptyOpenAiKeyNotify: Notify.emptyOpenAiKeyNotify,
37 | deletedNotify: Notify.deletedNotify,
38 | errorBuiltinSpeechRecognitionNotify: Notify.errorBuiltinSpeechRecognitionNotify,
39 | errorBuiltinSpeechSynthesisNotify: Notify.errorBuiltinSpeechSynthesisNotify,
40 | azureSynthesisErrorNotify: Notify.azureSynthesisErrorNotify,
41 | azureRecognitionErrorNotify: Notify.azureRecognitionErrorNotify,
42 | awsErrorNotify: Notify.awsErrorNotify,
43 | emptyAzureKeyNotify: Notify.emptyAzureKeyNotify,
44 | invalidAzureKeyNotify: Notify.invalidAzureKeyNotify,
45 | cannotBeEmptyNotify: Notify.cannotBeEmptyNotify,
46 | invalidAccessCodeNotify: Notify.invalidAccessCodeNotify,
47 | allConversationClearNotify: Notify.allConversationClearNotify,
48 | };
49 |
50 | const toggleSidebar = () => {
51 | setSidebarOpen(!sidebarOpen);
52 | };
53 |
54 | if (isMobile || browserName !== 'Chrome') {
55 | if (voice.service === 'System') {
56 | setVoice({ ...voice, service: 'Azure Speech to Text' });
57 | }
58 |
59 | if (isMobile && speech.service === 'System') {
60 | setSpeech({ ...speech, service: 'Azure TTS' });
61 | }
62 | }
63 |
64 | useEffect(() => {
65 | if (sessions.length === 0) {
66 | const uuid: string = uuidv4();
67 | addSession({
68 | id: uuid,
69 | topic: i18n.t('conversations.new-conversation'),
70 | messageCount: 0,
71 | });
72 | setCurrentSessionId(uuid);
73 | }
74 | if (sessions.length === 1 && currentSessionId !== sessions[0].id) {
75 | setCurrentSessionId(sessions[0].id);
76 | }
77 | }, [sessions.length]);
78 |
79 | return (
80 |
81 |
82 |
setOpenSetting(false)} />
83 | setOpenAbout(false)} notify={notifyDict} />
84 |
90 |
91 |
92 |
97 |
98 |
99 |
100 |
104 |
105 |
106 |
107 |
108 |
109 |
110 | {sidebarOpen && (
111 |
115 | )}
116 |
117 | );
118 | }
119 |
120 | export default Home;
121 |
--------------------------------------------------------------------------------
/src/pages/NotFound.tsx:
--------------------------------------------------------------------------------
1 | import { HomeIcon } from '@heroicons/react/24/outline';
2 |
3 | function NotFound() {
4 | function openHome() {
5 | window.location.href = '/';
6 | }
7 |
8 | return (
9 |
10 |
404
11 |
Page Not Found
12 |
26 |
27 | );
28 | }
29 |
30 | export default NotFound;
31 |
--------------------------------------------------------------------------------
/src/store/index.ts:
--------------------------------------------------------------------------------
1 | import { configureStore } from '@reduxjs/toolkit';
2 | import globalReducer from './reducer/global';
3 | import sessionReducer from './reducer/session';
4 |
5 | const store = configureStore({
6 | reducer: {
7 | global: globalReducer,
8 | session: sessionReducer,
9 | },
10 | });
11 |
12 | export default store;
13 |
--------------------------------------------------------------------------------
/src/store/module/global.ts:
--------------------------------------------------------------------------------
1 | import { useSelector, useDispatch } from 'react-redux';
2 | import store from '../index';
3 | import { useEffect } from 'react';
4 |
5 | function deepMerge(obj1: any, obj2: any) {
6 | const result = { ...obj1 };
7 |
8 | for (const key in obj2) {
9 | if (obj2.hasOwnProperty(key)) {
10 | if (typeof obj2[key] === 'object' && obj2[key] !== null && !Array.isArray(obj2[key])) {
11 | result[key] = deepMerge(obj1[key], obj2[key]);
12 | } else {
13 | result[key] = obj2[key];
14 | }
15 | }
16 | }
17 |
18 | return result;
19 | }
20 |
21 | const defaultGlobalState = {
22 | locale: navigator.language.split(/[-_]/)[0] || '',
23 | appearance: 'system',
24 | developer: false,
25 | disableSpeaker: false,
26 | disableMicrophone: false,
27 | key: {
28 | accessCode: '',
29 | openaiApiKey: '',
30 | openaiModel: '',
31 | openaiHost: '',
32 | awsRegion: '',
33 | awsKeyId: '',
34 | awsKey: '',
35 | azureRegion: '',
36 | azureKey: '',
37 | },
38 | chat: {
39 | systemRole: 'From now on, the number of words in your reply cannot exceed 50 words.',
40 | defaultPrompt: '',
41 | useAssistant: true,
42 | temperature: 0.8,
43 | maxMessages: 20,
44 | },
45 | speech: {
46 | service: 'System',
47 | systemLanguage: 'en',
48 | systemVoice: 'Daniel',
49 | systemRate: 1,
50 | systemPitch: 1,
51 | pollyLanguage: 'en-US',
52 | pollyVoice: '',
53 | pollyEngine: 'Standard',
54 | azureLanguage: 'en-US',
55 | azureVoice: '',
56 | },
57 | voice: {
58 | service: 'System',
59 | systemLanguage: 'en-US',
60 | azureLanguage: 'en-US',
61 | autoStart: false,
62 | startTime: 1,
63 | },
64 | };
65 |
66 | export const initialGlobalState = () => {
67 | let stateJson = localStorage.getItem('globalState');
68 |
69 | if (stateJson) {
70 | let storedState = JSON.parse(stateJson);
71 | // Merge storedState with defaultGlobalState
72 | let state = deepMerge(defaultGlobalState, storedState);
73 |
74 | // Update each property in the store
75 | for (let [key, value] of Object.entries(state)) {
76 | store.dispatch({
77 | type: `global/set${key.charAt(0).toUpperCase() + key.slice(1)}`,
78 | payload: value,
79 | });
80 | }
81 |
82 | // Save the updated state back to localStorage
83 | localStorage.setItem('globalState', JSON.stringify(state));
84 | } else {
85 | // Save the defaultGlobalState to localStorage
86 | localStorage.setItem('globalState', JSON.stringify(defaultGlobalState));
87 | }
88 | };
89 |
90 | const selectGlobal = (state: any) => state.global;
91 |
92 | const saveStateToLocalStorage = (state: any) => {
93 | localStorage.setItem('globalState', JSON.stringify(state));
94 | };
95 |
96 | export const useGlobalStore = () => {
97 | const dispatch = useDispatch();
98 |
99 | const state = useSelector(selectGlobal);
100 |
101 | useEffect(() => {
102 | // Subscribe to state changes
103 | const unsubscribe = store.subscribe(() => {
104 | saveStateToLocalStorage(store.getState().global);
105 | });
106 |
107 | // Clean up the subscription on unmount
108 | return () => {
109 | unsubscribe();
110 | };
111 | }, []);
112 | const setLocale = (locale: string) => {
113 | dispatch({ type: 'global/setLocale', payload: locale });
114 | };
115 |
116 | const setAppearance = (appearance: string) => {
117 | dispatch({ type: 'global/setAppearance', payload: appearance });
118 | };
119 |
120 | const setDeveloper = (developer: boolean) => {
121 | dispatch({ type: 'global/setDeveloper', payload: developer });
122 | };
123 |
124 | const setDisableSpeaker = (disableSpeaker: boolean) => {
125 | dispatch({ type: 'global/setDisableSpeaker', payload: disableSpeaker });
126 | };
127 |
128 | const setDisableMicrophone = (disableMicrophone: boolean) => {
129 | dispatch({ type: 'global/setDisableMicrophone', payload: disableMicrophone });
130 | };
131 |
132 | const setKey = (key: any) => {
133 | dispatch({ type: 'global/setKey', payload: key });
134 | };
135 |
136 | const setChat = (chat: any) => {
137 | dispatch({ type: 'global/setChat', payload: chat });
138 | };
139 |
140 | const setSpeech = (speech: any) => {
141 | dispatch({ type: 'global/setSpeech', payload: speech });
142 | };
143 |
144 | const setVoice = (voice: any) => {
145 | dispatch({ type: 'global/setVoice', payload: voice });
146 | };
147 |
148 | return {
149 | ...state,
150 | setLocale,
151 | setAppearance,
152 | setDeveloper,
153 | setDisableSpeaker,
154 | setDisableMicrophone,
155 | setKey,
156 | setChat,
157 | setSpeech,
158 | setVoice,
159 | };
160 | };
161 |
--------------------------------------------------------------------------------
/src/store/module/index.ts:
--------------------------------------------------------------------------------
1 | export * from './global';
2 | export * from './session';
3 |
--------------------------------------------------------------------------------
/src/store/module/session.ts:
--------------------------------------------------------------------------------
1 | import { useSelector, useDispatch } from 'react-redux';
2 | import store from '../index';
3 | import { useEffect } from 'react';
4 | import { SessionStore } from '../../typings/session';
5 | import { initialState } from '../reducer/session';
6 |
7 | function deepMerge(obj1: any, obj2: any) {
8 | const result = { ...obj1 };
9 |
10 | for (const key in obj2) {
11 | if (obj2.hasOwnProperty(key)) {
12 | if (typeof obj2[key] === 'object' && obj2[key] !== null && !Array.isArray(obj2[key])) {
13 | result[key] = deepMerge(obj1[key], obj2[key]);
14 | } else {
15 | result[key] = obj2[key];
16 | }
17 | }
18 | }
19 |
20 | return result;
21 | }
22 |
23 | export const initialSessionState = () => {
24 | let stateJson = localStorage.getItem('sessionState');
25 |
26 | if (stateJson) {
27 | let storedState = JSON.parse(stateJson);
28 | // Merge storedState with initialState
29 | let state = deepMerge(initialState, storedState);
30 |
31 | // Update each property in the store
32 | for (let [key, value] of Object.entries(state)) {
33 | store.dispatch({
34 | type: `session/set${key.charAt(0).toUpperCase() + key.slice(1)}`,
35 | payload: value,
36 | });
37 | }
38 |
39 | // Save the updated state back to localStorage
40 | localStorage.setItem('sessionState', JSON.stringify(state));
41 | } else {
42 | // Save the initialState to localStorage
43 | localStorage.setItem('sessionState', JSON.stringify(initialState));
44 | }
45 | };
46 |
47 | const selectSession = (state: any) => state.session;
48 |
49 | const saveStateToLocalStorage = (state: any) => {
50 | localStorage.setItem('sessionState', JSON.stringify(state));
51 | };
52 |
53 | export const useSessionStore = (): SessionStore => {
54 | const dispatch = useDispatch();
55 |
56 | const state = useSelector(selectSession);
57 |
58 | useEffect(() => {
59 | // Subscribe to state changes
60 | const unsubscribe = store.subscribe(() => {
61 | saveStateToLocalStorage(store.getState().session);
62 | });
63 |
64 | // Clean up the subscription on unmount
65 | return () => {
66 | unsubscribe();
67 | };
68 | }, []);
69 |
70 | const setCurrentSessionId = (currentSessionId: string) => {
71 | dispatch({ type: 'session/setCurrentSessionId', payload: currentSessionId });
72 | };
73 |
74 | const setSessions = (session: any) => {
75 | dispatch({ type: 'session/setSessions', payload: session });
76 | };
77 |
78 | const addSession = (session: any) => {
79 | dispatch({ type: 'session/addSession', payload: session });
80 | };
81 |
82 | const removeSession = (session: any) => {
83 | dispatch({ type: 'session/removeSession', payload: session });
84 | };
85 |
86 | const updateSession = (session: any) => {
87 | dispatch({ type: 'session/updateSession', payload: session });
88 | };
89 |
90 | const clearSessions = () => {
91 | dispatch({ type: 'session/clearSessions' });
92 | };
93 |
94 | const setMessageCount = (session: any) => {
95 | dispatch({ type: 'session/setMessageCount', payload: session });
96 | };
97 |
98 | const setLiked = (session: any) => {
99 | dispatch({ type: 'session/setLiked', payload: session });
100 | };
101 |
102 | const setIcon = (session: any) => {
103 | dispatch({ type: 'session/setIcon', payload: session });
104 | };
105 |
106 | return {
107 | ...state,
108 | setCurrentSessionId,
109 | setSessions,
110 | addSession,
111 | removeSession,
112 | updateSession,
113 | clearSessions,
114 | setMessageCount,
115 | setLiked,
116 | setIcon,
117 | };
118 | };
119 |
--------------------------------------------------------------------------------
/src/store/reducer/global.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from '@reduxjs/toolkit';
2 |
3 | export const globalSlice = createSlice({
4 | name: 'global',
5 | initialState: {
6 | locale: navigator.language.split(/[-_]/)[0] || '',
7 | appearance: 'system',
8 | developer: false,
9 | disableSpeaker: false,
10 | disableMicrophone: false,
11 | key: {
12 | accessCode: '',
13 | openaiApiKey: '',
14 | openaiModel: '',
15 | openaiHost: '',
16 | awsRegion: '',
17 | awsKeyId: '',
18 | awsKey: '',
19 | azureRegion: '',
20 | azureKey: '',
21 | },
22 | chat: {
23 | systemRole: 'From now on, the number of words in your reply cannot exceed 50 words.',
24 | defaultPrompt: '',
25 | useAssistant: true,
26 | temperature: 0.8,
27 | maxMessages: 20,
28 | },
29 | speech: {
30 | service: 'System',
31 | systemLanguage: 'en',
32 | systemVoice: 'Daniel',
33 | systemRate: 1,
34 | systemPitch: 1,
35 | pollyLanguage: 'en-US',
36 | pollyVoice: '',
37 | pollyEngine: 'Standard',
38 | azureLanguage: 'en-US',
39 | azureVoice: '',
40 | },
41 | voice: {
42 | service: 'System',
43 | systemLanguage: 'en-US',
44 | azureLanguage: 'en-US',
45 | autoStart: false,
46 | startTime: 1,
47 | },
48 | },
49 | reducers: {
50 | setLocale: (state, action) => {
51 | state.locale = action.payload;
52 | },
53 | setAppearance: (state, action) => {
54 | state.appearance = action.payload;
55 | },
56 | setDeveloper: (state, action) => {
57 | state.developer = action.payload;
58 | },
59 | setDisableSpeaker: (state, action) => {
60 | state.disableSpeaker = action.payload;
61 | },
62 | setDisableMicrophone: (state, action) => {
63 | state.disableMicrophone = action.payload;
64 | },
65 | setKey: (state, action) => {
66 | state.key = action.payload;
67 | },
68 | setChat: (state, action) => {
69 | state.chat = action.payload;
70 | },
71 | setSpeech: (state, action) => {
72 | state.speech = action.payload;
73 | },
74 | setVoice: (state, action) => {
75 | state.voice = action.payload;
76 | },
77 | },
78 | });
79 |
80 | export const {
81 | setLocale,
82 | setAppearance,
83 | setDeveloper,
84 | setDisableSpeaker,
85 | setDisableMicrophone,
86 | setKey,
87 | setChat,
88 | setSpeech,
89 | setVoice,
90 | } = globalSlice.actions;
91 |
92 | export default globalSlice.reducer;
93 |
--------------------------------------------------------------------------------
/src/store/reducer/session.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from '@reduxjs/toolkit';
2 | import { SessionState } from '../../typings/session';
3 | import { getFormatDateTime } from '../../helpers/utils';
4 | import { v4 as uuidv4 } from 'uuid';
5 | import i18n from '../../i18n'; // Import i18n instance
6 |
7 | export const initialState: SessionState = {
8 | currentSessionId: 0,
9 | order: '',
10 | sessions: [
11 | {
12 | id: uuidv4(),
13 | topic: i18n.t('conversations.new-conversation'),
14 | icon: 'blue-circle',
15 | tags: [],
16 | liked: false,
17 | date: getFormatDateTime(new Date().toISOString()),
18 | stats: {
19 | messageCount: 0,
20 | tokenCount: 0,
21 | characterCount: 0,
22 | },
23 | chat: {
24 | systemRole: '',
25 | defaultPrompt: '',
26 | useAssistant: true,
27 | temperature: 0.8,
28 | maxMessages: 20,
29 | },
30 | speech: {
31 | service: 'System',
32 | systemLanguage: 'en',
33 | systemVoice: 'Daniel',
34 | systemRate: 1,
35 | systemPitch: 1,
36 | pollyLanguage: 'en-US',
37 | pollyVoice: '',
38 | pollyEngine: 'Standard',
39 | azureLanguage: 'en-US',
40 | azureVoice: '',
41 | },
42 | voice: {
43 | service: 'System',
44 | systemLanguage: 'en-US',
45 | azureLanguage: 'en-US',
46 | autoStart: false,
47 | startTime: 1,
48 | },
49 | },
50 | ],
51 | };
52 |
53 | export const sessionSlice = createSlice({
54 | name: 'session',
55 | initialState,
56 | reducers: {
57 | setCurrentSessionId: (state, action) => {
58 | state.currentSessionId = action.payload;
59 | },
60 | setSessions: (state, action) => {
61 | state.sessions = action.payload;
62 | },
63 | addSession: (state, action) => {
64 | const { id, topic, messageCount } = action.payload;
65 | state.sessions.unshift({
66 | id,
67 | topic,
68 | icon: 'blue-circle',
69 | tags: [],
70 | liked: false,
71 | date: getFormatDateTime(new Date().toISOString()),
72 | stats: {
73 | messageCount: messageCount || 0,
74 | tokenCount: 0,
75 | characterCount: 0,
76 | },
77 | chat: {
78 | systemRole: '',
79 | defaultPrompt: '',
80 | useAssistant: true,
81 | temperature: 0.8,
82 | maxMessages: 20,
83 | },
84 | speech: {
85 | service: 'System',
86 | systemLanguage: 'en',
87 | systemVoice: 'Daniel',
88 | systemRate: 1,
89 | systemPitch: 1,
90 | pollyLanguage: 'en-US',
91 | pollyVoice: '',
92 | pollyEngine: 'Standard',
93 | azureLanguage: 'en-US',
94 | azureVoice: '',
95 | },
96 | voice: {
97 | service: 'System',
98 | systemLanguage: 'en-US',
99 | azureLanguage: 'en-US',
100 | autoStart: false,
101 | startTime: 1,
102 | },
103 | });
104 | },
105 | removeSession: (state, action) => {
106 | const { id } = action.payload;
107 | state.sessions = state.sessions.filter(session => session.id !== id);
108 | },
109 | updateSession: (state, action) => {
110 | const { id, topic } = action.payload;
111 | const index = state.sessions.findIndex(session => session.id === id);
112 | if (index !== -1) {
113 | state.sessions[index].topic = topic;
114 | }
115 | },
116 | clearSessions: state => {
117 | state.sessions = [];
118 | },
119 | setMessageCount: (state, action) => {
120 | const { id, messageCount } = action.payload;
121 | const index = state.sessions.findIndex(session => session.id === id);
122 | if (index !== -1) {
123 | state.sessions[index].stats.messageCount = messageCount;
124 | }
125 | },
126 | setLiked: (state, action) => {
127 | const { id, liked } = action.payload;
128 | const index = state.sessions.findIndex(session => session.id === id);
129 | if (index !== -1) {
130 | state.sessions[index].liked = liked;
131 | }
132 | },
133 | setIcon: (state, action) => {
134 | const { id, icon } = action.payload;
135 | const index = state.sessions.findIndex(session => session.id === id);
136 | if (index !== -1) {
137 | state.sessions[index].icon = icon;
138 | }
139 | },
140 | },
141 | });
142 |
143 | export const {
144 | setCurrentSessionId,
145 | setSessions,
146 | addSession,
147 | removeSession,
148 | updateSession,
149 | clearSessions,
150 | setMessageCount,
151 | setLiked,
152 | setIcon,
153 | } = sessionSlice.actions;
154 |
155 | export default sessionSlice.reducer;
156 |
--------------------------------------------------------------------------------
/src/typings/chat.d.ts:
--------------------------------------------------------------------------------
1 | declare namespace Chat {
2 | interface Chat {
3 | time?: string;
4 | message?: string;
5 | audio?: ArrayBuffer;
6 | uid?: string;
7 | role?: 'assistant' | 'user';
8 | content?: any;
9 | }
10 |
11 | interface History {
12 | title: string;
13 | isEdit: boolean;
14 | uuid: number;
15 | }
16 |
17 | interface ChatState {
18 | active: number | null;
19 | usingContext: boolean;
20 | history: History[];
21 | chat: { uuid: number; data: Chat[] }[];
22 | }
23 |
24 | interface ConversationRequest {
25 | conversationId?: string;
26 | parentMessageId?: string;
27 | }
28 |
29 | interface ConversationResponse {
30 | conversationId: string;
31 | detail: {
32 | choices: { finish_reason: string; index: number; logprobs: any; text: string }[];
33 | created: number;
34 | id: string;
35 | model: string;
36 | object: string;
37 | usage: { completion_tokens: number; prompt_tokens: number; total_tokens: number };
38 | };
39 | id: string;
40 | parentMessageId: string;
41 | role: string;
42 | text: string;
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/typings/env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | interface ImportMetaEnv {
4 | readonly VITE_GLOB_API_URL: string;
5 | readonly VITE_APP_API_BASE_URL: string;
6 | readonly VITE_GLOB_OPEN_LONG_REPLY: string;
7 | readonly VITE_GLOB_APP_PWA: string;
8 | }
9 |
--------------------------------------------------------------------------------
/src/typings/global.d.ts:
--------------------------------------------------------------------------------
1 | interface Window {
2 | $loadingBar?: import('naive-ui').LoadingBarProviderInst;
3 | $dialog?: import('naive-ui').DialogProviderInst;
4 | $message?: import('naive-ui').MessageProviderInst;
5 | $notification?: import('naive-ui').NotificationProviderInst;
6 | }
7 |
--------------------------------------------------------------------------------
/src/typings/session.d.ts:
--------------------------------------------------------------------------------
1 | import { getFormatDateTime } from '../helpers/utils';
2 |
3 | export interface SessionStore {
4 | sessions: Session[];
5 | currentSessionId: string;
6 | setCurrentSessionId: (id: string) => void;
7 | setSession: (session: any) => void;
8 | addSession: (session: any) => void;
9 | removeSession: (session: any) => void;
10 | updateSession: (session: any) => void;
11 | clearSessions: () => void;
12 | setMessageCount: (session: any) => void;
13 | setLiked: (session: any) => void;
14 | setIcon: (session: any) => void;
15 | }
16 |
17 | export interface Session {
18 | id: string;
19 | topic: string;
20 | icon: string;
21 | tags: string[];
22 | liked: boolean;
23 | date: string;
24 | stats: {
25 | messageCount: number;
26 | tokenCount: number;
27 | characterCount: number;
28 | };
29 | chat: {
30 | systemRole: string;
31 | defaultPrompt: string;
32 | useAssistant: boolean;
33 | temperature: number;
34 | maxMessages: number;
35 | };
36 | speech: {
37 | service: string;
38 | systemLanguage: string;
39 | systemVoice: string;
40 | systemRate: number;
41 | systemPitch: number;
42 | pollyLanguage: string;
43 | pollyVoice: string;
44 | pollyEngine: string;
45 | azureLanguage: string;
46 | azureVoice: string;
47 | };
48 | voice: {
49 | service: string;
50 | systemLanguage: string;
51 | azureLanguage: string;
52 | autoStart: boolean;
53 | startTime: number;
54 | };
55 | }
56 |
57 | export interface SessionState {
58 | currentSessionId: number;
59 | order: string;
60 | sessions: Session[];
61 | }
62 |
--------------------------------------------------------------------------------
/src/utils/speechSynthesis.ts:
--------------------------------------------------------------------------------
1 | import speechSynthesizeWithPolly from '../apis/amazonPolly';
2 | import speechSynthesizeWithAzure from '../apis/azureTTS';
3 | import { SpeakerAudioDestination } from 'microsoft-cognitiveservices-speech-sdk';
4 | import { getAzureToken } from '../apis/azureToken';
5 |
6 | interface SpeechSynthesisOptions {
7 | text: string;
8 | service: 'System' | 'Amazon Polly' | 'Azure TTS';
9 | language: string;
10 | rate?: number;
11 | pitch?: number;
12 | voiceName: string;
13 | engine?: string;
14 | region?: string;
15 | accessKeyId?: string;
16 | secretAccessKey?: string;
17 | notify: any;
18 | }
19 |
20 | interface getPollyVoicesOptions {
21 | text: string;
22 | voiceName: string;
23 | engine?: string;
24 | region: string;
25 | accessKeyId: string;
26 | secretAccessKey: string;
27 | }
28 |
29 | const synthesis = window.speechSynthesis;
30 | let pollyAudio: HTMLAudioElement | null = null;
31 | let azureAudio: SpeakerAudioDestination | null = null;
32 |
33 | async function getPollyVoices({
34 | text,
35 | voiceName,
36 | engine,
37 | region,
38 | accessKeyId,
39 | secretAccessKey,
40 | }: getPollyVoicesOptions) {
41 | return await speechSynthesizeWithPolly(
42 | text,
43 | voiceName,
44 | engine,
45 | region,
46 | accessKeyId,
47 | secretAccessKey
48 | );
49 | }
50 |
51 | function pollyEngineName(engine: string | undefined) {
52 | if (engine === 'Neural') {
53 | return 'neural';
54 | } else {
55 | return 'standard';
56 | }
57 | }
58 |
59 | export function speechSynthesis({
60 | text,
61 | service,
62 | language,
63 | rate,
64 | pitch,
65 | voiceName,
66 | engine,
67 | region,
68 | accessKeyId,
69 | secretAccessKey,
70 | notify,
71 | }: SpeechSynthesisOptions): Promise {
72 | return new Promise((resolve, reject) => {
73 | const speakWithVoice = () => {
74 | const synthesis = window.speechSynthesis;
75 | const utterance = new SpeechSynthesisUtterance(text);
76 |
77 | utterance.lang = language;
78 | utterance.rate = rate || 1;
79 | utterance.pitch = pitch || 1;
80 |
81 | const voice = synthesis.getVoices().find(v => v.name === voiceName);
82 |
83 | if (voice) {
84 | utterance.voice = voice;
85 | }
86 | console.log(utterance);
87 |
88 | // Add the 'end' event listener to resolve the Promise
89 | utterance.addEventListener('end', () => {
90 | resolve();
91 | });
92 |
93 | // Add the 'error' event listener to reject the Promise
94 | utterance.addEventListener('error', error => {
95 | if (error.error === 'interrupted') {
96 | return;
97 | }
98 | notify.errorBuiltinSpeechSynthesisNotify();
99 | reject(error);
100 | });
101 |
102 | synthesis.speak(utterance);
103 | };
104 |
105 | switch (service) {
106 | case 'System':
107 | if (window.speechSynthesis.getVoices().length === 0) {
108 | window.speechSynthesis.onvoiceschanged = () => {
109 | speakWithVoice();
110 | };
111 | } else {
112 | speakWithVoice();
113 | }
114 | break;
115 | case 'Amazon Polly':
116 | getPollyVoices({
117 | text,
118 | voiceName,
119 | engine: pollyEngineName(engine) || 'neural',
120 | region: region || 'us-east-1',
121 | accessKeyId: accessKeyId || '', //
122 | secretAccessKey: secretAccessKey || '',
123 | })
124 | .then(url => {
125 | pollyAudio = new Audio(url as string);
126 | pollyAudio.play();
127 | pollyAudio.onended = () => {
128 | resolve();
129 | };
130 | pollyAudio.onerror = error => {
131 | reject(error);
132 | notify.awsErrorNotify();
133 | };
134 | })
135 | .catch(error => {
136 | reject(error);
137 | notify.awsErrorNotify();
138 | });
139 | break;
140 | case 'Azure TTS':
141 | if (secretAccessKey == '') {
142 | reject('Azure access key is empty');
143 | notify.emptyAzureKeyNotify();
144 | return;
145 | }
146 | // Check if secret access key and region is valid
147 | getAzureToken(secretAccessKey || '', region || 'eastus')
148 | .then(token => {})
149 | .catch(error => {
150 | notify.invalidAzureKeyNotify();
151 | reject(error);
152 | });
153 | speechSynthesizeWithAzure(
154 | secretAccessKey || '',
155 | region || 'eastus',
156 | text,
157 | voiceName,
158 | language
159 | )
160 | .then(player => {
161 | azureAudio = player;
162 | player.onAudioEnd = () => {
163 | resolve();
164 | };
165 | })
166 | .catch(error => {
167 | console.error(error);
168 | notify.azureSynthesisErrorNotify();
169 | reject(error);
170 | });
171 | break;
172 | }
173 | });
174 | }
175 |
176 | export function stopSpeechSynthesis() {
177 | if (window.speechSynthesis) {
178 | window.speechSynthesis.cancel();
179 | } else {
180 | console.error('Speech synthesis is not supported in this browser.');
181 | }
182 | if (pollyAudio) {
183 | pollyAudio.pause();
184 | pollyAudio.currentTime = 0;
185 | }
186 | if (azureAudio) {
187 | azureAudio.pause();
188 | azureAudio.close();
189 | }
190 | }
191 |
192 | export function pauseSpeechSynthesis() {
193 | if (window.speechSynthesis) {
194 | window.speechSynthesis.pause();
195 | } else {
196 | console.error('Speech synthesis is not supported in this browser.');
197 | }
198 | if (pollyAudio) {
199 | pollyAudio.pause();
200 | }
201 | if (azureAudio) {
202 | azureAudio.pause();
203 | }
204 | }
205 |
206 | export function resumeSpeechSynthesis() {
207 | if (window.speechSynthesis) {
208 | window.speechSynthesis.resume();
209 | } else {
210 | console.error('Speech synthesis is not supported in this browser.');
211 | }
212 | if (pollyAudio) {
213 | pollyAudio.play();
214 | }
215 | if (azureAudio) {
216 | azureAudio.resume();
217 | }
218 | }
219 |
--------------------------------------------------------------------------------
/src/utils/version.ts:
--------------------------------------------------------------------------------
1 | import packageJson from '../../package.json';
2 | export const getVersion = () => {
3 | return packageJson.version;
4 | };
5 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/tailwind.config.cjs:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: 'class',
4 | content: [
5 | "./index.html",
6 | "./src/**/*.{vue,js,ts,jsx,tsx}",
7 | ],
8 | theme: {
9 | extend: {
10 | width: {
11 | 120: "480px",
12 | 125: "500px",
13 | 130: "520px",
14 | 140: "560px",
15 | 150: "600px",
16 | 160: "640px",
17 | 180: "720px",
18 | 200: "800px",
19 | },
20 | height: {
21 | 120: "480px",
22 | 125: "500px",
23 | 130: "520px",
24 | 135: "540px",
25 | 140: "560px",
26 | 150: "600px",
27 | 160: "640px",
28 | },
29 | maxHeight: {
30 | 140: "560px",
31 | 150: "600px",
32 | 160: "640px",
33 | },
34 | maxWidth: {
35 | 160: "640px",
36 | 180: "720px",
37 | 200: "800px",
38 | }
39 | },
40 | },
41 | plugins: [],
42 | }
43 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
6 | "allowJs": false,
7 | "skipLibCheck": true,
8 | "esModuleInterop": false,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "module": "ESNext",
13 | "moduleResolution": "Node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx"
18 | },
19 | "include": ["src"],
20 | "references": [{ "path": "./tsconfig.node.json" }]
21 | }
22 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "module": "ESNext",
5 | "moduleResolution": "Node",
6 | "allowSyntheticDefaultImports": true
7 | },
8 | "include": ["vite.config.ts"]
9 | }
10 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | })
8 |
--------------------------------------------------------------------------------