Răsfoiți Sursa

first commit

xiongzhu 2 ani în urmă
comite
ac57535409
100 a modificat fișierele cu 5816 adăugiri și 0 ștergeri
  1. 3 0
      .commitlintrc.json
  2. 10 0
      .env
  3. 2 0
      .eslintignore
  4. 26 0
      .eslintrc.cjs
  5. 17 0
      .gitattributes
  6. 41 0
      .github/workflows/build_docker.yml
  7. 47 0
      .github/workflows/ci.yml
  8. 22 0
      .github/workflows/issues_close.yml
  9. 32 0
      .gitignore
  10. 4 0
      .husky/commit-msg
  11. 4 0
      .husky/pre-commit
  12. 1 0
      .npmrc
  13. 8 0
      .prettierrc.js
  14. 5 0
      .vscode/settings.json
  15. 56 0
      Dockerfile
  16. 337 0
      README.md
  17. 1 0
      config/index.ts
  18. 16 0
      config/proxy.ts
  19. 83 0
      index.html
  20. 76 0
      package.json
  21. 6 0
      postcss.config.js
  22. BIN
      public/favicon.ico
  23. 1 0
      public/favicon.svg
  24. BIN
      public/pwa-192x192.png
  25. BIN
      public/pwa-512x512.png
  26. 17 0
      src/App.vue
  27. 65 0
      src/api/index.ts
  28. BIN
      src/assets/avatar.jpg
  29. 14 0
      src/assets/recommend.json
  30. 20 0
      src/components/common/HoverButton/Button.vue
  31. 46 0
      src/components/common/HoverButton/index.vue
  32. 43 0
      src/components/common/NaiveProvider/index.vue
  33. 478 0
      src/components/common/PromptStore/index.vue
  34. 66 0
      src/components/common/Setting/About.vue
  35. 70 0
      src/components/common/Setting/Advanced.vue
  36. 216 0
      src/components/common/Setting/General.vue
  37. 70 0
      src/components/common/Setting/index.vue
  38. 21 0
      src/components/common/SvgIcon/index.vue
  39. 35 0
      src/components/common/UserAvatar/index.vue
  40. 8 0
      src/components/common/index.ts
  41. 6 0
      src/components/custom/GithubSite.vue
  42. 3 0
      src/components/custom/index.ts
  43. 8 0
      src/hooks/useBasicLayout.ts
  44. 33 0
      src/hooks/useIconRender.ts
  45. 27 0
      src/hooks/useLanguage.ts
  46. 39 0
      src/hooks/useTheme.ts
  47. 9 0
      src/icons/403.vue
  48. 0 0
      src/icons/404.svg
  49. 44 0
      src/icons/500.vue
  50. 94 0
      src/locales/en-US.ts
  51. 34 0
      src/locales/index.ts
  52. 94 0
      src/locales/zh-CN.ts
  53. 94 0
      src/locales/zh-TW.ts
  54. 23 0
      src/main.ts
  55. 18 0
      src/plugins/assets.ts
  56. 4 0
      src/plugins/index.ts
  57. 28 0
      src/plugins/scrollbarStyle.ts
  58. 52 0
      src/router/index.ts
  59. 21 0
      src/router/permission.ts
  60. 10 0
      src/store/index.ts
  61. 26 0
      src/store/modules/app/helper.ts
  62. 34 0
      src/store/modules/app/index.ts
  63. 15 0
      src/store/modules/auth/helper.ts
  64. 53 0
      src/store/modules/auth/index.ts
  65. 22 0
      src/store/modules/chat/helper.ts
  66. 187 0
      src/store/modules/chat/index.ts
  67. 6 0
      src/store/modules/index.ts
  68. 18 0
      src/store/modules/prompt/helper.ts
  69. 17 0
      src/store/modules/prompt/index.ts
  70. 31 0
      src/store/modules/settings/helper.ts
  71. 22 0
      src/store/modules/settings/index.ts
  72. 33 0
      src/store/modules/user/helper.ts
  73. 22 0
      src/store/modules/user/index.ts
  74. 10 0
      src/styles/global.less
  75. 1103 0
      src/styles/lib/github-markdown.less
  76. 206 0
      src/styles/lib/highlight.less
  77. 3 0
      src/styles/lib/tailwind.css
  78. 45 0
      src/typings/chat.d.ts
  79. 8 0
      src/typings/env.d.ts
  80. 6 0
      src/typings/global.d.ts
  81. 17 0
      src/utils/crypto/index.ts
  82. 41 0
      src/utils/format/index.ts
  83. 15 0
      src/utils/functions/debounce.ts
  84. 7 0
      src/utils/functions/index.ts
  85. 55 0
      src/utils/is/index.ts
  86. 30 0
      src/utils/request/axios.ts
  87. 103 0
      src/utils/request/index.ts
  88. 1 0
      src/utils/storage/index.ts
  89. 68 0
      src/utils/storage/local.ts
  90. 74 0
      src/views/chat/components/Header/index.vue
  91. 31 0
      src/views/chat/components/Message/Avatar.vue
  92. 84 0
      src/views/chat/components/Message/Text.vue
  93. 126 0
      src/views/chat/components/Message/index.vue
  94. 73 0
      src/views/chat/components/Message/style.less
  95. 3 0
      src/views/chat/components/index.ts
  96. 28 0
      src/views/chat/hooks/useChat.ts
  97. 22 0
      src/views/chat/hooks/useCopyCode.ts
  98. 42 0
      src/views/chat/hooks/useScroll.ts
  99. 21 0
      src/views/chat/hooks/useUsingContext.ts
  100. 500 0
      src/views/chat/index.vue

+ 3 - 0
.commitlintrc.json

@@ -0,0 +1,3 @@
+{
+  "extends": ["@commitlint/config-conventional"]
+}

+ 10 - 0
.env

@@ -0,0 +1,10 @@
+# Glob API URL
+VITE_GLOB_API_URL=http://192.168.6.20:3002/
+
+VITE_APP_API_BASE_URL=http://192.168.6.20:3002/
+
+# Whether long replies are supported, which may result in higher API fees
+VITE_GLOB_OPEN_LONG_REPLY=false
+
+# When you want to use PWA
+VITE_GLOB_APP_PWA=false

+ 2 - 0
.eslintignore

@@ -0,0 +1,2 @@
+docker-compose
+kubernetes

+ 26 - 0
.eslintrc.cjs

@@ -0,0 +1,26 @@
+/* eslint-env node */
+require('@rushstack/eslint-patch/modern-module-resolution')
+
+module.exports = {
+    root: true,
+    extends: [
+        'plugin:vue/vue3-essential',
+        'eslint:recommended',
+        '@vue/eslint-config-typescript',
+        '@vue/eslint-config-prettier/skip-formatting'
+    ],
+    parserOptions: {
+        ecmaVersion: 'latest'
+    },
+    env: {
+        node: true
+    },
+    rules: {
+        'vue/no-deprecated-slot-attribute': 'off',
+        'no-unused-vars': 'off',
+        'vue/multi-word-component-names': 'off'
+    },
+    globals: {
+        Chat: 'readonly'
+    }
+}

+ 17 - 0
.gitattributes

@@ -0,0 +1,17 @@
+"*.vue"    eol=lf
+"*.js"     eol=lf
+"*.ts"     eol=lf
+"*.jsx"    eol=lf
+"*.tsx"    eol=lf
+"*.cjs"    eol=lf
+"*.cts"    eol=lf
+"*.mjs"    eol=lf
+"*.mts"    eol=lf
+"*.json"   eol=lf
+"*.html"   eol=lf
+"*.css"    eol=lf
+"*.less"   eol=lf
+"*.scss"   eol=lf
+"*.sass"   eol=lf
+"*.styl"   eol=lf
+"*.md"     eol=lf

+ 41 - 0
.github/workflows/build_docker.yml

@@ -0,0 +1,41 @@
+name: build_docker
+
+on:
+  push:
+    branches: [main]
+  release:
+    types: [created] # 表示在创建新的 Release 时触发
+
+jobs:
+  build_docker:
+    name: Build docker
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v3
+
+      - run: |
+          echo "本次构建的版本为:${GITHUB_REF_NAME} (但是这个变量目前上下文中无法获取到)"
+          echo 本次构建的版本为:${{ github.ref_name }}
+          env
+
+      - name: Set up QEMU
+        uses: docker/setup-qemu-action@v2
+      - name: Set up Docker Buildx
+        uses: docker/setup-buildx-action@v2
+      - name: Login to DockerHub
+        uses: docker/login-action@v2
+        with:
+          username: ${{ secrets.DOCKERHUB_USERNAME }}
+          password: ${{ secrets.DOCKERHUB_TOKEN }}
+      - name: Build and push
+        id: docker_build
+        uses: docker/build-push-action@v4
+        with:
+          context: .
+          push: true
+          labels: ${{ steps.meta.outputs.labels }}
+          platforms: linux/amd64,linux/arm64
+          tags: |
+            ${{ secrets.DOCKERHUB_USERNAME }}/chatgpt-web:${{ github.ref_name }}
+            ${{ secrets.DOCKERHUB_USERNAME }}/chatgpt-web:latest

+ 47 - 0
.github/workflows/ci.yml

@@ -0,0 +1,47 @@
+name: CI
+
+on:
+  push:
+    branches:
+      - main
+
+  pull_request:
+    branches:
+      - main
+
+jobs:
+  lint:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - name: Set node
+        uses: actions/setup-node@v3
+        with:
+          node-version: 18.x
+
+      - name: Setup
+        run: npm i -g @antfu/ni
+
+      - name: Install
+        run: nci
+
+      - name: Lint
+        run: nr lint:fix
+
+  typecheck:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - name: Set node
+        uses: actions/setup-node@v3
+        with:
+          node-version: 18.x
+
+      - name: Setup
+        run: npm i -g @antfu/ni
+
+      - name: Install
+        run: nci
+
+      - name: Typecheck
+        run: nr type-check

+ 22 - 0
.github/workflows/issues_close.yml

@@ -0,0 +1,22 @@
+name: Close inactive issues
+on:
+  schedule:
+    - cron: '30 1 * * *'
+
+jobs:
+  close-issues:
+    runs-on: ubuntu-latest
+    permissions:
+      issues: write
+      pull-requests: write
+    steps:
+      - uses: actions/stale@v5
+        with:
+          days-before-issue-stale: 10
+          days-before-issue-close: 2
+          stale-issue-label: stale
+          stale-issue-message: This issue is stale because it has been open for 10 days with no activity.
+          close-issue-message: This issue was closed because it has been inactive for 2 days since being marked as stale.
+          days-before-pr-stale: -1
+          days-before-pr-close: -1
+          repo-token: ${{ secrets.GITHUB_TOKEN }}

+ 32 - 0
.gitignore

