Browse Source

Merge branch 'main' into norwegian-translation

Xavier Barbosa 1 year ago
parent
commit
bfe33af03d
100 changed files with 4150 additions and 1424 deletions
  1. 29 0
      .env.template
  2. 4 0
      .github/ISSUE_TEMPLATE/功能建议.md
  3. 5 1
      .github/ISSUE_TEMPLATE/反馈问题.md
  4. 11 0
      .github/dependabot.yml
  5. 88 0
      .github/workflows/app.yml
  6. 2 2
      .github/workflows/sync.yml
  7. 7 3
      .gitignore
  8. 3 0
      Dockerfile
  9. 40 22
      README.md
  10. 3 2
      README_CN.md
  11. 171 0
      README_ES.md
  12. 2 8
      app/api/auth.ts
  13. 54 7
      app/api/common.ts
  14. 24 73
      app/api/openai/[...path]/route.ts
  15. 0 9
      app/api/openai/typing.ts
  16. 145 0
      app/client/api.ts
  17. 37 0
      app/client/controller.ts
  18. 227 0
      app/client/platforms/openai.ts
  19. 36 0
      app/components/auth.module.scss
  20. 46 0
      app/components/auth.tsx
  21. 14 4
      app/components/chat-list.tsx
  22. 95 0
      app/components/chat.module.scss
  23. 294 168
      app/components/chat.tsx
  24. 217 0
      app/components/exporter.module.scss
  25. 528 0
      app/components/exporter.tsx
  26. 7 11
      app/components/home.module.scss
  27. 46 15
      app/components/home.tsx
  28. 5 0
      app/components/input-range.module.scss
  29. 44 29
      app/components/markdown.tsx
  30. 76 20
      app/components/mask.tsx
  31. 76 0
      app/components/message-selector.module.scss
  32. 215 0
      app/components/message-selector.tsx
  33. 5 5
      app/components/model-config.tsx
  34. 16 6
      app/components/new-chat.module.scss
  35. 50 59
      app/components/new-chat.tsx
  36. 46 14
      app/components/settings.tsx
  37. 4 2
      app/components/sidebar.tsx
  38. 25 0
      app/components/ui-lib.module.scss
  39. 19 1
      app/components/ui-lib.tsx
  40. 17 13
      app/config/build.ts
  41. 27 0
      app/config/client.ts
  42. 4 0
      app/config/server.ts
  43. 12 0
      app/constant.ts
  44. 1 23
      app/icons/add.svg
  45. 0 22
      app/icons/black-bot.svg
  46. BIN
      app/icons/bot.png
  47. 1 1
      app/icons/bottom.svg
  48. 1 25
      app/icons/brain.svg
  49. 0 0
      app/icons/break.svg
  50. 0 0
      app/icons/chat-settings.svg
  51. BIN
      app/icons/chatgpt.png
  52. 1 1
      app/icons/copy.svg
  53. 0 8
      app/icons/delete.svg
  54. 1 0
      app/icons/down.svg
  55. 1 1
      app/icons/download.svg
  56. 1 1
      app/icons/export.svg
  57. 1 29
      app/icons/github.svg
  58. 1 1
      app/icons/left.svg
  59. 0 0
      app/icons/mask.svg
  60. 1 41
      app/icons/max.svg
  61. 1 25
      app/icons/menu.svg
  62. 0 45
      app/icons/min.svg
  63. 0 0
      app/icons/plugin.svg
  64. 1 1
      app/icons/prompt.svg
  65. 1 17
      app/icons/share.svg
  66. 1 33
      app/icons/three-dots.svg
  67. 11 19
      app/layout.tsx
  68. 71 25
      app/locales/cn.ts
  69. 231 0
      app/locales/cs.ts
  70. 1 14
      app/locales/de.ts
  71. 63 23
      app/locales/en.ts
  72. 1 14
      app/locales/es.ts
  73. 237 0
      app/locales/fr.ts
  74. 34 4
      app/locales/index.ts
  75. 1 14
      app/locales/it.ts
  76. 51 54
      app/locales/jp.ts
  77. 230 0
      app/locales/ko.ts
  78. 5 6
      app/locales/no.ts
  79. 51 58
      app/locales/ru.ts
  80. 1 14
      app/locales/tr.ts
  81. 1 14
      app/locales/tw.ts
  82. 2 14
      app/locales/vi.ts
  83. 70 0
      app/masks/cn.ts
  84. 7 0
      app/masks/en.ts
  85. 1 1
      app/masks/index.ts
  86. 3 1
      app/masks/typing.ts
  87. 0 285
      app/requests.ts
  88. 16 3
      app/store/access.ts
  89. 116 80
      app/store/chat.ts
  90. 23 2
      app/store/config.ts
  91. 5 2
      app/store/mask.ts
  92. 15 21
      app/store/update.ts
  93. 1 1
      app/styles/markdown.scss
  94. 1 0
      app/typing.ts
  95. 0 7
      app/utils.ts
  96. 13 0
      app/utils/format.ts
  97. 9 0
      app/utils/merge.ts
  98. 22 0
      app/utils/token.ts
  99. 30 0
      docker-compose.yml
  100. 37 0
      docs/cloudflare-pages-es.md

+ 29 - 0
.env.template

@@ -0,0 +1,29 @@
+
+# Your openai api key. (required)
+OPENAI_API_KEY=sk-xxxx
+
+# Access passsword, separated by comma. (optional)
+CODE=your-password
+
+# You can start service behind a proxy
+PROXY_URL=http://localhost:7890
+
+# Override openai api request base url. (optional)
+# Default: https://api.openai.com
+# Examples: http://your-openai-proxy.com
+BASE_URL=
+
+# Specify OpenAI organization ID.(optional)
+# Default: Empty
+# If you do not want users to input their own API key, set this value to 1.
+OPENAI_ORG_ID=
+
+# (optional)
+# Default: Empty
+# If you do not want users to input their own API key, set this value to 1.
+HIDE_USER_API_KEY=
+
+# (optional)
+# Default: Empty
+# If you do not want users to use GPT-4, set this value to 1.
+DISABLE_GPT4=

+ 4 - 0
.github/ISSUE_TEMPLATE/功能建议.md

@@ -7,6 +7,10 @@ assignees: ''
 
 ---
 
