Repository: WenJing95/chatgpt-web Branch: main Commit: ea19057bd9a2 Files: 109 Total size: 168.0 KB Directory structure: gitextract_deg4t8zk/ ├── .commitlintrc.json ├── .dockerignore ├── .editorconfig ├── .eslintrc.cjs ├── .gitattributes ├── .github/ │ └── workflows/ │ ├── build_docker.yml │ └── ci.yml ├── .gitignore ├── .husky/ │ ├── commit-msg │ └── pre-commit ├── .npmrc ├── .vscode/ │ ├── extensions.json │ └── settings.json ├── CONTRIBUTING.md ├── Dockerfile ├── README.md ├── config/ │ ├── index.ts │ └── proxy.ts ├── docker-compose/ │ ├── docker-compose.yml │ └── nginx/ │ ├── add_user.sh │ ├── auth/ │ │ └── .htpasswd │ ├── nginx.conf │ └── remove_user.sh ├── index.html ├── license ├── package.json ├── postcss.config.js ├── service/ │ ├── Dockerfile │ ├── api_model.py │ ├── chatgpt_wapper.py │ ├── entrypoint.sh │ ├── errors.py │ ├── main.py │ ├── message_store.py │ ├── requirements.txt │ ├── tools/ │ │ ├── __init__.py │ │ ├── local-whisper/ │ │ │ ├── __init__.py │ │ │ └── linux/ │ │ │ └── init.sh │ │ └── openai_token_control.py │ └── whisper_wapper.py ├── src/ │ ├── App.vue │ ├── api/ │ │ └── index.ts │ ├── components/ │ │ ├── common/ │ │ │ ├── HoverButton/ │ │ │ │ ├── Button.vue │ │ │ │ └── index.vue │ │ │ ├── NaiveProvider/ │ │ │ │ └── index.vue │ │ │ ├── Setting/ │ │ │ │ ├── About.vue │ │ │ │ ├── Advance.vue │ │ │ │ ├── General.vue │ │ │ │ └── index.vue │ │ │ ├── SvgIcon/ │ │ │ │ └── index.vue │ │ │ ├── UserAvatar/ │ │ │ │ └── index.vue │ │ │ └── index.ts │ │ └── custom/ │ │ ├── GithubSite.vue │ │ └── index.ts │ ├── hooks/ │ │ ├── useBasicLayout.ts │ │ ├── useIconRender.ts │ │ ├── useLanguage.ts │ │ └── useTheme.ts │ ├── locales/ │ │ ├── en-US.ts │ │ ├── index.ts │ │ ├── ja-JP.ts │ │ └── zh-CN.ts │ ├── main.ts │ ├── plugins/ │ │ ├── assets.ts │ │ ├── index.ts │ │ └── scrollbarStyle.ts │ ├── router/ │ │ └── index.ts │ ├── store/ │ │ ├── index.ts │ │ └── modules/ │ │ ├── app/ │ │ │ ├── helper.ts │ │ │ └── index.ts │ │ ├── chat/ │ │ │ ├── helper.ts │ │ │ └── index.ts │ │ ├── index.ts │ │ └── user/ │ │ ├── helper.ts │ │ └── index.ts │ ├── styles/ │ │ ├── global.less │ │ └── lib/ │ │ ├── github-markdown.less │ │ ├── highlight.less │ │ └── tailwind.css │ ├── typings/ │ │ ├── chat.d.ts │ │ ├── env.d.ts │ │ └── global.d.ts │ ├── utils/ │ │ ├── crypto/ │ │ │ └── index.ts │ │ ├── format/ │ │ │ └── index.ts │ │ ├── is/ │ │ │ └── index.ts │ │ ├── request/ │ │ │ ├── axios.ts │ │ │ └── index.ts │ │ └── storage/ │ │ ├── index.ts │ │ └── local.ts │ └── views/ │ ├── chat/ │ │ ├── components/ │ │ │ ├── Message/ │ │ │ │ ├── Avatar.vue │ │ │ │ ├── Text.vue │ │ │ │ ├── index.vue │ │ │ │ └── style.less │ │ │ └── index.ts │ │ ├── hooks/ │ │ │ ├── useChat.ts │ │ │ ├── useCopyCode.ts │ │ │ └── useScroll.ts │ │ ├── index.vue │ │ └── layout/ │ │ ├── Layout.vue │ │ ├── header/ │ │ │ └── index.vue │ │ ├── index.ts │ │ └── sider/ │ │ ├── Footer.vue │ │ ├── List.vue │ │ └── index.vue │ └── exception/ │ ├── 403/ │ │ └── index.vue │ └── 404/ │ └── index.vue ├── tailwind.config.js ├── tsconfig.json └── vite.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .commitlintrc.json ================================================ { "extends": ["@commitlint/config-conventional"] } ================================================ FILE: .dockerignore ================================================ node_modules Dockerfile .* */.* ================================================ FILE: .editorconfig ================================================ # Editor configuration, see http://editorconfig.org root = true [*] charset = utf-8 indent_style = tab indent_size = 2 end_of_line = lf trim_trailing_whitespace = true insert_final_newline = true ================================================ FILE: .eslintrc.cjs ================================================ module.exports = { root: true, extends: ['@antfu'], } ================================================ FILE: .gitattributes ================================================ "*.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 ================================================ FILE: .github/workflows/build_docker.yml ================================================ 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 ================================================ FILE: .github/workflows/ci.yml ================================================ 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 ================================================ FILE: .gitignore ================================================ # Logs logs *.log service/log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* #backend service/venv service/message_store.json docker-compose/nginx/html 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? ================================================ FILE: .husky/commit-msg ================================================ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" npx --no -- commitlint --edit ================================================ FILE: .husky/pre-commit ================================================ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" npx lint-staged ================================================ FILE: .npmrc ================================================ strict-peer-dependencies=false ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": ["Vue.volar", "dbaeumer.vscode-eslint"] } ================================================ FILE: .vscode/settings.json ================================================ { "prettier.enable": false, "editor.formatOnSave": false, "editor.codeActionsOnSave": { "source.fixAll.eslint": true }, "eslint.validate": [ "javascript", "javascriptreact", "typescript", "typescriptreact", "vue", "html", "json", "jsonc", "json5", "yaml", "yml", "markdown" ], "cSpell.words": [ "antfu", "axios", "bumpp", "chatgpt", "chenzhaoyu", "commitlint", "davinci", "dockerhub", "esno", "GPTAPI", "highlightjs", "hljs", "iconify", "katex", "katexmath", "linkify", "logprobs", "mdhljs", "mila", "nodata", "OPENAI", "pinia", "Popconfirm", "rushstack", "Sider", "tailwindcss", "traptitech", "tsup", "Typecheck", "unplugin", "VITE", "vueuse", "Zhao" ], "i18n-ally.enabledParsers": [ "ts" ], "i18n-ally.sortKeys": true, "i18n-ally.keepFulfilled": true, "i18n-ally.localesPaths": [ "src/locales" ], "i18n-ally.keystyle": "nested" } ================================================ FILE: CONTRIBUTING.md ================================================ # 贡献指南 感谢你的宝贵时间。你的贡献将使这个项目变得更好!在提交贡献之前,请务必花点时间阅读下面的入门指南。 ## 语义化版本 该项目遵循语义化版本。我们对重要的漏洞修复发布修订号,对新特性或不重要的变更发布次版本号,对重大且不兼容的变更发布主版本号。 每个重大更改都将记录在 `changelog` 中。 ## 提交 Pull Request 1. Fork [此仓库](https://github.com/Chanzhaoyu/chatgpt-web),从 `main` 创建分支。新功能实现请发 pull request 到 `feature` 分支。其他更改发到 `main` 分支。 2. 使用 `npm install pnpm -g` 安装 `pnpm` 工具。 3. `vscode` 安装了 `Eslint` 插件,其它编辑器如 `webStorm` 打开了 `eslint` 功能。 4. 根目录下执行 `pnpm bootstrap`。 5. `/service/` 目录下执行 `pnpm install`。 6. 对代码库进行更改。如果适用的话,请确保进行了相应的测试。 7. 请在根目录下执行 `pnpm lint:fix` 进行代码格式检查。 8. 请在根目录下执行 `pnpm type-check` 进行类型检查。 9. 提交 git commit, 请同时遵守 [Commit 规范](#commit-指南) 10. 提交 `pull request`, 如果有对应的 `issue`,请进行[关联](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)。 ## Commit 指南 Commit messages 请遵循[conventional-changelog 标准](https://www.conventionalcommits.org/en/v1.0.0/): ```bash <类型>[可选 范围]: <描述> [可选 正文] [可选 脚注] ``` ### Commit 类型 以下是 commit 类型列表: - feat: 新特性或功能 - fix: 缺陷修复 - docs: 文档更新 - style: 代码风格或者组件样式更新 - refactor: 代码重构,不引入新功能和缺陷修复 - perf: 性能优化 - test: 单元测试 - chore: 其他不修改 src 或测试文件的提交 ## License [MIT](./license) ================================================ FILE: Dockerfile ================================================ # build front-end FROM node:lts-alpine AS builder COPY ./ /app WORKDIR /app RUN apk add --no-cache git \ && npm install pnpm -g \ && pnpm install \ && pnpm run build \ && rm -rf /root/.npm /root/.pnpm-store /usr/local/share/.cache /tmp/* ================================================ FILE: README.md ================================================ # ChatGPT Web ### 使用界面 ![cover3](./docs/c3.png) ![cover](./docs/c1.png) ![cover2](./docs/c2.png) ### 性格微调功能 ![cover4](./docs/c4.png) ### 文本审查功能(使用OpenAI官方接口) ![cover5](./docs/c5.png) - [ChatGPT Web](#chatgpt-web) - [介绍](#介绍) - [快速部署](#快速部署) - [开发环境搭建](#开发环境搭建) - [Node](#Node) - [Python](#Python) - [开发环境启动项目](#开发环境启动项目) - [启动前端服务](#启动前端服务) - [启动后端服务](#启动后端服务) - [打包和部署](#打包和部署) - [前端打包](#前端资源打包(需要安装node)) - [后端打包](#后端服务打包为docker容器(需要安装docker和docker-compose)) - [使用DockerCompose启动](#使用DockerCompose启动) - [常见问题](#常见问题) - [参与贡献](#参与贡献) - [赞助原作者](#赞助) - [License](#license) ## 介绍 这是一个可以私有化部署的`ChatGpt`网页,使用`OpenAI`的官方API接入`gpt-3.5`或`gpt-4`模型来实现接近`ChatGPT Plus`的对话效果。 源代码Fork和修改于[Chanzhaoyu/chatgpt-web](https://github.com/Chanzhaoyu/chatgpt-web/) 与OpenAI官方提供`ChatGPT Plus`对比,`ChatGPT Web`有以下优势: 1. **省钱(仅限`gpt-3.5`)**。 - 按照日常用量,你可以用1折左右的价格,体验与`ChatGPT Plus`几乎相同的对话服务。 - 语音识别可以在本地离线完成,完全免费。 2. **0门槛使用**。你可以将自建的`ChatGPT Web` 站点分享给家人和朋友,他们不再需要解决网络问题,就可以轻松享受到`ChatGPT Plus`带来的生产力提升。 3. **可以缓解网络封锁的影响**。`ChatGPT Web`只需要一个`OpenAI API Key`即可使用,如果你所在的地区无法访问`OpenAI` ,你可以将`ChatGPT Web`部署在海外服务器上,或在当地服务器上配置`socks_proxy`参数来转发请求给代理软件,即可正常使用。 和[Chanzhaoyu的原版](https://github.com/Chanzhaoyu/chatgpt-web/)的主要区别: 1. 专注于易用、易部署、不操心,我将尽我所能做到对小白用户友好,因此本项目也会舍弃一些专业功能,例如: - 不支持accessToken这类非官方使用方式。我认为你只是希望有个稳定能用的AI助手,并不想折腾这些 - 不支持反向代理。第三方的反代地址安全性存疑、封号也超级快! - 不做导入和管理Prompt模板的功能。使用者没有“这个链接干什么用的”、“什么是json文件”、“为什么要我自己审核json文件的安全性,怎么审核?”此类烦恼 2. 可以识别语音消息:通过OpenAI官方`whisper-1`接口,或免费的`whisper.cpp`实现。懒得打字的时候很好用 3. 可以调整`ChatGpt`的性格 4. 可以调整记住的上下文数量 其它区别: 1. 增加日语界面 2. 优化了移动端体验 ## 快速部署 如果你不需要自己开发,只需要部署使用,可以直接跳到 [使用最新版本docker镜像启动](#使用最新版本docker镜像启动) ## 开发环境搭建 ### Node `node` 需要 `^16 || ^18` 版本(`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` ```shell npm install pnpm -g ``` ### Python `python` 需要 `3.10` 以上版本,进入文件夹 `/service` 运行以下命令 ```shell pip install --no-cache-dir -r requirements.txt ``` ### whisper.cpp编译 这一步的目的是设置本地语音识别功能,这个模块来自[ggerganov/whisper](https://github.com/ggerganov/whisper.cpp) 如果你的系统是windows,可以跳过这一步,因为whisper的二进制文件,我已经为你下载好,放在项目里了; 如果你的系统是linux,则需要你自己编译whisper: ```shell cd ./tools/local-whisper/linux/ chmod +x init.sh ./init.sh ``` `init.sh`脚本执行完成之后,你将看到`./tools/local-whisper/linux/whisper.cpp-master/`目录,目录中有个叫`main`的二进制文件,这就是你需要的`whisper`。 ## 开发环境启动项目 ### 启动前端服务 根目录下运行以下命令 ```shell # 前端网页的默认端口号是1002,对接的后端服务的默认端口号是3002,可以在 .env 和 .vite.config.ts 文件中修改 pnpm bootstrap pnpm dev ``` ### 启动后端服务 只有`--openai_api_key`是必填的启动参数,需要先去[OpenAI](https://platform.openai.com/) 注册账号,然后在[这里](https://platform.openai.com/account/api-keys)获取`OPENAI_API_KEY`。 ```shell # 进入文件夹 `/service` 运行以下命令 python main.py --openai_api_key="$OPENAI_API_KEY" ``` 除了`openai_api_key`这个必填的参数之外,还有以下可选参数可用: - `openai_timeout_ms` 访问OpenAI的超时时间(毫秒),默认值为 '100000' - `api_model` 默认值为 gpt-3.5-turbo 也可以使用很贵的 gpt-4 - `socks_proxy` 代理,默认值为空字符串,格式示例: `http://127.0.0.1:10808` - `use_local_whisper` 设置为`true`可以使用离线模型来完成语音识别,如果设置为`false`就会使用OpenAI的API进行语音识别,默认值为: `true` - `host` HOST,默认值为 0.0.0.0 - `port` PORT,默认值为 3002 也就是说你也可以这样启动 ```shell python main.py --openai_api_key="$OPENAI_API_KEY" --openai_timeout_ms="$OPENAI_TIMEOUT_MS" --api_model="$API_MODEL" --socks_proxy="$SOCKS_PROXY" --use_local_whisper="$USE_LOCAL_WHISPER" --host="$HOST" --port="$PORT" ``` ## 打包和部署 ### 前端资源打包(需要安装node) 1. 根目录下运行以下命令 ```shell pnpm run build ``` 2. 将打包好的文件夹`dist`文件夹复制到`/docker-compose/nginx`目录下,并改名为`html` ```shell cp dist/ docker-compose/nginx/html -r ``` 3. 配置访问权限 ```shell # 进入文件夹 `/docker-compose/nginx` cd docker-compose/nginx # 运行add_user.sh脚本,根据提示创建用户名和密码 # (密码文件将被保存在 /docker-compose/nginx/auth/.htpasswd) bash add_user.sh # 如果你想删除一个用户,可以使用remove_user.sh脚本 bash remove_user.sh ``` ### 后端服务打包为docker容器(需要安装docker和docker-compose) 1. 进入文件夹 `/service` 运行以下命令 ```shell docker build -t chatgpt-web-backend . ``` ### 使用DockerCompose启动 - 进入文件夹 `/docker-compose` 修改 `docker-compose.yml` 文件 ``` version: '3' services: app: image: chatgpt-web-backend # 这里填你打包的后端服务的镜像名字 ports: - 3002:3002 environment: OPENAI_API_KEY: your_openai_api_key # 访问OpenAI的超时时间(毫秒),可选,默认值为 '100000' OPENAI_TIMEOUT_MS: '100000' # 可选,默认值为 gpt-3.5-turbo API_MODEL: gpt-3.5-turbo # Socks代理,可选,格式为 http://127.0.0.1:10808 SOCKS_PROXY: '' # 可选,将USE_LOCAL_WHISPER设置为`true`可以使用离线模型来完成语音识别,如果设置为`false`就会使用OpenAI的API进行语音识别,默认值为: `true` # 使用离线模型就不需要向OpenAI付费,但会额外消耗一些服务器的cpu和内存资源,这里使用的是tiny模型,工作时占用的内存大概是125MB左右 # 具体性能消耗参考whisper.cpp的官方文档: https://github.com/ggerganov/whisper.cpp#memory-usage USE_LOCAL_WHISPER: 'true' # HOST,可选,默认值为 0.0.0.0 HOST: 0.0.0.0 # PORT,可选,默认值为 3002 PORT: 3002 nginx: depends_on: - app image: nginx:alpine ports: - '80:80' expose: - '80' volumes: - ./nginx/html/:/etc/nginx/html/ - ./nginx/auth/:/etc/nginx/auth/ - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf links: - app ``` - 进入文件夹 `/docker-compose` 运行以下命令 ```shell # 前台运行 docker-compose up # 或后台运行 docker-compose up -d ``` **建议先在前台运行试用一下,看看有没有报错,如果启动和使用都没有问题,再改成后台运行。** 启动成功之后,访问`http://你的机器ip`即可看到网页内容。 ## 使用最新版本docker镜像启动 - 如果你只是想自己使用docker部署,可以直接使用我已经打包好的镜像和前端资源 - 首先将前端资源的压缩包解压 前端资源的压缩包在`/docker-compose/nginx/html.zip`,使用`unzip html.zip`将其解压缩。你应该可以看到`/docker-compose/nginx/html`下面有`index.html`和其他前端资源。 - 然后给你的网站添加用户名和密码 ```shell # 进入文件夹 `/docker-compose/nginx` cd docker-compose/nginx # 运行add_user.sh脚本,根据提示创建用户名和密码 # (密码文件将被保存在 /docker-compose/nginx/auth/.htpasswd) bash add_user.sh # 如果你想删除一个用户,可以使用remove_user.sh脚本 bash remove_user.sh ``` - 最后修改`docker-compose/docker-compose.yml`文件。 除了填写你自己的`OPENAI_API_KEY`之外,还要根据自己的系统环境修改`image`的标签,如果你部署用的是x86架构的机器,就填写`wenjing95/chatgpt-web-backend:x86_64`,如果你用的是arm架构的机器,就填写`wenjing95/chatgpt-web-backend:aarch64`。 ``` version: '3' services: app: # 根据自己的系统选择x86_64还是aarch64 image: wenjing95/chatgpt-web-backend:x86_64 # image: wenjing95/chatgpt-web-backend:aarch64 ports: - 3002:3002 environment: # 记得填写你的OPENAI_API_KEY OPENAI_API_KEY: your_openai_api_key # 访问OpenAI的超时时间(毫秒),可选,默认值为 '100000' OPENAI_TIMEOUT_MS: '100000' # 可选,默认值为 gpt-3.5-turbo API_MODEL: gpt-3.5-turbo # Socks代理,可选,格式为 http://127.0.0.1:10808 SOCKS_PROXY: '' # 可选,将USE_LOCAL_WHISPER设置为`true`可以使用离线模型来完成语音识别,如果设置为`false`就会使用OpenAI的API进行语音识别,默认值为: `true` # 使用离线模型就不需要向OpenAI付费,但会额外消耗一些服务器的cpu和内存资源,这里使用的是tiny模型,工作时占用的内存大概是125MB左右 # 具体性能消耗参考whisper.cpp的官方文档: https://github.com/ggerganov/whisper.cpp#memory-usage USE_LOCAL_WHISPER: 'true' # HOST,可选,默认值为 0.0.0.0 HOST: 0.0.0.0 # PORT,可选,默认值为 3002 PORT: 3002 nginx: depends_on: - app image: nginx:alpine ports: - '80:80' expose: - '80' volumes: - ./nginx/html/:/etc/nginx/html/ - ./nginx/auth/:/etc/nginx/auth/ - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf links: - app ``` - 最后进入文件夹 `/docker-compose` 运行以下命令 ```shell # 前台运行 docker-compose up # 或后台运行 docker-compose up -d ``` **建议先在前台运行试用一下,看看有没有报错,如果启动和使用都没有问题,再改成后台运行。** 启动成功之后,访问`http://你的机器ip`即可看到网页内容。 ## 常见问题 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 配置同理。 Q: 为什么录音功能不能用? A: 录音需要https环境,推荐使用cloudflare的免费https证书。 Q: build docker容器的时候,显示`exec entrypoint.sh: no such file or directory`? A: 因为`entrypoint.sh`文件的换行符是`LF`,而不是`CRLF`,如果你用`CRLF`的IDE操作过这个文件,可能就会出错。可以使用`dos2unix`工具将`LF`换成`CRLF`。 ## 参与贡献 贡献之前请先阅读 [贡献指南](./CONTRIBUTING.md) 感谢原作者[Chanzhaoyu](https://github.com/Chanzhaoyu/chatgpt-web/)和所有做过贡献的人,还有生产力工具`ChatGpt` 和`Github Copilot`! ## 赞助 如果你觉得这个项目对你有帮助,请给我点个Star。 如果情况允许,请支持原作者[Chanzhaoyu](https://github.com/Chanzhaoyu/chatgpt-web/) ## License MIT © [WenJing95](./license) ================================================ FILE: config/index.ts ================================================ export * from './proxy' ================================================ FILE: config/proxy.ts ================================================ import type { ProxyOptions } from 'vite' export function createViteProxy(isOpenProxy: boolean, viteEnv: ImportMetaEnv) { if (!isOpenProxy) return const proxy: Record = { '/api': { target: viteEnv.VITE_APP_API_BASE_URL, changeOrigin: true, rewrite: path => path.replace('/api/', '/'), }, } return proxy } ================================================ FILE: docker-compose/docker-compose.yml ================================================ version: '3' services: app: # 根据自己的系统选择x86_64还是aarch64 image: wenjing95/chatgpt-web-backend:x86_64 # image: wenjing95/chatgpt-web-backend:aarch64 ports: - 3002:3002 environment: OPENAI_API_KEY: '' # 访问OpenAI的超时时间,可选,默认值为 '100000' OPENAI_TIMEOUT_MS: '100000' # 可选,默认值为 gpt-3.5-turbo API_MODEL: gpt-3.5-turbo # Socks代理,可选,格式为 http://127.0.0.1:10808 SOCKS_PROXY: '' # 可选,将USE_LOCAL_WHISPER设置为`true`可以使用离线模型来完成语音识别,如果设置为`false`就会使用OpenAI的API进行语音识别,默认值为: `true` # 使用离线模型就不需要向OpenAI付费,但会额外消耗一些服务器的cpu和内存资源,这里使用的是tiny模型,工作时占用的内存大概是125MB左右 # 具体性能消耗参考whisper.cpp的官方文档: https://github.com/ggerganov/whisper.cpp#memory-usage USE_LOCAL_WHISPER: 'true' # HOST,可选,默认值为 0.0.0.0 HOST: 0.0.0.0 # PORT,可选,默认值为 3002 PORT: 3002 nginx: depends_on: - app image: nginx:alpine ports: - '80:80' expose: - '80' volumes: - ./nginx/html/:/etc/nginx/html/ - ./nginx/auth/:/etc/nginx/auth/ - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf links: - app ================================================ FILE: docker-compose/nginx/add_user.sh ================================================ #!/bin/bash read -p "Enter your desired username: " username read -s -p "Enter your desired password: " password htpasswd_file="./auth/.htpasswd" if [ ! -f "$htpasswd_file" ]; then sudo touch "$htpasswd_file" fi sudo sh -c "echo -n '${username}:' >> $htpasswd_file" sudo sh -c "openssl passwd -apr1 ${password} >> $htpasswd_file" echo "Username: ${username}" echo "Password: ${password}" echo ".htpasswd file location: ${htpasswd_file}" ================================================ FILE: docker-compose/nginx/auth/.htpasswd ================================================ ================================================ FILE: docker-compose/nginx/nginx.conf ================================================ server { listen 80; server_name localhost; charset utf-8; error_page 500 502 503 504 /50x.html; auth_basic "Restricted Access"; auth_basic_user_file /etc/nginx/auth/.htpasswd; location / { root /etc/nginx/html/; try_files $uri /index.html; } location /api/ { proxy_set_header X-Real-IP $remote_addr; proxy_pass http://app:3002/; proxy_buffering off; client_max_body_size 3M; } proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } ================================================ FILE: docker-compose/nginx/remove_user.sh ================================================ #!/bin/bash htpasswd_file="./auth/.htpasswd" if [ ! -f "$htpasswd_file" ]; then echo "No .htpasswd file found" exit 1 fi read -p "Enter the username you want to remove: " username # Check if the user already exists in the file if ! grep -q "${username}:" "$htpasswd_file"; then echo "User not found in .htpasswd file" exit 1 fi # Delete the user from the file sudo sed -i "/${username}:/d" "$htpasswd_file" echo "User ${username} has been removed from the .htpasswd file." ================================================ FILE: index.html ================================================ ChatGPT Web
================================================ FILE: license ================================================ MIT License Copyright (c) 2023 WenJing Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: package.json ================================================ { "name": "chatgpt-web", "version": "2.10.9", "private": false, "description": "ChatGPT Web", "author": "WenJing", "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", "katex": "^0.16.4", "markdown-it": "^13.0.1", "naive-ui": "^2.34.3", "pinia": "^2.0.32", "recorder-core": "^1.2.23020100", "vite-plugin-pwa": "^0.14.4", "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", "@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", "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", "rimraf": "^4.2.0", "tailwindcss": "^3.2.7", "typescript": "~4.9.5", "vite": "^4.1.4", "vite-plugin-pwa": "^0.14.4", "vue-tsc": "^1.2.0" }, "lint-staged": { "*.{ts,tsx,vue}": [ "pnpm lint:fix" ] } } ================================================ FILE: postcss.config.js ================================================ module.exports = { plugins: { tailwindcss: {}, autoprefixer: {}, }, } ================================================ FILE: service/Dockerfile ================================================ # 使用 Python 3.10 作为基础镜像 FROM python:3.10 # 设置工作目录 WORKDIR /app # 复制项目文件到容器中 COPY . . # ==rust是tiktoken依赖的环境== RUN curl https://sh.rustup.rs -sSf | sh -s -- -y ENV PATH="/root/.cargo/bin:$PATH" # 编译whisper WORKDIR ./tools/local-whisper/linux/ RUN chmod +x init.sh RUN ./init.sh WORKDIR /app # 下载whisper使用的模型 #wget -P ./tools/local-whisper/model https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin # 安装依赖 RUN pip install --upgrade pip && pip install --upgrade --no-cache-dir -r requirements.txt # 设置环境变量 ENV OPENAI_API_KEY="" ENV OPENAI_TIMEOUT_MS="100000" ENV API_MODEL="gpt-3.5-turbo" ENV SOCKS_PROXY="" ENV USE_LOCAL_WHISPER: 'true' ENV HOST="127.0.0.1" ENV PORT=3002 # 复制 entrypoint.sh 脚本到容器中 COPY entrypoint.sh . # 修改 entrypoint.sh 的权限 RUN chmod +x entrypoint.sh # 启动命令 ENTRYPOINT ["./entrypoint.sh"] ================================================ FILE: service/api_model.py ================================================ class ApiModel: KNOWN_API_MODEL_NAMES = ["gpt-3.5-turbo", "gpt-4", "gpt-3.5-turbo-16k", # "gpt-4-32k" ] KNOWN_API_MODEL_MAX_TOKENS = {"gpt-3.5-turbo": 4000, "gpt-4": 8000, "gpt-3.5-turbo-16k": 16000, # "gpt-4-32k": 32000 } @classmethod def get_api_model_name(cls, api_model_name: str, default_api_model_name: str) -> str: return api_model_name if api_model_name in cls.KNOWN_API_MODEL_NAMES else default_api_model_name @classmethod def get_max_token(cls, api_model_name: str, default_max_token: int) -> int: return cls.KNOWN_API_MODEL_MAX_TOKENS.get(api_model_name, default_max_token) ================================================ FILE: service/chatgpt_wapper.py ================================================ import random import time import openai # for OpenAI API calls import traceback import json import asyncio from loguru import logger from backoff import on_exception, expo from tools.openai_token_control import discard_overlimit_messages import concurrent.futures from errors import Errors base = {"role": "system", "content": "You are a helpful assistant."} async def process(prompt, options, memory_count, top_p, message_store, timeout, max_token, model="gpt-3.5-turbo"): """ 发文字消息 """ # 不能是空消息 if not prompt: logger.error("Prompt is empty.") yield Errors.PROMPT_IS_EMPTY.value return # 内容审查 moderation_params = dict( model='text-moderation-stable', input=prompt, ) moderation_res = await _moderation_create_async(moderation_params) if moderation_res is None: yield Errors.SOMETHING_WRONG_IN_OPENAI_MODERATION_API.value return try: if moderation_res.results[0].flagged: warning_text = "[This content does not comply with OpenAI's usage policy. : {} ]".format( prompt ) logger.warning(warning_text) yield Errors.NOT_COMPLY_POLICY.value return chat = {"role": "user", "content": prompt} # 组合历史消息 if options: parent_message_id = options.get("parentMessageId") messages = message_store.get_from_key(parent_message_id) if messages: messages.append(chat) else: messages = [base, chat] else: parent_message_id = None messages = [base, chat] # 记忆 messages = messages[-memory_count:] # 消息不能超过token限制 # todo 压缩过去消息 messages = discard_overlimit_messages(messages, model, max_token) # send to OpenAI params = dict( stream=True, messages=messages, model=model, top_p=top_p, timeout=timeout ) if parent_message_id: params["request_id"] = parent_message_id res = await _chat_completions_create_async(params) if res is None: yield Errors.SOMETHING_WRONG_IN_OPENAI_GPT_API.value return # 处理结果 text = "" role = "" prev_message_dict = {} for openai_object in res: openai_object_dict = openai_object.to_dict_recursive() prev_message_dict = openai_object_dict if not role: role = openai_object_dict["choices"][0]["delta"].get("role", "") text_delta = openai_object_dict["choices"][0]["delta"].get("content", "") text += text_delta message = json.dumps(dict( role=role, id=openai_object_dict["id"], parentMessageId=parent_message_id, text=text, # delta=text_delta, # detail=dict( # id=openai_object_dict["id"], # object=openai_object_dict["object"], # # created=openai_object_dict["created"], # # model=openai_object_dict["model"], # # choices=openai_object_dict["choices"] # ) )) yield "data: " + message except: err = traceback.format_exc() logger.error(err) yield Errors.SOMETHING_WRONG.value return try: # save to cache chat = {"role": role, "content": text} messages.append(chat) parent_message_id = prev_message_dict["id"] message_store.set(parent_message_id, messages) except: err = traceback.format_exc() logger.error(err) @on_exception(expo, openai.error.RateLimitError, max_tries=5) def _moderation_create(params): return openai.Moderation.create(**params) async def _moderation_create_async(params): with concurrent.futures.ThreadPoolExecutor() as executor: try: result = await asyncio.get_event_loop().run_in_executor( executor, _moderation_create, params ) except: err = traceback.format_exc() logger.error(err) # 这里处理 openai.error.RateLimitError 之外的错误 return None return result @on_exception(expo, openai.error.RateLimitError, max_tries=5) def _chat_completions_create(params): return openai.ChatCompletion.create(**params) async def _chat_completions_create_async(params): with concurrent.futures.ThreadPoolExecutor() as executor: try: result = await asyncio.get_event_loop().run_in_executor( executor, _chat_completions_create, params ) except: err = traceback.format_exc() logger.error(err) # 这里处理 openai.error.RateLimitError 之外的错误 return None return result ================================================ FILE: service/entrypoint.sh ================================================ #!/bin/bash # 将环境变量传递给 Python 脚本 export OPENAI_API_KEY="$OPENAI_API_KEY" export OPENAI_TIMEOUT_MS="$OPENAI_TIMEOUT_MS" export API_MODEL="$API_MODEL" export SOCKS_PROXY="$SOCKS_PROXY" export USE_LOCAL_WHISPER="$USE_LOCAL_WHISPER" export HOST="$HOST" export PORT="$PORT" # 启动 Python 脚本 python main.py --openai_api_key="$OPENAI_API_KEY" --openai_timeout_ms="$OPENAI_TIMEOUT_MS" --api_model="$API_MODEL" --socks_proxy="$SOCKS_PROXY" --use_local_whisper="$USE_LOCAL_WHISPER" --host="$HOST" --port="$PORT" ================================================ FILE: service/errors.py ================================================ from enum import Enum class Errors(Enum): SOMETHING_WRONG = "ChatGptWebServerError:SomethingWrong" SOMETHING_WRONG_IN_OPENAI_GPT_API = "ChatGptWebServerError:SomethingWrongInOpenaiGptApi" SOMETHING_WRONG_IN_OPENAI_MODERATION_API = "ChatGptWebServerError:SomethingWrongInOpenaiModerationApi" SOMETHING_WRONG_IN_OPENAI_WHISPER_API = "ChatGptWebServerError:SomethingWrongInOpenaiWhisperApi" SOMETHING_WRONG_IN_LOCAL_WHISPER = "ChatGptWebServerError:SomethingWrongInLocalWhisper" UNKNOWN_WHISPER_SETTING = "ChatGptWebServerError:UnknownWhisperSetting" NOT_COMPLY_POLICY = "ChatGptWebServerError:NotComplyPolicy" PROMPT_IS_EMPTY = "ChatGptWebServerError:PromptIsEmpty" ================================================ FILE: service/main.py ================================================ import openai import os from os.path import abspath, dirname from loguru import logger from chatgpt_wapper import process from fastapi import FastAPI, UploadFile, File from fastapi.responses import JSONResponse, StreamingResponse import uvicorn from message_store import MessageStore from whisper_wapper import process_audio_local, process_audio_api import argparse from api_model import ApiModel import platform from pathlib import Path log_folder = os.path.join(abspath(dirname(__file__)), "log") logger.add(os.path.join(log_folder, "{time}.log"), level="INFO") DEFAULT_TIMEOUT_MS_STRING = "100000" MIN_TIMEOUT_MS = 15000 DEFAULT_DB_SIZE = 100000 DEFAULT_API_MODEL = "gpt-3.5-turbo" DEFAULT_MAX_TOKEN = 4000 CURRENT_OS = platform.system() USE_LOCAL_WHISPER = True current_file_path = Path(__file__).resolve() base_directory = current_file_path.parent AUDIO_TMP_PATH = base_directory / "audio_tmp" LOCAL_WHISPER_MODEL_PATH = base_directory / "tools" / "local-whisper" / "model" / "ggml-tiny.bin" LOCAL_WHISPER_BIN_PATH_LINUX = base_directory / "tools" / "local-whisper" / "linux" / "whisper.cpp-master" / "main" LOCAL_WHISPER_BIN_PATH_WINDOWS = base_directory / "tools" / "local-whisper" / "windows" / "main.exe" massage_store = MessageStore(db_path="message_store.json", table_name="chatgpt", max_size=DEFAULT_DB_SIZE) app = FastAPI() stream_response_headers = { "Content-Type": "application/octet-stream", "Cache-Control": "no-cache", } @app.post("/config") async def config(): return JSONResponse(content=dict( message=None, status="Success", data=dict( apiModel=API_MODEL, socksProxy=SOCKS_PROXY, timeoutMs=OPENAI_TIMEOUT * 1000, ) )) @app.post("/chat-process") async def chat_process(request_data: dict): prompt = request_data["prompt"] options = request_data["options"] if 1 == request_data["memory"]: memory_count = 5 elif 50 == request_data["memory"]: memory_count = 20 else: memory_count = 999 if 1 == request_data["top_p"]: top_p = 0.2 elif 50 == request_data["top_p"]: top_p = 0.6 else: top_p = 1 answer_text = process(prompt, options, memory_count, top_p, MASSAGE_STORE, OPENAI_TIMEOUT, MAX_TOKEN, model=API_MODEL) return StreamingResponse(content=answer_text, headers=stream_response_headers, media_type="text/event-stream") @app.post("/audio-chat-process") async def audio_chat_process(audio: UploadFile = File(...)): if USE_LOCAL_WHISPER: local_whisper_bin_path = LOCAL_WHISPER_BIN_PATH_LINUX if "Linux" == CURRENT_OS else LOCAL_WHISPER_BIN_PATH_WINDOWS prompt = process_audio_local(audio, audio_tmp_path=AUDIO_TMP_PATH, model_path=LOCAL_WHISPER_MODEL_PATH, local_whisper_bin_path=local_whisper_bin_path) else: prompt = process_audio_api(audio, timeout=OPENAI_TIMEOUT) return StreamingResponse(content=prompt, headers=stream_response_headers, media_type="text/event-stream") def init_config(): # 读取配置 parser = argparse.ArgumentParser(description='') parser.add_argument('--openai_api_key', type=str, help='API key for OpenAI') parser.add_argument('--api_model', type=str, default="gpt-3.5-turbo", help='OpenAI API model, default is gpt-3.5-turbo') parser.add_argument('--socks_proxy', type=str, default="", help='Socks proxy, default is "", e.g. http://127.0.0.1:10808') parser.add_argument('--timeout_ms', type=str, default=DEFAULT_TIMEOUT_MS_STRING, help="(Deprecate) Timeout for OpenAI API, default is '100000'") parser.add_argument('--openai_timeout_ms', type=str, default=DEFAULT_TIMEOUT_MS_STRING, help="Timeout for OpenAI API, default is '100000'") parser.add_argument('--use_local_whisper', type=str, default='True', help="Use local whisper or api whisper. local whisper is free, api whisper is best," " default is 'True'") parser.add_argument('--host', type=str, default="0.0.0.0", help='Host for server, default is 0.0.0.0') parser.add_argument('--port', type=str, default="3002", help="Port for server, default is '3002'") args = parser.parse_args() if not args.openai_api_key: err = "OpenAI API key is not found. use --openai_api_key to set it." logger.error(err) raise TypeError(err) openai_api_key = args.openai_api_key openai.api_key = args.openai_api_key input_api_model = args.api_model if not input_api_model: err = "API model is not found." logger.error(err) raise TypeError(err) if input_api_model not in ApiModel.KNOWN_API_MODEL_NAMES: warning = "Unknown Api model '{}'. Legal settings are {}, The system has automatically set it to {}".format( input_api_model, ApiModel.KNOWN_API_MODEL_NAMES, DEFAULT_API_MODEL ) logger.warning(warning) api_model = ApiModel.get_api_model_name(input_api_model, DEFAULT_API_MODEL) max_token = ApiModel.get_max_token(api_model, DEFAULT_MAX_TOKEN) socks_proxy = args.socks_proxy if socks_proxy: logger.info("Socks proxy is enabled.") logger.info("Socks proxy is {}.".format(socks_proxy)) openai.proxy = socks_proxy else: logger.info("Socks proxy is disabled.") if DEFAULT_TIMEOUT_MS_STRING != args.timeout_ms: logger.warning("The parameter '--timeout_ms' is deprecated, please use '--openai_timeout_ms' instead.") args.openai_timeout_ms = args.timeout_ms openai_timeout_ms = args.openai_timeout_ms or DEFAULT_TIMEOUT_MS_STRING if isinstance(openai_timeout_ms, str): try: openai_timeout_ms = int(openai_timeout_ms) except: openai_timeout_ms = DEFAULT_TIMEOUT_MS_STRING if openai_timeout_ms < MIN_TIMEOUT_MS: openai_timeout_ms = MIN_TIMEOUT_MS logger.warning( "OpenAI timeout is too short, the system has automatically set it to {openai_timeout_ms}(ms).".format( openai_timeout_ms=MIN_TIMEOUT_MS)) openai_timeout = openai_timeout_ms / 1000 # service_timeout_ms = args.service_timeout_ms or 100000 # if isinstance(service_timeout_ms, str): # try: # service_timeout_ms = int(service_timeout_ms) # except: # service_timeout_ms = 100000 # if service_timeout_ms < 15000: # service_timeout_ms = 15000 # logger.warning("Service timeout is too short, the system has automatically set it to 15000(ms).") # service_timeout = service_timeout_ms / 1000 # if openai_timeout_ms > service_timeout_ms: # openai_timeout = service_timeout # logger.warning( # "OpenAI timeout is longer than service timeout, the system has automatically set it to the same as service timeout.") host = args.host or "0.0.0.0" port = args.port or 3002 if isinstance(port, str): try: port = int(port) except: err = "Port must be a number." logger.error(err) raise TypeError(err) use_local_whisper = True if args.use_local_whisper in ['True', 'true'] else False AUDIO_TMP_PATH.mkdir(parents=True, exist_ok=True) return massage_store, openai_api_key, host, port, api_model, \ max_token, socks_proxy, openai_timeout, use_local_whisper if __name__ == "__main__": MASSAGE_STORE, OPENAI_API_KEY, HOST, PORT, API_MODEL, MAX_TOKEN, SOCKS_PROXY, OPENAI_TIMEOUT, USE_LOCAL_WHISPER = init_config() logger.info("OPENAI_API_KEY:{}".format(OPENAI_API_KEY)) logger.info("HOST:{}".format(HOST)) logger.info("PORT:{}".format(PORT)) logger.info("API_MODEL:{}".format(API_MODEL)) logger.info("MAX_TOKEN:{}".format(MAX_TOKEN)) logger.info("SOCKS_PROXY:{}".format(SOCKS_PROXY)) logger.info("OPENAI_TIMEOUT_MS:{}".format(OPENAI_TIMEOUT * 1000)) logger.info("USE_LOCAL_WHISPER:{}".format(USE_LOCAL_WHISPER)) logger.info("AUDIO_TMP_PATH:{}".format(AUDIO_TMP_PATH)) uvicorn.run(app, host=HOST, port=PORT) ================================================ FILE: service/message_store.py ================================================ import time from tinydb import TinyDB, Query class MessageStore: def __init__(self, db_path, table_name, max_size=100000): self.db = TinyDB(db_path) self.table = self.db.table(table_name) self.max_size = max_size def set(self, key, value): if len(self.table) >= self.max_size: self._delete_oldest() self.table.insert({'key': key, 'value': value, 'timestamp': time.time()}) def get_from_key(self, key): query = Query() result = self.table.get(query.key == key) if result is None: return None return result['value'] def _delete_oldest(self): records = self.table.all() if len(records) >= self.max_size: oldest_record = sorted(records, key=lambda r: r['timestamp'])[0] self.table.remove(doc_ids=[oldest_record.doc_id]) ================================================ FILE: service/tools/__init__.py ================================================ ================================================ FILE: service/tools/local-whisper/__init__.py ================================================ ================================================ FILE: service/tools/local-whisper/linux/init.sh ================================================ sudo apt update sudo apt install build-essential -y wget https://github.com/ggerganov/whisper.cpp/archive/refs/heads/master.zip unzip master.zip rm master.zip cd whisper.cpp-master || exit make ================================================ FILE: service/tools/openai_token_control.py ================================================ import tiktoken MAX_RANGES = 10 def discard_overlimit_messages(messages: list, model: str, max_token: int) -> list: """ Discards messages that exceed the maximum number of tokens allowed by OpenAI. """ range_count = 0 while True: # 如果消息太少,就不处理 if len(messages) <= 2: return messages # 防止意外(例如num_tokens_from_messages的实现没有及时跟随openai更新)出现死循环 if range_count > MAX_RANGES: return messages token_count = num_tokens_from_messages(messages, model=model) range_count += 1 if token_count <= max_token: return messages else: # 去掉过去的一半消息,给回答留下足够空间 # 通常来说问题比较短,回复比较长,如果只去掉最远的1、2条消息,可能会导致问题占了大部分token,比方说4090个 # 在最大token只能有4096个的情况下,回复只能有6个token,这样就会导致回复被截断 messages = messages[int(len(messages) / 2):] continue def num_tokens_from_string(string: str, encoding_name: str) -> int: """Returns the number of tokens in a text string.""" encoding = tiktoken.get_encoding(encoding_name) num_tokens = len(encoding.encode(string)) return num_tokens def num_tokens_from_messages(messages, model="gpt-3.5-turbo"): """Returns the number of tokens used by a list of messages.""" try: encoding = tiktoken.encoding_for_model(model) except KeyError: print("Warning: model not found. Using cl100k_base encoding.") encoding = tiktoken.get_encoding("cl100k_base") if model.startswith("gpt-3.5"): tokens_per_message = 4 tokens_per_name = -1 elif model.startswith("gpt-4"): tokens_per_message = 3 tokens_per_name = 1 else: tokens_per_message = 4 tokens_per_name = -1 #raise NotImplementedError( # f"""num_tokens_from_messages() is not implemented for model {model}. See https://github.com/openai/openai-python/blob/main/chatml.md for information on how messages are converted to tokens.""") num_tokens = 0 for message in messages: num_tokens += tokens_per_message for key, value in message.items(): num_tokens += len(encoding.encode(value)) if key == "name": num_tokens += tokens_per_name num_tokens += 3 # every reply is primed with <|start|>assistant<|message|> return num_tokens if __name__ == '__main__': print(num_tokens_from_string("test", "cl100k_base")) messages = [{"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "test"}] print(num_tokens_from_messages(messages, "gpt-3.5-turbo-0301")) print(num_tokens_from_messages(messages, "gpt-4")) print(num_tokens_from_messages(messages, "gpt-3.5-turbo")) print(num_tokens_from_messages(messages, "gpt-3.5-turbo-16k")) ================================================ FILE: service/whisper_wapper.py ================================================ import openai import subprocess from backoff import on_exception, expo from io import BytesIO from typing import Optional from fastapi import UploadFile import concurrent.futures import asyncio import traceback from loguru import logger from errors import Errors import shutil from pathlib import Path import uuid def upload_file_to_file_obj(upload_file: UploadFile, file_obj: Optional[BytesIO] = None): if file_obj is None: file_obj = BytesIO() file_obj.write(upload_file.file.read()) file_obj.seek(0) file_obj.name = upload_file.filename return file_obj async def process_audio_api(audio, timeout, model="whisper-1"): try: file = upload_file_to_file_obj(audio) params = dict( model=model, file=file, request_timeout=timeout, ) func = _create transcript = await _create_async(params, func) if transcript is None: yield Errors.SOMETHING_WRONG_IN_OPENAI_WHISPER_API.value return prompt = transcript["text"] logger.debug("audio prompt: {}".format(prompt)) del audio del file except: err = traceback.format_exc() logger.error(err) yield Errors.SOMETHING_WRONG.value return if not prompt: yield Errors.PROMPT_IS_EMPTY.value return yield "data: " + prompt @on_exception(expo, openai.error.RateLimitError, max_tries=5) def _create(params): return openai.Audio.transcribe(**params) async def process_audio_local(audio, audio_tmp_path, model_path, local_whisper_bin_path): async def save_audio_file_to_disk(audio_file: UploadFile, path: Path): loop = asyncio.get_event_loop() with path.open("wb") as buffer: await loop.run_in_executor(None, shutil.copyfileobj, audio_file.file, buffer) audio_file.file.seek(0) def delete_file_if_exists(file_path: Path): if file_path.exists(): file_path.unlink() filename = "{}.wav".format(str(uuid.uuid4())) file_path = audio_tmp_path / filename try: await save_audio_file_to_disk(audio, file_path) params = dict( file_path=file_path.as_posix(), model_path=model_path.as_posix(), local_whisper_bin_path=local_whisper_bin_path.as_posix(), ) func = _local_create transcript = await _create_async(params, func) if transcript is None: yield Errors.SOMETHING_WRONG_IN_LOCAL_WHISPER.value return prompt = transcript["text"] logger.debug("audio prompt: {}".format(prompt)) # remove leading newlines and spaces prompt = prompt.lstrip("\n ") if "[BLANK_AUDIO]" in prompt: prompt = "" except: err = traceback.format_exc() logger.error(err) yield Errors.SOMETHING_WRONG.value return finally: del audio delete_file_if_exists(file_path) if not prompt: yield Errors.PROMPT_IS_EMPTY.value return yield "data: " + prompt def _local_create(params): args = [ '--language', 'auto', '--model', params["model_path"], '-f', params["file_path"], '-nt', ] command = [params["local_whisper_bin_path"]] + args result = subprocess.run(command, capture_output=True, text=True) if result.returncode == 0: logger.debug("Subprocess executed successfully.") logger.debug("Output:", result.stdout) else: logger.debug("Subprocess execution failed.") logger.debug("Error:", result.stderr) return {"text": result.stdout} async def _create_async(params, func): with concurrent.futures.ThreadPoolExecutor() as executor: try: result = await asyncio.get_event_loop().run_in_executor( executor, func, params ) except: err = traceback.format_exc() logger.error(err) return None return result ================================================ FILE: src/App.vue ================================================ ================================================ FILE: src/api/index.ts ================================================ import type { AxiosProgressEvent, GenericAbortSignal } from 'axios' import { post } from '@/utils/request' export function fetchChatConfig() { return post({ url: '/config', }) } export function fetchChatAPIProcess( params: { prompt: string memory: number top_p: number options?: { conversationId?: string; parentMessageId?: string } signal?: GenericAbortSignal onDownloadProgress?: (progressEvent: AxiosProgressEvent) => void }, ) { return post({ url: '/chat-process', data: { prompt: params.prompt, options: params.options, memory: params.memory, top_p: params.top_p, }, signal: params.signal, onDownloadProgress: params.onDownloadProgress, }) } export function fetchAudioChatAPIProcess( params: { formData: FormData options?: { conversationId?: string; parentMessageId?: string } onDownloadProgress?: (progressEvent: AxiosProgressEvent) => void }, ) { return post({ url: '/audio-chat-process', data: params.formData, onDownloadProgress: params.onDownloadProgress, }) } ================================================ FILE: src/components/common/HoverButton/Button.vue ================================================ ================================================ FILE: src/components/common/HoverButton/index.vue ================================================ ================================================ FILE: src/components/common/NaiveProvider/index.vue ================================================ ================================================ FILE: src/components/common/Setting/About.vue ================================================ ================================================ FILE: src/components/common/Setting/Advance.vue ================================================ ================================================ FILE: src/components/common/Setting/General.vue ================================================ ================================================ FILE: src/components/common/Setting/index.vue ================================================ ================================================ FILE: src/components/common/SvgIcon/index.vue ================================================ ================================================ FILE: src/components/common/UserAvatar/index.vue ================================================ ================================================ FILE: src/components/common/index.ts ================================================ 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' export { HoverButton, NaiveProvider, SvgIcon, UserAvatar, Setting } ================================================ FILE: src/components/custom/GithubSite.vue ================================================ ================================================ FILE: src/components/custom/index.ts ================================================ import GithubSite from './GithubSite.vue' export { GithubSite } ================================================ FILE: src/hooks/useBasicLayout.ts ================================================ import { breakpointsTailwind, useBreakpoints } from '@vueuse/core' export function useBasicLayout() { const breakpoints = useBreakpoints(breakpointsTailwind) const isMobile = breakpoints.smaller('sm') return { isMobile } } ================================================ FILE: src/hooks/useIconRender.ts ================================================ 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, } } ================================================ FILE: src/hooks/useLanguage.ts ================================================ import { computed } from 'vue' import { enUS, jaJP, zhCN } from 'naive-ui' import { useAppStore } from '@/store' import { setLocale } from '@/locales' export function useLanguage() { const appStore = useAppStore() const language = computed(() => { switch (appStore.language) { case 'zh-CN': setLocale('zh-CN') return zhCN case 'en-US': setLocale('en-US') return enUS case 'ja-JP': setLocale('ja-JP') return jaJP default: setLocale('zh-CN') return zhCN } }) return { language } } ================================================ FILE: src/hooks/useTheme.ts ================================================ 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(() => { 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 } } ================================================ FILE: src/locales/en-US.ts ================================================ export default { common: { delete: 'Delete', save: 'Save', reset: 'Reset', yes: 'Yes', no: 'No', noData: 'No Data', wrong: 'Something went wrong, please try again later.', success: 'Success', failed: 'Failed', about_head: 'This project was created by Chanzhaoyu, and has been forked and modified by WenJing. Its released under the MIT License.', about_body: 'If you find this helpful, please give me a star on GitHub. If you would like to make a donate, please donate to the original author Chanzhaoyu. Thank you!', }, chat: { newChat: 'New Chat', placeholder: 'Ask me anything...(Shift + Enter = line break)', placeholderMobile: 'Ask me anything...', copy: 'Copy', copied: 'Copied', copyCode: 'Copy Code', clearChat: 'Clear Chat', clearChatConfirm: 'Are you sure to clear this chat?', deleteMessage: 'Delete Message', deleteMessageConfirm: 'Are you sure to delete this message?', deleteHistoryConfirm: 'Are you sure to clear this history?', clickToTalk: 'Click to talk', clickToSend: 'Click to send', recordingInProgress: '[Recording in progress...]', openMicrophoneFailedTitle: 'Microphone Opening Failed', openMicrophoneFailedText: 'HTTPS environment and permission are required', stopResponding: 'Stop Responding', preview: 'Preview', showRawText: 'Show as raw text', }, setting: { setting: 'Setting', randomAvatar: 'Generate a random avatar', general: 'General', advance: 'Advance', about: 'About', avatarLink: 'Avatar Link', name: 'Name', description: 'Description', resetUserInfo: 'Reset UserInfo', theme: 'Theme', language: 'Language', chatgpt_memory_title: 'ChatGpt\'s memory capacity', chatgpt_memory_memo: 'The stronger the memory, the more context ChatGPT can remember during conversations, but it may consume more costs.', chatgpt_memory_choice_1: 'Normal Memory (5 logs)', chatgpt_memory_choice_2: 'Medium Memory (20 logs)', chatgpt_memory_choice_3: 'Strong Memory (all logs)', chatgpt_top_p_title: 'The personality of ChatGpt', chatgpt_top_p_1_memo: 'Tends to precise analysis, reducing the possibility of ChatGPT\'s nonsense.', chatgpt_top_p_2_memo: 'Balancing accuracy and creativity in responses.', chatgpt_top_p_3_memo: 'Brainstorming mode, tends to provide richer information.', chatgpt_top_p_choice_1: 'Accurate', chatgpt_top_p_choice_2: 'Balanced personality', chatgpt_top_p_choice_3: 'Exploratory', api: 'API', timeout: 'Timeout', socks: 'Socks', }, server: { PromptIsEmpty: 'Hello! How can I assist you today?', NotComplyPolicy: 'Sorry, the content you have sent does not comply with our usage policy. Please note that our platform prohibits the publication of content that involves harassment, discrimination, violence, pornography, and other violations of laws, regulations, and social ethics. If you have any questions, please contact the developer for further assistance. Thank you.', SomethingWrong: 'Oops, something went wrong. Please try again later.', SomethingWrongInOpenaiGptApi: 'Oops, something went wrong in OpenAI GPT API. Please try again later.', SomethingWrongInOpenaiModerationApi: 'Oops, something went wrong in OpenAI Moderation API. Please try again later.', SomethingWrongInOpenaiWhisperApi: 'Oops, something went wrong in OpenAI Whisper API. Please try again later.', SomethingWrongInLocalWhisper: 'Oops, something went wrong in local Whisper API. Please contact the developer for further assistance.', }, } ================================================ FILE: src/locales/index.ts ================================================ import type { App } from 'vue' import { createI18n } from 'vue-i18n' import en from './en-US' import cn from './zh-CN' import jp from './ja-JP' 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': en, 'zh-CN': cn, 'ja-JP': jp, }, }) export function t(key: string) { return i18n.global.t(key) } export function setLocale(locale: Language) { i18n.global.locale = locale } export function setupI18n(app: App) { app.use(i18n) } export default i18n ================================================ FILE: src/locales/ja-JP.ts ================================================ export default { common: { delete: '削除', save: '保存する', reset: 'リセット', yes: 'はい', no: 'いいえ', noData: 'データなし', wrong: '問題が発生しました。後でもう一度試してください。', success: '成功しました', failed: '失敗しました', about_head: '作成者はChanzhaoyuで、編集者はWenJingです。ライセンスはMITです。', about_body: 'もしプロジェクトが役に立った場合は、Githubでスターをつけていただくか、元の作者に寄付をご検討いただけると幸いです。', }, chat: { newChat: '新しい会話', placeholder: '何でも聞いてください...(Shift + Enter = 改行)', placeholderMobile: '何でも聞いてください...', copy: 'コピー', copied: 'コピー済み', copyCode: 'コードをコピー', clearChat: 'チャットをクリア', clearChatConfirm: 'このチャットをクリアしてもよろしいですか?', deleteMessage: 'メッセージを削除', deleteMessageConfirm: 'このメッセージを削除してもよろしいですか?', deleteHistoryConfirm: 'この履歴をクリアしてもよろしいですか?', clickToTalk: 'クリックして録音開始', clickToSend: '送信', recordingInProgress: '[録音中...]', openMicrophoneFailedTitle: 'マイクのオープンに失敗しました', openMicrophoneFailedText: 'HTTPS環境下で、設定でマイクの使用が許可されていることを確認してください', stopResponding: '応答を停止する', preview: 'プレビュー', showRawText: '生のテキストを表示する', }, setting: { setting: '設定', randomAvatar: 'アバターをランダムに生成する', general: '一般', advance: '高度な設定', about: 'このアプリについて', avatarLink: 'アバターリンク', name: '名前', description: '説明', resetUserInfo: 'ユーザー情報をリセット', theme: 'テーマ', language: '言語', chatgpt_memory_title: '記憶力', chatgpt_memory_memo: '記憶力が強いほど、ChatGptは会話中に覚えている文脈が多くなりますが、より多くのコストがかかる可能性があります。', chatgpt_memory_choice_1: '記憶力が弱い(5件)', chatgpt_memory_choice_2: '記憶力が普通(20件)', chatgpt_memory_choice_3: '記憶力が強い(すべて)', chatgpt_top_p_title: '性格', chatgpt_top_p_1_memo: '正確な分析に傾くことで、ChatGptの無意味な発言の可能性を減らします。', chatgpt_top_p_2_memo: '回答の正確さと創造性のバランスを兼ね備える。', chatgpt_top_p_3_memo: 'ブレインストーミングモードで、より豊富な情報を提供する傾向があります。', chatgpt_top_p_choice_1: '正確性重視', chatgpt_top_p_choice_2: '一石二鳥', chatgpt_top_p_choice_3: 'アイデア出し重視', api: 'API', timeout: 'タイムアウト', socks: 'ソックス', }, server: { PromptIsEmpty: 'こんにちは!今日は何かお手伝いできますか?', NotComplyPolicy: '申し訳ありませんが、送信されたコンテンツが私たちの使用ポリシーに準拠していません。当社のプラットフォームでは、ハラスメント、差別、暴力、ポルノグラフィ、その他の法律、規制、社会倫理に違反するコンテンツの投稿を禁止しています。ご質問がある場合は、開発者にお問い合わせいただくか、サポートをご利用ください。ありがとうございます。', SomethingWrong: '申し訳ありませんが、問題が発生しました。後でもう一度お試しください。', SomethingWrongInOpenaiGptApi: '申し訳ありませんが、OpenAI GPT APIへのアクセス中に問題が発生しました。後でもう一度お試しください。', SomethingWrongInOpenaiModerationApi: '申し訳ありませんが、OpenAI Moderation APIへのアクセス中に問題が発生しました。後でもう一度お試しください。', SomethingWrongInOpenaiWhisperApi: '申し訳ありませんが、OpenAI Whisper APIへのアクセス中に問題が発生しました。後でもう一度お試しください。', SomethingWrongInLocalWhisper: '申し訳ありませんが、Local Whisper APIへのアクセス中に問題が発生しました。開発者に連絡してさらなるサポートを求めてください。', }, } ================================================ FILE: src/locales/zh-CN.ts ================================================ export default { common: { delete: '删除', save: '保存', reset: '重置', yes: '是', no: '否', noData: '暂无数据', wrong: '出错了,请稍后再试', success: '操作成功', failed: '操作失败', about_head: '本项目原作者为Chanzhaoyu, 经WenJing分叉和修改,基于 MIT 协议开源。', about_body: '如果你觉得此项目对你有帮助,请在Github给我点个Star;如果你希望捐助,请捐助原作者,谢谢你!', }, chat: { newChat: '新的对话', placeholder: '有问题尽管问我...(Shift + Enter = 换行)', placeholderMobile: '有问题尽管问我...', copy: '复制', copied: '复制成功', copyCode: '复制代码', clearChat: '清空会话', clearChatConfirm: '是否清空会话?', deleteMessage: '删除消息', deleteMessageConfirm: '是否删除此消息?', deleteHistoryConfirm: '确定删除此记录?', clickToTalk: '点我开始录音', clickToSend: '正在录音,点击发送', recordingInProgress: '[正在录音...]', openMicrophoneFailedTitle: '打开麦克风失败', openMicrophoneFailedText: '需要https环境并且在设置中开启权限', stopResponding: '停止回复', preview: '预览', showRawText: '显示原文', }, setting: { setting: '设置', randomAvatar: '随机生成一个头像', general: '一般', advance: '高级', about: '关于', avatarLink: '头像链接', name: '名称', description: '描述', resetUserInfo: '重置用户信息', theme: '主题', language: '语言', chatgpt_memory_title: '记忆力', chatgpt_memory_memo: '记忆力越强,ChatGPT 在对话过程中能记住的上下文越多,但可能会消耗更多的费用', chatgpt_memory_choice_1: '普通记忆(5条)', chatgpt_memory_choice_2: '中等记忆(20条)', chatgpt_memory_choice_3: '最强记忆(全部)', chatgpt_top_p_title: '性格', chatgpt_top_p_1_memo: '倾向于提供精确的分析,减少ChatGpt胡说八道的可能性', chatgpt_top_p_2_memo: '兼顾回答的准确性和想象力', chatgpt_top_p_3_memo: '倾向于提供更丰富的信息', chatgpt_top_p_choice_1: '准确可信', chatgpt_top_p_choice_2: '平衡性格', chatgpt_top_p_choice_3: '发散思维', api: 'API', timeout: '超时', socks: 'Socks', }, server: { PromptIsEmpty: '你好!今天我能为您提供什么帮助?', NotComplyPolicy: '对不起,您发送的内容不符合我们的使用政策。请注意,我们的平台禁止发布涉及骚扰、歧视、暴力、色情等违反法律法规和社会道德的内容。如有疑问,请联系开发者获取更多帮助。谢谢。', SomethingWrong: '出错了,请稍后再试', SomethingWrongInOpenaiGptApi: '访问OpenAI GPT API出错,请稍后再试', SomethingWrongInOpenaiModerationApi: '访问OpenAI Moderation API出错,请稍后再试', SomethingWrongInOpenaiWhisperApi: '访问OpenAI Whisper API出错,请稍后再试', SomethingWrongInLocalWhisper: '访问本地Whisper API出错,请联系开发者获取更多帮助', }, } ================================================ FILE: src/main.ts ================================================ 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() ================================================ FILE: src/plugins/assets.ts ================================================ 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 ================================================ FILE: src/plugins/index.ts ================================================ import setupAssets from './assets' import setupScrollbarStyle from './scrollbarStyle' export { setupAssets, setupScrollbarStyle } ================================================ FILE: src/plugins/scrollbarStyle.ts ================================================ 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 ================================================ FILE: src/router/index.ts ================================================ import type { App } from 'vue' import type { RouteRecordRaw } from 'vue-router' import { createRouter, createWebHashHistory } from 'vue-router' 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: '/403', name: '403', component: () => import('@/views/exception/403/index.vue'), }, { path: '/404', name: '404', component: () => import('@/views/exception/404/index.vue'), }, { path: '/:pathMatch(.*)*', name: 'notFound', redirect: '/404', }, ] export const router = createRouter({ history: createWebHashHistory(), routes, scrollBehavior: () => ({ left: 0, top: 0 }), }) export async function setupRouter(app: App) { app.use(router) await router.isReady() } ================================================ FILE: src/store/index.ts ================================================ import type { App } from 'vue' import { createPinia } from 'pinia' export const store = createPinia() export function setupStore(app: App) { app.use(store) } export * from './modules' ================================================ FILE: src/store/modules/app/helper.ts ================================================ import { ss } from '@/utils/storage' const LOCAL_NAME = 'appSetting' const ZH_CN = 'zh-CN' const EN_US = 'en-US' const JA_JP = 'ja-JP' export type Theme = 'light' | 'dark' | 'auto' export type Language = typeof ZH_CN | typeof EN_US | typeof JA_JP export type focusTextarea = true export interface AppState { siderCollapsed: boolean theme: Theme language: Language focusTextarea: focusTextarea } export function defaultSetting(): AppState { const browserLanguage = window.navigator.language || ZH_CN let defaultLanguage: string if (browserLanguage.includes('zh')) defaultLanguage = ZH_CN else if (browserLanguage.includes('ja')) defaultLanguage = JA_JP else if (browserLanguage.includes('en')) defaultLanguage = EN_US else defaultLanguage = ZH_CN return { siderCollapsed: false, theme: 'dark', language: defaultLanguage as Language, focusTextarea: true } } 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) } ================================================ FILE: src/store/modules/app/index.ts ================================================ 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() } }, setFocusTextarea() { this.focusTextarea = true this.recordState() }, recordState() { setLocalSetting(this.$state) }, }, }) export function useAppStoreWithOut() { return useAppStore(store) } ================================================ FILE: src/store/modules/chat/helper.ts ================================================ import { ss } from '@/utils/storage' const LOCAL_NAME = 'chatStorage' export function defaultState(): Chat.ChatState { const uuid = 1002 return { active: uuid, history: [{ uuid, title: 'New Chat', isEdit: false }], chat: [{ uuid, data: [] }] } } export function getLocalState(): Chat.ChatState { const localState = ss.get(LOCAL_NAME) return localState ?? defaultState() } export function setLocalState(state: Chat.ChatState) { ss.set(LOCAL_NAME, state) } ================================================ FILE: src/store/modules/chat/index.ts ================================================ 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: { 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) { 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, newChatText: string) { 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 === newChatText) 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 === newChatText) 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) { 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, chat_title: string) { if (!uuid || uuid === 0) { if (this.chat.length) { this.chat[0].data = [] this.history[0].title = chat_title 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) }, }, }) ================================================ FILE: src/store/modules/index.ts ================================================ export * from './app' export * from './chat' export * from './user' ================================================ FILE: src/store/modules/user/helper.ts ================================================ import { ss } from '@/utils/storage' const LOCAL_NAME = 'userStorage' export interface UserInfo { avatar: string name: string description: string chatgpt_top_p: number chatgpt_memory: number } export interface UserState { userInfo: UserInfo } export function defaultSetting(): UserState { return { userInfo: { avatar: 'https://api.multiavatar.com/0.8481955987976837.svg', name: 'ChatGPT Web', description: '', chatgpt_top_p: 100, chatgpt_memory: 50, }, } } 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) } ================================================ FILE: src/store/modules/user/index.ts ================================================ 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) { this.userInfo = { ...this.userInfo, ...userInfo } this.recordState() }, resetUserInfo() { this.userInfo = { ...defaultSetting().userInfo } this.recordState() }, recordState() { setLocalState(this.$state) }, }, }) ================================================ FILE: src/styles/global.less ================================================ html, body, #app { height: 100%; } body { padding-bottom: env(safe-area-inset-bottom); } .clickable-element { cursor: pointer; } ================================================ FILE: src/styles/lib/github-markdown.less ================================================ 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,"); mask-image: url("data:image/svg+xml,"); } .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: .67em 0; font-weight: var(--base-text-weight-semibold, 600); padding-bottom: .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: .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: .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: .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: .875em; } .markdown-body h6 { font-weight: var(--base-text-weight-semibold, 600); font-size: .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: .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 .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: .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: .2em .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 .2em .25em -1.4em; vertical-align: middle; } .markdown-body .contains-task-list:dir(rtl) .task-list-item-checkbox { margin: 0 -1.6em .25em .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%); } ================================================ FILE: src/styles/lib/highlight.less ================================================ 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 } } ================================================ FILE: src/styles/lib/tailwind.css ================================================ @tailwind base; @tailwind components; @tailwind utilities; ================================================ FILE: src/typings/chat.d.ts ================================================ 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 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 } } ================================================ FILE: src/typings/env.d.ts ================================================ /// interface ImportMetaEnv { readonly VITE_GLOB_API_URL: string; readonly VITE_GLOB_API_TIMEOUT: string; readonly VITE_APP_API_BASE_URL: string; readonly VITE_GLOB_OPEN_LONG_REPLY: string; readonly VITE_GLOB_APP_PWA: string; } ================================================ FILE: src/typings/global.d.ts ================================================ interface Window { $loadingBar?: import('naive-ui').LoadingBarProviderInst; $dialog?: import('naive-ui').DialogProviderInst; $message?: import('naive-ui').MessageProviderInst; $notification?: import('naive-ui').NotificationProviderInst; } ================================================ FILE: src/utils/crypto/index.ts ================================================ 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 } ================================================ FILE: src/utils/format/index.ts ================================================ /** * 转义 HTML 字符 * @param source */ export function encodeHTML(source: string) { return source .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } /** * 判断是否为代码块 * @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) } ================================================ FILE: src/utils/is/index.ts ================================================ export function isNumber(value: T | unknown): value is number { return Object.prototype.toString.call(value) === '[object Number]' } export function isString(value: T | unknown): value is string { return Object.prototype.toString.call(value) === '[object String]' } export function isBoolean(value: T | unknown): value is boolean { return Object.prototype.toString.call(value) === '[object Boolean]' } export function isNull(value: T | unknown): value is null { return Object.prototype.toString.call(value) === '[object Null]' } export function isUndefined(value: T | unknown): value is undefined { return Object.prototype.toString.call(value) === '[object Undefined]' } export function isObject(value: T | unknown): value is object { return Object.prototype.toString.call(value) === '[object Object]' } export function isArray(value: T | unknown): value is T { return Object.prototype.toString.call(value) === '[object Array]' } export function isFunction any | void | never>(value: T | unknown): value is T { return Object.prototype.toString.call(value) === '[object Function]' } export function isDate(value: T | unknown): value is T { return Object.prototype.toString.call(value) === '[object Date]' } export function isRegExp(value: T | unknown): value is T { return Object.prototype.toString.call(value) === '[object RegExp]' } export function isPromise>(value: T | unknown): value is T { return Object.prototype.toString.call(value) === '[object Promise]' } export function isSet>(value: T | unknown): value is T { return Object.prototype.toString.call(value) === '[object Set]' } export function isMap>(value: T | unknown): value is T { return Object.prototype.toString.call(value) === '[object Map]' } export function isFile(value: T | unknown): value is T { return Object.prototype.toString.call(value) === '[object File]' } ================================================ FILE: src/utils/request/axios.ts ================================================ import axios, { type AxiosResponse } from 'axios' const service = axios.create({ baseURL: import.meta.env.VITE_GLOB_API_URL, }) service.interceptors.request.use( (config) => { 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 ================================================ FILE: src/utils/request/index.ts ================================================ import type { AxiosProgressEvent, AxiosResponse, GenericAbortSignal } from 'axios' import request from './axios' export interface HttpOption { url: string data?: any method?: string headers?: any onDownloadProgress?: (progressEvent: AxiosProgressEvent) => void signal?: GenericAbortSignal beforeRequest?: () => void afterRequest?: () => void } export interface Response { data: T message: string | null status: string } function http( { url, data, method, headers, onDownloadProgress, signal, beforeRequest, afterRequest }: HttpOption, ) { const successHandler = (res: AxiosResponse>) => { if (res.data.status === 'Success' || typeof res.data === 'string') return res.data return Promise.reject(res.data) } const failHandler = (error: Response) => { 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( { url, data, method = 'GET', onDownloadProgress, signal, beforeRequest, afterRequest }: HttpOption, ): Promise> { return http({ url, method, data, onDownloadProgress, signal, beforeRequest, afterRequest, }) } export function post( { url, data, method = 'POST', headers, onDownloadProgress, signal, beforeRequest, afterRequest }: HttpOption, ): Promise> { return http({ url, method, data, headers, onDownloadProgress, signal, beforeRequest, afterRequest, }) } export default post ================================================ FILE: src/utils/storage/index.ts ================================================ export * from './local' ================================================ FILE: src/utils/storage/local.ts ================================================ import { deCrypto, enCrypto } from '../crypto' interface StorageData { 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(key: string, data: T) { const storageData: StorageData = { 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 }) ================================================ FILE: src/views/chat/components/Message/Avatar.vue ================================================ ================================================ FILE: src/views/chat/components/Message/Text.vue ================================================ ================================================ FILE: src/views/chat/components/Message/index.vue ================================================ ================================================ FILE: src/views/chat/components/Message/style.less ================================================ .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 { .raw-text { white-space: pre-wrap; color: var(--n-text-color); } } .highlight pre, pre { background-color: #282c34; } } ================================================ FILE: src/views/chat/components/index.ts ================================================ import Message from './Message/index.vue' export { Message } ================================================ FILE: src/views/chat/hooks/useChat.ts ================================================ 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, newChatText: string) => { chatStore.addChatByUuid(uuid, chat, newChatText) } const updateChat = (uuid: number, index: number, chat: Chat.Chat) => { chatStore.updateChatByUuid(uuid, index, chat) } const updateChatSome = (uuid: number, index: number, chat: Partial) => { chatStore.updateChatSomeByUuid(uuid, index, chat) } return { addChat, updateChat, updateChatSome, getChatByUuidAndIndex, } } ================================================ FILE: src/views/chat/hooks/useCopyCode.ts ================================================ 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()) } ================================================ FILE: src/views/chat/hooks/useScroll.ts ================================================ import type { Ref } from 'vue' import { nextTick, ref } from 'vue' type ScrollElement = HTMLDivElement | null interface ScrollReturn { scrollRef: Ref scrollToBottom: () => Promise scrollToTop: () => Promise scrollToBottomIfAtBottom: () => Promise } export function useScroll(): ScrollReturn { const scrollRef = ref(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 = 50 // 阈值,表示滚动条到底部的距离阈值 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, } } ================================================ FILE: src/views/chat/index.vue ================================================ ================================================ FILE: src/views/chat/layout/Layout.vue ================================================ ================================================ FILE: src/views/chat/layout/header/index.vue ================================================ ================================================ FILE: src/views/chat/layout/index.ts ================================================ import ChatLayout from './Layout.vue' export { ChatLayout } ================================================ FILE: src/views/chat/layout/sider/Footer.vue ================================================ ================================================ FILE: src/views/chat/layout/sider/List.vue ================================================ ================================================ FILE: src/views/chat/layout/sider/index.vue ================================================ ================================================ FILE: src/views/exception/403/index.vue ================================================ ================================================ FILE: src/views/exception/404/index.vue ================================================ ================================================ FILE: tailwind.config.js ================================================ /** @type {import('tailwindcss').Config} */ module.exports = { darkMode: 'class', content: [ './index.html', './src/**/*.{vue,js,ts,jsx,tsx}', ], theme: { extend: { animation: { blink: 'blink 1.2s infinite steps(1, start)', }, keyframes: { blink: { '0%, 100%': { 'background-color': 'currentColor' }, '50%': { 'background-color': 'transparent' }, }, }, }, }, plugins: [], } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "baseUrl": ".", "module": "ESNext", "target": "ESNext", "lib": ["DOM", "ESNext"], "strict": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "jsx": "preserve", "moduleResolution": "node", "resolveJsonModule": true, "noUnusedLocals": false, "strictNullChecks": true, "forceConsistentCasingInFileNames": true, "skipLibCheck": true, "paths": { "@/*": ["./src/*"] }, "types": ["vite/client", "node", "naive-ui/volar"] }, "exclude": ["node_modules", "dist", "service"] } ================================================ FILE: vite.config.ts ================================================ import path from 'path' import type { PluginOption } from 'vite' import { defineConfig, loadEnv } from 'vite' import vue from '@vitejs/plugin-vue' import { VitePWA } from 'vite-plugin-pwa' function setupPlugins(env: ImportMetaEnv): PluginOption[] { return [ vue(), env.VITE_GLOB_APP_PWA === 'true' && VitePWA({ injectRegister: 'auto', manifest: { name: 'chatGPT', short_name: 'chatGPT', icons: [ { src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' }, { src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' }, ], }, }), ] } export default defineConfig((env) => { const viteEnv = loadEnv(env.mode, process.cwd()) as unknown as ImportMetaEnv return { resolve: { alias: { '@': path.resolve(process.cwd(), 'src'), }, }, plugins: setupPlugins(viteEnv), server: { host: '0.0.0.0', port: 1002, open: false, proxy: { '/api': { target: viteEnv.VITE_APP_API_BASE_URL, changeOrigin: true, // 允许跨域 rewrite: path => path.replace('/api/', '/'), }, }, }, build: { reportCompressedSize: false, sourcemap: false, commonjsOptions: { ignoreTryCatch: false, }, }, } })