/* * 파일명: chatbot.js * 위치: /chatbot/assets/js/ * 기능: 챗봇 프론트엔드 JavaScript * 작성일: 2025-05-31 */ // =================================== // 전역 변수 // =================================== let chatbot = { isLoading: false, sessionId: null, apiUrl: './api/chat.php', elements: {}, settings: { maxMessageLength: 500, typingSpeed: 30, autoScrollDelay: 100 } }; // =================================== // DOM 요소 초기화 // =================================== /** * DOM 요소들을 초기화하고 이벤트 리스너 등록 */ function initializeChatbot() { // DOM 요소 저장 chatbot.elements = { chatContainer: document.getElementById('chatContainer'), messageInput: document.getElementById('messageInput'), sendButton: document.getElementById('sendButton'), loadingContainer: document.getElementById('loadingContainer'), statusBar: document.getElementById('statusBar'), tokenStats: document.getElementById('tokenStats'), clearButton: document.getElementById('clearButton') }; // 이벤트 리스너 등록 bindEventListeners(); // 세션 초기화 initSession(); // 사용량 통계 로드 loadUsageStats(); // 주기적 업데이트 시작 startPeriodicUpdates(); // 메시지 입력창에 포커스 if (chatbot.elements.messageInput) { chatbot.elements.messageInput.focus(); } } // =================================== // 이벤트 리스너 바인딩 // =================================== /** * 모든 이벤트 리스너를 바인딩 */ function bindEventListeners() { // 전송 버튼 클릭 if (chatbot.elements.sendButton) { chatbot.elements.sendButton.addEventListener('click', sendMessage); } // 엔터키로 메시지 전송 if (chatbot.elements.messageInput) { chatbot.elements.messageInput.addEventListener('keypress', function(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); // 입력 길이 체크 chatbot.elements.messageInput.addEventListener('input', checkInputLength); } // 대화 기록 삭제 버튼 if (chatbot.elements.clearButton) { chatbot.elements.clearButton.addEventListener('click', clearChatHistory); } // 페이지 언로드 시 세션 정리 window.addEventListener('beforeunload', function() { if (chatbot.sessionId) { // 세션 비활성화 (필요시) } }); } // =================================== // 세션 관리 // =================================== /** * 챗봇 세션 초기화 */ async function initSession() { try { const response = await fetch(chatbot.apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ action: 'init_session' }) }); const data = await response.json(); if (data.success) { chatbot.sessionId = data.session_id; // 환영 메시지 표시 if (data.welcome_message) { addMessage(data.welcome_message, 'bot', { tokens_used: 0, response_time: 0 }); } // 사용량 정보 업데이트 if (data.usage) { updateUsageDisplay(data.usage); } console.log('챗봇 세션이 초기화되었습니다:', chatbot.sessionId); } else { showError('세션 초기화에 실패했습니다: ' + data.error); } } catch (error) { showError('서버 연결에 실패했습니다: ' + error.message); console.error('세션 초기화 오류:', error); } } // =================================== // 메시지 전송 // =================================== /** * 메시지 전송 처리 */ async function sendMessage() { const message = chatbot.elements.messageInput.value.trim(); // 유효성 검사 if (!message) { showInputError('메시지를 입력해주세요.'); return; } if (message.length > chatbot.settings.maxMessageLength) { showInputError(`메시지가 너무 깁니다. (최대 ${chatbot.settings.maxMessageLength}자)`); return; } if (chatbot.isLoading) { return; } // UI 상태 변경 setLoadingState(true); // 사용자 메시지 표시 addMessage(message, 'user'); // 입력창 초기화 chatbot.elements.messageInput.value = ''; try { const response = await fetch(chatbot.apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ action: 'send_message', message: message }) }); const data = await response.json(); console.log('서버 응답:', data); // 디버깅용 if (data.success) { if (data.type === 'faq_suggestions') { // FAQ 제안이 있는 경우 showFAQSuggestions(data.suggestions, data.original_message); } else if (data.type === 'direct_answer') { // 직접 답변인 경우 if (data.message && typeof data.message === 'string') { // 응답 소스에 따라 표시 방식 결정 if (data.source && data.source.includes('faq')) { // FAQ 응답: 즉시 표시 (타이핑 효과 없음) addMessage(data.message, 'bot', { tokens_used: data.tokens_used || 0, response_time: data.response_time || 0 }); } else { // AI 응답: 타이핑 효과 적용 await addMessageWithTyping(data.message, 'bot', { tokens_used: data.tokens_used || 0, response_time: data.response_time || 0 }); } } else { addErrorMessage('응답 메시지가 올바르지 않습니다.'); } // 사용량 정보 업데이트 if (data.usage) { updateUsageDisplay(data.usage); } } else { // 알 수 없는 응답 타입 console.error('알 수 없는 응답 타입:', data.type); addErrorMessage('예상하지 못한 응답 형식입니다.'); } } else { // 오류 메시지 표시 addErrorMessage(data.error || '알 수 없는 오류가 발생했습니다.'); } } catch (error) { console.error('메시지 전송 오류:', error); addErrorMessage('네트워크 오류가 발생했습니다. 다시 시도해주세요.'); } finally { setLoadingState(false); // 입력창에 포커스 if (chatbot.elements.messageInput) { chatbot.elements.messageInput.focus(); } } } /** * FAQ 제안 목록 표시 */ function showFAQSuggestions(suggestions, originalMessage) { if (!chatbot.elements.chatContainer || !suggestions || !Array.isArray(suggestions)) { console.error('FAQ 제안 표시 오류: 잘못된 데이터', suggestions); addErrorMessage('FAQ 제안을 표시할 수 없습니다.'); return; } const suggestionDiv = document.createElement('div'); suggestionDiv.className = 'faq-suggestions'; let suggestionHTML = `
관련된 자주 묻는 질문들을 찾았습니다:
원하시는 질문을 선택해주세요.
`; // FAQ 옵션 버튼들 생성 suggestions.forEach((faq, index) => { if (faq && faq.faq_id && faq.question) { suggestionHTML += ` `; } }); // "해당되는 질문이 없음" 버튼 추가 suggestionHTML += `
`; suggestionDiv.innerHTML = suggestionHTML; chatbot.elements.chatContainer.appendChild(suggestionDiv); // 버튼 이벤트 리스너 등록 const faqButtons = suggestionDiv.querySelectorAll('.faq-option-btn'); faqButtons.forEach(button => { button.addEventListener('click', handleFAQSelection); }); scrollToBottom(); } /** * FAQ 선택 처리 */ async function handleFAQSelection(event) { const button = event.currentTarget; const faqId = button.getAttribute('data-faq-id'); const originalMessage = decodeURIComponent(button.getAttribute('data-original-message')); // 버튼 비활성화 const allButtons = document.querySelectorAll('.faq-option-btn'); allButtons.forEach(btn => { btn.disabled = true; btn.style.opacity = '0.6'; }); // 선택된 버튼 강조 button.style.background = 'var(--primary-color)'; button.style.color = 'white'; setLoadingState(true); try { let requestBody; if (button.classList.contains('no-match-btn')) { requestBody = { action: 'no_faq_match', original_message: originalMessage }; } else { requestBody = { action: 'select_faq', faq_id: parseInt(faqId), original_message: originalMessage }; } const response = await fetch(chatbot.apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(requestBody) }); const data = await response.json(); if (data.success && data.type === 'direct_answer') { if (data.message) { // FAQ 응답인지 AI 응답인지 확인 const isAIResponse = requestBody.action === 'no_faq_match'; if (isAIResponse) { // AI 응답: 타이핑 효과 적용 await addMessageWithTyping(data.message, 'bot', { tokens_used: data.tokens_used || 0, response_time: data.response_time || 0 }); } else { // FAQ 응답: 즉시 표시 addMessage(data.message, 'bot', { tokens_used: data.tokens_used || 0, response_time: data.response_time || 0 }); } } if (data.usage) { updateUsageDisplay(data.usage); } } else { addErrorMessage(data.error || '처리 중 오류가 발생했습니다.'); } } catch (error) { console.error('FAQ 선택 오류:', error); addErrorMessage('네트워크 오류가 발생했습니다. 다시 시도해주세요.'); } finally { setLoadingState(false); if (chatbot.elements.messageInput) { chatbot.elements.messageInput.focus(); } } } // =================================== // 메시지 표시 함수 // =================================== /** * 메시지를 채팅 컨테이너에 추가 */ function addMessage(content, type, metadata = null) { if (!chatbot.elements.chatContainer) return; const messageDiv = document.createElement('div'); messageDiv.className = `message ${type}`; // 아바타 생성 const avatar = document.createElement('div'); avatar.className = 'message-avatar'; avatar.innerHTML = type === 'user' ? '' : ''; // 메시지 내용 생성 const contentDiv = document.createElement('div'); contentDiv.className = 'message-content'; contentDiv.innerHTML = formatMessageContent(content); // 메타데이터 추가 if (metadata && (metadata.tokens_used > 0 || metadata.response_time > 0)) { const metaDiv = document.createElement('div'); metaDiv.className = 'message-meta'; metaDiv.innerHTML = `토큰: ${metadata.tokens_used} | 응답시간: ${metadata.response_time}초`; contentDiv.appendChild(metaDiv); } // 요소 조립 messageDiv.appendChild(avatar); messageDiv.appendChild(contentDiv); // 채팅 컨테이너에 추가 chatbot.elements.chatContainer.appendChild(messageDiv); // 스크롤 하단으로 이동 scrollToBottom(); } /** * 타이핑 효과와 함께 메시지 추가 */ async function addMessageWithTyping(content, type, metadata = null) { if (!chatbot.elements.chatContainer) return; // 메시지 컨테이너 생성 const messageDiv = document.createElement('div'); messageDiv.className = `message ${type}`; const avatar = document.createElement('div'); avatar.className = 'message-avatar'; avatar.innerHTML = type === 'user' ? '' : ''; const contentDiv = document.createElement('div'); contentDiv.className = 'message-content'; messageDiv.appendChild(avatar); messageDiv.appendChild(contentDiv); chatbot.elements.chatContainer.appendChild(messageDiv); // 타이핑 효과 await typeMessage(contentDiv, content, chatbot.settings.typingSpeed); // 메타데이터 추가 if (metadata && (metadata.tokens_used > 0 || metadata.response_time > 0)) { const metaDiv = document.createElement('div'); metaDiv.className = 'message-meta'; metaDiv.innerHTML = `토큰: ${metadata.tokens_used} | 응답시간: ${metadata.response_time}초`; contentDiv.appendChild(metaDiv); } scrollToBottom(); } /** * 타이핑 효과 구현 */ async function typeMessage(element, text, speed) { if (!text || typeof text !== 'string') { console.error('타이핑 효과 오류: 잘못된 텍스트', text); element.innerHTML = '메시지를 표시할 수 없습니다.'; return; } const formattedText = formatMessageContent(text); element.innerHTML = ''; let tempDiv = document.createElement('div'); tempDiv.innerHTML = formattedText; const textContent = tempDiv.textContent || tempDiv.innerText || ''; for (let i = 0; i <= textContent.length; i++) { element.textContent = textContent.substring(0, i); await new Promise(resolve => setTimeout(resolve, speed)); scrollToBottom(); } // 최종적으로 HTML 포맷팅 적용 element.innerHTML = formattedText; } /** * 메시지 내용 포맷팅 (마크다운 지원) */ function formatMessageContent(content) { if (!content || typeof content !== 'string') { return '메시지를 표시할 수 없습니다.'; } // 1. HTML 이스케이프 (보안) let html = content .replace(/&/g, '&') .replace(//g, '>'); // 2. 마크다운 문법 변환 html = html // 굵은 글씨: **텍스트** → 텍스트 .replace(/\*\*(.*?)\*\*/g, '$1') // 기울임 글씨: *텍스트* → 텍스트 .replace(/(?$1') // 코드: `텍스트` → 텍스트 .replace(/`(.*?)`/g, '$1') // 링크: [텍스트](URL) → 텍스트 .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ') // 줄바꿈: \n →
.replace(/\n/g, '
'); // 3. 리스트 처리 html = processMarkdownLists(html); return html; } /** * 마크다운 리스트 처리 */ function processMarkdownLists(html) { // 1. 번호 리스트 처리: 1. 항목 html = html.replace(/^(\d+)\.\s(.+)$/gm, '
  • $2
  • '); // 2. 일반 리스트 처리: - 항목 또는 * 항목 html = html.replace(/^[-*]\s(.+)$/gm, '
  • $1
  • '); // 3. 연속된 리스트 항목들을 ul/ol로 감싸기 // 번호 리스트 html = html.replace(/(
  • ]*>.*?<\/li>)(\s*
    \s*
  • ]*>.*?<\/li>)*/g, function(match) { const cleanMatch = match.replace(/
    \s*/g, ''); return '
      ' + cleanMatch.replace(/data-number="\d+"/g, '') + '
    '; }); // 일반 리스트 html = html.replace(/(
  • .*?<\/li>)(\s*
    \s*
  • .*?<\/li>)*/g, function(match) { const cleanMatch = match.replace(/
    \s*/g, ''); return ''; }); return html; } /** * 오류 메시지 표시 */ function addErrorMessage(error, suggestion = '') { if (!chatbot.elements.chatContainer) return; const errorDiv = document.createElement('div'); errorDiv.className = 'error-message'; errorDiv.innerHTML = ` 오류: ${error} ${suggestion ? `
    해결방법: ${suggestion}` : ''} `; chatbot.elements.chatContainer.appendChild(errorDiv); scrollToBottom(); } // =================================== // UI 상태 관리 // =================================== /** * 로딩 상태 설정 */ function setLoadingState(loading) { chatbot.isLoading = loading; if (chatbot.elements.sendButton) { chatbot.elements.sendButton.disabled = loading; chatbot.elements.sendButton.innerHTML = loading ? ' 전송중...' : ' 전송'; } if (chatbot.elements.loadingContainer) { chatbot.elements.loadingContainer.style.display = loading ? 'block' : 'none'; } if (chatbot.elements.messageInput) { chatbot.elements.messageInput.disabled = loading; } } /** * 입력 길이 체크 */ function checkInputLength() { if (!chatbot.elements.messageInput) return; const length = chatbot.elements.messageInput.value.length; const maxLength = chatbot.settings.maxMessageLength; if (length > maxLength) { chatbot.elements.messageInput.value = chatbot.elements.messageInput.value.substring(0, maxLength); showInputError(`최대 ${maxLength}자까지 입력 가능합니다.`); } } /** * 입력 오류 메시지 표시 */ function showInputError(message) { // 기존 오류 메시지 제거 const existingError = document.querySelector('.input-error'); if (existingError) { existingError.remove(); } // 새 오류 메시지 생성 const errorDiv = document.createElement('div'); errorDiv.className = 'input-error error-message'; errorDiv.textContent = message; // 입력 컨테이너 다음에 삽입 const inputContainer = document.querySelector('.input-container'); if (inputContainer) { inputContainer.insertAdjacentElement('afterend', errorDiv); // 3초 후 자동 제거 setTimeout(() => { if (errorDiv.parentNode) { errorDiv.remove(); } }, 3000); } } /** * 일반 오류 메시지 표시 */ function showError(message) { console.error('챗봇 오류:', message); addErrorMessage(message); } /** * 채팅 컨테이너 하단으로 스크롤 */ function scrollToBottom() { if (chatbot.elements.chatContainer) { setTimeout(() => { chatbot.elements.chatContainer.scrollTop = chatbot.elements.chatContainer.scrollHeight; }, chatbot.settings.autoScrollDelay); } } // =================================== // 사용량 통계 관리 // =================================== /** * 사용량 통계 로드 */ async function loadUsageStats() { try { const response = await fetch(chatbot.apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ action: 'get_usage' }) }); const data = await response.json(); if (data.success && data.usage) { updateUsageDisplay(data.usage); } } catch (error) { console.error('사용량 통계 로드 오류:', error); } } /** * 사용량 표시 업데이트 */ function updateUsageDisplay(usage) { if (!chatbot.elements.tokenStats) return; const percentage = usage.usage_percentage || 0; const statusClass = percentage > 90 ? 'danger' : percentage > 70 ? 'warning' : 'success'; chatbot.elements.tokenStats.innerHTML = `
    오늘 사용량: ${usage.total_tokens?.toLocaleString() || 0}/${usage.daily_limit?.toLocaleString() || 0} 토큰 (${percentage.toFixed(1)}%)
    메시지: ${usage.total_messages || 0}개
    ${usage.date}
    `; } // =================================== // 대화 기록 관리 // =================================== /** * 대화 기록 삭제 */ async function clearChatHistory() { if (!confirm('모든 대화 기록을 삭제하시겠습니까?')) { return; } try { const response = await fetch(chatbot.apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ action: 'clear_history' }) }); const data = await response.json(); if (data.success) { // 화면에서 메시지들 제거 if (chatbot.elements.chatContainer) { const messages = chatbot.elements.chatContainer.querySelectorAll('.message, .error-message'); messages.forEach(msg => msg.remove()); } // 성공 메시지 표시 addMessage('대화 기록이 삭제되었습니다.', 'bot'); console.log('대화 기록이 삭제되었습니다.'); } else { showError('대화 기록 삭제에 실패했습니다: ' + data.message); } } catch (error) { showError('서버 연결에 실패했습니다: ' + error.message); console.error('대화 기록 삭제 오류:', error); } } // =================================== // 주기적 업데이트 // =================================== /** * 주기적 업데이트 시작 */ function startPeriodicUpdates() { // 사용량 통계를 30초마다 업데이트 setInterval(loadUsageStats, 30000); // 세션 활성 상태 유지 (5분마다) setInterval(() => { if (chatbot.sessionId) { // 세션 핑 (필요시 구현) } }, 300000); } // =================================== // 초기화 및 이벤트 바인딩 // =================================== /** * DOM 로드 완료 시 챗봇 초기화 */ document.addEventListener('DOMContentLoaded', function() { console.log('챗봇 초기화 시작...'); initializeChatbot(); }); /** * 페이지 가시성 변경 시 처리 */ document.addEventListener('visibilitychange', function() { if (!document.hidden && chatbot.sessionId) { // 페이지가 다시 보일 때 사용량 통계 새로고침 loadUsageStats(); } });