Feat: Add chatbot component

This commit is contained in:
jhynsoo 2024-05-22 15:11:11 +09:00
parent 0c5ad07f0e
commit 8770fd9f38
9 changed files with 293 additions and 282 deletions

View File

@ -1,58 +1,11 @@
// import axios from 'axios';
// const apiKey = import.meta.env.VITE_OPENAI_API_KEY;
// const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
// export const sendMessageToChatGPT = async (message, retries = 3) => {
// let attempt = 0;
// const maxDelay = 32000; // 최대 지연 시간 (32초)
// while (attempt < retries) {
// try {
// console.log(apiKey)
// const response = await axios.post(
// 'https://api.openai.com/v1/chat/completions',
// {
// model: 'gpt-3.5-turbo', // 또는 다른 사용 가능한 모델로 변경
// messages: [
// { role: 'system', content: 'You are a tour guide for those who want to travel to Korea. Answer only questions related to your trip to Korea.' },
// { role: 'user', content: message }
// ],
// },
// {
// headers: {
// 'Content-Type': 'application/json',
// 'Authorization': `Bearer ${apiKey}`,
// },
// }
// );
// return response.data.choices[0].message.content;
// } catch (error) {
// if (error.response && error.response.status === 429) {
// attempt++;
// const backoffTime = Math.min(1000 * 2 ** attempt, maxDelay); // 지수 백오프
// console.warn(`Rate limit exceeded. Retrying in ${backoffTime / 1000} seconds...`);
// await delay(backoffTime);
// } else {
// console.error('Error calling OpenAI API:', error);
// throw error;
// }
// }
// }
// throw new Error('Exceeded maximum retry attempts');
// };
import { localAxios } from '@/utils/http-commons';
const local = localAxios;
const sendMessageToChatGPT = async (message) => {
try {
console.log(message)
const response = await local.post('/api/chatgpt/message', { message });
return response.data;
} catch (error) {
console.error('Error sending message to ChatGPT:', error);

View File

@ -0,0 +1,54 @@
<script setup>
import { ref } from 'vue';
import FilledButton from '../common/FilledButton.vue';
import TravelChatBot from './TravelChatBot.vue';
const isOpen = ref(false);
function toggleChatbot() {
isOpen.value = !isOpen.value;
}
</script>
<template>
<FilledButton class="chatbot-button" @click="toggleChatbot">채팅</FilledButton>
<div v-if="isOpen" class="chatbot-wrapper">
<TravelChatBot />
</div>
</template>
<style scoped>
.chatbot-button {
position: absolute;
bottom: 20px;
right: 20px;
width: 64px;
height: 64px;
border-radius: 16px;
border: 2px solid var(--color-primary);
color: var(--color-primary);
z-index: 9;
}
@keyframes show {
0% {
opacity: 0;
transform: translate3d(0, 20px, 0) scale(0.95);
}
}
.chatbot-wrapper {
position: absolute;
bottom: 104px;
right: 20px;
background-color: var(--color-background);
width: 390px;
height: calc(100% - 124px);
max-height: 600px;
border-radius: 20px;
box-shadow: var(--shadow);
animation: show 0.35s ease;
transform-origin: bottom right;
z-index: 9;
}
</style>

View File

@ -1,183 +1,160 @@
<!-- <template>
<div class="chat-container">
<div class="messages">
<div v-for="(msg, index) in messages" :key="index" :class="msg.role">
{{ msg.content }}
</div>
</div>
<div class="input-container">
<input
v-model="userInput"
@keyup.enter="handleSend"
type="text"
placeholder="Type your travel query..."
/>
<button @click="handleSend">Send</button>
<script setup>
import sendMessageToChatGPT from '@/api/chatbot';
import { nextTick, ref } from 'vue';
import SearchBox from '../common/SearchBox.vue';
const userInput = ref('');
const messages = ref([]);
const scrollRef = ref(null);
const pending = ref(false);
function handleSubmit() {
if (!userInput.value?.trim()) return;
messages.value.push({ role: 'user', content: userInput.value });
pending.value = true;
nextTick().then(() => {
scrollRef.value.scrollTop = scrollRef.value.scrollHeight;
});
sendMessageToChatGPT(userInput.value)
.then((response) => {
pending.value = false;
messages.value.push({ role: 'assistant', content: response });
})
.then(() => nextTick())
.then(() => {
scrollRef.value.scrollTop = scrollRef.value.scrollHeight;
});
userInput.value = '';
}
</script>
<template>
<div class="chat-container">
<div class="messages" ref="scrollRef">
<div v-for="(msg, index) in messages" :key="index" :class="['message', msg.role]">
{{ msg.content }}
</div>
<div v-if="pending" class="message assistant"><div class="skeleton"></div></div>
</div>
</template>
<script>
import { ref } from 'vue';
import { sendMessageToChatGPT } from "@/api/chatbot";
export default {
name: 'TravelChatBot',
setup() {
const messages = ref([]);
const userInput = ref('');
const handleSend = async () => {
if (userInput.value.trim() === '') return;
const userMessage = { role: 'user', content: userInput.value };
console.log(userMessage)
messages.value.push(userMessage);
userInput.value = '';
const botMessageContent = await sendMessageToChatGPT(userMessage.content);
const botMessage = { role: 'assistant', content: botMessageContent };
messages.value.push(botMessage);
};
return {
messages,
userInput,
handleSend,
};
},
};
</script>
<style scoped>
.chat-container {
display: flex;
flex-direction: column;
height: 100vh;
max-width: 600px;
margin: 0 auto;
border: 1px solid #ccc;
padding: 20px;
}
.messages {
flex: 1;
overflow-y: auto;
}
.messages .user {
text-align: right;
margin: 10px 0;
color: blue;
}
.messages .assistant {
text-align: left;
margin: 10px 0;
color: green;
}
.input-container {
display: flex;
}
input[type="text"] {
flex: 1;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
}
button {
padding: 10px;
margin-left: 10px;
border: none;
border-radius: 4px;
background-color: #007bff;
color: white;
cursor: pointer;
}
</style>
-->
<template>
<div class="chat-container">
<div class="chat-box">
<div class="messages">
<div v-for="(msg, index) in messages" :key="index" :class="['message', msg.role]">
<span>{{ msg.content }}</span>
</div>
</div>
<input v-model="userInput" @keyup.enter="sendMessage" placeholder="Ask me anything about travel" />
<button @click="sendMessage">Send</button>
</div>
<div class="input-wrapper">
<SearchBox v-model="userInput" :onSubmit="handleSubmit">
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon"
viewBox="0 0 24 24"
fill="none"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polygon points="3,11 22,2 13,21 11,13 3,11" />
</svg>
</SearchBox>
</div>
</template>
</div>
</template>
<script>
import sendMessageToChatGPT from "@/api/chatbot";
export default {
data() {
return {
userInput: '',
messages: [],
};
},
methods: {
async sendMessage() {
if (this.userInput.trim() === '') return;
this.messages.push({ role: 'user', content: this.userInput });
console.log(this.userInput)
const response = await sendMessageToChatGPT(this.userInput);
this.messages.push({ role: 'assistant', content: response });
this.userInput = '';
},
},
};
</script>
<style scoped>
.chat-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
<style scoped>
@keyframes slideUp {
0% {
opacity: 0;
transform: translateY(4px);
}
}
.chat-box {
width: 400px;
border: 1px solid #ccc;
padding: 20px;
border-radius: 10px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
@keyframes skeleton {
100% {
background-position: -100% 0;
}
}
.messages {
max-height: 300px;
overflow-y: auto;
margin-bottom: 20px;
}
.chat-container {
display: flex;
flex-direction: column;
align-items: stretch;
position: relative;
padding: 20px;
padding-right: 0;
width: 100%;
height: 100%;
border-radius: 16px;
}
.message {
padding: 10px;
border-radius: 5px;
margin-bottom: 10px;
}
.chat-container::-webkit-scrollbar {
width: 6px;
}
.user {
background-color: #e0f7fa;
align-self: flex-end;
}
.chat-container::-webkit-scrollbar-thumb {
background-color: var(--color-text-secondary);
border-radius: 6px;
}
.assistant {
background-color: #fff9c4;
}
</style>
.icon {
display: block;
width: 24px;
height: 24px;
stroke: var(--color-primary);
}
.messages {
display: flex;
flex-direction: column;
overflow-y: auto;
height: 100%;
padding-right: 20px;
}
.messages::-webkit-scrollbar {
width: 6px;
}
.messages::-webkit-scrollbar-thumb {
background-color: var(--color-text-secondary);
border-radius: 6px;
}
.message {
padding: 10px;
border-radius: 16px;
margin-bottom: 10px;
animation: slideUp 0.25s ease-out;
}
.skeleton {
width: 100%;
height: 1em;
background: linear-gradient(
120deg,
var(--color-border) 30%,
var(--color-background-soft) 38%,
var(--color-background-soft) 42%,
var(--color-border) 50%
);
background-size: 200% 100%;
background-position: 100% 0;
animation: skeleton 1.5s infinite;
border-radius: 4px;
}
.user {
background-color: var(--color-primary);
color: white;
align-self: flex-end;
}
.assistant {
background-color: var(--color-background-soft);
margin-right: 20px;
}
.input-wrapper {
justify-self: flex-end;
display: flex;
gap: 20px;
align-items: center;
padding-right: 20px;
width: 100%;
}
</style>

View File

@ -40,12 +40,12 @@ onMounted(() => {
</script>
<template>
<div id="map" ref="mapDiv" />
<div id="map" ref="mapDiv"></div>
</template>
<style scoped>
#map {
width: calc(100% - 400px);
width: 100%;
height: 100%;
}
</style>

View File

@ -11,21 +11,10 @@ const model = defineModel();
<template>
<form @submit.prevent="onSubmit" class="search-box">
<input type="text" :name="name" v-model.lazy="model" placeholder="검색어를 입력하세요" />
<input type="text" :name="name" v-model="model" placeholder="검색어를 입력하세요" />
<TextButton @click="onSubmit">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
class="bi bi-search"
viewBox="0 0 16 16"
part="svg"
>
<path
d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"
></path>
</svg>
<slot></slot>
</TextButton>
</form>
</template>

View File

@ -32,6 +32,7 @@ watch(sido, ({ sidoCode }) => {
axios.get(`//localhost:8000/area/gugun?sidoCode=${sidoCode}`).then(({ data }) => {
gugunList.value = [{ gugunCode: 0, gugunName: '전체' }, ...data];
});
console.log(keyword.value);
});
if (sidoList.value.length === 0) {
@ -48,9 +49,14 @@ function handleSubmit() {
const contentTypeQuery = `contentTypeId=${contentType.value?.value ?? 0}`;
const queryString = [areaQuery, keywordQuery, contentTypeQuery].join('&');
console.log(queryString);
setQueryString(queryString);
search();
}
watch(keyword, () => {
console.log('change');
});
</script>
<template>
@ -71,7 +77,21 @@ function handleSubmit() {
optionName="name"
/>
</nav>
<SearchBox name="keyword" v-model.lazy="keyword" :onSubmit="handleSubmit" />
<SearchBox name="keyword" v-model="keyword" :onSubmit="handleSubmit">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
class="bi bi-search"
viewBox="0 0 16 16"
part="svg"
>
<path
d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"
></path>
</svg>
</SearchBox>
</div>
</template>

View File

@ -1,31 +1,31 @@
<script setup>
import { ref } from "vue";
import { registMember } from "@/api/member";
import { useRouter } from "vue-router";
import { reactive, ref } from 'vue';
import { registMember } from '@/api/member';
import { useRouter } from 'vue-router';
const router = useRouter();
const email_id = ref("")
const email_domain = ref("")
const email_id = ref('');
const email_domain = ref('');
const member = ref({
id: "",
name: "",
password: "",
email: email_id.value + "@" + email_domain.value,
})
const member = reactive({
id: '',
name: '',
password: '',
email: email_id.value + '@' + email_domain.value,
});
function onSubmit() {
member.value.email = email_id.value + "@" + email_domain.value
console.log(member.value)
registMember(
member.value,
(response) => {
if (response.status == 200) console.log("회원가입 성공!")
router.push({ name : "user-login"})
},
(error) => console.error(error)
)
member.value.email = email_id.value + '@' + email_domain.value;
console.log(member.value);
registMember(
member.value,
(response) => {
if (response.status == 200) console.log('회원가입 성공!');
router.push({ name: 'user-login' });
},
(error) => console.error(error)
);
}
</script>
@ -41,15 +41,20 @@ function onSubmit() {
<form>
<div class="mb-3">
<label for="username" class="form-label">이름 : </label>
<input type="text" class="form-control" placeholder="이름..." v-model="member.name"/>
<input type="text" class="form-control" placeholder="이름..." v-model="member.name" />
</div>
<div class="mb-3">
<label for="userid" class="form-label">아이디 : </label>
<input type="text" class="form-control" placeholder="아이디..." v-model="member.id"/>
<input type="text" class="form-control" placeholder="아이디..." v-model="member.id" />
</div>
<div class="mb-3">
<label for="userpwd" class="form-label">비밀번호 : </label>
<input type="text" class="form-control" placeholder="비밀번호..." v-model="member.password"/>
<input
type="text"
class="form-control"
placeholder="비밀번호..."
v-model="member.password"
/>
</div>
<div class="mb-3">
<label for="pwdcheck" class="form-label">비밀번호확인 : </label>
@ -58,7 +63,12 @@ function onSubmit() {
<div class="mb-3">
<label for="emailid" class="form-label">이메일 : </label>
<div class="input-group">
<input type="text" class="form-control" placeholder="이메일아이디" v-model="email_id"/>
<input
type="text"
class="form-control"
placeholder="이메일아이디"
v-model="email_id"
/>
<span class="input-group-text">@</span>
<select class="form-select" aria-label="이메일 도메인 선택" v-model="email_domain">
<option selected>선택</option>
@ -70,7 +80,9 @@ function onSubmit() {
</div>
</div>
<div class="col-auto text-center">
<button type="button" class="btn btn-outline-primary mb-3" @click="onSubmit">회원가입</button>
<button type="button" class="btn btn-outline-primary mb-3" @click="onSubmit">
회원가입
</button>
<button type="button" class="btn btn-outline-success ms-1 mb-3">초기화</button>
</div>
</form>

View File

@ -2,7 +2,7 @@ import './assets/main.css';
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
import App from './App.vue';
import router from './router';
@ -15,7 +15,6 @@ pinia.use(piniaPluginPersistedstate);
app.use(createPinia());
app.use(router);
// app.mount('#app');
router.isReady().then(() => {
app.mount("#app");
});
app.mount('#app');
});

View File

@ -1,24 +1,31 @@
<script setup>
import AppHeader from '@/components/AppHeader.vue';
import ChatBotButton from '@/components/chatbot/ChatBotButton.vue';
import KakaoMap from '@/components/common/KakaoMap.vue';
import SideBar from '@/components/search/SideBar.vue';
</script>
<template>
<AppHeader />
<main>
<div class="view-wrapper">
<SideBar />
<KakaoMap />
</main>
<main>
<KakaoMap />
<ChatBotButton />
</main>
</div>
</template>
<style scoped>
main {
.view-wrapper {
display: flex;
position: absolute;
top: 80px;
left: 0;
position: relative;
width: 100%;
height: calc(100% - 80px);
}
main {
width: 100%;
height: 100%;
}
</style>