/* ─── State ─────────────────────────────────────────── */ let ws = null; let currentRoom = "general"; let currentUser = ""; let rooms = {}; let reconnectTimer = null; let reconnectDelay = 1000; /* ─── DOM refs ──────────────────────────────────────── */ const messagesEl = document.getElementById("messages"); const msgInput = document.getElementById("msg-input"); const channelNameEl = document.getElementById("ch-name"); const channelDescEl = document.getElementById("ch-desc"); const channelListEl = document.getElementById("channel-list"); const userDisplayEl = document.getElementById("user-display"); const connStatus = document.getElementById("conn-status"); const userModal = document.getElementById("user-modal-overlay"); const userNameInput = document.getElementById("username-input"); const typingEl = document.getElementById("typing-indicator"); /* ─── Init ──────────────────────────────────────────── */ window.addEventListener("DOMContentLoaded", () => { const saved = sessionStorage.getItem("guardia_user"); if (saved) { currentUser = saved; startApp(); } else { userModal.classList.add("show"); userNameInput.focus(); } }); document.getElementById("username-form").addEventListener("submit", e => { e.preventDefault(); const name = userNameInput.value.trim(); if (!name) return; currentUser = name; sessionStorage.setItem("guardia_user", name); userModal.classList.remove("show"); startApp(); }); function startApp() { userDisplayEl.textContent = currentUser; connect(); } /* ─── WebSocket ─────────────────────────────────────── */ function connect() { const proto = location.protocol === "https:" ? "wss" : "ws"; const url = `${proto}://${location.host}/ws/chat/${currentRoom}/${encodeURIComponent(currentUser)}`; ws = new WebSocket(url); showStatus("reconnecting", "연결 중…"); ws.onopen = () => { showStatus("online", "연결됨"); reconnectDelay = 1000; setTimeout(() => connStatus.classList.remove("show"), 2000); }; ws.onmessage = e => { const data = JSON.parse(e.data); // 타이핑 인디케이터 이벤트 if (data.type === "bot_typing") { showTyping(data.show); return; } if (data.type === "init") { rooms = data.rooms || {}; renderChannelList(); updateChannelHeader(); applyRoomClass(); messagesEl.innerHTML = ""; lastDate = ""; lastSender = ""; if (data.messages?.length) { data.messages.forEach(renderMessage); scrollBottom(); } else { renderWelcome(); } } else { showTyping(false); renderMessage(data); scrollBottom(); } }; ws.onclose = () => { showTyping(false); showStatus("offline", "연결 끊김 — 재연결 중…"); reconnectTimer = setTimeout(() => { reconnectDelay = Math.min(reconnectDelay * 2, 15000); connect(); }, reconnectDelay); }; ws.onerror = () => ws.close(); } function sendMessage() { const text = msgInput.value.trim(); if (!text || !ws || ws.readyState !== WebSocket.OPEN) return; ws.send(JSON.stringify({ content: text })); msgInput.value = ""; msgInput.style.height = "auto"; } /* ─── Typing indicator ──────────────────────────────── */ function showTyping(show) { if (show) { typingEl.innerHTML = `
GUARDiA-Bot 입력 중…
`; } else { typingEl.innerHTML = ""; } } /* ─── Channel switching ─────────────────────────────── */ function switchRoom(roomId) { if (roomId === currentRoom) return; currentRoom = roomId; showTyping(false); if (ws) { ws.onclose = null; ws.close(); } clearTimeout(reconnectTimer); connect(); document.querySelectorAll(".channel-item").forEach(el => { el.classList.toggle("active", el.dataset.room === roomId); }); updateChannelHeader(); } function updateChannelHeader() { const name = rooms[currentRoom] || currentRoom; const isChatbot = currentRoom === "chatbot"; channelNameEl.innerHTML = "# " + escHtml(name) + (isChatbot ? ' AI' : ""); channelDescEl.textContent = roomDesc(currentRoom); updateInputHint(); } function applyRoomClass() { if (currentRoom === "chatbot") { messagesEl.classList.add("chatbot-channel"); } else { messagesEl.classList.remove("chatbot-channel"); } } function roomDesc(id) { return { general: "팀 전체 공지 및 일반 대화", deploy: "배포 작업 요청 및 진행 현황", ops: "서버 운영 및 장애 대응", pm: "PM 승인 워크플로우 및 진척 관리", alerts: "자동 알림 및 임계치 경보", chatbot: "GUARDiA AI 챗봇 — 자연어로 자유롭게 대화하세요", }[id] || ""; } function updateInputHint() { const hintEl = document.getElementById("input-hint"); if (currentRoom === "chatbot") { hintEl.innerHTML = "자연어로 자유롭게 입력하세요 — AI가 맥락을 기억합니다  |  " + "초기화: '대화 초기화' 입력"; } else { hintEl.innerHTML = "@bot 도움 — 명령어 목록  |  " + "@bot 배포 — 배포 SR 생성  |  " + "@bot 서버 상태 — 인프라 현황"; } } /* ─── Render ────────────────────────────────────────── */ function renderChannelList() { channelListEl.innerHTML = ""; Object.entries(rooms).forEach(([id, label]) => { const el = document.createElement("div"); el.className = "channel-item" + (id === currentRoom ? " active" : ""); el.dataset.room = id; const badge = id === "chatbot" ? ' AI' : ""; el.innerHTML = `# ${escHtml(label)}${badge}`; el.addEventListener("click", () => switchRoom(id)); channelListEl.appendChild(el); }); } function renderWelcome() { if (currentRoom === "chatbot") { const div = document.createElement("div"); div.style.cssText = "padding:16px 0 8px;"; div.innerHTML = `
AI 챗봇 채널
GU
GUARDiA-Bot ${nowTime()}
안녕하세요! GUARDiA 인프라 AI 챗봇입니다.\n\n자연어로 자유롭게 질문하거나 작업을 요청하세요.\n예: 기재부 예산시스템 WAS 재기동해줘\n예: 서버 상태 알려줘\n예: 도움
`; messagesEl.appendChild(div); } else { const divider = document.createElement("div"); divider.className = "date-divider"; divider.textContent = `# ${rooms[currentRoom] || currentRoom} 채널에 오신 것을 환영합니다`; messagesEl.appendChild(divider); const hint = document.createElement("div"); hint.style.cssText = "padding:8px 0;font-size:13px;color:var(--text-muted)"; hint.innerHTML = "💡 @bot 도움 을 입력하면 GUARDiA Bot 명령어를 확인할 수 있습니다."; messagesEl.appendChild(hint); } } let lastDate = ""; let lastSender = ""; function renderMessage(msg) { const msgDate = new Date(msg.timestamp).toLocaleDateString("ko-KR", { month: "long", day: "numeric", weekday: "long" }); if (msgDate !== lastDate) { const divider = document.createElement("div"); divider.className = "date-divider"; divider.textContent = msgDate; messagesEl.appendChild(divider); lastDate = msgDate; lastSender = ""; } const isBot = msg.sender_type === "BOT"; const isSame = msg.sender === lastSender && !isBot; lastSender = msg.sender; const group = document.createElement("div"); group.className = "msg-group"; group.dataset.msgId = msg.message_id; const initials = (msg.sender || "?").slice(0, 2).toUpperCase(); const time = new Date(msg.timestamp).toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit" }); const avatarHTML = isSame ? `
` : `
${initials}
`; const metaHTML = isSame ? "" : `
${escHtml(msg.sender)} ${time}
`; const contentHTML = formatContent(msg.content); let widgetHTML = ""; if (msg.is_widget && msg.interactive_action) { const act = msg.interactive_action; if (act.type === "BUTTON") { widgetHTML = `
`; } else if (act.type === "APPROVAL_BUTTONS") { widgetHTML = `
`; } else if (act.type === "STAR_RATING") { const widgetId = `sr-widget-${msg.message_id}`; widgetHTML = `
⭐ 서비스 만족도를 평가해 주세요 (${escHtml(act.sr_id)})
${[1,2,3,4,5].map(n => `` ).join("")}
`; } } group.innerHTML = ` ${avatarHTML}
${metaHTML}
${contentHTML}
${widgetHTML}
`; messagesEl.appendChild(group); } /* ─── Star rating handler ───────────────────────────── */ async function handleStarRating(widgetId, srId, customer, itsmUrl, stars) { const widget = document.getElementById(widgetId); if (!widget) return; // 버튼 하이라이트 widget.querySelectorAll(".star-btn-msg").forEach((b, i) => { b.classList.toggle("lit", i < stars); }); try { const base = itsmUrl || "http://localhost:8001"; const res = await fetch(`${base}/api/rating/${srId}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ customer: customer || "고객", stars }), }); const done = document.createElement("div"); done.className = "star-rating-done"; if (res.ok || res.status === 409) { done.textContent = `${"★".repeat(stars)}${"☆".repeat(5-stars)} — 감사합니다! 평가가 등록되었습니다.`; } else { done.textContent = "평가 처리 중 오류가 발생했습니다."; } widget.querySelector(".star-rating-btns")?.remove(); widget.appendChild(done); } catch { const done = document.createElement("div"); done.className = "star-rating-done"; done.textContent = `${"★".repeat(stars)}${"☆".repeat(5-stars)} — 평가 감사합니다!`; widget.querySelector(".star-rating-btns")?.remove(); widget.appendChild(done); } } /* ─── Interactive actions ───────────────────────────── */ async function handleAction(btn, code, url, label) { btn.classList.add("clicked"); btn.closest(".widget-actions")?.querySelectorAll(".btn-action") .forEach(b => b.classList.add("clicked")); let resultText = ""; if (url) { try { const res = await fetch(url, { method: "POST" }); const data = await res.json(); resultText = data.message || `${label} 처리 완료`; } catch { resultText = "처리 중 오류가 발생했습니다."; } } else { resultText = `명령 전송됨: ${code}`; } const result = document.createElement("div"); result.className = "btn-result"; result.textContent = "→ " + resultText; btn.closest(".widget-actions").insertAdjacentElement("afterend", result); } /* ─── Formatting ────────────────────────────────────── */ function formatContent(text) { if (!text) return ""; text = text.replace(/```([\s\S]*?)```/g, (_, code) => `
${escHtml(code.trim())}
`); text = text.replace(/`([^`]+)`/g, (_, c) => `${escHtml(c)}`); text = text.replace(/\*\*(.+?)\*\*/g, "$1"); return text; } function escHtml(s) { return String(s ?? "") .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function escAttr(s) { return String(s ?? "").replace(/'/g, "\\'"); } function nowTime() { return new Date().toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit" }); } /* ─── UI helpers ────────────────────────────────────── */ function scrollBottom() { requestAnimationFrame(() => { messagesEl.scrollTop = messagesEl.scrollHeight; }); } function showStatus(cls, text) { connStatus.className = `show ${cls}`; connStatus.textContent = text; } /* ─── Input events ──────────────────────────────────── */ document.getElementById("send-btn").addEventListener("click", sendMessage); msgInput.addEventListener("keydown", e => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); msgInput.addEventListener("input", () => { msgInput.style.height = "auto"; msgInput.style.height = Math.min(msgInput.scrollHeight, 120) + "px"; });