Compare commits

...

103 Commits

Author SHA1 Message Date
Redon
4ab9f709de v2.5.1 (#75)
* perf: 新增会话添加在列表前方

* fix: 路由模式改为 hash 保证兼容性

* perf: 增强移动端体验

* chore: version 2.5.1
2023-02-21 08:57:54 +08:00
ChenZhaoYu
cb90d81c69 fix: 默认 active 赋值 2023-02-20 14:58:06 +08:00
Redon
fda6c6bb6a chore: v2.5.0 整体优化 (#70)
* feat: locale language

* refactor: 页面暂存

* feat: 逻辑判断

* feat: 分组消息

* feat: 实验场

* feat: 重新请求结果

* feat: 基础问答逻辑和重新询问

* feat: 上下文消息删除确认

* feat: 处理类型报错

* chore: 更新 deps 和移除 i18n

* feat: 路由页面切换终止请求

* feat: let me think

* feat: 信息更新代码高亮匹配

* feat: 加载时添加光标

* feat: 错误提示

* feat: 历史记录删除确认

* fix: 侧边栏高度不正确的问题

* chore: version 2.5.0

* chore: update deps
2023-02-20 14:10:51 +08:00
ChenZhaoYu
6216d84ecd chore: v2.4.1 2023-02-18 09:59:39 +08:00
Void
ea887ad755 输入框修改为富文本,支持换行 (#53)
* add Dockerfile

* update Readme.md

* Fixed : Docker log file

* Update vite.config.ts

* chore: code merge, update README.md

* chore: update GitHub workflow

* fix: 输入框修改为富文本,支持换行

* chore: 更新.gitignore

* Update index.vue

---------

Co-authored-by: Redon <790348264@qq.com>
2023-02-18 09:56:13 +08:00
Redon
c98a2a3cb8 feat: 移动端样式修复 (#56)
* feat: 移动端优化

* feat: 部份移动端问题修复
2023-02-18 09:25:57 +08:00
ChenZhaoYu
06f41c3758 chore: 更新封面 2023-02-17 11:10:17 +08:00
Redon
3f4cb5c900 feat: 响应式支持移动端 (#49)
* feat: 响应式兼容 h5

* feat: 补充空状态

* feat: thinking

* chore: @vueuse/core 导致的类型检查错误

* chore: version 2.4.0
2023-02-17 10:57:06 +08:00
ChenZhaoYu
405aeaa2a5 chore: version 2.3.3 2023-02-16 21:03:12 +08:00
ChenZhaoYu
fd28468778 chore: build_docker repositories 2023-02-16 20:30:49 +08:00
Stephen Ni
4128d319ed docs: polish docker run (#44)
* docs: polish docker run

* Update README.md

---------

Co-authored-by: Redon <790348264@qq.com>
2023-02-16 20:29:46 +08:00
Jerry Wang
4a06b3943c 优化 docker 构建的镜像文件大小,添加自动化构建 (#42)
* perf: optimize docker image

* perf: shrink image size

* fix: build docker image

* fix: docker image namespace

* docs: add docker compose example

* docs: fix docker compose example

* docs: fix again :)

* docs: and again.....
2023-02-16 20:25:29 +08:00
ChenZhaoYu
77cdcb7798 chore: 添加贡献列表 2023-02-16 14:17:03 +08:00
ChenZhaoYu
ee542a01f7 chore: eslint 依赖报错 2023-02-16 14:03:40 +08:00
ChenZhaoYu
30372c2e70 chore: update README 2023-02-16 13:42:19 +08:00
Void
6646695849 Add Dockerfile (#33)
* add Dockerfile

* update Readme.md

* Fixed : Docker log file

* Update vite.config.ts
2023-02-16 13:39:07 +08:00
ChenZhaoYu
e9db25a970 chore: version 2.3.2 2023-02-16 12:31:08 +08:00
ChenZhaoYu
63f4d7d77c chore: update deps 2023-02-16 12:29:47 +08:00
ChenZhaoYu
20255ddce4 chore: gitignore file 2023-02-16 12:00:03 +08:00
wang ha ha
bbd8ff773b 添加 docker 镜像打包部署脚本 (#27)
* 删除env

* 修改生产依赖安装缺失。

* 添加docker镜像构建

* no message

* 修改超时时间。

* 修复镜像构建错误。

* 修改环境变量获取baseapi

* 修改前端链接获取位置。

* 删除默认env

* 添加会 .env 文件,但是注释掉。

* 使用默认 api。

* 添加前端调试和部署路由兼容。

* 删除key

* Update .env

* Delete build_docker.yml

暂时不做 `git hooks` 处理

* Delete .gitignore

* Delete Dockerfile

工作路径好像有点错误,先不合并

* Delete build.sh

工作路径好像有点错误,先不合并

---------

Co-authored-by: 王金海(haha.wang) <wanghaha@huolala.cn>
Co-authored-by: haha.wang <haha.wang@huolala.cn>
Co-authored-by: Redon <790348264@qq.com>
2023-02-16 11:58:29 +08:00
Redon
c8518b7789 chore: 删除未使用代码 (#37) 2023-02-16 11:38:18 +08:00
ChenZhaoYu
bf015d35f7 chore: 添加 node>=14 说明 2023-02-16 11:15:00 +08:00
ChenZhaoYu
eab39b7f88 chore: update doc 2023-02-16 08:24:42 +08:00
ChenZhaoYu
981c036542 fix: type error 2023-02-16 08:23:31 +08:00
Redon
91f51b6338 pull (#34) (#35)
* fix: 修复部分多会话逻辑

* chore: version 2.3.1
2023-02-15 22:44:32 +08:00
ChenZhaoYu
26de5359ef chore: version 2.3.1 2023-02-15 22:34:44 +08:00
ChenZhaoYu
0423b87530 fix: 修复部分多会话逻辑 2023-02-15 22:31:55 +08:00
ChenZhaoYu
58464b2cee chore: 推送错误,应该在 dev 分支上 2023-02-15 16:55:21 +08:00
ChenZhaoYu
e23e1df3ce chore: 变量名错误 2023-02-15 16:47:32 +08:00
ChenZhaoYu
2c3bc77eb2 chore: 添加贡献值栏 2023-02-15 16:45:34 +08:00
ChenZhaoYu
52b43868eb chore: # CONTRIBUTING 2023-02-15 16:18:20 +08:00
ChenZhaoYu
7930ec5dab feat: 添加 ts 类型 2023-02-15 16:07:42 +08:00
ChenZhaoYu
7835740159 chore: version 2.3.0 2023-02-15 15:53:53 +08:00
ChenZhaoYu
c8745f2e21 feat: 调整超时时间 2023-02-15 15:05:57 +08:00
ChenZhaoYu
0b8b58f85c fix: 取消提示判断字段错误 2023-02-15 15:01:33 +08:00
ChenZhaoYu
c09a4f6c75 feat: 阻止移动端缩放 2023-02-15 14:43:23 +08:00
ChenZhaoYu
d9761ed0e5 fix: 修复提交按钮因为合并导致失效 #31 2023-02-15 14:25:25 +08:00
ChenZhaoYu
cb8e2cf814 fix: 侧边栏因为宽度变化导致的闪烁 2023-02-15 14:21:46 +08:00
ChenZhaoYu
f73aa5f9e1 perf: 优化部份内容 2023-02-15 14:10:43 +08:00
Yut
c617205a8c feat: #响应式 (#22)
* feat: #响应式

* fix: #兼容node 16

* feat: #接口代理

* fix: #延长超时时长到30秒

* fix: #for node 16

* fix: #node 16

* Update settings.json

* Update package.json

* Update index.vue

* Update Layout.vue

---------

Co-authored-by: Redon <790348264@qq.com>
2023-02-15 14:01:42 +08:00
ChenZhaoYu
79eeb51537 perf: 优化 pr 代码 2023-02-15 13:26:02 +08:00
Dwsy
b2977d8c44 add code highlighting 2023-02-15 13:04:31 +08:00
Dwsy
8dab963628 add code highlighting 2023-02-15 13:04:31 +08:00
Redon
79dd6c5e18 Merge pull request #26 from Chanzhaoyu/dev
chore: version 2.2.0
2023-02-14 16:58:58 +08:00
ChenZhaoYu
da75ca944c chore: version 2.2.0 2023-02-14 16:58:24 +08:00
ChenZhaoYu
4458e744cc feat: 侧边栏切换取消上次请求 2023-02-14 16:57:11 +08:00
ChenZhaoYu
d2ae2c4f54 feat: 优化侧边会话 2023-02-14 16:21:41 +08:00
ChenZhaoYu
cf8e2dd7b6 feat: 优化多会话内容 2023-02-14 15:56:44 +08:00
ChenZhaoYu
701ef0e6e1 chore: 优化部份实现 2023-02-14 15:37:18 +08:00
ChenZhaoYu
de34af8747 feat: 多会话基础逻辑梳理 2023-02-14 15:07:50 +08:00
ChenZhaoYu
33c02cfe10 feat: 侧边栏静态增删 2023-02-14 12:00:33 +08:00
ChenZhaoYu
b03f804e35 feat: 侧边栏记录 2023-02-14 11:34:46 +08:00
ChenZhaoYu
b6e5c59a9c feat: 预设 pinia 相关文件 2023-02-14 10:51:47 +08:00
ChenZhaoYu
797cc7461b Merge branch 'dev' 2023-02-14 10:09:10 +08:00
ChenZhaoYu
58547d1b58 chore: version 2.1.0 2023-02-14 10:08:31 +08:00
Redon
c933a851f4 Merge pull request #21 from Chanzhaoyu/dev
v2.1.0
2023-02-14 10:05:21 +08:00
ChenZhaoYu
86fcbbff0b feat: 上限文联想改为前端进行 2023-02-14 10:04:32 +08:00
ChenZhaoYu
5d1f8519c2 chore: typo description 2023-02-14 08:45:07 +08:00
ChenZhaoYu
3c21068150 chore: update readme 2023-02-13 21:07:34 +08:00
ChenZhaoYu
cd03a71ef1 chore: update deps 2023-02-13 21:02:25 +08:00
Redon
1d74717beb Merge pull request #20 from Chanzhaoyu/dev
fix: 修复代码提交前验证流程
2023-02-13 20:55:55 +08:00
ChenZhaoYu
e35abb1c98 fix: 修复代码提交前验证流程 2023-02-13 20:55:26 +08:00
Redon
b4b0f65158 Merge pull request #19 from Chanzhaoyu/dev
chore: 移除打包测试
2023-02-13 20:46:37 +08:00
ChenZhaoYu
910c19b02e chore: 移除打包测试 2023-02-13 20:42:45 +08:00
Redon
32049fbf18 Merge pull request #18 from Chanzhaoyu/dev
chore: key invalid tips
2023-02-13 20:34:08 +08:00
ChenZhaoYu
817a4842ac chore: key invalid tips 2023-02-13 20:33:36 +08:00
Redon
0cd120ec5b Merge pull request #17 from Chanzhaoyu/dev
chore: 优化 `service` 内命令
2023-02-13 20:24:17 +08:00
ChenZhaoYu
007763f7d7 chore: 忽略 service 文件夹 2023-02-13 20:13:19 +08:00
ChenZhaoYu
3debaec4b2 chore: 添加 service 打包编译设置 2023-02-13 20:10:08 +08:00
ChenZhaoYu
35f353c1f7 chore: 补全配置 2023-02-13 19:56:11 +08:00
Redon
3ac6746930 Merge pull request #16 from Chanzhaoyu/dev
fix: 上下文按钮失效
2023-02-13 19:40:46 +08:00
ChenZhaoYu
9b556e4833 chore: 添加对于模型的提示 2023-02-13 19:30:26 +08:00
ChenZhaoYu
071283c007 fix: 清除上下文按钮失效的问题 2023-02-13 19:25:59 +08:00
Redon
854261ef86 Merge pull request #15 from Chanzhaoyu/dev
chore: 修复报错和自动化错误
2023-02-13 19:20:51 +08:00
ChenZhaoYu
633e9ad8e2 chore: cli error 2023-02-13 19:18:01 +08:00
ChenZhaoYu
47dd6c9801 fix: type-check 2023-02-13 19:17:55 +08:00
Redon
0eb9df6d31 Merge pull request #14 from Chanzhaoyu/dev
chore: 优化部份问题
2023-02-13 19:11:49 +08:00
ChenZhaoYu
8bfbe6f7df feat: 调整页面样式 2023-02-13 19:10:21 +08:00
ChenZhaoYu
43e8635e7f chore: README.md 2023-02-13 19:09:34 +08:00
Redon
b7542c70cb Create ci.yml
chore: test cli
2023-02-13 18:58:26 +08:00
Redon
b594ec5de1 Merge pull request #12 from Chanzhaoyu/dev
chore: typo
2023-02-13 18:41:27 +08:00
ChenZhaoYu
9a9b154040 chore: typo 2023-02-13 18:21:26 +08:00
Redon
12681b1a05 Update README.md 2023-02-13 16:48:24 +08:00
Redon
9f4f6304c7 Merge pull request #11 from Chanzhaoyu/dev
chore: clear cache
2023-02-13 16:37:23 +08:00
ChenZhaoYu
93c63d9195 chore: clear cache 2023-02-13 16:35:58 +08:00
Redon
361494d444 Merge pull request #10 from Chanzhaoyu/dev
v2.0.0
2023-02-13 16:33:19 +08:00
ChenZhaoYu
2f69cfd10b chore: CHANGELOG.md 2023-02-13 16:32:28 +08:00
ChenZhaoYu
0086f17df4 chore: version 2.0.0 2023-02-13 16:30:12 +08:00
ChenZhaoYu
befad1cbe1 chore: README.md 2023-02-13 16:29:51 +08:00
ChenZhaoYu
6cafc07e0d feat: 调整内容 2023-02-13 16:01:18 +08:00
ChenZhaoYu
feb167e2da chore: 移除页面多余内容 2023-02-13 15:57:31 +08:00
ChenZhaoYu
847623e22a feat: 前后端统一接口类型 2023-02-13 15:54:17 +08:00
ChenZhaoYu
dd522eb404 feat: 统一服务端返回参数 2023-02-13 15:08:55 +08:00
ChenZhaoYu
73c9ac4d8f feat: 调整消息布局以便扩展 2023-02-13 14:29:45 +08:00
ChenZhaoYu
727562f495 feat: 添加用户头像 2023-02-13 14:13:20 +08:00
ChenZhaoYu
d7464642a7 feat: 侧边栏历史会话 2023-02-13 13:47:41 +08:00
ChenZhaoYu
951636869b feat: 调整为路由模式 2023-02-13 12:38:44 +08:00
ChenZhaoYu
39f718ef16 feat: 优化文件结构、组件、布局 2023-02-13 11:59:00 +08:00
ChenZhaoYu
72df35c929 chore: 调整部份脚本命令 2023-02-13 11:12:28 +08:00
ChenZhaoYu
cf7a56a015 chore: 打包脚本错误 2023-02-13 11:08:11 +08:00
ChenZhaoYu
5ca1b4557a chore: miss build-only script 2023-02-13 11:06:07 +08:00
ChenZhaoYu
11544ff24b chore: 添加 lint-staged 2023-02-13 11:03:10 +08:00
ChenZhaoYu
f910b5f8a4 chore: 调整文件结构 service 和 web 分离 2023-02-13 10:00:08 +08:00
91 changed files with 7151 additions and 1330 deletions

3
.commitlintrc.json Normal file
View File

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

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
Dockerfile
.git
.husky
.github
.vscode

7
.env
View File

@@ -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/

View File

@@ -1 +0,0 @@
components.d.ts

37
.github/workflows/build_docker.yml vendored Normal file
View 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
View 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
View File

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

4
.husky/pre-commit Executable file
View File

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

1
.npmrc Normal file
View File

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

View File

@@ -1,4 +0,0 @@
{
"semi": false,
"singleQuote": true
}

13
.vscode/settings.json vendored
View File

@@ -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"
]
}

View File

@@ -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
View 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
View 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"]

View File

@@ -1,39 +0,0 @@
# ChatGPT Web Bot
[中文](./README.md) | English
ChartGPT demo page built with express and vue3
![cover](./docs/cover.png)
## 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
View File

@@ -1,40 +1,157 @@
# ChatGPT Web Bot
中文 | [English](./README.en.md)
# ChatGPT Web
使用 express 和 vue3 搭建的 ChartGPT 演示网页
![cover](./docs/cover.png)
![PC](./docs/cover.png)
## 使用
> 确保 `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)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 62 KiB

View File

@@ -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>

View File

@@ -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

File diff suppressed because it is too large Load Diff

2
service/.env Normal file
View File

@@ -0,0 +1,2 @@
# OpenAI API Key - https://platform.openai.com/overview
OPENAI_API_KEY=

5
service/.eslintrc.json Normal file
View File

@@ -0,0 +1,5 @@
{
"root": true,
"ignorePatterns": ["build"],
"extends": ["@antfu"]
}

31
service/.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1 @@
enable-pre-post-scripts=true

3
service/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["dbaeumer.vscode-eslint"]
}

22
service/.vscode/settings.json vendored Normal file
View 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"
]
}

View File

@@ -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 }

View File

@@ -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
View 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

File diff suppressed because it is too large Load Diff

49
service/src/chatgpt.ts Normal file
View 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
View 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'))

View 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
View 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
View 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,
})

View File

@@ -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
View 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,
})
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1 @@
export { }

View 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>

View 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>

View 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>

View 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>

View 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 }

View 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>

View File

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

View File

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

View 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
View File

@@ -0,0 +1,6 @@
import type { App } from 'vue'
import setupHighlightDirective from './highlight'
export function setupDirectives(app: App) {
setupHighlightDirective(app)
}

View 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 }
}

View File

@@ -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
View 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
View File

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

31
src/router/index.ts Normal file
View 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
View 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'

View 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)
}

View 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)
},
},
})

View 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)
}

View 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)
},
},
})

View File

@@ -0,0 +1,2 @@
export * from './app'
export * from './chat'

45
src/typings/chat.d.ts vendored Normal file
View 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
}
}

View File

@@ -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;
}

View File

@@ -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
View 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
View 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]'
}

View 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

View 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

View File

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

View 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 })

View File

@@ -1,5 +0,0 @@
<template>
<span class="leading-relaxed whitespace-pre-wrap">
<slot />
</span>
</template>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 }

View 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>

View 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>

View 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,
}
}

View 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
View 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>

View 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>

View 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>

View File

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

View 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>

View 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
View 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

View File

@@ -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: [],
}

View File

@@ -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"]
}

View File

@@ -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,