/** * 자체 채팅 UI: 메시지 목록 + 입력창 + 스트리밍 렌더링 (custom-chatbot-spec.md §6) * 기존 드래그/리사이즈 패널(panel 요소) 안에 이 함수로 만든 DOM을 넣으면 됩니다. * * ⚠️ 3단계에서는 아직 기존 Dify 위젯/패널에 연결하지 않습니다. * 테스트 전용 페이지(chat-test.html)에서만 사용합니다. */ /* ────────────────────────────────────────────────────────────────────────── 대화 상태 저장/복원 (sessionStorage) 페이지를 이동해도 채팅창이 유지되는 것처럼 보이게 한다. - 저장 형태: { open: 패널이 열려있었는지, history: 대화이력, pending: 답변 대기 중인 사용자 발화 } - 복원되는 경우: 헤더 메뉴 등으로 일반 페이지 이동을 했을 때 - 지워지는 경우: ① 사용자가 X로 직접 닫을 때 ② 문의하기 페이지로 자동 이동할 때 - sessionStorage라서 탭을 닫으면 자동으로 사라진다. ────────────────────────────────────────────────────────────────────────── */ var CHAT_STATE_KEY = 'chatState'; // 서버로 보내고 브라우저에 남기는 이전 대화 이력 상한(메시지 개수, 10개 = 최근 5턴). // ⚠ app.py의 CHAT_HISTORY_MAX_MESSAGES와 같은 값이어야 한다 — 한쪽만 바꾸지 말 것. // 서버가 최종적으로 같은 수로 자르므로 여기 값은 전송량·저장량을 줄이는 보조 장치다. var CHAT_HISTORY_MAX_MESSAGES = 10; // 입력 한 건의 최대 글자 수 (입력란 maxlength). // ⚠ app.py의 CHAT_MESSAGE_MAX_CHARS와 같은 값이어야 한다 — 서버가 초과분을 400으로 거절한다. var CHAT_MESSAGE_MAX_CHARS = 1000; function loadChatState() { try { var raw = sessionStorage.getItem(CHAT_STATE_KEY); return raw ? JSON.parse(raw) : null; } catch (e) { return null; // 프라이빗 모드 등으로 sessionStorage를 못 쓰면 저장 기능만 조용히 비활성화 } } function saveChatState(patch) { try { var state = loadChatState() || { open: false, history: [], pending: '' }; for (var k in patch) state[k] = patch[k]; sessionStorage.setItem(CHAT_STATE_KEY, JSON.stringify(state)); } catch (e) { /* 저장 실패는 무시 (채팅 자체는 계속 동작) */ } } function clearChatState() { try { sessionStorage.removeItem(CHAT_STATE_KEY); } catch (e) {} } // savedState를 넘기면 그 대화 이력을 그대로 복원한 채로 채팅 UI를 만든다. function createChatUI(savedState) { const container = document.createElement('div'); container.id = 'chat-ui-container'; container.style.cssText = ` display: flex; flex-direction: column; height: 100%; width: 100%; background: #F3F4F6; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; `; const messagesEl = document.createElement('div'); messagesEl.id = 'chat-messages'; messagesEl.style.cssText = ` flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; `; const inputBar = document.createElement('div'); inputBar.style.cssText = ` flex: none; display: flex; gap: 8px; padding: 12px; border-top: 1px solid rgba(0,0,0,0.08); background: #fff; `; inputBar.innerHTML = ` `; container.appendChild(messagesEl); container.appendChild(inputBar); // 대화 이력 — 새로 열면 빈 배열, 페이지 이동 후 복원이면 저장된 이력을 이어받는다. // 복원된 이력도 상한으로 자른다 (상한 도입 전에 저장된 긴 이력 대비). let conversationHistory = (savedState && Array.isArray(savedState.history)) ? savedState.history.slice(-CHAT_HISTORY_MAX_MESSAGES) : []; // 답변 표시(2026-10-30): 지시문으로 마크다운을 막아도 모델이 **굵게** 를 쓰는 경우가 있어 표시 단계에서 한 번 더 정리한다. // 답변 원문은 innerHTML 에 넣지 않는다(XSS) — 텍스트 노드와 (textContent)만 만들어 붙인다. // ** 짝만 으로 바꾸고, 짝이 안 맞는 ** 는 지운다(스트리밍 도중 닫는 ** 가 아직 안 온 경우도 여기서 잠시 지워진다). // 줄바꿈·"1." 목록은 글자 그대로 두고 말풍선의 white-space:pre-wrap 이 보여 준다. function renderAnswer(el, text) { const parts = String(text || '').split('**'); const marks = parts.length - 1; el.textContent = ''; for (let i = 0; i < parts.length; i++) { if (!parts[i]) continue; const bold = (i % 2 === 1) && !(marks % 2 === 1 && i === marks); // 홀수 칸 = 짝 안쪽. 마지막 짝 없는 ** 뒤는 평문 if (bold) { const s = document.createElement('strong'); s.textContent = parts[i]; el.appendChild(s); } else { el.appendChild(document.createTextNode(parts[i])); } } } function addMessageBubble(role, text) { const bubble = document.createElement('div'); bubble.style.cssText = ` max-width: 80%; padding: 10px 14px; border-radius: 12px; font-size: 14px; line-height: 1.5; white-space: pre-wrap; ${role === 'user' ? 'align-self:flex-end; background:var(--brand-600, #0B5ED7); color:#fff;' : 'align-self:flex-start; background:#fff; color:#1a1a1a; border:1px solid rgba(0,0,0,0.06);'} `; if (role === 'assistant') renderAnswer(bubble, text); else bubble.textContent = text; messagesEl.appendChild(bubble); messagesEl.scrollTop = messagesEl.scrollHeight; return bubble; } // 상담/문의 접수 요청 시 서버(시스템 프롬프트)가 답변 끝에 붙이는 페이지 이동 마커. // 화면에는 절대 보이면 안 되고, 감지되면 contact.html로 이동시킨다. const REDIRECT_MARKER = '[[REDIRECT:contact]]'; // 스트리밍 도중에는 마커가 "[[REDI..." 처럼 잘려서 도착하므로, // 완성된 마커뿐 아니라 끝부분의 미완성 마커 조각도 함께 숨긴다. function stripMarker(text) { let out = text.split(REDIRECT_MARKER).join(''); for (let len = REDIRECT_MARKER.length - 1; len > 0; len--) { if (out.endsWith(REDIRECT_MARKER.slice(0, len))) { out = out.slice(0, -len); break; } } return out; } async function sendMessage() { const input = container.querySelector('#chat-input'); const text = input.value.trim(); if (!text) return; addMessageBubble('user', text); input.value = ''; // 사용자 발화 즉시 저장 → 답변을 기다리는 중에 페이지를 옮겨도 내 질문은 남는다. saveChatState({ history: conversationHistory, pending: text }); const assistantBubble = addMessageBubble('assistant', ''); let fullResponse = ''; try { const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, // 이력은 최근 CHAT_HISTORY_MAX_MESSAGES개만 보낸다 (서버도 같은 수로 자른다). body: JSON.stringify({ message: text, history: conversationHistory.slice(-CHAT_HISTORY_MAX_MESSAGES) }), }); // 서버가 거절한 요청(400 입력 오류·413 요청 과다·429 접속 제한 등): 본문은 SSE가 아니라 // JSON {"error": ...} 또는 짧은 텍스트다. 아래 스트림 파서는 'data: ' 줄이 없으면 조용히 // 무시해 빈 말풍선만 남았으므로, 오류 문구를 말풍선 하나로 보여 주고 이력에는 넣지 않는다. // (deploy-temp-spec.md D-2) if (!response.ok) { let errorText = '요청을 처리하지 못했습니다. 잠시 후 다시 시도해주세요.'; try { const errJson = await response.json(); if (errJson && typeof errJson.error === 'string') errorText = errJson.error; } catch (e) { /* JSON이 아니면(429 텍스트 등) 기본 문구 */ } assistantBubble.textContent = errorText; messagesEl.scrollTop = messagesEl.scrollHeight; saveChatState({ history: conversationHistory, pending: '' }); return; } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n\n'); buffer = lines.pop(); // 마지막 불완전한 조각은 다음 루프로 넘김 for (const line of lines) { if (!line.startsWith('data: ')) continue; const payload = line.slice(6); if (payload === '[DONE]') continue; const { content } = JSON.parse(payload); fullResponse += content; renderAnswer(assistantBubble, stripMarker(fullResponse)); messagesEl.scrollTop = messagesEl.scrollHeight; } } // 스트리밍 완료: 마커를 제거한 텍스트만 화면/대화이력에 남긴다. const shouldRedirect = fullResponse.indexOf(REDIRECT_MARKER) !== -1; const cleanResponse = fullResponse.split(REDIRECT_MARKER).join('').trim(); renderAnswer(assistantBubble, cleanResponse); messagesEl.scrollTop = messagesEl.scrollHeight; conversationHistory.push({ role: 'user', content: text }); conversationHistory.push({ role: 'assistant', content: cleanResponse }); // 오래된 턴은 버린다 — 화면 말풍선은 그대로 두고 전송·저장용 배열만 자른다. if (conversationHistory.length > CHAT_HISTORY_MAX_MESSAGES) { conversationHistory = conversationHistory.slice(-CHAT_HISTORY_MAX_MESSAGES); } if (shouldRedirect) { // 예외 케이스: 문의하기 페이지로 자동 이동할 때는 저장하지 않고 지운다. // → contact.html에 도착했을 때 채팅창이 다시 열리지 않고 깨끗하게 닫힌 상태가 된다. clearChatState(); // 안내 문구를 잠시 읽을 시간을 준 뒤 이동. // (페이지가 새로 로드되므로 패널을 따로 닫을 필요는 없음) setTimeout(function () { // 절대경로 — /resources/3 처럼 하위 경로 페이지에서도 올바르게 이동한다. window.location.href = '/contact.html'; }, 1500); } else { // 응답 완료 상태 저장 (대기 중이던 발화는 이제 이력에 들어갔으므로 pending 해제) saveChatState({ history: conversationHistory, pending: '' }); } } catch (err) { assistantBubble.textContent = '오류가 발생했습니다. 잠시 후 다시 시도해주세요.'; console.error(err); } } container.querySelector('#chat-send').addEventListener('click', sendMessage); container.querySelector('#chat-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') sendMessage(); }); // 첫인사 메시지 (선택) addMessageBubble('assistant', '안녕하세요! (주)메타덱스터 상담 챗봇입니다. 무엇을 도와드릴까요?'); // 페이지 이동 후 복원: 저장된 대화를 첫인사 아래에 그대로 다시 그린다. if (savedState) { conversationHistory.forEach(function (m) { addMessageBubble(m.role === 'user' ? 'user' : 'assistant', m.content); }); // 답변을 받기 전에 페이지를 옮긴 경우: 내 질문만 남아있으므로 그것도 표시한다. if (savedState.pending) addMessageBubble('user', savedState.pending); } return container; } /* ========================================================================== 챗봇 패널 컨트롤러 (custom-chatbot-spec.md §7 — Dify iframe → 자체 채팅 UI 전환) - 기존 드래그·리사이즈·닫기 패널(.dify-panel 등 CSS 재사용)을 그대로 유지 - 패널 본문을 iframe 대신 createChatUI() 결과로 채움 - 패널을 열 때마다 새 createChatUI()를 주입 → 새 DOM + 새 conversationHistory (대화 초기화) - 열기 버튼: 위젯 "AI 상담 시작"(#chatCta) + 하단 CTA "챗봇으로 물어보기"([data-open-chat]) 공용 ========================================================================== */ (function () { 'use strict'; function init() { var chatCta = document.getElementById('chatCta'); // 열기 버튼이 전혀 없는 페이지(예: chat-test.html)에서는 패널을 만들지 않음 if (!chatCta && !document.querySelector('[data-open-chat]')) return; if (document.getElementById('difyPanel')) return; // 중복 생성 방지 var panel = document.createElement('div'); panel.id = 'difyPanel'; panel.className = 'dify-panel'; panel.innerHTML = '
' + 'AI 상담' + '' + '
' + '
' + '
' + '
' + '
'; document.body.appendChild(panel); var head = panel.querySelector('#difyPanelHead'); var closeBtn = panel.querySelector('#difyPanelClose'); var bodyEl = panel.querySelector('#difyPanelBody'); // 채팅 UI가 들어갈 본문 영역(남은 공간을 채우고 내부 스크롤 허용) bodyEl.style.cssText = 'flex:1 1 auto; min-height:0; overflow:hidden; display:flex;'; var guard = document.createElement('div'); guard.className = 'dify-drag-guard'; document.body.appendChild(guard); // restoreState를 넘기면 저장된 대화를 이어서 열고, 없으면 새 대화로 연다. function openPanel(restoreState) { bodyEl.innerHTML = ''; bodyEl.appendChild(createChatUI(restoreState)); panel.classList.add('open'); var sw = document.getElementById('sideWidget'); if (sw) sw.classList.remove('open'); // 위젯 패널은 접기 var inp = bodyEl.querySelector('#chat-input'); if (inp) inp.focus(); // "열려있음"을 저장 → 다음 페이지에서 자동 복원의 조건이 된다. // 새로 여는 경우(restoreState 없음)에는 이력을 비워 새 대화로 시작한다. saveChatState({ open: true, history: (restoreState && restoreState.history) || [], pending: (restoreState && restoreState.pending) || '', }); } function closePanel() { panel.classList.remove('open'); bodyEl.innerHTML = ''; // 정리(다음 오픈 때 새로 생성) // 사용자가 직접 닫으면 저장된 대화도 폐기 → 다음에 열 때 새 대화로 시작 clearChatState(); } // 공용 오픈 함수 + 버튼 연결 // (리스너로 바로 넘기면 click 이벤트 객체가 restoreState 자리에 들어오므로 감싼다) window.openChatPanel = function () { openPanel(); }; if (chatCta) chatCta.addEventListener('click', function () { openPanel(); }); closeBtn.addEventListener('click', closePanel); document.addEventListener('click', function (e) { if (e.target.closest('[data-open-chat]')) { e.preventDefault(); openPanel(); } }); // ── 드래그 이동 / 리사이즈 (기존 패널과 동일 동작) ── function toLeftTop() { var r = panel.getBoundingClientRect(); panel.style.left = r.left + 'px'; panel.style.top = r.top + 'px'; panel.style.right = 'auto'; panel.style.bottom = 'auto'; } var dragging = null, resizing = null; head.addEventListener('mousedown', function (e) { if (e.target === closeBtn) return; toLeftTop(); var r = panel.getBoundingClientRect(); dragging = { dx: e.clientX - r.left, dy: e.clientY - r.top }; guard.classList.add('on'); e.preventDefault(); }); var handles = panel.querySelectorAll('.dify-panel__rz'); for (var i = 0; i < handles.length; i++) { handles[i].addEventListener('mousedown', function (e) { toLeftTop(); var r = panel.getBoundingClientRect(); resizing = { mode: e.currentTarget.getAttribute('data-rz'), right: r.right, bottom: r.bottom }; guard.classList.add('on'); e.preventDefault(); e.stopPropagation(); }); } document.addEventListener('mousemove', function (e) { if (dragging) { var nl = Math.min(Math.max(0, e.clientX - dragging.dx), window.innerWidth - 60); var nt = Math.min(Math.max(0, e.clientY - dragging.dy), window.innerHeight - 40); panel.style.left = nl + 'px'; panel.style.top = nt + 'px'; } else if (resizing) { if (resizing.mode.indexOf('l') !== -1) { var lx = Math.min(Math.max(0, e.clientX), resizing.right - 300); panel.style.left = lx + 'px'; panel.style.width = (resizing.right - lx) + 'px'; } if (resizing.mode.indexOf('t') !== -1) { var ty = Math.min(Math.max(0, e.clientY), resizing.bottom - 360); panel.style.top = ty + 'px'; panel.style.height = (resizing.bottom - ty) + 'px'; } } }); document.addEventListener('mouseup', function () { dragging = null; resizing = null; guard.classList.remove('on'); }); // ── 페이지 이동 후 자동 복원 ── // 직전 페이지에서 패널이 열려 있었다면 그대로 다시 열고 대화를 이어 보여준다. // (X로 닫았거나 문의하기 페이지로 자동 이동한 경우엔 저장이 지워져 있어 복원되지 않는다) var saved = loadChatState(); if (saved && saved.open) openPanel(saved); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();