mirror of
https://github.com/Chanzhaoyu/chatgpt-web.git
synced 2025-07-22 20:51:39 +00:00
Compare commits
103 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
4ab9f709de | ||
![]() |
cb90d81c69 | ||
![]() |
fda6c6bb6a | ||
![]() |
6216d84ecd | ||
![]() |
ea887ad755 | ||
![]() |
c98a2a3cb8 | ||
![]() |
06f41c3758 | ||
![]() |
3f4cb5c900 | ||
![]() |
405aeaa2a5 | ||
![]() |
fd28468778 | ||
![]() |
4128d319ed | ||
![]() |
4a06b3943c | ||
![]() |
77cdcb7798 | ||
![]() |
ee542a01f7 | ||
![]() |
30372c2e70 | ||
![]() |
6646695849 | ||
![]() |
e9db25a970 | ||
![]() |
63f4d7d77c | ||
![]() |
20255ddce4 | ||
![]() |
bbd8ff773b | ||
![]() |
c8518b7789 | ||
![]() |
bf015d35f7 | ||
![]() |
eab39b7f88 | ||
![]() |
981c036542 | ||
![]() |
91f51b6338 | ||
![]() |
26de5359ef | ||
![]() |
0423b87530 | ||
![]() |
58464b2cee | ||
![]() |
e23e1df3ce | ||
![]() |
2c3bc77eb2 | ||
![]() |
52b43868eb | ||
![]() |
7930ec5dab | ||
![]() |
7835740159 | ||
![]() |
c8745f2e21 | ||
![]() |
0b8b58f85c | ||
![]() |
c09a4f6c75 | ||
![]() |
d9761ed0e5 | ||
![]() |
cb8e2cf814 | ||
![]() |
f73aa5f9e1 | ||
![]() |
c617205a8c | ||
![]() |
79eeb51537 | ||
![]() |
b2977d8c44 | ||
![]() |
8dab963628 | ||
![]() |
79dd6c5e18 | ||
![]() |
da75ca944c | ||
![]() |
4458e744cc | ||
![]() |
d2ae2c4f54 | ||
![]() |
cf8e2dd7b6 | ||
![]() |
701ef0e6e1 | ||
![]() |
de34af8747 | ||
![]() |
33c02cfe10 | ||
![]() |
b03f804e35 | ||
![]() |
b6e5c59a9c | ||
![]() |
797cc7461b | ||
![]() |
58547d1b58 | ||
![]() |
c933a851f4 | ||
![]() |
86fcbbff0b | ||
![]() |
5d1f8519c2 | ||
![]() |
3c21068150 | ||
![]() |
cd03a71ef1 | ||
![]() |
1d74717beb | ||
![]() |
e35abb1c98 | ||
![]() |
b4b0f65158 | ||
![]() |
910c19b02e | ||
![]() |
32049fbf18 | ||
![]() |
817a4842ac | ||
![]() |
0cd120ec5b | ||
![]() |
007763f7d7 | ||
![]() |
3debaec4b2 | ||
![]() |
35f353c1f7 | ||
![]() |
3ac6746930 | ||
![]() |
9b556e4833 | ||
![]() |
071283c007 | ||
![]() |
854261ef86 | ||
![]() |
633e9ad8e2 | ||
![]() |
47dd6c9801 | ||
![]() |
0eb9df6d31 | ||
![]() |
8bfbe6f7df | ||
![]() |
43e8635e7f | ||
![]() |
b7542c70cb | ||
![]() |
b594ec5de1 | ||
![]() |
9a9b154040 | ||
![]() |
12681b1a05 | ||
![]() |
9f4f6304c7 | ||
![]() |
93c63d9195 | ||
![]() |
361494d444 | ||
![]() |
2f69cfd10b | ||
![]() |
0086f17df4 | ||
![]() |
befad1cbe1 | ||
![]() |
6cafc07e0d | ||
![]() |
feb167e2da | ||
![]() |
847623e22a | ||
![]() |
dd522eb404 | ||
![]() |
73c9ac4d8f | ||
![]() |
727562f495 | ||
![]() |
d7464642a7 | ||
![]() |
951636869b | ||
![]() |
39f718ef16 | ||
![]() |
72df35c929 | ||
![]() |
cf7a56a015 | ||
![]() |
5ca1b4557a | ||
![]() |
11544ff24b | ||
![]() |
f910b5f8a4 |
3
.commitlintrc.json
Normal file
3
.commitlintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["@commitlint/config-conventional"]
|
||||
}
|
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
Dockerfile
|
||||
.git
|
||||
.husky
|
||||
.github
|
||||
.vscode
|
7
.env
7
.env
@@ -1,5 +1,4 @@
|
||||
# OpenAI API Key
|
||||
OPENAI_API_KEY='xxxx'
|
||||
|
||||
# Glob API URL
|
||||
VITE_GLOB_API_URL='http://localhost:3002'
|
||||
VITE_GLOB_API_URL=/api
|
||||
|
||||
VITE_APP_API_BASE_URL=http://localhost:3002/
|
||||
|
@@ -1 +0,0 @@
|
||||
components.d.ts
|
37
.github/workflows/build_docker.yml
vendored
Normal file
37
.github/workflows/build_docker.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
name: build_docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build_docker:
|
||||
name: Build docker
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: chenzhaoyu94/chatgpt-web
|
||||
|
||||
- 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
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
47
.github/workflows/ci.yml
vendored
Normal file
47
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.x
|
||||
|
||||
- name: Setup
|
||||
run: npm i -g @antfu/ni
|
||||
|
||||
- name: Install
|
||||
run: nci
|
||||
|
||||
- name: Lint
|
||||
run: nr lint:fix
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.x
|
||||
|
||||
- name: Setup
|
||||
run: npm i -g @antfu/ni
|
||||
|
||||
- name: Install
|
||||
run: nci
|
||||
|
||||
- name: Typecheck
|
||||
run: nr type-check
|
4
.husky/commit-msg
Executable file
4
.husky/commit-msg
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx --no -- commitlint --edit
|
4
.husky/pre-commit
Executable file
4
.husky/pre-commit
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true
|
||||
}
|
13
.vscode/settings.json
vendored
13
.vscode/settings.json
vendored
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"prettier.enable": false,
|
||||
"editor.formatOnSave": false,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
},
|
||||
@@ -21,16 +23,25 @@
|
||||
"axios",
|
||||
"bumpp",
|
||||
"chatgpt",
|
||||
"chenzhaoyu",
|
||||
"commitlint",
|
||||
"dockerhub",
|
||||
"esno",
|
||||
"GPTAPI",
|
||||
"iconify",
|
||||
"logprobs",
|
||||
"nodata",
|
||||
"OPENAI",
|
||||
"pinia",
|
||||
"Popconfirm",
|
||||
"rushstack",
|
||||
"Sider",
|
||||
"tailwindcss",
|
||||
"tsup",
|
||||
"Typecheck",
|
||||
"unplugin",
|
||||
"VITE",
|
||||
"vueuse"
|
||||
"vueuse",
|
||||
"Zhao"
|
||||
]
|
||||
}
|
||||
|
111
CHANGELOG.md
111
CHANGELOG.md
@@ -1,3 +1,114 @@
|
||||
## v2.5.1
|
||||
|
||||
`2023-02-21`
|
||||
### Enhancement
|
||||
- 调整路由模式为 `hash`
|
||||
- 调整新增会话添加到列表最前
|
||||
- 调整移动端样式
|
||||
|
||||
## v2.5.0
|
||||
|
||||
`2023-02-20`
|
||||
|
||||
### Feature
|
||||
- 会话 `loading` 现在显示为光标动画
|
||||
- 会话现在可以再次生成回复
|
||||
- 会话异常可以再次进行请求
|
||||
- 所有删除选项添加确认操作
|
||||
|
||||
### Enhancement
|
||||
- 调整 `chat` 为路由页面而不是组件形式
|
||||
- 更新依赖至最新
|
||||
- 调整移动端体验
|
||||
|
||||
### BugFix
|
||||
- 修复移动端左侧菜单显示不完整的问题
|
||||
|
||||
## v2.4.1
|
||||
|
||||
`2023-02-18`
|
||||
|
||||
### Enhancement
|
||||
- 调整部份移动端上的样式
|
||||
- 输入框支持换行
|
||||
|
||||
## v2.4.0
|
||||
|
||||
`2023-02-17`
|
||||
|
||||
### Feature
|
||||
- 响应式支持移动端
|
||||
### Enhancement
|
||||
- 修改部份描述错误
|
||||
|
||||
## v2.3.3
|
||||
|
||||
`2023-02-16`
|
||||
|
||||
### Feature
|
||||
- 添加 `README` 部份说明和贡献列表
|
||||
- 添加 `docker` 镜像
|
||||
- 添加 `GitHub Action` 自动化构建
|
||||
|
||||
### BugFix
|
||||
- 回退依赖更新导致的 [Eslint 报错](https://github.com/eslint/eslint/issues/16896)
|
||||
|
||||
## v2.3.2
|
||||
|
||||
`2023-02-16`
|
||||
|
||||
### Enhancement
|
||||
- 更新依赖至最新
|
||||
- 优化部份内容
|
||||
|
||||
## v2.3.1
|
||||
|
||||
`2023-02-15`
|
||||
|
||||
### BugFix
|
||||
- 修复多会话状态下一些意想不到的问题
|
||||
|
||||
## v2.3.0
|
||||
|
||||
`2023-02-15`
|
||||
### Feature
|
||||
- 代码类型信息高亮显示
|
||||
- 支持 `node ^16` 版本
|
||||
- 移动端响应式初步支持
|
||||
- `vite` 中 `proxy` 代理
|
||||
|
||||
### Enhancement
|
||||
- 调整超时处理范围
|
||||
|
||||
### BugFix
|
||||
- 修复取消请求错误提示会添加到信息中
|
||||
- 修复部份情况下提交请求不可用
|
||||
- 修复侧边栏宽度变化闪烁的问题
|
||||
|
||||
## v2.2.0
|
||||
|
||||
`2023-02-14`
|
||||
### Feature
|
||||
- 会话和上下文本地储存
|
||||
- 侧边栏本地储存
|
||||
|
||||
## v2.1.0
|
||||
|
||||
`2023-02-14`
|
||||
### Enhancement
|
||||
- 更新依赖至最新
|
||||
- 联想功能移动至前端提交,后端只做转发
|
||||
|
||||
### BugFix
|
||||
- 修复部份项目检测有关 `Bug`
|
||||
- 修复清除上下文按钮失效
|
||||
|
||||
## v2.0.0
|
||||
|
||||
`2023-02-13`
|
||||
### Refactor
|
||||
重构并优化大部分内容
|
||||
|
||||
## v1.0.5
|
||||
|
||||
`2023-02-12`
|
||||
|
49
CONTRIBUTING.md
Normal file
49
CONTRIBUTING.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# 贡献指南
|
||||
感谢你的宝贵时间。你的贡献将使这个项目变得更好!在提交贡献之前,请务必花点时间阅读下面的入门指南。
|
||||
|
||||
## 语义化版本
|
||||
该项目遵循语义化版本。我们对重要的漏洞修复发布修订号,对新特性或不重要的变更发布次版本号,对重大且不兼容的变更发布主版本号。
|
||||
|
||||
每个重大更改都将记录在 `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)
|
20
Dockerfile
Normal file
20
Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
# build front-end
|
||||
FROM node:lts-alpine AS builder
|
||||
|
||||
COPY ./ /app
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm install pnpm -g && pnpm install && pnpm run build
|
||||
|
||||
# service
|
||||
FROM node:lts-alpine
|
||||
|
||||
COPY /service /app
|
||||
COPY --from=builder /app/dist /app/public
|
||||
|
||||
WORKDIR /app
|
||||
RUN npm install pnpm -g && pnpm install
|
||||
|
||||
EXPOSE 3002
|
||||
|
||||
CMD ["pnpm", "run", "start"]
|
39
README.en.md
39
README.en.md
@@ -1,39 +0,0 @@
|
||||
# ChatGPT Web Bot
|
||||
|
||||
[中文](./README.md) | English
|
||||
|
||||
ChartGPT demo page built with express and vue3
|
||||
|
||||

|
||||
|
||||
## Usage
|
||||
> Make sure `node >= 18`
|
||||
|
||||
If pnpm is not installed
|
||||
```shell
|
||||
npm install pnpm -g
|
||||
```
|
||||
|
||||
install node deps
|
||||
```shell
|
||||
pnpm install
|
||||
```
|
||||
|
||||
Sign up for an [OpenAI API key](https://platform.openai.com/overview) and store it in your environment.
|
||||
|
||||
```
|
||||
# .env
|
||||
OPENAI_API_KEY="Your Key"
|
||||
```
|
||||
|
||||
Run service
|
||||
```shell
|
||||
pnpm run service
|
||||
```
|
||||
|
||||
Run web
|
||||
```shell
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
## License
|
153
README.md
153
README.md
@@ -1,40 +1,157 @@
|
||||
# ChatGPT Web Bot
|
||||
|
||||
中文 | [English](./README.en.md)
|
||||
# ChatGPT Web
|
||||
|
||||
使用 express 和 vue3 搭建的 ChartGPT 演示网页
|
||||
|
||||

|
||||

|
||||
|
||||
## 使用
|
||||
> 确保 `node >= 18`
|
||||
> 提示:目前 `OpenAI` 开放的模型最高只有 `GPT-3`,和现在网页所使用的 `GPT-3.5` 或 `GPT-4` 有很大差距,需要等官方开放最新的模型接口。
|
||||
|
||||
## 待实现路线
|
||||
[✓] 多会话储存和上下文逻辑
|
||||
|
||||
[✓] 对代码等消息类型的格式化美化处理
|
||||
|
||||
[✗] 用户模块(注册、登录、个人中心)
|
||||
|
||||
[✗] 界面多语言
|
||||
|
||||
[✗] 界面主题
|
||||
|
||||
[✗] More...
|
||||
|
||||
## 前置要求
|
||||
|
||||
### 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
|
||||
如果你没有安装过 `pnpm`
|
||||
```shell
|
||||
npm install pnpm -g
|
||||
```
|
||||
|
||||
安装依赖
|
||||
### OpenAI API Key
|
||||
注册并获取 [OpenAI API key](https://platform.openai.com/overview) 并填写到本地环境变量
|
||||
```
|
||||
# service/.env 文件
|
||||
|
||||
OPENAI_API_KEY='Your key'
|
||||
```
|
||||
|
||||
## 安装依赖
|
||||
|
||||
> 为了简便 `后端开发人员` 的了解负担,所以并没有采用前端 `workspace` 模式,而是分文件夹存放。如果只需要前端页面做二次开发,删除 `service` 文件夹即可。
|
||||
|
||||
### 后端服务
|
||||
|
||||
进入文件夹 `/service` 运行以下命令
|
||||
|
||||
```shell
|
||||
pnpm install
|
||||
```
|
||||
|
||||
获取 [OpenAI API key](https://platform.openai.com/overview) 到本地环境变量
|
||||
|
||||
```
|
||||
# .env
|
||||
OPENAI_API_KEY="Your Key"
|
||||
```
|
||||
|
||||
运行服务
|
||||
### 网页
|
||||
根目录下运行以下命令
|
||||
```shell
|
||||
pnpm run service
|
||||
pnpm bootstrap
|
||||
```
|
||||
|
||||
运行网页
|
||||
|
||||
## 运行
|
||||
### 后端服务
|
||||
|
||||
进入文件夹 `/service` 运行以下命令
|
||||
|
||||
```shell
|
||||
pnpm run dev
|
||||
pnpm start
|
||||
```
|
||||
|
||||
### 网页
|
||||
根目录下运行以下命令
|
||||
```shell
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## 打包
|
||||
## Docker build
|
||||
|
||||
[参考信息](https://github.com/Chanzhaoyu/chatgpt-web/pull/42)
|
||||
|
||||
```bash
|
||||
docker build -t chatgpt-web .
|
||||
```
|
||||
|
||||
## Docker compose
|
||||
|
||||
[Hub 地址](https://hub.docker.com/repository/docker/chenzhaoyu94/chatgpt-web/general)
|
||||
|
||||
```yml
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
app:
|
||||
image: chenzhaoyu94/chatgpt-web:main
|
||||
ports:
|
||||
- 3002:3002
|
||||
environment:
|
||||
OPENAI_API_KEY: xxxxxx
|
||||
```
|
||||
|
||||
### 后端服务
|
||||
> 如果你不需要本项目的 `node` 接口,可以省略如下操作
|
||||
|
||||
复制 `service` 文件夹到你有 `node` 服务环境的服务器上。(搜索关键字:`express部署`)
|
||||
|
||||
```shell
|
||||
# 安装
|
||||
pnpm install
|
||||
|
||||
# 打包
|
||||
pnpm build
|
||||
|
||||
# 运行
|
||||
pnpm prod
|
||||
```
|
||||
|
||||
PS: 不进行打包,直接在服务器上运行 `pnpm start` 也可
|
||||
|
||||
### 前端打包
|
||||
|
||||
根目录下运行以下命令,然后将 `dist` 文件夹复制到你的托管服务器上
|
||||
|
||||
[参考信息](https://cn.vitejs.dev/guide/static-deploy.html#building-the-app)
|
||||
|
||||
```shell
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### 常见问题
|
||||
Q: 为什么 `Git` 提交总是报错?
|
||||
|
||||
A: 因为有提交信息验证,请遵循 [Commit 指南](./CONTRIBUTING.md)
|
||||
|
||||
Q: 如果只使用前端页面,在哪里改请求接口?
|
||||
|
||||
A: 根目录下 `.env` 文件中的 `VITE_GLOB_API_URL` 字段。
|
||||
|
||||
Q: 文件保存时全部爆红?
|
||||
|
||||
A: `vscode` 请安装项目推荐插件,或手动安装 `Eslint` 插件。
|
||||
|
||||
## 参与贡献
|
||||
|
||||
贡献之前请先阅读 [贡献指南](./CONTRIBUTING.md)
|
||||
|
||||
感谢所有做过贡献的人!
|
||||
|
||||
<a href="https://github.com/Chanzhaoyu/chatgpt-web/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=Chanzhaoyu/chatgpt-web" />
|
||||
</a>
|
||||
|
||||
## License
|
||||
MIT © [ChenZhaoYu](./license)
|
||||
|
BIN
docs/cover.png
BIN
docs/cover.png
Binary file not shown.
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 62 KiB |
125
index.html
125
index.html
@@ -1,71 +1,76 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-cmn-Hans">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ChatGPT Web</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<style>
|
||||
.loading-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.balls {
|
||||
width: 4em;
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<meta name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, viewport-fit=cover" />
|
||||
<title>ChatGPT Web</title>
|
||||
</head>
|
||||
|
||||
.balls div {
|
||||
width: 0.8em;
|
||||
height: 0.8em;
|
||||
border-radius: 50%;
|
||||
background-color: #4b9e5f;
|
||||
}
|
||||
<body>
|
||||
<div id="app">
|
||||
<style>
|
||||
.loading-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.balls div:nth-of-type(1) {
|
||||
transform: translateX(-100%);
|
||||
animation: left-swing 0.5s ease-in alternate infinite;
|
||||
}
|
||||
.balls {
|
||||
width: 4em;
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.balls div:nth-of-type(3) {
|
||||
transform: translateX(-95%);
|
||||
animation: right-swing 0.5s ease-out alternate infinite;
|
||||
}
|
||||
.balls div {
|
||||
width: 0.8em;
|
||||
height: 0.8em;
|
||||
border-radius: 50%;
|
||||
background-color: #4b9e5f;
|
||||
}
|
||||
|
||||
@keyframes left-swing {
|
||||
50%,
|
||||
100% {
|
||||
transform: translateX(95%);
|
||||
}
|
||||
}
|
||||
.balls div:nth-of-type(1) {
|
||||
transform: translateX(-100%);
|
||||
animation: left-swing 0.5s ease-in alternate infinite;
|
||||
}
|
||||
|
||||
@keyframes right-swing {
|
||||
50% {
|
||||
transform: translateX(-95%);
|
||||
}
|
||||
.balls div:nth-of-type(3) {
|
||||
transform: translateX(-95%);
|
||||
animation: right-swing 0.5s ease-out alternate infinite;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<div class="loading-wrap">
|
||||
<div class="balls">
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
@keyframes left-swing {
|
||||
|
||||
50%,
|
||||
100% {
|
||||
transform: translateX(95%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes right-swing {
|
||||
50% {
|
||||
transform: translateX(-95%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<div class="loading-wrap">
|
||||
<div class="balls">
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
53
package.json
53
package.json
@@ -1,49 +1,62 @@
|
||||
{
|
||||
"name": "chatgpt-web",
|
||||
"version": "1.0.5",
|
||||
"version": "2.5.1",
|
||||
"private": false,
|
||||
"description": "ChatGPT Web Bot",
|
||||
"author": "ChenZhaoYu <chenzhaoyu1994@gami.com>",
|
||||
"description": "ChatGPT Web",
|
||||
"author": "ChenZhaoYu <chenzhaoyu1994@gmail.com>",
|
||||
"keywords": [
|
||||
"chatgpt-web",
|
||||
"chatgpt",
|
||||
"chatbot",
|
||||
"web",
|
||||
"vue"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"service": "esno ./service/index.ts",
|
||||
"build": "run-p type-check build-only",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"lint": "eslint . --fix --ignore-path .gitignore",
|
||||
"release": "bumpp package.json --commit --push --tag"
|
||||
"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": {
|
||||
"@vueuse/core": "^9.13.0",
|
||||
"highlight.js": "^11.7.0",
|
||||
"naive-ui": "^2.34.3",
|
||||
"vue": "^3.2.47"
|
||||
"pinia": "^2.0.30",
|
||||
"vue": "^3.2.47",
|
||||
"vue-router": "^4.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^0.35.2",
|
||||
"@commitlint/cli": "^17.4.4",
|
||||
"@commitlint/config-conventional": "^17.4.4",
|
||||
"@iconify/vue": "^4.1.0",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/node": "^18.13.0",
|
||||
"@types/crypto-js": "^4.1.1",
|
||||
"@types/node": "^18.14.0",
|
||||
"@types/web-bluetooth": "^0.0.16",
|
||||
"@vitejs/plugin-vue": "^4.0.0",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"axios": "^1.3.2",
|
||||
"bumpp": "^8.2.1",
|
||||
"chatgpt": "^4.2.0",
|
||||
"dotenv": "^16.0.3",
|
||||
"axios": "^1.3.3",
|
||||
"crypto-js": "^4.1.1",
|
||||
"eslint": "^8.34.0",
|
||||
"esno": "^0.16.3",
|
||||
"express": "^4.18.2",
|
||||
"husky": "^8.0.3",
|
||||
"less": "^4.1.3",
|
||||
"lint-staged": "^13.1.2",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"postcss": "^8.4.21",
|
||||
"prettier": "^2.8.4",
|
||||
"tailwindcss": "^3.2.6",
|
||||
"rimraf": "^4.1.2",
|
||||
"tailwindcss": "^3.2.7",
|
||||
"typescript": "~4.9.5",
|
||||
"vite": "^4.1.1",
|
||||
"vue-tsc": "^1.0.24"
|
||||
"vite": "^4.1.2",
|
||||
"vue-tsc": "^1.1.4"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,vue}": [
|
||||
"pnpm lint:fix"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
2149
pnpm-lock.yaml
generated
2149
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
2
service/.env
Normal file
2
service/.env
Normal file
@@ -0,0 +1,2 @@
|
||||
# OpenAI API Key - https://platform.openai.com/overview
|
||||
OPENAI_API_KEY=
|
5
service/.eslintrc.json
Normal file
5
service/.eslintrc.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"root": true,
|
||||
"ignorePatterns": ["build"],
|
||||
"extends": ["@antfu"]
|
||||
}
|
31
service/.gitignore
vendored
Normal file
31
service/.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
build
|
1
service/.npmrc
Normal file
1
service/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
enable-pre-post-scripts=true
|
3
service/.vscode/extensions.json
vendored
Normal file
3
service/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["dbaeumer.vscode-eslint"]
|
||||
}
|
22
service/.vscode/settings.json
vendored
Normal file
22
service/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"prettier.enable": false,
|
||||
"editor.formatOnSave": false,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
},
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"typescript",
|
||||
"json",
|
||||
"jsonc",
|
||||
"json5",
|
||||
"yaml"
|
||||
],
|
||||
"cSpell.words": [
|
||||
"antfu",
|
||||
"chatgpt",
|
||||
"esno",
|
||||
"GPTAPI",
|
||||
"OPENAI"
|
||||
]
|
||||
}
|
@@ -1,54 +0,0 @@
|
||||
import dotenv from 'dotenv'
|
||||
import { ChatGPTAPI } from 'chatgpt'
|
||||
|
||||
interface ChatContext {
|
||||
conversationId?: string
|
||||
parentMessageId?: string
|
||||
}
|
||||
|
||||
dotenv.config()
|
||||
|
||||
const apiKey = process.env.OPENAI_API_KEY
|
||||
|
||||
if (apiKey === undefined)
|
||||
throw new Error('OPENAI_API_KEY is not defined')
|
||||
|
||||
const chatContext = new Set<ChatContext>()
|
||||
|
||||
/**
|
||||
* More Info: https://github.com/transitive-bullshit/chatgpt-api
|
||||
*/
|
||||
const api = new ChatGPTAPI({ apiKey })
|
||||
|
||||
async function chatReply(message: string) {
|
||||
if (!message)
|
||||
return
|
||||
|
||||
// Get the last context from the chat context
|
||||
// If there is a last context, add it to the options
|
||||
let options = {}
|
||||
const lastContext = Array.from(chatContext).pop()
|
||||
if (lastContext) {
|
||||
const { conversationId, parentMessageId } = lastContext
|
||||
options = { conversationId, parentMessageId }
|
||||
}
|
||||
|
||||
// Send the message to the API
|
||||
const response = await api.sendMessage(message, { ...options })
|
||||
|
||||
const { conversationId, id } = response
|
||||
|
||||
// Add the new context to the chat context
|
||||
if (conversationId && id)
|
||||
chatContext.add({ conversationId, parentMessageId: id })
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
async function clearChatContext() {
|
||||
// Clear the chat context
|
||||
chatContext.clear()
|
||||
return Promise.resolve({ message: 'Chat context cleared' })
|
||||
}
|
||||
|
||||
export { chatReply, clearChatContext }
|
@@ -1,26 +0,0 @@
|
||||
import express from 'express'
|
||||
import { chatReply, clearChatContext } from './chatgpt'
|
||||
|
||||
const app = express()
|
||||
|
||||
app.use(express.json())
|
||||
|
||||
app.all('*', (req, res, next) => {
|
||||
res.header('Access-Control-Allow-Origin', '*')
|
||||
res.header('Access-Control-Allow-Headers', 'Content-Type')
|
||||
res.header('Access-Control-Allow-Methods', '*')
|
||||
next()
|
||||
})
|
||||
|
||||
app.listen(3002, () => globalThis.console.log('Server is running on port 3002'))
|
||||
|
||||
app.post('/chat', async (req, res) => {
|
||||
const { message } = req.body
|
||||
const response = await chatReply(message)
|
||||
res.send(response)
|
||||
})
|
||||
|
||||
app.post('/clear', async (req, res) => {
|
||||
const response = await clearChatContext()
|
||||
res.send(response)
|
||||
})
|
41
service/package.json
Normal file
41
service/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "chatgpt-web-service",
|
||||
"version": "1.0.0",
|
||||
"private": false,
|
||||
"description": "ChatGPT Web Service",
|
||||
"author": "ChenZhaoYu <chenzhaoyu1994@gmail.com>",
|
||||
"keywords": [
|
||||
"chatgpt-web",
|
||||
"chatgpt",
|
||||
"chatbot",
|
||||
"express"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^16 || ^18"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "esno ./src/index.ts",
|
||||
"prod": "esno ./build/index.js",
|
||||
"build": "pnpm clean && tsup",
|
||||
"clean": "rimraf build",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"common:cleanup": "rimraf node_modules && rimraf pnpm-lock.yaml"
|
||||
},
|
||||
"dependencies": {
|
||||
"chatgpt": "^4.7.1",
|
||||
"dotenv": "^16.0.3",
|
||||
"esno": "^0.16.3",
|
||||
"express": "^4.18.2",
|
||||
"isomorphic-fetch": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^0.35.2",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/node": "^18.14.0",
|
||||
"eslint": "^8.34.0",
|
||||
"rimraf": "^4.1.2",
|
||||
"tsup": "^6.6.3",
|
||||
"typescript": "^4.9.5"
|
||||
}
|
||||
}
|
3548
service/pnpm-lock.yaml
generated
Normal file
3548
service/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
service/src/chatgpt.ts
Normal file
49
service/src/chatgpt.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import * as dotenv from 'dotenv'
|
||||
import 'isomorphic-fetch'
|
||||
import type { ChatGPTAPI, SendMessageOptions } from 'chatgpt'
|
||||
import { sendResponse } from './utils'
|
||||
|
||||
export interface ChatContext {
|
||||
conversationId?: string
|
||||
parentMessageId?: string
|
||||
}
|
||||
|
||||
dotenv.config()
|
||||
|
||||
const apiKey = process.env.OPENAI_API_KEY
|
||||
|
||||
if (apiKey === undefined)
|
||||
throw new Error('OPENAI_API_KEY is not defined')
|
||||
|
||||
let api: ChatGPTAPI
|
||||
|
||||
// To use ESM in CommonJS, you can use a dynamic import
|
||||
(async () => {
|
||||
// More Info: https://github.com/transitive-bullshit/chatgpt-api
|
||||
const { ChatGPTAPI } = await import('chatgpt')
|
||||
api = new ChatGPTAPI({ apiKey: process.env.OPENAI_API_KEY })
|
||||
})()
|
||||
|
||||
async function chatReply(
|
||||
message: string,
|
||||
lastContext?: { conversationId?: string; parentMessageId?: string },
|
||||
) {
|
||||
if (!message)
|
||||
return sendResponse({ type: 'Fail', message: 'Message is empty' })
|
||||
|
||||
try {
|
||||
let options: SendMessageOptions = { timeoutMs: 30 * 1000 }
|
||||
|
||||
if (lastContext)
|
||||
options = { ...lastContext }
|
||||
|
||||
const response = await api.sendMessage(message, { ...options })
|
||||
|
||||
return sendResponse({ type: 'Success', data: response })
|
||||
}
|
||||
catch (error: any) {
|
||||
return sendResponse({ type: 'Fail', message: error.message })
|
||||
}
|
||||
}
|
||||
|
||||
export { chatReply }
|
32
service/src/index.ts
Normal file
32
service/src/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import express from 'express'
|
||||
import type { ChatContext } from './chatgpt'
|
||||
import { chatReply } from './chatgpt'
|
||||
|
||||
const app = express()
|
||||
const router = express.Router()
|
||||
|
||||
app.use(express.static('public'))
|
||||
app.use(express.json())
|
||||
|
||||
app.all('*', (_, res, next) => {
|
||||
res.header('Access-Control-Allow-Origin', '*')
|
||||
res.header('Access-Control-Allow-Headers', 'Content-Type')
|
||||
res.header('Access-Control-Allow-Methods', '*')
|
||||
next()
|
||||
})
|
||||
|
||||
router.post('/chat', async (req, res) => {
|
||||
try {
|
||||
const { prompt, options = {} } = req.body as { prompt: string; options?: ChatContext }
|
||||
const response = await chatReply(prompt, options)
|
||||
res.send(response)
|
||||
}
|
||||
catch (error) {
|
||||
res.send(error)
|
||||
}
|
||||
})
|
||||
|
||||
app.use('', router)
|
||||
app.use('/api', router)
|
||||
|
||||
app.listen(3002, () => globalThis.console.log('Server is running on port 3002'))
|
22
service/src/utils/index.ts
Normal file
22
service/src/utils/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
interface SendResponseOptions {
|
||||
type: 'Success' | 'Fail'
|
||||
message?: string
|
||||
data?: any
|
||||
}
|
||||
|
||||
export function sendResponse(options: SendResponseOptions) {
|
||||
if (options.type === 'Success') {
|
||||
return Promise.resolve({
|
||||
message: options.message ?? null,
|
||||
data: options.data ?? null,
|
||||
status: options.type,
|
||||
})
|
||||
}
|
||||
|
||||
// eslint-disable-next-line prefer-promise-reject-errors
|
||||
return Promise.reject({
|
||||
message: options.message ?? 'Failed',
|
||||
data: options.data ?? null,
|
||||
status: options.type,
|
||||
})
|
||||
}
|
27
service/tsconfig.json
Normal file
27
service/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"lib": [
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"baseUrl": ".",
|
||||
"outDir": "build",
|
||||
"noEmit": true
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"build"
|
||||
],
|
||||
"include": [
|
||||
"**/*.ts"
|
||||
]
|
||||
}
|
13
service/tsup.config.ts
Normal file
13
service/tsup.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'tsup'
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
outDir: 'build',
|
||||
target: 'es2020',
|
||||
format: ['cjs'],
|
||||
splitting: false,
|
||||
sourcemap: true,
|
||||
minify: false,
|
||||
shims: true,
|
||||
dts: false,
|
||||
})
|
15
src/App.vue
15
src/App.vue
@@ -1,13 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { GithubSite, NaiveProvider } from '@/components'
|
||||
import Chat from '@/views/Chat/index.vue'
|
||||
import { NConfigProvider } from 'naive-ui'
|
||||
import { NaiveProvider } from '@/components/common'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NaiveProvider>
|
||||
<div class="h-full overflow-hidden pb-[50px] p-4">
|
||||
<Chat />
|
||||
<GithubSite />
|
||||
</div>
|
||||
</NaiveProvider>
|
||||
<NConfigProvider class="h-full">
|
||||
<NaiveProvider>
|
||||
<RouterView />
|
||||
</NaiveProvider>
|
||||
</NConfigProvider>
|
||||
</template>
|
||||
|
14
src/api/index.ts
Normal file
14
src/api/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { GenericAbortSignal } from 'axios'
|
||||
import { post } from '@/utils/request'
|
||||
|
||||
export function fetchChatAPI<T = any>(
|
||||
prompt: string,
|
||||
options?: { conversationId?: string; parentMessageId?: string },
|
||||
signal?: GenericAbortSignal,
|
||||
) {
|
||||
return post<T>({
|
||||
url: '/chat',
|
||||
data: { prompt, options },
|
||||
signal,
|
||||
})
|
||||
}
|
@@ -1,10 +0,0 @@
|
||||
<template>
|
||||
<div class="fixed bottom-0 left-0 right-0 p-4 text-sm text-center text-neutral-500">
|
||||
<span class="text-center">
|
||||
❤️ Made By ChenZhaoYu -
|
||||
<a href="https://github.com/Chanzhaoyu/chatgpt-bot" target="_blank" class="text-blue-500">
|
||||
Github
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
@@ -1,27 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { NConfigProvider, NMessageProvider, useMessage } from 'naive-ui'
|
||||
|
||||
function registerNaiveTools() {
|
||||
window.$message = useMessage()
|
||||
}
|
||||
|
||||
const NaiveProviderContent = defineComponent({
|
||||
name: 'NaiveProviderContent',
|
||||
setup() {
|
||||
registerNaiveTools()
|
||||
},
|
||||
render() {
|
||||
return h('div')
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NConfigProvider class="h-full">
|
||||
<NMessageProvider>
|
||||
<slot />
|
||||
<NaiveProviderContent />
|
||||
</NMessageProvider>
|
||||
</NConfigProvider>
|
||||
</template>
|
1
src/components/business/index.ts
Normal file
1
src/components/business/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { }
|
20
src/components/common/HoverButton/Button.vue
Normal file
20
src/components/common/HoverButton/Button.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang='ts'>
|
||||
interface Emit {
|
||||
(e: 'click'): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emit>()
|
||||
|
||||
function handleClick() {
|
||||
emit('click')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="flex items-center justify-center w-10 h-10 transition rounded-full hover:bg-neutral-100"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
46
src/components/common/HoverButton/index.vue
Normal file
46
src/components/common/HoverButton/index.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script setup lang='ts'>
|
||||
import { computed } from 'vue'
|
||||
import type { PopoverPlacement } from 'naive-ui'
|
||||
import { NTooltip } from 'naive-ui'
|
||||
import Button from './Button.vue'
|
||||
|
||||
interface Props {
|
||||
tooltip?: string
|
||||
placement?: PopoverPlacement
|
||||
}
|
||||
|
||||
interface Emit {
|
||||
(e: 'click'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
tooltip: '',
|
||||
placement: 'bottom',
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emit>()
|
||||
|
||||
const showTooltip = computed(() => Boolean(props.tooltip))
|
||||
|
||||
function handleClick() {
|
||||
emit('click')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="showTooltip">
|
||||
<NTooltip :placement="placement" trigger="hover">
|
||||
<template #trigger>
|
||||
<Button @click="handleClick">
|
||||
<slot />
|
||||
</Button>
|
||||
</template>
|
||||
{{ tooltip }}
|
||||
</NTooltip>
|
||||
</div>
|
||||
<div v-else>
|
||||
<Button @click="handleClick">
|
||||
<slot />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
43
src/components/common/NaiveProvider/index.vue
Normal file
43
src/components/common/NaiveProvider/index.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { defineComponent, h } from 'vue'
|
||||
import {
|
||||
NDialogProvider,
|
||||
NLoadingBarProvider,
|
||||
NMessageProvider,
|
||||
NNotificationProvider,
|
||||
useDialog,
|
||||
useLoadingBar,
|
||||
useMessage,
|
||||
useNotification,
|
||||
} from 'naive-ui'
|
||||
|
||||
function registerNaiveTools() {
|
||||
window.$loadingBar = useLoadingBar()
|
||||
window.$dialog = useDialog()
|
||||
window.$message = useMessage()
|
||||
window.$notification = useNotification()
|
||||
}
|
||||
|
||||
const NaiveProviderContent = defineComponent({
|
||||
name: 'NaiveProviderContent',
|
||||
setup() {
|
||||
registerNaiveTools()
|
||||
},
|
||||
render() {
|
||||
return h('div')
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NLoadingBarProvider>
|
||||
<NDialogProvider>
|
||||
<NNotificationProvider>
|
||||
<NMessageProvider>
|
||||
<slot />
|
||||
<NaiveProviderContent />
|
||||
</NMessageProvider>
|
||||
</NNotificationProvider>
|
||||
</NDialogProvider>
|
||||
</NLoadingBarProvider>
|
||||
</template>
|
19
src/components/common/UserAvatar/index.vue
Normal file
19
src/components/common/UserAvatar/index.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang='ts'>
|
||||
import { GithubSite } from '@/components/custom'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 overflow-hidden rounded-full">
|
||||
<img class="object-cover" src="@/assets/avatar.jpg" alt="avatar">
|
||||
</div>
|
||||
<div class="ml-2">
|
||||
<h2 class="font-bold text-md">
|
||||
ChenZhaoYu
|
||||
</h2>
|
||||
<p class="text-xs text-gray-500">
|
||||
<GithubSite />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
6
src/components/common/index.ts
Normal file
6
src/components/common/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import HoverButton from './HoverButton/index.vue'
|
||||
import NaiveProvider from './NaiveProvider/index.vue'
|
||||
import SvgIcon from './SvgIcon/index.vue'
|
||||
import UserAvatar from './UserAvatar/index.vue'
|
||||
|
||||
export { HoverButton, NaiveProvider, SvgIcon, UserAvatar }
|
8
src/components/custom/GithubSite.vue
Normal file
8
src/components/custom/GithubSite.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<div class="text-center text-neutral-500">
|
||||
<span>❤️ Star on</span>
|
||||
<a href="https://github.com/Chanzhaoyu/chatgpt-bot" target="_blank" class="text-blue-500">
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
3
src/components/custom/index.ts
Normal file
3
src/components/custom/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import GithubSite from './GithubSite.vue'
|
||||
|
||||
export { GithubSite }
|
@@ -1,5 +0,0 @@
|
||||
import NaiveProvider from './NaiveProvider.vue'
|
||||
import Icon from './Icon.vue'
|
||||
import GithubSite from './GithubSite.vue'
|
||||
|
||||
export { NaiveProvider, Icon, GithubSite }
|
21
src/directives/highlight.ts
Normal file
21
src/directives/highlight.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { App, Directive } from 'vue'
|
||||
import hljs from 'highlight.js'
|
||||
|
||||
function highlightCode(el: HTMLElement) {
|
||||
const regexp = /^(?:\s{4}|\t).+/gm
|
||||
if (el.textContent?.indexOf(' = ') !== -1 || el.textContent.match(regexp))
|
||||
hljs.highlightBlock(el)
|
||||
}
|
||||
|
||||
export default function setupHighlightDirective(app: App) {
|
||||
const highLightDirective: Directive<HTMLElement> = {
|
||||
mounted(el: HTMLElement) {
|
||||
highlightCode(el)
|
||||
},
|
||||
updated(el: HTMLElement) {
|
||||
highlightCode(el)
|
||||
},
|
||||
}
|
||||
|
||||
app.directive('highlight', highLightDirective)
|
||||
}
|
6
src/directives/index.ts
Normal file
6
src/directives/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { App } from 'vue'
|
||||
import setupHighlightDirective from './highlight'
|
||||
|
||||
export function setupDirectives(app: App) {
|
||||
setupHighlightDirective(app)
|
||||
}
|
8
src/hooks/useBasicLayout.ts
Normal file
8
src/hooks/useBasicLayout.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
||||
|
||||
export function useBasicLayout() {
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||
const isMobile = breakpoints.smaller('sm')
|
||||
|
||||
return { isMobile }
|
||||
}
|
24
src/main.ts
24
src/main.ts
@@ -1,18 +1,20 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './styles/global.css'
|
||||
import { setupDirectives } from './directives'
|
||||
import { setupAssets } from '@/plugins'
|
||||
import { setupStore } from '@/store'
|
||||
import { setupRouter } from '@/router'
|
||||
|
||||
/** Tailwind's Preflight Style Override */
|
||||
function naiveStyleOverride() {
|
||||
const meta = document.createElement('meta')
|
||||
meta.name = 'naive-ui-style'
|
||||
document.head.appendChild(meta)
|
||||
}
|
||||
|
||||
/** Setup */
|
||||
function bootstrap() {
|
||||
async function bootstrap() {
|
||||
const app = createApp(App)
|
||||
naiveStyleOverride()
|
||||
setupAssets()
|
||||
|
||||
setupStore(app)
|
||||
|
||||
setupDirectives(app)
|
||||
|
||||
await setupRouter(app)
|
||||
|
||||
app.mount('#app')
|
||||
}
|
||||
|
||||
|
15
src/plugins/assets.ts
Normal file
15
src/plugins/assets.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'highlight.js/styles/xcode.css'
|
||||
import '@/styles/global.css'
|
||||
|
||||
/** 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
|
3
src/plugins/index.ts
Normal file
3
src/plugins/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import setupAssets from './assets'
|
||||
|
||||
export { setupAssets }
|
31
src/router/index.ts
Normal file
31
src/router/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
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'),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
scrollBehavior: () => ({ left: 0, top: 0 }),
|
||||
})
|
||||
|
||||
export async function setupRouter(app: App) {
|
||||
app.use(router)
|
||||
await router.isReady()
|
||||
}
|
9
src/store/index.ts
Normal file
9
src/store/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { App } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
export function setupStore(app: App) {
|
||||
const store = createPinia()
|
||||
app.use(store)
|
||||
}
|
||||
|
||||
export * from './modules'
|
20
src/store/modules/app/helper.ts
Normal file
20
src/store/modules/app/helper.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ss } from '@/utils/storage'
|
||||
|
||||
const LOCAL_NAME = 'appSetting'
|
||||
|
||||
export interface AppState {
|
||||
siderCollapsed: boolean
|
||||
}
|
||||
|
||||
export function defaultSetting() {
|
||||
return { siderCollapsed: false }
|
||||
}
|
||||
|
||||
export function getLocalSetting() {
|
||||
const localSetting: AppState | undefined = ss.get(LOCAL_NAME)
|
||||
return localSetting ?? defaultSetting()
|
||||
}
|
||||
|
||||
export function setLocalSetting(setting: AppState) {
|
||||
ss.set(LOCAL_NAME, setting)
|
||||
}
|
13
src/store/modules/app/index.ts
Normal file
13
src/store/modules/app/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { AppState } from './helper'
|
||||
import { getLocalSetting, setLocalSetting } from './helper'
|
||||
|
||||
export const useAppStore = defineStore('app-store', {
|
||||
state: (): AppState => getLocalSetting(),
|
||||
actions: {
|
||||
setSiderCollapsed(collapsed: boolean) {
|
||||
this.siderCollapsed = collapsed
|
||||
setLocalSetting(this.$state)
|
||||
},
|
||||
},
|
||||
})
|
17
src/store/modules/chat/helper.ts
Normal file
17
src/store/modules/chat/helper.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { ss } from '@/utils/storage'
|
||||
|
||||
const LOCAL_NAME = 'chatStorage'
|
||||
|
||||
export function defaultState(): Chat.ChatState {
|
||||
const uuid = Date.now()
|
||||
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)
|
||||
}
|
138
src/store/modules/chat/index.ts
Normal file
138
src/store/modules/chat/index.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { getLocalState, setLocalState } from './helper'
|
||||
import { router } from '@/router'
|
||||
|
||||
export const useChatStore = defineStore('chat-store', {
|
||||
state: (): Chat.ChatState => getLocalState(),
|
||||
|
||||
getters: {
|
||||
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<Chat.History>) {
|
||||
const index = this.history.findIndex(item => item.uuid === uuid)
|
||||
if (index !== -1) {
|
||||
this.history[index] = { ...this.history[index], ...edit }
|
||||
this.recordState()
|
||||
}
|
||||
},
|
||||
|
||||
async deleteHistory(index: number) {
|
||||
this.history.splice(index, 1)
|
||||
this.chat.splice(index, 1)
|
||||
|
||||
if (this.history.length === 0) {
|
||||
this.active = null
|
||||
this.reloadRoute()
|
||||
return
|
||||
}
|
||||
|
||||
if (index > 0 && index <= this.history.length) {
|
||||
const uuid = this.history[index - 1].uuid
|
||||
this.active = uuid
|
||||
this.reloadRoute(uuid)
|
||||
return
|
||||
}
|
||||
|
||||
if (index === 0) {
|
||||
if (this.history.length > 0) {
|
||||
const uuid = this.history[0].uuid
|
||||
this.active = uuid
|
||||
this.reloadRoute(uuid)
|
||||
}
|
||||
}
|
||||
|
||||
if (index > this.history.length) {
|
||||
const uuid = this.history[this.history.length - 1].uuid
|
||||
this.active = uuid
|
||||
this.reloadRoute(uuid)
|
||||
}
|
||||
},
|
||||
|
||||
async setActive(uuid: number) {
|
||||
this.active = uuid
|
||||
return await this.reloadRoute(uuid)
|
||||
},
|
||||
|
||||
addChatByUuid(uuid: number, chat: Chat.Chat) {
|
||||
if (!uuid || uuid === 0) {
|
||||
if (this.history.length === 0) {
|
||||
const uuid = Date.now()
|
||||
this.history.push({ uuid, title: chat.text, isEdit: false })
|
||||
this.chat.push({ uuid, data: [chat] })
|
||||
this.active = uuid
|
||||
this.recordState()
|
||||
}
|
||||
else {
|
||||
this.chat[0].data.push(chat)
|
||||
if (this.history[0].title === 'New Chat')
|
||||
this.history[0].title = chat.text
|
||||
this.recordState()
|
||||
}
|
||||
}
|
||||
|
||||
const index = this.chat.findIndex(item => item.uuid === uuid)
|
||||
if (index !== -1) {
|
||||
this.chat[index].data.push(chat)
|
||||
if (this.history[index].title === 'New Chat')
|
||||
this.history[index].title = chat.text
|
||||
this.recordState()
|
||||
}
|
||||
},
|
||||
|
||||
updateChatByUuid(uuid: number, index: number, chat: Chat.Chat) {
|
||||
if (!uuid || uuid === 0) {
|
||||
if (this.chat.length) {
|
||||
this.chat[0].data[index] = chat
|
||||
this.recordState()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const chatIndex = this.chat.findIndex(item => item.uuid === uuid)
|
||||
if (chatIndex !== -1) {
|
||||
this.chat[chatIndex].data[index] = chat
|
||||
this.recordState()
|
||||
}
|
||||
},
|
||||
|
||||
clearChatByUuid(uuid: number) {
|
||||
if (!uuid || uuid === 0) {
|
||||
if (this.chat.length) {
|
||||
this.chat[0].data = []
|
||||
this.recordState()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const index = this.chat.findIndex(item => item.uuid === uuid)
|
||||
if (index !== -1) {
|
||||
this.chat[index].data = []
|
||||
this.recordState()
|
||||
}
|
||||
},
|
||||
|
||||
async reloadRoute(uuid?: number) {
|
||||
this.recordState()
|
||||
await router.push({ name: 'Chat', params: { uuid } })
|
||||
},
|
||||
|
||||
recordState() {
|
||||
setLocalState(this.$state)
|
||||
},
|
||||
},
|
||||
})
|
2
src/store/modules/index.ts
Normal file
2
src/store/modules/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './app'
|
||||
export * from './chat'
|
45
src/typings/chat.d.ts
vendored
Normal file
45
src/typings/chat.d.ts
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
declare namespace Chat {
|
||||
|
||||
interface Chat {
|
||||
dateTime: string
|
||||
text: string
|
||||
inversion?: boolean
|
||||
error?: boolean
|
||||
loading?: boolean
|
||||
conversationOptions?: ConversationRequest | null
|
||||
requestOptions: { prompt: string; options?: ConversationRequest | null }
|
||||
}
|
||||
|
||||
interface History {
|
||||
title: string
|
||||
isEdit: boolean
|
||||
uuid: number
|
||||
}
|
||||
|
||||
interface ChatState {
|
||||
active: number | null
|
||||
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
|
||||
}
|
||||
}
|
2
src/typings/env.d.ts
vendored
2
src/typings/env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
/** api url */
|
||||
readonly VITE_GLOB_API_URL: string;
|
||||
readonly VITE_APP_API_BASE_URL: string;
|
||||
}
|
||||
|
3
src/typings/global.d.ts
vendored
3
src/typings/global.d.ts
vendored
@@ -1,3 +1,6 @@
|
||||
interface Window {
|
||||
$loadingBar?: import('naive-ui').LoadingBarProviderInst;
|
||||
$dialog?: import('naive-ui').DialogProviderInst;
|
||||
$message?: import('naive-ui').MessageProviderInst;
|
||||
$notification?: import('naive-ui').NotificationProviderInst;
|
||||
}
|
||||
|
18
src/utils/crypto/index.ts
Normal file
18
src/utils/crypto/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
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
|
||||
}
|
55
src/utils/is/index.ts
Normal file
55
src/utils/is/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export function isNumber<T extends number>(value: T | unknown): value is number {
|
||||
return Object.prototype.toString.call(value) === '[object Number]'
|
||||
}
|
||||
|
||||
export function isString<T extends string>(value: T | unknown): value is string {
|
||||
return Object.prototype.toString.call(value) === '[object String]'
|
||||
}
|
||||
|
||||
export function isBoolean<T extends boolean>(value: T | unknown): value is boolean {
|
||||
return Object.prototype.toString.call(value) === '[object Boolean]'
|
||||
}
|
||||
|
||||
export function isNull<T extends null>(value: T | unknown): value is null {
|
||||
return Object.prototype.toString.call(value) === '[object Null]'
|
||||
}
|
||||
|
||||
export function isUndefined<T extends undefined>(value: T | unknown): value is undefined {
|
||||
return Object.prototype.toString.call(value) === '[object Undefined]'
|
||||
}
|
||||
|
||||
export function isObject<T extends object>(value: T | unknown): value is object {
|
||||
return Object.prototype.toString.call(value) === '[object Object]'
|
||||
}
|
||||
|
||||
export function isArray<T extends any[]>(value: T | unknown): value is T {
|
||||
return Object.prototype.toString.call(value) === '[object Array]'
|
||||
}
|
||||
|
||||
export function isFunction<T extends (...args: any[]) => any | void | never>(value: T | unknown): value is T {
|
||||
return Object.prototype.toString.call(value) === '[object Function]'
|
||||
}
|
||||
|
||||
export function isDate<T extends Date>(value: T | unknown): value is T {
|
||||
return Object.prototype.toString.call(value) === '[object Date]'
|
||||
}
|
||||
|
||||
export function isRegExp<T extends RegExp>(value: T | unknown): value is T {
|
||||
return Object.prototype.toString.call(value) === '[object RegExp]'
|
||||
}
|
||||
|
||||
export function isPromise<T extends Promise<any>>(value: T | unknown): value is T {
|
||||
return Object.prototype.toString.call(value) === '[object Promise]'
|
||||
}
|
||||
|
||||
export function isSet<T extends Set<any>>(value: T | unknown): value is T {
|
||||
return Object.prototype.toString.call(value) === '[object Set]'
|
||||
}
|
||||
|
||||
export function isMap<T extends Map<any, any>>(value: T | unknown): value is T {
|
||||
return Object.prototype.toString.call(value) === '[object Map]'
|
||||
}
|
||||
|
||||
export function isFile<T extends File>(value: T | unknown): value is T {
|
||||
return Object.prototype.toString.call(value) === '[object File]'
|
||||
}
|
29
src/utils/request/axios.ts
Normal file
29
src/utils/request/axios.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import axios, { type AxiosResponse } from 'axios'
|
||||
|
||||
const service = axios.create({
|
||||
baseURL: import.meta.env.VITE_GLOB_API_URL,
|
||||
timeout: 30 * 1000,
|
||||
})
|
||||
|
||||
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
|
71
src/utils/request/index.ts
Normal file
71
src/utils/request/index.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { AxiosResponse, GenericAbortSignal } from 'axios'
|
||||
import request from './axios'
|
||||
|
||||
export interface HttpOption {
|
||||
url: string
|
||||
data?: any
|
||||
method?: string
|
||||
headers?: any
|
||||
signal?: GenericAbortSignal
|
||||
beforeRequest?: () => void
|
||||
afterRequest?: () => void
|
||||
}
|
||||
|
||||
export interface Response<T = any> {
|
||||
data: T
|
||||
message: string | null
|
||||
status: string
|
||||
}
|
||||
|
||||
function http<T = any>({ url, data, method, headers, signal, beforeRequest, afterRequest }: HttpOption) {
|
||||
const successHandler = (res: AxiosResponse<Response<T>>) => {
|
||||
if (res.data.status === 'Success')
|
||||
return res.data
|
||||
|
||||
return Promise.reject(res.data)
|
||||
}
|
||||
|
||||
const failHandler = (error: Response<Error>) => {
|
||||
afterRequest?.()
|
||||
throw new Error(error?.message || 'Error')
|
||||
}
|
||||
|
||||
beforeRequest?.()
|
||||
|
||||
method = method || 'GET'
|
||||
|
||||
const params = Object.assign(typeof data === 'function' ? data() : data ?? {}, {})
|
||||
|
||||
return method === 'GET'
|
||||
? request.get(url, { params, signal }).then(successHandler, failHandler)
|
||||
: request.post(url, params, { headers, signal }).then(successHandler, failHandler)
|
||||
}
|
||||
|
||||
export function get<T = any>(
|
||||
{ url, data, method = 'GET', signal, beforeRequest, afterRequest }: HttpOption,
|
||||
): Promise<Response<T>> {
|
||||
return http<T>({
|
||||
url,
|
||||
method,
|
||||
data,
|
||||
signal,
|
||||
beforeRequest,
|
||||
afterRequest,
|
||||
})
|
||||
}
|
||||
|
||||
export function post<T = any>(
|
||||
{ url, data, method = 'POST', headers, signal, beforeRequest, afterRequest }: HttpOption,
|
||||
): Promise<Response<T>> {
|
||||
return http<T>({
|
||||
url,
|
||||
method,
|
||||
data,
|
||||
headers,
|
||||
signal,
|
||||
beforeRequest,
|
||||
afterRequest,
|
||||
})
|
||||
}
|
||||
|
||||
export default post
|
1
src/utils/storage/index.ts
Normal file
1
src/utils/storage/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './local'
|
70
src/utils/storage/local.ts
Normal file
70
src/utils/storage/local.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { deCrypto, enCrypto } from '../crypto'
|
||||
|
||||
interface StorageData<T = any> {
|
||||
data: T
|
||||
expire: number | null
|
||||
}
|
||||
|
||||
export function createLocalStorage(options?: { expire?: number | null; crypto?: boolean }) {
|
||||
const DEFAULT_CACHE_TIME = 60 * 60 * 24 * 7
|
||||
|
||||
const { expire, crypto } = Object.assign(
|
||||
{
|
||||
expire: DEFAULT_CACHE_TIME,
|
||||
crypto: true,
|
||||
},
|
||||
options,
|
||||
)
|
||||
|
||||
function set<T = any>(key: string, data: T) {
|
||||
const storageData: StorageData<T> = {
|
||||
data,
|
||||
expire: expire !== null ? new Date().getTime() + expire * 1000 : null,
|
||||
}
|
||||
|
||||
const json = crypto ? enCrypto(storageData) : JSON.stringify(storageData)
|
||||
window.localStorage.setItem(key, json)
|
||||
}
|
||||
|
||||
function get(key: string) {
|
||||
const json = window.localStorage.getItem(key)
|
||||
if (json) {
|
||||
let storageData: StorageData | null = null
|
||||
|
||||
try {
|
||||
storageData = crypto ? deCrypto(json) : JSON.parse(json)
|
||||
}
|
||||
catch {
|
||||
// Prevent failure
|
||||
}
|
||||
|
||||
if (storageData) {
|
||||
const { data, expire } = storageData
|
||||
if (expire === null || expire >= Date.now())
|
||||
return data
|
||||
}
|
||||
|
||||
remove(key)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function remove(key: string) {
|
||||
window.localStorage.removeItem(key)
|
||||
}
|
||||
|
||||
function clear() {
|
||||
window.localStorage.clear()
|
||||
}
|
||||
|
||||
return {
|
||||
set,
|
||||
get,
|
||||
remove,
|
||||
clear,
|
||||
}
|
||||
}
|
||||
|
||||
export const ls = createLocalStorage()
|
||||
|
||||
export const ss = createLocalStorage({ expire: null, crypto: false })
|
@@ -1,5 +0,0 @@
|
||||
<template>
|
||||
<span class="leading-relaxed whitespace-pre-wrap">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
@@ -1,31 +0,0 @@
|
||||
<script setup lang='ts'>
|
||||
import Avatar from './Avatar.vue'
|
||||
import Text from './Text.vue'
|
||||
|
||||
interface Props {
|
||||
message?: string
|
||||
dateTime?: string
|
||||
reversal?: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full mb-6" :class="[{ 'flex-row-reverse': reversal }]">
|
||||
<div
|
||||
class="flex items-center justify-center rounded-full overflow-hidden w-[32px] h-[32px]"
|
||||
:class="[reversal ? 'ml-3' : 'mr-3']"
|
||||
>
|
||||
<Avatar :image="reversal" />
|
||||
</div>
|
||||
<div class="flex flex-col flex-1 text-sm" :class="[reversal ? 'items-end' : 'items-start']">
|
||||
<span class="text-xs text-[#b4bbc4]">
|
||||
{{ dateTime }}
|
||||
</span>
|
||||
<div class="p-2 mt-2 rounded-md" :class="[reversal ? 'bg-[#d2f9d1]' : 'bg-[#f4f6f8]']">
|
||||
<Text>{{ message }}</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
@@ -1,118 +0,0 @@
|
||||
<script setup lang='ts'>
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
import { NButton, NInput, NPopover, useMessage } from 'naive-ui'
|
||||
import { Message } from './components'
|
||||
import { clearChatContext, fetchChatAPI } from './request'
|
||||
import { Icon } from '@/components'
|
||||
|
||||
interface ListProps {
|
||||
dateTime: string
|
||||
message: string
|
||||
reversal?: boolean
|
||||
}
|
||||
|
||||
const scrollRef = ref<HTMLDivElement>()
|
||||
|
||||
const ms = useMessage()
|
||||
|
||||
const prompt = ref('')
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const list = ref<ListProps[]>([])
|
||||
|
||||
onMounted(initChat)
|
||||
|
||||
function initChat() {
|
||||
addMessage('Hi, I am ChatGPT, a chatbot based on GPT-3.', false)
|
||||
}
|
||||
|
||||
async function handleClear() {
|
||||
try {
|
||||
const { message } = await clearChatContext()
|
||||
ms.success(message)
|
||||
list.value = []
|
||||
setTimeout(initChat, 100)
|
||||
}
|
||||
catch (error) {
|
||||
ms.error('Clear failed, please try again later.')
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnter(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter')
|
||||
handleSubmit()
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const message = prompt.value.trim()
|
||||
|
||||
if (!message || !message.length) {
|
||||
ms.warning('Please enter a message')
|
||||
return
|
||||
}
|
||||
|
||||
addMessage(message, true)
|
||||
prompt.value = ''
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const { text } = await fetchChatAPI(message)
|
||||
addMessage(text, false)
|
||||
}
|
||||
catch (error: any) {
|
||||
addMessage(error.message ?? 'Request failed, please try again later.', false)
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function addMessage(message: string, reversal = false) {
|
||||
list.value.push({ dateTime: new Date().toLocaleString(), message, reversal })
|
||||
nextTick(() => scrollRef.value && (scrollRef.value.scrollTop = scrollRef.value.scrollHeight))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full overflow-hidden border rounded-md shadow-md">
|
||||
<header class="flex items-center justify-between p-4">
|
||||
<h1 class="text-xl font-bold">
|
||||
ChatGPT Web
|
||||
</h1>
|
||||
<div>
|
||||
<NPopover>
|
||||
<template #trigger>
|
||||
<button
|
||||
class="w-[40px] h-[40px] rounded-full hover:bg-neutral-100 transition flex justify-center items-center"
|
||||
@click="handleClear"
|
||||
>
|
||||
<Icon icon="ri:delete-bin-6-line" />
|
||||
</button>
|
||||
</template>
|
||||
<span>Clear Context</span>
|
||||
</NPopover>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 overflow-hidden border-y">
|
||||
<div ref="scrollRef" class="h-full p-4 overflow-hidden overflow-y-auto">
|
||||
<div>
|
||||
<Message
|
||||
v-for="(item, index) of list" :key="index" :date-time="item.dateTime" :message="item.message"
|
||||
:reversal="item.reversal"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer class="p-4">
|
||||
<div class="flex items-center justify-between space-x-2">
|
||||
<NInput v-model:value="prompt" placeholder="Type a message..." @keypress="handleEnter" />
|
||||
<NButton type="primary" :loading="loading" @click="handleSubmit">
|
||||
<template #icon>
|
||||
<Icon icon="ri:send-plane-fill" />
|
||||
</template>
|
||||
</NButton>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
@@ -1,41 +0,0 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_GLOB_API_URL
|
||||
|
||||
async function fetchChatAPI(message: string) {
|
||||
if (!message || message.trim() === '')
|
||||
return
|
||||
|
||||
try {
|
||||
const { status, data } = await axios.post(`${BASE_URL}/chat`, { message })
|
||||
|
||||
if (status === 200) {
|
||||
if (data.text)
|
||||
return Promise.resolve(data)
|
||||
|
||||
if (data.statusText)
|
||||
return Promise.reject(new Error(data.statusText))
|
||||
}
|
||||
|
||||
return Promise.reject(new Error('Request failed'))
|
||||
}
|
||||
catch (error) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function clearChatContext() {
|
||||
try {
|
||||
const { status, data } = await axios.post(`${BASE_URL}/clear`)
|
||||
|
||||
if (status === 200)
|
||||
return Promise.resolve(data)
|
||||
|
||||
return Promise.reject(new Error('Request failed'))
|
||||
}
|
||||
catch (error) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
export { fetchChatAPI, clearChatContext }
|
28
src/views/chat/components/Message/Text.vue
Normal file
28
src/views/chat/components/Message/Text.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script lang="ts" setup>
|
||||
interface Props {
|
||||
inversion?: boolean
|
||||
error?: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="min-w-[20px] p-2 rounded-md"
|
||||
:class="[inversion ? 'bg-[#d2f9d1]' : 'bg-[#f4f6f8]', { 'text-red-500': error }]"
|
||||
>
|
||||
<span
|
||||
v-highlight
|
||||
class="leading-relaxed whitespace-pre-wrap"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.hljs {
|
||||
background-color: #fff0 !important;
|
||||
}
|
||||
</style>
|
54
src/views/chat/components/Message/index.vue
Normal file
54
src/views/chat/components/Message/index.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<script setup lang='ts'>
|
||||
import Avatar from './Avatar.vue'
|
||||
import Text from './Text.vue'
|
||||
import { SvgIcon } from '@/components/common'
|
||||
|
||||
interface Props {
|
||||
dateTime?: string
|
||||
text?: string
|
||||
inversion?: boolean
|
||||
error?: boolean
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
interface Emit {
|
||||
(ev: 'regenerate'): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<Emit>()
|
||||
|
||||
function handleRegenerate() {
|
||||
emit('regenerate')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full mb-6" :class="[{ 'flex-row-reverse': inversion }]">
|
||||
<div
|
||||
class="flex items-center justify-center rounded-full overflow-hidden w-[32px] h-[32px]"
|
||||
:class="[inversion ? 'ml-3' : 'mr-3']"
|
||||
>
|
||||
<Avatar :image="inversion" />
|
||||
</div>
|
||||
<div class="flex flex-col flex-1 text-sm" :class="[inversion ? 'items-end' : 'items-start']">
|
||||
<span class="text-xs text-[#b4bbc4]">
|
||||
{{ dateTime }}
|
||||
</span>
|
||||
<div class="flex items-end mt-2">
|
||||
<Text :inversion="inversion" :error="error">
|
||||
<span v-if="loading" class="w-[3px] h-[20px] block animate-blink" />
|
||||
<span v-else>{{ text }}</span>
|
||||
</Text>
|
||||
<button
|
||||
v-if="!inversion && !loading"
|
||||
class="mb-2 ml-2 transition text-neutral-400 hover:text-neutral-800"
|
||||
@click="handleRegenerate"
|
||||
>
|
||||
<SvgIcon icon="ri:restart-line" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
18
src/views/chat/hooks/useChat.ts
Normal file
18
src/views/chat/hooks/useChat.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useChatStore } from '@/store'
|
||||
|
||||
export function useChat() {
|
||||
const chatStore = useChatStore()
|
||||
|
||||
const addChat = (uuid: number, chat: Chat.Chat) => {
|
||||
chatStore.addChatByUuid(uuid, chat)
|
||||
}
|
||||
|
||||
const updateChat = (uuid: number, index: number, chat: Chat.Chat) => {
|
||||
chatStore.updateChatByUuid(uuid, index, chat)
|
||||
}
|
||||
|
||||
return {
|
||||
addChat,
|
||||
updateChat,
|
||||
}
|
||||
}
|
32
src/views/chat/hooks/useScroll.ts
Normal file
32
src/views/chat/hooks/useScroll.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
type ScrollElement = HTMLDivElement | null
|
||||
|
||||
interface ScrollReturn {
|
||||
scrollRef: Ref<ScrollElement>
|
||||
scrollToBottom: () => Promise<void>
|
||||
scrollToTop: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useScroll(): ScrollReturn {
|
||||
const scrollRef = ref<ScrollElement>(null)
|
||||
|
||||
const scrollToBottom = async () => {
|
||||
await nextTick()
|
||||
if (scrollRef.value)
|
||||
scrollRef.value.scrollTop = scrollRef.value.scrollHeight
|
||||
}
|
||||
|
||||
const scrollToTop = async () => {
|
||||
await nextTick()
|
||||
if (scrollRef.value)
|
||||
scrollRef.value.scrollTop = 0
|
||||
}
|
||||
|
||||
return {
|
||||
scrollRef,
|
||||
scrollToBottom,
|
||||
scrollToTop,
|
||||
}
|
||||
}
|
292
src/views/chat/index.vue
Normal file
292
src/views/chat/index.vue
Normal file
@@ -0,0 +1,292 @@
|
||||
<script setup lang='ts'>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { NButton, NInput, useDialog } from 'naive-ui'
|
||||
import { Message } from './components'
|
||||
import { useScroll } from './hooks/useScroll'
|
||||
import { useChat } from './hooks/useChat'
|
||||
import { HoverButton, SvgIcon } from '@/components/common'
|
||||
import { useBasicLayout } from '@/hooks/useBasicLayout'
|
||||
import { useChatStore } from '@/store'
|
||||
import { fetchChatAPI } from '@/api'
|
||||
|
||||
let controller = new AbortController()
|
||||
|
||||
const route = useRoute()
|
||||
const dialog = useDialog()
|
||||
|
||||
const chatStore = useChatStore()
|
||||
|
||||
const { isMobile } = useBasicLayout()
|
||||
const { addChat, updateChat } = useChat()
|
||||
const { scrollRef, scrollToBottom } = useScroll()
|
||||
|
||||
const { uuid } = route.params as { uuid: string }
|
||||
|
||||
const dataSources = computed(() => chatStore.getChatByUuid(+uuid))
|
||||
const conversationList = computed(() => dataSources.value.filter(item => (!item.inversion && !item.error)))
|
||||
|
||||
const prompt = ref<string>('')
|
||||
const loading = ref<boolean>(false)
|
||||
|
||||
function handleSubmit() {
|
||||
onConversation()
|
||||
}
|
||||
|
||||
async function onConversation() {
|
||||
const message = prompt.value
|
||||
|
||||
if (loading.value)
|
||||
return
|
||||
|
||||
if (!message || message.trim() === '')
|
||||
return
|
||||
|
||||
controller = new AbortController()
|
||||
|
||||
addChat(
|
||||
+uuid,
|
||||
{
|
||||
dateTime: new Date().toLocaleString(),
|
||||
text: message,
|
||||
inversion: true,
|
||||
error: false,
|
||||
conversationOptions: null,
|
||||
requestOptions: { prompt: message, options: null },
|
||||
},
|
||||
)
|
||||
scrollToBottom()
|
||||
|
||||
loading.value = true
|
||||
prompt.value = ''
|
||||
|
||||
let options: Chat.ConversationRequest = {}
|
||||
const lastContext = conversationList.value[conversationList.value.length - 1]?.conversationOptions
|
||||
|
||||
if (lastContext)
|
||||
options = { ...lastContext }
|
||||
|
||||
addChat(
|
||||
+uuid,
|
||||
{
|
||||
dateTime: new Date().toLocaleString(),
|
||||
text: 'Aha, Thinking...',
|
||||
loading: true,
|
||||
inversion: false,
|
||||
error: false,
|
||||
conversationOptions: null,
|
||||
requestOptions: { prompt: message, options: { ...options } },
|
||||
},
|
||||
)
|
||||
scrollToBottom()
|
||||
|
||||
try {
|
||||
const { data } = await fetchChatAPI<Chat.ConversationResponse>(message, options, controller.signal)
|
||||
updateChat(
|
||||
+uuid,
|
||||
dataSources.value.length - 1,
|
||||
{
|
||||
dateTime: new Date().toLocaleString(),
|
||||
text: data.text ?? '',
|
||||
inversion: false,
|
||||
error: false,
|
||||
loading: false,
|
||||
conversationOptions: { conversationId: data.conversationId, parentMessageId: data.id },
|
||||
requestOptions: { prompt: message, options: { ...options } },
|
||||
},
|
||||
)
|
||||
scrollToBottom()
|
||||
}
|
||||
catch (error: any) {
|
||||
let errorMessage = error?.message ?? 'Something went wrong, please try again later.'
|
||||
|
||||
if (error.message === 'canceled')
|
||||
errorMessage = 'Request canceled. Please try again.'
|
||||
|
||||
updateChat(
|
||||
+uuid,
|
||||
dataSources.value.length - 1,
|
||||
{
|
||||
dateTime: new Date().toLocaleString(),
|
||||
text: errorMessage,
|
||||
inversion: false,
|
||||
error: true,
|
||||
loading: false,
|
||||
conversationOptions: null,
|
||||
requestOptions: { prompt: message, options: { ...options } },
|
||||
},
|
||||
)
|
||||
scrollToBottom()
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onRegenerate(index: number) {
|
||||
if (loading.value)
|
||||
return
|
||||
|
||||
controller = new AbortController()
|
||||
|
||||
const { requestOptions } = dataSources.value[index]
|
||||
|
||||
const message = requestOptions?.prompt ?? ''
|
||||
|
||||
let options: Chat.ConversationRequest = {}
|
||||
|
||||
if (requestOptions.options)
|
||||
options = { ...requestOptions.options }
|
||||
|
||||
loading.value = true
|
||||
|
||||
updateChat(
|
||||
+uuid,
|
||||
index,
|
||||
{
|
||||
dateTime: new Date().toLocaleString(),
|
||||
text: 'Aha, Let me think again...',
|
||||
inversion: false,
|
||||
error: false,
|
||||
loading: true,
|
||||
conversationOptions: null,
|
||||
requestOptions: { prompt: message, ...options },
|
||||
},
|
||||
)
|
||||
scrollToBottom()
|
||||
|
||||
try {
|
||||
const { data } = await fetchChatAPI<Chat.ConversationResponse>(message, options, controller.signal)
|
||||
updateChat(
|
||||
+uuid,
|
||||
index,
|
||||
{
|
||||
dateTime: new Date().toLocaleString(),
|
||||
text: data.text ?? '',
|
||||
inversion: false,
|
||||
error: false,
|
||||
loading: false,
|
||||
conversationOptions: { conversationId: data.conversationId, parentMessageId: data.id },
|
||||
requestOptions: { prompt: message, ...options },
|
||||
},
|
||||
)
|
||||
scrollToBottom()
|
||||
}
|
||||
catch (error: any) {
|
||||
let errorMessage = 'Something went wrong, please try again later.'
|
||||
|
||||
if (error.message === 'canceled')
|
||||
errorMessage = 'Request canceled. Please try again.'
|
||||
|
||||
updateChat(
|
||||
+uuid,
|
||||
index,
|
||||
{
|
||||
dateTime: new Date().toLocaleString(),
|
||||
text: errorMessage,
|
||||
inversion: false,
|
||||
error: true,
|
||||
loading: false,
|
||||
conversationOptions: null,
|
||||
requestOptions: { prompt: message, ...options },
|
||||
},
|
||||
)
|
||||
scrollToBottom()
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
if (loading.value)
|
||||
return
|
||||
|
||||
dialog.warning({
|
||||
title: 'Clear Chat',
|
||||
content: 'Are you sure to clear this chat?',
|
||||
positiveText: 'Yes',
|
||||
negativeText: 'No',
|
||||
onPositiveClick: () => {
|
||||
chatStore.clearChatByUuid(+uuid)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleEnter(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
const buttonDisabled = computed(() => {
|
||||
return loading.value || !prompt.value || prompt.value.trim() === ''
|
||||
})
|
||||
|
||||
const footerClass = computed(() => {
|
||||
let classes = ['p-4']
|
||||
if (isMobile.value)
|
||||
classes = ['p-2', 'pr-4', 'fixed', 'bottom-4', 'left-0', 'right-0', 'z-30', 'h-14', 'overflow-hidden']
|
||||
return classes
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
scrollToBottom()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (loading.value)
|
||||
controller.abort()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full pt-14 pb-14">
|
||||
<main class="flex-1 overflow-hidden">
|
||||
<div ref="scrollRef" class="h-full p-4 overflow-hidden overflow-y-auto" :class="[{ 'p-2': isMobile }]">
|
||||
<template v-if="!dataSources.length">
|
||||
<div class="flex items-center justify-center mt-4 text-center text-neutral-300">
|
||||
<SvgIcon icon="ri:bubble-chart-fill" class="mr-2 text-3xl" />
|
||||
<span>Aha~</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>
|
||||
<Message
|
||||
v-for="(item, index) of dataSources"
|
||||
:key="index"
|
||||
:date-time="item.dateTime"
|
||||
:text="item.text"
|
||||
:inversion="item.inversion"
|
||||
:error="item.error"
|
||||
:loading="item.loading"
|
||||
@regenerate="onRegenerate(index)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
<footer :class="footerClass">
|
||||
<div class="flex items-center justify-between space-x-2">
|
||||
<HoverButton @click="handleClear">
|
||||
<span class="text-xl text-[#4f555e]">
|
||||
<SvgIcon icon="ri:delete-bin-line" />
|
||||
</span>
|
||||
</HoverButton>
|
||||
<NInput
|
||||
v-model:value="prompt"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 1, maxRows: 2 }"
|
||||
placeholder="Ask me anything..."
|
||||
@keypress="handleEnter"
|
||||
/>
|
||||
<NButton type="primary" :disabled="buttonDisabled" @click="handleSubmit">
|
||||
<template #icon>
|
||||
<SvgIcon icon="ri:send-plane-fill" />
|
||||
</template>
|
||||
</NButton>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
48
src/views/chat/layout/Layout.vue
Normal file
48
src/views/chat/layout/Layout.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script setup lang='ts'>
|
||||
import { computed } from 'vue'
|
||||
import { NLayout, NLayoutContent } from 'naive-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Sider from './sider/index.vue'
|
||||
import Header from './header/index.vue'
|
||||
import { useBasicLayout } from '@/hooks/useBasicLayout'
|
||||
import { useAppStore, useChatStore } from '@/store'
|
||||
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
const chatStore = useChatStore()
|
||||
|
||||
router.replace({ name: 'Chat', params: { uuid: chatStore.active } })
|
||||
|
||||
const { isMobile } = useBasicLayout()
|
||||
|
||||
const collapsed = computed(() => appStore.siderCollapsed)
|
||||
|
||||
const getMobileClass = computed(() => {
|
||||
if (isMobile.value)
|
||||
return ['rounded-none', 'shadow-none']
|
||||
return ['border', 'rounded-md', 'shadow-md']
|
||||
})
|
||||
|
||||
const getContainerClass = computed(() => {
|
||||
return [
|
||||
'h-full',
|
||||
{ 'pl-[260px]': !isMobile.value && !collapsed.value },
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full" :class="[isMobile ? 'p-0' : 'p-4']">
|
||||
<div class="h-full overflow-hidden" :class="getMobileClass">
|
||||
<NLayout class="z-40 transition" :class="getContainerClass" has-sider>
|
||||
<Sider />
|
||||
<Header v-if="isMobile" />
|
||||
<NLayoutContent class="h-full">
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<component :is="Component" :key="route.fullPath" />
|
||||
</RouterView>
|
||||
</NLayoutContent>
|
||||
</NLayout>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
32
src/views/chat/layout/header/index.vue
Normal file
32
src/views/chat/layout/header/index.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { SvgIcon } from '@/components/common'
|
||||
import { useAppStore, useChatStore } from '@/store'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const chatStore = useChatStore()
|
||||
|
||||
const collapsed = computed(() => appStore.siderCollapsed)
|
||||
|
||||
function handleAdd() {
|
||||
chatStore.addHistory({ title: 'New Chat', uuid: Date.now(), isEdit: false })
|
||||
}
|
||||
|
||||
function handleUpdateCollapsed() {
|
||||
appStore.setSiderCollapsed(!collapsed.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="fixed top-0 left-0 right-0 z-30 border-b bg-white/80 backdrop-blur">
|
||||
<div class="relative flex items-center justify-between h-14">
|
||||
<button class="flex items-center justify-center w-11 h-11" @click="handleUpdateCollapsed">
|
||||
<SvgIcon v-if="collapsed" class="text-2xl" icon="ri:align-justify" />
|
||||
<SvgIcon v-else class="text-2xl" icon="ri:align-right" />
|
||||
</button>
|
||||
<button class="flex items-center justify-center w-11 h-11" @click="handleAdd">
|
||||
<SvgIcon class="text-2xl" icon="ri:add-fill" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
3
src/views/chat/layout/index.ts
Normal file
3
src/views/chat/layout/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import ChatLayout from './Layout.vue'
|
||||
|
||||
export { ChatLayout }
|
99
src/views/chat/layout/sider/List.vue
Normal file
99
src/views/chat/layout/sider/List.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<script setup lang='ts'>
|
||||
import { computed } from 'vue'
|
||||
import { NInput, NPopconfirm, NScrollbar } from 'naive-ui'
|
||||
import { SvgIcon } from '@/components/common'
|
||||
import { useAppStore, useChatStore } from '@/store'
|
||||
import { useBasicLayout } from '@/hooks/useBasicLayout'
|
||||
|
||||
const { isMobile } = useBasicLayout()
|
||||
|
||||
const appStore = useAppStore()
|
||||
const chatStore = useChatStore()
|
||||
|
||||
const dataSources = computed(() => chatStore.history)
|
||||
|
||||
async function handleSelect({ uuid }: Chat.History) {
|
||||
if (isActive(uuid))
|
||||
return
|
||||
|
||||
await chatStore.setActive(uuid)
|
||||
|
||||
if (isMobile.value)
|
||||
appStore.setSiderCollapsed(true)
|
||||
}
|
||||
|
||||
function handleEdit({ uuid }: Chat.History, isEdit: boolean, event?: MouseEvent) {
|
||||
event?.stopPropagation()
|
||||
chatStore.updateHistory(uuid, { isEdit })
|
||||
}
|
||||
|
||||
function handleDelete(index: number, event?: MouseEvent | TouchEvent) {
|
||||
event?.stopPropagation()
|
||||
chatStore.deleteHistory(index)
|
||||
}
|
||||
|
||||
function handleEnter({ uuid }: Chat.History, isEdit: boolean, event: KeyboardEvent) {
|
||||
event?.stopPropagation()
|
||||
if (event.key === 'Enter')
|
||||
chatStore.updateHistory(uuid, { isEdit })
|
||||
}
|
||||
|
||||
function isActive(uuid: number) {
|
||||
return chatStore.active === uuid
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NScrollbar class="px-4">
|
||||
<div class="flex flex-col gap-2 text-sm">
|
||||
<template v-if="!dataSources.length">
|
||||
<div class="flex flex-col items-center mt-4 text-center text-neutral-300">
|
||||
<SvgIcon icon="ri:inbox-line" class="mb-2 text-3xl" />
|
||||
<span>No history</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-for="(item, index) of dataSources" :key="index">
|
||||
<a
|
||||
class="relative flex items-center gap-3 px-3 py-3 break-all border rounded-md cursor-pointer hover:bg-neutral-100 group"
|
||||
:class="isActive(item.uuid) && ['border-[#4b9e5f]', 'bg-neutral-100', 'text-[#4b9e5f]', 'pr-14']"
|
||||
@click="handleSelect(item)"
|
||||
>
|
||||
<span>
|
||||
<SvgIcon icon="ri:message-3-line" />
|
||||
</span>
|
||||
<div class="relative flex-1 overflow-hidden break-all text-ellipsis whitespace-nowrap">
|
||||
<NInput
|
||||
v-if="item.isEdit"
|
||||
v-model:value="item.title"
|
||||
size="tiny"
|
||||
@keypress="handleEnter(item, false, $event)"
|
||||
/>
|
||||
<span v-else>{{ item.title }}</span>
|
||||
</div>
|
||||
<div v-if="isActive(item.uuid)" class="absolute z-10 flex visible right-1">
|
||||
<template v-if="item.isEdit">
|
||||
<button class="p-1" @click="handleEdit(item, false, $event)">
|
||||
<SvgIcon icon="ri:save-line" />
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="p-1">
|
||||
<SvgIcon icon="ri:edit-line" @click="handleEdit(item, true, $event)" />
|
||||
</button>
|
||||
<NPopconfirm placement="bottom" @positive-click="handleDelete(index, $event)">
|
||||
<template #trigger>
|
||||
<button class="p-1">
|
||||
<SvgIcon icon="ri:delete-bin-line" />
|
||||
</button>
|
||||
</template>
|
||||
Are you sure to clear this history?
|
||||
</NPopconfirm>
|
||||
</template>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</NScrollbar>
|
||||
</template>
|
83
src/views/chat/layout/sider/index.vue
Normal file
83
src/views/chat/layout/sider/index.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<script setup lang='ts'>
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { computed, watch } from 'vue'
|
||||
import { NButton, NLayoutSider } from 'naive-ui'
|
||||
import List from './List.vue'
|
||||
import { HoverButton, SvgIcon, UserAvatar } from '@/components/common'
|
||||
import { useAppStore, useChatStore } from '@/store'
|
||||
import { useBasicLayout } from '@/hooks/useBasicLayout'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const chatStore = useChatStore()
|
||||
|
||||
const { isMobile } = useBasicLayout()
|
||||
|
||||
const collapsed = computed(() => appStore.siderCollapsed)
|
||||
|
||||
function handleAdd() {
|
||||
chatStore.addHistory({ title: 'New Chat', uuid: Date.now(), isEdit: false })
|
||||
}
|
||||
|
||||
function handleUpdateCollapsed() {
|
||||
appStore.setSiderCollapsed(!collapsed.value)
|
||||
}
|
||||
|
||||
const getMobileClass = computed<CSSProperties>(() => {
|
||||
if (isMobile.value) {
|
||||
return {
|
||||
position: 'fixed',
|
||||
zIndex: 50,
|
||||
}
|
||||
}
|
||||
return {}
|
||||
})
|
||||
|
||||
watch(
|
||||
isMobile,
|
||||
(val) => {
|
||||
appStore.setSiderCollapsed(val)
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
flush: 'post',
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NLayoutSider
|
||||
:collapsed="collapsed"
|
||||
:collapsed-width="0"
|
||||
:width="260"
|
||||
:show-trigger="isMobile ? false : 'arrow-circle'"
|
||||
collapse-mode="transform"
|
||||
position="absolute"
|
||||
bordered
|
||||
:style="getMobileClass"
|
||||
@update-collapsed="handleUpdateCollapsed"
|
||||
>
|
||||
<div class="flex flex-col h-full">
|
||||
<main class="flex flex-col flex-1 min-h-0">
|
||||
<div class="p-4">
|
||||
<NButton dashed block @click="handleAdd">
|
||||
New chat
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 pb-4 overflow-hidden">
|
||||
<List />
|
||||
</div>
|
||||
</main>
|
||||
<footer class="flex items-center justify-between min-w-0 p-4 overflow-hidden border-t">
|
||||
<UserAvatar />
|
||||
<HoverButton tooltip="Setting">
|
||||
<span class="text-xl text-[#4f555e]">
|
||||
<SvgIcon icon="ri:settings-4-line" />
|
||||
</span>
|
||||
</HoverButton>
|
||||
</footer>
|
||||
</div>
|
||||
</NLayoutSider>
|
||||
<template v-if="isMobile">
|
||||
<div v-show="!collapsed" class="fixed inset-0 z-40 bg-black/40" @click="handleUpdateCollapsed" />
|
||||
</template>
|
||||
</template>
|
11
start.sh
Normal file
11
start.sh
Normal file
@@ -0,0 +1,11 @@
|
||||
|
||||
cd ./service
|
||||
nohup pnpm start > service.log &
|
||||
echo "Start service complete!"
|
||||
|
||||
|
||||
cd ..
|
||||
echo "" > front.log
|
||||
nohup pnpm dev > front.log &
|
||||
echo "Start front complete!"
|
||||
tail -f front.log
|
@@ -5,7 +5,17 @@ module.exports = {
|
||||
'./src/**/*.{vue,js,ts,jsx,tsx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
extend: {
|
||||
animation: {
|
||||
blink: 'blink 1.2s infinite steps(1, start)',
|
||||
},
|
||||
keyframes: {
|
||||
blink: {
|
||||
'0%, 100%': { 'background-color': '#000' },
|
||||
'50%': { 'background-color': 'transparent' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
@@ -1,22 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"module": "ESNext",
|
||||
"target": "ESNext",
|
||||
"lib": ["DOM", "ESNext"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"jsx": "preserve",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"noUnusedLocals": true,
|
||||
"strictNullChecks": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"types": ["vite/client", "node", "naive-ui/volar"]
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"module": "ESNext",
|
||||
"target": "ESNext",
|
||||
"lib": ["DOM", "ESNext"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"jsx": "preserve",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"noUnusedLocals": true,
|
||||
"strictNullChecks": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
// @vueuse/core 不能通过 vue-tsc 检查,所以这里需要忽略,以后将移除
|
||||
"types": ["vite/client", "node", "naive-ui/volar", "web-bluetooth"]
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "service"]
|
||||
}
|
||||
|
@@ -1,8 +1,10 @@
|
||||
import path from 'path'
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig(() => {
|
||||
export default defineConfig((env) => {
|
||||
const viteEnv = loadEnv(env.mode, process.cwd()) as unknown as ImportMetaEnv
|
||||
|
||||
return {
|
||||
resolve: {
|
||||
alias: {
|
||||
@@ -14,6 +16,13 @@ export default defineConfig(() => {
|
||||
port: 1002,
|
||||
host: '0.0.0.0',
|
||||
open: false,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: viteEnv.VITE_APP_API_BASE_URL,
|
||||
changeOrigin: true, // 允许跨域
|
||||
rewrite: path => path.replace('/api/', '/'),
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
reportCompressedSize: false,
|
||||
|
Reference in New Issue
Block a user