dakaca commited on
Commit
a1fb323
·
1 Parent(s): 755671d

Upload 150 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .eslintignore +1 -0
  2. .eslintrc.json +4 -0
  3. .github/ISSUE_TEMPLATE/bug_report.md +43 -0
  4. .github/ISSUE_TEMPLATE/feature_request.md +20 -0
  5. .github/ISSUE_TEMPLATE/功能建议.md +20 -0
  6. .github/ISSUE_TEMPLATE/反馈问题.md +32 -0
  7. .github/workflows/docker.yml +52 -0
  8. .github/workflows/sync.yml +40 -0
  9. .gitignore +42 -0
  10. .gitpod.yml +11 -0
  11. .husky/pre-commit +4 -0
  12. .lintstagedrc.json +6 -0
  13. .prettierrc.js +10 -0
  14. CODE_OF_CONDUCT.md +128 -0
  15. Dockerfile +59 -0
  16. LICENSE +75 -0
  17. app/api/chat-stream/route.ts +62 -0
  18. app/api/common.ts +37 -0
  19. app/api/config/route.ts +23 -0
  20. app/api/openai/route.ts +33 -0
  21. app/api/openai/typing.ts +9 -0
  22. app/components/button.module.scss +64 -0
  23. app/components/button.tsx +45 -0
  24. app/components/chat-list.tsx +147 -0
  25. app/components/chat.module.scss +109 -0
  26. app/components/chat.tsx +787 -0
  27. app/components/emoji.tsx +59 -0
  28. app/components/error.tsx +73 -0
  29. app/components/home.module.scss +572 -0
  30. app/components/home.tsx +139 -0
  31. app/components/input-range.module.scss +7 -0
  32. app/components/input-range.tsx +37 -0
  33. app/components/markdown.tsx +123 -0
  34. app/components/mask.module.scss +108 -0
  35. app/components/mask.tsx +402 -0
  36. app/components/model-config.tsx +141 -0
  37. app/components/new-chat.module.scss +115 -0
  38. app/components/new-chat.tsx +185 -0
  39. app/components/settings.module.scss +77 -0
  40. app/components/settings.tsx +557 -0
  41. app/components/sidebar.tsx +181 -0
  42. app/components/ui-lib.module.scss +203 -0
  43. app/components/ui-lib.tsx +246 -0
  44. app/config/build.ts +27 -0
  45. app/config/server.ts +42 -0
  46. app/constant.ts +38 -0
  47. app/global.d.ts +11 -0
  48. app/icons/add.svg +23 -0
  49. app/icons/auto.svg +1 -0
  50. app/icons/black-bot.svg +28 -0
