Merge pull request #10 from Chanzhaoyu/dev

v2.0.0
This commit is contained in:
Redon 2023-02-13 16:33:19 +08:00 committed by GitHub
commit 361494d444
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
63 changed files with 5149 additions and 1120 deletions

3
.commitlintrc.json Normal file
View File

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

3
.env
View File

@ -1,5 +1,2 @@
# OpenAI API Key
OPENAI_API_KEY='xxxx'
# Glob API URL
VITE_GLOB_API_URL='http://localhost:3002'

View File

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

4
.husky/commit-msg Normal file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm exec commitlint --config .commitlintrc.json --edit "${1}"

4
.husky/pre-commit Normal file
View File

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

1
.npmrc Normal file
View File

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

View File

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

View File

@ -1,4 +1,6 @@
{
"prettier.enable": false,
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
@ -21,6 +23,7 @@
"axios",
"bumpp",
"chatgpt",
"commitlint",
"esno",
"GPTAPI",
"iconify",
@ -28,6 +31,7 @@
"OPENAI",
"pinia",
"rushstack",
"Sider",
"tailwindcss",
"unplugin",
"VITE",

View File

@ -1,3 +1,9 @@
## v2.0.0
`2023-02-13`
### Refactor
重构并优化大部分内容
## v1.0.5
`2023-02-12`

View File

@ -1,39 +0,0 @@
# ChatGPT Web Bot
[中文](./README.md) | English
ChartGPT demo page built with express and vue3
![cover](./docs/cover.png)
## Usage
> Make sure `node >= 18`
If pnpm is not installed
```shell
npm install pnpm -g
```
install node deps
```shell
pnpm install
```
Sign up for an [OpenAI API key](https://platform.openai.com/overview) and store it in your environment.
```
# .env
OPENAI_API_KEY="Your Key"
```
Run service
```shell
pnpm run service
```
Run web
```shell
pnpm run dev
```
## License

View File

@ -6,35 +6,98 @@
![cover](./docs/cover.png)
## 使用
> 确保 `node >= 18`
## Route Map
[x] 多会话
[x] 多语言
[x] 主题
[x] 用户模块
[x] More...
## 前置要求
### Node
`node` 版本需要 >= 18使用 [nvm](https://github.com/nvm-sh/nvm) 可管理本地多个 `node` 版本
```shell
node -v
```
### PNPM
如果你没有安装过 `pnpm`
```shell
npm install pnpm -g
```
安装依赖
### OpenAI API KEy
获取 [OpenAI API key](https://platform.openai.com/overview) 并填写到本地环境变量
```
# service/.env 文件
OPENAI_API_KEY='Your key'
```
## 安装依赖
> 为了简便 `后端开发人员` 的了解负担,所以并没有采用前端 `workspace` 模式,而是分文件夹存放。如果只需要前端页面做二次开发,删除 `service` 文件夹即可。
### 后端服务
进入文件夹 `/service` 运行以下命令
```shell
pnpm install
```
获取 [OpenAI API key](https://platform.openai.com/overview) 到本地环境变量
```
# .env
OPENAI_API_KEY="Your Key"
```
运行服务
### 网页
根目录下运行以下命令
```shell
pnpm run service
pnpm bootstrap
```
运行网页
## 运行
### 后端服务
进入文件夹 `/service` 运行以下命令
```shell
pnpm run dev
pnpm start
```
### 网页
根目录下运行以下命令
```shell
pnpm dev
```
## 打包
### 后端服务
> 如果你不需要本项目的 `node` 接口,可以省略如下操作
复制 `service` 文件夹到你有 `node` 服务环境的服务器上。(搜索关键字:`express部署`
操作上述 `安装``运行` 服务。
### 网页
根目录下运行以下命令,然后将 `dist` 文件夹复制到你的托管服务器上
```shell
pnpm build
```
### 常见问题
Q: 如果只使用前端页面,在哪里改请求接口?
A: 根目录下 `.env` 文件中的 `VITE_GLOB_API_URL` 字段。
Q: 文件保存时全部爆红?
A: `vscode` 请安装项目推荐插件,或手动安装 `Eslint` 插件。
## License
MIT © [ChenZhaoYu](./license)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 200 KiB

View File

@ -1,6 +1,6 @@
{
"name": "chatgpt-web",
"version": "1.0.5",
"version": "2.0.0",
"private": false,
"description": "ChatGPT Web Bot",
"author": "ChenZhaoYu <chenzhaoyu1994@gami.com>",
@ -12,38 +12,44 @@
],
"scripts": {
"dev": "vite",
"service": "esno ./service/index.ts",
"build": "run-p type-check build-only",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --noEmit",
"lint": "eslint . --fix --ignore-path .gitignore",
"release": "bumpp package.json --commit --push --tag"
"lint:fix": "eslint . --fix",
"bootstrap": "pnpm install && pnpm run common:prepare",
"common:cleanup": "rimraf node_modules && rimraf pnpm-lock.yaml",
"common:prepare": "husky install"
},
"dependencies": {
"naive-ui": "^2.34.3",
"vue": "^3.2.47"
"vue": "^3.2.47",
"vue-router": "^4.1.6"
},
"devDependencies": {
"@antfu/eslint-config": "^0.35.2",
"@commitlint/cli": "^17.4.2",
"@commitlint/config-conventional": "^17.4.2",
"@iconify/vue": "^4.1.0",
"@types/express": "^4.17.17",
"@types/node": "^18.13.0",
"@vitejs/plugin-vue": "^4.0.0",
"autoprefixer": "^10.4.13",
"axios": "^1.3.2",
"bumpp": "^8.2.1",
"chatgpt": "^4.2.0",
"dotenv": "^16.0.3",
"eslint": "^8.34.0",
"esno": "^0.16.3",
"express": "^4.18.2",
"husky": "^8.0.3",
"lint-staged": "^13.1.1",
"npm-run-all": "^4.1.5",
"postcss": "^8.4.21",
"prettier": "^2.8.4",
"rimraf": "^4.1.2",
"tailwindcss": "^3.2.6",
"typescript": "~4.9.5",
"vite": "^4.1.1",
"vue-tsc": "^1.0.24"
},
"lint-staged": {
"*.{js,ts,jsx,tsx,vue}": [
"pnpm run lint:fix",
"pnpm run type-check"
]
}
}

File diff suppressed because it is too large Load Diff

2
service/.env Normal file
View File

@ -0,0 +1,2 @@
# OpenAI API Key
OPENAI_API_KEY=

4
service/.eslintrc.json Normal file
View File

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

29
service/.gitignore vendored Normal file
View File

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

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

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

22
service/.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,22 @@
{
"prettier.enable": false,
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"eslint.validate": [
"javascript",
"typescript",
"json",
"jsonc",
"json5",
"yaml"
],
"cSpell.words": [
"antfu",
"chatgpt",
"esno",
"GPTAPI",
"OPENAI"
]
}

View File

@ -1,54 +0,0 @@
import dotenv from 'dotenv'
import { ChatGPTAPI } from 'chatgpt'
interface ChatContext {
conversationId?: string
parentMessageId?: string
}
dotenv.config()
const apiKey = process.env.OPENAI_API_KEY
if (apiKey === undefined)
throw new Error('OPENAI_API_KEY is not defined')
const chatContext = new Set<ChatContext>()
/**
* More Info: https://github.com/transitive-bullshit/chatgpt-api
*/
const api = new ChatGPTAPI({ apiKey })
async function chatReply(message: string) {
if (!message)
return
// Get the last context from the chat context
// If there is a last context, add it to the options
let options = {}
const lastContext = Array.from(chatContext).pop()
if (lastContext) {
const { conversationId, parentMessageId } = lastContext
options = { conversationId, parentMessageId }
}
// Send the message to the API
const response = await api.sendMessage(message, { ...options })
const { conversationId, id } = response
// Add the new context to the chat context
if (conversationId && id)
chatContext.add({ conversationId, parentMessageId: id })
return response
}
async function clearChatContext() {
// Clear the chat context
chatContext.clear()
return Promise.resolve({ message: 'Chat context cleared' })
}
export { chatReply, clearChatContext }

View File

@ -1,26 +0,0 @@
import express from 'express'
import { chatReply, clearChatContext } from './chatgpt'
const app = express()
app.use(express.json())
app.all('*', (req, res, next) => {
res.header('Access-Control-Allow-Origin', '*')
res.header('Access-Control-Allow-Headers', 'Content-Type')
res.header('Access-Control-Allow-Methods', '*')
next()
})
app.listen(3002, () => globalThis.console.log('Server is running on port 3002'))
app.post('/chat', async (req, res) => {
const { message } = req.body
const response = await chatReply(message)
res.send(response)
})
app.post('/clear', async (req, res) => {
const response = await clearChatContext()
res.send(response)
})

33
service/package.json Normal file
View File

@ -0,0 +1,33 @@
{
"name": "chatgpt-web-service",
"version": "1.0.0",
"private": false,
"description": "ChatGPT Web Bot node service",
"keywords": [
"chatgpt",
"chatbot",
"web",
"vue"
],
"engines": {
"node": ">=18.0.0"
},
"scripts": {
"start": "esno ./src/index.ts",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
"dependencies": {
"chatgpt": "^4.2.0",
"express": "^4.18.2"
},
"devDependencies": {
"@antfu/eslint-config": "^0.35.2",
"@types/express": "^4.17.17",
"@types/node": "^18.13.0",
"dotenv": "^16.0.3",
"eslint": "^8.34.0",
"esno": "^0.16.3",
"typescript": "^4.9.5"
}
}

3064
service/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

81
service/src/chatgpt.ts Normal file
View File

@ -0,0 +1,81 @@
import * as dotenv from 'dotenv'
import type { SendMessageOptions } from 'chatgpt'
import { ChatGPTAPI } from 'chatgpt'
import { sendResponse } from './utils'
export interface ChatContext {
conversationId?: string
parentMessageId?: string
}
dotenv.config()
const apiKey = process.env.OPENAI_API_KEY
if (apiKey === undefined)
throw new Error('OPENAI_API_KEY is not defined')
const chatContext = new Set<ChatContext>()
/**
* More Info: https://github.com/transitive-bullshit/chatgpt-api
*/
const api = new ChatGPTAPI({ apiKey })
async function chatReply(message: string) {
if (!message)
return sendResponse({ type: 'Fail', message: 'Message is empty' })
try {
// Get the last context from the chat context
let options: SendMessageOptions = {}
const lastContext = Array.from(chatContext).pop()
if (lastContext)
options = { ...lastContext }
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 })
}
catch (error: any) {
global.console.log(error)
return sendResponse({ type: 'Fail', message: error.message })
}
}
async function chatReplayOne(message: string, options?: ChatContext) {
if (!message)
return sendResponse({ type: 'Fail', message: 'Message is empty' })
try {
let messageOptions: SendMessageOptions = {}
if (options) {
const { conversationId, parentMessageId } = options
messageOptions = { conversationId, parentMessageId }
const response = await api.sendMessage(message, { ...messageOptions })
return sendResponse({ type: 'Success', data: response })
}
}
catch (error: any) {
return sendResponse({ type: 'Fail', message: error.message })
}
}
async function clearChatContext() {
// Clear the chat context
chatContext.clear()
return sendResponse({ type: 'Success', message: 'Chat context cleared' })
}
export { chatReply, chatReplayOne, clearChatContext }

43
service/src/index.ts Normal file
View File

@ -0,0 +1,43 @@
import express from 'express'
import type { ChatContext } from './chatgpt'
import { chatReplayOne, chatReply, clearChatContext } from './chatgpt'
const app = express()
app.use(express.json())
app.all('*', (req, res, next) => {
res.header('Access-Control-Allow-Origin', '*')
res.header('Access-Control-Allow-Headers', 'Content-Type')
res.header('Access-Control-Allow-Methods', '*')
next()
})
app.listen(3002, () => globalThis.console.log('Server is running on port 3002'))
app.post('/chat', async (req, res) => {
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 {
const { prompt, options = {} } = req.body as { prompt: string; options?: ChatContext }
const response = await chatReplayOne(prompt, options)
res.send(response)
}
catch (error) {
res.send(error)
}
})
app.post('/clear', async (req, res) => {
const response = await clearChatContext()
res.send(response)
})

View File

@ -0,0 +1,22 @@
interface SendResponseOptions {
type: 'Success' | 'Fail'
message?: string
data?: any
}
export function sendResponse(options: SendResponseOptions) {
if (options.type === 'Success') {
return Promise.resolve({
message: options.message ?? null,
data: options.data ?? null,
status: options.type,
})
}
// eslint-disable-next-line prefer-promise-reject-errors
return Promise.reject({
message: options.message ?? 'Failed',
data: options.data ?? null,
status: options.type,
})
}

View File

@ -1,13 +1,12 @@
<script setup lang="ts">
import { GithubSite, NaiveProvider } from '@/components'
import Chat from '@/views/Chat/index.vue'
import { NConfigProvider } from 'naive-ui'
import { NaiveProvider } from '@/components/common'
</script>
<template>
<NaiveProvider>
<div class="h-full overflow-hidden pb-[50px] p-4">
<Chat />
<GithubSite />
</div>
</NaiveProvider>
<NConfigProvider class="h-full">
<NaiveProvider>
<RouterView />
</NaiveProvider>
</NConfigProvider>
</template>

14
src/api/index.ts Normal file
View File

@ -0,0 +1,14 @@
import { post } from '@/utils/request'
export function fetchChatAPI<T = any>(prompt: string) {
return post<T>({
url: '/chat',
data: { prompt },
})
}
export function clearConversations<T = any>() {
return post<T>({
url: '/clear',
})
}

View File

@ -1,10 +0,0 @@
<template>
<div class="fixed bottom-0 left-0 right-0 p-4 text-sm text-center text-neutral-500">
<span class="text-center">
Made By ChenZhaoYu -
<a href="https://github.com/Chanzhaoyu/chatgpt-bot" target="_blank" class="text-blue-500">
Github
</a>
</span>
</div>
</template>

View File

@ -1,27 +0,0 @@
<script setup lang="ts">
import { defineComponent, h } from 'vue'
import { NConfigProvider, NMessageProvider, useMessage } from 'naive-ui'
function registerNaiveTools() {
window.$message = useMessage()
}
const NaiveProviderContent = defineComponent({
name: 'NaiveProviderContent',
setup() {
registerNaiveTools()
},
render() {
return h('div')
},
})
</script>
<template>
<NConfigProvider class="h-full">
<NMessageProvider>
<slot />
<NaiveProviderContent />
</NMessageProvider>
</NConfigProvider>
</template>

View File

@ -0,0 +1,52 @@
<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>

View File

@ -0,0 +1,16 @@
<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>

View File

@ -6,6 +6,7 @@ interface Props {
message?: string
dateTime?: string
reversal?: boolean
error?: boolean
}
defineProps<Props>()
@ -23,9 +24,9 @@ defineProps<Props>()
<span class="text-xs text-[#b4bbc4]">
{{ dateTime }}
</span>
<div class="p-2 mt-2 rounded-md" :class="[reversal ? 'bg-[#d2f9d1]' : 'bg-[#f4f6f8]']">
<Text>{{ message }}</Text>
</div>
<Text :reversal="reversal" :error="error">
{{ message }}
</Text>
</div>
</div>
</template>

View File

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

View File

@ -0,0 +1,116 @@
<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>

View File

@ -0,0 +1,19 @@
<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>

View File

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

View File

@ -0,0 +1,15 @@
<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>

View File

@ -0,0 +1,14 @@
<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>

View File

@ -0,0 +1,52 @@
<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>

View File

@ -0,0 +1,65 @@
<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>

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,23 @@
<script setup lang='ts'>
import { GithubSite } from '@/components/custom'
</script>
<template>
<div class="flex items-center">
<div class="w-10 h-10 overflow-hidden rounded-full">
<img class="object-cover" src="@/assets/avatar.jpg" alt="avatar">
</div>
<div class="ml-2">
<h2 class="font-bold text-md">
ChenZhaoYu
</h2>
<p class="text-xs text-gray-500">
<GithubSite />
</p>
</div>
</div>
</template>
<style>
</style>

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,8 @@
import './styles/global.css'
import { createApp } from 'vue'
import App from './App.vue'
import './styles/global.css'
import { setupRouter } from '@/router'
/** Tailwind's Preflight Style Override */
function naiveStyleOverride() {
@ -10,9 +12,10 @@ function naiveStyleOverride() {
}
/** Setup */
function bootstrap() {
async function bootstrap() {
const app = createApp(App)
naiveStyleOverride()
await setupRouter(app)
app.mount('#app')
}

22
src/router/index.ts Normal file
View File

@ -0,0 +1,22 @@
import type { App } from 'vue'
import type { RouteRecordRaw } from 'vue-router'
import { createRouter, createWebHashHistory } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'Home',
component: () => import('@/views/home/index.vue'),
},
]
export const router = createRouter({
history: createWebHashHistory(),
routes,
scrollBehavior: () => ({ left: 0, top: 0 }),
})
export async function setupRouter(app: App) {
app.use(router)
await router.isReady()
}

View File

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

View File

@ -0,0 +1,29 @@
import axios, { type AxiosResponse } from 'axios'
const service = axios.create({
baseURL: import.meta.env.VITE_GLOB_API_URL,
timeout: 10 * 1000,
})
service.interceptors.request.use(
(config) => {
return config
},
(error) => {
return Promise.reject(error.response)
},
)
service.interceptors.response.use(
(response: AxiosResponse): AxiosResponse => {
if (response.status === 200)
return response
throw new Error(response.status.toString())
},
(error) => {
return Promise.reject(error)
},
)
export default service

View File

@ -0,0 +1,72 @@
import type { AxiosResponse } from 'axios'
import request from './axios'
export interface HttpOption {
url: string
data?: any
method?: string
headers?: any
beforeRequest?: () => void
afterRequest?: () => void
}
export interface ExtraOption {
notification?: boolean
}
export interface Response<T = any> {
data: T
message: string | null
status: string
}
function http<T = any>({ url, data, method, headers, beforeRequest, afterRequest }: HttpOption) {
const successHandler = (res: AxiosResponse<Response<T>>) => {
if (res.data.status === 'Success')
return res.data
return Promise.reject(res.data)
}
const failHandler = (error: Response<Error>) => {
afterRequest?.()
throw new Error(error?.message || 'Error')
}
beforeRequest?.()
method = method || 'GET'
const params = Object.assign(typeof data === 'function' ? data() : data ?? {}, {})
return method === 'GET'
? request.get(url, { params }).then(successHandler, failHandler)
: request.post(url, params, { headers }).then(successHandler, failHandler)
}
export function get<T = any>(
{ url, data, method = 'GET', beforeRequest, afterRequest }: HttpOption,
): Promise<Response<T>> {
return http<T>({
url,
method,
data,
beforeRequest,
afterRequest,
})
}
export function post<T = any>(
{ url, data, method = 'POST', headers, beforeRequest, afterRequest }: HttpOption,
): Promise<Response<T>> {
return http<T>({
url,
method,
data,
headers,
beforeRequest,
afterRequest,
})
}
export default post

View File

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

View File

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

View File

@ -1,118 +0,0 @@
<script setup lang='ts'>
import { nextTick, onMounted, ref } from 'vue'
import { NButton, NInput, NPopover, useMessage } from 'naive-ui'
import { Message } from './components'
import { clearChatContext, fetchChatAPI } from './request'
import { Icon } from '@/components'
interface ListProps {
dateTime: string
message: string
reversal?: boolean
}
const scrollRef = ref<HTMLDivElement>()
const ms = useMessage()
const prompt = ref('')
const loading = ref(false)
const list = ref<ListProps[]>([])
onMounted(initChat)
function initChat() {
addMessage('Hi, I am ChatGPT, a chatbot based on GPT-3.', false)
}
async function handleClear() {
try {
const { message } = await clearChatContext()
ms.success(message)
list.value = []
setTimeout(initChat, 100)
}
catch (error) {
ms.error('Clear failed, please try again later.')
}
}
function handleEnter(event: KeyboardEvent) {
if (event.key === 'Enter')
handleSubmit()
}
async function handleSubmit() {
const message = prompt.value.trim()
if (!message || !message.length) {
ms.warning('Please enter a message')
return
}
addMessage(message, true)
prompt.value = ''
try {
loading.value = true
const { text } = await fetchChatAPI(message)
addMessage(text, false)
}
catch (error: any) {
addMessage(error.message ?? 'Request failed, please try again later.', false)
}
finally {
loading.value = false
}
}
function addMessage(message: string, reversal = false) {
list.value.push({ dateTime: new Date().toLocaleString(), message, reversal })
nextTick(() => scrollRef.value && (scrollRef.value.scrollTop = scrollRef.value.scrollHeight))
}
</script>
<template>
<div class="flex flex-col h-full overflow-hidden border rounded-md shadow-md">
<header class="flex items-center justify-between p-4">
<h1 class="text-xl font-bold">
ChatGPT Web
</h1>
<div>
<NPopover>
<template #trigger>
<button
class="w-[40px] h-[40px] rounded-full hover:bg-neutral-100 transition flex justify-center items-center"
@click="handleClear"
>
<Icon icon="ri:delete-bin-6-line" />
</button>
</template>
<span>Clear Context</span>
</NPopover>
</div>
</header>
<main class="flex-1 overflow-hidden border-y">
<div ref="scrollRef" class="h-full p-4 overflow-hidden overflow-y-auto">
<div>
<Message
v-for="(item, index) of list" :key="index" :date-time="item.dateTime" :message="item.message"
:reversal="item.reversal"
/>
</div>
</div>
</main>
<footer class="p-4">
<div class="flex items-center justify-between space-x-2">
<NInput v-model:value="prompt" placeholder="Type a message..." @keypress="handleEnter" />
<NButton type="primary" :loading="loading" @click="handleSubmit">
<template #icon>
<Icon icon="ri:send-plane-fill" />
</template>
</NButton>
</div>
</footer>
</div>
</template>

View File

@ -1,41 +0,0 @@
import axios from 'axios'
const BASE_URL = import.meta.env.VITE_GLOB_API_URL
async function fetchChatAPI(message: string) {
if (!message || message.trim() === '')
return
try {
const { status, data } = await axios.post(`${BASE_URL}/chat`, { message })
if (status === 200) {
if (data.text)
return Promise.resolve(data)
if (data.statusText)
return Promise.reject(new Error(data.statusText))
}
return Promise.reject(new Error('Request failed'))
}
catch (error) {
return Promise.reject(error)
}
}
async function clearChatContext() {
try {
const { status, data } = await axios.post(`${BASE_URL}/clear`)
if (status === 200)
return Promise.resolve(data)
return Promise.reject(new Error('Request failed'))
}
catch (error) {
return Promise.reject(error)
}
}
export { fetchChatAPI, clearChatContext }

13
src/views/home/index.vue Normal file
View File

@ -0,0 +1,13 @@
<script setup lang='ts'>
import { Chat } from '@/components/business'
</script>
<template>
<div class="h-full p-4 overflow-hidden">
<Chat />
</div>
</template>
<style>
</style>

View File

@ -1,22 +1,22 @@
{
"compilerOptions": {
"baseUrl": ".",
"module": "ESNext",
"target": "ESNext",
"lib": ["DOM", "ESNext"],
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"jsx": "preserve",
"moduleResolution": "node",
"resolveJsonModule": true,
"noUnusedLocals": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"paths": {
"@/*": ["./src/*"]
},
"types": ["vite/client", "node", "naive-ui/volar"]
},
"exclude": ["node_modules", "dist"]
"compilerOptions": {
"baseUrl": ".",
"module": "ESNext",
"target": "ESNext",
"lib": ["DOM", "ESNext"],
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"jsx": "preserve",
"moduleResolution": "node",
"resolveJsonModule": true,
"noUnusedLocals": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"paths": {
"@/*": ["./src/*"]
},
"types": ["vite/client", "node", "naive-ui/volar"]
},
"exclude": ["node_modules", "dist"]
}