@@ -0,0 +1,32 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+.DS_Store
+dist
+dist-ssr
+coverage
+*.local
+
+/cypress/videos/
+/cypress/screenshots/
+
+# Editor directories and files
+.vscode/*
+!.vscode/settings.json
+!.vscode/extensions.json
+.idea
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+# Environment variables files
+/service/.env

+ 4 - 0
.husky/commit-msg

@@ -0,0 +1,4 @@
+#!/usr/bin/env sh
+. "$(dirname -- "$0")/_/husky.sh"
+
+npx --no -- commitlint --edit 

+ 4 - 0
.husky/pre-commit

@@ -0,0 +1,4 @@
+#!/usr/bin/env sh
+. "$(dirname -- "$0")/_/husky.sh"
+
+npx lint-staged

+ 1 - 0
.npmrc

@@ -0,0 +1 @@
+strict-peer-dependencies=false

+ 8 - 0
.prettierrc.js

@@ -0,0 +1,8 @@
+module.exports = {
+    printWidth: 120,
+    singleQuote: true,
+    tabWidth: 4,
+    arrowParens: 'avoid',
+    trailingComma: 'none',
+    semi: false
+}

+ 5 - 0
.vscode/settings.json

@@ -0,0 +1,5 @@
+{
+    "i18n-ally.localesPaths": [
+        "src/locales"
+    ]
+}

+ 56 - 0
Dockerfile

@@ -0,0 +1,56 @@
+# build front-end
+FROM node:lts-alpine AS frontend
+
+RUN npm install pnpm -g
+
+WORKDIR /app
+
+COPY ./package.json /app
+
+COPY ./pnpm-lock.yaml /app
+
+RUN pnpm install
+
+COPY . /app
+
+RUN pnpm run build
+
+# build backend
+FROM node:lts-alpine as backend
+
+RUN npm install pnpm -g
+
+WORKDIR /app
+
+COPY /service/package.json /app
+
+COPY /service/pnpm-lock.yaml /app
+
+RUN pnpm install
+
+COPY /service /app
+
+RUN pnpm build
+
+# service
+FROM node:lts-alpine
+
+RUN npm install pnpm -g
+
+WORKDIR /app
+
+COPY /service/package.json /app
+
+COPY /service/pnpm-lock.yaml /app
+
+RUN pnpm install --production && rm -rf /root/.npm /root/.pnpm-store /usr/local/share/.cache /tmp/*
+
+COPY /service /app
+
+COPY --from=frontend /app/dist /app/public
+
+COPY --from=backend /app/build /app/build
+
+EXPOSE 3002
+
+CMD ["pnpm", "run", "prod"]

+ 337 - 0
README.md

@@ -0,0 +1,337 @@
+# ChatGPT Web
+
+> 声明:此项目只发布于 Github,基于 MIT 协议,免费且作为开源学习使用。并且不会有任何形式的卖号、付费服务、讨论群、讨论组等行为。谨防受骗。
+
+![cover](./docs/c1.png)
+![cover2](./docs/c2.png)
+
+- [ChatGPT Web](#chatgpt-web)
+	- [介绍](#介绍)
+	- [待实现路线](#待实现路线)
+	- [前置要求](#前置要求)
+		- [Node](#node)
+		- [PNPM](#pnpm)
+		- [填写密钥](#填写密钥)
+	- [安装依赖](#安装依赖)
+		- [后端](#后端)
+		- [前端](#前端)
+	- [测试环境运行](#测试环境运行)
+		- [后端服务](#后端服务)
+		- [前端网页](#前端网页)
+	- [环境变量](#环境变量)
+	- [打包](#打包)
+		- [使用 Docker](#使用-docker)
+			- [Docker 参数示例](#docker-参数示例)
+			- [Docker build \& Run](#docker-build--run)
+			- [Docker compose](#docker-compose)
+		- [使用 Railway 部署](#使用-railway-部署)
+			- [Railway 环境变量](#railway-环境变量)
+		- [手动打包](#手动打包)
+			- [后端服务](#后端服务-1)
+			- [前端网页](#前端网页-1)
+	- [常见问题](#常见问题)
+	- [参与贡献](#参与贡献)
+	- [赞助](#赞助)
+	- [License](#license)
+## 介绍
+
+支持双模型,提供了两种非官方 `ChatGPT API` 方法
+
+| 方式                                          | 免费? | 可靠性     | 质量 |
+| --------------------------------------------- | ------ | ---------- | ---- |
+| `ChatGPTAPI(gpt-3.5-turbo-0301)`                           | 否     | 可靠       | 相对较笨 |
+| `ChatGPTUnofficialProxyAPI(网页 accessToken)` | 是     | 相对不可靠 | 聪明 |
+
+对比:
+1. `ChatGPTAPI` 使用 `gpt-3.5-turbo` 通过 `OpenAI` 官方 `API` 调用 `ChatGPT`
+2. `ChatGPTUnofficialProxyAPI` 使用非官方代理服务器访问 `ChatGPT` 的后端`API`,绕过`Cloudflare`(依赖于第三方服务器,并且有速率限制)
+
+警告:
+1. 你应该首先使用 `API` 方式
+2. 使用 `API` 时,如果网络不通,那是国内被墙了,你需要自建代理,绝对不要使用别人的公开代理,那是危险的。
+3. 使用 `accessToken` 方式时反向代理将向第三方暴露您的访问令牌,这样做应该不会产生任何不良影响,但在使用这种方法之前请考虑风险。
+4. 使用 `accessToken` 时,不管你是国内还是国外的机器,都会使用代理。默认代理为 [acheong08](https://github.com/acheong08) 大佬的 `https://bypass.churchless.tech/api/conversation`,这不是后门也不是监听,除非你有能力自己翻过 `CF` 验证,用前请知悉。[社区代理](https://github.com/transitive-bullshit/chatgpt-api#reverse-proxy)(注意:只有这两个是推荐,其他第三方来源,请自行甄别)
+5. 把项目发布到公共网络时,你应该设置 `AUTH_SECRET_KEY` 变量添加你的密码访问权限,你也应该修改 `index.html` 中的 `title`,防止被关键词搜索到。
+
+切换方式:
+1. 进入 `service/.env.example` 文件,复制内容到 `service/.env` 文件
+2. 使用 `OpenAI API Key` 请填写 `OPENAI_API_KEY` 字段 [(获取 apiKey)](https://platform.openai.com/overview)
+3. 使用 `Web API` 请填写 `OPENAI_ACCESS_TOKEN` 字段 [(获取 accessToken)](https://chat.openai.com/api/auth/session)
+4. 同时存在时以 `OpenAI API Key` 优先
+
+环境变量:
+
+全部参数变量请查看或[这里](#环境变量)
+
+```
+/service/.env.example
+```
+
+## 待实现路线
+[✓] 双模型
+
+[✓] 多会话储存和上下文逻辑
+
+[✓] 对代码等消息类型的格式化美化处理
+
+[✓] 访问权限控制
+
+[✓] 数据导入、导出
+
+[✓] 保存消息到本地图片
+
+[✓] 界面多语言
+
+[✓] 界面主题
+
+[✗] More...
+
+## 前置要求
+
+### Node
+
+`node` 需要 `^16 || ^18 || ^19` 版本(`node >= 14` 需要安装 [fetch polyfill](https://github.com/developit/unfetch#usage-as-a-polyfill)),使用 [nvm](https://github.com/nvm-sh/nvm) 可管理本地多个 `node` 版本
+
+```shell
+node -v
+```
+
+### PNPM
+如果你没有安装过 `pnpm`
+```shell
+npm install pnpm -g
+```
+
+### 填写密钥
+获取 `Openai Api Key` 或 `accessToken` 并填写本地环境变量 [跳转](#介绍)
+
+```
+# service/.env 文件
+
+# OpenAI API Key - https://platform.openai.com/overview
+OPENAI_API_KEY=
+
+# change this to an `accessToken` extracted from the ChatGPT site's `https://chat.openai.com/api/auth/session` response
+OPENAI_ACCESS_TOKEN=
+```
+
+## 安装依赖
+
+> 为了简便 `后端开发人员` 的了解负担,所以并没有采用前端 `workspace` 模式,而是分文件夹存放。如果只需要前端页面做二次开发,删除 `service` 文件夹即可。
+
+### 后端
+
+进入文件夹 `/service` 运行以下命令
+
+```shell
+pnpm install
+```
+
+### 前端
+根目录下运行以下命令
+```shell
+pnpm bootstrap
+```
+
+## 测试环境运行
+### 后端服务
+
+进入文件夹 `/service` 运行以下命令
+
+```shell
+pnpm start
+```
+
+### 前端网页
+根目录下运行以下命令
+```shell
+pnpm dev
+```
+
+## 环境变量
+
+`API` 可用:
+
+- `OPENAI_API_KEY` 和 `OPENAI_ACCESS_TOKEN` 二选一
+- `OPENAI_API_MODEL`  设置模型,可选,默认:`gpt-3.5-turbo`
+- `OPENAI_API_BASE_URL` 设置接口地址,可选,默认:`https://api.openai.com`
+- `OPENAI_API_DISABLE_DEBUG` 设置接口关闭 debug 日志,可选,默认:empty 不关闭
+
+`ACCESS_TOKEN` 可用:
+
+- `OPENAI_ACCESS_TOKEN`  和 `OPENAI_API_KEY` 二选一,同时存在时,`OPENAI_API_KEY` 优先
+- `API_REVERSE_PROXY` 设置反向代理,可选,默认:`https://bypass.churchless.tech/api/conversation`,[社区](https://github.com/transitive-bullshit/chatgpt-api#reverse-proxy)(注意:只有这两个是推荐,其他第三方来源,请自行甄别)
+
+通用:
+
+- `AUTH_SECRET_KEY` 访问权限密钥,可选
+- `MAX_REQUEST_PER_HOUR` 每小时最大请求次数,可选,默认无限
+- `TIMEOUT_MS` 超时,单位毫秒,可选
+- `SOCKS_PROXY_HOST` 和 `SOCKS_PROXY_PORT` 一起时生效,可选
+- `SOCKS_PROXY_PORT` 和 `SOCKS_PROXY_HOST` 一起时生效,可选
+- `HTTPS_PROXY` 支持 `http`,`https`, `socks5`,可选
+- `ALL_PROXY` 支持 `http`,`https`, `socks5`,可选
+
+## 打包
+
+### 使用 Docker
+
+#### Docker 参数示例
+
+![docker](./docs/docker.png)
+
+#### Docker build & Run
+
+```bash
+docker build -t chatgpt-web .
+
+# 前台运行
+docker run --name chatgpt-web --rm -it -p 127.0.0.1:3002:3002 --env OPENAI_API_KEY=your_api_key chatgpt-web
+
+# 后台运行
+docker run --name chatgpt-web -d -p 127.0.0.1:3002:3002 --env OPENAI_API_KEY=your_api_key chatgpt-web
+
+# 运行地址
+http://localhost:3002/
+```
+
+#### Docker compose
+
+[Hub 地址](https://hub.docker.com/repository/docker/chenzhaoyu94/chatgpt-web/general)
+
+```yml
+version: '3'
+
+services:
+  app:
+    image: chenzhaoyu94/chatgpt-web # 总是使用 latest ,更新时重新 pull 该 tag 镜像即可
+    ports:
+      - 127.0.0.1:3002:3002
+    environment:
+      # 二选一
+      OPENAI_API_KEY: sk-xxx
+      # 二选一
+      OPENAI_ACCESS_TOKEN: xxx
+      # API接口地址,可选,设置 OPENAI_API_KEY 时可用
+      OPENAI_API_BASE_URL: xxx
+      # API模型,可选,设置 OPENAI_API_KEY 时可用,https://platform.openai.com/docs/models
+      # gpt-4, gpt-4-0314, gpt-4-32k, gpt-4-32k-0314, gpt-3.5-turbo, gpt-3.5-turbo-0301, text-davinci-003, text-davinci-002, code-davinci-002
+      OPENAI_API_MODEL: xxx
+      # 反向代理,可选
+      API_REVERSE_PROXY: xxx
+      # 访问权限密钥,可选
+      AUTH_SECRET_KEY: xxx
+      # 每小时最大请求次数,可选,默认无限
+      MAX_REQUEST_PER_HOUR: 0
+      # 超时,单位毫秒,可选
+      TIMEOUT_MS: 60000
+      # Socks代理,可选,和 SOCKS_PROXY_PORT 一起时生效
+      SOCKS_PROXY_HOST: xxx
+      # Socks代理端口,可选,和 SOCKS_PROXY_HOST 一起时生效
+      SOCKS_PROXY_PORT: xxx
+      # HTTPS 代理,可选,支持 http,https,socks5
+      HTTPS_PROXY: http://xxx:7890
+```
+- `OPENAI_API_BASE_URL`  可选,设置 `OPENAI_API_KEY` 时可用
+- `OPENAI_API_MODEL`  可选,设置 `OPENAI_API_KEY` 时可用
+###  使用 Railway 部署
+
+[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template/yytmgc)
+
+#### Railway 环境变量
+
+| 环境变量名称          | 必填                   | 备注                                                                                               |
+| --------------------- | ---------------------- | -------------------------------------------------------------------------------------------------- |
+| `PORT`                | 必填                   | 默认 `3002`
+| `AUTH_SECRET_KEY`          | 可选                   | 访问权限密钥                                        |
+| `MAX_REQUEST_PER_HOUR`          | 可选                   | 每小时最大请求次数,可选,默认无限                                        |
+| `TIMEOUT_MS`          | 可选                   | 超时时间,单位毫秒                                                                             |
+| `OPENAI_API_KEY`      | `OpenAI API` 二选一    | 使用 `OpenAI API` 所需的 `apiKey` [(获取 apiKey)](https://platform.openai.com/overview)            |
+| `OPENAI_ACCESS_TOKEN` | `Web API` 二选一       | 使用 `Web API` 所需的 `accessToken` [(获取 accessToken)](https://chat.openai.com/api/auth/session) |
+| `OPENAI_API_BASE_URL`   | 可选,`OpenAI API` 时可用 |  `API`接口地址  |
+| `OPENAI_API_MODEL`   | 可选,`OpenAI API` 时可用 |  `API`模型  |
+| `API_REVERSE_PROXY`   | 可选,`Web API` 时可用 | `Web API` 反向代理地址 [详情](https://github.com/transitive-bullshit/chatgpt-api#reverse-proxy)    |
+| `SOCKS_PROXY_HOST`   | 可选,和 `SOCKS_PROXY_PORT` 一起时生效 | Socks代理    |
+| `SOCKS_PROXY_PORT`   | 可选,和 `SOCKS_PROXY_HOST` 一起时生效 | Socks代理端口    |
+| `SOCKS_PROXY_USERNAME`   | 可选,和 `SOCKS_PROXY_HOST` 一起时生效 | Socks代理用户名    |
+| `SOCKS_PROXY_PASSWORD`   | 可选,和 `SOCKS_PROXY_HOST` 一起时生效 | Socks代理密码    |
+| `HTTPS_PROXY`   | 可选 | HTTPS 代理,支持 http,https, socks5    |
+| `ALL_PROXY`   | 可选 | 所有代理 代理,支持 http,https, socks5    |
+
+> 注意: `Railway` 修改环境变量会重新 `Deploy`
+
+### 手动打包
+#### 后端服务
+> 如果你不需要本项目的 `node` 接口,可以省略如下操作
+
+复制 `service` 文件夹到你有 `node` 服务环境的服务器上。
+
+```shell
+# 安装
+pnpm install
+
+# 打包
+pnpm build
+
+# 运行
+pnpm prod
+```
+
+PS: 不进行打包,直接在服务器上运行 `pnpm start` 也可
+
+#### 前端网页
+
+1、修改根目录下 `.env` 文件中的 `VITE_GLOB_API_URL` 为你的实际后端接口地址
+
+2、根目录下运行以下命令,然后将 `dist` 文件夹内的文件复制到你网站服务的根目录下
+
+[参考信息](https://cn.vitejs.dev/guide/static-deploy.html#building-the-app)
+
+```shell
+pnpm build
+```
+
+## 常见问题
+Q: 为什么 `Git` 提交总是报错?
+
+A: 因为有提交信息验证,请遵循 [Commit 指南](./CONTRIBUTING.md)
+
+Q: 如果只使用前端页面,在哪里改请求接口?
+
+A: 根目录下 `.env` 文件中的 `VITE_GLOB_API_URL` 字段。
+
+Q: 文件保存时全部爆红?
+
+A: `vscode` 请安装项目推荐插件,或手动安装 `Eslint` 插件。
+
+Q: 前端没有打字机效果?
+
+A: 一种可能原因是经过 Nginx 反向代理,开启了 buffer,则 Nginx 会尝试从后端缓冲一定大小的数据再发送给浏览器。请尝试在反代参数后添加 `proxy_buffering off;`,然后重载 Nginx。其他 web server 配置同理。
+
+## 参与贡献
+
+贡献之前请先阅读 [贡献指南](./CONTRIBUTING.md)
+
+感谢所有做过贡献的人!
+
+<a href="https://github.com/Chanzhaoyu/chatgpt-web/graphs/contributors">
+  <img src="https://contrib.rocks/image?repo=Chanzhaoyu/chatgpt-web" />
+</a>
+
+## 赞助
+
+如果你觉得这个项目对你有帮助,并且情况允许的话,可以给我一点点支持,总之非常感谢支持~
+
+<div style="display: flex; gap: 20px;">
+	<div style="text-align: center">
+		<img style="max-width: 100%" src="./docs/wechat.png" alt="微信" />
+		<p>WeChat Pay</p>
+	</div>
+	<div style="text-align: center">
+		<img style="max-width: 100%" src="./docs/alipay.png" alt="支付宝" />
+		<p>Alipay</p>
+	</div>
+</div>
+
+## License
+MIT © [ChenZhaoYu](./license)

+ 1 - 0
config/index.ts

@@ -0,0 +1 @@
+export * from './proxy'

+ 16 - 0
config/proxy.ts

@@ -0,0 +1,16 @@
+import type { ProxyOptions } from 'vite'
+
+export function createViteProxy(isOpenProxy: boolean, viteEnv: ImportMetaEnv) {
+  if (!isOpenProxy)
+    return
+
+  const proxy: Record<string, string | ProxyOptions> = {
+    '/api': {
+      target: viteEnv.VITE_APP_API_BASE_URL,
+      changeOrigin: true,
+      rewrite: path => path.replace('/api/', '/'),
+    },
+  }
+
+  return proxy
+}

+ 83 - 0
index.html

@@ -0,0 +1,83 @@
+<!DOCTYPE html>
+<html lang="zh-cmn-Hans">
+<head>
+	<meta charset="UTF-8">
+	<link rel="icon" type="image/svg+xml" href="/favicon.svg">
+	<meta content="yes" name="apple-mobile-web-app-capable"/>
+	<link rel="apple-touch-icon" href="/favicon.ico">
+	<meta name="viewport"
+		content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, viewport-fit=cover" />
+	<title>ChatGPT Web</title>
+</head>
+
+<body class="dark:bg-black">
+	<div id="app">
+		<style>
+			.loading-wrap {
+				display: flex;
+				justify-content: center;
+				align-items: center;
+				height: 100vh;
+			}
+
+			.balls {
+				width: 4em;
+				display: flex;
+				flex-flow: row nowrap;
+				align-items: center;
+				justify-content: space-between;
+			}
+
+			.balls div {
+				width: 0.8em;
+				height: 0.8em;
+				border-radius: 50%;
+				background-color: #4b9e5f;
+			}
+
+			.balls div:nth-of-type(1) {
+				transform: translateX(-100%);
+				animation: left-swing 0.5s ease-in alternate infinite;
+			}
+
+			.balls div:nth-of-type(3) {
+				transform: translateX(-95%);
+				animation: right-swing 0.5s ease-out alternate infinite;
+			}
+
+			@keyframes left-swing {
+
+				50%,
+				100% {
+					transform: translateX(95%);
+				}
+			}
+
+			@keyframes right-swing {
+				50% {
+					transform: translateX(-95%);
+				}
+
+				100% {
+					transform: translateX(100%);
+				}
+			}
+
+			@media (prefers-color-scheme: dark) {
+				body {
+					background: #121212;
+				}
+			}
+		</style>
+		<div class="loading-wrap">
+			<div class="balls">
+				<div></div>
+				<div></div>
+				<div></div>
+			</div>
+		</div>
+	</div>
+	<script type="module" src="/src/main.ts"></script>
+</body>
+
+</html>

+ 76 - 0
package.json

@@ -0,0 +1,76 @@
+{
+  "name": "chatgpt-web",
+  "version": "2.10.9",
+  "private": false,
+  "description": "ChatGPT Web",
+  "author": "ChenZhaoYu <chenzhaoyu1994@gmail.com>",
+  "keywords": [
+    "chatgpt-web",
+    "chatgpt",
+    "chatbot",
+    "vue"
+  ],
+  "scripts": {
+    "dev": "vite",
+    "build": "run-p type-check build-only",
+    "preview": "vite preview",
+    "build-only": "vite build",
+    "type-check": "vue-tsc --noEmit",
+    "lint": "eslint .",
+    "lint:fix": "eslint . --fix",
+    "bootstrap": "pnpm install && pnpm run common:prepare",
+    "common:cleanup": "rimraf node_modules && rimraf pnpm-lock.yaml",
+    "common:prepare": "husky install"
+  },
+  "dependencies": {
+    "@traptitech/markdown-it-katex": "^3.6.0",
+    "@vueuse/core": "^9.13.0",
+    "highlight.js": "^11.7.0",
+    "html2canvas": "^1.4.1",
+    "katex": "^0.16.4",
+    "markdown-it": "^13.0.1",
+    "naive-ui": "^2.34.3",
+    "pinia": "^2.0.33",
+    "vue": "^3.2.47",
+    "vue-i18n": "^9.2.2",
+    "vue-router": "^4.1.6"
+  },
+  "devDependencies": {
+    "@antfu/eslint-config": "^0.35.3",
+    "@commitlint/cli": "^17.4.4",
+    "@commitlint/config-conventional": "^17.4.4",
+    "@iconify/vue": "^4.1.0",
+    "@rushstack/eslint-patch": "^1.2.0",
+    "@types/crypto-js": "^4.1.1",
+    "@types/katex": "^0.16.0",
+    "@types/markdown-it": "^12.2.3",
+    "@types/markdown-it-link-attributes": "^3.0.1",
+    "@types/node": "^18.14.6",
+    "@vitejs/plugin-vue": "^4.0.0",
+    "@volar-plugins/prettier": "^1.2.0",
+    "@vue/eslint-config-prettier": "^7.1.0",
+    "@vue/eslint-config-typescript": "^11.0.2",
+    "autoprefixer": "^10.4.13",
+    "axios": "^1.3.4",
+    "crypto-js": "^4.1.1",
+    "eslint": "^8.35.0",
+    "husky": "^8.0.3",
+    "less": "^4.1.3",
+    "lint-staged": "^13.1.2",
+    "markdown-it-link-attributes": "^4.0.1",
+    "npm-run-all": "^4.1.5",
+    "postcss": "^8.4.21",
+    "prettier": "^2.8.7",
+    "rimraf": "^4.2.0",
+    "tailwindcss": "^3.2.7",
+    "typescript": "~4.9.5",
+    "vite": "^4.2.0",
+    "vite-plugin-pwa": "^0.14.4",
+    "vue-tsc": "^1.2.0"
+  },
+  "lint-staged": {
+    "*.{ts,tsx,vue}": [
+      "pnpm lint:fix"
+    ]
+  }
+}

+ 6 - 0
postcss.config.js

@@ -0,0 +1,6 @@
+module.exports = {
+  plugins: {
+    tailwindcss: {},
+    autoprefixer: {},
+  },
+}

BIN
public/favicon.ico


+ 1 - 0
public/favicon.svg

@@ -0,0 +1 @@
+<svg id="openai-symbol" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M29.71,13.09A8.09,8.09,0,0,0,20.34,2.68a8.08,8.08,0,0,0-13.7,2.9A8.08,8.08,0,0,0,2.3,18.9,8,8,0,0,0,3,25.45a8.08,8.08,0,0,0,8.69,3.87,8,8,0,0,0,6,2.68,8.09,8.09,0,0,0,7.7-5.61,8,8,0,0,0,5.33-3.86A8.09,8.09,0,0,0,29.71,13.09Zm-12,16.82a6,6,0,0,1-3.84-1.39l.19-.11,6.37-3.68a1,1,0,0,0,.53-.91v-9l2.69,1.56a.08.08,0,0,1,.05.07v7.44A6,6,0,0,1,17.68,29.91ZM4.8,24.41a6,6,0,0,1-.71-4l.19.11,6.37,3.68a1,1,0,0,0,1,0l7.79-4.49V22.8a.09.09,0,0,1,0,.08L13,26.6A6,6,0,0,1,4.8,24.41ZM3.12,10.53A6,6,0,0,1,6.28,7.9v7.57a1,1,0,0,0,.51.9l7.75,4.47L11.85,22.4a.14.14,0,0,1-.09,0L5.32,18.68a6,6,0,0,1-2.2-8.18Zm22.13,5.14-7.78-4.52L20.16,9.6a.08.08,0,0,1,.09,0l6.44,3.72a6,6,0,0,1-.9,10.81V16.56A1.06,1.06,0,0,0,25.25,15.67Zm2.68-4-.19-.12-6.36-3.7a1,1,0,0,0-1.05,0l-7.78,4.49V9.2a.09.09,0,0,1,0-.09L19,5.4a6,6,0,0,1,8.91,6.21ZM11.08,17.15,8.38,15.6a.14.14,0,0,1-.05-.08V8.1a6,6,0,0,1,9.84-4.61L18,3.6,11.61,7.28a1,1,0,0,0-.53.91ZM12.54,14,16,12l3.47,2v4L16,20l-3.47-2Z"/></svg>

BIN
public/pwa-192x192.png


BIN
public/pwa-512x512.png


+ 17 - 0
src/App.vue

@@ -0,0 +1,17 @@
+<script setup lang="ts">
+import { NConfigProvider } from 'naive-ui'
+import { NaiveProvider } from '@/components/common'
+import { useTheme } from '@/hooks/useTheme'
+import { useLanguage } from '@/hooks/useLanguage'
+
+const { theme, themeOverrides } = useTheme()
+const { language } = useLanguage()
+</script>
+
+<template>
+    <NConfigProvider class="h-full" :theme="theme" :theme-overrides="themeOverrides" :locale="language">
+        <NaiveProvider>
+            <RouterView />
+        </NaiveProvider>
+    </NConfigProvider>
+</template>

+ 65 - 0
src/api/index.ts

@@ -0,0 +1,65 @@
+import type { AxiosProgressEvent, GenericAbortSignal } from 'axios'
+import { post } from '@/utils/request'
+import { useAuthStore, useSettingStore } from '@/store'
+
+export function fetchChatAPI<T = any>(
+    prompt: string,
+    options?: { conversationId?: string; parentMessageId?: string },
+    signal?: GenericAbortSignal
+) {
+    return post<T>({
+        url: '/chat',
+        data: { prompt, options },
+        signal
+    })
+}
+
+export function fetchChatConfig<T = any>() {
+    return post<T>({
+        url: '/config'
+    })
+}
+
+export function fetchChatAPIProcess<T = any>(params: {
+    prompt: string
+    options?: { conversationId?: string; parentMessageId?: string }
+    signal?: GenericAbortSignal
+    onDownloadProgress?: (progressEvent: AxiosProgressEvent) => void
+}) {
+    const settingStore = useSettingStore()
+    const authStore = useAuthStore()
+
+    let data: Record<string, any> = {
+        prompt: params.prompt,
+        options: params.options
+    }
+
+    if (authStore.isChatGPTAPI) {
+        data = {
+            ...data,
+            systemMessage: settingStore.systemMessage,
+            temperature: settingStore.temperature,
+            top_p: settingStore.top_p
+        }
+    }
+
+    return post<T>({
+        url: '/chat-process',
+        data,
+        signal: params.signal,
+        onDownloadProgress: params.onDownloadProgress
+    })
+}
+
+export function fetchSession<T>() {
+    return post<T>({
+        url: '/session'
+    })
+}
+
+export function fetchVerify<T>(token: string) {
+    return post<T>({
+        url: '/verify',
+        data: { token }
+    })
+}

BIN
src/assets/avatar.jpg


+ 14 - 0
src/assets/recommend.json

@@ -0,0 +1,14 @@
+[
+    {
+        "key": "awesome-chatgpt-prompts-zh",
+        "desc": "ChatGPT 中文调教指南",
+        "downloadUrl": "https://raw.githubusercontent.com/PlexPt/awesome-chatgpt-prompts-zh/main/prompts-zh.json",
+        "url": "https://github.com/PlexPt/awesome-chatgpt-prompts-zh"
+    },
+    {
+        "key": "awesome-chatgpt-prompts-zh-TW",
+        "desc": "ChatGPT 中文調教指南 (透過 OpenAI / OpenCC 協助,從簡體中文轉換為繁體中文的版本)",
+        "downloadUrl": "https://raw.githubusercontent.com/PlexPt/awesome-chatgpt-prompts-zh/main/prompts-zh-TW.json",
+        "url": "https://github.com/PlexPt/awesome-chatgpt-prompts-zh"
+    }
+]

+ 20 - 0
src/components/common/HoverButton/Button.vue

@@ -0,0 +1,20 @@
+<script setup lang="ts">
+interface Emit {
+    (e: 'click'): void
+}
+
+const emit = defineEmits<Emit>()
+
+function handleClick() {
+    emit('click')
+}
+</script>
+
+<template>
+    <button
+        class="flex items-center justify-center w-10 h-10 transition rounded-full hover:bg-neutral-100 dark:hover:bg-[#414755]"
+        @click="handleClick"
+    >
+        <slot />
+    </button>
+</template>

+ 46 - 0
src/components/common/HoverButton/index.vue

@@ -0,0 +1,46 @@
+<script setup lang="ts">
+import { computed } from 'vue'
+import type { PopoverPlacement } from 'naive-ui'
+import { NTooltip } from 'naive-ui'
+import Button from './Button.vue'
+
+interface Props {
+    tooltip?: string
+    placement?: PopoverPlacement
+}
+
+interface Emit {
+    (e: 'click'): void
+}
+
+const props = withDefaults(defineProps<Props>(), {
+    tooltip: '',
+    placement: 'bottom'
+})
+
+const emit = defineEmits<Emit>()
+
+const showTooltip = computed(() => Boolean(props.tooltip))
+
+function handleClick() {
+    emit('click')
+}
+</script>
+
+<template>
+    <div v-if="showTooltip">
+        <NTooltip :placement="placement" trigger="hover">
+            <template #trigger>
+                <Button @click="handleClick">
+                    <slot />
+                </Button>
+            </template>
+            {{ tooltip }}
+        </NTooltip>
+    </div>
+    <div v-else>
+        <Button @click="handleClick">
+            <slot />
+        </Button>
+    </div>
+</template>

+ 43 - 0
src/components/common/NaiveProvider/index.vue

@@ -0,0 +1,43 @@
+<script setup lang="ts">
+import { defineComponent, h } from 'vue'
+import {
+    NDialogProvider,
+    NLoadingBarProvider,
+    NMessageProvider,
+    NNotificationProvider,
+    useDialog,
+    useLoadingBar,
+    useMessage,
+    useNotification
+} from 'naive-ui'
+
+function registerNaiveTools() {
+    window.$loadingBar = useLoadingBar()
+    window.$dialog = useDialog()
+    window.$message = useMessage()
+    window.$notification = useNotification()
+}
+
+const NaiveProviderContent = defineComponent({
+    name: 'NaiveProviderContent',
+    setup() {
+        registerNaiveTools()
+    },
+    render() {
+        return h('div')
+    }
+})
+</script>
+
+<template>
+    <NLoadingBarProvider>
+        <NDialogProvider>
+            <NNotificationProvider>
+                <NMessageProvider>
+                    <slot />
+                    <NaiveProviderContent />
+                </NMessageProvider>
+            </NNotificationProvider>
+        </NDialogProvider>
+    </NLoadingBarProvider>
+</template>

+ 478 - 0
src/components/common/PromptStore/index.vue

@@ -0,0 +1,478 @@
+<script setup lang="ts">
+import type { DataTableColumns } from 'naive-ui'
+import { computed, h, ref, watch } from 'vue'
+import {
+    NButton,
+    NCard,
+    NDataTable,
+    NDivider,
+    NInput,
+    NList,
+    NListItem,
+    NModal,
+    NPopconfirm,
+    NSpace,
+    NTabPane,
+    NTabs,
+    NThing,
+    useMessage
+} from 'naive-ui'
+import PromptRecommend from '../../../assets/recommend.json'
+import { SvgIcon } from '..'
+import { usePromptStore } from '@/store'
+import { useBasicLayout } from '@/hooks/useBasicLayout'
+import { t } from '@/locales'
+
+interface DataProps {
+    renderKey: string
+    renderValue: string
+    key: string
+    value: string
+}
+
+interface Props {
+    visible: boolean
+}
+
+interface Emit {
+    (e: 'update:visible', visible: boolean): void
+}
+
+const props = defineProps<Props>()
+
+const emit = defineEmits<Emit>()
+
+const message = useMessage()
+
+const show = computed({
+    get: () => props.visible,
+    set: (visible: boolean) => emit('update:visible', visible)
+})
+
+const showModal = ref(false)
+
+const importLoading = ref(false)
+const exportLoading = ref(false)
+
+const searchValue = ref<string>('')
+
+// 移动端自适应相关
+const { isMobile } = useBasicLayout()
+
+const promptStore = usePromptStore()
+
+// Prompt在线导入推荐List,根据部署者喜好进行修改(assets/recommend.json)
+const promptRecommendList = PromptRecommend
+const promptList = ref<any>(promptStore.promptList)
+
+// 用于添加修改的临时prompt参数
+const tempPromptKey = ref('')
+const tempPromptValue = ref('')
+
+// Modal模式,根据不同模式渲染不同的Modal内容
+const modalMode = ref('')
+
+// 这个是为了后期的修改Prompt内容考虑,因为要针对无uuid的list进行修改,且考虑到不能出现标题和内容的冲突,所以就需要一个临时item来记录一下
+const tempModifiedItem = ref<any>({})
+
+// 添加修改导入都使用一个Modal, 临时修改内容占用tempPromptKey,切换状态前先将内容都清楚
+const changeShowModal = (mode: 'add' | 'modify' | 'local_import', selected = { key: '', value: '' }) => {
+    if (mode === 'add') {
+        tempPromptKey.value = ''
+        tempPromptValue.value = ''
+    } else if (mode === 'modify') {
+        tempModifiedItem.value = { ...selected }
+        tempPromptKey.value = selected.key
+        tempPromptValue.value = selected.value
+    } else if (mode === 'local_import') {
+        tempPromptKey.value = 'local_import'
+        tempPromptValue.value = ''
+    }
+    showModal.value = !showModal.value
+    modalMode.value = mode
+}
+
+// 在线导入相关
+const downloadURL = ref('')
+const downloadDisabled = computed(() => downloadURL.value.trim().length < 1)
+const setDownloadURL = (url: string) => {
+    downloadURL.value = url
+}
+
+// 控制 input 按钮
+const inputStatus = computed(() => tempPromptKey.value.trim().length < 1 || tempPromptValue.value.trim().length < 1)
+
+// Prompt模板相关操作
+const addPromptTemplate = () => {
+    for (const i of promptList.value) {
+        if (i.key === tempPromptKey.value) {
+            message.error(t('store.addRepeatTitleTips'))
+            return
+        }
+        if (i.value === tempPromptValue.value) {
+            message.error(t('store.addRepeatContentTips', { msg: tempPromptKey.value }))
+            return
+        }
+    }
+    promptList.value.unshift({ key: tempPromptKey.value, value: tempPromptValue.value } as never)
+    message.success(t('common.addSuccess'))
+    changeShowModal('add')
+}
+
+const modifyPromptTemplate = () => {
+    let index = 0
+
+    // 通过临时索引把待修改项摘出来
+    for (const i of promptList.value) {
+        if (i.key === tempModifiedItem.value.key && i.value === tempModifiedItem.value.value) break
+        index = index + 1
+    }
+
+    const tempList = promptList.value.filter((_: any, i: number) => i !== index)
+
+    // 搜索有冲突的部分
+    for (const i of tempList) {
+        if (i.key === tempPromptKey.value) {
+            message.error(t('store.editRepeatTitleTips'))
+            return
+        }
+        if (i.value === tempPromptValue.value) {
+            message.error(t('store.editRepeatContentTips', { msg: i.key }))
+            return
+        }
+    }
+
+    promptList.value = [{ key: tempPromptKey.value, value: tempPromptValue.value }, ...tempList] as never
+    message.success(t('common.editSuccess'))
+    changeShowModal('modify')
+}
+
+const deletePromptTemplate = (row: { key: string; value: string }) => {
+    promptList.value = [
+        ...promptList.value.filter((item: { key: string; value: string }) => item.key !== row.key)
+    ] as never
+    message.success(t('common.deleteSuccess'))
+}
+
+const clearPromptTemplate = () => {
+    promptList.value = []
+    message.success(t('common.clearSuccess'))
+}
+
+const importPromptTemplate = (from = 'online') => {
+    try {
+        const jsonData = JSON.parse(tempPromptValue.value)
+        let key = ''
+        let value = ''
+        // 可以扩展加入更多模板字典的key
+        if ('key' in jsonData[0]) {
+            key = 'key'
+            value = 'value'
+        } else if ('act' in jsonData[0]) {
+            key = 'act'
+            value = 'prompt'
+        } else {
+            // 不支持的字典的key防止导入 以免破坏prompt商店打开
+            message.warning('prompt key not supported.')
+            throw new Error('prompt key not supported.')
+        }
+
+        for (const i of jsonData) {
+            if (!(key in i) || !(value in i)) throw new Error(t('store.importError'))
+            let safe = true
+            for (const j of promptList.value) {
+                if (j.key === i[key]) {
+                    message.warning(t('store.importRepeatTitle', { msg: i[key] }))
+                    safe = false
+                    break
+                }
+                if (j.value === i[value]) {
+                    message.warning(t('store.importRepeatContent', { msg: i[key] }))
+                    safe = false
+                    break
+                }
+            }
+            if (safe) promptList.value.unshift({ key: i[key], value: i[value] } as never)
+        }
+        message.success(t('common.importSuccess'))
+    } catch {
+        message.error('JSON 格式错误,请检查 JSON 格式')
+    }
+    if (from === 'local') showModal.value = !showModal.value
+}
+
+// 模板导出
+const exportPromptTemplate = () => {
+    exportLoading.value = true
+    const jsonDataStr = JSON.stringify(promptList.value)
+    const blob = new Blob([jsonDataStr], { type: 'application/json' })
+    const url = URL.createObjectURL(blob)
+    const link = document.createElement('a')
+    link.href = url
+    link.download = 'ChatGPTPromptTemplate.json'
+    link.click()
+    URL.revokeObjectURL(url)
+    exportLoading.value = false
+}
+
+// 模板在线导入
+const downloadPromptTemplate = async () => {
+    try {
+        importLoading.value = true
+        const response = await fetch(downloadURL.value)
+        const jsonData = await response.json()
+        if ('key' in jsonData[0] && 'value' in jsonData[0]) tempPromptValue.value = JSON.stringify(jsonData)
+        if ('act' in jsonData[0] && 'prompt' in jsonData[0]) {
+            const newJsonData = jsonData.map((item: { act: string; prompt: string }) => {
+                return {
+                    key: item.act,
+                    value: item.prompt
+                }
+            })
+            tempPromptValue.value = JSON.stringify(newJsonData)
+        }
+        importPromptTemplate()
+        downloadURL.value = ''
+    } catch {
+        message.error(t('store.downloadError'))
+        downloadURL.value = ''
+    } finally {
+        importLoading.value = false
+    }
+}
+
+// 移动端自适应相关
+const renderTemplate = () => {
+    const [keyLimit, valueLimit] = isMobile.value ? [10, 30] : [15, 50]
+
+    return promptList.value.map((item: { key: string; value: string }) => {
+        return {
+            renderKey: item.key.length <= keyLimit ? item.key : `${item.key.substring(0, keyLimit)}...`,
+            renderValue: item.value.length <= valueLimit ? item.value : `${item.value.substring(0, valueLimit)}...`,
+            key: item.key,
+            value: item.value
+        }
+    })
+}
+
+const pagination = computed(() => {
+    const [pageSize, pageSlot] = isMobile.value ? [6, 5] : [7, 15]
+    return {
+        pageSize,
+        pageSlot
+    }
+})
+
+// table相关
+const createColumns = (): DataTableColumns<DataProps> => {
+    return [
+        {
+            title: t('store.title'),
+            key: 'renderKey'
+        },
+        {
+            title: t('store.description'),
+            key: 'renderValue'
+        },
+        {
+            title: t('common.action'),
+            key: 'actions',
+            width: 100,
+            align: 'center',
+            render(row) {
+                return h(
+                    'div',
+                    { class: 'flex items-center flex-col gap-2' },
+                    {
+                        default: () => [
+                            h(
+                                NButton,
+                                {
+                                    tertiary: true,
+                                    size: 'small',
+                                    type: 'info',
+                                    onClick: () => changeShowModal('modify', row)
+                                },
+                                { default: () => t('common.edit') }
+                            ),
+                            h(
+                                NButton,
+                                {
+                                    tertiary: true,
+                                    size: 'small',
+                                    type: 'error',
+                                    onClick: () => deletePromptTemplate(row)
+                                },
+                                { default: () => t('common.delete') }
+                            )
+                        ]
+                    }
+                )
+            }
+        }
+    ]
+}
+
+const columns = createColumns()
+
+watch(
+    () => promptList,
+    () => {
+        promptStore.updatePromptList(promptList.value)
+    },
+    { deep: true }
+)
+
+const dataSource = computed(() => {
+    const data = renderTemplate()
+    const value = searchValue.value
+    if (value && value !== '') {
+        return data.filter((item: DataProps) => {
+            return item.renderKey.includes(value) || item.renderValue.includes(value)
+        })
+    }
+    return data
+})
+</script>
+
+<template>
+    <NModal v-model:show="show" style="width: 90%; max-width: 900px" preset="card">
+        <div class="space-y-4">
+            <NTabs type="segment">
+                <NTabPane name="local" :tab="$t('store.local')">
+                    <div class="flex gap-3 mb-4" :class="[isMobile ? 'flex-col' : 'flex-row justify-between']">
+                        <div class="flex items-center space-x-4">
+                            <NButton type="primary" size="small" @click="changeShowModal('add')">
+                                {{ $t('common.add') }}
+                            </NButton>
+                            <NButton size="small" @click="changeShowModal('local_import')">
+                                {{ $t('common.import') }}
+                            </NButton>
+                            <NButton size="small" :loading="exportLoading" @click="exportPromptTemplate()">
+                                {{ $t('common.export') }}
+                            </NButton>
+                            <NPopconfirm @positive-click="clearPromptTemplate">
+                                <template #trigger>
+                                    <NButton size="small">
+                                        {{ $t('common.clear') }}
+                                    </NButton>
+                                </template>
+                                {{ $t('store.clearStoreConfirm') }}
+                            </NPopconfirm>
+                        </div>
+                        <div class="flex items-center">
+                            <NInput v-model:value="searchValue" style="width: 100%" />
+                        </div>
+                    </div>
+                    <NDataTable
+                        v-if="!isMobile"
+                        :max-height="400"
+                        :columns="columns"
+                        :data="dataSource"
+                        :pagination="pagination"
+                        :bordered="false"
+                    />
+                    <NList v-if="isMobile" style="max-height: 400px; overflow-y: auto">
+                        <NListItem v-for="(item, index) of dataSource" :key="index">
+                            <NThing :title="item.renderKey" :description="item.renderValue" />
+                            <template #suffix>
+                                <div class="flex flex-col items-center gap-2">
+                                    <NButton tertiary size="small" type="info" @click="changeShowModal('modify', item)">
+                                        {{ t('common.edit') }}
+                                    </NButton>
+                                    <NButton tertiary size="small" type="error" @click="deletePromptTemplate(item)">
+                                        {{ t('common.delete') }}
+                                    </NButton>
+                                </div>
+                            </template>
+                        </NListItem>
+                    </NList>
+                </NTabPane>
+                <NTabPane name="download" :tab="$t('store.online')">
+                    <p class="mb-4">
+                        {{ $t('store.onlineImportWarning') }}
+                    </p>
+                    <div class="flex items-center gap-4">
+                        <NInput v-model:value="downloadURL" placeholder="" />
+                        <NButton
+                            strong
+                            secondary
+                            :disabled="downloadDisabled"
+                            :loading="importLoading"
+                            @click="downloadPromptTemplate()"
+                        >
+                            {{ $t('common.download') }}
+                        </NButton>
+                    </div>
+                    <NDivider />
+                    <div class="max-h-[360px] overflow-y-auto space-y-4">
+                        <NCard
+                            v-for="info in promptRecommendList"
+                            :key="info.key"
+                            :title="info.key"
+                            :bordered="true"
+                            embedded
+                        >
+                            <p class="overflow-hidden text-ellipsis whitespace-nowrap" :title="info.desc">
+                                {{ info.desc }}
+                            </p>
+                            <template #footer>
+                                <div class="flex items-center justify-end space-x-4">
+                                    <NButton text>
+                                        <a :href="info.url" target="_blank">
+                                            <SvgIcon class="text-xl" icon="ri:link" />
+                                        </a>
+                                    </NButton>
+                                    <NButton text @click="setDownloadURL(info.downloadUrl)">
+                                        <SvgIcon class="text-xl" icon="ri:add-fill" />
+                                    </NButton>
+                                </div>
+                            </template>
+                        </NCard>
+                    </div>
+                </NTabPane>
+            </NTabs>
+        </div>
+    </NModal>
+
+    <NModal v-model:show="showModal" style="width: 90%; max-width: 600px" preset="card">
+        <NSpace v-if="modalMode === 'add' || modalMode === 'modify'" vertical>
+            {{ t('store.title') }}
+            <NInput v-model:value="tempPromptKey" />
+            {{ t('store.description') }}
+            <NInput v-model:value="tempPromptValue" type="textarea" />
+            <NButton
+                block
+                type="primary"
+                :disabled="inputStatus"
+                @click="
+                    () => {
+                        modalMode === 'add' ? addPromptTemplate() : modifyPromptTemplate()
+                    }
+                "
+            >
+                {{ t('common.confirm') }}
+            </NButton>
+        </NSpace>
+        <NSpace v-if="modalMode === 'local_import'" vertical>
+            <NInput
+                v-model:value="tempPromptValue"
+                :placeholder="t('store.importPlaceholder')"
+                :autosize="{ minRows: 3, maxRows: 15 }"
+                type="textarea"
+            />
+            <NButton
+                block
+                type="primary"
+                :disabled="inputStatus"
+                @click="
+                    () => {
+                        importPromptTemplate('local')
+                    }
+                "
+            >
+                {{ t('common.import') }}
+            </NButton>
+        </NSpace>
+    </NModal>
+</template>

+ 66 - 0
src/components/common/Setting/About.vue

@@ -0,0 +1,66 @@
+<script setup lang="ts">
+import { computed, onMounted, ref } from 'vue'
+import { NSpin } from 'naive-ui'
+import { fetchChatConfig } from '@/api'
+import pkg from '@/../package.json'
+import { useAuthStore } from '@/store'
+
+interface ConfigState {
+    timeoutMs?: number
+    reverseProxy?: string
+    apiModel?: string
+    socksProxy?: string
+    httpsProxy?: string
+    usage?: string
+}
+
+const authStore = useAuthStore()
+
+const loading = ref(false)
+
+const config = ref<ConfigState>()
+
+const isChatGPTAPI = computed<boolean>(() => !!authStore.isChatGPTAPI)
+
+async function fetchConfig() {
+    try {
+        loading.value = true
+        const { data } = await fetchChatConfig<ConfigState>()
+        config.value = data
+    } finally {
+        loading.value = false
+    }
+}
+
+onMounted(() => {
+    fetchConfig()
+})
+</script>
+
+<template>
+    <NSpin :show="loading">
+        <div class="p-4 space-y-4">
+            <h2 class="text-xl font-bold">Version - {{ pkg.version }}</h2>
+            <div class="p-2 space-y-2 rounded-md bg-neutral-100 dark:bg-neutral-700">
+                <p>
+                    此项目开源于
+                    <a
+                        class="text-blue-600 dark:text-blue-500"
+                        href="https://github.com/Chanzhaoyu/chatgpt-web"
+                        target="_blank"
+                    >
+                        Github
+                    </a>
+                    ,免费且基于 MIT 协议,没有任何形式的付费行为!
+                </p>
+                <p>如果你觉得此项目对你有帮助,请在 Github 帮我点个 Star 或者给予一点赞助,谢谢!</p>
+            </div>
+            <p>{{ $t('setting.api') }}:{{ config?.apiModel ?? '-' }}</p>
+            <p v-if="isChatGPTAPI">{{ $t('setting.monthlyUsage') }}:{{ config?.usage ?? '-' }}</p>
+            <p v-if="!isChatGPTAPI">{{ $t('setting.reverseProxy') }}:{{ config?.reverseProxy ?? '-' }}</p>
+            <p>{{ $t('setting.timeout') }}:{{ config?.timeoutMs ?? '-' }}</p>
+            <p>{{ $t('setting.socks') }}:{{ config?.socksProxy ?? '-' }}</p>
+            <p>{{ $t('setting.httpsProxy') }}:{{ config?.httpsProxy ?? '-' }}</p>
+        </div>
+    </NSpin>
+</template>

+ 70 - 0
src/components/common/Setting/Advanced.vue

@@ -0,0 +1,70 @@
+<script lang="ts" setup>
+import { ref } from 'vue'
+import { NButton, NInput, NSlider, useMessage } from 'naive-ui'
+import { useSettingStore } from '@/store'
+import type { SettingsState } from '@/store/modules/settings/helper'
+import { t } from '@/locales'
+
+const settingStore = useSettingStore()
+
+const ms = useMessage()
+
+const systemMessage = ref(settingStore.systemMessage ?? '')
+
+const temperature = ref(settingStore.temperature ?? 0.5)
+
+const top_p = ref(settingStore.top_p ?? 1)
+
+function updateSettings(options: Partial<SettingsState>) {
+    settingStore.updateSetting(options)
+    ms.success(t('common.success'))
+}
+
+function handleReset() {
+    settingStore.resetSetting()
+    ms.success(t('common.success'))
+    window.location.reload()
+}
+</script>
+
+<template>
+    <div class="p-4 space-y-5 min-h-[200px]">
+        <div class="space-y-6">
+            <div class="flex items-center space-x-4">
+                <span class="flex-shrink-0 w-[120px]">{{ $t('setting.role') }}</span>
+                <div class="flex-1">
+                    <NInput v-model:value="systemMessage" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" />
+                </div>
+                <NButton size="tiny" text type="primary" @click="updateSettings({ systemMessage })">
+                    {{ $t('common.save') }}
+                </NButton>
+            </div>
+            <div class="flex items-center space-x-4">
+                <span class="flex-shrink-0 w-[120px]">{{ $t('setting.temperature') }} </span>
+                <div class="flex-1">
+                    <NSlider v-model:value="temperature" :max="1" :min="0" :step="0.1" />
+                </div>
+                <span>{{ temperature }}</span>
+                <NButton size="tiny" text type="primary" @click="updateSettings({ temperature })">
+                    {{ $t('common.save') }}
+                </NButton>
+            </div>
+            <div class="flex items-center space-x-4">
+                <span class="flex-shrink-0 w-[120px]">{{ $t('setting.top_p') }} </span>
+                <div class="flex-1">
+                    <NSlider v-model:value="top_p" :max="1" :min="0" :step="0.1" />
+                </div>
+                <span>{{ top_p }}</span>
+                <NButton size="tiny" text type="primary" @click="updateSettings({ top_p })">
+                    {{ $t('common.save') }}
+                </NButton>
+            </div>
+            <div class="flex items-center space-x-4">
+                <span class="flex-shrink-0 w-[120px]">&nbsp;</span>
+                <NButton size="small" @click="handleReset">
+                    {{ $t('common.reset') }}
+                </NButton>
+            </div>
+        </div>
+    </div>
+</template>

+ 216 - 0
src/components/common/Setting/General.vue

@@ -0,0 +1,216 @@
+<script lang="ts" setup>
+import { computed, ref } from 'vue'
+import { NButton, NInput, NPopconfirm, NSelect, useMessage } from 'naive-ui'
+import type { Language, Theme } from '@/store/modules/app/helper'
+import { SvgIcon } from '@/components/common'
+import { useAppStore, useUserStore } from '@/store'
+import type { UserInfo } from '@/store/modules/user/helper'
+import { getCurrentDate } from '@/utils/functions'
+import { useBasicLayout } from '@/hooks/useBasicLayout'
+import { t } from '@/locales'
+
+const appStore = useAppStore()
+const userStore = useUserStore()
+
+const { isMobile } = useBasicLayout()
+
+const ms = useMessage()
+
+const theme = computed(() => appStore.theme)
+
+const userInfo = computed(() => userStore.userInfo)
+
+const avatar = ref(userInfo.value.avatar ?? '')
+
+const name = ref(userInfo.value.name ?? '')
+
+const description = ref(userInfo.value.description ?? '')
+
+const language = computed({
+    get() {
+        return appStore.language
+    },
+    set(value: Language) {
+        appStore.setLanguage(value)
+    }
+})
+
+const themeOptions: { label: string; key: Theme; icon: string }[] = [
+    {
+        label: 'Auto',
+        key: 'auto',
+        icon: 'ri:contrast-line'
+    },
+    {
+        label: 'Light',
+        key: 'light',
+        icon: 'ri:sun-foggy-line'
+    },
+    {
+        label: 'Dark',
+        key: 'dark',
+        icon: 'ri:moon-foggy-line'
+    }
+]
+
+const languageOptions: { label: string; key: Language; value: Language }[] = [
+    { label: '简体中文', key: 'zh-CN', value: 'zh-CN' },
+    { label: '繁體中文', key: 'zh-TW', value: 'zh-TW' },
+    { label: 'English', key: 'en-US', value: 'en-US' }
+]
+
+function updateUserInfo(options: Partial<UserInfo>) {
+    userStore.updateUserInfo(options)
+    ms.success(t('common.success'))
+}
+
+function handleReset() {
+    userStore.resetUserInfo()
+    ms.success(t('common.success'))
+    window.location.reload()
+}
+
+function exportData(): void {
+    const date = getCurrentDate()
+    const data: string = localStorage.getItem('chatStorage') || '{}'
+    const jsonString: string = JSON.stringify(JSON.parse(data), null, 2)
+    const blob: Blob = new Blob([jsonString], { type: 'application/json' })
+    const url: string = URL.createObjectURL(blob)
+    const link: HTMLAnchorElement = document.createElement('a')
+    link.href = url
+    link.download = `chat-store_${date}.json`
+    document.body.appendChild(link)
+    link.click()
+    document.body.removeChild(link)
+}
+
+function importData(event: Event): void {
+    const target = event.target as HTMLInputElement
+    if (!target || !target.files) return
+
+    const file: File = target.files[0]
+    if (!file) return
+
+    const reader: FileReader = new FileReader()
+    reader.onload = () => {
+        try {
+            const data = JSON.parse(reader.result as string)
+            localStorage.setItem('chatStorage', JSON.stringify(data))
+            ms.success(t('common.success'))
+            location.reload()
+        } catch (error) {
+            ms.error(t('common.invalidFileFormat'))
+        }
+    }
+    reader.readAsText(file)
+}
+
+function clearData(): void {
+    localStorage.removeItem('chatStorage')
+    location.reload()
+}
+
+function handleImportButtonClick(): void {
+    const fileInput = document.getElementById('fileInput') as HTMLElement
+    if (fileInput) fileInput.click()
+}
+</script>
+
+<template>
+    <div class="p-4 space-y-5 min-h-[200px]">
+        <div class="space-y-6">
+            <div class="flex items-center space-x-4">
+                <span class="flex-shrink-0 w-[100px]">{{ $t('setting.avatarLink') }}</span>
+                <div class="flex-1">
+                    <NInput v-model:value="avatar" placeholder="" />
+                </div>
+                <NButton size="tiny" text type="primary" @click="updateUserInfo({ avatar })">
+                    {{ $t('common.save') }}
+                </NButton>
+            </div>
+            <div class="flex items-center space-x-4">
+                <span class="flex-shrink-0 w-[100px]">{{ $t('setting.name') }}</span>
+                <div class="w-[200px]">
+                    <NInput v-model:value="name" placeholder="" />
+                </div>
+                <NButton size="tiny" text type="primary" @click="updateUserInfo({ name })">
+                    {{ $t('common.save') }}
+                </NButton>
+            </div>
+            <div class="flex items-center space-x-4">
+                <span class="flex-shrink-0 w-[100px]">{{ $t('setting.description') }}</span>
+                <div class="flex-1">
+                    <NInput v-model:value="description" placeholder="" />
+                </div>
+                <NButton size="tiny" text type="primary" @click="updateUserInfo({ description })">
+                    {{ $t('common.save') }}
+                </NButton>
+            </div>
+            <div class="flex items-center space-x-4" :class="isMobile && 'items-start'">
+                <span class="flex-shrink-0 w-[100px]">{{ $t('setting.chatHistory') }}</span>
+
+                <div class="flex flex-wrap items-center gap-4">
+                    <NButton size="small" @click="exportData">
+                        <template #icon>
+                            <SvgIcon icon="ri:download-2-fill" />
+                        </template>
+                        {{ $t('common.export') }}
+                    </NButton>
+
+                    <input id="fileInput" type="file" style="display: none" @change="importData" />
+                    <NButton size="small" @click="handleImportButtonClick">
+                        <template #icon>
+                            <SvgIcon icon="ri:upload-2-fill" />
+                        </template>
+                        {{ $t('common.import') }}
+                    </NButton>
+
+                    <NPopconfirm placement="bottom" @positive-click="clearData">
+                        <template #trigger>
+                            <NButton size="small">
+                                <template #icon>
+                                    <SvgIcon icon="ri:close-circle-line" />
+                                </template>
+                                {{ $t('common.clear') }}
+                            </NButton>
+                        </template>
+                        {{ $t('chat.clearHistoryConfirm') }}
+                    </NPopconfirm>
+                </div>
+            </div>
+            <div class="flex items-center space-x-4">
+                <span class="flex-shrink-0 w-[100px]">{{ $t('setting.theme') }}</span>
+                <div class="flex flex-wrap items-center gap-4">
+                    <template v-for="item of themeOptions" :key="item.key">
+                        <NButton
+                            size="small"
+                            :type="item.key === theme ? 'primary' : undefined"
+                            @click="appStore.setTheme(item.key)"
+                        >
+                            <template #icon>
+                                <SvgIcon :icon="item.icon" />
+                            </template>
+                        </NButton>
+                    </template>
+                </div>
+            </div>
+            <div class="flex items-center space-x-4">
+                <span class="flex-shrink-0 w-[100px]">{{ $t('setting.language') }}</span>
+                <div class="flex flex-wrap items-center gap-4">
+                    <NSelect
+                        style="width: 140px"
+                        :value="language"
+                        :options="languageOptions"
+                        @update-value="value => appStore.setLanguage(value)"
+                    />
+                </div>
+            </div>
+            <div class="flex items-center space-x-4">
+                <span class="flex-shrink-0 w-[100px]">{{ $t('setting.resetUserInfo') }}</span>
+                <NButton size="small" @click="handleReset">
+                    {{ $t('common.reset') }}
+                </NButton>
+            </div>
+        </div>
+    </div>
+</template>

+ 70 - 0
src/components/common/Setting/index.vue

@@ -0,0 +1,70 @@
+<script setup lang="ts">
+import { computed, ref } from 'vue'
+import { NModal, NTabPane, NTabs } from 'naive-ui'
+import General from './General.vue'
+import Advanced from './Advanced.vue'
+import About from './About.vue'
+import { useAuthStore } from '@/store'
+import { SvgIcon } from '@/components/common'
+
+interface Props {
+    visible: boolean
+}
+
+interface Emit {
+    (e: 'update:visible', visible: boolean): void
+}
+
+const props = defineProps<Props>()
+
+const emit = defineEmits<Emit>()
+
+const authStore = useAuthStore()
+
+const isChatGPTAPI = computed<boolean>(() => !!authStore.isChatGPTAPI)
+
+const active = ref('General')
+
+const show = computed({
+    get() {
+        return props.visible
+    },
+    set(visible: boolean) {
+        emit('update:visible', visible)
+    }
+})
+</script>
+
+<template>
+    <NModal v-model:show="show" :auto-focus="false" preset="card" style="width: 95%; max-width: 640px">
+        <div>
+            <NTabs v-model:value="active" type="line" animated>
+                <NTabPane name="General" tab="General">
+                    <template #tab>
+                        <SvgIcon class="text-lg" icon="ri:file-user-line" />
+                        <span class="ml-2">{{ $t('setting.general') }}</span>
+                    </template>
+                    <div class="min-h-[100px]">
+                        <General />
+                    </div>
+                </NTabPane>
+                <NTabPane v-if="isChatGPTAPI" name="Advanced" tab="Advanced">
+                    <template #tab>
+                        <SvgIcon class="text-lg" icon="ri:equalizer-line" />
+                        <span class="ml-2">{{ $t('setting.advanced') }}</span>
+                    </template>
+                    <div class="min-h-[100px]">
+                        <Advanced />
+                    </div>
+                </NTabPane>
+                <NTabPane name="Config" tab="Config">
+                    <template #tab>
+                        <SvgIcon class="text-lg" icon="ri:list-settings-line" />
+                        <span class="ml-2">{{ $t('setting.config') }}</span>
+                    </template>
+                    <About />
+                </NTabPane>
+            </NTabs>
+        </div>
+    </NModal>
+</template>

+ 21 - 0
src/components/common/SvgIcon/index.vue

@@ -0,0 +1,21 @@
+<script setup lang="ts">
+import { computed, useAttrs } from 'vue'
+import { Icon } from '@iconify/vue'
+
+interface Props {
+    icon?: string
+}
+
+defineProps<Props>()
+
+const attrs = useAttrs()
+
+const bindAttrs = computed<{ class: string; style: string }>(() => ({
+    class: (attrs.class as string) || '',
+    style: (attrs.style as string) || ''
+}))
+</script>
+
+<template>
+    <Icon :icon="icon" v-bind="bindAttrs" />
+</template>

+ 35 - 0
src/components/common/UserAvatar/index.vue

@@ -0,0 +1,35 @@
+<script setup lang="ts">
+import { computed } from 'vue'
+import { NAvatar } from 'naive-ui'
+import { useUserStore } from '@/store'
+import defaultAvatar from '@/assets/avatar.jpg'
+import { isString } from '@/utils/is'
+
+const userStore = useUserStore()
+
+const userInfo = computed(() => userStore.userInfo)
+</script>
+
+<template>
+    <div class="flex items-center overflow-hidden">
+        <div class="w-10 h-10 overflow-hidden rounded-full shrink-0">
+            <template v-if="isString(userInfo.avatar) && userInfo.avatar.length > 0">
+                <NAvatar size="large" round :src="userInfo.avatar" :fallback-src="defaultAvatar" />
+            </template>
+            <template v-else>
+                <NAvatar size="large" round :src="defaultAvatar" />
+            </template>
+        </div>
+        <div class="flex-1 min-w-0 ml-2">
+            <h2 class="overflow-hidden font-bold text-md text-ellipsis whitespace-nowrap">
+                {{ userInfo.name ?? 'ChenZhaoYu' }}
+            </h2>
+            <p class="overflow-hidden text-xs text-gray-500 text-ellipsis whitespace-nowrap">
+                <span
+                    v-if="isString(userInfo.description) && userInfo.description !== ''"
+                    v-html="userInfo.description"
+                />
+            </p>
+        </div>
+    </div>
+</template>

+ 8 - 0
src/components/common/index.ts

@@ -0,0 +1,8 @@
+import HoverButton from './HoverButton/index.vue'
+import NaiveProvider from './NaiveProvider/index.vue'
+import SvgIcon from './SvgIcon/index.vue'
+import UserAvatar from './UserAvatar/index.vue'
+import Setting from './Setting/index.vue'
+import PromptStore from './PromptStore/index.vue'
+
+export { HoverButton, NaiveProvider, SvgIcon, UserAvatar, Setting, PromptStore }

+ 6 - 0
src/components/custom/GithubSite.vue

@@ -0,0 +1,6 @@
+<template>
+    <div class="text-neutral-400">
+        <span>Star on</span>
+        <a href="https://github.com/Chanzhaoyu/chatgpt-bot" target="_blank" class="text-blue-500"> GitHub </a>
+    </div>
+</template>

+ 3 - 0
src/components/custom/index.ts

@@ -0,0 +1,3 @@
+import GithubSite from './GithubSite.vue'
+
+export { GithubSite }

+ 8 - 0
src/hooks/useBasicLayout.ts

@@ -0,0 +1,8 @@
+import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
+
+export function useBasicLayout() {
+    const breakpoints = useBreakpoints(breakpointsTailwind)
+    const isMobile = breakpoints.smaller('sm')
+
+    return { isMobile }
+}

+ 33 - 0
src/hooks/useIconRender.ts

@@ -0,0 +1,33 @@
+import { h } from 'vue'
+import { SvgIcon } from '@/components/common'
+
+export const useIconRender = () => {
+    interface IconConfig {
+        icon?: string
+        color?: string
+        fontSize?: number
+    }
+
+    interface IconStyle {
+        color?: string
+        fontSize?: string
+    }
+
+    const iconRender = (config: IconConfig) => {
+        const { color, fontSize, icon } = config
+
+        const style: IconStyle = {}
+
+        if (color) style.color = color
+
+        if (fontSize) style.fontSize = `${fontSize}px`
+
+        if (!icon) window.console.warn('iconRender: icon is required')
+
+        return () => h(SvgIcon, { icon, style })
+    }
+
+    return {
+        iconRender
+    }
+}

+ 27 - 0
src/hooks/useLanguage.ts

@@ -0,0 +1,27 @@
+import { computed } from 'vue'
+import { enUS, zhCN, zhTW } from 'naive-ui'
+import { useAppStore } from '@/store'
+import { setLocale } from '@/locales'
+
+export function useLanguage() {
+    const appStore = useAppStore()
+
+    const language = computed(() => {
+        switch (appStore.language) {
+            case 'en-US':
+                setLocale('en-US')
+                return enUS
+            case 'zh-CN':
+                setLocale('zh-CN')
+                return zhCN
+            case 'zh-TW':
+                setLocale('zh-TW')
+                return zhTW
+            default:
+                setLocale('zh-CN')
+                return zhCN
+        }
+    })
+
+    return { language }
+}

+ 39 - 0
src/hooks/useTheme.ts

@@ -0,0 +1,39 @@
+import type { GlobalThemeOverrides } from 'naive-ui'
+import { computed, watch } from 'vue'
+import { darkTheme, useOsTheme } from 'naive-ui'
+import { useAppStore } from '@/store'
+
+export function useTheme() {
+    const appStore = useAppStore()
+
+    const OsTheme = useOsTheme()
+
+    const isDark = computed(() => {
+        if (appStore.theme === 'auto') return OsTheme.value === 'dark'
+        else return appStore.theme === 'dark'
+    })
+
+    const theme = computed(() => {
+        return isDark.value ? darkTheme : undefined
+    })
+
+    const themeOverrides = computed<GlobalThemeOverrides>(() => {
+        if (isDark.value) {
+            return {
+                common: {}
+            }
+        }
+        return {}
+    })
+
+    watch(
+        () => isDark.value,
+        dark => {
+            if (dark) document.documentElement.classList.add('dark')
+            else document.documentElement.classList.remove('dark')
+        },
+        { immediate: true }
+    )
+
+    return { theme, themeOverrides }
+}

Fișier diff suprimat deoarece este prea mare
+ 9 - 0
src/icons/403.vue


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
src/icons/404.svg


Fișier diff suprimat deoarece este prea mare
+ 44 - 0
src/icons/500.vue


+ 94 - 0
src/locales/en-US.ts

@@ -0,0 +1,94 @@
+export default {
+    common: {
+        add: 'Add',
+        addSuccess: 'Add Success',
+        edit: 'Edit',
+        editSuccess: 'Edit Success',
+        delete: 'Delete',
+        deleteSuccess: 'Delete Success',
+        save: 'Save',
+        saveSuccess: 'Save Success',
+        reset: 'Reset',
+        action: 'Action',
+        export: 'Export',
+        exportSuccess: 'Export Success',
+        import: 'Import',
+        importSuccess: 'Import Success',
+        clear: 'Clear',
+        clearSuccess: 'Clear Success',
+        yes: 'Yes',
+        no: 'No',
+        confirm: 'Confirm',
+        download: 'Download',
+        noData: 'No Data',
+        wrong: 'Something went wrong, please try again later.',
+        success: 'Success',
+        failed: 'Failed',
+        verify: 'Verify',
+        unauthorizedTips: 'Unauthorized, please verify first.'
+    },
+    chat: {
+        newChatButton: 'New Chat',
+        placeholder: 'Ask me anything...(Shift + Enter = line break, "/" to trigger prompts)',
+        placeholderMobile: 'Ask me anything...',
+        copy: 'Copy',
+        copied: 'Copied',
+        copyCode: 'Copy Code',
+        clearChat: 'Clear Chat',
+        clearChatConfirm: 'Are you sure to clear this chat?',
+        exportImage: 'Export Image',
+        exportImageConfirm: 'Are you sure to export this chat to png?',
+        exportSuccess: 'Export Success',
+        exportFailed: 'Export Failed',
+        usingContext: 'Context Mode',
+        turnOnContext: 'In the current mode, sending messages will carry previous chat records.',
+        turnOffContext: 'In the current mode, sending messages will not carry previous chat records.',
+        deleteMessage: 'Delete Message',
+        deleteMessageConfirm: 'Are you sure to delete this message?',
+        deleteHistoryConfirm: 'Are you sure to clear this history?',
+        clearHistoryConfirm: 'Are you sure to clear chat history?',
+        preview: 'Preview',
+        showRawText: 'Show as raw text'
+    },
+    setting: {
+        setting: 'Setting',
+        general: 'General',
+        advanced: 'Advanced',
+        config: 'Config',
+        avatarLink: 'Avatar Link',
+        name: 'Name',
+        description: 'Description',
+        role: 'Role',
+        temperature: 'Temperature',
+        top_p: 'Top_p',
+        resetUserInfo: 'Reset UserInfo',
+        chatHistory: 'ChatHistory',
+        theme: 'Theme',
+        language: 'Language',
+        api: 'API',
+        reverseProxy: 'Reverse Proxy',
+        timeout: 'Timeout',
+        socks: 'Socks',
+        httpsProxy: 'HTTPS Proxy',
+        balance: 'API Balance',
+        monthlyUsage: 'Monthly Usage'
+    },
+    store: {
+        siderButton: 'Prompt Store',
+        local: 'Local',
+        online: 'Online',
+        title: 'Title',
+        description: 'Description',
+        clearStoreConfirm: 'Whether to clear the data?',
+        importPlaceholder: 'Please paste the JSON data here',
+        addRepeatTitleTips: 'Title duplicate, please re-enter',
+        addRepeatContentTips: 'Content duplicate: {msg}, please re-enter',
+        editRepeatTitleTips: 'Title conflict, please revise',
+        editRepeatContentTips: 'Content conflict {msg} , please re-modify',
+        importError: 'Key value mismatch',
+        importRepeatTitle: 'Title repeatedly skipped: {msg}',
+        importRepeatContent: 'Content is repeatedly skipped: {msg}',
+        onlineImportWarning: 'Note: Please check the JSON file source!',
+        downloadError: 'Please check the network status and JSON file validity'
+    }
+}

+ 34 - 0
src/locales/index.ts

@@ -0,0 +1,34 @@
+import type { App } from 'vue'
+import { createI18n } from 'vue-i18n'
+import enUS from './en-US'
+import zhCN from './zh-CN'
+import zhTW from './zh-TW'
+import { useAppStoreWithOut } from '@/store/modules/app'
+import type { Language } from '@/store/modules/app/helper'
+
+const appStore = useAppStoreWithOut()
+
+const defaultLocale = appStore.language || 'zh-CN'
+
+const i18n = createI18n({
+    locale: defaultLocale,
+    fallbackLocale: 'en-US',
+    allowComposition: true,
+    messages: {
+        'en-US': enUS,
+        'zh-CN': zhCN,
+        'zh-TW': zhTW
+    }
+})
+
+export const t = i18n.global.t
+
+export function setLocale(locale: Language) {
+    i18n.global.locale = locale
+}
+
+export function setupI18n(app: App) {
+    app.use(i18n)
+}
+
+export default i18n

+ 94 - 0
src/locales/zh-CN.ts

@@ -0,0 +1,94 @@
+export default {
+    common: {
+        add: '添加',
+        addSuccess: '添加成功',
+        edit: '编辑',
+        editSuccess: '编辑成功',
+        delete: '删除',
+        deleteSuccess: '删除成功',
+        save: '保存',
+        saveSuccess: '保存成功',
+        reset: '重置',
+        action: '操作',
+        export: '导出',
+        exportSuccess: '导出成功',
+        import: '导入',
+        importSuccess: '导入成功',
+        clear: '清空',
+        clearSuccess: '清空成功',
+        yes: '是',
+        no: '否',
+        confirm: '确定',
+        download: '下载',
+        noData: '暂无数据',
+        wrong: '好像出错了,请稍后再试。',
+        success: '操作成功',
+        failed: '操作失败',
+        verify: '验证',
+        unauthorizedTips: '未经授权,请先进行验证。'
+    },
+    chat: {
+        newChatButton: '新建聊天',
+        placeholder: '来说点什么吧...(Shift + Enter = 换行,"/" 触发提示词)',
+        placeholderMobile: '来说点什么...',
+        copy: '复制',
+        copied: '复制成功',
+        copyCode: '复制代码',
+        clearChat: '清空会话',
+        clearChatConfirm: '是否清空会话?',
+        exportImage: '保存会话到图片',
+        exportImageConfirm: '是否将会话保存为图片?',
+        exportSuccess: '保存成功',
+        exportFailed: '保存失败',
+        usingContext: '上下文模式',
+        turnOnContext: '当前模式下, 发送消息会携带之前的聊天记录',
+        turnOffContext: '当前模式下, 发送消息不会携带之前的聊天记录',
+        deleteMessage: '删除消息',
+        deleteMessageConfirm: '是否删除此消息?',
+        deleteHistoryConfirm: '确定删除此记录?',
+        clearHistoryConfirm: '确定清空聊天记录?',
+        preview: '预览',
+        showRawText: '显示原文'
+    },
+    setting: {
+        setting: '设置',
+        general: '总览',
+        advanced: '高级',
+        config: '配置',
+        avatarLink: '头像链接',
+        name: '名称',
+        description: '描述',
+        role: '角色设定',
+        temperature: 'Temperature',
+        top_p: 'Top_p',
+        resetUserInfo: '重置用户信息',
+        chatHistory: '聊天记录',
+        theme: '主题',
+        language: '语言',
+        api: 'API',
+        reverseProxy: '反向代理',
+        timeout: '超时',
+        socks: 'Socks',
+        httpsProxy: 'HTTPS Proxy',
+        balance: 'API余额',
+        monthlyUsage: '本月使用量'
+    },
+    store: {
+        siderButton: '提示词商店',
+        local: '本地',
+        online: '在线',
+        title: '标题',
+        description: '描述',
+        clearStoreConfirm: '是否清空数据?',
+        importPlaceholder: '请粘贴 JSON 数据到此处',
+        addRepeatTitleTips: '标题重复,请重新输入',
+        addRepeatContentTips: '内容重复:{msg},请重新输入',
+        editRepeatTitleTips: '标题冲突,请重新修改',
+        editRepeatContentTips: '内容冲突{msg} ,请重新修改',
+        importError: '键值不匹配',
+        importRepeatTitle: '标题重复跳过:{msg}',
+        importRepeatContent: '内容重复跳过:{msg}',
+        onlineImportWarning: '注意:请检查 JSON 文件来源!',
+        downloadError: '请检查网络状态与 JSON 文件有效性'
+    }
+}

+ 94 - 0
src/locales/zh-TW.ts

@@ -0,0 +1,94 @@
+export default {
+    common: {
+        add: '新增',
+        addSuccess: '新增成功',
+        edit: '編輯',
+        editSuccess: '編輯成功',
+        delete: '刪除',
+        deleteSuccess: '刪除成功',
+        save: '儲存',
+        saveSuccess: '儲存成功',
+        reset: '重設',
+        action: '操作',
+        export: '匯出',
+        exportSuccess: '匯出成功',
+        import: '匯入',
+        importSuccess: '匯入成功',
+        clear: '清除',
+        clearSuccess: '清除成功',
+        yes: '是',
+        no: '否',
+        confirm: '確認',
+        download: '下載',
+        noData: '目前無資料',
+        wrong: '發生錯誤,請稍後再試。',
+        success: '操作成功',
+        failed: '操作失敗',
+        verify: '驗證',
+        unauthorizedTips: '未經授權,請先進行驗證。'
+    },
+    chat: {
+        newChatButton: '新建對話',
+        placeholder: '來說點什麼...(Shift + Enter = 換行,"/" 觸發提示詞)',
+        placeholderMobile: '來說點什麼...',
+        copy: '複製',
+        copied: '複製成功',
+        copyCode: '複製代碼',
+        clearChat: '清除對話',
+        clearChatConfirm: '是否清空對話?',
+        exportImage: '儲存對話為圖片',
+        exportImageConfirm: '是否將對話儲存為圖片?',
+        exportSuccess: '儲存成功',
+        exportFailed: '儲存失敗',
+        usingContext: '上下文模式',
+        turnOnContext: '啟用上下文模式,在此模式下,發送訊息會包含之前的聊天記錄。',
+        turnOffContext: '關閉上下文模式,在此模式下,發送訊息不會包含之前的聊天記錄。',
+        deleteMessage: '刪除訊息',
+        deleteMessageConfirm: '是否刪除此訊息?',
+        deleteHistoryConfirm: '確定刪除此紀錄?',
+        clearHistoryConfirm: '確定清除紀錄?',
+        preview: '預覽',
+        showRawText: '顯示原文'
+    },
+    setting: {
+        setting: '設定',
+        general: '總覽',
+        advanced: '高級',
+        config: '設定',
+        avatarLink: '頭貼連結',
+        name: '名稱',
+        description: '描述',
+        role: '角色設定',
+        temperature: 'Temperature',
+        top_p: 'Top_p',
+        resetUserInfo: '重設使用者資訊',
+        chatHistory: '紀錄',
+        theme: '主題',
+        language: '語言',
+        api: 'API',
+        reverseProxy: '反向代理',
+        timeout: '逾時',
+        socks: 'Socks',
+        httpsProxy: 'HTTPS Proxy',
+        balance: 'API余額',
+        monthlyUsage: '本月使用量'
+    },
+    store: {
+        siderButton: '提示詞商店',
+        local: '本機',
+        online: '線上',
+        title: '標題',
+        description: '描述',
+        clearStoreConfirm: '是否清除資料?',
+        importPlaceholder: '請將 JSON 資料貼在此處',
+        addRepeatTitleTips: '標題重複,請重新輸入',
+        addRepeatContentTips: '內容重複:{msg},請重新輸入',
+        editRepeatTitleTips: '標題衝突,請重新修改',
+        editRepeatContentTips: '內容衝突{msg} ,請重新修改',
+        importError: '鍵值不符合',
+        importRepeatTitle: '因標題重複跳過:{msg}',
+        importRepeatContent: '因內容重複跳過:{msg}',
+        onlineImportWarning: '注意:請檢查 JSON 檔案來源!',
+        downloadError: '請檢查網路狀態與 JSON 檔案有效性'
+    }
+}

+ 23 - 0
src/main.ts

@@ -0,0 +1,23 @@
+import { createApp } from 'vue'
+import App from './App.vue'
+import { setupI18n } from './locales'
+import { setupAssets, setupScrollbarStyle } from './plugins'
+import { setupStore } from './store'
+import { setupRouter } from './router'
+
+async function bootstrap() {
+    const app = createApp(App)
+    setupAssets()
+
+    setupScrollbarStyle()
+
+    setupStore(app)
+
+    setupI18n(app)
+
+    await setupRouter(app)
+
+    app.mount('#app')
+}
+
+bootstrap()

+ 18 - 0
src/plugins/assets.ts

@@ -0,0 +1,18 @@
+import 'katex/dist/katex.min.css'
+import '@/styles/lib/tailwind.css'
+import '@/styles/lib/highlight.less'
+import '@/styles/lib/github-markdown.less'
+import '@/styles/global.less'
+
+/** Tailwind's Preflight Style Override */
+function naiveStyleOverride() {
+    const meta = document.createElement('meta')
+    meta.name = 'naive-ui-style'
+    document.head.appendChild(meta)
+}
+
+function setupAssets() {
+    naiveStyleOverride()
+}
+
+export default setupAssets

