Spaces:
Build error
Build error
Upload 269 files
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- CODE_OF_CONDUCT.md +128 -0
- Dockerfile +62 -0
- LICENSE +21 -0
- README.md +133 -12
- app/.DS_Store +0 -0
- app/api/.DS_Store +0 -0
- app/api/auth.ts +31 -0
- app/api/common.ts +102 -0
- app/api/config/route.ts +29 -0
- app/api/cors/[...path]/route.ts +43 -0
- app/api/file/[...path]/route.ts +36 -0
- app/api/langchain-tools/arxiv.ts +77 -0
- app/api/langchain-tools/baidu_search.ts +80 -0
- app/api/langchain-tools/duckduckgo.ts +29 -0
- app/api/langchain-tools/duckduckgo_search.ts +534 -0
- app/api/langchain-tools/google_search.ts +80 -0
- app/api/langchain-tools/http_get.ts +70 -0
- app/api/langchain-tools/ua_tools.ts +20 -0
- app/api/langchain/tool/agent/route.ts +311 -0
- app/api/openai/[...path]/route.ts +77 -0
- app/client/api.ts +164 -0
- app/client/controller.ts +37 -0
- app/client/platforms/openai.ts +441 -0
- app/command.ts +75 -0
- app/components/auth.module.scss +36 -0
- app/components/auth.tsx +21 -0
- app/components/button.module.scss +83 -0
- app/components/button.tsx +51 -0
- app/components/chat-list.tsx +167 -0
- app/components/chat.module.scss +550 -0
- app/components/chat.tsx +1358 -0
- app/components/emoji.tsx +56 -0
- app/components/error.tsx +74 -0
- app/components/exporter.module.scss +217 -0
- app/components/exporter.tsx +648 -0
- app/components/home.module.scss +340 -0
- app/components/home.tsx +207 -0
- app/components/input-range.module.scss +13 -0
- app/components/input-range.tsx +37 -0
- app/components/markdown.tsx +163 -0
- app/components/mask.module.scss +108 -0
- app/components/mask.tsx +620 -0
- app/components/message-selector.module.scss +76 -0
- app/components/message-selector.tsx +215 -0
- app/components/model-config.tsx +214 -0
- app/components/new-chat.module.scss +125 -0
- app/components/new-chat.tsx +192 -0
- app/components/plugin-config.tsx +60 -0
- app/components/plugin.module.scss +110 -0
- app/components/plugin.tsx +497 -0
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 | |
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,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
+
export HOSTNAME="127.0.0.1"; \
|
45 |
+
protocol=$(echo $PROXY_URL | cut -d: -f1); \
|
46 |
+
host=$(echo $PROXY_URL | cut -d/ -f3 | cut -d: -f1); \
|
47 |
+
port=$(echo $PROXY_URL | cut -d: -f3); \
|
48 |
+
conf=/etc/proxychains.conf; \
|
49 |
+
echo "strict_chain" > $conf; \
|
50 |
+
echo "proxy_dns" >> $conf; \
|
51 |
+
echo "remote_dns_subnet 224" >> $conf; \
|
52 |
+
echo "tcp_read_time_out 15000" >> $conf; \
|
53 |
+
echo "tcp_connect_time_out 8000" >> $conf; \
|
54 |
+
echo "localnet 127.0.0.0/255.0.0.0" >> $conf; \
|
55 |
+
echo "localnet ::1/128" >> $conf; \
|
56 |
+
echo "[ProxyList]" >> $conf; \
|
57 |
+
echo "$protocol $host $port" >> $conf; \
|
58 |
+
cat /etc/proxychains.conf; \
|
59 |
+
proxychains -f $conf node server.js; \
|
60 |
+
else \
|
61 |
+
node server.js; \
|
62 |
+
fi
|
LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
MIT License
|
2 |
+
|
3 |
+
Copyright (c) 2023 Zhang Yifei
|
4 |
+
|
5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6 |
+
of this software and associated documentation files (the "Software"), to deal
|
7 |
+
in the Software without restriction, including without limitation the rights
|
8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9 |
+
copies of the Software, and to permit persons to whom the Software is
|
10 |
+
furnished to do so, subject to the following conditions:
|
11 |
+
|
12 |
+
The above copyright notice and this permission notice shall be included in all
|
13 |
+
copies or substantial portions of the Software.
|
14 |
+
|
15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21 |
+
SOFTWARE.
|
README.md
CHANGED
@@ -1,12 +1,133 @@
|
|
1 |
-
|
2 |
-
|
3 |
-
|
4 |
-
|
5 |
-
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
**Expert**: Linguist and Technology Expert
|
2 |
+
**Objective**: Provide a verbatim English translation of the given Chinese text.
|
3 |
+
**Assumptions**: The text relates to the features and configuration of a ChatGPT-like project with various plugins and deployment instructions.
|
4 |
+
|
5 |
+
### Accurate English Translation of Chinese Text:
|
6 |
+
|
7 |
+
**Main Features**
|
8 |
+
Apart from plugin tools, it maintains consistency with the original project ChatGPT-Next-Web's main features. Plugin functionality implemented based on LangChain currently supports the following plugins, with more to be added in the future:
|
9 |
+
- Search
|
10 |
+
- SerpAPI
|
11 |
+
- BingSerpAPI
|
12 |
+
- DuckDuckGo
|
13 |
+
- Calculation
|
14 |
+
- Calculator
|
15 |
+
- Web Requests
|
16 |
+
- WebBrowser
|
17 |
+
- Others
|
18 |
+
- Wiki
|
19 |
+
- DALL-E 3
|
20 |
+
- DALL-E 3 plugin requires R2 storage configuration, please refer to the Cloudflare R2 service configuration guide for setup.
|
21 |
+
- StableDiffusion
|
22 |
+
- This plugin is currently in a test version and may undergo significant changes in the future, please use with caution.
|
23 |
+
- Using this plugin requires certain expertise; issues related to Stable Diffusion itself are not covered in this project. If you decide to use this plugin, please configure it following the Stable Diffusion Plugin Configuration Guide.
|
24 |
+
- StableDiffusion plugin requires R2 storage configuration, please refer to the Cloudflare R2 service configuration guide for setup.
|
25 |
+
- Arxiv
|
26 |
+
|
27 |
+
**Development Plans**
|
28 |
+
- Support DuckDuckGo as the default search engine.
|
29 |
+
- When SERPAPI_API_KEY is configured, SerpAPI is used as the search plugin by default. If not configured, DuckDuckGo is used by default.
|
30 |
+
- When BING_SEARCH_API_KEY is configured, BingSerpAPI is used as the search plugin by default. If not configured, DuckDuckGo is used by default.
|
31 |
+
- Priority: SerpAPI > BingSerpAPI > DuckDuckGo
|
32 |
+
- Development of the plugin list page.
|
33 |
+
- Support for toggling specific plugins.
|
34 |
+
- Support for adding custom plugins.
|
35 |
+
- Support for Agent parameter configuration (agentType, maxIterations, returnIntermediateSteps, etc.).
|
36 |
+
- Support for ChatSession level plugin functionality toggle.
|
37 |
+
- Plugin toggle appears only when using models other than 0301 and 0314; other models default to closed status, and the toggle will not display.
|
38 |
+
|
39 |
+
**Known Issues**
|
40 |
+
- When using plugins, you need to switch to the 0613 version model, such as gpt-3.5-turbo-0613.
|
41 |
+
- Attempts to use agents like chat-conversational-react-description with plugins are not ideal, and support for other versions of the model is no longer considered.
|
42 |
+
- Limitations modified so that models other than 0301 and 0314 can call plugins. #10
|
43 |
+
- SERPAPI_API_KEY is currently required, but support for using DuckDuckGo to replace the search plugin will be added later.
|
44 |
+
- Agent does not support custom interface addresses.
|
45 |
+
- Plugins may fail in some scenarios.
|
46 |
+
- Issue occurs due to parameter errors when using the Calculator for calculations, currently unable to intervene.
|
47 |
+
- No feedback after plugin call failure.
|
48 |
+
|
49 |
+
**Latest Updates**
|
50 |
+
- 🚀 v2.9.6 version released.
|
51 |
+
- 🚀 v2.9.5 official version released.
|
52 |
+
- 🚀 v2.9.1-plugin-preview preview version released.
|
53 |
+
|
54 |
+
**Getting Started**
|
55 |
+
- Prepare your OpenAI API Key.
|
56 |
+
- Click the button on the right to start deployment: Deploy with Vercel, log in directly with your Github account, remember to fill in the API Key and page access password CODE on the environment variable page.
|
57 |
+
- After deployment, you can start using it.
|
58 |
+
- (Optional) Bind a custom domain: DNS of the domain assigned by Vercel may be contaminated in some areas, binding a custom domain can directly connect.
|
59 |
+
|
60 |
+
**FAQ**
|
61 |
+
- Simplified Chinese > Frequently Asked Questions
|
62 |
+
- English > FAQ
|
63 |
+
- Azure OpenAI
|
64 |
+
|
65 |
+
**Configure Page Access Password**
|
66 |
+
After configuring the password, users need to manually fill in the access code on the settings page to chat normally, otherwise, they will be prompted with an unauthorized status message.
|
67 |
+
Warning: Please set the password long enough, preferably more than 7 characters, to prevent brute force attacks.
|
68 |
+
This project provides limited permission control functionality, please add an environment variable named CODE on the Vercel project control panel's environment variable page, with values separated by English commas for custom passwords:
|
69 |
+
- code1,code2,code3
|
70 |
+
After adding or modifying this environment variable, please redeploy the project to make the changes take effect.
|
71 |
+
|
72 |
+
**Environment Variables**
|
73 |
+
Most of the project's configuration items are set through environment variables, tutorial: How to modify Vercel environment variables.
|
74 |
+
|
75 |
+
- OPENAI_API_KEY (mandatory)
|
76 |
+
- OpenAI key, your api key applied on the openai account page.
|
77 |
+
- SERPAPI_API_KEY (optional)
|
78 |
+
- SerpApi: Google Search API
|
79 |
+
- BING_SEARCH_API_KEY (optional)
|
80 |
+
|
81 |
+
|
82 |
+
- Web Search API | Microsoft Bing
|
83 |
+
- CHOOSE_SEARCH_ENGINE (optional)
|
84 |
+
- This item is for direct connection search engines, avoiding the trouble of small api trial amounts, but may not work due to network issues.
|
85 |
+
- Optional items include:
|
86 |
+
- google
|
87 |
+
- baidu
|
88 |
+
- CODE (optional)
|
89 |
+
- Access password, optional, multiple passwords can be separated by commas.
|
90 |
+
- Warning: If this item is not filled, anyone can directly use your deployed website, which may lead to rapid depletion of your token, it's recommended to fill this option.
|
91 |
+
- BASE_URL (optional)
|
92 |
+
- Default: https://api.openai.com
|
93 |
+
- Examples: http://your-openai-proxy.com
|
94 |
+
- OpenAI interface proxy URL, if you have manually configured an openai interface proxy, please fill this option.
|
95 |
+
- If encountering SSL certificate issues, set the BASE_URL protocol to http.
|
96 |
+
- OPENAI_ORG_ID (optional)
|
97 |
+
- Specifies the organization ID in OpenAI.
|
98 |
+
- HIDE_USER_API_KEY (optional)
|
99 |
+
- If you don't want users to fill in the API Key themselves, set this environment variable to 1.
|
100 |
+
- DISABLE_GPT4 (optional)
|
101 |
+
- If you don't want users to use GPT-4, set this environment variable to 1.
|
102 |
+
- HIDE_BALANCE_QUERY (optional)
|
103 |
+
- If you don't want users to query the balance, set this environment variable to 1.
|
104 |
+
- R2_ACCOUNT_ID (optional)
|
105 |
+
- Cloudflare R2 account ID, required for using the DALL-E plugin.
|
106 |
+
- R2_ACCESS_KEY_ID (optional)
|
107 |
+
- Cloudflare R2 access key ID, required for using the DALL-E plugin.
|
108 |
+
- R2_SECRET_ACCESS_KEY (optional)
|
109 |
+
- Cloudflare R2 secret access key, required for using the DALL-E plugin.
|
110 |
+
- R2_BUCKET (optional)
|
111 |
+
- Cloudflare R2 Bucket name, required for using the DALL-E plugin.
|
112 |
+
|
113 |
+
**Deployment**
|
114 |
+
Container Deployment (Recommended)
|
115 |
+
Docker version needs to be 20 or above, otherwise, it will prompt that the image is not found.
|
116 |
+
⚠️ Note: The docker version will most of the time lag behind the latest version by 1 to 2 days, so there will be continuous prompts for "updates available", which is normal.
|
117 |
+
|
118 |
+
docker run -d -p 3000:3000 \
|
119 |
+
-e OPENAI_API_KEY="sk-xxxx" \
|
120 |
+
-e CODE="page access password" \
|
121 |
+
gosuto/chatgpt-next-web-langchain
|
122 |
+
You can also specify a proxy:
|
123 |
+
|
124 |
+
docker run -d -p 3000:3000 \
|
125 |
+
-e OPENAI_API_KEY="sk-xxxx" \
|
126 |
+
-e CODE="page access password" \
|
127 |
+
--net=host \
|
128 |
+
-e PROXY_URL="http://127.0.0.1:7890" \
|
129 |
+
gosuto/chatgpt-next-web-langchain
|
130 |
+
If your local proxy requires a username and password, use:
|
131 |
+
|
132 |
+
-e PROXY_URL="http://127.0.0.1:7890 user password"
|
133 |
+
If you need to specify other environment variables, please add -e environment variable=environment variable value to the above command.
|
app/.DS_Store
ADDED
Binary file (8.2 kB). View file
|
|
app/api/.DS_Store
ADDED
Binary file (6.15 kB). View file
|
|
app/api/auth.ts
ADDED
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
+
function getIP(req: NextRequest) {
|
7 |
+
let ip = req.ip ?? req.headers.get("x-real-ip");
|
8 |
+
const forwardedFor = req.headers.get("x-forwarded-for");
|
9 |
+
|
10 |
+
if (!ip && forwardedFor) {
|
11 |
+
ip = forwardedFor.split(",").at(0) ?? "";
|
12 |
+
}
|
13 |
+
|
14 |
+
return ip;
|
15 |
+
}
|
16 |
+
|
17 |
+
function parseApiKey(bearToken: string) {
|
18 |
+
const token = bearToken.trim().replaceAll("Bearer ", "").trim();
|
19 |
+
const isOpenAiKey = !token.startsWith(ACCESS_CODE_PREFIX);
|
20 |
+
|
21 |
+
return {
|
22 |
+
accessCode: isOpenAiKey ? "" : token.slice(ACCESS_CODE_PREFIX.length),
|
23 |
+
apiKey: isOpenAiKey ? token : "",
|
24 |
+
};
|
25 |
+
}
|
26 |
+
|
27 |
+
export function auth(req: NextRequest) {
|
28 |
+
return {
|
29 |
+
error: false,
|
30 |
+
};
|
31 |
+
}
|
app/api/common.ts
ADDED
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { NextRequest, NextResponse } from "next/server";
|
2 |
+
import { getServerSideConfig } from "../config/server";
|
3 |
+
import { DEFAULT_MODELS, OPENAI_BASE_URL } from "../constant";
|
4 |
+
import { collectModelTable, collectModels } from "../utils/model";
|
5 |
+
|
6 |
+
const serverConfig = getServerSideConfig();
|
7 |
+
|
8 |
+
export async function requestOpenai(req: NextRequest) {
|
9 |
+
const controller = new AbortController();
|
10 |
+
const authValue = req.headers.get("Authorization") ?? "";
|
11 |
+
const openaiPath = `${req.nextUrl.pathname}${req.nextUrl.search}`.replaceAll(
|
12 |
+
"/api/openai/",
|
13 |
+
"",
|
14 |
+
);
|
15 |
+
|
16 |
+
let baseUrl = serverConfig.baseUrl ?? OPENAI_BASE_URL;
|
17 |
+
|
18 |
+
if (!baseUrl.startsWith("http")) {
|
19 |
+
baseUrl = `https://${baseUrl}`;
|
20 |
+
}
|
21 |
+
|
22 |
+
if (baseUrl.endsWith("/")) {
|
23 |
+
baseUrl = baseUrl.slice(0, -1);
|
24 |
+
}
|
25 |
+
|
26 |
+
console.log("[Proxy] ", openaiPath);
|
27 |
+
console.log("[Base Url]", baseUrl);
|
28 |
+
console.log("[Org ID]", serverConfig.openaiOrgId);
|
29 |
+
|
30 |
+
const timeoutId = setTimeout(
|
31 |
+
() => {
|
32 |
+
controller.abort();
|
33 |
+
},
|
34 |
+
10 * 60 * 1000,
|
35 |
+
);
|
36 |
+
|
37 |
+
const fetchUrl = `${baseUrl}/${openaiPath}`;
|
38 |
+
const fetchOptions: RequestInit = {
|
39 |
+
headers: {
|
40 |
+
"Content-Type": "application/json",
|
41 |
+
"Cache-Control": "no-store",
|
42 |
+
Authorization: authValue,
|
43 |
+
...(process.env.OPENAI_ORG_ID && {
|
44 |
+
"OpenAI-Organization": process.env.OPENAI_ORG_ID,
|
45 |
+
}),
|
46 |
+
},
|
47 |
+
method: req.method,
|
48 |
+
body: req.body,
|
49 |
+
// to fix #2485: https://stackoverflow.com/questions/55920957/cloudflare-worker-typeerror-one-time-use-body
|
50 |
+
redirect: "manual",
|
51 |
+
// @ts-ignore
|
52 |
+
duplex: "half",
|
53 |
+
signal: controller.signal,
|
54 |
+
};
|
55 |
+
|
56 |
+
// #1815 try to refuse gpt4 request
|
57 |
+
if (serverConfig.customModels && req.body) {
|
58 |
+
try {
|
59 |
+
const modelTable = collectModelTable(
|
60 |
+
DEFAULT_MODELS,
|
61 |
+
serverConfig.customModels,
|
62 |
+
);
|
63 |
+
const clonedBody = await req.text();
|
64 |
+
fetchOptions.body = clonedBody;
|
65 |
+
|
66 |
+
const jsonBody = JSON.parse(clonedBody) as { model?: string };
|
67 |
+
|
68 |
+
// not undefined and is false
|
69 |
+
if (modelTable[jsonBody?.model ?? ""] === false) {
|
70 |
+
return NextResponse.json(
|
71 |
+
{
|
72 |
+
error: true,
|
73 |
+
message: `you are not allowed to use ${jsonBody?.model} model`,
|
74 |
+
},
|
75 |
+
{
|
76 |
+
status: 403,
|
77 |
+
},
|
78 |
+
);
|
79 |
+
}
|
80 |
+
} catch (e) {
|
81 |
+
console.error("[OpenAI] gpt4 filter", e);
|
82 |
+
}
|
83 |
+
}
|
84 |
+
|
85 |
+
try {
|
86 |
+
const res = await fetch(fetchUrl, fetchOptions);
|
87 |
+
|
88 |
+
// to prevent browser prompt for credentials
|
89 |
+
const newHeaders = new Headers(res.headers);
|
90 |
+
newHeaders.delete("www-authenticate");
|
91 |
+
// to disable nginx buffering
|
92 |
+
newHeaders.set("X-Accel-Buffering", "no");
|
93 |
+
|
94 |
+
return new Response(res.body, {
|
95 |
+
status: res.status,
|
96 |
+
statusText: res.statusText,
|
97 |
+
headers: newHeaders,
|
98 |
+
});
|
99 |
+
} finally {
|
100 |
+
clearTimeout(timeoutId);
|
101 |
+
}
|
102 |
+
}
|
app/api/config/route.ts
ADDED
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { NextResponse } from "next/server";
|
2 |
+
|
3 |
+
import { getServerSideConfig } from "../../config/server";
|
4 |
+
|
5 |
+
const serverConfig = getServerSideConfig();
|
6 |
+
|
7 |
+
// Danger! Do not hard code any secret value here!
|
8 |
+
// 警告!不要在这里写入任何敏感信息!
|
9 |
+
const DANGER_CONFIG = {
|
10 |
+
needCode: serverConfig.needCode,
|
11 |
+
hideUserApiKey: serverConfig.hideUserApiKey,
|
12 |
+
disableGPT4: serverConfig.disableGPT4,
|
13 |
+
hideBalanceQuery: serverConfig.hideBalanceQuery,
|
14 |
+
disableFastLink: serverConfig.disableFastLink,
|
15 |
+
customModels: serverConfig.customModels,
|
16 |
+
};
|
17 |
+
|
18 |
+
declare global {
|
19 |
+
type DangerConfig = typeof DANGER_CONFIG;
|
20 |
+
}
|
21 |
+
|
22 |
+
async function handle() {
|
23 |
+
return NextResponse.json(DANGER_CONFIG);
|
24 |
+
}
|
25 |
+
|
26 |
+
export const GET = handle;
|
27 |
+
export const POST = handle;
|
28 |
+
|
29 |
+
export const runtime = "edge";
|
app/api/cors/[...path]/route.ts
ADDED
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { NextRequest, NextResponse } from "next/server";
|
2 |
+
|
3 |
+
async function handle(
|
4 |
+
req: NextRequest,
|
5 |
+
{ params }: { params: { path: string[] } },
|
6 |
+
) {
|
7 |
+
if (req.method === "OPTIONS") {
|
8 |
+
return NextResponse.json({ body: "OK" }, { status: 200 });
|
9 |
+
}
|
10 |
+
|
11 |
+
const [protocol, ...subpath] = params.path;
|
12 |
+
const targetUrl = `${protocol}://${subpath.join("/")}`;
|
13 |
+
|
14 |
+
const method = req.headers.get("method") ?? undefined;
|
15 |
+
const shouldNotHaveBody = ["get", "head"].includes(
|
16 |
+
method?.toLowerCase() ?? "",
|
17 |
+
);
|
18 |
+
|
19 |
+
const fetchOptions: RequestInit = {
|
20 |
+
headers: {
|
21 |
+
authorization: req.headers.get("authorization") ?? "",
|
22 |
+
},
|
23 |
+
body: shouldNotHaveBody ? null : req.body,
|
24 |
+
method,
|
25 |
+
// @ts-ignore
|
26 |
+
duplex: "half",
|
27 |
+
};
|
28 |
+
|
29 |
+
const fetchResult = await fetch(targetUrl, fetchOptions);
|
30 |
+
|
31 |
+
console.log("[Any Proxy]", targetUrl, {
|
32 |
+
status: fetchResult.status,
|
33 |
+
statusText: fetchResult.statusText,
|
34 |
+
});
|
35 |
+
|
36 |
+
return fetchResult;
|
37 |
+
}
|
38 |
+
|
39 |
+
export const POST = handle;
|
40 |
+
export const GET = handle;
|
41 |
+
export const OPTIONS = handle;
|
42 |
+
|
43 |
+
export const runtime = "nodejs";
|
app/api/file/[...path]/route.ts
ADDED
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { NextRequest, NextResponse } from "next/server";
|
2 |
+
import { auth } from "../../auth";
|
3 |
+
import S3FileStorage from "../../../utils/r2_file_storage";
|
4 |
+
|
5 |
+
async function handle(
|
6 |
+
req: NextRequest,
|
7 |
+
{ params }: { params: { path: string[] } },
|
8 |
+
) {
|
9 |
+
if (req.method === "OPTIONS") {
|
10 |
+
return NextResponse.json({ body: "OK" }, { status: 200 });
|
11 |
+
}
|
12 |
+
|
13 |
+
// const authResult = auth(req);
|
14 |
+
// if (authResult.error) {
|
15 |
+
// return NextResponse.json(authResult, {
|
16 |
+
// status: 401,
|
17 |
+
// });
|
18 |
+
// }
|
19 |
+
|
20 |
+
try {
|
21 |
+
var file = await S3FileStorage.get(params.path[0]);
|
22 |
+
return new Response(file?.transformToWebStream(), {
|
23 |
+
headers: {
|
24 |
+
"Content-Type": "image/png",
|
25 |
+
},
|
26 |
+
});
|
27 |
+
} catch (e) {
|
28 |
+
return new Response("not found", {
|
29 |
+
status: 404,
|
30 |
+
});
|
31 |
+
}
|
32 |
+
}
|
33 |
+
|
34 |
+
export const GET = handle;
|
35 |
+
|
36 |
+
export const runtime = "edge";
|
app/api/langchain-tools/arxiv.ts
ADDED
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { StructuredTool } from "langchain/tools";
|
2 |
+
import { z } from "zod";
|
3 |
+
|
4 |
+
export class ArxivAPIWrapper extends StructuredTool {
|
5 |
+
get lc_namespace() {
|
6 |
+
return [...super.lc_namespace, "test"];
|
7 |
+
}
|
8 |
+
|
9 |
+
name = "arxiv";
|
10 |
+
description = "Run Arxiv search and get the article information.";
|
11 |
+
|
12 |
+
SORT_BY = {
|
13 |
+
RELEVANCE: "relevance",
|
14 |
+
LAST_UPDATED_DATE: "lastUpdatedDate",
|
15 |
+
SUBMITTED_DATE: "submittedDate",
|
16 |
+
};
|
17 |
+
|
18 |
+
SORT_ORDER = {
|
19 |
+
ASCENDING: "ascending",
|
20 |
+
DESCENDING: "descending",
|
21 |
+
};
|
22 |
+
|
23 |
+
schema = z.object({
|
24 |
+
searchQuery: z
|
25 |
+
.string()
|
26 |
+
.describe("same as the search_query parameter rules of the arxiv API."),
|
27 |
+
sortBy: z
|
28 |
+
.string()
|
29 |
+
.describe('can be "relevance", "lastUpdatedDate", "submittedDate".'),
|
30 |
+
sortOrder: z
|
31 |
+
.string()
|
32 |
+
.describe('can be either "ascending" or "descending".'),
|
33 |
+
start: z
|
34 |
+
.number()
|
35 |
+
.default(0)
|
36 |
+
.describe("the index of the first returned result."),
|
37 |
+
maxResults: z
|
38 |
+
.number()
|
39 |
+
.default(10)
|
40 |
+
.describe("the number of results returned by the query."),
|
41 |
+
});
|
42 |
+
|
43 |
+
async _call({
|
44 |
+
searchQuery,
|
45 |
+
sortBy,
|
46 |
+
sortOrder,
|
47 |
+
start,
|
48 |
+
maxResults,
|
49 |
+
}: z.infer<typeof this.schema>) {
|
50 |
+
if (sortBy && !Object.values(this.SORT_BY).includes(sortBy)) {
|
51 |
+
throw new Error(
|
52 |
+
`unsupported sort by option. should be one of: ${Object.values(
|
53 |
+
this.SORT_BY,
|
54 |
+
).join(" ")}`,
|
55 |
+
);
|
56 |
+
}
|
57 |
+
if (sortOrder && !Object.values(this.SORT_ORDER).includes(sortOrder)) {
|
58 |
+
throw new Error(
|
59 |
+
`unsupported sort order option. should be one of: ${Object.values(
|
60 |
+
this.SORT_ORDER,
|
61 |
+
).join(" ")}`,
|
62 |
+
);
|
63 |
+
}
|
64 |
+
try {
|
65 |
+
let url = `http://export.arxiv.org/api/query?search_query=${searchQuery}&start=${start}&max_results=${maxResults}${
|
66 |
+
sortBy ? `&sortBy=${sortBy}` : ""
|
67 |
+
}${sortOrder ? `&sortOrder=${sortOrder}` : ""}`;
|
68 |
+
console.error("[arxiv]", url);
|
69 |
+
const response = await fetch(url);
|
70 |
+
const data = await response.text();
|
71 |
+
return data;
|
72 |
+
} catch (e) {
|
73 |
+
console.error("[arxiv]", e);
|
74 |
+
}
|
75 |
+
return "not found";
|
76 |
+
}
|
77 |
+
}
|
app/api/langchain-tools/baidu_search.ts
ADDED
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { decode } from "html-entities";
|
2 |
+
import { convert as htmlToText } from "html-to-text";
|
3 |
+
import { Tool } from "langchain/tools";
|
4 |
+
import * as cheerio from "cheerio";
|
5 |
+
import { getRandomUserAgent } from "./ua_tools";
|
6 |
+
|
7 |
+
interface SearchResults {
|
8 |
+
/** The web results of the search. */
|
9 |
+
results: SearchResult[];
|
10 |
+
}
|
11 |
+
|
12 |
+
interface SearchResult {
|
13 |
+
/** The URL of the result. */
|
14 |
+
url: string;
|
15 |
+
/** The title of the result. */
|
16 |
+
title: string;
|
17 |
+
/**
|
18 |
+
* The sanitized description of the result.
|
19 |
+
* Bold tags will still be present in this string.
|
20 |
+
*/
|
21 |
+
description: string;
|
22 |
+
}
|
23 |
+
|
24 |
+
async function search(
|
25 |
+
input: string,
|
26 |
+
maxResults: number,
|
27 |
+
): Promise<SearchResults> {
|
28 |
+
const results: SearchResults = {
|
29 |
+
results: [],
|
30 |
+
};
|
31 |
+
const headers = new Headers();
|
32 |
+
headers.append("User-Agent", getRandomUserAgent());
|
33 |
+
const resp = await fetch(
|
34 |
+
`https://www.baidu.com/s?f=8&ie=utf-8&rn=${maxResults}&wd=${encodeURIComponent(
|
35 |
+
input,
|
36 |
+
)}`,
|
37 |
+
{
|
38 |
+
headers: headers,
|
39 |
+
},
|
40 |
+
);
|
41 |
+
const respCheerio = cheerio.load(await resp.text());
|
42 |
+
respCheerio("div.c-container.new-pmd").each((i, elem) => {
|
43 |
+
const item = cheerio.load(elem);
|
44 |
+
const linkElement = item("a");
|
45 |
+
const url = (linkElement.attr("href") ?? "").trim();
|
46 |
+
if (url !== "" && url !== "#") {
|
47 |
+
const title = decode(linkElement.text());
|
48 |
+
const description = item.text().replace(title, "").trim();
|
49 |
+
results.results.push({
|
50 |
+
url,
|
51 |
+
title,
|
52 |
+
description,
|
53 |
+
});
|
54 |
+
}
|
55 |
+
});
|
56 |
+
return results;
|
57 |
+
}
|
58 |
+
|
59 |
+
export class BaiduSearch extends Tool {
|
60 |
+
name = "baidu_search";
|
61 |
+
maxResults = 6;
|
62 |
+
|
63 |
+
/** @ignore */
|
64 |
+
async _call(input: string) {
|
65 |
+
const searchResults = await search(input, this.maxResults);
|
66 |
+
|
67 |
+
if (searchResults.results.length === 0) {
|
68 |
+
return "No good search result found";
|
69 |
+
}
|
70 |
+
|
71 |
+
const results = searchResults.results
|
72 |
+
.slice(0, this.maxResults)
|
73 |
+
.map(({ title, description, url }) => htmlToText(description))
|
74 |
+
.join("\n\n");
|
75 |
+
return results;
|
76 |
+
}
|
77 |
+
|
78 |
+
description =
|
79 |
+
"a search engine. useful for when you need to answer questions about current events. input should be a search query.";
|
80 |
+
}
|
app/api/langchain-tools/duckduckgo.ts
ADDED
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { SafeSearchType, search } from "duck-duck-scrape";
|
2 |
+
import { convert as htmlToText } from "html-to-text";
|
3 |
+
import { Tool } from "langchain/tools";
|
4 |
+
|
5 |
+
export class DuckDuckGo extends Tool {
|
6 |
+
name = "duckduckgo_search";
|
7 |
+
maxResults = 4;
|
8 |
+
|
9 |
+
/** @ignore */
|
10 |
+
async _call(input: string) {
|
11 |
+
const searchResults = await search(input, {
|
12 |
+
safeSearch: SafeSearchType.OFF,
|
13 |
+
});
|
14 |
+
|
15 |
+
if (searchResults.noResults) {
|
16 |
+
return "No good search result found";
|
17 |
+
}
|
18 |
+
|
19 |
+
const results = searchResults.results
|
20 |
+
.slice(0, this.maxResults)
|
21 |
+
.map(({ title, description, url }) => htmlToText(description))
|
22 |
+
.join("\n\n");
|
23 |
+
|
24 |
+
return results;
|
25 |
+
}
|
26 |
+
|
27 |
+
description =
|
28 |
+
"a search engine. useful for when you need to answer questions about current events. input should be a search query.";
|
29 |
+
}
|
app/api/langchain-tools/duckduckgo_search.ts
ADDED
@@ -0,0 +1,534 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { decode } from "html-entities";
|
2 |
+
import { convert as htmlToText } from "html-to-text";
|
3 |
+
import { Tool } from "langchain/tools";
|
4 |
+
|
5 |
+
const SEARCH_REGEX =
|
6 |
+
/DDG\.pageLayout\.load\('d',(\[.+\])\);DDG\.duckbar\.load\('images'/;
|
7 |
+
const IMAGES_REGEX =
|
8 |
+
/;DDG\.duckbar\.load\('images', ({"ads":.+"vqd":{".+":"\d-\d+-\d+"}})\);DDG\.duckbar\.load\('news/;
|
9 |
+
const NEWS_REGEX =
|
10 |
+
/;DDG\.duckbar\.load\('news', ({"ads":.+"vqd":{".+":"\d-\d+-\d+"}})\);DDG\.duckbar\.load\('videos/;
|
11 |
+
const VIDEOS_REGEX =
|
12 |
+
/;DDG\.duckbar\.load\('videos', ({"ads":.+"vqd":{".+":"\d-\d+-\d+"}})\);DDG\.duckbar\.loadModule\('related_searches/;
|
13 |
+
const RELATED_SEARCHES_REGEX =
|
14 |
+
/DDG\.duckbar\.loadModule\('related_searches', ({"ads":.+"vqd":{".+":"\d-\d+-\d+"}})\);DDG\.duckbar\.load\('products/;
|
15 |
+
const VQD_REGEX = /vqd=['"](\d+-\d+(?:-\d+)?)['"]/;
|
16 |
+
|
17 |
+
interface CallbackSearchResult {
|
18 |
+
/** Website description */
|
19 |
+
a: string;
|
20 |
+
/** Unknown */
|
21 |
+
ae: null;
|
22 |
+
/** ddg!bang information (ex. w Wikipedia en.wikipedia.org) */
|
23 |
+
b?: string;
|
24 |
+
/** URL */
|
25 |
+
c: string;
|
26 |
+
/** URL of some sort. */
|
27 |
+
d: string;
|
28 |
+
/** Class name associations. */
|
29 |
+
da?: string;
|
30 |
+
/** Unknown */
|
31 |
+
h: number;
|
32 |
+
/** Website hostname */
|
33 |
+
i: string;
|
34 |
+
/** Unknown */
|
35 |
+
k: null;
|
36 |
+
/** Unknown */
|
37 |
+
m: number;
|
38 |
+
/** Unknown */
|
39 |
+
o: number;
|
40 |
+
/** Unknown */
|
41 |
+
p: number;
|
42 |
+
/** Unknown */
|
43 |
+
s: string;
|
44 |
+
/** Website Title */
|
45 |
+
t: string;
|
46 |
+
/** Website URL */
|
47 |
+
u: string;
|
48 |
+
}
|
49 |
+
|
50 |
+
interface CallbackNextSearch {
|
51 |
+
/** URL to the next page of results */
|
52 |
+
n: string;
|
53 |
+
}
|
54 |
+
|
55 |
+
interface CallbackDuckbarPayload<T> {
|
56 |
+
ads: null | any[];
|
57 |
+
query: string;
|
58 |
+
queryEncoded: string;
|
59 |
+
response_type: string;
|
60 |
+
results: T[];
|
61 |
+
vqd: {
|
62 |
+
[query: string]: string;
|
63 |
+
};
|
64 |
+
}
|
65 |
+
|
66 |
+
interface DuckbarImageResult {
|
67 |
+
/** The height of the image in pixels. */
|
68 |
+
height: number;
|
69 |
+
/** The image URL. */
|
70 |
+
image: string;
|
71 |
+
/** The source of the image. */
|
72 |
+
source: string;
|
73 |
+
/** The thumbnail URL. */
|
74 |
+
thumbnail: string;
|
75 |
+
/** The title (or caption) of the image. */
|
76 |
+
title: string;
|
77 |
+
/** The website URL of where the image came from. */
|
78 |
+
url: string;
|
79 |
+
/** The width of the image in pixels. */
|
80 |
+
width: number;
|
81 |
+
}
|
82 |
+
|
83 |
+
interface DuckbarVideoResult {
|
84 |
+
/** URL of the video */
|
85 |
+
content: string;
|
86 |
+
/** Description of the video */
|
87 |
+
description: string;
|
88 |
+
/** Duration of the video */
|
89 |
+
duration: string;
|
90 |
+
/** Embed HTML for the video */
|
91 |
+
embed_html: string;
|
92 |
+
/** Embed URL for the video */
|
93 |
+
embed_url: string;
|
94 |
+
/** Thumbnail images of the video */
|
95 |
+
images: {
|
96 |
+
large: string;
|
97 |
+
medium: string;
|
98 |
+
motion: string;
|
99 |
+
small: string;
|
100 |
+
};
|
101 |
+
/** Where this search result came from */
|
102 |
+
provider: string;
|
103 |
+
/** ISO timestamp of the upload */
|
104 |
+
published: string;
|
105 |
+
/** What site the video was on */
|
106 |
+
publisher: string;
|
107 |
+
/** Various statistics */
|
108 |
+
statistics: {
|
109 |
+
/** View count of the video */
|
110 |
+
viewCount: number | null;
|
111 |
+
};
|
112 |
+
/** Title of the video */
|
113 |
+
title: string;
|
114 |
+
/** Name of the video uploader(?) */
|
115 |
+
uploader: string;
|
116 |
+
}
|
117 |
+
|
118 |
+
interface DuckbarRelatedSearch {
|
119 |
+
display_text: string;
|
120 |
+
text: string;
|
121 |
+
web_search_url: string;
|
122 |
+
}
|
123 |
+
|
124 |
+
interface DuckbarNewsResult {
|
125 |
+
date: number;
|
126 |
+
excerpt: string;
|
127 |
+
image?: string;
|
128 |
+
relative_time: string;
|
129 |
+
syndicate: string;
|
130 |
+
title: string;
|
131 |
+
url: string;
|
132 |
+
use_relevancy: number;
|
133 |
+
is_old?: number;
|
134 |
+
fetch_image?: number;
|
135 |
+
}
|
136 |
+
|
137 |
+
interface SearchResults {
|
138 |
+
/** Whether there were no results found. */
|
139 |
+
noResults: boolean;
|
140 |
+
/** The VQD of the search query. */
|
141 |
+
vqd: string;
|
142 |
+
/** The web results of the search. */
|
143 |
+
results: SearchResult[];
|
144 |
+
/** The image results of the search. */
|
145 |
+
images?: DuckbarImageResult[];
|
146 |
+
/** The news article results of the search. */
|
147 |
+
news?: NewsResult[];
|
148 |
+
/** The video results of the search. */
|
149 |
+
videos?: VideoResult[];
|
150 |
+
/** The related searches of the query. */
|
151 |
+
related?: RelatedResult[];
|
152 |
+
}
|
153 |
+
|
154 |
+
interface VideoResult {
|
155 |
+
/** The URL of the video. */
|
156 |
+
url: string;
|
157 |
+
/** The title of the video. */
|
158 |
+
title: string;
|
159 |
+
/** The description of the video. */
|
160 |
+
description: string;
|
161 |
+
/** The image URL of the video. */
|
162 |
+
image: string;
|
163 |
+
/** The duration of the video. (i.e. "9:20") */
|
164 |
+
duration: string;
|
165 |
+
/** The ISO timestamp of when the video was published. */
|
166 |
+
published: string;
|
167 |
+
/** Where the video was publised on. (i.e. "YouTube") */
|
168 |
+
publishedOn: string;
|
169 |
+
/** The name of who uploaded the video. */
|
170 |
+
publisher: string;
|
171 |
+
/** The view count of the video. */
|
172 |
+
viewCount?: number;
|
173 |
+
}
|
174 |
+
|
175 |
+
interface NewsResult {
|
176 |
+
/** The timestamp of when the article was created. */
|
177 |
+
date: number;
|
178 |
+
/** An except of the article. */
|
179 |
+
excerpt: string;
|
180 |
+
/** The image URL used in the article. */
|
181 |
+
image?: string;
|
182 |
+
/** The relative time of when the article was posted, in human readable format. */
|
183 |
+
relativeTime: string;
|
184 |
+
/** Where this article was indexed from. */
|
185 |
+
syndicate: string;
|
186 |
+
/** The title of the article. */
|
187 |
+
title: string;
|
188 |
+
/** The URL of the article. */
|
189 |
+
url: string;
|
190 |
+
/** Whether this article is classified as old. */
|
191 |
+
isOld: boolean;
|
192 |
+
}
|
193 |
+
|
194 |
+
interface SearchResult {
|
195 |
+
/** The hostname of the website. (i.e. "google.com") */
|
196 |
+
hostname: string;
|
197 |
+
/** The URL of the result. */
|
198 |
+
url: string;
|
199 |
+
/** The title of the result. */
|
200 |
+
title: string;
|
201 |
+
/**
|
202 |
+
* The sanitized description of the result.
|
203 |
+
* Bold tags will still be present in this string.
|
204 |
+
*/
|
205 |
+
description: string;
|
206 |
+
/** The description of the result. */
|
207 |
+
rawDescription: string;
|
208 |
+
/** The icon of the website. */
|
209 |
+
icon: string;
|
210 |
+
/** The ddg!bang information of the website, if any. */
|
211 |
+
bang?: SearchResultBang;
|
212 |
+
}
|
213 |
+
|
214 |
+
interface SearchResultBang {
|
215 |
+
/** The prefix of the bang. (i.e. "w" for !w) */
|
216 |
+
prefix: string;
|
217 |
+
/** The title of the bang. */
|
218 |
+
title: string;
|
219 |
+
/** The domain of the bang. */
|
220 |
+
domain: string;
|
221 |
+
}
|
222 |
+
|
223 |
+
interface RelatedResult {
|
224 |
+
text: string;
|
225 |
+
raw: string;
|
226 |
+
}
|
227 |
+
|
228 |
+
enum SearchTimeType {
|
229 |
+
/** From any time. */
|
230 |
+
ALL = "a",
|
231 |
+
/** From the past day. */
|
232 |
+
DAY = "d",
|
233 |
+
/** From the past week. */
|
234 |
+
WEEK = "w",
|
235 |
+
/** From the past month. */
|
236 |
+
MONTH = "m",
|
237 |
+
/** From the past year. */
|
238 |
+
YEAR = "y",
|
239 |
+
}
|
240 |
+
|
241 |
+
interface SearchOptions {
|
242 |
+
/** The safe search type of the search. */
|
243 |
+
safeSearch?: SafeSearchType;
|
244 |
+
/** The time range of the searches, can be a SearchTimeType or a date range ("2021-03-16..2021-03-30") */
|
245 |
+
time?: SearchTimeType | string;
|
246 |
+
/** The locale(?) of the search. Defaults to "en-us". */
|
247 |
+
locale?: string;
|
248 |
+
/** The region of the search. Defaults to "wt-wt" or all regions. */
|
249 |
+
region?: string;
|
250 |
+
/** The market region(?) of the search. Defaults to "US". */
|
251 |
+
marketRegion?: string;
|
252 |
+
/** The number to offset the results to. */
|
253 |
+
offset?: number;
|
254 |
+
/**
|
255 |
+
* The string that acts like a key to a search.
|
256 |
+
* Set this if you made a search with the same query.
|
257 |
+
*/
|
258 |
+
vqd?: string;
|
259 |
+
}
|
260 |
+
|
261 |
+
enum SafeSearchType {
|
262 |
+
/** Strict filtering, no NSFW content. */
|
263 |
+
STRICT = 0,
|
264 |
+
/** Moderate filtering. */
|
265 |
+
MODERATE = -1,
|
266 |
+
/** No filtering. */
|
267 |
+
OFF = -2,
|
268 |
+
}
|
269 |
+
|
270 |
+
const defaultOptions: SearchOptions = {
|
271 |
+
safeSearch: SafeSearchType.OFF,
|
272 |
+
time: SearchTimeType.ALL,
|
273 |
+
locale: "en-us",
|
274 |
+
region: "wt-wt",
|
275 |
+
offset: 0,
|
276 |
+
marketRegion: "us",
|
277 |
+
};
|
278 |
+
|
279 |
+
async function search(
|
280 |
+
query: string,
|
281 |
+
options?: SearchOptions,
|
282 |
+
): Promise<SearchResults> {
|
283 |
+
if (!query) throw new Error("Query cannot be empty!");
|
284 |
+
if (!options) options = defaultOptions;
|
285 |
+
else options = sanityCheck(options);
|
286 |
+
|
287 |
+
let vqd = options.vqd!;
|
288 |
+
if (!vqd) vqd = await getVQD(query, "web");
|
289 |
+
|
290 |
+
const queryObject: Record<string, string> = {
|
291 |
+
q: query,
|
292 |
+
...(options.safeSearch !== SafeSearchType.STRICT ? { t: "D" } : {}),
|
293 |
+
l: options.locale!,
|
294 |
+
...(options.safeSearch === SafeSearchType.STRICT ? { p: "1" } : {}),
|
295 |
+
kl: options.region || "wt-wt",
|
296 |
+
s: String(options.offset),
|
297 |
+
dl: "en",
|
298 |
+
ct: "US",
|
299 |
+
ss_mkt: options.marketRegion!,
|
300 |
+
df: options.time! as string,
|
301 |
+
vqd,
|
302 |
+
...(options.safeSearch !== SafeSearchType.STRICT
|
303 |
+
? { ex: String(options.safeSearch) }
|
304 |
+
: {}),
|
305 |
+
sp: "1",
|
306 |
+
bpa: "1",
|
307 |
+
biaexp: "b",
|
308 |
+
msvrtexp: "b",
|
309 |
+
...(options.safeSearch === SafeSearchType.STRICT
|
310 |
+
? {
|
311 |
+
videxp: "a",
|
312 |
+
nadse: "b",
|
313 |
+
eclsexp: "a",
|
314 |
+
stiaexp: "a",
|
315 |
+
tjsexp: "b",
|
316 |
+
related: "b",
|
317 |
+
msnexp: "a",
|
318 |
+
}
|
319 |
+
: {
|
320 |
+
nadse: "b",
|
321 |
+
eclsexp: "b",
|
322 |
+
tjsexp: "b",
|
323 |
+
// cdrexp: 'b'
|
324 |
+
}),
|
325 |
+
};
|
326 |
+
|
327 |
+
const response = await fetch(
|
328 |
+
`https://links.duckduckgo.com/d.js?${queryString(queryObject)}`,
|
329 |
+
);
|
330 |
+
const data = await response.text();
|
331 |
+
|
332 |
+
if (data.includes("DDG.deep.is506"))
|
333 |
+
throw new Error("A server error occurred!");
|
334 |
+
|
335 |
+
const searchResults = JSON.parse(
|
336 |
+
SEARCH_REGEX.exec(data)![1].replace(/\t/g, " "),
|
337 |
+
) as (CallbackSearchResult | CallbackNextSearch)[];
|
338 |
+
|
339 |
+
if (searchResults.length === 1 && !("n" in searchResults[0])) {
|
340 |
+
const onlyResult = searchResults[0] as CallbackSearchResult;
|
341 |
+
/* istanbul ignore next */
|
342 |
+
if (
|
343 |
+
(!onlyResult.da && onlyResult.t === "EOF") ||
|
344 |
+
!onlyResult.a ||
|
345 |
+
onlyResult.d === "google.com search"
|
346 |
+
)
|
347 |
+
return {
|
348 |
+
noResults: true,
|
349 |
+
vqd,
|
350 |
+
results: [],
|
351 |
+
};
|
352 |
+
}
|
353 |
+
|
354 |
+
const results: SearchResults = {
|
355 |
+
noResults: false,
|
356 |
+
vqd,
|
357 |
+
results: [],
|
358 |
+
};
|
359 |
+
|
360 |
+
for (const search of searchResults) {
|
361 |
+
if ("n" in search) continue;
|
362 |
+
let bang: SearchResultBang | undefined;
|
363 |
+
if (search.b) {
|
364 |
+
const [prefix, title, domain] = search.b.split("\t");
|
365 |
+
bang = { prefix, title, domain };
|
366 |
+
}
|
367 |
+
results.results.push({
|
368 |
+
title: search.t,
|
369 |
+
description: decode(search.a),
|
370 |
+
rawDescription: search.a,
|
371 |
+
hostname: search.i,
|
372 |
+
icon: `https://external-content.duckduckgo.com/ip3/${search.i}.ico`,
|
373 |
+
url: search.u,
|
374 |
+
bang,
|
375 |
+
});
|
376 |
+
}
|
377 |
+
|
378 |
+
// Images
|
379 |
+
const imagesMatch = IMAGES_REGEX.exec(data);
|
380 |
+
if (imagesMatch) {
|
381 |
+
const imagesResult = JSON.parse(
|
382 |
+
imagesMatch[1].replace(/\t/g, " "),
|
383 |
+
) as CallbackDuckbarPayload<DuckbarImageResult>;
|
384 |
+
results.images = imagesResult.results.map((i) => {
|
385 |
+
i.title = decode(i.title);
|
386 |
+
return i;
|
387 |
+
});
|
388 |
+
}
|
389 |
+
|
390 |
+
// News
|
391 |
+
const newsMatch = NEWS_REGEX.exec(data);
|
392 |
+
if (newsMatch) {
|
393 |
+
const newsResult = JSON.parse(
|
394 |
+
newsMatch[1].replace(/\t/g, " "),
|
395 |
+
) as CallbackDuckbarPayload<DuckbarNewsResult>;
|
396 |
+
results.news = newsResult.results.map((article) => ({
|
397 |
+
date: article.date,
|
398 |
+
excerpt: decode(article.excerpt),
|
399 |
+
image: article.image,
|
400 |
+
relativeTime: article.relative_time,
|
401 |
+
syndicate: article.syndicate,
|
402 |
+
title: decode(article.title),
|
403 |
+
url: article.url,
|
404 |
+
isOld: !!article.is_old,
|
405 |
+
})) as NewsResult[];
|
406 |
+
}
|
407 |
+
|
408 |
+
// Videos
|
409 |
+
const videosMatch = VIDEOS_REGEX.exec(data);
|
410 |
+
if (videosMatch) {
|
411 |
+
const videoResult = JSON.parse(
|
412 |
+
videosMatch[1].replace(/\t/g, " "),
|
413 |
+
) as CallbackDuckbarPayload<DuckbarVideoResult>;
|
414 |
+
results.videos = [];
|
415 |
+
/* istanbul ignore next */
|
416 |
+
for (const video of videoResult.results) {
|
417 |
+
results.videos.push({
|
418 |
+
url: video.content,
|
419 |
+
title: decode(video.title),
|
420 |
+
description: decode(video.description),
|
421 |
+
image:
|
422 |
+
video.images.large ||
|
423 |
+
video.images.medium ||
|
424 |
+
video.images.small ||
|
425 |
+
video.images.motion,
|
426 |
+
duration: video.duration,
|
427 |
+
publishedOn: video.publisher,
|
428 |
+
published: video.published,
|
429 |
+
publisher: video.uploader,
|
430 |
+
viewCount: video.statistics.viewCount || undefined,
|
431 |
+
});
|
432 |
+
}
|
433 |
+
}
|
434 |
+
|
435 |
+
// Related Searches
|
436 |
+
const relatedMatch = RELATED_SEARCHES_REGEX.exec(data);
|
437 |
+
if (relatedMatch) {
|
438 |
+
const relatedResult = JSON.parse(
|
439 |
+
relatedMatch[1].replace(/\t/g, " "),
|
440 |
+
) as CallbackDuckbarPayload<DuckbarRelatedSearch>;
|
441 |
+
results.related = [];
|
442 |
+
for (const related of relatedResult.results) {
|
443 |
+
results.related.push({
|
444 |
+
text: related.text,
|
445 |
+
raw: related.display_text,
|
446 |
+
});
|
447 |
+
}
|
448 |
+
}
|
449 |
+
return results;
|
450 |
+
}
|
451 |
+
|
452 |
+
function queryString(query: Record<string, string>) {
|
453 |
+
return new URLSearchParams(query).toString();
|
454 |
+
}
|
455 |
+
|
456 |
+
async function getVQD(query: string, ia = "web") {
|
457 |
+
try {
|
458 |
+
const response = await fetch(
|
459 |
+
`https://duckduckgo.com/?${queryString({ q: query, ia })}`,
|
460 |
+
);
|
461 |
+
const data = await response.text();
|
462 |
+
return VQD_REGEX.exec(data)![1];
|
463 |
+
} catch (e) {
|
464 |
+
throw new Error(`Failed to get the VQD for query "${query}".`);
|
465 |
+
}
|
466 |
+
}
|
467 |
+
|
468 |
+
function sanityCheck(options: SearchOptions) {
|
469 |
+
options = Object.assign({}, defaultOptions, options);
|
470 |
+
|
471 |
+
if (!(options.safeSearch! in SafeSearchType))
|
472 |
+
throw new TypeError(
|
473 |
+
`${options.safeSearch} is an invalid safe search type!`,
|
474 |
+
);
|
475 |
+
|
476 |
+
/* istanbul ignore next */
|
477 |
+
if (typeof options.safeSearch! === "string")
|
478 |
+
options.safeSearch = SafeSearchType[
|
479 |
+
options.safeSearch!
|
480 |
+
] as any as SafeSearchType;
|
481 |
+
|
482 |
+
if (typeof options.offset !== "number")
|
483 |
+
throw new TypeError(`Search offset is not a number!`);
|
484 |
+
|
485 |
+
if (options.offset! < 0)
|
486 |
+
throw new RangeError("Search offset cannot be below zero!");
|
487 |
+
|
488 |
+
if (
|
489 |
+
options.time &&
|
490 |
+
!Object.values(SearchTimeType).includes(options.time as SearchTimeType) &&
|
491 |
+
!/\d{4}-\d{2}-\d{2}..\d{4}-\d{2}-\d{2}/.test(options.time as string)
|
492 |
+
)
|
493 |
+
throw new TypeError(`${options.time} is an invalid search time!`);
|
494 |
+
|
495 |
+
if (!options.locale || typeof options.locale! !== "string")
|
496 |
+
throw new TypeError("Search locale must be a string!");
|
497 |
+
|
498 |
+
if (!options.region || typeof options.region! !== "string")
|
499 |
+
throw new TypeError("Search region must be a string!");
|
500 |
+
|
501 |
+
if (!options.marketRegion || typeof options.marketRegion! !== "string")
|
502 |
+
throw new TypeError("Search market region must be a string!");
|
503 |
+
|
504 |
+
if (options.vqd && !/\d-\d+-\d+/.test(options.vqd))
|
505 |
+
throw new Error(`${options.vqd} is an invalid VQD!`);
|
506 |
+
|
507 |
+
return options;
|
508 |
+
}
|
509 |
+
|
510 |
+
export class DuckDuckGo extends Tool {
|
511 |
+
name = "duckduckgo_search";
|
512 |
+
maxResults = 4;
|
513 |
+
|
514 |
+
/** @ignore */
|
515 |
+
async _call(input: string) {
|
516 |
+
const searchResults = await search(input, {
|
517 |
+
safeSearch: SafeSearchType.OFF,
|
518 |
+
});
|
519 |
+
|
520 |
+
if (searchResults.noResults) {
|
521 |
+
return "No good search result found";
|
522 |
+
}
|
523 |
+
|
524 |
+
const results = searchResults.results
|
525 |
+
.slice(0, this.maxResults)
|
526 |
+
.map(({ title, description, url }) => htmlToText(description))
|
527 |
+
.join("\n\n");
|
528 |
+
|
529 |
+
return results;
|
530 |
+
}
|
531 |
+
|
532 |
+
description =
|
533 |
+
"a search engine. useful for when you need to answer questions about current events. input should be a search query.";
|
534 |
+
}
|
app/api/langchain-tools/google_search.ts
ADDED
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { decode } from "html-entities";
|
2 |
+
import { convert as htmlToText } from "html-to-text";
|
3 |
+
import { Tool } from "langchain/tools";
|
4 |
+
import * as cheerio from "cheerio";
|
5 |
+
import { getRandomUserAgent } from "./ua_tools";
|
6 |
+
|
7 |
+
interface SearchResults {
|
8 |
+
/** The web results of the search. */
|
9 |
+
results: SearchResult[];
|
10 |
+
}
|
11 |
+
|
12 |
+
interface SearchResult {
|
13 |
+
/** The URL of the result. */
|
14 |
+
url: string;
|
15 |
+
/** The title of the result. */
|
16 |
+
title: string;
|
17 |
+
/**
|
18 |
+
* The sanitized description of the result.
|
19 |
+
* Bold tags will still be present in this string.
|
20 |
+
*/
|
21 |
+
description: string;
|
22 |
+
}
|
23 |
+
|
24 |
+
async function search(
|
25 |
+
input: string,
|
26 |
+
maxResults: number,
|
27 |
+
): Promise<SearchResults> {
|
28 |
+
const results: SearchResults = {
|
29 |
+
results: [],
|
30 |
+
};
|
31 |
+
const headers = new Headers();
|
32 |
+
headers.append("User-Agent", getRandomUserAgent());
|
33 |
+
const resp = await fetch(
|
34 |
+
`https://www.google.com/search?nfpr=1&num=${maxResults}&pws=0&q=${encodeURIComponent(
|
35 |
+
input,
|
36 |
+
)}`,
|
37 |
+
{
|
38 |
+
headers: headers,
|
39 |
+
},
|
40 |
+
);
|
41 |
+
const respCheerio = cheerio.load(await resp.text());
|
42 |
+
respCheerio("div.g").each((i, elem) => {
|
43 |
+
const item = cheerio.load(elem);
|
44 |
+
const linkElement = item("a");
|
45 |
+
const url = (linkElement.attr("href") ?? "").trim();
|
46 |
+
if (url !== "" && url !== "#") {
|
47 |
+
const title = decode(item("h3").text());
|
48 |
+
const description = item(`div[data-sncf~="1"]`).text().trim();
|
49 |
+
results.results.push({
|
50 |
+
url,
|
51 |
+
title,
|
52 |
+
description,
|
53 |
+
});
|
54 |
+
}
|
55 |
+
});
|
56 |
+
return results;
|
57 |
+
}
|
58 |
+
|
59 |
+
export class GoogleSearch extends Tool {
|
60 |
+
name = "google_search";
|
61 |
+
maxResults = 6;
|
62 |
+
|
63 |
+
/** @ignore */
|
64 |
+
async _call(input: string) {
|
65 |
+
const searchResults = await search(input, this.maxResults);
|
66 |
+
|
67 |
+
if (searchResults.results.length === 0) {
|
68 |
+
return "No good search result found";
|
69 |
+
}
|
70 |
+
|
71 |
+
const results = searchResults.results
|
72 |
+
.slice(0, this.maxResults)
|
73 |
+
.map(({ title, description, url }) => htmlToText(description))
|
74 |
+
.join("\n\n");
|
75 |
+
return results;
|
76 |
+
}
|
77 |
+
|
78 |
+
description =
|
79 |
+
"a search engine. useful for when you need to answer questions about current events. input should be a search query.";
|
80 |
+
}
|
app/api/langchain-tools/http_get.ts
ADDED
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { htmlToText } from "html-to-text";
|
2 |
+
import { Tool } from "langchain/tools";
|
3 |
+
|
4 |
+
export interface Headers {
|
5 |
+
[key: string]: string;
|
6 |
+
}
|
7 |
+
|
8 |
+
export interface RequestTool {
|
9 |
+
headers: Headers;
|
10 |
+
maxOutputLength?: number;
|
11 |
+
timeout: number;
|
12 |
+
}
|
13 |
+
|
14 |
+
export class HttpGetTool extends Tool implements RequestTool {
|
15 |
+
name = "http_get";
|
16 |
+
|
17 |
+
maxOutputLength = Infinity;
|
18 |
+
|
19 |
+
timeout = 10000;
|
20 |
+
|
21 |
+
constructor(
|
22 |
+
public headers: Headers = {},
|
23 |
+
{ maxOutputLength }: { maxOutputLength?: number } = {},
|
24 |
+
{ timeout }: { timeout?: number } = {},
|
25 |
+
) {
|
26 |
+
super(...arguments);
|
27 |
+
|
28 |
+
this.maxOutputLength = maxOutputLength ?? this.maxOutputLength;
|
29 |
+
this.timeout = timeout ?? this.timeout;
|
30 |
+
}
|
31 |
+
|
32 |
+
/** @ignore */
|
33 |
+
async _call(input: string) {
|
34 |
+
try {
|
35 |
+
const res = await this.fetchWithTimeout(
|
36 |
+
input,
|
37 |
+
{
|
38 |
+
headers: this.headers,
|
39 |
+
},
|
40 |
+
this.timeout,
|
41 |
+
);
|
42 |
+
let text = await res.text();
|
43 |
+
text = htmlToText(text);
|
44 |
+
text = text.slice(0, this.maxOutputLength);
|
45 |
+
console.log(text);
|
46 |
+
return text;
|
47 |
+
} catch (error) {
|
48 |
+
console.error(error);
|
49 |
+
return (error as Error).toString();
|
50 |
+
}
|
51 |
+
}
|
52 |
+
|
53 |
+
async fetchWithTimeout(
|
54 |
+
resource: RequestInfo | URL,
|
55 |
+
options = {},
|
56 |
+
timeout: number = 30000,
|
57 |
+
) {
|
58 |
+
const controller = new AbortController();
|
59 |
+
const id = setTimeout(() => controller.abort(), timeout);
|
60 |
+
const response = await fetch(resource, {
|
61 |
+
...options,
|
62 |
+
signal: controller.signal,
|
63 |
+
});
|
64 |
+
clearTimeout(id);
|
65 |
+
return response;
|
66 |
+
}
|
67 |
+
|
68 |
+
description = `A portal to the internet. Use this when you need to get specific content from a website.
|
69 |
+
Input should be a url string (i.e. "https://www.google.com"). The output will be the text response of the GET request.`;
|
70 |
+
}
|
app/api/langchain-tools/ua_tools.ts
ADDED
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const uaList = [
|
2 |
+
// Chrome
|
3 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.61 Safari/537.36",
|
4 |
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.61 Safari/537.36",
|
5 |
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.61 Safari/537.36",
|
6 |
+
// Firefox
|
7 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:93.0) Gecko/20100101 Firefox/93.0",
|
8 |
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:93.0) Gecko/20100101 Firefox/93.0",
|
9 |
+
"Mozilla/5.0 (X11; Linux x86_64; rv:93.0) Gecko/20100101 Firefox/93.0",
|
10 |
+
// Safari
|
11 |
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Safari/605.1.15",
|
12 |
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 11_6_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Safari/605.1.15",
|
13 |
+
// Microsoft Edge
|
14 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.61 Safari/537.36 Edg/94.0.992.38",
|
15 |
+
];
|
16 |
+
|
17 |
+
export function getRandomUserAgent() {
|
18 |
+
const randomIndex = Math.floor(Math.random() * uaList.length);
|
19 |
+
return uaList[randomIndex];
|
20 |
+
}
|
app/api/langchain/tool/agent/route.ts
ADDED
@@ -0,0 +1,311 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { NextRequest, NextResponse } from "next/server";
|
2 |
+
import { getServerSideConfig } from "@/app/config/server";
|
3 |
+
import { auth } from "../../../auth";
|
4 |
+
|
5 |
+
import { ChatOpenAI } from "langchain/chat_models/openai";
|
6 |
+
import { BaseCallbackHandler } from "langchain/callbacks";
|
7 |
+
|
8 |
+
import { AIMessage, HumanMessage, SystemMessage } from "langchain/schema";
|
9 |
+
import { BufferMemory, ChatMessageHistory } from "langchain/memory";
|
10 |
+
import { initializeAgentExecutorWithOptions } from "langchain/agents";
|
11 |
+
import { ACCESS_CODE_PREFIX } from "@/app/constant";
|
12 |
+
import { OpenAI } from "langchain/llms/openai";
|
13 |
+
import { OpenAIEmbeddings } from "langchain/embeddings/openai";
|
14 |
+
|
15 |
+
import * as langchainTools from "langchain/tools";
|
16 |
+
import { HttpGetTool } from "@/app/api/langchain-tools/http_get";
|
17 |
+
import { DuckDuckGo } from "@/app/api/langchain-tools/duckduckgo_search";
|
18 |
+
import { WebBrowser } from "langchain/tools/webbrowser";
|
19 |
+
import { Calculator } from "langchain/tools/calculator";
|
20 |
+
import { DynamicTool, Tool } from "langchain/tools";
|
21 |
+
import { BaiduSearch } from "@/app/api/langchain-tools/baidu_search";
|
22 |
+
import { GoogleSearch } from "@/app/api/langchain-tools/google_search";
|
23 |
+
import { ArxivAPIWrapper } from "@/app/api/langchain-tools/arxiv";
|
24 |
+
|
25 |
+
const serverConfig = getServerSideConfig();
|
26 |
+
|
27 |
+
interface RequestMessage {
|
28 |
+
role: string;
|
29 |
+
content: string;
|
30 |
+
}
|
31 |
+
|
32 |
+
interface RequestBody {
|
33 |
+
messages: RequestMessage[];
|
34 |
+
model: string;
|
35 |
+
stream?: boolean;
|
36 |
+
temperature: number;
|
37 |
+
presence_penalty?: number;
|
38 |
+
frequency_penalty?: number;
|
39 |
+
top_p?: number;
|
40 |
+
baseUrl?: string;
|
41 |
+
apiKey?: string;
|
42 |
+
maxIterations: number;
|
43 |
+
returnIntermediateSteps: boolean;
|
44 |
+
useTools: (undefined | string)[];
|
45 |
+
}
|
46 |
+
|
47 |
+
class ResponseBody {
|
48 |
+
isSuccess: boolean = true;
|
49 |
+
message!: string;
|
50 |
+
isToolMessage: boolean = false;
|
51 |
+
toolName?: string;
|
52 |
+
}
|
53 |
+
|
54 |
+
interface ToolInput {
|
55 |
+
input: string;
|
56 |
+
}
|
57 |
+
|
58 |
+
async function handle(req: NextRequest) {
|
59 |
+
if (req.method === "OPTIONS") {
|
60 |
+
return NextResponse.json({ body: "OK" }, { status: 200 });
|
61 |
+
}
|
62 |
+
try {
|
63 |
+
const authResult = auth(req);
|
64 |
+
if (authResult.error) {
|
65 |
+
return NextResponse.json(authResult, {
|
66 |
+
status: 401,
|
67 |
+
});
|
68 |
+
}
|
69 |
+
|
70 |
+
const encoder = new TextEncoder();
|
71 |
+
const transformStream = new TransformStream();
|
72 |
+
const writer = transformStream.writable.getWriter();
|
73 |
+
const reqBody: RequestBody = await req.json();
|
74 |
+
const authToken = req.headers.get("Authorization") ?? "";
|
75 |
+
const token = authToken.trim().replaceAll("Bearer ", "").trim();
|
76 |
+
const isOpenAiKey = !token.startsWith(ACCESS_CODE_PREFIX);
|
77 |
+
let useTools = reqBody.useTools ?? [];
|
78 |
+
let apiKey = serverConfig.apiKey;
|
79 |
+
if (isOpenAiKey && token) {
|
80 |
+
apiKey = token;
|
81 |
+
}
|
82 |
+
|
83 |
+
// support base url
|
84 |
+
let baseUrl = "https://api.openai.com/v1";
|
85 |
+
if (serverConfig.baseUrl) baseUrl = serverConfig.baseUrl;
|
86 |
+
if (
|
87 |
+
reqBody.baseUrl?.startsWith("http://") ||
|
88 |
+
reqBody.baseUrl?.startsWith("https://")
|
89 |
+
)
|
90 |
+
baseUrl = reqBody.baseUrl;
|
91 |
+
if (!baseUrl.endsWith("/v1"))
|
92 |
+
baseUrl = baseUrl.endsWith("/") ? `${baseUrl}v1` : `${baseUrl}/v1`;
|
93 |
+
console.log("[baseUrl]", baseUrl);
|
94 |
+
|
95 |
+
const handler = BaseCallbackHandler.fromMethods({
|
96 |
+
async handleLLMNewToken(token: string) {
|
97 |
+
// console.log("[Token]", token);
|
98 |
+
if (token) {
|
99 |
+
var response = new ResponseBody();
|
100 |
+
response.message = token;
|
101 |
+
await writer.ready;
|
102 |
+
await writer.write(
|
103 |
+
encoder.encode(`data: ${JSON.stringify(response)}\n\n`),
|
104 |
+
);
|
105 |
+
}
|
106 |
+
},
|
107 |
+
async handleChainError(err, runId, parentRunId, tags) {
|
108 |
+
console.log("[handleChainError]", err, "writer error");
|
109 |
+
var response = new ResponseBody();
|
110 |
+
response.isSuccess = false;
|
111 |
+
response.message = err;
|
112 |
+
await writer.ready;
|
113 |
+
await writer.write(
|
114 |
+
encoder.encode(`data: ${JSON.stringify(response)}\n\n`),
|
115 |
+
);
|
116 |
+
await writer.close();
|
117 |
+
},
|
118 |
+
async handleChainEnd(outputs, runId, parentRunId, tags) {
|
119 |
+
console.log("[handleChainEnd]");
|
120 |
+
await writer.ready;
|
121 |
+
await writer.close();
|
122 |
+
},
|
123 |
+
async handleLLMEnd() {
|
124 |
+
// await writer.ready;
|
125 |
+
// await writer.close();
|
126 |
+
},
|
127 |
+
async handleLLMError(e: Error) {
|
128 |
+
console.log("[handleLLMError]", e, "writer error");
|
129 |
+
var response = new ResponseBody();
|
130 |
+
response.isSuccess = false;
|
131 |
+
response.message = e.message;
|
132 |
+
await writer.ready;
|
133 |
+
await writer.write(
|
134 |
+
encoder.encode(`data: ${JSON.stringify(response)}\n\n`),
|
135 |
+
);
|
136 |
+
await writer.close();
|
137 |
+
},
|
138 |
+
handleLLMStart(llm, _prompts: string[]) {
|
139 |
+
// console.log("handleLLMStart: I'm the second handler!!", { llm });
|
140 |
+
},
|
141 |
+
handleChainStart(chain) {
|
142 |
+
// console.log("handleChainStart: I'm the second handler!!", { chain });
|
143 |
+
},
|
144 |
+
async handleAgentAction(action) {
|
145 |
+
try {
|
146 |
+
console.log("[handleAgentAction]", action.tool);
|
147 |
+
if (!reqBody.returnIntermediateSteps) return;
|
148 |
+
var response = new ResponseBody();
|
149 |
+
response.isToolMessage = true;
|
150 |
+
response.message = JSON.stringify(action.toolInput);
|
151 |
+
response.toolName = action.tool;
|
152 |
+
await writer.ready;
|
153 |
+
await writer.write(
|
154 |
+
encoder.encode(`data: ${JSON.stringify(response)}\n\n`),
|
155 |
+
);
|
156 |
+
} catch (ex) {
|
157 |
+
console.error("[handleAgentAction]", ex);
|
158 |
+
var response = new ResponseBody();
|
159 |
+
response.isSuccess = false;
|
160 |
+
response.message = (ex as Error).message;
|
161 |
+
await writer.ready;
|
162 |
+
await writer.write(
|
163 |
+
encoder.encode(`data: ${JSON.stringify(response)}\n\n`),
|
164 |
+
);
|
165 |
+
await writer.close();
|
166 |
+
}
|
167 |
+
},
|
168 |
+
handleToolStart(tool, input) {
|
169 |
+
console.log("[handleToolStart]", { tool });
|
170 |
+
},
|
171 |
+
async handleToolEnd(output, runId, parentRunId, tags) {
|
172 |
+
console.log("[handleToolEnd]", { output, runId, parentRunId, tags });
|
173 |
+
},
|
174 |
+
handleAgentEnd(action, runId, parentRunId, tags) {
|
175 |
+
console.log("[handleAgentEnd]");
|
176 |
+
},
|
177 |
+
});
|
178 |
+
|
179 |
+
let searchTool: Tool = new DuckDuckGo();
|
180 |
+
if (process.env.CHOOSE_SEARCH_ENGINE) {
|
181 |
+
switch (process.env.CHOOSE_SEARCH_ENGINE) {
|
182 |
+
case "google":
|
183 |
+
searchTool = new GoogleSearch();
|
184 |
+
break;
|
185 |
+
case "baidu":
|
186 |
+
searchTool = new BaiduSearch();
|
187 |
+
break;
|
188 |
+
}
|
189 |
+
}
|
190 |
+
if (process.env.BING_SEARCH_API_KEY) {
|
191 |
+
let bingSearchTool = new langchainTools["BingSerpAPI"](
|
192 |
+
process.env.BING_SEARCH_API_KEY,
|
193 |
+
);
|
194 |
+
searchTool = new DynamicTool({
|
195 |
+
name: "bing_search",
|
196 |
+
description: bingSearchTool.description,
|
197 |
+
func: async (input: string) => bingSearchTool.call(input),
|
198 |
+
});
|
199 |
+
}
|
200 |
+
if (process.env.SERPAPI_API_KEY) {
|
201 |
+
let serpAPITool = new langchainTools["SerpAPI"](
|
202 |
+
process.env.SERPAPI_API_KEY,
|
203 |
+
);
|
204 |
+
searchTool = new DynamicTool({
|
205 |
+
name: "google_search",
|
206 |
+
description: serpAPITool.description,
|
207 |
+
func: async (input: string) => serpAPITool.call(input),
|
208 |
+
});
|
209 |
+
}
|
210 |
+
|
211 |
+
const model = new OpenAI(
|
212 |
+
{
|
213 |
+
temperature: 0,
|
214 |
+
modelName: reqBody.model,
|
215 |
+
openAIApiKey: apiKey,
|
216 |
+
},
|
217 |
+
{ basePath: baseUrl },
|
218 |
+
);
|
219 |
+
const embeddings = new OpenAIEmbeddings(
|
220 |
+
{
|
221 |
+
openAIApiKey: apiKey,
|
222 |
+
},
|
223 |
+
{ basePath: baseUrl },
|
224 |
+
);
|
225 |
+
|
226 |
+
const tools = [
|
227 |
+
// new RequestsGetTool(),
|
228 |
+
// new RequestsPostTool(),
|
229 |
+
];
|
230 |
+
const webBrowserTool = new WebBrowser({ model, embeddings });
|
231 |
+
const calculatorTool = new Calculator();
|
232 |
+
const arxivAPITool = new ArxivAPIWrapper();
|
233 |
+
if (useTools.includes("web-search")) tools.push(searchTool);
|
234 |
+
if (useTools.includes(webBrowserTool.name)) tools.push(webBrowserTool);
|
235 |
+
if (useTools.includes(calculatorTool.name)) tools.push(calculatorTool);
|
236 |
+
if (useTools.includes(arxivAPITool.name)) tools.push(arxivAPITool);
|
237 |
+
|
238 |
+
useTools.forEach((toolName) => {
|
239 |
+
if (toolName) {
|
240 |
+
var tool = langchainTools[
|
241 |
+
toolName as keyof typeof langchainTools
|
242 |
+
] as any;
|
243 |
+
if (tool) {
|
244 |
+
tools.push(new tool());
|
245 |
+
}
|
246 |
+
}
|
247 |
+
});
|
248 |
+
|
249 |
+
const pastMessages = new Array();
|
250 |
+
|
251 |
+
reqBody.messages
|
252 |
+
.slice(0, reqBody.messages.length - 1)
|
253 |
+
.forEach((message) => {
|
254 |
+
if (message.role === "system")
|
255 |
+
pastMessages.push(new SystemMessage(message.content));
|
256 |
+
if (message.role === "user")
|
257 |
+
pastMessages.push(new HumanMessage(message.content));
|
258 |
+
if (message.role === "assistant")
|
259 |
+
pastMessages.push(new AIMessage(message.content));
|
260 |
+
});
|
261 |
+
|
262 |
+
const memory = new BufferMemory({
|
263 |
+
memoryKey: "chat_history",
|
264 |
+
returnMessages: true,
|
265 |
+
inputKey: "input",
|
266 |
+
outputKey: "output",
|
267 |
+
chatHistory: new ChatMessageHistory(pastMessages),
|
268 |
+
});
|
269 |
+
|
270 |
+
const llm = new ChatOpenAI(
|
271 |
+
{
|
272 |
+
modelName: reqBody.model,
|
273 |
+
openAIApiKey: apiKey,
|
274 |
+
temperature: reqBody.temperature,
|
275 |
+
streaming: reqBody.stream,
|
276 |
+
topP: reqBody.top_p,
|
277 |
+
presencePenalty: reqBody.presence_penalty,
|
278 |
+
frequencyPenalty: reqBody.frequency_penalty,
|
279 |
+
},
|
280 |
+
{ basePath: baseUrl },
|
281 |
+
);
|
282 |
+
const executor = await initializeAgentExecutorWithOptions(tools, llm, {
|
283 |
+
agentType: "openai-functions",
|
284 |
+
returnIntermediateSteps: reqBody.returnIntermediateSteps,
|
285 |
+
maxIterations: reqBody.maxIterations,
|
286 |
+
memory: memory,
|
287 |
+
});
|
288 |
+
|
289 |
+
executor.call(
|
290 |
+
{
|
291 |
+
input: reqBody.messages.slice(-1)[0].content,
|
292 |
+
},
|
293 |
+
[handler],
|
294 |
+
);
|
295 |
+
|
296 |
+
console.log("returning response");
|
297 |
+
return new Response(transformStream.readable, {
|
298 |
+
headers: { "Content-Type": "text/event-stream" },
|
299 |
+
});
|
300 |
+
} catch (e) {
|
301 |
+
return new Response(JSON.stringify({ error: (e as any).message }), {
|
302 |
+
status: 500,
|
303 |
+
headers: { "Content-Type": "application/json" },
|
304 |
+
});
|
305 |
+
}
|
306 |
+
}
|
307 |
+
|
308 |
+
export const GET = handle;
|
309 |
+
export const POST = handle;
|
310 |
+
|
311 |
+
export const runtime = "edge";
|
app/api/openai/[...path]/route.ts
ADDED
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { type OpenAIListModelResponse } from "@/app/client/platforms/openai";
|
2 |
+
import { getServerSideConfig } from "@/app/config/server";
|
3 |
+
import { OpenaiPath } from "@/app/constant";
|
4 |
+
import { prettyObject } from "@/app/utils/format";
|
5 |
+
import { NextRequest, NextResponse } from "next/server";
|
6 |
+
import { auth } from "../../auth";
|
7 |
+
import { requestOpenai } from "../../common";
|
8 |
+
|
9 |
+
const ALLOWD_PATH = new Set(Object.values(OpenaiPath));
|
10 |
+
|
11 |
+
function getModels(remoteModelRes: OpenAIListModelResponse) {
|
12 |
+
const config = getServerSideConfig();
|
13 |
+
|
14 |
+
if (config.disableGPT4) {
|
15 |
+
remoteModelRes.data = remoteModelRes.data.filter(
|
16 |
+
(m) => !m.id.startsWith("gpt-4"),
|
17 |
+
);
|
18 |
+
}
|
19 |
+
|
20 |
+
return remoteModelRes;
|
21 |
+
}
|
22 |
+
|
23 |
+
async function handle(
|
24 |
+
req: NextRequest,
|
25 |
+
{ params }: { params: { path: string[] } },
|
26 |
+
) {
|
27 |
+
console.log("[OpenAI Route] params ", params);
|
28 |
+
|
29 |
+
if (req.method === "OPTIONS") {
|
30 |
+
return NextResponse.json({ body: "OK" }, { status: 200 });
|
31 |
+
}
|
32 |
+
|
33 |
+
const subpath = params.path.join("/");
|
34 |
+
|
35 |
+
if (!ALLOWD_PATH.has(subpath)) {
|
36 |
+
console.log("[OpenAI Route] forbidden path ", subpath);
|
37 |
+
return NextResponse.json(
|
38 |
+
{
|
39 |
+
error: true,
|
40 |
+
msg: "you are not allowed to request " + subpath,
|
41 |
+
},
|
42 |
+
{
|
43 |
+
status: 403,
|
44 |
+
},
|
45 |
+
);
|
46 |
+
}
|
47 |
+
|
48 |
+
const authResult = auth(req);
|
49 |
+
if (authResult.error) {
|
50 |
+
return NextResponse.json(authResult, {
|
51 |
+
status: 401,
|
52 |
+
});
|
53 |
+
}
|
54 |
+
|
55 |
+
try {
|
56 |
+
const response = await requestOpenai(req);
|
57 |
+
|
58 |
+
// list models
|
59 |
+
if (subpath === OpenaiPath.ListModelPath && response.status === 200) {
|
60 |
+
const resJson = (await response.json()) as OpenAIListModelResponse;
|
61 |
+
const availableModels = getModels(resJson);
|
62 |
+
return NextResponse.json(availableModels, {
|
63 |
+
status: response.status,
|
64 |
+
});
|
65 |
+
}
|
66 |
+
|
67 |
+
return response;
|
68 |
+
} catch (e) {
|
69 |
+
console.error("[OpenAI] ", e);
|
70 |
+
return NextResponse.json(prettyObject(e));
|
71 |
+
}
|
72 |
+
}
|
73 |
+
|
74 |
+
export const GET = handle;
|
75 |
+
export const POST = handle;
|
76 |
+
|
77 |
+
export const runtime = "edge";
|
app/client/api.ts
ADDED
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { getClientConfig } from "../config/client";
|
2 |
+
import { ACCESS_CODE_PREFIX } from "../constant";
|
3 |
+
import { ChatMessage, ModelType, useAccessStore } from "../store";
|
4 |
+
import { ChatGPTApi } from "./platforms/openai";
|
5 |
+
|
6 |
+
export const ROLES = ["system", "user", "assistant"] as const;
|
7 |
+
export type MessageRole = (typeof ROLES)[number];
|
8 |
+
|
9 |
+
export const Models = ["A N I M A"] as const;
|
10 |
+
export type ChatModel = ModelType;
|
11 |
+
|
12 |
+
export interface RequestMessage {
|
13 |
+
role: MessageRole;
|
14 |
+
content: string;
|
15 |
+
}
|
16 |
+
|
17 |
+
export interface LLMConfig {
|
18 |
+
model: string;
|
19 |
+
temperature?: number;
|
20 |
+
top_p?: number;
|
21 |
+
stream?: boolean;
|
22 |
+
presence_penalty?: number;
|
23 |
+
frequency_penalty?: number;
|
24 |
+
}
|
25 |
+
|
26 |
+
export interface LLMAgentConfig {
|
27 |
+
maxIterations: number;
|
28 |
+
returnIntermediateSteps: boolean;
|
29 |
+
useTools?: (string | undefined)[];
|
30 |
+
}
|
31 |
+
|
32 |
+
export interface ChatOptions {
|
33 |
+
messages: RequestMessage[];
|
34 |
+
config: LLMConfig;
|
35 |
+
onToolUpdate?: (toolName: string, toolInput: string) => void;
|
36 |
+
onUpdate?: (message: string, chunk: string) => void;
|
37 |
+
onFinish: (message: string) => void;
|
38 |
+
onError?: (err: Error) => void;
|
39 |
+
onController?: (controller: AbortController) => void;
|
40 |
+
}
|
41 |
+
|
42 |
+
export interface AgentChatOptions {
|
43 |
+
messages: RequestMessage[];
|
44 |
+
config: LLMConfig;
|
45 |
+
agentConfig: LLMAgentConfig;
|
46 |
+
onToolUpdate?: (toolName: string, toolInput: string) => void;
|
47 |
+
onUpdate?: (message: string, chunk: string) => void;
|
48 |
+
onFinish: (message: string) => void;
|
49 |
+
onError?: (err: Error) => void;
|
50 |
+
onController?: (controller: AbortController) => void;
|
51 |
+
}
|
52 |
+
|
53 |
+
export interface LLMUsage {
|
54 |
+
used: number;
|
55 |
+
total: number;
|
56 |
+
}
|
57 |
+
|
58 |
+
export interface LLMModel {
|
59 |
+
name: string;
|
60 |
+
available: boolean;
|
61 |
+
}
|
62 |
+
|
63 |
+
export abstract class LLMApi {
|
64 |
+
abstract chat(options: ChatOptions): Promise<void>;
|
65 |
+
abstract toolAgentChat(options: AgentChatOptions): Promise<void>;
|
66 |
+
abstract usage(): Promise<LLMUsage>;
|
67 |
+
abstract models(): Promise<LLMModel[]>;
|
68 |
+
}
|
69 |
+
|
70 |
+
type ProviderName = "openai" | "azure" | "claude" | "palm";
|
71 |
+
|
72 |
+
interface Model {
|
73 |
+
name: string;
|
74 |
+
provider: ProviderName;
|
75 |
+
ctxlen: number;
|
76 |
+
}
|
77 |
+
|
78 |
+
interface ChatProvider {
|
79 |
+
name: ProviderName;
|
80 |
+
apiConfig: {
|
81 |
+
baseUrl: string;
|
82 |
+
apiKey: string;
|
83 |
+
summaryModel: Model;
|
84 |
+
};
|
85 |
+
models: Model[];
|
86 |
+
|
87 |
+
chat: () => void;
|
88 |
+
usage: () => void;
|
89 |
+
}
|
90 |
+
|
91 |
+
export abstract class ToolApi {
|
92 |
+
abstract call(input: string): Promise<string>;
|
93 |
+
abstract name: string;
|
94 |
+
abstract description: string;
|
95 |
+
}
|
96 |
+
|
97 |
+
export class ClientApi {
|
98 |
+
public llm: LLMApi;
|
99 |
+
|
100 |
+
constructor() {
|
101 |
+
this.llm = new ChatGPTApi();
|
102 |
+
}
|
103 |
+
|
104 |
+
config() {}
|
105 |
+
|
106 |
+
prompts() {}
|
107 |
+
|
108 |
+
masks() {}
|
109 |
+
|
110 |
+
async share(messages: ChatMessage[], avatarUrl: string | null = null) {
|
111 |
+
const msgs = messages
|
112 |
+
.map((m) => ({
|
113 |
+
from: m.role === "user" ? "human" : "gpt",
|
114 |
+
value: m.content,
|
115 |
+
}))
|
116 |
+
.concat([
|
117 |
+
{
|
118 |
+
from: "human",
|
119 |
+
value: "Share from [A N I M A]: https://www.animabiomimicry.org",
|
120 |
+
},
|
121 |
+
]);
|
122 |
+
// 敬告二开开发者们,为了开源大模型的发展,请不要修改上述消息,此消息用于后续数据清洗使用
|
123 |
+
// Please do not modify this message
|
124 |
+
|
125 |
+
console.log("[Share]", messages, msgs);
|
126 |
+
const clientConfig = getClientConfig();
|
127 |
+
const proxyUrl = "/sharegpt";
|
128 |
+
const rawUrl = "https://sharegpt.com/api/conversations";
|
129 |
+
const shareUrl = clientConfig?.isApp ? rawUrl : proxyUrl;
|
130 |
+
const res = await fetch(shareUrl, {
|
131 |
+
body: JSON.stringify({
|
132 |
+
avatarUrl,
|
133 |
+
items: msgs,
|
134 |
+
}),
|
135 |
+
headers: {
|
136 |
+
"Content-Type": "application/json",
|
137 |
+
},
|
138 |
+
method: "POST",
|
139 |
+
});
|
140 |
+
|
141 |
+
const resJson = await res.json();
|
142 |
+
console.log("[Share]", resJson);
|
143 |
+
if (resJson.id) {
|
144 |
+
return `https://shareg.pt/${resJson.id}`;
|
145 |
+
}
|
146 |
+
}
|
147 |
+
}
|
148 |
+
|
149 |
+
export const api = new ClientApi();
|
150 |
+
|
151 |
+
function makeBearer(token: string): string {
|
152 |
+
return `Bearer ${token}`;
|
153 |
+
}
|
154 |
+
|
155 |
+
export function getHeaders() {
|
156 |
+
const accessStore = useAccessStore.getState();
|
157 |
+
const authHeader = 'Authorization'; // Define authHeader
|
158 |
+
const headers: Record<string, string> = {
|
159 |
+
"Content-Type": "application/json",
|
160 |
+
"x-requested-with": "XMLHttpRequest",
|
161 |
+
};
|
162 |
+
|
163 |
+
return headers;
|
164 |
+
}
|
app/client/controller.ts
ADDED
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// To store message streaming controller
|
2 |
+
export const ChatControllerPool = {
|
3 |
+
controllers: {} as Record<string, AbortController>,
|
4 |
+
|
5 |
+
addController(
|
6 |
+
sessionId: string,
|
7 |
+
messageId: string,
|
8 |
+
controller: AbortController,
|
9 |
+
) {
|
10 |
+
const key = this.key(sessionId, messageId);
|
11 |
+
this.controllers[key] = controller;
|
12 |
+
return key;
|
13 |
+
},
|
14 |
+
|
15 |
+
stop(sessionId: string, messageId: string) {
|
16 |
+
const key = this.key(sessionId, messageId);
|
17 |
+
const controller = this.controllers[key];
|
18 |
+
controller?.abort();
|
19 |
+
},
|
20 |
+
|
21 |
+
stopAll() {
|
22 |
+
Object.values(this.controllers).forEach((v) => v.abort());
|
23 |
+
},
|
24 |
+
|
25 |
+
hasPending() {
|
26 |
+
return Object.values(this.controllers).length > 0;
|
27 |
+
},
|
28 |
+
|
29 |
+
remove(sessionId: string, messageId: string) {
|
30 |
+
const key = this.key(sessionId, messageId);
|
31 |
+
delete this.controllers[key];
|
32 |
+
},
|
33 |
+
|
34 |
+
key(sessionId: string, messageIndex: string) {
|
35 |
+
return `${sessionId},${messageIndex}`;
|
36 |
+
},
|
37 |
+
};
|
app/client/platforms/openai.ts
ADDED
@@ -0,0 +1,441 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import {
|
2 |
+
DEFAULT_API_HOST,
|
3 |
+
DEFAULT_MODELS,
|
4 |
+
OpenaiPath,
|
5 |
+
REQUEST_TIMEOUT_MS,
|
6 |
+
} from "@/app/constant";
|
7 |
+
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
8 |
+
|
9 |
+
import {
|
10 |
+
AgentChatOptions,
|
11 |
+
ChatOptions,
|
12 |
+
getHeaders,
|
13 |
+
LLMApi,
|
14 |
+
LLMModel,
|
15 |
+
LLMUsage,
|
16 |
+
} from "../api";
|
17 |
+
import Locale from "../../locales";
|
18 |
+
import {
|
19 |
+
EventStreamContentType,
|
20 |
+
fetchEventSource,
|
21 |
+
} from "@fortaine/fetch-event-source";
|
22 |
+
import { prettyObject } from "@/app/utils/format";
|
23 |
+
import { getClientConfig } from "@/app/config/client";
|
24 |
+
|
25 |
+
export interface OpenAIListModelResponse {
|
26 |
+
object: string;
|
27 |
+
data: Array<{
|
28 |
+
id: string;
|
29 |
+
object: string;
|
30 |
+
root: string;
|
31 |
+
}>;
|
32 |
+
}
|
33 |
+
|
34 |
+
export class ChatGPTApi implements LLMApi {
|
35 |
+
private disableListModels = true;
|
36 |
+
|
37 |
+
path(path: string): string {
|
38 |
+
let openaiUrl = useAccessStore.getState().openaiUrl;
|
39 |
+
const apiPath = "/api/openai";
|
40 |
+
|
41 |
+
if (openaiUrl.length === 0) {
|
42 |
+
const isApp = !!getClientConfig()?.isApp;
|
43 |
+
openaiUrl = isApp ? DEFAULT_API_HOST : apiPath;
|
44 |
+
}
|
45 |
+
if (openaiUrl.endsWith("/")) {
|
46 |
+
openaiUrl = openaiUrl.slice(0, openaiUrl.length - 1);
|
47 |
+
}
|
48 |
+
if (!openaiUrl.startsWith("http") && !openaiUrl.startsWith(apiPath)) {
|
49 |
+
openaiUrl = "https://" + openaiUrl;
|
50 |
+
}
|
51 |
+
return [openaiUrl, path].join("/");
|
52 |
+
}
|
53 |
+
|
54 |
+
extractMessage(res: any) {
|
55 |
+
return res.response ?? "";
|
56 |
+
}
|
57 |
+
|
58 |
+
async chat(options: ChatOptions) {
|
59 |
+
const messages = options.messages.map((v) => ({
|
60 |
+
role: v.role,
|
61 |
+
content: v.content,
|
62 |
+
}));
|
63 |
+
|
64 |
+
const modelConfig = {
|
65 |
+
...useAppConfig.getState().modelConfig,
|
66 |
+
...useChatStore.getState().currentSession().mask.modelConfig,
|
67 |
+
...{
|
68 |
+
model: options.config.model,
|
69 |
+
},
|
70 |
+
};
|
71 |
+
|
72 |
+
const requestPayload = {
|
73 |
+
messages,
|
74 |
+
stream: options.config.stream,
|
75 |
+
model: modelConfig.model,
|
76 |
+
temperature: modelConfig.temperature,
|
77 |
+
presence_penalty: modelConfig.presence_penalty,
|
78 |
+
frequency_penalty: modelConfig.frequency_penalty,
|
79 |
+
top_p: modelConfig.top_p,
|
80 |
+
// max_tokens: Math.max(modelConfig.max_tokens, 1024),
|
81 |
+
// Please do not ask me why not send max_tokens, no reason, this param is just shit, I dont want to explain anymore.
|
82 |
+
};
|
83 |
+
|
84 |
+
console.log("[Request] openai payload: ", requestPayload);
|
85 |
+
|
86 |
+
const shouldStream = !!options.config.stream;
|
87 |
+
const controller = new AbortController();
|
88 |
+
options.onController?.(controller);
|
89 |
+
|
90 |
+
try {
|
91 |
+
const chatPath = this.path(OpenaiPath.ChatPath);
|
92 |
+
const chatPayload = {
|
93 |
+
method: "POST",
|
94 |
+
body: JSON.stringify(requestPayload),
|
95 |
+
signal: controller.signal,
|
96 |
+
headers: getHeaders(),
|
97 |
+
};
|
98 |
+
|
99 |
+
// make a fetch request
|
100 |
+
const requestTimeoutId = setTimeout(
|
101 |
+
() => controller.abort(),
|
102 |
+
REQUEST_TIMEOUT_MS,
|
103 |
+
);
|
104 |
+
|
105 |
+
if (shouldStream) {
|
106 |
+
let responseText = "";
|
107 |
+
let finished = false;
|
108 |
+
|
109 |
+
const finish = () => {
|
110 |
+
if (!finished) {
|
111 |
+
options.onFinish(responseText);
|
112 |
+
finished = true;
|
113 |
+
}
|
114 |
+
};
|
115 |
+
|
116 |
+
controller.signal.onabort = finish;
|
117 |
+
|
118 |
+
fetchEventSource(chatPath, {
|
119 |
+
...chatPayload,
|
120 |
+
async onopen(res) {
|
121 |
+
clearTimeout(requestTimeoutId);
|
122 |
+
const contentType = res.headers.get("content-type");
|
123 |
+
console.log(
|
124 |
+
"[OpenAI] request response content type: ",
|
125 |
+
contentType,
|
126 |
+
);
|
127 |
+
|
128 |
+
if (contentType?.startsWith("text/plain")) {
|
129 |
+
responseText = await res.clone().text();
|
130 |
+
return finish();
|
131 |
+
}
|
132 |
+
|
133 |
+
if (
|
134 |
+
!res.ok ||
|
135 |
+
!res.headers
|
136 |
+
.get("content-type")
|
137 |
+
?.startsWith(EventStreamContentType) ||
|
138 |
+
res.status !== 200
|
139 |
+
) {
|
140 |
+
const responseTexts = [responseText];
|
141 |
+
let extraInfo = await res.clone().text();
|
142 |
+
try {
|
143 |
+
const resJson = await res.clone().json();
|
144 |
+
extraInfo = prettyObject(resJson);
|
145 |
+
} catch {}
|
146 |
+
|
147 |
+
if (res.status === 401) {
|
148 |
+
responseTexts.push(Locale.Error.Unauthorized);
|
149 |
+
}
|
150 |
+
|
151 |
+
if (extraInfo) {
|
152 |
+
responseTexts.push(extraInfo);
|
153 |
+
}
|
154 |
+
|
155 |
+
responseText = responseTexts.join("\n\n");
|
156 |
+
|
157 |
+
return finish();
|
158 |
+
}
|
159 |
+
},
|
160 |
+
onmessage(msg) {
|
161 |
+
if (msg.data === "[DONE]" || finished) {
|
162 |
+
return finish();
|
163 |
+
}
|
164 |
+
const text = msg.data;
|
165 |
+
try {
|
166 |
+
const json = JSON.parse(text);
|
167 |
+
const delta = json.choices[0]?.delta.content;
|
168 |
+
if (delta) {
|
169 |
+
responseText += delta;
|
170 |
+
options.onUpdate?.(responseText, delta);
|
171 |
+
}
|
172 |
+
} catch (e) {
|
173 |
+
console.error("[Request] parse error", text, msg);
|
174 |
+
}
|
175 |
+
},
|
176 |
+
onclose() {
|
177 |
+
finish();
|
178 |
+
},
|
179 |
+
onerror(e) {
|
180 |
+
options.onError?.(e);
|
181 |
+
throw e;
|
182 |
+
},
|
183 |
+
openWhenHidden: true,
|
184 |
+
});
|
185 |
+
} else {
|
186 |
+
const res = await fetch(chatPath, chatPayload);
|
187 |
+
clearTimeout(requestTimeoutId);
|
188 |
+
|
189 |
+
const resJson = await res.json();
|
190 |
+
const message = this.extractMessage(resJson);
|
191 |
+
options.onFinish(message);
|
192 |
+
}
|
193 |
+
} catch (e) {
|
194 |
+
console.log("[Request] failed to make a chat request", e);
|
195 |
+
options.onError?.(e as Error);
|
196 |
+
}
|
197 |
+
}
|
198 |
+
|
199 |
+
async toolAgentChat(options: AgentChatOptions) {
|
200 |
+
const messages = options.messages.map((v) => ({
|
201 |
+
role: v.role,
|
202 |
+
content: v.content,
|
203 |
+
}));
|
204 |
+
|
205 |
+
const modelConfig = {
|
206 |
+
...useAppConfig.getState().modelConfig,
|
207 |
+
...useChatStore.getState().currentSession().mask.modelConfig,
|
208 |
+
...{
|
209 |
+
model: options.config.model,
|
210 |
+
},
|
211 |
+
};
|
212 |
+
|
213 |
+
const requestPayload = {
|
214 |
+
messages,
|
215 |
+
stream: options.config.stream,
|
216 |
+
model: modelConfig.model,
|
217 |
+
temperature: modelConfig.temperature,
|
218 |
+
presence_penalty: modelConfig.presence_penalty,
|
219 |
+
frequency_penalty: modelConfig.frequency_penalty,
|
220 |
+
top_p: modelConfig.top_p,
|
221 |
+
baseUrl: useAccessStore.getState().openaiUrl,
|
222 |
+
maxIterations: options.agentConfig.maxIterations,
|
223 |
+
returnIntermediateSteps: options.agentConfig.returnIntermediateSteps,
|
224 |
+
useTools: options.agentConfig.useTools,
|
225 |
+
};
|
226 |
+
|
227 |
+
console.log("[Request] openai payload: ", requestPayload);
|
228 |
+
|
229 |
+
const shouldStream = true;
|
230 |
+
const controller = new AbortController();
|
231 |
+
options.onController?.(controller);
|
232 |
+
|
233 |
+
try {
|
234 |
+
const path = "/api/langchain/tool/agent";
|
235 |
+
const chatPayload = {
|
236 |
+
method: "POST",
|
237 |
+
body: JSON.stringify(requestPayload),
|
238 |
+
signal: controller.signal,
|
239 |
+
headers: getHeaders(),
|
240 |
+
};
|
241 |
+
|
242 |
+
// make a fetch request
|
243 |
+
const requestTimeoutId = setTimeout(
|
244 |
+
() => controller.abort(),
|
245 |
+
REQUEST_TIMEOUT_MS,
|
246 |
+
);
|
247 |
+
console.log("shouldStream", shouldStream);
|
248 |
+
|
249 |
+
if (shouldStream) {
|
250 |
+
let responseText = "";
|
251 |
+
let finished = false;
|
252 |
+
|
253 |
+
const finish = () => {
|
254 |
+
if (!finished) {
|
255 |
+
options.onFinish(responseText);
|
256 |
+
finished = true;
|
257 |
+
}
|
258 |
+
};
|
259 |
+
|
260 |
+
controller.signal.onabort = finish;
|
261 |
+
|
262 |
+
fetchEventSource(path, {
|
263 |
+
...chatPayload,
|
264 |
+
async onopen(res) {
|
265 |
+
clearTimeout(requestTimeoutId);
|
266 |
+
const contentType = res.headers.get("content-type");
|
267 |
+
console.log(
|
268 |
+
"[OpenAI] request response content type: ",
|
269 |
+
contentType,
|
270 |
+
);
|
271 |
+
|
272 |
+
if (contentType?.startsWith("text/plain")) {
|
273 |
+
responseText = await res.clone().text();
|
274 |
+
return finish();
|
275 |
+
}
|
276 |
+
|
277 |
+
if (
|
278 |
+
!res.ok ||
|
279 |
+
!res.headers
|
280 |
+
.get("content-type")
|
281 |
+
?.startsWith(EventStreamContentType) ||
|
282 |
+
res.status !== 200
|
283 |
+
) {
|
284 |
+
const responseTexts = [responseText];
|
285 |
+
let extraInfo = await res.clone().text();
|
286 |
+
console.warn(`extraInfo: ${extraInfo}`);
|
287 |
+
// try {
|
288 |
+
// const resJson = await res.clone().json();
|
289 |
+
// extraInfo = prettyObject(resJson);
|
290 |
+
// } catch { }
|
291 |
+
|
292 |
+
if (res.status === 401) {
|
293 |
+
responseTexts.push(Locale.Error.Unauthorized);
|
294 |
+
}
|
295 |
+
|
296 |
+
if (extraInfo) {
|
297 |
+
responseTexts.push(extraInfo);
|
298 |
+
}
|
299 |
+
|
300 |
+
responseText = responseTexts.join("\n\n");
|
301 |
+
|
302 |
+
return finish();
|
303 |
+
}
|
304 |
+
},
|
305 |
+
onmessage(msg) {
|
306 |
+
let response = JSON.parse(msg.data);
|
307 |
+
if (!response.isSuccess) {
|
308 |
+
console.error("[Request]", msg.data);
|
309 |
+
responseText = msg.data;
|
310 |
+
throw Error(response.message);
|
311 |
+
}
|
312 |
+
if (msg.data === "[DONE]" || finished) {
|
313 |
+
return finish();
|
314 |
+
}
|
315 |
+
try {
|
316 |
+
if (response && !response.isToolMessage) {
|
317 |
+
responseText += response.message;
|
318 |
+
options.onUpdate?.(responseText, response.message);
|
319 |
+
} else {
|
320 |
+
options.onToolUpdate?.(response.toolName!, response.message);
|
321 |
+
}
|
322 |
+
} catch (e) {
|
323 |
+
console.error("[Request] parse error", response, msg);
|
324 |
+
}
|
325 |
+
},
|
326 |
+
onclose() {
|
327 |
+
finish();
|
328 |
+
},
|
329 |
+
onerror(e) {
|
330 |
+
options.onError?.(e);
|
331 |
+
throw e;
|
332 |
+
},
|
333 |
+
openWhenHidden: true,
|
334 |
+
});
|
335 |
+
} else {
|
336 |
+
const res = await fetch(path, chatPayload);
|
337 |
+
clearTimeout(requestTimeoutId);
|
338 |
+
|
339 |
+
const resJson = await res.json();
|
340 |
+
const message = this.extractMessage(resJson);
|
341 |
+
options.onFinish(message);
|
342 |
+
}
|
343 |
+
} catch (e) {
|
344 |
+
console.log("[Request] failed to make a chat reqeust", e);
|
345 |
+
options.onError?.(e as Error);
|
346 |
+
}
|
347 |
+
}
|
348 |
+
|
349 |
+
async usage() {
|
350 |
+
const formatDate = (d: Date) =>
|
351 |
+
`${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, "0")}-${d
|
352 |
+
.getDate()
|
353 |
+
.toString()
|
354 |
+
.padStart(2, "0")}`;
|
355 |
+
const ONE_DAY = 1 * 24 * 60 * 60 * 1000;
|
356 |
+
const now = new Date();
|
357 |
+
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
358 |
+
const startDate = formatDate(startOfMonth);
|
359 |
+
const endDate = formatDate(new Date(Date.now() + ONE_DAY));
|
360 |
+
|
361 |
+
const [used, subs] = await Promise.all([
|
362 |
+
fetch(
|
363 |
+
this.path(
|
364 |
+
`${OpenaiPath.UsagePath}?start_date=${startDate}&end_date=${endDate}`,
|
365 |
+
),
|
366 |
+
{
|
367 |
+
method: "GET",
|
368 |
+
headers: getHeaders(),
|
369 |
+
},
|
370 |
+
),
|
371 |
+
fetch(this.path(OpenaiPath.SubsPath), {
|
372 |
+
method: "GET",
|
373 |
+
headers: getHeaders(),
|
374 |
+
}),
|
375 |
+
]);
|
376 |
+
|
377 |
+
if (used.status === 401) {
|
378 |
+
throw new Error(Locale.Error.Unauthorized);
|
379 |
+
}
|
380 |
+
|
381 |
+
if (!used.ok || !subs.ok) {
|
382 |
+
throw new Error("Failed to query usage from openai");
|
383 |
+
}
|
384 |
+
|
385 |
+
const response = (await used.json()) as {
|
386 |
+
total_usage?: number;
|
387 |
+
error?: {
|
388 |
+
type: string;
|
389 |
+
message: string;
|
390 |
+
};
|
391 |
+
};
|
392 |
+
|
393 |
+
const total = (await subs.json()) as {
|
394 |
+
hard_limit_usd?: number;
|
395 |
+
};
|
396 |
+
|
397 |
+
if (response.error && response.error.type) {
|
398 |
+
throw Error(response.error.message);
|
399 |
+
}
|
400 |
+
|
401 |
+
if (response.total_usage) {
|
402 |
+
response.total_usage = Math.round(response.total_usage) / 100;
|
403 |
+
}
|
404 |
+
|
405 |
+
if (total.hard_limit_usd) {
|
406 |
+
total.hard_limit_usd = Math.round(total.hard_limit_usd * 100) / 100;
|
407 |
+
}
|
408 |
+
|
409 |
+
return {
|
410 |
+
used: response.total_usage,
|
411 |
+
total: total.hard_limit_usd,
|
412 |
+
} as LLMUsage;
|
413 |
+
}
|
414 |
+
|
415 |
+
async models(): Promise<LLMModel[]> {
|
416 |
+
if (this.disableListModels) {
|
417 |
+
return DEFAULT_MODELS.slice();
|
418 |
+
}
|
419 |
+
|
420 |
+
const res = await fetch(this.path(OpenaiPath.ListModelPath), {
|
421 |
+
method: "GET",
|
422 |
+
headers: {
|
423 |
+
...getHeaders(),
|
424 |
+
},
|
425 |
+
});
|
426 |
+
|
427 |
+
const resJson = (await res.json()) as OpenAIListModelResponse;
|
428 |
+
const chatModels = resJson.data?.filter((m) => m.id.startsWith("gpt-"));
|
429 |
+
console.log("[Models]", chatModels);
|
430 |
+
|
431 |
+
if (!chatModels) {
|
432 |
+
return [];
|
433 |
+
}
|
434 |
+
|
435 |
+
return chatModels.map((m) => ({
|
436 |
+
name: m.id,
|
437 |
+
available: true,
|
438 |
+
}));
|
439 |
+
}
|
440 |
+
}
|
441 |
+
export { OpenaiPath };
|
app/command.ts
ADDED
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useEffect } from "react";
|
2 |
+
import { useSearchParams } from "react-router-dom";
|
3 |
+
import Locale from "./locales";
|
4 |
+
|
5 |
+
type Command = (param: string) => void;
|
6 |
+
interface Commands {
|
7 |
+
fill?: Command;
|
8 |
+
submit?: Command;
|
9 |
+
mask?: Command;
|
10 |
+
code?: Command;
|
11 |
+
settings?: Command;
|
12 |
+
}
|
13 |
+
|
14 |
+
export function useCommand(commands: Commands = {}) {
|
15 |
+
const [searchParams, setSearchParams] = useSearchParams();
|
16 |
+
|
17 |
+
useEffect(() => {
|
18 |
+
let shouldUpdate = false;
|
19 |
+
searchParams.forEach((param, name) => {
|
20 |
+
const commandName = name as keyof Commands;
|
21 |
+
if (typeof commands[commandName] === "function") {
|
22 |
+
commands[commandName]!(param);
|
23 |
+
searchParams.delete(name);
|
24 |
+
shouldUpdate = true;
|
25 |
+
}
|
26 |
+
});
|
27 |
+
|
28 |
+
if (shouldUpdate) {
|
29 |
+
setSearchParams(searchParams);
|
30 |
+
}
|
31 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
32 |
+
}, [searchParams, commands]);
|
33 |
+
}
|
34 |
+
|
35 |
+
interface ChatCommands {
|
36 |
+
new?: Command;
|
37 |
+
newm?: Command;
|
38 |
+
next?: Command;
|
39 |
+
prev?: Command;
|
40 |
+
clear?: Command;
|
41 |
+
del?: Command;
|
42 |
+
}
|
43 |
+
|
44 |
+
export const ChatCommandPrefix = ":";
|
45 |
+
|
46 |
+
export function useChatCommand(commands: ChatCommands = {}) {
|
47 |
+
function extract(userInput: string) {
|
48 |
+
return (
|
49 |
+
userInput.startsWith(ChatCommandPrefix) ? userInput.slice(1) : userInput
|
50 |
+
) as keyof ChatCommands;
|
51 |
+
}
|
52 |
+
|
53 |
+
function search(userInput: string) {
|
54 |
+
const input = extract(userInput);
|
55 |
+
const desc = Locale.Chat.Commands;
|
56 |
+
return Object.keys(commands)
|
57 |
+
.filter((c) => c.startsWith(input))
|
58 |
+
.map((c) => ({
|
59 |
+
title: desc[c as keyof ChatCommands],
|
60 |
+
content: ChatCommandPrefix + c,
|
61 |
+
}));
|
62 |
+
}
|
63 |
+
|
64 |
+
function match(userInput: string) {
|
65 |
+
const command = extract(userInput);
|
66 |
+
const matched = typeof commands[command] === "function";
|
67 |
+
|
68 |
+
return {
|
69 |
+
matched,
|
70 |
+
invoke: () => matched && commands[command]!(userInput),
|
71 |
+
};
|
72 |
+
}
|
73 |
+
|
74 |
+
return { match, search };
|
75 |
+
}
|
app/components/auth.module.scss
ADDED
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.auth-page {
|
2 |
+
display: flex;
|
3 |
+
justify-content: center;
|
4 |
+
align-items: center;
|
5 |
+
height: 100%;
|
6 |
+
width: 100%;
|
7 |
+
flex-direction: column;
|
8 |
+
|
9 |
+
.auth-logo {
|
10 |
+
transform: scale(1.4);
|
11 |
+
}
|
12 |
+
|
13 |
+
.auth-title {
|
14 |
+
font-size: 24px;
|
15 |
+
font-weight: bold;
|
16 |
+
line-height: 2;
|
17 |
+
}
|
18 |
+
|
19 |
+
.auth-tips {
|
20 |
+
font-size: 14px;
|
21 |
+
}
|
22 |
+
|
23 |
+
.auth-input {
|
24 |
+
margin: 3vh 0;
|
25 |
+
}
|
26 |
+
|
27 |
+
.auth-actions {
|
28 |
+
display: flex;
|
29 |
+
justify-content: center;
|
30 |
+
flex-direction: column;
|
31 |
+
|
32 |
+
button:not(:last-child) {
|
33 |
+
margin-bottom: 10px;
|
34 |
+
}
|
35 |
+
}
|
36 |
+
}
|
app/components/auth.tsx
ADDED
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import styles from "./auth.module.scss";
|
2 |
+
import { IconButton } from "./button";
|
3 |
+
|
4 |
+
import { useNavigate } from "react-router-dom";
|
5 |
+
import { Path } from "../constant";
|
6 |
+
import { useAccessStore } from "../store";
|
7 |
+
import Locale from "../locales";
|
8 |
+
|
9 |
+
import BotIcon from "../icons/bot.svg";
|
10 |
+
import { useEffect } from "react";
|
11 |
+
import { getClientConfig } from "../config/client";
|
12 |
+
|
13 |
+
export function AuthPage() {
|
14 |
+
const navigate = useNavigate();
|
15 |
+
|
16 |
+
useEffect(() => {
|
17 |
+
navigate(Path.Chat);
|
18 |
+
}, []);
|
19 |
+
|
20 |
+
return null;
|
21 |
+
}
|
app/components/button.module.scss
ADDED
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
+
&.danger {
|
32 |
+
color: rgba($color: red, $alpha: 0.8);
|
33 |
+
border-color: rgba($color: red, $alpha: 0.5);
|
34 |
+
background-color: rgba($color: red, $alpha: 0.05);
|
35 |
+
|
36 |
+
&:hover {
|
37 |
+
border-color: red;
|
38 |
+
background-color: rgba($color: red, $alpha: 0.1);
|
39 |
+
}
|
40 |
+
|
41 |
+
path {
|
42 |
+
fill: red !important;
|
43 |
+
}
|
44 |
+
}
|
45 |
+
|
46 |
+
&:hover,
|
47 |
+
&:focus {
|
48 |
+
border-color: var(--primary);
|
49 |
+
}
|
50 |
+
}
|
51 |
+
|
52 |
+
.shadow {
|
53 |
+
box-shadow: var(--card-shadow);
|
54 |
+
}
|
55 |
+
|
56 |
+
.border {
|
57 |
+
border: var(--border-in-light);
|
58 |
+
}
|
59 |
+
|
60 |
+
.icon-button-icon {
|
61 |
+
width: 16px;
|
62 |
+
height: 16px;
|
63 |
+
display: flex;
|
64 |
+
justify-content: center;
|
65 |
+
align-items: center;
|
66 |
+
}
|
67 |
+
|
68 |
+
@media only screen and (max-width: 600px) {
|
69 |
+
.icon-button {
|
70 |
+
padding: 16px;
|
71 |
+
}
|
72 |
+
}
|
73 |
+
|
74 |
+
.icon-button-text {
|
75 |
+
font-size: 12px;
|
76 |
+
overflow: hidden;
|
77 |
+
text-overflow: ellipsis;
|
78 |
+
white-space: nowrap;
|
79 |
+
|
80 |
+
&:not(:first-child) {
|
81 |
+
margin-left: 5px;
|
82 |
+
}
|
83 |
+
}
|
app/components/button.tsx
ADDED
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as React from "react";
|
2 |
+
|
3 |
+
import styles from "./button.module.scss";
|
4 |
+
|
5 |
+
export type ButtonType = "primary" | "danger" | null;
|
6 |
+
|
7 |
+
export function IconButton(props: {
|
8 |
+
onClick?: () => void;
|
9 |
+
icon?: JSX.Element;
|
10 |
+
type?: ButtonType;
|
11 |
+
text?: string;
|
12 |
+
bordered?: boolean;
|
13 |
+
shadow?: boolean;
|
14 |
+
className?: string;
|
15 |
+
title?: string;
|
16 |
+
disabled?: boolean;
|
17 |
+
tabIndex?: number;
|
18 |
+
autoFocus?: boolean;
|
19 |
+
}) {
|
20 |
+
return (
|
21 |
+
<button
|
22 |
+
className={
|
23 |
+
styles["icon-button"] +
|
24 |
+
` ${props.bordered && styles.border} ${props.shadow && styles.shadow} ${
|
25 |
+
props.className ?? ""
|
26 |
+
} clickable ${styles[props.type ?? ""]}`
|
27 |
+
}
|
28 |
+
onClick={props.onClick}
|
29 |
+
title={props.title}
|
30 |
+
disabled={props.disabled}
|
31 |
+
role="button"
|
32 |
+
tabIndex={props.tabIndex}
|
33 |
+
autoFocus={props.autoFocus}
|
34 |
+
>
|
35 |
+
{props.icon && (
|
36 |
+
<div
|
37 |
+
className={
|
38 |
+
styles["icon-button-icon"] +
|
39 |
+
` ${props.type === "primary" && "no-dark"}`
|
40 |
+
}
|
41 |
+
>
|
42 |
+
{props.icon}
|
43 |
+
</div>
|
44 |
+
)}
|
45 |
+
|
46 |
+
{props.text && (
|
47 |
+
<div className={styles["icon-button-text"]}>{props.text}</div>
|
48 |
+
)}
|
49 |
+
</button>
|
50 |
+
);
|
51 |
+
}
|
app/components/chat-list.tsx
ADDED
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
+
import { useRef, useEffect } from "react";
|
20 |
+
import { showConfirm } from "./ui-lib";
|
21 |
+
import { useMobileScreen } from "../utils";
|
22 |
+
|
23 |
+
export function ChatItem(props: {
|
24 |
+
onClick?: () => void;
|
25 |
+
onDelete?: () => void;
|
26 |
+
title: string;
|
27 |
+
count: number;
|
28 |
+
time: string;
|
29 |
+
selected: boolean;
|
30 |
+
id: string;
|
31 |
+
index: number;
|
32 |
+
narrow?: boolean;
|
33 |
+
mask: Mask;
|
34 |
+
}) {
|
35 |
+
const draggableRef = useRef<HTMLDivElement | null>(null);
|
36 |
+
useEffect(() => {
|
37 |
+
if (props.selected && draggableRef.current) {
|
38 |
+
draggableRef.current?.scrollIntoView({
|
39 |
+
block: "center",
|
40 |
+
});
|
41 |
+
}
|
42 |
+
}, [props.selected]);
|
43 |
+
return (
|
44 |
+
<Draggable draggableId={`${props.id}`} index={props.index}>
|
45 |
+
{(provided) => (
|
46 |
+
<div
|
47 |
+
className={`${styles["chat-item"]} ${
|
48 |
+
props.selected && styles["chat-item-selected"]
|
49 |
+
}`}
|
50 |
+
onClick={props.onClick}
|
51 |
+
ref={(ele) => {
|
52 |
+
draggableRef.current = ele;
|
53 |
+
provided.innerRef(ele);
|
54 |
+
}}
|
55 |
+
{...provided.draggableProps}
|
56 |
+
{...provided.dragHandleProps}
|
57 |
+
title={`${props.title}\n${Locale.ChatItem.ChatItemCount(
|
58 |
+
props.count,
|
59 |
+
)}`}
|
60 |
+
>
|
61 |
+
{props.narrow ? (
|
62 |
+
<div className={styles["chat-item-narrow"]}>
|
63 |
+
<div className={styles["chat-item-avatar"] + " no-dark"}>
|
64 |
+
<MaskAvatar mask={props.mask} />
|
65 |
+
</div>
|
66 |
+
<div className={styles["chat-item-narrow-count"]}>
|
67 |
+
{props.count}
|
68 |
+
</div>
|
69 |
+
</div>
|
70 |
+
) : (
|
71 |
+
<>
|
72 |
+
<div className={styles["chat-item-title"]}>{props.title}</div>
|
73 |
+
<div className={styles["chat-item-info"]}>
|
74 |
+
<div className={styles["chat-item-count"]}>
|
75 |
+
{Locale.ChatItem.ChatItemCount(props.count)}
|
76 |
+
</div>
|
77 |
+
<div className={styles["chat-item-date"]}>{props.time}</div>
|
78 |
+
</div>
|
79 |
+
</>
|
80 |
+
)}
|
81 |
+
|
82 |
+
<div
|
83 |
+
className={styles["chat-item-delete"]}
|
84 |
+
onClickCapture={(e) => {
|
85 |
+
props.onDelete?.();
|
86 |
+
e.preventDefault();
|
87 |
+
e.stopPropagation();
|
88 |
+
}}
|
89 |
+
>
|
90 |
+
<DeleteIcon />
|
91 |
+
</div>
|
92 |
+
</div>
|
93 |
+
)}
|
94 |
+
</Draggable>
|
95 |
+
);
|
96 |
+
}
|
97 |
+
|
98 |
+
export function ChatList(props: { narrow?: boolean }) {
|
99 |
+
const [sessions, selectedIndex, selectSession, moveSession] = useChatStore(
|
100 |
+
(state) => [
|
101 |
+
state.sessions,
|
102 |
+
state.currentSessionIndex,
|
103 |
+
state.selectSession,
|
104 |
+
state.moveSession,
|
105 |
+
],
|
106 |
+
);
|
107 |
+
const chatStore = useChatStore();
|
108 |
+
const navigate = useNavigate();
|
109 |
+
const isMobileScreen = useMobileScreen();
|
110 |
+
|
111 |
+
const onDragEnd: OnDragEndResponder = (result) => {
|
112 |
+
const { destination, source } = result;
|
113 |
+
if (!destination) {
|
114 |
+
return;
|
115 |
+
}
|
116 |
+
|
117 |
+
if (
|
118 |
+
destination.droppableId === source.droppableId &&
|
119 |
+
destination.index === source.index
|
120 |
+
) {
|
121 |
+
return;
|
122 |
+
}
|
123 |
+
|
124 |
+
moveSession(source.index, destination.index);
|
125 |
+
};
|
126 |
+
|
127 |
+
return (
|
128 |
+
<DragDropContext onDragEnd={onDragEnd}>
|
129 |
+
<Droppable droppableId="chat-list">
|
130 |
+
{(provided) => (
|
131 |
+
<div
|
132 |
+
className={styles["chat-list"]}
|
133 |
+
ref={provided.innerRef}
|
134 |
+
{...provided.droppableProps}
|
135 |
+
>
|
136 |
+
{sessions.map((item, i) => (
|
137 |
+
<ChatItem
|
138 |
+
title={item.topic}
|
139 |
+
time={new Date(item.lastUpdate).toLocaleString()}
|
140 |
+
count={item.messages.length}
|
141 |
+
key={item.id}
|
142 |
+
id={item.id}
|
143 |
+
index={i}
|
144 |
+
selected={i === selectedIndex}
|
145 |
+
onClick={() => {
|
146 |
+
navigate(Path.Chat);
|
147 |
+
selectSession(i);
|
148 |
+
}}
|
149 |
+
onDelete={async () => {
|
150 |
+
if (
|
151 |
+
(!props.narrow && !isMobileScreen) ||
|
152 |
+
(await showConfirm(Locale.Home.DeleteChat))
|
153 |
+
) {
|
154 |
+
chatStore.deleteSession(i);
|
155 |
+
}
|
156 |
+
}}
|
157 |
+
narrow={props.narrow}
|
158 |
+
mask={item.mask}
|
159 |
+
/>
|
160 |
+
))}
|
161 |
+
{provided.placeholder}
|
162 |
+
</div>
|
163 |
+
)}
|
164 |
+
</Droppable>
|
165 |
+
</DragDropContext>
|
166 |
+
);
|
167 |
+
}
|
app/components/chat.module.scss
ADDED
@@ -0,0 +1,550 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
@import "../styles/animation.scss";
|
2 |
+
|
3 |
+
.chat-input-actions {
|
4 |
+
display: flex;
|
5 |
+
flex-wrap: wrap;
|
6 |
+
justify-content: space-between;
|
7 |
+
|
8 |
+
.chat-input-action {
|
9 |
+
display: inline-flex;
|
10 |
+
border-radius: 20px;
|
11 |
+
font-size: 12px;
|
12 |
+
background-color: var(--white);
|
13 |
+
color: var(--black);
|
14 |
+
border: var(--border-in-light);
|
15 |
+
padding: 4px 10px;
|
16 |
+
animation: slide-in ease 0.3s;
|
17 |
+
box-shadow: var(--card-shadow);
|
18 |
+
transition: width ease 0.3s;
|
19 |
+
align-items: center;
|
20 |
+
height: 16px;
|
21 |
+
width: var(--icon-width);
|
22 |
+
overflow: hidden;
|
23 |
+
|
24 |
+
&:not(:last-child) {
|
25 |
+
margin-right: 5px;
|
26 |
+
}
|
27 |
+
|
28 |
+
.text {
|
29 |
+
white-space: nowrap;
|
30 |
+
padding-left: 5px;
|
31 |
+
opacity: 0;
|
32 |
+
transform: translateX(-5px);
|
33 |
+
transition: all ease 0.3s;
|
34 |
+
pointer-events: none;
|
35 |
+
}
|
36 |
+
|
37 |
+
&:hover {
|
38 |
+
--delay: 0.5s;
|
39 |
+
width: var(--full-width);
|
40 |
+
transition-delay: var(--delay);
|
41 |
+
|
42 |
+
.text {
|
43 |
+
transition-delay: var(--delay);
|
44 |
+
opacity: 1;
|
45 |
+
transform: translate(0);
|
46 |
+
}
|
47 |
+
}
|
48 |
+
|
49 |
+
.text,
|
50 |
+
.icon {
|
51 |
+
display: flex;
|
52 |
+
align-items: center;
|
53 |
+
justify-content: center;
|
54 |
+
}
|
55 |
+
}
|
56 |
+
}
|
57 |
+
|
58 |
+
.prompt-toast {
|
59 |
+
position: absolute;
|
60 |
+
bottom: -50px;
|
61 |
+
z-index: 999;
|
62 |
+
display: flex;
|
63 |
+
justify-content: center;
|
64 |
+
width: calc(100% - 40px);
|
65 |
+
|
66 |
+
.prompt-toast-inner {
|
67 |
+
display: flex;
|
68 |
+
justify-content: center;
|
69 |
+
align-items: center;
|
70 |
+
font-size: 12px;
|
71 |
+
background-color: var(--white);
|
72 |
+
color: var(--black);
|
73 |
+
|
74 |
+
border: var(--border-in-light);
|
75 |
+
box-shadow: var(--card-shadow);
|
76 |
+
padding: 10px 20px;
|
77 |
+
border-radius: 100px;
|
78 |
+
|
79 |
+
animation: slide-in-from-top ease 0.3s;
|
80 |
+
|
81 |
+
.prompt-toast-content {
|
82 |
+
margin-left: 10px;
|
83 |
+
}
|
84 |
+
}
|
85 |
+
}
|
86 |
+
|
87 |
+
.section-title {
|
88 |
+
font-size: 12px;
|
89 |
+
font-weight: bold;
|
90 |
+
margin-bottom: 10px;
|
91 |
+
display: flex;
|
92 |
+
justify-content: space-between;
|
93 |
+
align-items: center;
|
94 |
+
|
95 |
+
.section-title-action {
|
96 |
+
display: flex;
|
97 |
+
align-items: center;
|
98 |
+
}
|
99 |
+
}
|
100 |
+
|
101 |
+
.context-prompt {
|
102 |
+
.context-prompt-insert {
|
103 |
+
display: flex;
|
104 |
+
justify-content: center;
|
105 |
+
padding: 4px;
|
106 |
+
opacity: 0.2;
|
107 |
+
transition: all ease 0.3s;
|
108 |
+
background-color: rgba(0, 0, 0, 0);
|
109 |
+
cursor: pointer;
|
110 |
+
border-radius: 4px;
|
111 |
+
margin-top: 4px;
|
112 |
+
margin-bottom: 4px;
|
113 |
+
|
114 |
+
&:hover {
|
115 |
+
opacity: 1;
|
116 |
+
background-color: rgba(0, 0, 0, 0.05);
|
117 |
+
}
|
118 |
+
}
|
119 |
+
|
120 |
+
.context-prompt-row {
|
121 |
+
display: flex;
|
122 |
+
justify-content: center;
|
123 |
+
width: 100%;
|
124 |
+
|
125 |
+
&:hover {
|
126 |
+
.context-drag {
|
127 |
+
opacity: 1;
|
128 |
+
}
|
129 |
+
}
|
130 |
+
|
131 |
+
.context-drag {
|
132 |
+
display: flex;
|
133 |
+
align-items: center;
|
134 |
+
opacity: 0.5;
|
135 |
+
transition: all ease 0.3s;
|
136 |
+
}
|
137 |
+
|
138 |
+
.context-role {
|
139 |
+
margin-right: 10px;
|
140 |
+
}
|
141 |
+
|
142 |
+
.context-content {
|
143 |
+
flex: 1;
|
144 |
+
max-width: 100%;
|
145 |
+
text-align: left;
|
146 |
+
}
|
147 |
+
|
148 |
+
.context-delete-button {
|
149 |
+
margin-left: 10px;
|
150 |
+
}
|
151 |
+
}
|
152 |
+
|
153 |
+
.context-prompt-button {
|
154 |
+
flex: 1;
|
155 |
+
}
|
156 |
+
}
|
157 |
+
|
158 |
+
.memory-prompt {
|
159 |
+
margin: 20px 0;
|
160 |
+
|
161 |
+
.memory-prompt-content {
|
162 |
+
background-color: var(--white);
|
163 |
+
color: var(--black);
|
164 |
+
border: var(--border-in-light);
|
165 |
+
border-radius: 10px;
|
166 |
+
padding: 10px;
|
167 |
+
font-size: 12px;
|
168 |
+
user-select: text;
|
169 |
+
}
|
170 |
+
}
|
171 |
+
|
172 |
+
.clear-context {
|
173 |
+
margin: 20px 0 0 0;
|
174 |
+
padding: 4px 0;
|
175 |
+
|
176 |
+
border-top: var(--border-in-light);
|
177 |
+
border-bottom: var(--border-in-light);
|
178 |
+
box-shadow: var(--card-shadow) inset;
|
179 |
+
|
180 |
+
display: flex;
|
181 |
+
justify-content: center;
|
182 |
+
align-items: center;
|
183 |
+
|
184 |
+
color: var(--black);
|
185 |
+
transition: all ease 0.3s;
|
186 |
+
cursor: pointer;
|
187 |
+
overflow: hidden;
|
188 |
+
position: relative;
|
189 |
+
font-size: 12px;
|
190 |
+
|
191 |
+
animation: slide-in ease 0.3s;
|
192 |
+
|
193 |
+
$linear: linear-gradient(
|
194 |
+
to right,
|
195 |
+
rgba(0, 0, 0, 0),
|
196 |
+
rgba(0, 0, 0, 1),
|
197 |
+
rgba(0, 0, 0, 0)
|
198 |
+
);
|
199 |
+
mask-image: $linear;
|
200 |
+
|
201 |
+
@mixin show {
|
202 |
+
transform: translateY(0);
|
203 |
+
position: relative;
|
204 |
+
transition: all ease 0.3s;
|
205 |
+
opacity: 1;
|
206 |
+
}
|
207 |
+
|
208 |
+
@mixin hide {
|
209 |
+
transform: translateY(-50%);
|
210 |
+
position: absolute;
|
211 |
+
transition: all ease 0.1s;
|
212 |
+
opacity: 0;
|
213 |
+
}
|
214 |
+
|
215 |
+
&-tips {
|
216 |
+
@include show;
|
217 |
+
opacity: 0.5;
|
218 |
+
}
|
219 |
+
|
220 |
+
&-revert-btn {
|
221 |
+
color: var(--primary);
|
222 |
+
@include hide;
|
223 |
+
}
|
224 |
+
|
225 |
+
&:hover {
|
226 |
+
opacity: 1;
|
227 |
+
border-color: var(--primary);
|
228 |
+
|
229 |
+
.clear-context-tips {
|
230 |
+
@include hide;
|
231 |
+
}
|
232 |
+
|
233 |
+
.clear-context-revert-btn {
|
234 |
+
@include show;
|
235 |
+
}
|
236 |
+
}
|
237 |
+
}
|
238 |
+
|
239 |
+
.chat {
|
240 |
+
display: flex;
|
241 |
+
flex-direction: column;
|
242 |
+
position: relative;
|
243 |
+
height: 100%;
|
244 |
+
}
|
245 |
+
|
246 |
+
.chat-body {
|
247 |
+
flex: 1;
|
248 |
+
overflow: auto;
|
249 |
+
overflow-x: hidden;
|
250 |
+
padding: 20px;
|
251 |
+
padding-bottom: 40px;
|
252 |
+
position: relative;
|
253 |
+
overscroll-behavior: none;
|
254 |
+
}
|
255 |
+
|
256 |
+
.chat-body-main-title {
|
257 |
+
cursor: pointer;
|
258 |
+
|
259 |
+
&:hover {
|
260 |
+
text-decoration: underline;
|
261 |
+
}
|
262 |
+
}
|
263 |
+
|
264 |
+
@media only screen and (max-width: 600px) {
|
265 |
+
.chat-body-title {
|
266 |
+
text-align: center;
|
267 |
+
}
|
268 |
+
}
|
269 |
+
|
270 |
+
.chat-message {
|
271 |
+
display: flex;
|
272 |
+
flex-direction: row;
|
273 |
+
|
274 |
+
&:last-child {
|
275 |
+
animation: slide-in ease 0.3s;
|
276 |
+
}
|
277 |
+
}
|
278 |
+
|
279 |
+
.chat-message-user {
|
280 |
+
display: flex;
|
281 |
+
flex-direction: row-reverse;
|
282 |
+
|
283 |
+
.chat-message-header {
|
284 |
+
flex-direction: row-reverse;
|
285 |
+
}
|
286 |
+
}
|
287 |
+
|
288 |
+
.chat-message-header {
|
289 |
+
margin-top: 20px;
|
290 |
+
display: flex;
|
291 |
+
align-items: center;
|
292 |
+
|
293 |
+
.chat-message-actions {
|
294 |
+
display: flex;
|
295 |
+
box-sizing: border-box;
|
296 |
+
font-size: 12px;
|
297 |
+
align-items: flex-end;
|
298 |
+
justify-content: space-between;
|
299 |
+
transition: all ease 0.3s;
|
300 |
+
transform: scale(0.9) translateY(5px);
|
301 |
+
margin: 0 10px;
|
302 |
+
opacity: 0;
|
303 |
+
pointer-events: none;
|
304 |
+
|
305 |
+
.chat-input-actions {
|
306 |
+
display: flex;
|
307 |
+
flex-wrap: nowrap;
|
308 |
+
}
|
309 |
+
}
|
310 |
+
}
|
311 |
+
|
312 |
+
.chat-message-container {
|
313 |
+
max-width: var(--message-max-width);
|
314 |
+
display: flex;
|
315 |
+
flex-direction: column;
|
316 |
+
align-items: flex-start;
|
317 |
+
|
318 |
+
&:hover {
|
319 |
+
.chat-message-edit {
|
320 |
+
opacity: 0.9;
|
321 |
+
}
|
322 |
+
|
323 |
+
.chat-message-actions {
|
324 |
+
opacity: 1;
|
325 |
+
pointer-events: all;
|
326 |
+
transform: scale(1) translateY(0);
|
327 |
+
}
|
328 |
+
}
|
329 |
+
}
|
330 |
+
|
331 |
+
.chat-message-user > .chat-message-container {
|
332 |
+
align-items: flex-end;
|
333 |
+
}
|
334 |
+
|
335 |
+
.chat-message-avatar {
|
336 |
+
position: relative;
|
337 |
+
|
338 |
+
.chat-message-edit {
|
339 |
+
position: absolute;
|
340 |
+
height: 100%;
|
341 |
+
width: 100%;
|
342 |
+
overflow: hidden;
|
343 |
+
display: flex;
|
344 |
+
align-items: center;
|
345 |
+
justify-content: center;
|
346 |
+
opacity: 0;
|
347 |
+
transition: all ease 0.3s;
|
348 |
+
|
349 |
+
button {
|
350 |
+
padding: 7px;
|
351 |
+
}
|
352 |
+
}
|
353 |
+
/* Specific styles for iOS devices */
|
354 |
+
@media screen and (max-device-width: 812px) and (-webkit-min-device-pixel-ratio: 2) {
|
355 |
+
@supports (-webkit-touch-callout: none) {
|
356 |
+
.chat-message-edit {
|
357 |
+
top: -8%;
|
358 |
+
}
|
359 |
+
}
|
360 |
+
}
|
361 |
+
}
|
362 |
+
|
363 |
+
.chat-message-checkmark {
|
364 |
+
display: inline-block;
|
365 |
+
margin-right: 5px;
|
366 |
+
height: 12px;
|
367 |
+
width: 12px;
|
368 |
+
color: #13a10e;
|
369 |
+
fill: #13a10e;
|
370 |
+
user-select: none;
|
371 |
+
backface-visibility: hidden;
|
372 |
+
transform: translateZ(0px);
|
373 |
+
}
|
374 |
+
|
375 |
+
.chat-message-tools-status {
|
376 |
+
display: flex;
|
377 |
+
justify-content: center;
|
378 |
+
align-items: center;
|
379 |
+
font-size: 12px;
|
380 |
+
margin-top: 5px;
|
381 |
+
line-height: 1.5;
|
382 |
+
}
|
383 |
+
|
384 |
+
.chat-message-tools-name {
|
385 |
+
color: #aaa;
|
386 |
+
}
|
387 |
+
|
388 |
+
.chat-message-tools-details {
|
389 |
+
margin-left: 5px;
|
390 |
+
font-weight: bold;
|
391 |
+
color: #999;
|
392 |
+
}
|
393 |
+
|
394 |
+
.chat-message-status {
|
395 |
+
font-size: 12px;
|
396 |
+
color: #aaa;
|
397 |
+
line-height: 1.5;
|
398 |
+
margin-top: 5px;
|
399 |
+
}
|
400 |
+
|
401 |
+
.chat-message-item {
|
402 |
+
box-sizing: border-box;
|
403 |
+
max-width: 100%;
|
404 |
+
margin-top: 10px;
|
405 |
+
border-radius: 10px;
|
406 |
+
background-color: rgba(0, 0, 0, 0.05);
|
407 |
+
padding: 10px;
|
408 |
+
font-size: 14px;
|
409 |
+
user-select: text;
|
410 |
+
word-break: break-word;
|
411 |
+
border: var(--border-in-light);
|
412 |
+
position: relative;
|
413 |
+
transition: all ease 0.3s;
|
414 |
+
}
|
415 |
+
|
416 |
+
.chat-message-action-date {
|
417 |
+
font-size: 12px;
|
418 |
+
opacity: 0.2;
|
419 |
+
white-space: nowrap;
|
420 |
+
transition: all ease 0.6s;
|
421 |
+
color: var(--black);
|
422 |
+
text-align: right;
|
423 |
+
width: 100%;
|
424 |
+
box-sizing: border-box;
|
425 |
+
padding-right: 10px;
|
426 |
+
pointer-events: none;
|
427 |
+
z-index: 1;
|
428 |
+
}
|
429 |
+
|
430 |
+
.chat-message-user > .chat-message-container > .chat-message-item {
|
431 |
+
background-color: var(--second);
|
432 |
+
|
433 |
+
&:hover {
|
434 |
+
min-width: 0;
|
435 |
+
}
|
436 |
+
}
|
437 |
+
|
438 |
+
.chat-input-panel {
|
439 |
+
position: relative;
|
440 |
+
width: 100%;
|
441 |
+
padding: 20px;
|
442 |
+
padding-top: 10px;
|
443 |
+
box-sizing: border-box;
|
444 |
+
flex-direction: column;
|
445 |
+
border-top: var(--border-in-light);
|
446 |
+
box-shadow: var(--card-shadow);
|
447 |
+
|
448 |
+
.chat-input-actions {
|
449 |
+
.chat-input-action {
|
450 |
+
margin-bottom: 10px;
|
451 |
+
}
|
452 |
+
}
|
453 |
+
}
|
454 |
+
|
455 |
+
@mixin single-line {
|
456 |
+
white-space: nowrap;
|
457 |
+
overflow: hidden;
|
458 |
+
text-overflow: ellipsis;
|
459 |
+
}
|
460 |
+
|
461 |
+
.prompt-hints {
|
462 |
+
min-height: 20px;
|
463 |
+
width: 100%;
|
464 |
+
max-height: 50vh;
|
465 |
+
overflow: auto;
|
466 |
+
display: flex;
|
467 |
+
flex-direction: column-reverse;
|
468 |
+
|
469 |
+
background-color: var(--white);
|
470 |
+
border: var(--border-in-light);
|
471 |
+
border-radius: 10px;
|
472 |
+
margin-bottom: 10px;
|
473 |
+
box-shadow: var(--shadow);
|
474 |
+
|
475 |
+
.prompt-hint {
|
476 |
+
color: var(--black);
|
477 |
+
padding: 6px 10px;
|
478 |
+
animation: slide-in ease 0.3s;
|
479 |
+
cursor: pointer;
|
480 |
+
transition: all ease 0.3s;
|
481 |
+
border: transparent 1px solid;
|
482 |
+
margin: 4px;
|
483 |
+
border-radius: 8px;
|
484 |
+
|
485 |
+
&:not(:last-child) {
|
486 |
+
margin-top: 0;
|
487 |
+
}
|
488 |
+
|
489 |
+
.hint-title {
|
490 |
+
font-size: 12px;
|
491 |
+
font-weight: bolder;
|
492 |
+
|
493 |
+
@include single-line();
|
494 |
+
}
|
495 |
+
.hint-content {
|
496 |
+
font-size: 12px;
|
497 |
+
|
498 |
+
@include single-line();
|
499 |
+
}
|
500 |
+
|
501 |
+
&-selected,
|
502 |
+
&:hover {
|
503 |
+
border-color: var(--primary);
|
504 |
+
}
|
505 |
+
}
|
506 |
+
}
|
507 |
+
|
508 |
+
.chat-input-panel-inner {
|
509 |
+
display: flex;
|
510 |
+
flex: 1;
|
511 |
+
}
|
512 |
+
|
513 |
+
.chat-input {
|
514 |
+
height: 100%;
|
515 |
+
width: 100%;
|
516 |
+
border-radius: 10px;
|
517 |
+
border: var(--border-in-light);
|
518 |
+
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.03);
|
519 |
+
background-color: var(--white);
|
520 |
+
color: var(--black);
|
521 |
+
font-family: inherit;
|
522 |
+
padding: 10px 90px 10px 14px;
|
523 |
+
resize: none;
|
524 |
+
outline: none;
|
525 |
+
box-sizing: border-box;
|
526 |
+
min-height: 68px;
|
527 |
+
}
|
528 |
+
|
529 |
+
.chat-input:focus {
|
530 |
+
border: 1px solid var(--primary);
|
531 |
+
}
|
532 |
+
|
533 |
+
.chat-input-send {
|
534 |
+
background-color: var(--primary);
|
535 |
+
color: white;
|
536 |
+
|
537 |
+
position: absolute;
|
538 |
+
right: 30px;
|
539 |
+
bottom: 32px;
|
540 |
+
}
|
541 |
+
|
542 |
+
@media only screen and (max-width: 600px) {
|
543 |
+
.chat-input {
|
544 |
+
font-size: 16px;
|
545 |
+
}
|
546 |
+
|
547 |
+
.chat-input-send {
|
548 |
+
bottom: 30px;
|
549 |
+
}
|
550 |
+
}
|
app/components/chat.tsx
ADDED
@@ -0,0 +1,1358 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useDebouncedCallback } from "use-debounce";
|
2 |
+
import React, {
|
3 |
+
useState,
|
4 |
+
useRef,
|
5 |
+
useEffect,
|
6 |
+
useMemo,
|
7 |
+
useCallback,
|
8 |
+
Fragment,
|
9 |
+
} from "react";
|
10 |
+
|
11 |
+
import SendWhiteIcon from "../icons/send-white.svg";
|
12 |
+
import BrainIcon from "../icons/brain.svg";
|
13 |
+
import RenameIcon from "../icons/rename.svg";
|
14 |
+
import ExportIcon from "../icons/share.svg";
|
15 |
+
import ReturnIcon from "../icons/return.svg";
|
16 |
+
import CopyIcon from "../icons/copy.svg";
|
17 |
+
import LoadingIcon from "../icons/three-dots.svg";
|
18 |
+
import PromptIcon from "../icons/prompt.svg";
|
19 |
+
import MaskIcon from "../icons/mask.svg";
|
20 |
+
import MaxIcon from "../icons/max.svg";
|
21 |
+
import MinIcon from "../icons/min.svg";
|
22 |
+
import ResetIcon from "../icons/reload.svg";
|
23 |
+
import BreakIcon from "../icons/break.svg";
|
24 |
+
import SettingsIcon from "../icons/chat-settings.svg";
|
25 |
+
import DeleteIcon from "../icons/clear.svg";
|
26 |
+
import PinIcon from "../icons/pin.svg";
|
27 |
+
import EditIcon from "../icons/rename.svg";
|
28 |
+
import ConfirmIcon from "../icons/confirm.svg";
|
29 |
+
import CancelIcon from "../icons/cancel.svg";
|
30 |
+
import EnablePluginIcon from "../icons/plugin_enable.svg";
|
31 |
+
import DisablePluginIcon from "../icons/plugin_disable.svg";
|
32 |
+
|
33 |
+
import LightIcon from "../icons/light.svg";
|
34 |
+
import DarkIcon from "../icons/dark.svg";
|
35 |
+
import AutoIcon from "../icons/auto.svg";
|
36 |
+
import BottomIcon from "../icons/bottom.svg";
|
37 |
+
import StopIcon from "../icons/pause.svg";
|
38 |
+
import RobotIcon from "../icons/robot.svg";
|
39 |
+
import CheckmarkIcon from "../icons/checkmark.svg";
|
40 |
+
|
41 |
+
import {
|
42 |
+
ChatMessage,
|
43 |
+
SubmitKey,
|
44 |
+
useChatStore,
|
45 |
+
BOT_HELLO,
|
46 |
+
createMessage,
|
47 |
+
useAccessStore,
|
48 |
+
Theme,
|
49 |
+
useAppConfig,
|
50 |
+
DEFAULT_TOPIC,
|
51 |
+
ModelType,
|
52 |
+
} from "../store";
|
53 |
+
|
54 |
+
import {
|
55 |
+
copyToClipboard,
|
56 |
+
selectOrCopy,
|
57 |
+
autoGrowTextArea,
|
58 |
+
useMobileScreen,
|
59 |
+
} from "../utils";
|
60 |
+
|
61 |
+
import dynamic from "next/dynamic";
|
62 |
+
|
63 |
+
import { ChatControllerPool } from "../client/controller";
|
64 |
+
import { Prompt, usePromptStore } from "../store/prompt";
|
65 |
+
import Locale from "../locales";
|
66 |
+
|
67 |
+
import { IconButton } from "./button";
|
68 |
+
import styles from "./chat.module.scss";
|
69 |
+
|
70 |
+
import {
|
71 |
+
List,
|
72 |
+
ListItem,
|
73 |
+
Modal,
|
74 |
+
Selector,
|
75 |
+
showConfirm,
|
76 |
+
showPrompt,
|
77 |
+
showToast,
|
78 |
+
} from "./ui-lib";
|
79 |
+
import { useNavigate } from "react-router-dom";
|
80 |
+
import {
|
81 |
+
CHAT_PAGE_SIZE,
|
82 |
+
LAST_INPUT_KEY,
|
83 |
+
Path,
|
84 |
+
REQUEST_TIMEOUT_MS,
|
85 |
+
UNFINISHED_INPUT,
|
86 |
+
} from "../constant";
|
87 |
+
import { Avatar } from "./emoji";
|
88 |
+
import { ContextPrompts, MaskAvatar, MaskConfig } from "./mask";
|
89 |
+
import { useMaskStore } from "../store/mask";
|
90 |
+
import { ChatCommandPrefix, useChatCommand, useCommand } from "../command";
|
91 |
+
import { prettyObject } from "../utils/format";
|
92 |
+
import { ExportMessageModal } from "./exporter";
|
93 |
+
import { getClientConfig } from "../config/client";
|
94 |
+
import { useAllModels } from "../utils/hooks";
|
95 |
+
|
96 |
+
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
|
97 |
+
loading: () => <LoadingIcon />,
|
98 |
+
});
|
99 |
+
|
100 |
+
export function SessionConfigModel(props: { onClose: () => void }) {
|
101 |
+
const chatStore = useChatStore();
|
102 |
+
const session = chatStore.currentSession();
|
103 |
+
const maskStore = useMaskStore();
|
104 |
+
const navigate = useNavigate();
|
105 |
+
|
106 |
+
return (
|
107 |
+
<div className="modal-mask">
|
108 |
+
<Modal
|
109 |
+
title={Locale.Context.Edit}
|
110 |
+
onClose={() => props.onClose()}
|
111 |
+
actions={[
|
112 |
+
<IconButton
|
113 |
+
key="reset"
|
114 |
+
icon={<ResetIcon />}
|
115 |
+
bordered
|
116 |
+
text={Locale.Chat.Config.Reset}
|
117 |
+
onClick={async () => {
|
118 |
+
if (await showConfirm(Locale.Memory.ResetConfirm)) {
|
119 |
+
chatStore.updateCurrentSession(
|
120 |
+
(session) => (session.memoryPrompt = ""),
|
121 |
+
);
|
122 |
+
}
|
123 |
+
}}
|
124 |
+
/>,
|
125 |
+
<IconButton
|
126 |
+
key="copy"
|
127 |
+
icon={<CopyIcon />}
|
128 |
+
bordered
|
129 |
+
text={Locale.Chat.Config.SaveAs}
|
130 |
+
onClick={() => {
|
131 |
+
navigate(Path.Masks);
|
132 |
+
setTimeout(() => {
|
133 |
+
maskStore.create(session.mask);
|
134 |
+
}, 500);
|
135 |
+
}}
|
136 |
+
/>,
|
137 |
+
]}
|
138 |
+
>
|
139 |
+
<MaskConfig
|
140 |
+
mask={session.mask}
|
141 |
+
updateMask={(updater) => {
|
142 |
+
const mask = { ...session.mask };
|
143 |
+
updater(mask);
|
144 |
+
chatStore.updateCurrentSession((session) => (session.mask = mask));
|
145 |
+
}}
|
146 |
+
shouldSyncFromGlobal
|
147 |
+
extraListItems={
|
148 |
+
session.mask.modelConfig.sendMemory ? (
|
149 |
+
<ListItem
|
150 |
+
className="copyable"
|
151 |
+
title={`${Locale.Memory.Title} (${session.lastSummarizeIndex} of ${session.messages.length})`}
|
152 |
+
subTitle={session.memoryPrompt || Locale.Memory.EmptyContent}
|
153 |
+
></ListItem>
|
154 |
+
) : (
|
155 |
+
<></>
|
156 |
+
)
|
157 |
+
}
|
158 |
+
></MaskConfig>
|
159 |
+
</Modal>
|
160 |
+
</div>
|
161 |
+
);
|
162 |
+
}
|
163 |
+
|
164 |
+
function PromptToast(props: {
|
165 |
+
showToast?: boolean;
|
166 |
+
showModal?: boolean;
|
167 |
+
setShowModal: (_: boolean) => void;
|
168 |
+
}) {
|
169 |
+
const chatStore = useChatStore();
|
170 |
+
const session = chatStore.currentSession();
|
171 |
+
const context = session.mask.context;
|
172 |
+
|
173 |
+
return (
|
174 |
+
<div className={styles["prompt-toast"]} key="prompt-toast">
|
175 |
+
{props.showToast && (
|
176 |
+
<div
|
177 |
+
className={styles["prompt-toast-inner"] + " clickable"}
|
178 |
+
role="button"
|
179 |
+
onClick={() => props.setShowModal(true)}
|
180 |
+
>
|
181 |
+
<BrainIcon />
|
182 |
+
<span className={styles["prompt-toast-content"]}>
|
183 |
+
{Locale.Context.Toast(context.length)}
|
184 |
+
</span>
|
185 |
+
</div>
|
186 |
+
)}
|
187 |
+
{props.showModal && (
|
188 |
+
<SessionConfigModel onClose={() => props.setShowModal(false)} />
|
189 |
+
)}
|
190 |
+
</div>
|
191 |
+
);
|
192 |
+
}
|
193 |
+
|
194 |
+
function useSubmitHandler() {
|
195 |
+
const config = useAppConfig();
|
196 |
+
const submitKey = config.submitKey;
|
197 |
+
const isComposing = useRef(false);
|
198 |
+
|
199 |
+
useEffect(() => {
|
200 |
+
const onCompositionStart = () => {
|
201 |
+
isComposing.current = true;
|
202 |
+
};
|
203 |
+
const onCompositionEnd = () => {
|
204 |
+
isComposing.current = false;
|
205 |
+
};
|
206 |
+
|
207 |
+
window.addEventListener("compositionstart", onCompositionStart);
|
208 |
+
window.addEventListener("compositionend", onCompositionEnd);
|
209 |
+
|
210 |
+
return () => {
|
211 |
+
window.removeEventListener("compositionstart", onCompositionStart);
|
212 |
+
window.removeEventListener("compositionend", onCompositionEnd);
|
213 |
+
};
|
214 |
+
}, []);
|
215 |
+
|
216 |
+
const shouldSubmit = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
217 |
+
if (e.key !== "Enter") return false;
|
218 |
+
if (e.key === "Enter" && (e.nativeEvent.isComposing || isComposing.current))
|
219 |
+
return false;
|
220 |
+
return (
|
221 |
+
(config.submitKey === SubmitKey.AltEnter && e.altKey) ||
|
222 |
+
(config.submitKey === SubmitKey.CtrlEnter && e.ctrlKey) ||
|
223 |
+
(config.submitKey === SubmitKey.ShiftEnter && e.shiftKey) ||
|
224 |
+
(config.submitKey === SubmitKey.MetaEnter && e.metaKey) ||
|
225 |
+
(config.submitKey === SubmitKey.Enter &&
|
226 |
+
!e.altKey &&
|
227 |
+
!e.ctrlKey &&
|
228 |
+
!e.shiftKey &&
|
229 |
+
!e.metaKey)
|
230 |
+
);
|
231 |
+
};
|
232 |
+
|
233 |
+
return {
|
234 |
+
submitKey,
|
235 |
+
shouldSubmit,
|
236 |
+
};
|
237 |
+
}
|
238 |
+
|
239 |
+
export type RenderPompt = Pick<Prompt, "title" | "content">;
|
240 |
+
|
241 |
+
export function PromptHints(props: {
|
242 |
+
prompts: RenderPompt[];
|
243 |
+
onPromptSelect: (prompt: RenderPompt) => void;
|
244 |
+
}) {
|
245 |
+
const noPrompts = props.prompts.length === 0;
|
246 |
+
const [selectIndex, setSelectIndex] = useState(0);
|
247 |
+
const selectedRef = useRef<HTMLDivElement>(null);
|
248 |
+
|
249 |
+
useEffect(() => {
|
250 |
+
setSelectIndex(0);
|
251 |
+
}, [props.prompts.length]);
|
252 |
+
|
253 |
+
useEffect(() => {
|
254 |
+
const onKeyDown = (e: KeyboardEvent) => {
|
255 |
+
if (noPrompts || e.metaKey || e.altKey || e.ctrlKey) {
|
256 |
+
return;
|
257 |
+
}
|
258 |
+
// arrow up / down to select prompt
|
259 |
+
const changeIndex = (delta: number) => {
|
260 |
+
e.stopPropagation();
|
261 |
+
e.preventDefault();
|
262 |
+
const nextIndex = Math.max(
|
263 |
+
0,
|
264 |
+
Math.min(props.prompts.length - 1, selectIndex + delta),
|
265 |
+
);
|
266 |
+
setSelectIndex(nextIndex);
|
267 |
+
selectedRef.current?.scrollIntoView({
|
268 |
+
block: "center",
|
269 |
+
});
|
270 |
+
};
|
271 |
+
|
272 |
+
if (e.key === "ArrowUp") {
|
273 |
+
changeIndex(1);
|
274 |
+
} else if (e.key === "ArrowDown") {
|
275 |
+
changeIndex(-1);
|
276 |
+
} else if (e.key === "Enter") {
|
277 |
+
const selectedPrompt = props.prompts.at(selectIndex);
|
278 |
+
if (selectedPrompt) {
|
279 |
+
props.onPromptSelect(selectedPrompt);
|
280 |
+
}
|
281 |
+
}
|
282 |
+
};
|
283 |
+
|
284 |
+
window.addEventListener("keydown", onKeyDown);
|
285 |
+
|
286 |
+
return () => window.removeEventListener("keydown", onKeyDown);
|
287 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
288 |
+
}, [props.prompts.length, selectIndex]);
|
289 |
+
|
290 |
+
if (noPrompts) return null;
|
291 |
+
return (
|
292 |
+
<div className={styles["prompt-hints"]}>
|
293 |
+
{props.prompts.map((prompt, i) => (
|
294 |
+
<div
|
295 |
+
ref={i === selectIndex ? selectedRef : null}
|
296 |
+
className={
|
297 |
+
styles["prompt-hint"] +
|
298 |
+
` ${i === selectIndex ? styles["prompt-hint-selected"] : ""}`
|
299 |
+
}
|
300 |
+
key={prompt.title + i.toString()}
|
301 |
+
onClick={() => props.onPromptSelect(prompt)}
|
302 |
+
onMouseEnter={() => setSelectIndex(i)}
|
303 |
+
>
|
304 |
+
<div className={styles["hint-title"]}>{prompt.title}</div>
|
305 |
+
<div className={styles["hint-content"]}>{prompt.content}</div>
|
306 |
+
</div>
|
307 |
+
))}
|
308 |
+
</div>
|
309 |
+
);
|
310 |
+
}
|
311 |
+
|
312 |
+
function ClearContextDivider() {
|
313 |
+
const chatStore = useChatStore();
|
314 |
+
|
315 |
+
return (
|
316 |
+
<div
|
317 |
+
className={styles["clear-context"]}
|
318 |
+
onClick={() =>
|
319 |
+
chatStore.updateCurrentSession(
|
320 |
+
(session) => (session.clearContextIndex = undefined),
|
321 |
+
)
|
322 |
+
}
|
323 |
+
>
|
324 |
+
<div className={styles["clear-context-tips"]}>{Locale.Context.Clear}</div>
|
325 |
+
<div className={styles["clear-context-revert-btn"]}>
|
326 |
+
{Locale.Context.Revert}
|
327 |
+
</div>
|
328 |
+
</div>
|
329 |
+
);
|
330 |
+
}
|
331 |
+
|
332 |
+
function ChatAction(props: {
|
333 |
+
text: string;
|
334 |
+
icon: JSX.Element;
|
335 |
+
onClick: () => void;
|
336 |
+
}) {
|
337 |
+
const iconRef = useRef<HTMLDivElement>(null);
|
338 |
+
const textRef = useRef<HTMLDivElement>(null);
|
339 |
+
const [width, setWidth] = useState({
|
340 |
+
full: 16,
|
341 |
+
icon: 16,
|
342 |
+
});
|
343 |
+
|
344 |
+
function updateWidth() {
|
345 |
+
if (!iconRef.current || !textRef.current) return;
|
346 |
+
const getWidth = (dom: HTMLDivElement) => dom.getBoundingClientRect().width;
|
347 |
+
const textWidth = getWidth(textRef.current);
|
348 |
+
const iconWidth = getWidth(iconRef.current);
|
349 |
+
setWidth({
|
350 |
+
full: textWidth + iconWidth,
|
351 |
+
icon: iconWidth,
|
352 |
+
});
|
353 |
+
}
|
354 |
+
|
355 |
+
return (
|
356 |
+
<div
|
357 |
+
className={`${styles["chat-input-action"]} clickable`}
|
358 |
+
onClick={() => {
|
359 |
+
props.onClick();
|
360 |
+
setTimeout(updateWidth, 1);
|
361 |
+
}}
|
362 |
+
onMouseEnter={updateWidth}
|
363 |
+
onTouchStart={updateWidth}
|
364 |
+
style={
|
365 |
+
{
|
366 |
+
"--icon-width": `${width.icon}px`,
|
367 |
+
"--full-width": `${width.full}px`,
|
368 |
+
} as React.CSSProperties
|
369 |
+
}
|
370 |
+
>
|
371 |
+
<div ref={iconRef} className={styles["icon"]}>
|
372 |
+
{props.icon}
|
373 |
+
</div>
|
374 |
+
<div className={styles["text"]} ref={textRef}>
|
375 |
+
{props.text}
|
376 |
+
</div>
|
377 |
+
</div>
|
378 |
+
);
|
379 |
+
}
|
380 |
+
|
381 |
+
function useScrollToBottom() {
|
382 |
+
// for auto-scroll
|
383 |
+
const scrollRef = useRef<HTMLDivElement>(null);
|
384 |
+
const [autoScroll, setAutoScroll] = useState(true);
|
385 |
+
|
386 |
+
function scrollDomToBottom() {
|
387 |
+
const dom = scrollRef.current;
|
388 |
+
if (dom) {
|
389 |
+
requestAnimationFrame(() => {
|
390 |
+
setAutoScroll(true);
|
391 |
+
dom.scrollTo(0, dom.scrollHeight);
|
392 |
+
});
|
393 |
+
}
|
394 |
+
}
|
395 |
+
|
396 |
+
// auto scroll
|
397 |
+
useEffect(() => {
|
398 |
+
if (autoScroll) {
|
399 |
+
scrollDomToBottom();
|
400 |
+
}
|
401 |
+
});
|
402 |
+
|
403 |
+
return {
|
404 |
+
scrollRef,
|
405 |
+
autoScroll,
|
406 |
+
setAutoScroll,
|
407 |
+
scrollDomToBottom,
|
408 |
+
};
|
409 |
+
}
|
410 |
+
|
411 |
+
export function ChatActions(props: {
|
412 |
+
showPromptModal: () => void;
|
413 |
+
scrollToBottom: () => void;
|
414 |
+
showPromptHints: () => void;
|
415 |
+
hitBottom: boolean;
|
416 |
+
}) {
|
417 |
+
const config = useAppConfig();
|
418 |
+
const navigate = useNavigate();
|
419 |
+
const chatStore = useChatStore();
|
420 |
+
|
421 |
+
// switch Plugins
|
422 |
+
const usePlugins = chatStore.currentSession().mask.usePlugins;
|
423 |
+
function switchUsePlugins() {
|
424 |
+
chatStore.updateCurrentSession((session) => {
|
425 |
+
session.mask.usePlugins = !session.mask.usePlugins;
|
426 |
+
});
|
427 |
+
}
|
428 |
+
|
429 |
+
// switch themes
|
430 |
+
const theme = config.theme;
|
431 |
+
function nextTheme() {
|
432 |
+
const themes = [Theme.Auto, Theme.Light, Theme.Dark];
|
433 |
+
const themeIndex = themes.indexOf(theme);
|
434 |
+
const nextIndex = (themeIndex + 1) % themes.length;
|
435 |
+
const nextTheme = themes[nextIndex];
|
436 |
+
config.update((config) => (config.theme = nextTheme));
|
437 |
+
}
|
438 |
+
|
439 |
+
// stop all responses
|
440 |
+
const couldStop = ChatControllerPool.hasPending();
|
441 |
+
const stopAll = () => ChatControllerPool.stopAll();
|
442 |
+
|
443 |
+
// switch model
|
444 |
+
const currentModel = chatStore.currentSession().mask.modelConfig.model;
|
445 |
+
const models = useAllModels()
|
446 |
+
.filter((m) => m.available)
|
447 |
+
.map((m) => m.name);
|
448 |
+
const [showModelSelector, setShowModelSelector] = useState(false);
|
449 |
+
|
450 |
+
return (
|
451 |
+
<div className={styles["chat-input-actions"]}>
|
452 |
+
<div>
|
453 |
+
{couldStop && (
|
454 |
+
<ChatAction
|
455 |
+
onClick={stopAll}
|
456 |
+
text={Locale.Chat.InputActions.Stop}
|
457 |
+
icon={<StopIcon />}
|
458 |
+
/>
|
459 |
+
)}
|
460 |
+
{!props.hitBottom && (
|
461 |
+
<ChatAction
|
462 |
+
onClick={props.scrollToBottom}
|
463 |
+
text={Locale.Chat.InputActions.ToBottom}
|
464 |
+
icon={<BottomIcon />}
|
465 |
+
/>
|
466 |
+
)}
|
467 |
+
{props.hitBottom && (
|
468 |
+
<ChatAction
|
469 |
+
onClick={props.showPromptModal}
|
470 |
+
text={Locale.Chat.InputActions.Settings}
|
471 |
+
icon={<SettingsIcon />}
|
472 |
+
/>
|
473 |
+
)}
|
474 |
+
|
475 |
+
<ChatAction
|
476 |
+
onClick={nextTheme}
|
477 |
+
text={Locale.Chat.InputActions.Theme[theme]}
|
478 |
+
icon={
|
479 |
+
<>
|
480 |
+
{theme === Theme.Auto ? (
|
481 |
+
<AutoIcon />
|
482 |
+
) : theme === Theme.Light ? (
|
483 |
+
<LightIcon />
|
484 |
+
) : theme === Theme.Dark ? (
|
485 |
+
<DarkIcon />
|
486 |
+
) : null}
|
487 |
+
</>
|
488 |
+
}
|
489 |
+
/>
|
490 |
+
|
491 |
+
<ChatAction
|
492 |
+
onClick={props.showPromptHints}
|
493 |
+
text={Locale.Chat.InputActions.Prompt}
|
494 |
+
icon={<PromptIcon />}
|
495 |
+
/>
|
496 |
+
|
497 |
+
<ChatAction
|
498 |
+
onClick={() => {
|
499 |
+
navigate(Path.Masks);
|
500 |
+
}}
|
501 |
+
text={Locale.Chat.InputActions.Masks}
|
502 |
+
icon={<MaskIcon />}
|
503 |
+
/>
|
504 |
+
|
505 |
+
<ChatAction
|
506 |
+
onClick={() => setShowModelSelector(true)}
|
507 |
+
text={currentModel}
|
508 |
+
icon={<RobotIcon />}
|
509 |
+
/>
|
510 |
+
|
511 |
+
{config.pluginConfig.enable &&
|
512 |
+
/^gpt(?!.*03\d{2}$).*$/.test(currentModel) && (
|
513 |
+
<ChatAction
|
514 |
+
onClick={switchUsePlugins}
|
515 |
+
text={
|
516 |
+
usePlugins
|
517 |
+
? Locale.Chat.InputActions.DisablePlugins
|
518 |
+
: Locale.Chat.InputActions.EnablePlugins
|
519 |
+
}
|
520 |
+
icon={usePlugins ? <EnablePluginIcon /> : <DisablePluginIcon />}
|
521 |
+
/>
|
522 |
+
)}
|
523 |
+
|
524 |
+
{showModelSelector && (
|
525 |
+
<Selector
|
526 |
+
defaultSelectedValue={currentModel}
|
527 |
+
items={models.map((m) => ({
|
528 |
+
title: m,
|
529 |
+
value: m,
|
530 |
+
}))}
|
531 |
+
onClose={() => setShowModelSelector(false)}
|
532 |
+
onSelection={(s) => {
|
533 |
+
if (s.length === 0) return;
|
534 |
+
chatStore.updateCurrentSession((session) => {
|
535 |
+
session.mask.modelConfig.model = s[0] as ModelType;
|
536 |
+
session.mask.syncGlobalConfig = false;
|
537 |
+
session.mask.usePlugins = /^gpt(?!.*03\d{2}$).*$/.test(
|
538 |
+
session.mask.modelConfig.model,
|
539 |
+
);
|
540 |
+
});
|
541 |
+
showToast(s[0]);
|
542 |
+
}}
|
543 |
+
/>
|
544 |
+
)}
|
545 |
+
</div>
|
546 |
+
<div>
|
547 |
+
<ChatAction
|
548 |
+
text={Locale.Chat.InputActions.Clear}
|
549 |
+
icon={<BreakIcon />}
|
550 |
+
onClick={() => {
|
551 |
+
chatStore.updateCurrentSession((session) => {
|
552 |
+
if (session.clearContextIndex === session.messages.length) {
|
553 |
+
session.clearContextIndex = undefined;
|
554 |
+
} else {
|
555 |
+
session.clearContextIndex = session.messages.length;
|
556 |
+
session.memoryPrompt = ""; // will clear memory
|
557 |
+
}
|
558 |
+
});
|
559 |
+
}}
|
560 |
+
/>
|
561 |
+
</div>
|
562 |
+
</div>
|
563 |
+
);
|
564 |
+
}
|
565 |
+
|
566 |
+
export function EditMessageModal(props: { onClose: () => void }) {
|
567 |
+
const chatStore = useChatStore();
|
568 |
+
const session = chatStore.currentSession();
|
569 |
+
const [messages, setMessages] = useState(session.messages.slice());
|
570 |
+
|
571 |
+
return (
|
572 |
+
<div className="modal-mask">
|
573 |
+
<Modal
|
574 |
+
title={Locale.Chat.EditMessage.Title}
|
575 |
+
onClose={props.onClose}
|
576 |
+
actions={[
|
577 |
+
<IconButton
|
578 |
+
text={Locale.UI.Cancel}
|
579 |
+
icon={<CancelIcon />}
|
580 |
+
key="cancel"
|
581 |
+
onClick={() => {
|
582 |
+
props.onClose();
|
583 |
+
}}
|
584 |
+
/>,
|
585 |
+
<IconButton
|
586 |
+
type="primary"
|
587 |
+
text={Locale.UI.Confirm}
|
588 |
+
icon={<ConfirmIcon />}
|
589 |
+
key="ok"
|
590 |
+
onClick={() => {
|
591 |
+
chatStore.updateCurrentSession(
|
592 |
+
(session) => (session.messages = messages),
|
593 |
+
);
|
594 |
+
props.onClose();
|
595 |
+
}}
|
596 |
+
/>,
|
597 |
+
]}
|
598 |
+
>
|
599 |
+
<List>
|
600 |
+
<ListItem
|
601 |
+
title={Locale.Chat.EditMessage.Topic.Title}
|
602 |
+
subTitle={Locale.Chat.EditMessage.Topic.SubTitle}
|
603 |
+
>
|
604 |
+
<input
|
605 |
+
type="text"
|
606 |
+
value={session.topic}
|
607 |
+
onInput={(e) =>
|
608 |
+
chatStore.updateCurrentSession(
|
609 |
+
(session) => (session.topic = e.currentTarget.value),
|
610 |
+
)
|
611 |
+
}
|
612 |
+
></input>
|
613 |
+
</ListItem>
|
614 |
+
</List>
|
615 |
+
<ContextPrompts
|
616 |
+
context={messages}
|
617 |
+
updateContext={(updater) => {
|
618 |
+
const newMessages = messages.slice();
|
619 |
+
updater(newMessages);
|
620 |
+
setMessages(newMessages);
|
621 |
+
}}
|
622 |
+
/>
|
623 |
+
</Modal>
|
624 |
+
</div>
|
625 |
+
);
|
626 |
+
}
|
627 |
+
|
628 |
+
function _Chat() {
|
629 |
+
type RenderMessage = ChatMessage & { preview?: boolean };
|
630 |
+
|
631 |
+
const chatStore = useChatStore();
|
632 |
+
const session = chatStore.currentSession();
|
633 |
+
const config = useAppConfig();
|
634 |
+
const fontSize = config.fontSize;
|
635 |
+
|
636 |
+
const [showExport, setShowExport] = useState(false);
|
637 |
+
|
638 |
+
const inputRef = useRef<HTMLTextAreaElement>(null);
|
639 |
+
const [userInput, setUserInput] = useState("");
|
640 |
+
const [isLoading, setIsLoading] = useState(false);
|
641 |
+
const { submitKey, shouldSubmit } = useSubmitHandler();
|
642 |
+
const { scrollRef, setAutoScroll, scrollDomToBottom } = useScrollToBottom();
|
643 |
+
const [hitBottom, setHitBottom] = useState(true);
|
644 |
+
const isMobileScreen = useMobileScreen();
|
645 |
+
const navigate = useNavigate();
|
646 |
+
|
647 |
+
// prompt hints
|
648 |
+
const promptStore = usePromptStore();
|
649 |
+
const [promptHints, setPromptHints] = useState<RenderPompt[]>([]);
|
650 |
+
const onSearch = useDebouncedCallback(
|
651 |
+
(text: string) => {
|
652 |
+
const matchedPrompts = promptStore.search(text);
|
653 |
+
setPromptHints(matchedPrompts);
|
654 |
+
},
|
655 |
+
100,
|
656 |
+
{ leading: true, trailing: true },
|
657 |
+
);
|
658 |
+
|
659 |
+
// auto grow input
|
660 |
+
const [inputRows, setInputRows] = useState(2);
|
661 |
+
const measure = useDebouncedCallback(
|
662 |
+
() => {
|
663 |
+
const rows = inputRef.current ? autoGrowTextArea(inputRef.current) : 1;
|
664 |
+
const inputRows = Math.min(
|
665 |
+
20,
|
666 |
+
Math.max(2 + Number(!isMobileScreen), rows),
|
667 |
+
);
|
668 |
+
setInputRows(inputRows);
|
669 |
+
},
|
670 |
+
100,
|
671 |
+
{
|
672 |
+
leading: true,
|
673 |
+
trailing: true,
|
674 |
+
},
|
675 |
+
);
|
676 |
+
|
677 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
678 |
+
useEffect(measure, [userInput]);
|
679 |
+
|
680 |
+
// chat commands shortcuts
|
681 |
+
const chatCommands = useChatCommand({
|
682 |
+
new: () => chatStore.newSession(),
|
683 |
+
newm: () => navigate(Path.NewChat),
|
684 |
+
prev: () => chatStore.nextSession(-1),
|
685 |
+
next: () => chatStore.nextSession(1),
|
686 |
+
clear: () =>
|
687 |
+
chatStore.updateCurrentSession(
|
688 |
+
(session) => (session.clearContextIndex = session.messages.length),
|
689 |
+
),
|
690 |
+
del: () => chatStore.deleteSession(chatStore.currentSessionIndex),
|
691 |
+
});
|
692 |
+
|
693 |
+
// only search prompts when user input is short
|
694 |
+
const SEARCH_TEXT_LIMIT = 30;
|
695 |
+
const onInput = (text: string) => {
|
696 |
+
setUserInput(text);
|
697 |
+
const n = text.trim().length;
|
698 |
+
|
699 |
+
// clear search results
|
700 |
+
if (n === 0) {
|
701 |
+
setPromptHints([]);
|
702 |
+
} else if (text.startsWith(ChatCommandPrefix)) {
|
703 |
+
setPromptHints(chatCommands.search(text));
|
704 |
+
} else if (!config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
|
705 |
+
// check if need to trigger auto completion
|
706 |
+
if (text.startsWith("/")) {
|
707 |
+
let searchText = text.slice(1);
|
708 |
+
onSearch(searchText);
|
709 |
+
}
|
710 |
+
}
|
711 |
+
};
|
712 |
+
|
713 |
+
const doSubmit = (userInput: string) => {
|
714 |
+
if (userInput.trim() === "") return;
|
715 |
+
const matchCommand = chatCommands.match(userInput);
|
716 |
+
if (matchCommand.matched) {
|
717 |
+
setUserInput("");
|
718 |
+
setPromptHints([]);
|
719 |
+
matchCommand.invoke();
|
720 |
+
return;
|
721 |
+
}
|
722 |
+
setIsLoading(true);
|
723 |
+
chatStore.onUserInput(userInput).then(() => setIsLoading(false));
|
724 |
+
localStorage.setItem(LAST_INPUT_KEY, userInput);
|
725 |
+
setUserInput("");
|
726 |
+
setPromptHints([]);
|
727 |
+
if (!isMobileScreen) inputRef.current?.focus();
|
728 |
+
setAutoScroll(true);
|
729 |
+
};
|
730 |
+
|
731 |
+
const onPromptSelect = (prompt: RenderPompt) => {
|
732 |
+
setTimeout(() => {
|
733 |
+
setPromptHints([]);
|
734 |
+
|
735 |
+
const matchedChatCommand = chatCommands.match(prompt.content);
|
736 |
+
if (matchedChatCommand.matched) {
|
737 |
+
// if user is selecting a chat command, just trigger it
|
738 |
+
matchedChatCommand.invoke();
|
739 |
+
setUserInput("");
|
740 |
+
} else {
|
741 |
+
// or fill the prompt
|
742 |
+
setUserInput(prompt.content);
|
743 |
+
}
|
744 |
+
inputRef.current?.focus();
|
745 |
+
}, 30);
|
746 |
+
};
|
747 |
+
|
748 |
+
// stop response
|
749 |
+
const onUserStop = (messageId: string) => {
|
750 |
+
ChatControllerPool.stop(session.id, messageId);
|
751 |
+
};
|
752 |
+
|
753 |
+
useEffect(() => {
|
754 |
+
chatStore.updateCurrentSession((session) => {
|
755 |
+
const stopTiming = Date.now() - REQUEST_TIMEOUT_MS;
|
756 |
+
session.messages.forEach((m) => {
|
757 |
+
// check if should stop all stale messages
|
758 |
+
if (m.isError || new Date(m.date).getTime() < stopTiming) {
|
759 |
+
if (m.streaming) {
|
760 |
+
m.streaming = false;
|
761 |
+
}
|
762 |
+
|
763 |
+
if (m.content.length === 0) {
|
764 |
+
m.isError = true;
|
765 |
+
m.content = prettyObject({
|
766 |
+
error: true,
|
767 |
+
message: "empty response",
|
768 |
+
});
|
769 |
+
}
|
770 |
+
}
|
771 |
+
});
|
772 |
+
|
773 |
+
// auto sync mask config from global config
|
774 |
+
if (session.mask.syncGlobalConfig) {
|
775 |
+
console.log("[Mask] syncing from global, name = ", session.mask.name);
|
776 |
+
session.mask.modelConfig = { ...config.modelConfig };
|
777 |
+
}
|
778 |
+
});
|
779 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
780 |
+
}, []);
|
781 |
+
|
782 |
+
// check if should send message
|
783 |
+
const onInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
784 |
+
// if ArrowUp and no userInput, fill with last input
|
785 |
+
if (
|
786 |
+
e.key === "ArrowUp" &&
|
787 |
+
userInput.length <= 0 &&
|
788 |
+
!(e.metaKey || e.altKey || e.ctrlKey)
|
789 |
+
) {
|
790 |
+
setUserInput(localStorage.getItem(LAST_INPUT_KEY) ?? "");
|
791 |
+
e.preventDefault();
|
792 |
+
return;
|
793 |
+
}
|
794 |
+
if (shouldSubmit(e) && promptHints.length === 0) {
|
795 |
+
doSubmit(userInput);
|
796 |
+
e.preventDefault();
|
797 |
+
}
|
798 |
+
};
|
799 |
+
const onRightClick = (e: any, message: ChatMessage) => {
|
800 |
+
// copy to clipboard
|
801 |
+
if (selectOrCopy(e.currentTarget, message.content)) {
|
802 |
+
if (userInput.length === 0) {
|
803 |
+
setUserInput(message.content);
|
804 |
+
}
|
805 |
+
|
806 |
+
e.preventDefault();
|
807 |
+
}
|
808 |
+
};
|
809 |
+
|
810 |
+
const deleteMessage = (msgId?: string) => {
|
811 |
+
chatStore.updateCurrentSession(
|
812 |
+
(session) =>
|
813 |
+
(session.messages = session.messages.filter((m) => m.id !== msgId)),
|
814 |
+
);
|
815 |
+
};
|
816 |
+
|
817 |
+
const onDelete = (msgId: string) => {
|
818 |
+
deleteMessage(msgId);
|
819 |
+
};
|
820 |
+
|
821 |
+
const onResend = (message: ChatMessage) => {
|
822 |
+
// when it is resending a message
|
823 |
+
// 1. for a user's message, find the next bot response
|
824 |
+
// 2. for a bot's message, find the last user's input
|
825 |
+
// 3. delete original user input and bot's message
|
826 |
+
// 4. resend the user's input
|
827 |
+
|
828 |
+
const resendingIndex = session.messages.findIndex(
|
829 |
+
(m) => m.id === message.id,
|
830 |
+
);
|
831 |
+
|
832 |
+
if (resendingIndex < 0 || resendingIndex >= session.messages.length) {
|
833 |
+
console.error("[Chat] failed to find resending message", message);
|
834 |
+
return;
|
835 |
+
}
|
836 |
+
|
837 |
+
let userMessage: ChatMessage | undefined;
|
838 |
+
let botMessage: ChatMessage | undefined;
|
839 |
+
|
840 |
+
if (message.role === "assistant") {
|
841 |
+
// if it is resending a bot's message, find the user input for it
|
842 |
+
botMessage = message;
|
843 |
+
for (let i = resendingIndex; i >= 0; i -= 1) {
|
844 |
+
if (session.messages[i].role === "user") {
|
845 |
+
userMessage = session.messages[i];
|
846 |
+
break;
|
847 |
+
}
|
848 |
+
}
|
849 |
+
} else if (message.role === "user") {
|
850 |
+
// if it is resending a user's input, find the bot's response
|
851 |
+
userMessage = message;
|
852 |
+
for (let i = resendingIndex; i < session.messages.length; i += 1) {
|
853 |
+
if (session.messages[i].role === "assistant") {
|
854 |
+
botMessage = session.messages[i];
|
855 |
+
break;
|
856 |
+
}
|
857 |
+
}
|
858 |
+
}
|
859 |
+
|
860 |
+
if (userMessage === undefined) {
|
861 |
+
console.error("[Chat] failed to resend", message);
|
862 |
+
return;
|
863 |
+
}
|
864 |
+
|
865 |
+
// delete the original messages
|
866 |
+
deleteMessage(userMessage.id);
|
867 |
+
deleteMessage(botMessage?.id);
|
868 |
+
|
869 |
+
// resend the message
|
870 |
+
setIsLoading(true);
|
871 |
+
chatStore.onUserInput(userMessage.content).then(() => setIsLoading(false));
|
872 |
+
inputRef.current?.focus();
|
873 |
+
};
|
874 |
+
|
875 |
+
const onPinMessage = (message: ChatMessage) => {
|
876 |
+
chatStore.updateCurrentSession((session) =>
|
877 |
+
session.mask.context.push(message),
|
878 |
+
);
|
879 |
+
|
880 |
+
showToast(Locale.Chat.Actions.PinToastContent, {
|
881 |
+
text: Locale.Chat.Actions.PinToastAction,
|
882 |
+
onClick: () => {
|
883 |
+
setShowPromptModal(true);
|
884 |
+
},
|
885 |
+
});
|
886 |
+
};
|
887 |
+
|
888 |
+
const context: RenderMessage[] = useMemo(() => {
|
889 |
+
return session.mask.hideContext ? [] : session.mask.context.slice();
|
890 |
+
}, [session.mask.context, session.mask.hideContext]);
|
891 |
+
const accessStore = useAccessStore();
|
892 |
+
|
893 |
+
if (
|
894 |
+
context.length === 0 &&
|
895 |
+
session.messages.at(0)?.content !== BOT_HELLO.content
|
896 |
+
) {
|
897 |
+
const copiedHello = Object.assign({}, BOT_HELLO);
|
898 |
+
if (!accessStore.isAuthorized()) {
|
899 |
+
copiedHello.content = Locale.Error.Unauthorized;
|
900 |
+
}
|
901 |
+
context.push(copiedHello);
|
902 |
+
}
|
903 |
+
|
904 |
+
// preview messages
|
905 |
+
const renderMessages = useMemo(() => {
|
906 |
+
return context
|
907 |
+
.concat(session.messages as RenderMessage[])
|
908 |
+
.concat(
|
909 |
+
isLoading
|
910 |
+
? [
|
911 |
+
{
|
912 |
+
...createMessage({
|
913 |
+
role: "assistant",
|
914 |
+
content: "……",
|
915 |
+
}),
|
916 |
+
preview: true,
|
917 |
+
},
|
918 |
+
]
|
919 |
+
: [],
|
920 |
+
)
|
921 |
+
/*.concat(
|
922 |
+
userInput.length > 0 && config.sendPreviewBubble
|
923 |
+
? [
|
924 |
+
{
|
925 |
+
...createMessage({
|
926 |
+
role: "user",
|
927 |
+
content: userInput,
|
928 |
+
}),
|
929 |
+
preview: true,
|
930 |
+
},
|
931 |
+
]
|
932 |
+
: [],
|
933 |
+
);*/
|
934 |
+
}, [
|
935 |
+
config.sendPreviewBubble,
|
936 |
+
context,
|
937 |
+
isLoading,
|
938 |
+
session.messages,
|
939 |
+
userInput,
|
940 |
+
]);
|
941 |
+
|
942 |
+
const [msgRenderIndex, _setMsgRenderIndex] = useState(
|
943 |
+
Math.max(0, renderMessages.length - CHAT_PAGE_SIZE),
|
944 |
+
);
|
945 |
+
function setMsgRenderIndex(newIndex: number) {
|
946 |
+
newIndex = Math.min(renderMessages.length - CHAT_PAGE_SIZE, newIndex);
|
947 |
+
newIndex = Math.max(0, newIndex);
|
948 |
+
_setMsgRenderIndex(newIndex);
|
949 |
+
}
|
950 |
+
|
951 |
+
const messages = useMemo(() => {
|
952 |
+
const endRenderIndex = Math.min(
|
953 |
+
msgRenderIndex + 3 * CHAT_PAGE_SIZE,
|
954 |
+
renderMessages.length,
|
955 |
+
);
|
956 |
+
return renderMessages.slice(msgRenderIndex, endRenderIndex);
|
957 |
+
}, [msgRenderIndex, renderMessages]);
|
958 |
+
|
959 |
+
const onChatBodyScroll = (e: HTMLElement) => {
|
960 |
+
const bottomHeight = e.scrollTop + e.clientHeight;
|
961 |
+
const edgeThreshold = e.clientHeight;
|
962 |
+
|
963 |
+
const isTouchTopEdge = e.scrollTop <= edgeThreshold;
|
964 |
+
const isTouchBottomEdge = bottomHeight >= e.scrollHeight - edgeThreshold;
|
965 |
+
const isHitBottom =
|
966 |
+
bottomHeight >= e.scrollHeight - (isMobileScreen ? 4 : 10);
|
967 |
+
|
968 |
+
const prevPageMsgIndex = msgRenderIndex - CHAT_PAGE_SIZE;
|
969 |
+
const nextPageMsgIndex = msgRenderIndex + CHAT_PAGE_SIZE;
|
970 |
+
|
971 |
+
if (isTouchTopEdge && !isTouchBottomEdge) {
|
972 |
+
setMsgRenderIndex(prevPageMsgIndex);
|
973 |
+
} else if (isTouchBottomEdge) {
|
974 |
+
setMsgRenderIndex(nextPageMsgIndex);
|
975 |
+
}
|
976 |
+
|
977 |
+
setHitBottom(isHitBottom);
|
978 |
+
setAutoScroll(isHitBottom);
|
979 |
+
};
|
980 |
+
|
981 |
+
function scrollToBottom() {
|
982 |
+
setMsgRenderIndex(renderMessages.length - CHAT_PAGE_SIZE);
|
983 |
+
scrollDomToBottom();
|
984 |
+
}
|
985 |
+
|
986 |
+
// clear context index = context length + index in messages
|
987 |
+
const clearContextIndex =
|
988 |
+
(session.clearContextIndex ?? -1) >= 0
|
989 |
+
? session.clearContextIndex! + context.length - msgRenderIndex
|
990 |
+
: -1;
|
991 |
+
|
992 |
+
const [showPromptModal, setShowPromptModal] = useState(false);
|
993 |
+
|
994 |
+
const clientConfig = useMemo(() => getClientConfig(), []);
|
995 |
+
|
996 |
+
const autoFocus = !isMobileScreen; // wont auto focus on mobile screen
|
997 |
+
const showMaxIcon = !isMobileScreen && !clientConfig?.isApp;
|
998 |
+
|
999 |
+
useCommand({
|
1000 |
+
fill: setUserInput,
|
1001 |
+
submit: (text) => {
|
1002 |
+
doSubmit(text);
|
1003 |
+
},
|
1004 |
+
code: (text) => {
|
1005 |
+
if (accessStore.disableFastLink) return;
|
1006 |
+
console.log("[Command] got code from url: ", text);
|
1007 |
+
showConfirm(Locale.URLCommand.Code + `code = ${text}`).then((res) => {
|
1008 |
+
if (res) {
|
1009 |
+
accessStore.update((access) => (access.accessCode = text));
|
1010 |
+
}
|
1011 |
+
});
|
1012 |
+
},
|
1013 |
+
settings: (text) => {
|
1014 |
+
if (accessStore.disableFastLink) return;
|
1015 |
+
|
1016 |
+
try {
|
1017 |
+
const payload = JSON.parse(text) as {
|
1018 |
+
key?: string;
|
1019 |
+
url?: string;
|
1020 |
+
};
|
1021 |
+
|
1022 |
+
console.log("[Command] got settings from url: ", payload);
|
1023 |
+
|
1024 |
+
if (payload.key || payload.url) {
|
1025 |
+
showConfirm(
|
1026 |
+
Locale.URLCommand.Settings +
|
1027 |
+
`\n${JSON.stringify(payload, null, 4)}`,
|
1028 |
+
).then((res) => {
|
1029 |
+
if (!res) return;
|
1030 |
+
if (payload.key) {
|
1031 |
+
accessStore.update((access) => (access.token = payload.key!));
|
1032 |
+
}
|
1033 |
+
if (payload.url) {
|
1034 |
+
accessStore.update((access) => (access.openaiUrl = payload.url!));
|
1035 |
+
}
|
1036 |
+
});
|
1037 |
+
}
|
1038 |
+
} catch {
|
1039 |
+
console.error("[Command] failed to get settings from url: ", text);
|
1040 |
+
}
|
1041 |
+
},
|
1042 |
+
});
|
1043 |
+
|
1044 |
+
// edit / insert message modal
|
1045 |
+
const [isEditingMessage, setIsEditingMessage] = useState(false);
|
1046 |
+
|
1047 |
+
// remember unfinished input
|
1048 |
+
useEffect(() => {
|
1049 |
+
// try to load from local storage
|
1050 |
+
const key = UNFINISHED_INPUT(session.id);
|
1051 |
+
const mayBeUnfinishedInput = localStorage.getItem(key);
|
1052 |
+
if (mayBeUnfinishedInput && userInput.length === 0) {
|
1053 |
+
setUserInput(mayBeUnfinishedInput);
|
1054 |
+
localStorage.removeItem(key);
|
1055 |
+
}
|
1056 |
+
|
1057 |
+
const dom = inputRef.current;
|
1058 |
+
return () => {
|
1059 |
+
localStorage.setItem(key, dom?.value ?? "");
|
1060 |
+
};
|
1061 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
1062 |
+
}, []);
|
1063 |
+
|
1064 |
+
return (
|
1065 |
+
<div className={styles.chat} key={session.id}>
|
1066 |
+
<div className="window-header" data-tauri-drag-region>
|
1067 |
+
{isMobileScreen && (
|
1068 |
+
<div className="window-actions">
|
1069 |
+
<div className={"window-action-button"}>
|
1070 |
+
<IconButton
|
1071 |
+
icon={<ReturnIcon />}
|
1072 |
+
bordered
|
1073 |
+
title={Locale.Chat.Actions.ChatList}
|
1074 |
+
onClick={() => navigate(Path.Home)}
|
1075 |
+
/>
|
1076 |
+
</div>
|
1077 |
+
</div>
|
1078 |
+
)}
|
1079 |
+
|
1080 |
+
<div className={`window-header-title ${styles["chat-body-title"]}`}>
|
1081 |
+
<div
|
1082 |
+
className={`window-header-main-title ${styles["chat-body-main-title"]}`}
|
1083 |
+
onClickCapture={() => setIsEditingMessage(true)}
|
1084 |
+
>
|
1085 |
+
{!session.topic ? DEFAULT_TOPIC : session.topic}
|
1086 |
+
</div>
|
1087 |
+
<div className="window-header-sub-title">
|
1088 |
+
{Locale.Chat.SubTitle(session.messages.length)}
|
1089 |
+
</div>
|
1090 |
+
</div>
|
1091 |
+
<div className="window-actions">
|
1092 |
+
{!isMobileScreen && (
|
1093 |
+
<div className="window-action-button">
|
1094 |
+
<IconButton
|
1095 |
+
icon={<RenameIcon />}
|
1096 |
+
bordered
|
1097 |
+
onClick={() => setIsEditingMessage(true)}
|
1098 |
+
/>
|
1099 |
+
</div>
|
1100 |
+
)}
|
1101 |
+
<div className="window-action-button">
|
1102 |
+
<IconButton
|
1103 |
+
icon={<ExportIcon />}
|
1104 |
+
bordered
|
1105 |
+
title={Locale.Chat.Actions.Export}
|
1106 |
+
onClick={() => {
|
1107 |
+
setShowExport(true);
|
1108 |
+
}}
|
1109 |
+
/>
|
1110 |
+
</div>
|
1111 |
+
{showMaxIcon && (
|
1112 |
+
<div className="window-action-button">
|
1113 |
+
<IconButton
|
1114 |
+
icon={config.tightBorder ? <MinIcon /> : <MaxIcon />}
|
1115 |
+
bordered
|
1116 |
+
onClick={() => {
|
1117 |
+
config.update(
|
1118 |
+
(config) => (config.tightBorder = !config.tightBorder),
|
1119 |
+
);
|
1120 |
+
}}
|
1121 |
+
/>
|
1122 |
+
</div>
|
1123 |
+
)}
|
1124 |
+
</div>
|
1125 |
+
|
1126 |
+
<PromptToast
|
1127 |
+
showToast={!hitBottom}
|
1128 |
+
showModal={showPromptModal}
|
1129 |
+
setShowModal={setShowPromptModal}
|
1130 |
+
/>
|
1131 |
+
</div>
|
1132 |
+
|
1133 |
+
<div
|
1134 |
+
className={styles["chat-body"]}
|
1135 |
+
ref={scrollRef}
|
1136 |
+
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
|
1137 |
+
onMouseDown={() => inputRef.current?.blur()}
|
1138 |
+
onTouchStart={() => {
|
1139 |
+
inputRef.current?.blur();
|
1140 |
+
setAutoScroll(false);
|
1141 |
+
}}
|
1142 |
+
>
|
1143 |
+
{messages.map((message, i) => {
|
1144 |
+
const isUser = message.role === "user";
|
1145 |
+
const isContext = i < context.length;
|
1146 |
+
const showActions =
|
1147 |
+
i > 0 &&
|
1148 |
+
!(message.preview || message.content.length === 0) &&
|
1149 |
+
!isContext;
|
1150 |
+
const showTyping = message.preview || message.streaming;
|
1151 |
+
|
1152 |
+
const shouldShowClearContextDivider = i === clearContextIndex - 1;
|
1153 |
+
|
1154 |
+
return (
|
1155 |
+
<Fragment key={message.id}>
|
1156 |
+
<div
|
1157 |
+
className={
|
1158 |
+
isUser ? styles["chat-message-user"] : styles["chat-message"]
|
1159 |
+
}
|
1160 |
+
>
|
1161 |
+
<div className={styles["chat-message-container"]}>
|
1162 |
+
<div className={styles["chat-message-header"]}>
|
1163 |
+
<div className={styles["chat-message-avatar"]}>
|
1164 |
+
<div className={styles["chat-message-edit"]}>
|
1165 |
+
<IconButton
|
1166 |
+
icon={<EditIcon />}
|
1167 |
+
onClick={async () => {
|
1168 |
+
const newMessage = await showPrompt(
|
1169 |
+
Locale.Chat.Actions.Edit,
|
1170 |
+
message.content,
|
1171 |
+
10,
|
1172 |
+
);
|
1173 |
+
chatStore.updateCurrentSession((session) => {
|
1174 |
+
const m = session.mask.context
|
1175 |
+
.concat(session.messages)
|
1176 |
+
.find((m) => m.id === message.id);
|
1177 |
+
if (m) {
|
1178 |
+
m.content = newMessage;
|
1179 |
+
}
|
1180 |
+
});
|
1181 |
+
}}
|
1182 |
+
></IconButton>
|
1183 |
+
</div>
|
1184 |
+
{isUser ? (
|
1185 |
+
<Avatar avatar={config.avatar} />
|
1186 |
+
) : (
|
1187 |
+
<>
|
1188 |
+
{["system"].includes(message.role) ? (
|
1189 |
+
<Avatar avatar="U+269B" />
|
1190 |
+
) : (
|
1191 |
+
<MaskAvatar mask={session.mask} />
|
1192 |
+
)}
|
1193 |
+
</>
|
1194 |
+
)}
|
1195 |
+
</div>
|
1196 |
+
|
1197 |
+
{showActions && (
|
1198 |
+
<div className={styles["chat-message-actions"]}>
|
1199 |
+
<div className={styles["chat-input-actions"]}>
|
1200 |
+
{message.streaming ? (
|
1201 |
+
<ChatAction
|
1202 |
+
text={Locale.Chat.Actions.Stop}
|
1203 |
+
icon={<StopIcon />}
|
1204 |
+
onClick={() => onUserStop(message.id ?? i)}
|
1205 |
+
/>
|
1206 |
+
) : (
|
1207 |
+
<>
|
1208 |
+
<ChatAction
|
1209 |
+
text={Locale.Chat.Actions.Retry}
|
1210 |
+
icon={<ResetIcon />}
|
1211 |
+
onClick={() => onResend(message)}
|
1212 |
+
/>
|
1213 |
+
|
1214 |
+
<ChatAction
|
1215 |
+
text={Locale.Chat.Actions.Delete}
|
1216 |
+
icon={<DeleteIcon />}
|
1217 |
+
onClick={() => onDelete(message.id ?? i)}
|
1218 |
+
/>
|
1219 |
+
|
1220 |
+
<ChatAction
|
1221 |
+
text={Locale.Chat.Actions.Pin}
|
1222 |
+
icon={<PinIcon />}
|
1223 |
+
onClick={() => onPinMessage(message)}
|
1224 |
+
/>
|
1225 |
+
<ChatAction
|
1226 |
+
text={Locale.Chat.Actions.Copy}
|
1227 |
+
icon={<CopyIcon />}
|
1228 |
+
onClick={() => copyToClipboard(message.content)}
|
1229 |
+
/>
|
1230 |
+
</>
|
1231 |
+
)}
|
1232 |
+
</div>
|
1233 |
+
</div>
|
1234 |
+
)}
|
1235 |
+
</div>
|
1236 |
+
{!isUser &&
|
1237 |
+
message.toolMessages &&
|
1238 |
+
message.toolMessages.map((tool, index) => (
|
1239 |
+
<div
|
1240 |
+
className={styles["chat-message-tools-status"]}
|
1241 |
+
key={index}
|
1242 |
+
>
|
1243 |
+
<div className={styles["chat-message-tools-name"]}>
|
1244 |
+
<CheckmarkIcon
|
1245 |
+
className={styles["chat-message-checkmark"]}
|
1246 |
+
/>
|
1247 |
+
{tool.toolName}:
|
1248 |
+
<code
|
1249 |
+
className={styles["chat-message-tools-details"]}
|
1250 |
+
>
|
1251 |
+
{tool.toolInput}
|
1252 |
+
</code>
|
1253 |
+
</div>
|
1254 |
+
</div>
|
1255 |
+
))}
|
1256 |
+
|
1257 |
+
{showTyping && (
|
1258 |
+
<div className={styles["chat-message-status"]}>
|
1259 |
+
{Locale.Chat.Typing}
|
1260 |
+
</div>
|
1261 |
+
)}
|
1262 |
+
<div className={styles["chat-message-item"]}>
|
1263 |
+
<Markdown
|
1264 |
+
content={message.content}
|
1265 |
+
loading={
|
1266 |
+
(message.preview || message.streaming) &&
|
1267 |
+
message.content.length === 0 &&
|
1268 |
+
!isUser
|
1269 |
+
}
|
1270 |
+
onContextMenu={(e) => onRightClick(e, message)}
|
1271 |
+
onDoubleClickCapture={() => {
|
1272 |
+
if (!isMobileScreen) return;
|
1273 |
+
setUserInput(message.content);
|
1274 |
+
}}
|
1275 |
+
fontSize={fontSize}
|
1276 |
+
parentRef={scrollRef}
|
1277 |
+
defaultShow={i >= messages.length - 6}
|
1278 |
+
/>
|
1279 |
+
</div>
|
1280 |
+
|
1281 |
+
<div className={styles["chat-message-action-date"]}>
|
1282 |
+
{isContext
|
1283 |
+
? Locale.Chat.IsContext
|
1284 |
+
: message.date.toLocaleString()}
|
1285 |
+
</div>
|
1286 |
+
</div>
|
1287 |
+
</div>
|
1288 |
+
{shouldShowClearContextDivider && <ClearContextDivider />}
|
1289 |
+
</Fragment>
|
1290 |
+
);
|
1291 |
+
})}
|
1292 |
+
</div>
|
1293 |
+
|
1294 |
+
<div className={styles["chat-input-panel"]}>
|
1295 |
+
<PromptHints prompts={promptHints} onPromptSelect={onPromptSelect} />
|
1296 |
+
|
1297 |
+
<ChatActions
|
1298 |
+
showPromptModal={() => setShowPromptModal(true)}
|
1299 |
+
scrollToBottom={scrollToBottom}
|
1300 |
+
hitBottom={hitBottom}
|
1301 |
+
showPromptHints={() => {
|
1302 |
+
// Click again to close
|
1303 |
+
if (promptHints.length > 0) {
|
1304 |
+
setPromptHints([]);
|
1305 |
+
return;
|
1306 |
+
}
|
1307 |
+
|
1308 |
+
inputRef.current?.focus();
|
1309 |
+
setUserInput("/");
|
1310 |
+
onSearch("");
|
1311 |
+
}}
|
1312 |
+
/>
|
1313 |
+
<div className={styles["chat-input-panel-inner"]}>
|
1314 |
+
<textarea
|
1315 |
+
ref={inputRef}
|
1316 |
+
className={styles["chat-input"]}
|
1317 |
+
placeholder={Locale.Chat.Input(submitKey)}
|
1318 |
+
onInput={(e) => onInput(e.currentTarget.value)}
|
1319 |
+
value={userInput}
|
1320 |
+
onKeyDown={onInputKeyDown}
|
1321 |
+
onFocus={scrollToBottom}
|
1322 |
+
onClick={scrollToBottom}
|
1323 |
+
rows={inputRows}
|
1324 |
+
autoFocus={autoFocus}
|
1325 |
+
style={{
|
1326 |
+
fontSize: config.fontSize,
|
1327 |
+
}}
|
1328 |
+
/>
|
1329 |
+
<IconButton
|
1330 |
+
icon={<SendWhiteIcon />}
|
1331 |
+
text={Locale.Chat.Send}
|
1332 |
+
className={styles["chat-input-send"]}
|
1333 |
+
type="primary"
|
1334 |
+
onClick={() => doSubmit(userInput)}
|
1335 |
+
/>
|
1336 |
+
</div>
|
1337 |
+
</div>
|
1338 |
+
|
1339 |
+
{showExport && (
|
1340 |
+
<ExportMessageModal onClose={() => setShowExport(false)} />
|
1341 |
+
)}
|
1342 |
+
|
1343 |
+
{isEditingMessage && (
|
1344 |
+
<EditMessageModal
|
1345 |
+
onClose={() => {
|
1346 |
+
setIsEditingMessage(false);
|
1347 |
+
}}
|
1348 |
+
/>
|
1349 |
+
)}
|
1350 |
+
</div>
|
1351 |
+
);
|
1352 |
+
}
|
1353 |
+
|
1354 |
+
export function Chat() {
|
1355 |
+
const chatStore = useChatStore();
|
1356 |
+
const sessionIndex = chatStore.currentSessionIndex;
|
1357 |
+
return <_Chat key={sessionIndex}></_Chat>;
|
1358 |
+
}
|
app/components/emoji.tsx
ADDED
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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("A N I M A") ? (
|
36 |
+
<BlackBotIcon className="user-avatar" />
|
37 |
+
) : (
|
38 |
+
<BotIcon className="user-avatar" />
|
39 |
+
)}
|
40 |
+
</div>
|
41 |
+
);
|
42 |
+
}
|
43 |
+
|
44 |
+
// Return null or some placeholder content for user avatar
|
45 |
+
return null;
|
46 |
+
}
|
47 |
+
|
48 |
+
export function EmojiAvatar(props: { avatar: string; size?: number }) {
|
49 |
+
return (
|
50 |
+
<Emoji
|
51 |
+
unified={props.avatar}
|
52 |
+
size={props.size ?? 18}
|
53 |
+
getEmojiUrl={getEmojiUrl}
|
54 |
+
/>
|
55 |
+
);
|
56 |
+
}
|
app/components/error.tsx
ADDED
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 { showConfirm } from "./ui-lib";
|
8 |
+
import { useSyncStore } from "../store/sync";
|
9 |
+
|
10 |
+
interface IErrorBoundaryState {
|
11 |
+
hasError: boolean;
|
12 |
+
error: Error | null;
|
13 |
+
info: React.ErrorInfo | null;
|
14 |
+
}
|
15 |
+
|
16 |
+
export class ErrorBoundary extends React.Component<any, IErrorBoundaryState> {
|
17 |
+
constructor(props: any) {
|
18 |
+
super(props);
|
19 |
+
this.state = { hasError: false, error: null, info: null };
|
20 |
+
}
|
21 |
+
|
22 |
+
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
23 |
+
// Update state with error details
|
24 |
+
this.setState({ hasError: true, error, info });
|
25 |
+
}
|
26 |
+
|
27 |
+
clearAndSaveData() {
|
28 |
+
try {
|
29 |
+
useSyncStore.getState().export();
|
30 |
+
} finally {
|
31 |
+
localStorage.clear();
|
32 |
+
location.reload();
|
33 |
+
}
|
34 |
+
}
|
35 |
+
|
36 |
+
render() {
|
37 |
+
if (this.state.hasError) {
|
38 |
+
// Render error message
|
39 |
+
return (
|
40 |
+
<div className="error">
|
41 |
+
<h2>Oops, something went wrong!</h2>
|
42 |
+
<pre>
|
43 |
+
<code>{this.state.error?.toString()}</code>
|
44 |
+
<code>{this.state.info?.componentStack}</code>
|
45 |
+
</pre>
|
46 |
+
|
47 |
+
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
48 |
+
{/* Commented out GitHub icon and link
|
49 |
+
<a href={ISSUE_URL} className="report">
|
50 |
+
<IconButton
|
51 |
+
text="Report This Error"
|
52 |
+
icon={<GithubIcon />}
|
53 |
+
bordered
|
54 |
+
/>
|
55 |
+
</a>
|
56 |
+
*/}
|
57 |
+
<IconButton
|
58 |
+
icon={<ResetIcon />}
|
59 |
+
text="Clear All Data"
|
60 |
+
onClick={async () => {
|
61 |
+
if (await showConfirm(Locale.Settings.Danger.Reset.Confirm)) {
|
62 |
+
this.clearAndSaveData();
|
63 |
+
}
|
64 |
+
}}
|
65 |
+
bordered
|
66 |
+
/>
|
67 |
+
</div>
|
68 |
+
</div>
|
69 |
+
);
|
70 |
+
}
|
71 |
+
// if no error occurred, render children
|
72 |
+
return this.props.children;
|
73 |
+
}
|
74 |
+
}
|
app/components/exporter.module.scss
ADDED
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.message-exporter {
|
2 |
+
&-body {
|
3 |
+
margin-top: 20px;
|
4 |
+
}
|
5 |
+
}
|
6 |
+
|
7 |
+
.export-content {
|
8 |
+
white-space: break-spaces;
|
9 |
+
padding: 10px !important;
|
10 |
+
}
|
11 |
+
|
12 |
+
.steps {
|
13 |
+
background-color: var(--gray);
|
14 |
+
border-radius: 10px;
|
15 |
+
overflow: hidden;
|
16 |
+
padding: 5px;
|
17 |
+
position: relative;
|
18 |
+
box-shadow: var(--card-shadow) inset;
|
19 |
+
|
20 |
+
.steps-progress {
|
21 |
+
$padding: 5px;
|
22 |
+
height: calc(100% - 2 * $padding);
|
23 |
+
width: calc(100% - 2 * $padding);
|
24 |
+
position: absolute;
|
25 |
+
top: $padding;
|
26 |
+
left: $padding;
|
27 |
+
|
28 |
+
&-inner {
|
29 |
+
box-sizing: border-box;
|
30 |
+
box-shadow: var(--card-shadow);
|
31 |
+
border: var(--border-in-light);
|
32 |
+
content: "";
|
33 |
+
display: inline-block;
|
34 |
+
width: 0%;
|
35 |
+
height: 100%;
|
36 |
+
background-color: var(--white);
|
37 |
+
transition: all ease 0.3s;
|
38 |
+
border-radius: 8px;
|
39 |
+
}
|
40 |
+
}
|
41 |
+
|
42 |
+
.steps-inner {
|
43 |
+
display: flex;
|
44 |
+
transform: scale(1);
|
45 |
+
|
46 |
+
.step {
|
47 |
+
flex-grow: 1;
|
48 |
+
padding: 5px 10px;
|
49 |
+
font-size: 14px;
|
50 |
+
color: var(--black);
|
51 |
+
opacity: 0.5;
|
52 |
+
transition: all ease 0.3s;
|
53 |
+
|
54 |
+
display: flex;
|
55 |
+
align-items: center;
|
56 |
+
justify-content: center;
|
57 |
+
|
58 |
+
$radius: 8px;
|
59 |
+
|
60 |
+
&-finished {
|
61 |
+
opacity: 0.9;
|
62 |
+
}
|
63 |
+
|
64 |
+
&:hover {
|
65 |
+
opacity: 0.8;
|
66 |
+
}
|
67 |
+
|
68 |
+
&-current {
|
69 |
+
color: var(--primary);
|
70 |
+
}
|
71 |
+
|
72 |
+
.step-index {
|
73 |
+
background-color: var(--gray);
|
74 |
+
border: var(--border-in-light);
|
75 |
+
border-radius: 6px;
|
76 |
+
display: inline-block;
|
77 |
+
padding: 0px 5px;
|
78 |
+
font-size: 12px;
|
79 |
+
margin-right: 8px;
|
80 |
+
opacity: 0.8;
|
81 |
+
}
|
82 |
+
|
83 |
+
.step-name {
|
84 |
+
font-size: 12px;
|
85 |
+
}
|
86 |
+
}
|
87 |
+
}
|
88 |
+
}
|
89 |
+
|
90 |
+
.preview-actions {
|
91 |
+
margin-bottom: 20px;
|
92 |
+
display: flex;
|
93 |
+
justify-content: space-between;
|
94 |
+
|
95 |
+
button {
|
96 |
+
flex-grow: 1;
|
97 |
+
&:not(:last-child) {
|
98 |
+
margin-right: 10px;
|
99 |
+
}
|
100 |
+
}
|
101 |
+
}
|
102 |
+
|
103 |
+
.image-previewer {
|
104 |
+
.preview-body {
|
105 |
+
border-radius: 10px;
|
106 |
+
padding: 20px;
|
107 |
+
box-shadow: var(--card-shadow) inset;
|
108 |
+
background-color: var(--gray);
|
109 |
+
|
110 |
+
.chat-info {
|
111 |
+
background-color: var(--second);
|
112 |
+
padding: 20px;
|
113 |
+
border-radius: 10px;
|
114 |
+
margin-bottom: 20px;
|
115 |
+
display: flex;
|
116 |
+
justify-content: space-between;
|
117 |
+
align-items: flex-end;
|
118 |
+
position: relative;
|
119 |
+
overflow: hidden;
|
120 |
+
|
121 |
+
@media screen and (max-width: 600px) {
|
122 |
+
flex-direction: column;
|
123 |
+
align-items: flex-start;
|
124 |
+
|
125 |
+
.icons {
|
126 |
+
margin-bottom: 20px;
|
127 |
+
}
|
128 |
+
}
|
129 |
+
|
130 |
+
.logo {
|
131 |
+
position: absolute;
|
132 |
+
top: 0px;
|
133 |
+
left: 0px;
|
134 |
+
height: 50%;
|
135 |
+
transform: scale(1.5);
|
136 |
+
}
|
137 |
+
|
138 |
+
.main-title {
|
139 |
+
font-size: 20px;
|
140 |
+
font-weight: bolder;
|
141 |
+
}
|
142 |
+
|
143 |
+
.sub-title {
|
144 |
+
font-size: 12px;
|
145 |
+
}
|
146 |
+
|
147 |
+
.icons {
|
148 |
+
margin-top: 10px;
|
149 |
+
display: flex;
|
150 |
+
align-items: center;
|
151 |
+
|
152 |
+
.icon-space {
|
153 |
+
font-size: 12px;
|
154 |
+
margin: 0 10px;
|
155 |
+
font-weight: bolder;
|
156 |
+
color: var(--primary);
|
157 |
+
}
|
158 |
+
}
|
159 |
+
|
160 |
+
.chat-info-item {
|
161 |
+
font-size: 12px;
|
162 |
+
color: var(--primary);
|
163 |
+
padding: 2px 15px;
|
164 |
+
border-radius: 10px;
|
165 |
+
background-color: var(--white);
|
166 |
+
box-shadow: var(--card-shadow);
|
167 |
+
|
168 |
+
&:not(:last-child) {
|
169 |
+
margin-bottom: 5px;
|
170 |
+
}
|
171 |
+
}
|
172 |
+
}
|
173 |
+
|
174 |
+
.message {
|
175 |
+
margin-bottom: 20px;
|
176 |
+
display: flex;
|
177 |
+
|
178 |
+
.avatar {
|
179 |
+
margin-right: 10px;
|
180 |
+
}
|
181 |
+
|
182 |
+
.body {
|
183 |
+
border-radius: 10px;
|
184 |
+
padding: 8px 10px;
|
185 |
+
max-width: calc(100% - 104px);
|
186 |
+
box-shadow: var(--card-shadow);
|
187 |
+
border: var(--border-in-light);
|
188 |
+
|
189 |
+
*:not(li) {
|
190 |
+
overflow: hidden;
|
191 |
+
}
|
192 |
+
}
|
193 |
+
|
194 |
+
&-assistant {
|
195 |
+
.body {
|
196 |
+
background-color: var(--white);
|
197 |
+
}
|
198 |
+
}
|
199 |
+
|
200 |
+
&-user {
|
201 |
+
flex-direction: row-reverse;
|
202 |
+
|
203 |
+
.avatar {
|
204 |
+
margin-right: 0;
|
205 |
+
}
|
206 |
+
|
207 |
+
.body {
|
208 |
+
background-color: var(--second);
|
209 |
+
margin-right: 10px;
|
210 |
+
}
|
211 |
+
}
|
212 |
+
}
|
213 |
+
}
|
214 |
+
|
215 |
+
.default-theme {
|
216 |
+
}
|
217 |
+
}
|
app/components/exporter.tsx
ADDED
@@ -0,0 +1,648 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/* eslint-disable @next/next/no-img-element */
|
2 |
+
import { ChatMessage, useAppConfig, useChatStore } from "../store";
|
3 |
+
import Locale from "../locales";
|
4 |
+
import styles from "./exporter.module.scss";
|
5 |
+
import {
|
6 |
+
List,
|
7 |
+
ListItem,
|
8 |
+
Modal,
|
9 |
+
Select,
|
10 |
+
showImageModal,
|
11 |
+
showModal,
|
12 |
+
showToast,
|
13 |
+
} from "./ui-lib";
|
14 |
+
import { IconButton } from "./button";
|
15 |
+
import { copyToClipboard, downloadAs, useMobileScreen } from "../utils";
|
16 |
+
|
17 |
+
import CopyIcon from "../icons/copy.svg";
|
18 |
+
import LoadingIcon from "../icons/three-dots.svg";
|
19 |
+
import ChatGptIcon from "../icons/chatgpt.png";
|
20 |
+
import ShareIcon from "../icons/share.svg";
|
21 |
+
import BotIcon from "../icons/bot.png";
|
22 |
+
|
23 |
+
import DownloadIcon from "../icons/download.svg";
|
24 |
+
import { useEffect, useMemo, useRef, useState } from "react";
|
25 |
+
import { MessageSelector, useMessageSelector } from "./message-selector";
|
26 |
+
import { Avatar } from "./emoji";
|
27 |
+
import dynamic from "next/dynamic";
|
28 |
+
import NextImage from "next/image";
|
29 |
+
|
30 |
+
import { toBlob, toJpeg, toPng } from "html-to-image";
|
31 |
+
import { DEFAULT_MASK_AVATAR } from "../store/mask";
|
32 |
+
import { api } from "../client/api";
|
33 |
+
import { prettyObject } from "../utils/format";
|
34 |
+
import { EXPORT_MESSAGE_CLASS_NAME } from "../constant";
|
35 |
+
import { getClientConfig } from "../config/client";
|
36 |
+
|
37 |
+
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
|
38 |
+
loading: () => <LoadingIcon />,
|
39 |
+
});
|
40 |
+
|
41 |
+
export function ExportMessageModal(props: { onClose: () => void }) {
|
42 |
+
return (
|
43 |
+
<div className="modal-mask">
|
44 |
+
<Modal title={Locale.Export.Title} onClose={props.onClose}>
|
45 |
+
<div style={{ minHeight: "40vh" }}>
|
46 |
+
<MessageExporter />
|
47 |
+
</div>
|
48 |
+
</Modal>
|
49 |
+
</div>
|
50 |
+
);
|
51 |
+
}
|
52 |
+
|
53 |
+
function useSteps(
|
54 |
+
steps: Array<{
|
55 |
+
name: string;
|
56 |
+
value: string;
|
57 |
+
}>,
|
58 |
+
) {
|
59 |
+
const stepCount = steps.length;
|
60 |
+
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
61 |
+
const nextStep = () =>
|
62 |
+
setCurrentStepIndex((currentStepIndex + 1) % stepCount);
|
63 |
+
const prevStep = () =>
|
64 |
+
setCurrentStepIndex((currentStepIndex - 1 + stepCount) % stepCount);
|
65 |
+
|
66 |
+
return {
|
67 |
+
currentStepIndex,
|
68 |
+
setCurrentStepIndex,
|
69 |
+
nextStep,
|
70 |
+
prevStep,
|
71 |
+
currentStep: steps[currentStepIndex],
|
72 |
+
};
|
73 |
+
}
|
74 |
+
|
75 |
+
function Steps<
|
76 |
+
T extends {
|
77 |
+
name: string;
|
78 |
+
value: string;
|
79 |
+
}[],
|
80 |
+
>(props: { steps: T; onStepChange?: (index: number) => void; index: number }) {
|
81 |
+
const steps = props.steps;
|
82 |
+
const stepCount = steps.length;
|
83 |
+
|
84 |
+
return (
|
85 |
+
<div className={styles["steps"]}>
|
86 |
+
<div className={styles["steps-progress"]}>
|
87 |
+
<div
|
88 |
+
className={styles["steps-progress-inner"]}
|
89 |
+
style={{
|
90 |
+
width: `${((props.index + 1) / stepCount) * 100}%`,
|
91 |
+
}}
|
92 |
+
></div>
|
93 |
+
</div>
|
94 |
+
<div className={styles["steps-inner"]}>
|
95 |
+
{steps.map((step, i) => {
|
96 |
+
return (
|
97 |
+
<div
|
98 |
+
key={i}
|
99 |
+
className={`${styles["step"]} ${
|
100 |
+
styles[i <= props.index ? "step-finished" : ""]
|
101 |
+
} ${i === props.index && styles["step-current"]} clickable`}
|
102 |
+
onClick={() => {
|
103 |
+
props.onStepChange?.(i);
|
104 |
+
}}
|
105 |
+
role="button"
|
106 |
+
>
|
107 |
+
<span className={styles["step-index"]}>{i + 1}</span>
|
108 |
+
<span className={styles["step-name"]}>{step.name}</span>
|
109 |
+
</div>
|
110 |
+
);
|
111 |
+
})}
|
112 |
+
</div>
|
113 |
+
</div>
|
114 |
+
);
|
115 |
+
}
|
116 |
+
|
117 |
+
export function MessageExporter() {
|
118 |
+
const steps = [
|
119 |
+
{
|
120 |
+
name: Locale.Export.Steps.Select,
|
121 |
+
value: "select",
|
122 |
+
},
|
123 |
+
{
|
124 |
+
name: Locale.Export.Steps.Preview,
|
125 |
+
value: "preview",
|
126 |
+
},
|
127 |
+
];
|
128 |
+
const { currentStep, setCurrentStepIndex, currentStepIndex } =
|
129 |
+
useSteps(steps);
|
130 |
+
const formats = ["text", "image", "json"] as const;
|
131 |
+
type ExportFormat = (typeof formats)[number];
|
132 |
+
|
133 |
+
const [exportConfig, setExportConfig] = useState({
|
134 |
+
format: "image" as ExportFormat,
|
135 |
+
includeContext: true,
|
136 |
+
});
|
137 |
+
|
138 |
+
function updateExportConfig(updater: (config: typeof exportConfig) => void) {
|
139 |
+
const config = { ...exportConfig };
|
140 |
+
updater(config);
|
141 |
+
setExportConfig(config);
|
142 |
+
}
|
143 |
+
|
144 |
+
const chatStore = useChatStore();
|
145 |
+
const session = chatStore.currentSession();
|
146 |
+
const { selection, updateSelection } = useMessageSelector();
|
147 |
+
const selectedMessages = useMemo(() => {
|
148 |
+
const ret: ChatMessage[] = [];
|
149 |
+
if (exportConfig.includeContext) {
|
150 |
+
ret.push(...session.mask.context);
|
151 |
+
}
|
152 |
+
ret.push(...session.messages.filter((m, i) => selection.has(m.id)));
|
153 |
+
return ret;
|
154 |
+
}, [
|
155 |
+
exportConfig.includeContext,
|
156 |
+
session.messages,
|
157 |
+
session.mask.context,
|
158 |
+
selection,
|
159 |
+
]);
|
160 |
+
function preview() {
|
161 |
+
if (exportConfig.format === "text") {
|
162 |
+
return (
|
163 |
+
<MarkdownPreviewer messages={selectedMessages} topic={session.topic} />
|
164 |
+
);
|
165 |
+
} else if (exportConfig.format === "json") {
|
166 |
+
return (
|
167 |
+
<JsonPreviewer messages={selectedMessages} topic={session.topic} />
|
168 |
+
);
|
169 |
+
} else {
|
170 |
+
return (
|
171 |
+
<ImagePreviewer messages={selectedMessages} topic={session.topic} />
|
172 |
+
);
|
173 |
+
}
|
174 |
+
}
|
175 |
+
return (
|
176 |
+
<>
|
177 |
+
<Steps
|
178 |
+
steps={steps}
|
179 |
+
index={currentStepIndex}
|
180 |
+
onStepChange={setCurrentStepIndex}
|
181 |
+
/>
|
182 |
+
<div
|
183 |
+
className={styles["message-exporter-body"]}
|
184 |
+
style={currentStep.value !== "select" ? { display: "none" } : {}}
|
185 |
+
>
|
186 |
+
<List>
|
187 |
+
<ListItem
|
188 |
+
title={Locale.Export.Format.Title}
|
189 |
+
subTitle={Locale.Export.Format.SubTitle}
|
190 |
+
>
|
191 |
+
<Select
|
192 |
+
value={exportConfig.format}
|
193 |
+
onChange={(e) =>
|
194 |
+
updateExportConfig(
|
195 |
+
(config) =>
|
196 |
+
(config.format = e.currentTarget.value as ExportFormat),
|
197 |
+
)
|
198 |
+
}
|
199 |
+
>
|
200 |
+
{formats.map((f) => (
|
201 |
+
<option key={f} value={f}>
|
202 |
+
{f}
|
203 |
+
</option>
|
204 |
+
))}
|
205 |
+
</Select>
|
206 |
+
</ListItem>
|
207 |
+
<ListItem
|
208 |
+
title={Locale.Export.IncludeContext.Title}
|
209 |
+
subTitle={Locale.Export.IncludeContext.SubTitle}
|
210 |
+
>
|
211 |
+
<input
|
212 |
+
type="checkbox"
|
213 |
+
checked={exportConfig.includeContext}
|
214 |
+
onChange={(e) => {
|
215 |
+
updateExportConfig(
|
216 |
+
(config) => (config.includeContext = e.currentTarget.checked),
|
217 |
+
);
|
218 |
+
}}
|
219 |
+
></input>
|
220 |
+
</ListItem>
|
221 |
+
</List>
|
222 |
+
<MessageSelector
|
223 |
+
selection={selection}
|
224 |
+
updateSelection={updateSelection}
|
225 |
+
defaultSelectAll
|
226 |
+
/>
|
227 |
+
</div>
|
228 |
+
{currentStep.value === "preview" && (
|
229 |
+
<div className={styles["message-exporter-body"]}>{preview()}</div>
|
230 |
+
)}
|
231 |
+
</>
|
232 |
+
);
|
233 |
+
}
|
234 |
+
|
235 |
+
export function RenderExport(props: {
|
236 |
+
messages: ChatMessage[];
|
237 |
+
onRender: (messages: ChatMessage[]) => void;
|
238 |
+
}) {
|
239 |
+
const domRef = useRef<HTMLDivElement>(null);
|
240 |
+
|
241 |
+
useEffect(() => {
|
242 |
+
if (!domRef.current) return;
|
243 |
+
const dom = domRef.current;
|
244 |
+
const messages = Array.from(
|
245 |
+
dom.getElementsByClassName(EXPORT_MESSAGE_CLASS_NAME),
|
246 |
+
);
|
247 |
+
|
248 |
+
if (messages.length !== props.messages.length) {
|
249 |
+
return;
|
250 |
+
}
|
251 |
+
|
252 |
+
const renderMsgs = messages.map((v, i) => {
|
253 |
+
const [role, _] = v.id.split(":");
|
254 |
+
return {
|
255 |
+
id: i.toString(),
|
256 |
+
role: role as any,
|
257 |
+
content: role === "user" ? v.textContent ?? "" : v.innerHTML,
|
258 |
+
date: "",
|
259 |
+
};
|
260 |
+
});
|
261 |
+
|
262 |
+
props.onRender(renderMsgs);
|
263 |
+
});
|
264 |
+
|
265 |
+
return (
|
266 |
+
<div ref={domRef}>
|
267 |
+
{props.messages.map((m, i) => (
|
268 |
+
<div
|
269 |
+
key={i}
|
270 |
+
id={`${m.role}:${i}`}
|
271 |
+
className={EXPORT_MESSAGE_CLASS_NAME}
|
272 |
+
>
|
273 |
+
<Markdown content={m.content} defaultShow />
|
274 |
+
</div>
|
275 |
+
))}
|
276 |
+
</div>
|
277 |
+
);
|
278 |
+
}
|
279 |
+
|
280 |
+
export function PreviewActions(props: {
|
281 |
+
download: () => void;
|
282 |
+
copy: () => void;
|
283 |
+
showCopy?: boolean;
|
284 |
+
messages?: ChatMessage[];
|
285 |
+
}) {
|
286 |
+
const [loading, setLoading] = useState(false);
|
287 |
+
const [shouldExport, setShouldExport] = useState(false);
|
288 |
+
|
289 |
+
const onRenderMsgs = (msgs: ChatMessage[]) => {
|
290 |
+
setShouldExport(false);
|
291 |
+
|
292 |
+
api
|
293 |
+
.share(msgs)
|
294 |
+
.then((res) => {
|
295 |
+
if (!res) return;
|
296 |
+
showModal({
|
297 |
+
title: Locale.Export.Share,
|
298 |
+
children: [
|
299 |
+
<input
|
300 |
+
type="text"
|
301 |
+
value={res}
|
302 |
+
key="input"
|
303 |
+
style={{
|
304 |
+
width: "100%",
|
305 |
+
maxWidth: "unset",
|
306 |
+
}}
|
307 |
+
readOnly
|
308 |
+
onClick={(e) => e.currentTarget.select()}
|
309 |
+
></input>,
|
310 |
+
],
|
311 |
+
actions: [
|
312 |
+
<IconButton
|
313 |
+
icon={<CopyIcon />}
|
314 |
+
text={Locale.Chat.Actions.Copy}
|
315 |
+
key="copy"
|
316 |
+
onClick={() => copyToClipboard(res)}
|
317 |
+
/>,
|
318 |
+
],
|
319 |
+
});
|
320 |
+
setTimeout(() => {
|
321 |
+
window.open(res, "_blank");
|
322 |
+
}, 800);
|
323 |
+
})
|
324 |
+
.catch((e) => {
|
325 |
+
console.error("[Share]", e);
|
326 |
+
showToast(prettyObject(e));
|
327 |
+
})
|
328 |
+
.finally(() => setLoading(false));
|
329 |
+
};
|
330 |
+
|
331 |
+
const share = async () => {
|
332 |
+
if (props.messages?.length) {
|
333 |
+
setLoading(true);
|
334 |
+
setShouldExport(true);
|
335 |
+
}
|
336 |
+
};
|
337 |
+
|
338 |
+
return (
|
339 |
+
<>
|
340 |
+
<div className={styles["preview-actions"]}>
|
341 |
+
{props.showCopy && (
|
342 |
+
<IconButton
|
343 |
+
text={Locale.Export.Copy}
|
344 |
+
bordered
|
345 |
+
shadow
|
346 |
+
icon={<CopyIcon />}
|
347 |
+
onClick={props.copy}
|
348 |
+
></IconButton>
|
349 |
+
)}
|
350 |
+
<IconButton
|
351 |
+
text={Locale.Export.Download}
|
352 |
+
bordered
|
353 |
+
shadow
|
354 |
+
icon={<DownloadIcon />}
|
355 |
+
onClick={props.download}
|
356 |
+
></IconButton>
|
357 |
+
<IconButton
|
358 |
+
text={Locale.Export.Share}
|
359 |
+
bordered
|
360 |
+
shadow
|
361 |
+
icon={loading ? <LoadingIcon /> : <ShareIcon />}
|
362 |
+
onClick={share}
|
363 |
+
></IconButton>
|
364 |
+
</div>
|
365 |
+
<div
|
366 |
+
style={{
|
367 |
+
position: "fixed",
|
368 |
+
right: "200vw",
|
369 |
+
pointerEvents: "none",
|
370 |
+
}}
|
371 |
+
>
|
372 |
+
{shouldExport && (
|
373 |
+
<RenderExport
|
374 |
+
messages={props.messages ?? []}
|
375 |
+
onRender={onRenderMsgs}
|
376 |
+
/>
|
377 |
+
)}
|
378 |
+
</div>
|
379 |
+
</>
|
380 |
+
);
|
381 |
+
}
|
382 |
+
|
383 |
+
function ExportAvatar(props: { avatar: string }) {
|
384 |
+
if (props.avatar === DEFAULT_MASK_AVATAR) {
|
385 |
+
return (
|
386 |
+
<img
|
387 |
+
src={BotIcon.src}
|
388 |
+
width={30}
|
389 |
+
height={30}
|
390 |
+
alt="bot"
|
391 |
+
className="user-avatar"
|
392 |
+
/>
|
393 |
+
);
|
394 |
+
}
|
395 |
+
|
396 |
+
return <Avatar avatar={props.avatar} />;
|
397 |
+
}
|
398 |
+
|
399 |
+
export function ImagePreviewer(props: {
|
400 |
+
messages: ChatMessage[];
|
401 |
+
topic: string;
|
402 |
+
}) {
|
403 |
+
const chatStore = useChatStore();
|
404 |
+
const session = chatStore.currentSession();
|
405 |
+
const mask = session.mask;
|
406 |
+
const config = useAppConfig();
|
407 |
+
|
408 |
+
const previewRef = useRef<HTMLDivElement>(null);
|
409 |
+
|
410 |
+
const copy = () => {
|
411 |
+
showToast(Locale.Export.Image.Toast);
|
412 |
+
const dom = previewRef.current;
|
413 |
+
if (!dom) return;
|
414 |
+
toBlob(dom).then((blob) => {
|
415 |
+
if (!blob) return;
|
416 |
+
try {
|
417 |
+
navigator.clipboard
|
418 |
+
.write([
|
419 |
+
new ClipboardItem({
|
420 |
+
"image/png": blob,
|
421 |
+
}),
|
422 |
+
])
|
423 |
+
.then(() => {
|
424 |
+
showToast(Locale.Copy.Success);
|
425 |
+
refreshPreview();
|
426 |
+
});
|
427 |
+
} catch (e) {
|
428 |
+
console.error("[Copy Image] ", e);
|
429 |
+
showToast(Locale.Copy.Failed);
|
430 |
+
}
|
431 |
+
});
|
432 |
+
};
|
433 |
+
|
434 |
+
const isMobile = useMobileScreen();
|
435 |
+
|
436 |
+
const download = async () => {
|
437 |
+
showToast(Locale.Export.Image.Toast);
|
438 |
+
const dom = previewRef.current;
|
439 |
+
if (!dom) return;
|
440 |
+
|
441 |
+
const isApp = getClientConfig()?.isApp;
|
442 |
+
|
443 |
+
try {
|
444 |
+
const blob = await toPng(dom);
|
445 |
+
if (!blob) return;
|
446 |
+
|
447 |
+
if (isMobile || (isApp && window.__TAURI__)) {
|
448 |
+
if (isApp && window.__TAURI__) {
|
449 |
+
const result = await window.__TAURI__.dialog.save({
|
450 |
+
defaultPath: `${props.topic}.png`,
|
451 |
+
filters: [
|
452 |
+
{
|
453 |
+
name: "PNG Files",
|
454 |
+
extensions: ["png"],
|
455 |
+
},
|
456 |
+
{
|
457 |
+
name: "All Files",
|
458 |
+
extensions: ["*"],
|
459 |
+
},
|
460 |
+
],
|
461 |
+
});
|
462 |
+
|
463 |
+
if (result !== null) {
|
464 |
+
const response = await fetch(blob);
|
465 |
+
const buffer = await response.arrayBuffer();
|
466 |
+
const uint8Array = new Uint8Array(buffer);
|
467 |
+
await window.__TAURI__.fs.writeBinaryFile(result, uint8Array);
|
468 |
+
showToast(Locale.Download.Success);
|
469 |
+
} else {
|
470 |
+
showToast(Locale.Download.Failed);
|
471 |
+
}
|
472 |
+
} else {
|
473 |
+
showImageModal(blob);
|
474 |
+
}
|
475 |
+
} else {
|
476 |
+
const link = document.createElement("a");
|
477 |
+
link.download = `${props.topic}.png`;
|
478 |
+
link.href = blob;
|
479 |
+
link.click();
|
480 |
+
refreshPreview();
|
481 |
+
}
|
482 |
+
} catch (error) {
|
483 |
+
showToast(Locale.Download.Failed);
|
484 |
+
}
|
485 |
+
};
|
486 |
+
|
487 |
+
const refreshPreview = () => {
|
488 |
+
const dom = previewRef.current;
|
489 |
+
if (dom) {
|
490 |
+
dom.innerHTML = dom.innerHTML; // Refresh the content of the preview by resetting its HTML for fix a bug glitching
|
491 |
+
}
|
492 |
+
};
|
493 |
+
|
494 |
+
return (
|
495 |
+
<div className={styles["image-previewer"]}>
|
496 |
+
<PreviewActions
|
497 |
+
copy={copy}
|
498 |
+
download={download}
|
499 |
+
showCopy={!isMobile}
|
500 |
+
messages={props.messages}
|
501 |
+
/>
|
502 |
+
<div
|
503 |
+
className={`${styles["preview-body"]} ${styles["default-theme"]}`}
|
504 |
+
ref={previewRef}
|
505 |
+
>
|
506 |
+
<div className={styles["chat-info"]}>
|
507 |
+
<div className={styles["logo"] + " no-dark"}>
|
508 |
+
<NextImage
|
509 |
+
src={ChatGptIcon.src}
|
510 |
+
alt="logo"
|
511 |
+
width={50}
|
512 |
+
height={50}
|
513 |
+
/>
|
514 |
+
</div>
|
515 |
+
|
516 |
+
<div>
|
517 |
+
<div className={styles["main-title"]}>ChatGPT Next Web</div>
|
518 |
+
<div className={styles["sub-title"]}>
|
519 |
+
github.com/Yidadaa/ChatGPT-Next-Web
|
520 |
+
</div>
|
521 |
+
<div className={styles["icons"]}>
|
522 |
+
<ExportAvatar avatar={config.avatar} />
|
523 |
+
<span className={styles["icon-space"]}>&</span>
|
524 |
+
<ExportAvatar avatar={mask.avatar} />
|
525 |
+
</div>
|
526 |
+
</div>
|
527 |
+
<div>
|
528 |
+
<div className={styles["chat-info-item"]}>
|
529 |
+
{Locale.Exporter.Model}: {mask.modelConfig.model}
|
530 |
+
</div>
|
531 |
+
<div className={styles["chat-info-item"]}>
|
532 |
+
{Locale.Exporter.Messages}: {props.messages.length}
|
533 |
+
</div>
|
534 |
+
<div className={styles["chat-info-item"]}>
|
535 |
+
{Locale.Exporter.Topic}: {session.topic}
|
536 |
+
</div>
|
537 |
+
<div className={styles["chat-info-item"]}>
|
538 |
+
{Locale.Exporter.Time}:{" "}
|
539 |
+
{new Date(
|
540 |
+
props.messages.at(-1)?.date ?? Date.now(),
|
541 |
+
).toLocaleString()}
|
542 |
+
</div>
|
543 |
+
</div>
|
544 |
+
</div>
|
545 |
+
{props.messages.map((m, i) => {
|
546 |
+
return (
|
547 |
+
<div
|
548 |
+
className={styles["message"] + " " + styles["message-" + m.role]}
|
549 |
+
key={i}
|
550 |
+
>
|
551 |
+
<div className={styles["avatar"]}>
|
552 |
+
<ExportAvatar
|
553 |
+
avatar={m.role === "user" ? config.avatar : mask.avatar}
|
554 |
+
/>
|
555 |
+
</div>
|
556 |
+
|
557 |
+
<div className={styles["body"]}>
|
558 |
+
<Markdown
|
559 |
+
content={m.content}
|
560 |
+
fontSize={config.fontSize}
|
561 |
+
defaultShow
|
562 |
+
/>
|
563 |
+
</div>
|
564 |
+
</div>
|
565 |
+
);
|
566 |
+
})}
|
567 |
+
</div>
|
568 |
+
</div>
|
569 |
+
);
|
570 |
+
}
|
571 |
+
|
572 |
+
export function MarkdownPreviewer(props: {
|
573 |
+
messages: ChatMessage[];
|
574 |
+
topic: string;
|
575 |
+
}) {
|
576 |
+
const mdText =
|
577 |
+
`# ${props.topic}\n\n` +
|
578 |
+
props.messages
|
579 |
+
.map((m) => {
|
580 |
+
return m.role === "user"
|
581 |
+
? `## ${Locale.Export.MessageFromYou}:\n${m.content}`
|
582 |
+
: `## ${Locale.Export.MessageFromChatGPT}:\n${m.content.trim()}`;
|
583 |
+
})
|
584 |
+
.join("\n\n");
|
585 |
+
|
586 |
+
const copy = () => {
|
587 |
+
copyToClipboard(mdText);
|
588 |
+
};
|
589 |
+
const download = () => {
|
590 |
+
downloadAs(mdText, `${props.topic}.md`);
|
591 |
+
};
|
592 |
+
return (
|
593 |
+
<>
|
594 |
+
<PreviewActions
|
595 |
+
copy={copy}
|
596 |
+
download={download}
|
597 |
+
showCopy={true}
|
598 |
+
messages={props.messages}
|
599 |
+
/>
|
600 |
+
<div className="markdown-body">
|
601 |
+
<pre className={styles["export-content"]}>{mdText}</pre>
|
602 |
+
</div>
|
603 |
+
</>
|
604 |
+
);
|
605 |
+
}
|
606 |
+
|
607 |
+
// modified by BackTrackZ now it's looks better
|
608 |
+
|
609 |
+
export function JsonPreviewer(props: {
|
610 |
+
messages: ChatMessage[];
|
611 |
+
topic: string;
|
612 |
+
}) {
|
613 |
+
const msgs = {
|
614 |
+
messages: [
|
615 |
+
{
|
616 |
+
role: "system",
|
617 |
+
content: `${Locale.FineTuned.Sysmessage} ${props.topic}`,
|
618 |
+
},
|
619 |
+
...props.messages.map((m) => ({
|
620 |
+
role: m.role,
|
621 |
+
content: m.content,
|
622 |
+
})),
|
623 |
+
],
|
624 |
+
};
|
625 |
+
const mdText = "```json\n" + JSON.stringify(msgs, null, 2) + "\n```";
|
626 |
+
const minifiedJson = JSON.stringify(msgs);
|
627 |
+
|
628 |
+
const copy = () => {
|
629 |
+
copyToClipboard(minifiedJson);
|
630 |
+
};
|
631 |
+
const download = () => {
|
632 |
+
downloadAs(JSON.stringify(msgs), `${props.topic}.json`);
|
633 |
+
};
|
634 |
+
|
635 |
+
return (
|
636 |
+
<>
|
637 |
+
<PreviewActions
|
638 |
+
copy={copy}
|
639 |
+
download={download}
|
640 |
+
showCopy={false}
|
641 |
+
messages={props.messages}
|
642 |
+
/>
|
643 |
+
<div className="markdown-body" onClick={copy}>
|
644 |
+
<Markdown content={mdText} />
|
645 |
+
</div>
|
646 |
+
</>
|
647 |
+
);
|
648 |
+
}
|
app/components/home.module.scss
ADDED
@@ -0,0 +1,340 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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: 370px;
|
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 |
+
&:hover,
|
66 |
+
&:active {
|
67 |
+
.sidebar-drag {
|
68 |
+
background-color: rgba($color: #000000, $alpha: 0.01);
|
69 |
+
|
70 |
+
svg {
|
71 |
+
opacity: 0.2;
|
72 |
+
}
|
73 |
+
}
|
74 |
+
}
|
75 |
+
}
|
76 |
+
|
77 |
+
.sidebar-drag {
|
78 |
+
$width: 14px;
|
79 |
+
|
80 |
+
position: absolute;
|
81 |
+
top: 0;
|
82 |
+
right: 0;
|
83 |
+
height: 100%;
|
84 |
+
width: $width;
|
85 |
+
background-color: rgba($color: #000000, $alpha: 0);
|
86 |
+
cursor: ew-resize;
|
87 |
+
transition: all ease 0.3s;
|
88 |
+
display: flex;
|
89 |
+
align-items: center;
|
90 |
+
|
91 |
+
svg {
|
92 |
+
opacity: 0;
|
93 |
+
margin-left: -2px;
|
94 |
+
}
|
95 |
+
}
|
96 |
+
|
97 |
+
.window-content {
|
98 |
+
width: var(--window-content-width);
|
99 |
+
height: 100%;
|
100 |
+
display: flex;
|
101 |
+
flex-direction: column;
|
102 |
+
}
|
103 |
+
|
104 |
+
.mobile {
|
105 |
+
display: none;
|
106 |
+
}
|
107 |
+
|
108 |
+
@media only screen and (max-width: 600px) {
|
109 |
+
.container {
|
110 |
+
min-height: unset;
|
111 |
+
min-width: unset;
|
112 |
+
max-height: unset;
|
113 |
+
min-width: unset;
|
114 |
+
border: 0;
|
115 |
+
border-radius: 0;
|
116 |
+
}
|
117 |
+
|
118 |
+
.sidebar {
|
119 |
+
position: absolute;
|
120 |
+
left: -100%;
|
121 |
+
z-index: 1000;
|
122 |
+
height: var(--full-height);
|
123 |
+
transition: all ease 0.3s;
|
124 |
+
box-shadow: none;
|
125 |
+
}
|
126 |
+
|
127 |
+
.sidebar-show {
|
128 |
+
left: 0;
|
129 |
+
}
|
130 |
+
|
131 |
+
.mobile {
|
132 |
+
display: block;
|
133 |
+
}
|
134 |
+
}
|
135 |
+
|
136 |
+
.sidebar-header {
|
137 |
+
position: relative;
|
138 |
+
padding-top: 20px;
|
139 |
+
padding-bottom: 20px;
|
140 |
+
}
|
141 |
+
|
142 |
+
.sidebar-logo {
|
143 |
+
position: absolute;
|
144 |
+
right: 0;
|
145 |
+
bottom: 18px;
|
146 |
+
}
|
147 |
+
|
148 |
+
.sidebar-title {
|
149 |
+
font-size: 20px;
|
150 |
+
font-weight: bold;
|
151 |
+
animation: slide-in ease 0.3s;
|
152 |
+
}
|
153 |
+
|
154 |
+
.sidebar-sub-title {
|
155 |
+
font-size: 12px;
|
156 |
+
font-weight: 400;
|
157 |
+
animation: slide-in ease 0.3s;
|
158 |
+
}
|
159 |
+
|
160 |
+
.sidebar-body {
|
161 |
+
flex: 1;
|
162 |
+
overflow: auto;
|
163 |
+
overflow-x: hidden;
|
164 |
+
}
|
165 |
+
|
166 |
+
.chat-item {
|
167 |
+
padding: 10px 14px;
|
168 |
+
background-color: var(--white);
|
169 |
+
border-radius: 10px;
|
170 |
+
margin-bottom: 10px;
|
171 |
+
box-shadow: var(--card-shadow);
|
172 |
+
transition: background-color 0.3s ease;
|
173 |
+
cursor: pointer;
|
174 |
+
user-select: none;
|
175 |
+
border: 2px solid transparent;
|
176 |
+
position: relative;
|
177 |
+
content-visibility: auto;
|
178 |
+
}
|
179 |
+
|
180 |
+
.chat-item:hover {
|
181 |
+
background-color: var(--hover-color);
|
182 |
+
}
|
183 |
+
|
184 |
+
.chat-item-selected {
|
185 |
+
border-color: var(--primary);
|
186 |
+
}
|
187 |
+
|
188 |
+
.chat-item-title {
|
189 |
+
font-size: 14px;
|
190 |
+
font-weight: bolder;
|
191 |
+
display: block;
|
192 |
+
width: calc(100% - 15px);
|
193 |
+
overflow: hidden;
|
194 |
+
text-overflow: ellipsis;
|
195 |
+
white-space: nowrap;
|
196 |
+
animation: slide-in ease 0.3s;
|
197 |
+
}
|
198 |
+
|
199 |
+
.chat-item-delete {
|
200 |
+
position: absolute;
|
201 |
+
top: 0;
|
202 |
+
right: 0;
|
203 |
+
transition: all ease 0.3s;
|
204 |
+
opacity: 0;
|
205 |
+
cursor: pointer;
|
206 |
+
}
|
207 |
+
|
208 |
+
.chat-item:hover > .chat-item-delete {
|
209 |
+
opacity: 0.5;
|
210 |
+
transform: translateX(-4px);
|
211 |
+
}
|
212 |
+
|
213 |
+
.chat-item:hover > .chat-item-delete:hover {
|
214 |
+
opacity: 1;
|
215 |
+
}
|
216 |
+
|
217 |
+
.chat-item-info {
|
218 |
+
display: flex;
|
219 |
+
justify-content: space-between;
|
220 |
+
color: rgb(166, 166, 166);
|
221 |
+
font-size: 12px;
|
222 |
+
margin-top: 8px;
|
223 |
+
animation: slide-in ease 0.3s;
|
224 |
+
}
|
225 |
+
|
226 |
+
.chat-item-count,
|
227 |
+
.chat-item-date {
|
228 |
+
overflow: hidden;
|
229 |
+
text-overflow: ellipsis;
|
230 |
+
white-space: nowrap;
|
231 |
+
}
|
232 |
+
|
233 |
+
.narrow-sidebar {
|
234 |
+
.sidebar-title,
|
235 |
+
.sidebar-sub-title {
|
236 |
+
display: none;
|
237 |
+
}
|
238 |
+
.sidebar-logo {
|
239 |
+
position: relative;
|
240 |
+
display: flex;
|
241 |
+
justify-content: center;
|
242 |
+
}
|
243 |
+
|
244 |
+
.sidebar-header-bar {
|
245 |
+
flex-direction: column;
|
246 |
+
|
247 |
+
.sidebar-bar-button {
|
248 |
+
&:not(:last-child) {
|
249 |
+
margin-right: 0;
|
250 |
+
margin-bottom: 10px;
|
251 |
+
}
|
252 |
+
}
|
253 |
+
}
|
254 |
+
|
255 |
+
.chat-item {
|
256 |
+
padding: 0;
|
257 |
+
min-height: 50px;
|
258 |
+
display: flex;
|
259 |
+
justify-content: center;
|
260 |
+
align-items: center;
|
261 |
+
transition: all ease 0.3s;
|
262 |
+
overflow: hidden;
|
263 |
+
|
264 |
+
&:hover {
|
265 |
+
.chat-item-narrow {
|
266 |
+
transform: scale(0.7) translateX(-50%);
|
267 |
+
}
|
268 |
+
}
|
269 |
+
}
|
270 |
+
|
271 |
+
.chat-item-narrow {
|
272 |
+
line-height: 0;
|
273 |
+
font-weight: lighter;
|
274 |
+
color: var(--black);
|
275 |
+
transform: translateX(0);
|
276 |
+
transition: all ease 0.3s;
|
277 |
+
padding: 4px;
|
278 |
+
display: flex;
|
279 |
+
flex-direction: column;
|
280 |
+
justify-content: center;
|
281 |
+
|
282 |
+
.chat-item-avatar {
|
283 |
+
display: flex;
|
284 |
+
justify-content: center;
|
285 |
+
opacity: 0.2;
|
286 |
+
position: absolute;
|
287 |
+
transform: scale(4);
|
288 |
+
}
|
289 |
+
|
290 |
+
.chat-item-narrow-count {
|
291 |
+
font-size: 24px;
|
292 |
+
font-weight: bolder;
|
293 |
+
text-align: center;
|
294 |
+
color: var(--primary);
|
295 |
+
opacity: 0.6;
|
296 |
+
}
|
297 |
+
}
|
298 |
+
|
299 |
+
.sidebar-tail {
|
300 |
+
flex-direction: column-reverse;
|
301 |
+
align-items: center;
|
302 |
+
|
303 |
+
.sidebar-actions {
|
304 |
+
flex-direction: column-reverse;
|
305 |
+
align-items: center;
|
306 |
+
|
307 |
+
.sidebar-action {
|
308 |
+
margin-right: 0;
|
309 |
+
margin-top: 15px;
|
310 |
+
}
|
311 |
+
}
|
312 |
+
}
|
313 |
+
}
|
314 |
+
|
315 |
+
.sidebar-tail {
|
316 |
+
display: flex;
|
317 |
+
justify-content: space-between;
|
318 |
+
padding-top: 20px;
|
319 |
+
}
|
320 |
+
|
321 |
+
.sidebar-actions {
|
322 |
+
display: inline-flex;
|
323 |
+
}
|
324 |
+
|
325 |
+
.sidebar-action:not(:last-child) {
|
326 |
+
margin-right: 15px;
|
327 |
+
}
|
328 |
+
|
329 |
+
.loading-content {
|
330 |
+
display: flex;
|
331 |
+
flex-direction: column;
|
332 |
+
justify-content: center;
|
333 |
+
align-items: center;
|
334 |
+
height: 100%;
|
335 |
+
width: 100%;
|
336 |
+
}
|
337 |
+
|
338 |
+
.rtl-screen {
|
339 |
+
direction: rtl;
|
340 |
+
}
|
app/components/home.tsx
ADDED
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 { getISOLang, getLang } from "../locales";
|
19 |
+
|
20 |
+
import {
|
21 |
+
HashRouter as Router,
|
22 |
+
Routes,
|
23 |
+
Route,
|
24 |
+
useLocation,
|
25 |
+
} from "react-router-dom";
|
26 |
+
import { SideBar } from "./sidebar";
|
27 |
+
import { useAppConfig } from "../store/config";
|
28 |
+
import { AuthPage } from "./auth";
|
29 |
+
import { getClientConfig } from "../config/client";
|
30 |
+
import { api } from "../client/api";
|
31 |
+
import { useAccessStore } from "../store";
|
32 |
+
|
33 |
+
export function Loading(props: { noLogo?: boolean }) {
|
34 |
+
return (
|
35 |
+
<div className={styles["loading-content"] + " no-dark"}>
|
36 |
+
{!props.noLogo && <BotIcon />}
|
37 |
+
<LoadingIcon />
|
38 |
+
</div>
|
39 |
+
);
|
40 |
+
}
|
41 |
+
|
42 |
+
const Settings = dynamic(async () => (await import("./settings")).Settings, {
|
43 |
+
loading: () => <Loading noLogo />,
|
44 |
+
});
|
45 |
+
|
46 |
+
const Chat = dynamic(async () => (await import("./chat")).Chat, {
|
47 |
+
loading: () => <Loading noLogo />,
|
48 |
+
});
|
49 |
+
|
50 |
+
const NewChat = dynamic(async () => (await import("./new-chat")).NewChat, {
|
51 |
+
loading: () => <Loading noLogo />,
|
52 |
+
});
|
53 |
+
|
54 |
+
const MaskPage = dynamic(async () => (await import("./mask")).MaskPage, {
|
55 |
+
loading: () => <Loading noLogo />,
|
56 |
+
});
|
57 |
+
|
58 |
+
const Plugins = dynamic(async () => (await import("./plugin")).PluginPage, {
|
59 |
+
loading: () => <Loading noLogo />,
|
60 |
+
});
|
61 |
+
|
62 |
+
export function useSwitchTheme() {
|
63 |
+
const config = useAppConfig();
|
64 |
+
|
65 |
+
useEffect(() => {
|
66 |
+
document.body.classList.remove("light");
|
67 |
+
document.body.classList.remove("dark");
|
68 |
+
|
69 |
+
if (config.theme === "dark") {
|
70 |
+
document.body.classList.add("dark");
|
71 |
+
} else if (config.theme === "light") {
|
72 |
+
document.body.classList.add("light");
|
73 |
+
}
|
74 |
+
|
75 |
+
const metaDescriptionDark = document.querySelector(
|
76 |
+
'meta[name="theme-color"][media*="dark"]',
|
77 |
+
);
|
78 |
+
const metaDescriptionLight = document.querySelector(
|
79 |
+
'meta[name="theme-color"][media*="light"]',
|
80 |
+
);
|
81 |
+
|
82 |
+
if (config.theme === "auto") {
|
83 |
+
metaDescriptionDark?.setAttribute("content", "#151515");
|
84 |
+
metaDescriptionLight?.setAttribute("content", "#fafafa");
|
85 |
+
} else {
|
86 |
+
const themeColor = getCSSVar("--theme-color");
|
87 |
+
metaDescriptionDark?.setAttribute("content", themeColor);
|
88 |
+
metaDescriptionLight?.setAttribute("content", themeColor);
|
89 |
+
}
|
90 |
+
}, [config.theme]);
|
91 |
+
}
|
92 |
+
|
93 |
+
function useHtmlLang() {
|
94 |
+
useEffect(() => {
|
95 |
+
const lang = getISOLang();
|
96 |
+
const htmlLang = document.documentElement.lang;
|
97 |
+
|
98 |
+
if (lang !== htmlLang) {
|
99 |
+
document.documentElement.lang = lang;
|
100 |
+
}
|
101 |
+
}, []);
|
102 |
+
}
|
103 |
+
|
104 |
+
const useHasHydrated = () => {
|
105 |
+
const [hasHydrated, setHasHydrated] = useState<boolean>(false);
|
106 |
+
|
107 |
+
useEffect(() => {
|
108 |
+
setHasHydrated(true);
|
109 |
+
}, []);
|
110 |
+
|
111 |
+
return hasHydrated;
|
112 |
+
};
|
113 |
+
|
114 |
+
const loadAsyncGoogleFont = () => {
|
115 |
+
const linkEl = document.createElement("link");
|
116 |
+
const proxyFontUrl = "/google-fonts";
|
117 |
+
const remoteFontUrl = "https://fonts.googleapis.com";
|
118 |
+
const googleFontUrl =
|
119 |
+
getClientConfig()?.buildMode === "export" ? remoteFontUrl : proxyFontUrl;
|
120 |
+
linkEl.rel = "stylesheet";
|
121 |
+
linkEl.href =
|
122 |
+
googleFontUrl +
|
123 |
+
"/css2?family=" +
|
124 |
+
encodeURIComponent("Noto Sans:wght@300;400;700;900") +
|
125 |
+
"&display=swap";
|
126 |
+
document.head.appendChild(linkEl);
|
127 |
+
};
|
128 |
+
|
129 |
+
function Screen() {
|
130 |
+
const config = useAppConfig();
|
131 |
+
const location = useLocation();
|
132 |
+
const isHome = location.pathname === Path.Home;
|
133 |
+
const isAuth = location.pathname === Path.Auth;
|
134 |
+
const isMobileScreen = useMobileScreen();
|
135 |
+
const shouldTightBorder = getClientConfig()?.isApp || (config.tightBorder && !isMobileScreen);
|
136 |
+
|
137 |
+
useEffect(() => {
|
138 |
+
loadAsyncGoogleFont();
|
139 |
+
}, []);
|
140 |
+
|
141 |
+
return (
|
142 |
+
<div
|
143 |
+
className={
|
144 |
+
styles.container +
|
145 |
+
` ${shouldTightBorder ? styles["tight-container"] : styles.container} ${
|
146 |
+
getLang() === "ar" ? styles["rtl-screen"] : ""
|
147 |
+
}`
|
148 |
+
}
|
149 |
+
>
|
150 |
+
{isAuth ? (
|
151 |
+
<>
|
152 |
+
<AuthPage />
|
153 |
+
</>
|
154 |
+
) : (
|
155 |
+
<>
|
156 |
+
<SideBar className={isHome ? styles["sidebar-show"] : ""} />
|
157 |
+
|
158 |
+
<div className={styles["window-content"]} id={SlotID.AppBody}>
|
159 |
+
<Routes>
|
160 |
+
<Route path={Path.Home} element={<Chat />} />
|
161 |
+
<Route path={Path.NewChat} element={<NewChat />} />
|
162 |
+
<Route path={Path.Masks} element={<MaskPage />} />
|
163 |
+
<Route path={Path.Plugins} element={<Plugins />} />
|
164 |
+
<Route path={Path.Chat} element={<Chat />} />
|
165 |
+
<Route path={Path.Settings} element={<Settings />} />
|
166 |
+
</Routes>
|
167 |
+
</div>
|
168 |
+
</>
|
169 |
+
)}
|
170 |
+
</div>
|
171 |
+
);
|
172 |
+
}
|
173 |
+
|
174 |
+
export function useLoadData() {
|
175 |
+
const config = useAppConfig();
|
176 |
+
|
177 |
+
useEffect(() => {
|
178 |
+
(async () => {
|
179 |
+
const models = await api.llm.models();
|
180 |
+
config.mergeModels(models);
|
181 |
+
})();
|
182 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
183 |
+
}, []);
|
184 |
+
}
|
185 |
+
|
186 |
+
export function Home() {
|
187 |
+
useSwitchTheme();
|
188 |
+
useLoadData();
|
189 |
+
useHtmlLang();
|
190 |
+
|
191 |
+
useEffect(() => {
|
192 |
+
console.log("[Config] got config from build time", getClientConfig());
|
193 |
+
useAccessStore.getState().fetch();
|
194 |
+
}, []);
|
195 |
+
|
196 |
+
if (!useHasHydrated()) {
|
197 |
+
return <Loading />;
|
198 |
+
}
|
199 |
+
|
200 |
+
return (
|
201 |
+
<ErrorBoundary>
|
202 |
+
<Router>
|
203 |
+
<Screen />
|
204 |
+
</Router>
|
205 |
+
</ErrorBoundary>
|
206 |
+
);
|
207 |
+
}
|
app/components/input-range.module.scss
ADDED
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.input-range {
|
2 |
+
border: var(--border-in-light);
|
3 |
+
border-radius: 10px;
|
4 |
+
padding: 5px 10px 5px 10px;
|
5 |
+
font-size: 12px;
|
6 |
+
display: flex;
|
7 |
+
justify-content: space-between;
|
8 |
+
max-width: 40%;
|
9 |
+
|
10 |
+
input[type="range"] {
|
11 |
+
max-width: calc(100% - 34px);
|
12 |
+
}
|
13 |
+
}
|
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,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
+
import mermaid from "mermaid";
|
11 |
+
|
12 |
+
import LoadingIcon from "../icons/three-dots.svg";
|
13 |
+
import React from "react";
|
14 |
+
import { useDebouncedCallback, useThrottledCallback } from "use-debounce";
|
15 |
+
import { showImageModal } from "./ui-lib";
|
16 |
+
|
17 |
+
export function Mermaid(props: { code: string }) {
|
18 |
+
const ref = useRef<HTMLDivElement>(null);
|
19 |
+
const [hasError, setHasError] = useState(false);
|
20 |
+
|
21 |
+
useEffect(() => {
|
22 |
+
if (props.code && ref.current) {
|
23 |
+
mermaid
|
24 |
+
.run({
|
25 |
+
nodes: [ref.current],
|
26 |
+
suppressErrors: true,
|
27 |
+
})
|
28 |
+
.catch((e) => {
|
29 |
+
setHasError(true);
|
30 |
+
console.error("[Mermaid] ", e.message);
|
31 |
+
});
|
32 |
+
}
|
33 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
34 |
+
}, [props.code]);
|
35 |
+
|
36 |
+
function viewSvgInNewWindow() {
|
37 |
+
const svg = ref.current?.querySelector("svg");
|
38 |
+
if (!svg) return;
|
39 |
+
const text = new XMLSerializer().serializeToString(svg);
|
40 |
+
const blob = new Blob([text], { type: "image/svg+xml" });
|
41 |
+
showImageModal(URL.createObjectURL(blob));
|
42 |
+
}
|
43 |
+
|
44 |
+
if (hasError) {
|
45 |
+
return null;
|
46 |
+
}
|
47 |
+
|
48 |
+
return (
|
49 |
+
<div
|
50 |
+
className="no-dark mermaid"
|
51 |
+
style={{
|
52 |
+
cursor: "pointer",
|
53 |
+
overflow: "auto",
|
54 |
+
}}
|
55 |
+
ref={ref}
|
56 |
+
onClick={() => viewSvgInNewWindow()}
|
57 |
+
>
|
58 |
+
{props.code}
|
59 |
+
</div>
|
60 |
+
);
|
61 |
+
}
|
62 |
+
|
63 |
+
export function PreCode(props: { children: any }) {
|
64 |
+
const ref = useRef<HTMLPreElement>(null);
|
65 |
+
const refText = ref.current?.innerText;
|
66 |
+
const [mermaidCode, setMermaidCode] = useState("");
|
67 |
+
|
68 |
+
const renderMermaid = useDebouncedCallback(() => {
|
69 |
+
if (!ref.current) return;
|
70 |
+
const mermaidDom = ref.current.querySelector("code.language-mermaid");
|
71 |
+
if (mermaidDom) {
|
72 |
+
setMermaidCode((mermaidDom as HTMLElement).innerText);
|
73 |
+
}
|
74 |
+
}, 600);
|
75 |
+
|
76 |
+
useEffect(() => {
|
77 |
+
setTimeout(renderMermaid, 1);
|
78 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
79 |
+
}, [refText]);
|
80 |
+
|
81 |
+
return (
|
82 |
+
<>
|
83 |
+
{mermaidCode.length > 0 && (
|
84 |
+
<Mermaid code={mermaidCode} key={mermaidCode} />
|
85 |
+
)}
|
86 |
+
<pre ref={ref}>
|
87 |
+
<span
|
88 |
+
className="copy-code-button"
|
89 |
+
onClick={() => {
|
90 |
+
if (ref.current) {
|
91 |
+
const code = ref.current.innerText;
|
92 |
+
copyToClipboard(code);
|
93 |
+
}
|
94 |
+
}}
|
95 |
+
></span>
|
96 |
+
{props.children}
|
97 |
+
</pre>
|
98 |
+
</>
|
99 |
+
);
|
100 |
+
}
|
101 |
+
|
102 |
+
function _MarkDownContent(props: { content: string }) {
|
103 |
+
return (
|
104 |
+
<ReactMarkdown
|
105 |
+
remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
|
106 |
+
rehypePlugins={[
|
107 |
+
RehypeKatex,
|
108 |
+
[
|
109 |
+
RehypeHighlight,
|
110 |
+
{
|
111 |
+
detect: false,
|
112 |
+
ignoreMissing: true,
|
113 |
+
},
|
114 |
+
],
|
115 |
+
]}
|
116 |
+
components={{
|
117 |
+
pre: PreCode,
|
118 |
+
p: (pProps) => <p {...pProps} dir="auto" />,
|
119 |
+
a: (aProps) => {
|
120 |
+
const href = aProps.href || "";
|
121 |
+
const isInternal = /^\/#/i.test(href);
|
122 |
+
const target = isInternal ? "_self" : aProps.target ?? "_blank";
|
123 |
+
return <a {...aProps} target={target} />;
|
124 |
+
},
|
125 |
+
}}
|
126 |
+
>
|
127 |
+
{props.content}
|
128 |
+
</ReactMarkdown>
|
129 |
+
);
|
130 |
+
}
|
131 |
+
|
132 |
+
export const MarkdownContent = React.memo(_MarkDownContent);
|
133 |
+
|
134 |
+
export function Markdown(
|
135 |
+
props: {
|
136 |
+
content: string;
|
137 |
+
loading?: boolean;
|
138 |
+
fontSize?: number;
|
139 |
+
parentRef?: RefObject<HTMLDivElement>;
|
140 |
+
defaultShow?: boolean;
|
141 |
+
} & React.DOMAttributes<HTMLDivElement>,
|
142 |
+
) {
|
143 |
+
const mdRef = useRef<HTMLDivElement>(null);
|
144 |
+
|
145 |
+
return (
|
146 |
+
<div
|
147 |
+
className="markdown-body"
|
148 |
+
style={{
|
149 |
+
fontSize: `${props.fontSize ?? 14}px`,
|
150 |
+
}}
|
151 |
+
ref={mdRef}
|
152 |
+
onContextMenu={props.onContextMenu}
|
153 |
+
onDoubleClickCapture={props.onDoubleClickCapture}
|
154 |
+
dir="auto"
|
155 |
+
>
|
156 |
+
{props.loading ? (
|
157 |
+
<LoadingIcon />
|
158 |
+
) : (
|
159 |
+
<MarkdownContent content={props.content} />
|
160 |
+
)}
|
161 |
+
</div>
|
162 |
+
);
|
163 |
+
}
|
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,620 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
+
import DragIcon from "../icons/drag.svg";
|
15 |
+
|
16 |
+
import { DEFAULT_MASK_AVATAR, Mask, useMaskStore } from "../store/mask";
|
17 |
+
import {
|
18 |
+
ChatMessage,
|
19 |
+
createMessage,
|
20 |
+
ModelConfig,
|
21 |
+
useAppConfig,
|
22 |
+
useChatStore,
|
23 |
+
} from "../store";
|
24 |
+
import { ROLES } from "../client/api";
|
25 |
+
import {
|
26 |
+
Input,
|
27 |
+
List,
|
28 |
+
ListItem,
|
29 |
+
Modal,
|
30 |
+
Popover,
|
31 |
+
Select,
|
32 |
+
showConfirm,
|
33 |
+
} from "./ui-lib";
|
34 |
+
import { Avatar, AvatarPicker } from "./emoji";
|
35 |
+
import Locale, { AllLangs, ALL_LANG_OPTIONS, Lang } from "../locales";
|
36 |
+
import { useNavigate } from "react-router-dom";
|
37 |
+
|
38 |
+
import chatStyle from "./chat.module.scss";
|
39 |
+
import { useEffect, useState } from "react";
|
40 |
+
import { copyToClipboard, downloadAs, readFromFile } from "../utils";
|
41 |
+
import { Updater } from "../typing";
|
42 |
+
import { ModelConfigList } from "./model-config";
|
43 |
+
import { FileName, Path } from "../constant";
|
44 |
+
import { BUILTIN_MASK_STORE } from "../masks";
|
45 |
+
import { nanoid } from "nanoid";
|
46 |
+
import {
|
47 |
+
DragDropContext,
|
48 |
+
Droppable,
|
49 |
+
Draggable,
|
50 |
+
OnDragEndResponder,
|
51 |
+
} from "@hello-pangea/dnd";
|
52 |
+
|
53 |
+
// drag and drop helper function
|
54 |
+
function reorder<T>(list: T[], startIndex: number, endIndex: number): T[] {
|
55 |
+
const result = [...list];
|
56 |
+
const [removed] = result.splice(startIndex, 1);
|
57 |
+
result.splice(endIndex, 0, removed);
|
58 |
+
return result;
|
59 |
+
}
|
60 |
+
|
61 |
+
export function MaskAvatar(props: { mask: Mask }) {
|
62 |
+
return props.mask.avatar !== DEFAULT_MASK_AVATAR ? (
|
63 |
+
<Avatar avatar={props.mask.avatar} />
|
64 |
+
) : (
|
65 |
+
<Avatar model={props.mask.modelConfig.model} />
|
66 |
+
);
|
67 |
+
}
|
68 |
+
|
69 |
+
export function MaskConfig(props: {
|
70 |
+
mask: Mask;
|
71 |
+
updateMask: Updater<Mask>;
|
72 |
+
extraListItems?: JSX.Element;
|
73 |
+
readonly?: boolean;
|
74 |
+
shouldSyncFromGlobal?: boolean;
|
75 |
+
}) {
|
76 |
+
const [showPicker, setShowPicker] = useState(false);
|
77 |
+
|
78 |
+
const updateConfig = (updater: (config: ModelConfig) => void) => {
|
79 |
+
if (props.readonly) return;
|
80 |
+
|
81 |
+
const config = { ...props.mask.modelConfig };
|
82 |
+
updater(config);
|
83 |
+
props.updateMask((mask) => {
|
84 |
+
mask.modelConfig = config;
|
85 |
+
// if user changed current session mask, it will disable auto sync
|
86 |
+
mask.syncGlobalConfig = false;
|
87 |
+
});
|
88 |
+
};
|
89 |
+
|
90 |
+
const copyMaskLink = () => {
|
91 |
+
const maskLink = `${location.protocol}//${location.host}/#${Path.NewChat}?mask=${props.mask.id}`;
|
92 |
+
copyToClipboard(maskLink);
|
93 |
+
};
|
94 |
+
|
95 |
+
const globalConfig = useAppConfig();
|
96 |
+
|
97 |
+
return (
|
98 |
+
<>
|
99 |
+
<ContextPrompts
|
100 |
+
context={props.mask.context}
|
101 |
+
updateContext={(updater) => {
|
102 |
+
const context = props.mask.context.slice();
|
103 |
+
updater(context);
|
104 |
+
props.updateMask((mask) => (mask.context = context));
|
105 |
+
}}
|
106 |
+
/>
|
107 |
+
|
108 |
+
<List>
|
109 |
+
<ListItem title={Locale.Mask.Config.Avatar}>
|
110 |
+
<Popover
|
111 |
+
content={
|
112 |
+
<AvatarPicker
|
113 |
+
onEmojiClick={(emoji) => {
|
114 |
+
props.updateMask((mask) => (mask.avatar = emoji));
|
115 |
+
setShowPicker(false);
|
116 |
+
}}
|
117 |
+
></AvatarPicker>
|
118 |
+
}
|
119 |
+
open={showPicker}
|
120 |
+
onClose={() => setShowPicker(false)}
|
121 |
+
>
|
122 |
+
<div
|
123 |
+
onClick={() => setShowPicker(true)}
|
124 |
+
style={{ cursor: "pointer" }}
|
125 |
+
>
|
126 |
+
<MaskAvatar mask={props.mask} />
|
127 |
+
</div>
|
128 |
+
</Popover>
|
129 |
+
</ListItem>
|
130 |
+
<ListItem title={Locale.Mask.Config.Name}>
|
131 |
+
<input
|
132 |
+
type="text"
|
133 |
+
value={props.mask.name}
|
134 |
+
onInput={(e) =>
|
135 |
+
props.updateMask((mask) => {
|
136 |
+
mask.name = e.currentTarget.value;
|
137 |
+
})
|
138 |
+
}
|
139 |
+
></input>
|
140 |
+
</ListItem>
|
141 |
+
<ListItem
|
142 |
+
title={Locale.Mask.Config.HideContext.Title}
|
143 |
+
subTitle={Locale.Mask.Config.HideContext.SubTitle}
|
144 |
+
>
|
145 |
+
<input
|
146 |
+
type="checkbox"
|
147 |
+
checked={props.mask.hideContext}
|
148 |
+
onChange={(e) => {
|
149 |
+
props.updateMask((mask) => {
|
150 |
+
mask.hideContext = e.currentTarget.checked;
|
151 |
+
});
|
152 |
+
}}
|
153 |
+
></input>
|
154 |
+
</ListItem>
|
155 |
+
|
156 |
+
{!props.shouldSyncFromGlobal ? (
|
157 |
+
<ListItem
|
158 |
+
title={Locale.Mask.Config.Share.Title}
|
159 |
+
subTitle={Locale.Mask.Config.Share.SubTitle}
|
160 |
+
>
|
161 |
+
<IconButton
|
162 |
+
icon={<CopyIcon />}
|
163 |
+
text={Locale.Mask.Config.Share.Action}
|
164 |
+
onClick={copyMaskLink}
|
165 |
+
/>
|
166 |
+
</ListItem>
|
167 |
+
) : null}
|
168 |
+
|
169 |
+
{props.shouldSyncFromGlobal ? (
|
170 |
+
<ListItem
|
171 |
+
title={Locale.Mask.Config.Sync.Title}
|
172 |
+
subTitle={Locale.Mask.Config.Sync.SubTitle}
|
173 |
+
>
|
174 |
+
<input
|
175 |
+
type="checkbox"
|
176 |
+
checked={props.mask.syncGlobalConfig}
|
177 |
+
onChange={async (e) => {
|
178 |
+
const checked = e.currentTarget.checked;
|
179 |
+
if (
|
180 |
+
checked &&
|
181 |
+
(await showConfirm(Locale.Mask.Config.Sync.Confirm))
|
182 |
+
) {
|
183 |
+
props.updateMask((mask) => {
|
184 |
+
mask.syncGlobalConfig = checked;
|
185 |
+
mask.modelConfig = { ...globalConfig.modelConfig };
|
186 |
+
});
|
187 |
+
} else if (!checked) {
|
188 |
+
props.updateMask((mask) => {
|
189 |
+
mask.syncGlobalConfig = checked;
|
190 |
+
});
|
191 |
+
}
|
192 |
+
}}
|
193 |
+
></input>
|
194 |
+
</ListItem>
|
195 |
+
) : null}
|
196 |
+
</List>
|
197 |
+
|
198 |
+
<List>
|
199 |
+
<ModelConfigList
|
200 |
+
modelConfig={{ ...props.mask.modelConfig }}
|
201 |
+
updateConfig={updateConfig}
|
202 |
+
/>
|
203 |
+
{props.extraListItems}
|
204 |
+
</List>
|
205 |
+
</>
|
206 |
+
);
|
207 |
+
}
|
208 |
+
|
209 |
+
function ContextPromptItem(props: {
|
210 |
+
index: number;
|
211 |
+
prompt: ChatMessage;
|
212 |
+
update: (prompt: ChatMessage) => void;
|
213 |
+
remove: () => void;
|
214 |
+
}) {
|
215 |
+
const [focusingInput, setFocusingInput] = useState(false);
|
216 |
+
|
217 |
+
return (
|
218 |
+
<div className={chatStyle["context-prompt-row"]}>
|
219 |
+
{!focusingInput && (
|
220 |
+
<>
|
221 |
+
<div className={chatStyle["context-drag"]}>
|
222 |
+
<DragIcon />
|
223 |
+
</div>
|
224 |
+
<Select
|
225 |
+
value={props.prompt.role}
|
226 |
+
className={chatStyle["context-role"]}
|
227 |
+
onChange={(e) =>
|
228 |
+
props.update({
|
229 |
+
...props.prompt,
|
230 |
+
role: e.target.value as any,
|
231 |
+
})
|
232 |
+
}
|
233 |
+
>
|
234 |
+
{ROLES.map((r) => (
|
235 |
+
<option key={r} value={r}>
|
236 |
+
{r}
|
237 |
+
</option>
|
238 |
+
))}
|
239 |
+
</Select>
|
240 |
+
</>
|
241 |
+
)}
|
242 |
+
<Input
|
243 |
+
value={props.prompt.content}
|
244 |
+
type="text"
|
245 |
+
className={chatStyle["context-content"]}
|
246 |
+
rows={focusingInput ? 5 : 1}
|
247 |
+
onFocus={() => setFocusingInput(true)}
|
248 |
+
onBlur={() => {
|
249 |
+
setFocusingInput(false);
|
250 |
+
// If the selection is not removed when the user loses focus, some
|
251 |
+
// extensions like "Translate" will always display a floating bar
|
252 |
+
window?.getSelection()?.removeAllRanges();
|
253 |
+
}}
|
254 |
+
onInput={(e) =>
|
255 |
+
props.update({
|
256 |
+
...props.prompt,
|
257 |
+
content: e.currentTarget.value as any,
|
258 |
+
})
|
259 |
+
}
|
260 |
+
/>
|
261 |
+
{!focusingInput && (
|
262 |
+
<IconButton
|
263 |
+
icon={<DeleteIcon />}
|
264 |
+
className={chatStyle["context-delete-button"]}
|
265 |
+
onClick={() => props.remove()}
|
266 |
+
bordered
|
267 |
+
/>
|
268 |
+
)}
|
269 |
+
</div>
|
270 |
+
);
|
271 |
+
}
|
272 |
+
|
273 |
+
export function ContextPrompts(props: {
|
274 |
+
context: ChatMessage[];
|
275 |
+
updateContext: (updater: (context: ChatMessage[]) => void) => void;
|
276 |
+
}) {
|
277 |
+
const context = props.context;
|
278 |
+
|
279 |
+
const addContextPrompt = (prompt: ChatMessage, i: number) => {
|
280 |
+
props.updateContext((context) => context.splice(i, 0, prompt));
|
281 |
+
};
|
282 |
+
|
283 |
+
const removeContextPrompt = (i: number) => {
|
284 |
+
props.updateContext((context) => context.splice(i, 1));
|
285 |
+
};
|
286 |
+
|
287 |
+
const updateContextPrompt = (i: number, prompt: ChatMessage) => {
|
288 |
+
props.updateContext((context) => (context[i] = prompt));
|
289 |
+
};
|
290 |
+
|
291 |
+
const onDragEnd: OnDragEndResponder = (result) => {
|
292 |
+
if (!result.destination) {
|
293 |
+
return;
|
294 |
+
}
|
295 |
+
const newContext = reorder(
|
296 |
+
context,
|
297 |
+
result.source.index,
|
298 |
+
result.destination.index,
|
299 |
+
);
|
300 |
+
props.updateContext((context) => {
|
301 |
+
context.splice(0, context.length, ...newContext);
|
302 |
+
});
|
303 |
+
};
|
304 |
+
|
305 |
+
return (
|
306 |
+
<>
|
307 |
+
<div className={chatStyle["context-prompt"]} style={{ marginBottom: 20 }}>
|
308 |
+
<DragDropContext onDragEnd={onDragEnd}>
|
309 |
+
<Droppable droppableId="context-prompt-list">
|
310 |
+
{(provided) => (
|
311 |
+
<div ref={provided.innerRef} {...provided.droppableProps}>
|
312 |
+
{context.map((c, i) => (
|
313 |
+
<Draggable
|
314 |
+
draggableId={c.id || i.toString()}
|
315 |
+
index={i}
|
316 |
+
key={c.id}
|
317 |
+
>
|
318 |
+
{(provided) => (
|
319 |
+
<div
|
320 |
+
ref={provided.innerRef}
|
321 |
+
{...provided.draggableProps}
|
322 |
+
{...provided.dragHandleProps}
|
323 |
+
>
|
324 |
+
<ContextPromptItem
|
325 |
+
index={i}
|
326 |
+
prompt={c}
|
327 |
+
update={(prompt) => updateContextPrompt(i, prompt)}
|
328 |
+
remove={() => removeContextPrompt(i)}
|
329 |
+
/>
|
330 |
+
<div
|
331 |
+
className={chatStyle["context-prompt-insert"]}
|
332 |
+
onClick={() => {
|
333 |
+
addContextPrompt(
|
334 |
+
createMessage({
|
335 |
+
role: "user",
|
336 |
+
content: "",
|
337 |
+
date: new Date().toLocaleString(),
|
338 |
+
}),
|
339 |
+
i + 1,
|
340 |
+
);
|
341 |
+
}}
|
342 |
+
>
|
343 |
+
<AddIcon />
|
344 |
+
</div>
|
345 |
+
</div>
|
346 |
+
)}
|
347 |
+
</Draggable>
|
348 |
+
))}
|
349 |
+
{provided.placeholder}
|
350 |
+
</div>
|
351 |
+
)}
|
352 |
+
</Droppable>
|
353 |
+
</DragDropContext>
|
354 |
+
|
355 |
+
{props.context.length === 0 && (
|
356 |
+
<div className={chatStyle["context-prompt-row"]}>
|
357 |
+
<IconButton
|
358 |
+
icon={<AddIcon />}
|
359 |
+
text={Locale.Context.Add}
|
360 |
+
bordered
|
361 |
+
className={chatStyle["context-prompt-button"]}
|
362 |
+
onClick={() =>
|
363 |
+
addContextPrompt(
|
364 |
+
createMessage({
|
365 |
+
role: "user",
|
366 |
+
content: "",
|
367 |
+
date: "",
|
368 |
+
}),
|
369 |
+
props.context.length,
|
370 |
+
)
|
371 |
+
}
|
372 |
+
/>
|
373 |
+
</div>
|
374 |
+
)}
|
375 |
+
</div>
|
376 |
+
</>
|
377 |
+
);
|
378 |
+
}
|
379 |
+
|
380 |
+
export function MaskPage() {
|
381 |
+
const navigate = useNavigate();
|
382 |
+
|
383 |
+
const maskStore = useMaskStore();
|
384 |
+
const chatStore = useChatStore();
|
385 |
+
|
386 |
+
const [filterLang, setFilterLang] = useState<Lang>();
|
387 |
+
|
388 |
+
const allMasks = maskStore
|
389 |
+
.getAll()
|
390 |
+
.filter((m) => !filterLang || m.lang === filterLang);
|
391 |
+
|
392 |
+
const [searchMasks, setSearchMasks] = useState<Mask[]>([]);
|
393 |
+
const [searchText, setSearchText] = useState("");
|
394 |
+
const masks = searchText.length > 0 ? searchMasks : allMasks;
|
395 |
+
|
396 |
+
// refactored already, now it accurate
|
397 |
+
const onSearch = (text: string) => {
|
398 |
+
setSearchText(text);
|
399 |
+
if (text.length > 0) {
|
400 |
+
const result = allMasks.filter((m) =>
|
401 |
+
m.name.toLowerCase().includes(text.toLowerCase())
|
402 |
+
);
|
403 |
+
setSearchMasks(result);
|
404 |
+
} else {
|
405 |
+
setSearchMasks(allMasks);
|
406 |
+
}
|
407 |
+
};
|
408 |
+
|
409 |
+
const [editingMaskId, setEditingMaskId] = useState<string | undefined>();
|
410 |
+
const editingMask =
|
411 |
+
maskStore.get(editingMaskId) ?? BUILTIN_MASK_STORE.get(editingMaskId);
|
412 |
+
const closeMaskModal = () => setEditingMaskId(undefined);
|
413 |
+
|
414 |
+
const downloadAll = () => {
|
415 |
+
downloadAs(JSON.stringify(masks.filter((v) => !v.builtin)), FileName.Masks);
|
416 |
+
};
|
417 |
+
|
418 |
+
const importFromFile = () => {
|
419 |
+
readFromFile().then((content) => {
|
420 |
+
try {
|
421 |
+
const importMasks = JSON.parse(content);
|
422 |
+
if (Array.isArray(importMasks)) {
|
423 |
+
for (const mask of importMasks) {
|
424 |
+
if (mask.name) {
|
425 |
+
maskStore.create(mask);
|
426 |
+
}
|
427 |
+
}
|
428 |
+
return;
|
429 |
+
}
|
430 |
+
//if the content is a single mask.
|
431 |
+
if (importMasks.name) {
|
432 |
+
maskStore.create(importMasks);
|
433 |
+
}
|
434 |
+
} catch {}
|
435 |
+
});
|
436 |
+
};
|
437 |
+
|
438 |
+
return (
|
439 |
+
<ErrorBoundary>
|
440 |
+
<div className={styles["mask-page"]}>
|
441 |
+
<div className="window-header">
|
442 |
+
<div className="window-header-title">
|
443 |
+
<div className="window-header-main-title">
|
444 |
+
{Locale.Mask.Page.Title}
|
445 |
+
</div>
|
446 |
+
<div className="window-header-submai-title">
|
447 |
+
{Locale.Mask.Page.SubTitle(allMasks.length)}
|
448 |
+
</div>
|
449 |
+
</div>
|
450 |
+
|
451 |
+
<div className="window-actions">
|
452 |
+
<div className="window-action-button">
|
453 |
+
<IconButton
|
454 |
+
icon={<DownloadIcon />}
|
455 |
+
bordered
|
456 |
+
onClick={downloadAll}
|
457 |
+
text={Locale.UI.Export}
|
458 |
+
/>
|
459 |
+
</div>
|
460 |
+
<div className="window-action-button">
|
461 |
+
<IconButton
|
462 |
+
icon={<UploadIcon />}
|
463 |
+
text={Locale.UI.Import}
|
464 |
+
bordered
|
465 |
+
onClick={() => importFromFile()}
|
466 |
+
/>
|
467 |
+
</div>
|
468 |
+
<div className="window-action-button">
|
469 |
+
<IconButton
|
470 |
+
icon={<CloseIcon />}
|
471 |
+
bordered
|
472 |
+
onClick={() => navigate(-1)}
|
473 |
+
/>
|
474 |
+
</div>
|
475 |
+
</div>
|
476 |
+
</div>
|
477 |
+
|
478 |
+
<div className={styles["mask-page-body"]}>
|
479 |
+
<div className={styles["mask-filter"]}>
|
480 |
+
<input
|
481 |
+
type="text"
|
482 |
+
className={styles["search-bar"]}
|
483 |
+
placeholder={Locale.Mask.Page.Search}
|
484 |
+
autoFocus
|
485 |
+
onInput={(e) => onSearch(e.currentTarget.value)}
|
486 |
+
/>
|
487 |
+
<Select
|
488 |
+
className={styles["mask-filter-lang"]}
|
489 |
+
value={filterLang ?? Locale.Settings.Lang.All}
|
490 |
+
onChange={(e) => {
|
491 |
+
const value = e.currentTarget.value;
|
492 |
+
if (value === Locale.Settings.Lang.All) {
|
493 |
+
setFilterLang(undefined);
|
494 |
+
} else {
|
495 |
+
setFilterLang(value as Lang);
|
496 |
+
}
|
497 |
+
}}
|
498 |
+
>
|
499 |
+
<option key="all" value={Locale.Settings.Lang.All}>
|
500 |
+
{Locale.Settings.Lang.All}
|
501 |
+
</option>
|
502 |
+
{AllLangs.map((lang) => (
|
503 |
+
<option value={lang} key={lang}>
|
504 |
+
{ALL_LANG_OPTIONS[lang]}
|
505 |
+
</option>
|
506 |
+
))}
|
507 |
+
</Select>
|
508 |
+
|
509 |
+
<IconButton
|
510 |
+
className={styles["mask-create"]}
|
511 |
+
icon={<AddIcon />}
|
512 |
+
text={Locale.Mask.Page.Create}
|
513 |
+
bordered
|
514 |
+
onClick={() => {
|
515 |
+
const createdMask = maskStore.create();
|
516 |
+
setEditingMaskId(createdMask.id);
|
517 |
+
}}
|
518 |
+
/>
|
519 |
+
</div>
|
520 |
+
|
521 |
+
<div>
|
522 |
+
{masks.map((m) => (
|
523 |
+
<div className={styles["mask-item"]} key={m.id}>
|
524 |
+
<div className={styles["mask-header"]}>
|
525 |
+
<div className={styles["mask-icon"]}>
|
526 |
+
<MaskAvatar mask={m} />
|
527 |
+
</div>
|
528 |
+
<div className={styles["mask-title"]}>
|
529 |
+
<div className={styles["mask-name"]}>{m.name}</div>
|
530 |
+
<div className={styles["mask-info"] + " one-line"}>
|
531 |
+
{`${Locale.Mask.Item.Info(m.context.length)} / ${
|
532 |
+
ALL_LANG_OPTIONS[m.lang]
|
533 |
+
} / ${m.modelConfig.model}`}
|
534 |
+
</div>
|
535 |
+
</div>
|
536 |
+
</div>
|
537 |
+
<div className={styles["mask-actions"]}>
|
538 |
+
<IconButton
|
539 |
+
icon={<AddIcon />}
|
540 |
+
text={Locale.Mask.Item.Chat}
|
541 |
+
onClick={() => {
|
542 |
+
chatStore.newSession(m);
|
543 |
+
navigate(Path.Chat);
|
544 |
+
}}
|
545 |
+
/>
|
546 |
+
{m.builtin ? (
|
547 |
+
<IconButton
|
548 |
+
icon={<EyeIcon />}
|
549 |
+
text={Locale.Mask.Item.View}
|
550 |
+
onClick={() => setEditingMaskId(m.id)}
|
551 |
+
/>
|
552 |
+
) : (
|
553 |
+
<IconButton
|
554 |
+
icon={<EditIcon />}
|
555 |
+
text={Locale.Mask.Item.Edit}
|
556 |
+
onClick={() => setEditingMaskId(m.id)}
|
557 |
+
/>
|
558 |
+
)}
|
559 |
+
{!m.builtin && (
|
560 |
+
<IconButton
|
561 |
+
icon={<DeleteIcon />}
|
562 |
+
text={Locale.Mask.Item.Delete}
|
563 |
+
onClick={async () => {
|
564 |
+
if (await showConfirm(Locale.Mask.Item.DeleteConfirm)) {
|
565 |
+
maskStore.delete(m.id);
|
566 |
+
}
|
567 |
+
}}
|
568 |
+
/>
|
569 |
+
)}
|
570 |
+
</div>
|
571 |
+
</div>
|
572 |
+
))}
|
573 |
+
</div>
|
574 |
+
</div>
|
575 |
+
</div>
|
576 |
+
|
577 |
+
{editingMask && (
|
578 |
+
<div className="modal-mask">
|
579 |
+
<Modal
|
580 |
+
title={Locale.Mask.EditModal.Title(editingMask?.builtin)}
|
581 |
+
onClose={closeMaskModal}
|
582 |
+
actions={[
|
583 |
+
<IconButton
|
584 |
+
icon={<DownloadIcon />}
|
585 |
+
text={Locale.Mask.EditModal.Download}
|
586 |
+
key="export"
|
587 |
+
bordered
|
588 |
+
onClick={() =>
|
589 |
+
downloadAs(
|
590 |
+
JSON.stringify(editingMask),
|
591 |
+
`${editingMask.name}.json`,
|
592 |
+
)
|
593 |
+
}
|
594 |
+
/>,
|
595 |
+
<IconButton
|
596 |
+
key="copy"
|
597 |
+
icon={<CopyIcon />}
|
598 |
+
bordered
|
599 |
+
text={Locale.Mask.EditModal.Clone}
|
600 |
+
onClick={() => {
|
601 |
+
navigate(Path.Masks);
|
602 |
+
maskStore.create(editingMask);
|
603 |
+
setEditingMaskId(undefined);
|
604 |
+
}}
|
605 |
+
/>,
|
606 |
+
]}
|
607 |
+
>
|
608 |
+
<MaskConfig
|
609 |
+
mask={editingMask}
|
610 |
+
updateMask={(updater) =>
|
611 |
+
maskStore.updateMask(editingMaskId!, updater)
|
612 |
+
}
|
613 |
+
readonly={editingMask.builtin}
|
614 |
+
/>
|
615 |
+
</Modal>
|
616 |
+
</div>
|
617 |
+
)}
|
618 |
+
</ErrorBoundary>
|
619 |
+
);
|
620 |
+
}
|
app/components/message-selector.module.scss
ADDED
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.message-selector {
|
2 |
+
.message-filter {
|
3 |
+
display: flex;
|
4 |
+
|
5 |
+
.search-bar {
|
6 |
+
max-width: unset;
|
7 |
+
flex-grow: 1;
|
8 |
+
margin-right: 10px;
|
9 |
+
}
|
10 |
+
|
11 |
+
.actions {
|
12 |
+
display: flex;
|
13 |
+
|
14 |
+
button:not(:last-child) {
|
15 |
+
margin-right: 10px;
|
16 |
+
}
|
17 |
+
}
|
18 |
+
|
19 |
+
@media screen and (max-width: 600px) {
|
20 |
+
flex-direction: column;
|
21 |
+
|
22 |
+
.search-bar {
|
23 |
+
margin-right: 0;
|
24 |
+
}
|
25 |
+
|
26 |
+
.actions {
|
27 |
+
margin-top: 20px;
|
28 |
+
|
29 |
+
button {
|
30 |
+
flex-grow: 1;
|
31 |
+
}
|
32 |
+
}
|
33 |
+
}
|
34 |
+
}
|
35 |
+
|
36 |
+
.messages {
|
37 |
+
margin-top: 20px;
|
38 |
+
border-radius: 10px;
|
39 |
+
border: var(--border-in-light);
|
40 |
+
overflow: hidden;
|
41 |
+
|
42 |
+
.message {
|
43 |
+
display: flex;
|
44 |
+
align-items: center;
|
45 |
+
padding: 8px 10px;
|
46 |
+
cursor: pointer;
|
47 |
+
|
48 |
+
&-selected {
|
49 |
+
background-color: var(--second);
|
50 |
+
}
|
51 |
+
|
52 |
+
&:not(:last-child) {
|
53 |
+
border-bottom: var(--border-in-light);
|
54 |
+
}
|
55 |
+
|
56 |
+
.avatar {
|
57 |
+
margin-right: 10px;
|
58 |
+
}
|
59 |
+
|
60 |
+
.body {
|
61 |
+
flex-grow: 1;
|
62 |
+
max-width: calc(100% - 40px);
|
63 |
+
|
64 |
+
.date {
|
65 |
+
font-size: 12px;
|
66 |
+
line-height: 1.2;
|
67 |
+
opacity: 0.5;
|
68 |
+
}
|
69 |
+
|
70 |
+
.content {
|
71 |
+
font-size: 12px;
|
72 |
+
}
|
73 |
+
}
|
74 |
+
}
|
75 |
+
}
|
76 |
+
}
|
app/components/message-selector.tsx
ADDED
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useEffect, useState } from "react";
|
2 |
+
import { ChatMessage, useAppConfig, useChatStore } from "../store";
|
3 |
+
import { Updater } from "../typing";
|
4 |
+
import { IconButton } from "./button";
|
5 |
+
import { Avatar } from "./emoji";
|
6 |
+
import { MaskAvatar } from "./mask";
|
7 |
+
import Locale from "../locales";
|
8 |
+
|
9 |
+
import styles from "./message-selector.module.scss";
|
10 |
+
|
11 |
+
function useShiftRange() {
|
12 |
+
const [startIndex, setStartIndex] = useState<number>();
|
13 |
+
const [endIndex, setEndIndex] = useState<number>();
|
14 |
+
const [shiftDown, setShiftDown] = useState(false);
|
15 |
+
|
16 |
+
const onClickIndex = (index: number) => {
|
17 |
+
if (shiftDown && startIndex !== undefined) {
|
18 |
+
setEndIndex(index);
|
19 |
+
} else {
|
20 |
+
setStartIndex(index);
|
21 |
+
setEndIndex(undefined);
|
22 |
+
}
|
23 |
+
};
|
24 |
+
|
25 |
+
useEffect(() => {
|
26 |
+
const onKeyDown = (e: KeyboardEvent) => {
|
27 |
+
if (e.key !== "Shift") return;
|
28 |
+
setShiftDown(true);
|
29 |
+
};
|
30 |
+
const onKeyUp = (e: KeyboardEvent) => {
|
31 |
+
if (e.key !== "Shift") return;
|
32 |
+
setShiftDown(false);
|
33 |
+
setStartIndex(undefined);
|
34 |
+
setEndIndex(undefined);
|
35 |
+
};
|
36 |
+
|
37 |
+
window.addEventListener("keyup", onKeyUp);
|
38 |
+
window.addEventListener("keydown", onKeyDown);
|
39 |
+
|
40 |
+
return () => {
|
41 |
+
window.removeEventListener("keyup", onKeyUp);
|
42 |
+
window.removeEventListener("keydown", onKeyDown);
|
43 |
+
};
|
44 |
+
}, []);
|
45 |
+
|
46 |
+
return {
|
47 |
+
onClickIndex,
|
48 |
+
startIndex,
|
49 |
+
endIndex,
|
50 |
+
};
|
51 |
+
}
|
52 |
+
|
53 |
+
export function useMessageSelector() {
|
54 |
+
const [selection, setSelection] = useState(new Set<string>());
|
55 |
+
const updateSelection: Updater<Set<string>> = (updater) => {
|
56 |
+
const newSelection = new Set<string>(selection);
|
57 |
+
updater(newSelection);
|
58 |
+
setSelection(newSelection);
|
59 |
+
};
|
60 |
+
|
61 |
+
return {
|
62 |
+
selection,
|
63 |
+
updateSelection,
|
64 |
+
};
|
65 |
+
}
|
66 |
+
|
67 |
+
export function MessageSelector(props: {
|
68 |
+
selection: Set<string>;
|
69 |
+
updateSelection: Updater<Set<string>>;
|
70 |
+
defaultSelectAll?: boolean;
|
71 |
+
onSelected?: (messages: ChatMessage[]) => void;
|
72 |
+
}) {
|
73 |
+
const chatStore = useChatStore();
|
74 |
+
const session = chatStore.currentSession();
|
75 |
+
const isValid = (m: ChatMessage) => m.content && !m.isError && !m.streaming;
|
76 |
+
const messages = session.messages.filter(
|
77 |
+
(m, i) =>
|
78 |
+
m.id && // message must have id
|
79 |
+
isValid(m) &&
|
80 |
+
(i >= session.messages.length - 1 || isValid(session.messages[i + 1])),
|
81 |
+
);
|
82 |
+
const messageCount = messages.length;
|
83 |
+
const config = useAppConfig();
|
84 |
+
|
85 |
+
const [searchInput, setSearchInput] = useState("");
|
86 |
+
const [searchIds, setSearchIds] = useState(new Set<string>());
|
87 |
+
const isInSearchResult = (id: string) => {
|
88 |
+
return searchInput.length === 0 || searchIds.has(id);
|
89 |
+
};
|
90 |
+
const doSearch = (text: string) => {
|
91 |
+
const searchResults = new Set<string>();
|
92 |
+
if (text.length > 0) {
|
93 |
+
messages.forEach((m) =>
|
94 |
+
m.content.includes(text) ? searchResults.add(m.id!) : null,
|
95 |
+
);
|
96 |
+
}
|
97 |
+
setSearchIds(searchResults);
|
98 |
+
};
|
99 |
+
|
100 |
+
// for range selection
|
101 |
+
const { startIndex, endIndex, onClickIndex } = useShiftRange();
|
102 |
+
|
103 |
+
const selectAll = () => {
|
104 |
+
props.updateSelection((selection) =>
|
105 |
+
messages.forEach((m) => selection.add(m.id!)),
|
106 |
+
);
|
107 |
+
};
|
108 |
+
|
109 |
+
useEffect(() => {
|
110 |
+
if (props.defaultSelectAll) {
|
111 |
+
selectAll();
|
112 |
+
}
|
113 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
114 |
+
}, []);
|
115 |
+
|
116 |
+
useEffect(() => {
|
117 |
+
if (startIndex === undefined || endIndex === undefined) {
|
118 |
+
return;
|
119 |
+
}
|
120 |
+
const [start, end] = [startIndex, endIndex].sort((a, b) => a - b);
|
121 |
+
props.updateSelection((selection) => {
|
122 |
+
for (let i = start; i <= end; i += 1) {
|
123 |
+
selection.add(messages[i].id ?? i);
|
124 |
+
}
|
125 |
+
});
|
126 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
127 |
+
}, [startIndex, endIndex]);
|
128 |
+
|
129 |
+
const LATEST_COUNT = 4;
|
130 |
+
|
131 |
+
return (
|
132 |
+
<div className={styles["message-selector"]}>
|
133 |
+
<div className={styles["message-filter"]}>
|
134 |
+
<input
|
135 |
+
type="text"
|
136 |
+
placeholder={Locale.Select.Search}
|
137 |
+
className={styles["filter-item"] + " " + styles["search-bar"]}
|
138 |
+
value={searchInput}
|
139 |
+
onInput={(e) => {
|
140 |
+
setSearchInput(e.currentTarget.value);
|
141 |
+
doSearch(e.currentTarget.value);
|
142 |
+
}}
|
143 |
+
></input>
|
144 |
+
|
145 |
+
<div className={styles["actions"]}>
|
146 |
+
<IconButton
|
147 |
+
text={Locale.Select.All}
|
148 |
+
bordered
|
149 |
+
className={styles["filter-item"]}
|
150 |
+
onClick={selectAll}
|
151 |
+
/>
|
152 |
+
<IconButton
|
153 |
+
text={Locale.Select.Latest}
|
154 |
+
bordered
|
155 |
+
className={styles["filter-item"]}
|
156 |
+
onClick={() =>
|
157 |
+
props.updateSelection((selection) => {
|
158 |
+
selection.clear();
|
159 |
+
messages
|
160 |
+
.slice(messageCount - LATEST_COUNT)
|
161 |
+
.forEach((m) => selection.add(m.id!));
|
162 |
+
})
|
163 |
+
}
|
164 |
+
/>
|
165 |
+
<IconButton
|
166 |
+
text={Locale.Select.Clear}
|
167 |
+
bordered
|
168 |
+
className={styles["filter-item"]}
|
169 |
+
onClick={() =>
|
170 |
+
props.updateSelection((selection) => selection.clear())
|
171 |
+
}
|
172 |
+
/>
|
173 |
+
</div>
|
174 |
+
</div>
|
175 |
+
|
176 |
+
<div className={styles["messages"]}>
|
177 |
+
{messages.map((m, i) => {
|
178 |
+
if (!isInSearchResult(m.id!)) return null;
|
179 |
+
|
180 |
+
return (
|
181 |
+
<div
|
182 |
+
className={`${styles["message"]} ${
|
183 |
+
props.selection.has(m.id!) && styles["message-selected"]
|
184 |
+
}`}
|
185 |
+
key={i}
|
186 |
+
onClick={() => {
|
187 |
+
props.updateSelection((selection) => {
|
188 |
+
const id = m.id ?? i;
|
189 |
+
selection.has(id) ? selection.delete(id) : selection.add(id);
|
190 |
+
});
|
191 |
+
onClickIndex(i);
|
192 |
+
}}
|
193 |
+
>
|
194 |
+
<div className={styles["avatar"]}>
|
195 |
+
{m.role === "user" ? (
|
196 |
+
<Avatar avatar={config.avatar}></Avatar>
|
197 |
+
) : (
|
198 |
+
<MaskAvatar mask={session.mask} />
|
199 |
+
)}
|
200 |
+
</div>
|
201 |
+
<div className={styles["body"]}>
|
202 |
+
<div className={styles["date"]}>
|
203 |
+
{new Date(m.date).toLocaleString()}
|
204 |
+
</div>
|
205 |
+
<div className={`${styles["content"]} one-line`}>
|
206 |
+
{m.content}
|
207 |
+
</div>
|
208 |
+
</div>
|
209 |
+
</div>
|
210 |
+
);
|
211 |
+
})}
|
212 |
+
</div>
|
213 |
+
</div>
|
214 |
+
);
|
215 |
+
}
|
app/components/model-config.tsx
ADDED
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { ModalConfigValidator, ModelConfig } from "../store";
|
2 |
+
|
3 |
+
import Locale from "../locales";
|
4 |
+
import { InputRange } from "./input-range";
|
5 |
+
import { ListItem, Select } from "./ui-lib";
|
6 |
+
import { useAllModels } from "../utils/hooks";
|
7 |
+
|
8 |
+
export function ModelConfigList(props: {
|
9 |
+
modelConfig: ModelConfig;
|
10 |
+
updateConfig: (updater: (config: ModelConfig) => void) => void;
|
11 |
+
}) {
|
12 |
+
const allModels = useAllModels();
|
13 |
+
|
14 |
+
return (
|
15 |
+
<>
|
16 |
+
<ListItem title={Locale.Settings.Model}>
|
17 |
+
<Select
|
18 |
+
value={props.modelConfig.model}
|
19 |
+
onChange={(e) => {
|
20 |
+
props.updateConfig(
|
21 |
+
(config) =>
|
22 |
+
(config.model = ModalConfigValidator.model(
|
23 |
+
e.currentTarget.value,
|
24 |
+
)),
|
25 |
+
);
|
26 |
+
}}
|
27 |
+
>
|
28 |
+
{allModels.map((v, i) => (
|
29 |
+
<option value={v.name} key={i} disabled={!v.available}>
|
30 |
+
{v.name}
|
31 |
+
</option>
|
32 |
+
))}
|
33 |
+
</Select>
|
34 |
+
</ListItem>
|
35 |
+
<ListItem
|
36 |
+
title={Locale.Settings.Temperature.Title}
|
37 |
+
subTitle={Locale.Settings.Temperature.SubTitle}
|
38 |
+
>
|
39 |
+
<InputRange
|
40 |
+
value={props.modelConfig.temperature?.toFixed(1)}
|
41 |
+
min="0"
|
42 |
+
max="1" // lets limit it to 0-1
|
43 |
+
step="0.1"
|
44 |
+
onChange={(e) => {
|
45 |
+
props.updateConfig(
|
46 |
+
(config) =>
|
47 |
+
(config.temperature = ModalConfigValidator.temperature(
|
48 |
+
e.currentTarget.valueAsNumber,
|
49 |
+
)),
|
50 |
+
);
|
51 |
+
}}
|
52 |
+
></InputRange>
|
53 |
+
</ListItem>
|
54 |
+
<ListItem
|
55 |
+
title={Locale.Settings.TopP.Title}
|
56 |
+
subTitle={Locale.Settings.TopP.SubTitle}
|
57 |
+
>
|
58 |
+
<InputRange
|
59 |
+
value={(props.modelConfig.top_p ?? 1).toFixed(1)}
|
60 |
+
min="0"
|
61 |
+
max="1"
|
62 |
+
step="0.1"
|
63 |
+
onChange={(e) => {
|
64 |
+
props.updateConfig(
|
65 |
+
(config) =>
|
66 |
+
(config.top_p = ModalConfigValidator.top_p(
|
67 |
+
e.currentTarget.valueAsNumber,
|
68 |
+
)),
|
69 |
+
);
|
70 |
+
}}
|
71 |
+
></InputRange>
|
72 |
+
</ListItem>
|
73 |
+
<ListItem
|
74 |
+
title={Locale.Settings.MaxTokens.Title}
|
75 |
+
subTitle={Locale.Settings.MaxTokens.SubTitle}
|
76 |
+
>
|
77 |
+
<input
|
78 |
+
type="number"
|
79 |
+
min={1024}
|
80 |
+
max={512000}
|
81 |
+
value={props.modelConfig.max_tokens}
|
82 |
+
onChange={(e) =>
|
83 |
+
props.updateConfig(
|
84 |
+
(config) =>
|
85 |
+
(config.max_tokens = ModalConfigValidator.max_tokens(
|
86 |
+
e.currentTarget.valueAsNumber,
|
87 |
+
)),
|
88 |
+
)
|
89 |
+
}
|
90 |
+
></input>
|
91 |
+
</ListItem>
|
92 |
+
<ListItem
|
93 |
+
title={Locale.Settings.PresencePenalty.Title}
|
94 |
+
subTitle={Locale.Settings.PresencePenalty.SubTitle}
|
95 |
+
>
|
96 |
+
<InputRange
|
97 |
+
value={props.modelConfig.presence_penalty?.toFixed(1)}
|
98 |
+
min="-2"
|
99 |
+
max="2"
|
100 |
+
step="0.1"
|
101 |
+
onChange={(e) => {
|
102 |
+
props.updateConfig(
|
103 |
+
(config) =>
|
104 |
+
(config.presence_penalty =
|
105 |
+
ModalConfigValidator.presence_penalty(
|
106 |
+
e.currentTarget.valueAsNumber,
|
107 |
+
)),
|
108 |
+
);
|
109 |
+
}}
|
110 |
+
></InputRange>
|
111 |
+
</ListItem>
|
112 |
+
|
113 |
+
<ListItem
|
114 |
+
title={Locale.Settings.FrequencyPenalty.Title}
|
115 |
+
subTitle={Locale.Settings.FrequencyPenalty.SubTitle}
|
116 |
+
>
|
117 |
+
<InputRange
|
118 |
+
value={props.modelConfig.frequency_penalty?.toFixed(1)}
|
119 |
+
min="-2"
|
120 |
+
max="2"
|
121 |
+
step="0.1"
|
122 |
+
onChange={(e) => {
|
123 |
+
props.updateConfig(
|
124 |
+
(config) =>
|
125 |
+
(config.frequency_penalty =
|
126 |
+
ModalConfigValidator.frequency_penalty(
|
127 |
+
e.currentTarget.valueAsNumber,
|
128 |
+
)),
|
129 |
+
);
|
130 |
+
}}
|
131 |
+
></InputRange>
|
132 |
+
</ListItem>
|
133 |
+
|
134 |
+
<ListItem
|
135 |
+
title={Locale.Settings.InjectSystemPrompts.Title}
|
136 |
+
subTitle={Locale.Settings.InjectSystemPrompts.SubTitle}
|
137 |
+
>
|
138 |
+
<input
|
139 |
+
type="checkbox"
|
140 |
+
checked={props.modelConfig.enableInjectSystemPrompts}
|
141 |
+
onChange={(e) =>
|
142 |
+
props.updateConfig(
|
143 |
+
(config) =>
|
144 |
+
(config.enableInjectSystemPrompts = e.currentTarget.checked),
|
145 |
+
)
|
146 |
+
}
|
147 |
+
></input>
|
148 |
+
</ListItem>
|
149 |
+
|
150 |
+
<ListItem
|
151 |
+
title={Locale.Settings.InputTemplate.Title}
|
152 |
+
subTitle={Locale.Settings.InputTemplate.SubTitle}
|
153 |
+
>
|
154 |
+
<input
|
155 |
+
type="text"
|
156 |
+
value={props.modelConfig.template}
|
157 |
+
onChange={(e) =>
|
158 |
+
props.updateConfig(
|
159 |
+
(config) => (config.template = e.currentTarget.value),
|
160 |
+
)
|
161 |
+
}
|
162 |
+
></input>
|
163 |
+
</ListItem>
|
164 |
+
|
165 |
+
<ListItem
|
166 |
+
title={Locale.Settings.HistoryCount.Title}
|
167 |
+
subTitle={Locale.Settings.HistoryCount.SubTitle}
|
168 |
+
>
|
169 |
+
<InputRange
|
170 |
+
title={props.modelConfig.historyMessageCount.toString()}
|
171 |
+
value={props.modelConfig.historyMessageCount}
|
172 |
+
min="0"
|
173 |
+
max="64"
|
174 |
+
step="1"
|
175 |
+
onChange={(e) =>
|
176 |
+
props.updateConfig(
|
177 |
+
(config) => (config.historyMessageCount = e.target.valueAsNumber),
|
178 |
+
)
|
179 |
+
}
|
180 |
+
></InputRange>
|
181 |
+
</ListItem>
|
182 |
+
|
183 |
+
<ListItem
|
184 |
+
title={Locale.Settings.CompressThreshold.Title}
|
185 |
+
subTitle={Locale.Settings.CompressThreshold.SubTitle}
|
186 |
+
>
|
187 |
+
<input
|
188 |
+
type="number"
|
189 |
+
min={500}
|
190 |
+
max={4000}
|
191 |
+
value={props.modelConfig.compressMessageLengthThreshold}
|
192 |
+
onChange={(e) =>
|
193 |
+
props.updateConfig(
|
194 |
+
(config) =>
|
195 |
+
(config.compressMessageLengthThreshold =
|
196 |
+
e.currentTarget.valueAsNumber),
|
197 |
+
)
|
198 |
+
}
|
199 |
+
></input>
|
200 |
+
</ListItem>
|
201 |
+
<ListItem title={Locale.Memory.Title} subTitle={Locale.Memory.Send}>
|
202 |
+
<input
|
203 |
+
type="checkbox"
|
204 |
+
checked={props.modelConfig.sendMemory}
|
205 |
+
onChange={(e) =>
|
206 |
+
props.updateConfig(
|
207 |
+
(config) => (config.sendMemory = e.currentTarget.checked),
|
208 |
+
)
|
209 |
+
}
|
210 |
+
></input>
|
211 |
+
</ListItem>
|
212 |
+
</>
|
213 |
+
);
|
214 |
+
}
|
app/components/new-chat.module.scss
ADDED
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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: 2vh;
|
58 |
+
animation: slide-in ease 0.45s;
|
59 |
+
display: flex;
|
60 |
+
justify-content: center;
|
61 |
+
font-size: 12px;
|
62 |
+
|
63 |
+
.skip {
|
64 |
+
margin-left: 10px;
|
65 |
+
}
|
66 |
+
}
|
67 |
+
|
68 |
+
.masks {
|
69 |
+
flex-grow: 1;
|
70 |
+
width: 100%;
|
71 |
+
overflow: auto;
|
72 |
+
align-items: center;
|
73 |
+
padding-top: 20px;
|
74 |
+
|
75 |
+
$linear: linear-gradient(
|
76 |
+
to bottom,
|
77 |
+
rgba(0, 0, 0, 0),
|
78 |
+
rgba(0, 0, 0, 1),
|
79 |
+
rgba(0, 0, 0, 0)
|
80 |
+
);
|
81 |
+
|
82 |
+
-webkit-mask-image: $linear;
|
83 |
+
mask-image: $linear;
|
84 |
+
|
85 |
+
animation: slide-in ease 0.5s;
|
86 |
+
|
87 |
+
.mask-row {
|
88 |
+
display: flex;
|
89 |
+
// justify-content: center;
|
90 |
+
margin-bottom: 10px;
|
91 |
+
|
92 |
+
@for $i from 1 to 10 {
|
93 |
+
&:nth-child(#{$i * 2}) {
|
94 |
+
margin-left: 50px;
|
95 |
+
}
|
96 |
+
}
|
97 |
+
|
98 |
+
.mask {
|
99 |
+
display: flex;
|
100 |
+
align-items: center;
|
101 |
+
padding: 10px 14px;
|
102 |
+
border: var(--border-in-light);
|
103 |
+
box-shadow: var(--card-shadow);
|
104 |
+
background-color: var(--white);
|
105 |
+
border-radius: 10px;
|
106 |
+
margin-right: 10px;
|
107 |
+
max-width: 8em;
|
108 |
+
transform: scale(1);
|
109 |
+
cursor: pointer;
|
110 |
+
transition: all ease 0.3s;
|
111 |
+
|
112 |
+
&:hover {
|
113 |
+
transform: translateY(-5px) scale(1.1);
|
114 |
+
z-index: 999;
|
115 |
+
border-color: var(--primary);
|
116 |
+
}
|
117 |
+
|
118 |
+
.mask-name {
|
119 |
+
margin-left: 10px;
|
120 |
+
font-size: 14px;
|
121 |
+
}
|
122 |
+
}
|
123 |
+
}
|
124 |
+
}
|
125 |
+
}
|
app/components/new-chat.tsx
ADDED
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
+
import { useCommand } from "../command";
|
17 |
+
import { showConfirm } from "./ui-lib";
|
18 |
+
import { BUILTIN_MASK_STORE } from "../masks";
|
19 |
+
|
20 |
+
function getIntersectionArea(aRect: DOMRect, bRect: DOMRect) {
|
21 |
+
const xmin = Math.max(aRect.x, bRect.x);
|
22 |
+
const xmax = Math.min(aRect.x + aRect.width, bRect.x + bRect.width);
|
23 |
+
const ymin = Math.max(aRect.y, bRect.y);
|
24 |
+
const ymax = Math.min(aRect.y + aRect.height, bRect.y + bRect.height);
|
25 |
+
const width = xmax - xmin;
|
26 |
+
const height = ymax - ymin;
|
27 |
+
const intersectionArea = width < 0 || height < 0 ? 0 : width * height;
|
28 |
+
return intersectionArea;
|
29 |
+
}
|
30 |
+
|
31 |
+
function MaskItem(props: { mask: Mask; onClick?: () => void }) {
|
32 |
+
return (
|
33 |
+
<div className={styles["mask"]} onClick={props.onClick}>
|
34 |
+
<MaskAvatar mask={props.mask} />
|
35 |
+
<div className={styles["mask-name"] + " one-line"}>{props.mask.name}</div>
|
36 |
+
</div>
|
37 |
+
);
|
38 |
+
}
|
39 |
+
|
40 |
+
function useMaskGroup(masks: Mask[]) {
|
41 |
+
const [groups, setGroups] = useState<Mask[][]>([]);
|
42 |
+
|
43 |
+
useEffect(() => {
|
44 |
+
const computeGroup = () => {
|
45 |
+
const appBody = document.getElementById(SlotID.AppBody);
|
46 |
+
if (!appBody || masks.length === 0) return;
|
47 |
+
|
48 |
+
const rect = appBody.getBoundingClientRect();
|
49 |
+
const maxWidth = rect.width;
|
50 |
+
const maxHeight = rect.height * 0.6;
|
51 |
+
const maskItemWidth = 120;
|
52 |
+
const maskItemHeight = 50;
|
53 |
+
|
54 |
+
const randomMask = () => masks[Math.floor(Math.random() * masks.length)];
|
55 |
+
let maskIndex = 0;
|
56 |
+
const nextMask = () => masks[maskIndex++ % masks.length];
|
57 |
+
|
58 |
+
const rows = Math.ceil(maxHeight / maskItemHeight);
|
59 |
+
const cols = Math.ceil(maxWidth / maskItemWidth);
|
60 |
+
|
61 |
+
const newGroups = new Array(rows)
|
62 |
+
.fill(0)
|
63 |
+
.map((_, _i) =>
|
64 |
+
new Array(cols)
|
65 |
+
.fill(0)
|
66 |
+
.map((_, j) => (j < 1 || j > cols - 2 ? randomMask() : nextMask())),
|
67 |
+
);
|
68 |
+
|
69 |
+
setGroups(newGroups);
|
70 |
+
};
|
71 |
+
|
72 |
+
computeGroup();
|
73 |
+
|
74 |
+
window.addEventListener("resize", computeGroup);
|
75 |
+
return () => window.removeEventListener("resize", computeGroup);
|
76 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
77 |
+
}, []);
|
78 |
+
|
79 |
+
return groups;
|
80 |
+
}
|
81 |
+
|
82 |
+
export function NewChat() {
|
83 |
+
const chatStore = useChatStore();
|
84 |
+
const maskStore = useMaskStore();
|
85 |
+
|
86 |
+
const masks = maskStore.getAll();
|
87 |
+
const groups = useMaskGroup(masks);
|
88 |
+
|
89 |
+
const navigate = useNavigate();
|
90 |
+
const config = useAppConfig();
|
91 |
+
|
92 |
+
const maskRef = useRef<HTMLDivElement>(null);
|
93 |
+
|
94 |
+
const { state } = useLocation();
|
95 |
+
|
96 |
+
const startChat = (mask?: Mask) => {
|
97 |
+
setTimeout(() => {
|
98 |
+
chatStore.newSession(mask);
|
99 |
+
navigate(Path.Chat);
|
100 |
+
}, 10);
|
101 |
+
};
|
102 |
+
|
103 |
+
useCommand({
|
104 |
+
mask: (id) => {
|
105 |
+
try {
|
106 |
+
const mask = maskStore.get(id) ?? BUILTIN_MASK_STORE.get(id);
|
107 |
+
startChat(mask ?? undefined);
|
108 |
+
} catch {
|
109 |
+
console.error("[New Chat] failed to create chat from mask id=", id);
|
110 |
+
}
|
111 |
+
},
|
112 |
+
});
|
113 |
+
|
114 |
+
useEffect(() => {
|
115 |
+
if (maskRef.current) {
|
116 |
+
maskRef.current.scrollLeft =
|
117 |
+
(maskRef.current.scrollWidth - maskRef.current.clientWidth) / 2;
|
118 |
+
}
|
119 |
+
}, [groups]);
|
120 |
+
|
121 |
+
return (
|
122 |
+
<div className={styles["new-chat"]}>
|
123 |
+
<div className={styles["mask-header"]}>
|
124 |
+
<IconButton
|
125 |
+
icon={<LeftIcon />}
|
126 |
+
text={Locale.NewChat.Return}
|
127 |
+
onClick={() => navigate(Path.Home)}
|
128 |
+
></IconButton>
|
129 |
+
{!state?.fromHome && (
|
130 |
+
<IconButton
|
131 |
+
text={Locale.NewChat.NotShow}
|
132 |
+
onClick={async () => {
|
133 |
+
if (await showConfirm(Locale.NewChat.ConfirmNoShow)) {
|
134 |
+
startChat();
|
135 |
+
config.update(
|
136 |
+
(config) => (config.dontShowMaskSplashScreen = true),
|
137 |
+
);
|
138 |
+
}
|
139 |
+
}}
|
140 |
+
></IconButton>
|
141 |
+
)}
|
142 |
+
</div>
|
143 |
+
<div className={styles["mask-cards"]}>
|
144 |
+
<div className={styles["mask-card"]}>
|
145 |
+
<EmojiAvatar avatar="1f606" size={24} />
|
146 |
+
</div>
|
147 |
+
<div className={styles["mask-card"]}>
|
148 |
+
<EmojiAvatar avatar="1f916" size={24} />
|
149 |
+
</div>
|
150 |
+
<div className={styles["mask-card"]}>
|
151 |
+
<EmojiAvatar avatar="1f479" size={24} />
|
152 |
+
</div>
|
153 |
+
</div>
|
154 |
+
|
155 |
+
<div className={styles["title"]}>{Locale.NewChat.Title}</div>
|
156 |
+
<div className={styles["sub-title"]}>{Locale.NewChat.SubTitle}</div>
|
157 |
+
|
158 |
+
<div className={styles["actions"]}>
|
159 |
+
<IconButton
|
160 |
+
text={Locale.NewChat.More}
|
161 |
+
onClick={() => navigate(Path.Masks)}
|
162 |
+
icon={<EyeIcon />}
|
163 |
+
bordered
|
164 |
+
shadow
|
165 |
+
/>
|
166 |
+
|
167 |
+
<IconButton
|
168 |
+
text={Locale.NewChat.Skip}
|
169 |
+
onClick={() => startChat()}
|
170 |
+
icon={<LightningIcon />}
|
171 |
+
type="primary"
|
172 |
+
shadow
|
173 |
+
className={styles["skip"]}
|
174 |
+
/>
|
175 |
+
</div>
|
176 |
+
|
177 |
+
<div className={styles["masks"]} ref={maskRef}>
|
178 |
+
{groups.map((masks, i) => (
|
179 |
+
<div key={i} className={styles["mask-row"]}>
|
180 |
+
{masks.map((mask, index) => (
|
181 |
+
<MaskItem
|
182 |
+
key={index}
|
183 |
+
mask={mask}
|
184 |
+
onClick={() => startChat(mask)}
|
185 |
+
/>
|
186 |
+
))}
|
187 |
+
</div>
|
188 |
+
))}
|
189 |
+
</div>
|
190 |
+
</div>
|
191 |
+
);
|
192 |
+
}
|
app/components/plugin-config.tsx
ADDED
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { PluginConfig } from "../store";
|
2 |
+
|
3 |
+
import Locale from "../locales";
|
4 |
+
import { ListItem } from "./ui-lib";
|
5 |
+
|
6 |
+
export function PluginConfigList(props: {
|
7 |
+
pluginConfig: PluginConfig;
|
8 |
+
updateConfig: (updater: (config: PluginConfig) => void) => void;
|
9 |
+
}) {
|
10 |
+
return (
|
11 |
+
<>
|
12 |
+
<ListItem
|
13 |
+
title={Locale.Settings.Plugin.Enable.Title}
|
14 |
+
subTitle={Locale.Settings.Plugin.Enable.SubTitle}
|
15 |
+
>
|
16 |
+
<input
|
17 |
+
type="checkbox"
|
18 |
+
checked={props.pluginConfig.enable}
|
19 |
+
onChange={(e) =>
|
20 |
+
props.updateConfig(
|
21 |
+
(config) => (config.enable = e.currentTarget.checked),
|
22 |
+
)
|
23 |
+
}
|
24 |
+
></input>
|
25 |
+
</ListItem>
|
26 |
+
<ListItem
|
27 |
+
title={Locale.Settings.Plugin.MaxIteration.Title}
|
28 |
+
subTitle={Locale.Settings.Plugin.MaxIteration.SubTitle}
|
29 |
+
>
|
30 |
+
<input
|
31 |
+
type="number"
|
32 |
+
min={1}
|
33 |
+
max={10}
|
34 |
+
value={props.pluginConfig.maxIterations}
|
35 |
+
onChange={(e) =>
|
36 |
+
props.updateConfig(
|
37 |
+
(config) =>
|
38 |
+
(config.maxIterations = e.currentTarget.valueAsNumber),
|
39 |
+
)
|
40 |
+
}
|
41 |
+
></input>
|
42 |
+
</ListItem>
|
43 |
+
<ListItem
|
44 |
+
title={Locale.Settings.Plugin.ReturnIntermediateStep.Title}
|
45 |
+
subTitle={Locale.Settings.Plugin.ReturnIntermediateStep.SubTitle}
|
46 |
+
>
|
47 |
+
<input
|
48 |
+
type="checkbox"
|
49 |
+
checked={props.pluginConfig.returnIntermediateSteps}
|
50 |
+
onChange={(e) =>
|
51 |
+
props.updateConfig(
|
52 |
+
(config) =>
|
53 |
+
(config.returnIntermediateSteps = e.currentTarget.checked),
|
54 |
+
)
|
55 |
+
}
|
56 |
+
></input>
|
57 |
+
</ListItem>
|
58 |
+
</>
|
59 |
+
);
|
60 |
+
}
|
app/components/plugin.module.scss
ADDED
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
@import "../styles/animation.scss";
|
2 |
+
.plugin-page {
|
3 |
+
height: 100%;
|
4 |
+
display: flex;
|
5 |
+
flex-direction: column;
|
6 |
+
|
7 |
+
.plugin-page-body {
|
8 |
+
padding: 20px;
|
9 |
+
overflow-y: auto;
|
10 |
+
|
11 |
+
.plugin-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 |
+
.plugin-filter-lang {
|
27 |
+
height: 100%;
|
28 |
+
margin-left: 10px;
|
29 |
+
}
|
30 |
+
|
31 |
+
.plugin-create {
|
32 |
+
height: 100%;
|
33 |
+
margin-left: 10px;
|
34 |
+
box-sizing: border-box;
|
35 |
+
min-width: 80px;
|
36 |
+
}
|
37 |
+
}
|
38 |
+
|
39 |
+
.plugin-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 |
+
.plugin-header {
|
61 |
+
display: flex;
|
62 |
+
align-items: center;
|
63 |
+
|
64 |
+
.plugin-icon {
|
65 |
+
display: flex;
|
66 |
+
align-items: center;
|
67 |
+
justify-content: center;
|
68 |
+
margin-right: 10px;
|
69 |
+
}
|
70 |
+
|
71 |
+
.plugin-title {
|
72 |
+
.plugin-name {
|
73 |
+
font-size: 14px;
|
74 |
+
font-weight: bold;
|
75 |
+
}
|
76 |
+
.plugin-info {
|
77 |
+
font-size: 12px;
|
78 |
+
}
|
79 |
+
}
|
80 |
+
}
|
81 |
+
|
82 |
+
.plugin-actions {
|
83 |
+
display: flex;
|
84 |
+
flex-wrap: nowrap;
|
85 |
+
transition: all ease 0.3s;
|
86 |
+
justify-content: center;
|
87 |
+
align-items: center;
|
88 |
+
}
|
89 |
+
|
90 |
+
@media screen and (max-width: 600px) {
|
91 |
+
display: flex;
|
92 |
+
flex-direction: column;
|
93 |
+
padding-bottom: 10px;
|
94 |
+
border-radius: 10px;
|
95 |
+
margin-bottom: 20px;
|
96 |
+
box-shadow: var(--card-shadow);
|
97 |
+
|
98 |
+
&:not(:last-child) {
|
99 |
+
border-bottom: var(--border-in-light);
|
100 |
+
}
|
101 |
+
|
102 |
+
.plugin-actions {
|
103 |
+
width: 100%;
|
104 |
+
justify-content: space-between;
|
105 |
+
padding-top: 10px;
|
106 |
+
}
|
107 |
+
}
|
108 |
+
}
|
109 |
+
}
|
110 |
+
}
|
app/components/plugin.tsx
ADDED
@@ -0,0 +1,497 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { IconButton } from "./button";
|
2 |
+
import { ErrorBoundary } from "./error";
|
3 |
+
|
4 |
+
import styles from "./plugin.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 |
+
import LeftIcon from "../icons/left.svg";
|
15 |
+
|
16 |
+
import { Plugin, usePluginStore } from "../store/plugin";
|
17 |
+
import {
|
18 |
+
ChatMessage,
|
19 |
+
createMessage,
|
20 |
+
ModelConfig,
|
21 |
+
useAppConfig,
|
22 |
+
useChatStore,
|
23 |
+
} from "../store";
|
24 |
+
import { ROLES } from "../client/api";
|
25 |
+
import {
|
26 |
+
Input,
|
27 |
+
List,
|
28 |
+
ListItem,
|
29 |
+
Modal,
|
30 |
+
Popover,
|
31 |
+
Select,
|
32 |
+
showConfirm,
|
33 |
+
} from "./ui-lib";
|
34 |
+
import { Avatar, AvatarPicker } from "./emoji";
|
35 |
+
import Locale, { AllLangs, ALL_LANG_OPTIONS, Lang } from "../locales";
|
36 |
+
import { useLocation, useNavigate } from "react-router-dom";
|
37 |
+
|
38 |
+
import chatStyle from "./chat.module.scss";
|
39 |
+
import { useEffect, useState } from "react";
|
40 |
+
import { copyToClipboard, downloadAs, readFromFile } from "../utils";
|
41 |
+
import { Updater } from "../typing";
|
42 |
+
import { ModelConfigList } from "./model-config";
|
43 |
+
import { FileName, Path } from "../constant";
|
44 |
+
import { BUILTIN_PLUGIN_STORE } from "../plugins";
|
45 |
+
import { nanoid } from "nanoid";
|
46 |
+
import { getISOLang, getLang } from "../locales";
|
47 |
+
|
48 |
+
// export function PluginConfig(props: {
|
49 |
+
// plugin: Plugin;
|
50 |
+
// updateMask: Updater<Plugin>;
|
51 |
+
// extraListItems?: JSX.Element;
|
52 |
+
// readonly?: boolean;
|
53 |
+
// shouldSyncFromGlobal?: boolean;
|
54 |
+
// }) {
|
55 |
+
// const [showPicker, setShowPicker] = useState(false);
|
56 |
+
|
57 |
+
// const updateConfig = (updater: (config: ModelConfig) => void) => {
|
58 |
+
// if (props.readonly) return;
|
59 |
+
|
60 |
+
// // const config = { ...props.mask.modelConfig };
|
61 |
+
// // updater(config);
|
62 |
+
// props.updateMask((mask) => {
|
63 |
+
// // mask.modelConfig = config;
|
64 |
+
// // // if user changed current session mask, it will disable auto sync
|
65 |
+
// // mask.syncGlobalConfig = false;
|
66 |
+
// });
|
67 |
+
// };
|
68 |
+
|
69 |
+
// const globalConfig = useAppConfig();
|
70 |
+
|
71 |
+
// return (
|
72 |
+
// <>
|
73 |
+
// <ContextPrompts
|
74 |
+
// context={props.mask.context}
|
75 |
+
// updateContext={(updater) => {
|
76 |
+
// const context = props.mask.context.slice();
|
77 |
+
// updater(context);
|
78 |
+
// props.updateMask((mask) => (mask.context = context));
|
79 |
+
// }}
|
80 |
+
// />
|
81 |
+
|
82 |
+
// <List>
|
83 |
+
// <ListItem title={Locale.Mask.Config.Avatar}>
|
84 |
+
// <Popover
|
85 |
+
// content={
|
86 |
+
// <AvatarPicker
|
87 |
+
// onEmojiClick={(emoji) => {
|
88 |
+
// props.updateMask((mask) => (mask.avatar = emoji));
|
89 |
+
// setShowPicker(false);
|
90 |
+
// }}
|
91 |
+
// ></AvatarPicker>
|
92 |
+
// }
|
93 |
+
// open={showPicker}
|
94 |
+
// onClose={() => setShowPicker(false)}
|
95 |
+
// >
|
96 |
+
// <div
|
97 |
+
// onClick={() => setShowPicker(true)}
|
98 |
+
// style={{ cursor: "pointer" }}
|
99 |
+
// >
|
100 |
+
// </div>
|
101 |
+
// </Popover>
|
102 |
+
// </ListItem>
|
103 |
+
// <ListItem title={Locale.Mask.Config.Name}>
|
104 |
+
// <input
|
105 |
+
// type="text"
|
106 |
+
// value={props.mask.name}
|
107 |
+
// onInput={(e) =>
|
108 |
+
// props.updateMask((mask) => {
|
109 |
+
// mask.name = e.currentTarget.value;
|
110 |
+
// })
|
111 |
+
// }
|
112 |
+
// ></input>
|
113 |
+
// </ListItem>
|
114 |
+
// <ListItem
|
115 |
+
// title={Locale.Mask.Config.HideContext.Title}
|
116 |
+
// subTitle={Locale.Mask.Config.HideContext.SubTitle}
|
117 |
+
// >
|
118 |
+
// <input
|
119 |
+
// type="checkbox"
|
120 |
+
// checked={props.mask.hideContext}
|
121 |
+
// onChange={(e) => {
|
122 |
+
// props.updateMask((mask) => {
|
123 |
+
// mask.hideContext = e.currentTarget.checked;
|
124 |
+
// });
|
125 |
+
// }}
|
126 |
+
// ></input>
|
127 |
+
// </ListItem>
|
128 |
+
|
129 |
+
// {!props.shouldSyncFromGlobal ? (
|
130 |
+
// <ListItem
|
131 |
+
// title={Locale.Mask.Config.Share.Title}
|
132 |
+
// subTitle={Locale.Mask.Config.Share.SubTitle}
|
133 |
+
// >
|
134 |
+
// <IconButton
|
135 |
+
// icon={<CopyIcon />}
|
136 |
+
// text={Locale.Mask.Config.Share.Action}
|
137 |
+
// onClick={copyMaskLink}
|
138 |
+
// />
|
139 |
+
// </ListItem>
|
140 |
+
// ) : null}
|
141 |
+
|
142 |
+
// {props.shouldSyncFromGlobal ? (
|
143 |
+
// <ListItem
|
144 |
+
// title={Locale.Mask.Config.Sync.Title}
|
145 |
+
// subTitle={Locale.Mask.Config.Sync.SubTitle}
|
146 |
+
// >
|
147 |
+
// <input
|
148 |
+
// type="checkbox"
|
149 |
+
// checked={props.mask.syncGlobalConfig}
|
150 |
+
// onChange={async (e) => {
|
151 |
+
// const checked = e.currentTarget.checked;
|
152 |
+
// if (
|
153 |
+
// checked &&
|
154 |
+
// (await showConfirm(Locale.Mask.Config.Sync.Confirm))
|
155 |
+
// ) {
|
156 |
+
// props.updateMask((mask) => {
|
157 |
+
// mask.syncGlobalConfig = checked;
|
158 |
+
// mask.modelConfig = { ...globalConfig.modelConfig };
|
159 |
+
// });
|
160 |
+
// } else if (!checked) {
|
161 |
+
// props.updateMask((mask) => {
|
162 |
+
// mask.syncGlobalConfig = checked;
|
163 |
+
// });
|
164 |
+
// }
|
165 |
+
// }}
|
166 |
+
// ></input>
|
167 |
+
// </ListItem>
|
168 |
+
// ) : null}
|
169 |
+
// </List>
|
170 |
+
|
171 |
+
// <List>
|
172 |
+
// <ModelConfigList
|
173 |
+
// modelConfig={{ ...props.mask.modelConfig }}
|
174 |
+
// updateConfig={updateConfig}
|
175 |
+
// />
|
176 |
+
// {props.extraListItems}
|
177 |
+
// </List>
|
178 |
+
// </>
|
179 |
+
// );
|
180 |
+
// }
|
181 |
+
|
182 |
+
function ContextPromptItem(props: {
|
183 |
+
prompt: ChatMessage;
|
184 |
+
update: (prompt: ChatMessage) => void;
|
185 |
+
remove: () => void;
|
186 |
+
}) {
|
187 |
+
const [focusingInput, setFocusingInput] = useState(false);
|
188 |
+
|
189 |
+
return (
|
190 |
+
<div className={chatStyle["context-prompt-row"]}>
|
191 |
+
{!focusingInput && (
|
192 |
+
<Select
|
193 |
+
value={props.prompt.role}
|
194 |
+
className={chatStyle["context-role"]}
|
195 |
+
onChange={(e) =>
|
196 |
+
props.update({
|
197 |
+
...props.prompt,
|
198 |
+
role: e.target.value as any,
|
199 |
+
})
|
200 |
+
}
|
201 |
+
>
|
202 |
+
{ROLES.map((r) => (
|
203 |
+
<option key={r} value={r}>
|
204 |
+
{r}
|
205 |
+
</option>
|
206 |
+
))}
|
207 |
+
</Select>
|
208 |
+
)}
|
209 |
+
<Input
|
210 |
+
value={props.prompt.content}
|
211 |
+
type="text"
|
212 |
+
className={chatStyle["context-content"]}
|
213 |
+
rows={focusingInput ? 5 : 1}
|
214 |
+
onFocus={() => setFocusingInput(true)}
|
215 |
+
onBlur={() => {
|
216 |
+
setFocusingInput(false);
|
217 |
+
// If the selection is not removed when the user loses focus, some
|
218 |
+
// extensions like "Translate" will always display a floating bar
|
219 |
+
window?.getSelection()?.removeAllRanges();
|
220 |
+
}}
|
221 |
+
onInput={(e) =>
|
222 |
+
props.update({
|
223 |
+
...props.prompt,
|
224 |
+
content: e.currentTarget.value as any,
|
225 |
+
})
|
226 |
+
}
|
227 |
+
/>
|
228 |
+
{!focusingInput && (
|
229 |
+
<IconButton
|
230 |
+
icon={<DeleteIcon />}
|
231 |
+
className={chatStyle["context-delete-button"]}
|
232 |
+
onClick={() => props.remove()}
|
233 |
+
bordered
|
234 |
+
/>
|
235 |
+
)}
|
236 |
+
</div>
|
237 |
+
);
|
238 |
+
}
|
239 |
+
|
240 |
+
export function ContextPrompts(props: {
|
241 |
+
context: ChatMessage[];
|
242 |
+
updateContext: (updater: (context: ChatMessage[]) => void) => void;
|
243 |
+
}) {
|
244 |
+
const context = props.context;
|
245 |
+
|
246 |
+
const addContextPrompt = (prompt: ChatMessage) => {
|
247 |
+
props.updateContext((context) => context.push(prompt));
|
248 |
+
};
|
249 |
+
|
250 |
+
const removeContextPrompt = (i: number) => {
|
251 |
+
props.updateContext((context) => context.splice(i, 1));
|
252 |
+
};
|
253 |
+
|
254 |
+
const updateContextPrompt = (i: number, prompt: ChatMessage) => {
|
255 |
+
props.updateContext((context) => (context[i] = prompt));
|
256 |
+
};
|
257 |
+
|
258 |
+
return (
|
259 |
+
<>
|
260 |
+
<div className={chatStyle["context-prompt"]} style={{ marginBottom: 20 }}>
|
261 |
+
{context.map((c, i) => (
|
262 |
+
<ContextPromptItem
|
263 |
+
key={i}
|
264 |
+
prompt={c}
|
265 |
+
update={(prompt) => updateContextPrompt(i, prompt)}
|
266 |
+
remove={() => removeContextPrompt(i)}
|
267 |
+
/>
|
268 |
+
))}
|
269 |
+
|
270 |
+
<div className={chatStyle["context-prompt-row"]}>
|
271 |
+
<IconButton
|
272 |
+
icon={<AddIcon />}
|
273 |
+
text={Locale.Context.Add}
|
274 |
+
bordered
|
275 |
+
className={chatStyle["context-prompt-button"]}
|
276 |
+
onClick={() =>
|
277 |
+
addContextPrompt(
|
278 |
+
createMessage({
|
279 |
+
role: "user",
|
280 |
+
content: "",
|
281 |
+
date: "",
|
282 |
+
}),
|
283 |
+
)
|
284 |
+
}
|
285 |
+
/>
|
286 |
+
</div>
|
287 |
+
</div>
|
288 |
+
</>
|
289 |
+
);
|
290 |
+
}
|
291 |
+
|
292 |
+
export function PluginPage() {
|
293 |
+
const navigate = useNavigate();
|
294 |
+
|
295 |
+
const pluginStore = usePluginStore();
|
296 |
+
const chatStore = useChatStore();
|
297 |
+
|
298 |
+
const allPlugins = pluginStore
|
299 |
+
.getAll()
|
300 |
+
.filter(
|
301 |
+
(m) => !getLang() || m.lang === (getLang() == "cn" ? getLang() : "en"),
|
302 |
+
);
|
303 |
+
|
304 |
+
const [searchPlugins, setSearchPlugins] = useState<Plugin[]>([]);
|
305 |
+
const [searchText, setSearchText] = useState("");
|
306 |
+
const plugins = searchText.length > 0 ? searchPlugins : allPlugins;
|
307 |
+
|
308 |
+
// simple search, will refactor later
|
309 |
+
const onSearch = (text: string) => {
|
310 |
+
setSearchText(text);
|
311 |
+
if (text.length > 0) {
|
312 |
+
const result = allPlugins.filter((m) => m.name.includes(text));
|
313 |
+
setSearchPlugins(result);
|
314 |
+
} else {
|
315 |
+
setSearchPlugins(allPlugins);
|
316 |
+
}
|
317 |
+
};
|
318 |
+
|
319 |
+
const [editingPluginId, setEditingPluginId] = useState<string | undefined>();
|
320 |
+
const editingPlugin =
|
321 |
+
pluginStore.get(editingPluginId) ??
|
322 |
+
BUILTIN_PLUGIN_STORE.get(editingPluginId);
|
323 |
+
const closePluginModal = () => setEditingPluginId(undefined);
|
324 |
+
|
325 |
+
const downloadAll = () => {
|
326 |
+
downloadAs(JSON.stringify(plugins), FileName.Plugins);
|
327 |
+
};
|
328 |
+
|
329 |
+
const updatePluginEnableStatus = (id: string, enable: boolean) => {
|
330 |
+
console.log(enable);
|
331 |
+
if (enable) pluginStore.enable(id);
|
332 |
+
else pluginStore.disable(id);
|
333 |
+
};
|
334 |
+
|
335 |
+
const importFromFile = () => {
|
336 |
+
readFromFile().then((content) => {
|
337 |
+
try {
|
338 |
+
const importPlugins = JSON.parse(content);
|
339 |
+
if (Array.isArray(importPlugins)) {
|
340 |
+
for (const plugin of importPlugins) {
|
341 |
+
if (plugin.name) {
|
342 |
+
pluginStore.create(plugin);
|
343 |
+
}
|
344 |
+
}
|
345 |
+
return;
|
346 |
+
}
|
347 |
+
if (importPlugins.name) {
|
348 |
+
pluginStore.create(importPlugins);
|
349 |
+
}
|
350 |
+
} catch {}
|
351 |
+
});
|
352 |
+
};
|
353 |
+
|
354 |
+
return (
|
355 |
+
<ErrorBoundary>
|
356 |
+
<div className={styles["plugin-page"]}>
|
357 |
+
<div className={styles["plugin-header"]}>
|
358 |
+
<IconButton
|
359 |
+
icon={<LeftIcon />}
|
360 |
+
text={Locale.NewChat.Return}
|
361 |
+
onClick={() => navigate(Path.Home)}
|
362 |
+
></IconButton>
|
363 |
+
</div>
|
364 |
+
<div className="window-header">
|
365 |
+
<div className="window-header-title">
|
366 |
+
<div className="window-header-main-title">
|
367 |
+
{Locale.Plugin.Page.Title}
|
368 |
+
</div>
|
369 |
+
<div className="window-header-submai-title">
|
370 |
+
{Locale.Plugin.Page.SubTitle(allPlugins.length)}
|
371 |
+
</div>
|
372 |
+
</div>
|
373 |
+
|
374 |
+
<div className="window-actions">
|
375 |
+
{/* <div className="window-action-button">
|
376 |
+
<IconButton
|
377 |
+
icon={<DownloadIcon />}
|
378 |
+
bordered
|
379 |
+
onClick={downloadAll}
|
380 |
+
/>
|
381 |
+
</div>
|
382 |
+
<div className="window-action-button">
|
383 |
+
<IconButton
|
384 |
+
icon={<UploadIcon />}
|
385 |
+
bordered
|
386 |
+
onClick={() => importFromFile()}
|
387 |
+
/>
|
388 |
+
</div> */}
|
389 |
+
</div>
|
390 |
+
</div>
|
391 |
+
|
392 |
+
<div className={styles["plugin-page-body"]}>
|
393 |
+
<div className={styles["plugin-filter"]}>
|
394 |
+
<input
|
395 |
+
type="text"
|
396 |
+
className={styles["search-bar"]}
|
397 |
+
placeholder={Locale.Plugin.Page.Search}
|
398 |
+
autoFocus
|
399 |
+
onInput={(e) => onSearch(e.currentTarget.value)}
|
400 |
+
/>
|
401 |
+
|
402 |
+
{/* <IconButton
|
403 |
+
className={styles["mask-create"]}
|
404 |
+
icon={<AddIcon />}
|
405 |
+
text={Locale.Mask.Page.Create}
|
406 |
+
bordered
|
407 |
+
onClick={() => {
|
408 |
+
const createdMask = pluginStore.create();
|
409 |
+
setEditingMaskId(createdMask.id);
|
410 |
+
}}
|
411 |
+
/> */}
|
412 |
+
</div>
|
413 |
+
|
414 |
+
<div>
|
415 |
+
{plugins.map((m) => (
|
416 |
+
<div className={styles["plugin-item"]} key={m.id}>
|
417 |
+
<div className={styles["plugin-header"]}>
|
418 |
+
<div className={styles["plugin-title"]}>
|
419 |
+
<div className={styles["plugin-name"]}>{m.name}</div>
|
420 |
+
{/* 描述 */}
|
421 |
+
<div className={styles["plugin-info"] + " one-line"}>
|
422 |
+
{`${m.description}`}
|
423 |
+
</div>
|
424 |
+
</div>
|
425 |
+
</div>
|
426 |
+
<div className={styles["plugin-actions"]}>
|
427 |
+
<input
|
428 |
+
type="checkbox"
|
429 |
+
checked={m.enable}
|
430 |
+
onChange={(e) => {
|
431 |
+
updatePluginEnableStatus(m.id, e.currentTarget.checked);
|
432 |
+
}}
|
433 |
+
></input>
|
434 |
+
{/* {m.builtin ? (
|
435 |
+
<IconButton
|
436 |
+
icon={<EyeIcon />}
|
437 |
+
text={Locale.Mask.Item.View}
|
438 |
+
onClick={() => setEditingMaskId(m.id)}
|
439 |
+
/>
|
440 |
+
) : (
|
441 |
+
<IconButton
|
442 |
+
icon={<EditIcon />}
|
443 |
+
text={Locale.Mask.Item.Edit}
|
444 |
+
onClick={() => setEditingMaskId(m.id)}
|
445 |
+
/>
|
446 |
+
)}
|
447 |
+
{!m.builtin && (
|
448 |
+
<IconButton
|
449 |
+
icon={<DeleteIcon />}
|
450 |
+
text={Locale.Mask.Item.Delete}
|
451 |
+
onClick={async () => {
|
452 |
+
if (await showConfirm(Locale.Mask.Item.DeleteConfirm)) {
|
453 |
+
maskStore.delete(m.id);
|
454 |
+
}
|
455 |
+
}}
|
456 |
+
/>
|
457 |
+
)} */}
|
458 |
+
</div>
|
459 |
+
</div>
|
460 |
+
))}
|
461 |
+
</div>
|
462 |
+
</div>
|
463 |
+
</div>
|
464 |
+
|
465 |
+
{editingPlugin && (
|
466 |
+
<div className="modal-mask">
|
467 |
+
<Modal
|
468 |
+
title={Locale.Plugin.EditModal.Title(editingPlugin?.builtin)}
|
469 |
+
onClose={closePluginModal}
|
470 |
+
actions={[
|
471 |
+
<IconButton
|
472 |
+
icon={<DownloadIcon />}
|
473 |
+
text={Locale.Plugin.EditModal.Download}
|
474 |
+
key="export"
|
475 |
+
bordered
|
476 |
+
onClick={() =>
|
477 |
+
downloadAs(
|
478 |
+
JSON.stringify(editingPlugin),
|
479 |
+
`${editingPlugin.name}.json`,
|
480 |
+
)
|
481 |
+
}
|
482 |
+
/>,
|
483 |
+
]}
|
484 |
+
>
|
485 |
+
{/* <PluginConfig
|
486 |
+
plugin={editingPlugin}
|
487 |
+
updatePlugin={(updater) =>
|
488 |
+
pluginStore.update(editingPluginId!, updater)
|
489 |
+
}
|
490 |
+
readonly={editingPlugin.builtin}
|
491 |
+
/> */}
|
492 |
+
</Modal>
|
493 |
+
</div>
|
494 |
+
)}
|
495 |
+
</ErrorBoundary>
|
496 |
+
);
|
497 |
+
}
|