first commit
This commit is contained in:
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
database.sqlite
|
||||||
|
.idea
|
||||||
|
.env
|
||||||
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
NUXT_OPENAI_API_KEY=YOUR_API_KEY
|
||||||
|
NUXT_OPENAI_MODEL_NAME=text-davinci-003
|
||||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
*.log*
|
||||||
|
.nuxt
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
|
.output
|
||||||
|
.env
|
||||||
|
.idea
|
||||||
|
dist
|
||||||
|
database.sqlite
|
||||||
15
Dockerfile
Normal file
15
Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
FROM node:18-alpine3.16 as builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json yarn.lock ./
|
||||||
|
|
||||||
|
RUN yarn install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN yarn build
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENTRYPOINT ["node", ".output/server/index.mjs"]
|
||||||
38
README.md
Normal file
38
README.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# ChatGPT UI
|
||||||
|
|
||||||
|
A web client for ChatGPT, using OpenAI's API. The implementation of the interface part uses [waylaidwanderer/node-chatgpt-api](https://github.com/waylaidwanderer/node-chatgpt-api)
|
||||||
|
|
||||||
|
This project is based on [nuxt3](https://nuxt.com/docs/getting-started/introduction)
|
||||||
|
|
||||||
|
## Quick start with docker
|
||||||
|
Clone the repository and run:
|
||||||
|
```bash
|
||||||
|
docker-compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
Make sure to install the dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# yarn
|
||||||
|
yarn install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development Server
|
||||||
|
|
||||||
|
Start the development server on http://localhost:3000
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
|
Build the application for production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn build
|
||||||
|
```
|
||||||
192
app.vue
Normal file
192
app.vue
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
<script setup>
|
||||||
|
import { fetchEventSource } from '@microsoft/fetch-event-source'
|
||||||
|
import ApiKeyEditor from "./components/ApiKeyEditor";
|
||||||
|
|
||||||
|
const runtimeConfig = useRuntimeConfig()
|
||||||
|
const fetchingResponse = ref(false)
|
||||||
|
const fetchReply = async (message, parentMessageId) => {
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
try {
|
||||||
|
await fetchEventSource('/api/conversation', {
|
||||||
|
signal: ctrl.signal,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: message,
|
||||||
|
parentMessageId: parentMessageId,
|
||||||
|
conversationId: currentConversation.value.id
|
||||||
|
}),
|
||||||
|
onopen(response) {
|
||||||
|
if (response.status === 200) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to send message. HTTP ${response.status} - ${response.statusText}`);
|
||||||
|
},
|
||||||
|
onclose() {
|
||||||
|
throw new Error(`Failed to send message. Server closed the connection unexpectedly.`);
|
||||||
|
},
|
||||||
|
onerror(err) {
|
||||||
|
throw err;
|
||||||
|
},
|
||||||
|
onmessage(message) {
|
||||||
|
if (message.event === 'error') {
|
||||||
|
throw new Error(JSON.parse(message.data).error);
|
||||||
|
}
|
||||||
|
const { type, data } = JSON.parse(message.data);
|
||||||
|
if (type === 'done') {
|
||||||
|
if (currentConversation.value.id === null) {
|
||||||
|
currentConversation.value.id = data.conversationId
|
||||||
|
}
|
||||||
|
currentConversation.value.messages[currentConversation.value.messages.length - 1].id = data.messageId
|
||||||
|
ctrl.abort();
|
||||||
|
fetchingResponse.value = false
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentConversation.value.messages[currentConversation.value.messages.length - 1].from === 'ai') {
|
||||||
|
currentConversation.value.messages[currentConversation.value.messages.length - 1].message += data
|
||||||
|
} else {
|
||||||
|
currentConversation.value.messages.push({id: null, from: 'ai', message: data})
|
||||||
|
}
|
||||||
|
scrollChatWindow()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
ctrl.abort()
|
||||||
|
showSnackbar(err.message)
|
||||||
|
fetchingResponse.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const theme = ref('light')
|
||||||
|
const toggleTheme = () => {
|
||||||
|
theme.value = theme.value === 'light' ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultConversation = ref({
|
||||||
|
id: null,
|
||||||
|
messages: []
|
||||||
|
})
|
||||||
|
const currentConversation = ref({})
|
||||||
|
|
||||||
|
const grab = ref(null)
|
||||||
|
const scrollChatWindow = () => {
|
||||||
|
grab.value.scrollIntoView({behavior: 'smooth'})
|
||||||
|
}
|
||||||
|
|
||||||
|
const createNewConversation = () => {
|
||||||
|
currentConversation.value = Object.assign(defaultConversation.value, {
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const send = (message) => {
|
||||||
|
fetchingResponse.value = true
|
||||||
|
let parentMessageId = null
|
||||||
|
if (currentConversation.value.messages.length > 0) {
|
||||||
|
const lastMessage = currentConversation.value.messages[currentConversation.value.messages.length - 1]
|
||||||
|
if (lastMessage.from === 'ai' && lastMessage.id !== null) {
|
||||||
|
parentMessageId = lastMessage.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentConversation.value.messages.push({from: 'me', parentMessageId: parentMessageId, message: message})
|
||||||
|
fetchReply(message, parentMessageId)
|
||||||
|
scrollChatWindow()
|
||||||
|
}
|
||||||
|
const stop = () => {
|
||||||
|
ctrl.abort();
|
||||||
|
fetchingResponse.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const snackbar = ref(false)
|
||||||
|
const snackbarText = ref('')
|
||||||
|
const showSnackbar = (text) => {
|
||||||
|
snackbarText.value = text
|
||||||
|
snackbar.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
onNuxtReady(() => {
|
||||||
|
createNewConversation()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-app
|
||||||
|
:theme="theme"
|
||||||
|
>
|
||||||
|
<v-navigation-drawer
|
||||||
|
theme="dark"
|
||||||
|
permanent
|
||||||
|
>
|
||||||
|
<v-list>
|
||||||
|
<ModelNameEditor/>
|
||||||
|
<ApiKeyEditor/>
|
||||||
|
</v-list>
|
||||||
|
|
||||||
|
<template v-slot:append>
|
||||||
|
<v-divider></v-divider>
|
||||||
|
<v-list>
|
||||||
|
<!-- <v-list-item title="Clear conversations"></v-list-item>-->
|
||||||
|
<v-list-item
|
||||||
|
:prepend-icon="theme === 'light' ? 'dark_mode' : 'light_mode'"
|
||||||
|
:title="(theme === 'light' ? 'Dark' : 'Light') + ' mode'"
|
||||||
|
@click="toggleTheme"
|
||||||
|
></v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</template>
|
||||||
|
</v-navigation-drawer>
|
||||||
|
<v-main>
|
||||||
|
<div ref="chatWindow">
|
||||||
|
<v-card
|
||||||
|
rounded="0"
|
||||||
|
elevation="0"
|
||||||
|
v-for="(conversation, index) in currentConversation.messages"
|
||||||
|
:key="index"
|
||||||
|
:variant="conversation.from === 'ai' ? 'tonal' : ''"
|
||||||
|
>
|
||||||
|
<v-container>
|
||||||
|
<v-card-text class="text-caption text-disabled">{{ conversation.from }}</v-card-text>
|
||||||
|
<v-card-text>
|
||||||
|
<MsgContent :content="conversation.message" />
|
||||||
|
</v-card-text>
|
||||||
|
</v-container>
|
||||||
|
<v-divider></v-divider>
|
||||||
|
</v-card>
|
||||||
|
<div ref="grab" class="w-100" style="height: 150px;"></div>
|
||||||
|
</div>
|
||||||
|
<v-container>
|
||||||
|
</v-container>
|
||||||
|
</v-main>
|
||||||
|
<v-footer app class="d-flex flex-column">
|
||||||
|
<div class="px-16 w-100 d-flex align-center">
|
||||||
|
<v-btn
|
||||||
|
v-show="fetchingResponse"
|
||||||
|
icon="close"
|
||||||
|
title="stop"
|
||||||
|
class="mr-3"
|
||||||
|
@click="stop"
|
||||||
|
></v-btn>
|
||||||
|
<MsgEditor :send-message="send" :disabled="fetchingResponse" :loading="fetchingResponse" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-4 py-2 text-disabled text-caption font-weight-light text-center w-100">
|
||||||
|
{{ new Date().getFullYear() }} — {{ runtimeConfig.public.appName }}
|
||||||
|
</div>
|
||||||
|
</v-footer>
|
||||||
|
<v-snackbar
|
||||||
|
v-model="snackbar"
|
||||||
|
multi-line
|
||||||
|
>
|
||||||
|
{{ snackbarText }}
|
||||||
|
|
||||||
|
<template v-slot:actions>
|
||||||
|
<v-btn
|
||||||
|
color="red"
|
||||||
|
variant="text"
|
||||||
|
@click="snackbar = false"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-snackbar>
|
||||||
|
</v-app>
|
||||||
|
</template>
|
||||||
48
components/ApiKeyEditor.vue
Normal file
48
components/ApiKeyEditor.vue
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<template>
|
||||||
|
<v-list-item v-if="showApiKeyEditor">
|
||||||
|
<v-text-field
|
||||||
|
label="Api key"
|
||||||
|
v-model="apiKeyInput"
|
||||||
|
hide-details
|
||||||
|
variant="outlined"
|
||||||
|
></v-text-field>
|
||||||
|
<template v-slot:append>
|
||||||
|
<v-icon icon="done" size="small" @click="submitApiKey"></v-icon>
|
||||||
|
<v-icon icon="close" size="small" @click="showApiKeyEditor = false"></v-icon>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
<v-list-item
|
||||||
|
v-else
|
||||||
|
:title="currentApiKey"
|
||||||
|
subtitle="OpenAI API key"
|
||||||
|
>
|
||||||
|
<template v-slot:append>
|
||||||
|
<v-icon icon="edit" @click="showApiKeyEditor = true"></v-icon>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const { data } = await useFetch('/api/settings/?key=apiKey')
|
||||||
|
const currentApiKey = ref(data.value.data??'Not set yet')
|
||||||
|
const apiKeyInput = ref(currentApiKey.value)
|
||||||
|
const showApiKeyEditor = ref(false)
|
||||||
|
const submitApiKey = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await useFetch('/api/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { key: 'apiKey', value: apiKeyInput.value }
|
||||||
|
})
|
||||||
|
if (data.value.status === 'success') {
|
||||||
|
currentApiKey.value = apiKeyInput.value
|
||||||
|
showApiKeyEditor.value = false
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
48
components/ModelNameEditor.vue
Normal file
48
components/ModelNameEditor.vue
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<template>
|
||||||
|
<v-list-item v-if="showModelNameEditor">
|
||||||
|
<v-text-field
|
||||||
|
label="Model name"
|
||||||
|
v-model="modelNameInput"
|
||||||
|
hide-details
|
||||||
|
variant="outlined"
|
||||||
|
></v-text-field>
|
||||||
|
<template v-slot:append>
|
||||||
|
<v-icon icon="done" size="small" @click="submitModelName"></v-icon>
|
||||||
|
<v-icon icon="close" size="small" @click="showModelNameEditor = false"></v-icon>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
<v-list-item
|
||||||
|
v-else
|
||||||
|
:title="currentModelName"
|
||||||
|
subtitle="Current model"
|
||||||
|
>
|
||||||
|
<template v-slot:append>
|
||||||
|
<v-icon icon="edit" @click="showModelNameEditor = true"></v-icon>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const { data } = await useFetch('/api/settings/?key=modelName')
|
||||||
|
const currentModelName = ref(data.value.data)
|
||||||
|
const modelNameInput = ref(currentModelName.value)
|
||||||
|
const showModelNameEditor = ref(false)
|
||||||
|
const submitModelName = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await useFetch('/api/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { key: 'modelName', value: modelNameInput.value }
|
||||||
|
})
|
||||||
|
if (data.value.status === 'success') {
|
||||||
|
currentModelName.value = modelNameInput.value
|
||||||
|
showModelNameEditor.value = false
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
25
components/MsgContent.vue
Normal file
25
components/MsgContent.vue
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<script setup>
|
||||||
|
import { marked } from "marked"
|
||||||
|
import hljs from "highlight.js"
|
||||||
|
|
||||||
|
marked.setOptions({
|
||||||
|
highlight: function (code, lang) {
|
||||||
|
const language = hljs.getLanguage(lang) ? lang : 'plaintext'
|
||||||
|
return hljs.highlight(code, { language }).value
|
||||||
|
},
|
||||||
|
langPrefix: 'hljs language-', // highlight.js css class prefix
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps(['content'])
|
||||||
|
const contentHtml = computed(() => {
|
||||||
|
return props.content ? marked(props.content) : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-html="contentHtml"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
</template>
|
||||||
56
components/MsgEditor.vue
Normal file
56
components/MsgEditor.vue
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<template>
|
||||||
|
<v-textarea
|
||||||
|
v-model="message"
|
||||||
|
clearable
|
||||||
|
label="Message"
|
||||||
|
placeholder="Type your message here"
|
||||||
|
rows="1"
|
||||||
|
:auto-grow="autoGrow"
|
||||||
|
:disabled="disabled"
|
||||||
|
:loading="loading"
|
||||||
|
hide-details
|
||||||
|
append-inner-icon="send"
|
||||||
|
@keyup.enter="send"
|
||||||
|
@click:append="send"
|
||||||
|
></v-textarea>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: "MsgEditor",
|
||||||
|
props: {
|
||||||
|
sendMessage: Function,
|
||||||
|
disabled: Boolean,
|
||||||
|
loading: Boolean,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
message: "",
|
||||||
|
rows: 1,
|
||||||
|
autoGrow: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
message(val) {
|
||||||
|
const lines = val.split(/\r\n|\r|\n/).length;
|
||||||
|
if (lines > 8) {
|
||||||
|
this.rows = lines;
|
||||||
|
this.autoGrow = false;
|
||||||
|
} else {
|
||||||
|
this.rows = 1;
|
||||||
|
this.autoGrow = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
send() {
|
||||||
|
const msg = this.message
|
||||||
|
this.message = ""
|
||||||
|
this.sendMessage(msg);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
||||||
8
docker-compose.yml
Normal file
8
docker-compose.yml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
version: '3'
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ./Dockerfile
|
||||||
|
ports:
|
||||||
|
- '${APP_PORT:-3000}:3000'
|
||||||
26
nuxt.config.ts
Normal file
26
nuxt.config.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
|
const appName = 'ChatGPT UI'
|
||||||
|
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
dev: false,
|
||||||
|
app: {
|
||||||
|
head: {
|
||||||
|
title: appName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
runtimeConfig: {
|
||||||
|
openaiApiKey: '',
|
||||||
|
openaiModelName: 'text-davinci-003',
|
||||||
|
public: {
|
||||||
|
appName: appName
|
||||||
|
}
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
transpile: ['vuetify']
|
||||||
|
},
|
||||||
|
css: [
|
||||||
|
'vuetify/styles',
|
||||||
|
'material-design-icons-iconfont/dist/material-design-icons.css',
|
||||||
|
'highlight.js/styles/panda-syntax-dark.css',
|
||||||
|
]
|
||||||
|
})
|
||||||
22
package.json
Normal file
22
package.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "nuxt build",
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"generate": "nuxt generate",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"postinstall": "nuxt prepare"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"material-design-icons-iconfont": "^6.7.0",
|
||||||
|
"nuxt": "^3.1.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@keyv/sqlite": "^3.6.4",
|
||||||
|
"@microsoft/fetch-event-source": "^2.0.1",
|
||||||
|
"@waylaidwanderer/chatgpt-api": "^1.12.2",
|
||||||
|
"highlight.js": "^11.7.0",
|
||||||
|
"marked": "^4.2.12",
|
||||||
|
"vuetify": "^3.0.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
21
plugins/vuetify.js
Normal file
21
plugins/vuetify.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { createVuetify } from 'vuetify'
|
||||||
|
import { aliases, md } from 'vuetify/iconsets/md'
|
||||||
|
import * as components from 'vuetify/components'
|
||||||
|
// import * as directives from 'vuetify/directives'
|
||||||
|
|
||||||
|
export default defineNuxtPlugin(nuxtApp => {
|
||||||
|
const vuetify = createVuetify({
|
||||||
|
ssr: true,
|
||||||
|
icons: {
|
||||||
|
defaultSet: 'md',
|
||||||
|
aliases,
|
||||||
|
sets: {
|
||||||
|
md
|
||||||
|
}
|
||||||
|
},
|
||||||
|
components,
|
||||||
|
// directives
|
||||||
|
})
|
||||||
|
|
||||||
|
nuxtApp.vueApp.use(vuetify)
|
||||||
|
})
|
||||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
113
server/api/conversation.post.js
Normal file
113
server/api/conversation.post.js
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import ChatGPTClient from '@waylaidwanderer/chatgpt-api'
|
||||||
|
import { PassThrough } from 'node:stream'
|
||||||
|
import {getSetting, setSetting} from "~/utils/keyv";
|
||||||
|
|
||||||
|
const serializeSSEEvent = (chunk) => {
|
||||||
|
let payload = "";
|
||||||
|
if (chunk.id) {
|
||||||
|
payload += `id: ${chunk.id}\n`;
|
||||||
|
}
|
||||||
|
if (chunk.event) {
|
||||||
|
payload += `event: ${chunk.event}\n`;
|
||||||
|
}
|
||||||
|
if (chunk.data) {
|
||||||
|
payload += `data: ${chunk.data}\n`;
|
||||||
|
}
|
||||||
|
if (chunk.retry) {
|
||||||
|
payload += `retry: ${chunk.retry}\n`;
|
||||||
|
}
|
||||||
|
if (!payload) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
payload += "\n";
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const runtimeConfig = useRuntimeConfig()
|
||||||
|
const body = await readBody(event)
|
||||||
|
const conversationId = body.conversationId ? body.conversationId.toString() : undefined
|
||||||
|
const parentMessageId = body.parentMessageId ? body.parentMessageId.toString() : undefined
|
||||||
|
const tunnel = new PassThrough()
|
||||||
|
const writeToTunnel = (data) => {
|
||||||
|
tunnel.write(serializeSSEEvent(data))
|
||||||
|
}
|
||||||
|
setResponseHeaders(event, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive'
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelName = await getSetting('modelName')
|
||||||
|
const apiKey = await getSetting('apiKey')
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
writeToTunnel({
|
||||||
|
event: 'error',
|
||||||
|
data: JSON.stringify({
|
||||||
|
code: 503,
|
||||||
|
error: 'You haven\'t set the api key of openai',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return sendStream(event, tunnel)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientOptions = {
|
||||||
|
// (Optional) Parameters as described in https://platform.openai.com/docs/api-reference/completions
|
||||||
|
modelOptions: {
|
||||||
|
// The model is set to text-chat-davinci-002-20221122 by default, but you can override
|
||||||
|
// it and any other parameters here
|
||||||
|
model: modelName,
|
||||||
|
},
|
||||||
|
// (Optional) Set custom instructions instead of "You are ChatGPT...".
|
||||||
|
// promptPrefix: 'You are Bob, a cowboy in Western times...',
|
||||||
|
// (Optional) Set a custom name for the user
|
||||||
|
// userLabel: 'User',
|
||||||
|
// (Optional) Set a custom name for ChatGPT
|
||||||
|
// chatGptLabel: 'ChatGPT',
|
||||||
|
// (Optional) Set to true to enable `console.debug()` logging
|
||||||
|
debug: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cacheOptions = {
|
||||||
|
// Options for the Keyv cache, see https://www.npmjs.com/package/keyv
|
||||||
|
// This is used for storing conversations, and supports additional drivers (conversations are stored in memory by default)
|
||||||
|
// For example, to use a JSON file (`npm i keyv-file`) as a database:
|
||||||
|
// store: new KeyvFile({ filename: 'cache.json' }),
|
||||||
|
uri: 'sqlite://database.sqlite'
|
||||||
|
};
|
||||||
|
|
||||||
|
const chatGptClient = new ChatGPTClient(apiKey, clientOptions, cacheOptions);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await chatGptClient.sendMessage(body.message, {
|
||||||
|
conversationId,
|
||||||
|
parentMessageId,
|
||||||
|
onProgress: (token) => {
|
||||||
|
// console.log(token)
|
||||||
|
writeToTunnel({ data: JSON.stringify({
|
||||||
|
type: 'token',
|
||||||
|
data: token
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
writeToTunnel({ data: JSON.stringify({
|
||||||
|
type: 'done',
|
||||||
|
data: response
|
||||||
|
}) })
|
||||||
|
console.log(response)
|
||||||
|
} catch (e) {
|
||||||
|
const code = e?.json?.data?.code || 503;
|
||||||
|
const message = e?.json?.error?.message || 'There was an error communicating with ChatGPT.';
|
||||||
|
writeToTunnel({
|
||||||
|
event: 'error',
|
||||||
|
data: JSON.stringify({
|
||||||
|
code,
|
||||||
|
error: message,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendStream(event, tunnel)
|
||||||
|
})
|
||||||
19
server/api/settings.js
Normal file
19
server/api/settings.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import {getSetting, setSetting} from "~/utils/keyv";
|
||||||
|
import {apiError, apiSuccess} from "~/utils/api";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const runtimeConfig = useRuntimeConfig()
|
||||||
|
const method = getMethod(event)
|
||||||
|
if (method === 'GET') {
|
||||||
|
const query = getQuery(event)
|
||||||
|
let value = await getSetting(query.key)
|
||||||
|
if (!value && query.key === 'modelName') {
|
||||||
|
value = runtimeConfig.openaiModelName
|
||||||
|
}
|
||||||
|
return apiSuccess(value)
|
||||||
|
} else if (method === 'POST') {
|
||||||
|
const body = await readBody(event)
|
||||||
|
await setSetting(body.key, body.value)
|
||||||
|
return apiSuccess()
|
||||||
|
}
|
||||||
|
})
|
||||||
4
tsconfig.json
Normal file
4
tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
// https://nuxt.com/docs/guide/concepts/typescript
|
||||||
|
"extends": "./.nuxt/tsconfig.json"
|
||||||
|
}
|
||||||
16
utils/api.js
Normal file
16
utils/api.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
|
||||||
|
export const apiSuccess = (data) => {
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
status: 'success',
|
||||||
|
data: data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiError = (message) => {
|
||||||
|
return {
|
||||||
|
code: 400,
|
||||||
|
status: 'error',
|
||||||
|
error: message
|
||||||
|
}
|
||||||
|
}
|
||||||
15
utils/keyv.js
Normal file
15
utils/keyv.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import Keyv from 'keyv'
|
||||||
|
|
||||||
|
const cacheOptions = {
|
||||||
|
namespace: 'settings',
|
||||||
|
uri: 'sqlite://database.sqlite',
|
||||||
|
}
|
||||||
|
const cache = new Keyv(cacheOptions);
|
||||||
|
|
||||||
|
export const getSetting = async (key) => {
|
||||||
|
return await cache.get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setSetting = async (key, value) => {
|
||||||
|
return await cache.set(key, value)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user