+ 4 - 0
src/plugins/index.ts

@@ -0,0 +1,4 @@
+import setupAssets from './assets'
+import setupScrollbarStyle from './scrollbarStyle'
+
+export { setupAssets, setupScrollbarStyle }

+ 28 - 0
src/plugins/scrollbarStyle.ts

@@ -0,0 +1,28 @@
+import { darkTheme, lightTheme } from 'naive-ui'
+
+const setupScrollbarStyle = () => {
+    const style = document.createElement('style')
+    const styleContent = `
+    ::-webkit-scrollbar {
+      background-color: transparent;
+      width: ${lightTheme.Scrollbar.common?.scrollbarWidth};
+    }
+    ::-webkit-scrollbar-thumb {
+      background-color: ${lightTheme.Scrollbar.common?.scrollbarColor};
+      border-radius: ${lightTheme.Scrollbar.common?.scrollbarBorderRadius};
+    }
+    html.dark ::-webkit-scrollbar {
+      background-color: transparent;
+      width: ${darkTheme.Scrollbar.common?.scrollbarWidth};
+    }
+    html.dark ::-webkit-scrollbar-thumb {
+      background-color: ${darkTheme.Scrollbar.common?.scrollbarColor};
+      border-radius: ${darkTheme.Scrollbar.common?.scrollbarBorderRadius};
+    }
+  `
+
+    style.innerHTML = styleContent
+    document.head.appendChild(style)
+}
+
+export default setupScrollbarStyle

