08-chat.js 11.4 KB
Newer Older
박민석's avatar
박민석 committed
1
/* eslint-disable no-undef */
박민석's avatar
박민석 committed
2
// import { marked } from 'https://cdn.jsdelivr.net/npm/marked/lib/marked.esm.js'
3

minseok.park's avatar
minseok.park committed
4 5 6
;(function () {
  'use strict'

minseok.park's avatar
minseok.park committed
7 8 9
  const chatButton = document.querySelector('.chat-btn')
  const chat = document.querySelector('.chat')
  const chatIcon = document.querySelector('.chat-btn svg')
박민석's avatar
박민석 committed
10 11
  const chatTextArea = document.getElementById('chat-input')
  const sendButton = document.getElementById('send-btn')
박민석's avatar
박민석 committed
12
  const stopButton = document.getElementById('stop-btn')
minseok.park's avatar
minseok.park committed
13
  const chatBody = document.querySelector('.chat .chat-body')
14
  const expandButton = document.querySelector('#expand-btn svg')
박민석's avatar
박민석 committed
15
  const closeButton = document.getElementById('close-btn')
minseok.park's avatar
minseok.park committed
16 17 18
  const productButton = document.getElementById('model-toggle')
  const productList = document.getElementById('model-popover')
  const backdrop = document.querySelector('.model .backdrop')
박민석's avatar
박민석 committed
19 20 21

  let INIT = false
  let isComposing = false
minseok.park's avatar
minseok.park committed
22
  let isLoading = false
박민석's avatar
박민석 committed
23 24
  let isWriting = false
  let isPause = false
박민석's avatar
박민석 committed
25
  let docType = 'all' // 선택된 제품 유형을 저장할 변수
minseok.park's avatar
minseok.park committed
26 27 28

  const openIconPath = `
    <path stroke-linecap="round" stroke-linejoin="round"
29 30 31 32 33 34 35 36
    d="M7.188 10a.312.312 0 1 1-.625 0
    .312.312 0 0 1 .625 0Zm0 0h-.313m3.438 0a.312.312 0
    1 1-.625 0 .312.312 0 0 1 .625 0Zm0 0h-.313m3.437 0a.312.312 0
    1 1-.625 0 .312.312 0 0 1 .625 0Zm0 0h-.312M17.5 10c0 3.797-3.353
    6.875-7.5 6.875a8.735 8.735 0 0 1-2.292-.303 5.368 5.368 0 0 1-3.639
    1.576 5.364 5.364 0 0 1-.428-.059 4.025 4.025 0 0 0 .88-1.818c.081-.409
    -.12-.806-.422-1.098C3.273 13.484 2.5 11.825 2.5 10c0-3.797 3.353-6.875
    7.5-6.875s7.5 3.078 7.5 6.875Z"></path>
minseok.park's avatar
minseok.park committed
37 38
  `
  const closeIconPath = `
39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
    <path clip-rule="evenodd" fill-rule="evenodd"
    d="M12.53 16.28a.75.75 0 0 1-1.06 0l-7.5-7.5a.75.75 0 0 1 1.06-1.06L12
    14.69l6.97-6.97a.75.75 0 1 1 1.06 1.06l-7.5 7.5Z"></path>
  `
  const collapseIconPath = `
    <path
    d="M3.28 2.22a.75.75 0 0 0-1.06 1.06L5.44 6.5H2.75a.75.75 0 0 0 0 1.5h4.5
    A.75.75 0 0 0 8 7.25v-4.5a.75.75 0 0 0-1.5 0v2.69L3.28 2.22ZM13.5 2.75
    a.75.75 0 0 0-1.5 0v4.5c0 .414.336.75.75.75h4.5a.75.75 0 0 0 0-1.5h-2.69l3.22-3.22
    a.75.75 0 0 0-1.06-1.06L13.5 5.44V2.75ZM3.28 17.78l3.22-3.22v2.69a.75.75 0 0 0 1.5
    0v-4.5a.75.75 0 0 0-.75-.75h-4.5a.75.75 0 0 0 0 1.5h2.69l-3.22 3.22a.75.75
    0 1 0 1.06 1.06ZM13.5 14.56l3.22 3.22a.75.75 0 1 0 1.06-1.06l-3.22-3.22h2.69
    a.75.75 0 0 0 0-1.5h-4.5a.75.75 0 0 0-.75.75v4.5a.75.75 0 0 0 1.5 0v-2.69Z">
    </path>
  `
  const expandIconPath = `
    <path d="
    m13.28 7.78 3.22-3.22v2.69a.75.75 0 0 0 1.5 0v-4.5a.75.75 0 0 0-.75-.75h-4.5a.75.75
    0 0 0 0 1.5h2.69l-3.22 3.22a.75.75 0 0 0 1.06 1.06ZM2 17.25v-4.5a.75.75 0 0 1 1.5 0v2.69l3.22-3.22a.75.75
    0 0 1 1.06 1.06L4.56 16.5h2.69a.75.75 0 0 1 0 1.5h-4.5a.747.747 0 0 1-.75-.75ZM12.22
    13.28l3.22 3.22h-2.69a.75.75 0 0 0 0 1.5h4.5a.747.747 0 0 0 .75-.75v-4.5a.75.75
    0 0 0-1.5 0v2.69l-3.22-3.22a.75.75 0 1 0-1.06 1.06ZM3.5 4.56l3.22 3.22a.75.75
    0 0 0 1.06-1.06L4.56 3.5h2.69a.75.75 0 0 0 0-1.5h-4.5a.75.75 0 0 0-.75.75v4.5a.75.75 0 0 0 1.5 0V4.56Z">
    </path>
minseok.park's avatar
minseok.park committed
63 64
  `

65 66 67 68 69 70
  // 페이지 로드 시 sessionStorage에서 메시지 불러오기
  window.addEventListener('load', () => {
    const storedMessages = sessionStorage.getItem('messages')
    if (storedMessages) {
      const messages = JSON.parse(storedMessages)
      messages.forEach(({ type, text }) => {
71
        const messageItem = document.createElement('div')
72
        messageItem.className = `message ${type}`
73
        messageItem.innerHTML = marked(text)
74 75 76 77 78 79 80 81 82 83
        chatBody.appendChild(messageItem)
      })
      chatBody.scrollTop = chatBody.scrollHeight
    } else {
      // 메시지가 없을 경우 초기 메시지 추가
      const initMessage = '제품 매뉴얼 가이드에 대한 도움을 드리겠습니다. 제품 선택 시 더 정확한 답변을 제공해 드립니다.'
      createAiMessage(initMessage)
    }
  })

minseok.park's avatar
minseok.park committed
84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130
  chatButton.addEventListener('click', () => {
    chat.classList.toggle('show')

    if (chat.classList.contains('show')) {
      chat.classList.add('expand')
      chatIcon.innerHTML = closeIconPath
      chatIcon.setAttribute('fill', 'none')
      chatIcon.setAttribute('stroke', '#fff')
      chatIcon.setAttribute('viewBox', '0 -1 24 24')

      expandButton.classList.add('expand')
      expandButton.innerHTML = collapseIconPath
      expandButton.setAttribute('fill', '#fff')
      expandButton.setAttribute('viewBox', '0 0 20 20')
    } else {
      chatIcon.innerHTML = openIconPath
      chatIcon.setAttribute('fill', '#fff')
      chatIcon.setAttribute('stroke', '#0072ce')
      chatIcon.setAttribute('viewBox', '0 0 20 20')

      expandButton.classList.remove('expand')
      expandButton.innerHTML = expandIconPath
      expandButton.setAttribute('fill', '#fff')
      expandButton.setAttribute('viewBox', '0 0 20 20')
    }

    if (!INIT) {
      initializeChat()
    }
  })

  expandButton.addEventListener('click', () => {
    expandButton.classList.toggle('expand')

    if (expandButton.classList.contains('expand')) {
      expandButton.innerHTML = collapseIconPath
      expandButton.setAttribute('fill', '#fff')
      expandButton.setAttribute('viewBox', '0 0 20 20')
      chat.classList.add('expand')
    } else {
      expandButton.innerHTML = expandIconPath
      expandButton.setAttribute('fill', '#fff')
      expandButton.setAttribute('viewBox', '0 0 20 20')
      chat.classList.remove('expand')
    }
  })

박민석's avatar
박민석 committed
131 132
  closeButton.addEventListener('click', () => {
    chat.classList.remove('show')
박민석's avatar
박민석 committed
133 134 135 136
    chatIcon.innerHTML = openIconPath
    chatIcon.setAttribute('fill', '#fff')
    chatIcon.setAttribute('stroke', '#0072ce')
    chatIcon.setAttribute('viewBox', '0 0 20 20')
박민석's avatar
박민석 committed
137 138
  })

minseok.park's avatar
minseok.park committed
139 140 141 142 143 144
  productButton.addEventListener('click', () => {
    if (productList.classList.contains('show')) {
      productList.classList.remove('show')
      backdrop.classList.remove('is-active')
    } else {
      productList.classList.add('show')
minseok.park's avatar
minseok.park committed
145
      backdrop.classList.add('is-active')
minseok.park's avatar
minseok.park committed
146 147 148 149 150 151 152 153 154 155 156 157 158
    }
  })

  backdrop.addEventListener('click', () => {
    if (backdrop.classList.contains('is-active')) {
      productList.classList.remove('show')
      backdrop.classList.remove('is-active')
    } else {
      productList.classList.add('show')
      backdrop.classList.add('is-active')
    }
  })

박민석's avatar
박민석 committed
159 160 161
  document.querySelectorAll('#model-popover li').forEach((item) => {
    item.addEventListener('click', function () {
      const selectedProduct = this.textContent
minseok.park's avatar
minseok.park committed
162 163 164
      docType = selectedProduct === '전체' ? 'all' : selectedProduct
      productList.classList.remove('show')
      backdrop.classList.remove('is-active')
박민석's avatar
박민석 committed
165

minseok.park's avatar
minseok.park committed
166
      productButton.textContent = selectedProduct
박민석's avatar
박민석 committed
167

minseok.park's avatar
minseok.park committed
168 169 170
      const message = docType === 'all'
        ? '전체 제품을 대상으로 매뉴얼 가이드를 제공해 드리겠습니다. 제품 선택 시 더 정확한 정보를 확인할 수 있습니다.'
        : `${docType.toUpperCase()} 매뉴얼 가이드에 대한 도움을 드리겠습니다.`
박민석's avatar
박민석 committed
171

minseok.park's avatar
minseok.park committed
172 173 174 175
      if (!isWriting && !isLoading) {
        isWriting = true
        createAiMessage(message)
      }
박민석's avatar
박민석 committed
176 177 178
    })
  })

minseok.park's avatar
minseok.park committed
179
  function initializeChat () {
박민석's avatar
박민석 committed
180 181 182 183 184 185 186 187 188 189 190
    if (!INIT) {
      chatTextArea.addEventListener('compositionstart', () => {
        isComposing = true
      })

      chatTextArea.addEventListener('compositionend', () => {
        isComposing = false
      })

      // Keydown event
      chatTextArea.addEventListener('keydown', function (e) {
박민석's avatar
박민석 committed
191
        if (e.key === 'Enter' && !e.shiftKey && !isComposing && !isLoading && !isWriting) {
박민석's avatar
박민석 committed
192 193 194
          e.preventDefault() // Prevent the default action of Enter (which is to create a new line)
          const { value } = e.target
          if (value.trim() !== '') {
minseok.park's avatar
minseok.park committed
195
            handleUserMessage(value)
박민석's avatar
박민석 committed
196 197 198 199 200
          }
        }
      })

      sendButton.addEventListener('click', () => {
박민석's avatar
박민석 committed
201
        if (!isLoading && !isWriting) {
minseok.park's avatar
minseok.park committed
202 203 204
          const message = chatTextArea.value
          handleUserMessage(message)
        }
박민석's avatar
박민석 committed
205
      })
박민석's avatar
박민석 committed
206 207 208 209 210 211

      stopButton.addEventListener('click', () => {
        isPause = true
        stopButton.classList.remove('active')
        sendButton.classList.add('active')
      })
212 213

      INIT = true
박민석's avatar
박민석 committed
214 215 216
    }
  }

minseok.park's avatar
minseok.park committed
217 218 219 220 221 222 223
  function handleUserMessage (message) {
    if (message.trim() !== '') {
      addMessageToChatBody(message)
      chatTextArea.value = ''
    }
  }

224
  function saveMessagesToStorage (type, text) {
minseok.park's avatar
minseok.park committed
225
    const messages = JSON.parse(sessionStorage.getItem('messages')) || []
226 227 228 229
    messages.push({ type, text })
    sessionStorage.setItem('messages', JSON.stringify(messages))
  }

minseok.park's avatar
minseok.park committed
230
  function addMessageToChatBody (message) {
박민석's avatar
박민석 committed
231
    if (message.trim() !== '') {
232
      const user = document.createElement('div')
박민석's avatar
박민석 committed
233
      user.className = 'message user'
234
      user.innerHTML = marked(message)
박민석's avatar
박민석 committed
235 236 237
      chatBody.appendChild(user)
      chatBody.scrollTop = chatBody.scrollHeight

238 239
      saveMessagesToStorage('user', message)

박민석's avatar
박민석 committed
240 241 242 243 244 245 246
      const chatLoader = document.createElement('div')
      chatLoader.className = 'chat-loader'
      const loader = document.createElement('div')
      loader.className = 'loader'
      chatLoader.appendChild(loader)
      chatBody.appendChild(chatLoader)
      chatBody.scrollTop = chatBody.scrollHeight
minseok.park's avatar
minseok.park committed
247

박민석's avatar
박민석 committed
248
      isLoading = true
박민석's avatar
박민석 committed
249

박민석's avatar
박민석 committed
250 251
      const payload = {
        question: message,
박민석's avatar
박민석 committed
252
        docsType: docType.toLowerCase(),
박민석's avatar
박민석 committed
253 254
      }

박민석's avatar
박민석 committed
255
      fetch('https://docs.bxi.link/ask', {
박민석's avatar
박민석 committed
256 257 258 259 260
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(payload),
박민석's avatar
박민석 committed
261 262
      })
        .then((response) => {
박민석's avatar
박민석 committed
263
          const reader = response.body.getReader()
박민석's avatar
박민석 committed
264 265
          const decoder = new TextDecoder('utf-8')

박민석's avatar
박민석 committed
266
          sendButton.classList.remove('active')
박민석's avatar
박민석 committed
267
          stopButton.classList.add('active')
박민석's avatar
박민석 committed
268

269
          const messageItem = document.createElement('div')
박민석's avatar
박민석 committed
270
          messageItem.className = 'message ai'
박민석's avatar
박민석 committed
271
          chatBody.appendChild(messageItem) // 실제 메시지가 들어갈 자리
박민석's avatar
박민석 committed
272

박민석's avatar
박민석 committed
273 274
          isWriting = true

박민석's avatar
박민석 committed
275
          function readStream () {
박민석's avatar
박민석 committed
276
            return reader.read().then(({ done, value }) => {
박민석's avatar
박민석 committed
277 278 279 280
              if (done) {
                isLoading = false
                return
              }
박민석's avatar
박민석 committed
281

박민석's avatar
박민석 committed
282
              const chunk = decoder.decode(value, { stream: true })
박민석's avatar
박민석 committed
283 284

              // 로더 제거 후 메시지 표시
박민석's avatar
박민석 committed
285
              if (chatLoader) chatBody.removeChild(chatLoader)
박민석's avatar
박민석 committed
286 287 288 289

              typeEffect(messageItem, chunk, 30)
              chatBody.scrollTop = chatBody.scrollHeight

박민석's avatar
박민석 committed
290 291 292 293 294
              return readStream()
            })
          }

          return readStream()
박민석's avatar
박민석 committed
295 296 297
        })
        .catch((error) => {
          console.error('Chatbot Error', error)
박민석's avatar
박민석 committed
298
          isLoading = false
박민석's avatar
박민석 committed
299
        })
박민석's avatar
박민석 committed
300 301
    }
  }
박민석's avatar
박민석 committed
302

박민석's avatar
박민석 committed
303 304 305
  // 타이핑 효과 함수
  function typeEffect (element, text, speed = 50) {
    let index = 0
306
    let partialMessage = ''
박민석's avatar
박민석 committed
307 308

    function type () {
박민석's avatar
박민석 committed
309
      if (isPause) {
310
        saveMessagesToStorage('ai', partialMessage)
박민석's avatar
박민석 committed
311 312 313 314 315
        isPause = false
        isWriting = false
        return
      }

박민석's avatar
박민석 committed
316
      if (index < text.length) {
317
        partialMessage += text.charAt(index)
318
        element.innerHTML = marked(partialMessage)
박민석's avatar
박민석 committed
319 320 321
        chatBody.scrollTop = chatBody.scrollHeight
        index++
        setTimeout(type, speed) // 한글자씩 추가
박민석's avatar
박민석 committed
322
      } else {
323
        saveMessagesToStorage('ai', partialMessage)
박민석's avatar
박민석 committed
324 325 326
        sendButton.classList.add('active')
        stopButton.classList.remove('active')
        isWriting = false
박민석's avatar
박민석 committed
327 328 329 330 331 332
      }
    }

    type()
  }

박민석's avatar
박민석 committed
333
  function createAiMessage (message) {
334
    const messageItem = document.createElement('div')
박민석's avatar
박민석 committed
335 336
    messageItem.className = 'message ai'
    chatBody.appendChild(messageItem)
박민석's avatar
박민석 committed
337 338

    typeEffect(messageItem, message, 30)
박민석's avatar
박민석 committed
339 340
    chatBody.scrollTop = chatBody.scrollHeight
  }
minseok.park's avatar
minseok.park committed
341
})()