/**
* 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');
}
}
});