+ 52 - 0
src/router/index.ts

@@ -0,0 +1,52 @@
+import type { App } from 'vue'
+import type { RouteRecordRaw } from 'vue-router'
+import { createRouter, createWebHashHistory } from 'vue-router'
+import { setupPageGuard } from './permission'
+import { ChatLayout } from '@/views/chat/layout'
+
+const routes: RouteRecordRaw[] = [
+    {
+        path: '/',
+        name: 'Root',
+        component: ChatLayout,
+        redirect: '/chat',
+        children: [
+            {
+                path: '/chat/:uuid?',
+                name: 'Chat',
+                component: () => import('@/views/chat/index.vue')
+            }
+        ]
+    },
+
+    {
+        path: '/404',
+        name: '404',
+        component: () => import('@/views/exception/404/index.vue')
+    },
+
+    {
+        path: '/500',
+        name: '500',
+        component: () => import('@/views/exception/500/index.vue')
+    },
+
+    {
+        path: '/:pathMatch(.*)*',
+        name: 'notFound',
+        redirect: '/404'
+    }
+]
+
+export const router = createRouter({
+    history: createWebHashHistory(),
+    routes,
+    scrollBehavior: () => ({ left: 0, top: 0 })
+})
+
+setupPageGuard(router)
+
+export async function setupRouter(app: App) {
+    app.use(router)
+    await router.isReady()
+}

+ 21 - 0
src/router/permission.ts

@@ -0,0 +1,21 @@
+import type { Router } from 'vue-router'
+import { useAuthStoreWithout } from '@/store/modules/auth'
+
+export function setupPageGuard(router: Router) {
+    router.beforeEach(async (to, from, next) => {
+        const authStore = useAuthStoreWithout()
+        if (!authStore.session) {
+            try {
+                const data = await authStore.getSession()
+                if (String(data.auth) === 'false' && authStore.token) authStore.removeToken()
+                if (to.path === '/500') next({ name: 'Root' })
+                else next()
+            } catch (error) {
+                if (to.path !== '/500') next({ name: '500' })
+                else next()
+            }
+        } else {
+            next()
+        }
+    })
+}

+ 10 - 0
src/store/index.ts

@@ -0,0 +1,10 @@
+import type { App } from 'vue'
+import { createPinia } from 'pinia'
+
+export const store = createPinia()
+
+export function setupStore(app: App) {
+    app.use(store)
+}
+
+export * from './modules'

+ 26 - 0
src/store/modules/app/helper.ts

@@ -0,0 +1,26 @@
+import { ss } from '@/utils/storage'
+
+const LOCAL_NAME = 'appSetting'
+
+export type Theme = 'light' | 'dark' | 'auto'
+
+export type Language = 'zh-CN' | 'zh-TW' | 'en-US'
+
+export interface AppState {
+    siderCollapsed: boolean
+    theme: Theme
+    language: Language
+}
+
+export function defaultSetting(): AppState {
+    return { siderCollapsed: false, theme: 'light', language: 'zh-CN' }
+}
+
+export function getLocalSetting(): AppState {
+    const localSetting: AppState | undefined = ss.get(LOCAL_NAME)
+    return { ...defaultSetting(), ...localSetting }
+}
+
+export function setLocalSetting(setting: AppState): void {
+    ss.set(LOCAL_NAME, setting)
+}

