Compare commits

..

7 Commits

Author SHA1 Message Date
Rafi
8ff914582a Fix the bug of conversation title 2023-04-01 21:54:15 +08:00
Rafi
f20a3562f3 Replace blank items in the conversation list with the default title 2023-04-01 21:22:44 +08:00
Rafi
4a1adf6d00 Add reset password function and display current user on the page. 2023-04-01 11:29:24 +08:00
Rafi
ddce1c9721 update readme 2023-03-31 17:37:10 +08:00
Rafi
f67ed7621c Multiple improvements for conversation 2023-03-30 21:45:23 +08:00
Rafi
97649e4bee pass 2023-03-30 18:12:55 +08:00
Rafi
1082da050b rm @vite-pwa/nuxt 2023-03-30 10:37:55 +08:00
15 changed files with 637 additions and 2295 deletions

View File

@@ -105,6 +105,7 @@ services:
image: wongsaang/chatgpt-ui-wsgi-server:latest image: wongsaang/chatgpt-ui-wsgi-server:latest
environment: environment:
- APP_DOMAIN=${APP_DOMAIN:-localhost:9000} # CSRF whitelistAdd the address of your chatgpt-ui-web-server here, default is localhost:9000 - APP_DOMAIN=${APP_DOMAIN:-localhost:9000} # CSRF whitelistAdd the address of your chatgpt-ui-web-server here, default is localhost:9000
- SERVER_WORKERS=3 # Number of gunicorn workers, default is 3
#- DB_URL=postgres://postgres:postgrespw@localhost:49153/chatgpt # If this parameter is not set, the built-in Sqlite will be used by default. It should be noted that if you do not connect to an external database, the data will be lost after the container is destroyed. #- DB_URL=postgres://postgres:postgrespw@localhost:49153/chatgpt # If this parameter is not set, the built-in Sqlite will be used by default. It should be noted that if you do not connect to an external database, the data will be lost after the container is destroyed.
#- OPENAI_API_PROXY=https://openai.proxy.com/v1 # Proxy for https://api.openai.com/v1 #- OPENAI_API_PROXY=https://openai.proxy.com/v1 # Proxy for https://api.openai.com/v1
- DJANGO_SUPERUSER_USERNAME=admin # default superuser name - DJANGO_SUPERUSER_USERNAME=admin # default superuser name

289
components/Conversation.vue Normal file
View File

