🤖

페르소나 챗봇

연결 중...
📢
const GEMINI_KEY = "AIzaSyAc5GpbX5UxRBrPdIZE93E9cAexDdqOMTc"; const GEN_MODEL = "gemini-2.5-flash"; // 임베딩 모델 폴백 (admin과 동일한 순서로 유지 - 같은 모델로 학습된 벡터와 비교) const EMB_MODELS = ["gemini-embedding-001", "text-embedding-004", "embedding-001"]; let WORKING_EMB_MODEL = null; let persona = null; let knowledge = []; let greetings = []; let suggests = []; let scheduleData = null; let chatHistory = []; let isActive = false; let photoData = null; const messagesEl = document.getElementById('messages'); const inputEl = document.getElementById('userInput'); const sendBtn = document.getElementById('sendBtn'); const inputArea = document.getElementById('inputArea'); const suggestBar = document.getElementById('suggestBar'); // ========== 설정 로드 ========== function loadSettings() { const theme = localStorage.getItem('chat_theme') || 'light'; applyTheme(theme); const fontSize = localStorage.getItem('chat_font_size') || 'medium'; applyFontSize(fontSize); } function applyTheme(theme) { if (theme === 'dark') { document.body.classList.add('dark'); document.getElementById('themeLabel').textContent = '라이트 모드'; document.getElementById('themeIcon').innerHTML = ''; } else { document.body.classList.remove('dark'); document.getElementById('themeLabel').textContent = '다크 모드'; document.getElementById('themeIcon').innerHTML = ''; } // theme-color meta 업데이트 document.querySelector('meta[name="theme-color"]') .setAttribute('content', theme === 'dark' ? '#312e81' : '#4f46e5'); } function applyFontSize(size) { const sizes = { small: '13px', medium: '14.5px', large: '16.5px' }; document.documentElement.style.setProperty('--msg-font-size', sizes[size] || sizes.medium); document.querySelectorAll('.font-size-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.size === size); }); } window.toggleTheme = function() { const cur = document.body.classList.contains('dark') ? 'dark' : 'light'; const next = cur === 'dark' ? 'light' : 'dark'; applyTheme(next); localStorage.setItem('chat_theme', next); closeMenu(); }; window.setFontSize = function(size) { applyFontSize(size); localStorage.setItem('chat_font_size', size); }; // ========== 메뉴 토글 ========== window.toggleMenu = function(e) { e.stopPropagation(); document.getElementById('menuDropdown').classList.toggle('show'); }; function closeMenu() { document.getElementById('menuDropdown').classList.remove('show'); } document.addEventListener('click', e => { if (!e.target.closest('#menuDropdown') && !e.target.closest('#menuBtn')) { closeMenu(); } }); // ========== 초기 로드 ========== async function init() { loadSettings(); try { const statusSnap = await getDoc(doc(db, 'config', 'status')); isActive = statusSnap.exists() && statusSnap.data().active; if (!isActive) { showInactive(); return; } // 페르소나 const personaSnap = await getDoc(doc(db, 'config', 'persona')); if (personaSnap.exists()) persona = personaSnap.data(); // 사진 const photoSnap = await getDoc(doc(db, 'config', 'photo')); if (photoSnap.exists() && photoSnap.data().data) { photoData = photoSnap.data().data; document.getElementById('avatar').innerHTML = `profile`; document.getElementById('modalAvatar').innerHTML = `profile`; } else if (persona?.name) { document.getElementById('avatar').textContent = persona.name[0]; document.getElementById('modalAvatar').textContent = persona.name[0]; } // 인사말 const greetSnap = await getDoc(doc(db, 'config', 'greetings')); greetings = greetSnap.exists() ? (greetSnap.data().items || []) : []; // 추천 질문 const sugSnap = await getDoc(doc(db, 'config', 'suggests')); suggests = sugSnap.exists() ? (sugSnap.data().items || []) : []; // 일정 const schSnap = await getDoc(doc(db, 'config', 'schedule')); if (schSnap.exists()) scheduleData = schSnap.data(); // 공지 (notice) - 상태와 별개 const noticeSnap = await getDoc(doc(db, 'config', 'notice')); if (noticeSnap.exists() && noticeSnap.data().text) { const noticeText = noticeSnap.data().text.trim(); if (noticeText) { const banner = document.getElementById('statusBanner'); document.getElementById('statusBannerText').textContent = noticeText; banner.classList.add('show'); } } // 지식베이스 const knowSnap = await getDocs(collection(db, 'knowledge')); knowSnap.forEach(d => { const data = d.data(); knowledge.push({ text: data.text, embedding: data.embedding, source: data.source }); }); // 헤더 업데이트 if (persona?.name) { document.getElementById('botName').textContent = persona.name; document.getElementById('modalName').textContent = persona.name; } document.getElementById('botStatus').textContent = '온라인'; if (persona?.status) { const statusLine = document.getElementById('statusLine'); statusLine.textContent = '✨ ' + persona.status; statusLine.style.display = 'block'; } // 모달 서브타이틀 const subtitleParts = []; if (persona?.job) subtitleParts.push(persona.job); if (persona?.org) subtitleParts.push(persona.org); document.getElementById('modalSubtitle').textContent = subtitleParts.join(' · '); // 모달 내용 구성 buildProfileModal(); // 추천 질문 렌더 renderSuggests(); // 자동 인사 autoGreet(); } catch (e) { console.error(e); document.getElementById('botStatus').textContent = '연결 실패'; addMessage('bot', '연결에 문제가 있습니다. 잠시 후 다시 시도해주세요.'); } } function buildProfileModal() { const body = document.getElementById('modalBody'); body.innerHTML = ''; if (!persona) { body.innerHTML = '

