mirror of
https://github.com/Yidadaa/ChatGPT-Next-Web.git
synced 2025-08-30 18:46:58 +08:00
Compare commits
223 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
7d7f3716be | ||
|
4e644cfca7 | ||
|
7572c99f4d | ||
|
bb30fdfa17 | ||
|
a4743034a9 | ||
|
c2a79e8a11 | ||
|
ec88785b6d | ||
|
d923869dc3 | ||
|
ae8ceb8dca | ||
|
8db26bbd5f | ||
|
efd3310711 | ||
|
a6890c0f58 | ||
|
b5a69b66b2 | ||
|
54447ba677 | ||
|
13a86aead7 | ||
|
73f4ea38c6 | ||
|
82c025a34a | ||
|
fb127f7857 | ||
|
6f82140ba5 | ||
|
6a9751efce | ||
|
5c75b6c784 | ||
|
34ea20b109 | ||
|
6e6d2a3310 | ||
|
56ba8a65e0 | ||
|
0e784c50ad | ||
|
73865651a0 | ||
|
f863a562a0 | ||
|
0d3bd42780 | ||
|
62f8675199 | ||
|
8a1c2f89be | ||
|
5843303076 | ||
|
e2c1475857 | ||
|
8d34b0f454 | ||
|
b44caeeefb | ||
|
502d22bd20 | ||
|
8d60a414f0 | ||
|
e8d71c815e | ||
|
1afca0b28a | ||
|
f9f29afba9 | ||
|
f52bcc2a37 | ||
|
2647bdb4ed | ||
|
c98517fc48 | ||
|
e8dd391ccf | ||
|
e3c3cd3d18 | ||
|
b05b96e3a4 | ||
|
b85245e317 | ||
|
f606a61e1c | ||
|
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 | ||
|
4dbc984351 | ||
|
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 | ||
|
cc1a1d4f3c | ||
|
0463b350d8 | ||
|
8f87a68f72 | ||
|
60f27fdfbb | ||
|
d17706636b | ||
|
9570691d5b | ||
|
efe4fcc188 | ||
|
efaf6590ef | ||
|
fb06fb8c38 | ||
|
f188841188 | ||
|
5593c067c4 | ||
|
689b7bab26 | ||
|
a81e7394f0 | ||
|
492fed6802 | ||
|
bdf17fafff | ||
|
58baa23199 | ||
|
dd80c4563d | ||
|
785372ad73 | ||
|
d8e4808316 | ||
|
6446692db0 | ||
|
cd73c3a7cb | ||
|
d0eee767fa | ||
|
e880df6db9 | ||
|
96c4f5bbd9 | ||
|
2645540721 | ||
|
b1f27aaf93 | ||
|
fb2d281aac | ||
|
8d0d08725d |
1
.eslintignore
Normal file
1
.eslintignore
Normal file
@@ -0,0 +1 @@
|
||||
public/serviceWorker.js
|
@@ -1,3 +1,4 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
"extends": "next/core-web-vitals",
|
||||
"plugins": ["prettier"]
|
||||
}
|
||||
|
52
.github/workflows/docker.yml
vendored
Normal file
52
.github/workflows/docker.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
name: Publish Docker image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
push_to_registry:
|
||||
name: Push Docker image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
-
|
||||
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
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: yidadaa/chatgpt-next-web
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=ref,event=tag
|
||||
|
||||
-
|
||||
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
|
||||
|
33
.github/workflows/sync.yml
vendored
Normal file
33
.github/workflows/sync.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
name: Upstream Sync
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 */6 * * *" # every 6 hours
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
sync_latest_from_upstream:
|
||||
name: Sync latest commits from upstream repo
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.repository.fork }}
|
||||
|
||||
steps:
|
||||
# Step 1: run a standard checkout action
|
||||
- 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
|
4
.gitignore
vendored
4
.gitignore
vendored
@@ -34,4 +34,6 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
dev
|
||||
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',
|
||||
};
|
40
Dockerfile
Normal file
40
Dockerfile
Normal file
@@ -0,0 +1,40 @@
|
||||
FROM node:18-alpine AS base
|
||||
|
||||
FROM base AS deps
|
||||
|
||||
RUN apk add --no-cache libc6-compat
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
RUN yarn install
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
RUN apk update && apk add --no-cache git
|
||||
|
||||
ENV OPENAI_API_KEY=""
|
||||
ENV CODE=""
|
||||
ARG DOCKER=true
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
RUN yarn build
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV OPENAI_API_KEY=""
|
||||
ENV CODE=""
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/.next/server ./.next/server
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node","server.js"]
|
116
README.md
116
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)
|
||||
|
||||
@@ -21,7 +21,8 @@ One-Click to deploy your own ChatGPT web UI.
|
||||
|
||||
- 在 1 分钟内使用 Vercel **免费一键部署**
|
||||
- 精心设计的 UI,响应式设计,支持深色模式
|
||||
- 极快的首屏加载速度(~85kb)
|
||||
- 极快的首屏加载速度(~100kb)
|
||||
- 海量的内置 prompt 列表,来自[中文](https://github.com/PlexPt/awesome-chatgpt-prompts-zh)和[英文](https://github.com/f/awesome-chatgpt-prompts)
|
||||
- 自动压缩上下文聊天记录,在节省 Token 的同时支持超长对话
|
||||
- 一键导出聊天记录,完整的 Markdown 支持
|
||||
- 拥有自己的域名?好上加好,绑定后即可在任何地方**无障碍**快速访问
|
||||
@@ -30,15 +31,29 @@ 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)
|
||||
- Fast first screen loading speed (~100kb)
|
||||
- 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
|
||||
@@ -59,7 +74,9 @@ One-Click to deploy your own ChatGPT web UI.
|
||||
- 前往 vercel 控制台,删除掉原先的 project,然后新建 project,选择你刚刚 fork 出来的项目重新进行部署即可;
|
||||
- 在重新部署的过程中,请手动添加名为 `OPENAI_API_KEY` 的环境变量,并填入你的 api key 作为值。
|
||||
|
||||
本项目会持续更新,如果你想让代码库总是保持更新,可以查看 [Github 的文档](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork) 了解如何让 fork 的项目与上游代码同步,建议定期进行同步操作以获得新功能。
|
||||
本项目会持续更新,当你 Fork 项目之后,默认会每天自动同步上游代码,无需额外操作。
|
||||
|
||||
如果你想让手动立即更新,可以查看 [Github 的文档](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork) 了解如何让 fork 的项目与上游代码同步。
|
||||
|
||||
你可以 star/watch 本项目或者 follow 作者来及时获得新功能更新通知。
|
||||
|
||||
@@ -72,13 +89,15 @@ We recommend that you follow the steps below to re-deploy:
|
||||
- Go to the Vercel dashboard, delete the original project, then create a new project and select the project you just forked to redeploy;
|
||||
- Please manually add an environment variable named `OPENAI_API_KEY` and enter your API key as the value during the redeploy process.
|
||||
|
||||
This project will be continuously maintained. If you want to keep the code repository up to date, you can check out the [Github documentation](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork) to learn how to synchronize a forked project with upstream code. It is recommended to perform synchronization operations regularly.
|
||||
This project will be continuously updated, and after forking the project, the upstream code will be automatically synchronized every day without additional operations.
|
||||
|
||||
If you want to update instantly, you can check out the [Github documentation](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork) to learn how to synchronize a forked project with upstream code.
|
||||
|
||||
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 +105,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 +113,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
|
||||
|
||||
点击下方按钮,开始二次开发:
|
||||
@@ -109,31 +160,24 @@ OPENAI_API_KEY=<your api key here>
|
||||
```
|
||||
|
||||
### 本地开发 Local Development
|
||||
|
||||
> 如果你是中国大陆用户,不建议在本地进行开发,除非你能够独立解决 OpenAI API 本地代理问题。
|
||||
|
||||
1. 安装 nodejs 和 yarn,具体细节请询问 ChatGPT;
|
||||
2. 执行 `yarn install && yarn dev` 即可。
|
||||
|
||||
### 本地部署 Local Deployment
|
||||
请直接询问 ChatGPT,使用下列 Prompt:
|
||||
```
|
||||
如何使用 pm2 和 yarn 部署 nextjs 项目到 ubuntu 服务器上,项目编译命令为 yarn build,启动命令为 yarn start,启动时需要设置环境变量为 OPENAI_API_KEY,端口为 3000,使用 ngnix 做反向代理
|
||||
|
||||
```shell
|
||||
bash <(curl -s https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/scripts/setup.sh)
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
### 容器部署 Docker Deployment
|
||||
|
||||
### Docker Deployment
|
||||
请直接询问 ChatGPT,使用下列 Prompt:
|
||||
```
|
||||
如何使用 docker 部署 nextjs 项目到 ubuntu 服务器上,项目编译命令为 yarn build,启动命令为 yarn start,启动时需要设置环境变量为 OPENAI_API_KEY,端口为 3000,使用 ngnix 做反向代理
|
||||
```
|
||||
```shell
|
||||
docker pull yidadaa/chatgpt-next-web
|
||||
|
||||
Please ask ChatGPT with prompt:
|
||||
```
|
||||
how to deploy nextjs project with docker 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
|
||||
docker run -d -p 3000:3000 -e OPENAI_API_KEY="" -e CODE="" yidadaa/chatgpt-next-web
|
||||
```
|
||||
|
||||
## 截图 Screenshots
|
||||
@@ -142,19 +186,19 @@ how to deploy nextjs project with docker on my ubuntu server, the build command
|
||||
|
||||

