Severian commited on
Commit
f0499d2
·
1 Parent(s): 62e1b4d

Upload 269 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. CODE_OF_CONDUCT.md +128 -0
  2. Dockerfile +62 -0
  3. LICENSE +21 -0
  4. README.md +133 -12
  5. app/.DS_Store +0 -0
  6. app/api/.DS_Store +0 -0
  7. app/api/auth.ts +31 -0
  8. app/api/common.ts +102 -0
  9. app/api/config/route.ts +29 -0
  10. app/api/cors/[...path]/route.ts +43 -0
  11. app/api/file/[...path]/route.ts +36 -0
  12. app/api/langchain-tools/arxiv.ts +77 -0
  13. app/api/langchain-tools/baidu_search.ts +80 -0
  14. app/api/langchain-tools/duckduckgo.ts +29 -0
  15. app/api/langchain-tools/duckduckgo_search.ts +534 -0
  16. app/api/langchain-tools/google_search.ts +80 -0
  17. app/api/langchain-tools/http_get.ts +70 -0
  18. app/api/langchain-tools/ua_tools.ts +20 -0
  19. app/api/langchain/tool/agent/route.ts +311 -0
  20. app/api/openai/[...path]/route.ts +77 -0
  21. app/client/api.ts +164 -0
  22. app/client/controller.ts +37 -0
  23. app/client/platforms/openai.ts +441 -0
  24. app/command.ts +75 -0
  25. app/components/auth.module.scss +36 -0
  26. app/components/auth.tsx +21 -0
  27. app/components/button.module.scss +83 -0
  28. app/components/button.tsx +51 -0
  29. app/components/chat-list.tsx +167 -0
  30. app/components/chat.module.scss +550 -0
  31. app/components/chat.tsx +1358 -0
  32. app/components/emoji.tsx +56 -0
  33. app/components/error.tsx +74 -0
  34. app/components/exporter.module.scss +217 -0
  35. app/components/exporter.tsx +648 -0
  36. app/components/home.module.scss +340 -0
  37. app/components/home.tsx +207 -0
  38. app/components/input-range.module.scss +13 -0
  39. app/components/input-range.tsx +37 -0
  40. app/components/markdown.tsx +163 -0
  41. app/components/mask.module.scss +108 -0
  42. app/components/mask.tsx +620 -0
  43. app/components/message-selector.module.scss +76 -0
  44. app/components/message-selector.tsx +215 -0
  45. app/components/model-config.tsx +214 -0
  46. app/components/new-chat.module.scss +125 -0
  47. app/components/new-chat.tsx +192 -0
  48. app/components/plugin-config.tsx +60 -0
  49. app/components/plugin.module.scss +110 -0
  50. 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
- title: Nexus
3
- emoji: 📈
4
- colorFrom: pink
5
- colorTo: indigo
6
- sdk: docker
7
- sdk_version: 4.7.1
8
- app_file: app.py
9
- pinned: false
10
- ---
11
-
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ }