/** * RAG 검색 챗봇 UI JavaScript * 메인 파일 - 코어 및 장치 제어 모듈과 통합 */ // DOM 요소 const chatTab = document.getElementById('chatTab'); const docsTab = document.getElementById('docsTab'); const deviceTab = document.getElementById('deviceTab'); // 장치 제어 탭 추가 const chatSection = document.getElementById('chatSection'); const docsSection = document.getElementById('docsSection'); const deviceSection = document.getElementById('deviceSection'); // 장치 제어 섹션 추가 const chatMessages = document.getElementById('chatMessages'); const userInput = document.getElementById('userInput'); const sendButton = document.getElementById('sendButton'); const micButton = document.getElementById('micButton'); const stopRecordingButton = document.getElementById('stopRecordingButton'); const recordingStatus = document.getElementById('recordingStatus'); const uploadForm = document.getElementById('uploadForm'); const documentFile = document.getElementById('documentFile'); const fileName = document.getElementById('fileName'); const uploadButton = document.getElementById('uploadButton'); const uploadStatus = document.getElementById('uploadStatus'); const refreshDocsButton = document.getElementById('refreshDocsButton'); const docsList = document.getElementById('docsList'); const docsLoading = document.getElementById('docsLoading'); const noDocsMessage = document.getElementById('noDocsMessage'); const llmSelect = document.getElementById('llmSelect'); const currentLLMInfo = document.getElementById('currentLLMInfo'); // LLM 관련 변수 let currentLLM = 'openai'; let supportedLLMs = []; // 녹음 관련 변수 let mediaRecorder = null; let audioChunks = []; let isRecording = false; // 앱 초기화 상태 확인 함수 async function checkAppStatus() { try { console.log('앱 상태 확인 요청 전송'); const response = await fetch('/api/status'); if (!response.ok) { console.error(`앱 상태 확인 실패: ${response.status}`); return false; } const data = await response.json(); console.log(`앱 상태 확인 결과: ${data.ready ? '준비됨' : '준비 안됨'}`); return data.ready; } catch (error) { console.error('앱 상태 확인 중 오류 발생:', error); return false; } } /** * LLM 목록 로드 함수 */ async function loadLLMs() { try { // API 요청 console.log('LLM 목록 요청 전송'); const response = await AppUtils.fetchWithTimeout('/api/llm', { method: 'GET' }); const data = await response.json(); supportedLLMs = data.supported_llms; currentLLM = data.current_llm.id; console.log(`LLM 목록 로드 성공: ${supportedLLMs.length}개 모델`); // LLM 선택 드롭다운 업데이트 llmSelect.innerHTML = ''; supportedLLMs.forEach(llm => { const option = document.createElement('option'); option.value = llm.id; option.textContent = llm.name; option.selected = llm.current; llmSelect.appendChild(option); }); // 현재 LLM 표시 updateCurrentLLMInfo(data.current_llm); } catch (error) { console.error('LLM 목록 로드 실패:', error); } } /** * LLM 변경 함수 * @param {string} llmId - 변경할 LLM ID */ async function changeLLM(llmId) { try { // API 요청 console.log(`LLM 변경 요청: ${llmId}`); const response = await AppUtils.fetchWithTimeout('/api/llm', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ llm_id: llmId }) }); const data = await response.json(); if (data.success) { currentLLM = llmId; updateCurrentLLMInfo(data.current_llm); console.log(`LLM 변경 성공: ${data.current_llm.name}`); // 시스템 메시지 추가 const systemMessage = `LLM이 ${data.current_llm.name}(으)로 변경되었습니다. 모델: ${data.current_llm.model}`; AppUtils.addSystemNotification(systemMessage); } else if (data.error) { console.error('LLM 변경 오류:', data.error); alert(`LLM 변경 오류: ${data.error}`); } } catch (error) { console.error('LLM 변경 실패:', error); alert('LLM 변경 중 오류가 발생했습니다.'); } } /** * 현재 LLM 정보 표시 업데이트 * @param {Object} llmInfo - LLM 정보 객체 */ function updateCurrentLLMInfo(llmInfo) { if (currentLLMInfo) { currentLLMInfo.textContent = `${llmInfo.name} (${llmInfo.model})`; } } // 탭 전환 함수 function switchTab(tabName) { console.log(`탭 전환: ${tabName}`); // 모든 탭과 섹션 비활성화 chatTab.classList.remove('active'); docsTab.classList.remove('active'); deviceTab.classList.remove('active'); chatSection.classList.remove('active'); docsSection.classList.remove('active'); deviceSection.classList.remove('active'); // 선택한 탭과 섹션 활성화 if (tabName === 'chat') { chatTab.classList.add('active'); chatSection.classList.add('active'); } else if (tabName === 'docs') { docsTab.classList.add('active'); docsSection.classList.add('active'); // 문서 목록 로드 loadDocuments(); } else if (tabName === 'device') { deviceTab.classList.add('active'); deviceSection.classList.add('active'); } } // 채팅 메시지 전송 함수 async function sendMessage() { const message = userInput.value.trim(); if (!message) { console.log('메시지가 비어있어 전송하지 않음'); return; } console.log('메시지 전송 시작'); // UI 업데이트 addMessage(message, 'user'); userInput.value = ''; adjustTextareaHeight(); // 로딩 메시지 추가 const loadingMessageId = addLoadingMessage(); try { // API 요청 console.log(`/api/chat API 호출: ${message.substring(0, 30)}${message.length > 30 ? '...' : ''}`); const response = await AppUtils.fetchWithTimeout('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: message, llm_id: currentLLM // 현재 선택된 LLM 전송 }) }); // 로딩 메시지 제거 removeLoadingMessage(loadingMessageId); // 응답 형식 확인 let data; try { data = await response.json(); console.log('API 응답 수신 완료'); // 디버깅: 응답 구조 및 내용 로깅 console.log('응답 구조:', Object.keys(data)); if (data.answer) { console.log('응답 길이:', data.answer.length); console.log('응답 내용 일부:', data.answer.substring(0, 50) + '...'); } } catch (jsonError) { console.error('응답 JSON 파싱 실패:', jsonError); AppUtils.addErrorMessage('서버 응답을 처리할 수 없습니다. 다시 시도해 주세요.'); return; } // 응답 표시 if (data.error) { console.error(`API 오류 응답: ${data.error}`); AppUtils.addErrorMessage(data.error); } else if (!data.answer || data.answer.trim() === '') { console.error('응답 내용이 비어있음'); AppUtils.addErrorMessage('서버에서 빈 응답을 받았습니다. 다시 시도해 주세요.'); } else { // LLM 정보 업데이트 if (data.llm) { console.log(`LLM 정보 업데이트: ${data.llm.name}`); updateCurrentLLMInfo(data.llm); } try { // 메시지 추가 addMessage(data.answer, 'bot', null, data.sources); console.log('챗봇 응답 표시 완료'); } catch (displayError) { console.error('응답 표시 중 오류:', displayError); AppUtils.addErrorMessage('응답을 표시하는 중 오류가 발생했습니다. 다시 시도해 주세요.'); } } } catch (error) { console.error('메시지 전송 중 오류 발생:', error); removeLoadingMessage(loadingMessageId); AppUtils.addErrorMessage('오류가 발생했습니다. 다시 시도해 주세요.'); } } /** * 음성 녹음 시작 함수 */ async function startRecording() { if (isRecording) { console.log('이미 녹음 중'); return; } try { console.log('마이크 접근 권한 요청'); const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); isRecording = true; audioChunks = []; mediaRecorder = new MediaRecorder(stream); mediaRecorder.addEventListener('dataavailable', (event) => { if (event.data.size > 0) { console.log('오디오 청크 데이터 수신'); audioChunks.push(event.data); } }); mediaRecorder.addEventListener('stop', () => { console.log('녹음 중지 이벤트 - 오디오 메시지 전송'); sendAudioMessage(); }); // 녹음 시작 mediaRecorder.start(); console.log('녹음 시작됨'); // UI 업데이트 micButton.style.display = 'none'; recordingStatus.classList.remove('hidden'); } catch (error) { console.error('음성 녹음 권한을 얻을 수 없습니다:', error); alert('마이크 접근 권한이 필요합니다.'); } } /** * 음성 녹음 중지 함수 */ function stopRecording() { if (!isRecording || !mediaRecorder) { console.log('녹음 중이 아님'); return; } console.log('녹음 중지 요청'); mediaRecorder.stop(); isRecording = false; // UI 업데이트 micButton.style.display = 'flex'; recordingStatus.classList.add('hidden'); } /** * 녹음된 오디오 메시지 전송 함수 */ async function sendAudioMessage() { if (audioChunks.length === 0) { console.log('오디오 청크가 없음'); return; } console.log(`오디오 메시지 전송 시작: ${audioChunks.length}개 청크`); // 오디오 Blob 생성 const audioBlob = new Blob(audioChunks, { type: 'audio/wav' }); // 로딩 메시지 추가 const loadingMessageId = addLoadingMessage(); try { // FormData에 오디오 추가 const formData = new FormData(); formData.append('audio', audioBlob, 'recording.wav'); // 현재 선택된 LLM 추가 formData.append('llm_id', currentLLM); console.log('/api/voice API 호출'); // API 요청 const response = await AppUtils.fetchWithTimeout('/api/voice', { method: 'POST', body: formData }, 30000); // 음성 처리는 더 긴 타임아웃 // 로딩 메시지 제거 removeLoadingMessage(loadingMessageId); // 응답 형식 확인 let data; try { data = await response.json(); console.log('음성 API 응답 수신 완료'); // 디버깅: 응답 구조 및 내용 로깅 console.log('음성 응답 구조:', Object.keys(data)); if (data.answer) { console.log('음성 응답 길이:', data.answer.length); console.log('음성 응답 내용 일부:', data.answer.substring(0, 50) + '...'); } if (data.transcription) { console.log('음성 인식 길이:', data.transcription.length); } } catch (jsonError) { console.error('음성 응답 JSON 파싱 실패:', jsonError); AppUtils.addErrorMessage('서버 응답을 처리할 수 없습니다. 다시 시도해 주세요.'); return; } // 응답 표시 if (data.error) { console.error(`음성 API 오류 응답: ${data.error}`); AppUtils.addErrorMessage(data.error); } else if (!data.answer || data.answer.trim() === '') { console.error('음성 응답 내용이 비어있음'); AppUtils.addErrorMessage('서버에서 빈 응답을 받았습니다. 다시 시도해 주세요.'); } else { try { // LLM 정보 업데이트 if (data.llm) { console.log(`LLM 정보 업데이트: ${data.llm.name}`); updateCurrentLLMInfo(data.llm); } // 사용자 메시지(음성 텍스트) 추가 if (data.transcription) { console.log(`음성 인식 결과: ${data.transcription.substring(0, 30)}${data.transcription.length > 30 ? '...' : ''}`); addMessage(data.transcription, 'user'); } // 봇 응답 추가 addMessage(data.answer, 'bot', data.transcription, data.sources); console.log('음성 채팅 응답 표시 완료'); } catch (displayError) { console.error('음성 응답 표시 중 오류:', displayError); AppUtils.addErrorMessage('응답을 표시하는 중 오류가 발생했습니다. 다시 시도해 주세요.'); } } } catch (error) { console.error('음성 메시지 전송 중 오류 발생:', error); removeLoadingMessage(loadingMessageId); AppUtils.addErrorMessage('오디오 처리 중 오류가 발생했습니다. 다시 시도해 주세요.'); } } /** * 문서 업로드 함수 */ async function uploadDocument() { if (documentFile.files.length === 0) { console.log('선택된 파일 없음'); alert('파일을 선택해 주세요.'); return; } console.log(`문서 업로드 시작: ${documentFile.files[0].name}`); // UI 업데이트 uploadStatus.classList.remove('hidden'); uploadStatus.className = 'upload-status'; uploadStatus.innerHTML = '
업로드 중...
'; uploadButton.disabled = true; try { const formData = new FormData(); formData.append('document', documentFile.files[0]); // API 요청 console.log('/api/upload API 호출'); const response = await AppUtils.fetchWithTimeout('/api/upload', { method: 'POST', body: formData }, 20000); // 업로드는 더 긴 타임아웃 const data = await response.json(); console.log('업로드 API 응답 수신 완료'); // 응답 처리 if (data.error) { console.error(`업로드 오류: ${data.error}`); uploadStatus.className = 'upload-status error'; uploadStatus.textContent = `오류: ${data.error}`; } else if (data.warning) { console.warn(`업로드 경고: ${data.message}`); uploadStatus.className = 'upload-status warning'; uploadStatus.textContent = data.message; } else { console.log(`업로드 성공: ${data.message}`); uploadStatus.className = 'upload-status success'; uploadStatus.textContent = data.message; // 문서 목록 새로고침 loadDocuments(); // 입력 필드 초기화 documentFile.value = ''; fileName.textContent = '선택된 파일 없음'; } } catch (error) { console.error('문서 업로드 중 오류 발생:', error); uploadStatus.className = 'upload-status error'; uploadStatus.textContent = '업로드 중 오류가 발생했습니다. 다시 시도해 주세요.'; } finally { uploadButton.disabled = false; } } /** * 문서 목록 로드 함수 */ async function loadDocuments() { console.log('문서 목록 로드 시작'); // UI 업데이트 docsList.querySelector('tbody').innerHTML = ''; docsLoading.classList.remove('hidden'); noDocsMessage.classList.add('hidden'); try { // API 요청 console.log('/api/documents API 호출'); const response = await AppUtils.fetchWithTimeout('/api/documents', { method: 'GET' }); const data = await response.json(); console.log(`문서 목록 로드 성공: ${data.documents ? data.documents.length : 0}개 문서`); // 응답 처리 docsLoading.classList.add('hidden'); if (!data.documents || data.documents.length === 0) { console.log('문서 없음'); noDocsMessage.classList.remove('hidden'); return; } // 문서 목록 업데이트 const tbody = docsList.querySelector('tbody'); data.documents.forEach(doc => { const row = document.createElement('tr'); const fileNameCell = document.createElement('td'); fileNameCell.textContent = doc.filename || doc.source; row.appendChild(fileNameCell); const chunksCell = document.createElement('td'); chunksCell.textContent = doc.chunks; row.appendChild(chunksCell); const typeCell = document.createElement('td'); typeCell.textContent = doc.filetype || '-'; row.appendChild(typeCell); tbody.appendChild(row); }); } catch (error) { console.error('문서 목록 로드 중 오류 발생:', error); docsLoading.classList.add('hidden'); noDocsMessage.classList.remove('hidden'); noDocsMessage.querySelector('p').textContent = '문서 목록을 불러오는 중 오류가 발생했습니다.'; } } /** * 메시지 추가 함수 * @param {string} text - 메시지 내용 * @param {string} sender - 메시지 발신자 ('user' 또는 'bot' 또는 'system') * @param {string|null} transcription - 음성 인식 텍스트 (선택 사항) * @param {Array|null} sources - 소스 정보 배열 (선택 사항) */ function addMessage(text, sender, transcription = null, sources = null) { console.log(`메시지 추가: sender=${sender}, length=${text ? text.length : 0}`); const messageDiv = document.createElement('div'); messageDiv.classList.add('message', sender); const contentDiv = document.createElement('div'); contentDiv.classList.add('message-content'); // 음성 인식 텍스트 추가 (있는 경우) if (transcription && sender === 'bot') { const transcriptionP = document.createElement('p'); transcriptionP.classList.add('transcription'); transcriptionP.textContent = `"${transcription}"`; contentDiv.appendChild(transcriptionP); } // 메시지 텍스트 추가 const textP = document.createElement('p'); textP.textContent = text; contentDiv.appendChild(textP); // 소스 정보 추가 (있는 경우) if (sources && sources.length > 0 && sender === 'bot') { console.log(`소스 정보 추가: ${sources.length}개 소스`); const sourcesDiv = document.createElement('div'); sourcesDiv.classList.add('sources'); const sourcesTitle = document.createElement('strong'); sourcesTitle.textContent = '출처: '; sourcesDiv.appendChild(sourcesTitle); sources.forEach((source, index) => { if (index < 3) { // 최대 3개까지만 표시 const sourceSpan = document.createElement('span'); sourceSpan.classList.add('source-item'); sourceSpan.textContent = source.source; sourcesDiv.appendChild(sourceSpan); } }); contentDiv.appendChild(sourcesDiv); } messageDiv.appendChild(contentDiv); chatMessages.appendChild(messageDiv); // 스크롤을 가장 아래로 이동 chatMessages.scrollTop = chatMessages.scrollHeight; } /** * 로딩 메시지 추가 함수 * @returns {string} 로딩 메시지 ID */ function addLoadingMessage() { console.log('로딩 메시지 추가'); const id = 'loading-' + Date.now(); const messageDiv = document.createElement('div'); messageDiv.classList.add('message', 'bot'); messageDiv.id = id; const contentDiv = document.createElement('div'); contentDiv.classList.add('message-content'); const loadingP = document.createElement('p'); loadingP.innerHTML = ' 생각 중...'; contentDiv.appendChild(loadingP); messageDiv.appendChild(contentDiv); chatMessages.appendChild(messageDiv); // 스크롤을 가장 아래로 이동 chatMessages.scrollTop = chatMessages.scrollHeight; return id; } /** * 로딩 메시지 제거 함수 * @param {string} id - 로딩 메시지 ID */ function removeLoadingMessage(id) { console.log(`로딩 메시지 제거: ${id}`); const loadingMessage = document.getElementById(id); if (loadingMessage) { loadingMessage.remove(); } } /** * textarea 높이 자동 조정 함수 */ function adjustTextareaHeight() { userInput.style.height = 'auto'; userInput.style.height = Math.min(userInput.scrollHeight, 100) + 'px'; } // 페이지 로드 시 초기화 document.addEventListener('DOMContentLoaded', () => { console.log('메인 UI 초기화 중...'); // 앱 상태 확인 (로딩 페이지가 아닌 경우에만) if (window.location.pathname === '/' && !document.getElementById('app-loading-indicator')) { // 앱 상태 주기적으로 확인 const statusInterval = setInterval(async () => { const isReady = await checkAppStatus(); if (isReady) { clearInterval(statusInterval); console.log('앱이 준비되었습니다.'); // 앱이 준비되면 LLM 목록 로드 loadLLMs(); } }, 5000); } // 탭 전환 이벤트 리스너 chatTab.addEventListener('click', () => { console.log('채팅 탭 클릭'); switchTab('chat'); }); docsTab.addEventListener('click', () => { console.log('문서 관리 탭 클릭'); switchTab('docs'); }); // 장치 제어 탭은 DeviceControl 모듈에서 이벤트 리스너 등록함 // LLM 선택 이벤트 리스너 llmSelect.addEventListener('change', (event) => { console.log(`LLM 변경: ${event.target.value}`); changeLLM(event.target.value); }); // 메시지 전송 이벤트 리스너 sendButton.addEventListener('click', () => { console.log('메시지 전송 버튼 클릭'); sendMessage(); }); userInput.addEventListener('keydown', (event) => { if (event.key === 'Enter' && !event.shiftKey) { console.log('텍스트 입력에서 엔터 키 감지'); event.preventDefault(); sendMessage(); } }); // 음성 인식 이벤트 리스너 micButton.addEventListener('click', () => { console.log('마이크 버튼 클릭'); startRecording(); }); stopRecordingButton.addEventListener('click', () => { console.log('녹음 중지 버튼 클릭'); stopRecording(); }); // 문서 업로드 이벤트 리스너 documentFile.addEventListener('change', (event) => { console.log('파일 선택 변경'); if (event.target.files.length > 0) { fileName.textContent = event.target.files[0].name; } else { fileName.textContent = '선택된 파일 없음'; } }); uploadForm.addEventListener('submit', (event) => { console.log('문서 업로드 폼 제출'); event.preventDefault(); uploadDocument(); }); // 문서 목록 새로고침 이벤트 리스너 refreshDocsButton.addEventListener('click', () => { console.log('문서 목록 새로고침 버튼 클릭'); loadDocuments(); }); // 자동 입력 필드 크기 조정 userInput.addEventListener('input', adjustTextareaHeight); // 초기 문서 목록 로드 if (docsSection.classList.contains('active')) { loadDocuments(); } console.log('메인 UI 초기화 완료'); });