feat(chatbot): AI 챗봇 채널 추가
- core/chatbot.py: 세션 기반 대화 엔진 (컨텍스트 기억, Ollama sLLM 연동 + 폴백) - #AI챗봇 전용 채널: @bot 없이 자연어 자유 대화 - 타이핑 인디케이터 (봇 응답 생성 중 애니메이션) - 인텐트 분류: 재기동/배포/로그/DB/SSL/디스크/크론/보안 - Ollama(sLLM) 우선, 미설치 시 지식베이스 자동 폴백 - 대화 초기화 명령 지원 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
85e4901541
commit
a3b3aaf29e
@ -11,6 +11,7 @@ ROOMS: dict[str, str] = {
|
||||
"ops": "운영",
|
||||
"pm": "PM관리",
|
||||
"alerts": "알림",
|
||||
"chatbot": "AI 챗봇",
|
||||
}
|
||||
|
||||
CONTEXT_RULES = {
|
||||
|
||||
286
messenger/core/chatbot.py
Normal file
286
messenger/core/chatbot.py
Normal file
@ -0,0 +1,286 @@
|
||||
"""
|
||||
GUARDiA Chatbot Engine
|
||||
- Per-user session & conversation history
|
||||
- Ollama(sLLM) integration with smart fallback
|
||||
- Intent classification + domain knowledge base
|
||||
"""
|
||||
import asyncio
|
||||
import random
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
import httpx
|
||||
_HTTPX = True
|
||||
except ImportError:
|
||||
_HTTPX = False
|
||||
|
||||
# ── Ollama 설정 ────────────────────────────────────────────────
|
||||
OLLAMA_URL = "http://localhost:11434/api/chat"
|
||||
OLLAMA_MODEL = "llama3:8b-instruct-q4_K_M"
|
||||
|
||||
SYSTEM_PROMPT = """당신은 GUARDiA 인프라 자동화 시스템의 AI 어시스턴트입니다.
|
||||
1,000개 이상의 관공서 레거시 서버(WEB/WAS/DB) 운영, 배포 자동화, 장애 대응을 전문으로 합니다.
|
||||
- 응답은 한국어로, 간결하고 실용적으로 작성하세요.
|
||||
- 배포·운영 요청은 SR(Service Request) 절차를 안내하세요.
|
||||
- 서버 자격증명(IP, 비밀번호) 등 민감 정보는 절대 노출하지 마세요.
|
||||
- 모르는 내용은 솔직하게 모른다고 하세요."""
|
||||
|
||||
# ── 대화 세션 저장소 ───────────────────────────────────────────
|
||||
# { user_id: [{"role": "user"|"assistant", "content": str}, ...] }
|
||||
chat_sessions: dict[str, list] = {}
|
||||
|
||||
# ── Intent 패턴 ────────────────────────────────────────────────
|
||||
_INTENTS = [
|
||||
("greeting", r"안녕|반가|하이|헬로|hello|hi\b|처음"),
|
||||
("deploy", r"배포|deploy|올려|업로드|파일.*전송|전송.*파일"),
|
||||
("restart", r"재기동|restart|리스타트|재시작|서비스.*시작|기동"),
|
||||
("logs", r"로그|log|에러|error|오류|warn|exception|스택"),
|
||||
("status", r"상태|status|모니터|현황|서버.*확인|확인.*서버|cpu|mem|메모리"),
|
||||
("ssl", r"ssl|인증서|https|certificate|만료|expire"),
|
||||
("disk", r"디스크|disk|용량|space|df\b|파티션"),
|
||||
("db", r"db|데이터베이스|database|쿼리|query|락|lock|oracle|postgres"),
|
||||
("cron", r"크론|cron|스케줄|schedule|배치|batch|정기"),
|
||||
("security", r"보안|security|권한|permission|계정|접근|차단"),
|
||||
("help", r"도움|help|명령|command|뭐.*할|어떻게|기능|사용법"),
|
||||
("thanks", r"감사|고마|thank|ㄱㅅ|수고"),
|
||||
("clear", r"초기화|대화.*지워|새로.*시작|clear|reset"),
|
||||
]
|
||||
|
||||
# ── 지식베이스 ─────────────────────────────────────────────────
|
||||
_KB: dict[str, list[str]] = {
|
||||
"greeting": [
|
||||
"안녕하세요! GUARDiA 인프라 봇입니다.\n배포, 서버 운영, 장애 대응을 도와드립니다. 무엇을 도와드릴까요?",
|
||||
"반갑습니다! 오늘 어떤 작업을 도와드릴까요?",
|
||||
],
|
||||
"deploy": [
|
||||
(
|
||||
"배포 작업을 안내해드리겠습니다. 다음 정보를 알려주세요:\n"
|
||||
"1. **대상 기관명** (예: 기재부, 국토부)\n"
|
||||
"2. **시스템명** (예: 예산시스템, 민원포털)\n"
|
||||
"3. **배포 레이어** — WEB / WAS\n"
|
||||
"4. **파일 종류** — class(동적) / html·js·css(정적)\n\n"
|
||||
"동적 파일(class)은 무중단 롤링 재기동이 자동 수행됩니다."
|
||||
),
|
||||
],
|
||||
"restart": [
|
||||
(
|
||||
"WAS 재기동 요청 절차:\n"
|
||||
"1. SR 자동 생성 → CMDB 서버 매핑\n"
|
||||
"2. PM 승인 대기\n"
|
||||
"3. 롤링 방식: WAS#1 재기동 → 헬스체크 → WAS#2 재기동\n\n"
|
||||
"대상 서버(기관명/시스템명)를 알려주시면 SR을 즉시 생성합니다."
|
||||
),
|
||||
],
|
||||
"logs": [
|
||||
(
|
||||
"로그 분석을 시작합니다. 어떤 서버의 로그인가요?\n"
|
||||
"• **WEB** (nginx/apache access·error log)\n"
|
||||
"• **WAS** (tomcat catalina.out / jboss server.log)\n"
|
||||
"• **DB** (alert log / slow query log)\n\n"
|
||||
"기관명과 서버 유형을 말씀해주세요."
|
||||
),
|
||||
],
|
||||
"status": [
|
||||
(
|
||||
"서버 현황 조회 결과 (모의 데이터):\n"
|
||||
"```\n"
|
||||
"WEB-01 ✅ 정상 CPU 12% MEM 45% DISK 38%\n"
|
||||
"WAS-01 ✅ 정상 CPU 34% MEM 62% DISK 55%\n"
|
||||
"WAS-02 ⚠️ 경고 CPU 87% MEM 78% DISK 55%\n"
|
||||
"DB-01 ✅ 정상 CPU 9% MEM 71% CONN 42/200\n"
|
||||
"DB-02 ✅ 정상 CPU 5% MEM 68% CONN 11/200\n"
|
||||
"```\n"
|
||||
"WAS-02 CPU가 임계치(80%)를 초과했습니다. 재기동을 권장합니다."
|
||||
),
|
||||
],
|
||||
"ssl": [
|
||||
(
|
||||
"SSL 인증서 만료일 현황 (모의 데이터):\n"
|
||||
"```\n"
|
||||
"portal.mof.go.kr D-14 ⚠️ 갱신 필요\n"
|
||||
"api.molit.go.kr D-87 ✅\n"
|
||||
"www.nts.go.kr D-203 ✅\n"
|
||||
"eis.mosf.go.kr D-7 🚨 긴급 갱신 필요\n"
|
||||
"```\n"
|
||||
"D-30 이하 인증서에 대해 갱신 SR을 생성해드릴까요?"
|
||||
),
|
||||
],
|
||||
"disk": [
|
||||
(
|
||||
"디스크 사용량 현황 (모의 데이터):\n"
|
||||
"```\n"
|
||||
"WAS-01 /app/logs 92% 🚨 즉시 정리 필요\n"
|
||||
"WAS-02 /app/logs 78% ⚠️\n"
|
||||
"DB-01 /data 61% ✅\n"
|
||||
"WEB-01 /var/log 43% ✅\n"
|
||||
"```\n"
|
||||
"WAS-01 로그 파티션이 임계치를 초과했습니다.\n"
|
||||
"30일 이상 된 로그를 자동 압축·이동할까요?"
|
||||
),
|
||||
],
|
||||
"db": [
|
||||
(
|
||||
"DB 상태 점검 항목:\n"
|
||||
"• **대기 쿼리 조회** — 현재 실행 중인 슬로우 쿼리\n"
|
||||
"• **락 분석** — 락 홀더·웨이터 확인\n"
|
||||
"• **연결 수** — 최대 연결 수 대비 현황\n"
|
||||
"• **테이블스페이스** — 사용량 조회\n\n"
|
||||
"어떤 항목을 확인하시겠습니까?"
|
||||
),
|
||||
],
|
||||
"cron": [
|
||||
(
|
||||
"크론 작업 관리 기능:\n"
|
||||
"• crontab 목록 조회\n"
|
||||
"• 특정 배치 실행 이력 확인\n"
|
||||
"• 배치 강제 실행 / 일시 중지\n\n"
|
||||
"대상 서버와 작업명을 알려주세요."
|
||||
),
|
||||
],
|
||||
"security": [
|
||||
(
|
||||
"GUARDiA 보안 정책:\n"
|
||||
"• SSH 접근: opsagent 전용 계정 사용, root 직접 접속 금지\n"
|
||||
"• 자격증명: AES-256 암호화 DB 저장\n"
|
||||
"• 명령 필터: 파괴적 명령(rm -rf /, drop table) 자동 차단\n"
|
||||
"• 감사 로그: SHA-256 해시 체이닝으로 위변조 방지\n\n"
|
||||
"특정 보안 정책에 대해 더 자세히 알려드릴까요?"
|
||||
),
|
||||
],
|
||||
"help": [
|
||||
(
|
||||
"**GUARDiA Bot 전체 기능**\n\n"
|
||||
"**배포**\n"
|
||||
"• 파일 배포 (class/html/js/img)\n"
|
||||
"• 무중단 롤링 재기동\n\n"
|
||||
"**운영**\n"
|
||||
"• 실시간 로그 분석\n"
|
||||
"• 서버 상태 모니터링\n"
|
||||
"• 디스크·DB·SSL 점검\n"
|
||||
"• 크론 작업 관리\n\n"
|
||||
"**이 채팅방에서는 자연어로 바로 말씀하세요.**\n"
|
||||
"예: `기재부 예산시스템 WAS 재기동해줘`"
|
||||
),
|
||||
],
|
||||
"thanks": [
|
||||
"천만에요! 다른 도움이 필요하시면 언제든지 말씀해주세요.",
|
||||
"도움이 됐다니 다행입니다! 추가 작업이 있으시면 알려주세요.",
|
||||
],
|
||||
"clear": [
|
||||
"대화 내용을 초기화했습니다. 새로운 대화를 시작해주세요!",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── 공개 API ────────────────────────────────────────────────────
|
||||
|
||||
def clear_session(user_id: str) -> None:
|
||||
chat_sessions.pop(user_id, None)
|
||||
|
||||
|
||||
async def get_chatbot_response(user_id: str, text: str) -> str:
|
||||
"""
|
||||
챗봇 응답 생성.
|
||||
1순위: Ollama sLLM → 2순위: 의도 분류 + 지식베이스
|
||||
"""
|
||||
if user_id not in chat_sessions:
|
||||
chat_sessions[user_id] = []
|
||||
|
||||
history = chat_sessions[user_id]
|
||||
history.append({"role": "user", "content": text})
|
||||
|
||||
# 세션 초기화 명령
|
||||
intent = _classify(text)
|
||||
if intent == "clear":
|
||||
clear_session(user_id)
|
||||
return random.choice(_KB["clear"])
|
||||
|
||||
# Ollama 시도 (타임아웃 5초)
|
||||
if _HTTPX:
|
||||
reply = await _try_ollama(history)
|
||||
if reply:
|
||||
_append(user_id, "assistant", reply)
|
||||
return reply
|
||||
|
||||
# 폴백: 지식베이스 + 컨텍스트 추론
|
||||
reply = _fallback_response(intent, text, history)
|
||||
_append(user_id, "assistant", reply)
|
||||
return reply
|
||||
|
||||
|
||||
# ── 내부 헬퍼 ──────────────────────────────────────────────────
|
||||
|
||||
def _classify(text: str) -> str:
|
||||
t = text.lower()
|
||||
for name, pattern in _INTENTS:
|
||||
if re.search(pattern, t):
|
||||
return name
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _append(user_id: str, role: str, content: str) -> None:
|
||||
h = chat_sessions.setdefault(user_id, [])
|
||||
h.append({"role": role, "content": content})
|
||||
if len(h) > 20:
|
||||
chat_sessions[user_id] = h[-20:]
|
||||
|
||||
|
||||
def _prev_intent(history: list) -> Optional[str]:
|
||||
for msg in reversed(history[:-1]):
|
||||
if msg["role"] == "user":
|
||||
return _classify(msg["content"])
|
||||
return None
|
||||
|
||||
|
||||
def _fallback_response(intent: str, text: str, history: list) -> str:
|
||||
if intent in _KB:
|
||||
return random.choice(_KB[intent])
|
||||
|
||||
prev = _prev_intent(history)
|
||||
|
||||
# 이전 대화 맥락 기반 추론
|
||||
if prev in ("deploy", "restart", "logs"):
|
||||
# 기관명/시스템명 입력으로 간주
|
||||
institutions = ["기재부", "국토부", "국세청", "복지부", "행안부", "교육부"]
|
||||
if any(inst in text for inst in institutions) or len(text) < 20:
|
||||
return (
|
||||
f"'{text}' 정보를 확인했습니다.\n"
|
||||
"SR을 생성하겠습니다. 추가 정보가 있으시면 계속 말씀해주세요."
|
||||
)
|
||||
|
||||
if any(k in text for k in ["어떻게", "방법", "절차", "가이드"]):
|
||||
return (
|
||||
"GUARDiA 작업 절차:\n"
|
||||
"1. 이 채팅에서 자연어로 요청\n"
|
||||
"2. Bot이 SR(Service Request) 자동 생성\n"
|
||||
"3. CMDB에서 대상 서버 자동 매핑\n"
|
||||
"4. PM 승인 (민감 작업)\n"
|
||||
"5. SSH/SFTP 에이전트리스 실행\n"
|
||||
"6. 결과 실시간 알림"
|
||||
)
|
||||
|
||||
return (
|
||||
f"'{text[:30]}' 요청을 접수했습니다.\n"
|
||||
"좀 더 구체적으로 말씀해주시면 바로 처리해드리겠습니다.\n"
|
||||
"예: `기재부 예산시스템 WAS 재기동해줘`\n"
|
||||
"또는 `도움`을 입력하면 전체 기능을 안내해드립니다."
|
||||
)
|
||||
|
||||
|
||||
async def _try_ollama(history: list) -> Optional[str]:
|
||||
"""Ollama API 호출 — 실패 시 None."""
|
||||
try:
|
||||
messages = [{"role": "system", "content": SYSTEM_PROMPT}] + history[-10:]
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
resp = await client.post(OLLAMA_URL, json={
|
||||
"model": OLLAMA_MODEL,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.7},
|
||||
})
|
||||
if resp.status_code == 200:
|
||||
return resp.json()["message"]["content"]
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
@ -13,10 +13,11 @@ from core.bot import (
|
||||
message_store,
|
||||
store_message,
|
||||
)
|
||||
from core.chatbot import get_chatbot_response
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# room_id → list of connected WebSocket clients
|
||||
# room_id → list of connected WebSocket clients
|
||||
room_channels: Dict[str, List[WebSocket]] = {}
|
||||
|
||||
|
||||
@ -37,24 +38,27 @@ async def broadcast(room_id: str, payload: dict) -> None:
|
||||
|
||||
def _make_bot_msg(room_id: str, reply: dict) -> dict:
|
||||
return {
|
||||
"message_id": _new_msg_id(),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"room_id": room_id,
|
||||
"sender": "GUARDiA-Bot",
|
||||
"sender_type": "BOT",
|
||||
"msg_type": "CHAT",
|
||||
"content": reply.get("content", ""),
|
||||
"is_widget": reply.get("is_widget", False),
|
||||
"message_id": _new_msg_id(),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"room_id": room_id,
|
||||
"sender": "GUARDiA-Bot",
|
||||
"sender_type": "BOT",
|
||||
"msg_type": "CHAT",
|
||||
"content": reply.get("content", ""),
|
||||
"is_widget": reply.get("is_widget", False),
|
||||
"interactive_action": reply.get("interactive_action"),
|
||||
}
|
||||
|
||||
|
||||
async def _send_typing(room_id: str, show: bool) -> None:
|
||||
await broadcast(room_id, {"type": "bot_typing", "show": show, "room_id": room_id})
|
||||
|
||||
|
||||
@router.websocket("/ws/chat/{room_id}/{client_id}")
|
||||
async def chat_endpoint(ws: WebSocket, room_id: str, client_id: str):
|
||||
await ws.accept()
|
||||
room_channels.setdefault(room_id, []).append(ws)
|
||||
|
||||
# 접속 시 최근 메시지 + 방 목록 전달
|
||||
history = message_store.get(room_id, [])[-50:]
|
||||
await ws.send_text(json.dumps(
|
||||
{"type": "init", "messages": history, "rooms": ROOMS},
|
||||
@ -63,28 +67,46 @@ async def chat_endpoint(ws: WebSocket, room_id: str, client_id: str):
|
||||
|
||||
try:
|
||||
while True:
|
||||
raw = await ws.receive_text()
|
||||
raw = await ws.receive_text()
|
||||
data = json.loads(raw)
|
||||
text = data.get("content", "").strip()
|
||||
if not text:
|
||||
continue
|
||||
|
||||
# 사용자 메시지 브로드캐스트
|
||||
# 사용자 메시지 저장 + 브로드캐스트
|
||||
user_msg = {
|
||||
"message_id": _new_msg_id(),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"room_id": room_id,
|
||||
"sender": client_id,
|
||||
"sender_type": "HUMAN",
|
||||
"msg_type": "CHAT",
|
||||
"content": text,
|
||||
"is_widget": False,
|
||||
"message_id": _new_msg_id(),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"room_id": room_id,
|
||||
"sender": client_id,
|
||||
"sender_type": "HUMAN",
|
||||
"msg_type": "CHAT",
|
||||
"content": text,
|
||||
"is_widget": False,
|
||||
"interactive_action": None,
|
||||
}
|
||||
store_message(room_id, user_msg)
|
||||
await broadcast(room_id, user_msg)
|
||||
|
||||
# 봇 명령 응답
|
||||
# ── chatbot 전용 채널 ───────────────────────────────
|
||||
if room_id == "chatbot":
|
||||
await _send_typing(room_id, True)
|
||||
try:
|
||||
reply_text = await asyncio.wait_for(
|
||||
get_chatbot_response(client_id, text),
|
||||
timeout=10.0,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
reply_text = "응답 시간이 초과되었습니다. 잠시 후 다시 시도해주세요."
|
||||
finally:
|
||||
await _send_typing(room_id, False)
|
||||
|
||||
bot_msg = _make_bot_msg(room_id, {"content": reply_text, "is_widget": False})
|
||||
store_message(room_id, bot_msg)
|
||||
await broadcast(room_id, bot_msg)
|
||||
continue
|
||||
|
||||
# ── 일반 채널: @bot 명령 처리 ──────────────────────
|
||||
bot_reply = get_bot_response(text)
|
||||
if bot_reply:
|
||||
await asyncio.sleep(0.4)
|
||||
|
||||
@ -56,11 +56,20 @@ function connect() {
|
||||
|
||||
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();
|
||||
@ -68,12 +77,14 @@ function connect() {
|
||||
renderWelcome();
|
||||
}
|
||||
} else {
|
||||
showTyping(false);
|
||||
renderMessage(data);
|
||||
scrollBottom();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
showTyping(false);
|
||||
showStatus("offline", "연결 끊김 — 재연결 중…");
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, 15000);
|
||||
@ -92,10 +103,26 @@ function sendMessage() {
|
||||
msgInput.style.height = "auto";
|
||||
}
|
||||
|
||||
/* ─── Typing indicator ──────────────────────────────── */
|
||||
function showTyping(show) {
|
||||
if (show) {
|
||||
typingEl.innerHTML = `
|
||||
<div class="typing-bubble">
|
||||
<div class="typing-dots">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
GUARDiA-Bot 입력 중…
|
||||
</div>`;
|
||||
} 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();
|
||||
@ -107,19 +134,44 @@ function switchRoom(roomId) {
|
||||
|
||||
function updateChannelHeader() {
|
||||
const name = rooms[currentRoom] || currentRoom;
|
||||
channelNameEl.textContent = "# " + name;
|
||||
const isChatbot = currentRoom === "chatbot";
|
||||
channelNameEl.innerHTML = "# " + escHtml(name) +
|
||||
(isChatbot ? ' <span class="chatbot-badge">AI</span>' : "");
|
||||
channelDescEl.textContent = roomDesc(currentRoom);
|
||||
updateInputHint();
|
||||
}
|
||||
|
||||
function applyRoomClass() {
|
||||
if (currentRoom === "chatbot") {
|
||||
messagesEl.classList.add("chatbot-channel");
|
||||
} else {
|
||||
messagesEl.classList.remove("chatbot-channel");
|
||||
}
|
||||
}
|
||||
|
||||
function roomDesc(id) {
|
||||
const descs = {
|
||||
return {
|
||||
general: "팀 전체 공지 및 일반 대화",
|
||||
deploy: "배포 작업 요청 및 진행 현황",
|
||||
ops: "서버 운영 및 장애 대응",
|
||||
pm: "PM 승인 워크플로우 및 진척 관리",
|
||||
alerts: "자동 알림 및 임계치 경보",
|
||||
};
|
||||
return descs[id] || "";
|
||||
chatbot: "GUARDiA AI 챗봇 — 자연어로 자유롭게 대화하세요",
|
||||
}[id] || "";
|
||||
}
|
||||
|
||||
function updateInputHint() {
|
||||
const hintEl = document.getElementById("input-hint");
|
||||
if (currentRoom === "chatbot") {
|
||||
hintEl.innerHTML =
|
||||
"자연어로 자유롭게 입력하세요 — AI가 맥락을 기억합니다 | " +
|
||||
"<strong>초기화</strong>: '대화 초기화' 입력";
|
||||
} else {
|
||||
hintEl.innerHTML =
|
||||
"<strong>@bot 도움</strong> — 명령어 목록 | " +
|
||||
"<strong>@bot 배포</strong> — 배포 SR 생성 | " +
|
||||
"<strong>@bot 서버 상태</strong> — 인프라 현황";
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Render ────────────────────────────────────────── */
|
||||
@ -129,22 +181,42 @@ function renderChannelList() {
|
||||
const el = document.createElement("div");
|
||||
el.className = "channel-item" + (id === currentRoom ? " active" : "");
|
||||
el.dataset.room = id;
|
||||
el.innerHTML = `<span class="ch-icon">#</span> ${label}`;
|
||||
const badge = id === "chatbot" ? ' <span class="chatbot-badge" style="font-size:9px">AI</span>' : "";
|
||||
el.innerHTML = `<span class="ch-icon">#</span> ${escHtml(label)}${badge}`;
|
||||
el.addEventListener("click", () => switchRoom(id));
|
||||
channelListEl.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
function renderWelcome() {
|
||||
const div = document.createElement("div");
|
||||
div.className = "date-divider";
|
||||
div.textContent = `# ${rooms[currentRoom] || currentRoom} 채널에 오신 것을 환영합니다`;
|
||||
messagesEl.appendChild(div);
|
||||
|
||||
const hint = document.createElement("div");
|
||||
hint.style.cssText = "padding:8px 0;font-size:13px;color:var(--text-muted)";
|
||||
hint.innerHTML = "💡 <code>@bot 도움</code> 을 입력하면 GUARDiA Bot 명령어를 확인할 수 있습니다.";
|
||||
messagesEl.appendChild(hint);
|
||||
if (currentRoom === "chatbot") {
|
||||
const div = document.createElement("div");
|
||||
div.style.cssText = "padding:16px 0 8px;";
|
||||
div.innerHTML = `
|
||||
<div class="date-divider">AI 챗봇 채널</div>
|
||||
<div class="msg-group">
|
||||
<div class="avatar bot">GU</div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-meta">
|
||||
<span class="msg-sender bot">GUARDiA-Bot</span>
|
||||
<span class="msg-time">${nowTime()}</span>
|
||||
</div>
|
||||
<div class="bot-bubble">
|
||||
<div class="msg-content">안녕하세요! GUARDiA 인프라 AI 챗봇입니다.\n\n자연어로 자유롭게 질문하거나 작업을 요청하세요.\n예: <code>기재부 예산시스템 WAS 재기동해줘</code>\n예: <code>서버 상태 알려줘</code>\n예: <code>도움</code></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
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 = "💡 <code>@bot 도움</code> 을 입력하면 GUARDiA Bot 명령어를 확인할 수 있습니다.";
|
||||
messagesEl.appendChild(hint);
|
||||
}
|
||||
}
|
||||
|
||||
let lastDate = "";
|
||||
@ -168,12 +240,12 @@ function renderMessage(msg) {
|
||||
const isSame = msg.sender === lastSender && !isBot;
|
||||
lastSender = msg.sender;
|
||||
|
||||
const group = document.createElement("div");
|
||||
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", {
|
||||
const initials = (msg.sender || "?").slice(0, 2).toUpperCase();
|
||||
const time = new Date(msg.timestamp).toLocaleTimeString("ko-KR", {
|
||||
hour: "2-digit", minute: "2-digit"
|
||||
});
|
||||
|
||||
@ -216,13 +288,11 @@ function renderMessage(msg) {
|
||||
}
|
||||
}
|
||||
|
||||
const bubbleClass = isBot ? "bot-bubble" : "";
|
||||
|
||||
group.innerHTML = `
|
||||
${avatarHTML}
|
||||
<div class="msg-body">
|
||||
${metaHTML}
|
||||
<div class="${bubbleClass}">
|
||||
<div class="${isBot ? "bot-bubble" : ""}">
|
||||
<div class="msg-content">${contentHTML}</div>
|
||||
${widgetHTML}
|
||||
</div>
|
||||
@ -234,8 +304,8 @@ function renderMessage(msg) {
|
||||
/* ─── Interactive actions ───────────────────────────── */
|
||||
async function handleAction(btn, code, url, label) {
|
||||
btn.classList.add("clicked");
|
||||
const siblings = btn.closest(".widget-actions")?.querySelectorAll(".btn-action");
|
||||
siblings?.forEach(b => b.classList.add("clicked"));
|
||||
btn.closest(".widget-actions")?.querySelectorAll(".btn-action")
|
||||
.forEach(b => b.classList.add("clicked"));
|
||||
|
||||
let resultText = "";
|
||||
if (url) {
|
||||
@ -256,39 +326,31 @@ async function handleAction(btn, code, url, label) {
|
||||
btn.closest(".widget-actions").insertAdjacentElement("afterend", result);
|
||||
}
|
||||
|
||||
/* ─── Formatting helpers ────────────────────────────── */
|
||||
/* ─── Formatting ────────────────────────────────────── */
|
||||
function formatContent(text) {
|
||||
// Code blocks
|
||||
if (!text) return "";
|
||||
text = text.replace(/```([\s\S]*?)```/g, (_, code) =>
|
||||
`<pre>${escHtml(code.trim())}</pre>`
|
||||
);
|
||||
// Inline code
|
||||
text = text.replace(/`([^`]+)`/g, (_, code) =>
|
||||
`<code>${escHtml(code)}</code>`
|
||||
);
|
||||
// Bold
|
||||
`<pre>${escHtml(code.trim())}</pre>`);
|
||||
text = text.replace(/`([^`]+)`/g, (_, c) => `<code>${escHtml(c)}</code>`);
|
||||
text = text.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
||||
// Remaining escaping for non-tagged parts (basic)
|
||||
return text;
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s)
|
||||
return String(s ?? "")
|
||||
.replace(/&/g, "&").replace(/</g, "<")
|
||||
.replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
function escAttr(s) { return String(s ?? "").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;
|
||||
});
|
||||
requestAnimationFrame(() => { messagesEl.scrollTop = messagesEl.scrollHeight; });
|
||||
}
|
||||
|
||||
function showStatus(cls, text) {
|
||||
connStatus.className = `show ${cls}`;
|
||||
connStatus.textContent = text;
|
||||
|
||||
@ -250,6 +250,41 @@ html, body { height: 100%; font-family: var(--font); background: var(--main-bg);
|
||||
margin-top: 4px; padding: 0 2px;
|
||||
}
|
||||
|
||||
/* ─── Typing indicator ──────────────────────────────── */
|
||||
#typing-indicator { min-height: 24px; padding: 2px 20px 0; }
|
||||
|
||||
.typing-bubble {
|
||||
display: inline-flex; align-items: center; gap: 10px;
|
||||
background: var(--bot-bubble); border-left: 3px solid var(--bot-border);
|
||||
border-radius: 0 var(--radius) var(--radius) 0;
|
||||
padding: 8px 14px; font-size: 13px; color: #8bb8d4;
|
||||
}
|
||||
.typing-dots { display: flex; gap: 4px; }
|
||||
.typing-dots span {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--accent); display: inline-block;
|
||||
animation: bounce 1.2s infinite ease-in-out;
|
||||
}
|
||||
.typing-dots span:nth-child(1) { animation-delay: 0s; }
|
||||
.typing-dots span:nth-child(2) { animation-delay: .2s; }
|
||||
.typing-dots span:nth-child(3) { animation-delay: .4s; }
|
||||
@keyframes bounce {
|
||||
0%, 60%, 100% { transform: translateY(0); opacity: .5; }
|
||||
30% { transform: translateY(-6px); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ─── chatbot 채널 전용 스타일 ──────────────────────── */
|
||||
.chatbot-channel .bot-bubble {
|
||||
background: #12294a; border-left-color: #2a7abf;
|
||||
}
|
||||
.chatbot-badge {
|
||||
display: inline-block; font-size: 10px; font-weight: 700;
|
||||
background: var(--accent); color: #fff; border-radius: 4px;
|
||||
padding: 1px 5px; margin-left: 6px; vertical-align: middle;
|
||||
}
|
||||
.channel-item[data-room="chatbot"] .ch-icon { color: #1d9bd1; }
|
||||
.channel-item[data-room="chatbot"].active { background: #0f3460; }
|
||||
|
||||
/* ─── Connection status ─────────────────────────────── */
|
||||
#conn-status {
|
||||
position: fixed; bottom: 16px; right: 16px;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user