|
||||
|
||||
## 说明 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
|
||||
### 捐赠者 Sponsor
|
||||
|
||||
[@mushan0x0](https://github.com/mushan0x0)
|
||||
[@ClarenceDan](https://github.com/ClarenceDan)
|
||||
[@zhangjia](https://github.com/zhangjia)
|
||||
[@hoochanlon](https://github.com/hoochanlon)
|
||||
|
||||
### 贡献者 Contributor
|
||||
|
||||
[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,28 +1,22 @@
|
||||
import type { ChatRequest } from "../chat/typing";
|
||||
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,25 +38,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
643
app/components/chat.tsx
Normal file
643
app/components/chat.tsx
Normal file
@@ -0,0 +1,643 @@
|
||||
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, 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([]);
|
||||
if (!isMobileScreen()) inputRef.current?.focus();
|
||||
setAutoScroll(true);
|
||||
};
|
||||
|
||||
// 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(() => {
|
||||
if (props.sideBarShowing && isMobileScreen()) return;
|
||||
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.filter((msg) => !msg.isError),
|
||||
session.topic,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PromptToast
|
||||
showToast={!hitBottom}
|
||||
showModal={showPromptModal}
|
||||
setShowModal={setShowPromptModal}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={styles["chat-body"]}
|
||||
ref={scrollRef}
|
||||
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
|
||||
onWheel={(e) => setAutoScroll(hitBottom && e.deltaY > 0)}
|
||||
onTouchStart={() => {
|
||||
inputRef.current?.blur();
|
||||
setAutoScroll(false);
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
}
|
47
app/components/error.tsx
Normal file
47
app/components/error.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from "react";
|
||||
import { IconButton } from "./button";
|
||||
import GithubIcon from "../icons/github.svg";
|
||||
import { ISSUE_URL } from "../constant";
|
||||
|
||||
interface IErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
info: React.ErrorInfo | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends React.Component<any, IErrorBoundaryState> {
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null, info: null };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||
// Update state with error details
|
||||
this.setState({ hasError: true, error, info });
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// Render error message
|
||||
return (
|
||||
<div className="error">
|
||||
<h2>Oops, something went wrong!</h2>
|
||||
<pre>
|
||||
<code>{this.state.error?.toString()}</code>
|
||||
<code>{this.state.info?.componentStack}</code>
|
||||
</pre>
|
||||
|
||||
<a href={ISSUE_URL} className="report">
|
||||
<IconButton
|
||||
text="Report This Error"
|
||||
icon={<GithubIcon />}
|
||||
bordered
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// if no error occurred, render children
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
@@ -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 {
|
||||
|
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useLayoutEffect } from "react";
|
||||
require("../polyfill");
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import { IconButton } from "./button";
|
||||
import styles from "./home.module.scss";
|
||||
@@ -8,26 +10,21 @@ 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 { useChatStore } from "../store";
|
||||
import { isMobileScreen } 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";
|
||||
import { ErrorBoundary } from "./error";
|
||||
|
||||
export function Loading(props: { noLogo?: boolean }) {
|
||||
return (
|
||||
@@ -38,442 +35,50 @@ 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);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.classList.remove("light");
|
||||
document.body.classList.remove("dark");
|
||||
|
||||
if (config.theme === "dark") {
|
||||
document.body.classList.add("dark");
|
||||
} else if (config.theme === "light") {
|
||||
document.body.classList.add("light");
|
||||
}
|
||||
|
||||
const themeColor = getComputedStyle(document.body)
|
||||
.getPropertyValue("--theme-color")
|
||||
.trim();
|
||||
const metaDescription = document.querySelector('meta[name="theme-color"]');
|
||||
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`;
|
||||
const useHasHydrated = () => {
|
||||
const [hasHydrated, setHasHydrated] = useState<boolean>(false);
|
||||
|
||||
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)}
|
||||
/>,
|
||||
],
|
||||
});
|
||||
}
|
||||
useEffect(() => {
|
||||
setHasHydrated(true);
|
||||
}, []);
|
||||
|
||||
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)}
|
||||
/>,
|
||||
],
|
||||
});
|
||||
}
|
||||
return hasHydrated;
|
||||
};
|
||||
|
||||
export function Home() {
|
||||
function _Home() {
|
||||
const [createNewSession, currentIndex, removeSession] = useChatStore(
|
||||
(state) => [
|
||||
state.newSession,
|
||||
state.currentSessionIndex,
|
||||
state.removeSession,
|
||||
]
|
||||
],
|
||||
);
|
||||
const loading = !useChatStore?.persist?.hasHydrated();
|
||||
const loading = !useHasHydrated();
|
||||
const [showSideBar, setShowSideBar] = useState(true);
|
||||
|
||||
// setting
|
||||
@@ -489,7 +94,9 @@ export function Home() {
|
||||
return (
|
||||
<div
|
||||
className={`${
|
||||
config.tightBorder ? styles["tight-container"] : styles.container
|
||||
config.tightBorder && !isMobileScreen()
|
||||
? styles["tight-container"]
|
||||
: styles.container
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
@@ -534,11 +141,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>
|
||||
@@ -546,7 +154,11 @@ export function Home() {
|
||||
<IconButton
|
||||
icon={<AddIcon />}
|
||||
text={Locale.Home.NewChat}
|
||||
onClick={createNewSession}
|
||||
onClick={() => {
|
||||
createNewSession();
|
||||
setShowSideBar(false);
|
||||
}}
|
||||
shadow
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -561,9 +173,21 @@ export function Home() {
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Chat key="chat" showSideBar={() => setShowSideBar(true)} />
|
||||
<Chat
|
||||
key="chat"
|
||||
showSideBar={() => setShowSideBar(true)}
|
||||
sideBarShowing={showSideBar}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Home() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<_Home></_Home>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
@@ -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,
|
||||
}}
|
||||
|
@@ -18,3 +18,12 @@
|
||||
.avatar {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.password-input {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
.password-eye {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { useState, useEffect, useMemo, HTMLProps } from "react";
|
||||
|
||||
import EmojiPicker, { Theme as EmojiTheme } from "emoji-picker-react";
|
||||
|
||||
@@ -7,8 +7,11 @@ 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 EyeIcon from "../icons/eye.svg";
|
||||
import EyeOffIcon from "../icons/eye-off.svg";
|
||||
|
||||
import { List, ListItem, Popover } from "./ui-lib";
|
||||
import { List, ListItem, Popover, showToast } from "./ui-lib";
|
||||
|
||||
import { IconButton } from "./button";
|
||||
import {
|
||||
@@ -18,13 +21,17 @@ import {
|
||||
ALL_MODELS,
|
||||
useUpdateStore,
|
||||
useAccessStore,
|
||||
ModalConfigValidator,
|
||||
} 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";
|
||||
import { ErrorBoundary } from "./error";
|
||||
|
||||
function SettingItem(props: {
|
||||
title: string;
|
||||
@@ -44,20 +51,39 @@ function SettingItem(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordInput(props: HTMLProps<HTMLInputElement>) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
function changeVisibility() {
|
||||
setVisible(!visible);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles["password-input"]}>
|
||||
<IconButton
|
||||
icon={visible ? <EyeIcon /> : <EyeOffIcon />}
|
||||
onClick={changeVisibility}
|
||||
className={styles["password-eye"]}
|
||||
/>
|
||||
<input {...props} type={visible ? "text" : "password"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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,18 +94,49 @@ 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();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const accessStore = useAccessStore();
|
||||
const enabledAccessControl = useMemo(
|
||||
() => accessStore.enabledAccessControl(),
|
||||
[]
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
const promptStore = usePromptStore();
|
||||
const builtinCount = SearchService.count.builtin;
|
||||
const customCount = promptStore.prompts.size ?? 0;
|
||||
|
||||
const showUsage = accessStore.token !== "";
|
||||
useEffect(() => {
|
||||
if (showUsage) {
|
||||
checkUsage();
|
||||
}
|
||||
}, [showUsage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ErrorBoundary>
|
||||
<div className={styles["window-header"]}>
|
||||
<div className={styles["window-header-title"]}>
|
||||
<div className={styles["window-header-main-title"]}>
|
||||
@@ -93,7 +150,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 +230,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 +250,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 +263,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}
|
||||
<select
|
||||
value={getLang()}
|
||||
onChange={(e) => {
|
||||
changeLang(e.target.value as any);
|
||||
}}
|
||||
>
|
||||
{AllLangs.map((lang) => (
|
||||
<option value={lang} key={lang}>
|
||||
{Locale.Settings.Lang.Options[lang]}
|
||||
</option>
|
||||
|
||||
<option value="cn" key="cn">
|
||||
{Locale.Settings.Lang.Options.cn}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</select>
|
||||
</SettingItem>
|
||||
|
||||
<div className="no-mobile">
|
||||
<SettingItem title={Locale.Settings.TightBorder}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.tightBorder}
|
||||
onChange={(e) =>
|
||||
updateConfig(
|
||||
(config) => (config.tightBorder = e.currentTarget.checked)
|
||||
)
|
||||
}
|
||||
></input>
|
||||
</SettingItem>
|
||||
</div>
|
||||
<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),
|
||||
)
|
||||
}
|
||||
></input>
|
||||
</SettingItem>
|
||||
|
||||
<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 ? (
|
||||
@@ -244,14 +359,14 @@ export function Settings(props: { closeSettings: () => void }) {
|
||||
title={Locale.Settings.AccessCode.Title}
|
||||
subTitle={Locale.Settings.AccessCode.SubTitle}
|
||||
>
|
||||
<input
|
||||
<PasswordInput
|
||||
value={accessStore.accessCode}
|
||||
type="text"
|
||||
placeholder={Locale.Settings.AccessCode.Placeholder}
|
||||
onChange={(e) => {
|
||||
accessStore.updateCode(e.currentTarget.value);
|
||||
}}
|
||||
></input>
|
||||
/>
|
||||
</SettingItem>
|
||||
) : (
|
||||
<></>
|
||||
@@ -261,14 +376,35 @@ export function Settings(props: { closeSettings: () => void }) {
|
||||
title={Locale.Settings.Token.Title}
|
||||
subTitle={Locale.Settings.Token.SubTitle}
|
||||
>
|
||||
<input
|
||||
<PasswordInput
|
||||
value={accessStore.token}
|
||||
type="text"
|
||||
placeholder={Locale.Settings.Token.Placeholder}
|
||||
onChange={(e) => {
|
||||
accessStore.updateToken(e.currentTarget.value);
|
||||
}}
|
||||
></input>
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
title={Locale.Settings.Usage.Title}
|
||||
subTitle={
|
||||
showUsage
|
||||
? loadingUsage
|
||||
? Locale.Settings.Usage.IsChecking
|
||||
: Locale.Settings.Usage.SubTitle(usage?.used ?? "[?]")
|
||||
: Locale.Settings.Usage.NoAccess
|
||||
}
|
||||
>
|
||||
{!showUsage || loadingUsage ? (
|
||||
<div />
|
||||
) : (
|
||||
<IconButton
|
||||
icon={<ResetIcon></ResetIcon>}
|
||||
text={Locale.Settings.Usage.Check}
|
||||
onClick={checkUsage}
|
||||
/>
|
||||
)}
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
@@ -279,13 +415,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 +440,7 @@ export function Settings(props: { closeSettings: () => void }) {
|
||||
updateConfig(
|
||||
(config) =>
|
||||
(config.compressMessageLengthThreshold =
|
||||
e.currentTarget.valueAsNumber)
|
||||
e.currentTarget.valueAsNumber),
|
||||
)
|
||||
}
|
||||
></input>
|
||||
@@ -317,7 +453,10 @@ export function Settings(props: { closeSettings: () => void }) {
|
||||
value={config.modelConfig.model}
|
||||
onChange={(e) => {
|
||||
updateConfig(
|
||||
(config) => (config.modelConfig.model = e.currentTarget.value)
|
||||
(config) =>
|
||||
(config.modelConfig.model = ModalConfigValidator.model(
|
||||
e.currentTarget.value,
|
||||
)),
|
||||
);
|
||||
}}
|
||||
>
|
||||
@@ -334,15 +473,17 @@ export function Settings(props: { closeSettings: () => void }) {
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
value={config.modelConfig.temperature.toFixed(1)}
|
||||
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)
|
||||
ModalConfigValidator.temperature(
|
||||
e.currentTarget.valueAsNumber,
|
||||
)),
|
||||
);
|
||||
}}
|
||||
></input>
|
||||
@@ -354,13 +495,15 @@ export function Settings(props: { closeSettings: () => void }) {
|
||||
<input
|
||||
type="number"
|
||||
min={100}
|
||||
max={4000}
|
||||
max={32000}
|
||||
value={config.modelConfig.max_tokens}
|
||||
onChange={(e) =>
|
||||
updateConfig(
|
||||
(config) =>
|
||||
(config.modelConfig.max_tokens =
|
||||
e.currentTarget.valueAsNumber)
|
||||
ModalConfigValidator.max_tokens(
|
||||
e.currentTarget.valueAsNumber,
|
||||
)),
|
||||
)
|
||||
}
|
||||
></input>
|
||||
@@ -371,7 +514,7 @@ export function Settings(props: { closeSettings: () => void }) {
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
value={config.modelConfig.presence_penalty.toFixed(1)}
|
||||
value={config.modelConfig.presence_penalty?.toFixed(1)}
|
||||
min="-2"
|
||||
max="2"
|
||||
step="0.5"
|
||||
@@ -379,13 +522,15 @@ export function Settings(props: { closeSettings: () => void }) {
|
||||
updateConfig(
|
||||
(config) =>
|
||||
(config.modelConfig.presence_penalty =
|
||||
e.currentTarget.valueAsNumber)
|
||||
ModalConfigValidator.presence_penalty(
|
||||
e.currentTarget.valueAsNumber,
|
||||
)),
|
||||
);
|
||||
}}
|
||||
></input>
|
||||
</SettingItem>
|
||||
</List>
|
||||
</div>
|
||||
</>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
@@ -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;
|
||||
@@ -32,4 +33,4 @@
|
||||
|
||||
.window-action-button {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,7 @@
|
||||
export const OWNER = "Yidadaa";
|
||||
export const REPO = "ChatGPT-Next-Web";
|
||||
export const REPO_URL = `https://github.com/${OWNER}/${REPO}`;
|
||||
export const ISSUE_URL = `https://github.com/${OWNER}/${REPO}/issues`;
|
||||
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 |
4
app/icons/eye-off.svg
Normal file
4
app/icons/eye-off.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.7071 5.70711C20.0976 5.31658 20.0976 4.68342 19.7071 4.29289C19.3166 3.90237 18.6834 3.90237 18.2929 4.29289L14.032 8.55382C13.4365 8.20193 12.7418 8 12 8C9.79086 8 8 9.79086 8 12C8 12.7418 8.20193 13.4365 8.55382 14.032L4.29289 18.2929C3.90237 18.6834 3.90237 19.3166 4.29289 19.7071C4.68342 20.0976 5.31658 20.0976 5.70711 19.7071L9.96803 15.4462C10.5635 15.7981 11.2582 16 12 16C14.2091 16 16 14.2091 16 12C16 11.2582 15.7981 10.5635 15.4462 9.96803L19.7071 5.70711ZM12.518 10.0677C12.3528 10.0236 12.1792 10 12 10C10.8954 10 10 10.8954 10 12C10 12.1792 10.0236 12.3528 10.0677 12.518L12.518 10.0677ZM11.482 13.9323L13.9323 11.482C13.9764 11.6472 14 11.8208 14 12C14 13.1046 13.1046 14 12 14C11.8208 14 11.6472 13.9764 11.482 13.9323ZM15.7651 4.8207C14.6287 4.32049 13.3675 4 12 4C9.14754 4 6.75717 5.39462 4.99812 6.90595C3.23268 8.42276 2.00757 10.1376 1.46387 10.9698C1.05306 11.5985 1.05306 12.4015 1.46387 13.0302C1.92276 13.7326 2.86706 15.0637 4.21194 16.3739L5.62626 14.9596C4.4555 13.8229 3.61144 12.6531 3.18002 12C3.6904 11.2274 4.77832 9.73158 6.30147 8.42294C7.87402 7.07185 9.81574 6 12 6C12.7719 6 13.5135 6.13385 14.2193 6.36658L15.7651 4.8207ZM12 18C11.2282 18 10.4866 17.8661 9.78083 17.6334L8.23496 19.1793C9.37136 19.6795 10.6326 20 12 20C14.8525 20 17.2429 18.6054 19.002 17.0941C20.7674 15.5772 21.9925 13.8624 22.5362 13.0302C22.947 12.4015 22.947 11.5985 22.5362 10.9698C22.0773 10.2674 21.133 8.93627 19.7881 7.62611L18.3738 9.04043C19.5446 10.1771 20.3887 11.3469 20.8201 12C20.3097 12.7726 19.2218 14.2684 17.6986 15.5771C16.1261 16.9282 14.1843 18 12 18Z" fill="#000000"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.8 KiB |
4
app/icons/eye.svg
Normal file
4
app/icons/eye.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.30147 15.5771C4.77832 14.2684 3.6904 12.7726 3.18002 12C3.6904 11.2274 4.77832 9.73158 6.30147 8.42294C7.87402 7.07185 9.81574 6 12 6C14.1843 6 16.1261 7.07185 17.6986 8.42294C19.2218 9.73158 20.3097 11.2274 20.8201 12C20.3097 12.7726 19.2218 14.2684 17.6986 15.5771C16.1261 16.9282 14.1843 18 12 18C9.81574 18 7.87402 16.9282 6.30147 15.5771ZM12 4C9.14754 4 6.75717 5.39462 4.99812 6.90595C3.23268 8.42276 2.00757 10.1376 1.46387 10.9698C1.05306 11.5985 1.05306 12.4015 1.46387 13.0302C2.00757 13.8624 3.23268 15.5772 4.99812 17.0941C6.75717 18.6054 9.14754 20 12 20C14.8525 20 17.2429 18.6054 19.002 17.0941C20.7674 15.5772 21.9925 13.8624 22.5362 13.0302C22.947 12.4015 22.947 11.5985 22.5362 10.9698C21.9925 10.1376 20.7674 8.42276 19.002 6.90595C17.2429 5.39462 14.8525 4 12 4ZM10 12C10 10.8954 10.8955 10 12 10C13.1046 10 14 10.8954 14 12C14 13.1046 13.1046 14 12 14C10.8955 14 10 13.1046 10 12ZM12 8C9.7909 8 8.00004 9.79086 8.00004 12C8.00004 14.2091 9.7909 16 12 16C14.2092 16 16 14.2091 16 12C16 9.79086 14.2092 8 12 8Z" fill="#000000"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.3 KiB |
@@ -1,24 +1,35 @@
|
||||
/* 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";
|
||||
|
||||
const COMMIT_ID = process
|
||||
.execSync("git rev-parse --short HEAD")
|
||||
.toString()
|
||||
.trim();
|
||||
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.");
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: "ChatGPT Next Web",
|
||||
description: "Your personal ChatGPT Chat Bot.",
|
||||
appleWebApp: {
|
||||
title: "ChatGPT Next Web",
|
||||
statusBarStyle: "black-translucent",
|
||||
},
|
||||
themeColor: "#fafafa",
|
||||
};
|
||||
|
||||
function Meta() {
|
||||
const metas = {
|
||||
version: COMMIT_ID,
|
||||
access: ACCESS_CODES.size > 0 ? "enabled" : "disabled",
|
||||
version: COMMIT_ID ?? "unknown",
|
||||
access: ACCESS_CODES.size > 0 || IS_IN_DOCKER ? "enabled" : "disabled",
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -50,6 +61,7 @@ export default function RootLayout({
|
||||
href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;700;900&display=swap"
|
||||
rel="stylesheet"
|
||||
></link>
|
||||
<script src="/serviceWorkerRegister.js" defer></script>
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
|
@@ -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,19 @@ const cn = {
|
||||
Lang: {
|
||||
Name: "Language",
|
||||
Options: {
|
||||
cn: "中文",
|
||||
cn: "简体中文",
|
||||
en: "English",
|
||||
tw: "繁體中文",
|
||||
es: "Español",
|
||||
it: "Italiano",
|
||||
},
|
||||
},
|
||||
Avatar: "头像",
|
||||
FontSize: {
|
||||
Title: "字体大小",
|
||||
SubTitle: "聊天内容的字体大小",
|
||||
},
|
||||
|
||||
Update: {
|
||||
Version: (x: string) => `当前版本:${x}`,
|
||||
IsLatest: "已是最新版本",
|
||||
@@ -61,6 +78,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 +102,15 @@ const cn = {
|
||||
SubTitle: "使用自己的 Key 可绕过受控访问限制",
|
||||
Placeholder: "OpenAI API Key",
|
||||
},
|
||||
Usage: {
|
||||
Title: "账户余额",
|
||||
SubTitle(used: any) {
|
||||
return `本月已使用 $${used}`;
|
||||
},
|
||||
IsChecking: "正在检查…",
|
||||
Check: "重新检查",
|
||||
NoAccess: "输入API Key查看余额",
|
||||
},
|
||||
AccessCode: {
|
||||
Title: "访问码",
|
||||
SubTitle: "现在是受控访问状态",
|
||||
@@ -101,9 +138,9 @@ const cn = {
|
||||
History: (content: string) =>
|
||||
"这是 ai 和用户的历史聊天总结作为前情提要:" + content,
|
||||
Topic:
|
||||
"直接返回这句话的简要主题,不要解释,如果没有主题,请直接返回“闲聊”",
|
||||
"使用四到五个字直接返回这句话的简要主题,不要解释、不要标点、不要语气词、不要多余文本,如果没有主题,请直接返回“闲聊”",
|
||||
Summarize:
|
||||
"简要总结一下你和用户的对话,用作后续的上下文提示 prompt,控制在 50 字以内",
|
||||
"简要总结一下你和用户的对话,用作后续的上下文提示 prompt,控制在 200 字以内",
|
||||
},
|
||||
ConfirmClearAll: "确认清除所有聊天、设置数据?",
|
||||
},
|
||||
@@ -111,6 +148,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,20 @@ 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",
|
||||
it: "Italiano",
|
||||
},
|
||||
},
|
||||
Avatar: "Avatar",
|
||||
FontSize: {
|
||||
Title: "Font Size",
|
||||
SubTitle: "Adjust font size of chat content",
|
||||
},
|
||||
Update: {
|
||||
Version: (x: string) => `Version: ${x}`,
|
||||
IsLatest: "Latest version",
|
||||
@@ -65,6 +79,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 +104,15 @@ 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",
|
||||
NoAccess: "Enter API Key to check balance",
|
||||
},
|
||||
AccessCode: {
|
||||
Title: "Access Code",
|
||||
SubTitle: "Access control enabled",
|
||||
@@ -108,9 +142,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 +152,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;
|
||||
|
164
app/locales/es.ts
Normal file
164
app/locales/es.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
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",
|
||||
it: "Italiano",
|
||||
},
|
||||
},
|
||||
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",
|
||||
NoAccess: "Introduzca la clave API para comprobar el saldo",
|
||||
},
|
||||
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,63 @@
|
||||
import CN from './cn'
|
||||
import EN from './en'
|
||||
import CN from "./cn";
|
||||
import EN from "./en";
|
||||
import TW from "./tw";
|
||||
import ES from "./es";
|
||||
import IT from "./it";
|
||||
|
||||
export type { LocaleType } from './cn'
|
||||
export type { LocaleType } from "./cn";
|
||||
|
||||
type Lang = 'en' | 'cn'
|
||||
export const AllLangs = ["en", "cn", "tw", "es", "it"] as const;
|
||||
type Lang = (typeof AllLangs)[number];
|
||||
|
||||
const LANG_KEY = 'lang'
|
||||
const LANG_KEY = "lang";
|
||||
|
||||
function getItem(key: string) {
|
||||
try {
|
||||
return localStorage.getItem(key)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setItem(key: string, value: string) {
|
||||
try {
|
||||
localStorage.setItem(key, value)
|
||||
} catch { }
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function getLanguage() {
|
||||
try {
|
||||
return navigator.language.toLowerCase()
|
||||
} catch {
|
||||
return 'cn'
|
||||
}
|
||||
try {
|
||||
return navigator.language.toLowerCase();
|
||||
} catch {
|
||||
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'
|
||||
} else {
|
||||
return 'en'
|
||||
}
|
||||
if (lang.includes("zh") || lang.includes("cn")) {
|
||||
return "cn";
|
||||
} else if (lang.includes("tw")) {
|
||||
return "tw";
|
||||
} else if (lang.includes("es")) {
|
||||
return "es";
|
||||
} else if (lang.includes("it")) {
|
||||
return "it";
|
||||
} else {
|
||||
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, it: IT }[getLang()];
|
||||
|
165
app/locales/it.ts
Normal file
165
app/locales/it.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { SubmitKey } from "../store/app";
|
||||
import type { LocaleType } from "./index";
|
||||
|
||||
const it: LocaleType = {
|
||||
WIP: "Work in progress...",
|
||||
Error: {
|
||||
Unauthorized:
|
||||
"Accesso non autorizzato, inserire il codice di accesso nella pagina delle impostazioni.",
|
||||
},
|
||||
ChatItem: {
|
||||
ChatItemCount: (count: number) => `${count} messaggi`,
|
||||
},
|
||||
Chat: {
|
||||
SubTitle: (count: number) => `${count} messaggi con ChatGPT`,
|
||||
Actions: {
|
||||
ChatList: "Vai alla Chat List",
|
||||
CompressedHistory: "Prompt di memoria della cronologia compressa",
|
||||
Export: "Esportazione di tutti i messaggi come Markdown",
|
||||
Copy: "Copia",
|
||||
Stop: "Stop",
|
||||
Retry: "Riprova",
|
||||
},
|
||||
Rename: "Rinomina Chat",
|
||||
Typing: "Typing…",
|
||||
Input: (submitKey: string) => {
|
||||
var inputHints = `Scrivi qualcosa e premi ${submitKey} per inviare`;
|
||||
if (submitKey === String(SubmitKey.Enter)) {
|
||||
inputHints += ", premi Shift + Enter per andare a capo";
|
||||
}
|
||||
return inputHints;
|
||||
},
|
||||
Send: "Invia",
|
||||
},
|
||||
Export: {
|
||||
Title: "Tutti i messaggi",
|
||||
Copy: "Copia tutto",
|
||||
Download: "Scarica",
|
||||
},
|
||||
Memory: {
|
||||
Title: "Prompt di memoria",
|
||||
EmptyContent: "Vuoto.",
|
||||
Copy: "Copia tutto",
|
||||
},
|
||||
Home: {
|
||||
NewChat: "Nuova Chat",
|
||||
DeleteChat: "Confermare la cancellazione della conversazione selezionata?",
|
||||
},
|
||||
Settings: {
|
||||
Title: "Impostazioni",
|
||||
SubTitle: "Tutte le impostazioni",
|
||||
Actions: {
|
||||
ClearAll: "Cancella tutti i dati",
|
||||
ResetAll: "Resetta tutte le impostazioni",
|
||||
Close: "Chiudi",
|
||||
},
|
||||
Lang: {
|
||||
Name: "Lingue",
|
||||
Options: {
|
||||
cn: "简体中文",
|
||||
en: "English",
|
||||
tw: "繁體中文",
|
||||
es: "Español",
|
||||
it: "Italiano",
|
||||
},
|
||||
},
|
||||
Avatar: "Avatar",
|
||||
FontSize: {
|
||||
Title: "Dimensione carattere",
|
||||
SubTitle: "Regolare la dimensione dei caratteri del contenuto della chat",
|
||||
},
|
||||
Update: {
|
||||
Version: (x: string) => `Versione: ${x}`,
|
||||
IsLatest: "Ultima versione",
|
||||
CheckUpdate: "Controlla aggiornamenti",
|
||||
IsChecking: "Sto controllando gli aggiornamenti...",
|
||||
FoundUpdate: (x: string) => `Trovata nuova versione: ${x}`,
|
||||
GoToUpdate: "Aggiorna",
|
||||
},
|
||||
SendKey: "Tasto invia",
|
||||
Theme: "tema",
|
||||
TightBorder: "Bordi stretti",
|
||||
SendPreviewBubble: "Invia l'anteprima della bolla",
|
||||
Prompt: {
|
||||
Disable: {
|
||||
Title: "Disabilita l'auto completamento",
|
||||
SubTitle: "Input / per attivare il completamento automatico",
|
||||
},
|
||||
List: "Elenco dei suggerimenti",
|
||||
ListCount: (builtin: number, custom: number) =>
|
||||
`${builtin} built-in, ${custom} user-defined`,
|
||||
Edit: "Modifica",
|
||||
},
|
||||
HistoryCount: {
|
||||
Title: "Conteggio dei messaggi allegati",
|
||||
SubTitle: "Numero di messaggi inviati allegati per richiesta",
|
||||
},
|
||||
CompressThreshold: {
|
||||
Title: "Soglia di compressione della cronologia",
|
||||
SubTitle:
|
||||
"Comprimerà se la lunghezza dei messaggi non compressi supera il valore",
|
||||
},
|
||||
Token: {
|
||||
Title: "Chiave API",
|
||||
SubTitle:
|
||||
"Utilizzare la chiave per ignorare il limite del codice di accesso",
|
||||
Placeholder: "OpenAI API Key",
|
||||
},
|
||||
Usage: {
|
||||
Title: "Bilancio Account",
|
||||
SubTitle(used: any) {
|
||||
return `Usato in questo mese $${used}`;
|
||||
},
|
||||
IsChecking: "Controllando...",
|
||||
Check: "Controlla ancora",
|
||||
NoAccess: "Inserire la chiave API per controllare il saldo",
|
||||
},
|
||||
AccessCode: {
|
||||
Title: "Codice d'accesso",
|
||||
SubTitle: "Controllo d'accesso abilitato",
|
||||
Placeholder: "Inserisci il codice d'accesso",
|
||||
},
|
||||
Model: "Modello GPT",
|
||||
Temperature: {
|
||||
Title: "Temperature",
|
||||
SubTitle: "Un valore maggiore rende l'output più casuale",
|
||||
},
|
||||
MaxTokens: {
|
||||
Title: "Token massimi",
|
||||
SubTitle: "Lunghezza massima dei token in ingresso e dei token generati",
|
||||
},
|
||||
PresencePenlty: {
|
||||
Title: "Penalità di presenza",
|
||||
SubTitle:
|
||||
"Un valore maggiore aumenta la probabilità di parlare di nuovi argomenti",
|
||||
},
|
||||
},
|
||||
Store: {
|
||||
DefaultTopic: "Nuova conversazione",
|
||||
BotHello: "Ciao, come posso aiutarti oggi?",
|
||||
Error: "Qualcosa è andato storto, riprova più tardi.",
|
||||
Prompt: {
|
||||
History: (content: string) =>
|
||||
"Questo è un riassunto della cronologia delle chat tra l'IA e l'utente:" +
|
||||
content,
|
||||
Topic:
|
||||
"Si prega di generare un titolo di quattro o cinque parole che riassuma la nostra conversazione senza alcuna traccia, punteggiatura, virgolette, punti, simboli o testo aggiuntivo. Rimuovere le virgolette",
|
||||
Summarize:
|
||||
"Riassumi brevemente la nostra discussione in 200 caratteri o meno per usarla come spunto per una futura conversazione.",
|
||||
},
|
||||
ConfirmClearAll:
|
||||
"Confermi la cancellazione di tutti i dati della chat e delle impostazioni?",
|
||||
},
|
||||
Copy: {
|
||||
Success: "Copiato sugli appunti",
|
||||
Failed:
|
||||
"Copia fallita, concedere l'autorizzazione all'accesso agli appunti",
|
||||
},
|
||||
Context: {
|
||||
Toast: (x: any) => `Con ${x} prompts contestuali`,
|
||||
Edit: "Prompt contestuali e di memoria",
|
||||
Add: "Aggiungi altro",
|
||||
},
|
||||
};
|
||||
|
||||
export default it;
|
157
app/locales/tw.ts
Normal file
157
app/locales/tw.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
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",
|
||||
it: "Italiano",
|
||||
},
|
||||
},
|
||||
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: "重新檢查",
|
||||
NoAccess: "輸入API Key查看餘額",
|
||||
},
|
||||
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,5 @@
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
|
||||
import { Home } from "./components/home";
|
||||
|
||||
export default function App() {
|
||||
|
27
app/polyfill.ts
Normal file
27
app/polyfill.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
declare global {
|
||||
interface Array<T> {
|
||||
at(index: number): T | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.prototype.at) {
|
||||
Array.prototype.at = function (index: number) {
|
||||
// Get the length of the array
|
||||
const length = this.length;
|
||||
|
||||
// Convert negative index to a positive index
|
||||
if (index < 0) {
|
||||
index = length + index;
|
||||
}
|
||||
|
||||
// Return undefined if the index is out of range
|
||||
if (index < 0 || index >= length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Use Array.prototype.slice method to get value at the specified index
|
||||
return Array.prototype.slice.call(this, index, index + 1)[0];
|
||||
};
|
||||
}
|
||||
|
||||
export {};
|
@@ -1,6 +1,7 @@
|
||||
import type { ChatRequest, ChatReponse } from "./api/chat/typing";
|
||||
import { filterConfig, Message, ModelConfig, useAccessStore } from "./store";
|
||||
import type { ChatRequest, ChatReponse } from "./api/openai/typing";
|
||||
import { 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(
|
||||
@@ -63,20 +114,15 @@ export async function requestChatStream(
|
||||
filterBot?: boolean;
|
||||
modelConfig?: ModelConfig;
|
||||
onMessage: (message: string, done: boolean) => void;
|
||||
onError: (error: Error) => void;
|
||||
onError: (error: Error, statusCode?: number) => void;
|
||||
onController?: (controller: AbortController) => void;
|
||||
}
|
||||
},
|
||||
) {
|
||||
const req = makeRequestParam(messages, {
|
||||
stream: true,
|
||||
filterBot: options?.filterBot,
|
||||
});
|
||||
|
||||
// valid and assign model config
|
||||
if (options?.modelConfig) {
|
||||
Object.assign(req, filterConfig(options.modelConfig));
|
||||
}
|
||||
|
||||
console.log("[Request] ", req);
|
||||
|
||||
const controller = new AbortController();
|
||||
@@ -87,6 +133,7 @@ export async function requestChatStream(
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
path: "v1/chat/completions",
|
||||
...getHeaders(),
|
||||
},
|
||||
body: JSON.stringify(req),
|
||||
@@ -126,11 +173,10 @@ export async function requestChatStream(
|
||||
finish();
|
||||
} else if (res.status === 401) {
|
||||
console.error("Anauthorized");
|
||||
responseText = Locale.Error.Unauthorized;
|
||||
finish();
|
||||
options?.onError(new Error("Anauthorized"), res.status);
|
||||
} else {
|
||||
console.error("Stream Error");
|
||||
options?.onError(new Error("Stream Error"));
|
||||
console.error("Stream Error", res.body);
|
||||
options?.onError(new Error("Stream Error"), res.status);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("NetWork Error", err);
|
||||
@@ -149,7 +195,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 +205,7 @@ export const ControllerPool = {
|
||||
addController(
|
||||
sessionIndex: number,
|
||||
messageIndex: number,
|
||||
controller: AbortController
|
||||
controller: AbortController,
|
||||
) {
|
||||
const key = this.key(sessionIndex, messageIndex);
|
||||
this.controllers[key] = controller;
|
||||
|
177
app/store/app.ts
177
app/store/app.ts
@@ -14,6 +14,7 @@ import Locale from "../locales";
|
||||
export type Message = ChatCompletionResponseMessage & {
|
||||
date: string;
|
||||
streaming?: boolean;
|
||||
isError?: boolean;
|
||||
};
|
||||
|
||||
export enum SubmitKey {
|
||||
@@ -21,6 +22,7 @@ export enum SubmitKey {
|
||||
CtrlEnter = "Ctrl + Enter",
|
||||
ShiftEnter = "Shift + Enter",
|
||||
AltEnter = "Alt + Enter",
|
||||
MetaEnter = "Meta + Enter",
|
||||
}
|
||||
|
||||
export enum Theme {
|
||||
@@ -30,14 +32,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 +54,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 = [
|
||||
@@ -78,41 +85,39 @@ export const ALL_MODELS = [
|
||||
},
|
||||
];
|
||||
|
||||
export function isValidModel(name: string) {
|
||||
return ALL_MODELS.some((m) => m.name === name && m.available);
|
||||
export function limitNumber(
|
||||
x: number,
|
||||
min: number,
|
||||
max: number,
|
||||
defaultValue: number,
|
||||
) {
|
||||
if (typeof x !== "number" || isNaN(x)) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return Math.min(max, Math.max(min, x));
|
||||
}
|
||||
|
||||
export function isValidNumber(x: number, min: number, max: number) {
|
||||
return typeof x === "number" && x <= max && x >= min;
|
||||
export function limitModel(name: string) {
|
||||
return ALL_MODELS.some((m) => m.name === name && m.available)
|
||||
? name
|
||||
: ALL_MODELS[4].name;
|
||||
}
|
||||
|
||||
export function filterConfig(config: ModelConfig): Partial<ModelConfig> {
|
||||
const validator: {
|
||||
[k in keyof ModelConfig]: (x: ModelConfig[keyof ModelConfig]) => boolean;
|
||||
} = {
|
||||
model(x) {
|
||||
return isValidModel(x as string);
|
||||
},
|
||||
max_tokens(x) {
|
||||
return isValidNumber(x as number, 100, 4000);
|
||||
},
|
||||
presence_penalty(x) {
|
||||
return isValidNumber(x as number, -2, 2);
|
||||
},
|
||||
temperature(x) {
|
||||
return isValidNumber(x as number, 0, 1);
|
||||
},
|
||||
};
|
||||
|
||||
Object.keys(validator).forEach((k) => {
|
||||
const key = k as keyof ModelConfig;
|
||||
if (!validator[key](config[key])) {
|
||||
delete config[key];
|
||||
}
|
||||
});
|
||||
|
||||
return config;
|
||||
}
|
||||
export const ModalConfigValidator = {
|
||||
model(x: string) {
|
||||
return limitModel(x);
|
||||
},
|
||||
max_tokens(x: number) {
|
||||
return limitNumber(x, 0, 32000, 2000);
|
||||
},
|
||||
presence_penalty(x: number) {
|
||||
return limitNumber(x, -2, 2, 0);
|
||||
},
|
||||
temperature(x: number) {
|
||||
return limitNumber(x, 0, 2, 1);
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_CONFIG: ChatConfig = {
|
||||
historyMessageCount: 4,
|
||||
@@ -120,8 +125,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 +150,7 @@ export interface ChatSession {
|
||||
id: number;
|
||||
topic: string;
|
||||
memoryPrompt: string;
|
||||
context: Message[];
|
||||
messages: Message[];
|
||||
stat: ChatStat;
|
||||
lastUpdate: string;
|
||||
@@ -148,6 +158,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 +171,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 +187,7 @@ interface ChatStore {
|
||||
config: ChatConfig;
|
||||
sessions: ChatSession[];
|
||||
currentSessionIndex: number;
|
||||
clearSessions: () => void;
|
||||
removeSession: (index: number) => void;
|
||||
selectSession: (index: number) => void;
|
||||
newSession: () => void;
|
||||
@@ -189,7 +200,7 @@ interface ChatStore {
|
||||
updateMessage: (
|
||||
sessionIndex: number,
|
||||
messageIndex: number,
|
||||
updater: (message?: Message) => void
|
||||
updater: (message?: Message) => void,
|
||||
) => void;
|
||||
getMessagesWithMemory: () => Message[];
|
||||
getMemoryPrompt: () => Message;
|
||||
@@ -200,6 +211,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 +226,13 @@ export const useChatStore = create<ChatStore>()(
|
||||
...DEFAULT_CONFIG,
|
||||
},
|
||||
|
||||
clearSessions() {
|
||||
set(() => ({
|
||||
sessions: [createEmptySession()],
|
||||
currentSessionIndex: 0,
|
||||
}));
|
||||
},
|
||||
|
||||
resetConfig() {
|
||||
set(() => ({ config: { ...DEFAULT_CONFIG } }));
|
||||
},
|
||||
@@ -326,9 +348,15 @@ export const useChatStore = create<ChatStore>()(
|
||||
set(() => ({}));
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
botMessage.content += "\n\n" + Locale.Store.Error;
|
||||
onError(error, statusCode) {
|
||||
if (statusCode === 401) {
|
||||
botMessage.content = Locale.Error.Unauthorized;
|
||||
} else {
|
||||
botMessage.content += "\n\n" + Locale.Store.Error;
|
||||
}
|
||||
botMessage.streaming = false;
|
||||
userMessage.isError = true;
|
||||
botMessage.isError = true;
|
||||
set(() => ({}));
|
||||
ControllerPool.remove(sessionIndex, messageIndex);
|
||||
},
|
||||
@@ -337,7 +365,7 @@ export const useChatStore = create<ChatStore>()(
|
||||
ControllerPool.addController(
|
||||
sessionIndex,
|
||||
messageIndex,
|
||||
controller
|
||||
controller,
|
||||
);
|
||||
},
|
||||
filterBot: !get().config.sendBotMessages,
|
||||
@@ -358,24 +386,27 @@ export const useChatStore = create<ChatStore>()(
|
||||
getMessagesWithMemory() {
|
||||
const session = get().currentSession();
|
||||
const config = get().config;
|
||||
const n = session.messages.length;
|
||||
const recentMessages = session.messages.slice(
|
||||
n - config.historyMessageCount
|
||||
);
|
||||
const messages = session.messages.filter((msg) => !msg.isError);
|
||||
const n = messages.length;
|
||||
|
||||
const memoryPrompt = get().getMemoryPrompt();
|
||||
const context = session.context.slice();
|
||||
|
||||
if (session.memoryPrompt) {
|
||||
recentMessages.unshift(memoryPrompt);
|
||||
if (session.memoryPrompt && session.memoryPrompt.length > 0) {
|
||||
const memoryPrompt = get().getMemoryPrompt();
|
||||
context.push(memoryPrompt);
|
||||
}
|
||||
|
||||
const recentMessages = context.concat(
|
||||
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 +418,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 +456,7 @@ export const useChatStore = create<ChatStore>()(
|
||||
"[Chat History] ",
|
||||
toBeSummarizedMsgs,
|
||||
historyMsgLength,
|
||||
config.compressMessageLengthThreshold
|
||||
config.compressMessageLengthThreshold,
|
||||
);
|
||||
|
||||
if (historyMsgLength > config.compressMessageLengthThreshold) {
|
||||
@@ -444,7 +478,7 @@ export const useChatStore = create<ChatStore>()(
|
||||
onError(error) {
|
||||
console.error("[Summarize] ", error);
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -472,7 +506,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);
|
||||
}
|
||||
}
|
@@ -7,6 +7,7 @@
|
||||
--second: rgb(231, 248, 255);
|
||||
--hover-color: #f3f3f3;
|
||||
--bar-color: rgba(0, 0, 0, 0.1);
|
||||
--theme-color: var(--gray);
|
||||
|
||||
/* shadow */
|
||||
--shadow: 50px 50px 100px 10px rgb(0, 0, 0, 0.1);
|
||||
@@ -28,6 +29,8 @@
|
||||
--bar-color: rgba(255, 255, 255, 0.1);
|
||||
|
||||
--border-in-light: 1px solid rgba(255, 255, 255, 0.192);
|
||||
|
||||
--theme-color: var(--gray);
|
||||
}
|
||||
|
||||
.light {
|
||||
@@ -50,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%;
|
||||
@@ -71,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;
|
||||
@@ -85,6 +92,10 @@ body {
|
||||
user-select: none;
|
||||
font-family: "Noto Sans SC", "SF Pro SC", "SF Pro Text", "SF Pro Icons",
|
||||
"PingFang SC", "Helvetica Neue", "Helvetica", "Arial", sans-serif;
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
background-color: var(--second);
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
@@ -106,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"] {
|
||||
@@ -168,11 +184,12 @@ input[type="range"]::-webkit-slider-thumb:hover {
|
||||
}
|
||||
|
||||
input[type="number"],
|
||||
input[type="text"] {
|
||||
input[type="text"],
|
||||
input[type="password"] {
|
||||
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);
|
||||
@@ -189,7 +206,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;
|
||||
@@ -219,6 +236,7 @@ pre {
|
||||
.copy-code-button {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 1em;
|
||||
cursor: pointer;
|
||||
padding: 0px 5px;
|
||||
background-color: var(--black);
|
||||
@@ -239,3 +257,30 @@ pre {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
|
||||
div:not(.no-dark) > svg {
|
||||
filter: invert(0.5);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
width: 80%;
|
||||
border-radius: 20px;
|
||||
border: var(--border-in-light);
|
||||
box-shadow: var(--card-shadow);
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
background-color: var(--white);
|
||||
color: var(--black);
|
||||
|
||||
pre {
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
115
app/styles/highlight.scss
Normal file
115
app/styles/highlight.scss
Normal file
@@ -0,0 +1,115 @@
|
||||
.markdown-body {
|
||||
pre {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
pre,
|
||||
code {
|
||||
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
|
||||
}
|
||||
|
||||
pre code {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 3px 5px;
|
||||
}
|
||||
|
||||
.hljs,
|
||||
pre {
|
||||
background: #1a1b26;
|
||||
color: #cbd2ea;
|
||||
}
|
||||
|
||||
/*!
|
||||
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-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;
|
||||
@@ -1117,4 +1116,4 @@
|
||||
|
||||
.markdown-body ::-webkit-calendar-picker-indicator {
|
||||
filter: invert(50%);
|
||||
}
|
||||
}
|
@@ -1,147 +0,0 @@
|
||||
.markdown-body {
|
||||
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();
|
||||
}
|
||||
}
|
42
app/utils.ts
42
app/utils.ts
@@ -2,33 +2,29 @@ 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) {
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then((res) => {
|
||||
showToast(Locale.Copy.Success);
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast(Locale.Copy.Failed);
|
||||
});
|
||||
export async function copyToClipboard(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch (error) {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
} finally {
|
||||
showToast(Locale.Copy.Success);
|
||||
}
|
||||
}
|
||||
|
||||
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 +41,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 +61,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 +72,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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@@ -14,4 +14,8 @@ const nextConfig = {
|
||||
}
|
||||
};
|
||||
|
||||
if (process.env.DOCKER) {
|
||||
nextConfig.output = 'standalone'
|
||||
}
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
54
package.json
54
package.json
@@ -2,37 +2,51 @@
|
||||
"name": "chatgpt-next-web",
|
||||
"version": "1.1",
|
||||
"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",
|
||||
"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: /
|
15
public/serviceWorker.js
Normal file
15
public/serviceWorker.js
Normal file
@@ -0,0 +1,15 @@
|
||||
const CHATGPT_NEXT_WEB_CACHE = "chatgpt-next-web-cache";
|
||||
|
||||
self.addEventListener("activate", function (event) {
|
||||
console.log("ServiceWorker activated.");
|
||||
});
|
||||
|
||||
self.addEventListener("install", function (event) {
|
||||
event.waitUntil(
|
||||
caches.open(CHATGPT_NEXT_WEB_CACHE).then(function (cache) {
|
||||
return cache.addAll([]);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (e) => {});
|
9
public/serviceWorkerRegister.js
Normal file
9
public/serviceWorkerRegister.js
Normal file
@@ -0,0 +1,9 @@
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function () {
|
||||
navigator.serviceWorker.register('/serviceWorker.js').then(function (registration) {
|
||||
console.log('ServiceWorker registration successful with scope: ', registration.scope);
|
||||
}, function (err) {
|
||||
console.error('ServiceWorker registration failed: ', err);
|
||||
});
|
||||
});
|
||||
}
|
@@ -1 +1,21 @@
|
||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||
{
|
||||
"name": "ChatGPT Next Web",
|
||||
"short_name": "ChatGPT",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"start_url": "/",
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
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