08-chat.js 11.3 KB
Newer Older
박민석's avatar
박민석 committed
1
/* eslint-disable no-undef */
minseok.park's avatar
minseok.park committed
2 3 4
;(function () {
  'use strict'

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

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

  const openIconPath = `
    <path stroke-linecap="round" stroke-linejoin="round"
27 28 29 30 31 32 33 34
    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
35 36
  `
  const closeIconPath = `
37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60
    <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
61 62
  `

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

minseok.park's avatar
minseok.park committed
82 83 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
  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
129 130
  closeButton.addEventListener('click', () => {
    chat.classList.remove('show')
박민석's avatar
박민석 committed
131 132 133 134
    chatIcon.innerHTML = openIconPath
    chatIcon.setAttribute('fill', '#fff')
    chatIcon.setAttribute('stroke', '#0072ce')
    chatIcon.setAttribute('viewBox', '0 0 20 20')
박민석's avatar
박민석 committed
135 136
  })

minseok.park's avatar
minseok.park committed
137 138 139 140 141 142
  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
143
      backdrop.classList.add('is-active')
minseok.park's avatar
minseok.park committed
144 145 146 147 148 149 150 151 152 153 154 155 156
    }
  })

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

minseok.park's avatar
minseok.park committed
164
      productButton.textContent = selectedProduct
박민석's avatar
박민석 committed
165

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

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

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

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

      // Keydown event
      chatTextArea.addEventListener('keydown', function (e) {
박민석's avatar
박민석 committed
189
        if (e.key === 'Enter' && !e.shiftKey && !isComposing && !isLoading && !isWriting) {
박민석's avatar
박민석 committed
190 191 192
          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
193
            handleUserMessage(value)
박민석's avatar
박민석 committed
194 195 196 197 198
          }
        }
      })

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

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

      INIT = true
박민석's avatar
박민석 committed
212 213 214
    }
  }

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

222 223 224 225 226 227
  function saveMessagesToStorage (type, text) {
    let messages = JSON.parse(sessionStorage.getItem('messages')) || []
    messages.push({ type, text })
    sessionStorage.setItem('messages', JSON.stringify(messages))
  }

minseok.park's avatar
minseok.park committed
228
  function addMessageToChatBody (message) {
박민석's avatar
박민석 committed
229 230 231 232 233 234 235
    if (message.trim() !== '') {
      const user = document.createElement('pre')
      user.className = 'message user'
      user.textContent = message
      chatBody.appendChild(user)
      chatBody.scrollTop = chatBody.scrollHeight

236 237
      saveMessagesToStorage('user', message)

박민석's avatar
박민석 committed
238 239 240 241 242 243 244
      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
245

박민석's avatar
박민석 committed
246
      isLoading = true
박민석's avatar
박민석 committed
247

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

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

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

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

박민석's avatar
박민석 committed
271 272
          isWriting = true

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

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

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

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

박민석's avatar
박민석 committed
288 289 290 291 292
              return readStream()
            })
          }

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

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

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

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

    type()
  }

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

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