Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import router from './router'
import App from './App.vue'
import './assets/main.css'
import { useAuthStore } from './store/authStore.js'
import { useChatSessionStore } from './store/chatSessionStore.js'

const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)

useAuthStore().restoreSession()
useChatSessionStore().restoreSessions()

app.mount('#app')
132 changes: 132 additions & 0 deletions frontend/src/store/chatSessionStore.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { defineStore } from 'pinia'
import { sendChatMessage } from '../api/chat.js'

const STORAGE_KEY = 'salmanhae.chatSessions'
const MAX_SESSIONS = 20

const generateId = () => `sess_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`

const makeSession = () => ({
id: generateId(),
title: '새 대화',
messages: [],
createdAt: new Date().toISOString(),
})

export const useChatSessionStore = defineStore('chatSession', {
state: () => ({
sessions: [],
currentSessionId: null,
isChatLoading: false,
chatError: '',
_chatRequestSeq: 0,
}),

getters: {
currentSession: (state) =>
state.sessions.find((s) => s.id === state.currentSessionId) ?? null,
chatMessages: (state) =>
state.sessions.find((s) => s.id === state.currentSessionId)?.messages ?? [],
sessionList: (state) =>
[...state.sessions].sort((a, b) => b.createdAt.localeCompare(a.createdAt)),
},

actions: {
restoreSessions() {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return
const parsed = JSON.parse(raw)
if (Array.isArray(parsed) && parsed.length) {
this.sessions = parsed.slice(0, MAX_SESSIONS)
this.currentSessionId = this.sessions[0].id
}
} catch {
// ignore
}
},

_persist() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.sessions.slice(0, MAX_SESSIONS)))
} catch {
// ignore
}
},

createSession() {
const session = makeSession()
this.sessions.unshift(session)
this.currentSessionId = session.id
this._persist()
return session
},

switchSession(id) {
if (this.sessions.some((s) => s.id === id)) {
this.currentSessionId = id
}
},

_ensureSession() {
if (!this.currentSessionId || !this.sessions.some((s) => s.id === this.currentSessionId)) {
this.createSession()
}
},

_updateTitle(session, firstUserMessage) {
if (session.title === '새 대화') {
session.title = firstUserMessage.slice(0, 30) + (firstUserMessage.length > 30 ? '…' : '')
}
},

async sendChat(message, selectedPropertyId = null) {
const text = String(message || '').trim()
if (!text || this.isChatLoading) return

this._ensureSession()
const session = this.sessions.find((s) => s.id === this.currentSessionId)
if (!session) return

const seq = ++this._chatRequestSeq
session.messages.push({ role: 'user', text })
this._updateTitle(session, text)
this._persist()

this.isChatLoading = true
this.chatError = ''

try {
const response = await sendChatMessage({
message: text,
sessionId: session.id,
selectedPropertyId,
})
if (seq !== this._chatRequestSeq) return

session.messages.push({
role: 'bot',
text: response.message,
intent: response.intent,
legalCards: response.legalCards,
analysisCards: response.analysisCards,
properties: response.properties,
})
this._persist()
} catch (error) {
if (seq !== this._chatRequestSeq) return
this.chatError = error.response?.data?.message || 'AI 응답을 불러오지 못했습니다.'
session.messages.push({
role: 'bot',
text: this.chatError,
isError: true,
legalCards: [],
analysisCards: [],
})
this._persist()
} finally {
if (seq === this._chatRequestSeq) this.isChatLoading = false
}
},
},
})
52 changes: 2 additions & 50 deletions frontend/src/store/mapStore.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// 채팅 관련 state/action은 chatSessionStore로 이관됨
import { defineStore } from 'pinia'
import { sendChatMessage } from '../api/chat.js'
import { fetchMapViewport, fetchPropertyDetail } from '../api/properties.js'
import { isPropertyItem, VIEWPORT_MODES } from '../utils/mapViewport.js'

