Spaces:
No application file
No application file
/** | |
* 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 = `<i class="fas fa-info-circle"></i> ${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 = '<div class="spinner"></div><p>μ λ‘λ μ€...</p>'; | |
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 = '<div class="spinner" style="width: 20px; height: 20px; display: inline-block; margin-right: 10px;"></div> μκ° μ€...'; | |
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 = `<i class="fas fa-exclamation-circle"></i> ${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; |