Compare commits
108 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94e23bb916 | ||
|
|
21fb4f817c | ||
|
|
2c509c329f | ||
|
|
4769c31d09 | ||
|
|
89712aada7 | ||
|
|
3e9db49aad | ||
|
|
2dcd292dd4 | ||
|
|
90f6989445 | ||
|
|
1406292405 | ||
|
|
628187f5c3 | ||
|
|
cc91e95eed | ||
|
|
abbdcf9c51 | ||
|
|
b6fd9ae766 | ||
|
|
1e2f893ef6 | ||
|
|
10058f151c | ||
|
|
09359c3c46 | ||
|
|
ba83856173 | ||
|
|
b84f7e4c72 | ||
|
|
c0f8e4316e | ||
|
|
bf5c0cdf04 | ||
|
|
66cecb6049 | ||
|
|
808ae600c2 | ||
|
|
f40048fb08 | ||
|
|
ac9536ab87 | ||
|
|
938c91f635 | ||
|
|
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 |
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
Dockerfile
|
||||||
|
.git
|
||||||
|
.husky
|
||||||
|
.github
|
||||||
|
.vscode
|
||||||
7
.env
@@ -1,2 +1,7 @@
|
|||||||
# Glob API URL
|
# Glob API URL
|
||||||
VITE_GLOB_API_URL='http://localhost:3002'
|
VITE_GLOB_API_URL=/api
|
||||||
|
|
||||||
|
VITE_APP_API_BASE_URL=http://localhost:3002/
|
||||||
|
|
||||||
|
# Glob API Timeout (ms)
|
||||||
|
VITE_GLOB_API_TIMEOUT=100000
|
||||||
|
|||||||
41
.github/workflows/build_docker.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: build_docker
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
release:
|
||||||
|
types: [created] # 表示在创建新的 Release 时触发
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build_docker:
|
||||||
|
name: Build docker
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- run: |
|
||||||
|
echo "本次构建的版本为:${GITHUB_REF_NAME} (但是这个变量目前上下文中无法获取到)"
|
||||||
|
echo 本次构建的版本为:${{ github.ref_name }}
|
||||||
|
env
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v2
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
- name: Build and push
|
||||||
|
id: docker_build
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
tags: |
|
||||||
|
${{ secrets.DOCKERHUB_USERNAME }}/chatgpt-web:${{ github.ref_name }}
|
||||||
|
${{ secrets.DOCKERHUB_USERNAME }}/chatgpt-web:latest
|
||||||
47
.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
|
||||||
2
.husky/commit-msg
Normal file → Executable file
@@ -1,4 +1,4 @@
|
|||||||
#!/usr/bin/env sh
|
#!/usr/bin/env sh
|
||||||
. "$(dirname -- "$0")/_/husky.sh"
|
. "$(dirname -- "$0")/_/husky.sh"
|
||||||
|
|
||||||
pnpm exec commitlint --config .commitlintrc.json --edit "${1}"
|
npx --no -- commitlint --edit
|
||||||
|
|||||||
2
.husky/pre-commit
Normal file → Executable file
@@ -1,4 +1,4 @@
|
|||||||
#!/usr/bin/env sh
|
#!/usr/bin/env sh
|
||||||
. "$(dirname -- "$0")/_/husky.sh"
|
. "$(dirname -- "$0")/_/husky.sh"
|
||||||
|
|
||||||
pnpm exec lint-staged
|
npx lint-staged
|
||||||
|
|||||||
11
.vscode/settings.json
vendored
@@ -23,18 +23,27 @@
|
|||||||
"axios",
|
"axios",
|
||||||
"bumpp",
|
"bumpp",
|
||||||
"chatgpt",
|
"chatgpt",
|
||||||
|
"chenzhaoyu",
|
||||||
"commitlint",
|
"commitlint",
|
||||||
|
"davinci",
|
||||||
|
"dockerhub",
|
||||||
"esno",
|
"esno",
|
||||||
"GPTAPI",
|
"GPTAPI",
|
||||||
|
"hljs",
|
||||||
"iconify",
|
"iconify",
|
||||||
|
"logprobs",
|
||||||
"nodata",
|
"nodata",
|
||||||
"OPENAI",
|
"OPENAI",
|
||||||
"pinia",
|
"pinia",
|
||||||
|
"Popconfirm",
|
||||||
"rushstack",
|
"rushstack",
|
||||||
"Sider",
|
"Sider",
|
||||||
"tailwindcss",
|
"tailwindcss",
|
||||||
|
"tsup",
|
||||||
|
"Typecheck",
|
||||||
"unplugin",
|
"unplugin",
|
||||||
"VITE",
|
"VITE",
|
||||||
"vueuse"
|
"vueuse",
|
||||||
|
"Zhao"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
218
CHANGELOG.md
@@ -1,3 +1,221 @@
|
|||||||
|
## v2.8.2
|
||||||
|
|
||||||
|
`2023-02-28`
|
||||||
|
### Enhancement
|
||||||
|
- 代码主题调整为 `One Dark - light|dark` 适配深色模式
|
||||||
|
### BugFix
|
||||||
|
- 修复普通文本代码渲染和深色模式下的问题[#139][#154]
|
||||||
|
|
||||||
|
## v2.8.1
|
||||||
|
|
||||||
|
`2023-02-27`
|
||||||
|
|
||||||
|
### BugFix
|
||||||
|
- 修复 `API` 版本不是 `Markdown` 时,普通 `HTML` 代码会被渲染的问题 [#146]
|
||||||
|
|
||||||
|
## v2.8.0
|
||||||
|
|
||||||
|
`2023-02-27`
|
||||||
|
|
||||||
|
- 感谢 [puppywang](https://github.com/Chanzhaoyu/chatgpt-web/commit/628187f5c3348bda0d0518f90699a86525d19018) 修复了 `2.7.0` 版本中关于流输出数据的问题(使用 `nginx` 需要自行配置 `octet-stream` 相关内容)
|
||||||
|
|
||||||
|
- 关于为什么使用 `octet-stream` 而不是 `sse`,是因为更好的兼容之前的模式。
|
||||||
|
|
||||||
|
- 建议更新到此版本获得比较完整的体验
|
||||||
|
|
||||||
|
### Enhancement
|
||||||
|
- 优化了部份代码和类型提示
|
||||||
|
- 输入框添加换行提示
|
||||||
|
- 移动端输入框现在回车为换行,而不是直接提交
|
||||||
|
- 移动端双击标题返回顶部,箭头返回底部
|
||||||
|
|
||||||
|
### BugFix
|
||||||
|
- 流输出数据下的问题[#122]
|
||||||
|
- 修复了 `API Key` 下部份代码不换行的问题
|
||||||
|
- 修复移动端深色模式部份样式问题[#123][#126]
|
||||||
|
- 修复主题模式图标不一致的问题[#126]
|
||||||
|
|
||||||
|
## v2.7.3
|
||||||
|
|
||||||
|
`2023-02-25`
|
||||||
|
|
||||||
|
### Feature
|
||||||
|
- 适配系统深色模式 [#118](https://github.com/Chanzhaoyu/chatgpt-web/issues/103)
|
||||||
|
### BugFix
|
||||||
|
- 修复用户消息能被渲染为 `HTML` 问题 [#117](https://github.com/Chanzhaoyu/chatgpt-web/issues/117)
|
||||||
|
|
||||||
|
## v2.7.2
|
||||||
|
|
||||||
|
`2023-02-24`
|
||||||
|
### Enhancement
|
||||||
|
- 消息使用 [github-markdown-css](https://www.npmjs.com/package/github-markdown-css) 进行美化,现在支持全语法
|
||||||
|
- 移除测试无用函数
|
||||||
|
|
||||||
|
## v2.7.1
|
||||||
|
|
||||||
|
`2023-02-23`
|
||||||
|
|
||||||
|
因为消息流在 `accessToken` 中存在解析失败和消息不完整等一系列的问题,调整回正常消息形式
|
||||||
|
|
||||||
|
### Feature
|
||||||
|
- 现在可以中断请求过长没有答复的消息
|
||||||
|
- 现在可以删除单条消息
|
||||||
|
- 设置中显示当前版本信息
|
||||||
|
|
||||||
|
### BugFix
|
||||||
|
- 回退 `2.7.0` 的消息不稳定的问题
|
||||||
|
|
||||||
|
## v2.7.0
|
||||||
|
|
||||||
|
`2023-02-23`
|
||||||
|
|
||||||
|
### Feature
|
||||||
|
- 使用消息流返回信息,反应更迅速
|
||||||
|
|
||||||
|
### Enhancement
|
||||||
|
- 样式的一点小改动
|
||||||
|
|
||||||
|
## v2.6.2
|
||||||
|
|
||||||
|
`2023-02-22`
|
||||||
|
### BugFix
|
||||||
|
- 还原修改代理导致的异常问题
|
||||||
|
|
||||||
|
## v2.6.1
|
||||||
|
|
||||||
|
`2023-02-22`
|
||||||
|
|
||||||
|
### Feature
|
||||||
|
- 新增 `Railway` 部署模版
|
||||||
|
|
||||||
|
### BugFix
|
||||||
|
- 手动打包 `Proxy` 问题
|
||||||
|
|
||||||
|
## v2.6.0
|
||||||
|
|
||||||
|
`2023-02-21`
|
||||||
|
### Feature
|
||||||
|
- 新增对 `网页 accessToken` 调用 `ChatGPT`,更智能不过不太稳定 [#51](https://github.com/Chanzhaoyu/chatgpt-web/issues/51)
|
||||||
|
- 前端页面设置按钮显示查看当前后端服务配置
|
||||||
|
|
||||||
|
### Enhancement
|
||||||
|
- 新增 `TIMEOUT_MS` 环境变量设定后端超时时常(单位:毫秒)[#62](https://github.com/Chanzhaoyu/chatgpt-web/issues/62)
|
||||||
|
|
||||||
|
## v2.5.2
|
||||||
|
|
||||||
|
`2023-02-21`
|
||||||
|
### Feature
|
||||||
|
- 增加对 `markdown` 格式的支持 [Demo](https://github.com/Chanzhaoyu/chatgpt-web/pull/77)
|
||||||
|
### BugFix
|
||||||
|
- 重载会话时滚动条保持
|
||||||
|
|
||||||
|
## 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
|
## v2.0.0
|
||||||
|
|
||||||
`2023-02-13`
|
`2023-02-13`
|
||||||
|
|||||||
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
@@ -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"]
|
||||||
224
README.md
@@ -1,27 +1,85 @@
|
|||||||
# ChatGPT Web Bot
|
# ChatGPT Web
|
||||||
|
|
||||||
中文 | [English](./README.en.md)
|
> 使用 `express` 和 `vue3` 搭建的支持 `ChatGPT` 双模型演示网页
|
||||||
|
|
||||||
使用 express 和 vue3 搭建的 ChartGPT 演示网页
|

|
||||||
|

|
||||||
|
|
||||||

|
- [ChatGPT Web](#chatgpt-web)
|
||||||
|
- [介绍](#介绍)
|
||||||
|
- [待实现路线](#待实现路线)
|
||||||
|
- [前置要求](#前置要求)
|
||||||
|
- [Node](#node)
|
||||||
|
- [PNPM](#pnpm)
|
||||||
|
- [填写密钥](#填写密钥)
|
||||||
|
- [安装依赖](#安装依赖)
|
||||||
|
- [后端](#后端)
|
||||||
|
- [前端](#前端)
|
||||||
|
- [测试环境运行](#测试环境运行)
|
||||||
|
- [后端服务](#后端服务)
|
||||||
|
- [前端网页](#前端网页)
|
||||||
|
- [打包](#打包)
|
||||||
|
- [使用 Docker](#使用-docker)
|
||||||
|
- [Docker 参数示例](#docker-参数示例)
|
||||||
|
- [Docker build \& Run](#docker-build--run)
|
||||||
|
- [Docker compose](#docker-compose)
|
||||||
|
- [使用 Railway 部署](#使用-railway-部署)
|
||||||
|
- [Railway 环境变量](#railway-环境变量)
|
||||||
|
- [手动打包](#手动打包)
|
||||||
|
- [后端服务](#后端服务-1)
|
||||||
|
- [前端网页](#前端网页-1)
|
||||||
|
- [常见问题](#常见问题)
|
||||||
|
- [参与贡献](#参与贡献)
|
||||||
|
- [赞助](#赞助)
|
||||||
|
- [License](#license)
|
||||||
|
## 介绍
|
||||||
|
|
||||||
## Route Map
|
支持双模型,提供了两种非官方 `ChatGPT API` 方法
|
||||||
[x] 多会话
|
|
||||||
|
|
||||||
[x] 多语言
|
| 方式 | 免费? | 可靠性 | 质量 |
|
||||||
|
| ---- | ---- | ---- | ---- |
|
||||||
|
| `ChatGPTAPI(GPT-3)` | 否 | 可靠 | 较笨 |
|
||||||
|
| `ChatGPTUnofficialProxyAPI(网页 accessToken)` | 是 | 相对不可靠 | 聪明 |
|
||||||
|
|
||||||
[x] 主题
|
对比:
|
||||||
|
1. `ChatGPTAPI` 使用 `text-davinci-003` 通过官方`OpenAI`补全`API`模拟`ChatGPT`(最稳健的方法,但它不是免费的,并且没有使用针对聊天进行微调的模型)
|
||||||
|
2. `ChatGPTUnofficialProxyAPI` 使用非官方代理服务器访问 `ChatGPT` 的后端`API`,绕过`Cloudflare`(使用真实的的`ChatGPT`,非常轻量级,但依赖于第三方服务器,并且有速率限制)
|
||||||
|
|
||||||
[x] 用户模块
|
[查看详情](https://github.com/Chanzhaoyu/chatgpt-web/issues/138)
|
||||||
|
|
||||||
[x] More...
|
切换方式:
|
||||||
|
1. 进入 `service/.env` 文件
|
||||||
|
2. 使用 `OpenAI API Key` 请填写 `OPENAI_API_KEY` 字段 [(获取 apiKey)](https://platform.openai.com/overview)
|
||||||
|
3. 使用 `Web API` 请填写 `OPENAI_ACCESS_TOKEN` 字段 [(获取 accessToken)](https://chat.openai.com/api/auth/session)
|
||||||
|
4. 同时存在时以 `OpenAI API Key` 优先
|
||||||
|
|
||||||
|
反向代理:
|
||||||
|
|
||||||
|
`ChatGPTUnofficialProxyAPI`时可用 [详情](https://github.com/transitive-bullshit/chatgpt-api#reverse-proxy)
|
||||||
|
|
||||||
|
```shell
|
||||||
|
# service/.env
|
||||||
|
API_REVERSE_PROXY=
|
||||||
|
```
|
||||||
|
|
||||||
|
## 待实现路线
|
||||||
|
[✓] 双模型
|
||||||
|
|
||||||
|
[✓] 多会话储存和上下文逻辑
|
||||||
|
|
||||||
|
[✓] 对代码等消息类型的格式化美化处理
|
||||||
|
|
||||||
|
[✗] 界面多语言
|
||||||
|
|
||||||
|
[✗] 界面主题
|
||||||
|
|
||||||
|
[✗] More...
|
||||||
|
|
||||||
## 前置要求
|
## 前置要求
|
||||||
|
|
||||||
### Node
|
### Node
|
||||||
|
|
||||||
`node` 版本需要 >= 18,使用 [nvm](https://github.com/nvm-sh/nvm) 可管理本地多个 `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
|
```shell
|
||||||
node -v
|
node -v
|
||||||
@@ -33,19 +91,24 @@ node -v
|
|||||||
npm install pnpm -g
|
npm install pnpm -g
|
||||||
```
|
```
|
||||||
|
|
||||||
### OpenAI API KEy
|
### 填写密钥
|
||||||
获取 [OpenAI API key](https://platform.openai.com/overview) 并填写到本地环境变量
|
获取 `Openai Api Key` 或 `accessToken` 并填写本地环境变量 [跳转](#介绍)
|
||||||
|
|
||||||
```
|
```
|
||||||
# service/.env 文件
|
# service/.env 文件
|
||||||
|
|
||||||
OPENAI_API_KEY='Your key'
|
# OpenAI API Key - https://platform.openai.com/overview
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
|
||||||
|
# change this to an `accessToken` extracted from the ChatGPT site's `https://chat.openai.com/api/auth/session` response
|
||||||
|
OPENAI_ACCESS_TOKEN=
|
||||||
```
|
```
|
||||||
|
|
||||||
## 安装依赖
|
## 安装依赖
|
||||||
|
|
||||||
> 为了简便 `后端开发人员` 的了解负担,所以并没有采用前端 `workspace` 模式,而是分文件夹存放。如果只需要前端页面做二次开发,删除 `service` 文件夹即可。
|
> 为了简便 `后端开发人员` 的了解负担,所以并没有采用前端 `workspace` 模式,而是分文件夹存放。如果只需要前端页面做二次开发,删除 `service` 文件夹即可。
|
||||||
|
|
||||||
### 后端服务
|
### 后端
|
||||||
|
|
||||||
进入文件夹 `/service` 运行以下命令
|
进入文件夹 `/service` 运行以下命令
|
||||||
|
|
||||||
@@ -53,14 +116,13 @@ OPENAI_API_KEY='Your key'
|
|||||||
pnpm install
|
pnpm install
|
||||||
```
|
```
|
||||||
|
|
||||||
### 网页
|
### 前端
|
||||||
根目录下运行以下命令
|
根目录下运行以下命令
|
||||||
```shell
|
```shell
|
||||||
pnpm bootstrap
|
pnpm bootstrap
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 测试环境运行
|
||||||
## 运行
|
|
||||||
### 后端服务
|
### 后端服务
|
||||||
|
|
||||||
进入文件夹 `/service` 运行以下命令
|
进入文件夹 `/service` 运行以下命令
|
||||||
@@ -69,28 +131,115 @@ pnpm bootstrap
|
|||||||
pnpm start
|
pnpm start
|
||||||
```
|
```
|
||||||
|
|
||||||
### 网页
|
### 前端网页
|
||||||
根目录下运行以下命令
|
根目录下运行以下命令
|
||||||
```shell
|
```shell
|
||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## 打包
|
## 打包
|
||||||
### 后端服务
|
|
||||||
|
### 使用 Docker
|
||||||
|
|
||||||
|
#### Docker 参数示例
|
||||||
|
|
||||||
|
- `OPENAI_API_KEY` 二选一
|
||||||
|
- `OPENAI_ACCESS_TOKEN` 二选一,同时存在时,`OPENAI_API_KEY` 优先
|
||||||
|
- `API_REVERSE_PROXY` 可选,设置 `OPENAI_ACCESS_TOKEN` 时可用 [参考](#介绍)
|
||||||
|
- `TIMEOUT_MS` 超时,单位毫秒,可选
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### Docker build & Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t chatgpt-web .
|
||||||
|
|
||||||
|
# 前台运行
|
||||||
|
docker run --name chatgpt-web --rm -it -p 3002:3002 --env OPENAI_API_KEY=your_api_key chatgpt-web
|
||||||
|
|
||||||
|
# 后台运行
|
||||||
|
docker run --name chatgpt-web -d -p 3002:3002 --env OPENAI_API_KEY=your_api_key chatgpt-web
|
||||||
|
|
||||||
|
# 运行地址
|
||||||
|
http://localhost:3002/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Docker compose
|
||||||
|
|
||||||
|
[Hub 地址](https://hub.docker.com/repository/docker/chenzhaoyu94/chatgpt-web/general)
|
||||||
|
|
||||||
|
```yml
|
||||||
|
version: '3'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: chenzhaoyu94/chatgpt-web # 总是使用latest,更新时重新pull该tag镜像即可
|
||||||
|
ports:
|
||||||
|
- 3002:3002
|
||||||
|
environment:
|
||||||
|
# 二选一
|
||||||
|
OPENAI_API_KEY: xxxxxx
|
||||||
|
# 二选一
|
||||||
|
OPENAI_ACCESS_TOKEN: xxxxxx
|
||||||
|
# 反向代理,可选
|
||||||
|
API_REVERSE_PROXY: xxx
|
||||||
|
# 超时,单位毫秒,可选
|
||||||
|
TIMEOUT_MS: 60000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用 Railway 部署
|
||||||
|
|
||||||
|
[](https://railway.app/new/template/yytmgc)
|
||||||
|
|
||||||
|
#### Railway 环境变量
|
||||||
|
|
||||||
|
| 环境变量名称 | 必填 | 备注 |
|
||||||
|
| --------------------------- | ---- | ----------------------- |
|
||||||
|
| `PORT` | 必填 | 默认 `3002` |
|
||||||
|
| `TIMEOUT_MS` | 可选 | 超时时间,单位毫秒, |
|
||||||
|
| `OPENAI_API_KEY` | `OpenAI API` 二选一 | 使用 `OpenAI API` 所需的 `apiKey` [(获取 apiKey)](https://platform.openai.com/overview) |
|
||||||
|
| `OPENAI_ACCESS_TOKEN` | `Web API` 二选一 | 使用 `Web API` 所需的 `accessToken` [(获取 accessToken)](https://chat.openai.com/api/auth/session) |
|
||||||
|
| `API_REVERSE_PROXY` | 可选,`Web API` 时可用 | `Web API` 反向代理地址 [详情](https://github.com/transitive-bullshit/chatgpt-api#reverse-proxy) |
|
||||||
|
|
||||||
|
> 注意: `Railway` 修改环境变量会重新 `Deploy`
|
||||||
|
|
||||||
|
### 手动打包
|
||||||
|
#### 后端服务
|
||||||
> 如果你不需要本项目的 `node` 接口,可以省略如下操作
|
> 如果你不需要本项目的 `node` 接口,可以省略如下操作
|
||||||
|
|
||||||
复制 `service` 文件夹到你有 `node` 服务环境的服务器上。(搜索关键字:`express部署)`
|
复制 `service` 文件夹到你有 `node` 服务环境的服务器上。
|
||||||
|
|
||||||
操作上述 `安装` 和 `运行` 服务。
|
```shell
|
||||||
|
# 安装
|
||||||
|
pnpm install
|
||||||
|
|
||||||
### 网页
|
# 打包
|
||||||
根目录下运行以下命令,然后将 `dist` 文件夹复制到你的托管服务器上
|
pnpm build
|
||||||
|
|
||||||
|
# 运行
|
||||||
|
pnpm prod
|
||||||
|
```
|
||||||
|
|
||||||
|
PS: 不进行打包,直接在服务器上运行 `pnpm start` 也可
|
||||||
|
|
||||||
|
#### 前端网页
|
||||||
|
|
||||||
|
1、修改根目录下 `.env` 内 `VITE_APP_API_BASE_URL` 为你的实际后端接口地址
|
||||||
|
|
||||||
|
2、根目录下运行以下命令,然后将 `dist` 文件夹内的文件复制到你网站服务的根目录下
|
||||||
|
|
||||||
|
[参考信息](https://cn.vitejs.dev/guide/static-deploy.html#building-the-app)
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
pnpm build
|
pnpm build
|
||||||
```
|
```
|
||||||
|
|
||||||
### 常见问题
|
## 常见问题
|
||||||
|
Q: 为什么 `Git` 提交总是报错?
|
||||||
|
|
||||||
|
A: 因为有提交信息验证,请遵循 [Commit 指南](./CONTRIBUTING.md)
|
||||||
|
|
||||||
Q: 如果只使用前端页面,在哪里改请求接口?
|
Q: 如果只使用前端页面,在哪里改请求接口?
|
||||||
|
|
||||||
A: 根目录下 `.env` 文件中的 `VITE_GLOB_API_URL` 字段。
|
A: 根目录下 `.env` 文件中的 `VITE_GLOB_API_URL` 字段。
|
||||||
@@ -99,5 +248,30 @@ Q: 文件保存时全部爆红?
|
|||||||
|
|
||||||
A: `vscode` 请安装项目推荐插件,或手动安装 `Eslint` 插件。
|
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>
|
||||||
|
|
||||||
|
## 赞助
|
||||||
|
|
||||||
|
如果你觉得这个项目对你有帮助,并且情况允许的话,可以给我一点点支持,总之非常感谢支持~
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 20px;">
|
||||||
|
<div style="text-align: center">
|
||||||
|
<img style="max-width: 100%" src="./docs/wechat.png" alt="微信" />
|
||||||
|
<p>WeChat Pay</p>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: center">
|
||||||
|
<img style="max-width: 100%" src="./docs/alipay.png" alt="支付宝" />
|
||||||
|
<p>Alipay</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
## License
|
## License
|
||||||
MIT © [ChenZhaoYu](./license)
|
MIT © [ChenZhaoYu](./license)
|
||||||
|
|||||||
1
config/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './proxy'
|
||||||
16
config/proxy.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { ProxyOptions } from 'vite'
|
||||||
|
|
||||||
|
export function createViteProxy(isOpenProxy: boolean, viteEnv: ImportMetaEnv) {
|
||||||
|
if (!isOpenProxy)
|
||||||
|
return
|
||||||
|
|
||||||
|
const proxy: Record<string, string | ProxyOptions> = {
|
||||||
|
'/api': {
|
||||||
|
target: viteEnv.VITE_APP_API_BASE_URL,
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: path => path.replace('/api/', '/'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxy
|
||||||
|
}
|
||||||
BIN
docs/alipay.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
docs/c1-2.8.0.png
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
docs/c1.png
Normal file
|
After Width: | Height: | Size: 410 KiB |
BIN
docs/c2-2.8.0.png
Normal file
|
After Width: | Height: | Size: 396 KiB |
BIN
docs/c2.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
docs/cover-2.png
|
Before Width: | Height: | Size: 200 KiB |
BIN
docs/cover.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
docs/cover2.png
Normal file
|
After Width: | Height: | Size: 518 KiB |
BIN
docs/docker.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
docs/wechat.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
130
index.html
@@ -1,71 +1,81 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-cmn-Hans">
|
<html lang="zh-cmn-Hans">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport"
|
||||||
<title>ChatGPT Web</title>
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, viewport-fit=cover" />
|
||||||
</head>
|
<title>ChatGPT Web</title>
|
||||||
<body>
|
</head>
|
||||||
<div id="app">
|
|
||||||
<style>
|
|
||||||
.loading-wrap {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.balls {
|
<body class="dark:bg-black">
|
||||||
width: 4em;
|
<div id="app">
|
||||||
display: flex;
|
<style>
|
||||||
flex-flow: row nowrap;
|
.loading-wrap {
|
||||||
align-items: center;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: center;
|
||||||
}
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
.balls div {
|
.balls {
|
||||||
width: 0.8em;
|
width: 4em;
|
||||||
height: 0.8em;
|
display: flex;
|
||||||
border-radius: 50%;
|
flex-flow: row nowrap;
|
||||||
background-color: #4b9e5f;
|
align-items: center;
|
||||||
}
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
.balls div:nth-of-type(1) {
|
.balls div {
|
||||||
transform: translateX(-100%);
|
width: 0.8em;
|
||||||
animation: left-swing 0.5s ease-in alternate infinite;
|
height: 0.8em;
|
||||||
}
|
border-radius: 50%;
|
||||||
|
background-color: #4b9e5f;
|
||||||
|
}
|
||||||
|
|
||||||
.balls div:nth-of-type(3) {
|
.balls div:nth-of-type(1) {
|
||||||
transform: translateX(-95%);
|
transform: translateX(-100%);
|
||||||
animation: right-swing 0.5s ease-out alternate infinite;
|
animation: left-swing 0.5s ease-in alternate infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes left-swing {
|
.balls div:nth-of-type(3) {
|
||||||
50%,
|
transform: translateX(-95%);
|
||||||
100% {
|
animation: right-swing 0.5s ease-out alternate infinite;
|
||||||
transform: translateX(95%);
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes right-swing {
|
@keyframes left-swing {
|
||||||
50% {
|
|
||||||
transform: translateX(-95%);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
50%,
|
||||||
transform: translateX(100%);
|
100% {
|
||||||
}
|
transform: translateX(95%);
|
||||||
}
|
}
|
||||||
</style>
|
}
|
||||||
<div class="loading-wrap">
|
|
||||||
<div class="balls">
|
@keyframes right-swing {
|
||||||
<div></div>
|
50% {
|
||||||
<div></div>
|
transform: translateX(-95%);
|
||||||
<div></div>
|
}
|
||||||
</div>
|
|
||||||
</div>
|
100% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body {
|
||||||
|
background: #121212;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="loading-wrap">
|
||||||
|
<div class="balls">
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
</div>
|
||||||
</body>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
39
package.json
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "chatgpt-web",
|
"name": "chatgpt-web",
|
||||||
"version": "2.0.0",
|
"version": "2.8.2",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "ChatGPT Web Bot",
|
"description": "ChatGPT Web",
|
||||||
"author": "ChenZhaoYu <chenzhaoyu1994@gami.com>",
|
"author": "ChenZhaoYu <chenzhaoyu1994@gmail.com>",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
"chatgpt-web",
|
||||||
"chatgpt",
|
"chatgpt",
|
||||||
"chatbot",
|
"chatbot",
|
||||||
"web",
|
|
||||||
"vue"
|
"vue"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -16,40 +16,49 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"build-only": "vite build",
|
"build-only": "vite build",
|
||||||
"type-check": "vue-tsc --noEmit",
|
"type-check": "vue-tsc --noEmit",
|
||||||
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"bootstrap": "pnpm install && pnpm run common:prepare",
|
"bootstrap": "pnpm install && pnpm run common:prepare",
|
||||||
"common:cleanup": "rimraf node_modules && rimraf pnpm-lock.yaml",
|
"common:cleanup": "rimraf node_modules && rimraf pnpm-lock.yaml",
|
||||||
"common:prepare": "husky install"
|
"common:prepare": "husky install"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@vueuse/core": "^9.13.0",
|
||||||
|
"highlight.js": "^11.7.0",
|
||||||
|
"marked": "^4.2.12",
|
||||||
"naive-ui": "^2.34.3",
|
"naive-ui": "^2.34.3",
|
||||||
|
"pinia": "^2.0.30",
|
||||||
"vue": "^3.2.47",
|
"vue": "^3.2.47",
|
||||||
"vue-router": "^4.1.6"
|
"vue-router": "^4.1.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@antfu/eslint-config": "^0.35.2",
|
"@antfu/eslint-config": "^0.35.2",
|
||||||
"@commitlint/cli": "^17.4.2",
|
"@commitlint/cli": "^17.4.4",
|
||||||
"@commitlint/config-conventional": "^17.4.2",
|
"@commitlint/config-conventional": "^17.4.4",
|
||||||
"@iconify/vue": "^4.1.0",
|
"@iconify/vue": "^4.1.0",
|
||||||
"@types/node": "^18.13.0",
|
"@types/crypto-js": "^4.1.1",
|
||||||
|
"@types/marked": "^4.0.8",
|
||||||
|
"@types/node": "^18.14.0",
|
||||||
|
"@types/web-bluetooth": "^0.0.16",
|
||||||
"@vitejs/plugin-vue": "^4.0.0",
|
"@vitejs/plugin-vue": "^4.0.0",
|
||||||
"autoprefixer": "^10.4.13",
|
"autoprefixer": "^10.4.13",
|
||||||
"axios": "^1.3.2",
|
"axios": "^1.3.3",
|
||||||
|
"crypto-js": "^4.1.1",
|
||||||
"eslint": "^8.34.0",
|
"eslint": "^8.34.0",
|
||||||
"husky": "^8.0.3",
|
"husky": "^8.0.3",
|
||||||
"lint-staged": "^13.1.1",
|
"less": "^4.1.3",
|
||||||
|
"lint-staged": "^13.1.2",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"postcss": "^8.4.21",
|
"postcss": "^8.4.21",
|
||||||
"rimraf": "^4.1.2",
|
"rimraf": "^4.1.2",
|
||||||
"tailwindcss": "^3.2.6",
|
"tailwindcss": "^3.2.7",
|
||||||
"typescript": "~4.9.5",
|
"typescript": "~4.9.5",
|
||||||
"vite": "^4.1.1",
|
"vite": "^4.1.2",
|
||||||
"vue-tsc": "^1.0.24"
|
"vue-tsc": "^1.1.4"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,ts,jsx,tsx,vue}": [
|
"*.{ts,tsx,vue}": [
|
||||||
"pnpm run lint:fix",
|
"pnpm lint:fix"
|
||||||
"pnpm run type-check"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
566
pnpm-lock.yaml
generated
11
service/.env
@@ -1,2 +1,11 @@
|
|||||||
# OpenAI API Key
|
# OpenAI API Key - https://platform.openai.com/overview
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
|
|
||||||
|
# change this to an `accessToken` extracted from the ChatGPT site's `https://chat.openai.com/api/auth/session` response
|
||||||
|
OPENAI_ACCESS_TOKEN=
|
||||||
|
|
||||||
|
# Reverse Proxy
|
||||||
|
API_REVERSE_PROXY=
|
||||||
|
|
||||||
|
# timeout
|
||||||
|
TIMEOUT_MS=60000
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
"root": true,
|
"root": true,
|
||||||
|
"ignorePatterns": ["build"],
|
||||||
"extends": ["@antfu"]
|
"extends": ["@antfu"]
|
||||||
}
|
}
|
||||||
|
|||||||
2
service/.gitignore
vendored
@@ -27,3 +27,5 @@ coverage
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
build
|
||||||
|
|||||||
1
service/.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
enable-pre-post-scripts=true
|
||||||
@@ -2,32 +2,43 @@
|
|||||||
"name": "chatgpt-web-service",
|
"name": "chatgpt-web-service",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "ChatGPT Web Bot node service",
|
"description": "ChatGPT Web Service",
|
||||||
|
"author": "ChenZhaoYu <chenzhaoyu1994@gmail.com>",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
"chatgpt-web",
|
||||||
"chatgpt",
|
"chatgpt",
|
||||||
"chatbot",
|
"chatbot",
|
||||||
"web",
|
"express"
|
||||||
"vue"
|
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": "^16 || ^18"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "esno ./src/index.ts",
|
"start": "esno ./src/index.ts",
|
||||||
|
"dev": "esno watch ./src/index.ts",
|
||||||
|
"prod": "esno ./build/index.js",
|
||||||
|
"build": "pnpm clean && tsup",
|
||||||
|
"clean": "rimraf build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix"
|
"lint:fix": "eslint . --fix",
|
||||||
|
"common:cleanup": "rimraf node_modules && rimraf pnpm-lock.yaml"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chatgpt": "^4.2.0",
|
"chatgpt": "^4.7.2",
|
||||||
"express": "^4.18.2"
|
"dotenv": "^16.0.3",
|
||||||
|
"esno": "^0.16.3",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"isomorphic-fetch": "^3.0.0",
|
||||||
|
"node-fetch": "^3.3.0",
|
||||||
|
"socks-proxy-agent": "^7.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@antfu/eslint-config": "^0.35.2",
|
"@antfu/eslint-config": "^0.35.2",
|
||||||
"@types/express": "^4.17.17",
|
"@types/express": "^4.17.17",
|
||||||
"@types/node": "^18.13.0",
|
"@types/node": "^18.14.0",
|
||||||
"dotenv": "^16.0.3",
|
|
||||||
"eslint": "^8.34.0",
|
"eslint": "^8.34.0",
|
||||||
"esno": "^0.16.3",
|
"rimraf": "^4.1.2",
|
||||||
|
"tsup": "^6.6.3",
|
||||||
"typescript": "^4.9.5"
|
"typescript": "^4.9.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
976
service/pnpm-lock.yaml
generated
@@ -1,81 +1,125 @@
|
|||||||
import * as dotenv from 'dotenv'
|
import * as dotenv from 'dotenv'
|
||||||
import type { SendMessageOptions } from 'chatgpt'
|
import 'isomorphic-fetch'
|
||||||
import { ChatGPTAPI } from 'chatgpt'
|
import type { ChatMessage, SendMessageOptions } from 'chatgpt'
|
||||||
|
import { ChatGPTAPI, ChatGPTUnofficialProxyAPI } from 'chatgpt'
|
||||||
|
import { SocksProxyAgent } from 'socks-proxy-agent'
|
||||||
|
import fetch from 'node-fetch'
|
||||||
import { sendResponse } from './utils'
|
import { sendResponse } from './utils'
|
||||||
|
import type { ApiModel, ChatContext, ChatGPTAPIOptions, ChatGPTUnofficialProxyAPIOptions, ModelConfig } from './types'
|
||||||
export interface ChatContext {
|
|
||||||
conversationId?: string
|
|
||||||
parentMessageId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
dotenv.config()
|
dotenv.config()
|
||||||
|
|
||||||
const apiKey = process.env.OPENAI_API_KEY
|
const timeoutMs: number = !isNaN(+process.env.TIMEOUT_MS) ? +process.env.TIMEOUT_MS : 30 * 1000
|
||||||
|
|
||||||
if (apiKey === undefined)
|
let apiModel: ApiModel
|
||||||
throw new Error('OPENAI_API_KEY is not defined')
|
|
||||||
|
|
||||||
const chatContext = new Set<ChatContext>()
|
if (!process.env.OPENAI_API_KEY && !process.env.OPENAI_ACCESS_TOKEN)
|
||||||
|
throw new Error('Missing OPENAI_API_KEY or OPENAI_ACCESS_TOKEN environment variable')
|
||||||
|
|
||||||
/**
|
let api: ChatGPTAPI | ChatGPTUnofficialProxyAPI
|
||||||
* More Info: https://github.com/transitive-bullshit/chatgpt-api
|
|
||||||
*/
|
|
||||||
const api = new ChatGPTAPI({ apiKey })
|
|
||||||
|
|
||||||
async function chatReply(message: string) {
|
// To use ESM in CommonJS, you can use a dynamic import
|
||||||
|
(async () => {
|
||||||
|
// More Info: https://github.com/transitive-bullshit/chatgpt-api
|
||||||
|
|
||||||
|
if (process.env.OPENAI_API_KEY) {
|
||||||
|
const options: ChatGPTAPIOptions = {
|
||||||
|
apiKey: process.env.OPENAI_API_KEY,
|
||||||
|
debug: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
api = new ChatGPTAPI({ ...options })
|
||||||
|
apiModel = 'ChatGPTAPI'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const options: ChatGPTUnofficialProxyAPIOptions = {
|
||||||
|
accessToken: process.env.OPENAI_ACCESS_TOKEN,
|
||||||
|
debug: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.SOCKS_PROXY_HOST && process.env.SOCKS_PROXY_PORT) {
|
||||||
|
const agent = new SocksProxyAgent({
|
||||||
|
hostname: process.env.SOCKS_PROXY_HOST,
|
||||||
|
port: process.env.SOCKS_PROXY_PORT,
|
||||||
|
})
|
||||||
|
options.fetch = (url, options) => {
|
||||||
|
return fetch(url, { agent, ...options })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.API_REVERSE_PROXY)
|
||||||
|
options.apiReverseProxyUrl = process.env.API_REVERSE_PROXY
|
||||||
|
|
||||||
|
api = new ChatGPTUnofficialProxyAPI({
|
||||||
|
accessToken: process.env.OPENAI_ACCESS_TOKEN,
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
apiModel = 'ChatGPTUnofficialProxyAPI'
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
async function chatReply(
|
||||||
|
message: string,
|
||||||
|
lastContext?: { conversationId?: string; parentMessageId?: string },
|
||||||
|
) {
|
||||||
if (!message)
|
if (!message)
|
||||||
return sendResponse({ type: 'Fail', message: 'Message is empty' })
|
return sendResponse({ type: 'Fail', message: 'Message is empty' })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the last context from the chat context
|
let options: SendMessageOptions = { timeoutMs }
|
||||||
let options: SendMessageOptions = {}
|
|
||||||
|
|
||||||
const lastContext = Array.from(chatContext).pop()
|
|
||||||
|
|
||||||
if (lastContext)
|
if (lastContext)
|
||||||
options = { ...lastContext }
|
options = { ...lastContext }
|
||||||
|
|
||||||
const response = await api.sendMessage(message, { ...options })
|
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 sendResponse({ type: 'Success', data: response })
|
return sendResponse({ type: 'Success', data: response })
|
||||||
}
|
}
|
||||||
catch (error: any) {
|
catch (error: any) {
|
||||||
global.console.log(error)
|
|
||||||
return sendResponse({ type: 'Fail', message: error.message })
|
return sendResponse({ type: 'Fail', message: error.message })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function chatReplayOne(message: string, options?: ChatContext) {
|
async function chatReplyProcess(
|
||||||
|
message: string,
|
||||||
|
lastContext?: { conversationId?: string; parentMessageId?: string },
|
||||||
|
process?: (chat: ChatMessage) => void,
|
||||||
|
) {
|
||||||
if (!message)
|
if (!message)
|
||||||
return sendResponse({ type: 'Fail', message: 'Message is empty' })
|
return sendResponse({ type: 'Fail', message: 'Message is empty' })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let messageOptions: SendMessageOptions = {}
|
let options: SendMessageOptions = { timeoutMs }
|
||||||
|
|
||||||
if (options) {
|
if (lastContext)
|
||||||
const { conversationId, parentMessageId } = options
|
options = { ...lastContext }
|
||||||
messageOptions = { conversationId, parentMessageId }
|
|
||||||
|
|
||||||
const response = await api.sendMessage(message, { ...messageOptions })
|
const response = await api.sendMessage(message, {
|
||||||
|
...options,
|
||||||
|
onProgress: (partialResponse) => {
|
||||||
|
process?.(partialResponse)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
return sendResponse({ type: 'Success', data: response })
|
return sendResponse({ type: 'Success', data: response })
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (error: any) {
|
catch (error: any) {
|
||||||
return sendResponse({ type: 'Fail', message: error.message })
|
return sendResponse({ type: 'Fail', message: error.message })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearChatContext() {
|
async function chatConfig() {
|
||||||
// Clear the chat context
|
return sendResponse({
|
||||||
chatContext.clear()
|
type: 'Success',
|
||||||
return sendResponse({ type: 'Success', message: 'Chat context cleared' })
|
data: {
|
||||||
|
apiModel,
|
||||||
|
reverseProxy: process.env.API_REVERSE_PROXY,
|
||||||
|
timeoutMs,
|
||||||
|
socksProxy: (process.env.SOCKS_PROXY_HOST && process.env.SOCKS_PROXY_PORT) ? (`${process.env.SOCKS_PROXY_HOST}:${process.env.SOCKS_PROXY_PORT}`) : '-',
|
||||||
|
} as ModelConfig,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export { chatReply, chatReplayOne, clearChatContext }
|
export type { ChatContext, ChatMessage }
|
||||||
|
|
||||||
|
export { chatReply, chatReplyProcess, chatConfig }
|
||||||
|
|||||||
@@ -1,35 +1,24 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
import type { ChatContext } from './chatgpt'
|
import type { ChatContext, ChatMessage } from './chatgpt'
|
||||||
import { chatReplayOne, chatReply, clearChatContext } from './chatgpt'
|
import { chatConfig, chatReply, chatReplyProcess } from './chatgpt'
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
app.use(express.static('public'))
|
||||||
app.use(express.json())
|
app.use(express.json())
|
||||||
|
|
||||||
app.all('*', (req, res, next) => {
|
app.all('*', (_, res, next) => {
|
||||||
res.header('Access-Control-Allow-Origin', '*')
|
res.header('Access-Control-Allow-Origin', '*')
|
||||||
res.header('Access-Control-Allow-Headers', 'Content-Type')
|
res.header('Access-Control-Allow-Headers', 'Content-Type')
|
||||||
res.header('Access-Control-Allow-Methods', '*')
|
res.header('Access-Control-Allow-Methods', '*')
|
||||||
next()
|
next()
|
||||||
})
|
})
|
||||||
|
|
||||||
app.listen(3002, () => globalThis.console.log('Server is running on port 3002'))
|
router.post('/chat', async (req, res) => {
|
||||||
|
|
||||||
app.post('/chat', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { prompt } = req.body as { prompt: string }
|
|
||||||
const response = await chatReply(prompt)
|
|
||||||
res.send(response)
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
res.send(error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
app.post('./chatOne', async (req, res) => {
|
|
||||||
try {
|
try {
|
||||||
const { prompt, options = {} } = req.body as { prompt: string; options?: ChatContext }
|
const { prompt, options = {} } = req.body as { prompt: string; options?: ChatContext }
|
||||||
const response = await chatReplayOne(prompt, options)
|
const response = await chatReply(prompt, options)
|
||||||
res.send(response)
|
res.send(response)
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
@@ -37,7 +26,36 @@ app.post('./chatOne', async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post('/clear', async (req, res) => {
|
router.post('/chat-process', async (req, res) => {
|
||||||
const response = await clearChatContext()
|
res.setHeader('Content-type', 'application/octet-stream')
|
||||||
res.send(response)
|
|
||||||
|
try {
|
||||||
|
const { prompt, options = {} } = req.body as { prompt: string; options?: ChatContext }
|
||||||
|
let firstChunk = true
|
||||||
|
await chatReplyProcess(prompt, options, (chat: ChatMessage) => {
|
||||||
|
res.write(firstChunk ? JSON.stringify(chat) : `\n${JSON.stringify(chat)}`)
|
||||||
|
firstChunk = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
res.write(JSON.stringify(error))
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
res.end()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.post('/config', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await chatConfig()
|
||||||
|
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'))
|
||||||
|
|||||||
30
service/src/types.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { FetchFn, openai } from 'chatgpt'
|
||||||
|
|
||||||
|
export interface ChatContext {
|
||||||
|
conversationId?: string
|
||||||
|
parentMessageId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatGPTAPIOptions {
|
||||||
|
apiKey: string
|
||||||
|
debug?: boolean
|
||||||
|
completionParams?: Partial<openai.CompletionParams>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatGPTUnofficialProxyAPIOptions {
|
||||||
|
accessToken: string
|
||||||
|
apiReverseProxyUrl?: string
|
||||||
|
model?: string
|
||||||
|
debug?: boolean
|
||||||
|
headers?: Record<string, string>
|
||||||
|
fetch?: FetchFn
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelConfig {
|
||||||
|
apiModel?: ApiModel
|
||||||
|
reverseProxy?: string
|
||||||
|
timeoutMs?: number
|
||||||
|
socksProxy?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiModel = 'ChatGPTAPI' | 'ChatGPTUnofficialProxyAPI' | undefined
|
||||||
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
@@ -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,
|
||||||
|
})
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NConfigProvider } from 'naive-ui'
|
import { NConfigProvider } from 'naive-ui'
|
||||||
import { NaiveProvider } from '@/components/common'
|
import { NaiveProvider } from '@/components/common'
|
||||||
|
import { useTheme } from '@/hooks/useTheme'
|
||||||
|
|
||||||
|
const { theme, themeOverrides } = useTheme()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<NConfigProvider class="h-full">
|
<NConfigProvider
|
||||||
|
class="h-full"
|
||||||
|
:theme="theme"
|
||||||
|
:theme-overrides="themeOverrides"
|
||||||
|
>
|
||||||
<NaiveProvider>
|
<NaiveProvider>
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</NaiveProvider>
|
</NaiveProvider>
|
||||||
|
|||||||
@@ -1,14 +1,35 @@
|
|||||||
|
import type { AxiosProgressEvent, GenericAbortSignal } from 'axios'
|
||||||
import { post } from '@/utils/request'
|
import { post } from '@/utils/request'
|
||||||
|
|
||||||
export function fetchChatAPI<T = any>(prompt: string) {
|
export function fetchChatAPI<T = any>(
|
||||||
|
prompt: string,
|
||||||
|
options?: { conversationId?: string; parentMessageId?: string },
|
||||||
|
signal?: GenericAbortSignal,
|
||||||
|
) {
|
||||||
return post<T>({
|
return post<T>({
|
||||||
url: '/chat',
|
url: '/chat',
|
||||||
data: { prompt },
|
data: { prompt, options },
|
||||||
|
signal,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearConversations<T = any>() {
|
export function fetchChatConfig<T = any>() {
|
||||||
return post<T>({
|
return post<T>({
|
||||||
url: '/clear',
|
url: '/config',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchChatAPIProcess<T = any>(
|
||||||
|
params: {
|
||||||
|
prompt: string
|
||||||
|
options?: { conversationId?: string; parentMessageId?: string }
|
||||||
|
signal?: GenericAbortSignal
|
||||||
|
onDownloadProgress?: (progressEvent: AxiosProgressEvent) => void },
|
||||||
|
) {
|
||||||
|
return post<T>({
|
||||||
|
url: '/chat-process',
|
||||||
|
data: { prompt: params.prompt, options: params.options },
|
||||||
|
signal: params.signal,
|
||||||
|
onDownloadProgress: params.onDownloadProgress,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
<script setup lang='ts'>
|
|
||||||
import { SvgIcon } from '@/components/common'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
text: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Emit {
|
|
||||||
(e: 'click',): void
|
|
||||||
(e: 'edit',): void
|
|
||||||
(e: 'delete',): void
|
|
||||||
}
|
|
||||||
|
|
||||||
defineProps<Props>()
|
|
||||||
|
|
||||||
const emit = defineEmits<Emit>()
|
|
||||||
|
|
||||||
function handleClick(event: Event) {
|
|
||||||
emit('click')
|
|
||||||
event.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEdit() {
|
|
||||||
emit('edit')
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDelete() {
|
|
||||||
emit('delete')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<a
|
|
||||||
class="relative flex items-center gap-3 px-3 py-3 break-all rounded-md cursor-pointer bg-neutral-50 pr-14 hover:bg-neutral-100 group"
|
|
||||||
@click="handleClick"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
<SvgIcon icon="ri:message-3-line" />
|
|
||||||
</span>
|
|
||||||
<div class="relative flex-1 overflow-hidden break-all text-ellipsis whitespace-nowrap max-h-5">
|
|
||||||
<span>{{ text }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="absolute z-10 flex visible right-1">
|
|
||||||
<button class="p-1" @click="handleEdit">
|
|
||||||
<SvgIcon icon="ri:edit-line" />
|
|
||||||
</button>
|
|
||||||
<button class="p-1" @click="handleDelete">
|
|
||||||
<SvgIcon icon="ri:delete-bin-line" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</template>
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
interface Props {
|
|
||||||
reversal?: boolean
|
|
||||||
error?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
defineProps<Props>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="p-2 mt-2 rounded-md" :class="[reversal ? 'bg-[#d2f9d1]' : 'bg-[#f4f6f8]']">
|
|
||||||
<span class="leading-relaxed whitespace-pre-wrap" :class="[{ 'text-red-400': error }]">
|
|
||||||
<slot />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<script setup lang='ts'>
|
|
||||||
import Avatar from './Avatar.vue'
|
|
||||||
import Text from './Text.vue'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
message?: string
|
|
||||||
dateTime?: string
|
|
||||||
reversal?: boolean
|
|
||||||
error?: 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>
|
|
||||||
<Text :reversal="reversal" :error="error">
|
|
||||||
{{ message }}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import Message from './Message/index.vue'
|
|
||||||
import ListItem from './ListItem/index.vue'
|
|
||||||
|
|
||||||
export { Message, ListItem }
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
<script setup lang='ts'>
|
|
||||||
import { nextTick, onMounted, ref } from 'vue'
|
|
||||||
import { NButton, NInput, useMessage } from 'naive-ui'
|
|
||||||
import { Message } from './components'
|
|
||||||
import { Layout } from './layout'
|
|
||||||
import { clearConversations, fetchChatAPI } from '@/api'
|
|
||||||
import { HoverButton, SvgIcon } from '@/components/common'
|
|
||||||
|
|
||||||
interface ListProps {
|
|
||||||
dateTime: string
|
|
||||||
message: string
|
|
||||||
reversal?: boolean
|
|
||||||
error?: 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 clearConversations()
|
|
||||||
ms.success(message ?? 'Success')
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
ms.error('Clear failed, please try again later.')
|
|
||||||
list.value = []
|
|
||||||
setTimeout(initChat, 100)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEnter(event: KeyboardEvent) {
|
|
||||||
if (event.key === 'Enter')
|
|
||||||
handleSubmit()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
if (loading.value)
|
|
||||||
return
|
|
||||||
|
|
||||||
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 { data } = await fetchChatAPI(message)
|
|
||||||
addMessage(data?.text ?? '', false)
|
|
||||||
}
|
|
||||||
catch (error: any) {
|
|
||||||
addMessage(`Error: ${error.message ?? 'Request failed, please try again later.'}`, false, true)
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addMessage(message: string, reversal = false, error = false) {
|
|
||||||
list.value.push({ dateTime: new Date().toLocaleString(), message, reversal, error })
|
|
||||||
nextTick(() => scrollRef.value && (scrollRef.value.scrollTop = scrollRef.value.scrollHeight))
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Layout>
|
|
||||||
<div class="flex flex-col h-full">
|
|
||||||
<main class="flex-1 overflow-hidden">
|
|
||||||
<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"
|
|
||||||
:error="item.error"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<footer class="p-4">
|
|
||||||
<div class="flex items-center justify-between space-x-2">
|
|
||||||
<HoverButton tooltip="Clear conversations" @click="handleClear">
|
|
||||||
<span class="text-xl text-[#4f555e]">
|
|
||||||
<SvgIcon icon="ri:delete-bin-line" />
|
|
||||||
</span>
|
|
||||||
</HoverButton>
|
|
||||||
<NInput v-model:value="prompt" placeholder="Type a message..." @keypress="handleEnter" />
|
|
||||||
<NButton type="primary" :loading="loading" @click="handleSubmit">
|
|
||||||
<template #icon>
|
|
||||||
<SvgIcon icon="ri:send-plane-fill" />
|
|
||||||
</template>
|
|
||||||
</NButton>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
</template>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<script setup lang='ts'>
|
|
||||||
import { NLayout, NLayoutContent } from 'naive-ui'
|
|
||||||
import Sider from './sider/index.vue'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="h-full overflow-hidden border rounded-md shadow-md min-w-[640px]">
|
|
||||||
<NLayout class="h-full" has-sider>
|
|
||||||
<Sider />
|
|
||||||
<NLayoutContent class="h-full">
|
|
||||||
<slot />
|
|
||||||
</NLayoutContent>
|
|
||||||
</NLayout>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
|
|
||||||
</style>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import Layout from './Layout.vue'
|
|
||||||
|
|
||||||
export { Layout }
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<script setup lang='ts'>
|
|
||||||
import { HoverButton, SvgIcon, UserAvatar } from '@/components/common'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<footer class="flex items-center justify-between 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>
|
|
||||||
</template>
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<script setup lang='ts'>
|
|
||||||
import { NScrollbar } from 'naive-ui'
|
|
||||||
import ListItem from './ListItem.vue'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<NScrollbar class="px-4">
|
|
||||||
<div class="flex flex-col gap-2 text-sm">
|
|
||||||
<ListItem text="Learning correlation" />
|
|
||||||
<ListItem text="Write Code" />
|
|
||||||
<ListItem text="docs..." />
|
|
||||||
</div>
|
|
||||||
</NScrollbar>
|
|
||||||
</template>
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
<script setup lang='ts'>
|
|
||||||
import { SvgIcon } from '@/components/common'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
text: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Emit {
|
|
||||||
(e: 'click',): void
|
|
||||||
(e: 'edit',): void
|
|
||||||
(e: 'delete',): void
|
|
||||||
}
|
|
||||||
|
|
||||||
defineProps<Props>()
|
|
||||||
|
|
||||||
const emit = defineEmits<Emit>()
|
|
||||||
|
|
||||||
function handleClick(event: Event) {
|
|
||||||
emit('click')
|
|
||||||
event.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEdit() {
|
|
||||||
emit('edit')
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDelete() {
|
|
||||||
emit('delete')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<a
|
|
||||||
class="relative flex items-center gap-3 px-3 py-3 break-all rounded-md cursor-pointer bg-neutral-50 pr-14 hover:bg-neutral-100 group"
|
|
||||||
@click="handleClick"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
<SvgIcon icon="ri:message-3-line" />
|
|
||||||
</span>
|
|
||||||
<div class="relative flex-1 overflow-hidden break-all text-ellipsis whitespace-nowrap max-h-5">
|
|
||||||
<span>{{ text }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="absolute z-10 flex visible right-1">
|
|
||||||
<button class="p-1" @click="handleEdit">
|
|
||||||
<SvgIcon icon="ri:edit-line" />
|
|
||||||
</button>
|
|
||||||
<button class="p-1" @click="handleDelete">
|
|
||||||
<SvgIcon icon="ri:delete-bin-line" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</template>
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
<script setup lang='ts'>
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
import { NButton, NLayoutSider, useMessage } from 'naive-ui'
|
|
||||||
import List from './List.vue'
|
|
||||||
import Footer from './Footer.vue'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
collapsed?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Emit {
|
|
||||||
(e: 'update:collapsed', value: boolean): void
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
collapsed: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<Emit>()
|
|
||||||
|
|
||||||
const ms = useMessage()
|
|
||||||
|
|
||||||
const collapsed = ref(props.collapsed)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.collapsed,
|
|
||||||
(value: boolean) => {
|
|
||||||
collapsed.value = value
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
function handleAdd() {
|
|
||||||
ms.info('Coming soon...')
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCollapsed() {
|
|
||||||
collapsed.value = !collapsed.value
|
|
||||||
emit('update:collapsed', collapsed.value)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<NLayoutSider
|
|
||||||
:collapsed="collapsed"
|
|
||||||
:collapsed-width="0"
|
|
||||||
:width="260"
|
|
||||||
collapse-mode="width"
|
|
||||||
show-trigger="arrow-circle"
|
|
||||||
bordered
|
|
||||||
@update:collapsed="handleCollapsed"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col h-full">
|
|
||||||
<main class="flex-1 min-h-0 overflow-hidden">
|
|
||||||
<div class="p-4">
|
|
||||||
<NButton dashed block @click="handleAdd">
|
|
||||||
New chat
|
|
||||||
</NButton>
|
|
||||||
</div>
|
|
||||||
<List />
|
|
||||||
</main>
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
</NLayoutSider>
|
|
||||||
</template>
|
|
||||||
@@ -1,3 +1 @@
|
|||||||
import Chat from './Chat/index.vue'
|
export { }
|
||||||
|
|
||||||
export { Chat }
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function handleClick() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button
|
<button
|
||||||
class="flex items-center justify-center w-10 h-10 transition rounded-full hover:bg-neutral-100"
|
class="flex items-center justify-center w-10 h-10 transition rounded-full hover:bg-neutral-100 dark:hover:bg-[#414755]"
|
||||||
@click="handleClick"
|
@click="handleClick"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
77
src/components/common/Setting/index.vue
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<script setup lang='ts'>
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { NCard, NModal } from 'naive-ui'
|
||||||
|
import pkg from '../../../../package.json'
|
||||||
|
import { fetchChatConfig } from '@/api'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
visible: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emit {
|
||||||
|
(e: 'update:visible', visible: boolean): void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigState {
|
||||||
|
timeoutMs?: number
|
||||||
|
reverseProxy?: string
|
||||||
|
apiModel?: string
|
||||||
|
socksProxy?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const emit = defineEmits<Emit>()
|
||||||
|
|
||||||
|
const show = computed({
|
||||||
|
get() {
|
||||||
|
return props.visible
|
||||||
|
},
|
||||||
|
set(visible: boolean) {
|
||||||
|
emit('update:visible', visible)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const config = ref<ConfigState>()
|
||||||
|
|
||||||
|
async function fetchConfig() {
|
||||||
|
try {
|
||||||
|
const { data } = await fetchChatConfig<ConfigState>()
|
||||||
|
config.value = data
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(val) => {
|
||||||
|
if (val)
|
||||||
|
fetchConfig()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NModal v-model:show="show" style="width: 80%; max-width: 460px;">
|
||||||
|
<NCard>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h2 class="text-xl font-bold text-center">
|
||||||
|
Version - {{ pkg.version }}
|
||||||
|
</h2>
|
||||||
|
<hr>
|
||||||
|
<p>
|
||||||
|
此项目开源于
|
||||||
|
<a class="text-blue-600" href="https://github.com/Chanzhaoyu/chatgpt-web" target="_blank">Github</a>
|
||||||
|
如果你觉得此项目对你有帮助,请帮我点个 Star,谢谢!
|
||||||
|
</p>
|
||||||
|
<hr>
|
||||||
|
<p>API方式:{{ config?.apiModel ?? '-' }}</p>
|
||||||
|
<p>反向代理:{{ config?.reverseProxy ?? '-' }}</p>
|
||||||
|
<p>超时时间:{{ config?.timeoutMs ?? '-' }}</p>
|
||||||
|
<p>Socks代理:{{ config?.socksProxy ?? '-' }}</p>
|
||||||
|
</div>
|
||||||
|
</NCard>
|
||||||
|
</NModal>
|
||||||
|
</template>
|
||||||
@@ -17,7 +17,3 @@ import { GithubSite } from '@/components/custom'
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
|
||||||
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ import HoverButton from './HoverButton/index.vue'
|
|||||||
import NaiveProvider from './NaiveProvider/index.vue'
|
import NaiveProvider from './NaiveProvider/index.vue'
|
||||||
import SvgIcon from './SvgIcon/index.vue'
|
import SvgIcon from './SvgIcon/index.vue'
|
||||||
import UserAvatar from './UserAvatar/index.vue'
|
import UserAvatar from './UserAvatar/index.vue'
|
||||||
|
import Setting from './Setting/index.vue'
|
||||||
|
|
||||||
export { HoverButton, NaiveProvider, SvgIcon, UserAvatar }
|
export { HoverButton, NaiveProvider, SvgIcon, UserAvatar, Setting }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="text-center text-neutral-500">
|
<div class="text-neutral-400">
|
||||||
<span>❤️ Star on</span>
|
<span>Star on</span>
|
||||||
<a href="https://github.com/Chanzhaoyu/chatgpt-bot" target="_blank" class="text-blue-500">
|
<a href="https://github.com/Chanzhaoyu/chatgpt-bot" target="_blank" class="text-blue-500">
|
||||||
GitHub
|
GitHub
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
23
src/directives/highlight.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { App, Directive } from 'vue'
|
||||||
|
import hljs from 'highlight.js'
|
||||||
|
import { includeCode } from '@/utils/format'
|
||||||
|
|
||||||
|
hljs.configure({ ignoreUnescapedHTML: true })
|
||||||
|
|
||||||
|
function highlightCode(el: HTMLElement) {
|
||||||
|
if (includeCode(el.textContent))
|
||||||
|
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
@@ -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
@@ -0,0 +1,8 @@
|
|||||||
|
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
||||||
|
|
||||||
|
export function useBasicLayout() {
|
||||||
|
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||||
|
const isMobile = breakpoints.smaller('sm')
|
||||||
|
|
||||||
|
return { isMobile }
|
||||||
|
}
|
||||||
43
src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import type { GlobalThemeOverrides } from 'naive-ui'
|
||||||
|
import { computed, watch } from 'vue'
|
||||||
|
import { darkTheme, useOsTheme } from 'naive-ui'
|
||||||
|
import { useAppStore } from '@/store'
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const appStore = useAppStore()
|
||||||
|
|
||||||
|
const OsTheme = useOsTheme()
|
||||||
|
|
||||||
|
const isDark = computed(() => {
|
||||||
|
if (appStore.theme === 'auto')
|
||||||
|
return OsTheme.value === 'dark'
|
||||||
|
else
|
||||||
|
return appStore.theme === 'dark'
|
||||||
|
})
|
||||||
|
|
||||||
|
const theme = computed(() => {
|
||||||
|
return isDark.value ? darkTheme : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const themeOverrides = computed<GlobalThemeOverrides>(() => {
|
||||||
|
if (isDark.value) {
|
||||||
|
return {
|
||||||
|
common: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => isDark.value,
|
||||||
|
(dark) => {
|
||||||
|
if (dark)
|
||||||
|
document.documentElement.classList.add('dark')
|
||||||
|
else
|
||||||
|
document.documentElement.classList.remove('dark')
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
return { theme, themeOverrides }
|
||||||
|
}
|
||||||
21
src/main.ts
@@ -1,21 +1,20 @@
|
|||||||
import './styles/global.css'
|
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
import { setupDirectives } from './directives'
|
||||||
|
import { setupAssets } from '@/plugins'
|
||||||
|
import { setupStore } from '@/store'
|
||||||
import { setupRouter } from '@/router'
|
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 */
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
naiveStyleOverride()
|
setupAssets()
|
||||||
|
|
||||||
|
setupStore(app)
|
||||||
|
|
||||||
|
setupDirectives(app)
|
||||||
|
|
||||||
await setupRouter(app)
|
await setupRouter(app)
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
17
src/plugins/assets.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import '@/styles/lib/tailwind.css'
|
||||||
|
import '@/styles/lib/highlight.less'
|
||||||
|
import '@/styles/lib/github-markdown.less'
|
||||||
|
import '@/styles/global.less'
|
||||||
|
|
||||||
|
/** Tailwind's Preflight Style Override */
|
||||||
|
function naiveStyleOverride() {
|
||||||
|
const meta = document.createElement('meta')
|
||||||
|
meta.name = 'naive-ui-style'
|
||||||
|
document.head.appendChild(meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupAssets() {
|
||||||
|
naiveStyleOverride()
|
||||||
|
}
|
||||||
|
|
||||||
|
export default setupAssets
|
||||||
3
src/plugins/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import setupAssets from './assets'
|
||||||
|
|
||||||
|
export { setupAssets }
|
||||||
@@ -1,12 +1,21 @@
|
|||||||
import type { App } from 'vue'
|
import type { App } from 'vue'
|
||||||
import type { RouteRecordRaw } from 'vue-router'
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
|
import { ChatLayout } from '@/views/chat/layout'
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
name: 'Home',
|
name: 'Root',
|
||||||
component: () => import('@/views/home/index.vue'),
|
component: ChatLayout,
|
||||||
|
redirect: '/chat',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '/chat/:uuid?',
|
||||||
|
name: 'Chat',
|
||||||
|
component: () => import('@/views/chat/index.vue'),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
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'
|
||||||
23
src/store/modules/app/helper.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { ss } from '@/utils/storage'
|
||||||
|
|
||||||
|
const LOCAL_NAME = 'appSetting'
|
||||||
|
|
||||||
|
export type Theme = 'light' | 'dark' | 'auto'
|
||||||
|
|
||||||
|
export interface AppState {
|
||||||
|
siderCollapsed: boolean
|
||||||
|
theme: Theme
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultSetting(): AppState {
|
||||||
|
return { siderCollapsed: false, theme: 'light' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocalSetting(): AppState {
|
||||||
|
const localSetting: AppState | undefined = ss.get(LOCAL_NAME)
|
||||||
|
return { ...defaultSetting(), ...localSetting }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLocalSetting(setting: AppState): void {
|
||||||
|
ss.set(LOCAL_NAME, setting)
|
||||||
|
}
|
||||||
22
src/store/modules/app/index.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import type { AppState, Theme } from './helper'
|
||||||
|
import { getLocalSetting, setLocalSetting } from './helper'
|
||||||
|
|
||||||
|
export const useAppStore = defineStore('app-store', {
|
||||||
|
state: (): AppState => getLocalSetting(),
|
||||||
|
actions: {
|
||||||
|
setSiderCollapsed(collapsed: boolean) {
|
||||||
|
this.siderCollapsed = collapsed
|
||||||
|
this.recordState()
|
||||||
|
},
|
||||||
|
|
||||||
|
setTheme(theme: Theme) {
|
||||||
|
this.theme = theme
|
||||||
|
this.recordState()
|
||||||
|
},
|
||||||
|
|
||||||
|
recordState() {
|
||||||
|
setLocalSetting(this.$state)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
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)
|
||||||
|
}
|
||||||
173
src/store/modules/chat/index.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { getLocalState, setLocalState } from './helper'
|
||||||
|
import { router } from '@/router'
|
||||||
|
|
||||||
|
export const useChatStore = defineStore('chat-store', {
|
||||||
|
state: (): Chat.ChatState => getLocalState(),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
getChatHistoryByCurrentActive(state: Chat.ChatState) {
|
||||||
|
const index = state.history.findIndex(item => item.uuid === state.active)
|
||||||
|
if (index !== -1)
|
||||||
|
return state.history[index]
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
|
||||||
|
getChatByUuid(state: Chat.ChatState) {
|
||||||
|
return (uuid?: number) => {
|
||||||
|
if (uuid)
|
||||||
|
return state.chat.find(item => item.uuid === uuid)?.data ?? []
|
||||||
|
return state.chat.find(item => item.uuid === state.active)?.data ?? []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
addHistory(history: Chat.History, chatData: Chat.Chat[] = []) {
|
||||||
|
this.history.unshift(history)
|
||||||
|
this.chat.unshift({ uuid: history.uuid, data: chatData })
|
||||||
|
this.active = history.uuid
|
||||||
|
this.reloadRoute(history.uuid)
|
||||||
|
},
|
||||||
|
|
||||||
|
updateHistory(uuid: number, edit: Partial<Chat.History>) {
|
||||||
|
const index = this.history.findIndex(item => item.uuid === uuid)
|
||||||
|
if (index !== -1) {
|
||||||
|
this.history[index] = { ...this.history[index], ...edit }
|
||||||
|
this.recordState()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteHistory(index: number) {
|
||||||
|
this.history.splice(index, 1)
|
||||||
|
this.chat.splice(index, 1)
|
||||||
|
|
||||||
|
if (this.history.length === 0) {
|
||||||
|
this.active = null
|
||||||
|
this.reloadRoute()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index > 0 && index <= this.history.length) {
|
||||||
|
const uuid = this.history[index - 1].uuid
|
||||||
|
this.active = uuid
|
||||||
|
this.reloadRoute(uuid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index === 0) {
|
||||||
|
if (this.history.length > 0) {
|
||||||
|
const uuid = this.history[0].uuid
|
||||||
|
this.active = uuid
|
||||||
|
this.reloadRoute(uuid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index > this.history.length) {
|
||||||
|
const uuid = this.history[this.history.length - 1].uuid
|
||||||
|
this.active = uuid
|
||||||
|
this.reloadRoute(uuid)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async setActive(uuid: number) {
|
||||||
|
this.active = uuid
|
||||||
|
return await this.reloadRoute(uuid)
|
||||||
|
},
|
||||||
|
|
||||||
|
getChatByUuidAndIndex(uuid: number, index: number) {
|
||||||
|
if (!uuid || uuid === 0) {
|
||||||
|
if (this.chat.length)
|
||||||
|
return this.chat[0].data[index]
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const chatIndex = this.chat.findIndex(item => item.uuid === uuid)
|
||||||
|
if (chatIndex !== -1)
|
||||||
|
return this.chat[chatIndex].data[index]
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
|
||||||
|
addChatByUuid(uuid: number, chat: Chat.Chat) {
|
||||||
|
if (!uuid || uuid === 0) {
|
||||||
|
if (this.history.length === 0) {
|
||||||
|
const uuid = Date.now()
|
||||||
|
this.history.push({ uuid, title: chat.text, isEdit: false })
|
||||||
|
this.chat.push({ uuid, data: [chat] })
|
||||||
|
this.active = uuid
|
||||||
|
this.recordState()
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.chat[0].data.push(chat)
|
||||||
|
if (this.history[0].title === 'New Chat')
|
||||||
|
this.history[0].title = chat.text
|
||||||
|
this.recordState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = this.chat.findIndex(item => item.uuid === uuid)
|
||||||
|
if (index !== -1) {
|
||||||
|
this.chat[index].data.push(chat)
|
||||||
|
if (this.history[index].title === 'New Chat')
|
||||||
|
this.history[index].title = chat.text
|
||||||
|
this.recordState()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateChatByUuid(uuid: number, index: number, chat: Chat.Chat) {
|
||||||
|
if (!uuid || uuid === 0) {
|
||||||
|
if (this.chat.length) {
|
||||||
|
this.chat[0].data[index] = chat
|
||||||
|
this.recordState()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatIndex = this.chat.findIndex(item => item.uuid === uuid)
|
||||||
|
if (chatIndex !== -1) {
|
||||||
|
this.chat[chatIndex].data[index] = chat
|
||||||
|
this.recordState()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteChatByUuid(uuid: number, index: number) {
|
||||||
|
if (!uuid || uuid === 0) {
|
||||||
|
if (this.chat.length) {
|
||||||
|
this.chat[0].data.splice(index, 1)
|
||||||
|
this.recordState()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatIndex = this.chat.findIndex(item => item.uuid === uuid)
|
||||||
|
if (chatIndex !== -1) {
|
||||||
|
this.chat[chatIndex].data.splice(index, 1)
|
||||||
|
this.recordState()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearChatByUuid(uuid: number) {
|
||||||
|
if (!uuid || uuid === 0) {
|
||||||
|
if (this.chat.length) {
|
||||||
|
this.chat[0].data = []
|
||||||
|
this.recordState()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = this.chat.findIndex(item => item.uuid === uuid)
|
||||||
|
if (index !== -1) {
|
||||||
|
this.chat[index].data = []
|
||||||
|
this.recordState()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async reloadRoute(uuid?: number) {
|
||||||
|
this.recordState()
|
||||||
|
await router.push({ name: 'Chat', params: { uuid } })
|
||||||
|
},
|
||||||
|
|
||||||
|
recordState() {
|
||||||
|
setLocalState(this.$state)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
2
src/store/modules/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './app'
|
||||||
|
export * from './chat'
|
||||||
5
src/styles/global.less
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
1102
src/styles/lib/github-markdown.less
Normal file
203
src/styles/lib/highlight.less
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
html.dark {
|
||||||
|
pre code.hljs {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 1em
|
||||||
|
}
|
||||||
|
|
||||||
|
code.hljs {
|
||||||
|
padding: 3px 5px
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs {
|
||||||
|
color: #abb2bf;
|
||||||
|
background: #282c34
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-keyword,
|
||||||
|
.hljs-operator,
|
||||||
|
.hljs-pattern-match {
|
||||||
|
color: #f92672
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-function,
|
||||||
|
.hljs-pattern-match .hljs-constructor {
|
||||||
|
color: #61aeee
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-function .hljs-params {
|
||||||
|
color: #a6e22e
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-function .hljs-params .hljs-typing {
|
||||||
|
color: #fd971f
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-module-access .hljs-module {
|
||||||
|
color: #7e57c2
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-constructor {
|
||||||
|
color: #e2b93d
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-constructor .hljs-string {
|
||||||
|
color: #9ccc65
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-comment,
|
||||||
|
.hljs-quote {
|
||||||
|
color: #b18eb1;
|
||||||
|
font-style: italic
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-doctag,
|
||||||
|
.hljs-formula {
|
||||||
|
color: #c678dd
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-deletion,
|
||||||
|
.hljs-name,
|
||||||
|
.hljs-section,
|
||||||
|
.hljs-selector-tag,
|
||||||
|
.hljs-subst {
|
||||||
|
color: #e06c75
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-literal {
|
||||||
|
color: #56b6c2
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-addition,
|
||||||
|
.hljs-attribute,
|
||||||
|
.hljs-meta .hljs-string,
|
||||||
|
.hljs-regexp,
|
||||||
|
.hljs-string {
|
||||||
|
color: #98c379
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-built_in,
|
||||||
|
.hljs-class .hljs-title,
|
||||||
|
.hljs-title.class_ {
|
||||||
|
color: #e6c07b
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-attr,
|
||||||
|
.hljs-number,
|
||||||
|
.hljs-selector-attr,
|
||||||
|
.hljs-selector-class,
|
||||||
|
.hljs-selector-pseudo,
|
||||||
|
.hljs-template-variable,
|
||||||
|
.hljs-type,
|
||||||
|
.hljs-variable {
|
||||||
|
color: #d19a66
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-bullet,
|
||||||
|
.hljs-link,
|
||||||
|
.hljs-meta,
|
||||||
|
.hljs-selector-id,
|
||||||
|
.hljs-symbol,
|
||||||
|
.hljs-title {
|
||||||
|
color: #61aeee
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-emphasis {
|
||||||
|
font-style: italic
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-strong {
|
||||||
|
font-weight: 700
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-link {
|
||||||
|
text-decoration: underline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
pre code.hljs {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 1em
|
||||||
|
}
|
||||||
|
|
||||||
|
code.hljs {
|
||||||
|
padding: 3px 5px
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs {
|
||||||
|
color: #383a42;
|
||||||
|
background: #fafafa
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-comment,
|
||||||
|
.hljs-quote {
|
||||||
|
color: #a0a1a7;
|
||||||
|
font-style: italic
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-doctag,
|
||||||
|
.hljs-formula,
|
||||||
|
.hljs-keyword {
|
||||||
|
color: #a626a4
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-deletion,
|
||||||
|
.hljs-name,
|
||||||
|
.hljs-section,
|
||||||
|
.hljs-selector-tag,
|
||||||
|
.hljs-subst {
|
||||||
|
color: #e45649
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-literal {
|
||||||
|
color: #0184bb
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-addition,
|
||||||
|
.hljs-attribute,
|
||||||
|
.hljs-meta .hljs-string,
|
||||||
|
.hljs-regexp,
|
||||||
|
.hljs-string {
|
||||||
|
color: #50a14f
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-attr,
|
||||||
|
.hljs-number,
|
||||||
|
.hljs-selector-attr,
|
||||||
|
.hljs-selector-class,
|
||||||
|
.hljs-selector-pseudo,
|
||||||
|
.hljs-template-variable,
|
||||||
|
.hljs-type,
|
||||||
|
.hljs-variable {
|
||||||
|
color: #986801
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-bullet,
|
||||||
|
.hljs-link,
|
||||||
|
.hljs-meta,
|
||||||
|
.hljs-selector-id,
|
||||||
|
.hljs-symbol,
|
||||||
|
.hljs-title {
|
||||||
|
color: #4078f2
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-built_in,
|
||||||
|
.hljs-class .hljs-title,
|
||||||
|
.hljs-title.class_ {
|
||||||
|
color: #c18401
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-emphasis {
|
||||||
|
font-style: italic
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-strong {
|
||||||
|
font-weight: 700
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-link {
|
||||||
|
text-decoration: underline
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,3 @@
|
|||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
#app {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/typings/env.d.ts
vendored
@@ -1,6 +1,7 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
/** api url */
|
|
||||||
readonly VITE_GLOB_API_URL: string;
|
readonly VITE_GLOB_API_URL: string;
|
||||||
|
readonly VITE_GLOB_API_TIMEOUT: string;
|
||||||
|
readonly VITE_APP_API_BASE_URL: string;
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
15
src/utils/format/index.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// 转义 HTML 字符
|
||||||
|
export function encodeHTML(source: string) {
|
||||||
|
return source
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断是否为代码块
|
||||||
|
export function includeCode(text: string | null | undefined) {
|
||||||
|
const regexp = /^(?:\s{4}|\t).+/gm
|
||||||
|
return !!(text?.includes(' = ') || text?.match(regexp))
|
||||||
|
}
|
||||||
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]'
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import axios, { type AxiosResponse } from 'axios'
|
|||||||
|
|
||||||
const service = axios.create({
|
const service = axios.create({
|
||||||
baseURL: import.meta.env.VITE_GLOB_API_URL,
|
baseURL: import.meta.env.VITE_GLOB_API_URL,
|
||||||
timeout: 10 * 1000,
|
timeout: !isNaN(+import.meta.env.VITE_GLOB_API_TIMEOUT) ? Number(import.meta.env.VITE_GLOB_API_TIMEOUT) : 60 * 1000,
|
||||||
})
|
})
|
||||||
|
|
||||||
service.interceptors.request.use(
|
service.interceptors.request.use(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AxiosResponse } from 'axios'
|
import type { AxiosProgressEvent, AxiosResponse, GenericAbortSignal } from 'axios'
|
||||||
import request from './axios'
|
import request from './axios'
|
||||||
|
|
||||||
export interface HttpOption {
|
export interface HttpOption {
|
||||||
@@ -6,23 +6,23 @@ export interface HttpOption {
|
|||||||
data?: any
|
data?: any
|
||||||
method?: string
|
method?: string
|
||||||
headers?: any
|
headers?: any
|
||||||
|
onDownloadProgress?: (progressEvent: AxiosProgressEvent) => void
|
||||||
|
signal?: GenericAbortSignal
|
||||||
beforeRequest?: () => void
|
beforeRequest?: () => void
|
||||||
afterRequest?: () => void
|
afterRequest?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExtraOption {
|
|
||||||
notification?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Response<T = any> {
|
export interface Response<T = any> {
|
||||||
data: T
|
data: T
|
||||||
message: string | null
|
message: string | null
|
||||||
status: string
|
status: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function http<T = any>({ url, data, method, headers, beforeRequest, afterRequest }: HttpOption) {
|
function http<T = any>(
|
||||||
|
{ url, data, method, headers, onDownloadProgress, signal, beforeRequest, afterRequest }: HttpOption,
|
||||||
|
) {
|
||||||
const successHandler = (res: AxiosResponse<Response<T>>) => {
|
const successHandler = (res: AxiosResponse<Response<T>>) => {
|
||||||
if (res.data.status === 'Success')
|
if (res.data.status === 'Success' || typeof res.data === 'string')
|
||||||
return res.data
|
return res.data
|
||||||
|
|
||||||
return Promise.reject(res.data)
|
return Promise.reject(res.data)
|
||||||
@@ -40,30 +40,34 @@ function http<T = any>({ url, data, method, headers, beforeRequest, afterRequest
|
|||||||
const params = Object.assign(typeof data === 'function' ? data() : data ?? {}, {})
|
const params = Object.assign(typeof data === 'function' ? data() : data ?? {}, {})
|
||||||
|
|
||||||
return method === 'GET'
|
return method === 'GET'
|
||||||
? request.get(url, { params }).then(successHandler, failHandler)
|
? request.get(url, { params, signal, onDownloadProgress }).then(successHandler, failHandler)
|
||||||
: request.post(url, params, { headers }).then(successHandler, failHandler)
|
: request.post(url, params, { headers, signal, onDownloadProgress }).then(successHandler, failHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function get<T = any>(
|
export function get<T = any>(
|
||||||
{ url, data, method = 'GET', beforeRequest, afterRequest }: HttpOption,
|
{ url, data, method = 'GET', onDownloadProgress, signal, beforeRequest, afterRequest }: HttpOption,
|
||||||
): Promise<Response<T>> {
|
): Promise<Response<T>> {
|
||||||
return http<T>({
|
return http<T>({
|
||||||
url,
|
url,
|
||||||
method,
|
method,
|
||||||
data,
|
data,
|
||||||
|
onDownloadProgress,
|
||||||
|
signal,
|
||||||
beforeRequest,
|
beforeRequest,
|
||||||
afterRequest,
|
afterRequest,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function post<T = any>(
|
export function post<T = any>(
|
||||||
{ url, data, method = 'POST', headers, beforeRequest, afterRequest }: HttpOption,
|
{ url, data, method = 'POST', headers, onDownloadProgress, signal, beforeRequest, afterRequest }: HttpOption,
|
||||||
): Promise<Response<T>> {
|
): Promise<Response<T>> {
|
||||||
return http<T>({
|
return http<T>({
|
||||||
url,
|
url,
|
||||||
method,
|
method,
|
||||||
data,
|
data,
|
||||||
headers,
|
headers,
|
||||||
|
onDownloadProgress,
|
||||||
|
signal,
|
||||||
beforeRequest,
|
beforeRequest,
|
||||||
afterRequest,
|
afterRequest,
|
||||||
})
|
})
|
||||||
|
|||||||
1
src/utils/storage/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './local'
|
||||||
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 })
|
||||||
@@ -8,9 +8,9 @@ defineProps<Props>()
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<img v-if="image" src="@/assets/avatar.jpg" class="object-cover w-full h-full " alt="avatar">
|
<img v-if="image" src="@/assets/avatar.jpg" class="object-cover w-full h-full " alt="avatar">
|
||||||
<span v-else class="text-[27px]">
|
<span v-else class="text-[27px] dark:text-white">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" aria-hidden="true" width="1em" height="1em">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" aria-hidden="true" width="1em" height="1em">
|
||||||
<path d="M29.71,13.09A8.09,8.09,0,0,0,20.34,2.68a8.08,8.08,0,0,0-13.7,2.9A8.08,8.08,0,0,0,2.3,18.9,8,8,0,0,0,3,25.45a8.08,8.08,0,0,0,8.69,3.87,8,8,0,0,0,6,2.68,8.09,8.09,0,0,0,7.7-5.61,8,8,0,0,0,5.33-3.86A8.09,8.09,0,0,0,29.71,13.09Zm-12,16.82a6,6,0,0,1-3.84-1.39l.19-.11,6.37-3.68a1,1,0,0,0,.53-.91v-9l2.69,1.56a.08.08,0,0,1,.05.07v7.44A6,6,0,0,1,17.68,29.91ZM4.8,24.41a6,6,0,0,1-.71-4l.19.11,6.37,3.68a1,1,0,0,0,1,0l7.79-4.49V22.8a.09.09,0,0,1,0,.08L13,26.6A6,6,0,0,1,4.8,24.41ZM3.12,10.53A6,6,0,0,1,6.28,7.9v7.57a1,1,0,0,0,.51.9l7.75,4.47L11.85,22.4a.14.14,0,0,1-.09,0L5.32,18.68a6,6,0,0,1-2.2-8.18Zm22.13,5.14-7.78-4.52L20.16,9.6a.08.08,0,0,1,.09,0l6.44,3.72a6,6,0,0,1-.9,10.81V16.56A1.06,1.06,0,0,0,25.25,15.67Zm2.68-4-.19-.12-6.36-3.7a1,1,0,0,0-1.05,0l-7.78,4.49V9.2a.09.09,0,0,1,0-.09L19,5.4a6,6,0,0,1,8.91,6.21ZM11.08,17.15,8.38,15.6a.14.14,0,0,1-.05-.08V8.1a6,6,0,0,1,9.84-4.61L18,3.6,11.61,7.28a1,1,0,0,0-.53.91ZM12.54,14,16,12l3.47,2v4L16,20l-3.47-2Z" />
|
<path d="M29.71,13.09A8.09,8.09,0,0,0,20.34,2.68a8.08,8.08,0,0,0-13.7,2.9A8.08,8.08,0,0,0,2.3,18.9,8,8,0,0,0,3,25.45a8.08,8.08,0,0,0,8.69,3.87,8,8,0,0,0,6,2.68,8.09,8.09,0,0,0,7.7-5.61,8,8,0,0,0,5.33-3.86A8.09,8.09,0,0,0,29.71,13.09Zm-12,16.82a6,6,0,0,1-3.84-1.39l.19-.11,6.37-3.68a1,1,0,0,0,.53-.91v-9l2.69,1.56a.08.08,0,0,1,.05.07v7.44A6,6,0,0,1,17.68,29.91ZM4.8,24.41a6,6,0,0,1-.71-4l.19.11,6.37,3.68a1,1,0,0,0,1,0l7.79-4.49V22.8a.09.09,0,0,1,0,.08L13,26.6A6,6,0,0,1,4.8,24.41ZM3.12,10.53A6,6,0,0,1,6.28,7.9v7.57a1,1,0,0,0,.51.9l7.75,4.47L11.85,22.4a.14.14,0,0,1-.09,0L5.32,18.68a6,6,0,0,1-2.2-8.18Zm22.13,5.14-7.78-4.52L20.16,9.6a.08.08,0,0,1,.09,0l6.44,3.72a6,6,0,0,1-.9,10.81V16.56A1.06,1.06,0,0,0,25.25,15.67Zm2.68-4-.19-.12-6.36-3.7a1,1,0,0,0-1.05,0l-7.78,4.49V9.2a.09.09,0,0,1,0-.09L19,5.4a6,6,0,0,1,8.91,6.21ZM11.08,17.15,8.38,15.6a.14.14,0,0,1-.05-.08V8.1a6,6,0,0,1,9.84-4.61L18,3.6,11.61,7.28a1,1,0,0,0-.53.91ZM12.54,14,16,12l3.47,2v4L16,20l-3.47-2Z" fill="currentColor" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
75
src/views/chat/components/Message/Text.vue
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { marked } from 'marked'
|
||||||
|
import hljs from 'highlight.js'
|
||||||
|
import { useBasicLayout } from '@/hooks/useBasicLayout'
|
||||||
|
import { encodeHTML } from '@/utils/format'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
inversion?: boolean
|
||||||
|
error?: boolean
|
||||||
|
text?: string
|
||||||
|
loading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const { isMobile } = useBasicLayout()
|
||||||
|
|
||||||
|
const renderer = new marked.Renderer()
|
||||||
|
|
||||||
|
renderer.html = (html) => {
|
||||||
|
return `<p>${encodeHTML(html)}</p>`
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.code = (code, language) => {
|
||||||
|
const validLang = !!(language && hljs.getLanguage(language))
|
||||||
|
if (validLang)
|
||||||
|
return `<pre><code class="hljs ${language}">${hljs.highlight(language, code).value}</code></pre>`
|
||||||
|
return `<pre style="background: none">${hljs.highlightAuto(code).value}</pre>`
|
||||||
|
}
|
||||||
|
|
||||||
|
marked.setOptions({
|
||||||
|
renderer,
|
||||||
|
highlight(code) {
|
||||||
|
return hljs.highlightAuto(code).value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapClass = computed(() => {
|
||||||
|
return [
|
||||||
|
'text-wrap',
|
||||||
|
'min-w-[20px]',
|
||||||
|
'rounded-md',
|
||||||
|
isMobile.value ? 'p-2' : 'p-3',
|
||||||
|
props.inversion ? 'bg-[#d2f9d1]' : 'bg-[#f4f6f8]',
|
||||||
|
props.inversion ? 'dark:bg-[#a1dc95]' : 'dark:bg-[#1e1e20]',
|
||||||
|
{ 'text-red-500': props.error },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const text = computed(() => {
|
||||||
|
const value = props.text ?? ''
|
||||||
|
if (!props.inversion)
|
||||||
|
return marked(value)
|
||||||
|
return value
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="text-black" :class="wrapClass">
|
||||||
|
<template v-if="loading">
|
||||||
|
<span class="dark:text-white w-[4px] h-[20px] block animate-blink" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="leading-relaxed break-all">
|
||||||
|
<div v-if="!inversion" class="markdown-body" v-html="text" />
|
||||||
|
<div v-else class="whitespace-pre-wrap" v-text="text" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less">
|
||||||
|
@import url(./style.less);
|
||||||
|
</style>
|
||||||
72
src/views/chat/components/Message/index.vue
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<script setup lang='ts'>
|
||||||
|
import AvatarComponent from './Avatar.vue'
|
||||||
|
import TextComponent 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
|
||||||
|
(ev: 'delete'): void
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
|
||||||
|
const emit = defineEmits<Emit>()
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
emit('delete')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRegenerate() {
|
||||||
|
emit('regenerate')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full mb-6 overflow-hidden" :class="[{ 'flex-row-reverse': inversion }]">
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center flex-shrink-0 h-8 overflow-hidden rounded-full basis-8"
|
||||||
|
:class="[inversion ? 'ml-2' : 'mr-2']"
|
||||||
|
>
|
||||||
|
<AvatarComponent :image="inversion" />
|
||||||
|
</div>
|
||||||
|
<div class="overflow-hidden text-sm " :class="[inversion ? 'items-end' : 'items-start']">
|
||||||
|
<p class="text-xs text-[#b4bbc4]" :class="[inversion ? 'text-right' : 'text-left']">
|
||||||
|
{{ dateTime }}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="flex items-end gap-2 mt-2"
|
||||||
|
:class="[inversion ? 'flex-row-reverse' : 'flex-row']"
|
||||||
|
>
|
||||||
|
<TextComponent
|
||||||
|
:inversion="inversion"
|
||||||
|
:error="error"
|
||||||
|
:text="text"
|
||||||
|
:loading="loading"
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<button
|
||||||
|
v-if="!inversion"
|
||||||
|
class="mb-2 transition text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200"
|
||||||
|
@click="handleRegenerate"
|
||||||
|
>
|
||||||
|
<SvgIcon icon="ri:restart-line" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="mb-1 transition text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200"
|
||||||
|
@click="handleDelete"
|
||||||
|
>
|
||||||
|
<SvgIcon icon="ri:delete-bin-6-line" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
38
src/views/chat/components/Message/style.less
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
.markdown-body {
|
||||||
|
background-color: transparent;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
p {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol {
|
||||||
|
list-style-type: decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style-type: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code,
|
||||||
|
pre tt {
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight pre,
|
||||||
|
pre {
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
code.hljs{
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark {
|
||||||
|
|
||||||
|
.highlight pre,
|
||||||
|
pre {
|
||||||
|
background-color: #282c34;
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/views/chat/components/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import Message from './Message/index.vue'
|
||||||
|
|
||||||
|
export { Message }
|
||||||
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
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
382
src/views/chat/index.vue
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
<script setup lang='ts'>
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { NButton, NInput, useDialog, useMessage } 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 { fetchChatAPIProcess } from '@/api'
|
||||||
|
|
||||||
|
let controller = new AbortController()
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const ms = useMessage()
|
||||||
|
|
||||||
|
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 {
|
||||||
|
await fetchChatAPIProcess<Chat.ConversationResponse>({
|
||||||
|
prompt: message,
|
||||||
|
options,
|
||||||
|
signal: controller.signal,
|
||||||
|
onDownloadProgress: ({ event }) => {
|
||||||
|
const xhr = event.target
|
||||||
|
const { responseText } = xhr
|
||||||
|
// Always process the final line
|
||||||
|
const lastIndex = responseText.lastIndexOf('\n')
|
||||||
|
let chunk = responseText
|
||||||
|
if (lastIndex !== -1)
|
||||||
|
chunk = responseText.substring(lastIndex)
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(chunk)
|
||||||
|
updateChat(
|
||||||
|
+uuid,
|
||||||
|
dataSources.value.length - 1,
|
||||||
|
{
|
||||||
|
dateTime: new Date().toLocaleString(),
|
||||||
|
text: data.text ?? '',
|
||||||
|
inversion: false,
|
||||||
|
error: false,
|
||||||
|
loading: false,
|
||||||
|
conversationOptions: { conversationId: data.conversationId, parentMessageId: data.id },
|
||||||
|
requestOptions: { prompt: message, options: { ...options } },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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 },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchChatAPIProcess<Chat.ConversationResponse>({
|
||||||
|
prompt: message,
|
||||||
|
options,
|
||||||
|
signal: controller.signal,
|
||||||
|
onDownloadProgress: ({ event }) => {
|
||||||
|
const xhr = event.target
|
||||||
|
const { responseText } = xhr
|
||||||
|
// Always process the final line
|
||||||
|
const lastIndex = responseText.lastIndexOf('\n')
|
||||||
|
let chunk = responseText
|
||||||
|
if (lastIndex !== -1)
|
||||||
|
chunk = responseText.substring(lastIndex)
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(chunk)
|
||||||
|
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 },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
index,
|
||||||
|
{
|
||||||
|
dateTime: new Date().toLocaleString(),
|
||||||
|
text: errorMessage,
|
||||||
|
inversion: false,
|
||||||
|
error: true,
|
||||||
|
loading: false,
|
||||||
|
conversationOptions: null,
|
||||||
|
requestOptions: { prompt: message, ...options },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(index: number) {
|
||||||
|
if (loading.value)
|
||||||
|
return
|
||||||
|
|
||||||
|
dialog.warning({
|
||||||
|
title: 'Delete Message',
|
||||||
|
content: 'Are you sure to delete this message?',
|
||||||
|
positiveText: 'Yes',
|
||||||
|
negativeText: 'No',
|
||||||
|
onPositiveClick: () => {
|
||||||
|
chatStore.deleteChatByUuid(+uuid, index)
|
||||||
|
ms.success('Message deleted successfully.')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (!isMobile.value) {
|
||||||
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
event.preventDefault()
|
||||||
|
handleSubmit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStop() {
|
||||||
|
if (loading.value) {
|
||||||
|
controller.abort()
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholder = computed(() => {
|
||||||
|
if (isMobile.value)
|
||||||
|
return 'Ask me anything...'
|
||||||
|
return 'Ask me anything... (Shift + Enter = line break)'
|
||||||
|
})
|
||||||
|
|
||||||
|
const buttonDisabled = computed(() => {
|
||||||
|
return loading.value || !prompt.value || prompt.value.trim() === ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapClass = computed(() => {
|
||||||
|
if (isMobile.value)
|
||||||
|
return ['pt-14', 'pb-16']
|
||||||
|
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
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" :class="wrapClass">
|
||||||
|
<main class="flex-1 overflow-hidden">
|
||||||
|
<div
|
||||||
|
id="scrollRef"
|
||||||
|
ref="scrollRef"
|
||||||
|
class="h-full overflow-hidden overflow-y-auto"
|
||||||
|
:class="[isMobile ? 'p-2' : 'p-4']"
|
||||||
|
>
|
||||||
|
<template v-if="!dataSources.length">
|
||||||
|
<div class="flex items-center justify-center mt-4 text-center text-neutral-300">
|
||||||
|
<SvgIcon icon="ri:bubble-chart-fill" class="mr-2 text-3xl" />
|
||||||
|
<span>Aha~</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div>
|
||||||
|
<Message
|
||||||
|
v-for="(item, index) of dataSources"
|
||||||
|
:key="index"
|
||||||
|
:date-time="item.dateTime"
|
||||||
|
:text="item.text"
|
||||||
|
:inversion="item.inversion"
|
||||||
|
:error="item.error"
|
||||||
|
:loading="item.loading"
|
||||||
|
@regenerate="onRegenerate(index)"
|
||||||
|
@delete="handleDelete(index)"
|
||||||
|
/>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<NButton v-if="loading" ghost @click="handleStop">
|
||||||
|
<template #icon>
|
||||||
|
<SvgIcon icon="ri:stop-circle-line" />
|
||||||
|
</template>
|
||||||
|
Stop Responding
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</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] dark:text-white">
|
||||||
|
<SvgIcon icon="ri:delete-bin-line" />
|
||||||
|
</span>
|
||||||
|
</HoverButton>
|
||||||
|
<NInput
|
||||||
|
v-model:value="prompt"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 1, maxRows: 2 }"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
@keypress="handleEnter"
|
||||||
|
/>
|
||||||
|
<NButton type="primary" :disabled="buttonDisabled" @click="handleSubmit">
|
||||||
|
<template #icon>
|
||||||
|
<span class="dark:text-black">
|
||||||
|
<SvgIcon icon="ri:send-plane-fill" />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
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', 'dark:border-neutral-800']
|
||||||
|
})
|
||||||
|
|
||||||
|
const getContainerClass = computed(() => {
|
||||||
|
return [
|
||||||
|
'h-full',
|
||||||
|
{ 'pl-[260px]': !isMobile.value && !collapsed.value },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-full dark:bg-[#24272e] transition-all" :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>
|
||||||
55
src/views/chat/layout/header/index.vue
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, nextTick } from 'vue'
|
||||||
|
import { SvgIcon } from '@/components/common'
|
||||||
|
import { useAppStore, useChatStore } from '@/store'
|
||||||
|
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const chatStore = useChatStore()
|
||||||
|
|
||||||
|
const collapsed = computed(() => appStore.siderCollapsed)
|
||||||
|
const currentChatHistory = computed(() => chatStore.getChatHistoryByCurrentActive)
|
||||||
|
|
||||||
|
function handleUpdateCollapsed() {
|
||||||
|
appStore.setSiderCollapsed(!collapsed.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onScrollToTop() {
|
||||||
|
const scrollRef = document.querySelector('#scrollRef')
|
||||||
|
if (scrollRef)
|
||||||
|
nextTick(() => scrollRef.scrollTop = 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onScrollToBottom() {
|
||||||
|
const scrollRef = document.querySelector('#scrollRef')
|
||||||
|
if (scrollRef)
|
||||||
|
nextTick(() => scrollRef.scrollTop = scrollRef.scrollHeight)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header
|
||||||
|
class="fixed top-0 left-0 right-0 z-30 border-b dark:border-neutral-800 bg-white/80 dark:bg-black/20 backdrop-blur"
|
||||||
|
>
|
||||||
|
<div class="relative flex items-center justify-between 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>
|
||||||
|
<h1
|
||||||
|
class="flex-1 px-4 overflow-hidden text-center cursor-pointer select-none text-ellipsis whitespace-nowrap"
|
||||||
|
@dblclick="onScrollToTop"
|
||||||
|
>
|
||||||
|
{{ currentChatHistory?.title ?? '' }}
|
||||||
|
</h1>
|
||||||
|
<button
|
||||||
|
class="flex items-center justify-center w-11 h-11"
|
||||||
|
@click="onScrollToBottom"
|
||||||
|
>
|
||||||
|
<SvgIcon class="text-2xl" icon="ri:arrow-down-s-line" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
3
src/views/chat/layout/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import ChatLayout from './Layout.vue'
|
||||||
|
|
||||||
|
export { ChatLayout }
|
||||||
64
src/views/chat/layout/sider/Footer.vue
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<script setup lang='ts'>
|
||||||
|
import { computed, h, ref } from 'vue'
|
||||||
|
import { NDropdown } from 'naive-ui'
|
||||||
|
import { HoverButton, Setting, SvgIcon, UserAvatar } from '@/components/common'
|
||||||
|
import { useAppStore } from '@/store'
|
||||||
|
|
||||||
|
const appStore = useAppStore()
|
||||||
|
|
||||||
|
const show = ref(false)
|
||||||
|
|
||||||
|
const theme = computed(() => appStore.theme)
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{
|
||||||
|
label: 'Dark',
|
||||||
|
key: 'dark',
|
||||||
|
icon: renderIcon('ri:moon-foggy-line'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Light',
|
||||||
|
key: 'light',
|
||||||
|
icon: renderIcon('ri:sun-foggy-line'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Auto',
|
||||||
|
key: 'auto',
|
||||||
|
icon: renderIcon('ri:contrast-line'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function renderIcon(icon: string) {
|
||||||
|
return () => {
|
||||||
|
return h(SvgIcon, { icon })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleThemeChange(key: 'light' | 'dark' | 'auto') {
|
||||||
|
appStore.setTheme(key)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<footer class="flex items-center justify-between min-w-0 p-4 overflow-hidden border-t dark:border-neutral-800">
|
||||||
|
<UserAvatar />
|
||||||
|
|
||||||
|
<NDropdown :options="options" placement="top" trigger="click" @select="handleThemeChange">
|
||||||
|
<HoverButton>
|
||||||
|
<span class="text-xl text-[#4f555e] dark:text-white">
|
||||||
|
<SvgIcon v-if="theme === 'dark'" icon="ri:moon-foggy-line" />
|
||||||
|
<SvgIcon v-if="theme === 'light'" icon="ri:sun-foggy-line" />
|
||||||
|
<SvgIcon v-if="theme === 'auto'" icon="ri:contrast-line" />
|
||||||
|
</span>
|
||||||
|
</HoverButton>
|
||||||
|
</NDropdown>
|
||||||
|
|
||||||
|
<HoverButton tooltip="Setting" @click="show = true">
|
||||||
|
<span class="text-xl text-[#4f555e] dark:text-white">
|
||||||
|
<SvgIcon icon="ri:settings-4-line" />
|
||||||
|
</span>
|
||||||
|
</HoverButton>
|
||||||
|
|
||||||
|
<Setting v-model:visible="show" />
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
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 dark:border-neutral-800 dark:hover:bg-[#24272e]"
|
||||||
|
:class="isActive(item.uuid) && ['border-[#4b9e5f]', 'bg-neutral-100', 'text-[#4b9e5f]', 'dark:bg-[#24272e]', 'dark:border-[#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>
|
||||||
76
src/views/chat/layout/sider/index.vue
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<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 Footer from './Footer.vue'
|
||||||
|
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 />
|
||||||
|
</div>
|
||||||
|
</NLayoutSider>
|
||||||
|
<template v-if="isMobile">
|
||||||
|
<div v-show="!collapsed" class="fixed inset-0 z-40 bg-black/40" @click="handleUpdateCollapsed" />
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<script setup lang='ts'>
|
|
||||||
import { Chat } from '@/components/business'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="h-full p-4 overflow-hidden">
|
|
||||||
<Chat />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
|
|
||||||
</style>
|
|
||||||
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
|
||||||
@@ -1,11 +1,22 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
darkMode: 'class',
|
||||||
content: [
|
content: [
|
||||||
'./index.html',
|
'./index.html',
|
||||||
'./src/**/*.{vue,js,ts,jsx,tsx}',
|
'./src/**/*.{vue,js,ts,jsx,tsx}',
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {
|
||||||
|
animation: {
|
||||||
|
blink: 'blink 1.2s infinite steps(1, start)',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
blink: {
|
||||||
|
'0%, 100%': { 'background-color': 'currentColor' },
|
||||||
|
'50%': { 'background-color': 'transparent' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
}
|
}
|
||||||
|
|||||||