+ 34 - 0
src/store/modules/app/index.ts

@@ -0,0 +1,34 @@
+import { defineStore } from 'pinia'
+import type { AppState, Language, Theme } from './helper'
+import { getLocalSetting, setLocalSetting } from './helper'
+import { store } from '@/store'
+
+export const useAppStore = defineStore('app-store', {
+    state: (): AppState => getLocalSetting(),
+    actions: {
+        setSiderCollapsed(collapsed: boolean) {
+            this.siderCollapsed = collapsed
+            this.recordState()
+        },
+
+        setTheme(theme: Theme) {
+            this.theme = theme
+            this.recordState()
+        },
+
+        setLanguage(language: Language) {
+            if (this.language !== language) {
+                this.language = language
+                this.recordState()
+            }
+        },
+
+        recordState() {
+            setLocalSetting(this.$state)
+        }
+    }
+})
+
+export function useAppStoreWithOut() {
+    return useAppStore(store)
+}

+ 15 - 0
src/store/modules/auth/helper.ts

@@ -0,0 +1,15 @@
+import { ss } from '@/utils/storage'
+
+const LOCAL_NAME = 'SECRET_TOKEN'
+
+export function getToken() {
+    return ss.get(LOCAL_NAME)
+}
+
+export function setToken(token: string) {
+    return ss.set(LOCAL_NAME, token)
+}
+
+export function removeToken() {
+    return ss.remove(LOCAL_NAME)
+}

+ 53 - 0
src/store/modules/auth/index.ts

@@ -0,0 +1,53 @@
+import { defineStore } from 'pinia'
+import { getToken, removeToken, setToken } from './helper'
+import { store } from '@/store'
+import { fetchSession } from '@/api'
+
+interface SessionResponse {
+    auth: boolean
+    model: 'ChatGPTAPI' | 'ChatGPTUnofficialProxyAPI'
+}
+
+export interface AuthState {
+    token: string | undefined
+    session: SessionResponse | null
+}
+
+export const useAuthStore = defineStore('auth-store', {
+    state: (): AuthState => ({
+        token: getToken(),
+        session: null
+    }),
+
+    getters: {
+        isChatGPTAPI(state): boolean {
+            return state.session?.model === 'ChatGPTAPI'
+        }
+    },
+
+    actions: {
+        async getSession() {
+            try {
+                const { data } = await fetchSession<SessionResponse>()
+                this.session = { ...data }
+                return Promise.resolve(data)
+            } catch (error) {
+                return Promise.reject(error)
+            }
+        },
+
+        setToken(token: string) {
+            this.token = token
+            setToken(token)
+        },
+
+        removeToken() {
+            this.token = undefined
+            removeToken()
+        }
+    }
+})
+
+export function useAuthStoreWithout() {
+    return useAuthStore(store)
+}

+ 22 - 0
src/store/modules/chat/helper.ts

@@ -0,0 +1,22 @@
+import { ss } from '@/utils/storage'
+
+const LOCAL_NAME = 'chatStorage'
+
+export function defaultState(): Chat.ChatState {
+    const uuid = 1002
+    return {
+        active: uuid,
+        usingContext: true,
+        history: [{ uuid, title: 'New Chat', isEdit: false }],
+        chat: [{ uuid, data: [] }]
+    }
+}
+
+export function getLocalState(): Chat.ChatState {
+    const localState = ss.get(LOCAL_NAME)
+    return { ...defaultState(), ...localState }
+}
+
+export function setLocalState(state: Chat.ChatState) {
+    ss.set(LOCAL_NAME, state)
+}

+ 187 - 0
src/store/modules/chat/index.ts

@@ -0,0 +1,187 @@
+import { defineStore } from 'pinia'
+import { getLocalState, setLocalState } from './helper'
+import { router } from '@/router'
+
+export const useChatStore = defineStore('chat-store', {
+    state: (): Chat.ChatState => getLocalState(),
+
+    getters: {
+        getChatHistoryByCurrentActive(state: Chat.ChatState) {
+            const index = state.history.findIndex(item => item.uuid === state.active)
+            if (index !== -1) return state.history[index]
+            return null
+        },
+
+        getChatByUuid(state: Chat.ChatState) {
+            return (uuid?: number) => {
+                if (uuid) return state.chat.find(item => item.uuid === uuid)?.data ?? []
+                return state.chat.find(item => item.uuid === state.active)?.data ?? []
+            }
+        }
+    },
+
+    actions: {
+        setUsingContext(context: boolean) {
+            this.usingContext = context
+            this.recordState()
+        },
+
+        addHistory(history: Chat.History, chatData: Chat.Chat[] = []) {
+            this.history.unshift(history)
+            this.chat.unshift({ uuid: history.uuid, data: chatData })
+            this.active = history.uuid
+            this.reloadRoute(history.uuid)
+        },
+
+        updateHistory(uuid: number, edit: Partial<Chat.History>) {
+            const index = this.history.findIndex(item => item.uuid === uuid)
+            if (index !== -1) {
+                this.history[index] = { ...this.history[index], ...edit }
+                this.recordState()
+            }
+        },
+
+        async deleteHistory(index: number) {
+            this.history.splice(index, 1)
+            this.chat.splice(index, 1)
+
+            if (this.history.length === 0) {
+                this.active = null
+                this.reloadRoute()
+                return
+            }
+
+            if (index > 0 && index <= this.history.length) {
+                const uuid = this.history[index - 1].uuid
+                this.active = uuid
+                this.reloadRoute(uuid)
+                return
+            }
+
+            if (index === 0) {
+                if (this.history.length > 0) {
+                    const uuid = this.history[0].uuid
+                    this.active = uuid
+                    this.reloadRoute(uuid)
+                }
+            }
+
+            if (index > this.history.length) {
+                const uuid = this.history[this.history.length - 1].uuid
+                this.active = uuid
+                this.reloadRoute(uuid)
+            }
+        },
+
+        async setActive(uuid: number) {
+            this.active = uuid
+            return await this.reloadRoute(uuid)
+        },
+
+        getChatByUuidAndIndex(uuid: number, index: number) {
+            if (!uuid || uuid === 0) {
+                if (this.chat.length) return this.chat[0].data[index]
+                return null
+            }
+            const chatIndex = this.chat.findIndex(item => item.uuid === uuid)
+            if (chatIndex !== -1) return this.chat[chatIndex].data[index]
+            return null
+        },
+
+        addChatByUuid(uuid: number, chat: Chat.Chat) {
+            if (!uuid || uuid === 0) {
+                if (this.history.length === 0) {
+                    const uuid = Date.now()
+                    this.history.push({ uuid, title: chat.text, isEdit: false })
+                    this.chat.push({ uuid, data: [chat] })
+                    this.active = uuid
+                    this.recordState()
+                } else {
+                    this.chat[0].data.push(chat)
+                    if (this.history[0].title === 'New Chat') this.history[0].title = chat.text
+                    this.recordState()
+                }
+            }
+
+            const index = this.chat.findIndex(item => item.uuid === uuid)
+            if (index !== -1) {
+                this.chat[index].data.push(chat)
+                if (this.history[index].title === 'New Chat') this.history[index].title = chat.text
+                this.recordState()
+            }
+        },
+
+        updateChatByUuid(uuid: number, index: number, chat: Chat.Chat) {
+            if (!uuid || uuid === 0) {
+                if (this.chat.length) {
+                    this.chat[0].data[index] = chat
+                    this.recordState()
+                }
+                return
+            }
+
+            const chatIndex = this.chat.findIndex(item => item.uuid === uuid)
+            if (chatIndex !== -1) {
+                this.chat[chatIndex].data[index] = chat
+                this.recordState()
+            }
+        },
+
+        updateChatSomeByUuid(uuid: number, index: number, chat: Partial<Chat.Chat>) {
+            if (!uuid || uuid === 0) {
+                if (this.chat.length) {
+                    this.chat[0].data[index] = { ...this.chat[0].data[index], ...chat }
+                    this.recordState()
+                }
+                return
+            }
+
+            const chatIndex = this.chat.findIndex(item => item.uuid === uuid)
+            if (chatIndex !== -1) {
+                this.chat[chatIndex].data[index] = { ...this.chat[chatIndex].data[index], ...chat }
+                this.recordState()
+            }
+        },
+
+        deleteChatByUuid(uuid: number, index: number) {
+            if (!uuid || uuid === 0) {
+                if (this.chat.length) {
+                    this.chat[0].data.splice(index, 1)
+                    this.recordState()
+                }
+                return
+            }
+
+            const chatIndex = this.chat.findIndex(item => item.uuid === uuid)
+            if (chatIndex !== -1) {
+                this.chat[chatIndex].data.splice(index, 1)
+                this.recordState()
+            }
+        },
+
+        clearChatByUuid(uuid: number) {
+            if (!uuid || uuid === 0) {
+                if (this.chat.length) {
+                    this.chat[0].data = []
+                    this.recordState()
+                }
+                return
+            }
+
+            const index = this.chat.findIndex(item => item.uuid === uuid)
+            if (index !== -1) {
+                this.chat[index].data = []
+                this.recordState()
+            }
+        },
+
+        async reloadRoute(uuid?: number) {
+            this.recordState()
+            await router.push({ name: 'Chat', params: { uuid } })
+        },
+
+        recordState() {
+            setLocalState(this.$state)
+        }
+    }
+})

+ 6 - 0
src/store/modules/index.ts

@@ -0,0 +1,6 @@
+export * from './app'
+export * from './chat'
+export * from './user'
+export * from './prompt'
+export * from './settings'
+export * from './auth'

+ 18 - 0
src/store/modules/prompt/helper.ts

@@ -0,0 +1,18 @@
+import { ss } from '@/utils/storage'
+
+const LOCAL_NAME = 'promptStore'
+
+export type PromptList = []
+
+export interface PromptStore {
+    promptList: PromptList
+}
+
+export function getLocalPromptList(): PromptStore {
+    const promptStore: PromptStore | undefined = ss.get(LOCAL_NAME)
+    return promptStore ?? { promptList: [] }
+}
+
+export function setLocalPromptList(promptStore: PromptStore): void {
+    ss.set(LOCAL_NAME, promptStore)
+}

+ 17 - 0
src/store/modules/prompt/index.ts

@@ -0,0 +1,17 @@
+import { defineStore } from 'pinia'
+import type { PromptStore } from './helper'
+import { getLocalPromptList, setLocalPromptList } from './helper'
+
+export const usePromptStore = defineStore('prompt-store', {
+    state: (): PromptStore => getLocalPromptList(),
+
+    actions: {
+        updatePromptList(promptList: []) {
+            this.$patch({ promptList })
+            setLocalPromptList({ promptList })
+        },
+        getPromptList() {
+            return this.$state
+        }
+    }
+})

+ 31 - 0
src/store/modules/settings/helper.ts

@@ -0,0 +1,31 @@
+import { ss } from '@/utils/storage'
+
+const LOCAL_NAME = 'settingsStorage'
+
+export interface SettingsState {
+    systemMessage: string
+    temperature: number
+    top_p: number
+}
+
+export function defaultSetting(): SettingsState {
+    return {
+        systemMessage:
+            "You are ChatGPT, a large language model trained by OpenAI. Follow the user's instructions carefully. Respond using markdown.",
+        temperature: 0.8,
+        top_p: 1
+    }
+}
+
+export function getLocalState(): SettingsState {
+    const localSetting: SettingsState | undefined = ss.get(LOCAL_NAME)
+    return { ...defaultSetting(), ...localSetting }
+}
+
+export function setLocalState(setting: SettingsState): void {
+    ss.set(LOCAL_NAME, setting)
+}
+
+export function removeLocalState() {
+    ss.remove(LOCAL_NAME)
+}

+ 22 - 0
src/store/modules/settings/index.ts

@@ -0,0 +1,22 @@
+import { defineStore } from 'pinia'
+import type { SettingsState } from './helper'
+import { defaultSetting, getLocalState, removeLocalState, setLocalState } from './helper'
+
+export const useSettingStore = defineStore('setting-store', {
+    state: (): SettingsState => getLocalState(),
+    actions: {
+        updateSetting(settings: Partial<SettingsState>) {
+            this.$state = { ...this.$state, ...settings }
+            this.recordState()
+        },
+
+        resetSetting() {
+            this.$state = defaultSetting()
+            removeLocalState()
+        },
+
+        recordState() {
+            setLocalState(this.$state)
+        }
+    }
+})

+ 33 - 0
src/store/modules/user/helper.ts

@@ -0,0 +1,33 @@
+import { ss } from '@/utils/storage'
+
+const LOCAL_NAME = 'userStorage'
+
+export interface UserInfo {
+    avatar: string
+    name: string
+    description: string
+}
+
+export interface UserState {
+    userInfo: UserInfo
+}
+
+export function defaultSetting(): UserState {
+    return {
+        userInfo: {
+            avatar: 'https://raw.githubusercontent.com/Chanzhaoyu/chatgpt-web/main/src/assets/avatar.jpg',
+            name: 'ChenZhaoYu',
+            description:
+                'Star on <a href="https://github.com/Chanzhaoyu/chatgpt-bot" class="text-blue-500" target="_blank" >Github</a>'
+        }
+    }
+}
+
+export function getLocalState(): UserState {
+    const localSetting: UserState | undefined = ss.get(LOCAL_NAME)
+    return { ...defaultSetting(), ...localSetting }
+}
+
+export function setLocalState(setting: UserState): void {
+    ss.set(LOCAL_NAME, setting)
+}

+ 22 - 0
src/store/modules/user/index.ts

@@ -0,0 +1,22 @@
+import { defineStore } from 'pinia'
+import type { UserInfo, UserState } from './helper'
+import { defaultSetting, getLocalState, setLocalState } from './helper'
+
+export const useUserStore = defineStore('user-store', {
+    state: (): UserState => getLocalState(),
+    actions: {
+        updateUserInfo(userInfo: Partial<UserInfo>) {
+            this.userInfo = { ...this.userInfo, ...userInfo }
+            this.recordState()
+        },
+
+        resetUserInfo() {
+            this.userInfo = { ...defaultSetting().userInfo }
+            this.recordState()
+        },
+
+        recordState() {
+            setLocalState(this.$state)
+        }
+    }
+})

+ 10 - 0
src/styles/global.less

@@ -0,0 +1,10 @@
+html,
+body,
+#app {
+    height: 100%;
+}
+
+body {
+    padding-bottom: constant(safe-area-inset-bottom);
+    padding-bottom: env(safe-area-inset-bottom);
+}

+ 1103 - 0
src/styles/lib/github-markdown.less

