/* * 파일명: 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 = `
`; 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 →