/** * 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 { const response = await fetch('/api/status'); if (!response.ok) { return false; } const data = await response.json(); return data.ready; } catch (error) { console.error('Status check failed:', error); return false; } } /** * LLM 목록 로드 함수 */ async function loadLLMs() { try { // API 요청 const response = await fetch('/api/llm'); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); supportedLLMs = data.supported_llms; currentLLM = data.current_llm.id; // 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 요청 const response = await fetch('/api/llm', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ llm_id: llmId }) }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } 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}`; 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})`; } } /** * 시스템 알림 메시지 추가 * @param {string} message - 알림 메시지 */ function addSystemNotification(message) { const messageDiv = document.createElement('div'); messageDiv.classList.add('message', 'system'); const contentDiv = document.createElement('div'); contentDiv.classList.add('message-content'); const messageP = document.createElement('p'); messageP.innerHTML = ` ${message}`; contentDiv.appendChild(messageP); messageDiv.appendChild(contentDiv); chatMessages.appendChild(messageDiv); // 스크롤을 가장 아래로 이동 chatMessages.scrollTop = chatMessages.scrollHeight; } // 페이지 로드 시 초기화 document.addEventListener('DOMContentLoaded', () => { // 앱 상태 확인 (로딩 페이지가 아닌 경우에만) 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'); loadDocuments(); }); // 장치관리 탭 이벤트 리스너 추가 deviceTab.addEventListener('click', () => { console.log("장치관리 탭 클릭"); switchTab('device'); // 장치관리 탭으로 전환 시 필요한 초기화 작업이 있다면 여기서 호출 if (typeof checkDeviceStatus === 'function') { console.log("장치 상태 확인 함수 호출"); checkDeviceStatus(); } else { console.log("checkDeviceStatus 함수가 정의되지 않았습니다"); } }); // LLM 선택 이벤트 리스너 llmSelect.addEventListener('change', (event) => { changeLLM(event.target.value); }); // 메시지 전송 이벤트 리스너 sendButton.addEventListener('click', sendMessage); userInput.addEventListener('keydown', (event) => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); sendMessage(); } }); // 음성 인식 이벤트 리스너 micButton.addEventListener('click', startRecording); stopRecordingButton.addEventListener('click', stopRecording); // 문서 업로드 이벤트 리스너 documentFile.addEventListener('change', (event) => { if (event.target.files.length > 0) { fileName.textContent = event.target.files[0].name; } else { fileName.textContent = '선택된 파일 없음'; } }); uploadForm.addEventListener('submit', (event) => { event.preventDefault(); uploadDocument(); }); // 문서 목록 새로고침 이벤트 리스너 refreshDocsButton.addEventListener('click', loadDocuments); // 자동 입력 필드 크기 조정 userInput.addEventListener('input', adjustTextareaHeight); // 초기 문서 목록 로드 if (docsSection.classList.contains('active')) { loadDocuments(); } }); /** * 탭 전환 함수 * @param {string} tabName - 활성화할 탭 이름 ('chat', 'docs', 'device') */ function switchTab(tabName) { console.log(`switchTab 함수 호출: ${tabName}`); if (tabName === 'chat') { chatTab.classList.add('active'); docsTab.classList.remove('active'); deviceTab.classList.remove('active'); chatSection.classList.add('active'); docsSection.classList.remove('active'); deviceSection.classList.remove('active'); } else if (tabName === 'docs') { chatTab.classList.remove('active'); docsTab.classList.add('active'); deviceTab.classList.remove('active'); chatSection.classList.remove('active'); docsSection.classList.add('active'); deviceSection.classList.remove('active'); } else if (tabName === 'device') { chatTab.classList.remove('active'); docsTab.classList.remove('active'); deviceTab.classList.add('active'); chatSection.classList.remove('active'); docsSection.classList.remove('active'); deviceSection.classList.add('active'); console.log("장치관리 탭으로 전환 완료"); } } /** * 채팅 메시지 전송 함수 */ async function sendMessage() { const message = userInput.value.trim(); if (!message) return; // UI 업데이트 addMessage(message, 'user'); userInput.value = ''; adjustTextareaHeight(); // 로딩 메시지 추가 const loadingMessageId = addLoadingMessage(); try { // API 요청 const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: message, llm_id: currentLLM // 현재 선택된 LLM 전송 }) }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); // 로딩 메시지 제거 removeLoadingMessage(loadingMessageId); // 응답 표시 if (data.error) { addErrorMessage(data.error); } else { // LLM 정보 업데이트 if (data.llm) { updateCurrentLLMInfo(data.llm); } addMessage(data.answer, 'bot', null, data.sources); } } catch (error) { console.error('Error:', error); removeLoadingMessage(loadingMessageId); addErrorMessage('오류가 발생했습니다. 다시 시도해 주세요.'); } } /** * 음성 녹음 시작 함수 */ async function startRecording() { if (isRecording) return; try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); isRecording = true; audioChunks = []; mediaRecorder = new MediaRecorder(stream); mediaRecorder.addEventListener('dataavailable', (event) => { if (event.data.size > 0) audioChunks.push(event.data); }); mediaRecorder.addEventListener('stop', sendAudioMessage); // 녹음 시작 mediaRecorder.start(); // UI 업데이트 micButton.style.display = 'none'; recordingStatus.classList.remove('hidden'); console.log('녹음 시작됨'); } catch (error) { console.error('음성 녹음 권한을 얻을 수 없습니다:', error); alert('마이크 접근 권한이 필요합니다.'); } } /** * 음성 녹음 중지 함수 */ function stopRecording() { if (!isRecording || !mediaRecorder) return; mediaRecorder.stop(); isRecording = false; // UI 업데이트 micButton.style.display = 'flex'; recordingStatus.classList.add('hidden'); console.log('녹음 중지됨'); } /** * 녹음된 오디오 메시지 전송 함수 */ async function sendAudioMessage() { if (audioChunks.length === 0) return; // 오디오 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); // API 요청 const response = await fetch('/api/voice', { method: 'POST', body: formData }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); // 로딩 메시지 제거 removeLoadingMessage(loadingMessageId); // 응답 표시 if (data.error) { addErrorMessage(data.error); } else { // LLM 정보 업데이트 if (data.llm) { updateCurrentLLMInfo(data.llm); } // 사용자 메시지(음성 텍스트) 추가 if (data.transcription) { addMessage(data.transcription, 'user'); } // 봇 응답 추가 addMessage(data.answer, 'bot', data.transcription, data.sources); } } catch (error) { console.error('Error:', error); removeLoadingMessage(loadingMessageId); addErrorMessage('오디오 처리 중 오류가 발생했습니다. 다시 시도해 주세요.'); } } /** * 문서 업로드 함수 */ async function uploadDocument() { if (documentFile.files.length === 0) { alert('파일을 선택해 주세요.'); return; } // 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 요청 const response = await fetch('/api/upload', { method: 'POST', body: formData }); const data = await response.json(); // 응답 처리 if (data.error) { uploadStatus.className = 'upload-status error'; uploadStatus.textContent = `오류: ${data.error}`; } else if (data.warning) { uploadStatus.className = 'upload-status warning'; uploadStatus.textContent = data.message; } else { uploadStatus.className = 'upload-status success'; uploadStatus.textContent = data.message; // 문서 목록 새로고침 loadDocuments(); // 입력 필드 초기화 documentFile.value = ''; fileName.textContent = '선택된 파일 없음'; } } catch (error) { console.error('Error:', error); uploadStatus.className = 'upload-status error'; uploadStatus.textContent = '업로드 중 오류가 발생했습니다. 다시 시도해 주세요.'; } finally { uploadButton.disabled = false; } } /** * 문서 목록 로드 함수 */ async function loadDocuments() { // UI 업데이트 docsList.querySelector('tbody').innerHTML = ''; docsLoading.classList.remove('hidden'); noDocsMessage.classList.add('hidden'); try { // API 요청 const response = await fetch('/api/documents'); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); // 응답 처리 docsLoading.classList.add('hidden'); if (!data.documents || data.documents.length === 0) { 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:', 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) { 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') { 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() { 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) { const loadingMessage = document.getElementById(id); if (loadingMessage) { loadingMessage.remove(); } } /** * 오류 메시지 추가 함수 * @param {string} errorText - 오류 메시지 내용 */ function addErrorMessage(errorText) { const messageDiv = document.createElement('div'); messageDiv.classList.add('message', 'system'); const contentDiv = document.createElement('div'); contentDiv.classList.add('message-content'); contentDiv.style.backgroundColor = 'rgba(239, 68, 68, 0.1)'; contentDiv.style.color = 'var(--error-color)'; const errorP = document.createElement('p'); errorP.innerHTML = ` ${errorText}`; contentDiv.appendChild(errorP); messageDiv.appendChild(contentDiv); chatMessages.appendChild(messageDiv); // 스크롤을 가장 아래로 이동 chatMessages.scrollTop = chatMessages.scrollHeight; } /** * textarea 높이 자동 조정 함수 */ function adjustTextareaHeight() { userInput.style.height = 'auto'; userInput.style.height = Math.min(userInput.scrollHeight, 100) + 'px'; } // 앱 전역에서 switchTab 함수가 사용 가능하도록 export window.switchTab = switchTab;