+> 为了提高交流效率,我们设立了官方 QQ 群和 QQ 频道,如果你在使用或者搭建过程中遇到了任何问题,请先第一时间加群或者频道咨询解决,除非是可以稳定复现的 Bug 或者较为有创意的功能建议,否则请不要随意往 Issue 区发送低质无意义帖子。
+
+> [点击加入官方群聊](https://github.com/Yidadaa/ChatGPT-Next-Web/discussions/1724)
+
 **这个功能与现有的问题有关吗?**
 如果有关,请在此列出链接或者描述问题。
 

+ 5 - 1
.github/ISSUE_TEMPLATE/反馈问题.md

@@ -7,9 +7,13 @@ assignees: ''
 
 ---
 
+> 为了提高交流效率,我们设立了官方 QQ 群和 QQ 频道,如果你在使用或者搭建过程中遇到了任何问题,请先第一时间加群或者频道咨询解决,除非是可以稳定复现的 Bug 或者较为有创意的功能建议,否则请不要随意往 Issue 区发送低质无意义帖子。
+
+> [点击加入官方群聊](https://github.com/Yidadaa/ChatGPT-Next-Web/discussions/1724)
+
 **反馈须知**
 
-⚠️ 注意:不遵循此模板的任何帖子都会被立即关闭。
+⚠️ 注意:不遵循此模板的任何帖子都会被立即关闭,如果没有提供下方的信息,我们无法定位你的问题
 
 请在下方中括号内输入 x 来表示你已经知晓相关内容。
 - [ ] 我确认已经在 [常见问题](https://github.com/Yidadaa/ChatGPT-Next-Web/blob/main/docs/faq-cn.md) 中搜索了此次反馈的问题,没有找到解答;

+ 11 - 0
.github/dependabot.yml

@@ -0,0 +1,11 @@
+# To get started with Dependabot version updates, you'll need to specify which
+# package ecosystems to update and where the package manifests are located.
+# Please see the documentation for all configuration options:
+# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
+
+version: 2
+updates:
+  - package-ecosystem: "npm" # See documentation for possible values
+    directory: "/" # Location of package manifests
+    schedule:
+      interval: "weekly"

+ 88 - 0
.github/workflows/app.yml

@@ -0,0 +1,88 @@
+name: Release App
+
+on:
+  workflow_dispatch:
+  release:
+    types: [published]
+
+jobs:
+  create-release:
+    permissions:
+      contents: write
+    runs-on: ubuntu-20.04
+    outputs:
+      release_id: ${{ steps.create-release.outputs.result }}
+
+    steps:
+      - uses: actions/checkout@v3
+      - name: setup node
+        uses: actions/setup-node@v3
+        with:
+          node-version: 16
+      - name: get version
+        run: echo "PACKAGE_VERSION=$(node -p "require('./src-tauri/tauri.conf.json').package.version")" >> $GITHUB_ENV
+      - name: create release
+        id: create-release
+        uses: actions/github-script@v6
+        with:
+          script: |
+            const { data } = await github.rest.repos.getLatestRelease({
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+            })
+            return data.id
+
+  build-tauri:
+    needs: create-release
+    permissions:
+      contents: write
+    strategy:
+      fail-fast: false
+      matrix:
+        platform: [macos-latest, ubuntu-20.04, windows-latest]
+
+    runs-on: ${{ matrix.platform }}
+    steps:
+      - uses: actions/checkout@v3
+      - name: setup node
+        uses: actions/setup-node@v3
+        with:
+          node-version: 16
+      - name: install Rust stable
+        uses: dtolnay/rust-toolchain@stable
+      - name: install dependencies (ubuntu only)
+        if: matrix.platform == 'ubuntu-20.04'
+        run: |
+          sudo apt-get update
+          sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
+      - name: install frontend dependencies
+        run: yarn install # change this to npm or pnpm depending on which one you use
+      - uses: tauri-apps/tauri-action@v0
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
+          TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
+        with:
+          releaseId: ${{ needs.create-release.outputs.release_id }}
+
+  publish-release:
+    permissions:
+      contents: write
+    runs-on: ubuntu-20.04
+    needs: [create-release, build-tauri]
+
+    steps:
+      - name: publish release
+        id: publish-release
+        uses: actions/github-script@v6
+        env:
+          release_id: ${{ needs.create-release.outputs.release_id }}
+        with:
+          script: |
+            github.rest.repos.updateRelease({
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              release_id: process.env.release_id,
+              draft: false,
+              prerelease: false
+            })

+ 2 - 2
.github/workflows/sync.yml

@@ -35,6 +35,6 @@ jobs:
       - name: Sync check
         if: failure()
         run: |
-          echo "::error::由于权限不足,导致同步失败(这是预期的行为),请前往仓库首页手动执行[Sync fork]。"
-          echo "::error::Due to insufficient permissions, synchronization failed (as expected). Please go to the repository homepage and manually perform [Sync fork]."
+          echo "[Error] 由于上游仓库的 workflow 文件变更,导致 GitHub 自动暂停了本次自动更新,你需要手动 Sync Fork 一次,详细教程请查看:https://github.com/Yidadaa/ChatGPT-Next-Web/blob/main/README_CN.md#%E6%89%93%E5%BC%80%E8%87%AA%E5%8A%A8%E6%9B%B4%E6%96%B0"
+          echo "[Error] Due to a change in the workflow file of the upstream repository, GitHub has automatically suspended the scheduled automatic update. You need to manually sync your fork. Please refer to the detailed tutorial for instructions: https://github.com/Yidadaa/ChatGPT-Next-Web#enable-automatic-updates"
           exit 1

+ 7 - 3
.gitignore

@@ -36,7 +36,11 @@ yarn-error.log*
 next-env.d.ts
 dev
 
-public/prompts.json
-
 .vscode
-.idea
+.idea
+
+# docker-compose env files
+.env
+
+*.key
+*.key.pub

+ 3 - 0
Dockerfile

@@ -41,6 +41,7 @@ COPY --from=builder /app/.next/server ./.next/server
 EXPOSE 3000
 
 CMD if [ -n "$PROXY_URL" ]; then \
+        export HOSTNAME="127.0.0.1"; \
         protocol=$(echo $PROXY_URL | cut -d: -f1); \
         host=$(echo $PROXY_URL | cut -d/ -f3 | cut -d: -f1); \
         port=$(echo $PROXY_URL | cut -d: -f3); \
@@ -50,6 +51,8 @@ CMD if [ -n "$PROXY_URL" ]; then \
         echo "remote_dns_subnet 224" >> $conf; \
         echo "tcp_read_time_out 15000" >> $conf; \
         echo "tcp_connect_time_out 8000" >> $conf; \
+        echo "localnet 127.0.0.0/255.0.0.0" >> $conf; \
+        echo "localnet ::1/128" >> $conf; \
         echo "[ProxyList]" >> $conf; \
         echo "$protocol $host $port" >> $conf; \
         cat /etc/proxychains.conf; \

+ 40 - 22
README.md

@@ -5,13 +5,30 @@
 
 English / [简体中文](./README_CN.md)
 
-One-Click to deploy well-designed ChatGPT web UI on Vercel.
+One-Click to get well-designed cross-platform ChatGPT web UI.
 
-一键免费部署你的私人 ChatGPT 网页应用。
+一键免费部署你的跨平台私人 ChatGPT 应用。
 
-[Demo](https://chatgpt.nextweb.fun/) / [Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [Join Discord](https://discord.gg/zrhvHCr79N) / [Buy Me a Coffee](https://www.buymeacoffee.com/yidadaa)
+[![Web][Web-image]][web-url]
+[![Windows][Windows-image]][download-url]
+[![MacOS][MacOS-image]][download-url]
+[![Linux][Linux-image]][download-url]
 
-[演示](https://chatgpt.nextweb.fun/) / [反馈](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [QQ 群](https://user-images.githubusercontent.com/16968934/236402186-fa76e930-64f5-47ae-b967-b0f04b1fbf56.jpg) / [打赏开发者](https://user-images.githubusercontent.com/16968934/227772541-5bcd52d8-61b7-488c-a203-0330d8006e2b.jpg)
+[Web App](https://chatgpt.nextweb.fun/) / [Desktop App](https://github.com/Yidadaa/ChatGPT-Next-Web/releases) / [Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [Buy Me a Coffee](https://www.buymeacoffee.com/yidadaa)
+
+[网页版](https://chatgpt.nextweb.fun/) / [客户端](https://github.com/Yidadaa/ChatGPT-Next-Web/releases) / [反馈](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [QQ 群](https://github.com/Yidadaa/ChatGPT-Next-Web/discussions/1724) / [打赏开发者](https://user-images.githubusercontent.com/16968934/227772541-5bcd52d8-61b7-488c-a203-0330d8006e2b.jpg)
+
+[web-url]: https://chatgpt.nextweb.fun
+   
+[download-url]: https://github.com/Yidadaa/ChatGPT-Next-Web/releases
+
+[Web-image]: https://img.shields.io/badge/Web-PWA-orange?logo=microsoftedge
+
+[Windows-image]: https://img.shields.io/badge/-Windows-blue?logo=windows
+
+[MacOS-image]: https://img.shields.io/badge/-MacOS-black?logo=apple
+
+[Linux-image]: https://img.shields.io/badge/-Linux-333?logo=ubuntu
 
 [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web)
 
@@ -24,6 +41,8 @@ One-Click to deploy well-designed ChatGPT web UI on Vercel.
 ## Features
 
 - **Deploy for free with one-click** on Vercel in under 1 minute
+- Compact client (~5MB) on Linux/Windows/MacOS, [download it now](https://github.com/Yidadaa/ChatGPT-Next-Web/releases)
+- Fully compatible with self-deployed llms, recommended for use with [RWKV-Runner](https://github.com/josStorer/RWKV-Runner) or [LocalAI](https://github.com/go-skynet/LocalAI)
 - Privacy first, all data stored locally in the browser
 - Markdown support: LaTex, mermaid, code highlight, etc.
 - Responsive design, dark mode and PWA
@@ -31,30 +50,28 @@ One-Click to deploy well-designed ChatGPT web UI on Vercel.
 - New in v2: create, share and debug your chat tools with prompt templates (mask)
 - Awesome prompts powered by [awesome-chatgpt-prompts-zh](https://github.com/PlexPt/awesome-chatgpt-prompts-zh) and [awesome-chatgpt-prompts](https://github.com/f/awesome-chatgpt-prompts)
 - Automatically compresses chat history to support long conversations while also saving your tokens
-- I18n: English, 简体中文, 繁体中文, 日本語, Español, Italiano, Türkçe, Deutsch
+- I18n: English, 简体中文, 繁体中文, 日本語, Français, Español, Italiano, Türkçe, Deutsch, Tiếng Việt, Русский, Čeština, 한국어
 
 ## Roadmap
 
 - [x] System Prompt: pin a user defined prompt as system prompt [#138](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)
 - [x] User Prompt: user can edit and save custom prompts to prompt list
 - [x] Prompt Template: create a new chat with pre-defined in-context prompts [#993](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/993)
-- [ ] Share as image, share to ShareGPT
-- [ ] Desktop App with tauri
-- [ ] Self-host Model: support llama, alpaca, ChatGLM, BELLE etc.
+- [x] Share as image, share to ShareGPT [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741)
+- [x] Desktop App with tauri
+- [x] Self-host Model: Fully compatible with [RWKV-Runner](https://github.com/josStorer/RWKV-Runner), as well as server deployment of [LocalAI](https://github.com/go-skynet/LocalAI): llama/gpt4all/rwkv/vicuna/koala/gpt4all-j/cerebras/falcon/dolly etc.
 - [ ] Plugins: support network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
 
-### Not in Plan
-
-- User login, accounts, cloud sync
-- UI text customize
-
 ## What's New
 
 - 🚀 v2.0 is released, now you can create prompt templates, turn your ideas into reality! Read this: [ChatGPT Prompt Engineering Tips: Zero, One and Few Shot Prompting](https://www.allabtai.com/prompt-engineering-tips-zero-one-and-few-shot-prompting/).
+- 🚀 v2.7 let's share conversations as image, or share to ShareGPT!
+- 🚀 v2.8 now we have a client that runs across all platforms!
 
 ## 主要功能
 
 - 在 1 分钟内使用 Vercel **免费一键部署**
+- 提供体积极小(~5MB)的跨平台客户端(Linux/Windows/MacOS), [下载地址](https://github.com/Yidadaa/ChatGPT-Next-Web/releases)
 - 完整的 Markdown 支持:LaTex 公式、Mermaid 流程图、代码高亮等等
 - 精心设计的 UI,响应式设计,支持深色模式,支持 PWA
 - 极快的首屏加载速度(~100kb),支持流式响应
@@ -62,7 +79,7 @@ One-Click to deploy well-designed ChatGPT web UI on Vercel.
 - 预制角色功能(面具),方便地创建、分享和调试你的个性化对话
 - 海量的内置 prompt 列表,来自[中文](https://github.com/PlexPt/awesome-chatgpt-prompts-zh)和[英文](https://github.com/f/awesome-chatgpt-prompts)
 - 自动压缩上下文聊天记录,在节省 Token 的同时支持超长对话
-- 多国语言支持:English, 简体中文, 繁体中文, 日本語, Español, Italiano, Türkçe, Deutsch
+- 多国语言支持:English, 简体中文, 繁体中文, 日本語, Español, Italiano, Türkçe, Deutsch, Tiếng Việt, Русский, Čeština
 - 拥有自己的域名?好上加好,绑定后即可在任何地方**无障碍**快速访问
 
 ## 开发计划
@@ -70,20 +87,17 @@ One-Click to deploy well-designed ChatGPT web UI on Vercel.
 - [x] 为每个对话设置系统 Prompt [#138](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)
 - [x] 允许用户自行编辑内置 Prompt 列表
 - [x] 预制角色:使用预制角色快速定制新对话 [#993](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/993)
-- [ ] 分享为图片,分享到 ShareGPT
-- [ ] 使用 tauri 打包桌面应用
-- [ ] 支持自部署的大语言模型
+- [x] 分享为图片,分享到 ShareGPT 链接 [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741)
+- [x] 使用 tauri 打包桌面应用
+- [x] 支持自部署的大语言模型:开箱即用 [RWKV-Runner](https://github.com/josStorer/RWKV-Runner) ,服务端部署 [LocalAI 项目](https://github.com/go-skynet/LocalAI) llama / gpt4all / rwkv / vicuna / koala / gpt4all-j / cerebras / falcon / dolly 等等
 - [ ] 插件机制,支持联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
 
-### 不会开发的功能
-
-- 界面文字自定义
-- 用户登录、账号管理、消息云同步
-
 ## 最新动态
 
 - 🚀 v2.0 已经发布,现在你可以使用面具功能快速创建预制对话了! 了解更多: [ChatGPT 提示词高阶技能:零次、一次和少样本提示](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)。
 - 💡 想要更方便地随时随地使用本项目?可以试下这款桌面插件:https://github.com/mushan0x0/AI0x0.com
+- 🚀 v2.7 现在可以将会话分享为图片了,也可以分享到 ShareGPT 的在线链接。
+- 🚀 v2.8 发布了横跨 Linux/Windows/MacOS 的体积极小的客户端。
 
 ## Get Started
 
@@ -186,6 +200,9 @@ Before starting development, you must create a new `.env.local` file at project
 
 ```
 OPENAI_API_KEY=<your api key here>
+
+# if you are not able to access openai service, use this BASE_URL
+BASE_URL=https://chatgpt1.nextweb.fun/api/proxy
 ```
 
 ### Local Development
@@ -265,6 +282,7 @@ bash <(curl -s https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/s
 [@jhansion](https://github.com/jhansion)
 [@Sha1rholder](https://github.com/Sha1rholder)
 [@AnsonHyq](https://github.com/AnsonHyq)
+[@synwith](https://github.com/synwith)
 
 ### Contributor
 

+ 3 - 2
README_CN.md

@@ -100,8 +100,6 @@ OpenAI 接口代理 URL,如果你手动配置了 openai 接口代理,请填
 
 ## 开发
 
-> 强烈不建议在本地进行开发或者部署,由于一些技术原因,很难在本地配置好 OpenAI API 代理,除非你能保证可以直连 OpenAI 服务器。
-
 点击下方按钮,开始二次开发:
 
 [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
@@ -110,6 +108,9 @@ OpenAI 接口代理 URL,如果你手动配置了 openai 接口代理,请填
 
 ```
 OPENAI_API_KEY=<your api key here>
+
+# 中国大陆用户,可以使用本项目自带的代理进行开发,你也可以自由选择其他代理地址
+BASE_URL=https://chatgpt1.nextweb.fun/api/proxy
 ```
 
 ### 本地开发

+ 171 - 0
README_ES.md

@@ -0,0 +1,171 @@
+<div align="center">
+<img src="./docs/images/icon.svg" alt="预览"/>
+
+<h1 align="center">ChatGPT Next Web</h1>
+
+Implemente su aplicación web privada ChatGPT de forma gratuita con un solo clic.
+
+[Demo demo](https://chat-gpt-next-web.vercel.app/) / [Problemas de comentarios](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [Únete a Discord](https://discord.gg/zrhvHCr79N) / [Grupo QQ](https://user-images.githubusercontent.com/16968934/228190818-7dd00845-e9b9-4363-97e5-44c507ac76da.jpeg) / [Desarrolladores de consejos](https://user-images.githubusercontent.com/16968934/227772541-5bcd52d8-61b7-488c-a203-0330d8006e2b.jpg) / [Donar](#捐赠-donate-usdt)
+
+[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web\&env=OPENAI_API_KEY\&env=CODE\&project-name=chatgpt-next-web\&repository-name=ChatGPT-Next-Web)
+
+[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
+
+![主界面](./docs/images/cover.png)
+
+</div>
+
+## Comenzar
+
+1.  Prepara el tuyo [Clave API OpenAI](https://platform.openai.com/account/api-keys);
+2.  Haga clic en el botón de la derecha para iniciar la implementación:
+    [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web\&env=OPENAI_API_KEY\&env=CODE\&project-name=chatgpt-next-web\&repository-name=ChatGPT-Next-Web), inicie sesión directamente con su cuenta de Github y recuerde completar la clave API y la suma en la página de variables de entorno[Contraseña de acceso a la página](#配置页面访问密码) CÓDIGO;
+3.  Una vez implementado, puede comenzar;
+4.  (Opcional)[Enlazar un nombre de dominio personalizado](https://vercel.com/docs/concepts/projects/domains/add-a-domain): El nombre de dominio DNS asignado por Vercel está contaminado en algunas regiones y puede conectarse directamente enlazando un nombre de dominio personalizado.
+
+## Manténgase actualizado
+
+Si sigue los pasos anteriores para implementar su proyecto con un solo clic, es posible que siempre diga "La actualización existe" porque Vercel creará un nuevo proyecto para usted de forma predeterminada en lugar de bifurcar el proyecto, lo que evitará que la actualización se detecte correctamente.
+Le recomendamos que siga estos pasos para volver a implementar:
+
+*   Eliminar el repositorio original;
+*   Utilice el botón de bifurcación en la esquina superior derecha de la página para bifurcar este proyecto;
+*   En Vercel, vuelva a seleccionar e implementar,[Echa un vistazo al tutorial detallado](./docs/vercel-cn.md#如何新建项目)。
+
+### Activar actualizaciones automáticas
+
+> Si encuentra un error de ejecución de Upstream Sync, ¡Sync Fork manualmente una vez!
+
+Cuando bifurca el proyecto, debido a las limitaciones de Github, debe ir manualmente a la página Acciones de su proyecto bifurcado para habilitar Flujos de trabajo y habilitar Upstream Sync Action, después de habilitarlo, puede activar las actualizaciones automáticas cada hora:
+
+![自动更新](./docs/images/enable-actions.jpg)
+
+![启用自动更新](./docs/images/enable-actions-sync.jpg)
+
+### Actualizar el código manualmente
+
+Si desea que el manual se actualice inmediatamente, puede consultarlo [Documentación para Github](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork) Aprenda a sincronizar un proyecto bifurcado con código ascendente.
+
+Puede destacar / ver este proyecto o seguir al autor para recibir notificaciones de nuevas actualizaciones de funciones.
+
+## Configurar la contraseña de acceso a la página
+
+> Después de configurar la contraseña, el usuario debe completar manualmente el código de acceso en la página de configuración para chatear normalmente, de lo contrario, se solicitará el estado no autorizado a través de un mensaje.
+
+> **advertir**: Asegúrese de establecer el número de dígitos de la contraseña lo suficientemente largo, preferiblemente más de 7 dígitos, de lo contrario[Será volado](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/518)。
+
+Este proyecto proporciona control de permisos limitado, agregue el nombre al nombre en la página Variables de entorno del Panel de control del proyecto Vercel `CODE` Variables de entorno con valores para contraseñas personalizadas separadas por comas:
+
+    code1,code2,code3
+
+Después de agregar o modificar la variable de entorno, por favor**Redesplegar**proyecto para poner en vigor los cambios.
+
+## Variable de entorno
+
+> La mayoría de los elementos de configuración de este proyecto se establecen a través de variables de entorno, tutorial:[Cómo modificar las variables de entorno de Vercel](./docs/vercel-cn.md)。
+
+### `OPENAI_API_KEY` (Requerido)
+
+OpanAI key, la clave API que solicita en la página de su cuenta openai.
+
+### `CODE` (Opcional)
+
+Las contraseñas de acceso, opcionalmente, se pueden separar por comas.
+
+**advertir**: Si no completa este campo, cualquiera puede usar directamente su sitio web implementado, lo que puede hacer que su token se consuma rápidamente, se recomienda completar esta opción.
+
+### `BASE_URL` (Opcional)
+
+> Predeterminado: `https://api.openai.com`
+
+> Ejemplos: `http://your-openai-proxy.com`
+
+URL del proxy de interfaz OpenAI, complete esta opción si configuró manualmente el proxy de interfaz openAI.
+
+> Si encuentra problemas con el certificado SSL, establezca el `BASE_URL` El protocolo se establece en http.
+
+### `OPENAI_ORG_ID` (Opcional)
+
+Especifica el identificador de la organización en OpenAI.
+
+### `HIDE_USER_API_KEY` (Opcional)
+
+Si no desea que los usuarios rellenen la clave de API ellos mismos, establezca esta variable de entorno en 1.
+
+### `DISABLE_GPT4` (Opcional)
+
+Si no desea que los usuarios utilicen GPT-4, establezca esta variable de entorno en 1.
+
+## explotación
+
+> No se recomienda encarecidamente desarrollar o implementar localmente, debido a algunas razones técnicas, es difícil configurar el agente API de OpenAI localmente, a menos que pueda asegurarse de que puede conectarse directamente al servidor OpenAI.
+
+Haga clic en el botón de abajo para iniciar el desarrollo secundario:
+
+[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
+
+Antes de empezar a escribir código, debe crear uno nuevo en la raíz del proyecto `.env.local` archivo, lleno de variables de entorno:
+
+    OPENAI_API_KEY=<your api key here>
+
+### Desarrollo local
+
+1.  Instale nodejs 18 e hilo, pregunte a ChatGPT para obtener más detalles;
+2.  ejecutar `yarn install && yarn dev` Enlatar. ⚠️ Nota: Este comando es solo para desarrollo local, no para implementación.
+3.  Úselo si desea implementar localmente `yarn install && yarn start` comando, puede cooperar con pm2 a daemon para evitar ser asesinado, pregunte a ChatGPT para obtener más detalles.
+
+## desplegar
+
+### Implementación de contenedores (recomendado)
+
+> La versión de Docker debe ser 20 o posterior, de lo contrario se indicará que no se puede encontrar la imagen.
+
+> ⚠️ Nota: Las versiones de Docker están de 1 a 2 días por detrás de la última versión la mayor parte del tiempo, por lo que es normal que sigas diciendo "La actualización existe" después de la implementación.
+
+```shell
+docker pull yidadaa/chatgpt-next-web
+
+docker run -d -p 3000:3000 \
+   -e OPENAI_API_KEY="sk-xxxx" \
+   -e CODE="页面访问密码" \
+   yidadaa/chatgpt-next-web
+```
+
+También puede especificar proxy:
+
+```shell
+docker run -d -p 3000:3000 \
+   -e OPENAI_API_KEY="sk-xxxx" \
+   -e CODE="页面访问密码" \
+   --net=host \
+   -e PROXY_URL="http://127.0.0.1:7890" \
+   yidadaa/chatgpt-next-web
+```
+
+Si necesita especificar otras variables de entorno, agréguelas usted mismo en el comando anterior `-e 环境变量=环境变量值` para especificar.
+
+### Implementación local
+
+Ejecute el siguiente comando en la consola:
+
+```shell
+bash <(curl -s https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/scripts/setup.sh)
+```
+
+⚠️ Nota: Si tiene problemas durante la instalación, utilice la implementación de Docker.
+
+## Reconocimiento
+
+### donante
+
+> Ver versión en inglés.
+
+### Colaboradores
+
+[Ver la lista de colaboradores del proyecto](https://github.com/Yidadaa/ChatGPT-Next-Web/graphs/contributors)
+
+## Licencia de código abierto
+
+> Contra 996, empezando por mí.
+
+[Licencia Anti 996](https://github.com/kattgu7/Anti-996-License/blob/master/LICENSE_CN_EN)

+ 2 - 8
app/api/auth.ts

@@ -3,8 +3,6 @@ import { getServerSideConfig } from "../config/server";
 import md5 from "spark-md5";
 import { ACCESS_CODE_PREFIX } from "../constant";
 
-const serverConfig = getServerSideConfig();
-
 function getIP(req: NextRequest) {
   let ip = req.ip ?? req.headers.get("x-real-ip");
   const forwardedFor = req.headers.get("x-forwarded-for");
@@ -34,6 +32,7 @@ export function auth(req: NextRequest) {
 
   const hashedCode = md5.hash(accessCode ?? "").trim();
 
+  const serverConfig = getServerSideConfig();
   console.log("[Auth] allowed hashed codes: ", [...serverConfig.codes]);
   console.log("[Auth] got access code:", accessCode);
   console.log("[Auth] hashed access code:", hashedCode);
@@ -43,8 +42,7 @@ export function auth(req: NextRequest) {
   if (serverConfig.needCode && !serverConfig.codes.has(hashedCode) && !token) {
     return {
       error: true,
-      needAccessCode: true,
-      msg: "Please go settings page and fill your access code.",
+      msg: !accessCode ? "empty access code" : "wrong access code",
     };
   }
 
@@ -56,10 +54,6 @@ export function auth(req: NextRequest) {
       req.headers.set("Authorization", `Bearer ${apiKey}`);
     } else {
       console.log("[Auth] admin did not provide an api key");
-      return {
-        error: true,
-        msg: "Empty Api Key",
-      };
     }
   } else {
     console.log("[Auth] use user api key");

+ 54 - 7
app/api/common.ts

@@ -1,11 +1,13 @@
-import { NextRequest } from "next/server";
+import { NextRequest, NextResponse } from "next/server";
 
-const OPENAI_URL = "api.openai.com";
+export const OPENAI_URL = "api.openai.com";
 const DEFAULT_PROTOCOL = "https";
 const PROTOCOL = process.env.PROTOCOL ?? DEFAULT_PROTOCOL;
 const BASE_URL = process.env.BASE_URL ?? OPENAI_URL;
+const DISABLE_GPT4 = !!process.env.DISABLE_GPT4;
 
 export async function requestOpenai(req: NextRequest) {
+  const controller = new AbortController();
   const authValue = req.headers.get("Authorization") ?? "";
   const openaiPath = `${req.nextUrl.pathname}${req.nextUrl.search}`.replaceAll(
     "/api/openai/",
@@ -25,11 +27,12 @@ export async function requestOpenai(req: NextRequest) {
     console.log("[Org ID]", process.env.OPENAI_ORG_ID);
   }
 
-  if (!authValue || !authValue.startsWith("Bearer sk-")) {
-    console.error("[OpenAI Request] invalid api key provided", authValue);
-  }
+  const timeoutId = setTimeout(() => {
+    controller.abort();
+  }, 10 * 60 * 1000);
 
-  return fetch(`${baseUrl}/${openaiPath}`, {
+  const fetchUrl = `${baseUrl}/${openaiPath}`;
+  const fetchOptions: RequestInit = {
     headers: {
       "Content-Type": "application/json",
       Authorization: authValue,
@@ -40,5 +43,49 @@ export async function requestOpenai(req: NextRequest) {
     cache: "no-store",
     method: req.method,
     body: req.body,
-  });
+    signal: controller.signal,
+  };
+
+  // #1815 try to refuse gpt4 request
+  if (DISABLE_GPT4 && req.body) {
+    try {
+      const clonedBody = await req.text();
+      fetchOptions.body = clonedBody;
+
+      const jsonBody = JSON.parse(clonedBody);
+
+      if ((jsonBody?.model ?? "").includes("gpt-4")) {
+        return NextResponse.json(
+          {
+            error: true,
+            message: "you are not allowed to use gpt-4 model",
+          },
+          {
+            status: 403,
+          },
+        );
+      }
+    } catch (e) {
+      console.error("[OpenAI] gpt4 filter", e);
+    }
+  }
+
+  try {
+    const res = await fetch(fetchUrl, fetchOptions);
+
+    // to prevent browser prompt for credentials
+    const newHeaders = new Headers(res.headers);
+    newHeaders.delete("www-authenticate");
+
+    // to disbale ngnix buffering
+    newHeaders.set("X-Accel-Buffering", "no");
+
+    return new Response(res.body, {
+      status: res.status,
+      statusText: res.statusText,
+      headers: newHeaders,
+    });
+  } finally {
+    clearTimeout(timeoutId);
+  }
 }

+ 24 - 73
app/api/openai/[...path]/route.ts

@@ -1,48 +1,10 @@
-import { createParser } from "eventsource-parser";
+import { OpenaiPath } from "@/app/constant";
+import { prettyObject } from "@/app/utils/format";
 import { NextRequest, NextResponse } from "next/server";
 import { auth } from "../../auth";
 import { requestOpenai } from "../../common";
 
-async function createStream(res: Response) {
-  const encoder = new TextEncoder();
-  const decoder = new TextDecoder();
-
-  const stream = new ReadableStream({
-    async start(controller) {
-      function onParse(event: any) {
-        if (event.type === "event") {
-          const data = event.data;
-          // https://beta.openai.com/docs/api-reference/completions/create#completions/create-stream
-          if (data === "[DONE]") {
-            controller.close();
-            return;
-          }
-          try {
-            const json = JSON.parse(data);
-            const text = json.choices[0].delta.content;
-            const queue = encoder.encode(text);
-            controller.enqueue(queue);
-          } catch (e) {
-            controller.error(e);
-          }
-        }
-      }
-
-      const parser = createParser(onParse);
-      for await (const chunk of res.body as any) {
-        parser.feed(decoder.decode(chunk, { stream: true }));
-      }
-    },
-  });
-  return stream;
-}
-
-function formatResponse(msg: any) {
-  const jsonMsg = ["```json\n", JSON.stringify(msg, null, "  "), "\n```"].join(
-    "",
-  );
-  return new Response(jsonMsg);
-}
+const ALLOWD_PATH = new Set(Object.values(OpenaiPath));
 
 async function handle(
   req: NextRequest,
@@ -50,6 +12,25 @@ async function handle(
 ) {
   console.log("[OpenAI Route] params ", params);
 
+  if (req.method === "OPTIONS") {
+    return NextResponse.json({ body: "OK" }, { status: 200 });
+  }
+
+  const subpath = params.path.join("/");
+
+  if (!ALLOWD_PATH.has(subpath)) {
+    console.log("[OpenAI Route] forbidden path ", subpath);
+    return NextResponse.json(
+      {
+        error: true,
+        msg: "you are not allowed to request " + subpath,
+      },
+      {
+        status: 403,
+      },
+    );
+  }
+
   const authResult = auth(req);
   if (authResult.error) {
     return NextResponse.json(authResult, {
@@ -58,40 +39,10 @@ async function handle(
   }
 
   try {
-    const api = await requestOpenai(req);
-
-    const contentType = api.headers.get("Content-Type") ?? "";
-
-    // streaming response
-    if (contentType.includes("stream")) {
-      const stream = await createStream(api);
-      const res = new Response(stream);
-      res.headers.set("Content-Type", contentType);
-      return res;
-    }
-
-    // try to parse error msg
-    try {
-      const mayBeErrorBody = await api.json();
-      if (mayBeErrorBody.error) {
-        console.error("[OpenAI Response] ", mayBeErrorBody);
-        return formatResponse(mayBeErrorBody);
-      } else {
-        const res = new Response(JSON.stringify(mayBeErrorBody));
-        res.headers.set("Content-Type", "application/json");
-        res.headers.set("Cache-Control", "no-cache");
-        return res;
-      }
-    } catch (e) {
-      console.error("[OpenAI Parse] ", e);
-      return formatResponse({
-        msg: "invalid response from openai server",
-        error: e,
-      });
-    }
+    return await requestOpenai(req);
   } catch (e) {
     console.error("[OpenAI] ", e);
-    return formatResponse(e);
+    return NextResponse.json(prettyObject(e));
   }
 }
 

+ 0 - 9
app/api/openai/typing.ts

@@ -1,9 +0,0 @@
-import type {
-  CreateChatCompletionRequest,
-  CreateChatCompletionResponse,
-} from "openai";
-
-export type ChatRequest = CreateChatCompletionRequest;
-export type ChatResponse = CreateChatCompletionResponse;
-
-export type Updater<T> = (updater: (value: T) => void) => void;

+ 145 - 0
app/client/api.ts

@@ -0,0 +1,145 @@
+import { getClientConfig } from "../config/client";
+import { ACCESS_CODE_PREFIX } from "../constant";
+import { ChatMessage, ModelType, useAccessStore } from "../store";
+import { ChatGPTApi } from "./platforms/openai";
+
+export const ROLES = ["system", "user", "assistant"] as const;
+export type MessageRole = (typeof ROLES)[number];
+
+export const Models = ["gpt-3.5-turbo", "gpt-4"] as const;
+export type ChatModel = ModelType;
+
+export interface RequestMessage {
+  role: MessageRole;
+  content: string;
+}
+
+export interface LLMConfig {
+  model: string;
+  temperature?: number;
+  top_p?: number;
+  stream?: boolean;
+  presence_penalty?: number;
+  frequency_penalty?: number;
+}
+
+export interface ChatOptions {
+  messages: RequestMessage[];
+  config: LLMConfig;
+
+  onUpdate?: (message: string, chunk: string) => void;
+  onFinish: (message: string) => void;
+  onError?: (err: Error) => void;
+  onController?: (controller: AbortController) => void;
+}
+
+export interface LLMUsage {
+  used: number;
+  total: number;
+}
+
+export abstract class LLMApi {
+  abstract chat(options: ChatOptions): Promise<void>;
+  abstract usage(): Promise<LLMUsage>;
+}
+
+type ProviderName = "openai" | "azure" | "claude" | "palm";
+
+interface Model {
+  name: string;
+  provider: ProviderName;
+  ctxlen: number;
+}
+
+interface ChatProvider {
+  name: ProviderName;
+  apiConfig: {
+    baseUrl: string;
+    apiKey: string;
+    summaryModel: Model;
+  };
+  models: Model[];
+
+  chat: () => void;
+  usage: () => void;
+}
+
+export class ClientApi {
+  public llm: LLMApi;
+
+  constructor() {
+    this.llm = new ChatGPTApi();
+  }
+
+  config() {}
+
+  prompts() {}
+
+  masks() {}
+
+  async share(messages: ChatMessage[], avatarUrl: string | null = null) {
+    const msgs = messages
+      .map((m) => ({
+        from: m.role === "user" ? "human" : "gpt",
+        value: m.content,
+      }))
+      .concat([
+        {
+          from: "human",
+          value:
+            "Share from [ChatGPT Next Web]: https://github.com/Yidadaa/ChatGPT-Next-Web",
+        },
+      ]);
+    // 敬告二开开发者们,为了开源大模型的发展,请不要修改上述消息,此消息用于后续数据清洗使用
+    // Please do not modify this message
+
+    console.log("[Share]", msgs);
+    const clientConfig = getClientConfig();
+    const proxyUrl = "/sharegpt";
+    const rawUrl = "https://sharegpt.com/api/conversations";
+    const shareUrl = clientConfig?.isApp ? rawUrl : proxyUrl;
+    const res = await fetch(shareUrl, {
+      body: JSON.stringify({
+        avatarUrl,
+        items: msgs,
+      }),
+      headers: {
+        "Content-Type": "application/json",
+      },
+      method: "POST",
+    });
+
+    const resJson = await res.json();
+    console.log("[Share]", resJson);
+    if (resJson.id) {
+      return `https://shareg.pt/${resJson.id}`;
+    }
+  }
+}
+
+export const api = new ClientApi();
+
+export function getHeaders() {
+  const accessStore = useAccessStore.getState();
+  let headers: Record<string, string> = {
+    "Content-Type": "application/json",
+    "x-requested-with": "XMLHttpRequest",
+  };
+
+  const makeBearer = (token: string) => `Bearer ${token.trim()}`;
+  const validString = (x: string) => x && x.length > 0;
+
+  // use user's api key first
+  if (validString(accessStore.token)) {
+    headers.Authorization = makeBearer(accessStore.token);
+  } else if (
+    accessStore.enabledAccessControl() &&
+    validString(accessStore.accessCode)
+  ) {
+    headers.Authorization = makeBearer(
+      ACCESS_CODE_PREFIX + accessStore.accessCode,
+    );
+  }
+
+  return headers;
+}

+ 37 - 0
app/client/controller.ts

@@ -0,0 +1,37 @@
+// To store message streaming controller
+export const ChatControllerPool = {
+  controllers: {} as Record<string, AbortController>,
+
+  addController(
+    sessionIndex: number,
+    messageId: number,
+    controller: AbortController,
+  ) {
+    const key = this.key(sessionIndex, messageId);
+    this.controllers[key] = controller;
+    return key;
+  },
+
+  stop(sessionIndex: number, messageId: number) {
+    const key = this.key(sessionIndex, messageId);
+    const controller = this.controllers[key];
+    controller?.abort();
+  },
+
+  stopAll() {
+    Object.values(this.controllers).forEach((v) => v.abort());
+  },
+
+  hasPending() {
+    return Object.values(this.controllers).length > 0;
+  },
+
+  remove(sessionIndex: number, messageId: number) {
+    const key = this.key(sessionIndex, messageId);
+    delete this.controllers[key];
+  },
+
+  key(sessionIndex: number, messageIndex: number) {
+    return `${sessionIndex},${messageIndex}`;
+  },
+};

+ 227 - 0
app/client/platforms/openai.ts

@@ -0,0 +1,227 @@
+import { OpenaiPath, REQUEST_TIMEOUT_MS } from "@/app/constant";
+import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
+
+import { ChatOptions, getHeaders, LLMApi, LLMUsage } from "../api";
+import Locale from "../../locales";
+import {
+  EventStreamContentType,
+  fetchEventSource,
+} from "@fortaine/fetch-event-source";
+import { prettyObject } from "@/app/utils/format";
+
+export class ChatGPTApi implements LLMApi {
+  path(path: string): string {
+    let openaiUrl = useAccessStore.getState().openaiUrl;
+    if (openaiUrl.endsWith("/")) {
+      openaiUrl = openaiUrl.slice(0, openaiUrl.length - 1);
+    }
+    return [openaiUrl, path].join("/");
+  }
+
+  extractMessage(res: any) {
+    return res.choices?.at(0)?.message?.content ?? "";
+  }
+
+  async chat(options: ChatOptions) {
+    const messages = options.messages.map((v) => ({
+      role: v.role,
+      content: v.content,
+    }));
+
+    const modelConfig = {
+      ...useAppConfig.getState().modelConfig,
+      ...useChatStore.getState().currentSession().mask.modelConfig,
+      ...{
+        model: options.config.model,
+      },
+    };
+
+    const requestPayload = {
+      messages,
+      stream: options.config.stream,
+      model: modelConfig.model,
+      temperature: modelConfig.temperature,
+      presence_penalty: modelConfig.presence_penalty,
+    };
+
+    console.log("[Request] openai payload: ", requestPayload);
+
+    const shouldStream = !!options.config.stream;
+    const controller = new AbortController();
+    options.onController?.(controller);
+
+    try {
+      const chatPath = this.path(OpenaiPath.ChatPath);
+      const chatPayload = {
+        method: "POST",
+        body: JSON.stringify(requestPayload),
+        signal: controller.signal,
+        headers: getHeaders(),
+      };
+
+      // make a fetch request
+      const requestTimeoutId = setTimeout(
+        () => controller.abort(),
+        REQUEST_TIMEOUT_MS,
+      );
+
+      if (shouldStream) {
+        let responseText = "";
+        let finished = false;
+
+        const finish = () => {
+          if (!finished) {
+            options.onFinish(responseText);
+            finished = true;
+          }
+        };
+
+        controller.signal.onabort = finish;
+
+        fetchEventSource(chatPath, {
+          ...chatPayload,
+          async onopen(res) {
+            clearTimeout(requestTimeoutId);
+            const contentType = res.headers.get("content-type");
+            console.log(
+              "[OpenAI] request response content type: ",
+              contentType,
+            );
+
+            if (contentType?.startsWith("text/plain")) {
+              responseText = await res.clone().text();
+              return finish();
+            }
+
+            if (
+              !res.ok ||
+              !res.headers
+                .get("content-type")
+                ?.startsWith(EventStreamContentType) ||
+              res.status !== 200
+            ) {
+              const responseTexts = [responseText];
+              let extraInfo = await res.clone().text();
+              try {
+                const resJson = await res.clone().json();
+                extraInfo = prettyObject(resJson);
+              } catch {}
+
+              if (res.status === 401) {
+                responseTexts.push(Locale.Error.Unauthorized);
+              }
+
+              if (extraInfo) {
+                responseTexts.push(extraInfo);
+              }
+
+              responseText = responseTexts.join("\n\n");
+
+              return finish();
+            }
+          },
+          onmessage(msg) {
+            if (msg.data === "[DONE]" || finished) {
+              return finish();
+            }
+            const text = msg.data;
+            try {
+              const json = JSON.parse(text);
+              const delta = json.choices[0].delta.content;
+              if (delta) {
+                responseText += delta;
+                options.onUpdate?.(responseText, delta);
+              }
+            } catch (e) {
+              console.error("[Request] parse error", text, msg);
+            }
+          },
+          onclose() {
+            finish();
+          },
+          onerror(e) {
+            options.onError?.(e);
+            throw e;
+          },
+          openWhenHidden: true,
+        });
+      } else {
+        const res = await fetch(chatPath, chatPayload);
+        clearTimeout(requestTimeoutId);
+
+        const resJson = await res.json();
+        const message = this.extractMessage(resJson);
+        options.onFinish(message);
+      }
+    } catch (e) {
+      console.log("[Request] failed to make a chat reqeust", e);
+      options.onError?.(e as Error);
+    }
+  }
+  async usage() {
+    const formatDate = (d: Date) =>
+      `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, "0")}-${d
+        .getDate()
+        .toString()
+        .padStart(2, "0")}`;
+    const ONE_DAY = 1 * 24 * 60 * 60 * 1000;
+    const now = new Date();
+    const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
+    const startDate = formatDate(startOfMonth);
+    const endDate = formatDate(new Date(Date.now() + ONE_DAY));
+
+    const [used, subs] = await Promise.all([
+      fetch(
+        this.path(
+          `${OpenaiPath.UsagePath}?start_date=${startDate}&end_date=${endDate}`,
+        ),
+        {
+          method: "GET",
+          headers: getHeaders(),
+        },
+      ),
+      fetch(this.path(OpenaiPath.SubsPath), {
+        method: "GET",
+        headers: getHeaders(),
+      }),
+    ]);
+
+    if (used.status === 401) {
+      throw new Error(Locale.Error.Unauthorized);
+    }
+
+    if (!used.ok || !subs.ok) {
+      throw new Error("Failed to query usage from openai");
+    }
+
+    const response = (await used.json()) as {
+      total_usage?: number;
+      error?: {
+        type: string;
+        message: string;
+      };
+    };
+
+    const total = (await subs.json()) as {
+      hard_limit_usd?: number;
+    };
+
+    if (response.error && response.error.type) {
+      throw Error(response.error.message);
+    }
+
+    if (response.total_usage) {
+      response.total_usage = Math.round(response.total_usage) / 100;
+    }
+
+    if (total.hard_limit_usd) {
+      total.hard_limit_usd = Math.round(total.hard_limit_usd * 100) / 100;
+    }
+
+    return {
+      used: response.total_usage,
+      total: total.hard_limit_usd,
+    } as LLMUsage;
+  }
+}
+export { OpenaiPath };

+ 36 - 0
app/components/auth.module.scss

@@ -0,0 +1,36 @@
+.auth-page {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  height: 100%;
+  width: 100%;
+  flex-direction: column;
+
+  .auth-logo {
+    transform: scale(1.4);
+  }
+
+  .auth-title {
+    font-size: 24px;
+    font-weight: bold;
+    line-height: 2;
+  }
+
+  .auth-tips {
+    font-size: 14px;
+  }
+
+  .auth-input {
+    margin: 3vh 0;
+  }
+
+  .auth-actions {
+    display: flex;
+    justify-content: center;
+    flex-direction: column;
+
+    button:not(:last-child) {
+      margin-bottom: 10px;
+    }
+  }
+}

+ 46 - 0
app/components/auth.tsx

@@ -0,0 +1,46 @@
+import styles from "./auth.module.scss";
+import { IconButton } from "./button";
+
+import { useNavigate } from "react-router-dom";
+import { Path } from "../constant";
+import { useAccessStore } from "../store";
+import Locale from "../locales";
+
+import BotIcon from "../icons/bot.svg";
+
+export function AuthPage() {
+  const navigate = useNavigate();
+  const access = useAccessStore();
+
+  const goHome = () => navigate(Path.Home);
+
+  return (
+    <div className={styles["auth-page"]}>
+      <div className={`no-dark ${styles["auth-logo"]}`}>
+        <BotIcon />
+      </div>
+
+      <div className={styles["auth-title"]}>{Locale.Auth.Title}</div>
+      <div className={styles["auth-tips"]}>{Locale.Auth.Tips}</div>
+
+      <input
+        className={styles["auth-input"]}
+        type="password"
+        placeholder={Locale.Auth.Input}
+        value={access.accessCode}
+        onChange={(e) => {
+          access.updateCode(e.currentTarget.value);
+        }}
+      />
+
+      <div className={styles["auth-actions"]}>
+        <IconButton
+          text={Locale.Auth.Confirm}
+          type="primary"
+          onClick={goHome}
+        />
+        <IconButton text={Locale.Auth.Later} onClick={goHome} />
+      </div>
+    </div>
+  );
+}

+ 14 - 4
app/components/chat-list.tsx

@@ -16,6 +16,7 @@ import { Link, useNavigate } from "react-router-dom";
 import { Path } from "../constant";
 import { MaskAvatar } from "./mask";
 import { Mask } from "../store/mask";
+import { useRef, useEffect } from "react";
 
 export function ChatItem(props: {
   onClick?: () => void;
@@ -29,6 +30,14 @@ export function ChatItem(props: {
   narrow?: boolean;
   mask: Mask;
 }) {
+  const draggableRef = useRef<HTMLDivElement | null>(null);
+  useEffect(() => {
+    if (props.selected && draggableRef.current) {
+      draggableRef.current?.scrollIntoView({
+        block: "center",
+      });
+    }
+  }, [props.selected]);
   return (
     <Draggable draggableId={`${props.id}`} index={props.index}>
       {(provided) => (
@@ -37,7 +46,10 @@ export function ChatItem(props: {
             props.selected && styles["chat-item-selected"]
           }`}
           onClick={props.onClick}
-          ref={provided.innerRef}
+          ref={(ele) => {
+            draggableRef.current = ele;
+            provided.innerRef(ele);
+          }}
           {...provided.draggableProps}
           {...provided.dragHandleProps}
           title={`${props.title}\n${Locale.ChatItem.ChatItemCount(
@@ -60,9 +72,7 @@ export function ChatItem(props: {
                 <div className={styles["chat-item-count"]}>
                   {Locale.ChatItem.ChatItemCount(props.count)}
                 </div>
-                <div className={styles["chat-item-date"]}>
-                  {new Date(props.time).toLocaleString()}
-                </div>
+                <div className={styles["chat-item-date"]}>{props.time}</div>
               </div>
             </>
           )}

+ 95 - 0
app/components/chat.module.scss

@@ -17,10 +17,38 @@
     transition: all ease 0.3s;
     margin-bottom: 10px;
     align-items: center;
+    height: 16px;
+    width: var(--icon-width);
 
     &:not(:last-child) {
       margin-right: 5px;
     }
+
+    .text {
+      white-space: nowrap;
+      padding-left: 5px;
+      opacity: 0;
+      transform: translateX(-5px);
+      transition: all ease 0.3s;
+      transition-delay: 0.1s;
+      pointer-events: none;
+    }
+
+    &:hover {
+      width: var(--full-width);
+
+      .text {
+        opacity: 1;
+        transform: translate(0);
+      }
+    }
+
+    .text,
+    .icon {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+    }
   }
 }
 
@@ -107,3 +135,70 @@
     user-select: text;
   }
 }
+
+.clear-context {
+  margin: 20px 0 0 0;
+  padding: 4px 0;
+
+  border-top: var(--border-in-light);
+  border-bottom: var(--border-in-light);
+  box-shadow: var(--card-shadow) inset;
+
+  display: flex;
+  justify-content: center;
+  align-items: center;
+
+  color: var(--black);
+  transition: all ease 0.3s;
+  cursor: pointer;
+  overflow: hidden;
+  position: relative;
+  font-size: 12px;
+
+  animation: slide-in ease 0.3s;
+
+  $linear: linear-gradient(
+    to right,
+    rgba(0, 0, 0, 0),
+    rgba(0, 0, 0, 1),
+    rgba(0, 0, 0, 0)
+  );
+  mask-image: $linear;
+
+  @mixin show {
+    transform: translateY(0);
+    position: relative;
+    transition: all ease 0.3s;
+    opacity: 1;
+  }
+
+  @mixin hide {
+    transform: translateY(-50%);
+    position: absolute;
+    transition: all ease 0.1s;
+    opacity: 0;
+  }
+
+  &-tips {
+    @include show;
+    opacity: 0.5;
+  }
+
+  &-revert-btn {
+    color: var(--primary);
+    @include hide;
+  }
+
+  &:hover {
+    opacity: 1;
+    border-color: var(--primary);
+
+    .clear-context-tips {
+      @include hide;
+    }
+
+    .clear-context-revert-btn {
+      @include show;
+    }
+  }
+}

+ 294 - 168
app/components/chat.tsx

@@ -1,5 +1,11 @@
 import { useDebouncedCallback } from "use-debounce";
-import { useState, useRef, useEffect, useLayoutEffect } from "react";
+import React, {
+  useState,
+  useRef,
+  useEffect,
+  useLayoutEffect,
+  useMemo,
+} from "react";
 
 import SendWhiteIcon from "../icons/send-white.svg";
 import BrainIcon from "../icons/brain.svg";
@@ -7,13 +13,14 @@ import RenameIcon from "../icons/rename.svg";
 import ExportIcon from "../icons/share.svg";
 import ReturnIcon from "../icons/return.svg";
 import CopyIcon from "../icons/copy.svg";
-import DownloadIcon from "../icons/download.svg";
 import LoadingIcon from "../icons/three-dots.svg";
 import PromptIcon from "../icons/prompt.svg";
 import MaskIcon from "../icons/mask.svg";
 import MaxIcon from "../icons/max.svg";
 import MinIcon from "../icons/min.svg";
 import ResetIcon from "../icons/reload.svg";
+import BreakIcon from "../icons/break.svg";
+import SettingsIcon from "../icons/chat-settings.svg";
 
 import LightIcon from "../icons/light.svg";
 import DarkIcon from "../icons/dark.svg";
@@ -22,7 +29,7 @@ import BottomIcon from "../icons/bottom.svg";
 import StopIcon from "../icons/pause.svg";
 
 import {
-  Message,
+  ChatMessage,
   SubmitKey,
   useChatStore,
   BOT_HELLO,
@@ -43,7 +50,7 @@ import {
 
 import dynamic from "next/dynamic";
 
-import { ControllerPool } from "../requests";
+import { ChatControllerPool } from "../client/controller";
 import { Prompt, usePromptStore } from "../store/prompt";
 import Locale from "../locales";
 
@@ -51,56 +58,21 @@ import { IconButton } from "./button";
 import styles from "./home.module.scss";
 import chatStyle from "./chat.module.scss";
 
-import { ListItem, Modal, showModal } from "./ui-lib";
+import { ListItem, Modal } from "./ui-lib";
 import { useLocation, useNavigate } from "react-router-dom";
-import { LAST_INPUT_KEY, Path } from "../constant";
+import { LAST_INPUT_KEY, Path, REQUEST_TIMEOUT_MS } from "../constant";
 import { Avatar } from "./emoji";
 import { MaskAvatar, MaskConfig } from "./mask";
 import { useMaskStore } from "../store/mask";
 import { useCommand } from "../command";
+import { prettyObject } from "../utils/format";
+import { ExportMessageModal } from "./exporter";
+import { getClientConfig } from "../config/client";
 
 const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
   loading: () => <LoadingIcon />,
 });
 
-function exportMessages(messages: Message[], topic: string) {
-  const mdText =
-    `# ${topic}\n\n` +
-    messages
-      .map((m) => {
-        return m.role === "user"
-          ? `## ${Locale.Export.MessageFromYou}:\n${m.content}`
-          : `## ${Locale.Export.MessageFromChatGPT}:\n${m.content.trim()}`;
-      })
-      .join("\n\n");
-  const filename = `${topic}.md`;
-
-  showModal({
-    title: Locale.Export.Title,
-    children: (
-      <div className="markdown-body">
-        <pre className={styles["export-content"]}>{mdText}</pre>
-      </div>
-    ),
-    actions: [
-      <IconButton
-        key="copy"
-        icon={<CopyIcon />}
-        bordered
-        text={Locale.Export.Copy}
-        onClick={() => copyToClipboard(mdText)}
-      />,
-      <IconButton
-        key="download"
-        icon={<DownloadIcon />}
-        bordered
-        text={Locale.Export.Download}
-        onClick={() => downloadAs(mdText, filename)}
-      />,
-    ],
-  });
-}
-
 export function SessionConfigModel(props: { onClose: () => void }) {
   const chatStore = useChatStore();
   const session = chatStore.currentSession();
@@ -118,9 +90,13 @@ export function SessionConfigModel(props: { onClose: () => void }) {
             icon={<ResetIcon />}
             bordered
             text={Locale.Chat.Config.Reset}
-            onClick={() =>
-              confirm(Locale.Memory.ResetConfirm) && chatStore.resetSession()
-            }
+            onClick={() => {
+              if (confirm(Locale.Memory.ResetConfirm)) {
+                chatStore.updateCurrentSession(
+                  (session) => (session.memoryPrompt = ""),
+                );
+              }
+            }}
           />,
           <IconButton
             key="copy"
@@ -143,6 +119,7 @@ export function SessionConfigModel(props: { onClose: () => void }) {
             updater(mask);
             chatStore.updateCurrentSession((session) => (session.mask = mask));
           }}
+          shouldSyncFromGlobal
           extraListItems={
             session.mask.modelConfig.sendMemory ? (
               <ListItem
@@ -230,7 +207,9 @@ export function PromptHints(props: {
   useEffect(() => {
     const onKeyDown = (e: KeyboardEvent) => {
       if (noPrompts) return;
-
+      if (e.metaKey || e.altKey || e.ctrlKey) {
+        return;
+      }
       // arrow up / down to select prompt
       const changeIndex = (delta: number) => {
         e.stopPropagation();
@@ -261,7 +240,7 @@ export function PromptHints(props: {
 
     return () => window.removeEventListener("keydown", onKeyDown);
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [noPrompts, selectIndex]);
+  }, [props.prompts.length, selectIndex]);
 
   if (noPrompts) return null;
   return (
@@ -285,6 +264,79 @@ export function PromptHints(props: {
   );
 }
 
+function ClearContextDivider() {
+  const chatStore = useChatStore();
+
+  return (
+    <div
+      className={chatStyle["clear-context"]}
+      onClick={() =>
+        chatStore.updateCurrentSession(
+          (session) => (session.clearContextIndex = undefined),
+        )
+      }
+    >
+      <div className={chatStyle["clear-context-tips"]}>
+        {Locale.Context.Clear}
+      </div>
+      <div className={chatStyle["clear-context-revert-btn"]}>
+        {Locale.Context.Revert}
+      </div>
+    </div>
+  );
+}
+
+function ChatAction(props: {
+  text: string;
+  icon: JSX.Element;
+  onClick: () => void;
+}) {
+  const iconRef = useRef<HTMLDivElement>(null);
+  const textRef = useRef<HTMLDivElement>(null);
+  const [width, setWidth] = useState({
+    full: 20,
+    icon: 20,
+  });
+
+  function updateWidth() {
+    if (!iconRef.current || !textRef.current) return;
+    const getWidth = (dom: HTMLDivElement) => dom.getBoundingClientRect().width;
+    const textWidth = getWidth(textRef.current);
+    const iconWidth = getWidth(iconRef.current);
+    setWidth({
+      full: textWidth + iconWidth,
+      icon: iconWidth,
+    });
+  }
+
+  useEffect(() => {
+    updateWidth();
+  }, []);
+
+  return (
+    <div
+      className={`${chatStyle["chat-input-action"]} clickable`}
+      onClick={() => {
+        props.onClick();
+        setTimeout(updateWidth, 1);
+      }}
+      style={
+        {
+          "--icon-width": `${width.icon}px`,
+          "--full-width": `${width.full}px`,
+        } as React.CSSProperties
+      }
+    >
+      <div ref={iconRef} className={chatStyle["icon"]}>
+        {props.icon}
+      </div>
+      <div className={chatStyle["text"]} ref={textRef}>
+        {props.text}
+      </div>
+    </div>
+  );
+}
+
 function useScrollToBottom() {
   // for auto-scroll
   const scrollRef = useRef<HTMLDivElement>(null);
@@ -317,6 +369,7 @@ export function ChatActions(props: {
 }) {
   const config = useAppConfig();
   const navigate = useNavigate();
+  const chatStore = useChatStore();
 
   // switch themes
   const theme = config.theme;
@@ -329,70 +382,83 @@ export function ChatActions(props: {
   }
 
   // stop all responses
-  const couldStop = ControllerPool.hasPending();
-  const stopAll = () => ControllerPool.stopAll();
+  const couldStop = ChatControllerPool.hasPending();
+  const stopAll = () => ChatControllerPool.stopAll();
 
   return (
     <div className={chatStyle["chat-input-actions"]}>
       {couldStop && (
-        <div
-          className={`${chatStyle["chat-input-action"]} clickable`}
+        <ChatAction
           onClick={stopAll}
-        >
-          <StopIcon />
-        </div>
+          text={Locale.Chat.InputActions.Stop}
+          icon={<StopIcon />}
+        />
       )}
       {!props.hitBottom && (
-        <div
-          className={`${chatStyle["chat-input-action"]} clickable`}
+        <ChatAction
           onClick={props.scrollToBottom}
-        >
-          <BottomIcon />
-        </div>
+          text={Locale.Chat.InputActions.ToBottom}
+          icon={<BottomIcon />}
+        />
       )}
       {props.hitBottom && (
-        <div
-          className={`${chatStyle["chat-input-action"]} clickable`}
+        <ChatAction
           onClick={props.showPromptModal}
-        >
-          <BrainIcon />
-        </div>
+          text={Locale.Chat.InputActions.Settings}
+          icon={<SettingsIcon />}
+        />
       )}
 
-      <div
-        className={`${chatStyle["chat-input-action"]} clickable`}
+      <ChatAction
         onClick={nextTheme}
-      >
-        {theme === Theme.Auto ? (
-          <AutoIcon />
-        ) : theme === Theme.Light ? (
-          <LightIcon />
-        ) : theme === Theme.Dark ? (
-          <DarkIcon />
-        ) : null}
-      </div>
+        text={Locale.Chat.InputActions.Theme[theme]}
+        icon={
+          <>
+            {theme === Theme.Auto ? (
+              <AutoIcon />
+            ) : theme === Theme.Light ? (
+              <LightIcon />
+            ) : theme === Theme.Dark ? (
+              <DarkIcon />
+            ) : null}
+          </>
+        }
+      />
 
-      <div
-        className={`${chatStyle["chat-input-action"]} clickable`}
+      <ChatAction
         onClick={props.showPromptHints}
-      >
-        <PromptIcon />
-      </div>
+        text={Locale.Chat.InputActions.Prompt}
+        icon={<PromptIcon />}
+      />
 
-      <div
-        className={`${chatStyle["chat-input-action"]} clickable`}
+      <ChatAction
         onClick={() => {
           navigate(Path.Masks);
         }}
-      >
-        <MaskIcon />
-      </div>
+        text={Locale.Chat.InputActions.Masks}
+        icon={<MaskIcon />}
+      />
+
+      <ChatAction
+        text={Locale.Chat.InputActions.Clear}
+        icon={<BreakIcon />}
+        onClick={() => {
+          chatStore.updateCurrentSession((session) => {
+            if (session.clearContextIndex === session.messages.length) {
+              session.clearContextIndex = undefined;
+            } else {
+              session.clearContextIndex = session.messages.length;
+              session.memoryPrompt = ""; // will clear memory
+            }
+          });
+        }}
+      />
     </div>
   );
 }
 
 export function Chat() {
-  type RenderMessage = Message & { preview?: boolean };
+  type RenderMessage = ChatMessage & { preview?: boolean };
 
   const chatStore = useChatStore();
   const [session, sessionIndex] = useChatStore((state) => [
@@ -402,6 +468,8 @@ export function Chat() {
   const config = useAppConfig();
   const fontSize = config.fontSize;
 
+  const [showExport, setShowExport] = useState(false);
+
   const inputRef = useRef<HTMLTextAreaElement>(null);
   const [userInput, setUserInput] = useState("");
   const [isLoading, setIsLoading] = useState(false);
@@ -485,23 +553,56 @@ export function Chat() {
 
   // stop response
   const onUserStop = (messageId: number) => {
-    ControllerPool.stop(sessionIndex, messageId);
+    ChatControllerPool.stop(sessionIndex, messageId);
   };
 
+  useEffect(() => {
+    chatStore.updateCurrentSession((session) => {
+      const stopTiming = Date.now() - REQUEST_TIMEOUT_MS;
+      session.messages.forEach((m) => {
+        // check if should stop all stale messages
+        if (m.isError || new Date(m.date).getTime() < stopTiming) {
+          if (m.streaming) {
+            m.streaming = false;
+          }
+
+          if (m.content.length === 0) {
+            m.isError = true;
+            m.content = prettyObject({
+              error: true,
+              message: "empty response",
+            });
+          }
+        }
+      });
+
+      // auto sync mask config from global config
+      if (session.mask.syncGlobalConfig) {
+        console.log("[Mask] syncing from global, name = ", session.mask.name);
+        session.mask.modelConfig = { ...config.modelConfig };
+      }
+    });
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
   // check if should send message
   const onInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
     // if ArrowUp and no userInput, fill with last input
-    if (e.key === "ArrowUp" && userInput.length <= 0) {
+    if (
+      e.key === "ArrowUp" &&
+      userInput.length <= 0 &&
+      !(e.metaKey || e.altKey || e.ctrlKey)
+    ) {
       setUserInput(localStorage.getItem(LAST_INPUT_KEY) ?? "");
       e.preventDefault();
       return;
     }
-    if (shouldSubmit(e)) {
+    if (shouldSubmit(e) && promptHints.length === 0) {
       doSubmit(userInput);
       e.preventDefault();
     }
   };
-  const onRightClick = (e: any, message: Message) => {
+  const onRightClick = (e: any, message: ChatMessage) => {
     // copy to clipboard
     if (selectOrCopy(e.currentTarget, message.content)) {
       e.preventDefault();
@@ -548,7 +649,9 @@ export function Chat() {
     inputRef.current?.focus();
   };
 
-  const context: RenderMessage[] = session.mask.context.slice();
+  const context: RenderMessage[] = session.mask.hideContext
+    ? []
+    : session.mask.context.slice();
 
   const accessStore = useAccessStore();
 
@@ -563,6 +666,12 @@ export function Chat() {
     context.push(copiedHello);
   }
 
+  // clear context index = context length + index in messages
+  const clearContextIndex =
+    (session.clearContextIndex ?? -1) >= 0
+      ? session.clearContextIndex! + context.length
+      : -1;
+
   // preview messages
   const messages = context
     .concat(session.messages as RenderMessage[])
@@ -602,9 +711,13 @@ export function Chat() {
     }
   };
 
+  const clientConfig = useMemo(() => getClientConfig(), []);
+
   const location = useLocation();
   const isChat = location.pathname === Path.Chat;
+
   const autoFocus = !isMobileScreen || isChat; // only focus in chat page
+  const showMaxIcon = !isMobileScreen && !clientConfig?.isApp;
 
   useCommand({
     fill: setUserInput,
@@ -615,7 +728,7 @@ export function Chat() {
 
   return (
     <div className={styles.chat} key={session.id}>
-      <div className="window-header">
+      <div className="window-header" data-tauri-drag-region>
         <div className="window-header-title">
           <div
             className={`window-header-main-title " ${styles["chat-body-title"]}`}
@@ -649,14 +762,11 @@ export function Chat() {
               bordered
               title={Locale.Chat.Actions.Export}
               onClick={() => {
-                exportMessages(
-                  session.messages.filter((msg) => !msg.isError),
-                  session.topic,
-                );
+                setShowExport(true);
               }}
             />
           </div>
-          {!isMobileScreen && (
+          {showMaxIcon && (
             <div className="window-action-button">
               <IconButton
                 icon={config.tightBorder ? <MinIcon /> : <MaxIcon />}
@@ -697,86 +807,91 @@ export function Chat() {
             !(message.preview || message.content.length === 0);
           const showTyping = message.preview || message.streaming;
 
+          const shouldShowClearContextDivider = i === clearContextIndex - 1;
+
           return (
-            <div
-              key={i}
-              className={
-                isUser ? styles["chat-message-user"] : styles["chat-message"]
-              }
-            >
-              <div className={styles["chat-message-container"]}>
-                <div className={styles["chat-message-avatar"]}>
-                  {message.role === "user" ? (
-                    <Avatar avatar={config.avatar} />
-                  ) : (
-                    <MaskAvatar mask={session.mask} />
-                  )}
-                </div>
-                {showTyping && (
-                  <div className={styles["chat-message-status"]}>
-                    {Locale.Chat.Typing}
+            <>
+              <div
+                key={i}
+                className={
+                  isUser ? styles["chat-message-user"] : styles["chat-message"]
+                }
+              >
+                <div className={styles["chat-message-container"]}>
+                  <div className={styles["chat-message-avatar"]}>
+                    {message.role === "user" ? (
+                      <Avatar avatar={config.avatar} />
+                    ) : (
+                      <MaskAvatar mask={session.mask} />
+                    )}
                   </div>
-                )}
-                <div className={styles["chat-message-item"]}>
-                  {showActions && (
-                    <div className={styles["chat-message-top-actions"]}>
-                      {message.streaming ? (
-                        <div
-                          className={styles["chat-message-top-action"]}
-                          onClick={() => onUserStop(message.id ?? i)}
-                        >
-                          {Locale.Chat.Actions.Stop}
-                        </div>
-                      ) : (
-                        <>
-                          <div
-                            className={styles["chat-message-top-action"]}
-                            onClick={() => onDelete(message.id ?? i)}
-                          >
-                            {Locale.Chat.Actions.Delete}
-                          </div>
+                  {showTyping && (
+                    <div className={styles["chat-message-status"]}>
+                      {Locale.Chat.Typing}
+                    </div>
+                  )}
+                  <div className={styles["chat-message-item"]}>
+                    {showActions && (
+                      <div className={styles["chat-message-top-actions"]}>
+                        {message.streaming ? (
                           <div
                             className={styles["chat-message-top-action"]}
-                            onClick={() => onResend(message.id ?? i)}
+                            onClick={() => onUserStop(message.id ?? i)}
                           >
-                            {Locale.Chat.Actions.Retry}
+                            {Locale.Chat.Actions.Stop}
                           </div>
-                        </>
-                      )}
-
-                      <div
-                        className={styles["chat-message-top-action"]}
-                        onClick={() => copyToClipboard(message.content)}
-                      >
-                        {Locale.Chat.Actions.Copy}
+                        ) : (
+                          <>
+                            <div
+                              className={styles["chat-message-top-action"]}
+                              onClick={() => onDelete(message.id ?? i)}
+                            >
+                              {Locale.Chat.Actions.Delete}
+                            </div>
+                            <div
+                              className={styles["chat-message-top-action"]}
+                              onClick={() => onResend(message.id ?? i)}
+                            >
+                              {Locale.Chat.Actions.Retry}
+                            </div>
+                          </>
+                        )}
+
+                        <div
+                          className={styles["chat-message-top-action"]}
+                          onClick={() => copyToClipboard(message.content)}
+                        >
+                          {Locale.Chat.Actions.Copy}
+                        </div>
+                      </div>
+                    )}
+                    <Markdown
+                      content={message.content}
+                      loading={
+                        (message.preview || message.content.length === 0) &&
+                        !isUser
+                      }
+                      onContextMenu={(e) => onRightClick(e, message)}
+                      onDoubleClickCapture={() => {
+                        if (!isMobileScreen) return;
+                        setUserInput(message.content);
+                      }}
+                      fontSize={fontSize}
+                      parentRef={scrollRef}
+                      defaultShow={i >= messages.length - 10}
+                    />
+                  </div>
+                  {!isUser && !message.preview && (
+                    <div className={styles["chat-message-actions"]}>
+                      <div className={styles["chat-message-action-date"]}>
+                        {message.date.toLocaleString()}
                       </div>
                     </div>
                   )}
-                  <Markdown
-                    content={message.content}
-                    loading={
-                      (message.preview || message.content.length === 0) &&
-                      !isUser
-                    }
-                    onContextMenu={(e) => onRightClick(e, message)}
-                    onDoubleClickCapture={() => {
-                      if (!isMobileScreen) return;
-                      setUserInput(message.content);
-                    }}
-                    fontSize={fontSize}
-                    parentRef={scrollRef}
-                    defaultShow={i >= messages.length - 10}
-                  />
                 </div>
-                {!isUser && !message.preview && (
-                  <div className={styles["chat-message-actions"]}>
-                    <div className={styles["chat-message-action-date"]}>
-                      {message.date.toLocaleString()}
-                    </div>
-                  </div>
-                )}
               </div>
-            </div>
+              {shouldShowClearContextDivider && <ClearContextDivider />}
+            </>
           );
         })}
       </div>
@@ -789,7 +904,14 @@ export function Chat() {
           scrollToBottom={scrollToBottom}
           hitBottom={hitBottom}
           showPromptHints={() => {
+            // Click again to close
+            if (promptHints.length > 0) {
+              setPromptHints([]);
+              return;
+            }
+
             inputRef.current?.focus();
+            setUserInput("/");
             onSearch("");
           }}
         />
@@ -815,6 +937,10 @@ export function Chat() {
           />
         </div>
       </div>
+
+      {showExport && (
+        <ExportMessageModal onClose={() => setShowExport(false)} />
+      )}
     </div>
   );
 }

+ 217 - 0
app/components/exporter.module.scss

@@ -0,0 +1,217 @@
+.message-exporter {
+  &-body {
+    margin-top: 20px;
+  }
+}
+
+.export-content {
+  white-space: break-spaces;
+  padding: 10px !important;
+}
+
+.steps {
+  background-color: var(--gray);
+  border-radius: 10px;
+  overflow: hidden;
+  padding: 5px;
+  position: relative;
+  box-shadow: var(--card-shadow) inset;
+
+  .steps-progress {
+    $padding: 5px;
+    height: calc(100% - 2 * $padding);
+    width: calc(100% - 2 * $padding);
+    position: absolute;
+    top: $padding;
+    left: $padding;
+
+    &-inner {
+      box-sizing: border-box;
+      box-shadow: var(--card-shadow);
+      border: var(--border-in-light);
+      content: "";
+      display: inline-block;
+      width: 0%;
+      height: 100%;
+      background-color: var(--white);
+      transition: all ease 0.3s;
+      border-radius: 8px;
+    }
+  }
+
+  .steps-inner {
+    display: flex;
+    transform: scale(1);
+
+    .step {
+      flex-grow: 1;
+      padding: 5px 10px;
+      font-size: 14px;
+      color: var(--black);
+      opacity: 0.5;
+      transition: all ease 0.3s;
+
+      display: flex;
+      align-items: center;
+      justify-content: center;
+
+      $radius: 8px;
+
+      &-finished {
+        opacity: 0.9;
+      }
+
+      &:hover {
+        opacity: 0.8;
+      }
+
+      &-current {
+        color: var(--primary);
+      }
+
+      .step-index {
+        background-color: var(--gray);
+        border: var(--border-in-light);
+        border-radius: 6px;
+        display: inline-block;
+        padding: 0px 5px;
+        font-size: 12px;
+        margin-right: 8px;
+        opacity: 0.8;
+      }
+
+      .step-name {
+        font-size: 12px;
+      }
+    }
+  }
+}
+
+.preview-actions {
+  margin-bottom: 20px;
+  display: flex;
+  justify-content: space-between;
+
+  button {
+    flex-grow: 1;
+    &:not(:last-child) {
+      margin-right: 10px;
+    }
+  }
+}
+
+.image-previewer {
+  .preview-body {
+    border-radius: 10px;
+    padding: 20px;
+    box-shadow: var(--card-shadow) inset;
+    background-color: var(--gray);
+
+    .chat-info {
+      background-color: var(--second);
+      padding: 20px;
+      border-radius: 10px;
+      margin-bottom: 20px;
+      display: flex;
+      justify-content: space-between;
+      align-items: flex-end;
+      position: relative;
+      overflow: hidden;
+
+      @media screen and (max-width: 600px) {
+        flex-direction: column;
+        align-items: flex-start;
+
+        .icons {
+          margin-bottom: 20px;
+        }
+      }
+
+      .logo {
+        position: absolute;
+        top: 0px;
+        left: 0px;
+        height: 50%;
+        transform: scale(1.5);
+      }
+
+      .main-title {
+        font-size: 20px;
+        font-weight: bolder;
+      }
+
+      .sub-title {
+        font-size: 12px;
+      }
+
+      .icons {
+        margin-top: 10px;
+        display: flex;
+        align-items: center;
+
+        .icon-space {
+          font-size: 12px;
+          margin: 0 10px;
+          font-weight: bolder;
+          color: var(--primary);
+        }
+      }
+
+      .chat-info-item {
+        font-size: 12px;
+        color: var(--primary);
+        padding: 2px 15px;
+        border-radius: 10px;
+        background-color: var(--white);
+        box-shadow: var(--card-shadow);
+
+        &:not(:last-child) {
+          margin-bottom: 5px;
+        }
+      }
+    }
+
+    .message {
+      margin-bottom: 20px;
+      display: flex;
+
+      .avatar {
+        margin-right: 10px;
+      }
+
+      .body {
+        border-radius: 10px;
+        padding: 8px 10px;
+        max-width: calc(100% - 104px);
+        box-shadow: var(--card-shadow);
+        border: var(--border-in-light);
+
+        * {
+          overflow: hidden;
+        }
+      }
+
+      &-assistant {
+        .body {
+          background-color: var(--white);
+        }
+      }
+
+      &-user {
+        flex-direction: row-reverse;
+
+        .avatar {
+          margin-right: 0;
+        }
+
+        .body {
+          background-color: var(--second);
+          margin-right: 10px;
+        }
+      }
+    }
+  }
+
+  .default-theme {
+  }
+}

+ 528 - 0
app/components/exporter.tsx

@@ -0,0 +1,528 @@
+import { ChatMessage, useAppConfig, useChatStore } from "../store";
+import Locale from "../locales";
+import styles from "./exporter.module.scss";
+import { List, ListItem, Modal, Select, showToast } from "./ui-lib";
+import { IconButton } from "./button";
+import { copyToClipboard, downloadAs, useMobileScreen } from "../utils";
+
+import CopyIcon from "../icons/copy.svg";
+import LoadingIcon from "../icons/three-dots.svg";
+import ChatGptIcon from "../icons/chatgpt.png";
+import ShareIcon from "../icons/share.svg";
+import BotIcon from "../icons/bot.png";
+
+import DownloadIcon from "../icons/download.svg";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { MessageSelector, useMessageSelector } from "./message-selector";
+import { Avatar } from "./emoji";
+import dynamic from "next/dynamic";
+import NextImage from "next/image";
+
+import { toBlob, toJpeg, toPng } from "html-to-image";
+import { DEFAULT_MASK_AVATAR } from "../store/mask";
+import { api } from "../client/api";
+import { prettyObject } from "../utils/format";
+import { EXPORT_MESSAGE_CLASS_NAME } from "../constant";
+
+const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
+  loading: () => <LoadingIcon />,
+});
+
+export function ExportMessageModal(props: { onClose: () => void }) {
+  return (
+    <div className="modal-mask">
+      <Modal title={Locale.Export.Title} onClose={props.onClose}>
+        <div style={{ minHeight: "40vh" }}>
+          <MessageExporter />
+        </div>
+      </Modal>
+    </div>
+  );
+}
+
+function useSteps(
+  steps: Array<{
+    name: string;
+    value: string;
+  }>,
+) {
+  const stepCount = steps.length;
+  const [currentStepIndex, setCurrentStepIndex] = useState(0);
+  const nextStep = () =>
+    setCurrentStepIndex((currentStepIndex + 1) % stepCount);
+  const prevStep = () =>
+    setCurrentStepIndex((currentStepIndex - 1 + stepCount) % stepCount);
+
+  return {
+    currentStepIndex,
+    setCurrentStepIndex,
+    nextStep,
+    prevStep,
+    currentStep: steps[currentStepIndex],
+  };
+}
+
+function Steps<
+  T extends {
+    name: string;
+    value: string;
+  }[],
+>(props: { steps: T; onStepChange?: (index: number) => void; index: number }) {
+  const steps = props.steps;
+  const stepCount = steps.length;
+
+  return (
+    <div className={styles["steps"]}>
+      <div className={styles["steps-progress"]}>
+        <div
+          className={styles["steps-progress-inner"]}
+          style={{
+            width: `${((props.index + 1) / stepCount) * 100}%`,
+          }}
+        ></div>
+      </div>
+      <div className={styles["steps-inner"]}>
+        {steps.map((step, i) => {
+          return (
+            <div
+              key={i}
+              className={`${styles["step"]} ${
+                styles[i <= props.index ? "step-finished" : ""]
+              } ${i === props.index && styles["step-current"]} clickable`}
+              onClick={() => {
+                props.onStepChange?.(i);
+              }}
+              role="button"
+            >
+              <span className={styles["step-index"]}>{i + 1}</span>
+              <span className={styles["step-name"]}>{step.name}</span>
+            </div>
+          );
+        })}
+      </div>
+    </div>
+  );
+}
+
+export function MessageExporter() {
+  const steps = [
+    {
+      name: Locale.Export.Steps.Select,
+      value: "select",
+    },
+    {
+      name: Locale.Export.Steps.Preview,
+      value: "preview",
+    },
+  ];
+  const { currentStep, setCurrentStepIndex, currentStepIndex } =
+    useSteps(steps);
+  const formats = ["text", "image"] as const;
+  type ExportFormat = (typeof formats)[number];
+
+  const [exportConfig, setExportConfig] = useState({
+    format: "image" as ExportFormat,
+    includeContext: true,
+  });
+
+  function updateExportConfig(updater: (config: typeof exportConfig) => void) {
+    const config = { ...exportConfig };
+    updater(config);
+    setExportConfig(config);
+  }
+
+  const chatStore = useChatStore();
+  const session = chatStore.currentSession();
+  const { selection, updateSelection } = useMessageSelector();
+  const selectedMessages = useMemo(() => {
+    const ret: ChatMessage[] = [];
+    if (exportConfig.includeContext) {
+      ret.push(...session.mask.context);
+    }
+    ret.push(...session.messages.filter((m, i) => selection.has(m.id ?? i)));
+    return ret;
+  }, [
+    exportConfig.includeContext,
+    session.messages,
+    session.mask.context,
+    selection,
+  ]);
+
+  return (
+    <>
+      <Steps
+        steps={steps}
+        index={currentStepIndex}
+        onStepChange={setCurrentStepIndex}
+      />
+      <div
+        className={styles["message-exporter-body"]}
+        style={currentStep.value !== "select" ? { display: "none" } : {}}
+      >
+        <List>
+          <ListItem
+            title={Locale.Export.Format.Title}
+            subTitle={Locale.Export.Format.SubTitle}
+          >
+            <Select
+              value={exportConfig.format}
+              onChange={(e) =>
+                updateExportConfig(
+                  (config) =>
+                    (config.format = e.currentTarget.value as ExportFormat),
+                )
+              }
+            >
+              {formats.map((f) => (
+                <option key={f} value={f}>
+                  {f}
+                </option>
+              ))}
+            </Select>
+          </ListItem>
+          <ListItem
+            title={Locale.Export.IncludeContext.Title}
+            subTitle={Locale.Export.IncludeContext.SubTitle}
+          >
+            <input
+              type="checkbox"
+              checked={exportConfig.includeContext}
+              onChange={(e) => {
+                updateExportConfig(
+                  (config) => (config.includeContext = e.currentTarget.checked),
+                );
+              }}
+            ></input>
+          </ListItem>
+        </List>
+        <MessageSelector
+          selection={selection}
+          updateSelection={updateSelection}
+          defaultSelectAll
+        />
+      </div>
+      {currentStep.value === "preview" && (
+        <div className={styles["message-exporter-body"]}>
+          {exportConfig.format === "text" ? (
+            <MarkdownPreviewer
+              messages={selectedMessages}
+              topic={session.topic}
+            />
+          ) : (
+            <ImagePreviewer messages={selectedMessages} topic={session.topic} />
+          )}
+        </div>
+      )}
+    </>
+  );
+}
+
+export function RenderExport(props: {
+  messages: ChatMessage[];
+  onRender: (messages: ChatMessage[]) => void;
+}) {
+  const domRef = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    if (!domRef.current) return;
+    const dom = domRef.current;
+    const messages = Array.from(
+      dom.getElementsByClassName(EXPORT_MESSAGE_CLASS_NAME),
+    );
+
+    if (messages.length !== props.messages.length) {
+      return;
+    }
+
+    const renderMsgs = messages.map((v) => {
+      const [_, role] = v.id.split(":");
+      return {
+        role: role as any,
+        content: v.innerHTML,
+        date: "",
+      };
+    });
+
+    props.onRender(renderMsgs);
+  });
+
+  return (
+    <div ref={domRef}>
+      {props.messages.map((m, i) => (
+        <div
+          key={i}
+          id={`${m.role}:${i}`}
+          className={EXPORT_MESSAGE_CLASS_NAME}
+        >
+          <Markdown content={m.content} defaultShow />
+        </div>
+      ))}
+    </div>
+  );
+}
+
+export function PreviewActions(props: {
+  download: () => void;
+  copy: () => void;
+  showCopy?: boolean;
+  messages?: ChatMessage[];
+}) {
+  const [loading, setLoading] = useState(false);
+  const [shouldExport, setShouldExport] = useState(false);
+
+  const onRenderMsgs = (msgs: ChatMessage[]) => {
+    setShouldExport(false);
+
+    api
+      .share(msgs)
+      .then((res) => {
+        if (!res) return;
+        copyToClipboard(res);
+        setTimeout(() => {
+          window.open(res, "_blank");
+        }, 800);
+      })
+      .catch((e) => {
+        console.error("[Share]", e);
+        showToast(prettyObject(e));
+      })
+      .finally(() => setLoading(false));
+  };
+
+  const share = async () => {
+    if (props.messages?.length) {
+      setLoading(true);
+      setShouldExport(true);
+    }
+  };
+
+  return (
+    <>
+      <div className={styles["preview-actions"]}>
+        {props.showCopy && (
+          <IconButton
+            text={Locale.Export.Copy}
+            bordered
+            shadow
+            icon={<CopyIcon />}
+            onClick={props.copy}
+          ></IconButton>
+        )}
+        <IconButton
+          text={Locale.Export.Download}
+          bordered
+          shadow
+          icon={<DownloadIcon />}
+          onClick={props.download}
+        ></IconButton>
+        <IconButton
+          text={Locale.Export.Share}
+          bordered
+          shadow
+          icon={loading ? <LoadingIcon /> : <ShareIcon />}
+          onClick={share}
+        ></IconButton>
+      </div>
+      <div
+        style={{
+          position: "fixed",
+          right: "200vw",
+          pointerEvents: "none",
+        }}
+      >
+        {shouldExport && (
+          <RenderExport
+            messages={props.messages ?? []}
+            onRender={onRenderMsgs}
+          />
+        )}
+      </div>
+    </>
+  );
+}
+
+function ExportAvatar(props: { avatar: string }) {
+  if (props.avatar === DEFAULT_MASK_AVATAR) {
+    return (
+      <NextImage
+        src={BotIcon.src}
+        width={30}
+        height={30}
+        alt="bot"
+        className="user-avatar"
+      />
+    );
+  }
+
+  return <Avatar avatar={props.avatar}></Avatar>;
+}
+
+export function ImagePreviewer(props: {
+  messages: ChatMessage[];
+  topic: string;
+}) {
+  const chatStore = useChatStore();
+  const session = chatStore.currentSession();
+  const mask = session.mask;
+  const config = useAppConfig();
+
+  const previewRef = useRef<HTMLDivElement>(null);
+
+  const copy = () => {
+    const dom = previewRef.current;
+    if (!dom) return;
+    toBlob(dom).then((blob) => {
+      if (!blob) return;
+      try {
+        navigator.clipboard
+          .write([
+            new ClipboardItem({
+              "image/png": blob,
+            }),
+          ])
+          .then(() => {
+            showToast(Locale.Copy.Success);
+          });
+      } catch (e) {
+        console.error("[Copy Image] ", e);
+        showToast(Locale.Copy.Failed);
+      }
+    });
+  };
+
+  const isMobile = useMobileScreen();
+
+  const download = () => {
+    const dom = previewRef.current;
+    if (!dom) return;
+    toPng(dom)
+      .then((blob) => {
+        if (!blob) return;
+
+        if (isMobile) {
+          const image = new Image();
+          image.src = blob;
+          const win = window.open("");
+          win?.document.write(image.outerHTML);
+        } else {
+          const link = document.createElement("a");
+          link.download = `${props.topic}.png`;
+          link.href = blob;
+          link.click();
+        }
+      })
+      .catch((e) => console.log("[Export Image] ", e));
+  };
+
+  return (
+    <div className={styles["image-previewer"]}>
+      <PreviewActions
+        copy={copy}
+        download={download}
+        showCopy={!isMobile}
+        messages={props.messages}
+      />
+      <div
+        className={`${styles["preview-body"]} ${styles["default-theme"]}`}
+        ref={previewRef}
+      >
+        <div className={styles["chat-info"]}>
+          <div className={styles["logo"] + " no-dark"}>
+            <NextImage
+              src={ChatGptIcon.src}
+              alt="logo"
+              width={50}
+              height={50}
+            />
+          </div>
+
+          <div>
+            <div className={styles["main-title"]}>ChatGPT Next Web</div>
+            <div className={styles["sub-title"]}>
+              github.com/Yidadaa/ChatGPT-Next-Web
+            </div>
+            <div className={styles["icons"]}>
+              <ExportAvatar avatar={config.avatar} />
+              <span className={styles["icon-space"]}>&</span>
+              <ExportAvatar avatar={mask.avatar} />
+            </div>
+          </div>
+          <div>
+            <div className={styles["chat-info-item"]}>
+              Model: {mask.modelConfig.model}
+            </div>
+            <div className={styles["chat-info-item"]}>
+              Messages: {props.messages.length}
+            </div>
+            <div className={styles["chat-info-item"]}>
+              Topic: {session.topic}
+            </div>
+            <div className={styles["chat-info-item"]}>
+              Time:{" "}
+              {new Date(
+                props.messages.at(-1)?.date ?? Date.now(),
+              ).toLocaleString()}
+            </div>
+          </div>
+        </div>
+        {props.messages.map((m, i) => {
+          return (
+            <div
+              className={styles["message"] + " " + styles["message-" + m.role]}
+              key={i}
+            >
+              <div className={styles["avatar"]}>
+                <ExportAvatar
+                  avatar={m.role === "user" ? config.avatar : mask.avatar}
+                />
+              </div>
+
+              <div className={styles["body"]}>
+                <Markdown
+                  content={m.content}
+                  fontSize={config.fontSize}
+                  defaultShow
+                />
+              </div>
+            </div>
+          );
+        })}
+      </div>
+    </div>
+  );
+}
+
+export function MarkdownPreviewer(props: {
+  messages: ChatMessage[];
+  topic: string;
+}) {
+  const mdText =
+    `# ${props.topic}\n\n` +
+    props.messages
+      .map((m) => {
+        return m.role === "user"
+          ? `## ${Locale.Export.MessageFromYou}:\n${m.content}`
+          : `## ${Locale.Export.MessageFromChatGPT}:\n${m.content.trim()}`;
+      })
+      .join("\n\n");
+
+  const copy = () => {
+    copyToClipboard(mdText);
+  };
+  const download = () => {
+    downloadAs(mdText, `${props.topic}.md`);
+  };
+
+  return (
+    <>
+      <PreviewActions
+        copy={copy}
+        download={download}
+        messages={props.messages}
+      />
+      <div className="markdown-body">
+        <pre className={styles["export-content"]}>{mdText}</pre>
+      </div>
+    </>
+  );
+}

+ 7 - 11
app/components/home.module.scss

@@ -141,7 +141,7 @@
 
 .sidebar-sub-title {
   font-size: 12px;
-  font-weight: 400px;
+  font-weight: 400;
   animation: slide-in ease 0.3s;
 }
 
@@ -176,7 +176,7 @@
   font-size: 14px;
   font-weight: bolder;
   display: block;
-  width: 200px;
+  width: calc(100% - 15px);
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
@@ -186,7 +186,7 @@
 .chat-item-delete {
   position: absolute;
   top: 10px;
-  right: -20px;
+  right: 0;
   transition: all ease 0.3s;
   opacity: 0;
   cursor: pointer;
@@ -194,7 +194,7 @@
 
 .chat-item:hover > .chat-item-delete {
   opacity: 0.5;
-  right: 10px;
+  transform: translateX(-10px);
 }
 
 .chat-item:hover > .chat-item-delete:hover {
@@ -369,7 +369,7 @@
   &:hover {
     .chat-message-top-actions {
       opacity: 1;
-      right: 10px;
+      transform: translateX(10px);
       pointer-events: all;
     }
   }
@@ -405,11 +405,12 @@
 }
 
 .chat-message-top-actions {
+  min-width: 120px;
   font-size: 12px;
   position: absolute;
   right: 20px;
   top: -26px;
-  left: 100px;
+  left: 30px;
   transition: all ease 0.3s;
   opacity: 0;
   pointer-events: none;
@@ -558,11 +559,6 @@
   }
 }
 
-.export-content {
-  white-space: break-spaces;
-  padding: 10px !important;
-}
-
 .loading-content {
   display: flex;
   flex-direction: column;

+ 46 - 15
app/components/home.tsx

@@ -23,7 +23,8 @@ import {
 } from "react-router-dom";
 import { SideBar } from "./sidebar";
 import { useAppConfig } from "../store/config";
-import { useMaskStore } from "../store/mask";
+import { AuthPage } from "./auth";
+import { getClientConfig } from "../config/client";
 
 export function Loading(props: { noLogo?: boolean }) {
   return (
@@ -64,17 +65,17 @@ export function useSwitchTheme() {
     }
 
     const metaDescriptionDark = document.querySelector(
-      'meta[name="theme-color"][media]',
+      'meta[name="theme-color"][media*="dark"]',
     );
     const metaDescriptionLight = document.querySelector(
-      'meta[name="theme-color"]:not([media])',
+      'meta[name="theme-color"][media*="light"]',
     );
 
     if (config.theme === "auto") {
       metaDescriptionDark?.setAttribute("content", "#151515");
       metaDescriptionLight?.setAttribute("content", "#fafafa");
     } else {
-      const themeColor = getCSSVar("--themeColor");
+      const themeColor = getCSSVar("--theme-color");
       metaDescriptionDark?.setAttribute("content", themeColor);
       metaDescriptionLight?.setAttribute("content", themeColor);
     }
@@ -91,12 +92,30 @@ const useHasHydrated = () => {
   return hasHydrated;
 };
 
+const loadAsyncGoogleFont = () => {
+  const linkEl = document.createElement("link");
+  const proxyFontUrl = "/google-fonts";
+  const remoteFontUrl = "https://fonts.googleapis.com";
+  const googleFontUrl =
+    getClientConfig()?.buildMode === "export" ? remoteFontUrl : proxyFontUrl;
+  linkEl.rel = "stylesheet";
+  linkEl.href =
+    googleFontUrl +
+    "/css2?family=Noto+Sans+SC:wght@300;400;700;900&display=swap";
+  document.head.appendChild(linkEl);
+};
+
 function Screen() {
   const config = useAppConfig();
   const location = useLocation();
   const isHome = location.pathname === Path.Home;
+  const isAuth = location.pathname === Path.Auth;
   const isMobileScreen = useMobileScreen();
 
+  useEffect(() => {
+    loadAsyncGoogleFont();
+  }, []);
+
   return (
     <div
       className={
@@ -108,17 +127,25 @@ function Screen() {
         }`
       }
     >
-      <SideBar className={isHome ? styles["sidebar-show"] : ""} />
-
-      <div className={styles["window-content"]} id={SlotID.AppBody}>
-        <Routes>
-          <Route path={Path.Home} element={<Chat />} />
-          <Route path={Path.NewChat} element={<NewChat />} />
-          <Route path={Path.Masks} element={<MaskPage />} />
-          <Route path={Path.Chat} element={<Chat />} />
-          <Route path={Path.Settings} element={<Settings />} />
-        </Routes>
-      </div>
+      {isAuth ? (
+        <>
+          <AuthPage />
+        </>
+      ) : (
+        <>
+          <SideBar className={isHome ? styles["sidebar-show"] : ""} />
+
+          <div className={styles["window-content"]} id={SlotID.AppBody}>
+            <Routes>
+              <Route path={Path.Home} element={<Chat />} />
+              <Route path={Path.NewChat} element={<NewChat />} />
+              <Route path={Path.Masks} element={<MaskPage />} />
+              <Route path={Path.Chat} element={<Chat />} />
+              <Route path={Path.Settings} element={<Settings />} />
+            </Routes>
+          </div>
+        </>
+      )}
     </div>
   );
 }
@@ -126,6 +153,10 @@ function Screen() {
 export function Home() {
   useSwitchTheme();
 
+  useEffect(() => {
+    console.log("[Config] got config from build time", getClientConfig());
+  }, []);
+
   if (!useHasHydrated()) {
     return <Loading />;
   }

+ 5 - 0
app/components/input-range.module.scss

@@ -4,4 +4,9 @@
   padding: 5px 15px 5px 10px;
   font-size: 12px;
   display: flex;
+  max-width: 40%;
+
+  input[type="range"] {
+    max-width: calc(100% - 50px);
+  }
 }

+ 44 - 29
app/components/markdown.tsx

@@ -11,6 +11,7 @@ import mermaid from "mermaid";
 
 import LoadingIcon from "../icons/three-dots.svg";
 import React from "react";
+import { useThrottledCallback } from "use-debounce";
 
 export function Mermaid(props: { code: string; onError: () => void }) {
   const ref = useRef<HTMLDivElement>(null);
@@ -121,49 +122,63 @@ export function Markdown(
     content: string;
     loading?: boolean;
     fontSize?: number;
-    parentRef: RefObject<HTMLDivElement>;
+    parentRef?: RefObject<HTMLDivElement>;
     defaultShow?: boolean;
   } & React.DOMAttributes<HTMLDivElement>,
 ) {
   const mdRef = useRef<HTMLDivElement>(null);
   const renderedHeight = useRef(0);
+  const renderedWidth = useRef(0);
   const inView = useRef(!!props.defaultShow);
+  const [_, triggerRender] = useState(0);
+  const checkInView = useThrottledCallback(
+    () => {
+      const parent = props.parentRef?.current;
+      const md = mdRef.current;
+      if (parent && md && !props.defaultShow) {
+        const parentBounds = parent.getBoundingClientRect();
+        const twoScreenHeight = Math.max(500, parentBounds.height * 2);
+        const mdBounds = md.getBoundingClientRect();
+        const parentTop = parentBounds.top - twoScreenHeight;
+        const parentBottom = parentBounds.bottom + twoScreenHeight;
+        const isOverlap =
+          Math.max(parentTop, mdBounds.top) <=
+          Math.min(parentBottom, mdBounds.bottom);
+        inView.current = isOverlap;
+        triggerRender(Date.now());
+      }
+
+      if (inView.current && md) {
+        const rect = md.getBoundingClientRect();
+        renderedHeight.current = Math.max(renderedHeight.current, rect.height);
+        renderedWidth.current = Math.max(renderedWidth.current, rect.width);
+      }
+      // eslint-disable-next-line react-hooks/exhaustive-deps
+    },
+    300,
+    {
+      leading: true,
+      trailing: true,
+    },
+  );
 
-  const parent = props.parentRef.current;
-  const md = mdRef.current;
-
-  const checkInView = () => {
-    if (parent && md) {
-      const parentBounds = parent.getBoundingClientRect();
-      const twoScreenHeight = Math.max(500, parentBounds.height * 2);
-      const mdBounds = md.getBoundingClientRect();
-      const parentTop = parentBounds.top - twoScreenHeight;
-      const parentBottom = parentBounds.bottom + twoScreenHeight;
-      const isOverlap =
-        Math.max(parentTop, mdBounds.top) <=
-        Math.min(parentBottom, mdBounds.bottom);
-      inView.current = isOverlap;
-    }
-
-    if (inView.current && md) {
-      renderedHeight.current = Math.max(
-        renderedHeight.current,
-        md.getBoundingClientRect().height,
-      );
-    }
-  };
+  useEffect(() => {
+    props.parentRef?.current?.addEventListener("scroll", checkInView);
+    checkInView();
+    return () =>
+      props.parentRef?.current?.removeEventListener("scroll", checkInView);
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
 
-  setTimeout(() => checkInView(), 1);
+  const getSize = (x: number) => (!inView.current && x > 0 ? x : "auto");
 
   return (
     <div
       className="markdown-body"
       style={{
         fontSize: `${props.fontSize ?? 14}px`,
-        height:
-          !inView.current && renderedHeight.current > 0
-            ? renderedHeight.current
-            : "auto",
+        height: getSize(renderedHeight.current),
+        width: getSize(renderedWidth.current),
       }}
       ref={mdRef}
       onContextMenu={props.onContextMenu}

+ 76 - 20
app/components/mask.tsx

@@ -13,16 +13,17 @@ import EyeIcon from "../icons/eye.svg";
 import CopyIcon from "../icons/copy.svg";
 
 import { DEFAULT_MASK_AVATAR, Mask, useMaskStore } from "../store/mask";
-import { Message, ModelConfig, ROLES, useChatStore } from "../store";
-import { Input, List, ListItem, Modal, Popover } from "./ui-lib";
+import { ChatMessage, ModelConfig, useAppConfig, useChatStore } from "../store";
+import { ROLES } from "../client/api";
+import { Input, List, ListItem, Modal, Popover, Select } from "./ui-lib";
 import { Avatar, AvatarPicker } from "./emoji";
-import Locale, { AllLangs, Lang } from "../locales";
+import Locale, { AllLangs, ALL_LANG_OPTIONS, Lang } from "../locales";
 import { useNavigate } from "react-router-dom";
 
 import chatStyle from "./chat.module.scss";
-import { useState } from "react";
+import { useEffect, useState } from "react";
 import { downloadAs, readFromFile } from "../utils";
-import { Updater } from "../api/openai/typing";
+import { Updater } from "../typing";
 import { ModelConfigList } from "./model-config";
 import { FileName, Path } from "../constant";
 import { BUILTIN_MASK_STORE } from "../masks";
@@ -40,6 +41,7 @@ export function MaskConfig(props: {
   updateMask: Updater<Mask>;
   extraListItems?: JSX.Element;
   readonly?: boolean;
+  shouldSyncFromGlobal?: boolean;
 }) {
   const [showPicker, setShowPicker] = useState(false);
 
@@ -48,9 +50,15 @@ export function MaskConfig(props: {
 
     const config = { ...props.mask.modelConfig };
     updater(config);
-    props.updateMask((mask) => (mask.modelConfig = config));
+    props.updateMask((mask) => {
+      mask.modelConfig = config;
+      // if user changed current session mask, it will disable auto sync
+      mask.syncGlobalConfig = false;
+    });
   };
 
+  const globalConfig = useAppConfig();
+
   return (
     <>
       <ContextPrompts
@@ -89,10 +97,48 @@ export function MaskConfig(props: {
             type="text"
             value={props.mask.name}
             onInput={(e) =>
-              props.updateMask((mask) => (mask.name = e.currentTarget.value))
+              props.updateMask((mask) => {
+                mask.name = e.currentTarget.value;
+              })
             }
           ></input>
         </ListItem>
+        <ListItem
+          title={Locale.Mask.Config.HideContext.Title}
+          subTitle={Locale.Mask.Config.HideContext.SubTitle}
+        >
+          <input
+            type="checkbox"
+            checked={props.mask.hideContext}
+            onChange={(e) => {
+              props.updateMask((mask) => {
+                mask.hideContext = e.currentTarget.checked;
+              });
+            }}
+          ></input>
+        </ListItem>
+        {props.shouldSyncFromGlobal ? (
+          <ListItem
+            title={Locale.Mask.Config.Sync.Title}
+            subTitle={Locale.Mask.Config.Sync.SubTitle}
+          >
+            <input
+              type="checkbox"
+              checked={props.mask.syncGlobalConfig}
+              onChange={(e) => {
+                if (
+                  e.currentTarget.checked &&
+                  confirm(Locale.Mask.Config.Sync.Confirm)
+                ) {
+                  props.updateMask((mask) => {
+                    mask.syncGlobalConfig = e.currentTarget.checked;
+                    mask.modelConfig = { ...globalConfig.modelConfig };
+                  });
+                }
+              }}
+            ></input>
+          </ListItem>
+        ) : null}
       </List>
 
       <List>
@@ -107,8 +153,8 @@ export function MaskConfig(props: {
 }
 
 function ContextPromptItem(props: {
-  prompt: Message;
-  update: (prompt: Message) => void;
+  prompt: ChatMessage;
+  update: (prompt: ChatMessage) => void;
   remove: () => void;
 }) {
   const [focusingInput, setFocusingInput] = useState(false);
@@ -116,7 +162,7 @@ function ContextPromptItem(props: {
   return (
     <div className={chatStyle["context-prompt-row"]}>
       {!focusingInput && (
-        <select
+        <Select
           value={props.prompt.role}
           className={chatStyle["context-role"]}
           onChange={(e) =>
@@ -131,7 +177,7 @@ function ContextPromptItem(props: {
               {r}
             </option>
           ))}
-        </select>
+        </Select>
       )}
       <Input
         value={props.prompt.content}
@@ -139,7 +185,12 @@ function ContextPromptItem(props: {
         className={chatStyle["context-content"]}
         rows={focusingInput ? 5 : 1}
         onFocus={() => setFocusingInput(true)}
-        onBlur={() => setFocusingInput(false)}
+        onBlur={() => {
+          setFocusingInput(false);
+          // If the selection is not removed when the user loses focus, some
+          // extensions like "Translate" will always display a floating bar
+          window?.getSelection()?.removeAllRanges();
+        }}
         onInput={(e) =>
           props.update({
             ...props.prompt,
@@ -160,12 +211,12 @@ function ContextPromptItem(props: {
 }
 
 export function ContextPrompts(props: {
-  context: Message[];
-  updateContext: (updater: (context: Message[]) => void) => void;
+  context: ChatMessage[];
+  updateContext: (updater: (context: ChatMessage[]) => void) => void;
 }) {
   const context = props.context;
 
-  const addContextPrompt = (prompt: Message) => {
+  const addContextPrompt = (prompt: ChatMessage) => {
     props.updateContext((context) => context.push(prompt));
   };
 
@@ -173,7 +224,7 @@ export function ContextPrompts(props: {
     props.updateContext((context) => context.splice(i, 1));
   };
 
-  const updateContextPrompt = (i: number, prompt: Message) => {
+  const updateContextPrompt = (i: number, prompt: ChatMessage) => {
     props.updateContext((context) => (context[i] = prompt));
   };
 
@@ -255,6 +306,11 @@ export function MaskPage() {
               maskStore.create(mask);
             }
           }
+          return;
+        }
+        //if the content is a single mask.
+        if (importMasks.name) {
+          maskStore.create(importMasks);
         }
       } catch {}
     });
@@ -307,7 +363,7 @@ export function MaskPage() {
               autoFocus
               onInput={(e) => onSearch(e.currentTarget.value)}
             />
-            <select
+            <Select
               className={styles["mask-filter-lang"]}
               value={filterLang ?? Locale.Settings.Lang.All}
               onChange={(e) => {
@@ -324,10 +380,10 @@ export function MaskPage() {
               </option>
               {AllLangs.map((lang) => (
                 <option value={lang} key={lang}>
-                  {Locale.Settings.Lang.Options[lang]}
+                  {ALL_LANG_OPTIONS[lang]}
                 </option>
               ))}
-            </select>
+            </Select>
 
             <IconButton
               className={styles["mask-create"]}
@@ -352,7 +408,7 @@ export function MaskPage() {
                     <div className={styles["mask-name"]}>{m.name}</div>
                     <div className={styles["mask-info"] + " one-line"}>
                       {`${Locale.Mask.Item.Info(m.context.length)} / ${
-                        Locale.Settings.Lang.Options[m.lang]
+                        ALL_LANG_OPTIONS[m.lang]
                       } / ${m.modelConfig.model}`}
                     </div>
                   </div>

+ 76 - 0
app/components/message-selector.module.scss

@@ -0,0 +1,76 @@
+.message-selector {
+  .message-filter {
+    display: flex;
+
+    .search-bar {
+      max-width: unset;
+      flex-grow: 1;
+      margin-right: 10px;
+    }
+
+    .actions {
+      display: flex;
+
+      button:not(:last-child) {
+        margin-right: 10px;
+      }
+    }
+
+    @media screen and (max-width: 600px) {
+      flex-direction: column;
+
+      .search-bar {
+        margin-right: 0;
+      }
+
+      .actions {
+        margin-top: 20px;
+
+        button {
+          flex-grow: 1;
+        }
+      }
+    }
+  }
+
+  .messages {
+    margin-top: 20px;
+    border-radius: 10px;
+    border: var(--border-in-light);
+    overflow: hidden;
+
+    .message {
+      display: flex;
+      align-items: center;
+      padding: 8px 10px;
+      cursor: pointer;
+
+      &-selected {
+        background-color: var(--second);
+      }
+
+      &:not(:last-child) {
+        border-bottom: var(--border-in-light);
+      }
+
+      .avatar {
+        margin-right: 10px;
+      }
+
+      .body {
+        flex-grow: 1;
+        max-width: calc(100% - 40px);
+
+        .date {
+          font-size: 12px;
+          line-height: 1.2;
+          opacity: 0.5;
+        }
+
+        .content {
+          font-size: 12px;
+        }
+      }
+    }
+  }
+}

+ 215 - 0
app/components/message-selector.tsx

@@ -0,0 +1,215 @@
+import { useEffect, useState } from "react";
+import { ChatMessage, useAppConfig, useChatStore } from "../store";
+import { Updater } from "../typing";
+import { IconButton } from "./button";
+import { Avatar } from "./emoji";
+import { MaskAvatar } from "./mask";
+import Locale from "../locales";
+
+import styles from "./message-selector.module.scss";
+
+function useShiftRange() {
+  const [startIndex, setStartIndex] = useState<number>();
+  const [endIndex, setEndIndex] = useState<number>();
+  const [shiftDown, setShiftDown] = useState(false);
+
+  const onClickIndex = (index: number) => {
+    if (shiftDown && startIndex !== undefined) {
+      setEndIndex(index);
+    } else {
+      setStartIndex(index);
+      setEndIndex(undefined);
+    }
+  };
+
+  useEffect(() => {
+    const onKeyDown = (e: KeyboardEvent) => {
+      if (e.key !== "Shift") return;
+      setShiftDown(true);
+    };
+    const onKeyUp = (e: KeyboardEvent) => {
+      if (e.key !== "Shift") return;
+      setShiftDown(false);
+      setStartIndex(undefined);
+      setEndIndex(undefined);
+    };
+
+    window.addEventListener("keyup", onKeyUp);
+    window.addEventListener("keydown", onKeyDown);
+
+    return () => {
+      window.removeEventListener("keyup", onKeyUp);
+      window.removeEventListener("keydown", onKeyDown);
+    };
+  }, []);
+
+  return {
+    onClickIndex,
+    startIndex,
+    endIndex,
+  };
+}
+
+export function useMessageSelector() {
+  const [selection, setSelection] = useState(new Set<number>());
+  const updateSelection: Updater<Set<number>> = (updater) => {
+    const newSelection = new Set<number>(selection);
+    updater(newSelection);
+    setSelection(newSelection);
+  };
+
+  return {
+    selection,
+    updateSelection,
+  };
+}
+
+export function MessageSelector(props: {
+  selection: Set<number>;
+  updateSelection: Updater<Set<number>>;
+  defaultSelectAll?: boolean;
+  onSelected?: (messages: ChatMessage[]) => void;
+}) {
+  const chatStore = useChatStore();
+  const session = chatStore.currentSession();
+  const isValid = (m: ChatMessage) => m.content && !m.isError && !m.streaming;
+  const messages = session.messages.filter(
+    (m, i) =>
+      m.id && // message must have id
+      isValid(m) &&
+      (i >= session.messages.length - 1 || isValid(session.messages[i + 1])),
+  );
+  const messageCount = messages.length;
+  const config = useAppConfig();
+
+  const [searchInput, setSearchInput] = useState("");
+  const [searchIds, setSearchIds] = useState(new Set<number>());
+  const isInSearchResult = (id: number) => {
+    return searchInput.length === 0 || searchIds.has(id);
+  };
+  const doSearch = (text: string) => {
+    const searchResults = new Set<number>();
+    if (text.length > 0) {
+      messages.forEach((m) =>
+        m.content.includes(text) ? searchResults.add(m.id!) : null,
+      );
+    }
+    setSearchIds(searchResults);
+  };
+
+  // for range selection
+  const { startIndex, endIndex, onClickIndex } = useShiftRange();
+
+  const selectAll = () => {
+    props.updateSelection((selection) =>
+      messages.forEach((m) => selection.add(m.id!)),
+    );
+  };
+
+  useEffect(() => {
+    if (props.defaultSelectAll) {
+      selectAll();
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  useEffect(() => {
+    if (startIndex === undefined || endIndex === undefined) {
+      return;
+    }
+    const [start, end] = [startIndex, endIndex].sort((a, b) => a - b);
+    props.updateSelection((selection) => {
+      for (let i = start; i <= end; i += 1) {
+        selection.add(messages[i].id ?? i);
+      }
+    });
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [startIndex, endIndex]);
+
+  const LATEST_COUNT = 4;
+
+  return (
+    <div className={styles["message-selector"]}>
+      <div className={styles["message-filter"]}>
+        <input
+          type="text"
+          placeholder={Locale.Select.Search}
+          className={styles["filter-item"] + " " + styles["search-bar"]}
+          value={searchInput}
+          onInput={(e) => {
+            setSearchInput(e.currentTarget.value);
+            doSearch(e.currentTarget.value);
+          }}
+        ></input>
+
+        <div className={styles["actions"]}>
+          <IconButton
+            text={Locale.Select.All}
+            bordered
+            className={styles["filter-item"]}
+            onClick={selectAll}
+          />
+          <IconButton
+            text={Locale.Select.Latest}
+            bordered
+            className={styles["filter-item"]}
+            onClick={() =>
+              props.updateSelection((selection) => {
+                selection.clear();
+                messages
+                  .slice(messageCount - LATEST_COUNT)
+                  .forEach((m) => selection.add(m.id!));
+              })
+            }
+          />
+          <IconButton
+            text={Locale.Select.Clear}
+            bordered
+            className={styles["filter-item"]}
+            onClick={() =>
+              props.updateSelection((selection) => selection.clear())
+            }
+          />
+        </div>
+      </div>
+
+      <div className={styles["messages"]}>
+        {messages.map((m, i) => {
+          if (!isInSearchResult(m.id!)) return null;
+
+          return (
+            <div
+              className={`${styles["message"]} ${
+                props.selection.has(m.id!) && styles["message-selected"]
+              }`}
+              key={i}
+              onClick={() => {
+                props.updateSelection((selection) => {
+                  const id = m.id ?? i;
+                  selection.has(id) ? selection.delete(id) : selection.add(id);
+                });
+                onClickIndex(i);
+              }}
+            >
+              <div className={styles["avatar"]}>
+                {m.role === "user" ? (
+                  <Avatar avatar={config.avatar}></Avatar>
+                ) : (
+                  <MaskAvatar mask={session.mask} />
+                )}
+              </div>
+              <div className={styles["body"]}>
+                <div className={styles["date"]}>
+                  {new Date(m.date).toLocaleString()}
+                </div>
+                <div className={`${styles["content"]} one-line`}>
+                  {m.content}
+                </div>
+              </div>
+            </div>
+          );
+        })}
+      </div>
+    </div>
+  );
+}

+ 5 - 5
app/components/model-config.tsx

@@ -2,7 +2,7 @@ import { ALL_MODELS, ModalConfigValidator, ModelConfig } from "../store";
 
 import Locale from "../locales";
 import { InputRange } from "./input-range";
-import { List, ListItem } from "./ui-lib";
+import { List, ListItem, Select } from "./ui-lib";
 
 export function ModelConfigList(props: {
   modelConfig: ModelConfig;
@@ -11,7 +11,7 @@ export function ModelConfigList(props: {
   return (
     <>
       <ListItem title={Locale.Settings.Model}>
-        <select
+        <Select
           value={props.modelConfig.model}
           onChange={(e) => {
             props.updateConfig(
@@ -27,7 +27,7 @@ export function ModelConfigList(props: {
               {v.name}
             </option>
           ))}
-        </select>
+        </Select>
       </ListItem>
       <ListItem
         title={Locale.Settings.Temperature.Title}
@@ -68,8 +68,8 @@ export function ModelConfigList(props: {
         ></input>
       </ListItem>
       <ListItem
-        title={Locale.Settings.PresencePenlty.Title}
-        subTitle={Locale.Settings.PresencePenlty.SubTitle}
+        title={Locale.Settings.PresencePenalty.Title}
+        subTitle={Locale.Settings.PresencePenalty.SubTitle}
       >
         <InputRange
           value={props.modelConfig.presence_penalty?.toFixed(1)}

+ 16 - 6
app/components/new-chat.module.scss

@@ -54,13 +54,13 @@
 
   .actions {
     margin-top: 5vh;
-    margin-bottom: 5vh;
+    margin-bottom: 2vh;
     animation: slide-in ease 0.45s;
     display: flex;
     justify-content: center;
+    font-size: 12px;
 
-    .more {
-      font-size: 12px;
+    .skip {
       margin-left: 10px;
     }
   }
@@ -68,16 +68,26 @@
   .masks {
     flex-grow: 1;
     width: 100%;
-    overflow: hidden;
+    overflow: auto;
     align-items: center;
     padding-top: 20px;
 
+    $linear: linear-gradient(
+      to bottom,
+      rgba(0, 0, 0, 0),
+      rgba(0, 0, 0, 1),
+      rgba(0, 0, 0, 0)
+    );
+
+    -webkit-mask-image: $linear;
+    mask-image: $linear;
+
     animation: slide-in ease 0.5s;
 
     .mask-row {
-      margin-bottom: 10px;
       display: flex;
-      justify-content: center;
+      // justify-content: center;
+      margin-bottom: 10px;
 
       @for $i from 1 to 10 {
         &:nth-child(#{$i * 2}) {

+ 50 - 59
app/components/new-chat.tsx

@@ -27,32 +27,8 @@ function getIntersectionArea(aRect: DOMRect, bRect: DOMRect) {
 }
 
 function MaskItem(props: { mask: Mask; onClick?: () => void }) {
-  const domRef = useRef<HTMLDivElement>(null);
-
-  useEffect(() => {
-    const changeOpacity = () => {
-      const dom = domRef.current;
-      const parent = document.getElementById(SlotID.AppBody);
-      if (!parent || !dom) return;
-
-      const domRect = dom.getBoundingClientRect();
-      const parentRect = parent.getBoundingClientRect();
-      const intersectionArea = getIntersectionArea(domRect, parentRect);
-      const domArea = domRect.width * domRect.height;
-      const ratio = intersectionArea / domArea;
-      const opacity = ratio > 0.9 ? 1 : 0.4;
-      dom.style.opacity = opacity.toString();
-    };
-
-    setTimeout(changeOpacity, 30);
-
-    window.addEventListener("resize", changeOpacity);
-
-    return () => window.removeEventListener("resize", changeOpacity);
-  }, [domRef]);
-
   return (
-    <div className={styles["mask"]} ref={domRef} onClick={props.onClick}>
+    <div className={styles["mask"]} onClick={props.onClick}>
       <MaskAvatar mask={props.mask} />
       <div className={styles["mask-name"] + " one-line"}>{props.mask.name}</div>
     </div>
@@ -63,32 +39,38 @@ function useMaskGroup(masks: Mask[]) {
   const [groups, setGroups] = useState<Mask[][]>([]);
 
   useEffect(() => {
-    const appBody = document.getElementById(SlotID.AppBody);
-    if (!appBody || masks.length === 0) return;
-
-    const rect = appBody.getBoundingClientRect();
-    const maxWidth = rect.width;
-    const maxHeight = rect.height * 0.6;
-    const maskItemWidth = 120;
-    const maskItemHeight = 50;
-
-    const randomMask = () => masks[Math.floor(Math.random() * masks.length)];
-    let maskIndex = 0;
-    const nextMask = () => masks[maskIndex++ % masks.length];
-
-    const rows = Math.ceil(maxHeight / maskItemHeight);
-    const cols = Math.ceil(maxWidth / maskItemWidth);
-
-    const newGroups = new Array(rows)
-      .fill(0)
-      .map((_, _i) =>
-        new Array(cols)
-          .fill(0)
-          .map((_, j) => (j < 1 || j > cols - 2 ? randomMask() : nextMask())),
-      );
+    const computeGroup = () => {
+      const appBody = document.getElementById(SlotID.AppBody);
+      if (!appBody || masks.length === 0) return;
+
+      const rect = appBody.getBoundingClientRect();
+      const maxWidth = rect.width;
+      const maxHeight = rect.height * 0.6;
+      const maskItemWidth = 120;
+      const maskItemHeight = 50;
+
+      const randomMask = () => masks[Math.floor(Math.random() * masks.length)];
+      let maskIndex = 0;
+      const nextMask = () => masks[maskIndex++ % masks.length];
+
+      const rows = Math.ceil(maxHeight / maskItemHeight);
+      const cols = Math.ceil(maxWidth / maskItemWidth);
+
+      const newGroups = new Array(rows)
+        .fill(0)
+        .map((_, _i) =>
+          new Array(cols)
+            .fill(0)
+            .map((_, j) => (j < 1 || j > cols - 2 ? randomMask() : nextMask())),
+        );
+
+      setGroups(newGroups);
+    };
 
-    setGroups(newGroups);
+    computeGroup();
 
+    window.addEventListener("resize", computeGroup);
+    return () => window.removeEventListener("resize", computeGroup);
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, []);
 
@@ -105,6 +87,8 @@ export function NewChat() {
   const navigate = useNavigate();
   const config = useAppConfig();
 
+  const maskRef = useRef<HTMLDivElement>(null);
+
   const { state } = useLocation();
 
   const startChat = (mask?: Mask) => {
@@ -123,6 +107,13 @@ export function NewChat() {
     },
   });
 
+  useEffect(() => {
+    if (maskRef.current) {
+      maskRef.current.scrollLeft =
+        (maskRef.current.scrollWidth - maskRef.current.clientWidth) / 2;
+    }
+  }, [groups]);
+
   return (
     <div className={styles["new-chat"]}>
       <div className={styles["mask-header"]}>
@@ -162,24 +153,24 @@ export function NewChat() {
 
       <div className={styles["actions"]}>
         <IconButton
-          text={Locale.NewChat.Skip}
-          onClick={() => startChat()}
-          icon={<LightningIcon />}
-          type="primary"
-          shadow
-        />
-
-        <IconButton
-          className={styles["more"]}
           text={Locale.NewChat.More}
           onClick={() => navigate(Path.Masks)}
           icon={<EyeIcon />}
           bordered
           shadow
         />
+
+        <IconButton
+          text={Locale.NewChat.Skip}
+          onClick={() => startChat()}
+          icon={<LightningIcon />}
+          type="primary"
+          shadow
+          className={styles["skip"]}
+        />
       </div>
 
-      <div className={styles["masks"]}>
+      <div className={styles["masks"]} ref={maskRef}>
         {groups.map((masks, i) => (
           <div key={i} className={styles["mask-row"]}>
             {masks.map((mask, index) => (

+ 46 - 14
app/components/settings.tsx

@@ -1,4 +1,4 @@
-import { useState, useEffect, useMemo, HTMLProps, useRef } from "react";
+import { useState, useEffect, useMemo } from "react";
 
 import styles from "./settings.module.scss";
 
@@ -10,7 +10,15 @@ import ClearIcon from "../icons/clear.svg";
 import LoadingIcon from "../icons/three-dots.svg";
 import EditIcon from "../icons/edit.svg";
 import EyeIcon from "../icons/eye.svg";
-import { Input, List, ListItem, Modal, PasswordInput, Popover } from "./ui-lib";
+import {
+  Input,
+  List,
+  ListItem,
+  Modal,
+  PasswordInput,
+  Popover,
+  Select,
+} from "./ui-lib";
 import { ModelConfigList } from "./model-config";
 
 import { IconButton } from "./button";
@@ -23,7 +31,12 @@ import {
   useAppConfig,
 } from "../store";
 
-import Locale, { AllLangs, changeLang, getLang } from "../locales";
+import Locale, {
+  AllLangs,
+  ALL_LANG_OPTIONS,
+  changeLang,
+  getLang,
+} from "../locales";
 import { copyToClipboard } from "../utils";
 import Link from "next/link";
 import { Path, UPDATE_URL } from "../constant";
@@ -32,6 +45,7 @@ import { ErrorBoundary } from "./error";
 import { InputRange } from "./input-range";
 import { useNavigate } from "react-router-dom";
 import { Avatar, AvatarPicker } from "./emoji";
+import { getClientConfig } from "../config/client";
 
 function EditPromptModal(props: { id: number; onClose: () => void }) {
   const promptStore = usePromptStore();
@@ -272,9 +286,12 @@ export function Settings() {
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, []);
 
+  const clientConfig = useMemo(() => getClientConfig(), []);
+  const showAccessCode = enabledAccessControl && !clientConfig?.isApp;
+
   return (
     <ErrorBoundary>
-      <div className="window-header">
+      <div className="window-header" data-tauri-drag-region>
         <div className="window-header-title">
           <div className="window-header-main-title">
             {Locale.Settings.Title}
@@ -368,7 +385,7 @@ export function Settings() {
           </ListItem>
 
           <ListItem title={Locale.Settings.SendKey}>
-            <select
+            <Select
               value={config.submitKey}
               onChange={(e) => {
                 updateConfig(
@@ -382,11 +399,11 @@ export function Settings() {
                   {v}
                 </option>
               ))}
-            </select>
+            </Select>
           </ListItem>
 
           <ListItem title={Locale.Settings.Theme}>
-            <select
+            <Select
               value={config.theme}
               onChange={(e) => {
                 updateConfig(
@@ -399,11 +416,11 @@ export function Settings() {
                   {v}
                 </option>
               ))}
-            </select>
+            </Select>
           </ListItem>
 
           <ListItem title={Locale.Settings.Lang.Name}>
-            <select
+            <Select
               value={getLang()}
               onChange={(e) => {
                 changeLang(e.target.value as any);
@@ -411,10 +428,10 @@ export function Settings() {
             >
               {AllLangs.map((lang) => (
                 <option value={lang} key={lang}>
-                  {Locale.Settings.Lang.Options[lang]}
+                  {ALL_LANG_OPTIONS[lang]}
                 </option>
               ))}
-            </select>
+            </Select>
           </ListItem>
 
           <ListItem
@@ -471,7 +488,7 @@ export function Settings() {
         </List>
 
         <List>
-          {enabledAccessControl ? (
+          {showAccessCode ? (
             <ListItem
               title={Locale.Settings.AccessCode.Title}
               subTitle={Locale.Settings.AccessCode.SubTitle}
@@ -528,6 +545,21 @@ export function Settings() {
               />
             )}
           </ListItem>
+
+          {!accessStore.hideUserApiKey ? (
+            <ListItem
+              title={Locale.Settings.Endpoint.Title}
+              subTitle={Locale.Settings.Endpoint.SubTitle}
+            >
+              <input
+                type="text"
+                value={accessStore.openaiUrl}
+                onChange={(e) =>
+                  accessStore.updateOpenAiUrl(e.currentTarget.value)
+                }
+              ></input>
+            </ListItem>
+          ) : null}
         </List>
 
         <List>
@@ -565,9 +597,9 @@ export function Settings() {
         <List>
           <ModelConfigList
             modelConfig={config.modelConfig}
-            updateConfig={(upater) => {
+            updateConfig={(updater) => {
               const modelConfig = { ...config.modelConfig };
-              upater(modelConfig);
+              updater(modelConfig);
               config.update((config) => (config.modelConfig = modelConfig));
             }}
           />

+ 4 - 2
app/components/sidebar.tsx

@@ -118,8 +118,10 @@ export function SideBar(props: { className?: string }) {
         shouldNarrow && styles["narrow-sidebar"]
       }`}
     >
-      <div className={styles["sidebar-header"]}>
-        <div className={styles["sidebar-title"]}>ChatGPT Next</div>
+      <div className={styles["sidebar-header"]} data-tauri-drag-region>
+        <div className={styles["sidebar-title"]} data-tauri-drag-region>
+          ChatGPT Next
+        </div>
         <div className={styles["sidebar-sub-title"]}>
           Build your own AI assistant.
         </div>

+ 25 - 0
app/components/ui-lib.module.scss

@@ -203,3 +203,28 @@
   resize: none;
   min-width: 50px;
 }
+
+.select-with-icon {
+  position: relative;
+  max-width: fit-content;
+  
+  .select-with-icon-select {
+    height: 100%;
+    border: var(--border-in-light);
+    padding: 10px 25px 10px 10px;
+    border-radius: 10px;
+    appearance: none;
+    cursor: pointer;
+    background-color: var(--white);
+    color: var(--black);
+    text-align: center;
+  }
+
+  .select-with-icon-icon {
+    position: absolute;
+    top: 50%;
+    right: 10px;
+    transform: translateY(-50%);
+    pointer-events: none;
+  }
+}

+ 19 - 1
app/components/ui-lib.tsx

@@ -3,6 +3,7 @@ import LoadingIcon from "../icons/three-dots.svg";
 import CloseIcon from "../icons/close.svg";
 import EyeIcon from "../icons/eye.svg";
 import EyeOffIcon from "../icons/eye-off.svg";
+import DownIcon from "../icons/down.svg";
 
 import { createRoot } from "react-dom/client";
 import React, { HTMLProps, useEffect, useState } from "react";
@@ -41,7 +42,7 @@ export function ListItem(props: {
   className?: string;
 }) {
   return (
-    <div className={styles["list-item"] + ` ${props.className}`}>
+    <div className={styles["list-item"] + ` ${props.className || ""}`}>
       <div className={styles["list-header"]}>
         {props.icon && <div className={styles["list-icon"]}>{props.icon}</div>}
         <div className={styles["list-item-title"]}>
@@ -244,3 +245,20 @@ export function PasswordInput(props: HTMLProps<HTMLInputElement>) {
     </div>
   );
 }
+
+export function Select(
+  props: React.DetailedHTMLProps<
+    React.SelectHTMLAttributes<HTMLSelectElement>,
+    HTMLSelectElement
+  >,
+) {
+  const { className, children, ...otherProps } = props;
+  return (
+    <div className={`${styles["select-with-icon"]} ${className}`}>
+      <select className={styles["select-with-icon-select"]} {...otherProps}>
+        {children}
+      </select>
+      <DownIcon className={styles["select-with-icon-icon"]} />
+    </div>
+  );
+}

+ 17 - 13
app/config/build.ts

@@ -1,16 +1,3 @@
-const COMMIT_ID: string = (() => {
-  try {
-    const childProcess = require("child_process");
-    return childProcess
-      .execSync('git log -1 --format="%at000" --date=unix')
-      .toString()
-      .trim();
-  } catch (e) {
-    console.error("[Build Config] No git or not from git repo.");
-    return "unknown";
-  }
-})();
-
 export const getBuildConfig = () => {
   if (typeof process === "undefined") {
     throw Error(
@@ -18,7 +5,24 @@ export const getBuildConfig = () => {
     );
   }
 
+  const COMMIT_ID: string = (() => {
+    try {
+      const childProcess = require("child_process");
+      return childProcess
+        .execSync('git log -1 --format="%at000" --date=unix')
+        .toString()
+        .trim();
+    } catch (e) {
+      console.error("[Build Config] No git or not from git repo.");
+      return "unknown";
+    }
+  })();
+
   return {
     commitId: COMMIT_ID,
+    buildMode: process.env.BUILD_MODE ?? "standalone",
+    isApp: !!process.env.BUILD_APP,
   };
 };
+
+export type BuildConfig = ReturnType<typeof getBuildConfig>;

+ 27 - 0
app/config/client.ts

@@ -0,0 +1,27 @@
+import { BuildConfig, getBuildConfig } from "./build";
+
+export function getClientConfig() {
+  if (typeof document !== "undefined") {
+    // client side
+    return JSON.parse(queryMeta("config")) as BuildConfig;
+  }
+
+  if (typeof process !== "undefined") {
+    // server side
+    return getBuildConfig();
+  }
+}
+
+function queryMeta(key: string, defaultValue?: string): string {
+  let ret: string;
+  if (document) {
+    const meta = document.head.querySelector(
+      `meta[name='${key}']`,
+    ) as HTMLMetaElement;
+    ret = meta?.content ?? "";
+  } else {
+    ret = defaultValue ?? "";
+  }
+
+  return ret;
+}

+ 4 - 0
app/config/server.ts

@@ -5,10 +5,13 @@ declare global {
     interface ProcessEnv {
       OPENAI_API_KEY?: string;
       CODE?: string;
+      BASE_URL?: string;
       PROXY_URL?: string;
       VERCEL?: string;
       HIDE_USER_API_KEY?: string; // disable user's api key input
       DISABLE_GPT4?: string; // allow user to use gpt-4 or not
+      BUILD_MODE?: "standalone" | "export";
+      BUILD_APP?: string; // is building desktop app
     }
   }
 }
@@ -38,6 +41,7 @@ export const getServerSideConfig = () => {
     code: process.env.CODE,
     codes: ACCESS_CODES,
     needCode: ACCESS_CODES.size > 0,
+    baseUrl: process.env.BASE_URL,
     proxyUrl: process.env.PROXY_URL,
     isVercel: !!process.env.VERCEL,
     hideUserApiKey: !!process.env.HIDE_USER_API_KEY,

+ 12 - 0
app/constant.ts

@@ -6,6 +6,7 @@ export const UPDATE_URL = `${REPO_URL}#keep-updated`;
 export const FETCH_COMMIT_URL = `https://api.github.com/repos/${OWNER}/${REPO}/commits?per_page=1`;
 export const FETCH_TAG_URL = `https://api.github.com/repos/${OWNER}/${REPO}/tags?per_page=1`;
 export const RUNTIME_CONFIG_DOM = "danger-runtime-config";
+export const DEFAULT_API_HOST = "https://chatgpt1.nextweb.fun/api/proxy";
 
 export enum Path {
   Home = "/",
@@ -13,6 +14,7 @@ export enum Path {
   Settings = "/settings",
   NewChat = "/new-chat",
   Masks = "/masks",
+  Auth = "/auth",
 }
 
 export enum SlotID {
@@ -40,3 +42,13 @@ export const NARROW_SIDEBAR_WIDTH = 100;
 export const ACCESS_CODE_PREFIX = "ak-";
 
 export const LAST_INPUT_KEY = "last-input";
+
+export const REQUEST_TIMEOUT_MS = 60000;
+
+export const EXPORT_MESSAGE_CLASS_NAME = "export-markdown";
+
+export const OpenaiPath = {
+  ChatPath: "v1/chat/completions",
+  UsagePath: "dashboard/billing/usage",
+  SubsPath: "dashboard/billing/subscription",
+};

+ 1 - 23
app/icons/add.svg

@@ -1,23 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16"
-  height="16" viewBox="0 0 16 16" fill="none">
-  <defs>
-    <rect id="path_0" x="0" y="0" width="16" height="16" />
-  </defs>
-  <g opacity="1" transform="translate(0 0)  rotate(0 8 8)">
-    <mask id="bg-mask-0" fill="white">
-      <use xlink:href="#path_0"></use>
-    </mask>
-    <g mask="url(#bg-mask-0)">
-      <path id="路径 1"
-        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
-        transform="translate(1.3333333333333333 1.3333333333333333)  rotate(0 6.666666666666666 6.666666666666666)"
-        d="M13.33,6.67C13.33,2.98 10.35,0 6.67,0C2.98,0 0,2.98 0,6.67C0,10.35 2.98,13.33 6.67,13.33C10.35,13.33 13.33,10.35 13.33,6.67Z " />
-      <path id="路径 2"
-        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
-        transform="translate(8 5.333333333333333)  rotate(0 0 2.6666666666666665)" d="M0,0L0,5.33 " />
-      <path id="路径 3"
-        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
-        transform="translate(5.333333333333333 8)  rotate(0 2.6666666666666665 0)" d="M0,0L5.33,0 " />
-    </g>
-  </g>
-</svg>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M13.33,6.67C13.33,2.98 10.35,0 6.67,0C2.98,0 0,2.98 0,6.67C0,10.35 2.98,13.33 6.67,13.33C10.35,13.33 13.33,10.35 13.33,6.67Z" transform="translate(1.3333333333333333 1.3333333333333333) rotate(0 6.666666666666666 6.666666666666666)"/><path id="路径 2" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0L0,5.33" transform="translate(8 5.333333333333333) rotate(0 0 2.6666666666666665)"/><path id="路径 3" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0L5.33,0" transform="translate(5.333333333333333 8) rotate(0 2.6666666666666665 0)"/></g></g></svg>

File diff suppressed because it is too large
+ 0 - 22
app/icons/black-bot.svg


BIN
app/icons/bot.png


+ 1 - 1
app/icons/bottom.svg

@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs><g opacity="1" transform="translate(0 0)  rotate(0 8 8)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path  id="路径 1" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(4 4)  rotate(0 4 2)" d="M8,0L4,4L0,0 " /><path  id="路径 2" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(4 8)  rotate(0 4 2)" d="M8,0L4,4L0,0 " /></g></g></svg>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M8,0L4,4L0,0" transform="translate(4 4) rotate(0 4 2)"/><path id="路径 2" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M8,0L4,4L0,0" transform="translate(4 8) rotate(0 4 2)"/></g></g></svg>

+ 1 - 25
app/icons/brain.svg

@@ -1,25 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16"
-  height="16" viewBox="0 0 16 16" fill="none">
-  <defs>
-    <rect id="path_0" x="0" y="0" width="16" height="16" />
-  </defs>
-  <g opacity="1" transform="translate(0 0)  rotate(0 8 8)">
-    <mask id="bg-mask-0" fill="white">
-      <use xlink:href="#path_0"></use>
-    </mask>
-    <g mask="url(#bg-mask-0)">
-      <path id="路径 1"
-        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
-        transform="translate(1.3333323286384866 1.3334133333333331)  rotate(0 6.66666716901409 6.66666)"
-        d="M5.01,13.33C4.69,12.27 4.19,11.47 3.53,10.95C2.55,10.17 0.97,10.65 0.39,9.84C-0.19,9.04 0.8,7.55 1.15,6.67C1.49,5.79 -0.18,5.48 0.02,5.23C0.15,5.07 0.99,4.59 2.55,3.79C3,1.26 4.63,0 7.47,0C11.71,0 13.33,3.6 13.33,5.89C13.33,8.18 11.37,10.65 8.58,11.18C8.33,11.55 8.69,12.26 9.66,13.33 " />
-      <path id="路径 2"
-        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
-        transform="translate(6.374029736345404 3.9567867125879106)  rotate(0 2.8215982497276006 2.4327734241007346)"
-        d="M2.1,3.33C1.91,4.42 2.14,4.93 2.79,4.86C3.44,4.79 3.84,4.52 3.97,4.05C4.99,4.33 5.54,4.09 5.63,3.33C5.75,2.18 5.13,1.26 4.88,1.26C4.63,1.26 3.97,1.23 3.97,0.88C3.97,0.52 3.2,0.33 2.5,0.33C1.81,0.33 2.23,-0.14 1.27,0.04C0.64,0.17 0.26,0.44 0.13,0.88C-0.09,1.72 -0.03,2.31 0.32,2.66C0.67,3 1.26,3.22 2.1,3.33Z " />
-      <path id="路径 3"
-        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
-        transform="translate(8.193033333333332 8.500066666666665)  rotate(0 0.9868499999999998 1.1846833333333333)"
-        d="M1.97,0C1.63,0.21 1.17,0.56 0.97,0.83C0.48,1.52 0.09,1.93 0,2.37 " />
-    </g>
-  </g>
-</svg>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M5.01,13.33C4.69,12.27 4.19,11.47 3.53,10.95C2.55,10.17 0.97,10.65 0.39,9.84C-0.19,9.04 0.8,7.55 1.15,6.67C1.49,5.79 -0.18,5.48 0.02,5.23C0.15,5.07 0.99,4.59 2.55,3.79C3,1.26 4.63,0 7.47,0C11.71,0 13.33,3.6 13.33,5.89C13.33,8.18 11.37,10.65 8.58,11.18C8.33,11.55 8.69,12.26 9.66,13.33" transform="translate(1.3333323286384866 1.3334133333333331) rotate(0 6.66666716901409 6.66666)"/><path id="路径 2" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M2.1,3.33C1.91,4.42 2.14,4.93 2.79,4.86C3.44,4.79 3.84,4.52 3.97,4.05C4.99,4.33 5.54,4.09 5.63,3.33C5.75,2.18 5.13,1.26 4.88,1.26C4.63,1.26 3.97,1.23 3.97,0.88C3.97,0.52 3.2,0.33 2.5,0.33C1.81,0.33 2.23,-0.14 1.27,0.04C0.64,0.17 0.26,0.44 0.13,0.88C-0.09,1.72 -0.03,2.31 0.32,2.66C0.67,3 1.26,3.22 2.1,3.33Z" transform="translate(6.374029736345404 3.9567867125879106) rotate(0 2.8215982497276006 2.4327734241007346)"/><path id="路径 3" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M1.97,0C1.63,0.21 1.17,0.56 0.97,0.83C0.48,1.52 0.09,1.93 0,2.37" transform="translate(8.193033333333332 8.500066666666665) rotate(0 0.9868499999999998 1.1846833333333333)"/></g></g></svg>

File diff suppressed because it is too large
+ 0 - 0
app/icons/break.svg


File diff suppressed because it is too large
+ 0 - 0
app/icons/chat-settings.svg


BIN
app/icons/chatgpt.png


+ 1 - 1
app/icons/copy.svg

@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs><g opacity="1" transform="translate(0 0)  rotate(0 8 8)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path  id="路径 1" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(4.333333333333333 1.6666666666666665)  rotate(0 5 5)" d="M0,2.48L0,0.94C0,0.42 0.42,0 0.94,0L9.06,0C9.58,0 10,0.42 10,0.94L10,9.06C10,9.58 9.58,10 9.06,10L7.51,10 " /><path  id="路径 2" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(1.6666666666666665 4.333333333333333)  rotate(0 5 5)" d="M0.94,0C0.42,0 0,0.42 0,0.94L0,9.06C0,9.58 0.42,10 0.94,10L9.06,10C9.58,10 10,9.58 10,9.06L10,0.94C10,0.42 9.58,0 9.06,0L0.94,0Z " /></g></g></svg>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,2.48L0,0.94C0,0.42 0.42,0 0.94,0L9.06,0C9.58,0 10,0.42 10,0.94L10,9.06C10,9.58 9.58,10 9.06,10L7.51,10" transform="translate(4.333333333333333 1.6666666666666665) rotate(0 5 5)"/><path id="路径 2" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0.94,0C0.42,0 0,0.42 0,0.94L0,9.06C0,9.58 0.42,10 0.94,10L9.06,10C9.58,10 10,9.58 10,9.06L10,0.94C10,0.42 9.58,0 9.06,0L0.94,0Z" transform="translate(1.6666666666666665 4.333333333333333) rotate(0 5 5)"/></g></g></svg>

File diff suppressed because it is too large
+ 0 - 8
app/icons/delete.svg


+ 1 - 0
app/icons/down.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(-90 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M4,8L0,4L4,0" transform="translate(6.333333333333333 4) rotate(0 2 4)"/></g></g></svg>

+ 1 - 1
app/icons/download.svg

@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs><g opacity="1" transform="translate(0 0)  rotate(0 8 8)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path  id="路径 1" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(2 2)  rotate(0 6 6)" d="M1,12L11,12C11.55,12 12,11.55 12,11L12,1C12,0.45 11.55,0 11,0L1,0C0.45,0 0,0.45 0,1L0,11C0,11.55 0.45,12 1,12Z " /><path  id="路径 2" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(1.3333333333333333 10.333333333333332)  rotate(0 6.666666666666666 0.6666666666666666)" d="M0,0L3.67,0L4.33,1.33L9,1.33L9.67,0L13.33,0 " /><path  id="路径 3" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(14 8.666666666666666)  rotate(0 0 1.6666666666666665)" d="M0,3.33L0,0 " /><path  id="路径 4" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(6 7.333333333333333)  rotate(0 2 1)" d="M0,0L2,2L4,0 " /><path  id="路径 5" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(8 4)  rotate(0 0 2.6666666666666665)" d="M0,5.33L0,0 " /><path  id="路径 6" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(2 8.666666666666666)  rotate(0 0 1.6666666666666665)" d="M0,3.33L0,0 " /></g></g></svg>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M1,12L11,12C11.55,12 12,11.55 12,11L12,1C12,0.45 11.55,0 11,0L1,0C0.45,0 0,0.45 0,1L0,11C0,11.55 0.45,12 1,12Z" transform="translate(2 2) rotate(0 6 6)"/><path id="路径 2" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0L3.67,0L4.33,1.33L9,1.33L9.67,0L13.33,0" transform="translate(1.3333333333333333 10.333333333333332) rotate(0 6.666666666666666 0.6666666666666666)"/><path id="路径 3" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,3.33L0,0" transform="translate(14 8.666666666666666) rotate(0 0 1.6666666666666665)"/><path id="路径 4" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0L2,2L4,0" transform="translate(6 7.333333333333333) rotate(0 2 1)"/><path id="路径 5" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,5.33L0,0" transform="translate(8 4) rotate(0 0 2.6666666666666665)"/><path id="路径 6" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,3.33L0,0" transform="translate(2 8.666666666666666) rotate(0 0 1.6666666666666665)"/></g></g></svg>

+ 1 - 1
app/icons/export.svg

@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs><g opacity="1" transform="translate(0 0)  rotate(0 8 8)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path  id="路径 1" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(1.2400716519614834 2.3333321805983163)  rotate(0 6.785117896431597 4.552683909700841)" d="M12.27,9.11C13.36,8.34 13.83,6.94 13.43,5.67C13.02,4.39 11.78,3.69 10.44,3.69L9.67,3.69C9.16,1.72 7.5,0.27 5.47,0.03C3.45,-0.2 1.5,0.84 0.56,2.64C-0.38,4.45 -0.11,6.64 1.23,8.17 " /><path  id="路径 2" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(8 7.666666666666666)  rotate(0 0.00140000000000029 3)" d="M0,6L0,0 " /><path  id="路径 3" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(5.8786 11.5454)  rotate(0 2.1213333333333333 1.0606666666666662)" d="M4.24,0L2.12,2.12L0,0 " /></g></g></svg>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M12.27,9.11C13.36,8.34 13.83,6.94 13.43,5.67C13.02,4.39 11.78,3.69 10.44,3.69L9.67,3.69C9.16,1.72 7.5,0.27 5.47,0.03C3.45,-0.2 1.5,0.84 0.56,2.64C-0.38,4.45 -0.11,6.64 1.23,8.17" transform="translate(1.2400716519614834 2.3333321805983163) rotate(0 6.785117896431597 4.552683909700841)"/><path id="路径 2" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,6L0,0" transform="translate(8 7.666666666666666) rotate(0 0.00140000000000029 3)"/><path id="路径 3" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M4.24,0L2.12,2.12L0,0" transform="translate(5.8786 11.5454) rotate(0 2.1213333333333333 1.0606666666666662)"/></g></g></svg>

+ 1 - 29
app/icons/github.svg

@@ -1,29 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16"
-  height="16" viewBox="0 0 16 16" fill="none">
-  <defs>
-    <rect id="path_0" x="0" y="0" width="16" height="16" />
-  </defs>
-  <g opacity="1" transform="translate(0 0)  rotate(0 8 8)">
-    <mask id="bg-mask-0" fill="white">
-      <use xlink:href="#path_0"></use>
-    </mask>
-    <g mask="url(#bg-mask-0)">
-      <path id="路径 1"
-        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
-        transform="translate(2.6666666666666665 1.644694921083138)  rotate(0 5.333333333333333 4.287969206125098)"
-        d="M7.11,8.51C7.92,8.35 8.64,8.06 9.21,7.64C10.17,6.91 10.67,5.79 10.67,4.69C10.67,3.91 10.37,3.19 9.86,2.58C9.58,2.24 10.41,-0.31 9.67,0.03C8.94,0.37 7.86,1.13 7.29,0.97C6.68,0.79 6.02,0.69 5.33,0.69C4.73,0.69 4.16,0.76 3.62,0.9C2.83,1.1 2.09,0.36 1.33,0.03C0.58,-0.29 0.99,2.34 0.77,2.62C0.28,3.22 0,3.93 0,4.69C0,5.79 0.6,6.91 1.56,7.64C2.21,8.12 3.01,8.42 3.91,8.58 " />
-      <path id="路径 2"
-        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
-        transform="translate(6.000666666666666 10.220633333333332)  rotate(0 0.2896166666666667 2.058116666666666)"
-        d="M0.58,0C0.19,0.43 0,0.83 0,1.21C0,1.59 0,2.56 0,4.12 " />
-      <path id="路径 3"
-        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
-        transform="translate(9.781533333333332 10.158866666666666)  rotate(0 0.2744333333333332 2.0890166666666663)"
-        d="M0,0C0.37,0.48 0.55,0.91 0.55,1.29C0.55,1.68 0.55,2.64 0.55,4.18 " />
-      <path id="路径 4"
-        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
-        transform="translate(2 10.405166666666666)  rotate(0 2.0004 1.050416666666667)"
-        d="M0,0C0.3,0.04 0.52,0.17 0.67,0.41C0.88,0.77 1.69,2.1 2.61,2.1C3.22,2.1 3.68,2.1 4,2.1 " />
-    </g>
-  </g>
-</svg>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M7.11,8.51C7.92,8.35 8.64,8.06 9.21,7.64C10.17,6.91 10.67,5.79 10.67,4.69C10.67,3.91 10.37,3.19 9.86,2.58C9.58,2.24 10.41,-0.31 9.67,0.03C8.94,0.37 7.86,1.13 7.29,0.97C6.68,0.79 6.02,0.69 5.33,0.69C4.73,0.69 4.16,0.76 3.62,0.9C2.83,1.1 2.09,0.36 1.33,0.03C0.58,-0.29 0.99,2.34 0.77,2.62C0.28,3.22 0,3.93 0,4.69C0,5.79 0.6,6.91 1.56,7.64C2.21,8.12 3.01,8.42 3.91,8.58" transform="translate(2.6666666666666665 1.644694921083138) rotate(0 5.333333333333333 4.287969206125098)"/><path id="路径 2" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0.58,0C0.19,0.43 0,0.83 0,1.21C0,1.59 0,2.56 0,4.12" transform="translate(6.000666666666666 10.220633333333332) rotate(0 0.2896166666666667 2.058116666666666)"/><path id="路径 3" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0C0.37,0.48 0.55,0.91 0.55,1.29C0.55,1.68 0.55,2.64 0.55,4.18" transform="translate(9.781533333333332 10.158866666666666) rotate(0 0.2744333333333332 2.0890166666666663)"/><path id="路径 4" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0C0.3,0.04 0.52,0.17 0.67,0.41C0.88,0.77 1.69,2.1 2.61,2.1C3.22,2.1 3.68,2.1 4,2.1" transform="translate(2 10.405166666666666) rotate(0 2.0004 1.050416666666667)"/></g></g></svg>

+ 1 - 1
app/icons/left.svg

@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs><g opacity="1" transform="translate(0 0)  rotate(0 8 8)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path  id="路径 1" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(6.333333333333333 4)  rotate(0 2 4)" d="M4,8L0,4L4,0 " /></g></g></svg>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M4,8L0,4L4,0" transform="translate(6.333333333333333 4) rotate(0 2 4)"/></g></g></svg>

File diff suppressed because it is too large
+ 0 - 0
app/icons/mask.svg


+ 1 - 41
app/icons/max.svg

@@ -1,41 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16"
-  height="16" viewBox="0 0 16 16" fill="none">
-  <defs>
-    <rect id="path_0" x="0" y="0" width="16" height="16" />
-  </defs>
-  <g opacity="1" transform="translate(0 0)  rotate(0 8 8)">
-    <mask id="bg-mask-0" fill="white">
-      <use xlink:href="#path_0"></use>
-    </mask>
-    <g mask="url(#bg-mask-0)">
-      <path id="路径 1"
-        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
-        transform="translate(2 2)  rotate(0 1.6666666666666665 1.6499166666666665)"
-        d="M0,0L3.33,3.3 " />
-      <path id="路径 2"
-        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
-        transform="translate(2 10.666666666666666)  rotate(0 1.6666666666666665 1.6499166666666671)"
-        d="M0,3.3L3.33,0 " />
-      <path id="路径 3"
-        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
-        transform="translate(10.700199999999999 10.666666666666666)  rotate(0 1.6499166666666671 1.6499166666666671)"
-        d="M3.3,3.3L0,0 " />
-      <path id="路径 4"
-        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
-        transform="translate(10.666666666666666 2)  rotate(0 1.6499166666666671 1.6499166666666665)"
-        d="M3.3,0L0,3.3 " />
-      <path id="路径 5"
-        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
-        transform="translate(11 2)  rotate(0 1.5 1.5)" d="M0,0L3,0L3,3 " />
-      <path id="路径 6"
-        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
-        transform="translate(11 11)  rotate(0 1.5 1.5)" d="M3,0L3,3L0,3 " />
-      <path id="路径 7"
-        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
-        transform="translate(2 11)  rotate(0 1.5 1.5)" d="M3,3L0,3L0,0 " />
-      <path id="路径 8"
-        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
-        transform="translate(2 2)  rotate(0 1.5 1.5)" d="M0,3L0,0L3,0 " />
-    </g>
-  </g>
-</svg>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0L3.33,3.3" transform="translate(2 2) rotate(0 1.6666666666666665 1.6499166666666665)"/><path id="路径 2" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,3.3L3.33,0" transform="translate(2 10.666666666666666) rotate(0 1.6666666666666665 1.6499166666666671)"/><path id="路径 3" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M3.3,3.3L0,0" transform="translate(10.700199999999999 10.666666666666666) rotate(0 1.6499166666666671 1.6499166666666671)"/><path id="路径 4" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M3.3,0L0,3.3" transform="translate(10.666666666666666 2) rotate(0 1.6499166666666671 1.6499166666666665)"/><path id="路径 5" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0L3,0L3,3" transform="translate(11 2) rotate(0 1.5 1.5)"/><path id="路径 6" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M3,0L3,3L0,3" transform="translate(11 11) rotate(0 1.5 1.5)"/><path id="路径 7" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M3,3L0,3L0,0" transform="translate(2 11) rotate(0 1.5 1.5)"/><path id="路径 8" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,3L0,0L3,0" transform="translate(2 2) rotate(0 1.5 1.5)"/></g></g></svg>

+ 1 - 25
app/icons/menu.svg

@@ -1,25 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16"
-  height="16" viewBox="0 0 16 16" fill="none">
-  <defs>
-    <rect id="path_0" x="0" y="0" width="16" height="16" />
-  </defs>
-  <g opacity="1" transform="translate(0 0)  rotate(0 8 8)">
-    <mask id="bg-mask-0" fill="white">
-      <use xlink:href="#path_0"></use>
-    </mask>
-    <g mask="url(#bg-mask-0)">
-      <path id="路径 1"
-        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
-        transform="translate(2.649903333333333 3.983233333333333)  rotate(0 5.333331666666666 0)"
-        d="M0,0L10.67,0 " />
-      <path id="路径 2"
-        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
-        transform="translate(2.649903333333333 7.983233333333333)  rotate(0 5.333331666666666 0)"
-        d="M0,0L10.67,0 " />
-      <path id="路径 3"
-        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
-        transform="translate(2.649903333333333 11.983233333333333)  rotate(0 5.333331666666666 0)"
-        d="M0,0L10.67,0 " />
-    </g>
-  </g>
-</svg>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0L10.67,0" transform="translate(2.649903333333333 3.983233333333333) rotate(0 5.333331666666666 0)"/><path id="路径 2" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0L10.67,0" transform="translate(2.649903333333333 7.983233333333333) rotate(0 5.333331666666666 0)"/><path id="路径 3" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0L10.67,0" transform="translate(2.649903333333333 11.983233333333333) rotate(0 5.333331666666666 0)"/></g></g></svg>

File diff suppressed because it is too large
+ 0 - 45
app/icons/min.svg


File diff suppressed because it is too large
+ 0 - 0
app/icons/plugin.svg


+ 1 - 1
app/icons/prompt.svg

@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs><g opacity="1" transform="translate(0 0)  rotate(0 8 8)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path  id="分组 1" style="stroke:#333333; stroke-width:1.3; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(5.333333333333333 1.3333333333333333)  rotate(0 4.666666666666666 4.666666666666666)" d="M1.36683 1.36683L2.77683 2.77683 M4.66667 0L4.66667 2 M4.66667 2L4.66667 0 M7.9623 1.36683L6.5523 2.77683 M6.5523 2.77683L7.9623 1.36683 M9.33333 4.66667L7.33333 4.66667 M7.33333 4.66667L9.33333 4.66667 M7.9623 7.9623L6.5523 6.5523 M6.5523 6.5523L7.9623 7.9623 M4.66667 9.33333L4.66667 7.33333 M4.66667 7.33333L4.66667 9.33333 M1.36683 7.9623L2.77683 6.5523 M2.77683 6.5523L1.36683 7.9623 M0 4.66667L2 4.66667 M2 4.66667L0 4.66667 " /><path  id="路径 9" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(1.847983333333333 6.1381)  rotate(0 4.006941666666666 4.006933333333333)" d="M8.01,0L0,8.01 " /></g></g></svg>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="分组 1" style="stroke:#333;stroke-width:1.3;stroke-opacity:1;stroke-dasharray:0 0" d="M1.36683 1.36683L2.77683 2.77683 M4.66667 0L4.66667 2 M4.66667 2L4.66667 0 M7.9623 1.36683L6.5523 2.77683 M6.5523 2.77683L7.9623 1.36683 M9.33333 4.66667L7.33333 4.66667 M7.33333 4.66667L9.33333 4.66667 M7.9623 7.9623L6.5523 6.5523 M6.5523 6.5523L7.9623 7.9623 M4.66667 9.33333L4.66667 7.33333 M4.66667 7.33333L4.66667 9.33333 M1.36683 7.9623L2.77683 6.5523 M2.77683 6.5523L1.36683 7.9623 M0 4.66667L2 4.66667 M2 4.66667L0 4.66667" transform="translate(5.333333333333333 1.3333333333333333) rotate(0 4.666666666666666 4.666666666666666)"/><path id="路径 9" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M8.01,0L0,8.01" transform="translate(1.847983333333333 6.1381) rotate(0 4.006941666666666 4.006933333333333)"/></g></g></svg>

+ 1 - 17
app/icons/share.svg

@@ -1,17 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16"
-  height="16" viewBox="0 0 16 16" fill="none">
-  <defs>
-    <rect id="path_0" x="0" y="0" width="16" height="16" />
-  </defs>
-  <g opacity="1" transform="translate(0 0)  rotate(0 8 8)">
-    <mask id="bg-mask-0" fill="white">
-      <use xlink:href="#path_0"></use>
-    </mask>
-    <g mask="url(#bg-mask-0)">
-      <path id="路径 1"
-        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
-        transform="translate(2 1.3333333333333333)  rotate(0 6.333333333333333 6.5)"
-        d="M6.67,3.67C1.67,3.67 0,7.33 0,13C0,13 2,8 6.67,8L6.67,11.67L12.67,6L6.67,0L6.67,3.67Z " />
-    </g>
-  </g>
-</svg>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M6.67,3.67C1.67,3.67 0,7.33 0,13C0,13 2,8 6.67,8L6.67,11.67L12.67,6L6.67,0L6.67,3.67Z" transform="translate(2 1.3333333333333333) rotate(0 6.333333333333333 6.5)"/></g></g></svg>

+ 1 - 33
app/icons/three-dots.svg

@@ -1,33 +1 @@
-<!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL -->
-<svg width="30" height="14" viewBox="0 0 120 30" xmlns="http://www.w3.org/2000/svg" fill="#fff">
-    <circle cx="15" cy="15" r="15" fill="var(--primary, red)">
-        <animate attributeName="r" from="15" to="15"
-            begin="0s" dur="0.8s"
-            values="15;9;15" calcMode="linear"
-            repeatCount="indefinite" />
-        <animate attributeName="fill-opacity" from="1" to="1"
-            begin="0s" dur="0.8s"
-            values="1;.5;1" calcMode="linear"
-            repeatCount="indefinite" />
-    </circle>
-    <circle cx="60" cy="15" r="9" fill-opacity="0.3" fill="var(--primary, red)">
-        <animate attributeName="r" from="9" to="9"
-            begin="0s" dur="0.8s"
-            values="9;15;9" calcMode="linear"
-            repeatCount="indefinite" />
-        <animate attributeName="fill-opacity" from="0.5" to="0.5"
-            begin="0s" dur="0.8s"
-            values=".5;1;.5" calcMode="linear"
-            repeatCount="indefinite" />
-    </circle>
-    <circle cx="105" cy="15" r="15" fill="var(--primary, red)">
-        <animate attributeName="r" from="15" to="15"
-            begin="0s" dur="0.8s"
-            values="15;9;15" calcMode="linear"
-            repeatCount="indefinite" />
-        <animate attributeName="fill-opacity" from="1" to="1"
-            begin="0s" dur="0.8s"
-            values="1;.5;1" calcMode="linear"
-            repeatCount="indefinite" />
-    </circle>
-</svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="30" height="14" fill="#fff" viewBox="0 0 120 30"><circle cx="15" cy="15" r="15" fill="var(--primary, red)"><animate attributeName="r" begin="0s" calcMode="linear" dur="0.8s" from="15" repeatCount="indefinite" to="15" values="15;9;15"/><animate attributeName="fill-opacity" begin="0s" calcMode="linear" dur="0.8s" from="1" repeatCount="indefinite" to="1" values="1;.5;1"/></circle><circle cx="60" cy="15" r="9" fill="var(--primary, red)" fill-opacity=".3"><animate attributeName="r" begin="0s" calcMode="linear" dur="0.8s" from="9" repeatCount="indefinite" to="9" values="9;15;9"/><animate attributeName="fill-opacity" begin="0s" calcMode="linear" dur="0.8s" from=".5" repeatCount="indefinite" to=".5" values=".5;1;.5"/></circle><circle cx="105" cy="15" r="15" fill="var(--primary, red)"><animate attributeName="r" begin="0s" calcMode="linear" dur="0.8s" from="15" repeatCount="indefinite" to="15" values="15;9;15"/><animate attributeName="fill-opacity" begin="0s" calcMode="linear" dur="0.8s" from="1" repeatCount="indefinite" to="1" values="1;.5;1"/></circle></svg>

+ 11 - 19
app/layout.tsx

@@ -2,18 +2,24 @@
 import "./styles/globals.scss";
 import "./styles/markdown.scss";
 import "./styles/highlight.scss";
-import { getBuildConfig } from "./config/build";
-
-const buildConfig = getBuildConfig();
+import { getClientConfig } from "./config/client";
 
 export const metadata = {
   title: "ChatGPT Next Web",
   description: "Your personal ChatGPT Chat Bot.",
+  viewport: {
+    width: "device-width",
+    initialScale: 1,
+    maximumScale: 1,
+  },
+  themeColor: [
+    { media: "(prefers-color-scheme: light)", color: "#fafafa" },
+    { media: "(prefers-color-scheme: dark)", color: "#151515" },
+  ],
   appleWebApp: {
     title: "ChatGPT Next Web",
     statusBarStyle: "default",
   },
-  themeColor: "#fafafa",
 };
 
 export default function RootLayout({
@@ -24,22 +30,8 @@ export default function RootLayout({
   return (
     <html lang="en">
       <head>
-        <meta
-          name="viewport"
-          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"
-        />
-        <meta
-          name="theme-color"
-          content="#151515"
-          media="(prefers-color-scheme: dark)"
-        />
-        <meta name="version" content={buildConfig.commitId} />
+        <meta name="config" content={JSON.stringify(getClientConfig())} />
         <link rel="manifest" href="/site.webmanifest"></link>
-        <link rel="preconnect" href="https://fonts.proxy.ustclug.org"></link>
-        <link
-          href="https://fonts.proxy.ustclug.org/css2?family=Noto+Sans+SC:wght@300;400;700;900&display=swap"
-          rel="stylesheet"
-        ></link>
         <script src="/serviceWorkerRegister.js" defer></script>
       </head>
       <body>{children}</body>

+ 71 - 25
app/locales/cn.ts

@@ -4,7 +4,14 @@ const cn = {
   WIP: "该功能仍在开发中……",
   Error: {
     Unauthorized:
-      "访问密码不正确或为空,请前往[设置](/#/settings)页输入正确的访问密码,或者填入你自己的 OpenAI API Key。",
+      "访问密码不正确或为空,请前往[登录](/#/auth)页输入正确的访问密码,或者在[设置](/#/settings)页填入你自己的 OpenAI API Key。",
+  },
+  Auth: {
+    Title: "需要密码",
+    Tips: "管理员开启了密码验证,请在下方填入访问码",
+    Input: "在此处填写访问码",
+    Confirm: "确认",
+    Later: "稍后再说",
   },
   ChatItem: {
     ChatItemCount: (count: number) => `${count} 条对话`,
@@ -20,6 +27,19 @@ const cn = {
       Retry: "重试",
       Delete: "删除",
     },
+    InputActions: {
+      Stop: "停止响应",
+      ToBottom: "滚到最新",
+      Theme: {
+        auto: "自动主题",
+        light: "亮色模式",
+        dark: "深色模式",
+      },
+      Prompt: "快捷指令",
+      Masks: "所有面具",
+      Clear: "清除聊天",
+      Settings: "对话设置",
+    },
     Rename: "重命名对话",
     Typing: "正在输入…",
     Input: (submitKey: string) => {
@@ -31,24 +51,43 @@ const cn = {
     },
     Send: "发送",
     Config: {
-      Reset: "重置默认",
-      SaveAs: "存为面具",
+      Reset: "清除记忆",
+      SaveAs: "存为面具",
     },
   },
   Export: {
-    Title: "导出聊天记录为 Markdown",
+    Title: "分享聊天记录",
     Copy: "全部复制",
     Download: "下载文件",
+    Share: "分享到 ShareGPT",
     MessageFromYou: "来自你的消息",
     MessageFromChatGPT: "来自 ChatGPT 的消息",
+    Format: {
+      Title: "导出格式",
+      SubTitle: "可以导出 Markdown 文本或者 PNG 图片",
+    },
+    IncludeContext: {
+      Title: "包含面具上下文",
+      SubTitle: "是否在消息中展示面具上下文",
+    },
+    Steps: {
+      Select: "选取",
+      Preview: "预览",
+    },
+  },
+  Select: {
+    Search: "搜索消息",
+    All: "选取全部",
+    Latest: "最近几条",
+    Clear: "清除选中",
   },
   Memory: {
     Title: "历史摘要",
     EmptyContent: "对话内容过短,无需总结",
     Send: "自动压缩聊天记录并作为上下文发送",
     Copy: "复制摘要",
-    Reset: "重置对话",
-    ResetConfirm: "重置后将清空当前对话记录以及历史摘要,确认重置?",
+    Reset: "[unused]",
+    ResetConfirm: "确认清空历史摘要?",
   },
   Home: {
     NewChat: "新的聊天",
@@ -69,19 +108,6 @@ const cn = {
     Lang: {
       Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language`
       All: "所有语言",
-      Options: {
-        cn: "简体中文",
-        en: "English",
-        tw: "繁體中文",
-        es: "Español",
-        it: "Italiano",
-        tr: "Türkçe",
-        jp: "日本語",
-        de: "Deutsch",
-        vi: "Vietnamese",
-        ru: "Русский",
-        no: "Norsk",
-      },
     },
     Avatar: "头像",
     FontSize: {
@@ -154,6 +180,10 @@ const cn = {
       SubTitle: "管理员已开启加密访问",
       Placeholder: "请输入访问密码",
     },
+    Endpoint: {
+      Title: "接口地址",
+      SubTitle: "除默认地址外,必须包含 http(s)://",
+    },
     Model: "模型 (model)",
     Temperature: {
       Title: "随机性 (temperature)",
@@ -163,7 +193,7 @@ const cn = {
       Title: "单次回复限制 (max_tokens)",
       SubTitle: "单次交互所用的最大 Token 数",
     },
-    PresencePenlty: {
+    PresencePenalty: {
       Title: "话题新鲜度 (presence_penalty)",
       SubTitle: "值越大,越有可能扩展到新话题",
     },
@@ -173,12 +203,11 @@ const cn = {
     BotHello: "有什么可以帮你的吗",
     Error: "出错了,稍后重试吧",
     Prompt: {
-      History: (content: string) =>
-        "这是 ai 和用户的历史聊天总结作为前情提要:" + content,
+      History: (content: string) => "这是历史聊天总结作为前情提要:" + content,
       Topic:
         "使用四到五个字直接返回这句话的简要主题,不要解释、不要标点、不要语气词、不要多余文本,如果没有主题,请直接返回“闲聊”",
       Summarize:
-        "简要总结一下你和用户的对话,用作后续的上下文提示 prompt,控制在 200 字以内",
+        "简要总结一下对话内容,用作后续的上下文提示 prompt,控制在 200 字以内",
     },
   },
   Copy: {
@@ -186,9 +215,11 @@ const cn = {
     Failed: "复制失败,请赋予剪切板权限",
   },
   Context: {
-    Toast: (x: any) => `已设置 ${x} 条前置上下文`,
+    Toast: (x: any) => `包含 ${x} 条预设提示词`,
     Edit: "当前对话设置",
     Add: "新增预设对话",
+    Clear: "上下文已清除",
+    Revert: "恢复上下文",
   },
   Plugin: {
     Name: "插件",
@@ -218,6 +249,15 @@ const cn = {
     Config: {
       Avatar: "角色头像",
       Name: "角色名称",
+      Sync: {
+        Title: "使用全局设置",
+        SubTitle: "当前对话是否使用全局模型设置",
+        Confirm: "当前对话的自定义设置将会被自动覆盖,确认启用全局设置?",
+      },
+      HideContext: {
+        Title: "隐藏预设对话",
+        SubTitle: "隐藏后预设对话不会出现在聊天界面",
+      },
     },
   },
   NewChat: {
@@ -239,6 +279,12 @@ const cn = {
   },
 };
 
-export type LocaleType = typeof cn;
+type DeepPartial<T> = T extends object
+  ? {
+      [P in keyof T]?: DeepPartial<T[P]>;
+    }
+  : T;
+export type LocaleType = DeepPartial<typeof cn>;
+export type RequiredLocaleType = typeof cn;
 
 export default cn;

+ 231 - 0
app/locales/cs.ts

@@ -0,0 +1,231 @@
+import { SubmitKey } from "../store/config";
+import type { LocaleType } from "./index";
+
+const cs: LocaleType = {
+  WIP: "V přípravě...",
+  Error: {
+    Unauthorized:
+      "Neoprávněný přístup, zadejte přístupový kód na stránce nastavení.",
+  },
+  ChatItem: {
+    ChatItemCount: (count: number) => `${count} zpráv`,
+  },
+  Chat: {
+    SubTitle: (count: number) => `${count} zpráv s ChatGPT`,
+    Actions: {
+      ChatList: "Přejít na seznam chatů",
+      CompressedHistory: "Pokyn z komprimované paměti historie",
+      Export: "Exportovat všechny zprávy jako Markdown",
+      Copy: "Kopírovat",
+      Stop: "Zastavit",
+      Retry: "Zopakovat",
+      Delete: "Smazat",
+    },
+    Rename: "Přejmenovat chat",
+    Typing: "Píše...",
+    Input: (submitKey: string) => {
+      var inputHints = `${submitKey} pro odeslání`;
+      if (submitKey === String(SubmitKey.Enter)) {
+        inputHints += ", Shift + Enter pro řádkování";
+      }
+      return inputHints + ", / pro vyhledávání pokynů";
+    },
+    Send: "Odeslat",
+    Config: {
+      Reset: "Obnovit výchozí",
+      SaveAs: "Uložit jako Masku",
+    },
+  },
+  Export: {
+    Title: "Všechny zprávy",
+    Copy: "Kopírovat vše",
+    Download: "Stáhnout",
+    MessageFromYou: "Zpráva od vás",
+    MessageFromChatGPT: "Zpráva z ChatGPT",
+  },
+  Memory: {
+    Title: "Pokyn z paměti",
+    EmptyContent: "Zatím nic.",
+    Send: "Odeslat paměť",
+    Copy: "Kopírovat paměť",
+    Reset: "Obnovit relaci",
+    ResetConfirm:
+      "Resetováním se vymaže historie aktuálních konverzací i paměť historie pokynů. Opravdu chcete provést obnovu?",
+  },
+  Home: {
+    NewChat: "Nový chat",
+    DeleteChat: "Potvrzujete smazání vybrané konverzace?",
+    DeleteToast: "Chat smazán",
+    Revert: "Zvrátit",
+  },
+  Settings: {
+    Title: "Nastavení",
+    SubTitle: "Všechna nastavení",
+    Actions: {
+      ClearAll: "Vymazat všechna data",
+      ResetAll: "Obnovit veškeré nastavení",
+      Close: "Zavřít",
+      ConfirmResetAll: "Jste si jisti, že chcete obnovit všechna nastavení?",
+      ConfirmClearAll: "Jste si jisti, že chcete smazat všechna data?",
+    },
+    Lang: {
+      Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language`
+      All: "Všechny jazyky",
+    },
+    Avatar: "Avatar",
+    FontSize: {
+      Title: "Velikost písma",
+      SubTitle: "Nastavení velikosti písma obsahu chatu",
+    },
+    Update: {
+      Version: (x: string) => `Verze: ${x}`,
+      IsLatest: "Aktuální verze",
+      CheckUpdate: "Zkontrolovat aktualizace",
+      IsChecking: "Kontrola aktualizace...",
+      FoundUpdate: (x: string) => `Nalezena nová verze: ${x}`,
+      GoToUpdate: "Aktualizovat",
+    },
+    SendKey: "Odeslat klíč",
+    Theme: "Téma",
+    TightBorder: "Těsné ohraničení",
+    SendPreviewBubble: {
+      Title: "Odesílat chatovací bublinu s náhledem",
+      SubTitle: "Zobrazit v náhledu bubliny",
+    },
+    Mask: {
+      Title: "Úvodní obrazovka Masek",
+      SubTitle: "Před zahájením nového chatu zobrazte úvodní obrazovku Masek",
+    },
+    Prompt: {
+      Disable: {
+        Title: "Deaktivovat automatické dokončování",
+        SubTitle: "Zadejte / pro spuštění automatického dokončování",
+      },
+      List: "Seznam pokynů",
+      ListCount: (builtin: number, custom: number) =>
+        `${builtin} vestavěných, ${custom} uživatelských`,
+      Edit: "Upravit",
+      Modal: {
+        Title: "Seznam pokynů",
+        Add: "Přidat pokyn",
+        Search: "Hledat pokyny",
+      },
+      EditModal: {
+        Title: "Editovat pokyn",
+      },
+    },
+    HistoryCount: {
+      Title: "Počet připojených zpráv",
+      SubTitle: "Počet odeslaných připojených zpráv na žádost",
+    },
+    CompressThreshold: {
+      Title: "Práh pro kompresi historie",
+      SubTitle:
+        "Komprese proběhne, pokud délka nekomprimovaných zpráv přesáhne tuto hodnotu",
+    },
+    Token: {
+      Title: "API klíč",
+      SubTitle: "Použitím klíče ignorujete omezení přístupového kódu",
+      Placeholder: "Klíč API OpenAI",
+    },
+    Usage: {
+      Title: "Stav účtu",
+      SubTitle(used: any, total: any) {
+        return `Použito tento měsíc $${used}, předplaceno $${total}`;
+      },
+      IsChecking: "Kontroluji...",
+      Check: "Zkontrolovat",
+      NoAccess: "Pro kontrolu zůstatku zadejte klíč API",
+    },
+    AccessCode: {
+      Title: "Přístupový kód",
+      SubTitle: "Kontrola přístupu povolena",
+      Placeholder: "Potřebujete přístupový kód",
+    },
+    Model: "Model",
+    Temperature: {
+      Title: "Teplota",
+      SubTitle: "Větší hodnota činí výstup náhodnějším",
+    },
+    MaxTokens: {
+      Title: "Max. počet tokenů",
+      SubTitle: "Maximální délka vstupního tokenu a generovaných tokenů",
+    },
+    PresencePenalty: {
+      Title: "Přítomnostní korekce",
+      SubTitle: "Větší hodnota zvyšuje pravděpodobnost nových témat.",
+    },
+  },
+  Store: {
+    DefaultTopic: "Nová konverzace",
+    BotHello: "Ahoj! Jak mohu dnes pomoci?",
+    Error: "Něco se pokazilo, zkuste to prosím později.",
+    Prompt: {
+      History: (content: string) =>
+        "Toto je shrnutí historie chatu mezi umělou inteligencí a uživatelem v podobě rekapitulace: " +
+        content,
+      Topic:
+        "Vytvořte prosím název o čtyřech až pěti slovech vystihující průběh našeho rozhovoru bez jakýchkoli úvodních slov, interpunkčních znamének, uvozovek, teček, symbolů nebo dalšího textu. Odstraňte uvozovky.",
+      Summarize:
+        "Krátce shrň naši diskusi v rozsahu do 200 slov a použij ji jako podnět pro budoucí kontext.",
+    },
+  },
+  Copy: {
+    Success: "Zkopírováno do schránky",
+    Failed: "Kopírování selhalo, prosím, povolte přístup ke schránce",
+  },
+  Context: {
+    Toast: (x: any) => `Použití ${x} kontextových pokynů`,
+    Edit: "Kontextové a paměťové pokyny",
+    Add: "Přidat pokyn",
+  },
+  Plugin: {
+    Name: "Plugin",
+  },
+  Mask: {
+    Name: "Maska",
+    Page: {
+      Title: "Šablona pokynu",
+      SubTitle: (count: number) => `${count} šablon pokynů`,
+      Search: "Hledat v šablonách",
+      Create: "Vytvořit",
+    },
+    Item: {
+      Info: (count: number) => `${count} pokynů`,
+      Chat: "Chat",
+      View: "Zobrazit",
+      Edit: "Upravit",
+      Delete: "Smazat",
+      DeleteConfirm: "Potvrdit smazání?",
+    },
+    EditModal: {
+      Title: (readonly: boolean) =>
+        `Editovat šablonu pokynu ${readonly ? "(pouze ke čtení)" : ""}`,
+      Download: "Stáhnout",
+      Clone: "Duplikovat",
+    },
+    Config: {
+      Avatar: "Avatar Bota",
+      Name: "Jméno Bota",
+    },
+  },
+  NewChat: {
+    Return: "Zpět",
+    Skip: "Přeskočit",
+    Title: "Vyberte Masku",
+    SubTitle: "Chatovat s duší za Maskou",
+    More: "Najít více",
+    NotShow: "Nezobrazovat znovu",
+    ConfirmNoShow: "Potvrdit zakázání?Můžete jej povolit později v nastavení.",
+  },
+
+  UI: {
+    Confirm: "Potvrdit",
+    Cancel: "Zrušit",
+    Close: "Zavřít",
+    Create: "Vytvořit",
+    Edit: "Upravit",
+  },
+};
+
+export default cs;

+ 1 - 14
app/locales/de.ts

@@ -72,19 +72,6 @@ const de: LocaleType = {
     Lang: {
       Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language`
       All: "Alle Sprachen",
-      Options: {
-        cn: "简体中文",
-        en: "English",
-        tw: "繁體中文",
-        es: "Español",
-        it: "Italiano",
-        tr: "Türkçe",
-        jp: "日本語",
-        de: "Deutsch",
-        vi: "Vietnamese",
-        ru: "Русский",
-        no: "Norsk",
-      },
     },
     Avatar: "Avatar",
     FontSize: {
@@ -166,7 +153,7 @@ const de: LocaleType = {
       Title: "Max Tokens", //Maximale Token
       SubTitle: "Maximale Anzahl der Anfrage- plus Antwort-Token",
     },
-    PresencePenlty: {
+    PresencePenalty: {
       Title: "Presence Penalty", //Anwesenheitsstrafe
       SubTitle:
         "Ein größerer Wert erhöht die Wahrscheinlichkeit, dass über neue Themen gesprochen wird",

+ 63 - 23
app/locales/en.ts

@@ -1,11 +1,18 @@
 import { SubmitKey } from "../store/config";
-import type { LocaleType } from "./index";
+import { RequiredLocaleType } from "./index";
 
-const en: LocaleType = {
+const en: RequiredLocaleType = {
   WIP: "Coming Soon...",
   Error: {
     Unauthorized:
-      "Unauthorized access, please enter access code in settings page.",
+      "Unauthorized access, please enter access code in [auth](/#/auth) page.",
+  },
+  Auth: {
+    Title: "Need Access Code",
+    Tips: "Please enter access code below",
+    Input: "access code",
+    Confirm: "Confirm",
+    Later: "Later",
   },
   ChatItem: {
     ChatItemCount: (count: number) => `${count} messages`,
@@ -21,6 +28,19 @@ const en: LocaleType = {
       Retry: "Retry",
       Delete: "Delete",
     },
+    InputActions: {
+      Stop: "Stop",
+      ToBottom: "To Latest",
+      Theme: {
+        auto: "Auto",
+        light: "Light Theme",
+        dark: "Dark Theme",
+      },
+      Prompt: "Prompts",
+      Masks: "Masks",
+      Clear: "Clear Context",
+      Settings: "Settings",
+    },
     Rename: "Rename Chat",
     Typing: "Typing…",
     Input: (submitKey: string) => {
@@ -37,11 +57,30 @@ const en: LocaleType = {
     },
   },
   Export: {
-    Title: "All Messages",
+    Title: "Export Messages",
     Copy: "Copy All",
     Download: "Download",
     MessageFromYou: "Message From You",
     MessageFromChatGPT: "Message From ChatGPT",
+    Share: "Share to ShareGPT",
+    Format: {
+      Title: "Export Format",
+      SubTitle: "Markdown or PNG Image",
+    },
+    IncludeContext: {
+      Title: "Including Context",
+      SubTitle: "Export context prompts in mask or not",
+    },
+    Steps: {
+      Select: "Select",
+      Preview: "Preview",
+    },
+  },
+  Select: {
+    Search: "Search",
+    All: "Select All",
+    Latest: "Select Latest",
+    Clear: "Clear",
   },
   Memory: {
     Title: "Memory Prompt",
@@ -71,19 +110,6 @@ const en: LocaleType = {
     Lang: {
       Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language`
       All: "All Languages",
-      Options: {
-        cn: "简体中文",
-        en: "English",
-        tw: "繁體中文",
-        es: "Español",
-        it: "Italiano",
-        tr: "Türkçe",
-        jp: "日本語",
-        de: "Deutsch",
-        vi: "Vietnamese",
-        ru: "Русский",
-        no: "Norsk",
-      },
     },
     Avatar: "Avatar",
     FontSize: {
@@ -155,6 +181,10 @@ const en: LocaleType = {
       SubTitle: "Access control enabled",
       Placeholder: "Need Access Code",
     },
+    Endpoint: {
+      Title: "Endpoint",
+      SubTitle: "Custom endpoint must start with http(s)://",
+    },
     Model: "Model",
     Temperature: {
       Title: "Temperature",
@@ -164,7 +194,7 @@ const en: LocaleType = {
       Title: "Max Tokens",
       SubTitle: "Maximum length of input tokens and generated tokens",
     },
-    PresencePenlty: {
+    PresencePenalty: {
       Title: "Presence Penalty",
       SubTitle:
         "A larger value increases the likelihood to talk about new topics",
@@ -176,12 +206,11 @@ const en: LocaleType = {
     Error: "Something went wrong, please try again later.",
     Prompt: {
       History: (content: string) =>
-        "This is a summary of the chat history between the AI and the user as a recap: " +
-        content,
+        "This is a summary of the chat history as a recap: " + content,
       Topic:
         "Please generate a four to five word title summarizing our conversation without any lead-in, punctuation, quotation marks, periods, symbols, or additional text. Remove enclosing quotation marks.",
       Summarize:
-        "Summarize our discussion briefly in 200 words or less to use as a prompt for future context.",
+        "Summarize the discussion briefly in 200 words or less to use as a prompt for future context.",
     },
   },
   Copy: {
@@ -192,6 +221,8 @@ const en: LocaleType = {
     Toast: (x: any) => `With ${x} contextual prompts`,
     Edit: "Contextual and Memory Prompts",
     Add: "Add a Prompt",
+    Clear: "Context Cleared",
+    Revert: "Revert",
   },
   Plugin: {
     Name: "Plugin",
@@ -221,15 +252,24 @@ const en: LocaleType = {
     Config: {
       Avatar: "Bot Avatar",
       Name: "Bot Name",
+      Sync: {
+        Title: "Use Global Config",
+        SubTitle: "Use global config in this chat",
+        Confirm: "Confirm to override custom config with global config?",
+      },
+      HideContext: {
+        Title: "Hide Context Prompts",
+        SubTitle: "Do not show in-context prompts in chat",
+      },
     },
   },
   NewChat: {
     Return: "Return",
-    Skip: "Skip",
+    Skip: "Just Start",
     Title: "Pick a Mask",
     SubTitle: "Chat with the Soul behind the Mask",
     More: "Find More",
-    NotShow: "Not Show Again",
+    NotShow: "Never Show Again",
     ConfirmNoShow: "Confirm to disable?You can enable it in settings later.",
   },
 

+ 1 - 14
app/locales/es.ts

@@ -71,19 +71,6 @@ const es: LocaleType = {
     Lang: {
       Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language`
       All: "Todos los idiomas",
-      Options: {
-        cn: "简体中文",
-        en: "English",
-        tw: "繁體中文",
-        es: "Español",
-        it: "Italiano",
-        tr: "Türkçe",
-        jp: "日本語",
-        de: "Deutsch",
-        vi: "Vietnamese",
-        ru: "Русский",
-        no: "Norsk",
-      },
     },
     Avatar: "Avatar",
     FontSize: {
@@ -164,7 +151,7 @@ const es: LocaleType = {
       Title: "Máximo de tokens",
       SubTitle: "Longitud máxima de tokens de entrada y tokens generados",
     },
-    PresencePenlty: {
+    PresencePenalty: {
       Title: "Penalización de presencia",
       SubTitle:
         "Un valor mayor aumenta la probabilidad de hablar sobre nuevos temas",

+ 237 - 0
app/locales/fr.ts

@@ -0,0 +1,237 @@
+import { SubmitKey } from "../store/config";
+import type { LocaleType } from "./index";
+
+const fr: LocaleType = {
+  WIP: "Prochainement...",
+  Error: {
+    Unauthorized:
+      "Accès non autorisé, veuillez saisir le code d'accès dans la page des paramètres.",
+  },
+  ChatItem: {
+    ChatItemCount: (count: number) => `${count} messages en total`,
+  },
+  Chat: {
+    SubTitle: (count: number) => `${count} messages échangés avec ChatGPT`,
+    Actions: {
+      ChatList: "Aller à la liste de discussion",
+      CompressedHistory: "Mémoire d'historique compressée Prompt",
+      Export: "Exporter tous les messages en tant que Markdown",
+      Copy: "Copier",
+      Stop: "Arrêter",
+      Retry: "Réessayer",
+      Delete: "Supprimer",
+    },
+    Rename: "Renommer la conversation",
+    Typing: "En train d'écrire…",
+    Input: (submitKey: string) => {
+      var inputHints = `Appuyez sur ${submitKey} pour envoyer`;
+      if (submitKey === String(SubmitKey.Enter)) {
+        inputHints += ", Shift + Enter pour insérer un saut de ligne";
+      }
+      return inputHints + ", / pour rechercher des prompts";
+    },
+    Send: "Envoyer",
+    Config: {
+      Reset: "Restaurer les paramètres par défaut",
+      SaveAs: "Enregistrer en tant que masque",
+    },
+  },
+  Export: {
+    Title: "Tous les messages",
+    Copy: "Tout sélectionner",
+    Download: "Télécharger",
+    MessageFromYou: "Message de votre part",
+    MessageFromChatGPT: "Message de ChatGPT",
+  },
+  Memory: {
+    Title: "Prompt mémoire",
+    EmptyContent: "Rien encore.",
+    Send: "Envoyer la mémoire",
+    Copy: "Copier la mémoire",
+    Reset: "Réinitialiser la session",
+    ResetConfirm:
+      "La réinitialisation supprimera l'historique de la conversation actuelle ainsi que la mémoire de l'historique. Êtes-vous sûr de vouloir procéder à la réinitialisation?",
+  },
+  Home: {
+    NewChat: "Nouvelle discussion",
+    DeleteChat: "Confirmer la suppression de la conversation sélectionnée ?",
+    DeleteToast: "Conversation supprimée",
+    Revert: "Revenir en arrière",
+  },
+  Settings: {
+    Title: "Paramètres",
+    SubTitle: "Toutes les configurations",
+    Actions: {
+      ClearAll: "Effacer toutes les données",
+      ResetAll: "Réinitialiser les configurations",
+      Close: "Fermer",
+      ConfirmResetAll:
+        "Êtes-vous sûr de vouloir réinitialiser toutes les configurations?",
+      ConfirmClearAll: "Êtes-vous sûr de vouloir supprimer toutes les données?",
+    },
+    Lang: {
+      Name: "Language", // ATTENTION : si vous souhaitez ajouter une nouvelle traduction, ne traduisez pas cette valeur, laissez-la sous forme de `Language`
+      All: "Toutes les langues",
+    },
+
+    Avatar: "Avatar",
+    FontSize: {
+      Title: "Taille des polices",
+      SubTitle: "Ajuste la taille de police du contenu de la conversation",
+    },
+    Update: {
+      Version: (x: string) => `Version : ${x}`,
+      IsLatest: "Dernière version",
+      CheckUpdate: "Vérifier la mise à jour",
+      IsChecking: "Vérification de la mise à jour...",
+      FoundUpdate: (x: string) => `Nouvelle version disponible : ${x}`,
+      GoToUpdate: "Mise à jour",
+    },
+    SendKey: "Clé d'envoi",
+    Theme: "Thème",
+    TightBorder: "Bordure serrée",
+    SendPreviewBubble: {
+      Title: "Aperçu de l'envoi dans une bulle",
+      SubTitle: "Aperçu du Markdown dans une bulle",
+    },
+    Mask: {
+      Title: "Écran de masque",
+      SubTitle:
+        "Afficher un écran de masque avant de démarrer une nouvelle discussion",
+    },
+    Prompt: {
+      Disable: {
+        Title: "Désactiver la saisie semi-automatique",
+        SubTitle: "Appuyez sur / pour activer la saisie semi-automatique",
+      },
+      List: "Liste de prompts",
+      ListCount: (builtin: number, custom: number) =>
+        `${builtin} intégré, ${custom} personnalisé`,
+      Edit: "Modifier",
+      Modal: {
+        Title: "Liste de prompts",
+        Add: "Ajouter un élément",
+        Search: "Rechercher des prompts",
+      },
+      EditModal: {
+        Title: "Modifier le prompt",
+      },
+    },
+    HistoryCount: {
+      Title: "Nombre de messages joints",
+      SubTitle: "Nombre de messages envoyés attachés par demande",
+    },
+    CompressThreshold: {
+      Title: "Seuil de compression de l'historique",
+      SubTitle:
+        "Comprimera si la longueur des messages non compressés dépasse cette valeur",
+    },
+    Token: {
+      Title: "Clé API",
+      SubTitle: "Utilisez votre clé pour ignorer la limite du code d'accès",
+      Placeholder: "Clé OpenAI API",
+    },
+    Usage: {
+      Title: "Solde du compte",
+      SubTitle(used: any, total: any) {
+        return `Épuisé ce mois-ci $${used}, abonnement $${total}`;
+      },
+      IsChecking: "Vérification...",
+      Check: "Vérifier",
+      NoAccess: "Entrez la clé API pour vérifier le solde",
+    },
+    AccessCode: {
+      Title: "Code d'accès",
+      SubTitle: "Contrôle d'accès activé",
+      Placeholder: "Code d'accès requis",
+    },
+    Model: "Modèle",
+    Temperature: {
+      Title: "Température",
+      SubTitle: "Une valeur plus élevée rendra les réponses plus aléatoires",
+    },
+    MaxTokens: {
+      Title: "Max Tokens",
+      SubTitle: "Longueur maximale des tokens d'entrée et des tokens générés",
+    },
+    PresencePenalty: {
+      Title: "Pénalité de présence",
+      SubTitle:
+        "Une valeur plus élevée augmentera la probabilité d'introduire de nouveaux sujets",
+    },
+  },
+  Store: {
+    DefaultTopic: "Nouvelle conversation",
+    BotHello: "Bonjour ! Comment puis-je vous aider aujourd'hui ?",
+    Error: "Quelque chose s'est mal passé, veuillez réessayer plus tard.",
+    Prompt: {
+      History: (content: string) =>
+        "Ceci est un résumé de l'historique des discussions entre l'IA et l'utilisateur : " +
+        content,
+      Topic:
+        "Veuillez générer un titre de quatre à cinq mots résumant notre conversation sans introduction, ponctuation, guillemets, points, symboles ou texte supplémentaire. Supprimez les guillemets inclus.",
+      Summarize:
+        "Résumez brièvement nos discussions en 200 mots ou moins pour les utiliser comme prompt de contexte futur.",
+    },
+  },
+  Copy: {
+    Success: "Copié dans le presse-papiers",
+    Failed:
+      "La copie a échoué, veuillez accorder l'autorisation d'accès au presse-papiers",
+  },
+  Context: {
+    Toast: (x: any) => `Avec ${x} contextes de prompts`,
+    Edit: "Contextes et mémoires de prompts",
+    Add: "Ajouter un prompt",
+  },
+  Plugin: {
+    Name: "Extension",
+  },
+  Mask: {
+    Name: "Masque",
+    Page: {
+      Title: "Modèle de prompt",
+      SubTitle: (count: number) => `${count} modèles de prompts`,
+      Search: "Rechercher des modèles",
+      Create: "Créer",
+    },
+    Item: {
+      Info: (count: number) => `${count} prompts`,
+      Chat: "Discussion",
+      View: "Vue",
+      Edit: "Modifier",
+      Delete: "Supprimer",
+      DeleteConfirm: "Confirmer la suppression?",
+    },
+    EditModal: {
+      Title: (readonly: boolean) =>
+        `Modifier le modèle de prompt ${readonly ? "(en lecture seule)" : ""}`,
+      Download: "Télécharger",
+      Clone: "Dupliquer",
+    },
+    Config: {
+      Avatar: "Avatar du bot",
+      Name: "Nom du bot",
+    },
+  },
+  NewChat: {
+    Return: "Retour",
+    Skip: "Passer",
+    Title: "Choisir un masque",
+    SubTitle: "Discutez avec l'âme derrière le masque",
+    More: "En savoir plus",
+    NotShow: "Ne pas afficher à nouveau",
+    ConfirmNoShow:
+      "Confirmez-vous vouloir désactiver cela? Vous pouvez le réactiver plus tard dans les paramètres.",
+  },
+
+  UI: {
+    Confirm: "Confirmer",
+    Cancel: "Annuler",
+    Close: "Fermer",
+    Create: "Créer",
+    Edit: "Éditer",
+  },
+};
+
+export default fr;

+ 34 - 4
app/locales/index.ts

@@ -1,6 +1,7 @@
 import CN from "./cn";
 import EN from "./en";
 import TW from "./tw";
+import FR from "./fr";
 import ES from "./es";
 import IT from "./it";
 import TR from "./tr";
@@ -9,13 +10,17 @@ import DE from "./de";
 import VI from "./vi";
 import RU from "./ru";
 import NO from "./no";
+import CS from "./cs";
+import KO from "./ko";
+import { merge } from "../utils/merge";
 
-export type { LocaleType } from "./cn";
+export type { LocaleType, RequiredLocaleType } from "./cn";
 
 export const AllLangs = [
   "en",
   "cn",
   "tw",
+  "fr",
   "es",
   "it",
   "tr",
@@ -23,10 +28,27 @@ export const AllLangs = [
   "de",
   "vi",
   "ru",
-  "no",
+  "cs",
+  "ko",
 ] as const;
 export type Lang = (typeof AllLangs)[number];
 
+export const ALL_LANG_OPTIONS: Record<Lang, string> = {
+  cn: "简体中文",
+  en: "English",
+  tw: "繁體中文",
+  fr: "Français",
+  es: "Español",
+  it: "Italiano",
+  tr: "Türkçe",
+  jp: "日本語",
+  de: "Deutsch",
+  vi: "Tiếng Việt",
+  ru: "Русский",
+  cs: "Čeština",
+  ko: "한국어",
+};
+
 const LANG_KEY = "lang";
 const DEFAULT_LANG = "en";
 
@@ -48,7 +70,6 @@ function getLanguage() {
   try {
     return navigator.language.toLowerCase();
   } catch {
-    console.log("[Lang] failed to detect user lang.");
     return DEFAULT_LANG;
   }
 }
@@ -76,10 +97,12 @@ export function changeLang(lang: Lang) {
   location.reload();
 }
 
-export default {
+const fallbackLang = EN;
+const targetLang = {
   en: EN,
   cn: CN,
   tw: TW,
+  fr: FR,
   es: ES,
   it: IT,
   tr: TR,
@@ -88,4 +111,11 @@ export default {
   vi: VI,
   ru: RU,
   no: NO,
+  cs: CS,
+  ko: KO,
 }[getLang()] as typeof CN;
+
+// if target lang missing some fields, it will use fallback lang string
+merge(fallbackLang, targetLang);
+
+export default fallbackLang as typeof CN;

+ 1 - 14
app/locales/it.ts

@@ -71,19 +71,6 @@ const it: LocaleType = {
     Lang: {
       Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language`
       All: "Tutte le lingue",
-      Options: {
-        cn: "简体中文",
-        en: "English",
-        tw: "繁體中文",
-        es: "Español",
-        it: "Italiano",
-        tr: "Türkçe",
-        jp: "日本語",
-        de: "Deutsch",
-        vi: "Vietnamese",
-        ru: "Русский",
-        no: "Norsk",
-      },
     },
     Avatar: "Avatar",
     FontSize: {
@@ -165,7 +152,7 @@ const it: LocaleType = {
       Title: "Token massimi",
       SubTitle: "Lunghezza massima dei token in ingresso e dei token generati",
     },
-    PresencePenlty: {
+    PresencePenalty: {
       Title: "Penalità di presenza",
       SubTitle:
         "Un valore maggiore aumenta la probabilità di parlare di nuovi argomenti",

+ 51 - 54
app/locales/jp.ts

@@ -2,10 +2,10 @@ import { SubmitKey } from "../store/config";
 import type { LocaleType } from "./index";
 
 const jp: LocaleType = {
-  WIP: "この機能は開発中です……",
+  WIP: "この機能は開発中です",
   Error: {
     Unauthorized:
-      "現在は未承認状態です。左下の設定ボタンをクリックし、アクセスパスワードを入力してください。",
+      "現在は未承認状態です。左下の設定ボタンをクリックし、アクセスパスワードかOpenAIのAPIキーを入力してください。",
   },
   ChatItem: {
     ChatItemCount: (count: number) => `${count} 通のチャット`,
@@ -19,7 +19,7 @@ const jp: LocaleType = {
       Copy: "コピー",
       Stop: "停止",
       Retry: "リトライ",
-      Delete: "Delete",
+      Delete: "削除",
     },
     Rename: "チャットの名前を変更",
     Typing: "入力中…",
@@ -32,7 +32,7 @@ const jp: LocaleType = {
     },
     Send: "送信",
     Config: {
-      Reset: "重置默认",
+      Reset: "リセット",
       SaveAs: "另存为面具",
     },
   },
@@ -70,20 +70,7 @@ const jp: LocaleType = {
     },
     Lang: {
       Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language`
-      All: "所有语言",
-      Options: {
-        cn: "简体中文",
-        en: "English",
-        tw: "繁體中文",
-        es: "Español",
-        it: "Italiano",
-        tr: "Türkçe",
-        jp: "日本語",
-        de: "Deutsch",
-        vi: "Vietnamese",
-        ru: "Русский",
-        no: "Norsk",
-      },
+      All: "全ての言語",
     },
     Avatar: "アバター",
     FontSize: {
@@ -104,11 +91,11 @@ const jp: LocaleType = {
     TightBorder: "ボーダーレスモード",
     SendPreviewBubble: {
       Title: "プレビューバブルの送信",
-      SubTitle: "在预览气泡中预览 Markdown 内容",
+      SubTitle: "プレビューバブルでマークダウンコンテンツをプレビュー",
     },
     Mask: {
-      Title: "面具启动页",
-      SubTitle: "新建聊天时,展示面具启动页",
+      Title: "キャラクターページ",
+      SubTitle: "新規チャット作成時にキャラクターページを表示する",
     },
     Prompt: {
       Disable: {
@@ -126,7 +113,7 @@ const jp: LocaleType = {
         Search: "プロンプトワード検索",
       },
       EditModal: {
-        Title: "编辑提示词",
+        Title: "編集",
       },
     },
     HistoryCount: {
@@ -167,7 +154,7 @@ const jp: LocaleType = {
       Title: "シングルレスポンス制限 (max_tokens)",
       SubTitle: "1回のインタラクションで使用される最大トークン数",
     },
-    PresencePenlty: {
+    PresencePenalty: {
       Title: "トピックの新鮮度 (presence_penalty)",
       SubTitle: "値が大きいほど、新しいトピックへの展開が可能になります。",
     },
@@ -191,54 +178,64 @@ const jp: LocaleType = {
     Failed: "コピーに失敗しました。クリップボード許可を与えてください。",
   },
   Context: {
-    Toast: (x: any) => `前置コンテキストが ${x} 件設定されました`,
-    Edit: "前置コンテキストと履歴メモリ",
-    Add: "新規追加",
+    Toast: (x: any) => `キャラクターが ${x} 件設定されました`,
+    Edit: "キャラクタープリセットとモデル設定",
+    Add: "追加",
   },
-  Plugin: { Name: "插件" },
+  Plugin: { Name: "プラグイン" },
   Mask: {
-    Name: "面具",
+    Name: "キャラクタープリセット",
     Page: {
-      Title: "预设角色面具",
-      SubTitle: (count: number) => `${count} 个预设角色定义`,
-      Search: "搜索角色面具",
-      Create: "新",
+      Title: "キャラクタープリセット",
+      SubTitle: (count: number) => `${count} 件見つかりました。`,
+      Search: "検索",
+      Create: "新",
     },
     Item: {
       Info: (count: number) => `包含 ${count} 条预设对话`,
-      Chat: "对话",
-      View: "查看",
-      Edit: "编辑",
-      Delete: "除",
-      DeleteConfirm: "确认删除?",
+      Chat: "会話",
+      View: "詳細",
+      Edit: "編集",
+      Delete: "除",
+      DeleteConfirm: "本当に削除しますか?",
     },
     EditModal: {
       Title: (readonly: boolean) =>
-        `编辑预设面具 ${readonly ? "(只读)" : ""}`,
-      Download: "下载预设",
-      Clone: "克隆预设",
+        `キャラクタープリセットを編集 ${readonly ? "(読み取り専用)" : ""}`,
+      Download: "ダウンロード",
+      Clone: "複製",
     },
     Config: {
-      Avatar: "角色头像",
-      Name: "角色名称",
+      Avatar: "キャラクターのアイコン",
+      Name: "キャラクターの名前",
+      Sync: {
+        Title: "グローバル設定を利用する",
+        SubTitle: "このチャットでグローバル設定を利用します。",
+        Confirm:
+          "カスタム設定を上書きしてグローバル設定を使用します、よろしいですか?",
+      },
+      HideContext: {
+        Title: "キャラクター設定を表示しない",
+        SubTitle: "チャット画面でのキャラクター設定を非表示にします。",
+      },
     },
   },
   NewChat: {
-    Return: "返回",
-    Skip: "跳过",
-    Title: "挑选一个面具",
-    SubTitle: "现在开始,与面具背后的灵魂思维碰撞",
-    More: "搜索更多",
-    NotShow: "不再展示",
-    ConfirmNoShow: "确认禁用?禁用后可以随时在设置中重新启用。",
+    Return: "戻る",
+    Skip: "スキップ",
+    Title: "キャラクター",
+    SubTitle: "さあ、AIにキャラクターを設定して会話を始めてみましょう",
+    More: "もっと探す",
+    NotShow: "今後は表示しない",
+    ConfirmNoShow: "いつでも設定から有効化できます。",
   },
 
   UI: {
-    Confirm: "确认",
-    Cancel: "取消",
-    Close: "关闭",
-    Create: "新",
-    Edit: "编辑",
+    Confirm: "確認",
+    Cancel: "キャンセル",
+    Close: "閉じる",
+    Create: "新",
+    Edit: "編集",
   },
 };
 

+ 230 - 0
app/locales/ko.ts

@@ -0,0 +1,230 @@
+import { SubmitKey } from "../store/config";
+
+import type { LocaleType } from "./index";
+
+const ko: LocaleType = {
+  WIP: "곧 출시 예정...",
+  Error: {
+    Unauthorized: "권한이 없습니다. 설정 페이지에서 액세스 코드를 입력하세요.",
+  },
+  ChatItem: {
+    ChatItemCount: (count: number) => `${count}개의 메시지`,
+  },
+  Chat: {
+    SubTitle: (count: number) => `ChatGPT와의 ${count}개의 메시지`,
+    Actions: {
+      ChatList: "채팅 목록으로 이동",
+      CompressedHistory: "압축된 기억력 메모리 프롬프트",
+      Export: "모든 메시지를 Markdown으로 내보내기",
+      Copy: "복사",
+      Stop: "중지",
+      Retry: "다시 시도",
+      Delete: "삭제",
+    },
+    Rename: "채팅 이름 변경",
+    Typing: "입력 중...",
+    Input: (submitKey: string) => {
+      var inputHints = `${submitKey}를 눌러 보내기`;
+      if (submitKey === String(SubmitKey.Enter)) {
+        inputHints += ", Shift + Enter로 줄 바꿈";
+      }
+      return inputHints + ", 프롬프트 검색을 위해 / 입력";
+    },
+    Send: "보내기",
+    Config: {
+      Reset: "기본값으로 재설정",
+      SaveAs: "마스크로 저장",
+    },
+  },
+  Export: {
+    Title: "모든 메시지",
+    Copy: "모두 복사",
+    Download: "다운로드",
+    MessageFromYou: "나의 메시지",
+    MessageFromChatGPT: "ChatGPT의 메시지",
+  },
+  Memory: {
+    Title: "기억 프롬프트",
+    EmptyContent: "아직 내용이 없습니다.",
+    Send: "기억 보내기",
+    Copy: "기억 복사",
+    Reset: "세션 재설정",
+    ResetConfirm:
+      "재설정하면 현재 대화 기록과 기억력이 삭제됩니다. 정말 재설정하시겠습니까?",
+  },
+  Home: {
+    NewChat: "새로운 채팅",
+    DeleteChat: "선택한 대화를 삭제하시겠습니까?",
+    DeleteToast: "채팅이 삭제되었습니다.",
+    Revert: "되돌리기",
+  },
+  Settings: {
+    Title: "설정",
+    SubTitle: "모든 설정",
+    Actions: {
+      ClearAll: "모든 데이터 지우기",
+      ResetAll: "모든 설정 초기화",
+      Close: "닫기",
+      ConfirmResetAll: "모든 설정을 초기화하시겠습니까?",
+      ConfirmClearAll: "모든 데이터를 지우시겠습니까?",
+    },
+    Lang: {
+      Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language`
+      All: "All Languages",
+    },
+    Avatar: "아바타",
+    FontSize: {
+      Title: "글꼴 크기",
+      SubTitle: "채팅 내용의 글꼴 크기 조정",
+    },
+    Update: {
+      Version: (x: string) => `버전: ${x}`,
+      IsLatest: "최신 버전",
+      CheckUpdate: "업데이트 확인",
+      IsChecking: "업데이트 확인 중...",
+      FoundUpdate: (x: string) => `새 버전 발견: ${x}`,
+      GoToUpdate: "업데이트",
+    },
+    SendKey: "전송 키",
+    Theme: "테마",
+    TightBorder: "조밀한 테두리",
+    SendPreviewBubble: {
+      Title: "미리 보기 버블 전송",
+      SubTitle: "버블에서 마크다운 미리 보기",
+    },
+    Mask: {
+      Title: "마스크 시작 화면",
+      SubTitle: "새로운 채팅 시작 전에 마스크 시작 화면 표시",
+    },
+    Prompt: {
+      Disable: {
+        Title: "자동 완성 비활성화",
+        SubTitle: "자동 완성을 활성화하려면 /를 입력하세요.",
+      },
+      List: "프롬프트 목록",
+      ListCount: (builtin: number, custom: number) =>
+        `내장 ${builtin}개, 사용자 정의 ${custom}개`,
+      Edit: "편집",
+      Modal: {
+        Title: "프롬프트 목록",
+        Add: "추가",
+        Search: "프롬프트 검색",
+      },
+      EditModal: {
+        Title: "프롬프트 편집",
+      },
+    },
+    HistoryCount: {
+      Title: "첨부된 메시지 수",
+      SubTitle: "요청당 첨부된 전송된 메시지 수",
+    },
+    CompressThreshold: {
+      Title: "기록 압축 임계값",
+      SubTitle: "미압축 메시지 길이가 임계값을 초과하면 압축됨",
+    },
+    Token: {
+      Title: "API 키",
+      SubTitle: "액세스 코드 제한을 무시하기 위해 키 사용",
+      Placeholder: "OpenAI API 키",
+    },
+    Usage: {
+      Title: "계정 잔액",
+      SubTitle(used: any, total: any) {
+        return `이번 달 사용액 ${used}, 구독액 ${total}`;
+      },
+      IsChecking: "확인 중...",
+      Check: "확인",
+      NoAccess: "잔액 확인을 위해 API 키를 입력하세요.",
+    },
+    AccessCode: {
+      Title: "액세스 코드",
+      SubTitle: "액세스 제어가 활성화됨",
+      Placeholder: "액세스 코드 입력",
+    },
+    Model: "모델",
+    Temperature: {
+      Title: "온도 (temperature)",
+      SubTitle: "값이 클수록 더 무작위한 출력이 생성됩니다.",
+    },
+    MaxTokens: {
+      Title: "최대 토큰 수 (max_tokens)",
+      SubTitle: "입력 토큰과 생성된 토큰의 최대 길이",
+    },
+    PresencePenalty: {
+      Title: "존재 페널티 (presence_penalty)",
+      SubTitle: "값이 클수록 새로운 주제에 대해 대화할 가능성이 높아집니다.",
+    },
+  },
+  Store: {
+    DefaultTopic: "새 대화",
+    BotHello: "안녕하세요! 오늘 도움이 필요하신가요?",
+    Error: "문제가 발생했습니다. 나중에 다시 시도해주세요.",
+    Prompt: {
+      History: (content: string) =>
+        "이것은 AI와 사용자 간의 대화 기록을 요약한 내용입니다: " + content,
+      Topic:
+        "다음과 같이 대화 내용을 요약하는 4~5단어 제목을 생성해주세요. 따옴표, 구두점, 인용부호, 기호 또는 추가 텍스트를 제거하십시오. 따옴표로 감싸진 부분을 제거하십시오.",
+      Summarize:
+        "200단어 이내로 저희 토론을 간략히 요약하여 앞으로의 맥락으로 사용할 수 있는 프롬프트로 만들어주세요.",
+    },
+  },
+  Copy: {
+    Success: "클립보드에 복사되었습니다.",
+    Failed: "복사 실패, 클립보드 접근 권한을 허용해주세요.",
+  },
+  Context: {
+    Toast: (x: any) => `컨텍스트 프롬프트 ${x}개 사용`,
+    Edit: "컨텍스트 및 메모리 프롬프트",
+    Add: "프롬프트 추가",
+  },
+  Plugin: {
+    Name: "플러그인",
+  },
+  Mask: {
+    Name: "마스크",
+    Page: {
+      Title: "프롬프트 템플릿",
+      SubTitle: (count: number) => `${count}개의 프롬프트 템플릿`,
+      Search: "템플릿 검색",
+      Create: "생성",
+    },
+    Item: {
+      Info: (count: number) => `${count}개의 프롬프롬프트`,
+      Chat: "채팅",
+      View: "보기",
+      Edit: "편집",
+      Delete: "삭제",
+      DeleteConfirm: "삭제하시겠습니까?",
+    },
+    EditModal: {
+      Title: (readonly: boolean) =>
+        `프롬프트 템플릿 편집 ${readonly ? "(읽기 전용)" : ""}`,
+      Download: "다운로드",
+      Clone: "복제",
+    },
+    Config: {
+      Avatar: "봇 아바타",
+      Name: "봇 이름",
+    },
+  },
+  NewChat: {
+    Return: "돌아가기",
+    Skip: "건너뛰기",
+    Title: "마스크 선택",
+    SubTitle: "마스크 뒤의 영혼과 대화하세요",
+    More: "더 보기",
+    NotShow: "다시 표시하지 않음",
+    ConfirmNoShow:
+      "비활성화하시겠습니까? 나중에 설정에서 다시 활성화할 수 있습니다.",
+  },
+
+  UI: {
+    Confirm: "확인",
+    Cancel: "취소",
+    Close: "닫기",
+    Create: "생성",
+    Edit: "편집",
+  },
+};
+
+export default ko;

+ 5 - 6
app/locales/no.ts

@@ -4,8 +4,7 @@ import type { LocaleType } from "./index";
 const no: LocaleType = {
   WIP: "Arbeid pågår ...",
   Error: {
-    Unauthorized:
-      "Du har ikke tilgang. Vennlig oppgi tildelt adgangskode.",
+    Unauthorized: "Du har ikke tilgang. Vennlig oppgi tildelt adgangskode.",
   },
   ChatItem: {
     ChatItemCount: (count: number) => `${count} meldinger`,
@@ -125,7 +124,8 @@ const no: LocaleType = {
     },
     Token: {
       Title: "API Key",
-      SubTitle: "Bruk din egen API-nøkkel for å ignorere tilgangskoden begrensning",
+      SubTitle:
+        "Bruk din egen API-nøkkel for å ignorere tilgangskoden begrensning",
       Placeholder: "OpenAI API-nøkkel",
     },
     Usage: {
@@ -153,8 +153,7 @@ const no: LocaleType = {
     },
     PresencePenlty: {
       Title: "Straff for tilstedeværelse",
-      SubTitle:
-        "Høyere verdi øker sjansen for ny tematikk",
+      SubTitle: "Høyere verdi øker sjansen for ny tematikk",
     },
   },
   Store: {
@@ -183,4 +182,4 @@ const no: LocaleType = {
   },
 };
 
-export default no;
+export default no;

+ 51 - 58
app/locales/ru.ts

@@ -71,64 +71,53 @@ const ru: LocaleType = {
     Lang: {
       Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language`
       All: "Все языки",
-      Options: {
-        cn: "简体中文",
-        en: "English",
-        tw: "繁體中文",
-        es: "Español",
-        it: "Italiano",
-        tr: "Türkçe",
-        jp: "日本語",
-        de: "Deutsch",
-        vi: "Vietnamese",
-        ru: "Русский",
-      },
     },
-      Avatar: "Аватар",
-      FontSize: {
-        Title: "Размер шрифта",
-        SubTitle: "Настроить размер шрифта контента чата",
-      },
-      Update: {
-        Version: (x: string) => `Версия: ${x}`,
-        IsLatest: "Последняя версия",
-        CheckUpdate: "Проверить обновление",
-        IsChecking: "Проверка обновления...",
-        FoundUpdate: (x: string) => `Найдена новая версия: ${x}`,
-        GoToUpdate: "Обновить",
-      },
-      SendKey: "Клавиша отправки",
-      Theme: "Тема",
-      TightBorder: "Узкая граница",
-      SendPreviewBubble: {
-        Title: "Отправить предпросмотр",
-        SubTitle: "Предварительный просмотр markdown в пузыре",
+    Avatar: "Аватар",
+    FontSize: {
+      Title: "Размер шрифта",
+      SubTitle: "Настроить размер шрифта контента чата",
+    },
+    Update: {
+      Version: (x: string) => `Версия: ${x}`,
+      IsLatest: "Последняя версия",
+      CheckUpdate: "Проверить обновление",
+      IsChecking: "Проверка обновления...",
+      FoundUpdate: (x: string) => `Найдена новая версия: ${x}`,
+      GoToUpdate: "Обновить",
+    },
+    SendKey: "Клавиша отправки",
+    Theme: "Тема",
+    TightBorder: "Узкая граница",
+    SendPreviewBubble: {
+      Title: "Отправить предпросмотр",
+      SubTitle: "Предварительный просмотр markdown в пузыре",
+    },
+    Mask: {
+      Title: "Экран заставки маски",
+      SubTitle: "Показывать экран заставки маски перед началом нового чата",
+    },
+    Prompt: {
+      Disable: {
+        Title: "Отключить автозаполнение",
+        SubTitle: "Ввод / для запуска автозаполнения",
       },
-      Mask: {
-        Title: "Экран заставки маски",
-        SubTitle: "Показывать экран заставки маски перед началом нового чата",
+      List: "Список подсказок",
+      ListCount: (builtin: number, custom: number) =>
+        `${builtin} встроенных, ${custom} пользовательских`,
+      Edit: "Редактировать",
+      Modal: {
+        Title: "Список подсказок",
+        Add: "Добавить",
+        Search: "Поиск подсказок",
       },
-      Prompt: {
-        Disable: {
-          Title: "Отключить автозаполнение",
-          SubTitle: "Ввод / для запуска автозаполнения",
-        },
-        List: "Список подсказок",
-        ListCount: (builtin: number, custom: number) =>
-          `${builtin} встроенных, ${custom} пользовательских`,
-        Edit: "Редактировать",
-        Modal: {
-          Title: "Список подсказок",
-          Add: "Добавить",
-          Search: "Поиск подсказок",
-        },
-        EditModal: {
-          Title: "Редактировать подсказку",
-        },
+      EditModal: {
+        Title: "Редактировать подсказку",
       },
-      HistoryCount: {
-        Title: "Количество прикрепляемых сообщений",
-        SubTitle: "Количество отправляемых сообщений, прикрепляемых к каждому запросу",
+    },
+    HistoryCount: {
+      Title: "Количество прикрепляемых сообщений",
+      SubTitle:
+        "Количество отправляемых сообщений, прикрепляемых к каждому запросу",
     },
     CompressThreshold: {
       Title: "Порог сжатия истории",
@@ -163,7 +152,7 @@ const ru: LocaleType = {
       Title: "Максимальное количество токенов",
       SubTitle: "Максимальная длина вводных и генерируемых токенов",
     },
-    PresencePenlty: {
+    PresencePenalty: {
       Title: "Штраф за повторения",
       SubTitle:
         "Чем выше значение, тем больше вероятность общения на новые темы",
@@ -185,7 +174,8 @@ const ru: LocaleType = {
   },
   Copy: {
     Success: "Скопировано в буфер обмена",
-    Failed: "Не удалось скопировать, пожалуйста, предоставьте разрешение на доступ к буферу обмена",
+    Failed:
+      "Не удалось скопировать, пожалуйста, предоставьте разрешение на доступ к буферу обмена",
   },
   Context: {
     Toast: (x: any) => `С ${x} контекстными подсказками`,
@@ -213,7 +203,9 @@ const ru: LocaleType = {
     },
     EditModal: {
       Title: (readonly: boolean) =>
-        `Редактирование шаблона подсказки ${readonly ? "(только для чтения)" : ""}`,
+        `Редактирование шаблона подсказки ${
+          readonly ? "(только для чтения)" : ""
+        }`,
       Download: "Скачать",
       Clone: "Клонировать",
     },
@@ -229,7 +221,8 @@ const ru: LocaleType = {
     SubTitle: "Общайтесь с душой за маской",
     More: "Найти еще",
     NotShow: "Не показывать снова",
-    ConfirmNoShow: "Подтвердите отключение? Вы можете включить это позже в настройках.",
+    ConfirmNoShow:
+      "Подтвердите отключение? Вы можете включить это позже в настройках.",
   },
 
   UI: {

+ 1 - 14
app/locales/tr.ts

@@ -71,19 +71,6 @@ const tr: LocaleType = {
     Lang: {
       Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language`
       All: "Tüm Diller",
-      Options: {
-        cn: "简体中文",
-        en: "English",
-        tw: "繁體中文",
-        es: "Español",
-        it: "Italiano",
-        tr: "Türkçe",
-        jp: "日本語",
-        de: "Deutsch",
-        vi: "Vietnamese",
-        ru: "Русский",
-        no: "Norsk",
-      },
     },
     Avatar: "Avatar",
     FontSize: {
@@ -166,7 +153,7 @@ const tr: LocaleType = {
       SubTitle:
         "Girdi belirteçlerinin ve oluşturulan belirteçlerin maksimum uzunluğu",
     },
-    PresencePenlty: {
+    PresencePenalty: {
       Title: "Varlık Cezası",
       SubTitle:
         "Daha büyük bir değer, yeni konular hakkında konuşma olasılığını artırır",

+ 1 - 14
app/locales/tw.ts

@@ -69,19 +69,6 @@ const tw: LocaleType = {
     Lang: {
       Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language`
       All: "所有语言",
-      Options: {
-        cn: "简体中文",
-        en: "English",
-        tw: "繁體中文",
-        es: "Español",
-        it: "Italiano",
-        tr: "Türkçe",
-        jp: "日本語",
-        de: "Deutsch",
-        vi: "Vietnamese",
-        ru: "Русский",
-        no: "Norsk",
-      },
     },
     Avatar: "大頭貼",
     FontSize: {
@@ -161,7 +148,7 @@ const tw: LocaleType = {
       Title: "單次回應限制 (max_tokens)",
       SubTitle: "單次互動所用的最大 Token 數",
     },
-    PresencePenlty: {
+    PresencePenalty: {
       Title: "話題新穎度 (presence_penalty)",
       SubTitle: "值越大,越有可能擴展到新話題",
     },

+ 2 - 14
app/locales/vi.ts

@@ -2,7 +2,7 @@ import { SubmitKey } from "../store/config";
 import type { LocaleType } from "./index";
 
 const vi: LocaleType = {
-  WIP: "Coming Soon...",
+  WIP: "Sắp ra mắt...",
   Error: {
     Unauthorized:
       "Truy cập chưa xác thực, vui lòng nhập mã truy cập trong trang cài đặt.",
@@ -71,18 +71,6 @@ const vi: LocaleType = {
     Lang: {
       Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language`
       All: "Tất cả ngôn ngữ",
-      Options: {
-        cn: "简体中文",
-        en: "English",
-        tw: "繁體中文",
-        es: "Español",
-        it: "Italiano",
-        tr: "Türkçe",
-        jp: "日本語",
-        de: "Deutsch",
-        vi: "Vietnamese",
-        ru: "Русский",
-      },
     },
     Avatar: "Ảnh đại diện",
     FontSize: {
@@ -162,7 +150,7 @@ const vi: LocaleType = {
       Title: "Giới hạn số lượng token (max_tokens)",
       SubTitle: "Số lượng token tối đa được sử dụng trong mỗi lần tương tác",
     },
-    PresencePenlty: {
+    PresencePenalty: {
       Title: "Chủ đề mới (presence_penalty)",
       SubTitle: "Giá trị càng lớn tăng khả năng mở rộng sang các chủ đề mới",
     },

File diff suppressed because it is too large
+ 70 - 0
app/masks/cn.ts


File diff suppressed because it is too large
+ 7 - 0
app/masks/en.ts


+ 1 - 1
app/masks/index.ts

@@ -15,7 +15,7 @@ export const BUILTIN_MASK_STORE = {
     return this.masks[id] as Mask | undefined;
   },
   add(m: BuiltinMask) {
-    const mask = { ...m, id: this.buildinId++ };
+    const mask = { ...m, id: this.buildinId++, builtin: true };
     this.masks[mask.id] = mask;
     return mask;
   },

+ 3 - 1
app/masks/typing.ts

@@ -1,3 +1,5 @@
 import { type Mask } from "../store/mask";
 
-export type BuiltinMask = Omit<Mask, "id">;
+export type BuiltinMask = Omit<Mask, "id"> & {
+  builtin: true;
+};

+ 0 - 285
app/requests.ts

@@ -1,285 +0,0 @@
-import type { ChatRequest, ChatResponse } from "./api/openai/typing";
-import {
-  Message,
-  ModelConfig,
-  ModelType,
-  useAccessStore,
-  useAppConfig,
-  useChatStore,
-} from "./store";
-import { showToast } from "./components/ui-lib";
-import { ACCESS_CODE_PREFIX } from "./constant";
-
-const TIME_OUT_MS = 60000;
-
-const makeRequestParam = (
-  messages: Message[],
-  options?: {
-    stream?: boolean;
-    overrideModel?: ModelType;
-  },
-): ChatRequest => {
-  let sendMessages = messages.map((v) => ({
-    role: v.role,
-    content: v.content,
-  }));
-
-  const modelConfig = {
-    ...useAppConfig.getState().modelConfig,
-    ...useChatStore.getState().currentSession().mask.modelConfig,
-  };
-
-  // override model config
-  if (options?.overrideModel) {
-    modelConfig.model = options.overrideModel;
-  }
-
-  return {
-    messages: sendMessages,
-    stream: options?.stream,
-    model: modelConfig.model,
-    temperature: modelConfig.temperature,
-    presence_penalty: modelConfig.presence_penalty,
-  };
-};
-
-function getHeaders() {
-  const accessStore = useAccessStore.getState();
-  let headers: Record<string, string> = {};
-
-  const makeBearer = (token: string) => `Bearer ${token.trim()}`;
-  const validString = (x: string) => x && x.length > 0;
-
-  // use user's api key first
-  if (validString(accessStore.token)) {
-    headers.Authorization = makeBearer(accessStore.token);
-  } else if (
-    accessStore.enabledAccessControl() &&
-    validString(accessStore.accessCode)
-  ) {
-    headers.Authorization = makeBearer(
-      ACCESS_CODE_PREFIX + accessStore.accessCode,
-    );
-  }
-
-  return headers;
-}
-
-export function requestOpenaiClient(path: string) {
-  const openaiUrl = useAccessStore.getState().openaiUrl;
-  return (body: any, method = "POST") =>
-    fetch(openaiUrl + path, {
-      method,
-      body: body && JSON.stringify(body),
-      headers: getHeaders(),
-    });
-}
-
-export async function requestChat(
-  messages: Message[],
-  options?: {
-    model?: ModelType;
-  },
-) {
-  const req: ChatRequest = makeRequestParam(messages, {
-    overrideModel: options?.model,
-  });
-
-  const res = await requestOpenaiClient("v1/chat/completions")(req);
-
-  try {
-    const response = (await res.json()) as ChatResponse;
-    return response;
-  } catch (error) {
-    console.error("[Request Chat] ", error, res.body);
-  }
-}
-
-export async function requestUsage() {
-  const formatDate = (d: Date) =>
-    `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, "0")}-${d
-      .getDate()
-      .toString()
-      .padStart(2, "0")}`;
-  const ONE_DAY = 1 * 24 * 60 * 60 * 1000;
-  const now = new Date();
-  const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
-  const startDate = formatDate(startOfMonth);
-  const endDate = formatDate(new Date(Date.now() + ONE_DAY));
-
-  const [used, subs] = await Promise.all([
-    requestOpenaiClient(
-      `dashboard/billing/usage?start_date=${startDate}&end_date=${endDate}`,
-    )(null, "GET"),
-    requestOpenaiClient("dashboard/billing/subscription")(null, "GET"),
-  ]);
-
-  const response = (await used.json()) as {
-    total_usage?: number;
-    error?: {
-      type: string;
-      message: string;
-    };
-  };
-
-  const total = (await subs.json()) as {
-    hard_limit_usd?: number;
-  };
-
-  if (response.error && response.error.type) {
-    showToast(response.error.message);
-    return;
-  }
-
-  if (response.total_usage) {
-    response.total_usage = Math.round(response.total_usage) / 100;
-  }
-
-  if (total.hard_limit_usd) {
-    total.hard_limit_usd = Math.round(total.hard_limit_usd * 100) / 100;
-  }
-
-  return {
-    used: response.total_usage,
-    subscription: total.hard_limit_usd,
-  };
-}
-
-export async function requestChatStream(
-  messages: Message[],
-  options?: {
-    modelConfig?: ModelConfig;
-    overrideModel?: ModelType;
-    onMessage: (message: string, done: boolean) => void;
-    onError: (error: Error, statusCode?: number) => void;
-    onController?: (controller: AbortController) => void;
-  },
-) {
-  const req = makeRequestParam(messages, {
-    stream: true,
-    overrideModel: options?.overrideModel,
-  });
-
-  console.log("[Request] ", req);
-
-  const controller = new AbortController();
-  const reqTimeoutId = setTimeout(() => controller.abort(), TIME_OUT_MS);
-
-  try {
-    const openaiUrl = useAccessStore.getState().openaiUrl;
-    const res = await fetch(openaiUrl + "v1/chat/completions", {
-      method: "POST",
-      headers: {
-        "Content-Type": "application/json",
-        ...getHeaders(),
-      },
-      body: JSON.stringify(req),
-      signal: controller.signal,
-    });
-
-    clearTimeout(reqTimeoutId);
-
-    let responseText = "";
-
-    const finish = () => {
-      options?.onMessage(responseText, true);
-      controller.abort();
-    };
-
-    if (res.ok) {
-      const reader = res.body?.getReader();
-      const decoder = new TextDecoder();
-
-      options?.onController?.(controller);
-
-      while (true) {
-        const resTimeoutId = setTimeout(() => finish(), TIME_OUT_MS);
-        const content = await reader?.read();
-        clearTimeout(resTimeoutId);
-
-        if (!content || !content.value) {
-          break;
-        }
-
-        const text = decoder.decode(content.value, { stream: true });
-        responseText += text;
-
-        const done = content.done;
-        options?.onMessage(responseText, false);
-
-        if (done) {
-          break;
-        }
-      }
-
-      finish();
-    } else if (res.status === 401) {
-      console.error("Unauthorized");
-      options?.onError(new Error("Unauthorized"), res.status);
-    } else {
-      console.error("Stream Error", res.body);
-      options?.onError(new Error("Stream Error"), res.status);
-    }
-  } catch (err) {
-    console.error("NetWork Error", err);
-    options?.onError(err as Error);
-  }
-}
-
-export async function requestWithPrompt(
-  messages: Message[],
-  prompt: string,
-  options?: {
-    model?: ModelType;
-  },
-) {
-  messages = messages.concat([
-    {
-      role: "user",
-      content: prompt,
-      date: new Date().toLocaleString(),
-    },
-  ]);
-
-  const res = await requestChat(messages, options);
-
-  return res?.choices?.at(0)?.message?.content ?? "";
-}
-
-// To store message streaming controller
-export const ControllerPool = {
-  controllers: {} as Record<string, AbortController>,
-
-  addController(
-    sessionIndex: number,
-    messageId: number,
-    controller: AbortController,
-  ) {
-    const key = this.key(sessionIndex, messageId);
-    this.controllers[key] = controller;
-    return key;
-  },
-
-  stop(sessionIndex: number, messageId: number) {
-    const key = this.key(sessionIndex, messageId);
-    const controller = this.controllers[key];
-    controller?.abort();
-  },
-
-  stopAll() {
-    Object.values(this.controllers).forEach((v) => v.abort());
-  },
-
-  hasPending() {
-    return Object.values(this.controllers).length > 0;
-  },
-
-  remove(sessionIndex: number, messageId: number) {
-    const key = this.key(sessionIndex, messageId);
-    delete this.controllers[key];
-  },
-
-  key(sessionIndex: number, messageIndex: number) {
-    return `${sessionIndex},${messageIndex}`;
-  },
-};

+ 16 - 3
app/store/access.ts

@@ -1,8 +1,10 @@
 import { create } from "zustand";
 import { persist } from "zustand/middleware";
-import { StoreKey } from "../constant";
+import { DEFAULT_API_HOST, StoreKey } from "../constant";
+import { getHeaders } from "../client/api";
 import { BOT_HELLO } from "./chat";
 import { ALL_MODELS } from "./config";
+import { getClientConfig } from "../config/client";
 
 export interface AccessControlStore {
   accessCode: string;
@@ -14,6 +16,7 @@ export interface AccessControlStore {
 
   updateToken: (_: string) => void;
   updateCode: (_: string) => void;
+  updateOpenAiUrl: (_: string) => void;
   enabledAccessControl: () => boolean;
   isAuthorized: () => boolean;
   fetch: () => void;
@@ -21,6 +24,10 @@ export interface AccessControlStore {
 
 let fetchState = 0; // 0 not fetch, 1 fetching, 2 done
 
+const DEFAULT_OPENAI_URL =
+  getClientConfig()?.buildMode === "export" ? DEFAULT_API_HOST : "/api/openai/";
+console.log("[API] default openai url", DEFAULT_OPENAI_URL);
+
 export const useAccessStore = create<AccessControlStore>()(
   persist(
     (set, get) => ({
@@ -28,7 +35,7 @@ export const useAccessStore = create<AccessControlStore>()(
       accessCode: "",
       needCode: true,
       hideUserApiKey: false,
-      openaiUrl: "/api/openai/",
+      openaiUrl: DEFAULT_OPENAI_URL,
 
       enabledAccessControl() {
         get().fetch();
@@ -41,6 +48,9 @@ export const useAccessStore = create<AccessControlStore>()(
       updateToken(token: string) {
         set(() => ({ token }));
       },
+      updateOpenAiUrl(url: string) {
+        set(() => ({ openaiUrl: url }));
+      },
       isAuthorized() {
         get().fetch();
 
@@ -50,11 +60,14 @@ export const useAccessStore = create<AccessControlStore>()(
         );
       },
       fetch() {
-        if (fetchState > 0) return;
+        if (fetchState > 0 || getClientConfig()?.buildMode === "export") return;
         fetchState = 1;
         fetch("/api/config", {
           method: "post",
           body: null,
+          headers: {
+            ...getHeaders(),
+          },
         })
           .then((res) => res.json())
           .then((res: DangerConfig) => {

+ 116 - 80
app/store/chat.ts

@@ -1,12 +1,6 @@
 import { create } from "zustand";
 import { persist } from "zustand/middleware";
 
-import { type ChatCompletionResponseMessage } from "openai";
-import {
-  ControllerPool,
-  requestChatStream,
-  requestWithPrompt,
-} from "../requests";
 import { trimTopic } from "../utils";
 
 import Locale from "../locales";
@@ -14,8 +8,12 @@ import { showToast } from "../components/ui-lib";
 import { ModelType } from "./config";
 import { createEmptyMask, Mask } from "./mask";
 import { StoreKey } from "../constant";
+import { api, RequestMessage } from "../client/api";
+import { ChatControllerPool } from "../client/controller";
+import { prettyObject } from "../utils/format";
+import { estimateTokenLength } from "../utils/token";
 
-export type Message = ChatCompletionResponseMessage & {
+export type ChatMessage = RequestMessage & {
   date: string;
   streaming?: boolean;
   isError?: boolean;
@@ -23,7 +21,7 @@ export type Message = ChatCompletionResponseMessage & {
   model?: ModelType;
 };
 
-export function createMessage(override: Partial<Message>): Message {
+export function createMessage(override: Partial<ChatMessage>): ChatMessage {
   return {
     id: Date.now(),
     date: new Date().toLocaleString(),
@@ -33,8 +31,6 @@ export function createMessage(override: Partial<Message>): Message {
   };
 }
 
-export const ROLES: Message["role"][] = ["system", "user", "assistant"];
-
 export interface ChatStat {
   tokenCount: number;
   wordCount: number;
@@ -43,20 +39,20 @@ export interface ChatStat {
 
 export interface ChatSession {
   id: number;
-
   topic: string;
 
   memoryPrompt: string;
-  messages: Message[];
+  messages: ChatMessage[];
   stat: ChatStat;
   lastUpdate: number;
   lastSummarizeIndex: number;
+  clearContextIndex?: number;
 
   mask: Mask;
 }
 
 export const DEFAULT_TOPIC = Locale.Store.DefaultTopic;
-export const BOT_HELLO: Message = createMessage({
+export const BOT_HELLO: ChatMessage = createMessage({
   role: "assistant",
   content: Locale.Store.BotHello,
 });
@@ -74,6 +70,7 @@ function createEmptySession(): ChatSession {
     },
     lastUpdate: Date.now(),
     lastSummarizeIndex: 0,
+
     mask: createEmptyMask(),
   };
 }
@@ -88,25 +85,25 @@ interface ChatStore {
   newSession: (mask?: Mask) => void;
   deleteSession: (index: number) => void;
   currentSession: () => ChatSession;
-  onNewMessage: (message: Message) => void;
+  onNewMessage: (message: ChatMessage) => void;
   onUserInput: (content: string) => Promise<void>;
   summarizeSession: () => void;
-  updateStat: (message: Message) => void;
+  updateStat: (message: ChatMessage) => void;
   updateCurrentSession: (updater: (session: ChatSession) => void) => void;
   updateMessage: (
     sessionIndex: number,
     messageIndex: number,
-    updater: (message?: Message) => void,
+    updater: (message?: ChatMessage) => void,
   ) => void;
   resetSession: () => void;
-  getMessagesWithMemory: () => Message[];
-  getMemoryPrompt: () => Message;
+  getMessagesWithMemory: () => ChatMessage[];
+  getMemoryPrompt: () => ChatMessage;
 
   clearAllData: () => void;
 }
 
-function countMessages(msgs: Message[]) {
-  return msgs.reduce((pre, cur) => pre + cur.content.length, 0);
+function countMessages(msgs: ChatMessage[]) {
+  return msgs.reduce((pre, cur) => pre + estimateTokenLength(cur.content), 0);
 }
 
 export const useChatStore = create<ChatStore>()(
@@ -230,6 +227,7 @@ export const useChatStore = create<ChatStore>()(
 
       onNewMessage(message) {
         get().updateCurrentSession((session) => {
+          session.messages = session.messages.concat();
           session.lastUpdate = Date.now();
         });
         get().updateStat(message);
@@ -240,12 +238,12 @@ export const useChatStore = create<ChatStore>()(
         const session = get().currentSession();
         const modelConfig = session.mask.modelConfig;
 
-        const userMessage: Message = createMessage({
+        const userMessage: ChatMessage = createMessage({
           role: "user",
           content,
         });
 
-        const botMessage: Message = createMessage({
+        const botMessage: ChatMessage = createMessage({
           role: "assistant",
           streaming: true,
           id: userMessage.id! + 1,
@@ -254,14 +252,19 @@ export const useChatStore = create<ChatStore>()(
 
         const systemInfo = createMessage({
           role: "system",
-          content: `IMPRTANT: You are a virtual assistant powered by the ${
+          content: `IMPORTANT: You are a virtual assistant powered by the ${
             modelConfig.model
           } model, now time is ${new Date().toLocaleString()}}`,
           id: botMessage.id! + 1,
         });
 
         // get recent messages
-        const systemMessages = [systemInfo];
+        const systemMessages = [];
+        // if user define a mask with context prompts, wont send system info
+        if (session.mask.context.length === 0) {
+          systemMessages.push(systemInfo);
+        }
+
         const recentMessages = get().getMessagesWithMemory();
         const sendMessages = systemMessages.concat(
           recentMessages.concat(userMessage),
@@ -271,51 +274,63 @@ export const useChatStore = create<ChatStore>()(
 
         // save user's and bot's message
         get().updateCurrentSession((session) => {
-          session.messages.push(userMessage);
-          session.messages.push(botMessage);
+          session.messages = session.messages.concat([userMessage, botMessage]);
         });
 
         // make request
         console.log("[User Input] ", sendMessages);
-        requestChatStream(sendMessages, {
-          onMessage(content, done) {
-            // stream response
-            if (done) {
-              botMessage.streaming = false;
-              botMessage.content = content;
+        api.llm.chat({
+          messages: sendMessages,
+          config: { ...modelConfig, stream: true },
+          onUpdate(message) {
+            botMessage.streaming = true;
+            if (message) {
+              botMessage.content = message;
+            }
+            get().updateCurrentSession((session) => {
+              session.messages = session.messages.concat();
+            });
+          },
+          onFinish(message) {
+            botMessage.streaming = false;
+            if (message) {
+              botMessage.content = message;
               get().onNewMessage(botMessage);
-              ControllerPool.remove(
-                sessionIndex,
-                botMessage.id ?? messageIndex,
-              );
-            } else {
-              botMessage.content = content;
-              set(() => ({}));
             }
+            ChatControllerPool.remove(
+              sessionIndex,
+              botMessage.id ?? messageIndex,
+            );
           },
-          onError(error, statusCode) {
+          onError(error) {
             const isAborted = error.message.includes("aborted");
-            if (statusCode === 401) {
-              botMessage.content = Locale.Error.Unauthorized;
-            } else if (!isAborted) {
-              botMessage.content += "\n\n" + Locale.Store.Error;
-            }
+            botMessage.content =
+              "\n\n" +
+              prettyObject({
+                error: true,
+                message: error.message,
+              });
             botMessage.streaming = false;
             userMessage.isError = !isAborted;
             botMessage.isError = !isAborted;
+            get().updateCurrentSession((session) => {
+              session.messages = session.messages.concat();
+            });
+            ChatControllerPool.remove(
+              sessionIndex,
+              botMessage.id ?? messageIndex,
+            );
 
-            set(() => ({}));
-            ControllerPool.remove(sessionIndex, botMessage.id ?? messageIndex);
+            console.error("[Chat] failed ", error);
           },
           onController(controller) {
             // collect controller for stop/retry
-            ControllerPool.addController(
+            ChatControllerPool.addController(
               sessionIndex,
               botMessage.id ?? messageIndex,
               controller,
             );
           },
-          modelConfig: { ...modelConfig },
         });
       },
 
@@ -329,13 +344,18 @@ export const useChatStore = create<ChatStore>()(
               ? Locale.Store.Prompt.History(session.memoryPrompt)
               : "",
           date: "",
-        } as Message;
+        } as ChatMessage;
       },
 
       getMessagesWithMemory() {
         const session = get().currentSession();
         const modelConfig = session.mask.modelConfig;
-        const messages = session.messages.filter((msg) => !msg.isError);
+
+        // wont send cleared context messages
+        const clearedContextMessages = session.messages.slice(
+          session.clearContextIndex ?? 0,
+        );
+        const messages = clearedContextMessages.filter((msg) => !msg.isError);
         const n = messages.length;
 
         const context = session.mask.context.slice();
@@ -356,17 +376,17 @@ export const useChatStore = create<ChatStore>()(
           n - modelConfig.historyMessageCount,
         );
         const longTermMemoryMessageIndex = session.lastSummarizeIndex;
-        const oldestIndex = Math.max(
+        const mostRecentIndex = Math.max(
           shortTermMemoryMessageIndex,
           longTermMemoryMessageIndex,
         );
-        const threshold = modelConfig.compressMessageLengthThreshold;
+        const threshold = modelConfig.compressMessageLengthThreshold * 2;
 
         // get recent messages as many as possible
         const reversedRecentMessages = [];
         for (
           let i = n - 1, count = 0;
-          i >= oldestIndex && count < threshold;
+          i >= mostRecentIndex && count < threshold;
           i -= 1
         ) {
           const msg = messages[i];
@@ -384,7 +404,7 @@ export const useChatStore = create<ChatStore>()(
       updateMessage(
         sessionIndex: number,
         messageIndex: number,
-        updater: (message?: Message) => void,
+        updater: (message?: ChatMessage) => void,
       ) {
         const sessions = get().sessions;
         const session = sessions.at(sessionIndex);
@@ -403,26 +423,44 @@ export const useChatStore = create<ChatStore>()(
       summarizeSession() {
         const session = get().currentSession();
 
+        // remove error messages if any
+        const messages = session.messages;
+
         // should summarize topic after chating more than 50 words
         const SUMMARIZE_MIN_LEN = 50;
         if (
           session.topic === DEFAULT_TOPIC &&
-          countMessages(session.messages) >= SUMMARIZE_MIN_LEN
+          countMessages(messages) >= SUMMARIZE_MIN_LEN
         ) {
-          requestWithPrompt(session.messages, Locale.Store.Prompt.Topic, {
-            model: "gpt-3.5-turbo",
-          }).then((res) => {
-            get().updateCurrentSession(
-              (session) =>
-                (session.topic = res ? trimTopic(res) : DEFAULT_TOPIC),
-            );
+          const topicMessages = messages.concat(
+            createMessage({
+              role: "user",
+              content: Locale.Store.Prompt.Topic,
+            }),
+          );
+          api.llm.chat({
+            messages: topicMessages,
+            config: {
+              model: "gpt-3.5-turbo",
+            },
+            onFinish(message) {
+              get().updateCurrentSession(
+                (session) =>
+                  (session.topic =
+                    message.length > 0 ? trimTopic(message) : DEFAULT_TOPIC),
+              );
+            },
           });
         }
 
         const modelConfig = session.mask.modelConfig;
-        let toBeSummarizedMsgs = session.messages.slice(
+        const summarizeIndex = Math.max(
           session.lastSummarizeIndex,
+          session.clearContextIndex ?? 0,
         );
+        let toBeSummarizedMsgs = messages
+          .filter((msg) => !msg.isError)
+          .slice(summarizeIndex);
 
         const historyMsgLength = countMessages(toBeSummarizedMsgs);
 
@@ -447,28 +485,26 @@ export const useChatStore = create<ChatStore>()(
 
         if (
           historyMsgLength > modelConfig.compressMessageLengthThreshold &&
-          session.mask.modelConfig.sendMemory
+          modelConfig.sendMemory
         ) {
-          requestChatStream(
-            toBeSummarizedMsgs.concat({
+          api.llm.chat({
+            messages: toBeSummarizedMsgs.concat({
               role: "system",
               content: Locale.Store.Prompt.Summarize,
               date: "",
             }),
-            {
-              overrideModel: "gpt-3.5-turbo",
-              onMessage(message, done) {
-                session.memoryPrompt = message;
-                if (done) {
-                  console.log("[Memory] ", session.memoryPrompt);
-                  session.lastSummarizeIndex = lastSummarizeIndex;
-                }
-              },
-              onError(error) {
-                console.error("[Summarize] ", error);
-              },
+            config: { ...modelConfig, stream: true },
+            onUpdate(message) {
+              session.memoryPrompt = message;
             },
-          );
+            onFinish(message) {
+              console.log("[Memory] ", message);
+              session.lastSummarizeIndex = lastSummarizeIndex;
+            },
+            onError(err) {
+              console.error("[Summarize] ", err);
+            },
+          });
         }
       },
 

+ 23 - 2
app/store/config.ts

@@ -1,5 +1,6 @@
 import { create } from "zustand";
 import { persist } from "zustand/middleware";
+import { getClientConfig } from "../config/client";
 import { StoreKey } from "../constant";
 
 export enum SubmitKey {
@@ -21,7 +22,7 @@ export const DEFAULT_CONFIG = {
   avatar: "1f603",
   fontSize: 14,
   theme: Theme.Auto as Theme,
-  tightBorder: false,
+  tightBorder: !!getClientConfig()?.isApp,
   sendPreviewBubble: true,
   sidebarWidth: 300,
 
@@ -60,6 +61,10 @@ export const ALL_MODELS = [
     name: "gpt-4-0314",
     available: ENABLE_GPT4,
   },
+  {
+    name: "gpt-4-0613",
+    available: ENABLE_GPT4,
+  },
   {
     name: "gpt-4-32k",
     available: ENABLE_GPT4,
@@ -68,6 +73,10 @@ export const ALL_MODELS = [
     name: "gpt-4-32k-0314",
     available: ENABLE_GPT4,
   },
+  {
+    name: "gpt-4-32k-0613",
+    available: ENABLE_GPT4,
+  },
   {
     name: "gpt-3.5-turbo",
     available: true,
@@ -76,6 +85,18 @@ export const ALL_MODELS = [
     name: "gpt-3.5-turbo-0301",
     available: true,
   },
+  {
+    name: "gpt-3.5-turbo-0613",
+    available: true,
+  },
+  {
+    name: "gpt-3.5-turbo-16k",
+    available: true,
+  },
+  {
+    name: "gpt-3.5-turbo-16k-0613",
+    available: true,
+  },
   {
     name: "qwen-v1", // 通义千问
     available: false,
@@ -116,7 +137,7 @@ export function limitNumber(
 export function limitModel(name: string) {
   return ALL_MODELS.some((m) => m.name === name && m.available)
     ? name
-    : ALL_MODELS[4].name;
+    : "gpt-3.5-turbo";
 }
 
 export const ModalConfigValidator = {

+ 5 - 2
app/store/mask.ts

@@ -2,7 +2,7 @@ import { create } from "zustand";
 import { persist } from "zustand/middleware";
 import { BUILTIN_MASKS } from "../masks";
 import { getLang, Lang } from "../locales";
-import { DEFAULT_TOPIC, Message } from "./chat";
+import { DEFAULT_TOPIC, ChatMessage } from "./chat";
 import { ModelConfig, ModelType, useAppConfig } from "./config";
 import { StoreKey } from "../constant";
 
@@ -10,7 +10,9 @@ export type Mask = {
   id: number;
   avatar: string;
   name: string;
-  context: Message[];
+  hideContext?: boolean;
+  context: ChatMessage[];
+  syncGlobalConfig?: boolean;
   modelConfig: ModelConfig;
   lang: Lang;
   builtin: boolean;
@@ -39,6 +41,7 @@ export const createEmptyMask = () =>
     avatar: DEFAULT_MASK_AVATAR,
     name: DEFAULT_TOPIC,
     context: [],
+    syncGlobalConfig: true, // use global config as default
     modelConfig: { ...useAppConfig.getState().modelConfig },
     lang: getLang(),
     builtin: false,

+ 15 - 21
app/store/update.ts

@@ -1,7 +1,8 @@
 import { create } from "zustand";
 import { persist } from "zustand/middleware";
-import { FETCH_COMMIT_URL, FETCH_TAG_URL, StoreKey } from "../constant";
-import { requestUsage } from "../requests";
+import { FETCH_COMMIT_URL, StoreKey } from "../constant";
+import { api } from "../client/api";
+import { getClientConfig } from "../config/client";
 
 export interface UpdateStore {
   lastUpdate: number;
@@ -16,20 +17,6 @@ export interface UpdateStore {
   updateUsage: (force?: boolean) => Promise<void>;
 }
 
-function queryMeta(key: string, defaultValue?: string): string {
-  let ret: string;
-  if (document) {
-    const meta = document.head.querySelector(
-      `meta[name='${key}']`,
-    ) as HTMLMetaElement;
-    ret = meta?.content ?? "";
-  } else {
-    ret = defaultValue ?? "";
-  }
-
-  return ret;
-}
-
 const ONE_MINUTE = 60 * 1000;
 
 export const useUpdateStore = create<UpdateStore>()(
@@ -43,7 +30,7 @@ export const useUpdateStore = create<UpdateStore>()(
       version: "unknown",
 
       async getLatestVersion(force = false) {
-        set(() => ({ version: queryMeta("version") ?? "unknown" }));
+        set(() => ({ version: getClientConfig()?.commitId ?? "unknown" }));
 
         const overTenMins = Date.now() - get().lastUpdate > 10 * ONE_MINUTE;
         if (!force && !overTenMins) return;
@@ -73,10 +60,17 @@ export const useUpdateStore = create<UpdateStore>()(
           lastUpdateUsage: Date.now(),
         }));
 
-        const usage = await requestUsage();
-
-        if (usage) {
-          set(() => usage);
+        try {
+          const usage = await api.llm.usage();
+
+          if (usage) {
+            set(() => ({
+              used: usage.used,
+              subscription: usage.total,
+            }));
+          }
+        } catch (e) {
+          console.error((e as Error).message);
         }
       },
     }),

+ 1 - 1
app/styles/markdown.scss

@@ -1116,4 +1116,4 @@
 
 .markdown-body ::-webkit-calendar-picker-indicator {
   filter: invert(50%);
-}
+}

+ 1 - 0
app/typing.ts

@@ -0,0 +1 @@
+export type Updater<T> = (updater: (value: T) => void) => void;

+ 0 - 7
app/utils.ts

@@ -98,13 +98,6 @@ export function useMobileScreen() {
   return width <= MOBILE_MAX_WIDTH;
 }
 
-export function isMobileScreen() {
-  if (typeof window === "undefined") {
-    return false;
-  }
-  return window.innerWidth <= MOBILE_MAX_WIDTH;
-}
-
 export function isFirefox() {
   return (
     typeof navigator !== "undefined" && /firefox/i.test(navigator.userAgent)

+ 13 - 0
app/utils/format.ts

@@ -0,0 +1,13 @@
+export function prettyObject(msg: any) {
+  const obj = msg;
+  if (typeof msg !== "string") {
+    msg = JSON.stringify(msg, null, "  ");
+  }
+  if (msg === "{}") {
+    return obj.toString();
+  }
+  if (msg.startsWith("```json")) {
+    return msg;
+  }
+  return ["```json", msg, "```"].join("\n");
+}

+ 9 - 0
app/utils/merge.ts

@@ -0,0 +1,9 @@
+export function merge(target: any, source: any) {
+  Object.keys(source).forEach(function (key) {
+    if (source[key] && typeof source[key] === "object") {
+      merge((target[key] = target[key] || {}), source[key]);
+      return;
+    }
+    target[key] = source[key];
+  });
+}

+ 22 - 0
app/utils/token.ts

@@ -0,0 +1,22 @@
+export function estimateTokenLength(input: string): number {
+  let tokenLength = 0;
+
+  for (let i = 0; i < input.length; i++) {
+    const charCode = input.charCodeAt(i);
+
+    if (charCode < 128) {
+      // ASCII character
+      if (charCode <= 122 && charCode >= 65) {
+        // a-Z
+        tokenLength += 0.25;
+      } else {
+        tokenLength += 0.5;
+      }
+    } else {
+      // Unicode character
+      tokenLength += 1.5;
+    }
+  }
+
+  return tokenLength;
+}

+ 30 - 0
docker-compose.yml

@@ -0,0 +1,30 @@
+version: '3.9'
+services:
+  chatgpt-next-web: 
+    profiles: ["no-proxy"]
+    container_name: chatgpt-next-web
+    image: yidadaa/chatgpt-next-web
+    ports:
+      - 3000:3000
+    environment:
+      - OPENAI_API_KEY=$OPENAI_API_KEY
+      - CODE=$CODE
+      - BASE_URL=$BASE_URL
+      - OPENAI_ORG_ID=$OPENAI_ORG_ID
+      - HIDE_USER_API_KEY=$HIDE_USER_API_KEY
+      - DISABLE_GPT4=$DISABLE_GPT4
+
+  chatgpt-next-web-proxy: 
+    profiles: ["proxy"]
+    container_name: chatgpt-next-web-proxy
+    image: yidadaa/chatgpt-next-web
+    ports:
+      - 3000:3000
+    environment:
+      - OPENAI_API_KEY=$OPENAI_API_KEY
+      - CODE=$CODE
+      - PROXY_URL=$PROXY_URL
+      - BASE_URL=$BASE_URL
+      - OPENAI_ORG_ID=$OPENAI_ORG_ID
+      - HIDE_USER_API_KEY=$HIDE_USER_API_KEY
+      - DISABLE_GPT4=$DISABLE_GPT4

+ 37 - 0
docs/cloudflare-pages-es.md

@@ -0,0 +1,37 @@
+# Guía de implementación de Cloudflare Pages
+
+## Cómo crear un nuevo proyecto
+
+Bifurca el proyecto en Github, luego inicia sesión en dash.cloudflare.com y ve a Pages.
+
+1.  Haga clic en "Crear un proyecto".
+2.  Selecciona Conectar a Git.
+3.  Vincula páginas de Cloudflare a tu cuenta de GitHub.
+4.  Seleccione este proyecto que bifurcó.
+5.  Haga clic en "Comenzar configuración".
+6.  Para "Nombre del proyecto" y "Rama de producción", puede utilizar los valores predeterminados o cambiarlos según sea necesario.
+7.  En Configuración de compilación, seleccione la opción Ajustes preestablecidos de Framework y seleccione Siguiente.js.
+8.  Debido a los errores de node:buffer, no use el "comando Construir" predeterminado por ahora. Utilice el siguiente comando:
+        npx https://prerelease-registry.devprod.cloudflare.dev/next-on-pages/runs/4930842298/npm-package-next-on-pages-230 --experimental-minify
+9.  Para "Generar directorio de salida", utilice los valores predeterminados y no los modifique.
+10. No modifique el "Directorio raíz".
+11. Para "Variables de entorno", haga clic en ">" y luego haga clic en "Agregar variable". Rellene la siguiente información:
+
+    *   `NODE_VERSION=20.1`
+    *   `NEXT_TELEMETRY_DISABLE=1`
+    *   `OPENAI_API_KEY=你自己的API Key`
+    *   `YARN_VERSION=1.22.19`
+    *   `PHP_VERSION=7.4`
+
+    Dependiendo de sus necesidades reales, puede completar opcionalmente las siguientes opciones:
+
+    *   `CODE= 可选填,访问密码,可以使用逗号隔开多个密码`
+    *   `OPENAI_ORG_ID= 可选填,指定 OpenAI 中的组织 ID`
+    *   `HIDE_USER_API_KEY=1 可选,不让用户自行填入 API Key`
+    *   `DISABLE_GPT4=1 可选,不让用户使用 GPT-4`
+12. Haga clic en "Guardar e implementar".
+13. Haga clic en "Cancelar implementación" porque necesita rellenar los indicadores de compatibilidad.
+14. Vaya a "Configuración de compilación", "Funciones" y busque "Indicadores de compatibilidad".
+15. Rellene "nodejs_compat" en "Configurar indicador de compatibilidad de producción" y "Configurar indicador de compatibilidad de vista previa".
+16. Vaya a "Implementaciones" y haga clic en "Reintentar implementación".
+17. Disfrutar.

Some files were not shown because too many files changed in this diff