.eslintignore ADDED
@@ -0,0 +1 @@
 
 
1
+ public/serviceWorker.js
.eslintrc.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "extends": "next/core-web-vitals",
3
+ "plugins": ["prettier"]
4
+ }
.github/ISSUE_TEMPLATE/bug_report.md ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Bug report
3
+ about: Create a report to help us improve
4
+ title: "[Bug] "
5
+ labels: ''
6
+ assignees: ''
7
+
8
+ ---
9
+
10
+ **Describe the bug**
11
+ A clear and concise description of what the bug is.
12
+
13
+ **To Reproduce**
14
+ Steps to reproduce the behavior:
15
+ 1. Go to '...'
16
+ 2. Click on '....'
17
+ 3. Scroll down to '....'
18
+ 4. See error
19
+
20
+ **Expected behavior**
21
+ A clear and concise description of what you expected to happen.
22
+
23
+ **Screenshots**
24
+ If applicable, add screenshots to help explain your problem.
25
+
26
+ **Deployment**
27
+ - [ ] Docker
28
+ - [ ] Vercel
29
+ - [ ] Server
30
+
31
+ **Desktop (please complete the following information):**
32
+ - OS: [e.g. iOS]
33
+ - Browser [e.g. chrome, safari]
34
+ - Version [e.g. 22]
35
+
36
+ **Smartphone (please complete the following information):**
37
+ - Device: [e.g. iPhone6]
38
+ - OS: [e.g. iOS8.1]
39
+ - Browser [e.g. stock browser, safari]
40
+ - Version [e.g. 22]
41
+
42
+ **Additional Logs**
43
+ Add any logs about the problem here.
.github/ISSUE_TEMPLATE/feature_request.md ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Feature request
3
+ about: Suggest an idea for this project
4
+ title: "[Feature] "
5
+ labels: ''
6
+ assignees: ''
7
+
8
+ ---
9
+
10
+ **Is your feature request related to a problem? Please describe.**
11
+ A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12
+
13
+ **Describe the solution you'd like**
14
+ A clear and concise description of what you want to happen.
15
+
16
+ **Describe alternatives you've considered**
17
+ A clear and concise description of any alternative solutions or features you've considered.
18
+
19
+ **Additional context**
20
+ Add any other context or screenshots about the feature request here.
.github/ISSUE_TEMPLATE/功能建议.md ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: 功能建议
3
+ about: 请告诉我们你的灵光一闪
4
+ title: "[Feature] "
5
+ labels: ''
6
+ assignees: ''
7
+
8
+ ---
9
+
10
+ **这个功能与现有的问题有关吗?**
11
+ 如果有关,请在此列出链接或者描述问题。
12
+
13
+ **你想要什么功能或者有什么建议?**
14
+ 尽管告诉我们。
15
+
16
+ **有没有可以参考的同类竞品?**
17
+ 可以给出参考产品的链接或者截图。
18
+
19
+ **其他信息**
20
+ 可以说说你的其他考虑。
.github/ISSUE_TEMPLATE/反馈问题.md ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: 反馈问题
3
+ about: 请告诉我们你遇到的问题
4
+ title: "[Bug] "
5
+ labels: ''
6
+ assignees: ''
7
+
8
+ ---
9
+
10
+ **反馈须知**
11
+
12
+ ⚠️ 注意:不遵循此模板的任何帖子都会被立即关闭。
13
+
14
+ 请在下方中括号内输入 x 来表示你已经知晓相关内容。
15
+ - [ ] 我确认已经在 [常见问题](https://github.com/Yidadaa/ChatGPT-Next-Web/blob/main/docs/faq-cn.md) 中搜索了此次反馈的问题,没有找到解答;
16
+ - [ ] 我确认已经在 [Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) 列表(包括已经 Close 的)中搜索了此次反馈的问题,没有找到解答。
17
+ - [ ] 我确认已经在 [Vercel 使用教程](https://github.com/Yidadaa/ChatGPT-Next-Web/blob/main/docs/vercel-cn.md) 中搜索了此次反馈的问题,没有找到解答。
18
+
19
+ **描述问题**
20
+ 请在此描述你遇到了什么问题。
21
+
22
+ **如何复现**
23
+ 请告诉我们你是通过什么操作触发的该问题。
24
+
25
+ **截图**
26
+ 请在此提供控制台截图、屏幕截图或者服务端的 log 截图。
27
+
28
+ **一些必要的信息**
29
+ - 系统:[比如 windows 10/ macos 12/ linux / android 11 / ios 16]
30
+ - 浏览器: [比如 chrome, safari]
31
+ - 版本: [填写设置页面的版本号]
32
+ - 部署方式:[比如 vercel、docker 或者服务器部署]
.github/workflows/docker.yml ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Publish Docker image
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ release:
6
+ types: [published]
7
+
8
+ jobs:
9
+ push_to_registry:
10
+ name: Push Docker image to Docker Hub
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ -
14
+ name: Check out the repo
15
+ uses: actions/checkout@v3
16
+ -
17
+ name: Log in to Docker Hub
18
+ uses: docker/login-action@v2
19
+ with:
20
+ username: ${{ secrets.DOCKER_USERNAME }}
21
+ password: ${{ secrets.DOCKER_PASSWORD }}
22
+
23
+ -
24
+ name: Extract metadata (tags, labels) for Docker
25
+ id: meta
26
+ uses: docker/metadata-action@v4
27
+ with:
28
+ images: yidadaa/chatgpt-next-web
29
+ tags: |
30
+ type=raw,value=latest
31
+ type=ref,event=tag
32
+
33
+ -
34
+ name: Set up QEMU
35
+ uses: docker/setup-qemu-action@v2
36
+
37
+ -
38
+ name: Set up Docker Buildx
39
+ uses: docker/setup-buildx-action@v2
40
+
41
+ -
42
+ name: Build and push Docker image
43
+ uses: docker/build-push-action@v4
44
+ with:
45
+ context: .
46
+ platforms: linux/amd64,linux/arm64
47
+ push: true
48
+ tags: ${{ steps.meta.outputs.tags }}
49
+ labels: ${{ steps.meta.outputs.labels }}
50
+ cache-from: type=gha
51
+ cache-to: type=gha,mode=max
52
+
.github/workflows/sync.yml ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Upstream Sync
2
+
3
+ permissions:
4
+ contents: write
5
+
6
+ on:
7
+ schedule:
8
+ - cron: "0 * * * *" # every hour
9
+ workflow_dispatch:
10
+
11
+ jobs:
12
+ sync_latest_from_upstream:
13
+ name: Sync latest commits from upstream repo
14
+ runs-on: ubuntu-latest
15
+ if: ${{ github.event.repository.fork }}
16
+
17
+ steps:
18
+ # Step 1: run a standard checkout action
19
+ - name: Checkout target repo
20
+ uses: actions/checkout@v3
21
+
22
+ # Step 2: run the sync action
23
+ - name: Sync upstream changes
24
+ id: sync
25
+ uses: aormsby/Fork-Sync-With-Upstream-action@v3.4
26
+ with:
27
+ upstream_sync_repo: Yidadaa/ChatGPT-Next-Web
28
+ upstream_sync_branch: main
29
+ target_sync_branch: main
30
+ target_repo_token: ${{ secrets.GITHUB_TOKEN }} # automatically generated, no need to set
31
+
32
+ # Set test_mode true to run tests instead of the true action!!
33
+ test_mode: false
34
+
35
+ - name: Sync check
36
+ if: failure()
37
+ run: |
38
+ echo "::error::由于权限不足,导致同步失败(这是预期的行为),请前往仓库首页手动执行[Sync fork]。"
39
+ echo "::error::Due to insufficient permissions, synchronization failed (as expected). Please go to the repository homepage and manually perform [Sync fork]."
40
+ exit 1
.gitignore ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ /.pnp
6
+ .pnp.js
7
+
8
+ # testing
9
+ /coverage
10
+
11
+ # next.js
12
+ /.next/
13
+ /out/
14
+
15
+ # production
16
+ /build
17
+
18
+ # misc
19
+ .DS_Store
20
+ *.pem
21
+
22
+ # debug
23
+ npm-debug.log*
24
+ yarn-debug.log*
25
+ yarn-error.log*
26
+ .pnpm-debug.log*
27
+
28
+ # local env files
29
+ .env*.local
30
+
31
+ # vercel
32
+ .vercel
33
+
34
+ # typescript
35
+ *.tsbuildinfo
36
+ next-env.d.ts
37
+ dev
38
+
39
+ public/prompts.json
40
+
41
+ .vscode
42
+ .idea
.gitpod.yml ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # This configuration file was automatically generated by Gitpod.
2
+ # Please adjust to your needs (see https://www.gitpod.io/docs/introduction/learn-gitpod/gitpod-yaml)
3
+ # and commit this file to your remote git repository to share the goodness with others.
4
+
5
+ # Learn more from ready-to-use templates: https://www.gitpod.io/docs/introduction/getting-started/quickstart
6
+
7
+ tasks:
8
+ - init: yarn install && yarn run dev
9
+ command: yarn run dev
10
+
11
+
.husky/pre-commit ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ #!/usr/bin/env sh
2
+ . "$(dirname -- "$0")/_/husky.sh"
3
+
4
+ npx lint-staged
.lintstagedrc.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "./app/**/*.{js,ts,jsx,tsx,json,html,css,md}": [
3
+ "eslint --fix",
4
+ "prettier --write"
5
+ ]
6
+ }
.prettierrc.js ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ module.exports = {
2
+ printWidth: 80,
3
+ tabWidth: 2,
4
+ useTabs: false,
5
+ semi: true,
6
+ singleQuote: false,
7
+ trailingComma: 'all',
8
+ bracketSpacing: true,
9
+ arrowParens: 'always',
10
+ };
CODE_OF_CONDUCT.md ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We as members, contributors, and leaders pledge to make participation in our
6
+ community a harassment-free experience for everyone, regardless of age, body
7
+ size, visible or invisible disability, ethnicity, sex characteristics, gender
8
+ identity and expression, level of experience, education, socio-economic status,
9
+ nationality, personal appearance, race, religion, or sexual identity
10
+ and orientation.
11
+
12
+ We pledge to act and interact in ways that contribute to an open, welcoming,
13
+ diverse, inclusive, and healthy community.
14
+
15
+ ## Our Standards
16
+
17
+ Examples of behavior that contributes to a positive environment for our
18
+ community include:
19
+
20
+ * Demonstrating empathy and kindness toward other people
21
+ * Being respectful of differing opinions, viewpoints, and experiences
22
+ * Giving and gracefully accepting constructive feedback
23
+ * Accepting responsibility and apologizing to those affected by our mistakes,
24
+ and learning from the experience
25
+ * Focusing on what is best not just for us as individuals, but for the
26
+ overall community
27
+
28
+ Examples of unacceptable behavior include:
29
+
30
+ * The use of sexualized language or imagery, and sexual attention or
31
+ advances of any kind
32
+ * Trolling, insulting or derogatory comments, and personal or political attacks
33
+ * Public or private harassment
34
+ * Publishing others' private information, such as a physical or email
35
+ address, without their explicit permission
36
+ * Other conduct which could reasonably be considered inappropriate in a
37
+ professional setting
38
+
39
+ ## Enforcement Responsibilities
40
+
41
+ Community leaders are responsible for clarifying and enforcing our standards of
42
+ acceptable behavior and will take appropriate and fair corrective action in
43
+ response to any behavior that they deem inappropriate, threatening, offensive,
44
+ or harmful.
45
+
46
+ Community leaders have the right and responsibility to remove, edit, or reject
47
+ comments, commits, code, wiki edits, issues, and other contributions that are
48
+ not aligned to this Code of Conduct, and will communicate reasons for moderation
49
+ decisions when appropriate.
50
+
51
+ ## Scope
52
+
53
+ This Code of Conduct applies within all community spaces, and also applies when
54
+ an individual is officially representing the community in public spaces.
55
+ Examples of representing our community include using an official e-mail address,
56
+ posting via an official social media account, or acting as an appointed
57
+ representative at an online or offline event.
58
+
59
+ ## Enforcement
60
+
61
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
62
+ reported to the community leaders responsible for enforcement at
63
+ flynn.zhang@foxmail.com.
64
+ All complaints will be reviewed and investigated promptly and fairly.
65
+
66
+ All community leaders are obligated to respect the privacy and security of the
67
+ reporter of any incident.
68
+
69
+ ## Enforcement Guidelines
70
+
71
+ Community leaders will follow these Community Impact Guidelines in determining
72
+ the consequences for any action they deem in violation of this Code of Conduct:
73
+
74
+ ### 1. Correction
75
+
76
+ **Community Impact**: Use of inappropriate language or other behavior deemed
77
+ unprofessional or unwelcome in the community.
78
+
79
+ **Consequence**: A private, written warning from community leaders, providing
80
+ clarity around the nature of the violation and an explanation of why the
81
+ behavior was inappropriate. A public apology may be requested.
82
+
83
+ ### 2. Warning
84
+
85
+ **Community Impact**: A violation through a single incident or series
86
+ of actions.
87
+
88
+ **Consequence**: A warning with consequences for continued behavior. No
89
+ interaction with the people involved, including unsolicited interaction with
90
+ those enforcing the Code of Conduct, for a specified period of time. This
91
+ includes avoiding interactions in community spaces as well as external channels
92
+ like social media. Violating these terms may lead to a temporary or
93
+ permanent ban.
94
+
95
+ ### 3. Temporary Ban
96
+
97
+ **Community Impact**: A serious violation of community standards, including
98
+ sustained inappropriate behavior.
99
+
100
+ **Consequence**: A temporary ban from any sort of interaction or public
101
+ communication with the community for a specified period of time. No public or
102
+ private interaction with the people involved, including unsolicited interaction
103
+ with those enforcing the Code of Conduct, is allowed during this period.
104
+ Violating these terms may lead to a permanent ban.
105
+
106
+ ### 4. Permanent Ban
107
+
108
+ **Community Impact**: Demonstrating a pattern of violation of community
109
+ standards, including sustained inappropriate behavior, harassment of an
110
+ individual, or aggression toward or disparagement of classes of individuals.
111
+
112
+ **Consequence**: A permanent ban from any sort of public interaction within
113
+ the community.
114
+
115
+ ## Attribution
116
+
117
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118
+ version 2.0, available at
119
+ https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
120
+
121
+ Community Impact Guidelines were inspired by [Mozilla's code of conduct
122
+ enforcement ladder](https://github.com/mozilla/diversity).
123
+
124
+ [homepage]: https://www.contributor-covenant.org
125
+
126
+ For answers to common questions about this code of conduct, see the FAQ at
127
+ https://www.contributor-covenant.org/faq. Translations are available at
128
+ https://www.contributor-covenant.org/translations.
Dockerfile ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:18-alpine AS base
2
+
3
+ FROM base AS deps
4
+
5
+ RUN apk add --no-cache libc6-compat
6
+
7
+ WORKDIR /app
8
+
9
+ COPY package.json yarn.lock ./
10
+
11
+ RUN yarn config set registry 'https://registry.npm.taobao.org'
12
+ RUN yarn install
13
+
14
+ FROM base AS builder
15
+
16
+ RUN apk update && apk add --no-cache git
17
+
18
+ ENV OPENAI_API_KEY=""
19
+ ENV CODE=""
20
+
21
+ WORKDIR /app
22
+ COPY --from=deps /app/node_modules ./node_modules
23
+ COPY . .
24
+
25
+ RUN yarn build
26
+
27
+ FROM base AS runner
28
+ WORKDIR /app
29
+
30
+ RUN apk add proxychains-ng
31
+
32
+ ENV PROXY_URL=""
33
+ ENV OPENAI_API_KEY=""
34
+ ENV CODE=""
35
+
36
+ COPY --from=builder /app/public ./public
37
+ COPY --from=builder /app/.next/standalone ./
38
+ COPY --from=builder /app/.next/static ./.next/static
39
+ COPY --from=builder /app/.next/server ./.next/server
40
+
41
+ EXPOSE 7860
42
+
43
+ CMD if [ -n "$PROXY_URL" ]; then \
44
+ protocol=$(echo $PROXY_URL | cut -d: -f1); \
45
+ host=$(echo $PROXY_URL | cut -d/ -f3 | cut -d: -f1); \
46
+ port=$(echo $PROXY_URL | cut -d: -f3); \
47
+ conf=/etc/proxychains.conf; \
48
+ echo "strict_chain" > $conf; \
49
+ echo "proxy_dns" >> $conf; \
50
+ echo "remote_dns_subnet 224" >> $conf; \
51
+ echo "tcp_read_time_out 15000" >> $conf; \
52
+ echo "tcp_connect_time_out 8000" >> $conf; \
53
+ echo "[ProxyList]" >> $conf; \
54
+ echo "$protocol $host $port" >> $conf; \
55
+ cat /etc/proxychains.conf; \
56
+ proxychains -f $conf node server.js; \
57
+ else \
58
+ node server.js; \
59
+ fi
LICENSE ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 版权所有(c)<2023><Zhang Yifei>
2
+
3
+ 反996许可证版本1.0
4
+
5
+ 在符合下列条件的情况下,
6
+ 特此免费向任何得到本授权作品的副本(包括源代码、文件和/或相关内容,以下统称为“授权作品”
7
+ )的个人和法人实体授权:被授权个人或法人实体有权以任何目的处置授权作品,包括但不限于使
8
+ 用、复制,修改,衍生利用、散布,发布和再许可:
9
+
10
+
11
+ 1. 个人或法人实体必须在许可作品的每个再散布或衍生副本上包含以上版权声明和本许可证,不
12
+ 得自行修改。
13
+ 2. 个人或法人实体必须严格遵守与个人实际所在地或个人出生地或归化地、或法人实体注册地或
14
+ 经营地(以较严格者为准)的司法管辖区所有适用的与劳动和就业相关法律、法规、规则和
15
+ 标准。如果该司法管辖区没有此类法律、法规、规章和标准或其法律、法规、规章和标准不可
16
+ 执行,则个人或法人实体必须遵守国际劳工标准的核心公约。
17
+ 3. 个人或法人不得以任何方式诱导或强迫其全职或兼职员工或其独立承包人以口头或书面形式同
18
+ 意直接或间接限制、削弱或放弃其所拥有的,受相关与劳动和就业有关的法律、法规、规则和
19
+ 标准保护的权利或补救措施,无论该等书面或口头协议是否被该司法管辖区的法律所承认,该
20
+ 等个人或法人实体也不得以任何方法限制其雇员或独立承包人向版权持有人或监督许可证合规
21
+ 情况的有关当局报告或投诉上述违反许可证的行为的权利。
22
+
23
+ 该授权作品是"按原样"提供,不做任何明示或暗示的保证,包括但不限于对适销性、特定用途适用
24
+ 性和非侵权性的保证。在任何情况下,无论是在合同诉讼、侵权诉讼或其他诉讼中,版权持有人均
25
+ 不承担因本软件或本软件的使用或其他交易而产生、引起或与之相关的任何索赔、损害或其他责任。
26
+
27
+
28
+ ------------------------- ENGLISH ------------------------------
29
+
30
+
31
+ Copyright (c) <2023> <Zhang Yifei>
32
+
33
+ Anti 996 License Version 1.0 (Draft)
34
+
35
+ Permission is hereby granted to any individual or legal entity obtaining a copy
36
+ of this licensed work (including the source code, documentation and/or related
37
+ items, hereinafter collectively referred to as the "licensed work"), free of
38
+ charge, to deal with the licensed work for any purpose, including without
39
+ limitation, the rights to use, reproduce, modify, prepare derivative works of,
40
+ publish, distribute and sublicense the licensed work, subject to the following
41
+ conditions:
42
+
43
+ 1. The individual or the legal entity must conspicuously display, without
44
+ modification, this License on each redistributed or derivative copy of the
45
+ Licensed Work.
46
+
47
+ 2. The individual or the legal entity must strictly comply with all applicable
48
+ laws, regulations, rules and standards of the jurisdiction relating to
49
+ labor and employment where the individual is physically located or where
50
+ the individual was born or naturalized; or where the legal entity is
51
+ registered or is operating (whichever is stricter). In case that the
52
+ jurisdiction has no such laws, regulations, rules and standards or its
53
+ laws, regulations, rules and standards are unenforceable, the individual
54
+ or the legal entity are required to comply with Core International Labor
55
+ Standards.
56
+
57
+ 3. The individual or the legal entity shall not induce or force its
58
+ employee(s), whether full-time or part-time, or its independent
59
+ contractor(s), in any methods, to agree in oral or written form,
60
+ to directly or indirectly restrict, weaken or relinquish his or
61
+ her rights or remedies under such laws, regulations, rules and
62
+ standards relating to labor and employment as mentioned above,
63
+ no matter whether such written or oral agreement are enforceable
64
+ under the laws of the said jurisdiction, nor shall such individual
65
+ or the legal entity limit, in any methods, the rights of its employee(s)
66
+ or independent contractor(s) from reporting or complaining to the copyright
67
+ holder or relevant authorities monitoring the compliance of the license
68
+ about its violation(s) of the said license.
69
+
70
+ THE LICENSED WORK IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
71
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
72
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE COPYRIGHT
73
+ HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
74
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN ANY WAY CONNECTION
75
+ WITH THE LICENSED WORK OR THE USE OR OTHER DEALINGS IN THE LICENSED WORK.
app/api/chat-stream/route.ts ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createParser } from "eventsource-parser";
2
+ import { NextRequest } from "next/server";
3
+ import { requestOpenai } from "../common";
4
+
5
+ async function createStream(req: NextRequest) {
6
+ const encoder = new TextEncoder();
7
+ const decoder = new TextDecoder();
8
+
9
+ const res = await requestOpenai(req);
10
+
11
+ const contentType = res.headers.get("Content-Type") ?? "";
12
+ if (!contentType.includes("stream")) {
13
+ const content = await (
14
+ await res.text()
15
+ ).replace(/provided:.*. You/, "provided: ***. You");
16
+ console.log("[Stream] error ", content);
17
+ return "```json\n" + content + "```";
18
+ }
19
+
20
+ const stream = new ReadableStream({
21
+ async start(controller) {
22
+ function onParse(event: any) {
23
+ if (event.type === "event") {
24
+ const data = event.data;
25
+ // https://beta.openai.com/docs/api-reference/completions/create#completions/create-stream
26
+ if (data === "[DONE]") {
27
+ controller.close();
28
+ return;
29
+ }
30
+ try {
31
+ const json = JSON.parse(data);
32
+ const text = json.choices[0].delta.content;
33
+ const queue = encoder.encode(text);
34
+ controller.enqueue(queue);
35
+ } catch (e) {
36
+ controller.error(e);
37
+ }
38
+ }
39
+ }
40
+
41
+ const parser = createParser(onParse);
42
+ for await (const chunk of res.body as any) {
43
+ parser.feed(decoder.decode(chunk, { stream: true }));
44
+ }
45
+ },
46
+ });
47
+ return stream;
48
+ }
49
+
50
+ export async function POST(req: NextRequest) {
51
+ try {
52
+ const stream = await createStream(req);
53
+ return new Response(stream);
54
+ } catch (error) {
55
+ console.error("[Chat Stream]", error);
56
+ return new Response(
57
+ ["```json\n", JSON.stringify(error, null, " "), "\n```"].join(""),
58
+ );
59
+ }
60
+ }
61
+
62
+ export const runtime = "edge";
app/api/common.ts ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest } from "next/server";
2
+
3
+ const OPENAI_URL = "api.openai.com";
4
+ const DEFAULT_PROTOCOL = "https";
5
+ const PROTOCOL = process.env.PROTOCOL ?? DEFAULT_PROTOCOL;
6
+ const BASE_URL = process.env.BASE_URL ?? OPENAI_URL;
7
+
8
+ export async function requestOpenai(req: NextRequest) {
9
+ const apiKey = req.headers.get("token");
10
+ const openaiPath = req.headers.get("path");
11
+
12
+ let baseUrl = BASE_URL;
13
+
14
+ if (!baseUrl.startsWith("http")) {
15
+ baseUrl = `${PROTOCOL}://${baseUrl}`;
16
+ }
17
+
18
+ console.log("[Proxy] ", openaiPath);
19
+ console.log("[Base Url]", baseUrl);
20
+
21
+ if (process.env.OPENAI_ORG_ID) {
22
+ console.log("[Org ID]", process.env.OPENAI_ORG_ID);
23
+ }
24
+
25
+ return fetch(`${baseUrl}/${openaiPath}`, {
26
+ headers: {
27
+ "Content-Type": "application/json",
28
+ Authorization: `Bearer ${apiKey}`,
29
+ ...(process.env.OPENAI_ORG_ID && {
30
+ "OpenAI-Organization": process.env.OPENAI_ORG_ID,
31
+ }),
32
+ },
33
+ cache: "no-store",
34
+ method: req.method,
35
+ body: req.body,
36
+ });
37
+ }
app/api/config/route.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+
3
+ import { getServerSideConfig } from "../../config/server";
4
+
5
+ const serverConfig = getServerSideConfig();
6
+
7
+ // Danger! Don not write any secret value here!
8
+ // 警告!不要在这里写入任何敏感信息!
9
+ const DANGER_CONFIG = {
10
+ needCode: serverConfig.needCode,
11
+ };
12
+
13
+ declare global {
14
+ type DangerConfig = typeof DANGER_CONFIG;
15
+ }
16
+
17
+ export async function POST(req: NextRequest) {
18
+ return NextResponse.json({
19
+ needCode: serverConfig.needCode,
20
+ });
21
+ }
22
+
23
+ export const runtime = "edge";
app/api/openai/route.ts ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { requestOpenai } from "../common";
3
+
4
+ async function makeRequest(req: NextRequest) {
5
+ try {
6
+ const api = await requestOpenai(req);
7
+ const res = new NextResponse(api.body);
8
+ res.headers.set("Content-Type", "application/json");
9
+ res.headers.set("Cache-Control", "no-cache");
10
+ return res;
11
+ } catch (e) {
12
+ console.error("[OpenAI] ", req.body, e);
13
+ return NextResponse.json(
14
+ {
15
+ error: true,
16
+ msg: JSON.stringify(e),
17
+ },
18
+ {
19
+ status: 500,
20
+ },
21
+ );
22
+ }
23
+ }
24
+
25
+ export async function POST(req: NextRequest) {
26
+ return makeRequest(req);
27
+ }
28
+
29
+ export async function GET(req: NextRequest) {
30
+ return makeRequest(req);
31
+ }
32
+
33
+ export const runtime = "edge";
app/api/openai/typing.ts ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import type {
2
+ CreateChatCompletionRequest,
3
+ CreateChatCompletionResponse,
4
+ } from "openai";
5
+
6
+ export type ChatRequest = CreateChatCompletionRequest;
7
+ export type ChatResponse = CreateChatCompletionResponse;
8
+
9
+ export type Updater<T> = (updater: (value: T) => void) => void;
app/components/button.module.scss ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .icon-button {
2
+ background-color: var(--white);
3
+ border-radius: 10px;
4
+ display: flex;
5
+ align-items: center;
6
+ justify-content: center;
7
+ padding: 10px;
8
+
9
+ cursor: pointer;
10
+ transition: all 0.3s ease;
11
+ overflow: hidden;
12
+ user-select: none;
13
+ outline: none;
14
+ border: none;
15
+ color: var(--black);
16
+
17
+ &[disabled] {
18
+ cursor: not-allowed;
19
+ opacity: 0.5;
20
+ }
21
+
22
+ &.primary {
23
+ background-color: var(--primary);
24
+ color: white;
25
+
26
+ path {
27
+ fill: white !important;
28
+ }
29
+ }
30
+ }
31
+
32
+ .shadow {
33
+ box-shadow: var(--card-shadow);
34
+ }
35
+
36
+ .border {
37
+ border: var(--border-in-light);
38
+ }
39
+
40
+ .icon-button:hover {
41
+ border-color: var(--primary);
42
+ }
43
+
44
+ .icon-button-icon {
45
+ width: 16px;
46
+ height: 16px;
47
+ display: flex;
48
+ justify-content: center;
49
+ align-items: center;
50
+ }
51
+
52
+ @media only screen and (max-width: 600px) {
53
+ .icon-button {
54
+ padding: 16px;
55
+ }
56
+ }
57
+
58
+ .icon-button-text {
59
+ margin-left: 5px;
60
+ font-size: 12px;
61
+ overflow: hidden;
62
+ text-overflow: ellipsis;
63
+ white-space: nowrap;
64
+ }
app/components/button.tsx ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+
3
+ import styles from "./button.module.scss";
4
+
5
+ export function IconButton(props: {
6
+ onClick?: () => void;
7
+ icon?: JSX.Element;
8
+ type?: "primary" | "danger";
9
+ text?: string;
10
+ bordered?: boolean;
11
+ shadow?: boolean;
12
+ className?: string;
13
+ title?: string;
14
+ disabled?: boolean;
15
+ }) {
16
+ return (
17
+ <button
18
+ className={
19
+ styles["icon-button"] +
20
+ ` ${props.bordered && styles.border} ${props.shadow && styles.shadow} ${
21
+ props.className ?? ""
22
+ } clickable ${styles[props.type ?? ""]}`
23
+ }
24
+ onClick={props.onClick}
25
+ title={props.title}
26
+ disabled={props.disabled}
27
+ role="button"
28
+ >
29
+ {props.icon && (
30
+ <div
31
+ className={
32
+ styles["icon-button-icon"] +
33
+ ` ${props.type === "primary" && "no-dark"}`
34
+ }
35
+ >
36
+ {props.icon}
37
+ </div>
38
+ )}
39
+
40
+ {props.text && (
41
+ <div className={styles["icon-button-text"]}>{props.text}</div>
42
+ )}
43
+ </button>
44
+ );
45
+ }
app/components/chat-list.tsx ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import DeleteIcon from "../icons/delete.svg";
2
+ import BotIcon from "../icons/bot.svg";
3
+
4
+ import styles from "./home.module.scss";
5
+ import {
6
+ DragDropContext,
7
+ Droppable,
8
+ Draggable,
9
+ OnDragEndResponder,
10
+ } from "@hello-pangea/dnd";
11
+
12
+ import { useChatStore } from "../store";
13
+
14
+ import Locale from "../locales";
15
+ import { Link, useNavigate } from "react-router-dom";
16
+ import { Path } from "../constant";
17
+ import { MaskAvatar } from "./mask";
18
+ import { Mask } from "../store/mask";
19
+
20
+ export function ChatItem(props: {
21
+ onClick?: () => void;
22
+ onDelete?: () => void;
23
+ title: string;
24
+ count: number;
25
+ time: string;
26
+ selected: boolean;
27
+ id: number;
28
+ index: number;
29
+ narrow?: boolean;
30
+ mask: Mask;
31
+ }) {
32
+ return (
33
+ <Draggable draggableId={`${props.id}`} index={props.index}>
34
+ {(provided) => (
35
+ <div
36
+ className={`${styles["chat-item"]} ${
37
+ props.selected && styles["chat-item-selected"]
38
+ }`}
39
+ onClick={props.onClick}
40
+ ref={provided.innerRef}
41
+ {...provided.draggableProps}
42
+ {...provided.dragHandleProps}
43
+ title={`${props.title}\n${Locale.ChatItem.ChatItemCount(
44
+ props.count,
45
+ )}`}
46
+ >
47
+ {props.narrow ? (
48
+ <div className={styles["chat-item-narrow"]}>
49
+ <div className={styles["chat-item-avatar"] + " no-dark"}>
50
+ <MaskAvatar mask={props.mask} />
51
+ </div>
52
+ <div className={styles["chat-item-narrow-count"]}>
53
+ {props.count}
54
+ </div>
55
+ </div>
56
+ ) : (
57
+ <>
58
+ <div className={styles["chat-item-title"]}>{props.title}</div>
59
+ <div className={styles["chat-item-info"]}>
60
+ <div className={styles["chat-item-count"]}>
61
+ {Locale.ChatItem.ChatItemCount(props.count)}
62
+ </div>
63
+ <div className={styles["chat-item-date"]}>
64
+ {new Date(props.time).toLocaleString()}
65
+ </div>
66
+ </div>
67
+ </>
68
+ )}
69
+
70
+ <div
71
+ className={styles["chat-item-delete"]}
72
+ onClickCapture={props.onDelete}
73
+ >
74
+ <DeleteIcon />
75
+ </div>
76
+ </div>
77
+ )}
78
+ </Draggable>
79
+ );
80
+ }
81
+
82
+ export function ChatList(props: { narrow?: boolean }) {
83
+ const [sessions, selectedIndex, selectSession, moveSession] = useChatStore(
84
+ (state) => [
85
+ state.sessions,
86
+ state.currentSessionIndex,
87
+ state.selectSession,
88
+ state.moveSession,
89
+ ],
90
+ );
91
+ const chatStore = useChatStore();
92
+ const navigate = useNavigate();
93
+
94
+ const onDragEnd: OnDragEndResponder = (result) => {
95
+ const { destination, source } = result;
96
+ if (!destination) {
97
+ return;
98
+ }
99
+
100
+ if (
101
+ destination.droppableId === source.droppableId &&
102
+ destination.index === source.index
103
+ ) {
104
+ return;
105
+ }
106
+
107
+ moveSession(source.index, destination.index);
108
+ };
109
+
110
+ return (
111
+ <DragDropContext onDragEnd={onDragEnd}>
112
+ <Droppable droppableId="chat-list">
113
+ {(provided) => (
114
+ <div
115
+ className={styles["chat-list"]}
116
+ ref={provided.innerRef}
117
+ {...provided.droppableProps}
118
+ >
119
+ {sessions.map((item, i) => (
120
+ <ChatItem
121
+ title={item.topic}
122
+ time={new Date(item.lastUpdate).toLocaleString()}
123
+ count={item.messages.length}
124
+ key={item.id}
125
+ id={item.id}
126
+ index={i}
127
+ selected={i === selectedIndex}
128
+ onClick={() => {
129
+ navigate(Path.Chat);
130
+ selectSession(i);
131
+ }}
132
+ onDelete={() => {
133
+ if (!props.narrow || confirm(Locale.Home.DeleteChat)) {
134
+ chatStore.deleteSession(i);
135
+ }
136
+ }}
137
+ narrow={props.narrow}
138
+ mask={item.mask}
139
+ />
140
+ ))}
141
+ {provided.placeholder}
142
+ </div>
143
+ )}
144
+ </Droppable>
145
+ </DragDropContext>
146
+ );
147
+ }
app/components/chat.module.scss ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "../styles/animation.scss";
2
+
3
+ .chat-input-actions {
4
+ display: flex;
5
+ flex-wrap: wrap;
6
+
7
+ .chat-input-action {
8
+ display: inline-flex;
9
+ border-radius: 20px;
10
+ font-size: 12px;
11
+ background-color: var(--white);
12
+ color: var(--black);
13
+ border: var(--border-in-light);
14
+ padding: 4px 10px;
15
+ animation: slide-in ease 0.3s;
16
+ box-shadow: var(--card-shadow);
17
+ transition: all ease 0.3s;
18
+ margin-bottom: 10px;
19
+ align-items: center;
20
+
21
+ &:not(:last-child) {
22
+ margin-right: 5px;
23
+ }
24
+ }
25
+ }
26
+
27
+ .prompt-toast {
28
+ position: absolute;
29
+ bottom: -50px;
30
+ z-index: 999;
31
+ display: flex;
32
+ justify-content: center;
33
+ width: calc(100% - 40px);
34
+
35
+ .prompt-toast-inner {
36
+ display: flex;
37
+ justify-content: center;
38
+ align-items: center;
39
+ font-size: 12px;
40
+ background-color: var(--white);
41
+ color: var(--black);
42
+
43
+ border: var(--border-in-light);
44
+ box-shadow: var(--card-shadow);
45
+ padding: 10px 20px;
46
+ border-radius: 100px;
47
+
48
+ animation: slide-in-from-top ease 0.3s;
49
+
50
+ .prompt-toast-content {
51
+ margin-left: 10px;
52
+ }
53
+ }
54
+ }
55
+
56
+ .section-title {
57
+ font-size: 12px;
58
+ font-weight: bold;
59
+ margin-bottom: 10px;
60
+ display: flex;
61
+ justify-content: space-between;
62
+ align-items: center;
63
+
64
+ .section-title-action {
65
+ display: flex;
66
+ align-items: center;
67
+ }
68
+ }
69
+
70
+ .context-prompt {
71
+ .context-prompt-row {
72
+ display: flex;
73
+ justify-content: center;
74
+ width: 100%;
75
+ margin-bottom: 10px;
76
+
77
+ .context-role {
78
+ margin-right: 10px;
79
+ }
80
+
81
+ .context-content {
82
+ flex: 1;
83
+ max-width: 100%;
84
+ text-align: left;
85
+ }
86
+
87
+ .context-delete-button {
88
+ margin-left: 10px;
89
+ }
90
+ }
91
+
92
+ .context-prompt-button {
93
+ flex: 1;
94
+ }
95
+ }
96
+
97
+ .memory-prompt {
98
+ margin: 20px 0;
99
+
100
+ .memory-prompt-content {
101
+ background-color: var(--white);
102
+ color: var(--black);
103
+ border: var(--border-in-light);
104
+ border-radius: 10px;
105
+ padding: 10px;
106
+ font-size: 12px;
107
+ user-select: text;
108
+ }
109
+ }
app/components/chat.tsx ADDED
@@ -0,0 +1,787 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useDebouncedCallback } from "use-debounce";
2
+ import { useState, useRef, useEffect, useLayoutEffect } from "react";
3
+
4
+ import SendWhiteIcon from "../icons/send-white.svg";
5
+ import BrainIcon from "../icons/brain.svg";
6
+ import RenameIcon from "../icons/rename.svg";
7
+ import ExportIcon from "../icons/share.svg";
8
+ import ReturnIcon from "../icons/return.svg";
9
+ import CopyIcon from "../icons/copy.svg";
10
+ import DownloadIcon from "../icons/download.svg";
11
+ import LoadingIcon from "../icons/three-dots.svg";
12
+ import PromptIcon from "../icons/prompt.svg";
13
+ import MaskIcon from "../icons/mask.svg";
14
+ import MaxIcon from "../icons/max.svg";
15
+ import MinIcon from "../icons/min.svg";
16
+ import ResetIcon from "../icons/reload.svg";
17
+
18
+ import LightIcon from "../icons/light.svg";
19
+ import DarkIcon from "../icons/dark.svg";
20
+ import AutoIcon from "../icons/auto.svg";
21
+ import BottomIcon from "../icons/bottom.svg";
22
+ import StopIcon from "../icons/pause.svg";
23
+
24
+ import {
25
+ Message,
26
+ SubmitKey,
27
+ useChatStore,
28
+ BOT_HELLO,
29
+ ROLES,
30
+ createMessage,
31
+ useAccessStore,
32
+ Theme,
33
+ useAppConfig,
34
+ ModelConfig,
35
+ DEFAULT_TOPIC,
36
+ } from "../store";
37
+
38
+ import {
39
+ copyToClipboard,
40
+ downloadAs,
41
+ selectOrCopy,
42
+ autoGrowTextArea,
43
+ useMobileScreen,
44
+ } from "../utils";
45
+
46
+ import dynamic from "next/dynamic";
47
+
48
+ import { ControllerPool } from "../requests";
49
+ import { Prompt, usePromptStore } from "../store/prompt";
50
+ import Locale from "../locales";
51
+
52
+ import { IconButton } from "./button";
53
+ import styles from "./home.module.scss";
54
+ import chatStyle from "./chat.module.scss";
55
+
56
+ import { ListItem, Modal, showModal } from "./ui-lib";
57
+ import { useNavigate } from "react-router-dom";
58
+ import { Path } from "../constant";
59
+ import { Avatar } from "./emoji";
60
+ import { MaskAvatar, MaskConfig } from "./mask";
61
+ import {
62
+ DEFAULT_MASK_AVATAR,
63
+ DEFAULT_MASK_ID,
64
+ useMaskStore,
65
+ } from "../store/mask";
66
+
67
+ const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
68
+ loading: () => <LoadingIcon />,
69
+ });
70
+
71
+ function exportMessages(messages: Message[], topic: string) {
72
+ const mdText =
73
+ `# ${topic}\n\n` +
74
+ messages
75
+ .map((m) => {
76
+ return m.role === "user"
77
+ ? `## ${Locale.Export.MessageFromYou}:\n${m.content}`
78
+ : `## ${Locale.Export.MessageFromChatGPT}:\n${m.content.trim()}`;
79
+ })
80
+ .join("\n\n");
81
+ const filename = `${topic}.md`;
82
+
83
+ showModal({
84
+ title: Locale.Export.Title,
85
+ children: (
86
+ <div className="markdown-body">
87
+ <pre className={styles["export-content"]}>{mdText}</pre>
88
+ </div>
89
+ ),
90
+ actions: [
91
+ <IconButton
92
+ key="copy"
93
+ icon={<CopyIcon />}
94
+ bordered
95
+ text={Locale.Export.Copy}
96
+ onClick={() => copyToClipboard(mdText)}
97
+ />,
98
+ <IconButton
99
+ key="download"
100
+ icon={<DownloadIcon />}
101
+ bordered
102
+ text={Locale.Export.Download}
103
+ onClick={() => downloadAs(mdText, filename)}
104
+ />,
105
+ ],
106
+ });
107
+ }
108
+
109
+ export function SessionConfigModel(props: { onClose: () => void }) {
110
+ const chatStore = useChatStore();
111
+ const session = chatStore.currentSession();
112
+ const maskStore = useMaskStore();
113
+ const navigate = useNavigate();
114
+
115
+ return (
116
+ <div className="modal-mask">
117
+ <Modal
118
+ title={Locale.Context.Edit}
119
+ onClose={() => props.onClose()}
120
+ actions={[
121
+ <IconButton
122
+ key="reset"
123
+ icon={<ResetIcon />}
124
+ bordered
125
+ text={Locale.Chat.Config.Reset}
126
+ onClick={() =>
127
+ confirm(Locale.Memory.ResetConfirm) && chatStore.resetSession()
128
+ }
129
+ />,
130
+ <IconButton
131
+ key="copy"
132
+ icon={<CopyIcon />}
133
+ bordered
134
+ text={Locale.Chat.Config.SaveAs}
135
+ onClick={() => {
136
+ navigate(Path.Masks);
137
+ setTimeout(() => {
138
+ maskStore.create(session.mask);
139
+ }, 500);
140
+ }}
141
+ />,
142
+ ]}
143
+ >
144
+ <MaskConfig
145
+ mask={session.mask}
146
+ updateMask={(updater) => {
147
+ const mask = { ...session.mask };
148
+ updater(mask);
149
+ chatStore.updateCurrentSession((session) => (session.mask = mask));
150
+ }}
151
+ extraListItems={
152
+ session.mask.modelConfig.sendMemory ? (
153
+ <ListItem
154
+ title={`${Locale.Memory.Title} (${session.lastSummarizeIndex} of ${session.messages.length})`}
155
+ subTitle={session.memoryPrompt || Locale.Memory.EmptyContent}
156
+ ></ListItem>
157
+ ) : (
158
+ <></>
159
+ )
160
+ }
161
+ ></MaskConfig>
162
+ </Modal>
163
+ </div>
164
+ );
165
+ }
166
+
167
+ function PromptToast(props: {
168
+ showToast?: boolean;
169
+ showModal?: boolean;
170
+ setShowModal: (_: boolean) => void;
171
+ }) {
172
+ const chatStore = useChatStore();
173
+ const session = chatStore.currentSession();
174
+ const context = session.mask.context;
175
+
176
+ return (
177
+ <div className={chatStyle["prompt-toast"]} key="prompt-toast">
178
+ {props.showToast && (
179
+ <div
180
+ className={chatStyle["prompt-toast-inner"] + " clickable"}
181
+ role="button"
182
+ onClick={() => props.setShowModal(true)}
183
+ >
184
+ <BrainIcon />
185
+ <span className={chatStyle["prompt-toast-content"]}>
186
+ {Locale.Context.Toast(context.length)}
187
+ </span>
188
+ </div>
189
+ )}
190
+ {props.showModal && (
191
+ <SessionConfigModel onClose={() => props.setShowModal(false)} />
192
+ )}
193
+ </div>
194
+ );
195
+ }
196
+
197
+ function useSubmitHandler() {
198
+ const config = useAppConfig();
199
+ const submitKey = config.submitKey;
200
+
201
+ const shouldSubmit = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
202
+ if (e.key !== "Enter") return false;
203
+ if (e.key === "Enter" && e.nativeEvent.isComposing) return false;
204
+ return (
205
+ (config.submitKey === SubmitKey.AltEnter && e.altKey) ||
206
+ (config.submitKey === SubmitKey.CtrlEnter && e.ctrlKey) ||
207
+ (config.submitKey === SubmitKey.ShiftEnter && e.shiftKey) ||
208
+ (config.submitKey === SubmitKey.MetaEnter && e.metaKey) ||
209
+ (config.submitKey === SubmitKey.Enter &&
210
+ !e.altKey &&
211
+ !e.ctrlKey &&
212
+ !e.shiftKey &&
213
+ !e.metaKey)
214
+ );
215
+ };
216
+
217
+ return {
218
+ submitKey,
219
+ shouldSubmit,
220
+ };
221
+ }
222
+
223
+ export function PromptHints(props: {
224
+ prompts: Prompt[];
225
+ onPromptSelect: (prompt: Prompt) => void;
226
+ }) {
227
+ if (props.prompts.length === 0) return null;
228
+
229
+ return (
230
+ <div className={styles["prompt-hints"]}>
231
+ {props.prompts.map((prompt, i) => (
232
+ <div
233
+ className={styles["prompt-hint"]}
234
+ key={prompt.title + i.toString()}
235
+ onClick={() => props.onPromptSelect(prompt)}
236
+ >
237
+ <div className={styles["hint-title"]}>{prompt.title}</div>
238
+ <div className={styles["hint-content"]}>{prompt.content}</div>
239
+ </div>
240
+ ))}
241
+ </div>
242
+ );
243
+ }
244
+
245
+ function useScrollToBottom() {
246
+ // for auto-scroll
247
+ const scrollRef = useRef<HTMLDivElement>(null);
248
+ const [autoScroll, setAutoScroll] = useState(true);
249
+ const scrollToBottom = () => {
250
+ const dom = scrollRef.current;
251
+ if (dom) {
252
+ setTimeout(() => (dom.scrollTop = dom.scrollHeight), 1);
253
+ }
254
+ };
255
+
256
+ // auto scroll
257
+ useLayoutEffect(() => {
258
+ autoScroll && scrollToBottom();
259
+ });
260
+
261
+ return {
262
+ scrollRef,
263
+ autoScroll,
264
+ setAutoScroll,
265
+ scrollToBottom,
266
+ };
267
+ }
268
+
269
+ export function ChatActions(props: {
270
+ showPromptModal: () => void;
271
+ scrollToBottom: () => void;
272
+ showPromptHints: () => void;
273
+ hitBottom: boolean;
274
+ }) {
275
+ const config = useAppConfig();
276
+ const navigate = useNavigate();
277
+
278
+ // switch themes
279
+ const theme = config.theme;
280
+ function nextTheme() {
281
+ const themes = [Theme.Auto, Theme.Light, Theme.Dark];
282
+ const themeIndex = themes.indexOf(theme);
283
+ const nextIndex = (themeIndex + 1) % themes.length;
284
+ const nextTheme = themes[nextIndex];
285
+ config.update((config) => (config.theme = nextTheme));
286
+ }
287
+
288
+ // stop all responses
289
+ const couldStop = ControllerPool.hasPending();
290
+ const stopAll = () => ControllerPool.stopAll();
291
+
292
+ return (
293
+ <div className={chatStyle["chat-input-actions"]}>
294
+ {couldStop && (
295
+ <div
296
+ className={`${chatStyle["chat-input-action"]} clickable`}
297
+ onClick={stopAll}
298
+ >
299
+ <StopIcon />
300
+ </div>
301
+ )}
302
+ {!props.hitBottom && (
303
+ <div
304
+ className={`${chatStyle["chat-input-action"]} clickable`}
305
+ onClick={props.scrollToBottom}
306
+ >
307
+ <BottomIcon />
308
+ </div>
309
+ )}
310
+ {props.hitBottom && (
311
+ <div
312
+ className={`${chatStyle["chat-input-action"]} clickable`}
313
+ onClick={props.showPromptModal}
314
+ >
315
+ <BrainIcon />
316
+ </div>
317
+ )}
318
+
319
+ <div
320
+ className={`${chatStyle["chat-input-action"]} clickable`}
321
+ onClick={nextTheme}
322
+ >
323
+ {theme === Theme.Auto ? (
324
+ <AutoIcon />
325
+ ) : theme === Theme.Light ? (
326
+ <LightIcon />
327
+ ) : theme === Theme.Dark ? (
328
+ <DarkIcon />
329
+ ) : null}
330
+ </div>
331
+
332
+ <div
333
+ className={`${chatStyle["chat-input-action"]} clickable`}
334
+ onClick={props.showPromptHints}
335
+ >
336
+ <PromptIcon />
337
+ </div>
338
+
339
+ <div
340
+ className={`${chatStyle["chat-input-action"]} clickable`}
341
+ onClick={() => {
342
+ navigate(Path.Masks);
343
+ }}
344
+ >
345
+ <MaskIcon />
346
+ </div>
347
+ </div>
348
+ );
349
+ }
350
+
351
+ export function Chat() {
352
+ type RenderMessage = Message & { preview?: boolean };
353
+
354
+ const chatStore = useChatStore();
355
+ const [session, sessionIndex] = useChatStore((state) => [
356
+ state.currentSession(),
357
+ state.currentSessionIndex,
358
+ ]);
359
+ const config = useAppConfig();
360
+ const fontSize = config.fontSize;
361
+
362
+ const inputRef = useRef<HTMLTextAreaElement>(null);
363
+ const [userInput, setUserInput] = useState("");
364
+ const [beforeInput, setBeforeInput] = useState("");
365
+ const [isLoading, setIsLoading] = useState(false);
366
+ const { submitKey, shouldSubmit } = useSubmitHandler();
367
+ const { scrollRef, setAutoScroll, scrollToBottom } = useScrollToBottom();
368
+ const [hitBottom, setHitBottom] = useState(true);
369
+ const isMobileScreen = useMobileScreen();
370
+ const navigate = useNavigate();
371
+
372
+ const onChatBodyScroll = (e: HTMLElement) => {
373
+ const isTouchBottom = e.scrollTop + e.clientHeight >= e.scrollHeight - 20;
374
+ setHitBottom(isTouchBottom);
375
+ };
376
+
377
+ // prompt hints
378
+ const promptStore = usePromptStore();
379
+ const [promptHints, setPromptHints] = useState<Prompt[]>([]);
380
+ const onSearch = useDebouncedCallback(
381
+ (text: string) => {
382
+ setPromptHints(promptStore.search(text));
383
+ },
384
+ 100,
385
+ { leading: true, trailing: true },
386
+ );
387
+
388
+ const onPromptSelect = (prompt: Prompt) => {
389
+ setPromptHints([]);
390
+ inputRef.current?.focus();
391
+ setTimeout(() => setUserInput(prompt.content), 60);
392
+ };
393
+
394
+ // auto grow input
395
+ const [inputRows, setInputRows] = useState(2);
396
+ const measure = useDebouncedCallback(
397
+ () => {
398
+ const rows = inputRef.current ? autoGrowTextArea(inputRef.current) : 1;
399
+ const inputRows = Math.min(
400
+ 5,
401
+ Math.max(2 + Number(!isMobileScreen), rows),
402
+ );
403
+ setInputRows(inputRows);
404
+ },
405
+ 100,
406
+ {
407
+ leading: true,
408
+ trailing: true,
409
+ },
410
+ );
411
+
412
+ // eslint-disable-next-line react-hooks/exhaustive-deps
413
+ useEffect(measure, [userInput]);
414
+
415
+ // only search prompts when user input is short
416
+ const SEARCH_TEXT_LIMIT = 30;
417
+ const onInput = (text: string) => {
418
+ setUserInput(text);
419
+ const n = text.trim().length;
420
+
421
+ // clear search results
422
+ if (n === 0) {
423
+ setPromptHints([]);
424
+ } else if (!config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
425
+ // check if need to trigger auto completion
426
+ if (text.startsWith("/")) {
427
+ let searchText = text.slice(1);
428
+ onSearch(searchText);
429
+ }
430
+ }
431
+ };
432
+
433
+ // submit user input
434
+ const onUserSubmit = () => {
435
+ if (userInput.length <= 0) return;
436
+ setIsLoading(true);
437
+ chatStore.onUserInput(userInput).then(() => setIsLoading(false));
438
+ setBeforeInput(userInput);
439
+ setUserInput("");
440
+ setPromptHints([]);
441
+ if (!isMobileScreen) inputRef.current?.focus();
442
+ setAutoScroll(true);
443
+ };
444
+
445
+ // stop response
446
+ const onUserStop = (messageId: number) => {
447
+ ControllerPool.stop(sessionIndex, messageId);
448
+ };
449
+
450
+ // check if should send message
451
+ const onInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
452
+ // if ArrowUp and no userInput
453
+ if (e.key === "ArrowUp" && userInput.length <= 0) {
454
+ setUserInput(beforeInput);
455
+ e.preventDefault();
456
+ return;
457
+ }
458
+ if (shouldSubmit(e)) {
459
+ onUserSubmit();
460
+ e.preventDefault();
461
+ }
462
+ };
463
+ const onRightClick = (e: any, message: Message) => {
464
+ // auto fill user input
465
+ if (message.role === "user") {
466
+ setUserInput(message.content);
467
+ }
468
+
469
+ // copy to clipboard
470
+ if (selectOrCopy(e.currentTarget, message.content)) {
471
+ e.preventDefault();
472
+ }
473
+ };
474
+
475
+ const findLastUserIndex = (messageId: number) => {
476
+ // find last user input message and resend
477
+ let lastUserMessageIndex: number | null = null;
478
+ for (let i = 0; i < session.messages.length; i += 1) {
479
+ const message = session.messages[i];
480
+ if (message.id === messageId) {
481
+ break;
482
+ }
483
+ if (message.role === "user") {
484
+ lastUserMessageIndex = i;
485
+ }
486
+ }
487
+
488
+ return lastUserMessageIndex;
489
+ };
490
+
491
+ const deleteMessage = (userIndex: number) => {
492
+ chatStore.updateCurrentSession((session) =>
493
+ session.messages.splice(userIndex, 2),
494
+ );
495
+ };
496
+
497
+ const onDelete = (botMessageId: number) => {
498
+ const userIndex = findLastUserIndex(botMessageId);
499
+ if (userIndex === null) return;
500
+ deleteMessage(userIndex);
501
+ };
502
+
503
+ const onResend = (botMessageId: number) => {
504
+ // find last user input message and resend
505
+ const userIndex = findLastUserIndex(botMessageId);
506
+ if (userIndex === null) return;
507
+
508
+ setIsLoading(true);
509
+ const content = session.messages[userIndex].content;
510
+ deleteMessage(userIndex);
511
+ chatStore.onUserInput(content).then(() => setIsLoading(false));
512
+ inputRef.current?.focus();
513
+ };
514
+
515
+ const context: RenderMessage[] = session.mask.context.slice();
516
+
517
+ const accessStore = useAccessStore();
518
+
519
+ if (
520
+ context.length === 0 &&
521
+ session.messages.at(0)?.content !== BOT_HELLO.content
522
+ ) {
523
+ const copiedHello = Object.assign({}, BOT_HELLO);
524
+ if (!accessStore.isAuthorized()) {
525
+ copiedHello.content = Locale.Error.Unauthorized;
526
+ }
527
+ context.push(copiedHello);
528
+ }
529
+
530
+ // preview messages
531
+ const messages = context
532
+ .concat(session.messages as RenderMessage[])
533
+ .concat(
534
+ isLoading
535
+ ? [
536
+ {
537
+ ...createMessage({
538
+ role: "assistant",
539
+ content: "……",
540
+ }),
541
+ preview: true,
542
+ },
543
+ ]
544
+ : [],
545
+ )
546
+ .concat(
547
+ userInput.length > 0 && config.sendPreviewBubble
548
+ ? [
549
+ {
550
+ ...createMessage({
551
+ role: "user",
552
+ content: userInput,
553
+ }),
554
+ preview: true,
555
+ },
556
+ ]
557
+ : [],
558
+ );
559
+
560
+ const [showPromptModal, setShowPromptModal] = useState(false);
561
+
562
+ const renameSession = () => {
563
+ const newTopic = prompt(Locale.Chat.Rename, session.topic);
564
+ if (newTopic && newTopic !== session.topic) {
565
+ chatStore.updateCurrentSession((session) => (session.topic = newTopic!));
566
+ }
567
+ };
568
+
569
+ // Auto focus
570
+ useEffect(() => {
571
+ if (isMobileScreen) return;
572
+ inputRef.current?.focus();
573
+ // eslint-disable-next-line react-hooks/exhaustive-deps
574
+ }, []);
575
+
576
+ return (
577
+ <div className={styles.chat} key={session.id}>
578
+ <div className="window-header">
579
+ <div className="window-header-title">
580
+ <div
581
+ className={`window-header-main-title " ${styles["chat-body-title"]}`}
582
+ onClickCapture={renameSession}
583
+ >
584
+ {!session.topic ? DEFAULT_TOPIC : session.topic}
585
+ </div>
586
+ <div className="window-header-sub-title">
587
+ {Locale.Chat.SubTitle(session.messages.length)}
588
+ </div>
589
+ </div>
590
+ <div className="window-actions">
591
+ <div className={"window-action-button" + " " + styles.mobile}>
592
+ <IconButton
593
+ icon={<ReturnIcon />}
594
+ bordered
595
+ title={Locale.Chat.Actions.ChatList}
596
+ onClick={() => navigate(Path.Home)}
597
+ />
598
+ </div>
599
+ <div className="window-action-button">
600
+ <IconButton
601
+ icon={<RenameIcon />}
602
+ bordered
603
+ onClick={renameSession}
604
+ />
605
+ </div>
606
+ <div className="window-action-button">
607
+ <IconButton
608
+ icon={<ExportIcon />}
609
+ bordered
610
+ title={Locale.Chat.Actions.Export}
611
+ onClick={() => {
612
+ exportMessages(
613
+ session.messages.filter((msg) => !msg.isError),
614
+ session.topic,
615
+ );
616
+ }}
617
+ />
618
+ </div>
619
+ {!isMobileScreen && (
620
+ <div className="window-action-button">
621
+ <IconButton
622
+ icon={config.tightBorder ? <MinIcon /> : <MaxIcon />}
623
+ bordered
624
+ onClick={() => {
625
+ config.update(
626
+ (config) => (config.tightBorder = !config.tightBorder),
627
+ );
628
+ }}
629
+ />
630
+ </div>
631
+ )}
632
+ </div>
633
+
634
+ <PromptToast
635
+ showToast={!hitBottom}
636
+ showModal={showPromptModal}
637
+ setShowModal={setShowPromptModal}
638
+ />
639
+ </div>
640
+
641
+ <div
642
+ className={styles["chat-body"]}
643
+ ref={scrollRef}
644
+ onScroll={(e) => onChatBodyScroll(e.currentTarget)}
645
+ onMouseDown={() => inputRef.current?.blur()}
646
+ onWheel={(e) => setAutoScroll(hitBottom && e.deltaY > 0)}
647
+ onTouchStart={() => {
648
+ inputRef.current?.blur();
649
+ setAutoScroll(false);
650
+ }}
651
+ >
652
+ {messages.map((message, i) => {
653
+ const isUser = message.role === "user";
654
+ const showActions =
655
+ !isUser &&
656
+ i > 0 &&
657
+ !(message.preview || message.content.length === 0);
658
+ const showTyping = message.preview || message.streaming;
659
+
660
+ return (
661
+ <div
662
+ key={i}
663
+ className={
664
+ isUser ? styles["chat-message-user"] : styles["chat-message"]
665
+ }
666
+ >
667
+ <div className={styles["chat-message-container"]}>
668
+ <div className={styles["chat-message-avatar"]}>
669
+ {message.role === "user" ? (
670
+ <Avatar avatar={config.avatar} />
671
+ ) : (
672
+ <MaskAvatar mask={session.mask} />
673
+ )}
674
+ </div>
675
+ {showTyping && (
676
+ <div className={styles["chat-message-status"]}>
677
+ {Locale.Chat.Typing}
678
+ </div>
679
+ )}
680
+ <div className={styles["chat-message-item"]}>
681
+ {showActions && (
682
+ <div className={styles["chat-message-top-actions"]}>
683
+ {message.streaming ? (
684
+ <div
685
+ className={styles["chat-message-top-action"]}
686
+ onClick={() => onUserStop(message.id ?? i)}
687
+ >
688
+ {Locale.Chat.Actions.Stop}
689
+ </div>
690
+ ) : (
691
+ <>
692
+ <div
693
+ className={styles["chat-message-top-action"]}
694
+ onClick={() => onDelete(message.id ?? i)}
695
+ >
696
+ {Locale.Chat.Actions.Delete}
697
+ </div>
698
+ <div
699
+ className={styles["chat-message-top-action"]}
700
+ onClick={() => onResend(message.id ?? i)}
701
+ >
702
+ {Locale.Chat.Actions.Retry}
703
+ </div>
704
+ </>
705
+ )}
706
+
707
+ <div
708
+ className={styles["chat-message-top-action"]}
709
+ onClick={() => copyToClipboard(message.content)}
710
+ >
711
+ {Locale.Chat.Actions.Copy}
712
+ </div>
713
+ </div>
714
+ )}
715
+ <Markdown
716
+ content={message.content}
717
+ loading={
718
+ (message.preview || message.content.length === 0) &&
719
+ !isUser
720
+ }
721
+ onContextMenu={(e) => onRightClick(e, message)}
722
+ onDoubleClickCapture={() => {
723
+ if (!isMobileScreen) return;
724
+ setUserInput(message.content);
725
+ }}
726
+ fontSize={fontSize}
727
+ parentRef={scrollRef}
728
+ defaultShow={i >= messages.length - 10}
729
+ />
730
+ </div>
731
+ {!isUser && !message.preview && (
732
+ <div className={styles["chat-message-actions"]}>
733
+ <div className={styles["chat-message-action-date"]}>
734
+ {message.date.toLocaleString()}
735
+ </div>
736
+ </div>
737
+ )}
738
+ </div>
739
+ </div>
740
+ );
741
+ })}
742
+ </div>
743
+
744
+ <div className={styles["chat-input-panel"]}>
745
+ <PromptHints prompts={promptHints} onPromptSelect={onPromptSelect} />
746
+
747
+ <ChatActions
748
+ showPromptModal={() => setShowPromptModal(true)}
749
+ scrollToBottom={scrollToBottom}
750
+ hitBottom={hitBottom}
751
+ showPromptHints={() => {
752
+ inputRef.current?.focus();
753
+ onSearch("");
754
+ }}
755
+ />
756
+ <div className={styles["chat-input-panel-inner"]}>
757
+ <textarea
758
+ ref={inputRef}
759
+ className={styles["chat-input"]}
760
+ placeholder={Locale.Chat.Input(submitKey)}
761
+ onInput={(e) => onInput(e.currentTarget.value)}
762
+ value={userInput}
763
+ onKeyDown={onInputKeyDown}
764
+ onFocus={() => setAutoScroll(true)}
765
+ onBlur={() => {
766
+ setTimeout(() => {
767
+ if (document.activeElement !== inputRef.current) {
768
+ setAutoScroll(false);
769
+ setPromptHints([]);
770
+ }
771
+ }, 100);
772
+ }}
773
+ autoFocus
774
+ rows={inputRows}
775
+ />
776
+ <IconButton
777
+ icon={<SendWhiteIcon />}
778
+ text={Locale.Chat.Send}
779
+ className={styles["chat-input-send"]}
780
+ type="primary"
781
+ onClick={onUserSubmit}
782
+ />
783
+ </div>
784
+ </div>
785
+ </div>
786
+ );
787
+ }
app/components/emoji.tsx ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import EmojiPicker, {
2
+ Emoji,
3
+ EmojiStyle,
4
+ Theme as EmojiTheme,
5
+ } from "emoji-picker-react";
6
+
7
+ import { ModelType } from "../store";
8
+
9
+ import BotIcon from "../icons/bot.svg";
10
+ import BlackBotIcon from "../icons/black-bot.svg";
11
+
12
+ export function getEmojiUrl(unified: string, style: EmojiStyle) {
13
+ return `https://cdn.staticfile.org/emoji-datasource-apple/14.0.0/img/${style}/64/${unified}.png`;
14
+ }
15
+
16
+ export function AvatarPicker(props: {
17
+ onEmojiClick: (emojiId: string) => void;
18
+ }) {
19
+ return (
20
+ <EmojiPicker
21
+ lazyLoadEmojis
22
+ theme={EmojiTheme.AUTO}
23
+ getEmojiUrl={getEmojiUrl}
24
+ onEmojiClick={(e) => {
25
+ props.onEmojiClick(e.unified);
26
+ }}
27
+ />
28
+ );
29
+ }
30
+
31
+ export function Avatar(props: { model?: ModelType; avatar?: string }) {
32
+ if (props.model) {
33
+ return (
34
+ <div className="no-dark">
35
+ {props.model?.startsWith("gpt-4") ? (
36
+ <BlackBotIcon className="user-avatar" />
37
+ ) : (
38
+ <BotIcon className="user-avatar" />
39
+ )}
40
+ </div>
41
+ );
42
+ }
43
+
44
+ return (
45
+ <div className="user-avatar">
46
+ {props.avatar && <EmojiAvatar avatar={props.avatar} />}
47
+ </div>
48
+ );
49
+ }
50
+
51
+ export function EmojiAvatar(props: { avatar: string; size?: number }) {
52
+ return (
53
+ <Emoji
54
+ unified={props.avatar}
55
+ size={props.size ?? 18}
56
+ getEmojiUrl={getEmojiUrl}
57
+ />
58
+ );
59
+ }
app/components/error.tsx ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { IconButton } from "./button";
3
+ import GithubIcon from "../icons/github.svg";
4
+ import ResetIcon from "../icons/reload.svg";
5
+ import { ISSUE_URL } from "../constant";
6
+ import Locale from "../locales";
7
+ import { downloadAs } from "../utils";
8
+
9
+ interface IErrorBoundaryState {
10
+ hasError: boolean;
11
+ error: Error | null;
12
+ info: React.ErrorInfo | null;
13
+ }
14
+
15
+ export class ErrorBoundary extends React.Component<any, IErrorBoundaryState> {
16
+ constructor(props: any) {
17
+ super(props);
18
+ this.state = { hasError: false, error: null, info: null };
19
+ }
20
+
21
+ componentDidCatch(error: Error, info: React.ErrorInfo) {
22
+ // Update state with error details
23
+ this.setState({ hasError: true, error, info });
24
+ }
25
+
26
+ clearAndSaveData() {
27
+ try {
28
+ downloadAs(
29
+ JSON.stringify(localStorage),
30
+ "chatgpt-next-web-snapshot.json",
31
+ );
32
+ } finally {
33
+ localStorage.clear();
34
+ location.reload();
35
+ }
36
+ }
37
+
38
+ render() {
39
+ if (this.state.hasError) {
40
+ // Render error message
41
+ return (
42
+ <div className="error">
43
+ <h2>Oops, something went wrong!</h2>
44
+ <pre>
45
+ <code>{this.state.error?.toString()}</code>
46
+ <code>{this.state.info?.componentStack}</code>
47
+ </pre>
48
+
49
+ <div style={{ display: "flex", justifyContent: "space-between" }}>
50
+ <a href={ISSUE_URL} className="report">
51
+ <IconButton
52
+ text="Report This Error"
53
+ icon={<GithubIcon />}
54
+ bordered
55
+ />
56
+ </a>
57
+ <IconButton
58
+ icon={<ResetIcon />}
59
+ text="Clear All Data"
60
+ onClick={() =>
61
+ confirm(Locale.Settings.Actions.ConfirmClearAll) &&
62
+ this.clearAndSaveData()
63
+ }
64
+ bordered
65
+ />
66
+ </div>
67
+ </div>
68
+ );
69
+ }
70
+ // if no error occurred, render children
71
+ return this.props.children;
72
+ }
73
+ }
app/components/home.module.scss ADDED
@@ -0,0 +1,572 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @mixin container {
2
+ background-color: var(--white);
3
+ border: var(--border-in-light);
4
+ border-radius: 20px;
5
+ box-shadow: var(--shadow);
6
+ color: var(--black);
7
+ background-color: var(--white);
8
+ min-width: 600px;
9
+ min-height: 480px;
10
+ max-width: 1200px;
11
+
12
+ display: flex;
13
+ overflow: hidden;
14
+ box-sizing: border-box;
15
+
16
+ width: var(--window-width);
17
+ height: var(--window-height);
18
+ }
19
+
20
+ .container {
21
+ @include container();
22
+ }
23
+
24
+ @media only screen and (min-width: 600px) {
25
+ .tight-container {
26
+ --window-width: 100vw;
27
+ --window-height: var(--full-height);
28
+ --window-content-width: calc(100% - var(--sidebar-width));
29
+
30
+ @include container();
31
+
32
+ max-width: 100vw;
33
+ max-height: var(--full-height);
34
+
35
+ border-radius: 0;
36
+ border: 0;
37
+ }
38
+ }
39
+
40
+ .sidebar {
41
+ top: 0;
42
+ width: var(--sidebar-width);
43
+ box-sizing: border-box;
44
+ padding: 20px;
45
+ background-color: var(--second);
46
+ display: flex;
47
+ flex-direction: column;
48
+ box-shadow: inset -2px 0px 2px 0px rgb(0, 0, 0, 0.05);
49
+ position: relative;
50
+ transition: width ease 0.05s;
51
+
52
+ .sidebar-header-bar {
53
+ display: flex;
54
+ margin-bottom: 20px;
55
+
56
+ .sidebar-bar-button {
57
+ flex-grow: 1;
58
+
59
+ &:not(:last-child) {
60
+ margin-right: 10px;
61
+ }
62
+ }
63
+ }
64
+ }
65
+
66
+ .sidebar-drag {
67
+ $width: 10px;
68
+
69
+ position: absolute;
70
+ top: 0;
71
+ right: 0;
72
+ height: 100%;
73
+ width: $width;
74
+ background-color: var(--black);
75
+ cursor: ew-resize;
76
+ opacity: 0;
77
+ transition: all ease 0.3s;
78
+
79
+ &:hover,
80
+ &:active {
81
+ opacity: 0.2;
82
+ }
83
+ }
84
+
85
+ .window-content {
86
+ width: var(--window-content-width);
87
+ height: 100%;
88
+ display: flex;
89
+ flex-direction: column;
90
+ }
91
+
92
+ .mobile {
93
+ display: none;
94
+ }
95
+
96
+ @media only screen and (max-width: 600px) {
97
+ .container {
98
+ min-height: unset;
99
+ min-width: unset;
100
+ max-height: unset;
101
+ min-width: unset;
102
+ border: 0;
103
+ border-radius: 0;
104
+ }
105
+
106
+ .sidebar {
107
+ position: absolute;
108
+ left: -100%;
109
+ z-index: 1000;
110
+ height: var(--full-height);
111
+ transition: all ease 0.3s;
112
+ box-shadow: none;
113
+ }
114
+
115
+ .sidebar-show {
116
+ left: 0;
117
+ }
118
+
119
+ .mobile {
120
+ display: block;
121
+ }
122
+ }
123
+
124
+ .sidebar-header {
125
+ position: relative;
126
+ padding-top: 20px;
127
+ padding-bottom: 20px;
128
+ }
129
+
130
+ .sidebar-logo {
131
+ position: absolute;
132
+ right: 0;
133
+ bottom: 18px;
134
+ }
135
+
136
+ .sidebar-title {
137
+ font-size: 20px;
138
+ font-weight: bold;
139
+ animation: slide-in ease 0.3s;
140
+ }
141
+
142
+ .sidebar-sub-title {
143
+ font-size: 12px;
144
+ font-weight: 400px;
145
+ animation: slide-in ease 0.3s;
146
+ }
147
+
148
+ .sidebar-body {
149
+ flex: 1;
150
+ overflow: auto;
151
+ overflow-x: hidden;
152
+ }
153
+
154
+ .chat-item {
155
+ padding: 10px 14px;
156
+ background-color: var(--white);
157
+ border-radius: 10px;
158
+ margin-bottom: 10px;
159
+ box-shadow: var(--card-shadow);
160
+ transition: background-color 0.3s ease;
161
+ cursor: pointer;
162
+ user-select: none;
163
+ border: 2px solid transparent;
164
+ position: relative;
165
+ }
166
+
167
+ .chat-item:hover {
168
+ background-color: var(--hover-color);
169
+ }
170
+
171
+ .chat-item-selected {
172
+ border-color: var(--primary);
173
+ }
174
+
175
+ .chat-item-title {
176
+ font-size: 14px;
177
+ font-weight: bolder;
178
+ display: block;
179
+ width: 200px;
180
+ overflow: hidden;
181
+ text-overflow: ellipsis;
182
+ white-space: nowrap;
183
+ animation: slide-in ease 0.3s;
184
+ }
185
+
186
+ .chat-item-delete {
187
+ position: absolute;
188
+ top: 10px;
189
+ right: -20px;
190
+ transition: all ease 0.3s;
191
+ opacity: 0;
192
+ cursor: pointer;
193
+ }
194
+
195
+ .chat-item:hover > .chat-item-delete {
196
+ opacity: 0.5;
197
+ right: 10px;
198
+ }
199
+
200
+ .chat-item:hover > .chat-item-delete:hover {
201
+ opacity: 1;
202
+ }
203
+
204
+ .chat-item-info {
205
+ display: flex;
206
+ justify-content: space-between;
207
+ color: rgb(166, 166, 166);
208
+ font-size: 12px;
209
+ margin-top: 8px;
210
+ animation: slide-in ease 0.3s;
211
+ }
212
+
213
+ .chat-item-count,
214
+ .chat-item-date {
215
+ overflow: hidden;
216
+ text-overflow: ellipsis;
217
+ white-space: nowrap;
218
+ }
219
+
220
+ .narrow-sidebar {
221
+ .sidebar-title,
222
+ .sidebar-sub-title {
223
+ display: none;
224
+ }
225
+ .sidebar-logo {
226
+ position: relative;
227
+ display: flex;
228
+ justify-content: center;
229
+ }
230
+
231
+ .sidebar-header-bar {
232
+ flex-direction: column;
233
+
234
+ .sidebar-bar-button {
235
+ &:not(:last-child) {
236
+ margin-right: 0;
237
+ margin-bottom: 10px;
238
+ }
239
+ }
240
+ }
241
+
242
+ .chat-item {
243
+ padding: 0;
244
+ min-height: 50px;
245
+ display: flex;
246
+ justify-content: center;
247
+ align-items: center;
248
+ transition: all ease 0.3s;
249
+ overflow: hidden;
250
+
251
+ &:hover {
252
+ .chat-item-narrow {
253
+ transform: scale(0.7) translateX(-50%);
254
+ }
255
+ }
256
+ }
257
+
258
+ .chat-item-narrow {
259
+ line-height: 0;
260
+ font-weight: lighter;
261
+ color: var(--black);
262
+ transform: translateX(0);
263
+ transition: all ease 0.3s;
264
+ padding: 4px;
265
+ display: flex;
266
+ flex-direction: column;
267
+ justify-content: center;
268
+
269
+ .chat-item-avatar {
270
+ display: flex;
271
+ justify-content: center;
272
+ opacity: 0.2;
273
+ position: absolute;
274
+ transform: scale(4);
275
+ }
276
+
277
+ .chat-item-narrow-count {
278
+ font-size: 24px;
279
+ font-weight: bolder;
280
+ text-align: center;
281
+ color: var(--primary);
282
+ opacity: 0.6;
283
+ }
284
+ }
285
+
286
+ .chat-item-delete {
287
+ top: 15px;
288
+ }
289
+
290
+ .chat-item:hover > .chat-item-delete {
291
+ opacity: 0.5;
292
+ right: 5px;
293
+ }
294
+
295
+ .sidebar-tail {
296
+ flex-direction: column-reverse;
297
+ align-items: center;
298
+
299
+ .sidebar-actions {
300
+ flex-direction: column-reverse;
301
+ align-items: center;
302
+
303
+ .sidebar-action {
304
+ margin-right: 0;
305
+ margin-top: 15px;
306
+ }
307
+ }
308
+ }
309
+ }
310
+
311
+ .sidebar-tail {
312
+ display: flex;
313
+ justify-content: space-between;
314
+ padding-top: 20px;
315
+ }
316
+
317
+ .sidebar-actions {
318
+ display: inline-flex;
319
+ }
320
+
321
+ .sidebar-action:not(:last-child) {
322
+ margin-right: 15px;
323
+ }
324
+
325
+ .chat {
326
+ display: flex;
327
+ flex-direction: column;
328
+ position: relative;
329
+ height: 100%;
330
+ }
331
+
332
+ .chat-body {
333
+ flex: 1;
334
+ overflow: auto;
335
+ padding: 20px;
336
+ padding-bottom: 40px;
337
+ position: relative;
338
+ }
339
+
340
+ .chat-body-title {
341
+ cursor: pointer;
342
+
343
+ &:hover {
344
+ text-decoration: underline;
345
+ }
346
+ }
347
+
348
+ .chat-message {
349
+ display: flex;
350
+ flex-direction: row;
351
+
352
+ &:last-child {
353
+ animation: slide-in ease 0.3s;
354
+ }
355
+ }
356
+
357
+ .chat-message-user {
358
+ display: flex;
359
+ flex-direction: row-reverse;
360
+ }
361
+
362
+ .chat-message-container {
363
+ max-width: var(--message-max-width);
364
+ display: flex;
365
+ flex-direction: column;
366
+ align-items: flex-start;
367
+
368
+ &:hover {
369
+ .chat-message-top-actions {
370
+ opacity: 1;
371
+ right: 10px;
372
+ pointer-events: all;
373
+ }
374
+ }
375
+ }
376
+
377
+ .chat-message-user > .chat-message-container {
378
+ align-items: flex-end;
379
+ }
380
+
381
+ .chat-message-avatar {
382
+ margin-top: 20px;
383
+ }
384
+
385
+ .chat-message-status {
386
+ font-size: 12px;
387
+ color: #aaa;
388
+ line-height: 1.5;
389
+ margin-top: 5px;
390
+ }
391
+
392
+ .chat-message-item {
393
+ box-sizing: border-box;
394
+ max-width: 100%;
395
+ margin-top: 10px;
396
+ border-radius: 10px;
397
+ background-color: rgba(0, 0, 0, 0.05);
398
+ padding: 10px;
399
+ font-size: 14px;
400
+ user-select: text;
401
+ word-break: break-word;
402
+ border: var(--border-in-light);
403
+ position: relative;
404
+ }
405
+
406
+ .chat-message-top-actions {
407
+ font-size: 12px;
408
+ position: absolute;
409
+ right: 20px;
410
+ top: -26px;
411
+ left: 100px;
412
+ transition: all ease 0.3s;
413
+ opacity: 0;
414
+ pointer-events: none;
415
+
416
+ display: flex;
417
+ flex-direction: row-reverse;
418
+
419
+ .chat-message-top-action {
420
+ opacity: 0.5;
421
+ color: var(--black);
422
+ white-space: nowrap;
423
+ cursor: pointer;
424
+
425
+ &:hover {
426
+ opacity: 1;
427
+ }
428
+
429
+ &:not(:first-child) {
430
+ margin-right: 10px;
431
+ }
432
+ }
433
+ }
434
+
435
+ .chat-message-user > .chat-message-container > .chat-message-item {
436
+ background-color: var(--second);
437
+ }
438
+
439
+ .chat-message-actions {
440
+ display: flex;
441
+ flex-direction: row-reverse;
442
+ width: 100%;
443
+ padding-top: 5px;
444
+ box-sizing: border-box;
445
+ font-size: 12px;
446
+ }
447
+
448
+ .chat-message-action-date {
449
+ color: #aaa;
450
+ }
451
+
452
+ .chat-input-panel {
453
+ position: relative;
454
+ width: 100%;
455
+ padding: 20px;
456
+ padding-top: 10px;
457
+ box-sizing: border-box;
458
+ flex-direction: column;
459
+ border-top-left-radius: 10px;
460
+ border-top-right-radius: 10px;
461
+ border-top: var(--border-in-light);
462
+ box-shadow: var(--card-shadow);
463
+ }
464
+
465
+ @mixin single-line {
466
+ white-space: nowrap;
467
+ overflow: hidden;
468
+ text-overflow: ellipsis;
469
+ }
470
+
471
+ .prompt-hints {
472
+ min-height: 20px;
473
+ width: 100%;
474
+ max-height: 50vh;
475
+ overflow: auto;
476
+ display: flex;
477
+ flex-direction: column-reverse;
478
+
479
+ background-color: var(--white);
480
+ border: var(--border-in-light);
481
+ border-radius: 10px;
482
+ margin-bottom: 10px;
483
+ box-shadow: var(--shadow);
484
+
485
+ .prompt-hint {
486
+ color: var(--black);
487
+ padding: 6px 10px;
488
+ animation: slide-in ease 0.3s;
489
+ cursor: pointer;
490
+ transition: all ease 0.3s;
491
+ border: transparent 1px solid;
492
+ margin: 4px;
493
+ border-radius: 8px;
494
+
495
+ &:not(:last-child) {
496
+ margin-top: 0;
497
+ }
498
+
499
+ .hint-title {
500
+ font-size: 12px;
501
+ font-weight: bolder;
502
+
503
+ @include single-line();
504
+ }
505
+ .hint-content {
506
+ font-size: 12px;
507
+
508
+ @include single-line();
509
+ }
510
+
511
+ &-selected,
512
+ &:hover {
513
+ border-color: var(--primary);
514
+ }
515
+ }
516
+ }
517
+
518
+ .chat-input-panel-inner {
519
+ display: flex;
520
+ flex: 1;
521
+ }
522
+
523
+ .chat-input {
524
+ height: 100%;
525
+ width: 100%;
526
+ border-radius: 10px;
527
+ border: var(--border-in-light);
528
+ box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.03);
529
+ background-color: var(--white);
530
+ color: var(--black);
531
+ font-family: inherit;
532
+ padding: 10px 90px 10px 14px;
533
+ resize: none;
534
+ outline: none;
535
+ }
536
+
537
+ .chat-input:focus {
538
+ border: 1px solid var(--primary);
539
+ }
540
+
541
+ .chat-input-send {
542
+ background-color: var(--primary);
543
+ color: white;
544
+
545
+ position: absolute;
546
+ right: 30px;
547
+ bottom: 32px;
548
+ }
549
+
550
+ @media only screen and (max-width: 600px) {
551
+ .chat-input {
552
+ font-size: 16px;
553
+ }
554
+
555
+ .chat-input-send {
556
+ bottom: 30px;
557
+ }
558
+ }
559
+
560
+ .export-content {
561
+ white-space: break-spaces;
562
+ padding: 10px !important;
563
+ }
564
+
565
+ .loading-content {
566
+ display: flex;
567
+ flex-direction: column;
568
+ justify-content: center;
569
+ align-items: center;
570
+ height: 100%;
571
+ width: 100%;
572
+ }
app/components/home.tsx ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ require("../polyfill");
4
+
5
+ import { useState, useEffect } from "react";
6
+
7
+ import styles from "./home.module.scss";
8
+
9
+ import BotIcon from "../icons/bot.svg";
10
+ import LoadingIcon from "../icons/three-dots.svg";
11
+
12
+ import { getCSSVar, useMobileScreen } from "../utils";
13
+
14
+ import dynamic from "next/dynamic";
15
+ import { Path, SlotID } from "../constant";
16
+ import { ErrorBoundary } from "./error";
17
+
18
+ import {
19
+ HashRouter as Router,
20
+ Routes,
21
+ Route,
22
+ useLocation,
23
+ } from "react-router-dom";
24
+ import { SideBar } from "./sidebar";
25
+ import { useAppConfig } from "../store/config";
26
+
27
+ export function Loading(props: { noLogo?: boolean }) {
28
+ return (
29
+ <div className={styles["loading-content"] + " no-dark"}>
30
+ {!props.noLogo && <BotIcon />}
31
+ <LoadingIcon />
32
+ </div>
33
+ );
34
+ }
35
+
36
+ const Settings = dynamic(async () => (await import("./settings")).Settings, {
37
+ loading: () => <Loading noLogo />,
38
+ });
39
+
40
+ const Chat = dynamic(async () => (await import("./chat")).Chat, {
41
+ loading: () => <Loading noLogo />,
42
+ });
43
+
44
+ const NewChat = dynamic(async () => (await import("./new-chat")).NewChat, {
45
+ loading: () => <Loading noLogo />,
46
+ });
47
+
48
+ const MaskPage = dynamic(async () => (await import("./mask")).MaskPage, {
49
+ loading: () => <Loading noLogo />,
50
+ });
51
+
52
+ export function useSwitchTheme() {
53
+ const config = useAppConfig();
54
+
55
+ useEffect(() => {
56
+ document.body.classList.remove("light");
57
+ document.body.classList.remove("dark");
58
+
59
+ if (config.theme === "dark") {
60
+ document.body.classList.add("dark");
61
+ } else if (config.theme === "light") {
62
+ document.body.classList.add("light");
63
+ }
64
+
65
+ const metaDescriptionDark = document.querySelector(
66
+ 'meta[name="theme-color"][media]',
67
+ );
68
+ const metaDescriptionLight = document.querySelector(
69
+ 'meta[name="theme-color"]:not([media])',
70
+ );
71
+
72
+ if (config.theme === "auto") {
73
+ metaDescriptionDark?.setAttribute("content", "#151515");
74
+ metaDescriptionLight?.setAttribute("content", "#fafafa");
75
+ } else {
76
+ const themeColor = getCSSVar("--themeColor");
77
+ metaDescriptionDark?.setAttribute("content", themeColor);
78
+ metaDescriptionLight?.setAttribute("content", themeColor);
79
+ }
80
+ }, [config.theme]);
81
+ }
82
+
83
+ const useHasHydrated = () => {
84
+ const [hasHydrated, setHasHydrated] = useState<boolean>(false);
85
+
86
+ useEffect(() => {
87
+ setHasHydrated(true);
88
+ }, []);
89
+
90
+ return hasHydrated;
91
+ };
92
+
93
+ function Screen() {
94
+ const config = useAppConfig();
95
+ const location = useLocation();
96
+ const isHome = location.pathname === Path.Home;
97
+ const isMobileScreen = useMobileScreen();
98
+
99
+ return (
100
+ <div
101
+ className={
102
+ styles.container +
103
+ ` ${
104
+ config.tightBorder && !isMobileScreen
105
+ ? styles["tight-container"]
106
+ : styles.container
107
+ }`
108
+ }
109
+ >
110
+ <SideBar className={isHome ? styles["sidebar-show"] : ""} />
111
+
112
+ <div className={styles["window-content"]} id={SlotID.AppBody}>
113
+ <Routes>
114
+ <Route path={Path.Home} element={<Chat />} />
115
+ <Route path={Path.NewChat} element={<NewChat />} />
116
+ <Route path={Path.Masks} element={<MaskPage />} />
117
+ <Route path={Path.Chat} element={<Chat />} />
118
+ <Route path={Path.Settings} element={<Settings />} />
119
+ </Routes>
120
+ </div>
121
+ </div>
122
+ );
123
+ }
124
+
125
+ export function Home() {
126
+ useSwitchTheme();
127
+
128
+ if (!useHasHydrated()) {
129
+ return <Loading />;
130
+ }
131
+
132
+ return (
133
+ <ErrorBoundary>
134
+ <Router>
135
+ <Screen />
136
+ </Router>
137
+ </ErrorBoundary>
138
+ );
139
+ }
app/components/input-range.module.scss ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ .input-range {
2
+ border: var(--border-in-light);
3
+ border-radius: 10px;
4
+ padding: 5px 15px 5px 10px;
5
+ font-size: 12px;
6
+ display: flex;
7
+ }
app/components/input-range.tsx ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import styles from "./input-range.module.scss";
3
+
4
+ interface InputRangeProps {
5
+ onChange: React.ChangeEventHandler<HTMLInputElement>;
6
+ title?: string;
7
+ value: number | string;
8
+ className?: string;
9
+ min: string;
10
+ max: string;
11
+ step: string;
12
+ }
13
+
14
+ export function InputRange({
15
+ onChange,
16
+ title,
17
+ value,
18
+ className,
19
+ min,
20
+ max,
21
+ step,
22
+ }: InputRangeProps) {
23
+ return (
24
+ <div className={styles["input-range"] + ` ${className ?? ""}`}>
25
+ {title || value}
26
+ <input
27
+ type="range"
28
+ title={title}
29
+ value={value}
30
+ min={min}
31
+ max={max}
32
+ step={step}
33
+ onChange={onChange}
34
+ ></input>
35
+ </div>
36
+ );
37
+ }
app/components/markdown.tsx ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ReactMarkdown from "react-markdown";
2
+ import "katex/dist/katex.min.css";
3
+ import RemarkMath from "remark-math";
4
+ import RemarkBreaks from "remark-breaks";
5
+ import RehypeKatex from "rehype-katex";
6
+ import RemarkGfm from "remark-gfm";
7
+ import RehypeHighlight from "rehype-highlight";
8
+ import { useRef, useState, RefObject, useEffect } from "react";
9
+ import { copyToClipboard } from "../utils";
10
+
11
+ import LoadingIcon from "../icons/three-dots.svg";
12
+ import React from "react";
13
+
14
+ export function PreCode(props: { children: any }) {
15
+ const ref = useRef<HTMLPreElement>(null);
16
+
17
+ return (
18
+ <pre ref={ref}>
19
+ <span
20
+ className="copy-code-button"
21
+ onClick={() => {
22
+ if (ref.current) {
23
+ const code = ref.current.innerText;
24
+ copyToClipboard(code);
25
+ }
26
+ }}
27
+ ></span>
28
+ {props.children}
29
+ </pre>
30
+ );
31
+ }
32
+
33
+ function _MarkDownContent(props: { content: string }) {
34
+ return (
35
+ <ReactMarkdown
36
+ remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
37
+ rehypePlugins={[
38
+ RehypeKatex,
39
+ [
40
+ RehypeHighlight,
41
+ {
42
+ detect: false,
43
+ ignoreMissing: true,
44
+ },
45
+ ],
46
+ ]}
47
+ components={{
48
+ pre: PreCode,
49
+ a: (aProps) => {
50
+ const href = aProps.href || "";
51
+ const isInternal = /^\/#/i.test(href);
52
+ const target = isInternal ? "_self" : aProps.target ?? "_blank";
53
+ return <a {...aProps} target={target} />;
54
+ },
55
+ }}
56
+ >
57
+ {props.content}
58
+ </ReactMarkdown>
59
+ );
60
+ }
61
+
62
+ export const MarkdownContent = React.memo(_MarkDownContent);
63
+
64
+ export function Markdown(
65
+ props: {
66
+ content: string;
67
+ loading?: boolean;
68
+ fontSize?: number;
69
+ parentRef: RefObject<HTMLDivElement>;
70
+ defaultShow?: boolean;
71
+ } & React.DOMAttributes<HTMLDivElement>,
72
+ ) {
73
+ const mdRef = useRef<HTMLDivElement>(null);
74
+ const renderedHeight = useRef(0);
75
+ const inView = useRef(!!props.defaultShow);
76
+
77
+ const parent = props.parentRef.current;
78
+ const md = mdRef.current;
79
+
80
+ const checkInView = () => {
81
+ if (parent && md) {
82
+ const parentBounds = parent.getBoundingClientRect();
83
+ const twoScreenHeight = Math.max(500, parentBounds.height * 2);
84
+ const mdBounds = md.getBoundingClientRect();
85
+ const isInRange = (x: number) =>
86
+ x <= parentBounds.bottom + twoScreenHeight &&
87
+ x >= parentBounds.top - twoScreenHeight;
88
+ inView.current = isInRange(mdBounds.top) || isInRange(mdBounds.bottom);
89
+ }
90
+
91
+ if (inView.current && md) {
92
+ renderedHeight.current = Math.max(
93
+ renderedHeight.current,
94
+ md.getBoundingClientRect().height,
95
+ );
96
+ }
97
+ };
98
+
99
+ checkInView();
100
+
101
+ return (
102
+ <div
103
+ className="markdown-body"
104
+ style={{
105
+ fontSize: `${props.fontSize ?? 14}px`,
106
+ height:
107
+ !inView.current && renderedHeight.current > 0
108
+ ? renderedHeight.current
109
+ : "auto",
110
+ }}
111
+ ref={mdRef}
112
+ onContextMenu={props.onContextMenu}
113
+ onDoubleClickCapture={props.onDoubleClickCapture}
114
+ >
115
+ {inView.current &&
116
+ (props.loading ? (
117
+ <LoadingIcon />
118
+ ) : (
119
+ <MarkdownContent content={props.content} />
120
+ ))}
121
+ </div>
122
+ );
123
+ }
app/components/mask.module.scss ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "../styles/animation.scss";
2
+ .mask-page {
3
+ height: 100%;
4
+ display: flex;
5
+ flex-direction: column;
6
+
7
+ .mask-page-body {
8
+ padding: 20px;
9
+ overflow-y: auto;
10
+
11
+ .mask-filter {
12
+ width: 100%;
13
+ max-width: 100%;
14
+ margin-bottom: 20px;
15
+ animation: slide-in ease 0.3s;
16
+ height: 40px;
17
+
18
+ display: flex;
19
+
20
+ .search-bar {
21
+ flex-grow: 1;
22
+ max-width: 100%;
23
+ min-width: 0;
24
+ }
25
+
26
+ .mask-filter-lang {
27
+ height: 100%;
28
+ margin-left: 10px;
29
+ }
30
+
31
+ .mask-create {
32
+ height: 100%;
33
+ margin-left: 10px;
34
+ box-sizing: border-box;
35
+ min-width: 80px;
36
+ }
37
+ }
38
+
39
+ .mask-item {
40
+ display: flex;
41
+ justify-content: space-between;
42
+ padding: 20px;
43
+ border: var(--border-in-light);
44
+ animation: slide-in ease 0.3s;
45
+
46
+ &:not(:last-child) {
47
+ border-bottom: 0;
48
+ }
49
+
50
+ &:first-child {
51
+ border-top-left-radius: 10px;
52
+ border-top-right-radius: 10px;
53
+ }
54
+
55
+ &:last-child {
56
+ border-bottom-left-radius: 10px;
57
+ border-bottom-right-radius: 10px;
58
+ }
59
+
60
+ .mask-header {
61
+ display: flex;
62
+ align-items: center;
63
+
64
+ .mask-icon {
65
+ display: flex;
66
+ align-items: center;
67
+ justify-content: center;
68
+ margin-right: 10px;
69
+ }
70
+
71
+ .mask-title {
72
+ .mask-name {
73
+ font-size: 14px;
74
+ font-weight: bold;
75
+ }
76
+ .mask-info {
77
+ font-size: 12px;
78
+ }
79
+ }
80
+ }
81
+
82
+ .mask-actions {
83
+ display: flex;
84
+ flex-wrap: nowrap;
85
+ transition: all ease 0.3s;
86
+ }
87
+
88
+ @media screen and (max-width: 600px) {
89
+ display: flex;
90
+ flex-direction: column;
91
+ padding-bottom: 10px;
92
+ border-radius: 10px;
93
+ margin-bottom: 20px;
94
+ box-shadow: var(--card-shadow);
95
+
96
+ &:not(:last-child) {
97
+ border-bottom: var(--border-in-light);
98
+ }
99
+
100
+ .mask-actions {
101
+ width: 100%;
102
+ justify-content: space-between;
103
+ padding-top: 10px;
104
+ }
105
+ }
106
+ }
107
+ }
108
+ }
app/components/mask.tsx ADDED
@@ -0,0 +1,402 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { IconButton } from "./button";
2
+ import { ErrorBoundary } from "./error";
3
+
4
+ import styles from "./mask.module.scss";
5
+
6
+ import DownloadIcon from "../icons/download.svg";
7
+ import UploadIcon from "../icons/upload.svg";
8
+ import EditIcon from "../icons/edit.svg";
9
+ import AddIcon from "../icons/add.svg";
10
+ import CloseIcon from "../icons/close.svg";
11
+ import DeleteIcon from "../icons/delete.svg";
12
+ import EyeIcon from "../icons/eye.svg";
13
+ import CopyIcon from "../icons/copy.svg";
14
+
15
+ import { DEFAULT_MASK_AVATAR, Mask, useMaskStore } from "../store/mask";
16
+ import { Message, ModelConfig, ROLES, useChatStore } from "../store";
17
+ import { Input, List, ListItem, Modal, Popover, showToast } from "./ui-lib";
18
+ import { Avatar, AvatarPicker } from "./emoji";
19
+ import Locale, { AllLangs, Lang } from "../locales";
20
+ import { useNavigate } from "react-router-dom";
21
+
22
+ import chatStyle from "./chat.module.scss";
23
+ import { useEffect, useState } from "react";
24
+ import { downloadAs } from "../utils";
25
+ import { Updater } from "../api/openai/typing";
26
+ import { ModelConfigList } from "./model-config";
27
+ import { FileName, Path } from "../constant";
28
+ import { BUILTIN_MASK_STORE } from "../masks";
29
+
30
+ export function MaskAvatar(props: { mask: Mask }) {
31
+ return props.mask.avatar !== DEFAULT_MASK_AVATAR ? (
32
+ <Avatar avatar={props.mask.avatar} />
33
+ ) : (
34
+ <Avatar model={props.mask.modelConfig.model} />
35
+ );
36
+ }
37
+
38
+ export function MaskConfig(props: {
39
+ mask: Mask;
40
+ updateMask: Updater<Mask>;
41
+ extraListItems?: JSX.Element;
42
+ readonly?: boolean;
43
+ }) {
44
+ const [showPicker, setShowPicker] = useState(false);
45
+
46
+ const updateConfig = (updater: (config: ModelConfig) => void) => {
47
+ if (props.readonly) return;
48
+
49
+ const config = { ...props.mask.modelConfig };
50
+ updater(config);
51
+ props.updateMask((mask) => (mask.modelConfig = config));
52
+ };
53
+
54
+ return (
55
+ <>
56
+ <ContextPrompts
57
+ context={props.mask.context}
58
+ updateContext={(updater) => {
59
+ const context = props.mask.context.slice();
60
+ updater(context);
61
+ props.updateMask((mask) => (mask.context = context));
62
+ }}
63
+ />
64
+
65
+ <List>
66
+ <ListItem title={Locale.Mask.Config.Avatar}>
67
+ <Popover
68
+ content={
69
+ <AvatarPicker
70
+ onEmojiClick={(emoji) => {
71
+ props.updateMask((mask) => (mask.avatar = emoji));
72
+ setShowPicker(false);
73
+ }}
74
+ ></AvatarPicker>
75
+ }
76
+ open={showPicker}
77
+ onClose={() => setShowPicker(false)}
78
+ >
79
+ <div
80
+ onClick={() => setShowPicker(true)}
81
+ style={{ cursor: "pointer" }}
82
+ >
83
+ <MaskAvatar mask={props.mask} />
84
+ </div>
85
+ </Popover>
86
+ </ListItem>
87
+ <ListItem title={Locale.Mask.Config.Name}>
88
+ <input
89
+ type="text"
90
+ value={props.mask.name}
91
+ onInput={(e) =>
92
+ props.updateMask((mask) => (mask.name = e.currentTarget.value))
93
+ }
94
+ ></input>
95
+ </ListItem>
96
+ </List>
97
+
98
+ <List>
99
+ <ModelConfigList
100
+ modelConfig={{ ...props.mask.modelConfig }}
101
+ updateConfig={updateConfig}
102
+ />
103
+ {props.extraListItems}
104
+ </List>
105
+ </>
106
+ );
107
+ }
108
+
109
+ export function ContextPrompts(props: {
110
+ context: Message[];
111
+ updateContext: (updater: (context: Message[]) => void) => void;
112
+ }) {
113
+ const context = props.context;
114
+
115
+ const addContextPrompt = (prompt: Message) => {
116
+ props.updateContext((context) => context.push(prompt));
117
+ };
118
+
119
+ const removeContextPrompt = (i: number) => {
120
+ props.updateContext((context) => context.splice(i, 1));
121
+ };
122
+
123
+ const updateContextPrompt = (i: number, prompt: Message) => {
124
+ props.updateContext((context) => (context[i] = prompt));
125
+ };
126
+
127
+ return (
128
+ <>
129
+ <div className={chatStyle["context-prompt"]} style={{ marginBottom: 20 }}>
130
+ {context.map((c, i) => (
131
+ <div className={chatStyle["context-prompt-row"]} key={i}>
132
+ <select
133
+ value={c.role}
134
+ className={chatStyle["context-role"]}
135
+ onChange={(e) =>
136
+ updateContextPrompt(i, {
137
+ ...c,
138
+ role: e.target.value as any,
139
+ })
140
+ }
141
+ >
142
+ {ROLES.map((r) => (
143
+ <option key={r} value={r}>
144
+ {r}
145
+ </option>
146
+ ))}
147
+ </select>
148
+ <Input
149
+ value={c.content}
150
+ type="text"
151
+ className={chatStyle["context-content"]}
152
+ rows={1}
153
+ onInput={(e) =>
154
+ updateContextPrompt(i, {
155
+ ...c,
156
+ content: e.currentTarget.value as any,
157
+ })
158
+ }
159
+ />
160
+ <IconButton
161
+ icon={<DeleteIcon />}
162
+ className={chatStyle["context-delete-button"]}
163
+ onClick={() => removeContextPrompt(i)}
164
+ bordered
165
+ />
166
+ </div>
167
+ ))}
168
+
169
+ <div className={chatStyle["context-prompt-row"]}>
170
+ <IconButton
171
+ icon={<AddIcon />}
172
+ text={Locale.Context.Add}
173
+ bordered
174
+ className={chatStyle["context-prompt-button"]}
175
+ onClick={() =>
176
+ addContextPrompt({
177
+ role: "system",
178
+ content: "",
179
+ date: "",
180
+ })
181
+ }
182
+ />
183
+ </div>
184
+ </div>
185
+ </>
186
+ );
187
+ }
188
+
189
+ export function MaskPage() {
190
+ const navigate = useNavigate();
191
+
192
+ const maskStore = useMaskStore();
193
+ const chatStore = useChatStore();
194
+
195
+ const [filterLang, setFilterLang] = useState<Lang>();
196
+
197
+ const allMasks = maskStore
198
+ .getAll()
199
+ .filter((m) => !filterLang || m.lang === filterLang);
200
+
201
+ const [searchMasks, setSearchMasks] = useState<Mask[]>([]);
202
+ const [searchText, setSearchText] = useState("");
203
+ const masks = searchText.length > 0 ? searchMasks : allMasks;
204
+
205
+ // simple search, will refactor later
206
+ const onSearch = (text: string) => {
207
+ setSearchText(text);
208
+ if (text.length > 0) {
209
+ const result = allMasks.filter((m) => m.name.includes(text));
210
+ setSearchMasks(result);
211
+ } else {
212
+ setSearchMasks(allMasks);
213
+ }
214
+ };
215
+
216
+ const [editingMaskId, setEditingMaskId] = useState<number | undefined>();
217
+ const editingMask =
218
+ maskStore.get(editingMaskId) ?? BUILTIN_MASK_STORE.get(editingMaskId);
219
+ const closeMaskModal = () => setEditingMaskId(undefined);
220
+
221
+ const downloadAll = () => {
222
+ downloadAs(JSON.stringify(masks), FileName.Masks);
223
+ };
224
+
225
+ return (
226
+ <ErrorBoundary>
227
+ <div className={styles["mask-page"]}>
228
+ <div className="window-header">
229
+ <div className="window-header-title">
230
+ <div className="window-header-main-title">
231
+ {Locale.Mask.Page.Title}
232
+ </div>
233
+ <div className="window-header-submai-title">
234
+ {Locale.Mask.Page.SubTitle(allMasks.length)}
235
+ </div>
236
+ </div>
237
+
238
+ <div className="window-actions">
239
+ <div className="window-action-button">
240
+ <IconButton
241
+ icon={<DownloadIcon />}
242
+ bordered
243
+ onClick={downloadAll}
244
+ />
245
+ </div>
246
+ <div className="window-action-button">
247
+ <IconButton
248
+ icon={<UploadIcon />}
249
+ bordered
250
+ onClick={() => showToast(Locale.WIP)}
251
+ />
252
+ </div>
253
+ <div className="window-action-button">
254
+ <IconButton
255
+ icon={<CloseIcon />}
256
+ bordered
257
+ onClick={() => navigate(-1)}
258
+ />
259
+ </div>
260
+ </div>
261
+ </div>
262
+
263
+ <div className={styles["mask-page-body"]}>
264
+ <div className={styles["mask-filter"]}>
265
+ <input
266
+ type="text"
267
+ className={styles["search-bar"]}
268
+ placeholder={Locale.Mask.Page.Search}
269
+ autoFocus
270
+ onInput={(e) => onSearch(e.currentTarget.value)}
271
+ />
272
+ <select
273
+ className={styles["mask-filter-lang"]}
274
+ value={filterLang ?? Locale.Settings.Lang.All}
275
+ onChange={(e) => {
276
+ const value = e.currentTarget.value;
277
+ if (value === Locale.Settings.Lang.All) {
278
+ setFilterLang(undefined);
279
+ } else {
280
+ setFilterLang(value as Lang);
281
+ }
282
+ }}
283
+ >
284
+ <option key="all" value={Locale.Settings.Lang.All}>
285
+ {Locale.Settings.Lang.All}
286
+ </option>
287
+ {AllLangs.map((lang) => (
288
+ <option value={lang} key={lang}>
289
+ {Locale.Settings.Lang.Options[lang]}
290
+ </option>
291
+ ))}
292
+ </select>
293
+
294
+ <IconButton
295
+ className={styles["mask-create"]}
296
+ icon={<AddIcon />}
297
+ text={Locale.Mask.Page.Create}
298
+ bordered
299
+ onClick={() => {
300
+ const createdMask = maskStore.create();
301
+ setEditingMaskId(createdMask.id);
302
+ }}
303
+ />
304
+ </div>
305
+
306
+ <div>
307
+ {masks.map((m) => (
308
+ <div className={styles["mask-item"]} key={m.id}>
309
+ <div className={styles["mask-header"]}>
310
+ <div className={styles["mask-icon"]}>
311
+ <MaskAvatar mask={m} />
312
+ </div>
313
+ <div className={styles["mask-title"]}>
314
+ <div className={styles["mask-name"]}>{m.name}</div>
315
+ <div className={styles["mask-info"] + " one-line"}>
316
+ {`${Locale.Mask.Item.Info(m.context.length)} / ${
317
+ Locale.Settings.Lang.Options[m.lang]
318
+ } / ${m.modelConfig.model}`}
319
+ </div>
320
+ </div>
321
+ </div>
322
+ <div className={styles["mask-actions"]}>
323
+ <IconButton
324
+ icon={<AddIcon />}
325
+ text={Locale.Mask.Item.Chat}
326
+ onClick={() => {
327
+ chatStore.newSession(m);
328
+ navigate(Path.Chat);
329
+ }}
330
+ />
331
+ {m.builtin ? (
332
+ <IconButton
333
+ icon={<EyeIcon />}
334
+ text={Locale.Mask.Item.View}
335
+ onClick={() => setEditingMaskId(m.id)}
336
+ />
337
+ ) : (
338
+ <IconButton
339
+ icon={<EditIcon />}
340
+ text={Locale.Mask.Item.Edit}
341
+ onClick={() => setEditingMaskId(m.id)}
342
+ />
343
+ )}
344
+ {!m.builtin && (
345
+ <IconButton
346
+ icon={<DeleteIcon />}
347
+ text={Locale.Mask.Item.Delete}
348
+ onClick={() => {
349
+ if (confirm(Locale.Mask.Item.DeleteConfirm)) {
350
+ maskStore.delete(m.id);
351
+ }
352
+ }}
353
+ />
354
+ )}
355
+ </div>
356
+ </div>
357
+ ))}
358
+ </div>
359
+ </div>
360
+ </div>
361
+
362
+ {editingMask && (
363
+ <div className="modal-mask">
364
+ <Modal
365
+ title={Locale.Mask.EditModal.Title(editingMask?.builtin)}
366
+ onClose={closeMaskModal}
367
+ actions={[
368
+ <IconButton
369
+ icon={<DownloadIcon />}
370
+ text={Locale.Mask.EditModal.Download}
371
+ key="export"
372
+ bordered
373
+ onClick={() =>
374
+ downloadAs(JSON.stringify(editingMask), "mask.json")
375
+ }
376
+ />,
377
+ <IconButton
378
+ key="copy"
379
+ icon={<CopyIcon />}
380
+ bordered
381
+ text={Locale.Mask.EditModal.Clone}
382
+ onClick={() => {
383
+ navigate(Path.Masks);
384
+ maskStore.create(editingMask);
385
+ setEditingMaskId(undefined);
386
+ }}
387
+ />,
388
+ ]}
389
+ >
390
+ <MaskConfig
391
+ mask={editingMask}
392
+ updateMask={(updater) =>
393
+ maskStore.update(editingMaskId!, updater)
394
+ }
395
+ readonly={editingMask.builtin}
396
+ />
397
+ </Modal>
398
+ </div>
399
+ )}
400
+ </ErrorBoundary>
401
+ );
402
+ }
app/components/model-config.tsx ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import styles from "./settings.module.scss";
2
+ import { ALL_MODELS, ModalConfigValidator, ModelConfig } from "../store";
3
+
4
+ import Locale from "../locales";
5
+ import { InputRange } from "./input-range";
6
+ import { List, ListItem } from "./ui-lib";
7
+
8
+ export function ModelConfigList(props: {
9
+ modelConfig: ModelConfig;
10
+ updateConfig: (updater: (config: ModelConfig) => void) => void;
11
+ }) {
12
+ return (
13
+ <>
14
+ <ListItem title={Locale.Settings.Model}>
15
+ <select
16
+ value={props.modelConfig.model}
17
+ onChange={(e) => {
18
+ props.updateConfig(
19
+ (config) =>
20
+ (config.model = ModalConfigValidator.model(
21
+ e.currentTarget.value,
22
+ )),
23
+ );
24
+ }}
25
+ >
26
+ {ALL_MODELS.map((v) => (
27
+ <option value={v.name} key={v.name} disabled={!v.available}>
28
+ {v.name}
29
+ </option>
30
+ ))}
31
+ </select>
32
+ </ListItem>
33
+ <ListItem
34
+ title={Locale.Settings.Temperature.Title}
35
+ subTitle={Locale.Settings.Temperature.SubTitle}
36
+ >
37
+ <InputRange
38
+ value={props.modelConfig.temperature?.toFixed(1)}
39
+ min="0"
40
+ max="1" // lets limit it to 0-1
41
+ step="0.1"
42
+ onChange={(e) => {
43
+ props.updateConfig(
44
+ (config) =>
45
+ (config.temperature = ModalConfigValidator.temperature(
46
+ e.currentTarget.valueAsNumber,
47
+ )),
48
+ );
49
+ }}
50
+ ></InputRange>
51
+ </ListItem>
52
+ <ListItem
53
+ title={Locale.Settings.MaxTokens.Title}
54
+ subTitle={Locale.Settings.MaxTokens.SubTitle}
55
+ >
56
+ <input
57
+ type="number"
58
+ min={100}
59
+ max={32000}
60
+ value={props.modelConfig.max_tokens}
61
+ onChange={(e) =>
62
+ props.updateConfig(
63
+ (config) =>
64
+ (config.max_tokens = ModalConfigValidator.max_tokens(
65
+ e.currentTarget.valueAsNumber,
66
+ )),
67
+ )
68
+ }
69
+ ></input>
70
+ </ListItem>
71
+ <ListItem
72
+ title={Locale.Settings.PresencePenlty.Title}
73
+ subTitle={Locale.Settings.PresencePenlty.SubTitle}
74
+ >
75
+ <InputRange
76
+ value={props.modelConfig.presence_penalty?.toFixed(1)}
77
+ min="-2"
78
+ max="2"
79
+ step="0.1"
80
+ onChange={(e) => {
81
+ props.updateConfig(
82
+ (config) =>
83
+ (config.presence_penalty =
84
+ ModalConfigValidator.presence_penalty(
85
+ e.currentTarget.valueAsNumber,
86
+ )),
87
+ );
88
+ }}
89
+ ></InputRange>
90
+ </ListItem>
91
+
92
+ <ListItem
93
+ title={Locale.Settings.HistoryCount.Title}
94
+ subTitle={Locale.Settings.HistoryCount.SubTitle}
95
+ >
96
+ <InputRange
97
+ title={props.modelConfig.historyMessageCount.toString()}
98
+ value={props.modelConfig.historyMessageCount}
99
+ min="0"
100
+ max="32"
101
+ step="1"
102
+ onChange={(e) =>
103
+ props.updateConfig(
104
+ (config) => (config.historyMessageCount = e.target.valueAsNumber),
105
+ )
106
+ }
107
+ ></InputRange>
108
+ </ListItem>
109
+
110
+ <ListItem
111
+ title={Locale.Settings.CompressThreshold.Title}
112
+ subTitle={Locale.Settings.CompressThreshold.SubTitle}
113
+ >
114
+ <input
115
+ type="number"
116
+ min={500}
117
+ max={4000}
118
+ value={props.modelConfig.compressMessageLengthThreshold}
119
+ onChange={(e) =>
120
+ props.updateConfig(
121
+ (config) =>
122
+ (config.compressMessageLengthThreshold =
123
+ e.currentTarget.valueAsNumber),
124
+ )
125
+ }
126
+ ></input>
127
+ </ListItem>
128
+ <ListItem title={Locale.Memory.Title} subTitle={Locale.Memory.Send}>
129
+ <input
130
+ type="checkbox"
131
+ checked={props.modelConfig.sendMemory}
132
+ onChange={(e) =>
133
+ props.updateConfig(
134
+ (config) => (config.sendMemory = e.currentTarget.checked),
135
+ )
136
+ }
137
+ ></input>
138
+ </ListItem>
139
+ </>
140
+ );
141
+ }
app/components/new-chat.module.scss ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "../styles/animation.scss";
2
+
3
+ .new-chat {
4
+ height: 100%;
5
+ width: 100%;
6
+ display: flex;
7
+ align-items: center;
8
+ justify-content: center;
9
+ flex-direction: column;
10
+
11
+ .mask-header {
12
+ display: flex;
13
+ justify-content: space-between;
14
+ width: 100%;
15
+ padding: 10px;
16
+ box-sizing: border-box;
17
+ animation: slide-in-from-top ease 0.3s;
18
+ }
19
+
20
+ .mask-cards {
21
+ display: flex;
22
+ margin-top: 5vh;
23
+ margin-bottom: 20px;
24
+ animation: slide-in ease 0.3s;
25
+
26
+ .mask-card {
27
+ padding: 20px 10px;
28
+ border: var(--border-in-light);
29
+ box-shadow: var(--card-shadow);
30
+ border-radius: 14px;
31
+ background-color: var(--white);
32
+ transform: scale(1);
33
+
34
+ &:first-child {
35
+ transform: rotate(-15deg) translateY(5px);
36
+ }
37
+
38
+ &:last-child {
39
+ transform: rotate(15deg) translateY(5px);
40
+ }
41
+ }
42
+ }
43
+
44
+ .title {
45
+ font-size: 32px;
46
+ font-weight: bolder;
47
+ margin-bottom: 1vh;
48
+ animation: slide-in ease 0.35s;
49
+ }
50
+
51
+ .sub-title {
52
+ animation: slide-in ease 0.4s;
53
+ }
54
+
55
+ .actions {
56
+ margin-top: 5vh;
57
+ margin-bottom: 5vh;
58
+ animation: slide-in ease 0.45s;
59
+ display: flex;
60
+ justify-content: center;
61
+
62
+ .more {
63
+ font-size: 12px;
64
+ margin-left: 10px;
65
+ }
66
+ }
67
+
68
+ .masks {
69
+ flex-grow: 1;
70
+ width: 100%;
71
+ overflow: hidden;
72
+ align-items: center;
73
+ padding-top: 20px;
74
+
75
+ animation: slide-in ease 0.5s;
76
+
77
+ .mask-row {
78
+ margin-bottom: 10px;
79
+ display: flex;
80
+ justify-content: center;
81
+
82
+ @for $i from 1 to 10 {
83
+ &:nth-child(#{$i * 2}) {
84
+ margin-left: 50px;
85
+ }
86
+ }
87
+
88
+ .mask {
89
+ display: flex;
90
+ align-items: center;
91
+ padding: 10px 14px;
92
+ border: var(--border-in-light);
93
+ box-shadow: var(--card-shadow);
94
+ background-color: var(--white);
95
+ border-radius: 10px;
96
+ margin-right: 10px;
97
+ max-width: 8em;
98
+ transform: scale(1);
99
+ cursor: pointer;
100
+ transition: all ease 0.3s;
101
+
102
+ &:hover {
103
+ transform: translateY(-5px) scale(1.1);
104
+ z-index: 999;
105
+ border-color: var(--primary);
106
+ }
107
+
108
+ .mask-name {
109
+ margin-left: 10px;
110
+ font-size: 14px;
111
+ }
112
+ }
113
+ }
114
+ }
115
+ }
app/components/new-chat.tsx ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { Path, SlotID } from "../constant";
3
+ import { IconButton } from "./button";
4
+ import { EmojiAvatar } from "./emoji";
5
+ import styles from "./new-chat.module.scss";
6
+
7
+ import LeftIcon from "../icons/left.svg";
8
+ import LightningIcon from "../icons/lightning.svg";
9
+ import EyeIcon from "../icons/eye.svg";
10
+
11
+ import { useLocation, useNavigate } from "react-router-dom";
12
+ import { Mask, useMaskStore } from "../store/mask";
13
+ import Locale from "../locales";
14
+ import { useAppConfig, useChatStore } from "../store";
15
+ import { MaskAvatar } from "./mask";
16
+
17
+ function getIntersectionArea(aRect: DOMRect, bRect: DOMRect) {
18
+ const xmin = Math.max(aRect.x, bRect.x);
19
+ const xmax = Math.min(aRect.x + aRect.width, bRect.x + bRect.width);
20
+ const ymin = Math.max(aRect.y, bRect.y);
21
+ const ymax = Math.min(aRect.y + aRect.height, bRect.y + bRect.height);
22
+ const width = xmax - xmin;
23
+ const height = ymax - ymin;
24
+ const intersectionArea = width < 0 || height < 0 ? 0 : width * height;
25
+ return intersectionArea;
26
+ }
27
+
28
+ function MaskItem(props: { mask: Mask; onClick?: () => void }) {
29
+ const domRef = useRef<HTMLDivElement>(null);
30
+
31
+ useEffect(() => {
32
+ const changeOpacity = () => {
33
+ const dom = domRef.current;
34
+ const parent = document.getElementById(SlotID.AppBody);
35
+ if (!parent || !dom) return;
36
+
37
+ const domRect = dom.getBoundingClientRect();
38
+ const parentRect = parent.getBoundingClientRect();
39
+ const intersectionArea = getIntersectionArea(domRect, parentRect);
40
+ const domArea = domRect.width * domRect.height;
41
+ const ratio = intersectionArea / domArea;
42
+ const opacity = ratio > 0.9 ? 1 : 0.4;
43
+ dom.style.opacity = opacity.toString();
44
+ };
45
+
46
+ setTimeout(changeOpacity, 30);
47
+
48
+ window.addEventListener("resize", changeOpacity);
49
+
50
+ return () => window.removeEventListener("resize", changeOpacity);
51
+ }, [domRef]);
52
+
53
+ return (
54
+ <div className={styles["mask"]} ref={domRef} onClick={props.onClick}>
55
+ <MaskAvatar mask={props.mask} />
56
+ <div className={styles["mask-name"] + " one-line"}>{props.mask.name}</div>
57
+ </div>
58
+ );
59
+ }
60
+
61
+ function useMaskGroup(masks: Mask[]) {
62
+ const [groups, setGroups] = useState<Mask[][]>([]);
63
+
64
+ useEffect(() => {
65
+ const appBody = document.getElementById(SlotID.AppBody);
66
+ if (!appBody || masks.length === 0) return;
67
+
68
+ const rect = appBody.getBoundingClientRect();
69
+ const maxWidth = rect.width;
70
+ const maxHeight = rect.height * 0.6;
71
+ const maskItemWidth = 120;
72
+ const maskItemHeight = 50;
73
+
74
+ const randomMask = () => masks[Math.floor(Math.random() * masks.length)];
75
+ let maskIndex = 0;
76
+ const nextMask = () => masks[maskIndex++ % masks.length];
77
+
78
+ const rows = Math.ceil(maxHeight / maskItemHeight);
79
+ const cols = Math.ceil(maxWidth / maskItemWidth);
80
+
81
+ const newGroups = new Array(rows)
82
+ .fill(0)
83
+ .map((_, _i) =>
84
+ new Array(cols)
85
+ .fill(0)
86
+ .map((_, j) => (j < 1 || j > cols - 2 ? randomMask() : nextMask())),
87
+ );
88
+
89
+ setGroups(newGroups);
90
+
91
+ // eslint-disable-next-line react-hooks/exhaustive-deps
92
+ }, []);
93
+
94
+ return groups;
95
+ }
96
+
97
+ export function NewChat() {
98
+ const chatStore = useChatStore();
99
+ const maskStore = useMaskStore();
100
+
101
+ const masks = maskStore.getAll();
102
+ const groups = useMaskGroup(masks);
103
+
104
+ const navigate = useNavigate();
105
+ const config = useAppConfig();
106
+
107
+ const { state } = useLocation();
108
+
109
+ const startChat = (mask?: Mask) => {
110
+ chatStore.newSession(mask);
111
+ navigate(Path.Chat);
112
+ };
113
+
114
+ return (
115
+ <div className={styles["new-chat"]}>
116
+ <div className={styles["mask-header"]}>
117
+ <IconButton
118
+ icon={<LeftIcon />}
119
+ text={Locale.NewChat.Return}
120
+ onClick={() => navigate(Path.Home)}
121
+ ></IconButton>
122
+ {!state?.fromHome && (
123
+ <IconButton
124
+ text={Locale.NewChat.NotShow}
125
+ onClick={() => {
126
+ if (confirm(Locale.NewChat.ConfirmNoShow)) {
127
+ startChat();
128
+ config.update(
129
+ (config) => (config.dontShowMaskSplashScreen = true),
130
+ );
131
+ }
132
+ }}
133
+ ></IconButton>
134
+ )}
135
+ </div>
136
+ <div className={styles["mask-cards"]}>
137
+ <div className={styles["mask-card"]}>
138
+ <EmojiAvatar avatar="1f606" size={24} />
139
+ </div>
140
+ <div className={styles["mask-card"]}>
141
+ <EmojiAvatar avatar="1f916" size={24} />
142
+ </div>
143
+ <div className={styles["mask-card"]}>
144
+ <EmojiAvatar avatar="1f479" size={24} />
145
+ </div>
146
+ </div>
147
+
148
+ <div className={styles["title"]}>{Locale.NewChat.Title}</div>
149
+ <div className={styles["sub-title"]}>{Locale.NewChat.SubTitle}</div>
150
+
151
+ <div className={styles["actions"]}>
152
+ <IconButton
153
+ text={Locale.NewChat.Skip}
154
+ onClick={() => startChat()}
155
+ icon={<LightningIcon />}
156
+ type="primary"
157
+ shadow
158
+ />
159
+
160
+ <IconButton
161
+ className={styles["more"]}
162
+ text={Locale.NewChat.More}
163
+ onClick={() => navigate(Path.Masks)}
164
+ icon={<EyeIcon />}
165
+ bordered
166
+ shadow
167
+ />
168
+ </div>
169
+
170
+ <div className={styles["masks"]}>
171
+ {groups.map((masks, i) => (
172
+ <div key={i} className={styles["mask-row"]}>
173
+ {masks.map((mask, index) => (
174
+ <MaskItem
175
+ key={index}
176
+ mask={mask}
177
+ onClick={() => startChat(mask)}
178
+ />
179
+ ))}
180
+ </div>
181
+ ))}
182
+ </div>
183
+ </div>
184
+ );
185
+ }
app/components/settings.module.scss ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .settings {
2
+ padding: 20px;
3
+ overflow: auto;
4
+ }
5
+
6
+ .avatar {
7
+ cursor: pointer;
8
+ }
9
+
10
+ .edit-prompt-modal {
11
+ display: flex;
12
+ flex-direction: column;
13
+
14
+ .edit-prompt-title {
15
+ max-width: unset;
16
+ margin-bottom: 20px;
17
+ text-align: left;
18
+ }
19
+ .edit-prompt-content {
20
+ max-width: unset;
21
+ }
22
+ }
23
+
24
+ .user-prompt-modal {
25
+ min-height: 40vh;
26
+
27
+ .user-prompt-search {
28
+ width: 100%;
29
+ max-width: 100%;
30
+ margin-bottom: 10px;
31
+ background-color: var(--gray);
32
+ }
33
+
34
+ .user-prompt-list {
35
+ border: var(--border-in-light);
36
+ border-radius: 10px;
37
+
38
+ .user-prompt-item {
39
+ display: flex;
40
+ justify-content: space-between;
41
+ padding: 10px;
42
+
43
+ &:not(:last-child) {
44
+ border-bottom: var(--border-in-light);
45
+ }
46
+
47
+ .user-prompt-header {
48
+ max-width: calc(100% - 100px);
49
+
50
+ .user-prompt-title {
51
+ font-size: 14px;
52
+ line-height: 2;
53
+ font-weight: bold;
54
+ }
55
+ .user-prompt-content {
56
+ font-size: 12px;
57
+ }
58
+ }
59
+
60
+ .user-prompt-buttons {
61
+ display: flex;
62
+ align-items: center;
63
+
64
+ .user-prompt-button {
65
+ height: 100%;
66
+
67
+ &:not(:last-child) {
68
+ margin-right: 5px;
69
+ }
70
+ }
71
+ }
72
+ }
73
+ }
74
+
75
+ .user-prompt-actions {
76
+ }
77
+ }
app/components/settings.tsx ADDED
@@ -0,0 +1,557 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useMemo, HTMLProps, useRef } from "react";
2
+
3
+ import styles from "./settings.module.scss";
4
+
5
+ import ResetIcon from "../icons/reload.svg";
6
+ import AddIcon from "../icons/add.svg";
7
+ import CloseIcon from "../icons/close.svg";
8
+ import CopyIcon from "../icons/copy.svg";
9
+ import ClearIcon from "../icons/clear.svg";
10
+ import EditIcon from "../icons/edit.svg";
11
+ import EyeIcon from "../icons/eye.svg";
12
+ import { Input, List, ListItem, Modal, PasswordInput, Popover } from "./ui-lib";
13
+ import { ModelConfigList } from "./model-config";
14
+
15
+ import { IconButton } from "./button";
16
+ import {
17
+ SubmitKey,
18
+ useChatStore,
19
+ Theme,
20
+ useUpdateStore,
21
+ useAccessStore,
22
+ useAppConfig,
23
+ } from "../store";
24
+
25
+ import Locale, { AllLangs, changeLang, getLang } from "../locales";
26
+ import { copyToClipboard } from "../utils";
27
+ import Link from "next/link";
28
+ import { Path, UPDATE_URL } from "../constant";
29
+ import { Prompt, SearchService, usePromptStore } from "../store/prompt";
30
+ import { ErrorBoundary } from "./error";
31
+ import { InputRange } from "./input-range";
32
+ import { useNavigate } from "react-router-dom";
33
+ import { Avatar, AvatarPicker } from "./emoji";
34
+
35
+ function EditPromptModal(props: { id: number; onClose: () => void }) {
36
+ const promptStore = usePromptStore();
37
+ const prompt = promptStore.get(props.id);
38
+
39
+ return prompt ? (
40
+ <div className="modal-mask">
41
+ <Modal
42
+ title={Locale.Settings.Prompt.EditModal.Title}
43
+ onClose={props.onClose}
44
+ actions={[
45
+ <IconButton
46
+ key=""
47
+ onClick={props.onClose}
48
+ text={Locale.UI.Confirm}
49
+ bordered
50
+ />,
51
+ ]}
52
+ >
53
+ <div className={styles["edit-prompt-modal"]}>
54
+ <input
55
+ type="text"
56
+ value={prompt.title}
57
+ readOnly={!prompt.isUser}
58
+ className={styles["edit-prompt-title"]}
59
+ onInput={(e) =>
60
+ promptStore.update(
61
+ props.id,
62
+ (prompt) => (prompt.title = e.currentTarget.value),
63
+ )
64
+ }
65
+ ></input>
66
+ <Input
67
+ value={prompt.content}
68
+ readOnly={!prompt.isUser}
69
+ className={styles["edit-prompt-content"]}
70
+ rows={10}
71
+ onInput={(e) =>
72
+ promptStore.update(
73
+ props.id,
74
+ (prompt) => (prompt.content = e.currentTarget.value),
75
+ )
76
+ }
77
+ ></Input>
78
+ </div>
79
+ </Modal>
80
+ </div>
81
+ ) : null;
82
+ }
83
+
84
+ function UserPromptModal(props: { onClose?: () => void }) {
85
+ const promptStore = usePromptStore();
86
+ const userPrompts = promptStore.getUserPrompts();
87
+ const builtinPrompts = SearchService.builtinPrompts;
88
+ const allPrompts = userPrompts.concat(builtinPrompts);
89
+ const [searchInput, setSearchInput] = useState("");
90
+ const [searchPrompts, setSearchPrompts] = useState<Prompt[]>([]);
91
+ const prompts = searchInput.length > 0 ? searchPrompts : allPrompts;
92
+
93
+ const [editingPromptId, setEditingPromptId] = useState<number>();
94
+
95
+ useEffect(() => {
96
+ if (searchInput.length > 0) {
97
+ const searchResult = SearchService.search(searchInput);
98
+ setSearchPrompts(searchResult);
99
+ } else {
100
+ setSearchPrompts([]);
101
+ }
102
+ }, [searchInput]);
103
+
104
+ return (
105
+ <div className="modal-mask">
106
+ <Modal
107
+ title={Locale.Settings.Prompt.Modal.Title}
108
+ onClose={() => props.onClose?.()}
109
+ actions={[
110
+ <IconButton
111
+ key="add"
112
+ onClick={() =>
113
+ promptStore.add({
114
+ title: "Empty Prompt",
115
+ content: "Empty Prompt Content",
116
+ })
117
+ }
118
+ icon={<AddIcon />}
119
+ bordered
120
+ text={Locale.Settings.Prompt.Modal.Add}
121
+ />,
122
+ ]}
123
+ >
124
+ <div className={styles["user-prompt-modal"]}>
125
+ <input
126
+ type="text"
127
+ className={styles["user-prompt-search"]}
128
+ placeholder={Locale.Settings.Prompt.Modal.Search}
129
+ value={searchInput}
130
+ onInput={(e) => setSearchInput(e.currentTarget.value)}
131
+ ></input>
132
+
133
+ <div className={styles["user-prompt-list"]}>
134
+ {prompts.map((v, _) => (
135
+ <div className={styles["user-prompt-item"]} key={v.id ?? v.title}>
136
+ <div className={styles["user-prompt-header"]}>
137
+ <div className={styles["user-prompt-title"]}>{v.title}</div>
138
+ <div className={styles["user-prompt-content"] + " one-line"}>
139
+ {v.content}
140
+ </div>
141
+ </div>
142
+
143
+ <div className={styles["user-prompt-buttons"]}>
144
+ {v.isUser && (
145
+ <IconButton
146
+ icon={<ClearIcon />}
147
+ className={styles["user-prompt-button"]}
148
+ onClick={() => promptStore.remove(v.id!)}
149
+ />
150
+ )}
151
+ {v.isUser ? (
152
+ <IconButton
153
+ icon={<EditIcon />}
154
+ className={styles["user-prompt-button"]}
155
+ onClick={() => setEditingPromptId(v.id)}
156
+ />
157
+ ) : (
158
+ <IconButton
159
+ icon={<EyeIcon />}
160
+ className={styles["user-prompt-button"]}
161
+ onClick={() => setEditingPromptId(v.id)}
162
+ />
163
+ )}
164
+ <IconButton
165
+ icon={<CopyIcon />}
166
+ className={styles["user-prompt-button"]}
167
+ onClick={() => copyToClipboard(v.content)}
168
+ />
169
+ </div>
170
+ </div>
171
+ ))}
172
+ </div>
173
+ </div>
174
+ </Modal>
175
+
176
+ {editingPromptId !== undefined && (
177
+ <EditPromptModal
178
+ id={editingPromptId!}
179
+ onClose={() => setEditingPromptId(undefined)}
180
+ />
181
+ )}
182
+ </div>
183
+ );
184
+ }
185
+
186
+ export function Settings() {
187
+ const navigate = useNavigate();
188
+ const [showEmojiPicker, setShowEmojiPicker] = useState(false);
189
+ const config = useAppConfig();
190
+ const updateConfig = config.update;
191
+ const resetConfig = config.reset;
192
+ const chatStore = useChatStore();
193
+
194
+ const updateStore = useUpdateStore();
195
+ const [checkingUpdate, setCheckingUpdate] = useState(false);
196
+ const currentVersion = updateStore.version;
197
+ const remoteId = updateStore.remoteVersion;
198
+ const hasNewVersion = currentVersion !== remoteId;
199
+
200
+ function checkUpdate(force = false) {
201
+ setCheckingUpdate(true);
202
+ updateStore.getLatestVersion(force).then(() => {
203
+ setCheckingUpdate(false);
204
+ });
205
+ }
206
+
207
+ const usage = {
208
+ used: updateStore.used,
209
+ subscription: updateStore.subscription,
210
+ };
211
+ const [loadingUsage, setLoadingUsage] = useState(false);
212
+ function checkUsage(force = false) {
213
+ setLoadingUsage(true);
214
+ updateStore.updateUsage(force).finally(() => {
215
+ setLoadingUsage(false);
216
+ });
217
+ }
218
+
219
+ const accessStore = useAccessStore();
220
+ const enabledAccessControl = useMemo(
221
+ () => accessStore.enabledAccessControl(),
222
+ // eslint-disable-next-line react-hooks/exhaustive-deps
223
+ [],
224
+ );
225
+
226
+ const promptStore = usePromptStore();
227
+ const builtinCount = SearchService.count.builtin;
228
+ const customCount = promptStore.getUserPrompts().length ?? 0;
229
+ const [shouldShowPromptModal, setShowPromptModal] = useState(false);
230
+
231
+ const showUsage = accessStore.isAuthorized();
232
+ useEffect(() => {
233
+ // checks per minutes
234
+ checkUpdate();
235
+ showUsage && checkUsage();
236
+ // eslint-disable-next-line react-hooks/exhaustive-deps
237
+ }, []);
238
+
239
+ useEffect(() => {
240
+ const keydownEvent = (e: KeyboardEvent) => {
241
+ if (e.key === "Escape") {
242
+ navigate(Path.Home);
243
+ }
244
+ };
245
+ document.addEventListener("keydown", keydownEvent);
246
+ return () => {
247
+ document.removeEventListener("keydown", keydownEvent);
248
+ };
249
+ // eslint-disable-next-line react-hooks/exhaustive-deps
250
+ }, []);
251
+
252
+ return (
253
+ <ErrorBoundary>
254
+ <div className="window-header">
255
+ <div className="window-header-title">
256
+ <div className="window-header-main-title">
257
+ {Locale.Settings.Title}
258
+ </div>
259
+ <div className="window-header-sub-title">
260
+ {Locale.Settings.SubTitle}
261
+ </div>
262
+ </div>
263
+ <div className="window-actions">
264
+ <div className="window-action-button">
265
+ <IconButton
266
+ icon={<ClearIcon />}
267
+ onClick={() => {
268
+ if (confirm(Locale.Settings.Actions.ConfirmClearAll)) {
269
+ chatStore.clearAllData();
270
+ }
271
+ }}
272
+ bordered
273
+ title={Locale.Settings.Actions.ClearAll}
274
+ />
275
+ </div>
276
+ <div className="window-action-button">
277
+ <IconButton
278
+ icon={<ResetIcon />}
279
+ onClick={() => {
280
+ if (confirm(Locale.Settings.Actions.ConfirmResetAll)) {
281
+ resetConfig();
282
+ }
283
+ }}
284
+ bordered
285
+ title={Locale.Settings.Actions.ResetAll}
286
+ />
287
+ </div>
288
+ <div className="window-action-button">
289
+ <IconButton
290
+ icon={<CloseIcon />}
291
+ onClick={() => navigate(Path.Home)}
292
+ bordered
293
+ title={Locale.Settings.Actions.Close}
294
+ />
295
+ </div>
296
+ </div>
297
+ </div>
298
+ <div className={styles["settings"]}>
299
+ <List>
300
+ <ListItem title={Locale.Settings.Avatar}>
301
+ <Popover
302
+ onClose={() => setShowEmojiPicker(false)}
303
+ content={
304
+ <AvatarPicker
305
+ onEmojiClick={(avatar: string) => {
306
+ updateConfig((config) => (config.avatar = avatar));
307
+ setShowEmojiPicker(false);
308
+ }}
309
+ />
310
+ }
311
+ open={showEmojiPicker}
312
+ >
313
+ <div
314
+ className={styles.avatar}
315
+ onClick={() => setShowEmojiPicker(true)}
316
+ >
317
+ <Avatar avatar={config.avatar} />
318
+ </div>
319
+ </Popover>
320
+ </ListItem>
321
+
322
+ <ListItem
323
+ title={Locale.Settings.Update.Version(currentVersion ?? "unknown")}
324
+ subTitle={
325
+ checkingUpdate
326
+ ? Locale.Settings.Update.IsChecking
327
+ : hasNewVersion
328
+ ? Locale.Settings.Update.FoundUpdate(remoteId ?? "ERROR")
329
+ : Locale.Settings.Update.IsLatest
330
+ }
331
+ >
332
+ {checkingUpdate ? (
333
+ <div />
334
+ ) : hasNewVersion ? (
335
+ <Link href={UPDATE_URL} target="_blank" className="link">
336
+ {Locale.Settings.Update.GoToUpdate}
337
+ </Link>
338
+ ) : (
339
+ <IconButton
340
+ icon={<ResetIcon></ResetIcon>}
341
+ text={Locale.Settings.Update.CheckUpdate}
342
+ onClick={() => checkUpdate(true)}
343
+ />
344
+ )}
345
+ </ListItem>
346
+
347
+ <ListItem title={Locale.Settings.SendKey}>
348
+ <select
349
+ value={config.submitKey}
350
+ onChange={(e) => {
351
+ updateConfig(
352
+ (config) =>
353
+ (config.submitKey = e.target.value as any as SubmitKey),
354
+ );
355
+ }}
356
+ >
357
+ {Object.values(SubmitKey).map((v) => (
358
+ <option value={v} key={v}>
359
+ {v}
360
+ </option>
361
+ ))}
362
+ </select>
363
+ </ListItem>
364
+
365
+ <ListItem title={Locale.Settings.Theme}>
366
+ <select
367
+ value={config.theme}
368
+ onChange={(e) => {
369
+ updateConfig(
370
+ (config) => (config.theme = e.target.value as any as Theme),
371
+ );
372
+ }}
373
+ >
374
+ {Object.values(Theme).map((v) => (
375
+ <option value={v} key={v}>
376
+ {v}
377
+ </option>
378
+ ))}
379
+ </select>
380
+ </ListItem>
381
+
382
+ <ListItem title={Locale.Settings.Lang.Name}>
383
+ <select
384
+ value={getLang()}
385
+ onChange={(e) => {
386
+ changeLang(e.target.value as any);
387
+ }}
388
+ >
389
+ {AllLangs.map((lang) => (
390
+ <option value={lang} key={lang}>
391
+ {Locale.Settings.Lang.Options[lang]}
392
+ </option>
393
+ ))}
394
+ </select>
395
+ </ListItem>
396
+
397
+ <ListItem
398
+ title={Locale.Settings.FontSize.Title}
399
+ subTitle={Locale.Settings.FontSize.SubTitle}
400
+ >
401
+ <InputRange
402
+ title={`${config.fontSize ?? 14}px`}
403
+ value={config.fontSize}
404
+ min="12"
405
+ max="18"
406
+ step="1"
407
+ onChange={(e) =>
408
+ updateConfig(
409
+ (config) =>
410
+ (config.fontSize = Number.parseInt(e.currentTarget.value)),
411
+ )
412
+ }
413
+ ></InputRange>
414
+ </ListItem>
415
+
416
+ <ListItem
417
+ title={Locale.Settings.SendPreviewBubble.Title}
418
+ subTitle={Locale.Settings.SendPreviewBubble.SubTitle}
419
+ >
420
+ <input
421
+ type="checkbox"
422
+ checked={config.sendPreviewBubble}
423
+ onChange={(e) =>
424
+ updateConfig(
425
+ (config) =>
426
+ (config.sendPreviewBubble = e.currentTarget.checked),
427
+ )
428
+ }
429
+ ></input>
430
+ </ListItem>
431
+
432
+ <ListItem
433
+ title={Locale.Settings.Mask.Title}
434
+ subTitle={Locale.Settings.Mask.SubTitle}
435
+ >
436
+ <input
437
+ type="checkbox"
438
+ checked={!config.dontShowMaskSplashScreen}
439
+ onChange={(e) =>
440
+ updateConfig(
441
+ (config) =>
442
+ (config.dontShowMaskSplashScreen =
443
+ !e.currentTarget.checked),
444
+ )
445
+ }
446
+ ></input>
447
+ </ListItem>
448
+ </List>
449
+
450
+ <List>
451
+ {enabledAccessControl ? (
452
+ <ListItem
453
+ title={Locale.Settings.AccessCode.Title}
454
+ subTitle={Locale.Settings.AccessCode.SubTitle}
455
+ >
456
+ <PasswordInput
457
+ value={accessStore.accessCode}
458
+ type="text"
459
+ placeholder={Locale.Settings.AccessCode.Placeholder}
460
+ onChange={(e) => {
461
+ accessStore.updateCode(e.currentTarget.value);
462
+ }}
463
+ />
464
+ </ListItem>
465
+ ) : (
466
+ <></>
467
+ )}
468
+
469
+ <ListItem
470
+ title={Locale.Settings.Token.Title}
471
+ subTitle={Locale.Settings.Token.SubTitle}
472
+ >
473
+ <PasswordInput
474
+ value={accessStore.token}
475
+ type="text"
476
+ placeholder={Locale.Settings.Token.Placeholder}
477
+ onChange={(e) => {
478
+ accessStore.updateToken(e.currentTarget.value);
479
+ }}
480
+ />
481
+ </ListItem>
482
+
483
+ <ListItem
484
+ title={Locale.Settings.Usage.Title}
485
+ subTitle={
486
+ showUsage
487
+ ? loadingUsage
488
+ ? Locale.Settings.Usage.IsChecking
489
+ : Locale.Settings.Usage.SubTitle(
490
+ usage?.used ?? "[?]",
491
+ usage?.subscription ?? "[?]",
492
+ )
493
+ : Locale.Settings.Usage.NoAccess
494
+ }
495
+ >
496
+ {!showUsage || loadingUsage ? (
497
+ <div />
498
+ ) : (
499
+ <IconButton
500
+ icon={<ResetIcon></ResetIcon>}
501
+ text={Locale.Settings.Usage.Check}
502
+ onClick={() => checkUsage(true)}
503
+ />
504
+ )}
505
+ </ListItem>
506
+ </List>
507
+
508
+ <List>
509
+ <ListItem
510
+ title={Locale.Settings.Prompt.Disable.Title}
511
+ subTitle={Locale.Settings.Prompt.Disable.SubTitle}
512
+ >
513
+ <input
514
+ type="checkbox"
515
+ checked={config.disablePromptHint}
516
+ onChange={(e) =>
517
+ updateConfig(
518
+ (config) =>
519
+ (config.disablePromptHint = e.currentTarget.checked),
520
+ )
521
+ }
522
+ ></input>
523
+ </ListItem>
524
+
525
+ <ListItem
526
+ title={Locale.Settings.Prompt.List}
527
+ subTitle={Locale.Settings.Prompt.ListCount(
528
+ builtinCount,
529
+ customCount,
530
+ )}
531
+ >
532
+ <IconButton
533
+ icon={<EditIcon />}
534
+ text={Locale.Settings.Prompt.Edit}
535
+ onClick={() => setShowPromptModal(true)}
536
+ />
537
+ </ListItem>
538
+ </List>
539
+
540
+ <List>
541
+ <ModelConfigList
542
+ modelConfig={config.modelConfig}
543
+ updateConfig={(upater) => {
544
+ const modelConfig = { ...config.modelConfig };
545
+ upater(modelConfig);
546
+ config.update((config) => (config.modelConfig = modelConfig));
547
+ }}
548
+ />
549
+ </List>
550
+
551
+ {shouldShowPromptModal && (
552
+ <UserPromptModal onClose={() => setShowPromptModal(false)} />
553
+ )}
554
+ </div>
555
+ </ErrorBoundary>
556
+ );
557
+ }
app/components/sidebar.tsx ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef } from "react";
2
+
3
+ import styles from "./home.module.scss";
4
+
5
+ import { IconButton } from "./button";
6
+ import SettingsIcon from "../icons/settings.svg";
7
+ import GithubIcon from "../icons/github.svg";
8
+ import ChatGptIcon from "../icons/chatgpt.svg";
9
+ import AddIcon from "../icons/add.svg";
10
+ import CloseIcon from "../icons/close.svg";
11
+ import MaskIcon from "../icons/mask.svg";
12
+ import PluginIcon from "../icons/plugin.svg";
13
+
14
+ import Locale from "../locales";
15
+
16
+ import { useAppConfig, useChatStore } from "../store";
17
+
18
+ import {
19
+ MAX_SIDEBAR_WIDTH,
20
+ MIN_SIDEBAR_WIDTH,
21
+ NARROW_SIDEBAR_WIDTH,
22
+ Path,
23
+ REPO_URL,
24
+ } from "../constant";
25
+
26
+ import { Link, useNavigate } from "react-router-dom";
27
+ import { useMobileScreen } from "../utils";
28
+ import dynamic from "next/dynamic";
29
+ import { showToast } from "./ui-lib";
30
+
31
+ const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, {
32
+ loading: () => null,
33
+ });
34
+
35
+ function useDragSideBar() {
36
+ const limit = (x: number) => Math.min(MAX_SIDEBAR_WIDTH, x);
37
+
38
+ const config = useAppConfig();
39
+ const startX = useRef(0);
40
+ const startDragWidth = useRef(config.sidebarWidth ?? 300);
41
+ const lastUpdateTime = useRef(Date.now());
42
+
43
+ const handleMouseMove = useRef((e: MouseEvent) => {
44
+ if (Date.now() < lastUpdateTime.current + 50) {
45
+ return;
46
+ }
47
+ lastUpdateTime.current = Date.now();
48
+ const d = e.clientX - startX.current;
49
+ const nextWidth = limit(startDragWidth.current + d);
50
+ config.update((config) => (config.sidebarWidth = nextWidth));
51
+ });
52
+
53
+ const handleMouseUp = useRef(() => {
54
+ startDragWidth.current = config.sidebarWidth ?? 300;
55
+ window.removeEventListener("mousemove", handleMouseMove.current);
56
+ window.removeEventListener("mouseup", handleMouseUp.current);
57
+ });
58
+
59
+ const onDragMouseDown = (e: MouseEvent) => {
60
+ startX.current = e.clientX;
61
+
62
+ window.addEventListener("mousemove", handleMouseMove.current);
63
+ window.addEventListener("mouseup", handleMouseUp.current);
64
+ };
65
+ const isMobileScreen = useMobileScreen();
66
+ const shouldNarrow =
67
+ !isMobileScreen && config.sidebarWidth < MIN_SIDEBAR_WIDTH;
68
+
69
+ useEffect(() => {
70
+ const barWidth = shouldNarrow
71
+ ? NARROW_SIDEBAR_WIDTH
72
+ : limit(config.sidebarWidth ?? 300);
73
+ const sideBarWidth = isMobileScreen ? "100vw" : `${barWidth}px`;
74
+ document.documentElement.style.setProperty("--sidebar-width", sideBarWidth);
75
+ }, [config.sidebarWidth, isMobileScreen, shouldNarrow]);
76
+
77
+ return {
78
+ onDragMouseDown,
79
+ shouldNarrow,
80
+ };
81
+ }
82
+
83
+ export function SideBar(props: { className?: string }) {
84
+ const chatStore = useChatStore();
85
+
86
+ // drag side bar
87
+ const { onDragMouseDown, shouldNarrow } = useDragSideBar();
88
+ const navigate = useNavigate();
89
+
90
+ const config = useAppConfig();
91
+
92
+ return (
93
+ <div
94
+ className={`${styles.sidebar} ${props.className} ${
95
+ shouldNarrow && styles["narrow-sidebar"]
96
+ }`}
97
+ >
98
+ <div className={styles["sidebar-header"]}>
99
+ <div className={styles["sidebar-title"]}>ChatGPT Next</div>
100
+ <div className={styles["sidebar-sub-title"]}>
101
+ Build your own AI assistant.
102
+ </div>
103
+ <div className={styles["sidebar-logo"] + " no-dark"}>
104
+ <ChatGptIcon />
105
+ </div>
106
+ </div>
107
+
108
+ <div className={styles["sidebar-header-bar"]}>
109
+ <IconButton
110
+ icon={<MaskIcon />}
111
+ text={shouldNarrow ? undefined : Locale.Mask.Name}
112
+ className={styles["sidebar-bar-button"]}
113
+ onClick={() => navigate(Path.NewChat, { state: { fromHome: true } })}
114
+ shadow
115
+ />
116
+ <IconButton
117
+ icon={<PluginIcon />}
118
+ text={shouldNarrow ? undefined : Locale.Plugin.Name}
119
+ className={styles["sidebar-bar-button"]}
120
+ onClick={() => showToast(Locale.WIP)}
121
+ shadow
122
+ />
123
+ </div>
124
+
125
+ <div
126
+ className={styles["sidebar-body"]}
127
+ onClick={(e) => {
128
+ if (e.target === e.currentTarget) {
129
+ navigate(Path.Home);
130
+ }
131
+ }}
132
+ >
133
+ <ChatList narrow={shouldNarrow} />
134
+ </div>
135
+
136
+ <div className={styles["sidebar-tail"]}>
137
+ <div className={styles["sidebar-actions"]}>
138
+ <div className={styles["sidebar-action"] + " " + styles.mobile}>
139
+ <IconButton
140
+ icon={<CloseIcon />}
141
+ onClick={() => {
142
+ if (confirm(Locale.Home.DeleteChat)) {
143
+ chatStore.deleteSession(chatStore.currentSessionIndex);
144
+ }
145
+ }}
146
+ />
147
+ </div>
148
+ <div className={styles["sidebar-action"]}>
149
+ <Link to={Path.Settings}>
150
+ <IconButton icon={<SettingsIcon />} shadow />
151
+ </Link>
152
+ </div>
153
+ <div className={styles["sidebar-action"]}>
154
+ <a href={REPO_URL} target="_blank">
155
+ <IconButton icon={<GithubIcon />} shadow />
156
+ </a>
157
+ </div>
158
+ </div>
159
+ <div>
160
+ <IconButton
161
+ icon={<AddIcon />}
162
+ text={shouldNarrow ? undefined : Locale.Home.NewChat}
163
+ onClick={() => {
164
+ if (config.dontShowMaskSplashScreen) {
165
+ chatStore.newSession();
166
+ } else {
167
+ navigate(Path.NewChat);
168
+ }
169
+ }}
170
+ shadow
171
+ />
172
+ </div>
173
+ </div>
174
+
175
+ <div
176
+ className={styles["sidebar-drag"]}
177
+ onMouseDown={(e) => onDragMouseDown(e as any)}
178
+ ></div>
179
+ </div>
180
+ );
181
+ }
app/components/ui-lib.module.scss ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "../styles/animation.scss";
2
+
3
+ .card {
4
+ background-color: var(--white);
5
+ border-radius: 10px;
6
+ box-shadow: var(--card-shadow);
7
+ padding: 10px;
8
+ }
9
+
10
+ .popover {
11
+ position: relative;
12
+ z-index: 2;
13
+ }
14
+
15
+ .popover-content {
16
+ position: absolute;
17
+ animation: slide-in 0.3s ease;
18
+ right: 0;
19
+ top: calc(100% + 10px);
20
+ }
21
+
22
+ .popover-mask {
23
+ position: fixed;
24
+ top: 0;
25
+ left: 0;
26
+ width: 100vw;
27
+ height: 100vh;
28
+ }
29
+
30
+ .list-item {
31
+ display: flex;
32
+ justify-content: space-between;
33
+ align-items: center;
34
+ min-height: 40px;
35
+ border-bottom: var(--border-in-light);
36
+ padding: 10px 20px;
37
+ animation: slide-in ease 0.6s;
38
+
39
+ .list-header {
40
+ display: flex;
41
+ align-items: center;
42
+
43
+ .list-icon {
44
+ margin-right: 10px;
45
+ }
46
+
47
+ .list-item-title {
48
+ font-size: 14px;
49
+ font-weight: bolder;
50
+ }
51
+
52
+ .list-item-sub-title {
53
+ font-size: 12px;
54
+ font-weight: normal;
55
+ }
56
+ }
57
+ }
58
+
59
+ .list {
60
+ border: var(--border-in-light);
61
+ border-radius: 10px;
62
+ box-shadow: var(--card-shadow);
63
+ margin-bottom: 20px;
64
+ animation: slide-in ease 0.3s;
65
+ }
66
+
67
+ .list .list-item:last-child {
68
+ border: 0;
69
+ }
70
+
71
+ .modal-container {
72
+ box-shadow: var(--card-shadow);
73
+ background-color: var(--white);
74
+ border-radius: 12px;
75
+ width: 60vw;
76
+ animation: slide-in ease 0.3s;
77
+
78
+ --modal-padding: 20px;
79
+
80
+ .modal-header {
81
+ padding: var(--modal-padding);
82
+ display: flex;
83
+ align-items: center;
84
+ justify-content: space-between;
85
+ border-bottom: var(--border-in-light);
86
+
87
+ .modal-title {
88
+ font-weight: bolder;
89
+ font-size: 16px;
90
+ }
91
+
92
+ .modal-close-btn {
93
+ cursor: pointer;
94
+
95
+ &:hover {
96
+ filter: brightness(1.2);
97
+ }
98
+ }
99
+ }
100
+
101
+ .modal-content {
102
+ max-height: 40vh;
103
+ padding: var(--modal-padding);
104
+ overflow: auto;
105
+ }
106
+
107
+ .modal-footer {
108
+ padding: var(--modal-padding);
109
+ display: flex;
110
+ justify-content: flex-end;
111
+ border-top: var(--border-in-light);
112
+ box-shadow: var(--shadow);
113
+
114
+ .modal-actions {
115
+ display: flex;
116
+ align-items: center;
117
+
118
+ .modal-action {
119
+ &:not(:last-child) {
120
+ margin-right: 20px;
121
+ }
122
+ }
123
+ }
124
+ }
125
+ }
126
+
127
+ .show {
128
+ opacity: 1;
129
+ transition: all ease 0.3s;
130
+ transform: translateY(0);
131
+ position: fixed;
132
+ left: 0;
133
+ bottom: 0;
134
+ animation: slide-in ease 0.6s;
135
+ z-index: 99999;
136
+ }
137
+
138
+ .hide {
139
+ opacity: 0;
140
+ transition: all ease 0.3s;
141
+ transform: translateY(20px);
142
+ }
143
+
144
+ .toast-container {
145
+ position: fixed;
146
+ bottom: 5vh;
147
+ left: 0;
148
+ width: 100vw;
149
+ display: flex;
150
+ justify-content: center;
151
+ pointer-events: none;
152
+
153
+ .toast-content {
154
+ max-width: 80vw;
155
+ word-break: break-all;
156
+ font-size: 14px;
157
+ background-color: var(--white);
158
+ box-shadow: var(--card-shadow);
159
+ border: var(--border-in-light);
160
+ color: var(--black);
161
+ padding: 10px 20px;
162
+ border-radius: 50px;
163
+ margin-bottom: 20px;
164
+ display: flex;
165
+ align-items: center;
166
+ pointer-events: all;
167
+
168
+ .toast-action {
169
+ padding-left: 20px;
170
+ color: var(--primary);
171
+ opacity: 0.8;
172
+ border: 0;
173
+ background: none;
174
+ cursor: pointer;
175
+ font-family: inherit;
176
+
177
+ &:hover {
178
+ opacity: 1;
179
+ }
180
+ }
181
+ }
182
+ }
183
+
184
+ .input {
185
+ border: var(--border-in-light);
186
+ border-radius: 10px;
187
+ padding: 10px;
188
+ font-family: inherit;
189
+ background-color: var(--white);
190
+ color: var(--black);
191
+ resize: none;
192
+ min-width: 50px;
193
+ }
194
+
195
+ @media only screen and (max-width: 600px) {
196
+ .modal-container {
197
+ width: 90vw;
198
+
199
+ .modal-content {
200
+ max-height: 50vh;
201
+ }
202
+ }
203
+ }
app/components/ui-lib.tsx ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import styles from "./ui-lib.module.scss";
2
+ import LoadingIcon from "../icons/three-dots.svg";
3
+ import CloseIcon from "../icons/close.svg";
4
+ import EyeIcon from "../icons/eye.svg";
5
+ import EyeOffIcon from "../icons/eye-off.svg";
6
+
7
+ import { createRoot } from "react-dom/client";
8
+ import React, { HTMLProps, useEffect, useState } from "react";
9
+ import { IconButton } from "./button";
10
+
11
+ export function Popover(props: {
12
+ children: JSX.Element;
13
+ content: JSX.Element;
14
+ open?: boolean;
15
+ onClose?: () => void;
16
+ }) {
17
+ return (
18
+ <div className={styles.popover}>
19
+ {props.children}
20
+ {props.open && (
21
+ <div className={styles["popover-content"]}>
22
+ <div className={styles["popover-mask"]} onClick={props.onClose}></div>
23
+ {props.content}
24
+ </div>
25
+ )}
26
+ </div>
27
+ );
28
+ }
29
+
30
+ export function Card(props: { children: JSX.Element[]; className?: string }) {
31
+ return (
32
+ <div className={styles.card + " " + props.className}>{props.children}</div>
33
+ );
34
+ }
35
+
36
+ export function ListItem(props: {
37
+ title: string;
38
+ subTitle?: string;
39
+ children?: JSX.Element | JSX.Element[];
40
+ icon?: JSX.Element;
41
+ className?: string;
42
+ }) {
43
+ return (
44
+ <div className={styles["list-item"] + ` ${props.className}`}>
45
+ <div className={styles["list-header"]}>
46
+ {props.icon && <div className={styles["list-icon"]}>{props.icon}</div>}
47
+ <div className={styles["list-item-title"]}>
48
+ <div>{props.title}</div>
49
+ {props.subTitle && (
50
+ <div className={styles["list-item-sub-title"]}>
51
+ {props.subTitle}
52
+ </div>
53
+ )}
54
+ </div>
55
+ </div>
56
+ {props.children}
57
+ </div>
58
+ );
59
+ }
60
+
61
+ export function List(props: {
62
+ children:
63
+ | Array<JSX.Element | null | undefined>
64
+ | JSX.Element
65
+ | null
66
+ | undefined;
67
+ }) {
68
+ return <div className={styles.list}>{props.children}</div>;
69
+ }
70
+
71
+ export function Loading() {
72
+ return (
73
+ <div
74
+ style={{
75
+ height: "100vh",
76
+ width: "100vw",
77
+ display: "flex",
78
+ alignItems: "center",
79
+ justifyContent: "center",
80
+ }}
81
+ >
82
+ <LoadingIcon />
83
+ </div>
84
+ );
85
+ }
86
+
87
+ interface ModalProps {
88
+ title: string;
89
+ children?: JSX.Element | JSX.Element[];
90
+ actions?: JSX.Element[];
91
+ onClose?: () => void;
92
+ }
93
+ export function Modal(props: ModalProps) {
94
+ useEffect(() => {
95
+ const onKeyDown = (e: KeyboardEvent) => {
96
+ if (e.key === "Escape") {
97
+ props.onClose?.();
98
+ }
99
+ };
100
+
101
+ window.addEventListener("keydown", onKeyDown);
102
+
103
+ return () => {
104
+ window.removeEventListener("keydown", onKeyDown);
105
+ };
106
+ // eslint-disable-next-line react-hooks/exhaustive-deps
107
+ }, []);
108
+
109
+ return (
110
+ <div className={styles["modal-container"]}>
111
+ <div className={styles["modal-header"]}>
112
+ <div className={styles["modal-title"]}>{props.title}</div>
113
+
114
+ <div className={styles["modal-close-btn"]} onClick={props.onClose}>
115
+ <CloseIcon />
116
+ </div>
117
+ </div>
118
+
119
+ <div className={styles["modal-content"]}>{props.children}</div>
120
+
121
+ <div className={styles["modal-footer"]}>
122
+ <div className={styles["modal-actions"]}>
123
+ {props.actions?.map((action, i) => (
124
+ <div key={i} className={styles["modal-action"]}>
125
+ {action}
126
+ </div>
127
+ ))}
128
+ </div>
129
+ </div>
130
+ </div>
131
+ );
132
+ }
133
+
134
+ export function showModal(props: ModalProps) {
135
+ const div = document.createElement("div");
136
+ div.className = "modal-mask";
137
+ document.body.appendChild(div);
138
+
139
+ const root = createRoot(div);
140
+ const closeModal = () => {
141
+ props.onClose?.();
142
+ root.unmount();
143
+ div.remove();
144
+ };
145
+
146
+ div.onclick = (e) => {
147
+ if (e.target === div) {
148
+ closeModal();
149
+ }
150
+ };
151
+
152
+ root.render(<Modal {...props} onClose={closeModal}></Modal>);
153
+ }
154
+
155
+ export type ToastProps = {
156
+ content: string;
157
+ action?: {
158
+ text: string;
159
+ onClick: () => void;
160
+ };
161
+ onClose?: () => void;
162
+ };
163
+
164
+ export function Toast(props: ToastProps) {
165
+ return (
166
+ <div className={styles["toast-container"]}>
167
+ <div className={styles["toast-content"]}>
168
+ <span>{props.content}</span>
169
+ {props.action && (
170
+ <button
171
+ onClick={() => {
172
+ props.action?.onClick?.();
173
+ props.onClose?.();
174
+ }}
175
+ className={styles["toast-action"]}
176
+ >
177
+ {props.action.text}
178
+ </button>
179
+ )}
180
+ </div>
181
+ </div>
182
+ );
183
+ }
184
+
185
+ export function showToast(
186
+ content: string,
187
+ action?: ToastProps["action"],
188
+ delay = 3000,
189
+ ) {
190
+ const div = document.createElement("div");
191
+ div.className = styles.show;
192
+ document.body.appendChild(div);
193
+
194
+ const root = createRoot(div);
195
+ const close = () => {
196
+ div.classList.add(styles.hide);
197
+
198
+ setTimeout(() => {
199
+ root.unmount();
200
+ div.remove();
201
+ }, 300);
202
+ };
203
+
204
+ setTimeout(() => {
205
+ close();
206
+ }, delay);
207
+
208
+ root.render(<Toast content={content} action={action} onClose={close} />);
209
+ }
210
+
211
+ export type InputProps = React.HTMLProps<HTMLTextAreaElement> & {
212
+ autoHeight?: boolean;
213
+ rows?: number;
214
+ };
215
+
216
+ export function Input(props: InputProps) {
217
+ return (
218
+ <textarea
219
+ {...props}
220
+ className={`${styles["input"]} ${props.className}`}
221
+ ></textarea>
222
+ );
223
+ }
224
+
225
+ export function PasswordInput(props: HTMLProps<HTMLInputElement>) {
226
+ const [visible, setVisible] = useState(false);
227
+
228
+ function changeVisibility() {
229
+ setVisible(!visible);
230
+ }
231
+
232
+ return (
233
+ <div className={"password-input-container"}>
234
+ <IconButton
235
+ icon={visible ? <EyeIcon /> : <EyeOffIcon />}
236
+ onClick={changeVisibility}
237
+ className={"password-eye"}
238
+ />
239
+ <input
240
+ {...props}
241
+ type={visible ? "text" : "password"}
242
+ className={"password-input"}
243
+ />
244
+ </div>
245
+ );
246
+ }
app/config/build.ts ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const COMMIT_ID: string = (() => {
2
+ try {
3
+ const childProcess = require("child_process");
4
+ return (
5
+ childProcess
6
+ // .execSync("git describe --tags --abbrev=0")
7
+ .execSync("git rev-parse --short HEAD")
8
+ .toString()
9
+ .trim()
10
+ );
11
+ } catch (e) {
12
+ console.error("[Build Config] No git or not from git repo.");
13
+ return "unknown";
14
+ }
15
+ })();
16
+
17
+ export const getBuildConfig = () => {
18
+ if (typeof process === "undefined") {
19
+ throw Error(
20
+ "[Server Config] you are importing a nodejs-only module outside of nodejs",
21
+ );
22
+ }
23
+
24
+ return {
25
+ commitId: COMMIT_ID,
26
+ };
27
+ };
app/config/server.ts ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import md5 from "spark-md5";
2
+
3
+ declare global {
4
+ namespace NodeJS {
5
+ interface ProcessEnv {
6
+ OPENAI_API_KEY?: string;
7
+ CODE?: string;
8
+ PROXY_URL?: string;
9
+ VERCEL?: string;
10
+ }
11
+ }
12
+ }
13
+
14
+ const ACCESS_CODES = (function getAccessCodes(): Set<string> {
15
+ const code = process.env.CODE;
16
+
17
+ try {
18
+ const codes = (code?.split(",") ?? [])
19
+ .filter((v) => !!v)
20
+ .map((v) => md5.hash(v.trim()));
21
+ return new Set(codes);
22
+ } catch (e) {
23
+ return new Set();
24
+ }
25
+ })();
26
+
27
+ export const getServerSideConfig = () => {
28
+ if (typeof process === "undefined") {
29
+ throw Error(
30
+ "[Server Config] you are importing a nodejs-only module outside of nodejs",
31
+ );
32
+ }
33
+
34
+ return {
35
+ apiKey: process.env.OPENAI_API_KEY,
36
+ code: process.env.CODE,
37
+ codes: ACCESS_CODES,
38
+ needCode: ACCESS_CODES.size > 0,
39
+ proxyUrl: process.env.PROXY_URL,
40
+ isVercel: !!process.env.VERCEL,
41
+ };
42
+ };
app/constant.ts ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const OWNER = "Yidadaa";
2
+ export const REPO = "ChatGPT-Next-Web";
3
+ export const REPO_URL = `https://github.com/${OWNER}/${REPO}`;
4
+ export const ISSUE_URL = `https://github.com/${OWNER}/${REPO}/issues`;
5
+ export const UPDATE_URL = `${REPO_URL}#keep-updated`;
6
+ export const FETCH_COMMIT_URL = `https://api.github.com/repos/${OWNER}/${REPO}/commits?per_page=1`;
7
+ export const FETCH_TAG_URL = `https://api.github.com/repos/${OWNER}/${REPO}/tags?per_page=1`;
8
+ export const RUNTIME_CONFIG_DOM = "danger-runtime-config";
9
+
10
+ export enum Path {
11
+ Home = "/",
12
+ Chat = "/chat",
13
+ Settings = "/settings",
14
+ NewChat = "/new-chat",
15
+ Masks = "/masks",
16
+ }
17
+
18
+ export enum SlotID {
19
+ AppBody = "app-body",
20
+ }
21
+
22
+ export enum FileName {
23
+ Masks = "masks.json",
24
+ Prompts = "prompts.json",
25
+ }
26
+
27
+ export enum StoreKey {
28
+ Chat = "chat-next-web-store",
29
+ Access = "access-control",
30
+ Config = "app-config",
31
+ Mask = "mask-store",
32
+ Prompt = "prompt-store",
33
+ Update = "chat-update",
34
+ }
35
+
36
+ export const MAX_SIDEBAR_WIDTH = 500;
37
+ export const MIN_SIDEBAR_WIDTH = 230;
38
+ export const NARROW_SIDEBAR_WIDTH = 100;
app/global.d.ts ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ declare module "*.jpg";
2
+ declare module "*.png";
3
+ declare module "*.woff2";
4
+ declare module "*.woff";
5
+ declare module "*.ttf";
6
+ declare module "*.scss" {
7
+ const content: Record<string, string>;
8
+ export default content;
9
+ }
10
+
11
+ declare module "*.svg";
app/icons/add.svg ADDED
app/icons/auto.svg ADDED
app/icons/black-bot.svg ADDED