@@ -0,0 +1,289 @@
<script setup>
import {EventStreamContentType, fetchEventSource} from '@microsoft/fetch-event-source'
import {addConversation} from "../utils/helper";
const { $i18n, $auth } = useNuxtApp()
const runtimeConfig = useRuntimeConfig()
const currentModel = useCurrentModel()
const openaiApiKey = useApiKey()
const fetchingResponse = ref(false)
const messageQueue = []
let isProcessingQueue = false
const props = defineProps({
conversation: {
type: Object,
required: true
}
})
const processMessageQueue = () => {
if (isProcessingQueue || messageQueue.length === 0) {
return
}
if (!props.conversation.messages[props.conversation.messages.length - 1].is_bot) {
props.conversation.messages.push({id: null, is_bot: true, message: ''})
}
isProcessingQueue = true
const nextMessage = messageQueue.shift()
if (runtimeConfig.public.typewriter) {
let wordIndex = 0;
const intervalId = setInterval(() => {
props.conversation.messages[props.conversation.messages.length - 1].message += nextMessage[wordIndex]
wordIndex++
if (wordIndex === nextMessage.length) {
clearInterval(intervalId)
isProcessingQueue = false
processMessageQueue()
}
}, runtimeConfig.public.typewriterDelay)
} else {
props.conversation.messages[props.conversation.messages.length - 1].message += nextMessage
isProcessingQueue = false
processMessageQueue()
}
}
let ctrl
const abortFetch = () => {
if (ctrl) {
ctrl.abort()
}
fetchingResponse.value = false
}
const fetchReply = async (message) => {
ctrl = new AbortController()
let webSearchParams = {}
if (enableWebSearch.value) {
webSearchParams['web_search'] = {
ua: navigator.userAgent,
default_prompt: $i18n.t('webSearchDefaultPrompt')
}
}
const data = Object.assign({}, currentModel.value, {
openaiApiKey: enableCustomApiKey.value ? openaiApiKey.value : null,
message: message,
conversationId: props.conversation.id
}, webSearchParams)
try {
await fetchEventSource('/api/conversation/', {
signal: ctrl.signal,
method: 'POST',
headers: {
'accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
onopen(response) {
if (response.ok && response.headers.get('content-type') === EventStreamContentType) {
return;
}
throw new Error(`Failed to send message. HTTP ${response.status} - ${response.statusText}`);
},
onclose() {
if (ctrl.signal.aborted === true) {
return;
}
throw new Error(`Failed to send message. Server closed the connection unexpectedly.`);
},
onerror(err) {
throw err;
},
async onmessage(message) {
const event = message.event
const data = JSON.parse(message.data)
if (event === 'error') {
abortFetch()
showSnackbar(data.error)
return;
}
if (event === 'userMessageId') {
props.conversation.messages[props.conversation.messages.length - 1].id = data.userMessageId
return;
}
if (event === 'done') {
if (props.conversation.id === null) {
props.conversation.id = data.conversationId
genTitle(props.conversation.id)
}
props.conversation.messages[props.conversation.messages.length - 1].id = data.messageId
abortFetch()
return;
}
messageQueue.push(data.content)
processMessageQueue()
scrollChatWindow()
},
})
} catch (err) {
console.log(err)
abortFetch()
showSnackbar(err.message)
}
}
const grab = ref(null)
const scrollChatWindow = () => {
if (grab.value === null) {
return;
}
grab.value.scrollIntoView({behavior: 'smooth'})
}
const checkOrAddConversation = () => {
if (props.conversation.messages.length === 0) {
props.conversation.messages.push({id: null, is_bot: true, message: ''})
}
}
const send = (message) => {
fetchingResponse.value = true
if (props.conversation.messages.length === 0) {
addConversation(props.conversation)
}
props.conversation.messages.push({message: message})
fetchReply(message)
scrollChatWindow()
}
const stop = () => {
abortFetch()
}
const snackbar = ref(false)
const snackbarText = ref('')
const showSnackbar = (text) => {
snackbarText.value = text
snackbar.value = true
}
const editor = ref(null)
const usePrompt = (prompt) => {
editor.value.usePrompt(prompt)
}
const deleteMessage = (index) => {
props.conversation.messages.splice(index, 1)
}
const showWebSearchToggle = ref(false)
const enableWebSearch = ref(false)
const enableCustomApiKey = ref(false)
const settings = useSettings()
watchEffect(() => {
if (settings.value) {
const settingsValue = toRaw(settings.value)
showWebSearchToggle.value = settingsValue.open_web_search && settingsValue.open_web_search === 'True'
enableCustomApiKey.value = settingsValue.open_api_key_setting && settingsValue.open_api_key_setting === 'True'
}
})
</script>
<template>
<div
v-if="conversation.loadingMessages"
class="text-center"
>
<v-progress-circular
indeterminate
color="primary"
></v-progress-circular>
</div>
<div v-else>
<div
v-if="conversation.messages.length > 0"
ref="chatWindow"
>
<v-container>
<v-row>
<v-col
v-for="(message, index) in conversation.messages" :key="index"
cols="12"
>
<div
class="d-flex align-center"
:class="message.is_bot ? 'justify-start' : 'justify-end'"
>
<MessageActions
v-if="!message.is_bot"
:message="message"
:message-index="index"
:use-prompt="usePrompt"
:delete-message="deleteMessage"
/>
<MsgContent :message="message" />
<MessageActions
v-if="message.is_bot"
:message="message"
:message-index="index"
:use-prompt="usePrompt"
:delete-message="deleteMessage"
/>
</div>
</v-col>
</v-row>
</v-container>
<div ref="grab" class="w-100" style="height: 200px;"></div>
</div>
<Welcome v-if="conversation.id === null && conversation.messages.length === 0" />
</div>
<v-footer app>
<div class="px-md-16 w-100 d-flex flex-column">
<div class="d-flex align-center">
<v-btn
v-show="fetchingResponse"
icon="close"
title="stop"
class="mr-3"
@click="stop"
></v-btn>
<MsgEditor ref="editor" :send-message="send" :disabled="fetchingResponse" :loading="fetchingResponse" />
</div>
<v-toolbar
density="comfortable"
color="transparent"
>
<Prompt v-show="!fetchingResponse" :use-prompt="usePrompt" />
<v-switch
v-if="showWebSearchToggle"
v-model="enableWebSearch"
hide-details
color="primary"
:label="$t('webSearch')"
></v-switch>
<v-spacer></v-spacer>
</v-toolbar>
</div>
</v-footer>
<v-snackbar
v-model="snackbar"
multi-line
location="top"
>
{{ snackbarText }}
<template v-slot:actions>
<v-btn
color="red"
variant="text"
@click="snackbar = false"
>
Close
</v-btn>
</template>
</v-snackbar>
</template>

View File

@@ -5,8 +5,8 @@ export const useCurrentModel = () => useState('currentModel', () => getCurrentMo
export const useApiKey = () => useState('apiKey', () => getStoredApiKey()) export const useApiKey = () => useState('apiKey', () => getStoredApiKey())
export const useConversion = () => useState('conversion', () => getDefaultConversionData()) export const useConversation = () => useState('conversation', () => getDefaultConversationData())
export const useConversions = () => useState('conversions', () => []) export const useConversations = () => useState('conversations', () => [])
export const useSettings = () => useState('settings', () => {}) export const useSettings = () => useState('settings', () => {})

View File

@@ -17,6 +17,7 @@ services:
image: wongsaang/chatgpt-ui-wsgi-server:latest image: wongsaang/chatgpt-ui-wsgi-server:latest
environment: environment:
- APP_DOMAIN=${APP_DOMAIN:-localhost:9000} - APP_DOMAIN=${APP_DOMAIN:-localhost:9000}
- SERVER_WORKERS=3 # The number of worker processes for handling requests.
# - DB_URL=postgres://postgres:postgrespw@localhost:49153/chatgpt # If this parameter is not set, the built-in Sqlite will be used by default. It should be noted that if you do not connect to an external database, the data will be lost after the container is destroyed. # - DB_URL=postgres://postgres:postgrespw@localhost:49153/chatgpt # If this parameter is not set, the built-in Sqlite will be used by default. It should be noted that if you do not connect to an external database, the data will be lost after the container is destroyed.
- DJANGO_SUPERUSER_USERNAME=admin # default superuser name - DJANGO_SUPERUSER_USERNAME=admin # default superuser name
- DJANGO_SUPERUSER_PASSWORD=password # default superuser password - DJANGO_SUPERUSER_PASSWORD=password # default superuser password

View File

@@ -101,6 +101,7 @@ services:
image: wongsaang/chatgpt-ui-wsgi-server:latest image: wongsaang/chatgpt-ui-wsgi-server:latest
environment: environment:
- APP_DOMAIN=${APP_DOMAIN:-localhost:9000} # CSRF 白名单,在这里设置为 chatgpt-ui-web-server 的地址+端口, 默认: localhost:9000 - APP_DOMAIN=${APP_DOMAIN:-localhost:9000} # CSRF 白名单,在这里设置为 chatgpt-ui-web-server 的地址+端口, 默认: localhost:9000
- SERVER_WORKERS=3 # gunicorn 的工作进程数,默认为 3
#- DB_URL=postgres://postgres:postgrespw@localhost:49153/chatgpt # 连接外部数据库,如果不设置这个参数,则默认使用内置的 Sqlite。需要注意的是如果不连接外部数据库数据将在容器销毁后丢失。链接格式请看下面的 DB_URL 格式对照表 #- DB_URL=postgres://postgres:postgrespw@localhost:49153/chatgpt # 连接外部数据库,如果不设置这个参数,则默认使用内置的 Sqlite。需要注意的是如果不连接外部数据库数据将在容器销毁后丢失。链接格式请看下面的 DB_URL 格式对照表
#- OPENAI_API_PROXY=https://openai.proxy.com/v1 # https://api.openai.com/v1 的代理地址 #- OPENAI_API_PROXY=https://openai.proxy.com/v1 # https://api.openai.com/v1 的代理地址
- DJANGO_SUPERUSER_USERNAME=admin # 默认超级用户 - DJANGO_SUPERUSER_USERNAME=admin # 默认超级用户

View File

@@ -21,6 +21,7 @@
"themeMode": "Theme Mode", "themeMode": "Theme Mode",
"feedback": "Feedback", "feedback": "Feedback",
"newConversation": "New conversation", "newConversation": "New conversation",
"defaultConversationTitle": "Unnamed",
"clearConversations": "Clear conversations", "clearConversations": "Clear conversations",
"modelParameters": "Model Parameters", "modelParameters": "Model Parameters",
"model": "Model", "model": "Model",
@@ -38,8 +39,17 @@
"copied": "Copied", "copied": "Copied",
"delete": "Delete", "delete": "Delete",
"signOut": "Sign out", "signOut": "Sign out",
"resetPassword": "Reset password",
"submit": "Submit",
"agree": "Agree",
"newPassword": "New password",
"currentPassword": "Current password",
"confirmPassword": "Confirm password",
"yourPasswordHasBeenReset": "Your password has been reset",
"nowYouNeedToSignInAgain": "Now you need to sign in again",
"webSearch": "Web Search", "webSearch": "Web Search",
"webSearchDefaultPrompt": "Web search results:\n\n[web_results]\nCurrent date: [current_date]\n\nInstructions: Using the provided web search results, write a comprehensive reply to the given query. Make sure to cite results using [[number](URL)] notation after the reference. If the provided search results refer to multiple subjects with the same name, write separate answers for each subject.\nQuery: [query]", "webSearchDefaultPrompt": "Web search results:\n\n[web_results]\nCurrent date: [current_date]\n\nInstructions: Using the provided web search results, write a comprehensive reply to the given query. Make sure to cite results using [[number](URL)] notation after the reference. If the provided search results refer to multiple subjects with the same name, write separate answers for each subject.\nQuery: [query]",
"genTitlePrompt": "Generate a short title for the following content, no more than 10 words. \n\nContent: ",
"welcomeScreen": { "welcomeScreen": {
"introduction1": "is an unofficial client for ChatGPT, but uses the official OpenAI API.", "introduction1": "is an unofficial client for ChatGPT, but uses the official OpenAI API.",
"introduction2": "You will need an OpenAI API Key before you can use this client.", "introduction2": "You will need an OpenAI API Key before you can use this client.",

View File

@@ -21,6 +21,7 @@
"themeMode": "Тема", "themeMode": "Тема",
"feedback": "Обратная связь", "feedback": "Обратная связь",
"newConversation": "Новый чат", "newConversation": "Новый чат",
"defaultConversationTitle": "Безымянный",
"clearConversations": "Очистить чаты", "clearConversations": "Очистить чаты",
"modelParameters": "Параметры модели", "modelParameters": "Параметры модели",
"model": "Модель", "model": "Модель",
@@ -38,8 +39,17 @@
"copied": "Скопировано", "copied": "Скопировано",
"delete": "Удалить", "delete": "Удалить",
"signOut": "Выход", "signOut": "Выход",
"resetPassword": "Сбросить пароль",
"submit": "Отправить",
"agree": "Согласен",
"newPassword": "Новый пароль",
"currentPassword": "Текущий пароль",
"confirmPassword": "Подтвердите пароль",
"yourPasswordHasBeenReset": "Ваш пароль был сброшен",
"nowYouNeedToSignInAgain": "Теперь вам нужно снова войти в систему",
"webSearch": "Поиск в интернете", "webSearch": "Поиск в интернете",
"webSearchDefaultPrompt": "Результаты веб-поиска:\n\n[web_results]\nТекущая дата: [current_date]\n\nИнструкции: Используя предоставленные результаты веб-поиска, напишите развернутый ответ на заданный запрос. Обязательно цитируйте результаты, используя обозначение [[number](URL)] после ссылки. Если предоставленные результаты поиска относятся к нескольким темам с одинаковым названием, напишите отдельные ответы для каждой темы.\nЗапрос: [query]", "webSearchDefaultPrompt": "Результаты веб-поиска:\n\n[web_results]\nТекущая дата: [current_date]\n\nИнструкции: Используя предоставленные результаты веб-поиска, напишите развернутый ответ на заданный запрос. Обязательно цитируйте результаты, используя обозначение [[number](URL)] после ссылки. Если предоставленные результаты поиска относятся к нескольким темам с одинаковым названием, напишите отдельные ответы для каждой темы.\nЗапрос: [query]",
"genTitlePrompt": "Придумайте короткий заголовок для следующего содержания, не более 10 слов. \n\nСодержание: ",
"welcomeScreen": { "welcomeScreen": {
"introduction1": "является неофициальным клиентом для ChatGPT, но использует официальный API OpenAI.", "introduction1": "является неофициальным клиентом для ChatGPT, но использует официальный API OpenAI.",
"introduction2": "Вам понадобится ключ API OpenAI, прежде чем вы сможете использовать этот клиент.", "introduction2": "Вам понадобится ключ API OpenAI, прежде чем вы сможете использовать этот клиент.",

View File

@@ -21,6 +21,7 @@
"themeMode": "主题模式", "themeMode": "主题模式",
"feedback": "反馈", "feedback": "反馈",
"newConversation": "新的对话", "newConversation": "新的对话",
"defaultConversationTitle": "未命名",
"clearConversations": "清除对话", "clearConversations": "清除对话",
"modelParameters": "模型参数", "modelParameters": "模型参数",
"model": "模型", "model": "模型",
@@ -38,8 +39,17 @@
"copied": "已复制", "copied": "已复制",
"delete": "删除", "delete": "删除",
"signOut": "退出登录", "signOut": "退出登录",
"resetPassword": "重置密码",
"submit": "提交",
"agree": "同意",
"newPassword": "新密码",
"currentPassword": "当前密码",
"confirmPassword": "确认密码",
"yourPasswordHasBeenReset": "您的密码已重置",
"nowYouNeedToSignInAgain": "现在您需要再次登录",
"webSearch": "网页搜索", "webSearch": "网页搜索",
"webSearchDefaultPrompt": "网络搜索结果:\n\n[web_results]\n当前日期[current_date]\n\n说明使用提供的网络搜索结果对给定的查询写出全面的回复。确保在引用参考文献后使用 [[number](URL)] 符号进行引用结果. 如果提供的搜索结果涉及到多个具有相同名称的主题,请针对每个主题编写单独的答案。\n查询[query]", "webSearchDefaultPrompt": "网络搜索结果:\n\n[web_results]\n当前日期[current_date]\n\n说明使用提供的网络搜索结果对给定的查询写出全面的回复。确保在引用参考文献后使用 [[number](URL)] 符号进行引用结果. 如果提供的搜索结果涉及到多个具有相同名称的主题,请针对每个主题编写单独的答案。\n查询[query]",
"genTitlePrompt": "为以下内容生成一个不超过10个字的简短标题。 \n\n内容: ",
"welcomeScreen": { "welcomeScreen": {
"introduction1": "是一个非官方的ChatGPT客户端但使用OpenAI的官方API", "introduction1": "是一个非官方的ChatGPT客户端但使用OpenAI的官方API",
"introduction2": "在使用本客户端之前您需要一个OpenAI API密钥。", "introduction2": "在使用本客户端之前您需要一个OpenAI API密钥。",

View File

@@ -22,8 +22,8 @@ const setLang = (lang) => {
setLocale(lang) setLocale(lang)
} }
const conversations = useConversions() const conversations = useConversations()
const currentConversation = useConversion() const currentConversation = useConversation()
const editingConversation = ref(null) const editingConversation = ref(null)
const deletingConversationIndex = ref(null) const deletingConversationIndex = ref(null)
@@ -54,7 +54,7 @@ const deleteConversation = async (index) => {
deletingConversationIndex.value = null deletingConversationIndex.value = null
if (!error.value) { if (!error.value) {
if (conversations.value[index].id === currentConversation.value.id) { if (conversations.value[index].id === currentConversation.value.id) {
createNewConversion() createNewConversation()
} }
conversations.value.splice(index, 1) conversations.value.splice(index, 1)
} }
@@ -78,7 +78,7 @@ const loadingConversations = ref(false)
const loadConversations = async () => { const loadConversations = async () => {
loadingConversations.value = true loadingConversations.value = true
conversations.value = await getConversions() conversations.value = await getConversations()
loadingConversations.value = false loadingConversations.value = false
} }
@@ -123,19 +123,53 @@ onMounted(async () => {
:permanent="drawerPermanent" :permanent="drawerPermanent"
width="300" width="300"
> >
<div class="px-2 py-2"> <template
v-slot:prepend
>
<v-list> <v-list>
<v-list-item> <v-list-item
<v-btn :title="$auth.user.username"
block :subtitle="$auth.user.email"
variant="outlined" >
prepend-icon="add" <template v-slot:prepend>
@click="createNewConversion()" <v-icon
class="text-none" icon="face"
> size="x-large"
{{ $t('newConversation') }} ></v-icon>
</v-btn> </template>
<template v-slot:append>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
size="small"
variant="text"
icon="expand_more"
></v-btn>
</template>
<v-list>
<v-list-item
:title="$t('resetPassword')"
to="/account/resetPassword"
>
</v-list-item>
<v-list-item
:title="$t('signOut')"
@click="signOut"
>
</v-list-item>
</v-list>
</v-menu>
</template>
</v-list-item> </v-list-item>
</v-list>
<v-divider></v-divider>
</template>
<div class="px-2">
<v-list>
<v-list-item v-show="loadingConversations"> <v-list-item v-show="loadingConversations">
<v-list-item-title class="d-flex justify-center"> <v-list-item-title class="d-flex justify-center">
<v-progress-circular indeterminate></v-progress-circular> <v-progress-circular indeterminate></v-progress-circular>
@@ -172,19 +206,19 @@ onMounted(async () => {
<v-list-item <v-list-item
rounded="xl" rounded="xl"
active-color="primary" active-color="primary"
@click="openConversationMessages(conversation)" :to="conversation.id ? `/${conversation.id}` : undefined"
v-bind="props" v-bind="props"
> >
<v-list-item-title>{{ conversation.topic }}</v-list-item-title> <v-list-item-title>{{ conversation.topic !== "" ? conversation.topic : $t('defaultConversationTitle') }}</v-list-item-title>
<template v-slot:append> <template v-slot:append>
<div <div
v-show="isHovering" v-show="isHovering && conversation.id"
> >
<v-btn <v-btn
icon="edit" icon="edit"
size="small" size="small"
variant="text" variant="text"
@click.stop="editConversation(cIdx)" @click.prevent="editConversation(cIdx)"
> >
</v-btn> </v-btn>
<v-btn <v-btn
@@ -192,7 +226,7 @@ onMounted(async () => {
size="small" size="small"
variant="text" variant="text"
:loading="deletingConversationIndex === cIdx" :loading="deletingConversationIndex === cIdx"
@click.stop="deleteConversation(cIdx)" @click.prevent="deleteConversation(cIdx)"
> >
</v-btn> </v-btn>
</div> </div>
@@ -286,32 +320,33 @@ onMounted(async () => {
@click="feedback" @click="feedback"
></v-list-item> ></v-list-item>
<v-list-item
rounded="xl"
prepend-icon="logout"
:title="$t('signOut')"
@click="signOut"
></v-list-item>
</v-list> </v-list>
</div> </div>
</template> </template>
</v-navigation-drawer> </v-navigation-drawer>
<v-app-bar <v-app-bar
class="d-md-none" class=""
> >
<v-app-bar-nav-icon @click="drawer = !drawer"></v-app-bar-nav-icon> <v-app-bar-nav-icon @click="drawer = !drawer"></v-app-bar-nav-icon>
<v-toolbar-title>{{ runtimeConfig.public.appName }}</v-toolbar-title> <v-toolbar-title>{{ currentConversation.id ? currentConversation.topic : runtimeConfig.public.appName }}</v-toolbar-title>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn <v-btn
:title="$t('newConversation')" :title="$t('newConversation')"
icon="add" icon="add"
@click="createNewConversion()" @click="createNewConversation"
class="d-md-none"
></v-btn> ></v-btn>
<v-btn
variant="outlined"
class="text-none d-none d-md-block"
@click="createNewConversation"
>
{{ $t('newConversation') }}
</v-btn>
</v-app-bar> </v-app-bar>

View File

@@ -11,7 +11,6 @@
"@kevinmarrec/nuxt-pwa": "^0.17.0", "@kevinmarrec/nuxt-pwa": "^0.17.0",
"@nuxtjs/color-mode": "^3.2.0", "@nuxtjs/color-mode": "^3.2.0",
"@nuxtjs/i18n": "^8.0.0-beta.9", "@nuxtjs/i18n": "^8.0.0-beta.9",
"@vite-pwa/nuxt": "^0.0.7",
"material-design-icons-iconfont": "^6.7.0", "material-design-icons-iconfont": "^6.7.0",
"nuxt": "^3.2.0" "nuxt": "^3.2.0"
}, },

View File

@@ -0,0 +1,176 @@
<script setup>
definePageMeta({
middleware: ["auth"]
})
const formData = ref({
old_password: '',
new_password1: '',
new_password2: ''
})
const formRules = ref({
old_password: [
v => !!v || 'Current password is required'
],
new_password1: [
v => !!v || 'New password is required'
],
new_password2: [
v => !!v || 'Confirm password is required',
v => v === formData.value.new_password1 || 'Passwords do not match'
]
})
const fieldErrors = ref({
old_password: '',
new_password1: '',
new_password2: '',
})
const { $auth } = useNuxtApp()
const errorMsg = ref(null)
const resetForm = ref(null)
const valid = ref(true)
const submitting = ref(false)
const route = useRoute()
const passwordInputType = ref('password')
const signOut = async () => {
const { data, error } = await useFetch('/api/account/logout/', {
method: 'POST'
})
if (!error.value) {
await $auth.logout()
}
}
const submit = async () => {
errorMsg.value = null
const { valid } = await resetForm.value.validate()
if (valid) {
submitting.value = true
const { data, error } = await useFetch('/api/account/password/change/', {
method: 'POST',
body: JSON.stringify(formData.value)
})
submitting.value = false
if (error.value) {
if (error.value.status === 400) {
for (const key in formData.value) {
if (error.value.data[key]) {
fieldErrors.value[key] = error.value.data[key][0]
}
}
if (error.value.data.non_field_errors) {
errorMsg.value = error.value.data.non_field_errors[0]
}
} else {
if (error.value.data.detail) {
errorMsg.value = error.value.data.detail
} else {
errorMsg.value = 'Something went wrong. Please try again.'
}
}
} else {
successDialog.value = true
}
}
}
const handleFieldUpdate = (field) => {
fieldErrors.value[field] = ''
}
const successDialog = ref(false)
</script>
<template>
<v-card
style="height: 100vh"
>
<v-container>
<v-row>
<v-col
sm="9"
offset-sm="1"
md="6"
offset-md="3"
>
<v-card
class="mt-15"
elevation="0"
>
<div class="text-center text-h4">{{ $t('resetPassword') }}</div>
<v-card-text>
<v-form ref="resetForm">
<v-text-field
v-model="formData.old_password"
:rules="formRules.old_password"
:error-messages="fieldErrors.old_password"
@update:modelValue="handleFieldUpdate('old_password')"
:label="$t('currentPassword')"
variant="underlined"
clearable
></v-text-field>
<v-text-field
v-model="formData.new_password1"
:rules="formRules.new_password1"
:error-messages="fieldErrors.new_password1"
@update:modelValue="handleFieldUpdate('new_password1')"
:label="$t('newPassword')"
variant="underlined"
clearable
></v-text-field>
<v-text-field
v-model="formData.new_password2"
:rules="formRules.new_password2"
:error-messages="fieldErrors.new_password2"
@update:modelValue="handleFieldUpdate('new_password2')"
:label="$t('confirmPassword')"
variant="underlined"
clearable
></v-text-field>
</v-form>
<div v-if="errorMsg" class="text-red">{{ errorMsg }}</div>
<div
class="mt-5 d-flex justify-space-between"
>
<v-btn
block
color="primary"
:loading="submitting"
@click="submit"
size="large"
>{{ $t('submit') }}</v-btn>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</v-card>
<v-dialog
v-model="successDialog"
persistent
width="auto"
>
<v-card>
<v-card-title class="text-h5">
{{ $t('yourPasswordHasBeenReset') }}
</v-card-title>
<v-card-text>{{ $t('nowYouNeedToSignInAgain') }}</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="green-darken-1"
variant="text"
@click="signOut"
>
{{ $t('agree') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>

View File

@@ -83,7 +83,7 @@ const submit = async () => {
} }
const handleFieldUpdate = (field) => { const handleFieldUpdate = (field) => {
// fieldErrors.value[field] = '' fieldErrors.value[field] = ''
} }
</script> </script>
@@ -122,7 +122,7 @@ const handleFieldUpdate = (field) => {
:error-messages="fieldErrors.email" :error-messages="fieldErrors.email"
label="Email" label="Email"
variant="underlined" variant="underlined"
@@update:modelValue="handleFieldUpdate('email')" @update:modelValue="handleFieldUpdate('email')"
clearable clearable
></v-text-field> ></v-text-field>

View File

@@ -1,270 +1,46 @@
<script setup> <script setup>
import Prompt from "~/components/Prompt.vue";
definePageMeta({ definePageMeta({
middleware: ["auth"] middleware: ["auth"],
path: '/:id?',
keepalive: true
}) })
import {EventStreamContentType, fetchEventSource} from '@microsoft/fetch-event-source' const route = useRoute()
const currentConversation = useConversation()
const conversation = ref(getDefaultConversationData())
const { $i18n, $auth } = useNuxtApp() const loadConversation = async () => {
const runtimeConfig = useRuntimeConfig() const { data, error } = await useAuthFetch('/api/chat/conversations/' + route.params.id)
const currentModel = useCurrentModel() if (!error.value) {
const openaiApiKey = useApiKey() conversation.value = Object.assign(conversation.value, data.value)
const fetchingResponse = ref(false) console.log(conversation.value)
const messageQueue = [] }
let isProcessingQueue = false }
const processMessageQueue = () => { const loadMessage = async () => {
if (isProcessingQueue || messageQueue.length === 0) { const { data, error } = await useAuthFetch('/api/chat/messages/?conversationId=' + route.params.id)
return if (!error.value) {
conversation.value.messages = data.value
} }
if (!currentConversation.value.messages[currentConversation.value.messages.length - 1].is_bot) { }
currentConversation.value.messages.push({id: null, is_bot: true, message: ''})
} onActivated(async () => {
isProcessingQueue = true console.log('activated')
const nextMessage = messageQueue.shift() if (route.params.id) {
if (runtimeConfig.public.typewriter) { conversation.value.loadingMessages = true
let wordIndex = 0; await loadConversation()
const intervalId = setInterval(() => { await loadMessage()
currentConversation.value.messages[currentConversation.value.messages.length - 1].message += nextMessage[wordIndex] conversation.value.loadingMessages = false
wordIndex++
if (wordIndex === nextMessage.length) {
clearInterval(intervalId)
isProcessingQueue = false
processMessageQueue()
}
}, runtimeConfig.public.typewriterDelay)
} else { } else {
currentConversation.value.messages[currentConversation.value.messages.length - 1].message += nextMessage conversation.value = getDefaultConversationData()
isProcessingQueue = false
processMessageQueue()
} }
} currentConversation.value = Object.assign({}, conversation.value)
})
let ctrl
const abortFetch = () => {
if (ctrl) {
ctrl.abort()
}
fetchingResponse.value = false
}
const fetchReply = async (message) => {
ctrl = new AbortController()
let webSearchParams = {}
if (enableWebSearch.value) {
webSearchParams['web_search'] = {
ua: navigator.userAgent,
default_prompt: $i18n.t('webSearchDefaultPrompt')
}
}
const data = Object.assign({}, currentModel.value, {
openaiApiKey: enableCustomApiKey.value ? openaiApiKey.value : null,
message: message,
conversationId: currentConversation.value.id
}, webSearchParams)
try {
await fetchEventSource('/api/conversation/', {
signal: ctrl.signal,
method: 'POST',
headers: {
'accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
onopen(response) {
if (response.ok && response.headers.get('content-type') === EventStreamContentType) {
return;
}
throw new Error(`Failed to send message. HTTP ${response.status} - ${response.statusText}`);
},
onclose() {
if (ctrl.signal.aborted === true) {
return;
}
throw new Error(`Failed to send message. Server closed the connection unexpectedly.`);
},
onerror(err) {
throw err;
},
async onmessage(message) {
const event = message.event
const data = JSON.parse(message.data)
if (event === 'error') {
abortFetch()
showSnackbar(data.error)
return;
}
if (event === 'userMessageId') {
currentConversation.value.messages[currentConversation.value.messages.length - 1].id = data.userMessageId
return;
}
if (event === 'done') {
if (currentConversation.value.id === null) {
currentConversation.value.id = data.conversationId
genTitle(currentConversation.value.id)
}
currentConversation.value.messages[currentConversation.value.messages.length - 1].id = data.messageId
abortFetch()
return;
}
messageQueue.push(data.content)
processMessageQueue()
scrollChatWindow()
},
})
} catch (err) {
console.log(err)
abortFetch()
showSnackbar(err.message)
}
}
const currentConversation = useConversion()
const grab = ref(null)
const scrollChatWindow = () => {
if (grab.value === null) {
return;
}
grab.value.scrollIntoView({behavior: 'smooth'})
}
const send = (message) => {
fetchingResponse.value = true
currentConversation.value.messages.push({message: message})
fetchReply(message)
scrollChatWindow()
}
const stop = () => {
abortFetch()
}
const snackbar = ref(false)
const snackbarText = ref('')
const showSnackbar = (text) => {
snackbarText.value = text
snackbar.value = true
}
const editor = ref(null)
const usePrompt = (prompt) => {
editor.value.usePrompt(prompt)
}
const deleteMessage = (index) => {
currentConversation.value.messages.splice(index, 1)
}
const showWebSearchToggle = ref(false)
const enableWebSearch = ref(false)
const enableCustomApiKey = ref(false)
const settings = useSettings()
watchEffect(() => { watchEffect(() => {
if (settings.value) { console.log('conversation.value', conversation.value)
const settingsValue = toRaw(settings.value)
showWebSearchToggle.value = settingsValue.open_web_search && settingsValue.open_web_search === 'True'
enableCustomApiKey.value = settingsValue.open_api_key_setting && settingsValue.open_api_key_setting === 'True'
}
}) })
</script> </script>
<template> <template>
<div <Conversation :conversation="conversation" />
v-if="currentConversation.messages.length > 0"
ref="chatWindow"
>
<v-container>
<v-row>
<v-col
v-for="(message, index) in currentConversation.messages" :key="index"
cols="12"
>
<div
class="d-flex align-center"
:class="message.is_bot ? 'justify-start' : 'justify-end'"
>
<MessageActions
v-if="!message.is_bot"
:message="message"
:message-index="index"
:use-prompt="usePrompt"
:delete-message="deleteMessage"
/>
<MsgContent :message="message" />
<MessageActions
v-if="message.is_bot"
:message="message"
:message-index="index"
:use-prompt="usePrompt"
:delete-message="deleteMessage"
/>
</div>
</v-col>
</v-row>
</v-container>
<div ref="grab" class="w-100" style="height: 200px;"></div>
</div>
<Welcome v-else />
<v-footer app>
<div class="px-md-16 w-100 d-flex flex-column">
<div class="d-flex align-center">
<v-btn
v-show="fetchingResponse"
icon="close"
title="stop"
class="mr-3"
@click="stop"
></v-btn>
<MsgEditor ref="editor" :send-message="send" :disabled="fetchingResponse" :loading="fetchingResponse" />
</div>
<v-toolbar
density="comfortable"
color="transparent"
>
<Prompt v-show="!fetchingResponse" :use-prompt="usePrompt" />
<v-switch
v-if="showWebSearchToggle"
v-model="enableWebSearch"
hide-details
color="primary"
:label="$t('webSearch')"
></v-switch>
<v-spacer></v-spacer>
</v-toolbar>
<!-- <div class="py-2 text-disabled text-caption font-weight-light text-center">-->
<!-- © {{ new Date().getFullYear() }} {{ runtimeConfig.public.appName }}-->
<!-- </div>-->
</div>
</v-footer>
<v-snackbar
v-model="snackbar"
multi-line
location="top"
>
{{ snackbarText }}
<template v-slot:actions>
<v-btn
color="red"
variant="text"
@click="snackbar = false"
>
Close
</v-btn>
</template>
</v-snackbar>
</template> </template>

View File

@@ -1,14 +1,15 @@
export const getDefaultConversionData = () => { export const getDefaultConversationData = () => {
const { $i18n } = useNuxtApp()
return { return {
id: null, id: null,
topic: null, topic: $i18n.t('defaultConversationTitle'),
messages: [], messages: [],
loadingMessages: false, loadingMessages: false,
} }
} }
export const getConversions = async () => { export const getConversations = async () => {
const { data, error } = await useAuthFetch('/api/chat/conversations/') const { data, error } = await useAuthFetch('/api/chat/conversations/')
if (!error.value) { if (!error.value) {
return data.value return data.value
@@ -16,37 +17,33 @@ export const getConversions = async () => {
return [] return []
} }
export const createNewConversion = () => { export const createNewConversation = () => {
const conversation = useConversion() navigateTo('/')
conversation.value = getDefaultConversionData()
} }
export const openConversationMessages = async (currentConversation) => {
const conversation = useConversion() export const addConversation = (conversation) => {
conversation.value = Object.assign(conversation.value, currentConversation) const conversations = useConversations()
conversation.value.loadingMessages = true conversations.value = [conversation, ...conversations.value]
const { data, error } = await useAuthFetch('/api/chat/messages/?conversationId=' + currentConversation.id)
if (!error.value) {
conversation.value.messages = data.value
}
conversation.value.loadingMessages = true
} }
export const genTitle = async (conversationId) => { export const genTitle = async (conversationId) => {
const { $i18n } = useNuxtApp()
const { data, error } = await useAuthFetch('/api/gen_title/', { const { data, error } = await useAuthFetch('/api/gen_title/', {
method: 'POST', method: 'POST',
body: { body: {
conversationId: conversationId conversationId: conversationId,
prompt: $i18n.t('genTitlePrompt')
} }
}) })
if (!error.value) { if (!error.value) {
const conversation = { const conversations = useConversations()
id: conversationId, let index = conversations.value.findIndex(item => item.id === conversationId)
topic: data.value.title, if (index === -1) {
index = 0
} }
const conversations = useConversions() conversations.value[index].topic = data.value.title
// prepend to conversations
conversations.value = [conversation, ...conversations.value]
return data.value.title return data.value.title
} }
return null return null

2003
yarn.lock

File diff suppressed because it is too large Load Diff