mirror of
				https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
				synced 2025-10-31 22:33:45 +08:00 
			
		
		
		
	Compare commits
	
		
			160 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 5843303076 | ||
|  | e2c1475857 | ||
|  | 8d34b0f454 | ||
|  | b44caeeefb | ||
|  | 8d60a414f0 | ||
|  | e8d71c815e | ||
|  | 1afca0b28a | ||
|  | f52bcc2a37 | ||
|  | 2647bdb4ed | ||
|  | e8dd391ccf | ||
|  | e3c3cd3d18 | ||
|  | b05b96e3a4 | ||
|  | b85245e317 | ||
|  | a10f4d8abc | ||
|  | c978de2c10 | ||
|  | 6c1862797b | ||
|  | 4f0108b0ea | ||
|  | 7b5af271d5 | ||
|  | 37587f6f71 | ||
|  | 328a903c24 | ||
|  | fdbdd33e77 | ||
|  | a356ee857c | ||
|  | cf6f09b7b8 | ||
|  | a90e646381 | ||
|  | 16028795f9 | ||
|  | 12f342f015 | ||
|  | e248e9196a | ||
|  | fea4f561b4 | ||
|  | d226090926 | ||
|  | 2d534bfdf4 | ||
|  | ed5cd11d6a | ||
|  | 0a60a87c9f | ||
|  | 506cdbc83c | ||
|  | a64c4384b1 | ||
|  | d54c983187 | ||
|  | cd5f8f7407 | ||
|  | 00a282214e | ||
|  | 44145f11db | ||
|  | ad63b10aea | ||
|  | 327ac765df | ||
|  | 83cea2adb8 | ||
|  | 0385f6ede9 | ||
|  | 45bf2c3d25 | ||
|  | e6b64b0f2c | ||
|  | 4dc1e025e1 | ||
|  | ba08b10de1 | ||
|  | de35862cc5 | ||
|  | 407c9fc9c3 | ||
|  | 536358cb3c | ||
|  | 5f7a264e52 | ||
|  | c70c311989 | ||
|  | e5aa72af76 | ||
|  | eb586ba361 | ||
|  | 1db210097c | ||
|  | 7f16698f01 | ||
|  | 61eb356fd9 | ||
|  | 35a402c67e | ||
|  | 5a910e0f29 | ||
|  | be8a35063c | ||
|  | df75b9973a | ||
|  | 2f2e0b6762 | ||
|  | 83862eae44 | ||
|  | 6e6faec398 | ||
|  | e7e39ba56e | ||
|  | fe9dd88c3f | ||
|  | b3fdf3efec | ||
|  | 802ea20ec4 | ||
|  | 52a217883d | ||
|  | 7783545bff | ||
|  | 1b140a1ed3 | ||
|  | bf50ebac94 | ||
|  | 7599ae385b | ||
|  | 7827b40f17 | ||
|  | dea3d26335 | ||
|  | 9eb77207fb | ||
|  | 164d3fb4fe | ||
|  | 0c9add7988 | ||
|  | 166329abee | ||
|  | 7d7abca2c4 | ||
|  | dab69c7507 | ||
|  | 852f8b8aa5 | ||
|  | fe858621f2 | ||
|  | 6e4e804af8 | ||
|  | e68aaf24f1 | ||
|  | 29c20a3d5c | ||
|  | 6ed61f533a | ||
|  | ad7a365f32 | ||
|  | 2c5420ab9e | ||
|  | 8d6d6bbf5d | ||
|  | d5235c81d0 | ||
|  | 9e5b119e92 | ||
|  | d9fc9cd198 | ||
|  | 8b4db412d8 | ||
|  | 9c6f3ebb54 | ||
|  | 53e30e20db | ||
|  | d908099798 | ||
|  | 469c8e9b00 | ||
|  | cd9799588d | ||
|  | 447dec9444 | ||
|  | 45088a3e06 | ||
|  | 08f3c7026d | ||
|  | e606810581 | ||
|  | 73b2ede53a | ||
|  | f1b2f895b4 | ||
|  | 9724308008 | ||
|  | be4706d02d | ||
|  | 752c083905 | ||
|  | c47e90004a | ||
|  | 380f818285 | ||
|  | f893f53b1c | ||
|  | cac604aee3 | ||
|  | 525b5b8ee6 | ||
|  | 974c455bf9 | ||
|  | b94607f636 | ||
|  | 8a05f84838 | ||
|  | ca0082856a | ||
|  | e12238ba8b | ||
|  | 71f119c9e8 | ||
|  | 067121d968 | ||
|  | f5d775c055 | ||
|  | f979822508 | ||
|  | cfbe6d77b5 | ||
|  | dee7950601 | ||
|  | 18d8eb4767 | ||
|  | f2d019ff97 | ||
|  | 3a170d50cb | ||
|  | 17ee2ee135 | ||
|  | 07956486b5 | ||
|  | e648a59b1f | ||
|  | 83400093a2 | ||
|  | 6782e65fdf | ||
|  | 7d5e742ea6 | ||
|  | 19b511e3f8 | ||
|  | cde3cbed21 | ||
|  | 307be405ac | ||
|  | 1c017b8ee9 | ||
|  | 48dc2c2295 | ||
|  | 3a3999d73a | ||
|  | 7f3cbaa064 | ||
|  | 7a5c35baf3 | ||
|  | eb72c83b7e | ||
|  | e93ea0fa97 | ||
|  | 3b6f93afdf | ||
|  | 4597a2286a | ||
|  | 780968979d | ||
|  | adc0db4c74 | ||
|  | f0dd95a2a3 | ||
|  | 6155a190ac | ||
|  | 493aa8c591 | ||
|  | 6c82f804ae | ||
|  | a2807c9815 | ||
|  | d822f333c2 | ||
|  | 8f498075b9 | ||
|  | c4bf6a6383 | ||
|  | 939402b2d9 | ||
|  | 684a3c41ef | ||
|  | 306f0850e9 | ||
|  | 55f37248f7 | ||
|  | c93a46a02f | ||
|  | 77a3fdea5f | 
| @@ -1,3 +1,4 @@ | ||||
| { | ||||
|   "extends": "next/core-web-vitals" | ||||
|   "extends": "next/core-web-vitals", | ||||
|   "plugins": ["prettier"] | ||||
| } | ||||
|   | ||||
							
								
								
									
										35
									
								
								.github/workflows/docker.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										35
									
								
								.github/workflows/docker.yml
									
									
									
									
										vendored
									
									
								
							| @@ -1,6 +1,7 @@ | ||||
| name: Publish Docker image | ||||
|  | ||||
| on: | ||||
|   workflow_dispatch: | ||||
|   release: | ||||
|     types: [published] | ||||
|  | ||||
| @@ -9,25 +10,43 @@ jobs: | ||||
|     name: Push Docker image to Docker Hub | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Check out the repo | ||||
|       - | ||||
|         name: Check out the repo | ||||
|         uses: actions/checkout@v3 | ||||
|        | ||||
|       - name: Log in to Docker Hub | ||||
|         uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 | ||||
|       - | ||||
|         name: Log in to Docker Hub | ||||
|         uses: docker/login-action@v2 | ||||
|         with: | ||||
|           username: ${{ secrets.DOCKER_USERNAME }} | ||||
|           password: ${{ secrets.DOCKER_PASSWORD }} | ||||
|        | ||||
|       - name: Extract metadata (tags, labels) for Docker | ||||
|       -  | ||||
|         name: Extract metadata (tags, labels) for Docker | ||||
|         id: meta | ||||
|         uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 | ||||
|         uses: docker/metadata-action@v4 | ||||
|         with: | ||||
|           images: yidadaa/chatgpt-next-web | ||||
|           tags: | | ||||
|             type=raw,value=latest | ||||
|             type=ref,event=tag | ||||
|        | ||||
|       - name: Build and push Docker image | ||||
|         uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc | ||||
|       -  | ||||
|         name: Set up QEMU | ||||
|         uses: docker/setup-qemu-action@v2 | ||||
|  | ||||
|       -  | ||||
|         name: Set up Docker Buildx | ||||
|         uses: docker/setup-buildx-action@v2 | ||||
|        | ||||
|       -  | ||||
|         name: Build and push Docker image | ||||
|         uses: docker/build-push-action@v4 | ||||
|         with: | ||||
|           context: . | ||||
|           platforms: linux/amd64 | ||||
|           push: true | ||||
|           tags: ${{ steps.meta.outputs.tags }} | ||||
|           labels: ${{ steps.meta.outputs.labels }} | ||||
|           cache-from: type=gha | ||||
|           cache-to: type=gha,mode=max | ||||
|              | ||||
|   | ||||
							
								
								
									
										29
									
								
								.github/workflows/sync.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								.github/workflows/sync.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | ||||
| name: Upstream Sync | ||||
|  | ||||
| on: | ||||
|   schedule: | ||||
|     - cron: '0 */12 * * *' # every 12 hours | ||||
|   workflow_dispatch: # on button click | ||||
|  | ||||
| jobs: | ||||
|   sync_latest_from_upstream: | ||||
|     name: Sync latest commits from upstream repo | ||||
|     runs-on: ubuntu-latest | ||||
|  | ||||
|     steps: | ||||
|     # Step 1: run a standard checkout action, provided by github | ||||
|     - name: Checkout target repo | ||||
|       uses: actions/checkout@v3 | ||||
|  | ||||
|     # Step 2: run the sync action | ||||
|     - name: Sync upstream changes | ||||
|       id: sync | ||||
|       uses: aormsby/Fork-Sync-With-Upstream-action@v3.4 | ||||
|       with: | ||||
|         upstream_sync_repo: Yidadaa/ChatGPT-Next-Web | ||||
|         upstream_sync_branch: main | ||||
|         target_sync_branch: main | ||||
|         target_repo_token: ${{ secrets.GITHUB_TOKEN }} # automatically generated, no need to set | ||||
|          | ||||
|         # Set test_mode true to run tests instead of the true action!! | ||||
|         test_mode: false | ||||
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -35,3 +35,5 @@ yarn-error.log* | ||||
| *.tsbuildinfo | ||||
| next-env.d.ts | ||||
| dev | ||||
|  | ||||
| public/prompts.json | ||||
							
								
								
									
										4
									
								
								.husky/pre-commit
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										4
									
								
								.husky/pre-commit
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| #!/usr/bin/env sh | ||||
| . "$(dirname -- "$0")/_/husky.sh" | ||||
|  | ||||
| npx lint-staged | ||||
							
								
								
									
										6
									
								
								.lintstagedrc.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								.lintstagedrc.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| { | ||||
|   "./app/**/*.{js,ts,jsx,tsx,json,html,css,md}": [ | ||||
|     "eslint --fix", | ||||
|     "prettier --write" | ||||
|   ] | ||||
| } | ||||
							
								
								
									
										10
									
								
								.prettierrc.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								.prettierrc.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| module.exports = { | ||||
|   printWidth: 80, | ||||
|   tabWidth: 2, | ||||
|   useTabs: false, | ||||
|   semi: true, | ||||
|   singleQuote: false, | ||||
|   trailingComma: 'all', | ||||
|   bracketSpacing: true, | ||||
|   arrowParens: 'always', | ||||
| }; | ||||
| @@ -6,13 +6,9 @@ RUN apk add --no-cache libc6-compat | ||||
|  | ||||
| WORKDIR /app | ||||
|  | ||||
| COPY package.json yarn.lock* package-lock.json* ./ | ||||
| COPY package.json yarn.lock ./ | ||||
|  | ||||
| RUN \ | ||||
|   if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ | ||||
|   elif [ -f package-lock.json ]; then npm ci; \ | ||||
|   else echo "Lockfile not found." && exit 1; \ | ||||
|   fi | ||||
| RUN yarn install | ||||
|  | ||||
| FROM base AS builder | ||||
|  | ||||
|   | ||||
							
								
								
									
										92
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										92
									
								
								README.md
									
									
									
									
									
								
							| @@ -7,9 +7,9 @@ | ||||
