/**
* 자체 채팅 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();
}
})();