@@ -0,0 +1,1103 @@
+html.dark {
+    .markdown-body {
+        color-scheme: dark;
+        --color-prettylights-syntax-comment: #8b949e;
+        --color-prettylights-syntax-constant: #79c0ff;
+        --color-prettylights-syntax-entity: #d2a8ff;
+        --color-prettylights-syntax-storage-modifier-import: #c9d1d9;
+        --color-prettylights-syntax-entity-tag: #7ee787;
+        --color-prettylights-syntax-keyword: #ff7b72;
+        --color-prettylights-syntax-string: #a5d6ff;
+        --color-prettylights-syntax-variable: #ffa657;
+        --color-prettylights-syntax-brackethighlighter-unmatched: #f85149;
+        --color-prettylights-syntax-invalid-illegal-text: #f0f6fc;
+        --color-prettylights-syntax-invalid-illegal-bg: #8e1519;
+        --color-prettylights-syntax-carriage-return-text: #f0f6fc;
+        --color-prettylights-syntax-carriage-return-bg: #b62324;
+        --color-prettylights-syntax-string-regexp: #7ee787;
+        --color-prettylights-syntax-markup-list: #f2cc60;
+        --color-prettylights-syntax-markup-heading: #1f6feb;
+        --color-prettylights-syntax-markup-italic: #c9d1d9;
+        --color-prettylights-syntax-markup-bold: #c9d1d9;
+        --color-prettylights-syntax-markup-deleted-text: #ffdcd7;
+        --color-prettylights-syntax-markup-deleted-bg: #67060c;
+        --color-prettylights-syntax-markup-inserted-text: #aff5b4;
+        --color-prettylights-syntax-markup-inserted-bg: #033a16;
+        --color-prettylights-syntax-markup-changed-text: #ffdfb6;
+        --color-prettylights-syntax-markup-changed-bg: #5a1e02;
+        --color-prettylights-syntax-markup-ignored-text: #c9d1d9;
+        --color-prettylights-syntax-markup-ignored-bg: #1158c7;
+        --color-prettylights-syntax-meta-diff-range: #d2a8ff;
+        --color-prettylights-syntax-brackethighlighter-angle: #8b949e;
+        --color-prettylights-syntax-sublimelinter-gutter-mark: #484f58;
+        --color-prettylights-syntax-constant-other-reference-link: #a5d6ff;
+        --color-fg-default: #c9d1d9;
+        --color-fg-muted: #8b949e;
+        --color-fg-subtle: #6e7681;
+        --color-canvas-default: #0d1117;
+        --color-canvas-subtle: #161b22;
+        --color-border-default: #30363d;
+        --color-border-muted: #21262d;
+        --color-neutral-muted: rgba(110, 118, 129, 0.4);
+        --color-accent-fg: #58a6ff;
+        --color-accent-emphasis: #1f6feb;
+        --color-attention-subtle: rgba(187, 128, 9, 0.15);
+        --color-danger-fg: #f85149;
+    }
+}
+
+html {
+    .markdown-body {
+        color-scheme: light;
+        --color-prettylights-syntax-comment: #6e7781;
+        --color-prettylights-syntax-constant: #0550ae;
+        --color-prettylights-syntax-entity: #8250df;
+        --color-prettylights-syntax-storage-modifier-import: #24292f;
+        --color-prettylights-syntax-entity-tag: #116329;
+        --color-prettylights-syntax-keyword: #cf222e;
+        --color-prettylights-syntax-string: #0a3069;
+        --color-prettylights-syntax-variable: #953800;
+        --color-prettylights-syntax-brackethighlighter-unmatched: #82071e;
+        --color-prettylights-syntax-invalid-illegal-text: #f6f8fa;
+        --color-prettylights-syntax-invalid-illegal-bg: #82071e;
+        --color-prettylights-syntax-carriage-return-text: #f6f8fa;
+        --color-prettylights-syntax-carriage-return-bg: #cf222e;
+        --color-prettylights-syntax-string-regexp: #116329;
+        --color-prettylights-syntax-markup-list: #3b2300;
+        --color-prettylights-syntax-markup-heading: #0550ae;
+        --color-prettylights-syntax-markup-italic: #24292f;
+        --color-prettylights-syntax-markup-bold: #24292f;
+        --color-prettylights-syntax-markup-deleted-text: #82071e;
+        --color-prettylights-syntax-markup-deleted-bg: #ffebe9;
+        --color-prettylights-syntax-markup-inserted-text: #116329;
+        --color-prettylights-syntax-markup-inserted-bg: #dafbe1;
+        --color-prettylights-syntax-markup-changed-text: #953800;
+        --color-prettylights-syntax-markup-changed-bg: #ffd8b5;
+        --color-prettylights-syntax-markup-ignored-text: #eaeef2;
+        --color-prettylights-syntax-markup-ignored-bg: #0550ae;
+        --color-prettylights-syntax-meta-diff-range: #8250df;
+        --color-prettylights-syntax-brackethighlighter-angle: #57606a;
+        --color-prettylights-syntax-sublimelinter-gutter-mark: #8c959f;
+        --color-prettylights-syntax-constant-other-reference-link: #0a3069;
+        --color-fg-default: #24292f;
+        --color-fg-muted: #57606a;
+        --color-fg-subtle: #6e7781;
+        --color-canvas-default: #ffffff;
+        --color-canvas-subtle: #f6f8fa;
+        --color-border-default: #d0d7de;
+        --color-border-muted: hsla(210, 18%, 87%, 1);
+        --color-neutral-muted: rgba(175, 184, 193, 0.2);
+        --color-accent-fg: #0969da;
+        --color-accent-emphasis: #0969da;
+        --color-attention-subtle: #fff8c5;
+        --color-danger-fg: #cf222e;
+    }
+}
+
+.markdown-body {
+    -ms-text-size-adjust: 100%;
+    -webkit-text-size-adjust: 100%;
+    margin: 0;
+    color: var(--color-fg-default);
+    background-color: var(--color-canvas-default);
+    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif,
+        'Apple Color Emoji', 'Segoe UI Emoji';
+    font-size: 16px;
+    line-height: 1.5;
+    word-wrap: break-word;
+}
+
+.markdown-body .octicon {
+    display: inline-block;
+    fill: currentColor;
+    vertical-align: text-bottom;
+}
+
+.markdown-body h1:hover .anchor .octicon-link:before,
+.markdown-body h2:hover .anchor .octicon-link:before,
+.markdown-body h3:hover .anchor .octicon-link:before,
+.markdown-body h4:hover .anchor .octicon-link:before,
+.markdown-body h5:hover .anchor .octicon-link:before,
+.markdown-body h6:hover .anchor .octicon-link:before {
+    width: 16px;
+    height: 16px;
+    content: ' ';
+    display: inline-block;
+    background-color: currentColor;
+    -webkit-mask-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' aria-hidden='true'><path fill-rule='evenodd' d='M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z'></path></svg>");
+    mask-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' aria-hidden='true'><path fill-rule='evenodd' d='M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z'></path></svg>");
+}
+
+.markdown-body details,
+.markdown-body figcaption,
+.markdown-body figure {
+    display: block;
+}
+
+.markdown-body summary {
+    display: list-item;
+}
+
+.markdown-body [hidden] {
+    display: none !important;
+}
+
+.markdown-body a {
+    background-color: transparent;
+    color: var(--color-accent-fg);
+    text-decoration: none;
+}
+
+.markdown-body abbr[title] {
+    border-bottom: none;
+    text-decoration: underline dotted;
+}
+
+.markdown-body b,
+.markdown-body strong {
+    font-weight: var(--base-text-weight-semibold, 600);
+}
+
+.markdown-body dfn {
+    font-style: italic;
+}
+
+.markdown-body h1 {
+    margin: 0.67em 0;
+    font-weight: var(--base-text-weight-semibold, 600);
+    padding-bottom: 0.3em;
+    font-size: 2em;
+    border-bottom: 1px solid var(--color-border-muted);
+}
+
+.markdown-body mark {
+    background-color: var(--color-attention-subtle);
+    color: var(--color-fg-default);
+}
+
+.markdown-body small {
+    font-size: 90%;
+}
+
+.markdown-body sub,
+.markdown-body sup {
+    font-size: 75%;
+    line-height: 0;
+    position: relative;
+    vertical-align: baseline;
+}
+
+.markdown-body sub {
+    bottom: -0.25em;
+}
+
+.markdown-body sup {
+    top: -0.5em;
+}
+
+.markdown-body img {
+    border-style: none;
+    max-width: 100%;
+    box-sizing: content-box;
+    background-color: var(--color-canvas-default);
+}
+
+.markdown-body code,
+.markdown-body kbd,
+.markdown-body pre,
+.markdown-body samp {
+    font-family: monospace;
+    font-size: 1em;
+}
+
+.markdown-body figure {
+    margin: 1em 40px;
+}
+
+.markdown-body hr {
+    box-sizing: content-box;
+    overflow: hidden;
+    background: transparent;
+    border-bottom: 1px solid var(--color-border-muted);
+    height: 0.25em;
+    padding: 0;
+    margin: 24px 0;
+    background-color: var(--color-border-default);
+    border: 0;
+}
+
+.markdown-body input {
+    font: inherit;
+    margin: 0;
+    overflow: visible;
+    font-family: inherit;
+    font-size: inherit;
+    line-height: inherit;
+}
+
+.markdown-body [type='button'],
+.markdown-body [type='reset'],
+.markdown-body [type='submit'] {
+    -webkit-appearance: button;
+}
+
+.markdown-body [type='checkbox'],
+.markdown-body [type='radio'] {
+    box-sizing: border-box;
+    padding: 0;
+}
+
+.markdown-body [type='number']::-webkit-inner-spin-button,
+.markdown-body [type='number']::-webkit-outer-spin-button {
+    height: auto;
+}
+
+.markdown-body [type='search']::-webkit-search-cancel-button,
+.markdown-body [type='search']::-webkit-search-decoration {
+    -webkit-appearance: none;
+}
+
+.markdown-body ::-webkit-input-placeholder {
+    color: inherit;
+    opacity: 0.54;
+}
+
+.markdown-body ::-webkit-file-upload-button {
+    -webkit-appearance: button;
+    font: inherit;
+}
+
+.markdown-body a:hover {
+    text-decoration: underline;
+}
+
+.markdown-body ::placeholder {
+    color: var(--color-fg-subtle);
+    opacity: 1;
+}
+
+.markdown-body hr::before {
+    display: table;
+    content: '';
+}
+
+.markdown-body hr::after {
+    display: table;
+    clear: both;
+    content: '';
+}
+
+.markdown-body table {
+    border-spacing: 0;
+    border-collapse: collapse;
+    display: block;
+    width: max-content;
+    max-width: 100%;
+    overflow: auto;
+}
+
+.markdown-body td,
+.markdown-body th {
+    padding: 0;
+}
+
+.markdown-body details summary {
+    cursor: pointer;
+}
+
+.markdown-body details:not([open]) > *:not(summary) {
+    display: none !important;
+}
+
+.markdown-body a:focus,
+.markdown-body [role='button']:focus,
+.markdown-body input[type='radio']:focus,
+.markdown-body input[type='checkbox']:focus {
+    outline: 2px solid var(--color-accent-fg);
+    outline-offset: -2px;
+    box-shadow: none;
+}
+
+.markdown-body a:focus:not(:focus-visible),
+.markdown-body [role='button']:focus:not(:focus-visible),
+.markdown-body input[type='radio']:focus:not(:focus-visible),
+.markdown-body input[type='checkbox']:focus:not(:focus-visible) {
+    outline: solid 1px transparent;
+}
+
+.markdown-body a:focus-visible,
+.markdown-body [role='button']:focus-visible,
+.markdown-body input[type='radio']:focus-visible,
+.markdown-body input[type='checkbox']:focus-visible {
+    outline: 2px solid var(--color-accent-fg);
+    outline-offset: -2px;
+    box-shadow: none;
+}
+
+.markdown-body a:not([class]):focus,
+.markdown-body a:not([class]):focus-visible,
+.markdown-body input[type='radio']:focus,
+.markdown-body input[type='radio']:focus-visible,
+.markdown-body input[type='checkbox']:focus,
+.markdown-body input[type='checkbox']:focus-visible {
+    outline-offset: 0;
+}
+
+.markdown-body kbd {
+    display: inline-block;
+    padding: 3px 5px;
+    font: 11px ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
+    line-height: 10px;
+    color: var(--color-fg-default);
+    vertical-align: middle;
+    background-color: var(--color-canvas-subtle);
+    border: solid 1px var(--color-neutral-muted);
+    border-bottom-color: var(--color-neutral-muted);
+    border-radius: 6px;
+    box-shadow: inset 0 -1px 0 var(--color-neutral-muted);
+}
+
+.markdown-body h1,
+.markdown-body h2,
+.markdown-body h3,
+.markdown-body h4,
+.markdown-body h5,
+.markdown-body h6 {
+    margin-top: 24px;
+    margin-bottom: 16px;
+    font-weight: var(--base-text-weight-semibold, 600);
+    line-height: 1.25;
+}
+
+.markdown-body h2 {
+    font-weight: var(--base-text-weight-semibold, 600);
+    padding-bottom: 0.3em;
+    font-size: 1.5em;
+    border-bottom: 1px solid var(--color-border-muted);
+}
+
+.markdown-body h3 {
+    font-weight: var(--base-text-weight-semibold, 600);
+    font-size: 1.25em;
+}
+
+.markdown-body h4 {
+    font-weight: var(--base-text-weight-semibold, 600);
+    font-size: 1em;
+}
+
+.markdown-body h5 {
+    font-weight: var(--base-text-weight-semibold, 600);
+    font-size: 0.875em;
+}
+
+.markdown-body h6 {
+    font-weight: var(--base-text-weight-semibold, 600);
+    font-size: 0.85em;
+    color: var(--color-fg-muted);
+}
+
+.markdown-body p {
+    margin-top: 0;
+    margin-bottom: 10px;
+}
+
+.markdown-body blockquote {
+    margin: 0;
+    padding: 0 1em;
+    color: var(--color-fg-muted);
+    border-left: 0.25em solid var(--color-border-default);
+}
+
+.markdown-body ul,
+.markdown-body ol {
+    margin-top: 0;
+    margin-bottom: 0;
+    padding-left: 2em;
+}
+
+.markdown-body ol ol,
+.markdown-body ul ol {
+    list-style-type: lower-roman;
+}
+
+.markdown-body ul ul ol,
+.markdown-body ul ol ol,
+.markdown-body ol ul ol,
+.markdown-body ol ol ol {
+    list-style-type: lower-alpha;
+}
+
+.markdown-body dd {
+    margin-left: 0;
+}
+
+.markdown-body tt,
+.markdown-body code,
+.markdown-body samp {
+    font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
+    font-size: 12px;
+}
+
+.markdown-body pre {
+    margin-top: 0;
+    margin-bottom: 0;
+    font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
+    font-size: 12px;
+    word-wrap: normal;
+}
+
+.markdown-body .octicon {
+    display: inline-block;
+    overflow: visible !important;
+    vertical-align: text-bottom;
+    fill: currentColor;
+}
+
+.markdown-body input::-webkit-outer-spin-button,
+.markdown-body input::-webkit-inner-spin-button {
+    margin: 0;
+    -webkit-appearance: none;
+    appearance: none;
+}
+
+.markdown-body::before {
+    display: table;
+    content: '';
+}
+
+.markdown-body::after {
+    display: table;
+    clear: both;
+    content: '';
+}
+
+.markdown-body > *:first-child {
+    margin-top: 0 !important;
+}
+
+.markdown-body > *:last-child {
+    margin-bottom: 0 !important;
+}
+
+.markdown-body a:not([href]) {
+    color: inherit;
+    text-decoration: none;
+}
+
+.markdown-body .absent {
+    color: var(--color-danger-fg);
+}
+
+.markdown-body .anchor {
+    float: left;
+    padding-right: 4px;
+    margin-left: -20px;
+    line-height: 1;
+}
+
+.markdown-body .anchor:focus {
+    outline: none;
+}
+
+.markdown-body p,
+.markdown-body blockquote,
+.markdown-body ul,
+.markdown-body ol,
+.markdown-body dl,
+.markdown-body table,
+.markdown-body pre,
+.markdown-body details {
+    margin-top: 0;
+    margin-bottom: 16px;
+}
+
+.markdown-body blockquote > :first-child {
+    margin-top: 0;
+}
+
+.markdown-body blockquote > :last-child {
+    margin-bottom: 0;
+}
+
+.markdown-body h1 .octicon-link,
+.markdown-body h2 .octicon-link,
+.markdown-body h3 .octicon-link,
+.markdown-body h4 .octicon-link,
+.markdown-body h5 .octicon-link,
+.markdown-body h6 .octicon-link {
+    color: var(--color-fg-default);
+    vertical-align: middle;
+    visibility: hidden;
+}
+
+.markdown-body h1:hover .anchor,
+.markdown-body h2:hover .anchor,
+.markdown-body h3:hover .anchor,
+.markdown-body h4:hover .anchor,
+.markdown-body h5:hover .anchor,
+.markdown-body h6:hover .anchor {
+    text-decoration: none;
+}
+
+.markdown-body h1:hover .anchor .octicon-link,
+.markdown-body h2:hover .anchor .octicon-link,
+.markdown-body h3:hover .anchor .octicon-link,
+.markdown-body h4:hover .anchor .octicon-link,
+.markdown-body h5:hover .anchor .octicon-link,
+.markdown-body h6:hover .anchor .octicon-link {
+    visibility: visible;
+}
+
+.markdown-body h1 tt,
+.markdown-body h1 code,
+.markdown-body h2 tt,
+.markdown-body h2 code,
+.markdown-body h3 tt,
+.markdown-body h3 code,
+.markdown-body h4 tt,
+.markdown-body h4 code,
+.markdown-body h5 tt,
+.markdown-body h5 code,
+.markdown-body h6 tt,
+.markdown-body h6 code {
+    padding: 0 0.2em;
+    font-size: inherit;
+}
+
+.markdown-body summary h1,
+.markdown-body summary h2,
+.markdown-body summary h3,
+.markdown-body summary h4,
+.markdown-body summary h5,
+.markdown-body summary h6 {
+    display: inline-block;
+}
+
+.markdown-body summary h1 .anchor,
+.markdown-body summary h2 .anchor,
+.markdown-body summary h3 .anchor,
+.markdown-body summary h4 .anchor,
+.markdown-body summary h5 .anchor,
+.markdown-body summary h6 .anchor {
+    margin-left: -40px;
+}
+
+.markdown-body summary h1,
+.markdown-body summary h2 {
+    padding-bottom: 0;
+    border-bottom: 0;
+}
+
+.markdown-body ul.no-list,
+.markdown-body ol.no-list {
+    padding: 0;
+    list-style-type: none;
+}
+
+.markdown-body ol[type='a'] {
+    list-style-type: lower-alpha;
+}
+
+.markdown-body ol[type='A'] {
+    list-style-type: upper-alpha;
+}
+
+.markdown-body ol[type='i'] {
+    list-style-type: lower-roman;
+}
+
+.markdown-body ol[type='I'] {
+    list-style-type: upper-roman;
+}
+
+.markdown-body ol[type='1'] {
+    list-style-type: decimal;
+}
+
+.markdown-body div > ol:not([type]) {
+    list-style-type: decimal;
+}
+
+.markdown-body ul ul,
+.markdown-body ul ol,
+.markdown-body ol ol,
+.markdown-body ol ul {
+    margin-top: 0;
+    margin-bottom: 0;
+}
+
+.markdown-body li > p {
+    margin-top: 16px;
+}
+
+.markdown-body li + li {
+    margin-top: 0.25em;
+}
+
+.markdown-body dl {
+    padding: 0;
+}
+
+.markdown-body dl dt {
+    padding: 0;
+    margin-top: 16px;
+    font-size: 1em;
+    font-style: italic;
+    font-weight: var(--base-text-weight-semibold, 600);
+}
+
+.markdown-body dl dd {
+    padding: 0 16px;
+    margin-bottom: 16px;
+}
+
+.markdown-body table th {
+    font-weight: var(--base-text-weight-semibold, 600);
+}
+
+.markdown-body table th,
+.markdown-body table td {
+    padding: 6px 13px;
+    border: 1px solid var(--color-border-default);
+}
+
+.markdown-body table tr {
+    background-color: var(--color-canvas-default);
+    border-top: 1px solid var(--color-border-muted);
+}
+
+.markdown-body table tr:nth-child(2n) {
+    background-color: var(--color-canvas-subtle);
+}
+
+.markdown-body table img {
+    background-color: transparent;
+}
+
+.markdown-body img[align='right'] {
+    padding-left: 20px;
+}
+
+.markdown-body img[align='left'] {
+    padding-right: 20px;
+}
+
+.markdown-body .emoji {
+    max-width: none;
+    vertical-align: text-top;
+    background-color: transparent;
+}
+
+.markdown-body span.frame {
+    display: block;
+    overflow: hidden;
+}
+
+.markdown-body span.frame > span {
+    display: block;
+    float: left;
+    width: auto;
+    padding: 7px;
+    margin: 13px 0 0;
+    overflow: hidden;
+    border: 1px solid var(--color-border-default);
+}
+
+.markdown-body span.frame span img {
+    display: block;
+    float: left;
+}
+
+.markdown-body span.frame span span {
+    display: block;
+    padding: 5px 0 0;
+    clear: both;
+    color: var(--color-fg-default);
+}
+
+.markdown-body span.align-center {
+    display: block;
+    overflow: hidden;
+    clear: both;
+}
+
+.markdown-body span.align-center > span {
+    display: block;
+    margin: 13px auto 0;
+    overflow: hidden;
+    text-align: center;
+}
+
+.markdown-body span.align-center span img {
+    margin: 0 auto;
+    text-align: center;
+}
+
+.markdown-body span.align-right {
+    display: block;
+    overflow: hidden;
+    clear: both;
+}
+
+.markdown-body span.align-right > span {
+    display: block;
+    margin: 13px 0 0;
+    overflow: hidden;
+    text-align: right;
+}
+
+.markdown-body span.align-right span img {
+    margin: 0;
+    text-align: right;
+}
+
+.markdown-body span.float-left {
+    display: block;
+    float: left;
+    margin-right: 13px;
+    overflow: hidden;
+}
+
+.markdown-body span.float-left span {
+    margin: 13px 0 0;
+}
+
+.markdown-body span.float-right {
+    display: block;
+    float: right;
+    margin-left: 13px;
+    overflow: hidden;
+}
+
+.markdown-body span.float-right > span {
+    display: block;
+    margin: 13px auto 0;
+    overflow: hidden;
+    text-align: right;
+}
+
+.markdown-body code,
+.markdown-body tt {
+    padding: 0.2em 0.4em;
+    margin: 0;
+    font-size: 85%;
+    white-space: break-spaces;
+    background-color: var(--color-neutral-muted);
+    border-radius: 6px;
+}
+
+.markdown-body code br,
+.markdown-body tt br {
+    display: none;
+}
+
+.markdown-body del code {
+    text-decoration: inherit;
+}
+
+.markdown-body samp {
+    font-size: 85%;
+}
+
+.markdown-body pre code {
+    font-size: 100%;
+}
+
+.markdown-body pre > code {
+    padding: 0;
+    margin: 0;
+    word-break: normal;
+    white-space: pre;
+    background: transparent;
+    border: 0;
+}
+
+.markdown-body .highlight {
+    margin-bottom: 16px;
+}
+
+.markdown-body .highlight pre {
+    margin-bottom: 0;
+    word-break: normal;
+}
+
+.markdown-body .highlight pre,
+.markdown-body pre {
+    padding: 16px;
+    overflow: auto;
+    font-size: 85%;
+    line-height: 1.45;
+    background-color: var(--color-canvas-subtle);
+    border-radius: 6px;
+}
+
+.markdown-body pre code,
+.markdown-body pre tt {
+    display: inline;
+    max-width: auto;
+    padding: 0;
+    margin: 0;
+    overflow: visible;
+    line-height: inherit;
+    word-wrap: normal;
+    background-color: transparent;
+    border: 0;
+}
+
+.markdown-body .csv-data td,
+.markdown-body .csv-data th {
+    padding: 5px;
+    overflow: hidden;
+    font-size: 12px;
+    line-height: 1;
+    text-align: left;
+    white-space: nowrap;
+}
+
+.markdown-body .csv-data .blob-num {
+    padding: 10px 8px 9px;
+    text-align: right;
+    background: var(--color-canvas-default);
+    border: 0;
+}
+
+.markdown-body .csv-data tr {
+    border-top: 0;
+}
+
+.markdown-body .csv-data th {
+    font-weight: var(--base-text-weight-semibold, 600);
+    background: var(--color-canvas-subtle);
+    border-top: 0;
+}
+
+.markdown-body [data-footnote-ref]::before {
+    content: '[';
+}
+
+.markdown-body [data-footnote-ref]::after {
+    content: ']';
+}
+
+.markdown-body .footnotes {
+    font-size: 12px;
+    color: var(--color-fg-muted);
+    border-top: 1px solid var(--color-border-default);
+}
+
+.markdown-body .footnotes ol {
+    padding-left: 16px;
+}
+
+.markdown-body .footnotes ol ul {
+    display: inline-block;
+    padding-left: 16px;
+    margin-top: 16px;
+}
+
+.markdown-body .footnotes li {
+    position: relative;
+}
+
+.markdown-body .footnotes li:target::before {
+    position: absolute;
+    top: -8px;
+    right: -8px;
+    bottom: -8px;
+    left: -24px;
+    pointer-events: none;
+    content: '';
+    border: 2px solid var(--color-accent-emphasis);
+    border-radius: 6px;
+}
+
+.markdown-body .footnotes li:target {
+    color: var(--color-fg-default);
+}
+
+.markdown-body .footnotes .data-footnote-backref g-emoji {
+    font-family: monospace;
+}
+
+.markdown-body .pl-c {
+    color: var(--color-prettylights-syntax-comment);
+}
+
+.markdown-body .pl-c1,
+.markdown-body .pl-s .pl-v {
+    color: var(--color-prettylights-syntax-constant);
+}
+
+.markdown-body .pl-e,
+.markdown-body .pl-en {
+    color: var(--color-prettylights-syntax-entity);
+}
+
+.markdown-body .pl-smi,
+.markdown-body .pl-s .pl-s1 {
+    color: var(--color-prettylights-syntax-storage-modifier-import);
+}
+
+.markdown-body .pl-ent {
+    color: var(--color-prettylights-syntax-entity-tag);
+}
+
+.markdown-body .pl-k {
+    color: var(--color-prettylights-syntax-keyword);
+}
+
+.markdown-body .pl-s,
+.markdown-body .pl-pds,
+.markdown-body .pl-s .pl-pse .pl-s1,
+.markdown-body .pl-sr,
+.markdown-body .pl-sr .pl-cce,
+.markdown-body .pl-sr .pl-sre,
+.markdown-body .pl-sr .pl-sra {
+    color: var(--color-prettylights-syntax-string);
+}
+
+.markdown-body .pl-v,
+.markdown-body .pl-smw {
+    color: var(--color-prettylights-syntax-variable);
+}
+
+.markdown-body .pl-bu {
+    color: var(--color-prettylights-syntax-brackethighlighter-unmatched);
+}
+
+.markdown-body .pl-ii {
+    color: var(--color-prettylights-syntax-invalid-illegal-text);
+    background-color: var(--color-prettylights-syntax-invalid-illegal-bg);
+}
+
+.markdown-body .pl-c2 {
+    color: var(--color-prettylights-syntax-carriage-return-text);
+    background-color: var(--color-prettylights-syntax-carriage-return-bg);
+}
+
+.markdown-body .pl-sr .pl-cce {
+    font-weight: bold;
+    color: var(--color-prettylights-syntax-string-regexp);
+}
+
+.markdown-body .pl-ml {
+    color: var(--color-prettylights-syntax-markup-list);
+}
+
+.markdown-body .pl-mh,
+.markdown-body .pl-mh .pl-en,
+.markdown-body .pl-ms {
+    font-weight: bold;
+    color: var(--color-prettylights-syntax-markup-heading);
+}
+
+.markdown-body .pl-mi {
+    font-style: italic;
+    color: var(--color-prettylights-syntax-markup-italic);
+}
+
+.markdown-body .pl-mb {
+    font-weight: bold;
+    color: var(--color-prettylights-syntax-markup-bold);
+}
+
+.markdown-body .pl-md {
+    color: var(--color-prettylights-syntax-markup-deleted-text);
+    background-color: var(--color-prettylights-syntax-markup-deleted-bg);
+}
+
+.markdown-body .pl-mi1 {
+    color: var(--color-prettylights-syntax-markup-inserted-text);
+    background-color: var(--color-prettylights-syntax-markup-inserted-bg);
+}
+
+.markdown-body .pl-mc {
+    color: var(--color-prettylights-syntax-markup-changed-text);
+    background-color: var(--color-prettylights-syntax-markup-changed-bg);
+}
+
+.markdown-body .pl-mi2 {
+    color: var(--color-prettylights-syntax-markup-ignored-text);
+    background-color: var(--color-prettylights-syntax-markup-ignored-bg);
+}
+
+.markdown-body .pl-mdr {
+    font-weight: bold;
+    color: var(--color-prettylights-syntax-meta-diff-range);
+}
+
+.markdown-body .pl-ba {
+    color: var(--color-prettylights-syntax-brackethighlighter-angle);
+}
+
+.markdown-body .pl-sg {
+    color: var(--color-prettylights-syntax-sublimelinter-gutter-mark);
+}
+
+.markdown-body .pl-corl {
+    text-decoration: underline;
+    color: var(--color-prettylights-syntax-constant-other-reference-link);
+}
+
+.markdown-body g-emoji {
+    display: inline-block;
+    min-width: 1ch;
+    font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
+    font-size: 1em;
+    font-style: normal !important;
+    font-weight: var(--base-text-weight-normal, 400);
+    line-height: 1;
+    vertical-align: -0.075em;
+}
+
+.markdown-body g-emoji img {
+    width: 1em;
+    height: 1em;
+}
+
+.markdown-body .task-list-item {
+    list-style-type: none;
+}
+
+.markdown-body .task-list-item label {
+    font-weight: var(--base-text-weight-normal, 400);
+}
+
+.markdown-body .task-list-item.enabled label {
+    cursor: pointer;
+}
+
+.markdown-body .task-list-item + .task-list-item {
+    margin-top: 4px;
+}
+
+.markdown-body .task-list-item .handle {
+    display: none;
+}
+
+.markdown-body .task-list-item-checkbox {
+    margin: 0 0.2em 0.25em -1.4em;
+    vertical-align: middle;
+}
+
+.markdown-body .contains-task-list:dir(rtl) .task-list-item-checkbox {
+    margin: 0 -1.6em 0.25em 0.2em;
+}
+
+.markdown-body .contains-task-list {
+    position: relative;
+}
+
+.markdown-body .contains-task-list:hover .task-list-item-convert-container,
+.markdown-body .contains-task-list:focus-within .task-list-item-convert-container {
+    display: block;
+    width: auto;
+    height: 24px;
+    overflow: visible;
+    clip: auto;
+}
+
+.markdown-body ::-webkit-calendar-picker-indicator {
+    filter: invert(50%);
+}

+ 206 - 0
src/styles/lib/highlight.less

@@ -0,0 +1,206 @@
+html.dark {
+    pre code.hljs {
+        display: block;
+        overflow-x: auto;
+        padding: 1em;
+    }
+
+    code.hljs {
+        padding: 3px 5px;
+    }
+
+    .hljs {
+        color: #abb2bf;
+        background: #282c34;
+    }
+
+    .hljs-keyword,
+    .hljs-operator,
+    .hljs-pattern-match {
+        color: #f92672;
+    }
+
+    .hljs-function,
+    .hljs-pattern-match .hljs-constructor {
+        color: #61aeee;
+    }
+
+    .hljs-function .hljs-params {
+        color: #a6e22e;
+    }
+
+    .hljs-function .hljs-params .hljs-typing {
+        color: #fd971f;
+    }
+
+    .hljs-module-access .hljs-module {
+        color: #7e57c2;
+    }
+
+    .hljs-constructor {
+        color: #e2b93d;
+    }
+
+    .hljs-constructor .hljs-string {
+        color: #9ccc65;
+    }
+
+    .hljs-comment,
+    .hljs-quote {
+        color: #b18eb1;
+        font-style: italic;
+    }
+
+    .hljs-doctag,
+    .hljs-formula {
+        color: #c678dd;
+    }
+
+    .hljs-deletion,
+    .hljs-name,
+    .hljs-section,
+    .hljs-selector-tag,
+    .hljs-subst {
+        color: #e06c75;
+    }
+
+    .hljs-literal {
+        color: #56b6c2;
+    }
+
+    .hljs-addition,
+    .hljs-attribute,
+    .hljs-meta .hljs-string,
+    .hljs-regexp,
+    .hljs-string {
+        color: #98c379;
+    }
+
+    .hljs-built_in,
+    .hljs-class .hljs-title,
+    .hljs-title.class_ {
+        color: #e6c07b;
+    }
+
+    .hljs-attr,
+    .hljs-number,
+    .hljs-selector-attr,
+    .hljs-selector-class,
+    .hljs-selector-pseudo,
+    .hljs-template-variable,
+    .hljs-type,
+    .hljs-variable {
+        color: #d19a66;
+    }
+
+    .hljs-bullet,
+    .hljs-link,
+    .hljs-meta,
+    .hljs-selector-id,
+    .hljs-symbol,
+    .hljs-title {
+        color: #61aeee;
+    }
+
+    .hljs-emphasis {
+        font-style: italic;
+    }
+
+    .hljs-strong {
+        font-weight: 700;
+    }
+
+    .hljs-link {
+        text-decoration: underline;
+    }
+}
+
+html {
+    pre code.hljs {
+        display: block;
+        overflow-x: auto;
+        padding: 1em;
+    }
+
+    code.hljs {
+        padding: 3px 5px;
+        &::-webkit-scrollbar {
+            height: 4px;
+        }
+    }
+
+    .hljs {
+        color: #383a42;
+        background: #fafafa;
+    }
+
+    .hljs-comment,
+    .hljs-quote {
+        color: #a0a1a7;
+        font-style: italic;
+    }
+
+    .hljs-doctag,
+    .hljs-formula,
+    .hljs-keyword {
+        color: #a626a4;
+    }
+
+    .hljs-deletion,
+    .hljs-name,
+    .hljs-section,
+    .hljs-selector-tag,
+    .hljs-subst {
+        color: #e45649;
+    }
+
+    .hljs-literal {
+        color: #0184bb;
+    }
+
+    .hljs-addition,
+    .hljs-attribute,
+    .hljs-meta .hljs-string,
+    .hljs-regexp,
+    .hljs-string {
+        color: #50a14f;
+    }
+
+    .hljs-attr,
+    .hljs-number,
+    .hljs-selector-attr,
+    .hljs-selector-class,
+    .hljs-selector-pseudo,
+    .hljs-template-variable,
+    .hljs-type,
+    .hljs-variable {
+        color: #986801;
+    }
+
+    .hljs-bullet,
+    .hljs-link,
+    .hljs-meta,
+    .hljs-selector-id,
+    .hljs-symbol,
+    .hljs-title {
+        color: #4078f2;
+    }
+
+    .hljs-built_in,
+    .hljs-class .hljs-title,
+    .hljs-title.class_ {
+        color: #c18401;
+    }
+
+    .hljs-emphasis {
+        font-style: italic;
+    }
+
+    .hljs-strong {
+        font-weight: 700;
+    }
+
+    .hljs-link {
+        text-decoration: underline;
+    }
+}

