/** * RAG 챗봇 클라이언트 - 채팅 기능 * 텍스트 및 음성 대화 기능 구현 */ document.addEventListener('DOMContentLoaded', function() { // DOM 요소 const chatMessages = document.getElementById('chatMessages'); const userInput = document.getElementById('userInput'); const sendButton = document.getElementById('sendButton'); const micButton = document.getElementById('micButton'); const clearChat = document.getElementById('clearChat'); const recordingAlert = document.getElementById('recordingAlert'); const processingAlert = document.getElementById('processingAlert'); const typingAlert = document.getElementById('typingAlert'); const sourceList = document.getElementById('sourceList'); // 설정 요소 const retrieverType = document.getElementById('retrieverType'); const topK = document.getElementById('topK'); const temperatureSlider = document.getElementById('temperature'); const temperatureValue = document.getElementById('temperatureValue'); // 음성 녹음 관련 변수 let mediaRecorder; let audioChunks = []; let isRecording = false; // marked.js 설정 (마크다운 파서) marked.setOptions({ renderer: new marked.Renderer(), highlight: function(code, language) { const validLang = hljs.getLanguage(language) ? language : 'plaintext'; return hljs.highlight(validLang, code).value; }, gfm: true, breaks: true }); // 채팅 기록 로드 loadChatHistory(); // 이벤트 리스너 설정 // 텍스트 입력 이벤트 userInput.addEventListener('keypress', function(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); // 전송 버튼 클릭 이벤트 sendButton.addEventListener('click', sendMessage); // 마이크 버튼 클릭 이벤트 micButton.addEventListener('click', toggleRecording); // 녹음 알림 클릭 이벤트 (녹음 중지) recordingAlert.addEventListener('click', stopRecording); // 대화 지우기 버튼 클릭 이벤트 clearChat.addEventListener('click', function() { if (confirm('정말 대화 내용을 모두 지우시겠습니까?')) { chatMessages.innerHTML = ''; sourceList.innerHTML = '
참고 문서가 여기에 표시됩니다
'; localStorage.removeItem('chatHistory'); } }); // 온도 슬라이더 이벤트 temperatureSlider.addEventListener('input', function() { temperatureValue.textContent = this.value; }); /** * 텍스트 메시지 전송 함수 */ function sendMessage() { const message = userInput.value.trim(); if (message === '') return; // 사용자 메시지 표시 addUserMessage(message); userInput.value = ''; // 설정 가져오기 const retriever = retrieverType.value; const k = parseInt(topK.value); const temperature = parseFloat(temperatureSlider.value); // 봇 응답 가져오기 getBotResponse(message, retriever, k, temperature); } /** * 음성 녹음 토글 함수 */ async function toggleRecording() { if (!isRecording) { startRecording(); } else { stopRecording(); } } /** * 음성 녹음 시작 함수 */ async function startRecording() { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); mediaRecorder = new MediaRecorder(stream); audioChunks = []; mediaRecorder.addEventListener('dataavailable', event => { audioChunks.push(event.data); }); mediaRecorder.addEventListener('stop', async () => { const audioBlob = new Blob(audioChunks, { type: 'audio/wav' }); await processAudio(audioBlob); }); mediaRecorder.start(); isRecording = true; micButton.classList.add('recording'); recordingAlert.classList.remove('d-none'); } catch (err) { console.error('마이크 접근 오류:', err); alert('마이크 접근에 실패했습니다. 마이크 권한을 확인해주세요.'); } } /** * 음성 녹음 중지 함수 */ function stopRecording() { if (mediaRecorder && isRecording) { mediaRecorder.stop(); isRecording = false; micButton.classList.remove('recording'); recordingAlert.classList.add('d-none'); processingAlert.classList.remove('d-none'); // 스트림 트랙 중지 mediaRecorder.stream.getTracks().forEach(track => track.stop()); } } /** * 녹음된 오디오 처리 함수 * @param {Blob} audioBlob - 녹음된 오디오 데이터 */ async function processAudio(audioBlob) { try { const formData = new FormData(); formData.append('audio', audioBlob, 'recording.wav'); const response = await fetch('/api/voice', { method: 'POST', body: formData }); processingAlert.classList.add('d-none'); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || '음성 처리 중 오류가 발생했습니다.'); } const data = await response.json(); // 인식된 텍스트가 있으면 사용자 메시지로 표시 if (data.transcription) { addUserMessage(data.transcription); // 봇 응답이 있으면 표시 if (data.answer) { addBotMessage(data.answer, data.context_docs || []); } } else { throw new Error('음성 인식에 실패했습니다.'); } } catch (err) { console.error('음성 처리 오류:', err); processingAlert.classList.add('d-none'); alert('음성 처리 중 오류가 발생했습니다: ' + err.message); } } /** * API로부터 봇 응답 가져오기 * @param {string} message - 사용자 메시지 * @param {string} retriever - 검색기 유형 * @param {number} k - Top-K 문서 수 * @param {number} temperature - 생성 다양성 */ async function getBotResponse(message, retriever, k, temperature) { try { typingAlert.classList.remove('d-none'); sourceList.innerHTML = '
참고 문서 로딩중...
'; const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: message, retriever_type: retriever, top_k: k, temperature: temperature }) }); typingAlert.classList.add('d-none'); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || '응답을 가져오는 중 오류가 발생했습니다.'); } const data = await response.json(); addBotMessage(data.answer, data.context_docs || []); } catch (err) { console.error('API 요청 오류:', err); typingAlert.classList.add('d-none'); alert('봇 응답을 가져오는 중 오류가 발생했습니다: ' + err.message); } } /** * 사용자 메시지 추가 함수 * @param {string} message - 사용자 메시지 */ function addUserMessage(message) { const template = document.getElementById('userMessageTemplate'); const messageNode = template.content.cloneNode(true); messageNode.querySelector('.message-text').textContent = message; chatMessages.appendChild(messageNode); // 스크롤 하단으로 이동 chatMessages.scrollTop = chatMessages.scrollHeight; // 채팅 기록 저장 saveChatHistory(); } /** * 봇 메시지 추가 함수 * @param {string} message - 봇 메시지 * @param {Array} sources - 참고 문서 목록 */ function addBotMessage(message, sources) { const template = document.getElementById('botMessageTemplate'); const messageNode = template.content.cloneNode(true); // 마크다운 변환 및 코드 하이라이트 적용 const sanitizedHTML = DOMPurify.sanitize(marked.parse(message)); messageNode.querySelector('.message-text').innerHTML = sanitizedHTML; // 코드 하이라이팅 적용 messageNode.querySelectorAll('pre code').forEach((block) => { hljs.highlightBlock(block); }); chatMessages.appendChild(messageNode); // 스크롤 하단으로 이동 chatMessages.scrollTop = chatMessages.scrollHeight; // 참고 문서 표시 updateSourceList(sources); // 채팅 기록 저장 saveChatHistory(); } /** * 참고 문서 목록 업데이트 함수 * @param {Array} sources - 참고 문서 목록 */ function updateSourceList(sources) { sourceList.innerHTML = ''; if (!sources || sources.length === 0) { sourceList.innerHTML = '
참고 문서가 없습니다
'; return; } sources.forEach((source, index) => { const sourceItem = document.createElement('div'); sourceItem.className = 'list-group-item source-item'; const score = source.score || 0; const scoreValue = Math.round(score * 100) / 100; const scoreColor = getScoreColor(score); sourceItem.innerHTML = `
${scoreValue.toFixed(2)}
${source.source || '문서 #' + (index + 1)}
관련성 점수: ${scoreValue.toFixed(2)}
`; sourceList.appendChild(sourceItem); }); } /** * 점수 색상 계산 함수 * @param {number} score - 관련성 점수 (0-1) * @returns {string} - 색상 코드 */ function getScoreColor(score) { if (score >= 0.8) return '#198754'; // 높은 관련성 (초록색) if (score >= 0.6) return '#0d6efd'; // 중간 관련성 (파란색) if (score >= 0.4) return '#fd7e14'; // 낮은 관련성 (주황색) return '#6c757d'; // 매우 낮은 관련성 (회색) } /** * 채팅 기록 저장 함수 */ function saveChatHistory() { const history = { messages: [], sources: [] }; // 메시지 저장 document.querySelectorAll('.chat-message').forEach(msg => { const isUser = msg.classList.contains('user-message'); const text = msg.querySelector('.message-text').textContent || msg.querySelector('.message-text').innerHTML; history.messages.push({ isUser: isUser, content: text }); }); // 참고 문서 저장 const sourcesHtml = sourceList.innerHTML; history.sources = sourcesHtml; // 로컬 스토리지에 저장 localStorage.setItem('chatHistory', JSON.stringify(history)); } /** * 채팅 기록 로드 함수 */ function loadChatHistory() { const history = localStorage.getItem('chatHistory'); if (!history) return; try { const historyData = JSON.parse(history); // 메시지 로드 historyData.messages.forEach(msg => { if (msg.isUser) { const template = document.getElementById('userMessageTemplate'); const messageNode = template.content.cloneNode(true); messageNode.querySelector('.message-text').textContent = msg.content; chatMessages.appendChild(messageNode); } else { const template = document.getElementById('botMessageTemplate'); const messageNode = template.content.cloneNode(true); // 마크다운 변환 및 코드 하이라이트 적용 const sanitizedHTML = DOMPurify.sanitize(marked.parse(msg.content)); messageNode.querySelector('.message-text').innerHTML = sanitizedHTML; // 코드 하이라이팅 적용 messageNode.querySelectorAll('pre code').forEach((block) => { hljs.highlightBlock(block); }); chatMessages.appendChild(messageNode); } }); // 참고 문서 로드 if (historyData.sources) { sourceList.innerHTML = historyData.sources; } // 스크롤 하단으로 이동 chatMessages.scrollTop = chatMessages.scrollHeight; } catch (err) { console.error('채팅 기록 로드 실패:', err); localStorage.removeItem('chatHistory'); } } });