프로필 정보가 없습니다.

'; return; } const map = [ ['직업', persona.job], ['소속', persona.org], ['직책', persona.position], ['거주지', persona.residence], ['고향', persona.hometown], ['MBTI', persona.mbti], ['취미', persona.hobby], ['특기', persona.talent], ['관심사', persona.interest], ['좌우명', persona.motto], ]; let added = 0; map.forEach(([label, val]) => { if (val && val.toString().trim()) { const row = document.createElement('div'); row.className = 'modal-row'; row.innerHTML = ``; body.appendChild(row); added++; } }); if (persona.intro && persona.intro.trim()) { const row = document.createElement('div'); row.className = 'modal-row'; row.innerHTML = ``; body.appendChild(row); added++; } if (added === 0) { body.innerHTML = '

공개된 정보가 없습니다.

'; } } function escapeHtml(str) { return String(str).replace(/[&<>"']/g, c => ( { '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c] )); } window.openProfileModal = function() { document.getElementById('profileModal').classList.add('show'); }; window.closeProfileModal = function() { document.getElementById('profileModal').classList.remove('show'); }; function showInactive() { document.getElementById('botStatus').textContent = '비활성'; messagesEl.innerHTML = `

챗봇이 아직 활성화되지 않았습니다

관리자가 페르소나와 지식베이스를 준비 중입니다.
잠시 후 다시 방문해주세요.

`; inputArea.style.display = 'none'; suggestBar.style.display = 'none'; } // ========== UI ========== function addMessage(role, text, source = null) { const wrap = document.createElement('div'); wrap.className = 'msg-wrap ' + role; const div = document.createElement('div'); div.className = 'msg ' + role; div.textContent = text; if (source) { const s = document.createElement('div'); s.className = 'source'; s.textContent = '📚 ' + source; div.appendChild(s); } wrap.appendChild(div); // 메시지 액션 (복사 등) const actions = document.createElement('div'); actions.className = 'msg-actions'; const copyBtn = document.createElement('button'); copyBtn.className = 'msg-action-btn'; copyBtn.innerHTML = `복사`; copyBtn.onclick = () => copyText(text); actions.appendChild(copyBtn); wrap.appendChild(actions); // 모바일에서 탭하면 액션 표시 wrap.addEventListener('click', e => { if (e.target.closest('.msg-action-btn')) return; document.querySelectorAll('.msg-wrap.tapped').forEach(el => { if (el !== wrap) el.classList.remove('tapped'); }); wrap.classList.toggle('tapped'); }); messagesEl.appendChild(wrap); scrollToBottom(); return wrap; } function copyText(text) { if (navigator.clipboard) { navigator.clipboard.writeText(text).then(() => showToast('복사되었습니다')); } else { const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); showToast('복사되었습니다'); } catch(e) {} document.body.removeChild(ta); } } function showToast(msg) { const t = document.getElementById('toast'); t.textContent = msg; t.classList.add('show'); setTimeout(() => t.classList.remove('show'), 1800); } function scrollToBottom() { setTimeout(() => { messagesEl.scrollTop = messagesEl.scrollHeight; }, 50); } function showTyping() { const div = document.createElement('div'); div.className = 'typing'; div.id = 'typingIndicator'; div.innerHTML = ''; messagesEl.appendChild(div); scrollToBottom(); } function hideTyping() { const t = document.getElementById('typingIndicator'); if (t) t.remove(); } function autoGreet() { let greet; if (greetings.length > 0) { greet = greetings[Math.floor(Math.random() * greetings.length)]; } else { greet = '안녕하세요! 무엇을 도와드릴까요?'; } let opening = greet; if (persona?.name) { opening = `안녕하세요, 저는 ${persona.name}입니다. ${greet}`; } addMessage('bot', opening); } function renderSuggests() { if (!suggests || suggests.length === 0) { suggestBar.classList.add('empty'); return; } let display = suggests; if (suggests.length > 3) { display = [...suggests].sort(() => Math.random() - 0.5).slice(0, 3); } suggestBar.innerHTML = ''; display.forEach(s => { const chip = document.createElement('button'); chip.className = 'suggest-chip'; chip.textContent = s; chip.onclick = () => { inputEl.value = s; sendMessage(); }; suggestBar.appendChild(chip); }); suggestBar.classList.remove('empty'); } // ========== RAG ========== function cosine(a, b) { let dot = 0, na = 0, nb = 0; for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; } return dot / (Math.sqrt(na) * Math.sqrt(nb) + 1e-10); } async function embedQuery(text) { const safeText = text.length > 4000 ? text.slice(0, 4000) : text; const candidates = WORKING_EMB_MODEL ? [WORKING_EMB_MODEL] : EMB_MODELS; let lastErr = null; for (const model of candidates) { const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:embedContent?key=${GEMINI_KEY}`; try { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: { parts: [{ text: safeText }] } }) }); if (!res.ok) { lastErr = new Error(`[${model}] HTTP ${res.status}`); continue; } const data = await res.json(); if (!data.embedding || !data.embedding.values) { lastErr = new Error(`[${model}] empty embedding`); continue; } if (!WORKING_EMB_MODEL) WORKING_EMB_MODEL = model; return data.embedding.values; } catch (err) { lastErr = err; } } throw new Error('임베딩 실패: ' + (lastErr ? lastErr.message : 'unknown')); } async function retrieveKnowledge(query, topK = 4) { if (knowledge.length === 0) return []; try { const qEmb = await embedQuery(query); // 질문 임베딩과 차원이 같은 지식만 비교 (옛 모델로 학습된 데이터는 무시) const compatible = knowledge.filter(k => Array.isArray(k.embedding) && k.embedding.length === qEmb.length ); if (compatible.length < knowledge.length) { console.warn(`[RAG] 차원 불일치로 ${knowledge.length - compatible.length}개 청크 제외 (질문 차원: ${qEmb.length})`); } if (compatible.length === 0) return []; const scored = compatible.map(k => ({ ...k, score: cosine(qEmb, k.embedding) })); scored.sort((a, b) => b.score - a.score); return scored.slice(0, topK).filter(s => s.score > 0.3); } catch (e) { console.error('지식 검색 실패:', e); return []; } } function buildSystemPrompt(retrievedDocs) { if (!persona) return '당신은 친절한 챗봇입니다.'; const lines = []; lines.push('당신은 다음 페르소나를 가진 사람입니다. 1인칭으로 말하며, 본인인 것처럼 답해주세요.'); lines.push(''); lines.push('=== 나의 페르소나 ==='); const map = { name: '이름', job: '직업', age: '나이', birth: '생년월일', gender: '성별', org: '소속', position: '직책', hometown: '고향', residence: '거주지', zodiac: '별자리', mbti: 'MBTI', blood: '혈액형', religion: '종교', education: '학력', career: '경력', expertise: '전문분야', cert: '자격증', award: '수상실적', achievement: '업적', hobby: '취미', talent: '특기', food: '좋아하는 음식', music: '좋아하는 음악', book: '좋아하는 책/영화', idol: '존경하는 인물', motto: '좌우명', family: '가족관계', languages: '사용 언어', interest: '요즘 관심사', intro: '자기소개' }; for (const [k, label] of Object.entries(map)) { if (persona[k] && persona[k].toString().trim()) { lines.push(`- ${label}: ${persona[k]}`); } } if (persona.status) { lines.push(`- 오늘의 상태: ${persona.status}`); } if (scheduleData && (scheduleData.summary || (scheduleData.items && scheduleData.items.length > 0))) { lines.push(''); lines.push('=== 이번달 일정 ==='); const today = new Date(); const todayStr = today.toISOString().slice(0, 10); lines.push(`(오늘은 ${todayStr})`); if (scheduleData.summary) lines.push(`이번달 요약: ${scheduleData.summary}`); if (scheduleData.items && scheduleData.items.length > 0) { lines.push('주요 일정:'); scheduleData.items.forEach(it => { lines.push(`- ${it.date || '날짜 미정'}: ${it.content}`); }); } lines.push('일정 관련 질문(예: "이번주 일정", "다음 약속")에는 위 일정 정보를 바탕으로 답하세요.'); } lines.push(''); lines.push('=== 답변 규칙 ==='); if (persona.tone) lines.push(`- 답변 태도: ${persona.tone}`); if (persona.callname) lines.push(`- 상대방을 "${persona.callname}"이라고 부르세요`); if (persona.self) lines.push(`- 자신을 "${persona.self}"라고 지칭하세요`); lines.push('- 페르소나에 없는 내용을 묻는다면, 자연스럽게 모르거나 해당 사항이 없다고 답하세요'); lines.push('- 1인칭으로 본인처럼 답변하세요'); lines.push('- 답변은 너무 길지 않게, 자연스러운 대화 형태로 해주세요'); if (persona.instruction) lines.push(`- 특별 지시: ${persona.instruction}`); if (retrievedDocs.length > 0) { lines.push(''); lines.push('=== 참고 지식 (질문과 관련된 내용) ==='); retrievedDocs.forEach((d, i) => { lines.push(`[참고${i+1}] (출처: ${d.source})`); lines.push(d.text); lines.push(''); }); lines.push('위 참고 지식이 질문과 관련 있다면 적극 활용하여 답변하세요.'); } return lines.join('\n'); } async function callGemini(systemPrompt, userMsg) { const url = `https://generativelanguage.googleapis.com/v1beta/models/${GEN_MODEL}:generateContent?key=${GEMINI_KEY}`; const recent = chatHistory.slice(-12); const contents = [...recent, { role: 'user', parts: [{ text: userMsg }] }]; const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ systemInstruction: { parts: [{ text: systemPrompt }] }, contents: contents, generationConfig: { temperature: 0.8, maxOutputTokens: 2048 } }) }); if (!res.ok) { const err = await res.text(); throw new Error('Gemini 호출 실패: ' + err); } const data = await res.json(); if (!data.candidates || data.candidates.length === 0) { throw new Error('응답이 없습니다'); } const reply = data.candidates[0].content.parts.map(p => p.text || '').join(''); return reply.trim(); } async function sendMessage() { const text = inputEl.value.trim(); if (!text) return; inputEl.value = ''; inputEl.style.height = 'auto'; sendBtn.disabled = true; addMessage('user', text); showTyping(); try { const docs = await retrieveKnowledge(text); const systemPrompt = buildSystemPrompt(docs); const reply = await callGemini(systemPrompt, text); chatHistory.push({ role: 'user', parts: [{ text }] }); chatHistory.push({ role: 'model', parts: [{ text: reply }] }); hideTyping(); const sources = docs.length > 0 ? [...new Set(docs.map(d => d.source))].slice(0, 3).join(', ') : null; addMessage('bot', reply, sources); renderSuggests(); } catch (e) { hideTyping(); addMessage('bot', '죄송합니다. 답변 생성 중 오류가 발생했어요. 잠시 후 다시 시도해주세요.\n\n(' + e.message + ')'); console.error(e); } finally { sendBtn.disabled = false; } } window.sendMessage = sendMessage; window.clearChat = function() { closeMenu(); if (!confirm('대화 내용을 모두 지우시겠어요?')) return; chatHistory = []; messagesEl.innerHTML = ''; if (isActive) { autoGreet(); renderSuggests(); } }; window.exportChat = function() { closeMenu(); if (chatHistory.length === 0) { showToast('내보낼 대화가 없습니다'); return; } const lines = []; const name = persona?.name || '챗봇'; const now = new Date(); lines.push(`${name} 챗봇과의 대화 내역`); lines.push(`(${now.toLocaleString('ko-KR')})`); lines.push('=' .repeat(40)); lines.push(''); chatHistory.forEach(item => { const role = item.role === 'user' ? '나' : name; const text = item.parts.map(p => p.text || '').join(''); lines.push(`[${role}]`); lines.push(text); lines.push(''); }); const blob = new Blob([lines.join('\n')], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `chat_${name}_${now.toISOString().slice(0,10)}.txt`; a.click(); URL.revokeObjectURL(url); showToast('대화를 다운로드했습니다'); }; // ========== 음성 입력 ========== let recognition = null; let isRecording = false; window.toggleVoice = function() { const SR = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SR) { showToast('이 브라우저는 음성 인식을 지원하지 않습니다'); return; } const btn = document.getElementById('voiceBtn'); if (isRecording && recognition) { recognition.stop(); return; } recognition = new SR(); recognition.lang = 'ko-KR'; recognition.interimResults = true; recognition.continuous = false; let finalText = ''; recognition.onstart = () => { isRecording = true; btn.classList.add('recording'); inputEl.placeholder = '듣고 있어요...'; }; recognition.onresult = (e) => { let interim = ''; for (let i = e.resultIndex; i < e.results.length; i++) { if (e.results[i].isFinal) finalText += e.results[i][0].transcript; else interim += e.results[i][0].transcript; } inputEl.value = finalText + interim; inputEl.style.height = 'auto'; inputEl.style.height = Math.min(inputEl.scrollHeight, 110) + 'px'; }; recognition.onerror = (e) => { showToast('음성 인식 오류: ' + e.error); }; recognition.onend = () => { isRecording = false; btn.classList.remove('recording'); inputEl.placeholder = '메시지를 입력하세요...'; recognition = null; }; try { recognition.start(); } catch (e) { showToast('음성 인식을 시작할 수 없습니다'); } }; // 입력 영역 이벤트 inputEl.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey && !isMobile()) { e.preventDefault(); sendMessage(); } }); inputEl.addEventListener('input', () => { inputEl.style.height = 'auto'; inputEl.style.height = Math.min(inputEl.scrollHeight, 110) + 'px'; }); function isMobile() { return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); } inputEl.addEventListener('focus', () => { setTimeout(scrollToBottom, 300); }); if (window.visualViewport) { window.visualViewport.addEventListener('resize', () => { scrollToBottom(); }); } init();