|  | ||||
| One-Click to deploy your own ChatGPT web UI. | ||||
|  | ||||
| [演示 Demo](https://chat-gpt-next-web.vercel.app/) / [反馈 Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [加入 Discord](https://discord.gg/zrhvHCr79N) / [微信群](https://user-images.githubusercontent.com/16968934/227772522-b3ba3713-9206-4c8d-a81f-22300b7c313a.jpg) / [打赏开发者](https://user-images.githubusercontent.com/16968934/227772541-5bcd52d8-61b7-488c-a203-0330d8006e2b.jpg) | ||||
| [演示 Demo](https://chat-gpt-next-web.vercel.app/) / [反馈 Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [加入 Discord](https://discord.gg/zrhvHCr79N) / [QQ 群](https://user-images.githubusercontent.com/16968934/228190818-7dd00845-e9b9-4363-97e5-44c507ac76da.jpeg) / [打赏开发者](https://user-images.githubusercontent.com/16968934/227772541-5bcd52d8-61b7-488c-a203-0330d8006e2b.jpg) / [Donate](#捐赠-donate-usdt) | ||||
|  | ||||
| [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web) | ||||
| [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web) | ||||
|  | ||||
| [](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web) | ||||
|  | ||||
| @@ -22,6 +22,7 @@ One-Click to deploy your own ChatGPT web UI. | ||||
| - 在 1 分钟内使用 Vercel **免费一键部署** | ||||
| - 精心设计的 UI,响应式设计,支持深色模式 | ||||
| - 极快的首屏加载速度(~85kb) | ||||
| - 海量的内置 prompt 列表,来自[中文](https://github.com/PlexPt/awesome-chatgpt-prompts-zh)和[英文](https://github.com/f/awesome-chatgpt-prompts) | ||||
| - 自动压缩上下文聊天记录,在节省 Token 的同时支持超长对话 | ||||
| - 一键导出聊天记录,完整的 Markdown 支持 | ||||
| - 拥有自己的域名?好上加好,绑定后即可在任何地方**无障碍**快速访问 | ||||
| @@ -31,14 +32,28 @@ One-Click to deploy your own ChatGPT web UI. | ||||
| - **Deploy for free with one-click** on Vercel in under 1 minute | ||||
| - Responsive design, and dark mode | ||||
| - Fast first screen loading speed (~85kb) | ||||
| - Awesome prompts powered by [awesome-chatgpt-prompts-zh](https://github.com/PlexPt/awesome-chatgpt-prompts-zh) and [awesome-chatgpt-prompts](https://github.com/f/awesome-chatgpt-prompts) | ||||
| - Automatically compresses chat history to support long conversations while also saving your tokens | ||||
| - One-click export all chat history with full Markdown support | ||||
| - I18n supported | ||||
|  | ||||
| ## 使用 | ||||
| ## 开发计划 Roadmap | ||||
|  | ||||
| - System Prompt: pin a user defined prompt as system prompt 为每个对话设置系统 Prompt [#138](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138) | ||||
| - User Prompt: user can edit and save custom prompts to prompt list 允许用户自行编辑内置 Prompt 列表 | ||||
| - Self-host Model: support llama, alpaca, ChatGLM, BELLE etc. 支持自部署的大语言模型 | ||||
| - Plugins: support network search, caculator, any other apis etc. 插件机制,支持联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) | ||||
|  | ||||
| ### 不会开发的功能 Not in Plan | ||||
|  | ||||
| - User login, accounts, cloud sync 用户登录、账号管理、消息云同步 | ||||
| - UI text customize 界面文字自定义 | ||||
|  | ||||
| ## 开始使用 | ||||
|  | ||||
| 1. 准备好你的 [OpenAI API Key](https://platform.openai.com/account/api-keys); | ||||
| 2. 点击右侧按钮开始部署: | ||||
|    [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web),直接使用 Github 账号登陆即可,记得在环境变量页填入 API Key; | ||||
|    [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web),直接使用 Github 账号登录即可,记得在环境变量页填入 API Key; | ||||
| 3. 部署完毕后,即可开始使用; | ||||
| 4. (可选)[绑定自定义域名](https://vercel.com/docs/concepts/projects/domains/add-a-domain):Vercel 分配的域名 DNS 在某些区域被污染了,绑定自定义域名即可直连。 | ||||
|  | ||||
| @@ -46,7 +61,7 @@ One-Click to deploy your own ChatGPT web UI. | ||||
|  | ||||
| 1. Get [OpenAI API Key](https://platform.openai.com/account/api-keys); | ||||
| 2. Click | ||||
|    [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web); | ||||
|    [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web); | ||||
| 3. Enjoy :) | ||||
|  | ||||
| ## 保持更新 Keep Updated | ||||
| @@ -76,9 +91,9 @@ This project will be continuously maintained. If you want to keep the code repos | ||||
|  | ||||
| You can star or watch this project or follow author to get release notifictions in time. | ||||
|  | ||||
| ## 访问控制 Access Control | ||||
| ## 配置密码 Password | ||||
|  | ||||
| 本项目提供有限的权限控制功能,请在环境变量页增加名为 `CODE` 的环境变量,值为用英文逗号分隔的自定义控制码: | ||||
| 本项目提供有限的权限控制功能,请在 Vercel 项目控制面板的环境变量页增加名为 `CODE` 的环境变量,值为用英文逗号分隔的自定义密码: | ||||
|  | ||||
| ``` | ||||
| code1,code2,code3 | ||||
| @@ -86,7 +101,7 @@ code1,code2,code3 | ||||
|  | ||||
| 增加或修改该环境变量后,请**重新部署**项目使改动生效。 | ||||
|  | ||||
| This project provides limited access control. Please add an environment variable named `CODE` on the environment variables page. The value should be a custom control code separated by comma like this: | ||||
| This project provides limited access control. Please add an environment variable named `CODE` on the vercel environment variables page. The value should be passwords separated by comma like this: | ||||
|  | ||||
| ``` | ||||
| code1,code2,code3 | ||||
| @@ -94,6 +109,38 @@ code1,code2,code3 | ||||
|  | ||||
| After adding or modifying this environment variable, please redeploy the project for the changes to take effect. | ||||
|  | ||||
| ## 环境变量 Environment Variables | ||||
|  | ||||
| ### `OPENAI_API_KEY` (required) | ||||
|  | ||||
| OpanAI 密钥。 | ||||
|  | ||||
| Your openai api key. | ||||
|  | ||||
| ### `CODE` (optional) | ||||
|  | ||||
| 访问密码,可选,可以使用逗号隔开多个密码。 | ||||
|  | ||||
| Access passsword, separated by comma. | ||||
|  | ||||
| ### `BASE_URL` (optional) | ||||
|  | ||||
| > Default: `api.openai.com` | ||||
|  | ||||
| OpenAI 接口代理 URL。 | ||||
|  | ||||
| Override openai api request base url. | ||||
|  | ||||
| ### `PROTOCOL` (optional) | ||||
|  | ||||
| > Default: `https` | ||||
|  | ||||
| > Values: `http` | `https` | ||||
|  | ||||
| OpenAI 接口协议。 | ||||
|  | ||||
| Override openai api request protocol. | ||||
|  | ||||
| ## 开发 Development | ||||
|  | ||||
| 点击下方按钮,开始二次开发: | ||||
| @@ -117,16 +164,8 @@ OPENAI_API_KEY=<your api key here> | ||||
|  | ||||
| ### 本地部署 Local Deployment | ||||
|  | ||||
| 请直接询问 ChatGPT,使用下列 Prompt: | ||||
|  | ||||
| ``` | ||||
| 如何使用 pm2 和 yarn 部署 nextjs 项目到 ubuntu 服务器上,项目编译命令为 yarn build,启动命令为 yarn start,启动时需要设置环境变量为 OPENAI_API_KEY,端口为 3000,使用 ngnix 做反向代理 | ||||
| ``` | ||||
|  | ||||
| Please ask ChatGPT with prompt: | ||||
|  | ||||
| ``` | ||||
| how to deploy nextjs project with pm2 and yarn on my ubuntu server, the build command is `yarn build`, the start command is `yarn start`, the project must start with env var named `OPENAI_API_KEY`, the port is 3000, use ngnix | ||||
| ```shell | ||||
| bash <(curl -s https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/scripts/setup.sh) | ||||
| ``` | ||||
|  | ||||
| ### 容器部署 Docker Deployment | ||||
| @@ -134,7 +173,7 @@ how to deploy nextjs project with pm2 and yarn on my ubuntu server, the build co | ||||
| ```shell | ||||
| docker pull yidadaa/chatgpt-next-web | ||||
|  | ||||
| docker run -d -p 3000:3000 -e OPEN_API_KEY="" -e CODE="" aprilnea/chatgpt-next-web | ||||
| docker run -d -p 3000:3000 -e OPENAI_API_KEY="" -e CODE="" yidadaa/chatgpt-next-web | ||||
| ``` | ||||
|  | ||||
| ## 截图 Screenshots | ||||
| @@ -143,15 +182,6 @@ docker run -d -p 3000:3000 -e OPEN_API_KEY="" -e CODE="" aprilnea/chatgpt-next-w | ||||
|  | ||||
|  | ||||
|  | ||||
| ## 说明 Attention | ||||
|  | ||||
| 本项目的演示地址所用的 OpenAI 账户的免费额度将于 2023-04-01 过期,届时将无法通过演示地址在线体验。 | ||||
|  | ||||
| 如果你想贡献出自己的 API Key,可以通过作者主页的邮箱发送给作者,并标注过期时间。 | ||||
|  | ||||
| The free trial of the OpenAI account used by the demo will expire on April 1, 2023, and the demo will not be available at that time. | ||||
|  | ||||
| If you would like to contribute your API key, you can email it to the author and indicate the expiration date of the API key. | ||||
|  | ||||
| ## 鸣谢 Special Thanks | ||||
|  | ||||
| @@ -159,11 +189,13 @@ If you would like to contribute your API key, you can email it to the author and | ||||
|  | ||||
| [@mushan0x0](https://github.com/mushan0x0) | ||||
| [@ClarenceDan](https://github.com/ClarenceDan) | ||||
| [@zhangjia](https://github.com/zhangjia) | ||||
| [@hoochanlon](https://github.com/hoochanlon) | ||||
|  | ||||
| ### 贡献者 Contributor | ||||
|  | ||||
| [@AprilNEA](https://github.com/AprilNEA) | ||||
| [Contributors](https://github.com/Yidadaa/ChatGPT-Next-Web/graphs/contributors) | ||||
|  | ||||
| ## LICENSE | ||||
|  | ||||
| - [Anti 996 License](https://github.com/kattgu7/Anti-996-License/blob/master/LICENSE_CN_EN) | ||||
| [Anti 996 License](https://github.com/kattgu7/Anti-996-License/blob/master/LICENSE_CN_EN) | ||||
|   | ||||
| @@ -14,3 +14,4 @@ export function getAccessCodes(): Set<string> { | ||||
| } | ||||
|  | ||||
| export const ACCESS_CODES = getAccessCodes(); | ||||
| export const IS_IN_DOCKER = process.env.DOCKER; | ||||
|   | ||||
| @@ -1,27 +1,22 @@ | ||||
| import { createParser } from "eventsource-parser"; | ||||
| import { NextRequest } from "next/server"; | ||||
| import { requestOpenai } from "../common"; | ||||
|  | ||||
| async function createStream(req: NextRequest) { | ||||
|   const encoder = new TextEncoder(); | ||||
|   const decoder = new TextDecoder(); | ||||
|  | ||||
|   let apiKey = process.env.OPENAI_API_KEY; | ||||
|   const res = await requestOpenai(req); | ||||
|  | ||||
|   const userApiKey = req.headers.get("token"); | ||||
|   if (userApiKey) { | ||||
|     apiKey = userApiKey; | ||||
|     console.log("[Stream] using user api key"); | ||||
|   const contentType = res.headers.get("Content-Type") ?? ""; | ||||
|   if (!contentType.includes("stream")) { | ||||
|     const content = await ( | ||||
|       await res.text() | ||||
|     ).replace(/provided:.*. You/, "provided: ***. You"); | ||||
|     console.log("[Stream] error ", content); | ||||
|     return "```json\n" + content + "```"; | ||||
|   } | ||||
|  | ||||
|   const res = await fetch("https://api.openai.com/v1/chat/completions", { | ||||
|     headers: { | ||||
|       "Content-Type": "application/json", | ||||
|       Authorization: `Bearer ${apiKey}`, | ||||
|     }, | ||||
|     method: "POST", | ||||
|     body: req.body, | ||||
|   }); | ||||
|  | ||||
|   const stream = new ReadableStream({ | ||||
|     async start(controller) { | ||||
|       function onParse(event: any) { | ||||
|   | ||||
							
								
								
									
										1
									
								
								app/api/chat/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								app/api/chat/.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -1 +0,0 @@ | ||||
| config.ts | ||||
| @@ -1,29 +0,0 @@ | ||||
| import { OpenAIApi, Configuration } from "openai"; | ||||
| import { ChatRequest } from "./typing"; | ||||
|  | ||||
| export async function POST(req: Request) { | ||||
|   try { | ||||
|     let apiKey = process.env.OPENAI_API_KEY; | ||||
|  | ||||
|     const userApiKey = req.headers.get("token"); | ||||
|     if (userApiKey) { | ||||
|       apiKey = userApiKey; | ||||
|     } | ||||
|  | ||||
|     const openai = new OpenAIApi( | ||||
|       new Configuration({ | ||||
|         apiKey, | ||||
|       }) | ||||
|     ); | ||||
|  | ||||
|     const requestBody = (await req.json()) as ChatRequest; | ||||
|     const completion = await openai!.createChatCompletion({ | ||||
|       ...requestBody, | ||||
|     }); | ||||
|  | ||||
|     return new Response(JSON.stringify(completion.data)); | ||||
|   } catch (e) { | ||||
|     console.error("[Chat] ", e); | ||||
|     return new Response(JSON.stringify(e)); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										22
									
								
								app/api/common.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								app/api/common.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | ||||
| import { NextRequest } from "next/server"; | ||||
|  | ||||
| const OPENAI_URL = "api.openai.com"; | ||||
| const DEFAULT_PROTOCOL = "https"; | ||||
| const PROTOCOL = process.env.PROTOCOL ?? DEFAULT_PROTOCOL; | ||||
| const BASE_URL = process.env.BASE_URL ?? OPENAI_URL; | ||||
|  | ||||
| export async function requestOpenai(req: NextRequest) { | ||||
|   const apiKey = req.headers.get("token"); | ||||
|   const openaiPath = req.headers.get("path"); | ||||
|  | ||||
|   console.log("[Proxy] ", openaiPath); | ||||
|  | ||||
|   return fetch(`${PROTOCOL}://${BASE_URL}/${openaiPath}`, { | ||||
|     headers: { | ||||
|       "Content-Type": "application/json", | ||||
|       Authorization: `Bearer ${apiKey}`, | ||||
|     }, | ||||
|     method: req.method, | ||||
|     body: req.body, | ||||
|   }); | ||||
| } | ||||
							
								
								
									
										30
									
								
								app/api/openai/route.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								app/api/openai/route.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| import { NextRequest, NextResponse } from "next/server"; | ||||
| import { requestOpenai } from "../common"; | ||||
|  | ||||
| async function makeRequest(req: NextRequest) { | ||||
|   try { | ||||
|     const api = await requestOpenai(req); | ||||
|     const res = new NextResponse(api.body); | ||||
|     res.headers.set("Content-Type", "application/json"); | ||||
|     return res; | ||||
|   } catch (e) { | ||||
|     console.error("[OpenAI] ", req.body, e); | ||||
|     return NextResponse.json( | ||||
|       { | ||||
|         error: true, | ||||
|         msg: JSON.stringify(e), | ||||
|       }, | ||||
|       { | ||||
|         status: 500, | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export async function POST(req: NextRequest) { | ||||
|   return makeRequest(req); | ||||
| } | ||||
|  | ||||
| export async function GET(req: NextRequest) { | ||||
|   return makeRequest(req); | ||||
| } | ||||
| @@ -6,19 +6,21 @@ | ||||
|   justify-content: center; | ||||
|   padding: 10px; | ||||
|  | ||||
|   box-shadow: var(--card-shadow); | ||||
|   cursor: pointer; | ||||
|   transition: all 0.3s ease; | ||||
|   overflow: hidden; | ||||
|   user-select: none; | ||||
| } | ||||
|  | ||||
| .shadow { | ||||
|   box-shadow: var(--card-shadow); | ||||
| } | ||||
|  | ||||
| .border { | ||||
|   border: var(--border-in-light); | ||||
| } | ||||
|  | ||||
| .icon-button:hover { | ||||
|   filter: brightness(0.9); | ||||
|   border-color: var(--primary); | ||||
| } | ||||
|  | ||||
| @@ -36,24 +38,6 @@ | ||||
|   } | ||||
| } | ||||
|  | ||||
| @mixin dark-button { | ||||
|   div:not(:global(.no-dark))>.icon-button-icon { | ||||
|     filter: invert(0.5); | ||||
|   } | ||||
|  | ||||
|   .icon-button:hover { | ||||
|     filter: brightness(1.2); | ||||
|   } | ||||
| } | ||||
|  | ||||
| :global(.dark) { | ||||
|   @include dark-button; | ||||
| } | ||||
|  | ||||
| @media (prefers-color-scheme: dark) { | ||||
|   @include dark-button; | ||||
| } | ||||
|  | ||||
| .icon-button-text { | ||||
|   margin-left: 5px; | ||||
|   font-size: 12px; | ||||
|   | ||||
| @@ -7,6 +7,8 @@ export function IconButton(props: { | ||||
|   icon: JSX.Element; | ||||
|   text?: string; | ||||
|   bordered?: boolean; | ||||
|   shadow?: boolean; | ||||
|   noDark?: boolean; | ||||
|   className?: string; | ||||
|   title?: string; | ||||
| }) { | ||||
| @@ -14,12 +16,19 @@ export function IconButton(props: { | ||||
|     <div | ||||
|       className={ | ||||
|         styles["icon-button"] + | ||||
|         ` ${props.bordered && styles.border} ${props.className ?? ""}` | ||||
|         ` ${props.bordered && styles.border} ${props.shadow && styles.shadow} ${ | ||||
|           props.className ?? "" | ||||
|         } clickable` | ||||
|       } | ||||
|       onClick={props.onClick} | ||||
|       title={props.title} | ||||
|       role="button" | ||||
|     > | ||||
|       <div className={styles["icon-button-icon"]}>{props.icon}</div> | ||||
|       <div | ||||
|         className={styles["icon-button-icon"] + ` ${props.noDark && "no-dark"}`} | ||||
|       > | ||||
|         {props.icon} | ||||
|       </div> | ||||
|       {props.text && ( | ||||
|         <div className={styles["icon-button-text"]}>{props.text}</div> | ||||
|       )} | ||||
|   | ||||
							
								
								
									
										73
									
								
								app/components/chat-list.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								app/components/chat-list.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,73 @@ | ||||
| import { useState, useRef, useEffect, useLayoutEffect } from "react"; | ||||
| import DeleteIcon from "../icons/delete.svg"; | ||||
| import styles from "./home.module.scss"; | ||||
|  | ||||
| import { | ||||
|   Message, | ||||
|   SubmitKey, | ||||
|   useChatStore, | ||||
|   ChatSession, | ||||
|   BOT_HELLO, | ||||
| } from "../store"; | ||||
|  | ||||
| import Locale from "../locales"; | ||||
| import { isMobileScreen } from "../utils"; | ||||
|  | ||||
| export function ChatItem(props: { | ||||
|   onClick?: () => void; | ||||
|   onDelete?: () => void; | ||||
|   title: string; | ||||
|   count: number; | ||||
|   time: string; | ||||
|   selected: boolean; | ||||
| }) { | ||||
|   return ( | ||||
|     <div | ||||
|       className={`${styles["chat-item"]} ${ | ||||
|         props.selected && styles["chat-item-selected"] | ||||
|       }`} | ||||
|       onClick={props.onClick} | ||||
|     > | ||||
|       <div className={styles["chat-item-title"]}>{props.title}</div> | ||||
|       <div className={styles["chat-item-info"]}> | ||||
|         <div className={styles["chat-item-count"]}> | ||||
|           {Locale.ChatItem.ChatItemCount(props.count)} | ||||
|         </div> | ||||
|         <div className={styles["chat-item-date"]}>{props.time}</div> | ||||
|       </div> | ||||
|       <div className={styles["chat-item-delete"]} onClick={props.onDelete}> | ||||
|         <DeleteIcon /> | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export function ChatList() { | ||||
|   const [sessions, selectedIndex, selectSession, removeSession] = useChatStore( | ||||
|     (state) => [ | ||||
|       state.sessions, | ||||
|       state.currentSessionIndex, | ||||
|       state.selectSession, | ||||
|       state.removeSession, | ||||
|     ], | ||||
|   ); | ||||
|  | ||||
|   return ( | ||||
|     <div className={styles["chat-list"]}> | ||||
|       {sessions.map((item, i) => ( | ||||
|         <ChatItem | ||||
|           title={item.topic} | ||||
|           time={item.lastUpdate} | ||||
|           count={item.messages.length} | ||||
|           key={i} | ||||
|           selected={i === selectedIndex} | ||||
|           onClick={() => selectSession(i)} | ||||
|           onDelete={() => | ||||
|             (!isMobileScreen() || confirm(Locale.Home.DeleteChat)) && | ||||
|             removeSession(i) | ||||
|           } | ||||
|         /> | ||||
|       ))} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										75
									
								
								app/components/chat.module.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								app/components/chat.module.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,75 @@ | ||||
| @import "../styles/animation.scss"; | ||||
|  | ||||
| .prompt-toast { | ||||
|   position: absolute; | ||||
|   bottom: -50px; | ||||
|   z-index: 999; | ||||
|   display: flex; | ||||
|   justify-content: center; | ||||
|   width: calc(100% - 40px); | ||||
|  | ||||
|   .prompt-toast-inner { | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|     align-items: center; | ||||
|     font-size: 12px; | ||||
|     background-color: var(--white); | ||||
|     color: var(--black); | ||||
|  | ||||
|     border: var(--border-in-light); | ||||
|     box-shadow: var(--card-shadow); | ||||
|     padding: 10px 20px; | ||||
|     border-radius: 100px; | ||||
|  | ||||
|     animation: slide-in-from-top ease 0.3s; | ||||
|  | ||||
|     .prompt-toast-content { | ||||
|       margin-left: 10px; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| .context-prompt { | ||||
|   .context-prompt-row { | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|     width: 100%; | ||||
|     margin-bottom: 10px; | ||||
|  | ||||
|     .context-role { | ||||
|       margin-right: 10px; | ||||
|     } | ||||
|  | ||||
|     .context-content { | ||||
|       flex: 1; | ||||
|       max-width: 100%; | ||||
|       text-align: left; | ||||
|     } | ||||
|  | ||||
|     .context-delete-button { | ||||
|       margin-left: 10px; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .context-prompt-button { | ||||
|     flex: 1; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .memory-prompt { | ||||
|   margin-top: 20px; | ||||
|  | ||||
|   .memory-prompt-title { | ||||
|     font-size: 12px; | ||||
|     font-weight: bold; | ||||
|     margin-bottom: 10px; | ||||
|   } | ||||
|  | ||||
|   .memory-prompt-content { | ||||
|     background-color: var(--gray); | ||||
|     border-radius: 6px; | ||||
|     padding: 10px; | ||||
|     font-size: 12px; | ||||
|     user-select: text; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										642
									
								
								app/components/chat.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										642
									
								
								app/components/chat.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,642 @@ | ||||
| import { useDebouncedCallback } from "use-debounce"; | ||||
| import { useState, useRef, useEffect, useLayoutEffect } from "react"; | ||||
|  | ||||
| import SendWhiteIcon from "../icons/send-white.svg"; | ||||
| import BrainIcon from "../icons/brain.svg"; | ||||
| import ExportIcon from "../icons/export.svg"; | ||||
| import MenuIcon from "../icons/menu.svg"; | ||||
| import CopyIcon from "../icons/copy.svg"; | ||||
| import DownloadIcon from "../icons/download.svg"; | ||||
| import LoadingIcon from "../icons/three-dots.svg"; | ||||
| import BotIcon from "../icons/bot.svg"; | ||||
| import AddIcon from "../icons/add.svg"; | ||||
| import DeleteIcon from "../icons/delete.svg"; | ||||
|  | ||||
| import { | ||||
|   Message, | ||||
|   SubmitKey, | ||||
|   useChatStore, | ||||
|   ChatSession, | ||||
|   BOT_HELLO, | ||||
|   ROLES, | ||||
| } from "../store"; | ||||
|  | ||||
| import { | ||||
|   copyToClipboard, | ||||
|   downloadAs, | ||||
|   isMobileScreen, | ||||
|   selectOrCopy, | ||||
| } from "../utils"; | ||||
|  | ||||
| import dynamic from "next/dynamic"; | ||||
|  | ||||
| import { ControllerPool } from "../requests"; | ||||
| import { Prompt, usePromptStore } from "../store/prompt"; | ||||
| import Locale from "../locales"; | ||||
|  | ||||
| import { IconButton } from "./button"; | ||||
| import styles from "./home.module.scss"; | ||||
| import chatStyle from "./chat.module.scss"; | ||||
|  | ||||
| import { Modal, showModal, showToast } from "./ui-lib"; | ||||
|  | ||||
| const Markdown = dynamic(async () => (await import("./markdown")).Markdown, { | ||||
|   loading: () => <LoadingIcon />, | ||||
| }); | ||||
|  | ||||
| const Emoji = dynamic(async () => (await import("emoji-picker-react")).Emoji, { | ||||
|   loading: () => <LoadingIcon />, | ||||
| }); | ||||
|  | ||||
| export function Avatar(props: { role: Message["role"] }) { | ||||
|   const config = useChatStore((state) => state.config); | ||||
|  | ||||
|   if (props.role !== "user") { | ||||
|     return <BotIcon className={styles["user-avtar"]} />; | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <div className={styles["user-avtar"]}> | ||||
|       <Emoji unified={config.avatar} size={18} /> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function exportMessages(messages: Message[], topic: string) { | ||||
|   const mdText = | ||||
|     `# ${topic}\n\n` + | ||||
|     messages | ||||
|       .map((m) => { | ||||
|         return m.role === "user" ? `## ${m.content}` : m.content.trim(); | ||||
|       }) | ||||
|       .join("\n\n"); | ||||
|   const filename = `${topic}.md`; | ||||
|  | ||||
|   showModal({ | ||||
|     title: Locale.Export.Title, | ||||
|     children: ( | ||||
|       <div className="markdown-body"> | ||||
|         <pre className={styles["export-content"]}>{mdText}</pre> | ||||
|       </div> | ||||
|     ), | ||||
|     actions: [ | ||||
|       <IconButton | ||||
|         key="copy" | ||||
|         icon={<CopyIcon />} | ||||
|         bordered | ||||
|         text={Locale.Export.Copy} | ||||
|         onClick={() => copyToClipboard(mdText)} | ||||
|       />, | ||||
|       <IconButton | ||||
|         key="download" | ||||
|         icon={<DownloadIcon />} | ||||
|         bordered | ||||
|         text={Locale.Export.Download} | ||||
|         onClick={() => downloadAs(mdText, filename)} | ||||
|       />, | ||||
|     ], | ||||
|   }); | ||||
| } | ||||
|  | ||||
| function PromptToast(props: { | ||||
|   showToast?: boolean; | ||||
|   showModal?: boolean; | ||||
|   setShowModal: (_: boolean) => void; | ||||
| }) { | ||||
|   const chatStore = useChatStore(); | ||||
|   const session = chatStore.currentSession(); | ||||
|   const context = session.context; | ||||
|  | ||||
|   const addContextPrompt = (prompt: Message) => { | ||||
|     chatStore.updateCurrentSession((session) => { | ||||
|       session.context.push(prompt); | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   const removeContextPrompt = (i: number) => { | ||||
|     chatStore.updateCurrentSession((session) => { | ||||
|       session.context.splice(i, 1); | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   const updateContextPrompt = (i: number, prompt: Message) => { | ||||
|     chatStore.updateCurrentSession((session) => { | ||||
|       session.context[i] = prompt; | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <div className={chatStyle["prompt-toast"]} key="prompt-toast"> | ||||
|       {props.showToast && ( | ||||
|         <div | ||||
|           className={chatStyle["prompt-toast-inner"] + " clickable"} | ||||
|           role="button" | ||||
|           onClick={() => props.setShowModal(true)} | ||||
|         > | ||||
|           <BrainIcon /> | ||||
|           <span className={chatStyle["prompt-toast-content"]}> | ||||
|             {Locale.Context.Toast(context.length)} | ||||
|           </span> | ||||
|         </div> | ||||
|       )} | ||||
|       {props.showModal && ( | ||||
|         <div className="modal-mask"> | ||||
|           <Modal | ||||
|             title={Locale.Context.Edit} | ||||
|             onClose={() => props.setShowModal(false)} | ||||
|             actions={[ | ||||
|               <IconButton | ||||
|                 key="copy" | ||||
|                 icon={<CopyIcon />} | ||||
|                 bordered | ||||
|                 text={Locale.Memory.Copy} | ||||
|                 onClick={() => copyToClipboard(session.memoryPrompt)} | ||||
|               />, | ||||
|             ]} | ||||
|           > | ||||
|             <> | ||||
|               {" "} | ||||
|               <div className={chatStyle["context-prompt"]}> | ||||
|                 {context.map((c, i) => ( | ||||
|                   <div className={chatStyle["context-prompt-row"]} key={i}> | ||||
|                     <select | ||||
|                       value={c.role} | ||||
|                       className={chatStyle["context-role"]} | ||||
|                       onChange={(e) => | ||||
|                         updateContextPrompt(i, { | ||||
|                           ...c, | ||||
|                           role: e.target.value as any, | ||||
|                         }) | ||||
|                       } | ||||
|                     > | ||||
|                       {ROLES.map((r) => ( | ||||
|                         <option key={r} value={r}> | ||||
|                           {r} | ||||
|                         </option> | ||||
|                       ))} | ||||
|                     </select> | ||||
|                     <input | ||||
|                       value={c.content} | ||||
|                       type="text" | ||||
|                       className={chatStyle["context-content"]} | ||||
|                       onChange={(e) => | ||||
|                         updateContextPrompt(i, { | ||||
|                           ...c, | ||||
|                           content: e.target.value as any, | ||||
|                         }) | ||||
|                       } | ||||
|                     ></input> | ||||
|                     <IconButton | ||||
|                       icon={<DeleteIcon />} | ||||
|                       className={chatStyle["context-delete-button"]} | ||||
|                       onClick={() => removeContextPrompt(i)} | ||||
|                       bordered | ||||
|                     /> | ||||
|                   </div> | ||||
|                 ))} | ||||
|  | ||||
|                 <div className={chatStyle["context-prompt-row"]}> | ||||
|                   <IconButton | ||||
|                     icon={<AddIcon />} | ||||
|                     text={Locale.Context.Add} | ||||
|                     bordered | ||||
|                     className={chatStyle["context-prompt-button"]} | ||||
|                     onClick={() => | ||||
|                       addContextPrompt({ | ||||
|                         role: "system", | ||||
|                         content: "", | ||||
|                         date: "", | ||||
|                       }) | ||||
|                     } | ||||
|                   /> | ||||
|                 </div> | ||||
|               </div> | ||||
|               <div className={chatStyle["memory-prompt"]}> | ||||
|                 <div className={chatStyle["memory-prompt-title"]}> | ||||
|                   {Locale.Memory.Title} ({session.lastSummarizeIndex} of{" "} | ||||
|                   {session.messages.length}) | ||||
|                 </div> | ||||
|                 <div className={chatStyle["memory-prompt-content"]}> | ||||
|                   {session.memoryPrompt || Locale.Memory.EmptyContent} | ||||
|                 </div> | ||||
|               </div> | ||||
|             </> | ||||
|           </Modal> | ||||
|         </div> | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function useSubmitHandler() { | ||||
|   const config = useChatStore((state) => state.config); | ||||
|   const submitKey = config.submitKey; | ||||
|  | ||||
|   const shouldSubmit = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { | ||||
|     if (e.key !== "Enter") return false; | ||||
|     if (e.key === "Enter" && e.nativeEvent.isComposing) return false; | ||||
|     return ( | ||||
|       (config.submitKey === SubmitKey.AltEnter && e.altKey) || | ||||
|       (config.submitKey === SubmitKey.CtrlEnter && e.ctrlKey) || | ||||
|       (config.submitKey === SubmitKey.ShiftEnter && e.shiftKey) || | ||||
|       (config.submitKey === SubmitKey.MetaEnter && e.metaKey) || | ||||
|       (config.submitKey === SubmitKey.Enter && | ||||
|         !e.altKey && | ||||
|         !e.ctrlKey && | ||||
|         !e.shiftKey && | ||||
|         !e.metaKey) | ||||
|     ); | ||||
|   }; | ||||
|  | ||||
|   return { | ||||
|     submitKey, | ||||
|     shouldSubmit, | ||||
|   }; | ||||
| } | ||||
|  | ||||
| export function PromptHints(props: { | ||||
|   prompts: Prompt[]; | ||||
|   onPromptSelect: (prompt: Prompt) => void; | ||||
| }) { | ||||
|   if (props.prompts.length === 0) return null; | ||||
|  | ||||
|   return ( | ||||
|     <div className={styles["prompt-hints"]}> | ||||
|       {props.prompts.map((prompt, i) => ( | ||||
|         <div | ||||
|           className={styles["prompt-hint"]} | ||||
|           key={prompt.title + i.toString()} | ||||
|           onClick={() => props.onPromptSelect(prompt)} | ||||
|         > | ||||
|           <div className={styles["hint-title"]}>{prompt.title}</div> | ||||
|           <div className={styles["hint-content"]}>{prompt.content}</div> | ||||
|         </div> | ||||
|       ))} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function useScrollToBottom() { | ||||
|   // for auto-scroll | ||||
|   const scrollRef = useRef<HTMLDivElement>(null); | ||||
|   const [autoScroll, setAutoScroll] = useState(true); | ||||
|  | ||||
|   // auto scroll | ||||
|   useLayoutEffect(() => { | ||||
|     const dom = scrollRef.current; | ||||
|     if (dom && autoScroll) { | ||||
|       setTimeout(() => (dom.scrollTop = dom.scrollHeight), 1); | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   return { | ||||
|     scrollRef, | ||||
|     autoScroll, | ||||
|     setAutoScroll, | ||||
|   }; | ||||
| } | ||||
|  | ||||
| export function Chat(props: { | ||||
|   showSideBar?: () => void; | ||||
|   sideBarShowing?: boolean; | ||||
| }) { | ||||
|   type RenderMessage = Message & { preview?: boolean }; | ||||
|  | ||||
|   const chatStore = useChatStore(); | ||||
|   const [session, sessionIndex] = useChatStore((state) => [ | ||||
|     state.currentSession(), | ||||
|     state.currentSessionIndex, | ||||
|   ]); | ||||
|   const fontSize = useChatStore((state) => state.config.fontSize); | ||||
|  | ||||
|   const inputRef = useRef<HTMLTextAreaElement>(null); | ||||
|   const [userInput, setUserInput] = useState(""); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|   const { submitKey, shouldSubmit } = useSubmitHandler(); | ||||
|   const { scrollRef, setAutoScroll } = useScrollToBottom(); | ||||
|   const [hitBottom, setHitBottom] = useState(false); | ||||
|  | ||||
|   const onChatBodyScroll = (e: HTMLElement) => { | ||||
|     const isTouchBottom = e.scrollTop + e.clientHeight >= e.scrollHeight - 20; | ||||
|     setHitBottom(isTouchBottom); | ||||
|   }; | ||||
|  | ||||
|   // prompt hints | ||||
|   const promptStore = usePromptStore(); | ||||
|   const [promptHints, setPromptHints] = useState<Prompt[]>([]); | ||||
|   const onSearch = useDebouncedCallback( | ||||
|     (text: string) => { | ||||
|       setPromptHints(promptStore.search(text)); | ||||
|     }, | ||||
|     100, | ||||
|     { leading: true, trailing: true }, | ||||
|   ); | ||||
|  | ||||
|   const onPromptSelect = (prompt: Prompt) => { | ||||
|     setUserInput(prompt.content); | ||||
|     setPromptHints([]); | ||||
|     inputRef.current?.focus(); | ||||
|   }; | ||||
|  | ||||
|   const scrollInput = () => { | ||||
|     const dom = inputRef.current; | ||||
|     if (!dom) return; | ||||
|     const paddingBottomNum: number = parseInt( | ||||
|       window.getComputedStyle(dom).paddingBottom, | ||||
|       10, | ||||
|     ); | ||||
|     dom.scrollTop = dom.scrollHeight - dom.offsetHeight + paddingBottomNum; | ||||
|   }; | ||||
|  | ||||
|   // only search prompts when user input is short | ||||
|   const SEARCH_TEXT_LIMIT = 30; | ||||
|   const onInput = (text: string) => { | ||||
|     scrollInput(); | ||||
|     setUserInput(text); | ||||
|     const n = text.trim().length; | ||||
|  | ||||
|     // clear search results | ||||
|     if (n === 0) { | ||||
|       setPromptHints([]); | ||||
|     } else if (!chatStore.config.disablePromptHint && n < SEARCH_TEXT_LIMIT) { | ||||
|       // check if need to trigger auto completion | ||||
|       if (text.startsWith("/")) { | ||||
|         let searchText = text.slice(1); | ||||
|         if (searchText.length === 0) { | ||||
|           searchText = " "; | ||||
|         } | ||||
|         onSearch(searchText); | ||||
|       } | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   // submit user input | ||||
|   const onUserSubmit = () => { | ||||
|     if (userInput.length <= 0) return; | ||||
|     setIsLoading(true); | ||||
|     chatStore.onUserInput(userInput).then(() => setIsLoading(false)); | ||||
|     setUserInput(""); | ||||
|     setPromptHints([]); | ||||
|     inputRef.current?.focus(); | ||||
|   }; | ||||
|  | ||||
|   // stop response | ||||
|   const onUserStop = (messageIndex: number) => { | ||||
|     ControllerPool.stop(sessionIndex, messageIndex); | ||||
|   }; | ||||
|  | ||||
|   // check if should send message | ||||
|   const onInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { | ||||
|     if (shouldSubmit(e)) { | ||||
|       onUserSubmit(); | ||||
|       e.preventDefault(); | ||||
|     } | ||||
|   }; | ||||
|   const onRightClick = (e: any, message: Message) => { | ||||
|     // auto fill user input | ||||
|     if (message.role === "user") { | ||||
|       setUserInput(message.content); | ||||
|     } | ||||
|  | ||||
|     // copy to clipboard | ||||
|     if (selectOrCopy(e.currentTarget, message.content)) { | ||||
|       e.preventDefault(); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const onResend = (botIndex: number) => { | ||||
|     // find last user input message and resend | ||||
|     for (let i = botIndex; i >= 0; i -= 1) { | ||||
|       if (messages[i].role === "user") { | ||||
|         setIsLoading(true); | ||||
|         chatStore | ||||
|           .onUserInput(messages[i].content) | ||||
|           .then(() => setIsLoading(false)); | ||||
|         inputRef.current?.focus(); | ||||
|         return; | ||||
|       } | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const config = useChatStore((state) => state.config); | ||||
|  | ||||
|   const context: RenderMessage[] = session.context.slice(); | ||||
|  | ||||
|   if ( | ||||
|     context.length === 0 && | ||||
|     session.messages.at(0)?.content !== BOT_HELLO.content | ||||
|   ) { | ||||
|     context.push(BOT_HELLO); | ||||
|   } | ||||
|  | ||||
|   // preview messages | ||||
|   const messages = context | ||||
|     .concat(session.messages as RenderMessage[]) | ||||
|     .concat( | ||||
|       isLoading | ||||
|         ? [ | ||||
|             { | ||||
|               role: "assistant", | ||||
|               content: "……", | ||||
|               date: new Date().toLocaleString(), | ||||
|               preview: true, | ||||
|             }, | ||||
|           ] | ||||
|         : [], | ||||
|     ) | ||||
|     .concat( | ||||
|       userInput.length > 0 && config.sendPreviewBubble | ||||
|         ? [ | ||||
|             { | ||||
|               role: "user", | ||||
|               content: userInput, | ||||
|               date: new Date().toLocaleString(), | ||||
|               preview: true, | ||||
|             }, | ||||
|           ] | ||||
|         : [], | ||||
|     ); | ||||
|  | ||||
|   const [showPromptModal, setShowPromptModal] = useState(false); | ||||
|  | ||||
|   // Auto focus | ||||
|   useEffect(() => { | ||||
|     inputRef.current?.focus(); | ||||
|   }, []); | ||||
|  | ||||
|   return ( | ||||
|     <div className={styles.chat} key={session.id}> | ||||
|       <div className={styles["window-header"]}> | ||||
|         <div | ||||
|           className={styles["window-header-title"]} | ||||
|           onClick={props?.showSideBar} | ||||
|         > | ||||
|           <div | ||||
|             className={`${styles["window-header-main-title"]} ${styles["chat-body-title"]}`} | ||||
|             onClick={() => { | ||||
|               const newTopic = prompt(Locale.Chat.Rename, session.topic); | ||||
|               if (newTopic && newTopic !== session.topic) { | ||||
|                 chatStore.updateCurrentSession( | ||||
|                   (session) => (session.topic = newTopic!), | ||||
|                 ); | ||||
|               } | ||||
|             }} | ||||
|           > | ||||
|             {session.topic} | ||||
|           </div> | ||||
|           <div className={styles["window-header-sub-title"]}> | ||||
|             {Locale.Chat.SubTitle(session.messages.length)} | ||||
|           </div> | ||||
|         </div> | ||||
|         <div className={styles["window-actions"]}> | ||||
|           <div className={styles["window-action-button"] + " " + styles.mobile}> | ||||
|             <IconButton | ||||
|               icon={<MenuIcon />} | ||||
|               bordered | ||||
|               title={Locale.Chat.Actions.ChatList} | ||||
|               onClick={props?.showSideBar} | ||||
|             /> | ||||
|           </div> | ||||
|           <div className={styles["window-action-button"]}> | ||||
|             <IconButton | ||||
|               icon={<BrainIcon />} | ||||
|               bordered | ||||
|               title={Locale.Chat.Actions.CompressedHistory} | ||||
|               onClick={() => { | ||||
|                 setShowPromptModal(true); | ||||
|               }} | ||||
|             /> | ||||
|           </div> | ||||
|           <div className={styles["window-action-button"]}> | ||||
|             <IconButton | ||||
|               icon={<ExportIcon />} | ||||
|               bordered | ||||
|               title={Locale.Chat.Actions.Export} | ||||
|               onClick={() => { | ||||
|                 exportMessages(session.messages, session.topic); | ||||
|               }} | ||||
|             /> | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
|         <PromptToast | ||||
|           showToast={!hitBottom} | ||||
|           showModal={showPromptModal} | ||||
|           setShowModal={setShowPromptModal} | ||||
|         /> | ||||
|       </div> | ||||
|  | ||||
|       <div | ||||
|         className={styles["chat-body"]} | ||||
|         ref={scrollRef} | ||||
|         onScroll={(e) => onChatBodyScroll(e.currentTarget)} | ||||
|         onMouseOver={() => inputRef.current?.blur()} | ||||
|         onTouchStart={() => inputRef.current?.blur()} | ||||
|       > | ||||
|         {messages.map((message, i) => { | ||||
|           const isUser = message.role === "user"; | ||||
|  | ||||
|           return ( | ||||
|             <div | ||||
|               key={i} | ||||
|               className={ | ||||
|                 isUser ? styles["chat-message-user"] : styles["chat-message"] | ||||
|               } | ||||
|             > | ||||
|               <div className={styles["chat-message-container"]}> | ||||
|                 <div className={styles["chat-message-avatar"]}> | ||||
|                   <Avatar role={message.role} /> | ||||
|                 </div> | ||||
|                 {(message.preview || message.streaming) && ( | ||||
|                   <div className={styles["chat-message-status"]}> | ||||
|                     {Locale.Chat.Typing} | ||||
|                   </div> | ||||
|                 )} | ||||
|                 <div className={styles["chat-message-item"]}> | ||||
|                   {!isUser && | ||||
|                     !(message.preview || message.content.length === 0) && ( | ||||
|                       <div className={styles["chat-message-top-actions"]}> | ||||
|                         {message.streaming ? ( | ||||
|                           <div | ||||
|                             className={styles["chat-message-top-action"]} | ||||
|                             onClick={() => onUserStop(i)} | ||||
|                           > | ||||
|                             {Locale.Chat.Actions.Stop} | ||||
|                           </div> | ||||
|                         ) : ( | ||||
|                           <div | ||||
|                             className={styles["chat-message-top-action"]} | ||||
|                             onClick={() => onResend(i)} | ||||
|                           > | ||||
|                             {Locale.Chat.Actions.Retry} | ||||
|                           </div> | ||||
|                         )} | ||||
|  | ||||
|                         <div | ||||
|                           className={styles["chat-message-top-action"]} | ||||
|                           onClick={() => copyToClipboard(message.content)} | ||||
|                         > | ||||
|                           {Locale.Chat.Actions.Copy} | ||||
|                         </div> | ||||
|                       </div> | ||||
|                     )} | ||||
|                   {(message.preview || message.content.length === 0) && | ||||
|                   !isUser ? ( | ||||
|                     <LoadingIcon /> | ||||
|                   ) : ( | ||||
|                     <div | ||||
|                       className="markdown-body" | ||||
|                       style={{ fontSize: `${fontSize}px` }} | ||||
|                       onContextMenu={(e) => onRightClick(e, message)} | ||||
|                       onDoubleClickCapture={() => { | ||||
|                         if (!isMobileScreen()) return; | ||||
|                         setUserInput(message.content); | ||||
|                       }} | ||||
|                     > | ||||
|                       <Markdown content={message.content} /> | ||||
|                     </div> | ||||
|                   )} | ||||
|                 </div> | ||||
|                 {!isUser && !message.preview && ( | ||||
|                   <div className={styles["chat-message-actions"]}> | ||||
|                     <div className={styles["chat-message-action-date"]}> | ||||
|                       {message.date.toLocaleString()} | ||||
|                     </div> | ||||
|                   </div> | ||||
|                 )} | ||||
|               </div> | ||||
|             </div> | ||||
|           ); | ||||
|         })} | ||||
|       </div> | ||||
|  | ||||
|       <div className={styles["chat-input-panel"]}> | ||||
|         <PromptHints prompts={promptHints} onPromptSelect={onPromptSelect} /> | ||||
|         <div className={styles["chat-input-panel-inner"]}> | ||||
|           <textarea | ||||
|             ref={inputRef} | ||||
|             className={styles["chat-input"]} | ||||
|             placeholder={Locale.Chat.Input(submitKey)} | ||||
|             rows={2} | ||||
|             onInput={(e) => onInput(e.currentTarget.value)} | ||||
|             value={userInput} | ||||
|             onKeyDown={onInputKeyDown} | ||||
|             onFocus={() => setAutoScroll(true)} | ||||
|             onBlur={() => { | ||||
|               setAutoScroll(false); | ||||
|               setTimeout(() => setPromptHints([]), 500); | ||||
|             }} | ||||
|             autoFocus={!props?.sideBarShowing} | ||||
|           /> | ||||
|           <IconButton | ||||
|             icon={<SendWhiteIcon />} | ||||
|             text={Locale.Chat.Send} | ||||
|             className={styles["chat-input-send"]} | ||||
|             noDark | ||||
|             onClick={onUserSubmit} | ||||
|           /> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
| @@ -1,4 +1,5 @@ | ||||
| @import "./window.scss"; | ||||
| @import "../styles/animation.scss"; | ||||
|  | ||||
| @mixin container { | ||||
|   background-color: var(--white); | ||||
| @@ -26,13 +27,13 @@ | ||||
| @media only screen and (min-width: 600px) { | ||||
|   .tight-container { | ||||
|     --window-width: 100vw; | ||||
|     --window-height: 100vh; | ||||
|     --window-height: var(--full-height); | ||||
|     --window-content-width: calc(100% - var(--sidebar-width)); | ||||
|  | ||||
|     @include container(); | ||||
|  | ||||
|     max-width: 100vw; | ||||
|     max-height: 100vh; | ||||
|     max-height: var(--full-height); | ||||
|  | ||||
|     border-radius: 0; | ||||
|   } | ||||
| @@ -73,8 +74,8 @@ | ||||
|   .sidebar { | ||||
|     position: absolute; | ||||
|     left: -100%; | ||||
|     z-index: 999; | ||||
|     height: 100vh; | ||||
|     z-index: 1000; | ||||
|     height: var(--full-height); | ||||
|     transition: all ease 0.3s; | ||||
|     box-shadow: none; | ||||
|   } | ||||
| @@ -132,18 +133,6 @@ | ||||
|   overflow: hidden; | ||||
| } | ||||
|  | ||||
| @keyframes slide-in { | ||||
|   from { | ||||
|     opacity: 0; | ||||
|     transform: translateY(20px); | ||||
|   } | ||||
|  | ||||
|   to { | ||||
|     opacity: 1; | ||||
|     transform: translateY(0px); | ||||
|   } | ||||
| } | ||||
|  | ||||
| .chat-item:hover { | ||||
|   background-color: var(--hover-color); | ||||
| } | ||||
| @@ -218,7 +207,15 @@ | ||||
|   flex: 1; | ||||
|   overflow: auto; | ||||
|   padding: 20px; | ||||
|   margin-bottom: 100px; | ||||
|   position: relative; | ||||
| } | ||||
|  | ||||
| .chat-body-title { | ||||
|   cursor: pointer; | ||||
|  | ||||
|   &:hover { | ||||
|     text-decoration: underline; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .chat-message { | ||||
| @@ -292,6 +289,7 @@ | ||||
|   position: absolute; | ||||
|   right: 20px; | ||||
|   top: -26px; | ||||
|   left: 100px; | ||||
|   transition: all ease 0.3s; | ||||
|   opacity: 0; | ||||
|   pointer-events: none; | ||||
| @@ -302,6 +300,7 @@ | ||||
|   .chat-message-top-action { | ||||
|     opacity: 0.5; | ||||
|     color: var(--black); | ||||
|     white-space: nowrap; | ||||
|     cursor: pointer; | ||||
|  | ||||
|     &:hover { | ||||
| @@ -332,12 +331,64 @@ | ||||
| } | ||||
|  | ||||
| .chat-input-panel { | ||||
|   position: absolute; | ||||
|   bottom: 20px; | ||||
|   display: flex; | ||||
|   width: 100%; | ||||
|   padding: 20px; | ||||
|   padding-top: 5px; | ||||
|   box-sizing: border-box; | ||||
|   flex-direction: column; | ||||
| } | ||||
|  | ||||
| @mixin single-line { | ||||
|   white-space: nowrap; | ||||
|   overflow: hidden; | ||||
|   text-overflow: ellipsis; | ||||
| } | ||||
|  | ||||
| .prompt-hints { | ||||
|   min-height: 20px; | ||||
|   width: 100%; | ||||
|   max-height: 50vh; | ||||
|   overflow: auto; | ||||
|   display: flex; | ||||
|   flex-direction: column-reverse; | ||||
|  | ||||
|   background-color: var(--white); | ||||
|   border: var(--border-in-light); | ||||
|   border-radius: 10px; | ||||
|   margin-bottom: 10px; | ||||
|   box-shadow: var(--shadow); | ||||
|  | ||||
|   .prompt-hint { | ||||
|     color: var(--black); | ||||
|     padding: 6px 10px; | ||||
|     animation: slide-in ease 0.3s; | ||||
|     cursor: pointer; | ||||
|     transition: all ease 0.3s; | ||||
|     border: transparent 1px solid; | ||||
|     margin: 4px; | ||||
|     border-radius: 8px; | ||||
|  | ||||
|     &:not(:last-child) { | ||||
|       margin-top: 0; | ||||
|     } | ||||
|  | ||||
|     .hint-title { | ||||
|       font-size: 12px; | ||||
|       font-weight: bolder; | ||||
|  | ||||
|       @include single-line(); | ||||
|     } | ||||
|     .hint-content { | ||||
|       font-size: 12px; | ||||
|  | ||||
|       @include single-line(); | ||||
|     } | ||||
|  | ||||
|     &-selected, | ||||
|     &:hover { | ||||
|       border-color: var(--primary); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| .chat-input-panel-inner { | ||||
| @@ -354,7 +405,7 @@ | ||||
|   background-color: var(--white); | ||||
|   color: var(--black); | ||||
|   font-family: inherit; | ||||
|   padding: 10px 14px; | ||||
|   padding: 10px 14px 50px; | ||||
|   resize: none; | ||||
|   outline: none; | ||||
| } | ||||
| @@ -375,7 +426,7 @@ | ||||
|  | ||||
|   position: absolute; | ||||
|   right: 30px; | ||||
|   bottom: 10px; | ||||
|   bottom: 30px; | ||||
| } | ||||
|  | ||||
| .export-content { | ||||
|   | ||||
| @@ -8,26 +8,31 @@ import styles from "./home.module.scss"; | ||||
| import SettingsIcon from "../icons/settings.svg"; | ||||
| import GithubIcon from "../icons/github.svg"; | ||||
| import ChatGptIcon from "../icons/chatgpt.svg"; | ||||
| import SendWhiteIcon from "../icons/send-white.svg"; | ||||
| import BrainIcon from "../icons/brain.svg"; | ||||
| import ExportIcon from "../icons/export.svg"; | ||||
|  | ||||
| import BotIcon from "../icons/bot.svg"; | ||||
| import AddIcon from "../icons/add.svg"; | ||||
| import DeleteIcon from "../icons/delete.svg"; | ||||
| import LoadingIcon from "../icons/three-dots.svg"; | ||||
| import MenuIcon from "../icons/menu.svg"; | ||||
| import CloseIcon from "../icons/close.svg"; | ||||
| import CopyIcon from "../icons/copy.svg"; | ||||
| import DownloadIcon from "../icons/download.svg"; | ||||
|  | ||||
| import { Message, SubmitKey, useChatStore, ChatSession } from "../store"; | ||||
| import { showModal, showToast } from "./ui-lib"; | ||||
| import { copyToClipboard, downloadAs, isIOS, selectOrCopy } from "../utils"; | ||||
| import { | ||||
|   Message, | ||||
|   SubmitKey, | ||||
|   useChatStore, | ||||
|   ChatSession, | ||||
|   BOT_HELLO, | ||||
| } from "../store"; | ||||
| import { | ||||
|   copyToClipboard, | ||||
|   downloadAs, | ||||
|   isMobileScreen, | ||||
|   selectOrCopy, | ||||
| } from "../utils"; | ||||
| import Locale from "../locales"; | ||||
| import { ChatList } from "./chat-list"; | ||||
| import { Chat } from "./chat"; | ||||
|  | ||||
| import dynamic from "next/dynamic"; | ||||
| import { REPO_URL } from "../constant"; | ||||
| import { ControllerPool } from "../requests"; | ||||
|  | ||||
| export function Loading(props: { noLogo?: boolean }) { | ||||
|   return ( | ||||
| @@ -38,361 +43,10 @@ export function Loading(props: { noLogo?: boolean }) { | ||||
|   ); | ||||
| } | ||||
|  | ||||
| const Markdown = dynamic(async () => (await import("./markdown")).Markdown, { | ||||
|   loading: () => <LoadingIcon />, | ||||
| }); | ||||
|  | ||||
| const Settings = dynamic(async () => (await import("./settings")).Settings, { | ||||
|   loading: () => <Loading noLogo />, | ||||
| }); | ||||
|  | ||||
| const Emoji = dynamic(async () => (await import("emoji-picker-react")).Emoji, { | ||||
|   loading: () => <LoadingIcon />, | ||||
| }); | ||||
|  | ||||
| export function Avatar(props: { role: Message["role"] }) { | ||||
|   const config = useChatStore((state) => state.config); | ||||
|  | ||||
|   if (props.role === "assistant") { | ||||
|     return <BotIcon className={styles["user-avtar"]} />; | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <div className={styles["user-avtar"]}> | ||||
|       <Emoji unified={config.avatar} size={18} /> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export function ChatItem(props: { | ||||
|   onClick?: () => void; | ||||
|   onDelete?: () => void; | ||||
|   title: string; | ||||
|   count: number; | ||||
|   time: string; | ||||
|   selected: boolean; | ||||
| }) { | ||||
|   return ( | ||||
|     <div | ||||
|       className={`${styles["chat-item"]} ${ | ||||
|         props.selected && styles["chat-item-selected"] | ||||
|       }`} | ||||
|       onClick={props.onClick} | ||||
|     > | ||||
|       <div className={styles["chat-item-title"]}>{props.title}</div> | ||||
|       <div className={styles["chat-item-info"]}> | ||||
|         <div className={styles["chat-item-count"]}> | ||||
|           {Locale.ChatItem.ChatItemCount(props.count)} | ||||
|         </div> | ||||
|         <div className={styles["chat-item-date"]}>{props.time}</div> | ||||
|       </div> | ||||
|       <div className={styles["chat-item-delete"]} onClick={props.onDelete}> | ||||
|         <DeleteIcon /> | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export function ChatList() { | ||||
|   const [sessions, selectedIndex, selectSession, removeSession] = useChatStore( | ||||
|     (state) => [ | ||||
|       state.sessions, | ||||
|       state.currentSessionIndex, | ||||
|       state.selectSession, | ||||
|       state.removeSession, | ||||
|     ] | ||||
|   ); | ||||
|  | ||||
|   return ( | ||||
|     <div className={styles["chat-list"]}> | ||||
|       {sessions.map((item, i) => ( | ||||
|         <ChatItem | ||||
|           title={item.topic} | ||||
|           time={item.lastUpdate} | ||||
|           count={item.messages.length} | ||||
|           key={i} | ||||
|           selected={i === selectedIndex} | ||||
|           onClick={() => selectSession(i)} | ||||
|           onDelete={() => removeSession(i)} | ||||
|         /> | ||||
|       ))} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function useSubmitHandler() { | ||||
|   const config = useChatStore((state) => state.config); | ||||
|   const submitKey = config.submitKey; | ||||
|  | ||||
|   const shouldSubmit = (e: KeyboardEvent) => { | ||||
|     if (e.key !== "Enter") return false; | ||||
|  | ||||
|     return ( | ||||
|       (config.submitKey === SubmitKey.AltEnter && e.altKey) || | ||||
|       (config.submitKey === SubmitKey.CtrlEnter && e.ctrlKey) || | ||||
|       (config.submitKey === SubmitKey.ShiftEnter && e.shiftKey) || | ||||
|       (config.submitKey === SubmitKey.Enter && | ||||
|         !e.altKey && | ||||
|         !e.ctrlKey && | ||||
|         !e.shiftKey) | ||||
|     ); | ||||
|   }; | ||||
|  | ||||
|   return { | ||||
|     submitKey, | ||||
|     shouldSubmit, | ||||
|   }; | ||||
| } | ||||
|  | ||||
| export function Chat(props: { showSideBar?: () => void }) { | ||||
|   type RenderMessage = Message & { preview?: boolean }; | ||||
|  | ||||
|   const [session, sessionIndex] = useChatStore((state) => [ | ||||
|     state.currentSession(), | ||||
|     state.currentSessionIndex, | ||||
|   ]); | ||||
|   const [userInput, setUserInput] = useState(""); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|   const { submitKey, shouldSubmit } = useSubmitHandler(); | ||||
|  | ||||
|   const onUserInput = useChatStore((state) => state.onUserInput); | ||||
|  | ||||
|   // submit user input | ||||
|   const onUserSubmit = () => { | ||||
|     if (userInput.length <= 0) return; | ||||
|     setIsLoading(true); | ||||
|     onUserInput(userInput).then(() => setIsLoading(false)); | ||||
|     setUserInput(""); | ||||
|   }; | ||||
|  | ||||
|   // stop response | ||||
|   const onUserStop = (messageIndex: number) => { | ||||
|     console.log(ControllerPool, sessionIndex, messageIndex); | ||||
|     ControllerPool.stop(sessionIndex, messageIndex); | ||||
|   }; | ||||
|  | ||||
|   // check if should send message | ||||
|   const onInputKeyDown = (e: KeyboardEvent) => { | ||||
|     if (shouldSubmit(e)) { | ||||
|       onUserSubmit(); | ||||
|       e.preventDefault(); | ||||
|     } | ||||
|   }; | ||||
|   const onRightClick = (e: any, message: Message) => { | ||||
|     // auto fill user input | ||||
|     if (message.role === "user") { | ||||
|       setUserInput(message.content); | ||||
|     } | ||||
|  | ||||
|     // copy to clipboard | ||||
|     if (selectOrCopy(e.currentTarget, message.content)) { | ||||
|       e.preventDefault(); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const onResend = (botIndex: number) => { | ||||
|     // find last user input message and resend | ||||
|     for (let i = botIndex; i >= 0; i -= 1) { | ||||
|       if (messages[i].role === "user") { | ||||
|         setIsLoading(true); | ||||
|         onUserInput(messages[i].content).then(() => setIsLoading(false)); | ||||
|         return; | ||||
|       } | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   // for auto-scroll | ||||
|   const latestMessageRef = useRef<HTMLDivElement>(null); | ||||
|  | ||||
|   // wont scroll while hovering messages | ||||
|   const [autoScroll, setAutoScroll] = useState(false); | ||||
|  | ||||
|   // preview messages | ||||
|   const messages = (session.messages as RenderMessage[]) | ||||
|     .concat( | ||||
|       isLoading | ||||
|         ? [ | ||||
|             { | ||||
|               role: "assistant", | ||||
|               content: "……", | ||||
|               date: new Date().toLocaleString(), | ||||
|               preview: true, | ||||
|             }, | ||||
|           ] | ||||
|         : [] | ||||
|     ) | ||||
|     .concat( | ||||
|       userInput.length > 0 | ||||
|         ? [ | ||||
|             { | ||||
|               role: "user", | ||||
|               content: userInput, | ||||
|               date: new Date().toLocaleString(), | ||||
|               preview: true, | ||||
|             }, | ||||
|           ] | ||||
|         : [] | ||||
|     ); | ||||
|  | ||||
|   // auto scroll | ||||
|   useLayoutEffect(() => { | ||||
|     setTimeout(() => { | ||||
|       const dom = latestMessageRef.current; | ||||
|       if (dom && !isIOS() && autoScroll) { | ||||
|         dom.scrollIntoView({ | ||||
|           behavior: "smooth", | ||||
|           block: "end", | ||||
|         }); | ||||
|       } | ||||
|     }, 500); | ||||
|   }); | ||||
|  | ||||
|   return ( | ||||
|     <div className={styles.chat} key={session.id}> | ||||
|       <div className={styles["window-header"]}> | ||||
|         <div | ||||
|           className={styles["window-header-title"]} | ||||
|           onClick={props?.showSideBar} | ||||
|         > | ||||
|           <div className={styles["window-header-main-title"]}> | ||||
|             {session.topic} | ||||
|           </div> | ||||
|           <div className={styles["window-header-sub-title"]}> | ||||
|             {Locale.Chat.SubTitle(session.messages.length)} | ||||
|           </div> | ||||
|         </div> | ||||
|         <div className={styles["window-actions"]}> | ||||
|           <div className={styles["window-action-button"] + " " + styles.mobile}> | ||||
|             <IconButton | ||||
|               icon={<MenuIcon />} | ||||
|               bordered | ||||
|               title={Locale.Chat.Actions.ChatList} | ||||
|               onClick={props?.showSideBar} | ||||
|             /> | ||||
|           </div> | ||||
|           <div className={styles["window-action-button"]}> | ||||
|             <IconButton | ||||
|               icon={<BrainIcon />} | ||||
|               bordered | ||||
|               title={Locale.Chat.Actions.CompressedHistory} | ||||
|               onClick={() => { | ||||
|                 showMemoryPrompt(session); | ||||
|               }} | ||||
|             /> | ||||
|           </div> | ||||
|           <div className={styles["window-action-button"]}> | ||||
|             <IconButton | ||||
|               icon={<ExportIcon />} | ||||
|               bordered | ||||
|               title={Locale.Chat.Actions.Export} | ||||
|               onClick={() => { | ||||
|                 exportMessages(session.messages, session.topic); | ||||
|               }} | ||||
|             /> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       <div className={styles["chat-body"]}> | ||||
|         {messages.map((message, i) => { | ||||
|           const isUser = message.role === "user"; | ||||
|  | ||||
|           return ( | ||||
|             <div | ||||
|               key={i} | ||||
|               className={ | ||||
|                 isUser ? styles["chat-message-user"] : styles["chat-message"] | ||||
|               } | ||||
|             > | ||||
|               <div className={styles["chat-message-container"]}> | ||||
|                 <div className={styles["chat-message-avatar"]}> | ||||
|                   <Avatar role={message.role} /> | ||||
|                 </div> | ||||
|                 {(message.preview || message.streaming) && ( | ||||
|                   <div className={styles["chat-message-status"]}> | ||||
|                     {Locale.Chat.Typing} | ||||
|                   </div> | ||||
|                 )} | ||||
|                 <div className={styles["chat-message-item"]}> | ||||
|                   {!isUser && ( | ||||
|                     <div className={styles["chat-message-top-actions"]}> | ||||
|                       {message.streaming ? ( | ||||
|                         <div | ||||
|                           className={styles["chat-message-top-action"]} | ||||
|                           onClick={() => onUserStop(i)} | ||||
|                         > | ||||
|                           {Locale.Chat.Actions.Stop} | ||||
|                         </div> | ||||
|                       ) : ( | ||||
|                         <div | ||||
|                           className={styles["chat-message-top-action"]} | ||||
|                           onClick={() => onResend(i)} | ||||
|                         > | ||||
|                           {Locale.Chat.Actions.Retry} | ||||
|                         </div> | ||||
|                       )} | ||||
|  | ||||
|                       <div | ||||
|                         className={styles["chat-message-top-action"]} | ||||
|                         onClick={() => copyToClipboard(message.content)} | ||||
|                       > | ||||
|                         {Locale.Chat.Actions.Copy} | ||||
|                       </div> | ||||
|                     </div> | ||||
|                   )} | ||||
|                   {(message.preview || message.content.length === 0) && | ||||
|                   !isUser ? ( | ||||
|                     <LoadingIcon /> | ||||
|                   ) : ( | ||||
|                     <div | ||||
|                       className="markdown-body" | ||||
|                       onContextMenu={(e) => onRightClick(e, message)} | ||||
|                     > | ||||
|                       <Markdown content={message.content} /> | ||||
|                     </div> | ||||
|                   )} | ||||
|                 </div> | ||||
|                 {!isUser && !message.preview && ( | ||||
|                   <div className={styles["chat-message-actions"]}> | ||||
|                     <div className={styles["chat-message-action-date"]}> | ||||
|                       {message.date.toLocaleString()} | ||||
|                     </div> | ||||
|                   </div> | ||||
|                 )} | ||||
|               </div> | ||||
|             </div> | ||||
|           ); | ||||
|         })} | ||||
|         <div ref={latestMessageRef} style={{ opacity: 0, height: "2em" }}> | ||||
|           - | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       <div className={styles["chat-input-panel"]}> | ||||
|         <div className={styles["chat-input-panel-inner"]}> | ||||
|           <textarea | ||||
|             className={styles["chat-input"]} | ||||
|             placeholder={Locale.Chat.Input(submitKey)} | ||||
|             rows={3} | ||||
|             onInput={(e) => setUserInput(e.currentTarget.value)} | ||||
|             value={userInput} | ||||
|             onKeyDown={(e) => onInputKeyDown(e as any)} | ||||
|             onFocus={() => setAutoScroll(true)} | ||||
|             onBlur={() => setAutoScroll(false)} | ||||
|             autoFocus | ||||
|           /> | ||||
|           <IconButton | ||||
|             icon={<SendWhiteIcon />} | ||||
|             text={Locale.Chat.Send} | ||||
|             className={styles["chat-input-send"] + " no-dark"} | ||||
|             onClick={onUserSubmit} | ||||
|           /> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function useSwitchTheme() { | ||||
|   const config = useChatStore((state) => state.config); | ||||
|  | ||||
| @@ -406,70 +60,14 @@ function useSwitchTheme() { | ||||
|       document.body.classList.add("light"); | ||||
|     } | ||||
|  | ||||
|     const themeColor = getComputedStyle(document.body).getPropertyValue("--theme-color").trim(); | ||||
|     const themeColor = getComputedStyle(document.body) | ||||
|       .getPropertyValue("--theme-color") | ||||
|       .trim(); | ||||
|     const metaDescription = document.querySelector('meta[name="theme-color"]'); | ||||
|     metaDescription?.setAttribute('content', themeColor); | ||||
|     metaDescription?.setAttribute("content", themeColor); | ||||
|   }, [config.theme]); | ||||
| } | ||||
|  | ||||
| function exportMessages(messages: Message[], topic: string) { | ||||
|   const mdText = | ||||
|     `# ${topic}\n\n` + | ||||
|     messages | ||||
|       .map((m) => { | ||||
|         return m.role === "user" ? `## ${m.content}` : m.content.trim(); | ||||
|       }) | ||||
|       .join("\n\n"); | ||||
|   const filename = `${topic}.md`; | ||||
|  | ||||
|   showModal({ | ||||
|     title: Locale.Export.Title, | ||||
|     children: ( | ||||
|       <div className="markdown-body"> | ||||
|         <pre className={styles["export-content"]}>{mdText}</pre> | ||||
|       </div> | ||||
|     ), | ||||
|     actions: [ | ||||
|       <IconButton | ||||
|         key="copy" | ||||
|         icon={<CopyIcon />} | ||||
|         bordered | ||||
|         text={Locale.Export.Copy} | ||||
|         onClick={() => copyToClipboard(mdText)} | ||||
|       />, | ||||
|       <IconButton | ||||
|         key="download" | ||||
|         icon={<DownloadIcon />} | ||||
|         bordered | ||||
|         text={Locale.Export.Download} | ||||
|         onClick={() => downloadAs(mdText, filename)} | ||||
|       />, | ||||
|     ], | ||||
|   }); | ||||
| } | ||||
|  | ||||
| function showMemoryPrompt(session: ChatSession) { | ||||
|   showModal({ | ||||
|     title: `${Locale.Memory.Title} (${session.lastSummarizeIndex} of ${session.messages.length})`, | ||||
|     children: ( | ||||
|       <div className="markdown-body"> | ||||
|         <pre className={styles["export-content"]}> | ||||
|           {session.memoryPrompt || Locale.Memory.EmptyContent} | ||||
|         </pre> | ||||
|       </div> | ||||
|     ), | ||||
|     actions: [ | ||||
|       <IconButton | ||||
|         key="copy" | ||||
|         icon={<CopyIcon />} | ||||
|         bordered | ||||
|         text={Locale.Memory.Copy} | ||||
|         onClick={() => copyToClipboard(session.memoryPrompt)} | ||||
|       />, | ||||
|     ], | ||||
|   }); | ||||
| } | ||||
|  | ||||
| const useHasHydrated = () => { | ||||
|   const [hasHydrated, setHasHydrated] = useState<boolean>(false); | ||||
|  | ||||
| @@ -486,7 +84,7 @@ export function Home() { | ||||
|       state.newSession, | ||||
|       state.currentSessionIndex, | ||||
|       state.removeSession, | ||||
|     ] | ||||
|     ], | ||||
|   ); | ||||
|   const loading = !useHasHydrated(); | ||||
|   const [showSideBar, setShowSideBar] = useState(true); | ||||
| @@ -504,7 +102,9 @@ export function Home() { | ||||
|   return ( | ||||
|     <div | ||||
|       className={`${ | ||||
|         config.tightBorder ? styles["tight-container"] : styles.container | ||||
|         config.tightBorder && !isMobileScreen() | ||||
|           ? styles["tight-container"] | ||||
|           : styles.container | ||||
|       }`} | ||||
|     > | ||||
|       <div | ||||
| @@ -549,11 +149,12 @@ export function Home() { | ||||
|                   setOpenSettings(true); | ||||
|                   setShowSideBar(false); | ||||
|                 }} | ||||
|                 shadow | ||||
|               /> | ||||
|             </div> | ||||
|             <div className={styles["sidebar-action"]}> | ||||
|               <a href={REPO_URL} target="_blank"> | ||||
|                 <IconButton icon={<GithubIcon />} /> | ||||
|                 <IconButton icon={<GithubIcon />} shadow /> | ||||
|               </a> | ||||
|             </div> | ||||
|           </div> | ||||
| @@ -561,7 +162,11 @@ export function Home() { | ||||
|             <IconButton | ||||
|               icon={<AddIcon />} | ||||
|               text={Locale.Home.NewChat} | ||||
|               onClick={createNewSession} | ||||
|               onClick={() => { | ||||
|                 createNewSession(); | ||||
|                 setShowSideBar(false); | ||||
|               }} | ||||
|               shadow | ||||
|             /> | ||||
|           </div> | ||||
|         </div> | ||||
| @@ -576,7 +181,11 @@ export function Home() { | ||||
|             }} | ||||
|           /> | ||||
|         ) : ( | ||||
|           <Chat key="chat" showSideBar={() => setShowSideBar(true)} /> | ||||
|           <Chat | ||||
|             key="chat" | ||||
|             showSideBar={() => setShowSideBar(true)} | ||||
|             sideBarShowing={showSideBar} | ||||
|           /> | ||||
|         )} | ||||
|       </div> | ||||
|     </div> | ||||
|   | ||||
| @@ -1,10 +1,11 @@ | ||||
| import ReactMarkdown from "react-markdown"; | ||||
| import "katex/dist/katex.min.css"; | ||||
| import RemarkMath from "remark-math"; | ||||
| import RemarkBreaks from "remark-breaks"; | ||||
| import RehypeKatex from "rehype-katex"; | ||||
| import RemarkGfm from "remark-gfm"; | ||||
| import RehypePrsim from "rehype-prism-plus"; | ||||
| import { useRef } from "react"; | ||||
| import RehypeHighlight from "rehype-highlight"; | ||||
| import { useRef, useState, RefObject, useEffect } from "react"; | ||||
| import { copyToClipboard } from "../utils"; | ||||
|  | ||||
| export function PreCode(props: { children: any }) { | ||||
| @@ -26,11 +27,43 @@ export function PreCode(props: { children: any }) { | ||||
|   ); | ||||
| } | ||||
|  | ||||
| const useLazyLoad = (ref: RefObject<Element>): boolean => { | ||||
|   const [isIntersecting, setIntersecting] = useState<boolean>(false); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const observer = new IntersectionObserver(([entry]) => { | ||||
|       if (entry.isIntersecting) { | ||||
|         setIntersecting(true); | ||||
|         observer.disconnect(); | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     if (ref.current) { | ||||
|       observer.observe(ref.current); | ||||
|     } | ||||
|  | ||||
|     return () => { | ||||
|       observer.disconnect(); | ||||
|     }; | ||||
|   }, [ref]); | ||||
|  | ||||
|   return isIntersecting; | ||||
| }; | ||||
|  | ||||
| export function Markdown(props: { content: string }) { | ||||
|   return ( | ||||
|     <ReactMarkdown | ||||
|       remarkPlugins={[RemarkMath, RemarkGfm]} | ||||
|       rehypePlugins={[RehypeKatex, [RehypePrsim, { ignoreMissing: true }]]} | ||||
|       remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]} | ||||
|       rehypePlugins={[ | ||||
|         RehypeKatex, | ||||
|         [ | ||||
|           RehypeHighlight, | ||||
|           { | ||||
|             detect: false, | ||||
|             ignoreMissing: true, | ||||
|           }, | ||||
|         ], | ||||
|       ]} | ||||
|       components={{ | ||||
|         pre: PreCode, | ||||
|       }} | ||||
|   | ||||
| @@ -7,8 +7,9 @@ import styles from "./settings.module.scss"; | ||||
| import ResetIcon from "../icons/reload.svg"; | ||||
| import CloseIcon from "../icons/close.svg"; | ||||
| import ClearIcon from "../icons/clear.svg"; | ||||
| import EditIcon from "../icons/edit.svg"; | ||||
|  | ||||
| import { List, ListItem, Popover } from "./ui-lib"; | ||||
| import { List, ListItem, Popover, showToast } from "./ui-lib"; | ||||
|  | ||||
| import { IconButton } from "./button"; | ||||
| import { | ||||
| @@ -19,12 +20,14 @@ import { | ||||
|   useUpdateStore, | ||||
|   useAccessStore, | ||||
| } from "../store"; | ||||
| import { Avatar } from "./home"; | ||||
| import { Avatar } from "./chat"; | ||||
|  | ||||
| import Locale, { changeLang, getLang } from "../locales"; | ||||
| import { getCurrentCommitId } from "../utils"; | ||||
| import Locale, { AllLangs, changeLang, getLang } from "../locales"; | ||||
| import { getCurrentVersion } from "../utils"; | ||||
| import Link from "next/link"; | ||||
| import { UPDATE_URL } from "../constant"; | ||||
| import { SearchService, usePromptStore } from "../store/prompt"; | ||||
| import { requestUsage } from "../requests"; | ||||
|  | ||||
| function SettingItem(props: { | ||||
|   title: string; | ||||
| @@ -46,18 +49,18 @@ function SettingItem(props: { | ||||
|  | ||||
| export function Settings(props: { closeSettings: () => void }) { | ||||
|   const [showEmojiPicker, setShowEmojiPicker] = useState(false); | ||||
|   const [config, updateConfig, resetConfig, clearAllData] = useChatStore( | ||||
|     (state) => [ | ||||
|   const [config, updateConfig, resetConfig, clearAllData, clearSessions] = | ||||
|     useChatStore((state) => [ | ||||
|       state.config, | ||||
|       state.updateConfig, | ||||
|       state.resetConfig, | ||||
|       state.clearAllData, | ||||
|     ] | ||||
|   ); | ||||
|       state.clearSessions, | ||||
|     ]); | ||||
|  | ||||
|   const updateStore = useUpdateStore(); | ||||
|   const [checkingUpdate, setCheckingUpdate] = useState(false); | ||||
|   const currentId = getCurrentCommitId(); | ||||
|   const currentId = getCurrentVersion(); | ||||
|   const remoteId = updateStore.remoteId; | ||||
|   const hasNewVersion = currentId !== remoteId; | ||||
|  | ||||
| @@ -68,16 +71,38 @@ export function Settings(props: { closeSettings: () => void }) { | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   const [usage, setUsage] = useState<{ | ||||
|     used?: number; | ||||
|   }>(); | ||||
|   const [loadingUsage, setLoadingUsage] = useState(false); | ||||
|   function checkUsage() { | ||||
|     setLoadingUsage(true); | ||||
|     requestUsage() | ||||
|       .then((res) => | ||||
|         setUsage({ | ||||
|           used: res, | ||||
|         }), | ||||
|       ) | ||||
|       .finally(() => { | ||||
|         setLoadingUsage(false); | ||||
|       }); | ||||
|   } | ||||
|  | ||||
|   useEffect(() => { | ||||
|     checkUpdate(); | ||||
|     checkUsage(); | ||||
|   }, []); | ||||
|  | ||||
|   const accessStore = useAccessStore(); | ||||
|   const enabledAccessControl = useMemo( | ||||
|     () => accessStore.enabledAccessControl(), | ||||
|     [] | ||||
|     [], | ||||
|   ); | ||||
|  | ||||
|   const promptStore = usePromptStore(); | ||||
|   const builtinCount = SearchService.count.builtin; | ||||
|   const customCount = promptStore.prompts.size ?? 0; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <div className={styles["window-header"]}> | ||||
| @@ -93,7 +118,7 @@ export function Settings(props: { closeSettings: () => void }) { | ||||
|           <div className={styles["window-action-button"]}> | ||||
|             <IconButton | ||||
|               icon={<ClearIcon />} | ||||
|               onClick={clearAllData} | ||||
|               onClick={clearSessions} | ||||
|               bordered | ||||
|               title={Locale.Settings.Actions.ClearAll} | ||||
|             /> | ||||
| @@ -173,7 +198,7 @@ export function Settings(props: { closeSettings: () => void }) { | ||||
|               onChange={(e) => { | ||||
|                 updateConfig( | ||||
|                   (config) => | ||||
|                     (config.submitKey = e.target.value as any as SubmitKey) | ||||
|                     (config.submitKey = e.target.value as any as SubmitKey), | ||||
|                 ); | ||||
|               }} | ||||
|             > | ||||
| @@ -193,7 +218,7 @@ export function Settings(props: { closeSettings: () => void }) { | ||||
|               value={config.theme} | ||||
|               onChange={(e) => { | ||||
|                 updateConfig( | ||||
|                   (config) => (config.theme = e.target.value as any as Theme) | ||||
|                   (config) => (config.theme = e.target.value as any as Theme), | ||||
|                 ); | ||||
|               }} | ||||
|             > | ||||
| @@ -206,37 +231,95 @@ export function Settings(props: { closeSettings: () => void }) { | ||||
|           </ListItem> | ||||
|  | ||||
|           <SettingItem title={Locale.Settings.Lang.Name}> | ||||
|             <div className=""> | ||||
|             <select | ||||
|               value={getLang()} | ||||
|               onChange={(e) => { | ||||
|                 changeLang(e.target.value as any); | ||||
|               }} | ||||
|             > | ||||
|                 <option value="en" key="en"> | ||||
|                   {Locale.Settings.Lang.Options.en} | ||||
|                 </option> | ||||
|  | ||||
|                 <option value="cn" key="cn"> | ||||
|                   {Locale.Settings.Lang.Options.cn} | ||||
|               {AllLangs.map((lang) => ( | ||||
|                 <option value={lang} key={lang}> | ||||
|                   {Locale.Settings.Lang.Options[lang]} | ||||
|                 </option> | ||||
|               ))} | ||||
|             </select> | ||||
|             </div> | ||||
|           </SettingItem> | ||||
|  | ||||
|           <div className="no-mobile"> | ||||
|           <SettingItem | ||||
|             title={Locale.Settings.FontSize.Title} | ||||
|             subTitle={Locale.Settings.FontSize.SubTitle} | ||||
|           > | ||||
|             <input | ||||
|               type="range" | ||||
|               title={`${config.fontSize ?? 14}px`} | ||||
|               value={config.fontSize} | ||||
|               min="12" | ||||
|               max="18" | ||||
|               step="1" | ||||
|               onChange={(e) => | ||||
|                 updateConfig( | ||||
|                   (config) => | ||||
|                     (config.fontSize = Number.parseInt(e.currentTarget.value)), | ||||
|                 ) | ||||
|               } | ||||
|             ></input> | ||||
|           </SettingItem> | ||||
|  | ||||
|           <SettingItem title={Locale.Settings.TightBorder}> | ||||
|             <input | ||||
|               type="checkbox" | ||||
|               checked={config.tightBorder} | ||||
|               onChange={(e) => | ||||
|                 updateConfig( | ||||
|                     (config) => (config.tightBorder = e.currentTarget.checked) | ||||
|                   (config) => (config.tightBorder = e.currentTarget.checked), | ||||
|                 ) | ||||
|               } | ||||
|             ></input> | ||||
|           </SettingItem> | ||||
|           </div> | ||||
|  | ||||
|           <SettingItem title={Locale.Settings.SendPreviewBubble}> | ||||
|             <input | ||||
|               type="checkbox" | ||||
|               checked={config.sendPreviewBubble} | ||||
|               onChange={(e) => | ||||
|                 updateConfig( | ||||
|                   (config) => | ||||
|                     (config.sendPreviewBubble = e.currentTarget.checked), | ||||
|                 ) | ||||
|               } | ||||
|             ></input> | ||||
|           </SettingItem> | ||||
|         </List> | ||||
|         <List> | ||||
|           <SettingItem | ||||
|             title={Locale.Settings.Prompt.Disable.Title} | ||||
|             subTitle={Locale.Settings.Prompt.Disable.SubTitle} | ||||
|           > | ||||
|             <input | ||||
|               type="checkbox" | ||||
|               checked={config.disablePromptHint} | ||||
|               onChange={(e) => | ||||
|                 updateConfig( | ||||
|                   (config) => | ||||
|                     (config.disablePromptHint = e.currentTarget.checked), | ||||
|                 ) | ||||
|               } | ||||
|             ></input> | ||||
|           </SettingItem> | ||||
|  | ||||
|           <SettingItem | ||||
|             title={Locale.Settings.Prompt.List} | ||||
|             subTitle={Locale.Settings.Prompt.ListCount( | ||||
|               builtinCount, | ||||
|               customCount, | ||||
|             )} | ||||
|           > | ||||
|             <IconButton | ||||
|               icon={<EditIcon />} | ||||
|               text={Locale.Settings.Prompt.Edit} | ||||
|               onClick={() => showToast(Locale.WIP)} | ||||
|             /> | ||||
|           </SettingItem> | ||||
|         </List> | ||||
|         <List> | ||||
|           {enabledAccessControl ? ( | ||||
| @@ -271,6 +354,25 @@ export function Settings(props: { closeSettings: () => void }) { | ||||
|             ></input> | ||||
|           </SettingItem> | ||||
|  | ||||
|           <SettingItem | ||||
|             title={Locale.Settings.Usage.Title} | ||||
|             subTitle={ | ||||
|               loadingUsage | ||||
|                 ? Locale.Settings.Usage.IsChecking | ||||
|                 : Locale.Settings.Usage.SubTitle(usage?.used ?? "[?]") | ||||
|             } | ||||
|           > | ||||
|             {loadingUsage ? ( | ||||
|               <div /> | ||||
|             ) : ( | ||||
|               <IconButton | ||||
|                 icon={<ResetIcon></ResetIcon>} | ||||
|                 text={Locale.Settings.Usage.Check} | ||||
|                 onClick={checkUsage} | ||||
|               /> | ||||
|             )} | ||||
|           </SettingItem> | ||||
|  | ||||
|           <SettingItem | ||||
|             title={Locale.Settings.HistoryCount.Title} | ||||
|             subTitle={Locale.Settings.HistoryCount.SubTitle} | ||||
| @@ -279,13 +381,13 @@ export function Settings(props: { closeSettings: () => void }) { | ||||
|               type="range" | ||||
|               title={config.historyMessageCount.toString()} | ||||
|               value={config.historyMessageCount} | ||||
|               min="2" | ||||
|               min="0" | ||||
|               max="25" | ||||
|               step="2" | ||||
|               onChange={(e) => | ||||
|                 updateConfig( | ||||
|                   (config) => | ||||
|                     (config.historyMessageCount = e.target.valueAsNumber) | ||||
|                     (config.historyMessageCount = e.target.valueAsNumber), | ||||
|                 ) | ||||
|               } | ||||
|             ></input> | ||||
| @@ -304,7 +406,7 @@ export function Settings(props: { closeSettings: () => void }) { | ||||
|                 updateConfig( | ||||
|                   (config) => | ||||
|                     (config.compressMessageLengthThreshold = | ||||
|                       e.currentTarget.valueAsNumber) | ||||
|                       e.currentTarget.valueAsNumber), | ||||
|                 ) | ||||
|               } | ||||
|             ></input> | ||||
| @@ -317,7 +419,8 @@ export function Settings(props: { closeSettings: () => void }) { | ||||
|               value={config.modelConfig.model} | ||||
|               onChange={(e) => { | ||||
|                 updateConfig( | ||||
|                   (config) => (config.modelConfig.model = e.currentTarget.value) | ||||
|                   (config) => | ||||
|                     (config.modelConfig.model = e.currentTarget.value), | ||||
|                 ); | ||||
|               }} | ||||
|             > | ||||
| @@ -336,13 +439,13 @@ export function Settings(props: { closeSettings: () => void }) { | ||||
|               type="range" | ||||
|               value={config.modelConfig.temperature.toFixed(1)} | ||||
|               min="0" | ||||
|               max="1" | ||||
|               max="2" | ||||
|               step="0.1" | ||||
|               onChange={(e) => { | ||||
|                 updateConfig( | ||||
|                   (config) => | ||||
|                     (config.modelConfig.temperature = | ||||
|                       e.currentTarget.valueAsNumber) | ||||
|                       e.currentTarget.valueAsNumber), | ||||
|                 ); | ||||
|               }} | ||||
|             ></input> | ||||
| @@ -360,7 +463,7 @@ export function Settings(props: { closeSettings: () => void }) { | ||||
|                 updateConfig( | ||||
|                   (config) => | ||||
|                     (config.modelConfig.max_tokens = | ||||
|                       e.currentTarget.valueAsNumber) | ||||
|                       e.currentTarget.valueAsNumber), | ||||
|                 ) | ||||
|               } | ||||
|             ></input> | ||||
| @@ -379,7 +482,7 @@ export function Settings(props: { closeSettings: () => void }) { | ||||
|                 updateConfig( | ||||
|                   (config) => | ||||
|                     (config.modelConfig.presence_penalty = | ||||
|                       e.currentTarget.valueAsNumber) | ||||
|                       e.currentTarget.valueAsNumber), | ||||
|                 ); | ||||
|               }} | ||||
|             ></input> | ||||
|   | ||||
| @@ -1,3 +1,5 @@ | ||||
| @import "../styles/animation.scss"; | ||||
|  | ||||
| .card { | ||||
|   background-color: var(--white); | ||||
|   border-radius: 10px; | ||||
| @@ -24,18 +26,6 @@ | ||||
|   height: 100vh; | ||||
| } | ||||
|  | ||||
| @keyframes slide-in { | ||||
|   from { | ||||
|     transform: translateY(10px); | ||||
|     opacity: 0; | ||||
|   } | ||||
|  | ||||
|   to { | ||||
|     transform: translateY(0); | ||||
|     opacity: 1; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .list-item { | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
| @@ -138,6 +128,8 @@ | ||||
|   justify-content: center; | ||||
|  | ||||
|   .toast-content { | ||||
|     max-width: 80vw; | ||||
|     word-break: break-all; | ||||
|     font-size: 14px; | ||||
|     background-color: var(--white); | ||||
|     box-shadow: var(--card-shadow); | ||||
|   | ||||
| @@ -36,7 +36,7 @@ export function ListItem(props: { children: JSX.Element[] }) { | ||||
|   return <div className={styles["list-item"]}>{props.children}</div>; | ||||
| } | ||||
|  | ||||
| export function List(props: { children: JSX.Element[] }) { | ||||
| export function List(props: { children: JSX.Element[] | JSX.Element }) { | ||||
|   return <div className={styles.list}>{props.children}</div>; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| .window-header { | ||||
|   padding: 14px 20px; | ||||
|   border-bottom: rgba(0, 0, 0, 0.1) 1px solid; | ||||
|   position: relative; | ||||
|  | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   | ||||
| @@ -3,3 +3,4 @@ export const REPO = "ChatGPT-Next-Web"; | ||||
| export const REPO_URL = `https://github.com/${OWNER}/${REPO}`; | ||||
| export const UPDATE_URL = `${REPO_URL}#%E4%BF%9D%E6%8C%81%E6%9B%B4%E6%96%B0-keep-updated`; | ||||
| export const FETCH_COMMIT_URL = `https://api.github.com/repos/${OWNER}/${REPO}/commits?per_page=1`; | ||||
| export const FETCH_TAG_URL = `https://api.github.com/repos/${OWNER}/${REPO}/tags?per_page=1`; | ||||
|   | ||||
							
								
								
									
										1
									
								
								app/icons/edit.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								app/icons/edit.svg
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs><g opacity="1" transform="translate(0 0)  rotate(0 8 8)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path  id="路径 1" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(10.5 11)  rotate(0 1.4166666666666665 1.8333333333333333)" d="M2.83,0L2.83,3C2.83,3.37 2.53,3.67 2.17,3.67L0,3.67 " /><path  id="路径 2" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(2.6666666666666665 1.3333333333333333)  rotate(0 5.333333333333333 6.666666666666666)" d="M10.67,4L10.67,0.67C10.67,0.3 10.37,0 10,0L0.67,0C0.3,0 0,0.3 0,0.67L0,12.67C0,13.03 0.3,13.33 0.67,13.33L2.67,13.33 " /><path  id="路径 3" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(5.333333333333333 5.333333333333333)  rotate(0 2.333333333333333 0)" d="M0,0L4.67,0 " /><path  id="路径 4" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(7.666666666666666 7.666666666666666)  rotate(0 2.833333333333333 3.5)" d="M0,7L5.67,0 " /><path  id="路径 5" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(5.333333333333333 8)  rotate(0 1.3333333333333333 0)" d="M0,0L2.67,0 " /></g></g></svg> | ||||
| After Width: | Height: | Size: 1.6 KiB | 
| @@ -1,18 +1,19 @@ | ||||
| /* eslint-disable @next/next/no-page-custom-font */ | ||||
| import "./styles/globals.scss"; | ||||
| import "./styles/markdown.scss"; | ||||
| import "./styles/prism.scss"; | ||||
| import "./styles/highlight.scss"; | ||||
| import process from "child_process"; | ||||
| import { ACCESS_CODES } from "./api/access"; | ||||
| import { ACCESS_CODES, IS_IN_DOCKER } from "./api/access"; | ||||
|  | ||||
| let COMMIT_ID: string | undefined; | ||||
| try { | ||||
|   COMMIT_ID = process | ||||
|     // .execSync("git describe --tags --abbrev=0") | ||||
|     .execSync("git rev-parse --short HEAD") | ||||
|     .toString() | ||||
|     .trim(); | ||||
| } catch (e) { | ||||
|   console.error("No git or not from git repo.") | ||||
|   console.error("No git or not from git repo."); | ||||
| } | ||||
|  | ||||
| export const metadata = { | ||||
| @@ -22,13 +23,13 @@ export const metadata = { | ||||
|     title: "ChatGPT Next Web", | ||||
|     statusBarStyle: "black-translucent", | ||||
|   }, | ||||
|   themeColor: "#fafafa" | ||||
|   themeColor: "#fafafa", | ||||
| }; | ||||
|  | ||||
| function Meta() { | ||||
|   const metas = { | ||||
|     version: COMMIT_ID ?? "unknown", | ||||
|     access: ACCESS_CODES.size > 0 ? "enabled" : "disabled", | ||||
|     access: ACCESS_CODES.size > 0 || IS_IN_DOCKER ? "enabled" : "disabled", | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|   | ||||
| @@ -1,3 +1,5 @@ | ||||
| import { SubmitKey } from "../store/app"; | ||||
|  | ||||
| const cn = { | ||||
|   WIP: "该功能仍在开发中……", | ||||
|   Error: { | ||||
| @@ -16,8 +18,15 @@ const cn = { | ||||
|       Stop: "停止", | ||||
|       Retry: "重试", | ||||
|     }, | ||||
|     Rename: "重命名对话", | ||||
|     Typing: "正在输入…", | ||||
|     Input: (submitKey: string) => `输入消息,${submitKey} 发送`, | ||||
|     Input: (submitKey: string) => { | ||||
|       var inputHints = `输入消息,${submitKey} 发送`; | ||||
|       if (submitKey === String(SubmitKey.Enter)) { | ||||
|         inputHints += ",Shift + Enter 换行"; | ||||
|       } | ||||
|       return inputHints; | ||||
|     }, | ||||
|     Send: "发送", | ||||
|   }, | ||||
|   Export: { | ||||
| @@ -26,7 +35,7 @@ const cn = { | ||||
|     Download: "下载文件", | ||||
|   }, | ||||
|   Memory: { | ||||
|     Title: "上下文记忆 Prompt", | ||||
|     Title: "历史记忆", | ||||
|     EmptyContent: "尚未记忆", | ||||
|     Copy: "全部复制", | ||||
|   }, | ||||
| @@ -45,11 +54,18 @@ const cn = { | ||||
|     Lang: { | ||||
|       Name: "Language", | ||||
|       Options: { | ||||
|         cn: "中文", | ||||
|         cn: "简体中文", | ||||
|         en: "English", | ||||
|         tw: "繁體中文", | ||||
|         es: "Español", | ||||
|       }, | ||||
|     }, | ||||
|     Avatar: "头像", | ||||
|     FontSize: { | ||||
|       Title: "字体大小", | ||||
|       SubTitle: "聊天内容的字体大小", | ||||
|     }, | ||||
|  | ||||
|     Update: { | ||||
|       Version: (x: string) => `当前版本:${x}`, | ||||
|       IsLatest: "已是最新版本", | ||||
| @@ -61,6 +77,17 @@ const cn = { | ||||
|     SendKey: "发送键", | ||||
|     Theme: "主题", | ||||
|     TightBorder: "紧凑边框", | ||||
|     SendPreviewBubble: "发送预览气泡", | ||||
|     Prompt: { | ||||
|       Disable: { | ||||
|         Title: "禁用提示词自动补全", | ||||
|         SubTitle: "在输入框开头输入 / 即可触发自动补全", | ||||
|       }, | ||||
|       List: "自定义提示词列表", | ||||
|       ListCount: (builtin: number, custom: number) => | ||||
|         `内置 ${builtin} 条,用户定义 ${custom} 条`, | ||||
|       Edit: "编辑", | ||||
|     }, | ||||
|     HistoryCount: { | ||||
|       Title: "附带历史消息数", | ||||
|       SubTitle: "每次请求携带的历史消息数", | ||||
| @@ -74,6 +101,14 @@ const cn = { | ||||
|       SubTitle: "使用自己的 Key 可绕过受控访问限制", | ||||
|       Placeholder: "OpenAI API Key", | ||||
|     }, | ||||
|     Usage: { | ||||
|       Title: "账户余额", | ||||
|       SubTitle(used: any) { | ||||
|         return `本月已使用 $${used}`; | ||||
|       }, | ||||
|       IsChecking: "正在检查…", | ||||
|       Check: "重新检查", | ||||
|     }, | ||||
|     AccessCode: { | ||||
|       Title: "访问码", | ||||
|       SubTitle: "现在是受控访问状态", | ||||
| @@ -101,9 +136,9 @@ const cn = { | ||||
|       History: (content: string) => | ||||
|         "这是 ai 和用户的历史聊天总结作为前情提要:" + content, | ||||
|       Topic: | ||||
|         "直接返回这句话的简要主题,不要解释,如果没有主题,请直接返回“闲聊”", | ||||
|         "使用四到五个字直接返回这句话的简要主题,不要解释、不要标点、不要语气词、不要多余文本,如果没有主题,请直接返回“闲聊”", | ||||
|       Summarize: | ||||
|         "简要总结一下你和用户的对话,用作后续的上下文提示 prompt,控制在 50 字以内", | ||||
|         "简要总结一下你和用户的对话,用作后续的上下文提示 prompt,控制在 200 字以内", | ||||
|     }, | ||||
|     ConfirmClearAll: "确认清除所有聊天、设置数据?", | ||||
|   }, | ||||
| @@ -111,6 +146,11 @@ const cn = { | ||||
|     Success: "已写入剪切板", | ||||
|     Failed: "复制失败,请赋予剪切板权限", | ||||
|   }, | ||||
|   Context: { | ||||
|     Toast: (x: any) => `已设置 ${x} 条前置上下文`, | ||||
|     Edit: "前置上下文和历史记忆", | ||||
|     Add: "新增一条", | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| export type LocaleType = typeof cn; | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| import { SubmitKey } from "../store/app"; | ||||
| import type { LocaleType } from "./index"; | ||||
|  | ||||
| const en: LocaleType = { | ||||
| @@ -19,9 +20,15 @@ const en: LocaleType = { | ||||
|       Stop: "Stop", | ||||
|       Retry: "Retry", | ||||
|     }, | ||||
|     Rename: "Rename Chat", | ||||
|     Typing: "Typing…", | ||||
|     Input: (submitKey: string) => | ||||
|       `Type something and press ${submitKey} to send`, | ||||
|     Input: (submitKey: string) => { | ||||
|       var inputHints = `Type something and press ${submitKey} to send`; | ||||
|       if (submitKey === String(SubmitKey.Enter)) { | ||||
|         inputHints += ", press Shift + Enter to newline"; | ||||
|       } | ||||
|       return inputHints; | ||||
|     }, | ||||
|     Send: "Send", | ||||
|   }, | ||||
|   Export: { | ||||
| @@ -47,13 +54,19 @@ const en: LocaleType = { | ||||
|       Close: "Close", | ||||
|     }, | ||||
|     Lang: { | ||||
|       Name: "语言", | ||||
|       Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language` | ||||
|       Options: { | ||||
|         cn: "中文", | ||||
|         cn: "简体中文", | ||||
|         en: "English", | ||||
|         tw: "繁體中文", | ||||
|         es: "Español", | ||||
|       }, | ||||
|     }, | ||||
|     Avatar: "Avatar", | ||||
|     FontSize: { | ||||
|       Title: "Font Size", | ||||
|       SubTitle: "Adjust font size of chat content", | ||||
|     }, | ||||
|     Update: { | ||||
|       Version: (x: string) => `Version: ${x}`, | ||||
|       IsLatest: "Latest version", | ||||
| @@ -65,6 +78,17 @@ const en: LocaleType = { | ||||
|     SendKey: "Send Key", | ||||
|     Theme: "Theme", | ||||
|     TightBorder: "Tight Border", | ||||
|     SendPreviewBubble: "Send Preview Bubble", | ||||
|     Prompt: { | ||||
|       Disable: { | ||||
|         Title: "Disable auto-completion", | ||||
|         SubTitle: "Input / to trigger auto-completion", | ||||
|       }, | ||||
|       List: "Prompt List", | ||||
|       ListCount: (builtin: number, custom: number) => | ||||
|         `${builtin} built-in, ${custom} user-defined`, | ||||
|       Edit: "Edit", | ||||
|     }, | ||||
|     HistoryCount: { | ||||
|       Title: "Attached Messages Count", | ||||
|       SubTitle: "Number of sent messages attached per request", | ||||
| @@ -79,6 +103,14 @@ const en: LocaleType = { | ||||
|       SubTitle: "Use your key to ignore access code limit", | ||||
|       Placeholder: "OpenAI API Key", | ||||
|     }, | ||||
|     Usage: { | ||||
|       Title: "Account Balance", | ||||
|       SubTitle(used: any) { | ||||
|         return `Used this month $${used}`; | ||||
|       }, | ||||
|       IsChecking: "Checking...", | ||||
|       Check: "Check Again", | ||||
|     }, | ||||
|     AccessCode: { | ||||
|       Title: "Access Code", | ||||
|       SubTitle: "Access control enabled", | ||||
| @@ -108,9 +140,9 @@ const en: LocaleType = { | ||||
|         "This is a summary of the chat history between the AI and the user as a recap: " + | ||||
|         content, | ||||
|       Topic: | ||||
|         "Provide a brief topic of the sentence without explanation. If there is no topic, return 'Chitchat'.", | ||||
|         "Please generate a four to five word title summarizing our conversation without any lead-in, punctuation, quotation marks, periods, symbols, or additional text. Remove enclosing quotation marks.", | ||||
|       Summarize: | ||||
|         "Summarize our discussion briefly in 50 characters or less to use as a prompt for future context.", | ||||
|         "Summarize our discussion briefly in 200 words or less to use as a prompt for future context.", | ||||
|     }, | ||||
|     ConfirmClearAll: "Confirm to clear all chat and setting data?", | ||||
|   }, | ||||
| @@ -118,6 +150,11 @@ const en: LocaleType = { | ||||
|     Success: "Copied to clipboard", | ||||
|     Failed: "Copy failed, please grant permission to access clipboard", | ||||
|   }, | ||||
|   Context: { | ||||
|     Toast: (x: any) => `With ${x} contextual prompts`, | ||||
|     Edit: "Contextual and Memory Prompts", | ||||
|     Add: "Add One", | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| export default en; | ||||
|   | ||||
							
								
								
									
										162
									
								
								app/locales/es.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										162
									
								
								app/locales/es.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,162 @@ | ||||
| import { SubmitKey } from "../store/app"; | ||||
| import type { LocaleType } from "./index"; | ||||
|  | ||||
| const es: LocaleType = { | ||||
|   WIP: "En construcción...", | ||||
|   Error: { | ||||
|     Unauthorized: | ||||
|       "Acceso no autorizado, por favor ingrese el código de acceso en la página de configuración.", | ||||
|   }, | ||||
|   ChatItem: { | ||||
|     ChatItemCount: (count: number) => `${count} mensajes`, | ||||
|   }, | ||||
|   Chat: { | ||||
|     SubTitle: (count: number) => `${count} mensajes con ChatGPT`, | ||||
|     Actions: { | ||||
|       ChatList: "Ir a la lista de chats", | ||||
|       CompressedHistory: "Historial de memoria comprimido", | ||||
|       Export: "Exportar todos los mensajes como Markdown", | ||||
|       Copy: "Copiar", | ||||
|       Stop: "Detener", | ||||
|       Retry: "Reintentar", | ||||
|     }, | ||||
|     Rename: "Renombrar chat", | ||||
|     Typing: "Escribiendo...", | ||||
|     Input: (submitKey: string) => { | ||||
|       var inputHints = `Escribe algo y presiona ${submitKey} para enviar`; | ||||
|       if (submitKey === String(SubmitKey.Enter)) { | ||||
|         inputHints += ", presiona Shift + Enter para nueva línea"; | ||||
|       } | ||||
|       return inputHints; | ||||
|     }, | ||||
|     Send: "Enviar", | ||||
|   }, | ||||
|   Export: { | ||||
|     Title: "Todos los mensajes", | ||||
|     Copy: "Copiar todo", | ||||
|     Download: "Descargar", | ||||
|   }, | ||||
|   Memory: { | ||||
|     Title: "Historial de memoria", | ||||
|     EmptyContent: "Aún no hay nada.", | ||||
|     Copy: "Copiar todo", | ||||
|   }, | ||||
|   Home: { | ||||
|     NewChat: "Nuevo chat", | ||||
|     DeleteChat: "¿Confirmar eliminación de la conversación seleccionada?", | ||||
|   }, | ||||
|   Settings: { | ||||
|     Title: "Configuración", | ||||
|     SubTitle: "Todas las configuraciones", | ||||
|     Actions: { | ||||
|       ClearAll: "Borrar todos los datos", | ||||
|       ResetAll: "Restablecer todas las configuraciones", | ||||
|       Close: "Cerrar", | ||||
|     }, | ||||
|     Lang: { | ||||
|       Name: "Language", | ||||
|       Options: { | ||||
|         cn: "简体中文", | ||||
|         en: "Inglés", | ||||
|         tw: "繁體中文", | ||||
|         es: "Español", | ||||
|       }, | ||||
|     }, | ||||
|     Avatar: "Avatar", | ||||
|     FontSize: { | ||||
|       Title: "Tamaño de fuente", | ||||
|       SubTitle: "Ajustar el tamaño de fuente del contenido del chat", | ||||
|     }, | ||||
|     Update: { | ||||
|       Version: (x: string) => `Versión: ${x}`, | ||||
|       IsLatest: "Última versión", | ||||
|       CheckUpdate: "Buscar actualizaciones", | ||||
|       IsChecking: "Buscando actualizaciones...", | ||||
|       FoundUpdate: (x: string) => `Se encontró una nueva versión: ${x}`, | ||||
|       GoToUpdate: "Actualizar", | ||||
|     }, | ||||
|     SendKey: "Tecla de envío", | ||||
|     Theme: "Tema", | ||||
|     TightBorder: "Borde ajustado", | ||||
|     SendPreviewBubble: "Enviar burbuja de vista previa", | ||||
|     Prompt: { | ||||
|       Disable: { | ||||
|         Title: "Desactivar autocompletado", | ||||
|         SubTitle: "Escribe / para activar el autocompletado", | ||||
|       }, | ||||
|       List: "Lista de autocompletado", | ||||
|       ListCount: (builtin: number, custom: number) => | ||||
|         `${builtin} incorporado, ${custom} definido por el usuario`, | ||||
|       Edit: "Editar", | ||||
|     }, | ||||
|     HistoryCount: { | ||||
|       Title: "Cantidad de mensajes adjuntos", | ||||
|       SubTitle: "Número de mensajes enviados adjuntos por solicitud", | ||||
|     }, | ||||
|     CompressThreshold: { | ||||
|       Title: "Umbral de compresión de historial", | ||||
|       SubTitle: | ||||
|         "Se comprimirán los mensajes si la longitud de los mensajes no comprimidos supera el valor", | ||||
|     }, | ||||
|     Token: { | ||||
|       Title: "Clave de API", | ||||
|       SubTitle: "Utiliza tu clave para ignorar el límite de código de acceso", | ||||
|       Placeholder: "Clave de la API de OpenAI", | ||||
|     }, | ||||
|     Usage: { | ||||
|       Title: "Saldo de la cuenta", | ||||
|       SubTitle(used: any) { | ||||
|         return `Usado $${used}`; | ||||
|       }, | ||||
|       IsChecking: "Comprobando...", | ||||
|       Check: "Comprobar de nuevo", | ||||
|     }, | ||||
|     AccessCode: { | ||||
|       Title: "Código de acceso", | ||||
|       SubTitle: "Control de acceso habilitado", | ||||
|       Placeholder: "Necesita código de acceso", | ||||
|     }, | ||||
|     Model: "Modelo", | ||||
|     Temperature: { | ||||
|       Title: "Temperatura", | ||||
|       SubTitle: "Un valor mayor genera una salida más aleatoria", | ||||
|     }, | ||||
|     MaxTokens: { | ||||
|       Title: "Máximo de tokens", | ||||
|       SubTitle: "Longitud máxima de tokens de entrada y tokens generados", | ||||
|     }, | ||||
|     PresencePenlty: { | ||||
|       Title: "Penalización de presencia", | ||||
|       SubTitle: | ||||
|         "Un valor mayor aumenta la probabilidad de hablar sobre nuevos temas", | ||||
|     }, | ||||
|   }, | ||||
|   Store: { | ||||
|     DefaultTopic: "Nueva conversación", | ||||
|     BotHello: "¡Hola! ¿Cómo puedo ayudarte hoy?", | ||||
|     Error: "Algo salió mal, por favor intenta nuevamente más tarde.", | ||||
|     Prompt: { | ||||
|       History: (content: string) => | ||||
|         "Este es un resumen del historial del chat entre la IA y el usuario como recapitulación: " + | ||||
|         content, | ||||
|       Topic: | ||||
|         "Por favor, genera un título de cuatro a cinco palabras que resuma nuestra conversación sin ningún inicio, puntuación, comillas, puntos, símbolos o texto adicional. Elimina las comillas que lo envuelven.", | ||||
|       Summarize: | ||||
|         "Resuma nuestra discusión brevemente en 200 caracteres o menos para usarlo como un recordatorio para futuros contextos.", | ||||
|     }, | ||||
|     ConfirmClearAll: | ||||
|       "¿Confirmar para borrar todos los datos de chat y configuración?", | ||||
|   }, | ||||
|   Copy: { | ||||
|     Success: "Copiado al portapapeles", | ||||
|     Failed: | ||||
|       "La copia falló, por favor concede permiso para acceder al portapapeles", | ||||
|   }, | ||||
|   Context: { | ||||
|     Toast: (x: any) => `With ${x} contextual prompts`, | ||||
|     Edit: "Contextual and Memory Prompts", | ||||
|     Add: "Add One", | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| export default es; | ||||
| @@ -1,53 +1,60 @@ | ||||
| import CN from './cn' | ||||
| import EN from './en' | ||||
| import CN from "./cn"; | ||||
| import EN from "./en"; | ||||
| import TW from "./tw"; | ||||
| import ES from "./es"; | ||||
|  | ||||
| export type { LocaleType } from './cn' | ||||
| export type { LocaleType } from "./cn"; | ||||
|  | ||||
| type Lang = 'en' | 'cn' | ||||
| export const AllLangs = ["cn", "tw", "en", "es"] as const; | ||||
| type Lang = (typeof AllLangs)[number]; | ||||
|  | ||||
| const LANG_KEY = 'lang' | ||||
| const LANG_KEY = "lang"; | ||||
|  | ||||
| function getItem(key: string) { | ||||
|   try { | ||||
|         return localStorage.getItem(key) | ||||
|     return localStorage.getItem(key); | ||||
|   } catch { | ||||
|         return null | ||||
|     return null; | ||||
|   } | ||||
| } | ||||
|  | ||||
| function setItem(key: string, value: string) { | ||||
|   try { | ||||
|         localStorage.setItem(key, value) | ||||
|     } catch { } | ||||
|     localStorage.setItem(key, value); | ||||
|   } catch {} | ||||
| } | ||||
|  | ||||
| function getLanguage() { | ||||
|   try { | ||||
|         return navigator.language.toLowerCase() | ||||
|     return navigator.language.toLowerCase(); | ||||
|   } catch { | ||||
|         return 'cn' | ||||
|     return "cn"; | ||||
|   } | ||||
| } | ||||
|  | ||||
| export function getLang(): Lang { | ||||
|     const savedLang = getItem(LANG_KEY) | ||||
|   const savedLang = getItem(LANG_KEY); | ||||
|  | ||||
|     if (['en', 'cn'].includes(savedLang ?? '')) { | ||||
|         return savedLang as Lang | ||||
|   if (AllLangs.includes((savedLang ?? "") as Lang)) { | ||||
|     return savedLang as Lang; | ||||
|   } | ||||
|  | ||||
|     const lang = getLanguage() | ||||
|   const lang = getLanguage(); | ||||
|  | ||||
|     if (lang.includes('zh') || lang.includes('cn')) { | ||||
|         return 'cn' | ||||
|   if (lang.includes("zh") || lang.includes("cn")) { | ||||
|     return "cn"; | ||||
|   } else if (lang.includes("tw")) { | ||||
|     return "tw"; | ||||
|   } else if (lang.includes("es")) { | ||||
|     return "es"; | ||||
|   } else { | ||||
|         return 'en' | ||||
|     return "en"; | ||||
|   } | ||||
| } | ||||
|  | ||||
| export function changeLang(lang: Lang) { | ||||
|     setItem(LANG_KEY, lang) | ||||
|     location.reload() | ||||
|   setItem(LANG_KEY, lang); | ||||
|   location.reload(); | ||||
| } | ||||
|  | ||||
| export default { en: EN, cn: CN }[getLang()] | ||||
| export default { en: EN, cn: CN, tw: TW, es: ES }[getLang()]; | ||||
|   | ||||
							
								
								
									
										155
									
								
								app/locales/tw.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										155
									
								
								app/locales/tw.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,155 @@ | ||||
| import { SubmitKey } from "../store/app"; | ||||
| import type { LocaleType } from "./index"; | ||||
|  | ||||
| const tw: LocaleType = { | ||||
|   WIP: "該功能仍在開發中……", | ||||
|   Error: { | ||||
|     Unauthorized: "目前您的狀態是未授權,請前往設定頁面填寫授權碼。", | ||||
|   }, | ||||
|   ChatItem: { | ||||
|     ChatItemCount: (count: number) => `${count} 條對話`, | ||||
|   }, | ||||
|   Chat: { | ||||
|     SubTitle: (count: number) => `您已經與 ChatGPT 進行了 ${count} 條對話`, | ||||
|     Actions: { | ||||
|       ChatList: "查看消息列表", | ||||
|       CompressedHistory: "查看壓縮後的歷史 Prompt", | ||||
|       Export: "匯出聊天紀錄", | ||||
|       Copy: "複製", | ||||
|       Stop: "停止", | ||||
|       Retry: "重試", | ||||
|     }, | ||||
|     Rename: "重命名對話", | ||||
|     Typing: "正在輸入…", | ||||
|     Input: (submitKey: string) => { | ||||
|       var inputHints = `輸入訊息後,按下 ${submitKey} 鍵即可發送`; | ||||
|       if (submitKey === String(SubmitKey.Enter)) { | ||||
|         inputHints += ",Shift + Enter 鍵換行"; | ||||
|       } | ||||
|       return inputHints; | ||||
|     }, | ||||
|     Send: "發送", | ||||
|   }, | ||||
|   Export: { | ||||
|     Title: "匯出聊天記錄為 Markdown", | ||||
|     Copy: "複製全部", | ||||
|     Download: "下載檔案", | ||||
|   }, | ||||
|   Memory: { | ||||
|     Title: "上下文記憶 Prompt", | ||||
|     EmptyContent: "尚未記憶", | ||||
|     Copy: "複製全部", | ||||
|   }, | ||||
|   Home: { | ||||
|     NewChat: "新的對話", | ||||
|     DeleteChat: "確定要刪除選取的對話嗎?", | ||||
|   }, | ||||
|   Settings: { | ||||
|     Title: "設定", | ||||
|     SubTitle: "設定選項", | ||||
|     Actions: { | ||||
|       ClearAll: "清除所有數據", | ||||
|       ResetAll: "重置所有設定", | ||||
|       Close: "關閉", | ||||
|     }, | ||||
|     Lang: { | ||||
|       Name: "Language", | ||||
|       Options: { | ||||
|         cn: "简体中文", | ||||
|         en: "English", | ||||
|         tw: "繁體中文", | ||||
|         es: "Español", | ||||
|       }, | ||||
|     }, | ||||
|     Avatar: "大頭貼", | ||||
|     FontSize: { | ||||
|       Title: "字型大小", | ||||
|       SubTitle: "聊天內容的字型大小", | ||||
|     }, | ||||
|     Update: { | ||||
|       Version: (x: string) => `當前版本:${x}`, | ||||
|       IsLatest: "已是最新版本", | ||||
|       CheckUpdate: "檢查更新", | ||||
|       IsChecking: "正在檢查更新...", | ||||
|       FoundUpdate: (x: string) => `發現新版本:${x}`, | ||||
|       GoToUpdate: "前往更新", | ||||
|     }, | ||||
|     SendKey: "發送鍵", | ||||
|     Theme: "主題", | ||||
|     TightBorder: "緊湊邊框", | ||||
|     SendPreviewBubble: "發送預覽氣泡", | ||||
|     Prompt: { | ||||
|       Disable: { | ||||
|         Title: "停用提示詞自動補全", | ||||
|         SubTitle: "在輸入框開頭輸入 / 即可觸發自動補全", | ||||
|       }, | ||||
|       List: "自定義提示詞列表", | ||||
|       ListCount: (builtin: number, custom: number) => | ||||
|         `內置 ${builtin} 條,用戶定義 ${custom} 條`, | ||||
|       Edit: "編輯", | ||||
|     }, | ||||
|     HistoryCount: { | ||||
|       Title: "附帶歷史訊息數", | ||||
|       SubTitle: "每次請求附帶的歷史訊息數", | ||||
|     }, | ||||
|     CompressThreshold: { | ||||
|       Title: "歷史訊息長度壓縮閾值", | ||||
|       SubTitle: "當未壓縮的歷史訊息超過該值時,將進行壓縮", | ||||
|     }, | ||||
|     Token: { | ||||
|       Title: "API Key", | ||||
|       SubTitle: "使用自己的 Key 可規避受控訪問限制", | ||||
|       Placeholder: "OpenAI API Key", | ||||
|     }, | ||||
|     Usage: { | ||||
|       Title: "帳戶餘額", | ||||
|       SubTitle(used: any) { | ||||
|         return `本月已使用 $${used}`; | ||||
|       }, | ||||
|       IsChecking: "正在檢查…", | ||||
|       Check: "重新檢查", | ||||
|     }, | ||||
|     AccessCode: { | ||||
|       Title: "訪問碼", | ||||
|       SubTitle: "現在是受控訪問狀態", | ||||
|       Placeholder: "請輸入訪問碼", | ||||
|     }, | ||||
|     Model: "模型 (model)", | ||||
|     Temperature: { | ||||
|       Title: "隨機性 (temperature)", | ||||
|       SubTitle: "值越大,回復越隨機", | ||||
|     }, | ||||
|     MaxTokens: { | ||||
|       Title: "單次回復限制 (max_tokens)", | ||||
|       SubTitle: "單次交互所用的最大 Token 數", | ||||
|     }, | ||||
|     PresencePenlty: { | ||||
|       Title: "話題新穎度 (presence_penalty)", | ||||
|       SubTitle: "值越大,越有可能擴展到新話題", | ||||
|     }, | ||||
|   }, | ||||
|   Store: { | ||||
|     DefaultTopic: "新的對話", | ||||
|     BotHello: "請問需要我的協助嗎?", | ||||
|     Error: "出錯了,請稍後再嘗試", | ||||
|     Prompt: { | ||||
|       History: (content: string) => | ||||
|         "這是 AI 與用戶的歷史聊天總結,作為前情提要:" + content, | ||||
|       Topic: "直接返回這句話的簡要主題,無須解釋,若無主題,請直接返回「閒聊」", | ||||
|       Summarize: | ||||
|         "簡要總結一下你和用戶的對話,作為後續的上下文提示 prompt,且字數控制在 200 字以內", | ||||
|     }, | ||||
|     ConfirmClearAll: "確認清除所有對話、設定數據?", | ||||
|   }, | ||||
|   Copy: { | ||||
|     Success: "已複製到剪貼簿中", | ||||
|     Failed: "複製失敗,請賦予剪貼簿權限", | ||||
|   }, | ||||
|   Context: { | ||||
|     Toast: (x: any) => `已設置 ${x} 條前置上下文`, | ||||
|     Edit: "前置上下文和歷史記憶", | ||||
|     Add: "新增壹條", | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| export default tw; | ||||
| @@ -1,4 +1,7 @@ | ||||
| import { Analytics } from "@vercel/analytics/react"; | ||||
|  | ||||
| import "array.prototype.at"; | ||||
|  | ||||
| import { Home } from "./components/home"; | ||||
|  | ||||
| export default function App() { | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import type { ChatRequest, ChatReponse } from "./api/chat/typing"; | ||||
| import type { ChatRequest, ChatReponse } from "./api/openai/typing"; | ||||
| import { filterConfig, Message, ModelConfig, useAccessStore } from "./store"; | ||||
| import Locale from "./locales"; | ||||
| import { showToast } from "./components/ui-lib"; | ||||
|  | ||||
| const TIME_OUT_MS = 30000; | ||||
|  | ||||
| @@ -9,7 +10,7 @@ const makeRequestParam = ( | ||||
|   options?: { | ||||
|     filterBot?: boolean; | ||||
|     stream?: boolean; | ||||
|   } | ||||
|   }, | ||||
| ): ChatRequest => { | ||||
|   let sendMessages = messages.map((v) => ({ | ||||
|     role: v.role, | ||||
| @@ -42,19 +43,69 @@ function getHeaders() { | ||||
|   return headers; | ||||
| } | ||||
|  | ||||
| export function requestOpenaiClient(path: string) { | ||||
|   return (body: any, method = "POST") => | ||||
|     fetch("/api/openai", { | ||||
|       method, | ||||
|       headers: { | ||||
|         "Content-Type": "application/json", | ||||
|         "Cache-Control": "no-cache", | ||||
|         path, | ||||
|         ...getHeaders(), | ||||
|       }, | ||||
|       body: body && JSON.stringify(body), | ||||
|     }); | ||||
| } | ||||
|  | ||||
| export async function requestChat(messages: Message[]) { | ||||
|   const req: ChatRequest = makeRequestParam(messages, { filterBot: true }); | ||||
|  | ||||
|   const res = await fetch("/api/chat", { | ||||
|     method: "POST", | ||||
|     headers: { | ||||
|       "Content-Type": "application/json", | ||||
|       ...getHeaders(), | ||||
|     }, | ||||
|     body: JSON.stringify(req), | ||||
|   }); | ||||
|   const res = await requestOpenaiClient("v1/chat/completions")(req); | ||||
|  | ||||
|   return (await res.json()) as ChatReponse; | ||||
|   try { | ||||
|     const response = (await res.json()) as ChatReponse; | ||||
|     return response; | ||||
|   } catch (error) { | ||||
|     console.error("[Request Chat] ", error, res.body); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export async function requestUsage() { | ||||
|   const formatDate = (d: Date) => | ||||
|     `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, "0")}-${d | ||||
|       .getDate() | ||||
|       .toString() | ||||
|       .padStart(2, "0")}`; | ||||
|   const ONE_DAY = 24 * 60 * 60 * 1000; | ||||
|   const now = new Date(Date.now() + ONE_DAY); | ||||
|   const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); | ||||
|   const startDate = formatDate(startOfMonth); | ||||
|   const endDate = formatDate(now); | ||||
|   const res = await requestOpenaiClient( | ||||
|     `dashboard/billing/usage?start_date=${startDate}&end_date=${endDate}`, | ||||
|   )(null, "GET"); | ||||
|  | ||||
|   try { | ||||
|     const response = (await res.json()) as { | ||||
|       total_usage: number; | ||||
|       error?: { | ||||
|         type: string; | ||||
|         message: string; | ||||
|       }; | ||||
|     }; | ||||
|  | ||||
|     if (response.error && response.error.type) { | ||||
|       showToast(response.error.message); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (response.total_usage) { | ||||
|       response.total_usage = Math.round(response.total_usage) / 100; | ||||
|     } | ||||
|     return response.total_usage; | ||||
|   } catch (error) { | ||||
|     console.error("[Request usage] ", error, res.body); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export async function requestChatStream( | ||||
| @@ -65,7 +116,7 @@ export async function requestChatStream( | ||||
|     onMessage: (message: string, done: boolean) => void; | ||||
|     onError: (error: Error) => void; | ||||
|     onController?: (controller: AbortController) => void; | ||||
|   } | ||||
|   }, | ||||
| ) { | ||||
|   const req = makeRequestParam(messages, { | ||||
|     stream: true, | ||||
| @@ -87,6 +138,7 @@ export async function requestChatStream( | ||||
|       method: "POST", | ||||
|       headers: { | ||||
|         "Content-Type": "application/json", | ||||
|         path: "v1/chat/completions", | ||||
|         ...getHeaders(), | ||||
|       }, | ||||
|       body: JSON.stringify(req), | ||||
| @@ -129,7 +181,7 @@ export async function requestChatStream( | ||||
|       responseText = Locale.Error.Unauthorized; | ||||
|       finish(); | ||||
|     } else { | ||||
|       console.error("Stream Error"); | ||||
|       console.error("Stream Error", res.body); | ||||
|       options?.onError(new Error("Stream Error")); | ||||
|     } | ||||
|   } catch (err) { | ||||
| @@ -149,7 +201,7 @@ export async function requestWithPrompt(messages: Message[], prompt: string) { | ||||
|  | ||||
|   const res = await requestChat(messages); | ||||
|  | ||||
|   return res.choices.at(0)?.message?.content ?? ""; | ||||
|   return res?.choices?.at(0)?.message?.content ?? ""; | ||||
| } | ||||
|  | ||||
| // To store message streaming controller | ||||
| @@ -159,7 +211,7 @@ export const ControllerPool = { | ||||
|   addController( | ||||
|     sessionIndex: number, | ||||
|     messageIndex: number, | ||||
|     controller: AbortController | ||||
|     controller: AbortController, | ||||
|   ) { | ||||
|     const key = this.key(sessionIndex, messageIndex); | ||||
|     this.controllers[key] = controller; | ||||
|   | ||||
							
								
								
									
										107
									
								
								app/store/app.ts
									
									
									
									
									
								
							
							
						
						
									
										107
									
								
								app/store/app.ts
									
									
									
									
									
								
							| @@ -21,6 +21,7 @@ export enum SubmitKey { | ||||
|   CtrlEnter = "Ctrl + Enter", | ||||
|   ShiftEnter = "Shift + Enter", | ||||
|   AltEnter = "Alt + Enter", | ||||
|   MetaEnter = "Meta + Enter", | ||||
| } | ||||
|  | ||||
| export enum Theme { | ||||
| @@ -30,14 +31,17 @@ export enum Theme { | ||||
| } | ||||
|  | ||||
| export interface ChatConfig { | ||||
|   maxToken?: number; | ||||
|   historyMessageCount: number; // -1 means all | ||||
|   compressMessageLengthThreshold: number; | ||||
|   sendBotMessages: boolean; // send bot's message or not | ||||
|   submitKey: SubmitKey; | ||||
|   avatar: string; | ||||
|   fontSize: number; | ||||
|   theme: Theme; | ||||
|   tightBorder: boolean; | ||||
|   sendPreviewBubble: boolean; | ||||
|  | ||||
|   disablePromptHint: boolean; | ||||
|  | ||||
|   modelConfig: { | ||||
|     model: string; | ||||
| @@ -49,6 +53,8 @@ export interface ChatConfig { | ||||
|  | ||||
| export type ModelConfig = ChatConfig["modelConfig"]; | ||||
|  | ||||
| export const ROLES: Message["role"][] = ["system", "user", "assistant"]; | ||||
|  | ||||
| const ENABLE_GPT4 = true; | ||||
|  | ||||
| export const ALL_MODELS = [ | ||||
| @@ -86,7 +92,9 @@ export function isValidNumber(x: number, min: number, max: number) { | ||||
|   return typeof x === "number" && x <= max && x >= min; | ||||
| } | ||||
|  | ||||
| export function filterConfig(config: ModelConfig): Partial<ModelConfig> { | ||||
| export function filterConfig(oldConfig: ModelConfig): Partial<ModelConfig> { | ||||
|   const config = Object.assign({}, oldConfig); | ||||
|  | ||||
|   const validator: { | ||||
|     [k in keyof ModelConfig]: (x: ModelConfig[keyof ModelConfig]) => boolean; | ||||
|   } = { | ||||
| @@ -100,7 +108,7 @@ export function filterConfig(config: ModelConfig): Partial<ModelConfig> { | ||||
|       return isValidNumber(x as number, -2, 2); | ||||
|     }, | ||||
|     temperature(x) { | ||||
|       return isValidNumber(x as number, 0, 1); | ||||
|       return isValidNumber(x as number, 0, 2); | ||||
|     }, | ||||
|   }; | ||||
|  | ||||
| @@ -120,8 +128,12 @@ const DEFAULT_CONFIG: ChatConfig = { | ||||
|   sendBotMessages: true as boolean, | ||||
|   submitKey: SubmitKey.CtrlEnter as SubmitKey, | ||||
|   avatar: "1f603", | ||||
|   fontSize: 14, | ||||
|   theme: Theme.Auto as Theme, | ||||
|   tightBorder: false, | ||||
|   sendPreviewBubble: true, | ||||
|  | ||||
|   disablePromptHint: false, | ||||
|  | ||||
|   modelConfig: { | ||||
|     model: "gpt-3.5-turbo", | ||||
| @@ -141,6 +153,7 @@ export interface ChatSession { | ||||
|   id: number; | ||||
|   topic: string; | ||||
|   memoryPrompt: string; | ||||
|   context: Message[]; | ||||
|   messages: Message[]; | ||||
|   stat: ChatStat; | ||||
|   lastUpdate: string; | ||||
| @@ -148,6 +161,11 @@ export interface ChatSession { | ||||
| } | ||||
|  | ||||
| const DEFAULT_TOPIC = Locale.Store.DefaultTopic; | ||||
| export const BOT_HELLO: Message = { | ||||
|   role: "assistant", | ||||
|   content: Locale.Store.BotHello, | ||||
|   date: "", | ||||
| }; | ||||
|  | ||||
| function createEmptySession(): ChatSession { | ||||
|   const createDate = new Date().toLocaleString(); | ||||
| @@ -156,13 +174,8 @@ function createEmptySession(): ChatSession { | ||||
|     id: Date.now(), | ||||
|     topic: DEFAULT_TOPIC, | ||||
|     memoryPrompt: "", | ||||
|     messages: [ | ||||
|       { | ||||
|         role: "assistant", | ||||
|         content: Locale.Store.BotHello, | ||||
|         date: createDate, | ||||
|       }, | ||||
|     ], | ||||
|     context: [], | ||||
|     messages: [], | ||||
|     stat: { | ||||
|       tokenCount: 0, | ||||
|       wordCount: 0, | ||||
| @@ -177,6 +190,7 @@ interface ChatStore { | ||||
|   config: ChatConfig; | ||||
|   sessions: ChatSession[]; | ||||
|   currentSessionIndex: number; | ||||
|   clearSessions: () => void; | ||||
|   removeSession: (index: number) => void; | ||||
|   selectSession: (index: number) => void; | ||||
|   newSession: () => void; | ||||
| @@ -189,7 +203,7 @@ interface ChatStore { | ||||
|   updateMessage: ( | ||||
|     sessionIndex: number, | ||||
|     messageIndex: number, | ||||
|     updater: (message?: Message) => void | ||||
|     updater: (message?: Message) => void, | ||||
|   ) => void; | ||||
|   getMessagesWithMemory: () => Message[]; | ||||
|   getMemoryPrompt: () => Message; | ||||
| @@ -200,6 +214,10 @@ interface ChatStore { | ||||
|   clearAllData: () => void; | ||||
| } | ||||
|  | ||||
| function countMessages(msgs: Message[]) { | ||||
|   return msgs.reduce((pre, cur) => pre + cur.content.length, 0); | ||||
| } | ||||
|  | ||||
| const LOCAL_KEY = "chat-next-web-store"; | ||||
|  | ||||
| export const useChatStore = create<ChatStore>()( | ||||
| @@ -211,6 +229,13 @@ export const useChatStore = create<ChatStore>()( | ||||
|         ...DEFAULT_CONFIG, | ||||
|       }, | ||||
|  | ||||
|       clearSessions() { | ||||
|         set(() => ({ | ||||
|           sessions: [createEmptySession()], | ||||
|           currentSessionIndex: 0, | ||||
|         })); | ||||
|       }, | ||||
|  | ||||
|       resetConfig() { | ||||
|         set(() => ({ config: { ...DEFAULT_CONFIG } })); | ||||
|       }, | ||||
| @@ -337,7 +362,7 @@ export const useChatStore = create<ChatStore>()( | ||||
|             ControllerPool.addController( | ||||
|               sessionIndex, | ||||
|               messageIndex, | ||||
|               controller | ||||
|               controller, | ||||
|             ); | ||||
|           }, | ||||
|           filterBot: !get().config.sendBotMessages, | ||||
| @@ -359,23 +384,25 @@ export const useChatStore = create<ChatStore>()( | ||||
|         const session = get().currentSession(); | ||||
|         const config = get().config; | ||||
|         const n = session.messages.length; | ||||
|         const recentMessages = session.messages.slice( | ||||
|           n - config.historyMessageCount | ||||
|         ); | ||||
|  | ||||
|         const context = session.context.slice(); | ||||
|  | ||||
|         if (session.memoryPrompt && session.memoryPrompt.length > 0) { | ||||
|           const memoryPrompt = get().getMemoryPrompt(); | ||||
|  | ||||
|         if (session.memoryPrompt) { | ||||
|           recentMessages.unshift(memoryPrompt); | ||||
|           context.push(memoryPrompt); | ||||
|         } | ||||
|  | ||||
|         const recentMessages = context.concat( | ||||
|           session.messages.slice(Math.max(0, n - config.historyMessageCount)), | ||||
|         ); | ||||
|  | ||||
|         return recentMessages; | ||||
|       }, | ||||
|  | ||||
|       updateMessage( | ||||
|         sessionIndex: number, | ||||
|         messageIndex: number, | ||||
|         updater: (message?: Message) => void | ||||
|         updater: (message?: Message) => void, | ||||
|       ) { | ||||
|         const sessions = get().sessions; | ||||
|         const session = sessions.at(sessionIndex); | ||||
| @@ -387,29 +414,32 @@ export const useChatStore = create<ChatStore>()( | ||||
|       summarizeSession() { | ||||
|         const session = get().currentSession(); | ||||
|  | ||||
|         if (session.topic === DEFAULT_TOPIC && session.messages.length >= 3) { | ||||
|           // should summarize topic | ||||
|         // should summarize topic after chating more than 50 words | ||||
|         const SUMMARIZE_MIN_LEN = 50; | ||||
|         if ( | ||||
|           session.topic === DEFAULT_TOPIC && | ||||
|           countMessages(session.messages) >= SUMMARIZE_MIN_LEN | ||||
|         ) { | ||||
|           requestWithPrompt(session.messages, Locale.Store.Prompt.Topic).then( | ||||
|             (res) => { | ||||
|               get().updateCurrentSession( | ||||
|                 (session) => (session.topic = trimTopic(res)) | ||||
|                 (session) => (session.topic = trimTopic(res)), | ||||
|               ); | ||||
|             } | ||||
|             }, | ||||
|           ); | ||||
|         } | ||||
|  | ||||
|         const config = get().config; | ||||
|         let toBeSummarizedMsgs = session.messages.slice( | ||||
|           session.lastSummarizeIndex | ||||
|         ); | ||||
|         const historyMsgLength = toBeSummarizedMsgs.reduce( | ||||
|           (pre, cur) => pre + cur.content.length, | ||||
|           0 | ||||
|           session.lastSummarizeIndex, | ||||
|         ); | ||||
|  | ||||
|         if (historyMsgLength > 4000) { | ||||
|         const historyMsgLength = countMessages(toBeSummarizedMsgs); | ||||
|  | ||||
|         if (historyMsgLength > get().config?.modelConfig?.max_tokens ?? 4000) { | ||||
|           const n = toBeSummarizedMsgs.length; | ||||
|           toBeSummarizedMsgs = toBeSummarizedMsgs.slice( | ||||
|             -config.historyMessageCount | ||||
|             Math.max(0, n - config.historyMessageCount), | ||||
|           ); | ||||
|         } | ||||
|  | ||||
| @@ -422,7 +452,7 @@ export const useChatStore = create<ChatStore>()( | ||||
|           "[Chat History] ", | ||||
|           toBeSummarizedMsgs, | ||||
|           historyMsgLength, | ||||
|           config.compressMessageLengthThreshold | ||||
|           config.compressMessageLengthThreshold, | ||||
|         ); | ||||
|  | ||||
|         if (historyMsgLength > config.compressMessageLengthThreshold) { | ||||
| @@ -444,7 +474,7 @@ export const useChatStore = create<ChatStore>()( | ||||
|               onError(error) { | ||||
|                 console.error("[Summarize] ", error); | ||||
|               }, | ||||
|             } | ||||
|             }, | ||||
|           ); | ||||
|         } | ||||
|       }, | ||||
| @@ -472,7 +502,16 @@ export const useChatStore = create<ChatStore>()( | ||||
|     }), | ||||
|     { | ||||
|       name: LOCAL_KEY, | ||||
|       version: 1, | ||||
|       version: 1.1, | ||||
|       migrate(persistedState, version) { | ||||
|         const state = persistedState as ChatStore; | ||||
|  | ||||
|         if (version === 1) { | ||||
|           state.sessions.forEach((s) => (s.context = [])); | ||||
|         } | ||||
|   ) | ||||
|  | ||||
|         return state; | ||||
|       }, | ||||
|     }, | ||||
|   ), | ||||
| ); | ||||
|   | ||||
							
								
								
									
										117
									
								
								app/store/prompt.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								app/store/prompt.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,117 @@ | ||||
| import { create } from "zustand"; | ||||
| import { persist } from "zustand/middleware"; | ||||
| import Fuse from "fuse.js"; | ||||
|  | ||||
| export interface Prompt { | ||||
|   id?: number; | ||||
|   title: string; | ||||
|   content: string; | ||||
| } | ||||
|  | ||||
| export interface PromptStore { | ||||
|   latestId: number; | ||||
|   prompts: Map<number, Prompt>; | ||||
|  | ||||
|   add: (prompt: Prompt) => number; | ||||
|   remove: (id: number) => void; | ||||
|   search: (text: string) => Prompt[]; | ||||
| } | ||||
|  | ||||
| export const PROMPT_KEY = "prompt-store"; | ||||
|  | ||||
| export const SearchService = { | ||||
|   ready: false, | ||||
|   engine: new Fuse<Prompt>([], { keys: ["title"] }), | ||||
|   count: { | ||||
|     builtin: 0, | ||||
|   }, | ||||
|  | ||||
|   init(prompts: Prompt[]) { | ||||
|     if (this.ready) { | ||||
|       return; | ||||
|     } | ||||
|     this.engine.setCollection(prompts); | ||||
|     this.ready = true; | ||||
|   }, | ||||
|  | ||||
|   remove(id: number) { | ||||
|     this.engine.remove((doc) => doc.id === id); | ||||
|   }, | ||||
|  | ||||
|   add(prompt: Prompt) { | ||||
|     this.engine.add(prompt); | ||||
|   }, | ||||
|  | ||||
|   search(text: string) { | ||||
|     const results = this.engine.search(text); | ||||
|     return results.map((v) => v.item); | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| export const usePromptStore = create<PromptStore>()( | ||||
|   persist( | ||||
|     (set, get) => ({ | ||||
|       latestId: 0, | ||||
|       prompts: new Map(), | ||||
|  | ||||
|       add(prompt) { | ||||
|         const prompts = get().prompts; | ||||
|         prompt.id = get().latestId + 1; | ||||
|         prompts.set(prompt.id, prompt); | ||||
|  | ||||
|         set(() => ({ | ||||
|           latestId: prompt.id!, | ||||
|           prompts: prompts, | ||||
|         })); | ||||
|  | ||||
|         return prompt.id!; | ||||
|       }, | ||||
|  | ||||
|       remove(id) { | ||||
|         const prompts = get().prompts; | ||||
|         prompts.delete(id); | ||||
|         SearchService.remove(id); | ||||
|  | ||||
|         set(() => ({ | ||||
|           prompts, | ||||
|         })); | ||||
|       }, | ||||
|  | ||||
|       search(text) { | ||||
|         return SearchService.search(text) as Prompt[]; | ||||
|       }, | ||||
|     }), | ||||
|     { | ||||
|       name: PROMPT_KEY, | ||||
|       version: 1, | ||||
|       onRehydrateStorage(state) { | ||||
|         const PROMPT_URL = "./prompts.json"; | ||||
|  | ||||
|         type PromptList = Array<[string, string]>; | ||||
|  | ||||
|         fetch(PROMPT_URL) | ||||
|           .then((res) => res.json()) | ||||
|           .then((res) => { | ||||
|             const builtinPrompts = [res.en, res.cn] | ||||
|               .map((promptList: PromptList) => { | ||||
|                 return promptList.map( | ||||
|                   ([title, content]) => | ||||
|                     ({ | ||||
|                       title, | ||||
|                       content, | ||||
|                     } as Prompt), | ||||
|                 ); | ||||
|               }) | ||||
|               .concat([...(state?.prompts?.values() ?? [])]); | ||||
|  | ||||
|             const allPromptsForSearch = builtinPrompts.reduce( | ||||
|               (pre, cur) => pre.concat(cur), | ||||
|               [], | ||||
|             ); | ||||
|             SearchService.count.builtin = res.en.length + res.cn.length; | ||||
|             SearchService.init(allPromptsForSearch); | ||||
|           }); | ||||
|       }, | ||||
|     }, | ||||
|   ), | ||||
| ); | ||||
| @@ -1,7 +1,7 @@ | ||||
| import { create } from "zustand"; | ||||
| import { persist } from "zustand/middleware"; | ||||
| import { FETCH_COMMIT_URL } from "../constant"; | ||||
| import { getCurrentCommitId } from "../utils"; | ||||
| import { FETCH_COMMIT_URL, FETCH_TAG_URL } from "../constant"; | ||||
| import { getCurrentVersion } from "../utils"; | ||||
|  | ||||
| export interface UpdateStore { | ||||
|   lastUpdate: number; | ||||
| @@ -19,16 +19,17 @@ export const useUpdateStore = create<UpdateStore>()( | ||||
|       remoteId: "", | ||||
|  | ||||
|       async getLatestCommitId(force = false) { | ||||
|         const overOneHour = Date.now() - get().lastUpdate > 3600 * 1000; | ||||
|         const shouldFetch = force || overOneHour; | ||||
|         const overTenMins = Date.now() - get().lastUpdate > 10 * 60 * 1000; | ||||
|         const shouldFetch = force || overTenMins; | ||||
|         if (!shouldFetch) { | ||||
|           return getCurrentCommitId(); | ||||
|           return getCurrentVersion(); | ||||
|         } | ||||
|  | ||||
|         try { | ||||
|           // const data = await (await fetch(FETCH_TAG_URL)).json(); | ||||
|           // const remoteId = data[0].name as string; | ||||
|           const data = await (await fetch(FETCH_COMMIT_URL)).json(); | ||||
|           const sha = data[0].sha as string; | ||||
|           const remoteId = sha.substring(0, 7); | ||||
|           const remoteId = (data[0].sha as string).substring(0, 7); | ||||
|           set(() => ({ | ||||
|             lastUpdate: Date.now(), | ||||
|             remoteId, | ||||
| @@ -37,13 +38,13 @@ export const useUpdateStore = create<UpdateStore>()( | ||||
|           return remoteId; | ||||
|         } catch (error) { | ||||
|           console.error("[Fetch Upstream Commit Id]", error); | ||||
|           return getCurrentCommitId(); | ||||
|           return getCurrentVersion(); | ||||
|         } | ||||
|       }, | ||||
|     }), | ||||
|     { | ||||
|       name: UPDATE_KEY, | ||||
|       version: 1, | ||||
|     } | ||||
|   ) | ||||
|     }, | ||||
|   ), | ||||
| ); | ||||
|   | ||||
							
								
								
									
										23
									
								
								app/styles/animation.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								app/styles/animation.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| @keyframes slide-in { | ||||
|   from { | ||||
|     opacity: 0; | ||||
|     transform: translateY(20px); | ||||
|   } | ||||
|  | ||||
|   to { | ||||
|     opacity: 1; | ||||
|     transform: translateY(0px); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @keyframes slide-in-from-top { | ||||
|   from { | ||||
|     opacity: 0; | ||||
|     transform: translateY(-20px); | ||||
|   } | ||||
|  | ||||
|   to { | ||||
|     opacity: 1; | ||||
|     transform: translateY(0px); | ||||
|   } | ||||
| } | ||||
| @@ -53,12 +53,13 @@ | ||||
|   --sidebar-width: 300px; | ||||
|   --window-content-width: calc(100% - var(--sidebar-width)); | ||||
|   --message-max-width: 80%; | ||||
|   --full-height: 100%; | ||||
| } | ||||
|  | ||||
| @media only screen and (max-width: 600px) { | ||||
|   :root { | ||||
|     --window-width: 100vw; | ||||
|     --window-height: 100vh; | ||||
|     --window-height: var(--full-height); | ||||
|     --sidebar-width: 100vw; | ||||
|     --window-content-width: var(--window-width); | ||||
|     --message-max-width: 100%; | ||||
| @@ -74,13 +75,16 @@ | ||||
|     @include dark; | ||||
|   } | ||||
| } | ||||
| html { | ||||
|   height: var(--full-height); | ||||
| } | ||||
|  | ||||
| body { | ||||
|   background-color: var(--gray); | ||||
|   color: var(--black); | ||||
|   margin: 0; | ||||
|   padding: 0; | ||||
|   height: 100vh; | ||||
|   height: var(--full-height); | ||||
|   width: 100vw; | ||||
|   display: flex; | ||||
|   justify-content: center; | ||||
| @@ -113,12 +117,17 @@ body { | ||||
|  | ||||
| select { | ||||
|   border: var(--border-in-light); | ||||
|   padding: 8px 10px; | ||||
|   padding: 10px; | ||||
|   border-radius: 10px; | ||||
|   appearance: none; | ||||
|   cursor: pointer; | ||||
|   background-color: var(--white); | ||||
|   color: var(--black); | ||||
|   text-align: center; | ||||
| } | ||||
|  | ||||
| input { | ||||
|   text-align: center; | ||||
| } | ||||
|  | ||||
| input[type="checkbox"] { | ||||
| @@ -179,7 +188,7 @@ input[type="text"] { | ||||
|   appearance: none; | ||||
|   border-radius: 10px; | ||||
|   border: var(--border-in-light); | ||||
|   height: 32px; | ||||
|   min-height: 36px; | ||||
|   box-sizing: border-box; | ||||
|   background: var(--white); | ||||
|   color: var(--black); | ||||
| @@ -196,7 +205,7 @@ div.math { | ||||
|   position: fixed; | ||||
|   top: 0; | ||||
|   left: 0; | ||||
|   height: 100vh; | ||||
|   height: var(--full-height); | ||||
|   width: 100vw; | ||||
|   background-color: rgba($color: #000000, $alpha: 0.5); | ||||
|   display: flex; | ||||
| @@ -226,6 +235,7 @@ pre { | ||||
|   .copy-code-button { | ||||
|     position: absolute; | ||||
|     right: 10px; | ||||
|     top: 1em; | ||||
|     cursor: pointer; | ||||
|     padding: 0px 5px; | ||||
|     background-color: var(--black); | ||||
| @@ -246,3 +256,15 @@ pre { | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| .clickable { | ||||
|   cursor: pointer; | ||||
|  | ||||
|   div:not(.no-dark) > svg { | ||||
|     filter: invert(0.5); | ||||
|   } | ||||
|  | ||||
|   &:hover { | ||||
|     filter: brightness(0.9); | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										114
									
								
								app/styles/highlight.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								app/styles/highlight.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,114 @@ | ||||
| .markdown-body { | ||||
|   pre { | ||||
|     padding: 0; | ||||
|   } | ||||
|  | ||||
|   pre, | ||||
|   code { | ||||
|     font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace; | ||||
|   } | ||||
|  | ||||
|   pre code.hljs { | ||||
|     display: block; | ||||
|     overflow-x: auto; | ||||
|     padding: 1em; | ||||
|   } | ||||
|  | ||||
|   code.hljs { | ||||
|     padding: 3px 5px; | ||||
|   } | ||||
|  | ||||
|   /*! | ||||
|   Theme: Tokyo-night-Dark | ||||
|   origin: https://github.com/enkia/tokyo-night-vscode-theme | ||||
|   Description: Original highlight.js style | ||||
|   Author: (c) Henri Vandersleyen <hvandersleyen@gmail.com> | ||||
|   License: see project LICENSE | ||||
|   Touched: 2022 | ||||
| */ | ||||
|   .hljs-comment, | ||||
|   .hljs-meta { | ||||
|     color: #565f89; | ||||
|   } | ||||
|  | ||||
|   .hljs-deletion, | ||||
|   .hljs-doctag, | ||||
|   .hljs-regexp, | ||||
|   .hljs-selector-attr, | ||||
|   .hljs-selector-class, | ||||
|   .hljs-selector-id, | ||||
|   .hljs-selector-pseudo, | ||||
|   .hljs-tag, | ||||
|   .hljs-template-tag, | ||||
|   .hljs-variable.language_ { | ||||
|     color: #f7768e; | ||||
|   } | ||||
|  | ||||
|   .hljs-link, | ||||
|   .hljs-literal, | ||||
|   .hljs-number, | ||||
|   .hljs-params, | ||||
|   .hljs-template-variable, | ||||
|   .hljs-type, | ||||
|   .hljs-variable { | ||||
|     color: #ff9e64; | ||||
|   } | ||||
|  | ||||
|   .hljs-attribute, | ||||
|   .hljs-built_in { | ||||
|     color: #e0af68; | ||||
|   } | ||||
|  | ||||
|   .hljs-keyword, | ||||
|   .hljs-property, | ||||
|   .hljs-subst, | ||||
|   .hljs-title, | ||||
|   .hljs-title.class_, | ||||
|   .hljs-title.class_.inherited__, | ||||
|   .hljs-title.function_ { | ||||
|     color: #7dcfff; | ||||
|   } | ||||
|  | ||||
|   .hljs-selector-tag { | ||||
|     color: #73daca; | ||||
|   } | ||||
|  | ||||
|   .hljs-addition, | ||||
|   .hljs-bullet, | ||||
|   .hljs-quote, | ||||
|   .hljs-string, | ||||
|   .hljs-symbol { | ||||
|     color: #9ece6a; | ||||
|   } | ||||
|  | ||||
|   .hljs-code, | ||||
|   .hljs-formula, | ||||
|   .hljs-section { | ||||
|     color: #7aa2f7; | ||||
|   } | ||||
|  | ||||
|   .hljs-attr, | ||||
|   .hljs-char.escape_, | ||||
|   .hljs-keyword, | ||||
|   .hljs-name, | ||||
|   .hljs-operator { | ||||
|     color: #bb9af7; | ||||
|   } | ||||
|  | ||||
|   .hljs-punctuation { | ||||
|     color: #c0caf5; | ||||
|   } | ||||
|  | ||||
|   .hljs { | ||||
|     background: #1a1b26; | ||||
|     color: #9aa5ce; | ||||
|   } | ||||
|  | ||||
|   .hljs-emphasis { | ||||
|     font-style: italic; | ||||
|   } | ||||
|  | ||||
|   .hljs-strong { | ||||
|     font-weight: 700; | ||||
|   } | ||||
| } | ||||
| @@ -839,21 +839,20 @@ | ||||
|  | ||||
| .markdown-body .highlight pre, | ||||
| .markdown-body pre { | ||||
|   padding: 16px; | ||||
|   padding: 16px 16px 8px 16px; | ||||
|   overflow: auto; | ||||
|   font-size: 85%; | ||||
|   line-height: 1.45; | ||||
|   background-color: var(--color-canvas-subtle); | ||||
|   border-radius: 6px; | ||||
| } | ||||
|  | ||||
| .markdown-body pre code, | ||||
| .markdown-body pre tt { | ||||
|   display: inline; | ||||
|   max-width: auto; | ||||
|   display: inline-block; | ||||
|   max-width: 100%; | ||||
|   padding: 0; | ||||
|   margin: 0; | ||||
|   overflow: visible; | ||||
|   overflow-x: scroll; | ||||
|   line-height: inherit; | ||||
|   word-wrap: normal; | ||||
|   background-color: transparent; | ||||
|   | ||||
| @@ -1,152 +0,0 @@ | ||||
| .markdown-body { | ||||
|   pre { | ||||
|     background: #282a36; | ||||
|     color: #f8f8f2; | ||||
|   } | ||||
|  | ||||
|   code[class*="language-"], | ||||
|   pre[class*="language-"] { | ||||
|     color: #f8f8f2; | ||||
|     background: none; | ||||
|     text-shadow: 0 1px rgba(0, 0, 0, 0.3); | ||||
|     font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace; | ||||
|     text-align: left; | ||||
|     white-space: pre; | ||||
|     word-spacing: normal; | ||||
|     word-break: normal; | ||||
|     word-wrap: normal; | ||||
|     line-height: 1.5; | ||||
|     -moz-tab-size: 4; | ||||
|     -o-tab-size: 4; | ||||
|     tab-size: 4; | ||||
|     -webkit-hyphens: none; | ||||
|     -moz-hyphens: none; | ||||
|     -ms-hyphens: none; | ||||
|     hyphens: none; | ||||
|   } | ||||
|  | ||||
|   /* Code blocks */ | ||||
|   pre[class*="language-"] { | ||||
|     padding: 1em; | ||||
|     margin: 0.5em 0; | ||||
|     overflow: auto; | ||||
|     border-radius: 0.3em; | ||||
|   } | ||||
|  | ||||
|   :not(pre) > code[class*="language-"], | ||||
|   pre[class*="language-"] { | ||||
|     background: #282a36; | ||||
|   } | ||||
|  | ||||
|   /* Inline code */ | ||||
|   :not(pre) > code[class*="language-"] { | ||||
|     padding: 0.1em; | ||||
|     border-radius: 0.3em; | ||||
|     white-space: normal; | ||||
|   } | ||||
|  | ||||
|   .token.comment, | ||||
|   .token.prolog, | ||||
|   .token.doctype, | ||||
|   .token.cdata { | ||||
|     color: #6272a4; | ||||
|   } | ||||
|  | ||||
|   .token.punctuation { | ||||
|     color: #f8f8f2; | ||||
|   } | ||||
|  | ||||
|   .namespace { | ||||
|     opacity: 0.7; | ||||
|   } | ||||
|  | ||||
|   .token.property, | ||||
|   .token.tag, | ||||
|   .token.constant, | ||||
|   .token.symbol, | ||||
|   .token.deleted { | ||||
|     color: #ff79c6; | ||||
|   } | ||||
|  | ||||
|   .token.boolean, | ||||
|   .token.number { | ||||
|     color: #bd93f9; | ||||
|   } | ||||
|  | ||||
|   .token.selector, | ||||
|   .token.attr-name, | ||||
|   .token.string, | ||||
|   .token.char, | ||||
|   .token.builtin, | ||||
|   .token.inserted { | ||||
|     color: #50fa7b; | ||||
|   } | ||||
|  | ||||
|   .token.operator, | ||||
|   .token.entity, | ||||
|   .token.url, | ||||
|   .language-css .token.string, | ||||
|   .style .token.string, | ||||
|   .token.variable { | ||||
|     color: #f8f8f2; | ||||
|   } | ||||
|  | ||||
|   .token.atrule, | ||||
|   .token.attr-value, | ||||
|   .token.function, | ||||
|   .token.class-name { | ||||
|     color: #f1fa8c; | ||||
|   } | ||||
|  | ||||
|   .token.keyword { | ||||
|     color: #8be9fd; | ||||
|   } | ||||
|  | ||||
|   .token.regex, | ||||
|   .token.important { | ||||
|     color: #ffb86c; | ||||
|   } | ||||
|  | ||||
|   .token.important, | ||||
|   .token.bold { | ||||
|     font-weight: bold; | ||||
|   } | ||||
|  | ||||
|   .token.italic { | ||||
|     font-style: italic; | ||||
|   } | ||||
|  | ||||
|   .token.entity { | ||||
|     cursor: help; | ||||
|   } | ||||
| } | ||||
|  | ||||
| // @mixin light { | ||||
| //   .markdown-body pre[class*="language-"] { | ||||
| //     filter: invert(1) hue-rotate(50deg) brightness(1.3); | ||||
| //   } | ||||
| // } | ||||
|  | ||||
| // @mixin dark { | ||||
| //   .markdown-body pre[class*="language-"] { | ||||
| //     filter: none; | ||||
| //   } | ||||
| // } | ||||
|  | ||||
| // :root { | ||||
| // @include light(); | ||||
| // } | ||||
|  | ||||
| // .light { | ||||
| //   @include light(); | ||||
| // } | ||||
|  | ||||
| // .dark { | ||||
| //   @include dark(); | ||||
| // } | ||||
|  | ||||
| // @media (prefers-color-scheme: dark) { | ||||
| //   :root { | ||||
| //     @include dark(); | ||||
| //   } | ||||
| // } | ||||
							
								
								
									
										20
									
								
								app/utils.ts
									
									
									
									
									
								
							
							
						
						
									
										20
									
								
								app/utils.ts
									
									
									
									
									
								
							| @@ -2,15 +2,7 @@ import { showToast } from "./components/ui-lib"; | ||||
| import Locale from "./locales"; | ||||
|  | ||||
| export function trimTopic(topic: string) { | ||||
|   const s = topic.split(""); | ||||
|   let lastChar = s.at(-1); // 获取 s 的最后一个字符 | ||||
|   let pattern = /[,。!?、]/; // 定义匹配中文标点符号的正则表达式 | ||||
|   while (lastChar && pattern.test(lastChar!)) { | ||||
|     s.pop(); | ||||
|     lastChar = s.at(-1); | ||||
|   } | ||||
|  | ||||
|   return s.join(""); | ||||
|   return topic.replace(/[,。!?、,.!?]*$/, ""); | ||||
| } | ||||
|  | ||||
| export function copyToClipboard(text: string) { | ||||
| @@ -28,7 +20,7 @@ export function downloadAs(text: string, filename: string) { | ||||
|   const element = document.createElement("a"); | ||||
|   element.setAttribute( | ||||
|     "href", | ||||
|     "data:text/plain;charset=utf-8," + encodeURIComponent(text) | ||||
|     "data:text/plain;charset=utf-8," + encodeURIComponent(text), | ||||
|   ); | ||||
|   element.setAttribute("download", filename); | ||||
|  | ||||
| @@ -45,6 +37,10 @@ export function isIOS() { | ||||
|   return /iphone|ipad|ipod/.test(userAgent); | ||||
| } | ||||
|  | ||||
| export function isMobileScreen() { | ||||
|   return window.innerWidth <= 600; | ||||
| } | ||||
|  | ||||
| export function selectOrCopy(el: HTMLElement, content: string) { | ||||
|   const currentSelection = window.getSelection(); | ||||
|  | ||||
| @@ -61,7 +57,7 @@ export function queryMeta(key: string, defaultValue?: string): string { | ||||
|   let ret: string; | ||||
|   if (document) { | ||||
|     const meta = document.head.querySelector( | ||||
|       `meta[name='${key}']` | ||||
|       `meta[name='${key}']`, | ||||
|     ) as HTMLMetaElement; | ||||
|     ret = meta?.content ?? ""; | ||||
|   } else { | ||||
| @@ -72,7 +68,7 @@ export function queryMeta(key: string, defaultValue?: string): string { | ||||
| } | ||||
|  | ||||
| let currentId: string; | ||||
| export function getCurrentCommitId() { | ||||
| export function getCurrentVersion() { | ||||
|   if (currentId) { | ||||
|     return currentId; | ||||
|   } | ||||
|   | ||||
| @@ -3,10 +3,10 @@ import { ACCESS_CODES } from "./app/api/access"; | ||||
| import md5 from "spark-md5"; | ||||
|  | ||||
| export const config = { | ||||
|   matcher: ["/api/chat", "/api/chat-stream"], | ||||
|   matcher: ["/api/openai", "/api/chat-stream"], | ||||
| }; | ||||
|  | ||||
| export function middleware(req: NextRequest, res: NextResponse) { | ||||
| export function middleware(req: NextRequest) { | ||||
|   const accessCode = req.headers.get("access-code"); | ||||
|   const token = req.headers.get("token"); | ||||
|   const hashedCode = md5.hash(accessCode ?? "").trim(); | ||||
| @@ -18,14 +18,40 @@ export function middleware(req: NextRequest, res: NextResponse) { | ||||
|   if (ACCESS_CODES.size > 0 && !ACCESS_CODES.has(hashedCode) && !token) { | ||||
|     return NextResponse.json( | ||||
|       { | ||||
|         error: true, | ||||
|         needAccessCode: true, | ||||
|         hint: "Please go settings page and fill your access code.", | ||||
|         msg: "Please go settings page and fill your access code.", | ||||
|       }, | ||||
|       { | ||||
|         status: 401, | ||||
|       } | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   return NextResponse.next(); | ||||
|   // inject api key | ||||
|   if (!token) { | ||||
|     const apiKey = process.env.OPENAI_API_KEY; | ||||
|     if (apiKey) { | ||||
|       console.log("[Auth] set system token"); | ||||
|       req.headers.set("token", apiKey); | ||||
|     } else { | ||||
|       return NextResponse.json( | ||||
|         { | ||||
|           error: true, | ||||
|           msg: "Empty Api Key", | ||||
|         }, | ||||
|         { | ||||
|           status: 401, | ||||
|         }, | ||||
|       ); | ||||
|     } | ||||
|   } else { | ||||
|     console.log("[Auth] set user token"); | ||||
|   } | ||||
|  | ||||
|   return NextResponse.next({ | ||||
|     request: { | ||||
|       headers: req.headers, | ||||
|     }, | ||||
|   }); | ||||
| } | ||||
|   | ||||
							
								
								
									
										54
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										54
									
								
								package.json
									
									
									
									
									
								
							| @@ -4,36 +4,50 @@ | ||||
|   "private": false, | ||||
|   "license": "Anti 996", | ||||
|   "scripts": { | ||||
|     "dev": "next dev", | ||||
|     "build": "next build", | ||||
|     "dev": "yarn fetch && next dev", | ||||
|     "build": "yarn fetch && next build", | ||||
|     "start": "next start", | ||||
|     "lint": "next lint" | ||||
|     "lint": "next lint", | ||||
|     "fetch": "node ./scripts/fetch-prompts.mjs", | ||||
|     "prepare": "husky install" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "@svgr/webpack": "^6.5.1", | ||||
|     "@vercel/analytics": "^0.1.11", | ||||
|     "array.prototype.at": "^1.1.1", | ||||
|     "emoji-picker-react": "^4.4.7", | ||||
|     "eventsource-parser": "^0.1.0", | ||||
|     "fuse.js": "^6.6.2", | ||||
|     "next": "^13.2.3", | ||||
|     "node-fetch": "^3.3.1", | ||||
|     "openai": "^3.2.1", | ||||
|     "react": "^18.2.0", | ||||
|     "react-dom": "^18.2.0", | ||||
|     "react-markdown": "^8.0.5", | ||||
|     "rehype-highlight": "^6.0.0", | ||||
|     "rehype-katex": "^6.0.2", | ||||
|     "remark-breaks": "^3.0.2", | ||||
|     "remark-gfm": "^3.0.1", | ||||
|     "remark-math": "^5.1.1", | ||||
|     "sass": "^1.59.2", | ||||
|     "spark-md5": "^3.0.2", | ||||
|     "use-debounce": "^9.0.3", | ||||
|     "zustand": "^4.3.6" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@types/node": "^18.14.6", | ||||
|     "@types/react": "^18.0.28", | ||||
|     "@types/react-dom": "^18.0.11", | ||||
|     "@types/react-katex": "^3.0.0", | ||||
|     "@types/spark-md5": "^3.0.2", | ||||
|     "@vercel/analytics": "^0.1.11", | ||||
|     "cross-env": "^7.0.3", | ||||
|     "emoji-picker-react": "^4.4.7", | ||||
|     "eslint": "8.35.0", | ||||
|     "eslint": "^8.36.0", | ||||
|     "eslint-config-next": "13.2.3", | ||||
|     "eventsource-parser": "^0.1.0", | ||||
|     "next": "^13.2.3", | ||||
|     "openai": "^3.2.1", | ||||
|     "react": "^18.2.0", | ||||
|     "react-dom": "^18.2.0", | ||||
|     "react-markdown": "^8.0.5", | ||||
|     "rehype-katex": "^6.0.2", | ||||
|     "rehype-prism-plus": "^1.5.1", | ||||
|     "remark-gfm": "^3.0.1", | ||||
|     "remark-math": "^5.1.1", | ||||
|     "sass": "^1.59.2", | ||||
|     "spark-md5": "^3.0.2", | ||||
|     "typescript": "4.9.5", | ||||
|     "zustand": "^4.3.6" | ||||
|     "eslint-config-prettier": "^8.8.0", | ||||
|     "eslint-plugin-prettier": "^4.2.1", | ||||
|     "husky": "^8.0.0", | ||||
|     "lint-staged": "^13.2.0", | ||||
|     "prettier": "^2.8.7", | ||||
|     "typescript": "4.9.5" | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										4
									
								
								public/robots.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								public/robots.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| User-agent: * | ||||
| Disallow: / | ||||
| User-agent: vitals.vercel-insights.com | ||||
| Allow: / | ||||
| @@ -1,24 +1,15 @@ | ||||
| const CHATGPT_NEXT_WEB_CACHE = "chatgpt-next-web-cache"; | ||||
|  | ||||
| self.addEventListener('activate', function (event) { | ||||
|   console.log('ServiceWorker activated.'); | ||||
| self.addEventListener("activate", function (event) { | ||||
|   console.log("ServiceWorker activated."); | ||||
| }); | ||||
|  | ||||
| self.addEventListener('install', function (event) { | ||||
| self.addEventListener("install", function (event) { | ||||
|   event.waitUntil( | ||||
|     caches.open(CHATGPT_NEXT_WEB_CACHE) | ||||
|       .then(function (cache) { | ||||
|         return cache.addAll([ | ||||
|         ]); | ||||
|       }) | ||||
|     caches.open(CHATGPT_NEXT_WEB_CACHE).then(function (cache) { | ||||
|       return cache.addAll([]); | ||||
|     }), | ||||
|   ); | ||||
| }); | ||||
|  | ||||
| self.addEventListener('fetch', function (event) { | ||||
|   event.respondWith( | ||||
|     caches.match(event.request) | ||||
|       .then(function (response) { | ||||
|         return response || fetch(event.request); | ||||
|       }) | ||||
|   ); | ||||
| }); | ||||
| self.addEventListener("fetch", (e) => {}); | ||||
|   | ||||
							
								
								
									
										53
									
								
								scripts/fetch-prompts.mjs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								scripts/fetch-prompts.mjs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,53 @@ | ||||
| import fetch from "node-fetch"; | ||||
| import fs from "fs/promises"; | ||||
|  | ||||
| const RAW_CN_URL = | ||||
|   "https://raw.githubusercontent.com/PlexPt/awesome-chatgpt-prompts-zh/main/prompts-zh.json"; | ||||
| const CN_URL = | ||||
|   "https://cdn.jsdelivr.net/gh/PlexPt/awesome-chatgpt-prompts-zh@main/prompts-zh.json"; | ||||
| const RAW_EN_URL = | ||||
|   "https://raw.githubusercontent.com/f/awesome-chatgpt-prompts/main/prompts.csv"; | ||||
| const EN_URL = | ||||
|   "https://cdn.jsdelivr.net/gh/f/awesome-chatgpt-prompts@main/prompts.csv"; | ||||
| const FILE = "./public/prompts.json"; | ||||
|  | ||||
| async function fetchCN() { | ||||
|   console.log("[Fetch] fetching cn prompts..."); | ||||
|   try { | ||||
|     const raw = await (await fetch(CN_URL)).json(); | ||||
|     return raw.map((v) => [v.act, v.prompt]); | ||||
|   } catch (error) { | ||||
|     console.error("[Fetch] failed to fetch cn prompts", error); | ||||
|     return []; | ||||
|   } | ||||
| } | ||||
|  | ||||
| async function fetchEN() { | ||||
|   console.log("[Fetch] fetching en prompts..."); | ||||
|   try { | ||||
|     const raw = await (await fetch(EN_URL)).text(); | ||||
|     return raw | ||||
|       .split("\n") | ||||
|       .slice(1) | ||||
|       .map((v) => v.split('","').map((v) => v.replace('"', ""))); | ||||
|   } catch (error) { | ||||
|     console.error("[Fetch] failed to fetch cn prompts", error); | ||||
|     return []; | ||||
|   } | ||||
| } | ||||
|  | ||||
| async function main() { | ||||
|   Promise.all([fetchCN(), fetchEN()]) | ||||
|     .then(([cn, en]) => { | ||||
|       fs.writeFile(FILE, JSON.stringify({ cn, en })); | ||||
|     }) | ||||
|     .catch((e) => { | ||||
|       console.error("[Fetch] failed to fetch prompts"); | ||||
|       fs.writeFile(FILE, JSON.stringify({ cn: [], en: [] })); | ||||
|     }) | ||||
|     .finally(() => { | ||||
|       console.log("[Fetch] saved to " + FILE); | ||||
|     }); | ||||
| } | ||||
|  | ||||
| main(); | ||||
							
								
								
									
										64
									
								
								scripts/setup.sh
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								scripts/setup.sh
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,64 @@ | ||||
| #!/bin/bash | ||||
|  | ||||
| # Check if running on a supported system | ||||
| case "$(uname -s)" in | ||||
|   Linux) | ||||
|     if [[ -f "/etc/lsb-release" ]]; then | ||||
|       . /etc/lsb-release | ||||
|       if [[ "$DISTRIB_ID" != "Ubuntu" ]]; then | ||||
|         echo "This script only works on Ubuntu, not $DISTRIB_ID." | ||||
|         exit 1 | ||||
|       fi | ||||
|     else | ||||
|       if [[ ! "$(cat /etc/*-release | grep '^ID=')" =~ ^(ID=\"ubuntu\")|(ID=\"centos\")|(ID=\"arch\")$ ]]; then | ||||
|         echo "Unsupported Linux distribution." | ||||
|         exit 1 | ||||
|       fi | ||||
|     fi | ||||
|     ;; | ||||
|   Darwin) | ||||
|     echo "Running on MacOS." | ||||
|     ;; | ||||
|   *) | ||||
|     echo "Unsupported operating system." | ||||
|     exit 1 | ||||
|     ;; | ||||
| esac | ||||
|  | ||||
| # Check if needed dependencies are installed and install if necessary | ||||
| if ! command -v node >/dev/null || ! command -v git >/dev/null || ! command -v yarn >/dev/null; then | ||||
|   case "$(uname -s)" in | ||||
|     Linux) | ||||
|       if [[ "$(cat /etc/*-release | grep '^ID=')" = "ID=\"ubuntu\"" ]]; then | ||||
|         sudo apt-get update | ||||
|         sudo apt-get -y install nodejs git yarn | ||||
|       elif [[ "$(cat /etc/*-release | grep '^ID=')" = "ID=\"centos\"" ]]; then | ||||
|         sudo yum -y install epel-release | ||||
|         sudo yum -y install nodejs git yarn | ||||
|       elif [[ "$(cat /etc/*-release | grep '^ID=')" = "ID=\"arch\"" ]]; then | ||||
|         sudo pacman -Syu -y | ||||
|         sudo pacman -S -y nodejs git yarn | ||||
|       else | ||||
|         echo "Unsupported Linux distribution" | ||||
|         exit 1 | ||||
|       fi | ||||
|       ;; | ||||
|     Darwin) | ||||
|       /usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" | ||||
|       brew install node git yarn | ||||
|       ;; | ||||
|   esac | ||||
| fi | ||||
|  | ||||
| # Clone the repository and install dependencies | ||||
| git clone https://github.com/Yidadaa/ChatGPT-Next-Web | ||||
| cd ChatGPT-Next-Web | ||||
| yarn install | ||||
|  | ||||
| # Prompt user for environment variables | ||||
| read -p "Enter OPENAI_API_KEY: " OPENAI_API_KEY | ||||
| read -p "Enter CODE: " CODE | ||||
| read -p "Enter PORT: " PORT | ||||
|  | ||||
| # Build and run the project using the environment variables | ||||
| OPENAI_API_KEY=$OPENAI_API_KEY CODE=$CODE PORT=$PORT yarn build && OPENAI_API_KEY=$OPENAI_API_KEY CODE=$CODE PORT=$PORT yarn start | ||||
		Reference in New Issue
	
	Block a user