Expand Down Expand Up @@ -60,16 +60,11 @@ export default defineStore('map', {
},
isLoading: false,
isDetailLoading: false,
isChatLoading: false,
error: '',
detailError: '',
chatError: '',
chatSessionId: null,
chatMessages: [],
lastFetchedAt: null,
requestSeq: 0,
detailRequestSeq: 0,
chatRequestSeq: 0
detailRequestSeq: 0
}),
getters: {
regions: () => [
Expand Down Expand Up @@ -221,48 +216,5 @@ export default defineStore('map', {
this.isDetailLoading = false
this.selectedViewportItem = item
},
async sendChat(message) {
const text = String(message || '').trim()
if (!text || this.isChatLoading) return

const seq = ++this.chatRequestSeq
this.chatMessages.push({ role: 'user', text })
this.isChatLoading = true
this.chatError = ''

try {
const response = await sendChatMessage({
message: text,
sessionId: this.chatSessionId,
selectedPropertyId: this.selectedPropertyId
})

if (seq !== this.chatRequestSeq) return

this.chatSessionId = response.sessionId || this.chatSessionId
this.chatMessages.push({
role: 'bot',
text: response.message,
intent: response.intent,
legalCards: response.legalCards,
analysisCards: response.analysisCards,
properties: response.properties
})
} catch (error) {
if (seq !== this.chatRequestSeq) return
this.chatError = error.response?.data?.message || 'AI 계약 상담 응답을 불러오지 못했습니다.'
this.chatMessages.push({
role: 'bot',
text: this.chatError,
isError: true,
legalCards: [],
analysisCards: []
})
} finally {
if (seq === this.chatRequestSeq) {
this.isChatLoading = false
}
}
}
}
})
14 changes: 8 additions & 6 deletions frontend/src/views/Chatbot.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<div ref="messageArea" class="chat-body">

<!-- Welcome -->
<template v-if="!store.chatMessages.length">
<template v-if="!chatStore.chatMessages.length">
<div class="chat-welcome">
<div class="welcome-orb" />
<h2 class="welcome-title">안녕하세요!<br /><span class="welcome-accent">무엇이 궁금하신가요?</span></h2>
Expand All @@ -16,7 +16,7 @@
v-for="item in examplePrompts"
:key="item.text"
class="example-card"
:disabled="store.isChatLoading"
:disabled="chatStore.isChatLoading"
@click="send(item.text)"
>
<span class="example-card__icon" v-html="item.icon" />
Expand All @@ -30,7 +30,7 @@
<template v-else>
<div class="date-sep">Today {{ nowTime }}</div>

<div v-for="(msg, i) in store.chatMessages" :key="i" :class="['msg-group', msg.role === 'user' ? 'msg-group--user' : 'msg-group--bot']">
<div v-for="(msg, i) in chatStore.chatMessages" :key="i" :class="['msg-group', msg.role === 'user' ? 'msg-group--user' : 'msg-group--bot']">
<div :class="['msg-bubble', msg.role === 'user' ? 'msg-bubble--user' : 'msg-bubble--bot', msg.isError && 'msg-bubble--error']">
<p class="msg-text">{{ msg.text }}</p>

Expand Down Expand Up @@ -74,7 +74,7 @@
</div>
</div>

<div v-if="store.isChatLoading" class="msg-group msg-group--bot">
<div v-if="chatStore.isChatLoading" class="msg-group msg-group--bot">
<div class="msg-bubble msg-bubble--bot">
<div class="loading-dots"><span /><span /><span /></div>
</div>
Expand Down Expand Up @@ -105,7 +105,7 @@
</div>
<button
class="send-btn"
:disabled="store.isChatLoading || !input.trim()"
:disabled="chatStore.isChatLoading || !input.trim()"
@click="send(input)"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
Expand All @@ -124,8 +124,10 @@
<script setup>
import { nextTick, ref } from 'vue'
import useMapStore from '../store/mapStore'
import { useChatSessionStore } from '../store/chatSessionStore.js'

const store = useMapStore()
const chatStore = useChatSessionStore()
const input = ref('')
const messageArea = ref(null)
const textarea = ref(null)
Expand Down Expand Up @@ -203,7 +205,7 @@ const send = async (text) => {
input.value = ''
await nextTick()
if (textarea.value) textarea.value.style.height = 'auto'
const response = store.sendChat(message)
const response = chatStore.sendChat(message, store.selectedPropertyId)
await scrollToBottom()
await response
await scrollToBottom()
Expand Down
Loading