+ 3 - 0
src/styles/lib/tailwind.css

@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;

+ 45 - 0
src/typings/chat.d.ts

@@ -0,0 +1,45 @@
+declare namespace Chat {
+    interface Chat {
+        dateTime: string
+        text: string
+        inversion?: boolean
+        error?: boolean
+        loading?: boolean
+        conversationOptions?: ConversationRequest | null
+        requestOptions: { prompt: string; options?: ConversationRequest | null }
+    }
+
+    interface History {
+        title: string
+        isEdit: boolean
+        uuid: number
+    }
+
+    interface ChatState {
+        active: number | null
+        usingContext: boolean
+        history: History[]
+        chat: { uuid: number; data: Chat[] }[]
+    }
+
+    interface ConversationRequest {
+        conversationId?: string
+        parentMessageId?: string
+    }
+
+    interface ConversationResponse {
+        conversationId: string
+        detail: {
+            choices: { finish_reason: string; index: number; logprobs: any; text: string }[]
+            created: number
+            id: string
+            model: string
+            object: string
+            usage: { completion_tokens: number; prompt_tokens: number; total_tokens: number }
+        }
+        id: string
+        parentMessageId: string
+        role: string
+        text: string
+    }
+}

+ 8 - 0
src/typings/env.d.ts

@@ -0,0 +1,8 @@
+/// <reference types="vite/client" />
+
+interface ImportMetaEnv {
+    readonly VITE_GLOB_API_URL: string
+    readonly VITE_APP_API_BASE_URL: string
+    readonly VITE_GLOB_OPEN_LONG_REPLY: string
+    readonly VITE_GLOB_APP_PWA: string
+}

+ 6 - 0
src/typings/global.d.ts

@@ -0,0 +1,6 @@
+interface Window {
+    $loadingBar?: import('naive-ui').LoadingBarProviderInst
+    $dialog?: import('naive-ui').DialogProviderInst
+    $message?: import('naive-ui').MessageProviderInst
+    $notification?: import('naive-ui').NotificationProviderInst
+}

+ 17 - 0
src/utils/crypto/index.ts

@@ -0,0 +1,17 @@
+import CryptoJS from 'crypto-js'
+
+const CryptoSecret = '__CRYPTO_SECRET__'
+
+export function enCrypto(data: any) {
+    const str = JSON.stringify(data)
+    return CryptoJS.AES.encrypt(str, CryptoSecret).toString()
+}
+
+export function deCrypto(data: string) {
+    const bytes = CryptoJS.AES.decrypt(data, CryptoSecret)
+    const str = bytes.toString(CryptoJS.enc.Utf8)
+
+    if (str) return JSON.parse(str)
+
+    return null
+}

+ 41 - 0
src/utils/format/index.ts

