Spaces:
Running
Running
xiao
commited on
Commit
•
6a37520
1
Parent(s):
e2d7d0b
初始化
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .eslintignore +1 -0
- .eslintrc.json +4 -0
- .gitignore +42 -0
- .gitpod.yml +11 -0
- .lintstagedrc.json +6 -0
- .prettierrc.js +10 -0
- CODE_OF_CONDUCT.md +128 -0
- Dockerfile +59 -0
- LICENSE +75 -0
- README.md +263 -0
- README_CN.md +171 -0
- app/api/auth.ts +71 -0
- app/api/common.ts +44 -0
- app/api/config/route.ts +25 -0
- app/api/lemur/route.ts +70 -0
- app/api/newbing/route.ts +81 -0
- app/api/openai-image/route.ts +38 -0
- app/api/openai-image/typing.ts +6 -0
- app/api/openai/[...path]/route.ts +101 -0
- app/api/openai/typing.ts +9 -0
- app/api/wanjuan/route.ts +31 -0
- app/bing-chat/index.d.ts +274 -0
- app/bing-chat/index.js +284 -0
- app/bing-chat/index.js.map +1 -0
- app/components/button.module.scss +64 -0
- app/components/button.tsx +45 -0
- app/components/chat-list.tsx +147 -0
- app/components/chat.module.scss +109 -0
- app/components/chat.tsx +825 -0
- app/components/emoji.tsx +59 -0
- app/components/error.tsx +73 -0
- app/components/home.module.scss +572 -0
- app/components/home.tsx +139 -0
- app/components/input-range.module.scss +7 -0
- app/components/input-range.tsx +37 -0
- app/components/markdown.tsx +123 -0
- app/components/mask.module.scss +108 -0
- app/components/mask.tsx +420 -0
- app/components/model-config.tsx +141 -0
- app/components/new-chat.module.scss +115 -0
- app/components/new-chat.tsx +185 -0
- app/components/settings.module.scss +77 -0
- app/components/settings.tsx +606 -0
- app/components/sidebar.tsx +182 -0
- app/components/ui-lib.module.scss +203 -0
- app/components/ui-lib.tsx +246 -0
- app/config/build.ts +24 -0
- app/config/server.ts +44 -0
- app/constant.ts +40 -0
- app/global.d.ts +11 -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 |
+
}
|
.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 |
+
|
.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.npmmirror.com/'
|
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 3000
|
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.
|
README.md
ADDED
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<div align="center">
|
2 |
+
<img src="./docs/images/icon.svg" alt="icon"/>
|
3 |
+
|
4 |
+
<h1 align="center">ChatGPT Next Web</h1>
|
5 |
+
|
6 |
+
English / [简体中文](./README_CN.md)
|
7 |
+
|
8 |
+
One-Click to deploy well-designed ChatGPT web UI on Vercel.
|
9 |
+
|
10 |
+
一键免费部署你的私人 ChatGPT 网页应用。
|
11 |
+
|
12 |
+
[Demo](https://chatgpt.nextweb.fun/) / [Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [Join Discord](https://discord.gg/zrhvHCr79N) / [Buy Me a Coffee](https://www.buymeacoffee.com/yidadaa)
|
13 |
+
|
14 |
+
[演示](https://chatgpt.nextweb.fun/) / [反馈](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [QQ 群](https://user-images.githubusercontent.com/16968934/234462588-e8eff256-f5ca-46ef-8f5f-d7db6d28735a.jpg) / [打赏开发者](https://user-images.githubusercontent.com/16968934/227772541-5bcd52d8-61b7-488c-a203-0330d8006e2b.jpg)
|
15 |
+
|
16 |
+
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web)
|
17 |
+
|
18 |
+
[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
19 |
+
|
20 |
+
![cover](./docs/images/cover.png)
|
21 |
+
|
22 |
+
</div>
|
23 |
+
|
24 |
+
## Features
|
25 |
+
|
26 |
+
- **Deploy for free with one-click** on Vercel in under 1 minute
|
27 |
+
- Privacy first, all data stored locally in the browser
|
28 |
+
- Responsive design, dark mode and PWA
|
29 |
+
- Fast first screen loading speed (~100kb), support streaming response
|
30 |
+
- New in v2: create, share and debug your chat tools with prompt templates (mask)
|
31 |
+
- Awesome prompts powered by [awesome-chatgpt-prompts-zh](https://github.com/PlexPt/awesome-chatgpt-prompts-zh) and [awesome-chatgpt-prompts](https://github.com/f/awesome-chatgpt-prompts)
|
32 |
+
- Automatically compresses chat history to support long conversations while also saving your tokens
|
33 |
+
- One-click export all chat history with full Markdown support
|
34 |
+
- I18n supported
|
35 |
+
|
36 |
+
## Roadmap
|
37 |
+
|
38 |
+
- [x] System Prompt: pin a user defined prompt as system prompt [#138](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)
|
39 |
+
- [x] User Prompt: user can edit and save custom prompts to prompt list
|
40 |
+
- [x] Prompt Template: create a new chat with pre-defined in-context prompts [#993](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/993)
|
41 |
+
- [ ] Share as image, share to ShareGPT
|
42 |
+
- [ ] Desktop App with tauri
|
43 |
+
- [ ] Self-host Model: support llama, alpaca, ChatGLM, BELLE etc.
|
44 |
+
- [ ] Plugins: support network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
|
45 |
+
|
46 |
+
### Not in Plan
|
47 |
+
|
48 |
+
- User login, accounts, cloud sync
|
49 |
+
- UI text customize
|
50 |
+
|
51 |
+
## What's New
|
52 |
+
|
53 |
+
- 🚀 v2.0 is released, now you can create prompt templates, turn your ideas into reality! Read this: [ChatGPT Prompt Engineering Tips: Zero, One and Few Shot Prompting](https://www.allabtai.com/prompt-engineering-tips-zero-one-and-few-shot-prompting/).
|
54 |
+
|
55 |
+
## 主要功能
|
56 |
+
|
57 |
+
- 在 1 分钟内使用 Vercel **免费一键部署**
|
58 |
+
- 精心设计的 UI,响应式设计,支持深色模式,支持 PWA
|
59 |
+
- 极快的首屏加载速度(~100kb),支持流式响应
|
60 |
+
- 隐私安全,所有数据保存在用户浏览器本地
|
61 |
+
- 预制角色功能(面具),方便地创建、分享和调试你的个性化对话
|
62 |
+
- 海量的内置 prompt 列表,来自[中文](https://github.com/PlexPt/awesome-chatgpt-prompts-zh)和[英文](https://github.com/f/awesome-chatgpt-prompts)
|
63 |
+
- 自动压缩上下文聊天记录,在节省 Token 的同时支持超长对话
|
64 |
+
- 一键导出聊天记录,完整的 Markdown 支持
|
65 |
+
- 拥有自己的域名?好上加好,绑定后即可在任何地方**无障碍**快速访问
|
66 |
+
|
67 |
+
## 开发计划
|
68 |
+
|
69 |
+
- [x] 为每个对话设置系统 Prompt [#138](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)
|
70 |
+
- [x] 允许用户自行编辑内置 Prompt 列表
|
71 |
+
- [x] 预制角色:使用预制角色快速定制新对话 [#993](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/993)
|
72 |
+
- [ ] 分享为图片,分享到 ShareGPT
|
73 |
+
- [ ] 使用 tauri 打包桌面应用
|
74 |
+
- [ ] 支持自部署的大语言模型
|
75 |
+
- [ ] 插件机制,支持联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
|
76 |
+
|
77 |
+
### 不会开发的功能
|
78 |
+
|
79 |
+
- 界面文字自定义
|
80 |
+
- 用户登录、账号管理、消息云同步
|
81 |
+
|
82 |
+
## 最新动态
|
83 |
+
|
84 |
+
- 🚀 v2.0 已经发布,现在你可以使用面具功能快速创建预制对话了! 了解更多: [ChatGPT 提示词高阶技能:零次、一次和少样本提示](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)。
|
85 |
+
|
86 |
+
## Get Started
|
87 |
+
|
88 |
+
> [简体中文 > 如何开始使用](./README_CN.md#开始使用)
|
89 |
+
|
90 |
+
1. Get [OpenAI API Key](https://platform.openai.com/account/api-keys);
|
91 |
+
2. Click
|
92 |
+
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web), remember that `CODE` is your page password;
|
93 |
+
3. Enjoy :)
|
94 |
+
|
95 |
+
## FAQ
|
96 |
+
|
97 |
+
[简体中文 > 常见问题](./docs/faq-cn.md)
|
98 |
+
|
99 |
+
[English > FAQ](./docs/faq-en.md)
|
100 |
+
|
101 |
+
## Keep Updated
|
102 |
+
|
103 |
+
> [简体中文 > 如何保持代码更新](./README_CN.md#保持更新)
|
104 |
+
|
105 |
+
If you have deployed your own project with just one click following the steps above, you may encounter the issue of "Updates Available" constantly showing up. This is because Vercel will create a new project for you by default instead of forking this project, resulting in the inability to detect updates correctly.
|
106 |
+
|
107 |
+
We recommend that you follow the steps below to re-deploy:
|
108 |
+
|
109 |
+
- Delete the original repository;
|
110 |
+
- Use the fork button in the upper right corner of the page to fork this project;
|
111 |
+
- Choose and deploy in Vercel again, [please see the detailed tutorial](./docs/vercel-cn.md).
|
112 |
+
|
113 |
+
### Enable Automatic Updates
|
114 |
+
|
115 |
+
> If you encounter a failure of Upstream Sync execution, please manually sync fork once.
|
116 |
+
|
117 |
+
After forking the project, due to the limitations imposed by GitHub, you need to manually enable Workflows and Upstream Sync Action on the Actions page of the forked project. Once enabled, automatic updates will be scheduled every hour:
|
118 |
+
|
119 |
+
![Automatic Updates](./docs/images/enable-actions.jpg)
|
120 |
+
|
121 |
+
![Enable Automatic Updates](./docs/images/enable-actions-sync.jpg)
|
122 |
+
|
123 |
+
### Manually Updating Code
|
124 |
+
|
125 |
+
If you want to update instantly, you can check out the [GitHub documentation](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork) to learn how to synchronize a forked project with upstream code.
|
126 |
+
|
127 |
+
You can star or watch this project or follow author to get release notifictions in time.
|
128 |
+
|
129 |
+
## Access Password
|
130 |
+
|
131 |
+
> [简体中文 > 如何增加访问密码](./README_CN.md#配置页面访问密码)
|
132 |
+
|
133 |
+
This project provides limited access control. Please add an environment variable named `CODE` on the vercel environment variables page. The value should be passwords separated by comma like this:
|
134 |
+
|
135 |
+
```
|
136 |
+
code1,code2,code3
|
137 |
+
```
|
138 |
+
|
139 |
+
After adding or modifying this environment variable, please redeploy the project for the changes to take effect.
|
140 |
+
|
141 |
+
## Environment Variables
|
142 |
+
|
143 |
+
> [简体中文 > 如何配置 api key、访问密码、接口代理](./README_CN.md#环境变量)
|
144 |
+
|
145 |
+
### `OPENAI_API_KEY` (required)
|
146 |
+
|
147 |
+
Your openai api key.
|
148 |
+
|
149 |
+
### `CODE` (optional)
|
150 |
+
|
151 |
+
Access passsword, separated by comma.
|
152 |
+
|
153 |
+
### `BASE_URL` (optional)
|
154 |
+
|
155 |
+
> Default: `https://api.openai.com`
|
156 |
+
|
157 |
+
> Examples: `http://your-openai-proxy.com`
|
158 |
+
|
159 |
+
Override openai api request base url.
|
160 |
+
|
161 |
+
### `OPENAI_ORG_ID` (optional)
|
162 |
+
|
163 |
+
Specify OpenAI organization ID.
|
164 |
+
|
165 |
+
### `HIDE_USER_API_KEY` (optional)
|
166 |
+
|
167 |
+
> Default: Empty
|
168 |
+
|
169 |
+
If you do not want users to input their own API key, set this environment variable to 1.
|
170 |
+
|
171 |
+
## Development
|
172 |
+
|
173 |
+
> [简体中文 > 如何进行二次开发](./README_CN.md#开发)
|
174 |
+
|
175 |
+
[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
176 |
+
|
177 |
+
Before starting development, you must create a new `.env.local` file at project root, and place your api key into it:
|
178 |
+
|
179 |
+
```
|
180 |
+
OPENAI_API_KEY=<your api key here>
|
181 |
+
```
|
182 |
+
|
183 |
+
### Local Development
|
184 |
+
|
185 |
+
```shell
|
186 |
+
# 1. install nodejs and yarn first
|
187 |
+
# 2. config local env vars in `.env.local`
|
188 |
+
# 3. run
|
189 |
+
yarn install
|
190 |
+
yarn dev
|
191 |
+
```
|
192 |
+
|
193 |
+
## Deployment
|
194 |
+
|
195 |
+
> [简体中文 > 如何部署到私人服务器](./README_CN.md#部署)
|
196 |
+
|
197 |
+
### Docker (Recommended)
|
198 |
+
|
199 |
+
```shell
|
200 |
+
docker pull yidadaa/chatgpt-next-web
|
201 |
+
|
202 |
+
docker run -d -p 3000:3000 \
|
203 |
+
-e OPENAI_API_KEY="sk-xxxx" \
|
204 |
+
-e CODE="your-password" \
|
205 |
+
yidadaa/chatgpt-next-web
|
206 |
+
```
|
207 |
+
|
208 |
+
You can start service behind a proxy:
|
209 |
+
|
210 |
+
```shell
|
211 |
+
docker run -d -p 3000:3000 \
|
212 |
+
-e OPENAI_API_KEY="sk-xxxx" \
|
213 |
+
-e CODE="your-password" \
|
214 |
+
-e PROXY_URL="http://localhost:7890" \
|
215 |
+
yidadaa/chatgpt-next-web
|
216 |
+
```
|
217 |
+
|
218 |
+
### Shell
|
219 |
+
|
220 |
+
```shell
|
221 |
+
bash <(curl -s https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/scripts/setup.sh)
|
222 |
+
```
|
223 |
+
|
224 |
+
## Screenshots
|
225 |
+
|
226 |
+
![Settings](./docs/images/settings.png)
|
227 |
+
|
228 |
+
![More](./docs/images/more.png)
|
229 |
+
|
230 |
+
## Donation
|
231 |
+
|
232 |
+
[Buy Me a Coffee](https://www.buymeacoffee.com/yidadaa)
|
233 |
+
|
234 |
+
## Special Thanks
|
235 |
+
|
236 |
+
### Sponsor
|
237 |
+
|
238 |
+
> 仅列出捐赠金额 >= 100RMB 的用户。
|
239 |
+
|
240 |
+
[@mushan0x0](https://github.com/mushan0x0)
|
241 |
+
[@ClarenceDan](https://github.com/ClarenceDan)
|
242 |
+
[@zhangjia](https://github.com/zhangjia)
|
243 |
+
[@hoochanlon](https://github.com/hoochanlon)
|
244 |
+
[@relativequantum](https://github.com/relativequantum)
|
245 |
+
[@desenmeng](https://github.com/desenmeng)
|
246 |
+
[@webees](https://github.com/webees)
|
247 |
+
[@chazzhou](https://github.com/chazzhou)
|
248 |
+
[@hauy](https://github.com/hauy)
|
249 |
+
[@Corwin006](https://github.com/Corwin006)
|
250 |
+
[@yankunsong](https://github.com/yankunsong)
|
251 |
+
[@ypwhs](https://github.com/ypwhs)
|
252 |
+
[@fxxxchao](https://github.com/fxxxchao)
|
253 |
+
[@hotic](https://github.com/hotic)
|
254 |
+
[@WingCH](https://github.com/WingCH)
|
255 |
+
[@jtung4](https://github.com/jtung4)
|
256 |
+
|
257 |
+
### Contributor
|
258 |
+
|
259 |
+
[Contributors](https://github.com/Yidadaa/ChatGPT-Next-Web/graphs/contributors)
|
260 |
+
|
261 |
+
## LICENSE
|
262 |
+
|
263 |
+
[Anti 996 License](https://github.com/kattgu7/Anti-996-License/blob/master/LICENSE_CN_EN)
|
README_CN.md
ADDED
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<div align="center">
|
2 |
+
<img src="./docs/images/icon.svg" alt="预览"/>
|
3 |
+
|
4 |
+
<h1 align="center">ChatGPT Next Web</h1>
|
5 |
+
|
6 |
+
一键免费部署你的私人 ChatGPT 网页应用。
|
7 |
+
|
8 |
+
[演示 Demo](https://chat-gpt-next-web.vercel.app/) / [反馈 Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [加入 Discord](https://discord.gg/zrhvHCr79N) / [QQ 群](https://user-images.githubusercontent.com/16968934/228190818-7dd00845-e9b9-4363-97e5-44c507ac76da.jpeg) / [打赏开发者](https://user-images.githubusercontent.com/16968934/227772541-5bcd52d8-61b7-488c-a203-0330d8006e2b.jpg) / [Donate](#捐赠-donate-usdt)
|
9 |
+
|
10 |
+
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web)
|
11 |
+
|
12 |
+
[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
13 |
+
|
14 |
+
![主界面](./docs/images/cover.png)
|
15 |
+
|
16 |
+
</div>
|
17 |
+
|
18 |
+
## 开始使用
|
19 |
+
|
20 |
+
1. 准备好你的 [OpenAI API Key](https://platform.openai.com/account/api-keys);
|
21 |
+
2. 点击右侧按钮开始部署:
|
22 |
+
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web),直接使用 Github 账号登录即可,记得在环境变量页填入 API Key 和[页面访问密码](#配置页面访问密码) CODE;
|
23 |
+
3. 部署完毕后,即可开始使用;
|
24 |
+
4. (可选)[绑定自定义域名](https://vercel.com/docs/concepts/projects/domains/add-a-domain):Vercel 分配的域名 DNS 在某些区域被污染了,绑定自定义域名即可直连。
|
25 |
+
|
26 |
+
## 保持更新
|
27 |
+
|
28 |
+
如果你按照上述步骤一键部署了自己的项目,可能会发现总是提示“存在更新”的问题,这是由于 Vercel 会默认为你创建一个新项目而不是 fork 本项目,这会导致无法正确地检测更新。
|
29 |
+
推荐你按照下列步骤重新部署:
|
30 |
+
|
31 |
+
- 删除掉原先的仓库;
|
32 |
+
- 使用页面右上角的 fork 按钮,fork 本项目;
|
33 |
+
- 在 Vercel 重新选择并部署,[请查看详细教程](./docs/vercel-cn.md#如何新建项目)。
|
34 |
+
|
35 |
+
### 打开自动更新
|
36 |
+
|
37 |
+
> 如果你遇到了 Upstream Sync 执行错误,请手动 Sync Fork 一次!
|
38 |
+
|
39 |
+
当你 fork 项目之后,由于 Github 的限制,需要手动去你 fork 后的项目的 Actions 页面启用 Workflows,并启用 Upstream Sync Action,启用之后即可开启每小时定时自动更新:
|
40 |
+
|
41 |
+
![自动更新](./docs/images/enable-actions.jpg)
|
42 |
+
|
43 |
+
![启用自动更新](./docs/images/enable-actions-sync.jpg)
|
44 |
+
|
45 |
+
### 手动更新代码
|
46 |
+
|
47 |
+
如果你想让手动立即更新,可以查看 [Github 的文档](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork) 了解如何让 fork 的项目与上游代码同步。
|
48 |
+
|
49 |
+
你可以 star/watch 本项目或者 follow 作者来及时获得新功能更新通知。
|
50 |
+
|
51 |
+
## 配置页面访问密码
|
52 |
+
|
53 |
+
> 配置密码后,用户需要在设置页手动填写访问码才可以正常聊天,否则会通过消息提示未授权状态。
|
54 |
+
|
55 |
+
> **警告**:请务必将密码的位数设置得足够长,最好 7 位以上,否则[会被爆破](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/518)。
|
56 |
+
|
57 |
+
本项目提供有限的权限控制功能,请在 Vercel 项目控制面板的环境变量页增加名为 `CODE` 的环境变量,值为用英文逗号分隔的自定义密码:
|
58 |
+
|
59 |
+
```
|
60 |
+
code1,code2,code3
|
61 |
+
```
|
62 |
+
|
63 |
+
增加或修改该环境变量后,请**重新部署**项目使改动生效。
|
64 |
+
|
65 |
+
## 环境变量
|
66 |
+
|
67 |
+
> 本项目大多数配置项都通过环境变量来设置。
|
68 |
+
|
69 |
+
### `OPENAI_API_KEY` (必填项)
|
70 |
+
|
71 |
+
OpanAI 密钥,你在 openai 账户页面申请的 api key。
|
72 |
+
|
73 |
+
### `CODE` (可选)
|
74 |
+
|
75 |
+
访问密码,可选,可以使用逗号隔开多个密码。
|
76 |
+
|
77 |
+
**警告**:如果不填写此项,则任何人都可以直接使用你部署后的网站,可能会导致你的 token 被急速消耗完毕,建议填写此选项。
|
78 |
+
|
79 |
+
### `BASE_URL` (可选)
|
80 |
+
|
81 |
+
> Default: `https://api.openai.com`
|
82 |
+
|
83 |
+
> Examples: `http://your-openai-proxy.com`
|
84 |
+
|
85 |
+
OpenAI 接口代理 URL,如果你手动配置了 openai 接口代理,请填写此选项。
|
86 |
+
|
87 |
+
> 如果遇到 ssl 证书问题,请将 `BASE_URL` 的协议设置为 http。
|
88 |
+
|
89 |
+
### `OPENAI_ORG_ID` (可选)
|
90 |
+
|
91 |
+
指定 OpenAI 中的组织 ID。
|
92 |
+
|
93 |
+
### `HIDE_USER_API_KEY` (可选)
|
94 |
+
|
95 |
+
如果你不想让用户自行填入 API Key,将此环境变量设置为 1 即可。
|
96 |
+
|
97 |
+
## 开发
|
98 |
+
|
99 |
+
> 强烈不建议在本地进行开发或者部署,由于一些技术原因,很难在本地配置好 OpenAI API 代理,除非你能保证可以直连 OpenAI 服务器。
|
100 |
+
|
101 |
+
点击下方按钮,开始二次开发:
|
102 |
+
|
103 |
+
[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
104 |
+
|
105 |
+
在开始写���码之前,需要在项目根目录新建一个 `.env.local` 文件,里面填入环境变量:
|
106 |
+
|
107 |
+
```
|
108 |
+
OPENAI_API_KEY=<your api key here>
|
109 |
+
```
|
110 |
+
|
111 |
+
### 本地开发
|
112 |
+
|
113 |
+
1. 安装 nodejs 18 和 yarn,具体细节请询问 ChatGPT;
|
114 |
+
2. 执行 `yarn install && yarn dev` 即可。⚠️ 注意:此命令仅用于本地开发,不要用于部署!
|
115 |
+
3. 如果你想本地部署,请使用 `yarn install && yarn start` 命令,你可以配合 pm2 来守护进程,防止被杀死,详情询问 ChatGPT。
|
116 |
+
|
117 |
+
## 部署
|
118 |
+
|
119 |
+
### 容器部署 (推荐)
|
120 |
+
|
121 |
+
> Docker 版本需要在 20 及其以上,否则会提示找不到镜像。
|
122 |
+
|
123 |
+
> ⚠️ 注意:docker 版本在大多数时间都会落后最新的版本 1 到 2 天,所以部署后会持续出现“存在更新”的提示,属于正常现象。
|
124 |
+
|
125 |
+
```shell
|
126 |
+
docker pull yidadaa/chatgpt-next-web
|
127 |
+
|
128 |
+
docker run -d -p 3000:3000 \
|
129 |
+
-e OPENAI_API_KEY="sk-xxxx" \
|
130 |
+
-e CODE="页面访问密码" \
|
131 |
+
yidadaa/chatgpt-next-web
|
132 |
+
```
|
133 |
+
|
134 |
+
你也可以指定 proxy:
|
135 |
+
|
136 |
+
```shell
|
137 |
+
docker run -d -p 3000:3000 \
|
138 |
+
-e OPENAI_API_KEY="sk-xxxx" \
|
139 |
+
-e CODE="页面访问密码" \
|
140 |
+
--net=host \
|
141 |
+
-e PROXY_URL="http://127.0.0.1:7890" \
|
142 |
+
yidadaa/chatgpt-next-web
|
143 |
+
```
|
144 |
+
|
145 |
+
如果你需要指定其他环境变量,请自行在上述命令中增加 `-e 环境变量=环境变量值` 来指定。
|
146 |
+
|
147 |
+
### 本地部署
|
148 |
+
|
149 |
+
在控制台运行下方命令:
|
150 |
+
|
151 |
+
```shell
|
152 |
+
bash <(curl -s https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/scripts/setup.sh)
|
153 |
+
```
|
154 |
+
|
155 |
+
⚠️ 注意:如果你安装过程中遇到了问题,请使用 docker 部署。
|
156 |
+
|
157 |
+
## 鸣谢
|
158 |
+
|
159 |
+
### 捐赠者
|
160 |
+
|
161 |
+
> 见英文版。
|
162 |
+
|
163 |
+
### 贡献者
|
164 |
+
|
165 |
+
[见项目贡献者列表](https://github.com/Yidadaa/ChatGPT-Next-Web/graphs/contributors)
|
166 |
+
|
167 |
+
## 开源协议
|
168 |
+
|
169 |
+
> 反对 996,从我开始。
|
170 |
+
|
171 |
+
[Anti 996 License](https://github.com/kattgu7/Anti-996-License/blob/master/LICENSE_CN_EN)
|
app/api/auth.ts
ADDED
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { NextRequest } from "next/server";
|
2 |
+
import { getServerSideConfig } from "../config/server";
|
3 |
+
import md5 from "spark-md5";
|
4 |
+
import { ACCESS_CODE_PREFIX } from "../constant";
|
5 |
+
|
6 |
+
const serverConfig = getServerSideConfig();
|
7 |
+
|
8 |
+
function getIP(req: NextRequest) {
|
9 |
+
let ip = req.ip ?? req.headers.get("x-real-ip");
|
10 |
+
const forwardedFor = req.headers.get("x-forwarded-for");
|
11 |
+
|
12 |
+
if (!ip && forwardedFor) {
|
13 |
+
ip = forwardedFor.split(",").at(0) ?? "";
|
14 |
+
}
|
15 |
+
|
16 |
+
return ip;
|
17 |
+
}
|
18 |
+
|
19 |
+
function parseApiKey(bearToken: string) {
|
20 |
+
const token = bearToken.trim().replaceAll("Bearer ", "").trim();
|
21 |
+
const isOpenAiKey = !token.startsWith(ACCESS_CODE_PREFIX);
|
22 |
+
|
23 |
+
return {
|
24 |
+
accessCode: isOpenAiKey ? "" : token.slice(ACCESS_CODE_PREFIX.length),
|
25 |
+
apiKey: isOpenAiKey ? token : "",
|
26 |
+
};
|
27 |
+
}
|
28 |
+
|
29 |
+
export function auth(req: NextRequest) {
|
30 |
+
const authToken = req.headers.get("Authorization") ?? "";
|
31 |
+
|
32 |
+
// check if it is openai api key or user token
|
33 |
+
const { accessCode, apiKey: token } = parseApiKey(authToken);
|
34 |
+
|
35 |
+
const hashedCode = md5.hash(accessCode ?? "").trim();
|
36 |
+
|
37 |
+
console.log("[Auth] allowed hashed codes: ", [...serverConfig.codes]);
|
38 |
+
console.log("[Auth] got access code:", accessCode);
|
39 |
+
console.log("[Auth] hashed access code:", hashedCode);
|
40 |
+
console.log("[User IP] ", getIP(req));
|
41 |
+
console.log("[Time] ", new Date().toLocaleString());
|
42 |
+
|
43 |
+
if (serverConfig.needCode && !serverConfig.codes.has(hashedCode) && !token) {
|
44 |
+
return {
|
45 |
+
error: true,
|
46 |
+
needAccessCode: true,
|
47 |
+
msg: "Please go settings page and fill your access code.",
|
48 |
+
};
|
49 |
+
}
|
50 |
+
|
51 |
+
// if user does not provide an api key, inject system api key
|
52 |
+
if (!token) {
|
53 |
+
const apiKey = serverConfig.apiKey;
|
54 |
+
if (apiKey) {
|
55 |
+
console.log("[Auth] use system api key");
|
56 |
+
req.headers.set("Authorization", `Bearer ${apiKey}`);
|
57 |
+
} else {
|
58 |
+
console.log("[Auth] admin did not provide an api key");
|
59 |
+
return {
|
60 |
+
error: true,
|
61 |
+
msg: "Empty Api Key",
|
62 |
+
};
|
63 |
+
}
|
64 |
+
} else {
|
65 |
+
console.log("[Auth] use user api key");
|
66 |
+
}
|
67 |
+
|
68 |
+
return {
|
69 |
+
error: false,
|
70 |
+
};
|
71 |
+
}
|
app/api/common.ts
ADDED
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 authValue = req.headers.get("Authorization") ?? "";
|
10 |
+
const openaiPath = `${req.nextUrl.pathname}${req.nextUrl.search}`.replaceAll(
|
11 |
+
"/api/openai/",
|
12 |
+
"",
|
13 |
+
);
|
14 |
+
|
15 |
+
let baseUrl = BASE_URL;
|
16 |
+
|
17 |
+
if (!baseUrl.startsWith("http")) {
|
18 |
+
baseUrl = `${PROTOCOL}://${baseUrl}`;
|
19 |
+
}
|
20 |
+
|
21 |
+
console.log("[Proxy] ", openaiPath);
|
22 |
+
console.log("[Base Url]", baseUrl);
|
23 |
+
|
24 |
+
if (process.env.OPENAI_ORG_ID) {
|
25 |
+
console.log("[Org ID]", process.env.OPENAI_ORG_ID);
|
26 |
+
}
|
27 |
+
|
28 |
+
if (!authValue || !authValue.startsWith("Bearer sk-")) {
|
29 |
+
console.error("[OpenAI Request] invlid api key provided", authValue);
|
30 |
+
}
|
31 |
+
|
32 |
+
return fetch(`${baseUrl}/${openaiPath}`, {
|
33 |
+
headers: {
|
34 |
+
"Content-Type": "application/json",
|
35 |
+
Authorization: authValue,
|
36 |
+
...(process.env.OPENAI_ORG_ID && {
|
37 |
+
"OpenAI-Organization": process.env.OPENAI_ORG_ID,
|
38 |
+
}),
|
39 |
+
},
|
40 |
+
cache: "no-store",
|
41 |
+
method: req.method,
|
42 |
+
body: req.body,
|
43 |
+
});
|
44 |
+
}
|
app/api/config/route.ts
ADDED
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
+
hideUserApiKey: serverConfig.hideUserApiKey,
|
12 |
+
};
|
13 |
+
|
14 |
+
declare global {
|
15 |
+
type DangerConfig = typeof DANGER_CONFIG;
|
16 |
+
}
|
17 |
+
|
18 |
+
async function handle() {
|
19 |
+
return NextResponse.json(DANGER_CONFIG);
|
20 |
+
}
|
21 |
+
|
22 |
+
export const GET = handle;
|
23 |
+
export const POST = handle;
|
24 |
+
|
25 |
+
export const runtime = "edge";
|
app/api/lemur/route.ts
ADDED
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { createParser } from "eventsource-parser";
|
2 |
+
import { NextRequest } from "next/server";
|
3 |
+
|
4 |
+
async function createStream(req: NextRequest) {
|
5 |
+
const encoder = new TextEncoder();
|
6 |
+
const decoder = new TextDecoder();
|
7 |
+
|
8 |
+
const res = await fetch(
|
9 |
+
"http://lemurchat.anfans.cn/api/chat/conversation-trial",
|
10 |
+
{
|
11 |
+
headers: {
|
12 |
+
"Content-Type": "application/json",
|
13 |
+
},
|
14 |
+
method: "POST",
|
15 |
+
body: req.body,
|
16 |
+
},
|
17 |
+
);
|
18 |
+
|
19 |
+
const stream = new ReadableStream({
|
20 |
+
async start(controller) {
|
21 |
+
function onParse(event: any) {
|
22 |
+
if (event.type === "event") {
|
23 |
+
const data = event.data;
|
24 |
+
if (event.id == "1") {
|
25 |
+
let text1 = data.slice(data.indexOf("content"));
|
26 |
+
const text = text1.slice(12, text1.indexOf("index") - 6);
|
27 |
+
const queue = encoder.encode(text);
|
28 |
+
controller.enqueue(queue);
|
29 |
+
return;
|
30 |
+
}
|
31 |
+
// https://beta.openai.com/docs/api-reference/completions/create#completions/create-stream
|
32 |
+
try {
|
33 |
+
const json = JSON.parse(data);
|
34 |
+
// console.log(data.indexOf("content"))
|
35 |
+
if (data.indexOf("content") == -1) {
|
36 |
+
controller.close();
|
37 |
+
return;
|
38 |
+
}
|
39 |
+
// console.log(event.data)
|
40 |
+
const text = JSON.parse(json.data.slice(5)).choices[0].delta
|
41 |
+
.content;
|
42 |
+
const queue = encoder.encode(text);
|
43 |
+
controller.enqueue(queue);
|
44 |
+
} catch (e) {
|
45 |
+
controller.error(e);
|
46 |
+
}
|
47 |
+
}
|
48 |
+
}
|
49 |
+
|
50 |
+
const parser = createParser(onParse);
|
51 |
+
for await (const chunk of res.body as any) {
|
52 |
+
parser.feed(decoder.decode(chunk));
|
53 |
+
}
|
54 |
+
},
|
55 |
+
});
|
56 |
+
return stream;
|
57 |
+
}
|
58 |
+
|
59 |
+
export async function POST(req: NextRequest) {
|
60 |
+
try {
|
61 |
+
const stream = await createStream(req);
|
62 |
+
return new Response(stream);
|
63 |
+
} catch (error) {
|
64 |
+
console.error("[Chat Stream]", error);
|
65 |
+
}
|
66 |
+
}
|
67 |
+
|
68 |
+
export const config = {
|
69 |
+
runtime: "edge",
|
70 |
+
};
|
app/api/newbing/route.ts
ADDED
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { BingChat, ChatMessage } from "../../bing-chat/index";
|
2 |
+
|
3 |
+
export async function POST(req: Request) {
|
4 |
+
try {
|
5 |
+
let cookies = process.env.COOKIES;
|
6 |
+
const api = new BingChat({
|
7 |
+
cookie: cookies,
|
8 |
+
});
|
9 |
+
let chat: ChatMessage = {
|
10 |
+
id: "",
|
11 |
+
text: "",
|
12 |
+
author: "bot",
|
13 |
+
conversationId: "",
|
14 |
+
clientId: "",
|
15 |
+
conversationSignature: "",
|
16 |
+
};
|
17 |
+
const res: any = await bingAiMessageSendWrapper(
|
18 |
+
api,
|
19 |
+
await req.json(),
|
20 |
+
chat,
|
21 |
+
);
|
22 |
+
// const res = await api.sendMessage(await req.json(), {
|
23 |
+
// // print the partial response as the AI is "typing"
|
24 |
+
// onProgress: (partialResponse) => {
|
25 |
+
// console.log(partialResponse.text);
|
26 |
+
// },
|
27 |
+
// variant: "Precise",
|
28 |
+
// });
|
29 |
+
// console.log(res['text'])
|
30 |
+
return new Response(res["text"]);
|
31 |
+
} catch (e) {
|
32 |
+
console.error("[NewBing] ", e);
|
33 |
+
return new Response(JSON.stringify(e));
|
34 |
+
}
|
35 |
+
}
|
36 |
+
|
37 |
+
/**
|
38 |
+
* @param { import("bing-chat").BingChat } client
|
39 |
+
* @param { string } message
|
40 |
+
* @param { import("bing-chat").ChatMessage } [session]
|
41 |
+
* @returns { Promise<import("bing-chat").ChatMessage> }
|
42 |
+
*/
|
43 |
+
function bingAiMessageSendWrapper(
|
44 |
+
client: BingChat,
|
45 |
+
message: string,
|
46 |
+
session: ChatMessage,
|
47 |
+
) {
|
48 |
+
const TIMEOUT_THRESHOLD = 120 * 1000;
|
49 |
+
return new Promise((resolve, reject) => {
|
50 |
+
let response = {
|
51 |
+
text: "",
|
52 |
+
};
|
53 |
+
let responseText = "";
|
54 |
+
let temp = {
|
55 |
+
time: new Date().valueOf(),
|
56 |
+
response: response,
|
57 |
+
};
|
58 |
+
const verifyIfResponseChangedInterval = setInterval(() => {
|
59 |
+
if (new Date().valueOf() - temp.time > TIMEOUT_THRESHOLD) {
|
60 |
+
clearInterval(verifyIfResponseChangedInterval);
|
61 |
+
temp.response.text = responseText;
|
62 |
+
resolve(temp.response);
|
63 |
+
}
|
64 |
+
}, 500);
|
65 |
+
client
|
66 |
+
.sendMessage(message, {
|
67 |
+
...session,
|
68 |
+
onProgress: (partialResponse) => {
|
69 |
+
temp.response = partialResponse;
|
70 |
+
responseText += partialResponse.text;
|
71 |
+
temp.time = new Date().valueOf();
|
72 |
+
},
|
73 |
+
})
|
74 |
+
.then((response) => {
|
75 |
+
resolve(response);
|
76 |
+
})
|
77 |
+
.catch((error) => {
|
78 |
+
reject(error);
|
79 |
+
});
|
80 |
+
});
|
81 |
+
}
|
app/api/openai-image/route.ts
ADDED
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { OpenAIApi, Configuration } from "openai";
|
2 |
+
import { ChatImageRequest } from "../openai-image/typing";
|
3 |
+
import { NextRequest, NextResponse } from "next/server";
|
4 |
+
import { auth } from "../auth";
|
5 |
+
|
6 |
+
export async function POST(req: NextRequest) {
|
7 |
+
const authResult = auth(req);
|
8 |
+
if (authResult.error) {
|
9 |
+
return NextResponse.json(authResult, {
|
10 |
+
status: 401,
|
11 |
+
});
|
12 |
+
}
|
13 |
+
try {
|
14 |
+
let apiKey = process.env.OPENAI_API_KEY;
|
15 |
+
|
16 |
+
const userApiKey = req.headers.get("token");
|
17 |
+
if (userApiKey) {
|
18 |
+
apiKey = userApiKey;
|
19 |
+
console.log("user api key:" + apiKey);
|
20 |
+
}
|
21 |
+
|
22 |
+
const openai = new OpenAIApi(
|
23 |
+
new Configuration({
|
24 |
+
apiKey,
|
25 |
+
}),
|
26 |
+
);
|
27 |
+
|
28 |
+
const requestBody = (await req.json()) as ChatImageRequest;
|
29 |
+
const response = await openai.createImage({
|
30 |
+
...requestBody,
|
31 |
+
});
|
32 |
+
console.log("[Chat-image]" + response.data.data[0].url);
|
33 |
+
return new Response(JSON.stringify(response.data));
|
34 |
+
} catch (e) {
|
35 |
+
console.error("[Chat-image] ", e);
|
36 |
+
return new Response(JSON.stringify(e));
|
37 |
+
}
|
38 |
+
}
|
app/api/openai-image/typing.ts
ADDED
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import type { CreateImageRequest, ImagesResponse } from "openai";
|
2 |
+
|
3 |
+
export type ChatImageRequest = CreateImageRequest;
|
4 |
+
export type ChatImagesResponse = ImagesResponse;
|
5 |
+
|
6 |
+
export type Updater<T> = (updater: (value: T) => void) => void;
|
app/api/openai/[...path]/route.ts
ADDED
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { createParser } from "eventsource-parser";
|
2 |
+
import { NextRequest, NextResponse } from "next/server";
|
3 |
+
import { auth } from "../../auth";
|
4 |
+
import { requestOpenai } from "../../common";
|
5 |
+
|
6 |
+
async function createStream(res: Response) {
|
7 |
+
const encoder = new TextEncoder();
|
8 |
+
const decoder = new TextDecoder();
|
9 |
+
|
10 |
+
const stream = new ReadableStream({
|
11 |
+
async start(controller) {
|
12 |
+
function onParse(event: any) {
|
13 |
+
if (event.type === "event") {
|
14 |
+
const data = event.data;
|
15 |
+
// https://beta.openai.com/docs/api-reference/completions/create#completions/create-stream
|
16 |
+
if (data === "[DONE]") {
|
17 |
+
controller.close();
|
18 |
+
return;
|
19 |
+
}
|
20 |
+
try {
|
21 |
+
const json = JSON.parse(data);
|
22 |
+
const text = json.choices[0].delta.content;
|
23 |
+
const queue = encoder.encode(text);
|
24 |
+
controller.enqueue(queue);
|
25 |
+
} catch (e) {
|
26 |
+
controller.error(e);
|
27 |
+
}
|
28 |
+
}
|
29 |
+
}
|
30 |
+
|
31 |
+
const parser = createParser(onParse);
|
32 |
+
for await (const chunk of res.body as any) {
|
33 |
+
parser.feed(decoder.decode(chunk, { stream: true }));
|
34 |
+
}
|
35 |
+
},
|
36 |
+
});
|
37 |
+
return stream;
|
38 |
+
}
|
39 |
+
|
40 |
+
function formatResponse(msg: any) {
|
41 |
+
const jsonMsg = ["```json\n", JSON.stringify(msg, null, " "), "\n```"].join(
|
42 |
+
"",
|
43 |
+
);
|
44 |
+
return new Response(jsonMsg);
|
45 |
+
}
|
46 |
+
|
47 |
+
async function handle(
|
48 |
+
req: NextRequest,
|
49 |
+
{ params }: { params: { path: string[] } },
|
50 |
+
) {
|
51 |
+
console.log("[OpenAI Route] params ", params);
|
52 |
+
|
53 |
+
const authResult = auth(req);
|
54 |
+
if (authResult.error) {
|
55 |
+
return NextResponse.json(authResult, {
|
56 |
+
status: 401,
|
57 |
+
});
|
58 |
+
}
|
59 |
+
|
60 |
+
try {
|
61 |
+
const api = await requestOpenai(req);
|
62 |
+
|
63 |
+
const contentType = api.headers.get("Content-Type") ?? "";
|
64 |
+
|
65 |
+
// streaming response
|
66 |
+
if (contentType.includes("stream")) {
|
67 |
+
const stream = await createStream(api);
|
68 |
+
const res = new Response(stream);
|
69 |
+
res.headers.set("Content-Type", contentType);
|
70 |
+
return res;
|
71 |
+
}
|
72 |
+
|
73 |
+
// try to parse error msg
|
74 |
+
try {
|
75 |
+
const mayBeErrorBody = await api.json();
|
76 |
+
if (mayBeErrorBody.error) {
|
77 |
+
console.error("[OpenAI Response] ", mayBeErrorBody);
|
78 |
+
return formatResponse(mayBeErrorBody);
|
79 |
+
} else {
|
80 |
+
const res = new Response(JSON.stringify(mayBeErrorBody));
|
81 |
+
res.headers.set("Content-Type", "application/json");
|
82 |
+
res.headers.set("Cache-Control", "no-cache");
|
83 |
+
return res;
|
84 |
+
}
|
85 |
+
} catch (e) {
|
86 |
+
console.error("[OpenAI Parse] ", e);
|
87 |
+
return formatResponse({
|
88 |
+
msg: "invalid response from openai server",
|
89 |
+
error: e,
|
90 |
+
});
|
91 |
+
}
|
92 |
+
} catch (e) {
|
93 |
+
console.error("[OpenAI] ", e);
|
94 |
+
return formatResponse(e);
|
95 |
+
}
|
96 |
+
}
|
97 |
+
|
98 |
+
export const GET = handle;
|
99 |
+
export const POST = handle;
|
100 |
+
|
101 |
+
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/api/wanjuan/route.ts
ADDED
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
export async function POST(req: Request) {
|
2 |
+
try {
|
3 |
+
let body = { message: await req.json() };
|
4 |
+
|
5 |
+
console.log(JSON.stringify(body));
|
6 |
+
let res = "";
|
7 |
+
await fetch("http://47.94.237.159:8080/v1/wanjuan", {
|
8 |
+
method: "POST",
|
9 |
+
body: JSON.stringify(body),
|
10 |
+
})
|
11 |
+
.then((response) => response.json())
|
12 |
+
.then((data) => {
|
13 |
+
// console.log(data)
|
14 |
+
if (data["statusInfo"]["code"] == 0) {
|
15 |
+
// console.log("123123")
|
16 |
+
res = data["data"]["msgContent"];
|
17 |
+
} else {
|
18 |
+
res = data["statusInfo"]["message"];
|
19 |
+
}
|
20 |
+
})
|
21 |
+
.catch((err) => {
|
22 |
+
console.error("[WanJuan] ", err);
|
23 |
+
res = "出错了请重试!";
|
24 |
+
});
|
25 |
+
// console.log("12312"+res);
|
26 |
+
return new Response(res);
|
27 |
+
} catch (e) {
|
28 |
+
console.error("[WanJuan] ", e);
|
29 |
+
return new Response(JSON.stringify(e));
|
30 |
+
}
|
31 |
+
}
|
app/bing-chat/index.d.ts
ADDED
@@ -0,0 +1,274 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
type Author = "user" | "bot";
|
2 |
+
type SendMessageOptions = {
|
3 |
+
conversationId?: string;
|
4 |
+
clientId?: string;
|
5 |
+
conversationSignature?: string;
|
6 |
+
invocationId?: string;
|
7 |
+
messageType?: string;
|
8 |
+
variant?: string;
|
9 |
+
locale?: string;
|
10 |
+
market?: string;
|
11 |
+
region?: string;
|
12 |
+
location?: {
|
13 |
+
lat: number | string;
|
14 |
+
lng: number | string;
|
15 |
+
re?: string;
|
16 |
+
};
|
17 |
+
onProgress?: (partialResponse: ChatMessage) => void;
|
18 |
+
};
|
19 |
+
interface ChatMessage {
|
20 |
+
id: string;
|
21 |
+
text: string;
|
22 |
+
author: Author;
|
23 |
+
conversationId: string;
|
24 |
+
clientId: string;
|
25 |
+
conversationSignature: string;
|
26 |
+
conversationExpiryTime?: string;
|
27 |
+
invocationId?: string;
|
28 |
+
messageType?: string;
|
29 |
+
variant?: string;
|
30 |
+
detail?: ChatMessageFull | ChatMessagePartial;
|
31 |
+
}
|
32 |
+
interface ConversationResult {
|
33 |
+
conversationId: string;
|
34 |
+
clientId: string;
|
35 |
+
conversationSignature: string;
|
36 |
+
result: APIResult;
|
37 |
+
}
|
38 |
+
interface APIResult {
|
39 |
+
value: string;
|
40 |
+
message: null;
|
41 |
+
}
|
42 |
+
interface ChatUpdate {
|
43 |
+
type: 1;
|
44 |
+
target: string;
|
45 |
+
arguments: ChatUpdateArgument[];
|
46 |
+
}
|
47 |
+
interface ChatUpdateArgument {
|
48 |
+
messages: ChatMessagePartial[];
|
49 |
+
requestId: string;
|
50 |
+
result: null;
|
51 |
+
}
|
52 |
+
interface ChatMessagePartial {
|
53 |
+
text: string;
|
54 |
+
author: Author;
|
55 |
+
createdAt: string;
|
56 |
+
timestamp: string;
|
57 |
+
messageId: string;
|
58 |
+
offense: string;
|
59 |
+
adaptiveCards: AdaptiveCard[];
|
60 |
+
sourceAttributions: any[];
|
61 |
+
feedback: ChatMessageFeedback;
|
62 |
+
contentOrigin: string;
|
63 |
+
privacy?: null;
|
64 |
+
messageType?: string;
|
65 |
+
}
|
66 |
+
interface AdaptiveCard {
|
67 |
+
type: string;
|
68 |
+
version: string;
|
69 |
+
body: AdaptiveCardBody[];
|
70 |
+
}
|
71 |
+
interface AdaptiveCardBody {
|
72 |
+
type: string;
|
73 |
+
text: string;
|
74 |
+
wrap: boolean;
|
75 |
+
}
|
76 |
+
interface ChatMessageFeedback {
|
77 |
+
tag: null;
|
78 |
+
updatedOn: null;
|
79 |
+
type: string;
|
80 |
+
}
|
81 |
+
interface ChatUpdateCompleteResponse {
|
82 |
+
type: 2;
|
83 |
+
invocationId: string;
|
84 |
+
item: ChatResponseItem;
|
85 |
+
}
|
86 |
+
interface ChatResponseItem {
|
87 |
+
messages: ChatMessageFull[];
|
88 |
+
firstNewMessageIndex: number;
|
89 |
+
suggestedResponses: null;
|
90 |
+
conversationId: string;
|
91 |
+
requestId: string;
|
92 |
+
conversationExpiryTime: string;
|
93 |
+
telemetry: Telemetry;
|
94 |
+
result: ChatRequestResult;
|
95 |
+
}
|
96 |
+
interface ChatMessageFull {
|
97 |
+
text: string;
|
98 |
+
author: Author;
|
99 |
+
from?: ChatMessageFrom;
|
100 |
+
createdAt: string;
|
101 |
+
timestamp: string;
|
102 |
+
locale?: string;
|
103 |
+
market?: string;
|
104 |
+
region?: string;
|
105 |
+
location?: string;
|
106 |
+
locationHints?: LocationHint[];
|
107 |
+
messageId: string;
|
108 |
+
requestId: string;
|
109 |
+
offense: string;
|
110 |
+
feedback: ChatMessageFeedback;
|
111 |
+
contentOrigin: string;
|
112 |
+
privacy?: null;
|
113 |
+
inputMethod?: string;
|
114 |
+
adaptiveCards?: AdaptiveCard[];
|
115 |
+
sourceAttributions?: any[];
|
116 |
+
suggestedResponses?: SuggestedResponse[];
|
117 |
+
messageType?: string;
|
118 |
+
}
|
119 |
+
interface ChatMessageFrom {
|
120 |
+
id: string;
|
121 |
+
name: null;
|
122 |
+
}
|
123 |
+
interface LocationHint {
|
124 |
+
country: string;
|
125 |
+
countryConfidence: number;
|
126 |
+
state: string;
|
127 |
+
city: string;
|
128 |
+
cityConfidence: number;
|
129 |
+
zipCode: string;
|
130 |
+
timeZoneOffset: number;
|
131 |
+
dma: number;
|
132 |
+
sourceType: number;
|
133 |
+
center: Coords;
|
134 |
+
regionType: number;
|
135 |
+
}
|
136 |
+
interface Coords {
|
137 |
+
latitude: number;
|
138 |
+
longitude: number;
|
139 |
+
height: null;
|
140 |
+
}
|
141 |
+
interface SuggestedResponse {
|
142 |
+
text: string;
|
143 |
+
messageId: string;
|
144 |
+
messageType: string;
|
145 |
+
contentOrigin: string;
|
146 |
+
author?: Author;
|
147 |
+
createdAt?: string;
|
148 |
+
timestamp?: string;
|
149 |
+
offense?: string;
|
150 |
+
feedback?: ChatMessageFeedback;
|
151 |
+
privacy?: null;
|
152 |
+
}
|
153 |
+
interface ChatRequestResult {
|
154 |
+
value: string;
|
155 |
+
serviceVersion: string;
|
156 |
+
}
|
157 |
+
interface Telemetry {
|
158 |
+
metrics?: null;
|
159 |
+
startTime: string;
|
160 |
+
}
|
161 |
+
interface ChatRequest {
|
162 |
+
arguments: ChatRequestArgument[];
|
163 |
+
invocationId: string;
|
164 |
+
target: string;
|
165 |
+
type: number;
|
166 |
+
}
|
167 |
+
interface ChatRequestArgument {
|
168 |
+
source: string;
|
169 |
+
optionsSets: string[];
|
170 |
+
allowedMessageTypes: string[];
|
171 |
+
sliceIds: any[];
|
172 |
+
traceId: string;
|
173 |
+
isStartOfSession: boolean;
|
174 |
+
message: ChatRequestMessage;
|
175 |
+
conversationSignature: string;
|
176 |
+
participant: Participant;
|
177 |
+
conversationId: string;
|
178 |
+
previousMessages: PreviousMessage[];
|
179 |
+
}
|
180 |
+
interface ChatRequestMessage {
|
181 |
+
locale: string;
|
182 |
+
market: string;
|
183 |
+
region?: string;
|
184 |
+
location?: string;
|
185 |
+
locationHints?: LocationHintChatRequestMessage[];
|
186 |
+
timestamp: string;
|
187 |
+
author: Author;
|
188 |
+
inputMethod: string;
|
189 |
+
text: string;
|
190 |
+
messageType: string;
|
191 |
+
}
|
192 |
+
interface LocationHintChatRequestMessage {
|
193 |
+
country: string;
|
194 |
+
state: string;
|
195 |
+
city: string;
|
196 |
+
zipcode: string;
|
197 |
+
timezoneoffset: number;
|
198 |
+
dma: number;
|
199 |
+
countryConfidence: number;
|
200 |
+
cityConfidence: number;
|
201 |
+
Center: Center;
|
202 |
+
RegionType: number;
|
203 |
+
SourceType: number;
|
204 |
+
}
|
205 |
+
interface Center {
|
206 |
+
Latitude: number;
|
207 |
+
Longitude: number;
|
208 |
+
}
|
209 |
+
interface Participant {
|
210 |
+
id: string;
|
211 |
+
}
|
212 |
+
interface PreviousMessage {
|
213 |
+
text: string;
|
214 |
+
author: Author;
|
215 |
+
adaptiveCards: any[];
|
216 |
+
suggestedResponses: SuggestedResponse[];
|
217 |
+
messageId: string;
|
218 |
+
messageType: string;
|
219 |
+
}
|
220 |
+
|
221 |
+
declare class BingChat {
|
222 |
+
protected _cookie: string;
|
223 |
+
protected _debug: boolean;
|
224 |
+
constructor(opts: {
|
225 |
+
cookie: string | undefined;
|
226 |
+
/** @defaultValue `false` **/
|
227 |
+
debug?: boolean;
|
228 |
+
});
|
229 |
+
/**
|
230 |
+
* Sends a message to Bing Chat, waits for the response to resolve, and returns
|
231 |
+
* the response.
|
232 |
+
*
|
233 |
+
* If you want to receive a stream of partial responses, use `opts.onProgress`.
|
234 |
+
*
|
235 |
+
* @param message - The prompt message to send
|
236 |
+
* @param opts.conversationId - Optional ID of a conversation to continue (defaults to a random UUID)
|
237 |
+
* @param opts.onProgress - Optional callback which will be invoked every time the partial response is updated
|
238 |
+
*
|
239 |
+
* @returns The response from Bing Chat
|
240 |
+
*/
|
241 |
+
sendMessage(text: string, opts?: SendMessageOptions): Promise<ChatMessage>;
|
242 |
+
createConversation(): Promise<ConversationResult>;
|
243 |
+
}
|
244 |
+
|
245 |
+
export {
|
246 |
+
APIResult,
|
247 |
+
AdaptiveCard,
|
248 |
+
AdaptiveCardBody,
|
249 |
+
Author,
|
250 |
+
BingChat,
|
251 |
+
Center,
|
252 |
+
ChatMessage,
|
253 |
+
ChatMessageFeedback,
|
254 |
+
ChatMessageFrom,
|
255 |
+
ChatMessageFull,
|
256 |
+
ChatMessagePartial,
|
257 |
+
ChatRequest,
|
258 |
+
ChatRequestArgument,
|
259 |
+
ChatRequestMessage,
|
260 |
+
ChatRequestResult,
|
261 |
+
ChatResponseItem,
|
262 |
+
ChatUpdate,
|
263 |
+
ChatUpdateArgument,
|
264 |
+
ChatUpdateCompleteResponse,
|
265 |
+
ConversationResult,
|
266 |
+
Coords,
|
267 |
+
LocationHint,
|
268 |
+
LocationHintChatRequestMessage,
|
269 |
+
Participant,
|
270 |
+
PreviousMessage,
|
271 |
+
SendMessageOptions,
|
272 |
+
SuggestedResponse,
|
273 |
+
Telemetry,
|
274 |
+
};
|
app/bing-chat/index.js
ADDED
@@ -0,0 +1,284 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// src/bing-chat.ts
|
2 |
+
import crypto from "node:crypto";
|
3 |
+
import WebSocket from "ws";
|
4 |
+
|
5 |
+
// src/fetch.ts
|
6 |
+
var fetch = globalThis.fetch;
|
7 |
+
if (typeof fetch !== "function") {
|
8 |
+
throw new Error("Invalid environment: global fetch not defined");
|
9 |
+
}
|
10 |
+
|
11 |
+
// src/bing-chat.ts
|
12 |
+
var terminalChar = "";
|
13 |
+
var BingChat = class {
|
14 |
+
constructor(opts) {
|
15 |
+
const { cookie, debug = false } = opts;
|
16 |
+
this._cookie = cookie;
|
17 |
+
this._debug = !!debug;
|
18 |
+
if (!this._cookie) {
|
19 |
+
throw new Error("Bing cookie is required");
|
20 |
+
}
|
21 |
+
}
|
22 |
+
/**
|
23 |
+
* Sends a message to Bing Chat, waits for the response to resolve, and returns
|
24 |
+
* the response.
|
25 |
+
*
|
26 |
+
* If you want to receive a stream of partial responses, use `opts.onProgress`.
|
27 |
+
*
|
28 |
+
* @param message - The prompt message to send
|
29 |
+
* @param opts.conversationId - Optional ID of a conversation to continue (defaults to a random UUID)
|
30 |
+
* @param opts.onProgress - Optional callback which will be invoked every time the partial response is updated
|
31 |
+
*
|
32 |
+
* @returns The response from Bing Chat
|
33 |
+
*/
|
34 |
+
async sendMessage(text, opts = {}) {
|
35 |
+
const {
|
36 |
+
invocationId = "1",
|
37 |
+
onProgress,
|
38 |
+
locale = "en-US",
|
39 |
+
market = "en-US",
|
40 |
+
region = "US",
|
41 |
+
location,
|
42 |
+
messageType = "Chat",
|
43 |
+
variant = "Balanced",
|
44 |
+
} = opts;
|
45 |
+
let { conversationId, clientId, conversationSignature } = opts;
|
46 |
+
const isStartOfSession = !(
|
47 |
+
conversationId &&
|
48 |
+
clientId &&
|
49 |
+
conversationSignature
|
50 |
+
);
|
51 |
+
if (isStartOfSession) {
|
52 |
+
const conversation = await this.createConversation();
|
53 |
+
conversationId = conversation.conversationId;
|
54 |
+
clientId = conversation.clientId;
|
55 |
+
conversationSignature = conversation.conversationSignature;
|
56 |
+
}
|
57 |
+
const result = {
|
58 |
+
author: "bot",
|
59 |
+
id: crypto.randomUUID(),
|
60 |
+
conversationId,
|
61 |
+
clientId,
|
62 |
+
conversationSignature,
|
63 |
+
invocationId: `${parseInt(invocationId, 10) + 1}`,
|
64 |
+
text: "",
|
65 |
+
};
|
66 |
+
const responseP = new Promise(async (resolve, reject) => {
|
67 |
+
const chatWebsocketUrl = "wss://sydney.bing.com/sydney/ChatHub";
|
68 |
+
const ws = new WebSocket(chatWebsocketUrl, {
|
69 |
+
perMessageDeflate: false,
|
70 |
+
headers: {
|
71 |
+
"accept-language": "en-US,en;q=0.9",
|
72 |
+
"cache-control": "no-cache",
|
73 |
+
pragma: "no-cache",
|
74 |
+
},
|
75 |
+
});
|
76 |
+
let isFulfilled = false;
|
77 |
+
function cleanup() {
|
78 |
+
ws.close();
|
79 |
+
ws.removeAllListeners();
|
80 |
+
}
|
81 |
+
ws.on("error", (error) => {
|
82 |
+
console.warn("WebSocket error:", error);
|
83 |
+
cleanup();
|
84 |
+
if (!isFulfilled) {
|
85 |
+
isFulfilled = true;
|
86 |
+
reject(new Error(`WebSocket error: ${error.toString()}`));
|
87 |
+
}
|
88 |
+
});
|
89 |
+
ws.on("close", () => {});
|
90 |
+
ws.on("open", () => {
|
91 |
+
ws.send(`{"protocol":"json","version":1}${terminalChar}`);
|
92 |
+
});
|
93 |
+
let stage = 0;
|
94 |
+
ws.on("message", (data) => {
|
95 |
+
var _a, _b;
|
96 |
+
const objects = data.toString().split(terminalChar);
|
97 |
+
const messages = objects
|
98 |
+
.map((object) => {
|
99 |
+
try {
|
100 |
+
return JSON.parse(object);
|
101 |
+
} catch (error) {
|
102 |
+
return object;
|
103 |
+
}
|
104 |
+
})
|
105 |
+
.filter(Boolean);
|
106 |
+
if (!messages.length) {
|
107 |
+
return;
|
108 |
+
}
|
109 |
+
if (stage === 0) {
|
110 |
+
ws.send(`{"type":6}${terminalChar}`);
|
111 |
+
const traceId = crypto.randomBytes(16).toString("hex");
|
112 |
+
const locationStr = location
|
113 |
+
? `lat:${location.lat};long:${location.lng};re=${
|
114 |
+
location.re || "1000m"
|
115 |
+
};`
|
116 |
+
: void 0;
|
117 |
+
const optionsSets = [
|
118 |
+
"nlu_direct_response_filter",
|
119 |
+
"deepleo",
|
120 |
+
"enable_debug_commands",
|
121 |
+
"disable_emoji_spoken_text",
|
122 |
+
"responsible_ai_policy_235",
|
123 |
+
"enablemm",
|
124 |
+
"trffovrd",
|
125 |
+
"h3toppfp3",
|
126 |
+
"forcerep",
|
127 |
+
"cpcttl1d",
|
128 |
+
"dv3sugg",
|
129 |
+
];
|
130 |
+
if (variant == "Balanced") {
|
131 |
+
optionsSets.push("galileo");
|
132 |
+
optionsSets.push("glprompt");
|
133 |
+
} else if (variant == "Creative") {
|
134 |
+
optionsSets.push("h3imaginative");
|
135 |
+
optionsSets.push("gencontentv3");
|
136 |
+
} else if (variant == "Precise") {
|
137 |
+
optionsSets.push("h3precise");
|
138 |
+
}
|
139 |
+
const params = {
|
140 |
+
arguments: [
|
141 |
+
{
|
142 |
+
source: "cib",
|
143 |
+
optionsSets,
|
144 |
+
allowedMessageTypes: [
|
145 |
+
"Chat",
|
146 |
+
"InternalSearchQuery",
|
147 |
+
"InternalSearchResult",
|
148 |
+
"InternalLoaderMessage",
|
149 |
+
"RenderCardRequest",
|
150 |
+
"AdsQuery",
|
151 |
+
"SemanticSerp",
|
152 |
+
],
|
153 |
+
sliceIds: [],
|
154 |
+
traceId,
|
155 |
+
isStartOfSession,
|
156 |
+
message: {
|
157 |
+
locale,
|
158 |
+
market,
|
159 |
+
region,
|
160 |
+
location: locationStr,
|
161 |
+
author: "user",
|
162 |
+
inputMethod: "Keyboard",
|
163 |
+
messageType,
|
164 |
+
text,
|
165 |
+
},
|
166 |
+
conversationSignature,
|
167 |
+
participant: { id: clientId },
|
168 |
+
conversationId,
|
169 |
+
},
|
170 |
+
],
|
171 |
+
invocationId,
|
172 |
+
target: "chat",
|
173 |
+
type: 4,
|
174 |
+
};
|
175 |
+
if (this._debug) {
|
176 |
+
console.log(chatWebsocketUrl, JSON.stringify(params, null, 2));
|
177 |
+
}
|
178 |
+
ws.send(`${JSON.stringify(params)}${terminalChar}`);
|
179 |
+
++stage;
|
180 |
+
return;
|
181 |
+
}
|
182 |
+
for (const message of messages) {
|
183 |
+
if (message.type === 1) {
|
184 |
+
const update = message;
|
185 |
+
const msg =
|
186 |
+
(_a = update.arguments[0].messages) == null ? void 0 : _a[0];
|
187 |
+
if (!msg) continue;
|
188 |
+
if (!msg.messageType) {
|
189 |
+
result.author = msg.author;
|
190 |
+
result.text = msg.text;
|
191 |
+
result.detail = msg;
|
192 |
+
onProgress == null ? void 0 : onProgress(result);
|
193 |
+
}
|
194 |
+
} else if (message.type === 2) {
|
195 |
+
const response = message;
|
196 |
+
if (this._debug) {
|
197 |
+
console.log("RESPONSE", JSON.stringify(response, null, 2));
|
198 |
+
}
|
199 |
+
const validMessages =
|
200 |
+
(_b = response.item.messages) == null
|
201 |
+
? void 0
|
202 |
+
: _b.filter((m) => !m.messageType);
|
203 |
+
const lastMessage =
|
204 |
+
validMessages == null
|
205 |
+
? void 0
|
206 |
+
: validMessages[
|
207 |
+
(validMessages == null ? void 0 : validMessages.length) - 1
|
208 |
+
];
|
209 |
+
if (lastMessage) {
|
210 |
+
result.conversationId = response.item.conversationId;
|
211 |
+
result.conversationExpiryTime =
|
212 |
+
response.item.conversationExpiryTime;
|
213 |
+
result.author = lastMessage.author;
|
214 |
+
result.text = lastMessage.text;
|
215 |
+
result.detail = lastMessage;
|
216 |
+
if (!isFulfilled) {
|
217 |
+
isFulfilled = true;
|
218 |
+
resolve(result);
|
219 |
+
}
|
220 |
+
}
|
221 |
+
} else if (message.type === 3) {
|
222 |
+
if (!isFulfilled) {
|
223 |
+
isFulfilled = true;
|
224 |
+
resolve(result);
|
225 |
+
}
|
226 |
+
cleanup();
|
227 |
+
return;
|
228 |
+
} else {
|
229 |
+
}
|
230 |
+
}
|
231 |
+
});
|
232 |
+
});
|
233 |
+
return responseP;
|
234 |
+
}
|
235 |
+
async createConversation() {
|
236 |
+
const requestId = crypto.randomUUID();
|
237 |
+
const cookie = this._cookie.includes(";")
|
238 |
+
? this._cookie
|
239 |
+
: `_U=${this._cookie}`;
|
240 |
+
return fetch("https://www.bing.com/turing/conversation/create", {
|
241 |
+
headers: {
|
242 |
+
accept: "application/json",
|
243 |
+
"accept-language": "en-US,en;q=0.9",
|
244 |
+
"content-type": "application/json",
|
245 |
+
"sec-ch-ua":
|
246 |
+
'"Not_A Brand";v="99", "Microsoft Edge";v="109", "Chromium";v="109"',
|
247 |
+
"sec-ch-ua-arch": '"x86"',
|
248 |
+
"sec-ch-ua-bitness": '"64"',
|
249 |
+
"sec-ch-ua-full-version": '"109.0.1518.78"',
|
250 |
+
"sec-ch-ua-full-version-list":
|
251 |
+
'"Not_A Brand";v="99.0.0.0", "Microsoft Edge";v="109.0.1518.78", "Chromium";v="109.0.5414.120"',
|
252 |
+
"sec-ch-ua-mobile": "?0",
|
253 |
+
"sec-ch-ua-model": "",
|
254 |
+
"sec-ch-ua-platform": '"macOS"',
|
255 |
+
"sec-ch-ua-platform-version": '"12.6.0"',
|
256 |
+
"sec-fetch-dest": "empty",
|
257 |
+
"sec-fetch-mode": "cors",
|
258 |
+
"sec-fetch-site": "same-origin",
|
259 |
+
"x-edge-shopping-flag": "1",
|
260 |
+
"x-ms-client-request-id": requestId,
|
261 |
+
"x-ms-useragent":
|
262 |
+
"azsdk-js-api-client-factory/1.0.0-beta.1 core-rest-pipeline/1.10.0 OS/MacIntel",
|
263 |
+
"x-forwarded-for": "1.1.1.1",
|
264 |
+
cookie,
|
265 |
+
},
|
266 |
+
referrer: "https://www.bing.com/search",
|
267 |
+
referrerPolicy: "origin-when-cross-origin",
|
268 |
+
body: null,
|
269 |
+
method: "GET",
|
270 |
+
mode: "cors",
|
271 |
+
credentials: "include",
|
272 |
+
}).then((res) => {
|
273 |
+
if (res.ok) {
|
274 |
+
return res.json();
|
275 |
+
} else {
|
276 |
+
throw new Error(
|
277 |
+
`unexpected HTTP error createConversation ${res.status}: ${res.statusText}`,
|
278 |
+
);
|
279 |
+
}
|
280 |
+
});
|
281 |
+
}
|
282 |
+
};
|
283 |
+
export { BingChat };
|
284 |
+
//# sourceMappingURL=index.js.map
|
app/bing-chat/index.js.map
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
{"version":3,"sources":["../src/bing-chat.ts","../src/fetch.ts"],"sourcesContent":["import crypto from 'node:crypto'\n\nimport WebSocket from 'ws'\n\nimport * as types from './types'\nimport { fetch } from './fetch'\n\nconst terminalChar = '\u001e'\n\nexport class BingChat {\n protected _cookie: string\n protected _debug: boolean\n\n constructor(opts: {\n cookie: string\n\n /** @defaultValue `false` **/\n debug?: boolean\n }) {\n const { cookie, debug = false } = opts\n\n this._cookie = cookie\n this._debug = !!debug\n\n if (!this._cookie) {\n throw new Error('Bing cookie is required')\n }\n }\n\n /**\n * Sends a message to Bing Chat, waits for the response to resolve, and returns\n * the response.\n *\n * If you want to receive a stream of partial responses, use `opts.onProgress`.\n *\n * @param message - The prompt message to send\n * @param opts.conversationId - Optional ID of a conversation to continue (defaults to a random UUID)\n * @param opts.onProgress - Optional callback which will be invoked every time the partial response is updated\n *\n * @returns The response from Bing Chat\n */\n async sendMessage(\n text: string,\n opts: types.SendMessageOptions = {}\n ): Promise<types.ChatMessage> {\n const {\n invocationId = '1',\n onProgress,\n locale = 'en-US',\n market = 'en-US',\n region = 'US',\n location,\n messageType = 'Chat',\n variant = 'Balanced'\n } = opts\n\n let { conversationId, clientId, conversationSignature } = opts\n const isStartOfSession = !(\n conversationId &&\n clientId &&\n conversationSignature\n )\n\n if (isStartOfSession) {\n const conversation = await this.createConversation()\n conversationId = conversation.conversationId\n clientId = conversation.clientId\n conversationSignature = conversation.conversationSignature\n }\n\n const result: types.ChatMessage = {\n author: 'bot',\n id: crypto.randomUUID(),\n conversationId,\n clientId,\n conversationSignature,\n invocationId: `${parseInt(invocationId, 10) + 1}`,\n text: ''\n }\n\n const responseP = new Promise<types.ChatMessage>(\n async (resolve, reject) => {\n const chatWebsocketUrl = 'wss://sydney.bing.com/sydney/ChatHub'\n const ws = new WebSocket(chatWebsocketUrl, {\n perMessageDeflate: false,\n headers: {\n 'accept-language': 'en-US,en;q=0.9',\n 'cache-control': 'no-cache',\n pragma: 'no-cache'\n }\n })\n\n let isFulfilled = false\n\n function cleanup() {\n ws.close()\n ws.removeAllListeners()\n }\n\n ws.on('error', (error) => {\n console.warn('WebSocket error:', error)\n cleanup()\n if (!isFulfilled) {\n isFulfilled = true\n reject(new Error(`WebSocket error: ${error.toString()}`))\n }\n })\n ws.on('close', () => {\n // TODO\n })\n\n ws.on('open', () => {\n ws.send(`{\"protocol\":\"json\",\"version\":1}${terminalChar}`)\n })\n let stage = 0\n\n ws.on('message', (data) => {\n const objects = data.toString().split(terminalChar)\n\n const messages = objects\n .map((object) => {\n try {\n return JSON.parse(object)\n } catch (error) {\n return object\n }\n })\n .filter(Boolean)\n\n if (!messages.length) {\n return\n }\n\n if (stage === 0) {\n ws.send(`{\"type\":6}${terminalChar}`)\n\n const traceId = crypto.randomBytes(16).toString('hex')\n\n // example location: 'lat:47.639557;long:-122.128159;re=1000m;'\n const locationStr = location\n ? `lat:${location.lat};long:${location.lng};re=${\n location.re || '1000m'\n };`\n : undefined\n\n // Sets the correct options for the variant of the model\n const optionsSets = [\n 'nlu_direct_response_filter',\n 'deepleo',\n 'enable_debug_commands',\n 'disable_emoji_spoken_text',\n 'responsible_ai_policy_235',\n 'enablemm'\n ]\n if (variant == 'Balanced') {\n optionsSets.push('galileo')\n } else {\n optionsSets.push('clgalileo')\n if (variant == 'Creative') {\n optionsSets.push('h3imaginative')\n } else if (variant == 'Precise') {\n optionsSets.push('h3precise')\n }\n }\n const params = {\n arguments: [\n {\n source: 'cib',\n optionsSets,\n allowedMessageTypes: [\n 'Chat',\n 'InternalSearchQuery',\n 'InternalSearchResult',\n 'InternalLoaderMessage',\n 'RenderCardRequest',\n 'AdsQuery',\n 'SemanticSerp'\n ],\n sliceIds: [],\n traceId,\n isStartOfSession,\n message: {\n locale,\n market,\n region,\n location: locationStr,\n author: 'user',\n inputMethod: 'Keyboard',\n messageType,\n text\n },\n conversationSignature,\n participant: { id: clientId },\n conversationId\n }\n ],\n invocationId,\n target: 'chat',\n type: 4\n }\n\n if (this._debug) {\n console.log(chatWebsocketUrl, JSON.stringify(params, null, 2))\n }\n\n ws.send(`${JSON.stringify(params)}${terminalChar}`)\n\n ++stage\n return\n }\n\n for (const message of messages) {\n // console.log(JSON.stringify(message, null, 2))\n\n if (message.type === 1) {\n const update = message as types.ChatUpdate\n const msg = update.arguments[0].messages?.[0]\n\n if (!msg) continue\n\n // console.log('RESPONSE0', JSON.stringify(update, null, 2))\n\n if (!msg.messageType) {\n result.author = msg.author\n result.text = msg.text\n result.detail = msg\n\n onProgress?.(result)\n }\n } else if (message.type === 2) {\n const response = message as types.ChatUpdateCompleteResponse\n if (this._debug) {\n console.log('RESPONSE', JSON.stringify(response, null, 2))\n }\n const validMessages = response.item.messages?.filter(\n (m) => !m.messageType\n )\n const lastMessage = validMessages?.[validMessages?.length - 1]\n\n if (lastMessage) {\n result.conversationId = response.item.conversationId\n result.conversationExpiryTime =\n response.item.conversationExpiryTime\n\n result.author = lastMessage.author\n result.text = lastMessage.text\n result.detail = lastMessage\n\n if (!isFulfilled) {\n isFulfilled = true\n resolve(result)\n }\n }\n } else if (message.type === 3) {\n if (!isFulfilled) {\n isFulfilled = true\n resolve(result)\n }\n\n cleanup()\n return\n } else {\n // TODO: handle other message types\n // these may be for displaying \"adaptive cards\"\n // console.warn('unexpected message type', message.type, message)\n }\n }\n })\n }\n )\n\n return responseP\n }\n\n async createConversation(): Promise<types.ConversationResult> {\n const requestId = crypto.randomUUID()\n\n const cookie = this._cookie.includes(';')\n ? this._cookie\n : `_U=${this._cookie}`\n\n return fetch('https://www.bing.com/turing/conversation/create', {\n headers: {\n accept: 'application/json',\n 'accept-language': 'en-US,en;q=0.9',\n 'content-type': 'application/json',\n 'sec-ch-ua':\n '\"Not_A Brand\";v=\"99\", \"Microsoft Edge\";v=\"109\", \"Chromium\";v=\"109\"',\n 'sec-ch-ua-arch': '\"x86\"',\n 'sec-ch-ua-bitness': '\"64\"',\n 'sec-ch-ua-full-version': '\"109.0.1518.78\"',\n 'sec-ch-ua-full-version-list':\n '\"Not_A Brand\";v=\"99.0.0.0\", \"Microsoft Edge\";v=\"109.0.1518.78\", \"Chromium\";v=\"109.0.5414.120\"',\n 'sec-ch-ua-mobile': '?0',\n 'sec-ch-ua-model': '',\n 'sec-ch-ua-platform': '\"macOS\"',\n 'sec-ch-ua-platform-version': '\"12.6.0\"',\n 'sec-fetch-dest': 'empty',\n 'sec-fetch-mode': 'cors',\n 'sec-fetch-site': 'same-origin',\n 'x-edge-shopping-flag': '1',\n 'x-ms-client-request-id': requestId,\n 'x-ms-useragent':\n 'azsdk-js-api-client-factory/1.0.0-beta.1 core-rest-pipeline/1.10.0 OS/MacIntel',\n cookie\n },\n referrer: 'https://www.bing.com/search',\n referrerPolicy: 'origin-when-cross-origin',\n body: null,\n method: 'GET',\n mode: 'cors',\n credentials: 'include'\n }).then((res) => {\n if (res.ok) {\n return res.json()\n } else {\n throw new Error(\n `unexpected HTTP error createConversation ${res.status}: ${res.statusText}`\n )\n }\n })\n }\n}\n","/// <reference lib=\"dom\" />\n\nconst fetch = globalThis.fetch\n\nif (typeof fetch !== 'function') {\n throw new Error('Invalid environment: global fetch not defined')\n}\n\nexport { fetch }\n"],"mappings":";AAAA,OAAO,YAAY;AAEnB,OAAO,eAAe;;;ACAtB,IAAM,QAAQ,WAAW;AAEzB,IAAI,OAAO,UAAU,YAAY;AAC/B,QAAM,IAAI,MAAM,+CAA+C;AACjE;;;ADCA,IAAM,eAAe;AAEd,IAAM,WAAN,MAAe;AAAA,EAIpB,YAAY,MAKT;AACD,UAAM,EAAE,QAAQ,QAAQ,MAAM,IAAI;AAElC,SAAK,UAAU;AACf,SAAK,SAAS,CAAC,CAAC;AAEhB,QAAI,CAAC,KAAK,SAAS;AACjB,YAAM,IAAI,MAAM,yBAAyB;AAAA,IAC3C;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAcA,MAAM,YACJ,MACA,OAAiC,CAAC,GACN;AAC5B,UAAM;AAAA,MACJ,eAAe;AAAA,MACf;AAAA,MACA,SAAS;AAAA,MACT,SAAS;AAAA,MACT,SAAS;AAAA,MACT;AAAA,MACA,cAAc;AAAA,MACd,UAAU;AAAA,IACZ,IAAI;AAEJ,QAAI,EAAE,gBAAgB,UAAU,sBAAsB,IAAI;AAC1D,UAAM,mBAAmB,EACvB,kBACA,YACA;AAGF,QAAI,kBAAkB;AACpB,YAAM,eAAe,MAAM,KAAK,mBAAmB;AACnD,uBAAiB,aAAa;AAC9B,iBAAW,aAAa;AACxB,8BAAwB,aAAa;AAAA,IACvC;AAEA,UAAM,SAA4B;AAAA,MAChC,QAAQ;AAAA,MACR,IAAI,OAAO,WAAW;AAAA,MACtB;AAAA,MACA;AAAA,MACA;AAAA,MACA,cAAc,GAAG,SAAS,cAAc,EAAE,IAAI;AAAA,MAC9C,MAAM;AAAA,IACR;AAEA,UAAM,YAAY,IAAI;AAAA,MACpB,OAAO,SAAS,WAAW;AACzB,cAAM,mBAAmB;AACzB,cAAM,KAAK,IAAI,UAAU,kBAAkB;AAAA,UACzC,mBAAmB;AAAA,UACnB,SAAS;AAAA,YACP,mBAAmB;AAAA,YACnB,iBAAiB;AAAA,YACjB,QAAQ;AAAA,UACV;AAAA,QACF,CAAC;AAED,YAAI,cAAc;AAElB,iBAAS,UAAU;AACjB,aAAG,MAAM;AACT,aAAG,mBAAmB;AAAA,QACxB;AAEA,WAAG,GAAG,SAAS,CAAC,UAAU;AACxB,kBAAQ,KAAK,oBAAoB,KAAK;AACtC,kBAAQ;AACR,cAAI,CAAC,aAAa;AAChB,0BAAc;AACd,mBAAO,IAAI,MAAM,oBAAoB,MAAM,SAAS,GAAG,CAAC;AAAA,UAC1D;AAAA,QACF,CAAC;AACD,WAAG,GAAG,SAAS,MAAM;AAAA,QAErB,CAAC;AAED,WAAG,GAAG,QAAQ,MAAM;AAClB,aAAG,KAAK,kCAAkC,cAAc;AAAA,QAC1D,CAAC;AACD,YAAI,QAAQ;AAEZ,WAAG,GAAG,WAAW,CAAC,SAAS;AApHnC;AAqHU,gBAAM,UAAU,KAAK,SAAS,EAAE,MAAM,YAAY;AAElD,gBAAM,WAAW,QACd,IAAI,CAAC,WAAW;AACf,gBAAI;AACF,qBAAO,KAAK,MAAM,MAAM;AAAA,YAC1B,SAAS,OAAP;AACA,qBAAO;AAAA,YACT;AAAA,UACF,CAAC,EACA,OAAO,OAAO;AAEjB,cAAI,CAAC,SAAS,QAAQ;AACpB;AAAA,UACF;AAEA,cAAI,UAAU,GAAG;AACf,eAAG,KAAK,aAAa,cAAc;AAEnC,kBAAM,UAAU,OAAO,YAAY,EAAE,EAAE,SAAS,KAAK;AAGrD,kBAAM,cAAc,WAChB,OAAO,SAAS,YAAY,SAAS,UACnC,SAAS,MAAM,aAEjB;AAGJ,kBAAM,cAAc;AAAA,cAClB;AAAA,cACA;AAAA,cACA;AAAA,cACA;AAAA,cACA;AAAA,cACA;AAAA,YACF;AACA,gBAAI,WAAW,YAAY;AACzB,0BAAY,KAAK,SAAS;AAAA,YAC5B,OAAO;AACL,0BAAY,KAAK,WAAW;AAC5B,kBAAI,WAAW,YAAY;AACzB,4BAAY,KAAK,eAAe;AAAA,cAClC,WAAW,WAAW,WAAW;AAC/B,4BAAY,KAAK,WAAW;AAAA,cAC9B;AAAA,YACF;AACA,kBAAM,SAAS;AAAA,cACb,WAAW;AAAA,gBACT;AAAA,kBACE,QAAQ;AAAA,kBACR;AAAA,kBACA,qBAAqB;AAAA,oBACnB;AAAA,oBACA;AAAA,oBACA;AAAA,oBACA;AAAA,oBACA;AAAA,oBACA;AAAA,oBACA;AAAA,kBACF;AAAA,kBACA,UAAU,CAAC;AAAA,kBACX;AAAA,kBACA;AAAA,kBACA,SAAS;AAAA,oBACP;AAAA,oBACA;AAAA,oBACA;AAAA,oBACA,UAAU;AAAA,oBACV,QAAQ;AAAA,oBACR,aAAa;AAAA,oBACb;AAAA,oBACA;AAAA,kBACF;AAAA,kBACA;AAAA,kBACA,aAAa,EAAE,IAAI,SAAS;AAAA,kBAC5B;AAAA,gBACF;AAAA,cACF;AAAA,cACA;AAAA,cACA,QAAQ;AAAA,cACR,MAAM;AAAA,YACR;AAEA,gBAAI,KAAK,QAAQ;AACf,sBAAQ,IAAI,kBAAkB,KAAK,UAAU,QAAQ,MAAM,CAAC,CAAC;AAAA,YAC/D;AAEA,eAAG,KAAK,GAAG,KAAK,UAAU,MAAM,IAAI,cAAc;AAElD,cAAE;AACF;AAAA,UACF;AAEA,qBAAW,WAAW,UAAU;AAG9B,gBAAI,QAAQ,SAAS,GAAG;AACtB,oBAAM,SAAS;AACf,oBAAM,OAAM,YAAO,UAAU,CAAC,EAAE,aAApB,mBAA+B;AAE3C,kBAAI,CAAC;AAAK;AAIV,kBAAI,CAAC,IAAI,aAAa;AACpB,uBAAO,SAAS,IAAI;AACpB,uBAAO,OAAO,IAAI;AAClB,uBAAO,SAAS;AAEhB,yDAAa;AAAA,cACf;AAAA,YACF,WAAW,QAAQ,SAAS,GAAG;AAC7B,oBAAM,WAAW;AACjB,kBAAI,KAAK,QAAQ;AACf,wBAAQ,IAAI,YAAY,KAAK,UAAU,UAAU,MAAM,CAAC,CAAC;AAAA,cAC3D;AACA,oBAAM,iBAAgB,cAAS,KAAK,aAAd,mBAAwB;AAAA,gBAC5C,CAAC,MAAM,CAAC,EAAE;AAAA;AAEZ,oBAAM,cAAc,gDAAgB,+CAAe,UAAS;AAE5D,kBAAI,aAAa;AACf,uBAAO,iBAAiB,SAAS,KAAK;AACtC,uBAAO,yBACL,SAAS,KAAK;AAEhB,uBAAO,SAAS,YAAY;AAC5B,uBAAO,OAAO,YAAY;AAC1B,uBAAO,SAAS;AAEhB,oBAAI,CAAC,aAAa;AAChB,gCAAc;AACd,0BAAQ,MAAM;AAAA,gBAChB;AAAA,cACF;AAAA,YACF,WAAW,QAAQ,SAAS,GAAG;AAC7B,kBAAI,CAAC,aAAa;AAChB,8BAAc;AACd,wBAAQ,MAAM;AAAA,cAChB;AAEA,sBAAQ;AACR;AAAA,YACF,OAAO;AAAA,YAIP;AAAA,UACF;AAAA,QACF,CAAC;AAAA,MACH;AAAA,IACF;AAEA,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,qBAAwD;AAC5D,UAAM,YAAY,OAAO,WAAW;AAEpC,UAAM,SAAS,KAAK,QAAQ,SAAS,GAAG,IACpC,KAAK,UACL,MAAM,KAAK;AAEf,WAAO,MAAM,mDAAmD;AAAA,MAC9D,SAAS;AAAA,QACP,QAAQ;AAAA,QACR,mBAAmB;AAAA,QACnB,gBAAgB;AAAA,QAChB,aACE;AAAA,QACF,kBAAkB;AAAA,QAClB,qBAAqB;AAAA,QACrB,0BAA0B;AAAA,QAC1B,+BACE;AAAA,QACF,oBAAoB;AAAA,QACpB,mBAAmB;AAAA,QACnB,sBAAsB;AAAA,QACtB,8BAA8B;AAAA,QAC9B,kBAAkB;AAAA,QAClB,kBAAkB;AAAA,QAClB,kBAAkB;AAAA,QAClB,wBAAwB;AAAA,QACxB,0BAA0B;AAAA,QAC1B,kBACE;AAAA,QACF;AAAA,MACF;AAAA,MACA,UAAU;AAAA,MACV,gBAAgB;AAAA,MAChB,MAAM;AAAA,MACN,QAAQ;AAAA,MACR,MAAM;AAAA,MACN,aAAa;AAAA,IACf,CAAC,EAAE,KAAK,CAAC,QAAQ;AACf,UAAI,IAAI,IAAI;AACV,eAAO,IAAI,KAAK;AAAA,MAClB,OAAO;AACL,cAAM,IAAI;AAAA,UACR,4CAA4C,IAAI,WAAW,IAAI;AAAA,QACjE;AAAA,MACF;AAAA,IACF,CAAC;AAAA,EACH;AACF;","names":[]}
|
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,825 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 { useLocation, 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 |
+
const noPrompts = props.prompts.length === 0;
|
228 |
+
const [selectIndex, setSelectIndex] = useState(0);
|
229 |
+
const selectedRef = useRef<HTMLDivElement>(null);
|
230 |
+
|
231 |
+
useEffect(() => {
|
232 |
+
setSelectIndex(0);
|
233 |
+
}, [props.prompts.length]);
|
234 |
+
|
235 |
+
useEffect(() => {
|
236 |
+
const onKeyDown = (e: KeyboardEvent) => {
|
237 |
+
if (noPrompts) return;
|
238 |
+
|
239 |
+
// arrow up / down to select prompt
|
240 |
+
const changeIndex = (delta: number) => {
|
241 |
+
e.stopPropagation();
|
242 |
+
e.preventDefault();
|
243 |
+
const nextIndex = Math.max(
|
244 |
+
0,
|
245 |
+
Math.min(props.prompts.length - 1, selectIndex + delta),
|
246 |
+
);
|
247 |
+
setSelectIndex(nextIndex);
|
248 |
+
selectedRef.current?.scrollIntoView({
|
249 |
+
block: "center",
|
250 |
+
});
|
251 |
+
};
|
252 |
+
|
253 |
+
if (e.key === "ArrowUp") {
|
254 |
+
changeIndex(1);
|
255 |
+
} else if (e.key === "ArrowDown") {
|
256 |
+
changeIndex(-1);
|
257 |
+
} else if (e.key === "Enter") {
|
258 |
+
const selectedPrompt = props.prompts.at(selectIndex);
|
259 |
+
if (selectedPrompt) {
|
260 |
+
props.onPromptSelect(selectedPrompt);
|
261 |
+
}
|
262 |
+
}
|
263 |
+
};
|
264 |
+
|
265 |
+
window.addEventListener("keydown", onKeyDown);
|
266 |
+
|
267 |
+
return () => window.removeEventListener("keydown", onKeyDown);
|
268 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
269 |
+
}, [noPrompts, selectIndex]);
|
270 |
+
|
271 |
+
if (noPrompts) return null;
|
272 |
+
return (
|
273 |
+
<div className={styles["prompt-hints"]}>
|
274 |
+
{props.prompts.map((prompt, i) => (
|
275 |
+
<div
|
276 |
+
ref={i === selectIndex ? selectedRef : null}
|
277 |
+
className={
|
278 |
+
styles["prompt-hint"] +
|
279 |
+
` ${i === selectIndex ? styles["prompt-hint-selected"] : ""}`
|
280 |
+
}
|
281 |
+
key={prompt.title + i.toString()}
|
282 |
+
onClick={() => props.onPromptSelect(prompt)}
|
283 |
+
onMouseEnter={() => setSelectIndex(i)}
|
284 |
+
>
|
285 |
+
<div className={styles["hint-title"]}>{prompt.title}</div>
|
286 |
+
<div className={styles["hint-content"]}>{prompt.content}</div>
|
287 |
+
</div>
|
288 |
+
))}
|
289 |
+
</div>
|
290 |
+
);
|
291 |
+
}
|
292 |
+
|
293 |
+
function useScrollToBottom() {
|
294 |
+
// for auto-scroll
|
295 |
+
const scrollRef = useRef<HTMLDivElement>(null);
|
296 |
+
const [autoScroll, setAutoScroll] = useState(true);
|
297 |
+
const scrollToBottom = () => {
|
298 |
+
const dom = scrollRef.current;
|
299 |
+
if (dom) {
|
300 |
+
setTimeout(() => (dom.scrollTop = dom.scrollHeight), 1);
|
301 |
+
}
|
302 |
+
};
|
303 |
+
|
304 |
+
// auto scroll
|
305 |
+
useLayoutEffect(() => {
|
306 |
+
autoScroll && scrollToBottom();
|
307 |
+
});
|
308 |
+
|
309 |
+
return {
|
310 |
+
scrollRef,
|
311 |
+
autoScroll,
|
312 |
+
setAutoScroll,
|
313 |
+
scrollToBottom,
|
314 |
+
};
|
315 |
+
}
|
316 |
+
|
317 |
+
export function ChatActions(props: {
|
318 |
+
showPromptModal: () => void;
|
319 |
+
scrollToBottom: () => void;
|
320 |
+
showPromptHints: () => void;
|
321 |
+
hitBottom: boolean;
|
322 |
+
}) {
|
323 |
+
const config = useAppConfig();
|
324 |
+
const navigate = useNavigate();
|
325 |
+
|
326 |
+
// switch themes
|
327 |
+
const theme = config.theme;
|
328 |
+
function nextTheme() {
|
329 |
+
const themes = [Theme.Auto, Theme.Light, Theme.Dark];
|
330 |
+
const themeIndex = themes.indexOf(theme);
|
331 |
+
const nextIndex = (themeIndex + 1) % themes.length;
|
332 |
+
const nextTheme = themes[nextIndex];
|
333 |
+
config.update((config) => (config.theme = nextTheme));
|
334 |
+
}
|
335 |
+
|
336 |
+
// stop all responses
|
337 |
+
const couldStop = ControllerPool.hasPending();
|
338 |
+
const stopAll = () => ControllerPool.stopAll();
|
339 |
+
|
340 |
+
return (
|
341 |
+
<div className={chatStyle["chat-input-actions"]}>
|
342 |
+
{couldStop && (
|
343 |
+
<div
|
344 |
+
className={`${chatStyle["chat-input-action"]} clickable`}
|
345 |
+
onClick={stopAll}
|
346 |
+
>
|
347 |
+
<StopIcon />
|
348 |
+
</div>
|
349 |
+
)}
|
350 |
+
{!props.hitBottom && (
|
351 |
+
<div
|
352 |
+
className={`${chatStyle["chat-input-action"]} clickable`}
|
353 |
+
onClick={props.scrollToBottom}
|
354 |
+
>
|
355 |
+
<BottomIcon />
|
356 |
+
</div>
|
357 |
+
)}
|
358 |
+
{props.hitBottom && (
|
359 |
+
<div
|
360 |
+
className={`${chatStyle["chat-input-action"]} clickable`}
|
361 |
+
onClick={props.showPromptModal}
|
362 |
+
>
|
363 |
+
<BrainIcon />
|
364 |
+
</div>
|
365 |
+
)}
|
366 |
+
|
367 |
+
<div
|
368 |
+
className={`${chatStyle["chat-input-action"]} clickable`}
|
369 |
+
onClick={nextTheme}
|
370 |
+
>
|
371 |
+
{theme === Theme.Auto ? (
|
372 |
+
<AutoIcon />
|
373 |
+
) : theme === Theme.Light ? (
|
374 |
+
<LightIcon />
|
375 |
+
) : theme === Theme.Dark ? (
|
376 |
+
<DarkIcon />
|
377 |
+
) : null}
|
378 |
+
</div>
|
379 |
+
|
380 |
+
<div
|
381 |
+
className={`${chatStyle["chat-input-action"]} clickable`}
|
382 |
+
onClick={props.showPromptHints}
|
383 |
+
>
|
384 |
+
<PromptIcon />
|
385 |
+
</div>
|
386 |
+
|
387 |
+
<div
|
388 |
+
className={`${chatStyle["chat-input-action"]} clickable`}
|
389 |
+
onClick={() => {
|
390 |
+
navigate(Path.Masks);
|
391 |
+
}}
|
392 |
+
>
|
393 |
+
<MaskIcon />
|
394 |
+
</div>
|
395 |
+
</div>
|
396 |
+
);
|
397 |
+
}
|
398 |
+
|
399 |
+
export function Chat() {
|
400 |
+
type RenderMessage = Message & { preview?: boolean };
|
401 |
+
|
402 |
+
const chatStore = useChatStore();
|
403 |
+
const [session, sessionIndex] = useChatStore((state) => [
|
404 |
+
state.currentSession(),
|
405 |
+
state.currentSessionIndex,
|
406 |
+
]);
|
407 |
+
const config = useAppConfig();
|
408 |
+
const fontSize = config.fontSize;
|
409 |
+
|
410 |
+
const inputRef = useRef<HTMLTextAreaElement>(null);
|
411 |
+
const [userInput, setUserInput] = useState("");
|
412 |
+
const [beforeInput, setBeforeInput] = useState("");
|
413 |
+
const [isLoading, setIsLoading] = useState(false);
|
414 |
+
const { submitKey, shouldSubmit } = useSubmitHandler();
|
415 |
+
const { scrollRef, setAutoScroll, scrollToBottom } = useScrollToBottom();
|
416 |
+
const [hitBottom, setHitBottom] = useState(true);
|
417 |
+
const isMobileScreen = useMobileScreen();
|
418 |
+
const navigate = useNavigate();
|
419 |
+
|
420 |
+
const onChatBodyScroll = (e: HTMLElement) => {
|
421 |
+
const isTouchBottom = e.scrollTop + e.clientHeight >= e.scrollHeight - 100;
|
422 |
+
setHitBottom(isTouchBottom);
|
423 |
+
};
|
424 |
+
|
425 |
+
// prompt hints
|
426 |
+
const promptStore = usePromptStore();
|
427 |
+
const [promptHints, setPromptHints] = useState<Prompt[]>([]);
|
428 |
+
const onSearch = useDebouncedCallback(
|
429 |
+
(text: string) => {
|
430 |
+
setPromptHints(promptStore.search(text));
|
431 |
+
},
|
432 |
+
100,
|
433 |
+
{ leading: true, trailing: true },
|
434 |
+
);
|
435 |
+
|
436 |
+
const onPromptSelect = (prompt: Prompt) => {
|
437 |
+
setPromptHints([]);
|
438 |
+
inputRef.current?.focus();
|
439 |
+
setTimeout(() => setUserInput(prompt.content), 60);
|
440 |
+
};
|
441 |
+
|
442 |
+
// auto grow input
|
443 |
+
const [inputRows, setInputRows] = useState(2);
|
444 |
+
const measure = useDebouncedCallback(
|
445 |
+
() => {
|
446 |
+
const rows = inputRef.current ? autoGrowTextArea(inputRef.current) : 1;
|
447 |
+
const inputRows = Math.min(
|
448 |
+
20,
|
449 |
+
Math.max(2 + Number(!isMobileScreen), rows),
|
450 |
+
);
|
451 |
+
setInputRows(inputRows);
|
452 |
+
},
|
453 |
+
100,
|
454 |
+
{
|
455 |
+
leading: true,
|
456 |
+
trailing: true,
|
457 |
+
},
|
458 |
+
);
|
459 |
+
|
460 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
461 |
+
useEffect(measure, [userInput]);
|
462 |
+
|
463 |
+
// only search prompts when user input is short
|
464 |
+
const SEARCH_TEXT_LIMIT = 30;
|
465 |
+
const onInput = (text: string) => {
|
466 |
+
setUserInput(text);
|
467 |
+
const n = text.trim().length;
|
468 |
+
|
469 |
+
// clear search results
|
470 |
+
if (n === 0) {
|
471 |
+
setPromptHints([]);
|
472 |
+
} else if (!config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
|
473 |
+
// check if need to trigger auto completion
|
474 |
+
if (text.startsWith("/")) {
|
475 |
+
let searchText = text.slice(1);
|
476 |
+
onSearch(searchText);
|
477 |
+
}
|
478 |
+
}
|
479 |
+
};
|
480 |
+
|
481 |
+
// submit user input
|
482 |
+
const onUserSubmit = () => {
|
483 |
+
if (userInput.trim() === "") return;
|
484 |
+
setIsLoading(true);
|
485 |
+
chatStore.onUserInput(userInput).then(() => setIsLoading(false));
|
486 |
+
setBeforeInput(userInput);
|
487 |
+
setUserInput("");
|
488 |
+
setPromptHints([]);
|
489 |
+
if (!isMobileScreen) inputRef.current?.focus();
|
490 |
+
setAutoScroll(true);
|
491 |
+
};
|
492 |
+
|
493 |
+
// stop response
|
494 |
+
const onUserStop = (messageId: number) => {
|
495 |
+
ControllerPool.stop(sessionIndex, messageId);
|
496 |
+
};
|
497 |
+
|
498 |
+
// check if should send message
|
499 |
+
const onInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
500 |
+
// if ArrowUp and no userInput
|
501 |
+
if (e.key === "ArrowUp" && userInput.length <= 0) {
|
502 |
+
setUserInput(beforeInput);
|
503 |
+
e.preventDefault();
|
504 |
+
return;
|
505 |
+
}
|
506 |
+
if (shouldSubmit(e)) {
|
507 |
+
onUserSubmit();
|
508 |
+
e.preventDefault();
|
509 |
+
}
|
510 |
+
};
|
511 |
+
const onRightClick = (e: any, message: Message) => {
|
512 |
+
// auto fill user input
|
513 |
+
if (message.role === "user") {
|
514 |
+
setUserInput(message.content);
|
515 |
+
}
|
516 |
+
|
517 |
+
// copy to clipboard
|
518 |
+
if (selectOrCopy(e.currentTarget, message.content)) {
|
519 |
+
e.preventDefault();
|
520 |
+
}
|
521 |
+
};
|
522 |
+
|
523 |
+
const findLastUserIndex = (messageId: number) => {
|
524 |
+
// find last user input message and resend
|
525 |
+
let lastUserMessageIndex: number | null = null;
|
526 |
+
for (let i = 0; i < session.messages.length; i += 1) {
|
527 |
+
const message = session.messages[i];
|
528 |
+
if (message.id === messageId) {
|
529 |
+
break;
|
530 |
+
}
|
531 |
+
if (message.role === "user") {
|
532 |
+
lastUserMessageIndex = i;
|
533 |
+
}
|
534 |
+
}
|
535 |
+
|
536 |
+
return lastUserMessageIndex;
|
537 |
+
};
|
538 |
+
|
539 |
+
const deleteMessage = (userIndex: number) => {
|
540 |
+
chatStore.updateCurrentSession((session) =>
|
541 |
+
session.messages.splice(userIndex, 2),
|
542 |
+
);
|
543 |
+
};
|
544 |
+
|
545 |
+
const onDelete = (botMessageId: number) => {
|
546 |
+
const userIndex = findLastUserIndex(botMessageId);
|
547 |
+
if (userIndex === null) return;
|
548 |
+
deleteMessage(userIndex);
|
549 |
+
};
|
550 |
+
|
551 |
+
const onResend = (botMessageId: number) => {
|
552 |
+
// find last user input message and resend
|
553 |
+
const userIndex = findLastUserIndex(botMessageId);
|
554 |
+
if (userIndex === null) return;
|
555 |
+
|
556 |
+
setIsLoading(true);
|
557 |
+
const content = session.messages[userIndex].content;
|
558 |
+
deleteMessage(userIndex);
|
559 |
+
chatStore.onUserInput(content).then(() => setIsLoading(false));
|
560 |
+
inputRef.current?.focus();
|
561 |
+
};
|
562 |
+
|
563 |
+
const context: RenderMessage[] = session.mask.context.slice();
|
564 |
+
|
565 |
+
const accessStore = useAccessStore();
|
566 |
+
|
567 |
+
if (
|
568 |
+
context.length === 0 &&
|
569 |
+
session.messages.at(0)?.content !== BOT_HELLO.content
|
570 |
+
) {
|
571 |
+
const copiedHello = Object.assign({}, BOT_HELLO);
|
572 |
+
if (!accessStore.isAuthorized()) {
|
573 |
+
copiedHello.content = Locale.Error.Unauthorized;
|
574 |
+
}
|
575 |
+
context.push(copiedHello);
|
576 |
+
}
|
577 |
+
|
578 |
+
// preview messages
|
579 |
+
const messages = context
|
580 |
+
.concat(session.messages as RenderMessage[])
|
581 |
+
.concat(
|
582 |
+
isLoading
|
583 |
+
? [
|
584 |
+
{
|
585 |
+
...createMessage({
|
586 |
+
role: "assistant",
|
587 |
+
content: "……",
|
588 |
+
}),
|
589 |
+
preview: true,
|
590 |
+
},
|
591 |
+
]
|
592 |
+
: [],
|
593 |
+
)
|
594 |
+
.concat(
|
595 |
+
userInput.length > 0 && config.sendPreviewBubble
|
596 |
+
? [
|
597 |
+
{
|
598 |
+
...createMessage({
|
599 |
+
role: "user",
|
600 |
+
content: userInput,
|
601 |
+
}),
|
602 |
+
preview: true,
|
603 |
+
},
|
604 |
+
]
|
605 |
+
: [],
|
606 |
+
);
|
607 |
+
|
608 |
+
const [showPromptModal, setShowPromptModal] = useState(false);
|
609 |
+
|
610 |
+
const renameSession = () => {
|
611 |
+
const newTopic = prompt(Locale.Chat.Rename, session.topic);
|
612 |
+
if (newTopic && newTopic !== session.topic) {
|
613 |
+
chatStore.updateCurrentSession((session) => (session.topic = newTopic!));
|
614 |
+
}
|
615 |
+
};
|
616 |
+
|
617 |
+
const location = useLocation();
|
618 |
+
const isChat = location.pathname === Path.Chat;
|
619 |
+
const autoFocus = !isMobileScreen || isChat; // only focus in chat page
|
620 |
+
|
621 |
+
return (
|
622 |
+
<div className={styles.chat} key={session.id}>
|
623 |
+
<div className="window-header">
|
624 |
+
<div className="window-header-title">
|
625 |
+
<div
|
626 |
+
className={`window-header-main-title " ${styles["chat-body-title"]}`}
|
627 |
+
onClickCapture={renameSession}
|
628 |
+
>
|
629 |
+
{!session.topic ? DEFAULT_TOPIC : session.topic}
|
630 |
+
</div>
|
631 |
+
<div className="window-header-sub-title">
|
632 |
+
{Locale.Chat.SubTitle(session.messages.length)}
|
633 |
+
</div>
|
634 |
+
</div>
|
635 |
+
<div className="window-actions">
|
636 |
+
<div className={"window-action-button" + " " + styles.mobile}>
|
637 |
+
<IconButton
|
638 |
+
icon={<ReturnIcon />}
|
639 |
+
bordered
|
640 |
+
title={Locale.Chat.Actions.ChatList}
|
641 |
+
onClick={() => navigate(Path.Home)}
|
642 |
+
/>
|
643 |
+
</div>
|
644 |
+
<div className="window-action-button">
|
645 |
+
<IconButton
|
646 |
+
icon={<RenameIcon />}
|
647 |
+
bordered
|
648 |
+
onClick={renameSession}
|
649 |
+
/>
|
650 |
+
</div>
|
651 |
+
<div className="window-action-button">
|
652 |
+
<IconButton
|
653 |
+
icon={<ExportIcon />}
|
654 |
+
bordered
|
655 |
+
title={Locale.Chat.Actions.Export}
|
656 |
+
onClick={() => {
|
657 |
+
exportMessages(
|
658 |
+
session.messages.filter((msg) => !msg.isError),
|
659 |
+
session.topic,
|
660 |
+
);
|
661 |
+
}}
|
662 |
+
/>
|
663 |
+
</div>
|
664 |
+
{!isMobileScreen && (
|
665 |
+
<div className="window-action-button">
|
666 |
+
<IconButton
|
667 |
+
icon={config.tightBorder ? <MinIcon /> : <MaxIcon />}
|
668 |
+
bordered
|
669 |
+
onClick={() => {
|
670 |
+
config.update(
|
671 |
+
(config) => (config.tightBorder = !config.tightBorder),
|
672 |
+
);
|
673 |
+
}}
|
674 |
+
/>
|
675 |
+
</div>
|
676 |
+
)}
|
677 |
+
</div>
|
678 |
+
|
679 |
+
<PromptToast
|
680 |
+
showToast={!hitBottom}
|
681 |
+
showModal={showPromptModal}
|
682 |
+
setShowModal={setShowPromptModal}
|
683 |
+
/>
|
684 |
+
</div>
|
685 |
+
|
686 |
+
<div
|
687 |
+
className={styles["chat-body"]}
|
688 |
+
ref={scrollRef}
|
689 |
+
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
|
690 |
+
onMouseDown={() => inputRef.current?.blur()}
|
691 |
+
onWheel={(e) => setAutoScroll(hitBottom && e.deltaY > 0)}
|
692 |
+
onTouchStart={() => {
|
693 |
+
inputRef.current?.blur();
|
694 |
+
setAutoScroll(false);
|
695 |
+
}}
|
696 |
+
>
|
697 |
+
{messages.map((message, i) => {
|
698 |
+
const isUser = message.role === "user";
|
699 |
+
const showActions =
|
700 |
+
!isUser &&
|
701 |
+
i > 0 &&
|
702 |
+
!(message.preview || message.content.length === 0);
|
703 |
+
const showTyping = message.preview || message.streaming;
|
704 |
+
|
705 |
+
return (
|
706 |
+
<div
|
707 |
+
key={i}
|
708 |
+
className={
|
709 |
+
isUser ? styles["chat-message-user"] : styles["chat-message"]
|
710 |
+
}
|
711 |
+
>
|
712 |
+
<div className={styles["chat-message-container"]}>
|
713 |
+
<div className={styles["chat-message-avatar"]}>
|
714 |
+
{message.role === "user" ? (
|
715 |
+
<Avatar avatar={config.avatar} />
|
716 |
+
) : (
|
717 |
+
<MaskAvatar mask={session.mask} />
|
718 |
+
)}
|
719 |
+
</div>
|
720 |
+
{showTyping && (
|
721 |
+
<div className={styles["chat-message-status"]}>
|
722 |
+
{Locale.Chat.Typing}
|
723 |
+
</div>
|
724 |
+
)}
|
725 |
+
<div className={styles["chat-message-item"]}>
|
726 |
+
{showActions && (
|
727 |
+
<div className={styles["chat-message-top-actions"]}>
|
728 |
+
{message.streaming ? (
|
729 |
+
<div
|
730 |
+
className={styles["chat-message-top-action"]}
|
731 |
+
onClick={() => onUserStop(message.id ?? i)}
|
732 |
+
>
|
733 |
+
{Locale.Chat.Actions.Stop}
|
734 |
+
</div>
|
735 |
+
) : (
|
736 |
+
<>
|
737 |
+
<div
|
738 |
+
className={styles["chat-message-top-action"]}
|
739 |
+
onClick={() => onDelete(message.id ?? i)}
|
740 |
+
>
|
741 |
+
{Locale.Chat.Actions.Delete}
|
742 |
+
</div>
|
743 |
+
<div
|
744 |
+
className={styles["chat-message-top-action"]}
|
745 |
+
onClick={() => onResend(message.id ?? i)}
|
746 |
+
>
|
747 |
+
{Locale.Chat.Actions.Retry}
|
748 |
+
</div>
|
749 |
+
</>
|
750 |
+
)}
|
751 |
+
|
752 |
+
<div
|
753 |
+
className={styles["chat-message-top-action"]}
|
754 |
+
onClick={() => copyToClipboard(message.content)}
|
755 |
+
>
|
756 |
+
{Locale.Chat.Actions.Copy}
|
757 |
+
</div>
|
758 |
+
</div>
|
759 |
+
)}
|
760 |
+
<Markdown
|
761 |
+
content={message.content}
|
762 |
+
loading={
|
763 |
+
(message.preview || message.content.length === 0) &&
|
764 |
+
!isUser
|
765 |
+
}
|
766 |
+
onContextMenu={(e) => onRightClick(e, message)}
|
767 |
+
onDoubleClickCapture={() => {
|
768 |
+
if (!isMobileScreen) return;
|
769 |
+
setUserInput(message.content);
|
770 |
+
}}
|
771 |
+
fontSize={fontSize}
|
772 |
+
parentRef={scrollRef}
|
773 |
+
defaultShow={i >= messages.length - 10}
|
774 |
+
/>
|
775 |
+
</div>
|
776 |
+
{!isUser && !message.preview && (
|
777 |
+
<div className={styles["chat-message-actions"]}>
|
778 |
+
<div className={styles["chat-message-action-date"]}>
|
779 |
+
{message.date.toLocaleString()}
|
780 |
+
</div>
|
781 |
+
</div>
|
782 |
+
)}
|
783 |
+
</div>
|
784 |
+
</div>
|
785 |
+
);
|
786 |
+
})}
|
787 |
+
</div>
|
788 |
+
|
789 |
+
<div className={styles["chat-input-panel"]}>
|
790 |
+
<PromptHints prompts={promptHints} onPromptSelect={onPromptSelect} />
|
791 |
+
|
792 |
+
<ChatActions
|
793 |
+
showPromptModal={() => setShowPromptModal(true)}
|
794 |
+
scrollToBottom={scrollToBottom}
|
795 |
+
hitBottom={hitBottom}
|
796 |
+
showPromptHints={() => {
|
797 |
+
inputRef.current?.focus();
|
798 |
+
onSearch("");
|
799 |
+
}}
|
800 |
+
/>
|
801 |
+
<div className={styles["chat-input-panel-inner"]}>
|
802 |
+
<textarea
|
803 |
+
ref={inputRef}
|
804 |
+
className={styles["chat-input"]}
|
805 |
+
placeholder={Locale.Chat.Input(submitKey)}
|
806 |
+
onInput={(e) => onInput(e.currentTarget.value)}
|
807 |
+
value={userInput}
|
808 |
+
onKeyDown={onInputKeyDown}
|
809 |
+
onFocus={() => setAutoScroll(true)}
|
810 |
+
onBlur={() => setAutoScroll(false)}
|
811 |
+
rows={inputRows}
|
812 |
+
autoFocus={autoFocus}
|
813 |
+
/>
|
814 |
+
<IconButton
|
815 |
+
icon={<SendWhiteIcon />}
|
816 |
+
text={Locale.Chat.Send}
|
817 |
+
className={styles["chat-input-send"]}
|
818 |
+
type="primary"
|
819 |
+
onClick={onUserSubmit}
|
820 |
+
/>
|
821 |
+
</div>
|
822 |
+
</div>
|
823 |
+
</div>
|
824 |
+
);
|
825 |
+
}
|
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,420 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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, readFromFile } 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 |
+
const importFromFile = () => {
|
226 |
+
readFromFile().then((content) => {
|
227 |
+
try {
|
228 |
+
const importMasks = JSON.parse(content);
|
229 |
+
if (Array.isArray(importMasks)) {
|
230 |
+
for (const mask of importMasks) {
|
231 |
+
if (mask.name) {
|
232 |
+
maskStore.create(mask);
|
233 |
+
}
|
234 |
+
}
|
235 |
+
}
|
236 |
+
} catch {}
|
237 |
+
});
|
238 |
+
};
|
239 |
+
|
240 |
+
return (
|
241 |
+
<ErrorBoundary>
|
242 |
+
<div className={styles["mask-page"]}>
|
243 |
+
<div className="window-header">
|
244 |
+
<div className="window-header-title">
|
245 |
+
<div className="window-header-main-title">
|
246 |
+
{Locale.Mask.Page.Title}
|
247 |
+
</div>
|
248 |
+
<div className="window-header-submai-title">
|
249 |
+
{Locale.Mask.Page.SubTitle(allMasks.length)}
|
250 |
+
</div>
|
251 |
+
</div>
|
252 |
+
|
253 |
+
<div className="window-actions">
|
254 |
+
<div className="window-action-button">
|
255 |
+
<IconButton
|
256 |
+
icon={<DownloadIcon />}
|
257 |
+
bordered
|
258 |
+
onClick={downloadAll}
|
259 |
+
/>
|
260 |
+
</div>
|
261 |
+
<div className="window-action-button">
|
262 |
+
<IconButton
|
263 |
+
icon={<UploadIcon />}
|
264 |
+
bordered
|
265 |
+
onClick={() => importFromFile()}
|
266 |
+
/>
|
267 |
+
</div>
|
268 |
+
<div className="window-action-button">
|
269 |
+
<IconButton
|
270 |
+
icon={<CloseIcon />}
|
271 |
+
bordered
|
272 |
+
onClick={() => navigate(-1)}
|
273 |
+
/>
|
274 |
+
</div>
|
275 |
+
</div>
|
276 |
+
</div>
|
277 |
+
|
278 |
+
<div className={styles["mask-page-body"]}>
|
279 |
+
<div className={styles["mask-filter"]}>
|
280 |
+
<input
|
281 |
+
type="text"
|
282 |
+
className={styles["search-bar"]}
|
283 |
+
placeholder={Locale.Mask.Page.Search}
|
284 |
+
autoFocus
|
285 |
+
onInput={(e) => onSearch(e.currentTarget.value)}
|
286 |
+
/>
|
287 |
+
<select
|
288 |
+
className={styles["mask-filter-lang"]}
|
289 |
+
value={filterLang ?? Locale.Settings.Lang.All}
|
290 |
+
onChange={(e) => {
|
291 |
+
const value = e.currentTarget.value;
|
292 |
+
if (value === Locale.Settings.Lang.All) {
|
293 |
+
setFilterLang(undefined);
|
294 |
+
} else {
|
295 |
+
setFilterLang(value as Lang);
|
296 |
+
}
|
297 |
+
}}
|
298 |
+
>
|
299 |
+
<option key="all" value={Locale.Settings.Lang.All}>
|
300 |
+
{Locale.Settings.Lang.All}
|
301 |
+
</option>
|
302 |
+
{AllLangs.map((lang) => (
|
303 |
+
<option value={lang} key={lang}>
|
304 |
+
{Locale.Settings.Lang.Options[lang]}
|
305 |
+
</option>
|
306 |
+
))}
|
307 |
+
</select>
|
308 |
+
|
309 |
+
<IconButton
|
310 |
+
className={styles["mask-create"]}
|
311 |
+
icon={<AddIcon />}
|
312 |
+
text={Locale.Mask.Page.Create}
|
313 |
+
bordered
|
314 |
+
onClick={() => {
|
315 |
+
const createdMask = maskStore.create();
|
316 |
+
setEditingMaskId(createdMask.id);
|
317 |
+
}}
|
318 |
+
/>
|
319 |
+
</div>
|
320 |
+
|
321 |
+
<div>
|
322 |
+
{masks.map((m) => (
|
323 |
+
<div className={styles["mask-item"]} key={m.id}>
|
324 |
+
<div className={styles["mask-header"]}>
|
325 |
+
<div className={styles["mask-icon"]}>
|
326 |
+
<MaskAvatar mask={m} />
|
327 |
+
</div>
|
328 |
+
<div className={styles["mask-title"]}>
|
329 |
+
<div className={styles["mask-name"]}>{m.name}</div>
|
330 |
+
<div className={styles["mask-info"] + " one-line"}>
|
331 |
+
{`${Locale.Mask.Item.Info(m.context.length)} / ${
|
332 |
+
Locale.Settings.Lang.Options[m.lang]
|
333 |
+
} / ${m.modelConfig.model}`}
|
334 |
+
</div>
|
335 |
+
</div>
|
336 |
+
</div>
|
337 |
+
<div className={styles["mask-actions"]}>
|
338 |
+
<IconButton
|
339 |
+
icon={<AddIcon />}
|
340 |
+
text={Locale.Mask.Item.Chat}
|
341 |
+
onClick={() => {
|
342 |
+
chatStore.newSession(m);
|
343 |
+
navigate(Path.Chat);
|
344 |
+
}}
|
345 |
+
/>
|
346 |
+
{m.builtin ? (
|
347 |
+
<IconButton
|
348 |
+
icon={<EyeIcon />}
|
349 |
+
text={Locale.Mask.Item.View}
|
350 |
+
onClick={() => setEditingMaskId(m.id)}
|
351 |
+
/>
|
352 |
+
) : (
|
353 |
+
<IconButton
|
354 |
+
icon={<EditIcon />}
|
355 |
+
text={Locale.Mask.Item.Edit}
|
356 |
+
onClick={() => setEditingMaskId(m.id)}
|
357 |
+
/>
|
358 |
+
)}
|
359 |
+
{!m.builtin && (
|
360 |
+
<IconButton
|
361 |
+
icon={<DeleteIcon />}
|
362 |
+
text={Locale.Mask.Item.Delete}
|
363 |
+
onClick={() => {
|
364 |
+
if (confirm(Locale.Mask.Item.DeleteConfirm)) {
|
365 |
+
maskStore.delete(m.id);
|
366 |
+
}
|
367 |
+
}}
|
368 |
+
/>
|
369 |
+
)}
|
370 |
+
</div>
|
371 |
+
</div>
|
372 |
+
))}
|
373 |
+
</div>
|
374 |
+
</div>
|
375 |
+
</div>
|
376 |
+
|
377 |
+
{editingMask && (
|
378 |
+
<div className="modal-mask">
|
379 |
+
<Modal
|
380 |
+
title={Locale.Mask.EditModal.Title(editingMask?.builtin)}
|
381 |
+
onClose={closeMaskModal}
|
382 |
+
actions={[
|
383 |
+
<IconButton
|
384 |
+
icon={<DownloadIcon />}
|
385 |
+
text={Locale.Mask.EditModal.Download}
|
386 |
+
key="export"
|
387 |
+
bordered
|
388 |
+
onClick={() =>
|
389 |
+
downloadAs(
|
390 |
+
JSON.stringify(editingMask),
|
391 |
+
`${editingMask.name}.json`,
|
392 |
+
)
|
393 |
+
}
|
394 |
+
/>,
|
395 |
+
<IconButton
|
396 |
+
key="copy"
|
397 |
+
icon={<CopyIcon />}
|
398 |
+
bordered
|
399 |
+
text={Locale.Mask.EditModal.Clone}
|
400 |
+
onClick={() => {
|
401 |
+
navigate(Path.Masks);
|
402 |
+
maskStore.create(editingMask);
|
403 |
+
setEditingMaskId(undefined);
|
404 |
+
}}
|
405 |
+
/>,
|
406 |
+
]}
|
407 |
+
>
|
408 |
+
<MaskConfig
|
409 |
+
mask={editingMask}
|
410 |
+
updateMask={(updater) =>
|
411 |
+
maskStore.update(editingMaskId!, updater)
|
412 |
+
}
|
413 |
+
readonly={editingMask.builtin}
|
414 |
+
/>
|
415 |
+
</Modal>
|
416 |
+
</div>
|
417 |
+
)}
|
418 |
+
</ErrorBoundary>
|
419 |
+
);
|
420 |
+
}
|
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,606 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
+
ALL_BOT,
|
24 |
+
ModalConfigValidator,
|
25 |
+
} from "../store";
|
26 |
+
|
27 |
+
import Locale, { AllLangs, changeLang, getLang } from "../locales";
|
28 |
+
import { copyToClipboard } from "../utils";
|
29 |
+
import Link from "next/link";
|
30 |
+
import { Path, UPDATE_URL } from "../constant";
|
31 |
+
import { Prompt, SearchService, usePromptStore } from "../store/prompt";
|
32 |
+
import { ErrorBoundary } from "./error";
|
33 |
+
import { InputRange } from "./input-range";
|
34 |
+
import { useNavigate } from "react-router-dom";
|
35 |
+
import { Avatar, AvatarPicker } from "./emoji";
|
36 |
+
|
37 |
+
function EditPromptModal(props: { id: number; onClose: () => void }) {
|
38 |
+
const promptStore = usePromptStore();
|
39 |
+
const prompt = promptStore.get(props.id);
|
40 |
+
|
41 |
+
return prompt ? (
|
42 |
+
<div className="modal-mask">
|
43 |
+
<Modal
|
44 |
+
title={Locale.Settings.Prompt.EditModal.Title}
|
45 |
+
onClose={props.onClose}
|
46 |
+
actions={[
|
47 |
+
<IconButton
|
48 |
+
key=""
|
49 |
+
onClick={props.onClose}
|
50 |
+
text={Locale.UI.Confirm}
|
51 |
+
bordered
|
52 |
+
/>,
|
53 |
+
]}
|
54 |
+
>
|
55 |
+
<div className={styles["edit-prompt-modal"]}>
|
56 |
+
<input
|
57 |
+
type="text"
|
58 |
+
value={prompt.title}
|
59 |
+
readOnly={!prompt.isUser}
|
60 |
+
className={styles["edit-prompt-title"]}
|
61 |
+
onInput={(e) =>
|
62 |
+
promptStore.update(
|
63 |
+
props.id,
|
64 |
+
(prompt) => (prompt.title = e.currentTarget.value),
|
65 |
+
)
|
66 |
+
}
|
67 |
+
></input>
|
68 |
+
<Input
|
69 |
+
value={prompt.content}
|
70 |
+
readOnly={!prompt.isUser}
|
71 |
+
className={styles["edit-prompt-content"]}
|
72 |
+
rows={10}
|
73 |
+
onInput={(e) =>
|
74 |
+
promptStore.update(
|
75 |
+
props.id,
|
76 |
+
(prompt) => (prompt.content = e.currentTarget.value),
|
77 |
+
)
|
78 |
+
}
|
79 |
+
></Input>
|
80 |
+
</div>
|
81 |
+
</Modal>
|
82 |
+
</div>
|
83 |
+
) : null;
|
84 |
+
}
|
85 |
+
|
86 |
+
function UserPromptModal(props: { onClose?: () => void }) {
|
87 |
+
const promptStore = usePromptStore();
|
88 |
+
const userPrompts = promptStore.getUserPrompts();
|
89 |
+
const builtinPrompts = SearchService.builtinPrompts;
|
90 |
+
const allPrompts = userPrompts.concat(builtinPrompts);
|
91 |
+
const [searchInput, setSearchInput] = useState("");
|
92 |
+
const [searchPrompts, setSearchPrompts] = useState<Prompt[]>([]);
|
93 |
+
const prompts = searchInput.length > 0 ? searchPrompts : allPrompts;
|
94 |
+
|
95 |
+
const [editingPromptId, setEditingPromptId] = useState<number>();
|
96 |
+
|
97 |
+
useEffect(() => {
|
98 |
+
if (searchInput.length > 0) {
|
99 |
+
const searchResult = SearchService.search(searchInput);
|
100 |
+
setSearchPrompts(searchResult);
|
101 |
+
} else {
|
102 |
+
setSearchPrompts([]);
|
103 |
+
}
|
104 |
+
}, [searchInput]);
|
105 |
+
|
106 |
+
return (
|
107 |
+
<div className="modal-mask">
|
108 |
+
<Modal
|
109 |
+
title={Locale.Settings.Prompt.Modal.Title}
|
110 |
+
onClose={() => props.onClose?.()}
|
111 |
+
actions={[
|
112 |
+
<IconButton
|
113 |
+
key="add"
|
114 |
+
onClick={() =>
|
115 |
+
promptStore.add({
|
116 |
+
title: "Empty Prompt",
|
117 |
+
content: "Empty Prompt Content",
|
118 |
+
})
|
119 |
+
}
|
120 |
+
icon={<AddIcon />}
|
121 |
+
bordered
|
122 |
+
text={Locale.Settings.Prompt.Modal.Add}
|
123 |
+
/>,
|
124 |
+
]}
|
125 |
+
>
|
126 |
+
<div className={styles["user-prompt-modal"]}>
|
127 |
+
<input
|
128 |
+
type="text"
|
129 |
+
className={styles["user-prompt-search"]}
|
130 |
+
placeholder={Locale.Settings.Prompt.Modal.Search}
|
131 |
+
value={searchInput}
|
132 |
+
onInput={(e) => setSearchInput(e.currentTarget.value)}
|
133 |
+
></input>
|
134 |
+
|
135 |
+
<div className={styles["user-prompt-list"]}>
|
136 |
+
{prompts.map((v, _) => (
|
137 |
+
<div className={styles["user-prompt-item"]} key={v.id ?? v.title}>
|
138 |
+
<div className={styles["user-prompt-header"]}>
|
139 |
+
<div className={styles["user-prompt-title"]}>{v.title}</div>
|
140 |
+
<div className={styles["user-prompt-content"] + " one-line"}>
|
141 |
+
{v.content}
|
142 |
+
</div>
|
143 |
+
</div>
|
144 |
+
|
145 |
+
<div className={styles["user-prompt-buttons"]}>
|
146 |
+
{v.isUser && (
|
147 |
+
<IconButton
|
148 |
+
icon={<ClearIcon />}
|
149 |
+
className={styles["user-prompt-button"]}
|
150 |
+
onClick={() => promptStore.remove(v.id!)}
|
151 |
+
/>
|
152 |
+
)}
|
153 |
+
{v.isUser ? (
|
154 |
+
<IconButton
|
155 |
+
icon={<EditIcon />}
|
156 |
+
className={styles["user-prompt-button"]}
|
157 |
+
onClick={() => setEditingPromptId(v.id)}
|
158 |
+
/>
|
159 |
+
) : (
|
160 |
+
<IconButton
|
161 |
+
icon={<EyeIcon />}
|
162 |
+
className={styles["user-prompt-button"]}
|
163 |
+
onClick={() => setEditingPromptId(v.id)}
|
164 |
+
/>
|
165 |
+
)}
|
166 |
+
<IconButton
|
167 |
+
icon={<CopyIcon />}
|
168 |
+
className={styles["user-prompt-button"]}
|
169 |
+
onClick={() => copyToClipboard(v.content)}
|
170 |
+
/>
|
171 |
+
</div>
|
172 |
+
</div>
|
173 |
+
))}
|
174 |
+
</div>
|
175 |
+
</div>
|
176 |
+
</Modal>
|
177 |
+
|
178 |
+
{editingPromptId !== undefined && (
|
179 |
+
<EditPromptModal
|
180 |
+
id={editingPromptId!}
|
181 |
+
onClose={() => setEditingPromptId(undefined)}
|
182 |
+
/>
|
183 |
+
)}
|
184 |
+
</div>
|
185 |
+
);
|
186 |
+
}
|
187 |
+
|
188 |
+
function formatVersionDate(t: string) {
|
189 |
+
const d = new Date(+t);
|
190 |
+
const year = d.getUTCFullYear();
|
191 |
+
const month = d.getUTCMonth() + 1;
|
192 |
+
const day = d.getUTCDate();
|
193 |
+
|
194 |
+
return [
|
195 |
+
year.toString(),
|
196 |
+
month.toString().padStart(2, "0"),
|
197 |
+
day.toString().padStart(2, "0"),
|
198 |
+
].join("");
|
199 |
+
}
|
200 |
+
|
201 |
+
export function Settings() {
|
202 |
+
const navigate = useNavigate();
|
203 |
+
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
204 |
+
const config = useAppConfig();
|
205 |
+
const updateConfig = config.update;
|
206 |
+
const resetConfig = config.reset;
|
207 |
+
const chatStore = useChatStore();
|
208 |
+
|
209 |
+
const updateStore = useUpdateStore();
|
210 |
+
const [checkingUpdate, setCheckingUpdate] = useState(false);
|
211 |
+
const currentVersion = formatVersionDate(updateStore.version);
|
212 |
+
const remoteId = formatVersionDate(updateStore.remoteVersion);
|
213 |
+
const hasNewVersion = currentVersion !== remoteId;
|
214 |
+
|
215 |
+
function checkUpdate(force = false) {
|
216 |
+
setCheckingUpdate(true);
|
217 |
+
updateStore.getLatestVersion(force).then(() => {
|
218 |
+
setCheckingUpdate(false);
|
219 |
+
});
|
220 |
+
|
221 |
+
console.log(
|
222 |
+
"[Update] local version ",
|
223 |
+
new Date(+updateStore.version).toLocaleString(),
|
224 |
+
);
|
225 |
+
console.log(
|
226 |
+
"[Update] remote version ",
|
227 |
+
new Date(+updateStore.remoteVersion).toLocaleString(),
|
228 |
+
);
|
229 |
+
}
|
230 |
+
|
231 |
+
const usage = {
|
232 |
+
used: updateStore.used,
|
233 |
+
subscription: updateStore.subscription,
|
234 |
+
};
|
235 |
+
const [loadingUsage, setLoadingUsage] = useState(false);
|
236 |
+
function checkUsage(force = false) {
|
237 |
+
setLoadingUsage(true);
|
238 |
+
updateStore.updateUsage(force).finally(() => {
|
239 |
+
setLoadingUsage(false);
|
240 |
+
});
|
241 |
+
}
|
242 |
+
|
243 |
+
const accessStore = useAccessStore();
|
244 |
+
const enabledAccessControl = useMemo(
|
245 |
+
() => accessStore.enabledAccessControl(),
|
246 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
247 |
+
[],
|
248 |
+
);
|
249 |
+
|
250 |
+
const promptStore = usePromptStore();
|
251 |
+
const builtinCount = SearchService.count.builtin;
|
252 |
+
const customCount = promptStore.getUserPrompts().length ?? 0;
|
253 |
+
const [shouldShowPromptModal, setShowPromptModal] = useState(false);
|
254 |
+
|
255 |
+
const showUsage = accessStore.isAuthorized();
|
256 |
+
useEffect(() => {
|
257 |
+
// checks per minutes
|
258 |
+
checkUpdate();
|
259 |
+
showUsage && checkUsage();
|
260 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
261 |
+
}, []);
|
262 |
+
|
263 |
+
useEffect(() => {
|
264 |
+
const keydownEvent = (e: KeyboardEvent) => {
|
265 |
+
if (e.key === "Escape") {
|
266 |
+
navigate(Path.Home);
|
267 |
+
}
|
268 |
+
};
|
269 |
+
document.addEventListener("keydown", keydownEvent);
|
270 |
+
return () => {
|
271 |
+
document.removeEventListener("keydown", keydownEvent);
|
272 |
+
};
|
273 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
274 |
+
}, []);
|
275 |
+
|
276 |
+
return (
|
277 |
+
<ErrorBoundary>
|
278 |
+
<div className="window-header">
|
279 |
+
<div className="window-header-title">
|
280 |
+
<div className="window-header-main-title">
|
281 |
+
{Locale.Settings.Title}
|
282 |
+
</div>
|
283 |
+
<div className="window-header-sub-title">
|
284 |
+
{Locale.Settings.SubTitle}
|
285 |
+
</div>
|
286 |
+
</div>
|
287 |
+
<div className="window-actions">
|
288 |
+
<div className="window-action-button">
|
289 |
+
<IconButton
|
290 |
+
icon={<ClearIcon />}
|
291 |
+
onClick={() => {
|
292 |
+
if (confirm(Locale.Settings.Actions.ConfirmClearAll)) {
|
293 |
+
chatStore.clearAllData();
|
294 |
+
}
|
295 |
+
}}
|
296 |
+
bordered
|
297 |
+
title={Locale.Settings.Actions.ClearAll}
|
298 |
+
/>
|
299 |
+
</div>
|
300 |
+
<div className="window-action-button">
|
301 |
+
<IconButton
|
302 |
+
icon={<ResetIcon />}
|
303 |
+
onClick={() => {
|
304 |
+
if (confirm(Locale.Settings.Actions.ConfirmResetAll)) {
|
305 |
+
resetConfig();
|
306 |
+
}
|
307 |
+
}}
|
308 |
+
bordered
|
309 |
+
title={Locale.Settings.Actions.ResetAll}
|
310 |
+
/>
|
311 |
+
</div>
|
312 |
+
<div className="window-action-button">
|
313 |
+
<IconButton
|
314 |
+
icon={<CloseIcon />}
|
315 |
+
onClick={() => navigate(Path.Home)}
|
316 |
+
bordered
|
317 |
+
title={Locale.Settings.Actions.Close}
|
318 |
+
/>
|
319 |
+
</div>
|
320 |
+
</div>
|
321 |
+
</div>
|
322 |
+
<div className={styles["settings"]}>
|
323 |
+
<List>
|
324 |
+
<ListItem title={Locale.Settings.Avatar}>
|
325 |
+
<Popover
|
326 |
+
onClose={() => setShowEmojiPicker(false)}
|
327 |
+
content={
|
328 |
+
<AvatarPicker
|
329 |
+
onEmojiClick={(avatar: string) => {
|
330 |
+
updateConfig((config) => (config.avatar = avatar));
|
331 |
+
setShowEmojiPicker(false);
|
332 |
+
}}
|
333 |
+
/>
|
334 |
+
}
|
335 |
+
open={showEmojiPicker}
|
336 |
+
>
|
337 |
+
<div
|
338 |
+
className={styles.avatar}
|
339 |
+
onClick={() => setShowEmojiPicker(true)}
|
340 |
+
>
|
341 |
+
<Avatar avatar={config.avatar} />
|
342 |
+
</div>
|
343 |
+
</Popover>
|
344 |
+
</ListItem>
|
345 |
+
|
346 |
+
{/* <ListItem
|
347 |
+
title={Locale.Settings.Update.Version(currentVersion ?? "unknown")}
|
348 |
+
subTitle={
|
349 |
+
checkingUpdate
|
350 |
+
? Locale.Settings.Update.IsChecking
|
351 |
+
: hasNewVersion
|
352 |
+
? Locale.Settings.Update.FoundUpdate(remoteId ?? "ERROR")
|
353 |
+
: Locale.Settings.Update.IsLatest
|
354 |
+
}
|
355 |
+
>
|
356 |
+
{checkingUpdate ? (
|
357 |
+
<div />
|
358 |
+
) : hasNewVersion ? (
|
359 |
+
<Link href={UPDATE_URL} target="_blank" className="link">
|
360 |
+
{Locale.Settings.Update.GoToUpdate}
|
361 |
+
</Link>
|
362 |
+
) : (
|
363 |
+
<IconButton
|
364 |
+
icon={<ResetIcon></ResetIcon>}
|
365 |
+
text={Locale.Settings.Update.CheckUpdate}
|
366 |
+
onClick={() => checkUpdate(true)}
|
367 |
+
/>
|
368 |
+
)}
|
369 |
+
</ListItem> */}
|
370 |
+
|
371 |
+
<ListItem title={Locale.Settings.SendKey}>
|
372 |
+
<select
|
373 |
+
value={config.submitKey}
|
374 |
+
onChange={(e) => {
|
375 |
+
updateConfig(
|
376 |
+
(config) =>
|
377 |
+
(config.submitKey = e.target.value as any as SubmitKey),
|
378 |
+
);
|
379 |
+
}}
|
380 |
+
>
|
381 |
+
{Object.values(SubmitKey).map((v) => (
|
382 |
+
<option value={v} key={v}>
|
383 |
+
{v}
|
384 |
+
</option>
|
385 |
+
))}
|
386 |
+
</select>
|
387 |
+
</ListItem>
|
388 |
+
|
389 |
+
<ListItem title={Locale.Settings.Theme}>
|
390 |
+
<select
|
391 |
+
value={config.theme}
|
392 |
+
onChange={(e) => {
|
393 |
+
updateConfig(
|
394 |
+
(config) => (config.theme = e.target.value as any as Theme),
|
395 |
+
);
|
396 |
+
}}
|
397 |
+
>
|
398 |
+
{Object.values(Theme).map((v) => (
|
399 |
+
<option value={v} key={v}>
|
400 |
+
{v}
|
401 |
+
</option>
|
402 |
+
))}
|
403 |
+
</select>
|
404 |
+
</ListItem>
|
405 |
+
|
406 |
+
<ListItem title={Locale.Settings.Lang.Name}>
|
407 |
+
<select
|
408 |
+
value={getLang()}
|
409 |
+
onChange={(e) => {
|
410 |
+
changeLang(e.target.value as any);
|
411 |
+
}}
|
412 |
+
>
|
413 |
+
{AllLangs.map((lang) => (
|
414 |
+
<option value={lang} key={lang}>
|
415 |
+
{Locale.Settings.Lang.Options[lang]}
|
416 |
+
</option>
|
417 |
+
))}
|
418 |
+
</select>
|
419 |
+
</ListItem>
|
420 |
+
|
421 |
+
<ListItem
|
422 |
+
title={Locale.Settings.FontSize.Title}
|
423 |
+
subTitle={Locale.Settings.FontSize.SubTitle}
|
424 |
+
>
|
425 |
+
<InputRange
|
426 |
+
title={`${config.fontSize ?? 14}px`}
|
427 |
+
value={config.fontSize}
|
428 |
+
min="12"
|
429 |
+
max="18"
|
430 |
+
step="1"
|
431 |
+
onChange={(e) =>
|
432 |
+
updateConfig(
|
433 |
+
(config) =>
|
434 |
+
(config.fontSize = Number.parseInt(e.currentTarget.value)),
|
435 |
+
)
|
436 |
+
}
|
437 |
+
></InputRange>
|
438 |
+
</ListItem>
|
439 |
+
|
440 |
+
<ListItem
|
441 |
+
title={Locale.Settings.SendPreviewBubble.Title}
|
442 |
+
subTitle={Locale.Settings.SendPreviewBubble.SubTitle}
|
443 |
+
>
|
444 |
+
<input
|
445 |
+
type="checkbox"
|
446 |
+
checked={config.sendPreviewBubble}
|
447 |
+
onChange={(e) =>
|
448 |
+
updateConfig(
|
449 |
+
(config) =>
|
450 |
+
(config.sendPreviewBubble = e.currentTarget.checked),
|
451 |
+
)
|
452 |
+
}
|
453 |
+
></input>
|
454 |
+
</ListItem>
|
455 |
+
|
456 |
+
<ListItem
|
457 |
+
title={Locale.Settings.Mask.Title}
|
458 |
+
subTitle={Locale.Settings.Mask.SubTitle}
|
459 |
+
>
|
460 |
+
<input
|
461 |
+
type="checkbox"
|
462 |
+
checked={!config.dontShowMaskSplashScreen}
|
463 |
+
onChange={(e) =>
|
464 |
+
updateConfig(
|
465 |
+
(config) =>
|
466 |
+
(config.dontShowMaskSplashScreen =
|
467 |
+
!e.currentTarget.checked),
|
468 |
+
)
|
469 |
+
}
|
470 |
+
></input>
|
471 |
+
</ListItem>
|
472 |
+
</List>
|
473 |
+
|
474 |
+
<List>
|
475 |
+
{enabledAccessControl ? (
|
476 |
+
<ListItem
|
477 |
+
title={Locale.Settings.AccessCode.Title}
|
478 |
+
subTitle={Locale.Settings.AccessCode.SubTitle}
|
479 |
+
>
|
480 |
+
<PasswordInput
|
481 |
+
value={accessStore.accessCode}
|
482 |
+
type="text"
|
483 |
+
placeholder={Locale.Settings.AccessCode.Placeholder}
|
484 |
+
onChange={(e) => {
|
485 |
+
accessStore.updateCode(e.currentTarget.value);
|
486 |
+
}}
|
487 |
+
/>
|
488 |
+
</ListItem>
|
489 |
+
) : (
|
490 |
+
<></>
|
491 |
+
)}
|
492 |
+
|
493 |
+
{!accessStore.hideUserApiKey ? (
|
494 |
+
<ListItem
|
495 |
+
title={Locale.Settings.Token.Title}
|
496 |
+
subTitle={Locale.Settings.Token.SubTitle}
|
497 |
+
>
|
498 |
+
<PasswordInput
|
499 |
+
value={accessStore.token}
|
500 |
+
type="text"
|
501 |
+
placeholder={Locale.Settings.Token.Placeholder}
|
502 |
+
onChange={(e) => {
|
503 |
+
accessStore.updateToken(e.currentTarget.value);
|
504 |
+
}}
|
505 |
+
/>
|
506 |
+
</ListItem>
|
507 |
+
) : null}
|
508 |
+
|
509 |
+
<ListItem
|
510 |
+
title={Locale.Settings.Usage.Title}
|
511 |
+
subTitle={
|
512 |
+
showUsage
|
513 |
+
? loadingUsage
|
514 |
+
? Locale.Settings.Usage.IsChecking
|
515 |
+
: Locale.Settings.Usage.SubTitle(
|
516 |
+
usage?.used ?? "[?]",
|
517 |
+
usage?.subscription ?? "[?]",
|
518 |
+
)
|
519 |
+
: Locale.Settings.Usage.NoAccess
|
520 |
+
}
|
521 |
+
>
|
522 |
+
{!showUsage || loadingUsage ? (
|
523 |
+
<div />
|
524 |
+
) : (
|
525 |
+
<IconButton
|
526 |
+
icon={<ResetIcon></ResetIcon>}
|
527 |
+
text={Locale.Settings.Usage.Check}
|
528 |
+
onClick={() => checkUsage(true)}
|
529 |
+
/>
|
530 |
+
)}
|
531 |
+
</ListItem>
|
532 |
+
</List>
|
533 |
+
|
534 |
+
<List>
|
535 |
+
<ListItem
|
536 |
+
title={Locale.Settings.Prompt.Disable.Title}
|
537 |
+
subTitle={Locale.Settings.Prompt.Disable.SubTitle}
|
538 |
+
>
|
539 |
+
<input
|
540 |
+
type="checkbox"
|
541 |
+
checked={config.disablePromptHint}
|
542 |
+
onChange={(e) =>
|
543 |
+
updateConfig(
|
544 |
+
(config) =>
|
545 |
+
(config.disablePromptHint = e.currentTarget.checked),
|
546 |
+
)
|
547 |
+
}
|
548 |
+
></input>
|
549 |
+
</ListItem>
|
550 |
+
|
551 |
+
<ListItem
|
552 |
+
title={Locale.Settings.Prompt.List}
|
553 |
+
subTitle={Locale.Settings.Prompt.ListCount(
|
554 |
+
builtinCount,
|
555 |
+
customCount,
|
556 |
+
)}
|
557 |
+
>
|
558 |
+
<IconButton
|
559 |
+
icon={<EditIcon />}
|
560 |
+
text={Locale.Settings.Prompt.Edit}
|
561 |
+
onClick={() => setShowPromptModal(true)}
|
562 |
+
/>
|
563 |
+
</ListItem>
|
564 |
+
</List>
|
565 |
+
|
566 |
+
<List>
|
567 |
+
<ListItem title={Locale.Settings.Bot}>
|
568 |
+
<select
|
569 |
+
value={config.bot}
|
570 |
+
onChange={(e) => {
|
571 |
+
updateConfig(
|
572 |
+
(config) =>
|
573 |
+
(config.bot = ModalConfigValidator.bot(
|
574 |
+
e.currentTarget.value,
|
575 |
+
)),
|
576 |
+
);
|
577 |
+
chatStore.clearAll();
|
578 |
+
}}
|
579 |
+
>
|
580 |
+
{ALL_BOT.map((v) => (
|
581 |
+
<option value={v.name} key={v.name} disabled={!v.available}>
|
582 |
+
{v.name}
|
583 |
+
</option>
|
584 |
+
))}
|
585 |
+
</select>
|
586 |
+
</ListItem>
|
587 |
+
</List>
|
588 |
+
|
589 |
+
<List>
|
590 |
+
<ModelConfigList
|
591 |
+
modelConfig={config.modelConfig}
|
592 |
+
updateConfig={(upater) => {
|
593 |
+
const modelConfig = { ...config.modelConfig };
|
594 |
+
upater(modelConfig);
|
595 |
+
config.update((config) => (config.modelConfig = modelConfig));
|
596 |
+
}}
|
597 |
+
/>
|
598 |
+
</List>
|
599 |
+
|
600 |
+
{shouldShowPromptModal && (
|
601 |
+
<UserPromptModal onClose={() => setShowPromptModal(false)} />
|
602 |
+
)}
|
603 |
+
</div>
|
604 |
+
</ErrorBoundary>
|
605 |
+
);
|
606 |
+
}
|
app/components/sidebar.tsx
ADDED
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"]}>
|
100 |
+
ChatGPT Next【{config.bot}】
|
101 |
+
</div>
|
102 |
+
<div className={styles["sidebar-sub-title"]}>必应暂不支持上下文</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 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 |
+
navigate(Path.Chat);
|
167 |
+
} else {
|
168 |
+
navigate(Path.NewChat);
|
169 |
+
}
|
170 |
+
}}
|
171 |
+
shadow
|
172 |
+
/>
|
173 |
+
</div>
|
174 |
+
</div>
|
175 |
+
|
176 |
+
<div
|
177 |
+
className={styles["sidebar-drag"]}
|
178 |
+
onMouseDown={(e) => onDragMouseDown(e as any)}
|
179 |
+
></div>
|
180 |
+
</div>
|
181 |
+
);
|
182 |
+
}
|
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,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const COMMIT_ID: string = (() => {
|
2 |
+
try {
|
3 |
+
const childProcess = require("child_process");
|
4 |
+
return childProcess
|
5 |
+
.execSync('git log -1 --format="%at000" --date=unix')
|
6 |
+
.toString()
|
7 |
+
.trim();
|
8 |
+
} catch (e) {
|
9 |
+
console.error("[Build Config] No git or not from git repo.");
|
10 |
+
return "unknown";
|
11 |
+
}
|
12 |
+
})();
|
13 |
+
|
14 |
+
export const getBuildConfig = () => {
|
15 |
+
if (typeof process === "undefined") {
|
16 |
+
throw Error(
|
17 |
+
"[Server Config] you are importing a nodejs-only module outside of nodejs",
|
18 |
+
);
|
19 |
+
}
|
20 |
+
|
21 |
+
return {
|
22 |
+
commitId: COMMIT_ID,
|
23 |
+
};
|
24 |
+
};
|
app/config/server.ts
ADDED
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
+
HIDE_USER_API_KEY?: string; // disable user's api key input
|
11 |
+
}
|
12 |
+
}
|
13 |
+
}
|
14 |
+
|
15 |
+
const ACCESS_CODES = (function getAccessCodes(): Set<string> {
|
16 |
+
const code = process.env.CODE;
|
17 |
+
|
18 |
+
try {
|
19 |
+
const codes = (code?.split(",") ?? [])
|
20 |
+
.filter((v) => !!v)
|
21 |
+
.map((v) => md5.hash(v.trim()));
|
22 |
+
return new Set(codes);
|
23 |
+
} catch (e) {
|
24 |
+
return new Set();
|
25 |
+
}
|
26 |
+
})();
|
27 |
+
|
28 |
+
export const getServerSideConfig = () => {
|
29 |
+
if (typeof process === "undefined") {
|
30 |
+
throw Error(
|
31 |
+
"[Server Config] you are importing a nodejs-only module outside of nodejs",
|
32 |
+
);
|
33 |
+
}
|
34 |
+
|
35 |
+
return {
|
36 |
+
apiKey: process.env.OPENAI_API_KEY,
|
37 |
+
code: process.env.CODE,
|
38 |
+
codes: ACCESS_CODES,
|
39 |
+
needCode: ACCESS_CODES.size > 0,
|
40 |
+
proxyUrl: process.env.PROXY_URL,
|
41 |
+
isVercel: !!process.env.VERCEL,
|
42 |
+
hideUserApiKey: !!process.env.HIDE_USER_API_KEY,
|
43 |
+
};
|
44 |
+
};
|
app/constant.ts
ADDED
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
39 |
+
|
40 |
+
export const ACCESS_CODE_PREFIX = "ak-";
|
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";
|