Compare commits

...

5 Commits

Author SHA1 Message Date
Wong Saang
65629ca5a6 Merge pull request #2 from Afeigege/main
fix: Send button doesn't work, input box doesn't wrap on mobile
2023-02-13 12:57:11 +08:00
Afeigege
f64a45c0ee fix: Send button doesn't work, input box doesn't wrap on mobile 2023-02-13 12:47:30 +08:00
Rafi
91ca495f20 Using @nuxtjs/color-mode module to control theme 2023-02-12 18:22:08 +08:00
Rafi
42c9864487 Update the layout to accommodate mobile 2023-02-12 17:37:46 +08:00
Rafi
60f8454578 Update the layout to accommodate mobile 2023-02-12 17:32:25 +08:00
6 changed files with 244 additions and 177 deletions

208
app.vue
View File

@@ -1,123 +1,23 @@
<script setup> <script setup>
import { fetchEventSource } from '@microsoft/fetch-event-source'
const runtimeConfig = useRuntimeConfig() const runtimeConfig = useRuntimeConfig()
const currentModel = useCurrentModel() const colorMode = useColorMode()
const openaiApiKey = useApiKey() const drawer = ref(null)
const fetchingResponse = ref(false) const themes = ref([
const fetchReply = async (message, parentMessageId) => { { title: 'Light', value: 'light' },
const ctrl = new AbortController() { title: 'Dark', value: 'dark' },
try { { title: 'System', value: 'system'}
await fetchEventSource('/api/conversation', { ])
signal: ctrl.signal, const setTheme = (theme) => {
method: 'POST', colorMode.preference = theme
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: currentModel.value,
openaiApiKey: openaiApiKey.value,
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> </script>
<template> <template>
<v-app <v-app
:theme="theme" :theme="$colorMode.value"
> >
<v-navigation-drawer <v-navigation-drawer
permanent v-model="drawer"
> >
<v-list> <v-list>
<ModelDialog/> <ModelDialog/>
@@ -128,68 +28,40 @@ onNuxtReady(() => {
<v-list> <v-list>
<ApiKeyDialog/> <ApiKeyDialog/>
<v-list-item <v-menu
rounded="xl" >
:prepend-icon="theme === 'light' ? 'dark_mode' : 'light_mode'" <template v-slot:activator="{ props }">
:title="(theme === 'light' ? 'Dark' : 'Light') + ' mode'" <v-list-item
@click="toggleTheme" v-bind="props"
></v-list-item> rounded="xl"
:prepend-icon="$colorMode.value === 'light' ? 'light_mode' : 'dark_mode'"
title="Theme mode"
></v-list-item>
</template>
<v-list>
<v-list-item
v-for="(theme, idx) in themes"
:key="idx"
@click="setTheme(theme.value)"
>
<v-list-item-title>{{ theme.title }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-list> </v-list>
</template> </template>
</v-navigation-drawer> </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"> <v-app-bar
{{ new Date().getFullYear() }} {{ runtimeConfig.public.appName }} class="d-lg-none"
</div>
</v-footer>
<v-snackbar
v-model="snackbar"
multi-line
> >
{{ snackbarText }} <v-app-bar-nav-icon @click="drawer = !drawer"></v-app-bar-nav-icon>
<template v-slot:actions> <v-toolbar-title>{{ runtimeConfig.public.appName }}</v-toolbar-title>
<v-btn </v-app-bar>
color="red"
variant="text" <v-main>
@click="snackbar = false" <NuxtPage/>
> </v-main>
Close
</v-btn>
</template>
</v-snackbar>
</v-app> </v-app>
</template> </template>

View File

@@ -1,21 +1,21 @@
<template> <template>
<v-textarea <v-textarea
v-model="message" v-model="message"
clearable label="Write a message..."
label="Message" placeholder="Write a message..."
placeholder="Type your message here"
rows="1" rows="1"
:auto-grow="autoGrow" :auto-grow="autoGrow"
:disabled="disabled" :disabled="disabled"
:loading="loading" :loading="loading"
hide-details :hint="hint"
append-inner-icon="send" append-inner-icon="send"
@keyup.enter="send" @keyup.enter.exact="enterOnly"
@click:append="send" @click:appendInner="clickSendBtn"
></v-textarea> ></v-textarea>
</template> </template>
<script> <script>
import { isMobile } from 'is-mobile'
export default { export default {
name: "MsgEditor", name: "MsgEditor",
props: { props: {
@@ -30,6 +30,11 @@ export default {
autoGrow: true, autoGrow: true,
}; };
}, },
computed: {
hint() {
return isMobile() ? "" : "Press Enter to send your message or Shift+Enter to add a new line.";
},
},
watch: { watch: {
message(val) { message(val) {
const lines = val.split(/\r\n|\r|\n/).length; const lines = val.split(/\r\n|\r|\n/).length;
@@ -44,10 +49,24 @@ export default {
}, },
methods: { methods: {
send() { send() {
const msg = this.message let msg = this.message
// remove the last "\n"
if (msg[msg.length - 1] === "\n") {
msg = msg.slice(0, -1)
}
if (msg.length > 0) {
this.sendMessage(msg)
}
this.message = "" this.message = ""
this.sendMessage(msg);
}, },
clickSendBtn () {
this.send()
},
enterOnly () {
if (!isMobile()) {
this.send()
}
}
}, },
} }
</script> </script>

View File

@@ -21,5 +21,6 @@ export default defineNuxtConfig({
'vuetify/styles', 'vuetify/styles',
'material-design-icons-iconfont/dist/material-design-icons.css', 'material-design-icons-iconfont/dist/material-design-icons.css',
'highlight.js/styles/panda-syntax-dark.css', 'highlight.js/styles/panda-syntax-dark.css',
] ],
modules: ['@nuxtjs/color-mode']
}) })

View File

@@ -8,6 +8,7 @@
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare"
}, },
"devDependencies": { "devDependencies": {
"@nuxtjs/color-mode": "^3.2.0",
"material-design-icons-iconfont": "^6.7.0", "material-design-icons-iconfont": "^6.7.0",
"nuxt": "^3.1.2" "nuxt": "^3.1.2"
}, },
@@ -16,6 +17,7 @@
"@microsoft/fetch-event-source": "^2.0.1", "@microsoft/fetch-event-source": "^2.0.1",
"@waylaidwanderer/chatgpt-api": "^1.12.2", "@waylaidwanderer/chatgpt-api": "^1.12.2",
"highlight.js": "^11.7.0", "highlight.js": "^11.7.0",
"is-mobile": "^3.1.1",
"marked": "^4.2.12", "marked": "^4.2.12",
"vuetify": "^3.0.6" "vuetify": "^3.0.6"
} }

159
pages/index.vue Normal file
View File

@@ -0,0 +1,159 @@
<script setup>
import { fetchEventSource } from '@microsoft/fetch-event-source'
const runtimeConfig = useRuntimeConfig()
const currentModel = useCurrentModel()
const openaiApiKey = useApiKey()
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({
model: currentModel.value,
openaiApiKey: openaiApiKey.value,
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 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
}
createNewConversation()
</script>
<template>
<div ref="chatWindow">
<v-card
rounded="0"
elevation="0"
v-for="(conversation, index) in currentConversation.messages"
:key="index"
:variant="conversation.from === 'ai' ? 'tonal' : 'text'"
>
<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-footer app class="d-flex flex-column">
<div class="px-md-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>
</template>

View File

@@ -786,6 +786,15 @@
vite-plugin-checker "^0.5.5" vite-plugin-checker "^0.5.5"
vue-bundle-renderer "^1.0.0" vue-bundle-renderer "^1.0.0"
"@nuxtjs/color-mode@^3.2.0":
version "3.2.0"
resolved "https://registry.npmmirror.com/@nuxtjs/color-mode/-/color-mode-3.2.0.tgz#b5b6a3931a6ddd9646c3aad121d357c635792eb7"
integrity sha512-isDR01yfadopiHQ/VEVUpyNSPrk5PCjUHS4t1qYRZwuRGefU4s9Iaxf6H9nmr1QFzoMgTm+3T0r/54jLwtpZbA==
dependencies:
"@nuxt/kit" "^3.0.0"
lodash.template "^4.5.0"
pathe "^1.0.0"
"@planetscale/database@^1.5.0": "@planetscale/database@^1.5.0":
version "1.5.0" version "1.5.0"
resolved "https://registry.npmmirror.com/@planetscale/database/-/database-1.5.0.tgz#073d9ca9841ad62896a6e31f610e89112e6264ef" resolved "https://registry.npmmirror.com/@planetscale/database/-/database-1.5.0.tgz#073d9ca9841ad62896a6e31f610e89112e6264ef"
@@ -2878,6 +2887,11 @@ is-lambda@^1.0.1:
resolved "https://registry.npmmirror.com/is-lambda/-/is-lambda-1.0.1.tgz#3d9877899e6a53efc0160504cde15f82e6f061d5" resolved "https://registry.npmmirror.com/is-lambda/-/is-lambda-1.0.1.tgz#3d9877899e6a53efc0160504cde15f82e6f061d5"
integrity sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ== integrity sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==
is-mobile@^3.1.1:
version "3.1.1"
resolved "https://registry.npmmirror.com/is-mobile/-/is-mobile-3.1.1.tgz#3b9e48f40068e4ea2da411f5009779844ce8d6aa"
integrity sha512-RRoXXR2HNFxNkUnxtaBdGBXtFlUMFa06S0NUKf/LCF+MuGLu13gi9iBCkoEmc6+rpXuwi5Mso5V8Zf7mNynMBQ==
is-module@^1.0.0: is-module@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.npmmirror.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591" resolved "https://registry.npmmirror.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591"