@@ -0,0 +1,41 @@
+/**
+ * 转义 HTML 字符
+ * @param source
+ */
+export function encodeHTML(source: string) {
+    return source
+        .replace(/&/g, '&amp;')
+        .replace(/</g, '&lt;')
+        .replace(/>/g, '&gt;')
+        .replace(/"/g, '&quot;')
+        .replace(/'/g, '&#39;')
+}
+
+/**
+ * 判断是否为代码块
+ * @param text
+ */
+export function includeCode(text: string | null | undefined) {
+    const regexp = /^(?:\s{4}|\t).+/gm
+    return !!(text?.includes(' = ') || text?.match(regexp))
+}
+
+/**
+ * 复制文本
+ * @param options
+ */
+export function copyText(options: { text: string; origin?: boolean }) {
+    const props = { origin: true, ...options }
+
+    let input: HTMLInputElement | HTMLTextAreaElement
+
+    if (props.origin) input = document.createElement('textarea')
+    else input = document.createElement('input')
+
+    input.setAttribute('readonly', 'readonly')
+    input.value = props.text
+    document.body.appendChild(input)
+    input.select()
+    if (document.execCommand('copy')) document.execCommand('copy')
+    document.body.removeChild(input)
+}

+ 15 - 0
src/utils/functions/debounce.ts

@@ -0,0 +1,15 @@
+type CallbackFunc<T extends unknown[]> = (...args: T) => void
+
+export function debounce<T extends unknown[]>(func: CallbackFunc<T>, wait: number): (...args: T) => void {
+    let timeoutId: ReturnType<typeof setTimeout> | undefined
+
+    return (...args: T) => {
+        const later = () => {
+            clearTimeout(timeoutId)
+            func(...args)
+        }
+
+        clearTimeout(timeoutId)
+        timeoutId = setTimeout(later, wait)
+    }
+}

+ 7 - 0
src/utils/functions/index.ts

@@ -0,0 +1,7 @@
+export function getCurrentDate() {
+    const date = new Date()
+    const day = date.getDate()
+    const month = date.getMonth() + 1
+    const year = date.getFullYear()
+    return `${year}-${month}-${day}`
+}

+ 55 - 0
src/utils/is/index.ts

@@ -0,0 +1,55 @@
+export function isNumber<T extends number>(value: T | unknown): value is number {
+    return Object.prototype.toString.call(value) === '[object Number]'
+}
+
+export function isString<T extends string>(value: T | unknown): value is string {
+    return Object.prototype.toString.call(value) === '[object String]'
+}
+
+export function isBoolean<T extends boolean>(value: T | unknown): value is boolean {
+    return Object.prototype.toString.call(value) === '[object Boolean]'
+}
+
+export function isNull<T extends null>(value: T | unknown): value is null {
+    return Object.prototype.toString.call(value) === '[object Null]'
+}
+
+export function isUndefined<T extends undefined>(value: T | unknown): value is undefined {
+    return Object.prototype.toString.call(value) === '[object Undefined]'
+}
+
+export function isObject<T extends object>(value: T | unknown): value is object {
+    return Object.prototype.toString.call(value) === '[object Object]'
+}
+
+export function isArray<T extends any[]>(value: T | unknown): value is T {
+    return Object.prototype.toString.call(value) === '[object Array]'
+}
+
+export function isFunction<T extends (...args: any[]) => any | void | never>(value: T | unknown): value is T {
+    return Object.prototype.toString.call(value) === '[object Function]'
+}
+
+export function isDate<T extends Date>(value: T | unknown): value is T {
+    return Object.prototype.toString.call(value) === '[object Date]'
+}
+
+export function isRegExp<T extends RegExp>(value: T | unknown): value is T {
+    return Object.prototype.toString.call(value) === '[object RegExp]'
+}
+
+export function isPromise<T extends Promise<any>>(value: T | unknown): value is T {
+    return Object.prototype.toString.call(value) === '[object Promise]'
+}
+
+export function isSet<T extends Set<any>>(value: T | unknown): value is T {
+    return Object.prototype.toString.call(value) === '[object Set]'
+}
+
+export function isMap<T extends Map<any, any>>(value: T | unknown): value is T {
+    return Object.prototype.toString.call(value) === '[object Map]'
+}
+
+export function isFile<T extends File>(value: T | unknown): value is T {
+    return Object.prototype.toString.call(value) === '[object File]'
+}

+ 30 - 0
src/utils/request/axios.ts

@@ -0,0 +1,30 @@
+import axios, { type AxiosResponse } from 'axios'
+import { useAuthStore } from '@/store'
+
+const service = axios.create({
+    baseURL: import.meta.env.VITE_GLOB_API_URL
+})
+
+service.interceptors.request.use(
+    config => {
+        const token = useAuthStore().token
+        if (token) config.headers.Authorization = `Bearer ${token}`
+        return config
+    },
+    error => {
+        return Promise.reject(error.response)
+    }
+)
+
+service.interceptors.response.use(
+    (response: AxiosResponse): AxiosResponse => {
+        if (response.status === 200) return response
+
+        throw new Error(response.status.toString())
+    },
+    error => {
+        return Promise.reject(error)
+    }
+)
+
+export default service

+ 103 - 0
src/utils/request/index.ts

@@ -0,0 +1,103 @@
+import type { AxiosProgressEvent, AxiosResponse, GenericAbortSignal } from 'axios'
+import request from './axios'
+import { useAuthStore } from '@/store'
+
+export interface HttpOption {
+    url: string
+    data?: any
+    method?: string
+    headers?: any
+    onDownloadProgress?: (progressEvent: AxiosProgressEvent) => void
+    signal?: GenericAbortSignal
+    beforeRequest?: () => void
+    afterRequest?: () => void
+}
+
+export interface Response<T = any> {
+    data: T
+    message: string | null
+    status: string
+}
+
+function http<T = any>({
+    url,
+    data,
+    method,
+    headers,
+    onDownloadProgress,
+    signal,
+    beforeRequest,
+    afterRequest
+}: HttpOption) {
+    const successHandler = (res: AxiosResponse<Response<T>>) => {
+        const authStore = useAuthStore()
+
+        if (res.data.status === 'Success' || typeof res.data === 'string') return res.data
+
+        if (res.data.status === 'Unauthorized') {
+            authStore.removeToken()
+            window.location.reload()
+        }
+
+        return Promise.reject(res.data)
+    }
+
+    const failHandler = (error: Response<Error>) => {
+        afterRequest?.()
+        throw new Error(error?.message || 'Error')
+    }
+
+    beforeRequest?.()
+
+    method = method || 'GET'
+
+    const params = Object.assign(typeof data === 'function' ? data() : data ?? {}, {})
+
+    return method === 'GET'
+        ? request.get(url, { params, signal, onDownloadProgress }).then(successHandler, failHandler)
+        : request.post(url, params, { headers, signal, onDownloadProgress }).then(successHandler, failHandler)
+}
+
+export function get<T = any>({
+    url,
+    data,
+    method = 'GET',
+    onDownloadProgress,
+    signal,
+    beforeRequest,
+    afterRequest
+}: HttpOption): Promise<Response<T>> {
+    return http<T>({
+        url,
+        method,
+        data,
+        onDownloadProgress,
+        signal,
+        beforeRequest,
+        afterRequest
+    })
+}
+
+export function post<T = any>({
+    url,
+    data,
+    method = 'POST',
+    headers,
+    onDownloadProgress,
+    signal,
+    beforeRequest,
+    afterRequest
+}: HttpOption): Promise<Response<T>> {
+    return http<T>({
+        url,
+        method,
+        data,
+        headers,
+        onDownloadProgress,
+        signal,
+        beforeRequest,
+        afterRequest
+    })
+}
+
+export default post

+ 1 - 0
src/utils/storage/index.ts

@@ -0,0 +1 @@
+export * from './local'

+ 68 - 0
src/utils/storage/local.ts

@@ -0,0 +1,68 @@
+import { deCrypto, enCrypto } from '../crypto'
+
+interface StorageData<T = any> {
+    data: T
+    expire: number | null
+}
+
+export function createLocalStorage(options?: { expire?: number | null; crypto?: boolean }) {
+    const DEFAULT_CACHE_TIME = 60 * 60 * 24 * 7
+
+    const { expire, crypto } = Object.assign(
+        {
+            expire: DEFAULT_CACHE_TIME,
+            crypto: true
+        },
+        options
+    )
+
+    function set<T = any>(key: string, data: T) {
+        const storageData: StorageData<T> = {
+            data,
+            expire: expire !== null ? new Date().getTime() + expire * 1000 : null
+        }
+
+        const json = crypto ? enCrypto(storageData) : JSON.stringify(storageData)
+        window.localStorage.setItem(key, json)
+    }
+
+    function get(key: string) {
+        const json = window.localStorage.getItem(key)
+        if (json) {
+            let storageData: StorageData | null = null
+
+            try {
+                storageData = crypto ? deCrypto(json) : JSON.parse(json)
+            } catch {
+                // Prevent failure
+            }
+
+            if (storageData) {
+                const { data, expire } = storageData
+                if (expire === null || expire >= Date.now()) return data
+            }
+
+            remove(key)
+            return null
+        }
+    }
+
+    function remove(key: string) {
+        window.localStorage.removeItem(key)
+    }
+
+    function clear() {
+        window.localStorage.clear()
+    }
+
+    return {
+        set,
+        get,
+        remove,
+        clear
+    }
+}
+
+export const ls = createLocalStorage()
+
+export const ss = createLocalStorage({ expire: null, crypto: false })

+ 74 - 0
src/views/chat/components/Header/index.vue

@@ -0,0 +1,74 @@
+<script lang="ts" setup>
+import { computed, nextTick } from 'vue'
+import { HoverButton, SvgIcon } from '@/components/common'
+import { useAppStore, useChatStore } from '@/store'
+
+interface Props {
+    usingContext: boolean
+}
+
+interface Emit {
+    (ev: 'export'): void
+    (ev: 'toggleUsingContext'): void
+}
+
+defineProps<Props>()
+
+const emit = defineEmits<Emit>()
+
+const appStore = useAppStore()
+const chatStore = useChatStore()
+
+const collapsed = computed(() => appStore.siderCollapsed)
+const currentChatHistory = computed(() => chatStore.getChatHistoryByCurrentActive)
+
+function handleUpdateCollapsed() {
+    appStore.setSiderCollapsed(!collapsed.value)
+}
+
+function onScrollToTop() {
+    const scrollRef = document.querySelector('#scrollRef')
+    if (scrollRef) nextTick(() => (scrollRef.scrollTop = 0))
+}
+
+function handleExport() {
+    emit('export')
+}
+
+function toggleUsingContext() {
+    emit('toggleUsingContext')
+}
+</script>
+
+<template>
+    <header
+        class="sticky top-0 left-0 right-0 z-30 border-b dark:border-neutral-800 bg-white/80 dark:bg-black/20 backdrop-blur"
+    >
+        <div class="relative flex items-center justify-between min-w-0 overflow-hidden h-14">
+            <div class="flex items-center">
+                <button class="flex items-center justify-center w-11 h-11" @click="handleUpdateCollapsed">
+                    <SvgIcon v-if="collapsed" class="text-2xl" icon="ri:align-justify" />
+                    <SvgIcon v-else class="text-2xl" icon="ri:align-right" />
+                </button>
+            </div>
+            <h1
+                class="flex-1 px-4 pr-6 overflow-hidden cursor-pointer select-none text-ellipsis whitespace-nowrap"
+                @dblclick="onScrollToTop"
+            >
+                {{ currentChatHistory?.title ?? '' }}
+            </h1>
+            <div class="flex items-center space-x-2">
+                <HoverButton @click="toggleUsingContext">
+                    <span class="text-xl" :class="{ 'text-[#4b9e5f]': usingContext, 'text-[#a8071a]': !usingContext }">
+                        <SvgIcon icon="ri:chat-history-line" />
+                    </span>
+                </HoverButton>
+                <HoverButton @click="handleExport">
+                    <span class="text-xl text-[#4f555e] dark:text-white">
+                        <SvgIcon icon="ri:download-2-line" />
+                    </span>
+                </HoverButton>
+            </div>
+        </div>
+    </header>
+</template>

+ 31 - 0
src/views/chat/components/Message/Avatar.vue

@@ -0,0 +1,31 @@
+<script lang="ts" setup>
+import { computed } from 'vue'
+import { NAvatar } from 'naive-ui'
+import { useUserStore } from '@/store'
+import { isString } from '@/utils/is'
+import defaultAvatar from '@/assets/avatar.jpg'
+
+interface Props {
+    image?: boolean
+}
+defineProps<Props>()
+
+const userStore = useUserStore()
+
+const avatar = computed(() => userStore.userInfo.avatar)
+</script>
+
+<template>
+    <template v-if="image">
+        <NAvatar v-if="isString(avatar) && avatar.length > 0" :src="avatar" :fallback-src="defaultAvatar" />
+        <NAvatar v-else round :src="defaultAvatar" />
+    </template>
+    <span v-else class="text-[28px] dark:text-white">
+        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" aria-hidden="true" width="1em" height="1em">
+            <path
+                d="M29.71,13.09A8.09,8.09,0,0,0,20.34,2.68a8.08,8.08,0,0,0-13.7,2.9A8.08,8.08,0,0,0,2.3,18.9,8,8,0,0,0,3,25.45a8.08,8.08,0,0,0,8.69,3.87,8,8,0,0,0,6,2.68,8.09,8.09,0,0,0,7.7-5.61,8,8,0,0,0,5.33-3.86A8.09,8.09,0,0,0,29.71,13.09Zm-12,16.82a6,6,0,0,1-3.84-1.39l.19-.11,6.37-3.68a1,1,0,0,0,.53-.91v-9l2.69,1.56a.08.08,0,0,1,.05.07v7.44A6,6,0,0,1,17.68,29.91ZM4.8,24.41a6,6,0,0,1-.71-4l.19.11,6.37,3.68a1,1,0,0,0,1,0l7.79-4.49V22.8a.09.09,0,0,1,0,.08L13,26.6A6,6,0,0,1,4.8,24.41ZM3.12,10.53A6,6,0,0,1,6.28,7.9v7.57a1,1,0,0,0,.51.9l7.75,4.47L11.85,22.4a.14.14,0,0,1-.09,0L5.32,18.68a6,6,0,0,1-2.2-8.18Zm22.13,5.14-7.78-4.52L20.16,9.6a.08.08,0,0,1,.09,0l6.44,3.72a6,6,0,0,1-.9,10.81V16.56A1.06,1.06,0,0,0,25.25,15.67Zm2.68-4-.19-.12-6.36-3.7a1,1,0,0,0-1.05,0l-7.78,4.49V9.2a.09.09,0,0,1,0-.09L19,5.4a6,6,0,0,1,8.91,6.21ZM11.08,17.15,8.38,15.6a.14.14,0,0,1-.05-.08V8.1a6,6,0,0,1,9.84-4.61L18,3.6,11.61,7.28a1,1,0,0,0-.53.91ZM12.54,14,16,12l3.47,2v4L16,20l-3.47-2Z"
+                fill="currentColor"
+            />
+        </svg>
+    </span>
+</template>

+ 84 - 0
src/views/chat/components/Message/Text.vue

@@ -0,0 +1,84 @@
+<script lang="ts" setup>
+import { computed, ref } from 'vue'
+import MarkdownIt from 'markdown-it'
+import mdKatex from '@traptitech/markdown-it-katex'
+import mila from 'markdown-it-link-attributes'
+import hljs from 'highlight.js'
+import { useBasicLayout } from '@/hooks/useBasicLayout'
+import { t } from '@/locales'
+
+interface Props {
+    inversion?: boolean
+    error?: boolean
+    text?: string
+    loading?: boolean
+    asRawText?: boolean
+}
+
+const props = defineProps<Props>()
+
+const { isMobile } = useBasicLayout()
+
+const textRef = ref<HTMLElement>()
+
+const mdi = new MarkdownIt({
+    linkify: true,
+    highlight(code, language) {
+        const validLang = !!(language && hljs.getLanguage(language))
+        if (validLang) {
+            const lang = language ?? ''
+            return highlightBlock(hljs.highlight(code, { language: lang }).value, lang)
+        }
+        return highlightBlock(hljs.highlightAuto(code).value, '')
+    }
+})
+
+mdi.use(mila, { attrs: { target: '_blank', rel: 'noopener' } })
+mdi.use(mdKatex, { blockClass: 'katexmath-block rounded-md p-[10px]', errorColor: ' #cc0000' })
+
+const wrapClass = computed(() => {
+    return [
+        'text-wrap',
+        'min-w-[20px]',
+        'rounded-md',
+        isMobile.value ? 'p-2' : 'px-3 py-2',
+        props.inversion ? 'bg-[#d2f9d1]' : 'bg-[#f4f6f8]',
+        props.inversion ? 'dark:bg-[#a1dc95]' : 'dark:bg-[#1e1e20]',
+        props.inversion ? 'message-request' : 'message-reply',
+        { 'text-red-500': props.error }
+    ]
+})
+
+const text = computed(() => {
+    const value = props.text ?? ''
+    if (!props.asRawText) return mdi.render(value)
+    return value
+})
+
+function highlightBlock(str: string, lang?: string) {
+    return `<pre class="code-block-wrapper"><div class="code-block-header"><span class="code-block-header__lang">${lang}</span><span class="code-block-header__copy">${t(
+        'chat.copyCode'
+    )}</span></div><code class="hljs code-block-body ${lang}">${str}</code></pre>`
+}
+
+defineExpose({ textRef })
+</script>
+
+<template>
+    <div class="text-black" :class="wrapClass">
+        <div ref="textRef" class="leading-relaxed break-words">
+            <div v-if="!inversion">
+                <div v-if="!asRawText" class="markdown-body" v-html="text" />
+                <div v-else class="whitespace-pre-wrap" v-text="text" />
+            </div>
+            <div v-else class="whitespace-pre-wrap" v-text="text" />
+            <template v-if="loading">
+                <span class="dark:text-white w-[4px] h-[20px] block animate-blink" />
+            </template>
+        </div>
+    </div>
+</template>
+
+<style lang="less">
+@import url(./style.less);
+</style>

+ 126 - 0
src/views/chat/components/Message/index.vue

@@ -0,0 +1,126 @@
+<script setup lang="ts">
+import { computed, ref } from 'vue'
+import { NDropdown } from 'naive-ui'
+import AvatarComponent from './Avatar.vue'
+import TextComponent from './Text.vue'
+import { SvgIcon } from '@/components/common'
+import { copyText } from '@/utils/format'
+import { useIconRender } from '@/hooks/useIconRender'
+import { t } from '@/locales'
+import { useBasicLayout } from '@/hooks/useBasicLayout'
+
+interface Props {
+    dateTime?: string
+    text?: string
+    inversion?: boolean
+    error?: boolean
+    loading?: boolean
+}
+
+interface Emit {
+    (ev: 'regenerate'): void
+    (ev: 'delete'): void
+}
+
+const props = defineProps<Props>()
+
+const emit = defineEmits<Emit>()
+
+const { isMobile } = useBasicLayout()
+
+const { iconRender } = useIconRender()
+
+const textRef = ref<HTMLElement>()
+
+const asRawText = ref(props.inversion)
+
+const messageRef = ref<HTMLElement>()
+
+const options = computed(() => {
+    const common = [
+        {
+            label: t('chat.copy'),
+            key: 'copyText',
+            icon: iconRender({ icon: 'ri:file-copy-2-line' })
+        },
+        {
+            label: t('common.delete'),
+            key: 'delete',
+            icon: iconRender({ icon: 'ri:delete-bin-line' })
+        }
+    ]
+
+    if (!props.inversion) {
+        common.unshift({
+            label: asRawText.value ? t('chat.preview') : t('chat.showRawText'),
+            key: 'toggleRenderType',
+            icon: iconRender({ icon: asRawText.value ? 'ic:outline-code-off' : 'ic:outline-code' })
+        })
+    }
+
+    return common
+})
+
+function handleSelect(key: 'copyText' | 'delete' | 'toggleRenderType') {
+    switch (key) {
+        case 'copyText':
+            copyText({ text: props.text ?? '' })
+            return
+        case 'toggleRenderType':
+            asRawText.value = !asRawText.value
+            return
+        case 'delete':
+            emit('delete')
+    }
+}
+
+function handleRegenerate() {
+    messageRef.value?.scrollIntoView()
+    emit('regenerate')
+}
+</script>
+
+<template>
+    <div ref="messageRef" class="flex w-full mb-6 overflow-hidden" :class="[{ 'flex-row-reverse': inversion }]">
+        <div
+            class="flex items-center justify-center flex-shrink-0 h-8 overflow-hidden rounded-full basis-8"
+            :class="[inversion ? 'ml-2' : 'mr-2']"
+        >
+            <AvatarComponent :image="inversion" />
+        </div>
+        <div class="overflow-hidden text-sm" :class="[inversion ? 'items-end' : 'items-start']">
+            <p class="text-xs text-[#b4bbc4]" :class="[inversion ? 'text-right' : 'text-left']">
+                {{ dateTime }}
+            </p>
+            <div class="flex items-end gap-1 mt-2" :class="[inversion ? 'flex-row-reverse' : 'flex-row']">
+                <TextComponent
+                    ref="textRef"
+                    :inversion="inversion"
+                    :error="error"
+                    :text="text"
+                    :loading="loading"
+                    :as-raw-text="asRawText"
+                />
+                <div class="flex flex-col">
+                    <button
+                        v-if="!inversion"
+                        class="mb-2 transition text-neutral-300 hover:text-neutral-800 dark:hover:text-neutral-300"
+                        @click="handleRegenerate"
+                    >
+                        <SvgIcon icon="ri:restart-line" />
+                    </button>
+                    <NDropdown
+                        :trigger="isMobile ? 'click' : 'hover'"
+                        :placement="!inversion ? 'right' : 'left'"
+                        :options="options"
+                        @select="handleSelect"
+                    >
+                        <button class="transition text-neutral-300 hover:text-neutral-800 dark:hover:text-neutral-200">
+                            <SvgIcon icon="ri:more-2-fill" />
+                        </button>
+                    </NDropdown>
+                </div>
+            </div>
+        </div>
+    </div>
+</template>

+ 73 - 0
src/views/chat/components/Message/style.less

@@ -0,0 +1,73 @@
+.markdown-body {
+    background-color: transparent;
+    font-size: 14px;
+
+    p {
+        white-space: pre-wrap;
+    }
+
+    ol {
+        list-style-type: decimal;
+    }
+
+    ul {
+        list-style-type: disc;
+    }
+
+    pre code,
+    pre tt {
+        line-height: 1.65;
+    }
+
+    .highlight pre,
+    pre {
+        background-color: #fff;
+    }
+
+    code.hljs {
+        padding: 0;
+    }
+
+    .code-block {
+        &-wrapper {
+            position: relative;
+            padding-top: 24px;
+        }
+
+        &-header {
+            position: absolute;
+            top: 5px;
+            right: 0;
+            width: 100%;
+            padding: 0 1rem;
+            display: flex;
+            justify-content: flex-end;
+            align-items: center;
+            color: #b3b3b3;
+
+            &__copy {
+                cursor: pointer;
+                margin-left: 0.5rem;
+                user-select: none;
+
+                &:hover {
+                    color: #65a665;
+                }
+            }
+        }
+    }
+}
+
+html.dark {
+    .message-reply {
+        .whitespace-pre-wrap {
+            white-space: pre-wrap;
+            color: var(--n-text-color);
+        }
+    }
+
+    .highlight pre,
+    pre {
+        background-color: #282c34;
+    }
+}

+ 3 - 0
src/views/chat/components/index.ts

@@ -0,0 +1,3 @@
+import Message from './Message/index.vue'
+
+export { Message }

+ 28 - 0
src/views/chat/hooks/useChat.ts

@@ -0,0 +1,28 @@
+import { useChatStore } from '@/store'
+
+export function useChat() {
+    const chatStore = useChatStore()
+
+    const getChatByUuidAndIndex = (uuid: number, index: number) => {
+        return chatStore.getChatByUuidAndIndex(uuid, index)
+    }
+
+    const addChat = (uuid: number, chat: Chat.Chat) => {
+        chatStore.addChatByUuid(uuid, chat)
+    }
+
+    const updateChat = (uuid: number, index: number, chat: Chat.Chat) => {
+        chatStore.updateChatByUuid(uuid, index, chat)
+    }
+
+    const updateChatSome = (uuid: number, index: number, chat: Partial<Chat.Chat>) => {
+        chatStore.updateChatSomeByUuid(uuid, index, chat)
+    }
+
+    return {
+        addChat,
+        updateChat,
+        updateChatSome,
+        getChatByUuidAndIndex
+    }
+}

+ 22 - 0
src/views/chat/hooks/useCopyCode.ts

@@ -0,0 +1,22 @@
+import { onMounted, onUpdated } from 'vue'
+import { copyText } from '@/utils/format'
+
+export function useCopyCode() {
+    function copyCodeBlock() {
+        const codeBlockWrapper = document.querySelectorAll('.code-block-wrapper')
+        codeBlockWrapper.forEach(wrapper => {
+            const copyBtn = wrapper.querySelector('.code-block-header__copy')
+            const codeBlock = wrapper.querySelector('.code-block-body')
+            if (copyBtn && codeBlock) {
+                copyBtn.addEventListener('click', () => {
+                    if (navigator.clipboard?.writeText) navigator.clipboard.writeText(codeBlock.textContent ?? '')
+                    else copyText({ text: codeBlock.textContent ?? '', origin: true })
+                })
+            }
+        })
+    }
+
+    onMounted(() => copyCodeBlock())
+
+    onUpdated(() => copyCodeBlock())
+}

+ 42 - 0
src/views/chat/hooks/useScroll.ts

@@ -0,0 +1,42 @@
+import type { Ref } from 'vue'
+import { nextTick, ref } from 'vue'
+
+type ScrollElement = HTMLDivElement | null
+
+interface ScrollReturn {
+    scrollRef: Ref<ScrollElement>
+    scrollToBottom: () => Promise<void>
+    scrollToTop: () => Promise<void>
+    scrollToBottomIfAtBottom: () => Promise<void>
+}
+
+export function useScroll(): ScrollReturn {
+    const scrollRef = ref<ScrollElement>(null)
+
+    const scrollToBottom = async () => {
+        await nextTick()
+        if (scrollRef.value) scrollRef.value.scrollTop = scrollRef.value.scrollHeight
+    }
+
+    const scrollToTop = async () => {
+        await nextTick()
+        if (scrollRef.value) scrollRef.value.scrollTop = 0
+    }
+
+    const scrollToBottomIfAtBottom = async () => {
+        await nextTick()
+        if (scrollRef.value) {
+            const threshold = 100 // 阈值,表示滚动条到底部的距离阈值
+            const distanceToBottom =
+                scrollRef.value.scrollHeight - scrollRef.value.scrollTop - scrollRef.value.clientHeight
+            if (distanceToBottom <= threshold) scrollRef.value.scrollTop = scrollRef.value.scrollHeight
+        }
+    }
+
+    return {
+        scrollRef,
+        scrollToBottom,
+        scrollToTop,
+        scrollToBottomIfAtBottom
+    }
+}

+ 21 - 0
src/views/chat/hooks/useUsingContext.ts

@@ -0,0 +1,21 @@
+import { computed } from 'vue'
+import { useMessage } from 'naive-ui'
+import { t } from '@/locales'
+import { useChatStore } from '@/store'
+
+export function useUsingContext() {
+    const ms = useMessage()
+    const chatStore = useChatStore()
+    const usingContext = computed<boolean>(() => chatStore.usingContext)
+
+    function toggleUsingContext() {
+        chatStore.setUsingContext(!usingContext.value)
+        if (usingContext.value) ms.success(t('chat.turnOnContext'))
+        else ms.warning(t('chat.turnOffContext'))
+    }
+
+    return {
+        usingContext,
+        toggleUsingContext
+    }
+}

+ 500 - 0
src/views/chat/index.vue

@@ -0,0 +1,500 @@
+<script setup lang="ts">
+import type { Ref } from 'vue'
+import { computed, onMounted, onUnmounted, ref } from 'vue'
+import { useRoute } from 'vue-router'
+import { storeToRefs } from 'pinia'
+import { NAutoComplete, NButton, NInput, useDialog, useMessage } from 'naive-ui'
+import html2canvas from 'html2canvas'
+import { Message } from './components'
+import { useScroll } from './hooks/useScroll'
+import { useChat } from './hooks/useChat'
+import { useCopyCode } from './hooks/useCopyCode'
+import { useUsingContext } from './hooks/useUsingContext'
+import HeaderComponent from './components/Header/index.vue'
+import { HoverButton, SvgIcon } from '@/components/common'
+import { useBasicLayout } from '@/hooks/useBasicLayout'
+import { useChatStore, usePromptStore } from '@/store'
+import { fetchChatAPIProcess } from '@/api'
+import { t } from '@/locales'
+
+let controller = new AbortController()
+
+const openLongReply = import.meta.env.VITE_GLOB_OPEN_LONG_REPLY === 'true'
+
+const route = useRoute()
+const dialog = useDialog()
+const ms = useMessage()
+
+const chatStore = useChatStore()
+
+useCopyCode()
+
+const { isMobile } = useBasicLayout()
+const { addChat, updateChat, updateChatSome, getChatByUuidAndIndex } = useChat()
+const { scrollRef, scrollToBottom, scrollToBottomIfAtBottom } = useScroll()
+const { usingContext, toggleUsingContext } = useUsingContext()
+
+const { uuid } = route.params as { uuid: string }
+
+const dataSources = computed(() => chatStore.getChatByUuid(+uuid))
+const conversationList = computed(() => dataSources.value.filter(item => !item.inversion && !!item.conversationOptions))
+
+const prompt = ref<string>('')
+const loading = ref<boolean>(false)
+const inputRef = ref<Ref | null>(null)
+
+// 添加PromptStore
+const promptStore = usePromptStore()
+
+// 使用storeToRefs,保证store修改后,联想部分能够重新渲染
+const { promptList: promptTemplate } = storeToRefs<any>(promptStore)
+
+// 未知原因刷新页面,loading 状态不会重置,手动重置
+dataSources.value.forEach((item, index) => {
+    if (item.loading) updateChatSome(+uuid, index, { loading: false })
+})
+
+function handleSubmit() {
+    onConversation()
+}
+
+async function onConversation() {
+    let message = prompt.value
+
+    if (loading.value) return
+
+    if (!message || message.trim() === '') return
+
+    controller = new AbortController()
+
+    addChat(+uuid, {
+        dateTime: new Date().toLocaleString(),
+        text: message,
+        inversion: true,
+        error: false,
+        conversationOptions: null,
+        requestOptions: { prompt: message, options: null }
+    })
+    scrollToBottom()
+
+    loading.value = true
+    prompt.value = ''
+
+    let options: Chat.ConversationRequest = {}
+    const lastContext = conversationList.value[conversationList.value.length - 1]?.conversationOptions
+
+    if (lastContext && usingContext.value) options = { ...lastContext }
+
+    addChat(+uuid, {
+        dateTime: new Date().toLocaleString(),
+        text: '',
+        loading: true,
+        inversion: false,
+        error: false,
+        conversationOptions: null,
+        requestOptions: { prompt: message, options: { ...options } }
+    })
+    scrollToBottom()
+
+    try {
+        let lastText = ''
+        const fetchChatAPIOnce = async () => {
+            await fetchChatAPIProcess<Chat.ConversationResponse>({
+                prompt: message,
+                options,
+                signal: controller.signal,
+                onDownloadProgress: ({ event }) => {
+                    const xhr = event.target
+                    const { responseText } = xhr
+                    // Always process the final line
+                    const lastIndex = responseText.lastIndexOf('\n', responseText.length - 2)
+                    let chunk = responseText
+                    if (lastIndex !== -1) chunk = responseText.substring(lastIndex)
+                    try {
+                        const data = JSON.parse(chunk)
+                        updateChat(+uuid, dataSources.value.length - 1, {
+                            dateTime: new Date().toLocaleString(),
+                            text: lastText + (data.text ?? ''),
+                            inversion: false,
+                            error: false,
+                            loading: true,
+                            conversationOptions: { conversationId: data.conversationId, parentMessageId: data.id },
+                            requestOptions: { prompt: message, options: { ...options } }
+                        })
+
+                        if (openLongReply && data.detail.choices[0].finish_reason === 'length') {
+                            options.parentMessageId = data.id
+                            lastText = data.text
+                            message = ''
+                            return fetchChatAPIOnce()
+                        }
+
+                        scrollToBottomIfAtBottom()
+                    } catch (error) {
+                        //
+                    }
+                }
+            })
+            updateChatSome(+uuid, dataSources.value.length - 1, { loading: false })
+        }
+
+        await fetchChatAPIOnce()
+    } catch (error: any) {
+        const errorMessage = error?.message ?? t('common.wrong')
+
+        if (error.message === 'canceled') {
+            updateChatSome(+uuid, dataSources.value.length - 1, {
+                loading: false
+            })
+            scrollToBottomIfAtBottom()
+            return
+        }
+
+        const currentChat = getChatByUuidAndIndex(+uuid, dataSources.value.length - 1)
+
+        if (currentChat?.text && currentChat.text !== '') {
+            updateChatSome(+uuid, dataSources.value.length - 1, {
+                text: `${currentChat.text}\n[${errorMessage}]`,
+                error: false,
+                loading: false
+            })
+            return
+        }
+
+        updateChat(+uuid, dataSources.value.length - 1, {
+            dateTime: new Date().toLocaleString(),
+            text: errorMessage,
+            inversion: false,
+            error: true,
+            loading: false,
+            conversationOptions: null,
+            requestOptions: { prompt: message, options: { ...options } }
+        })
+        scrollToBottomIfAtBottom()
+    } finally {
+        loading.value = false
+    }
+}
+
+async function onRegenerate(index: number) {
+    if (loading.value) return
+
+    controller = new AbortController()
+
+    const { requestOptions } = dataSources.value[index]
+
+    let message = requestOptions?.prompt ?? ''
+
+    let options: Chat.ConversationRequest = {}
+
+    if (requestOptions.options) options = { ...requestOptions.options }
+
+    loading.value = true
+
+    updateChat(+uuid, index, {
+        dateTime: new Date().toLocaleString(),
+        text: '',
+        inversion: false,
+        error: false,
+        loading: true,
+        conversationOptions: null,
+        requestOptions: { prompt: message, options: { ...options } }
+    })
+
+    try {
+        let lastText = ''
+        const fetchChatAPIOnce = async () => {
+            await fetchChatAPIProcess<Chat.ConversationResponse>({
+                prompt: message,
+                options,
+                signal: controller.signal,
+                onDownloadProgress: ({ event }) => {
+                    const xhr = event.target
+                    const { responseText } = xhr
+                    // Always process the final line
+                    const lastIndex = responseText.lastIndexOf('\n', responseText.length - 2)
+                    let chunk = responseText
+                    if (lastIndex !== -1) chunk = responseText.substring(lastIndex)
+                    try {
+                        const data = JSON.parse(chunk)
+                        updateChat(+uuid, index, {
+                            dateTime: new Date().toLocaleString(),
+                            text: lastText + (data.text ?? ''),
+                            inversion: false,
+                            error: false,
+                            loading: true,
+                            conversationOptions: { conversationId: data.conversationId, parentMessageId: data.id },
+                            requestOptions: { prompt: message, options: { ...options } }
+                        })
+
+                        if (openLongReply && data.detail.choices[0].finish_reason === 'length') {
+                            options.parentMessageId = data.id
+                            lastText = data.text
+                            message = ''
+                            return fetchChatAPIOnce()
+                        }
+                    } catch (error) {
+                        //
+                    }
+                }
+            })
+            updateChatSome(+uuid, index, { loading: false })
+        }
+        await fetchChatAPIOnce()
+    } catch (error: any) {
+        if (error.message === 'canceled') {
+            updateChatSome(+uuid, index, {
+                loading: false
+            })
+            return
+        }
+
+        const errorMessage = error?.message ?? t('common.wrong')
+
+        updateChat(+uuid, index, {
+            dateTime: new Date().toLocaleString(),
+            text: errorMessage,
+            inversion: false,
+            error: true,
+            loading: false,
+            conversationOptions: null,
+            requestOptions: { prompt: message, options: { ...options } }
+        })
+    } finally {
+        loading.value = false
+    }
+}
+
+function handleExport() {
+    if (loading.value) return
+
+    const d = dialog.warning({
+        title: t('chat.exportImage'),
+        content: t('chat.exportImageConfirm'),
+        positiveText: t('common.yes'),
+        negativeText: t('common.no'),
+        onPositiveClick: async () => {
+            try {
+                d.loading = true
+                const ele = document.getElementById('image-wrapper')
+                const canvas = await html2canvas(ele as HTMLDivElement, {
+                    useCORS: true
+                })
+                const imgUrl = canvas.toDataURL('image/png')
+                const tempLink = document.createElement('a')
+                tempLink.style.display = 'none'
+                tempLink.href = imgUrl
+                tempLink.setAttribute('download', 'chat-shot.png')
+                if (typeof tempLink.download === 'undefined') tempLink.setAttribute('target', '_blank')
+
+                document.body.appendChild(tempLink)
+                tempLink.click()
+                document.body.removeChild(tempLink)
+                window.URL.revokeObjectURL(imgUrl)
+                d.loading = false
+                ms.success(t('chat.exportSuccess'))
+                Promise.resolve()
+            } catch (error: any) {
+                ms.error(t('chat.exportFailed'))
+            } finally {
+                d.loading = false
+            }
+        }
+    })
+}
+
+function handleDelete(index: number) {
+    if (loading.value) return
+
+    dialog.warning({
+        title: t('chat.deleteMessage'),
+        content: t('chat.deleteMessageConfirm'),
+        positiveText: t('common.yes'),
+        negativeText: t('common.no'),
+        onPositiveClick: () => {
+            chatStore.deleteChatByUuid(+uuid, index)
+        }
+    })
+}
+
+function handleClear() {
+    if (loading.value) return
+
+    dialog.warning({
+        title: t('chat.clearChat'),
+        content: t('chat.clearChatConfirm'),
+        positiveText: t('common.yes'),
+        negativeText: t('common.no'),
+        onPositiveClick: () => {
+            chatStore.clearChatByUuid(+uuid)
+        }
+    })
+}
+
+function handleEnter(event: KeyboardEvent) {
+    if (!isMobile.value) {
+        if (event.key === 'Enter' && !event.shiftKey) {
+            event.preventDefault()
+            handleSubmit()
+        }
+    } else {
+        if (event.key === 'Enter' && event.ctrlKey) {
+            event.preventDefault()
+            handleSubmit()
+        }
+    }
+}
+
+function handleStop() {
+    if (loading.value) {
+        controller.abort()
+        loading.value = false
+    }
+}
+
+// 可优化部分
+// 搜索选项计算,这里使用value作为索引项,所以当出现重复value时渲染异常(多项同时出现选中效果)
+// 理想状态下其实应该是key作为索引项,但官方的renderOption会出现问题,所以就需要value反renderLabel实现
+const searchOptions = computed(() => {
+    if (prompt.value.startsWith('/')) {
+        return promptTemplate.value
+            .filter((item: { key: string }) => item.key.toLowerCase().includes(prompt.value.substring(1).toLowerCase()))
+            .map((obj: { value: any }) => {
+                return {
+                    label: obj.value,
+                    value: obj.value
+                }
+            })
+    } else {
+        return []
+    }
+})
+
+// value反渲染key
+const renderOption = (option: { label: string }) => {
+    for (const i of promptTemplate.value) {
+        if (i.value === option.label) return [i.key]
+    }
+    return []
+}
+
+const placeholder = computed(() => {
+    if (isMobile.value) return t('chat.placeholderMobile')
+    return t('chat.placeholder')
+})
+
+const buttonDisabled = computed(() => {
+    return loading.value || !prompt.value || prompt.value.trim() === ''
+})
+
+const footerClass = computed(() => {
+    let classes = ['p-4']
+    if (isMobile.value) classes = ['sticky', 'left-0', 'bottom-0', 'right-0', 'p-2', 'pr-3', 'overflow-hidden']
+    return classes
+})
+
+onMounted(() => {
+    scrollToBottom()
+    if (inputRef.value && !isMobile.value) inputRef.value?.focus()
+})
+
+onUnmounted(() => {
+    if (loading.value) controller.abort()
+})
+</script>
+
+<template>
+    <div class="flex flex-col w-full h-full">
+        <HeaderComponent
+            v-if="isMobile"
+            :using-context="usingContext"
+            @export="handleExport"
+            @toggle-using-context="toggleUsingContext"
+        />
+        <main class="flex-1 overflow-hidden">
+            <div id="scrollRef" ref="scrollRef" class="h-full overflow-hidden overflow-y-auto">
+                <div
+                    id="image-wrapper"
+                    class="w-full max-w-screen-xl m-auto dark:bg-[#101014]"
+                    :class="[isMobile ? 'p-2' : 'p-4']"
+                >
+                    <template v-if="!dataSources.length">
+                        <div class="flex items-center justify-center mt-4 text-center text-neutral-300">
+                            <SvgIcon icon="ri:bubble-chart-fill" class="mr-2 text-3xl" />
+                            <span>Aha~</span>
+                        </div>
+                    </template>
+                    <template v-else>
+                        <div>
+                            <Message
+                                v-for="(item, index) of dataSources"
+                                :key="index"
+                                :date-time="item.dateTime"
+                                :text="item.text"
+                                :inversion="item.inversion"
+                                :error="item.error"
+                                :loading="item.loading"
+                                @regenerate="onRegenerate(index)"
+                                @delete="handleDelete(index)"
+                            />
+                            <div class="sticky bottom-0 left-0 flex justify-center">
+                                <NButton v-if="loading" type="warning" @click="handleStop">
+                                    <template #icon>
+                                        <SvgIcon icon="ri:stop-circle-line" />
+                                    </template>
+                                    Stop Responding
+                                </NButton>
+                            </div>
+                        </div>
+                    </template>
+                </div>
+            </div>
+        </main>
+        <footer :class="footerClass">
+            <div class="w-full max-w-screen-xl m-auto">
+                <div class="flex items-center justify-between space-x-2">
+                    <HoverButton @click="handleClear">
+                        <span class="text-xl text-[#4f555e] dark:text-white">
+                            <SvgIcon icon="ri:delete-bin-line" />
+                        </span>
+                    </HoverButton>
+                    <HoverButton v-if="!isMobile" @click="handleExport">
+                        <span class="text-xl text-[#4f555e] dark:text-white">
+                            <SvgIcon icon="ri:download-2-line" />
+                        </span>
+                    </HoverButton>
+                    <HoverButton v-if="!isMobile" @click="toggleUsingContext">
+                        <span
+                            class="text-xl"
+                            :class="{ 'text-[#4b9e5f]': usingContext, 'text-[#a8071a]': !usingContext }"
+                        >
+                            <SvgIcon icon="ri:chat-history-line" />
+                        </span>
+                    </HoverButton>
+                    <NAutoComplete v-model:value="prompt" :options="searchOptions" :render-label="renderOption">
+                        <template #default="{ handleInput, handleBlur, handleFocus }">
+                            <NInput
+                                ref="inputRef"
+                                v-model:value="prompt"
+                                type="textarea"
+                                :placeholder="placeholder"
+                                :autosize="{ minRows: 1, maxRows: isMobile ? 4 : 8 }"
+                                @input="handleInput"
+                                @focus="handleFocus"
+                                @blur="handleBlur"
+                                @keypress="handleEnter"
+                            />
+                        </template>
+                    </NAutoComplete>
+                    <NButton type="primary" :disabled="buttonDisabled" @click="handleSubmit">
+                        <template #icon>
+                            <span class="dark:text-black">
+                                <SvgIcon icon="ri:send-plane-fill" />
+                            </span>
+                        </template>
+                    </NButton>
+                </div>
+            </div>
+        </footer>
+    </div>
+</template>

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff