feat(onboarding): 설치 후 자동 실행 가이드 챗봇 구현
[온보딩 API (routers/onboarding.py)] - 8단계 온보딩 플로우: 0. 환영 → 1. 비밀번호변경 → 2. 대시보드 → 3. 프로젝트등록 → 4. 서버등록 → 5. 소스코드등록 → 6. 메신저봇 → 7. 완료 - POST /api/onboarding/message: 현재화면 + 사용자질문 → Ollama 답변 - 화면별 스포트라이트 target 정의 (CSS selector) - 사용자별 단계 상태 영속 관리 [온보딩 챗봇 UI (static/onboarding.js)] - 우측 고정 패널 (360px, 모바일 하단 슬라이드) - 타이핑 애니메이션 효과 + 마크다운 렌더링 - 스포트라이트: 현재 단계 UI 요소를 하이라이트 - 화면 변화 감지 (MutationObserver + click 이벤트) - 최소화/닫기/재시작 제어 - 사용자 질문 입력 → Ollama 실시간 답변 - 온보딩 완료 후 우측하단 ? 도움말 버튼 - 액션버튼: next/navigate/external/complete/skip [설치 자동화 연동] - install_auto.sh: 설치 완료 후 onboarding reset API 호출 - 브라우저 열릴 때 챗봇 자동 표시 사용자 경험: 설치 완료 → 브라우저 자동 오픈 → 챗봇 우측 등장 → "환영합니다!" → 비밀번호변경 화면 이동 안내 → CMDB 서버등록 스포트라이트 → Gitea 소스등록 → 완료 후 ? 버튼으로 재시작 가능 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bae659adba
commit
97afb8d51d
@ -46,6 +46,7 @@ from routers import (
|
|||||||
jmeter,
|
jmeter,
|
||||||
public_checklist,
|
public_checklist,
|
||||||
customer_portal,
|
customer_portal,
|
||||||
|
onboarding,
|
||||||
groupware,
|
groupware,
|
||||||
siem,
|
siem,
|
||||||
topology,
|
topology,
|
||||||
@ -264,6 +265,7 @@ app.include_router(public_checklist.router)
|
|||||||
|
|
||||||
# 추가 기능
|
# 추가 기능
|
||||||
app.include_router(customer_portal.router) # 고객 셀프서비스 포털
|
app.include_router(customer_portal.router) # 고객 셀프서비스 포털
|
||||||
|
app.include_router(onboarding.router) # 온보딩 가이드 챗봇
|
||||||
app.include_router(groupware.router) # 그룹웨어 전자결재 연동
|
app.include_router(groupware.router) # 그룹웨어 전자결재 연동
|
||||||
app.include_router(siem.router) # SIEM 보안 이벤트 연동
|
app.include_router(siem.router) # SIEM 보안 이벤트 연동
|
||||||
app.include_router(topology.router) # 네트워크 토폴로지 시각화
|
app.include_router(topology.router) # 네트워크 토폴로지 시각화
|
||||||
|
|||||||
369
itsm/routers/onboarding.py
Normal file
369
itsm/routers/onboarding.py
Normal file
@ -0,0 +1,369 @@
|
|||||||
|
"""
|
||||||
|
GUARDiA ITSM 온보딩 가이드 API
|
||||||
|
|
||||||
|
설치 완료 후 자동 시작되어 사용자를 단계별로 안내.
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
GET /api/onboarding/status — 현재 온보딩 상태
|
||||||
|
POST /api/onboarding/step — 단계 업데이트
|
||||||
|
POST /api/onboarding/message — 현재 화면 기반 봇 응답
|
||||||
|
POST /api/onboarding/complete — 온보딩 완료 처리
|
||||||
|
POST /api/onboarding/reset — 온보딩 초기화 (재시작)
|
||||||
|
GET /api/onboarding/steps — 전체 단계 목록
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from core.auth import get_current_user
|
||||||
|
from database import get_db
|
||||||
|
from models import User
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/onboarding", tags=["onboarding"])
|
||||||
|
|
||||||
|
# ── 온보딩 단계 정의 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ONBOARDING_STEPS = [
|
||||||
|
{
|
||||||
|
"id": "welcome",
|
||||||
|
"order": 0,
|
||||||
|
"title": "환영합니다!",
|
||||||
|
"icon": "👋",
|
||||||
|
"view": None, # 어느 화면에서나
|
||||||
|
"target": None, # 하이라이트할 요소
|
||||||
|
"message": (
|
||||||
|
"안녕하세요! GUARDiA ITSM에 오신 것을 환영합니다.\n\n"
|
||||||
|
"설치가 완료되었습니다. 지금부터 **5단계** 가이드로\n"
|
||||||
|
"첫 번째 프로젝트를 등록하고 소스코드 관리까지 완료해 드리겠습니다.\n\n"
|
||||||
|
"준비가 되셨으면 **시작하기**를 눌러주세요."
|
||||||
|
),
|
||||||
|
"actions": [{"label": "시작하기 →", "action": "next"}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "change_password",
|
||||||
|
"order": 1,
|
||||||
|
"title": "비밀번호 변경",
|
||||||
|
"icon": "🔑",
|
||||||
|
"view": "/change-password",
|
||||||
|
"target": "#current-password",
|
||||||
|
"message": (
|
||||||
|
"보안을 위해 **초기 비밀번호를 변경**해 주세요.\n\n"
|
||||||
|
"초기 비밀번호: `1111`\n\n"
|
||||||
|
"현재 비밀번호에 `1111`을 입력하고,\n"
|
||||||
|
"새로운 안전한 비밀번호로 변경해 주세요.\n\n"
|
||||||
|
"💡 **강력한 비밀번호 조건**\n"
|
||||||
|
"- 8자 이상\n- 대문자 + 숫자 + 특수문자 포함"
|
||||||
|
),
|
||||||
|
"actions": [
|
||||||
|
{"label": "비밀번호 변경 페이지로", "action": "navigate", "path": "/change-password"},
|
||||||
|
{"label": "이미 변경했어요", "action": "next"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "explore_dashboard",
|
||||||
|
"order": 2,
|
||||||
|
"title": "대시보드 둘러보기",
|
||||||
|
"icon": "📊",
|
||||||
|
"view": "/",
|
||||||
|
"target": ".dash-tab-nav",
|
||||||
|
"message": (
|
||||||
|
"이곳이 GUARDiA **통합 대시보드**입니다.\n\n"
|
||||||
|
"4가지 탭으로 구성되어 있어요:\n"
|
||||||
|
"- 📊 **운영 현황** — SR·SLA·이슈 현황\n"
|
||||||
|
"- 🖥️ **인프라** — 서버·CMDB 현황\n"
|
||||||
|
"- 🔒 **보안** — 취약점·패치 현황\n"
|
||||||
|
"- 🤖 **AI 인사이트** — 예측·이상탐지\n\n"
|
||||||
|
"각 탭을 클릭해서 살펴보세요!"
|
||||||
|
),
|
||||||
|
"actions": [
|
||||||
|
{"label": "대시보드로 이동", "action": "navigate", "path": "/"},
|
||||||
|
{"label": "다음 단계 →", "action": "next"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "create_project",
|
||||||
|
"order": 3,
|
||||||
|
"title": "첫 프로젝트 등록",
|
||||||
|
"icon": "🏗️",
|
||||||
|
"view": "/si",
|
||||||
|
"target": ".btn-primary",
|
||||||
|
"message": (
|
||||||
|
"**PMS(프로젝트 관리)**에서 첫 번째 프로젝트를 등록해보세요.\n\n"
|
||||||
|
"**1.** 좌측 메뉴 → **PMS 프로젝트** 클릭\n"
|
||||||
|
"**2.** 우측 상단 **+ 프로젝트 생성** 버튼 클릭\n"
|
||||||
|
"**3.** 아래 정보를 입력하세요:\n\n"
|
||||||
|
"```\n프로젝트 코드: PRJ-2026-001\n프로젝트명: 우리 회사 정보화사업\n담당 PM: admin\n```\n\n"
|
||||||
|
"등록하면 WBS, 산출물, 보고서 기능을 바로 사용할 수 있어요."
|
||||||
|
),
|
||||||
|
"actions": [
|
||||||
|
{"label": "프로젝트 관리로 이동", "action": "navigate", "path": "/si"},
|
||||||
|
{"label": "프로젝트 등록 완료", "action": "next"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "register_server",
|
||||||
|
"order": 4,
|
||||||
|
"title": "관리 서버 등록",
|
||||||
|
"icon": "🖥️",
|
||||||
|
"view": "/",
|
||||||
|
"target": "[data-view='cmdb']",
|
||||||
|
"message": (
|
||||||
|
"**CMDB**에 관리할 서버를 등록해 주세요.\n\n"
|
||||||
|
"**1.** 좌측 메뉴 → **인프라 → CMDB** 클릭\n"
|
||||||
|
"**2.** **서버 등록** 버튼 클릭\n"
|
||||||
|
"**3.** 아래 정보를 입력하세요:\n\n"
|
||||||
|
"```\n서버명: web-server-01\nIP: 10.0.0.1\nOS: CentOS 7\nSSH 계정: opsagent\n```\n\n"
|
||||||
|
"⚠️ 보안: SSH 비밀번호는 AES-256-GCM으로 암호화 저장됩니다.\n\n"
|
||||||
|
"서버 등록 후 메신저 봇으로 즉시 제어할 수 있어요."
|
||||||
|
),
|
||||||
|
"actions": [
|
||||||
|
{"label": "CMDB로 이동", "action": "navigate", "path": "/?view=cmdb"},
|
||||||
|
{"label": "나중에 등록", "action": "next"},
|
||||||
|
{"label": "서버 등록 완료", "action": "next"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "register_source",
|
||||||
|
"order": 5,
|
||||||
|
"title": "소스 코드 등록",
|
||||||
|
"icon": "📦",
|
||||||
|
"view": None,
|
||||||
|
"target": None,
|
||||||
|
"message": (
|
||||||
|
"**Gitea**에 소스 코드를 등록하세요.\n\n"
|
||||||
|
"Gitea는 설치 시 자동으로 구성되었습니다.\n\n"
|
||||||
|
"**1.** Gitea 접속: `http://[서버IP]:3000`\n"
|
||||||
|
"**2.** 계정: `gitadmin / Gitea@guardia!`\n"
|
||||||
|
"**3.** guardia 조직 → GUARDiA 저장소에 소스 Push:\n\n"
|
||||||
|
"```bash\ngit remote add guardia \\\n http://gitadmin@[서버IP]:3000/guardia/GUARDiA.git\ngit push guardia main\n```\n\n"
|
||||||
|
"등록 후 Jenkins CI/CD와 자동 연동됩니다."
|
||||||
|
),
|
||||||
|
"actions": [
|
||||||
|
{"label": "Gitea 열기", "action": "external", "url": "http://localhost:3000"},
|
||||||
|
{"label": "소스 등록 완료", "action": "next"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "setup_messenger",
|
||||||
|
"order": 6,
|
||||||
|
"title": "메신저 봇 연결",
|
||||||
|
"icon": "💬",
|
||||||
|
"view": None,
|
||||||
|
"target": None,
|
||||||
|
"message": (
|
||||||
|
"**메신저 봇**을 연결하면 채팅으로 인프라를 제어할 수 있어요.\n\n"
|
||||||
|
"지원 플랫폼:\n"
|
||||||
|
"- 카카오워크\n- 네이버웍스\n- 슬랙\n\n"
|
||||||
|
"**설정 방법:**\n"
|
||||||
|
"`.env` 파일에 아래를 추가:\n\n"
|
||||||
|
"```\nMESSENGER_BASE_URL=http://메신저서버:8002\nBOT_SEND_API=http://봇API주소\n```\n\n"
|
||||||
|
"설정 후 `/sr 테스트 메시지`로 동작을 확인하세요.\n\n"
|
||||||
|
"📌 봇 명령어 25개: `/sr`, `/deploy`, `/rca`, `/scan` 등"
|
||||||
|
),
|
||||||
|
"actions": [
|
||||||
|
{"label": "나중에 설정", "action": "next"},
|
||||||
|
{"label": "설정 완료", "action": "next"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "complete",
|
||||||
|
"order": 7,
|
||||||
|
"title": "설정 완료!",
|
||||||
|
"icon": "🎉",
|
||||||
|
"view": None,
|
||||||
|
"target": None,
|
||||||
|
"message": (
|
||||||
|
"**축하합니다!** GUARDiA ITSM 초기 설정이 완료되었습니다.\n\n"
|
||||||
|
"✅ 비밀번호 변경\n"
|
||||||
|
"✅ 프로젝트 등록\n"
|
||||||
|
"✅ 서버 등록\n"
|
||||||
|
"✅ 소스 코드 등록\n"
|
||||||
|
"✅ 메신저 봇 연결\n\n"
|
||||||
|
"이제 GUARDiA의 모든 기능을 사용할 수 있습니다!\n\n"
|
||||||
|
"도움이 필요하면 우측 하단 **?** 버튼을 클릭하거나\n"
|
||||||
|
"봇 명령어 `/help`를 입력하세요."
|
||||||
|
),
|
||||||
|
"actions": [
|
||||||
|
{"label": "완료 — 시작하기!", "action": "complete"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── 온보딩 상태 (운영 시 DB 테이블로 이전) ─────────────────────────────────
|
||||||
|
# 사용자별 상태 저장
|
||||||
|
_onboarding_state: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user_state(username: str) -> dict:
|
||||||
|
if username not in _onboarding_state:
|
||||||
|
_onboarding_state[username] = {
|
||||||
|
"username": username,
|
||||||
|
"current_step": 0,
|
||||||
|
"completed": False,
|
||||||
|
"dismissed": False,
|
||||||
|
"started_at": datetime.utcnow().isoformat(),
|
||||||
|
"completed_at": None,
|
||||||
|
"step_history": [],
|
||||||
|
}
|
||||||
|
return _onboarding_state[username]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 스키마 ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class StepUpdateRequest(BaseModel):
|
||||||
|
step_id: str
|
||||||
|
action: str = "complete" # complete | skip | back
|
||||||
|
|
||||||
|
|
||||||
|
class MessageRequest(BaseModel):
|
||||||
|
current_view: str = "/"
|
||||||
|
current_step: Optional[str] = None
|
||||||
|
user_message: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 엔드포인트 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def get_onboarding_status(
|
||||||
|
cu: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""현재 온보딩 상태 반환."""
|
||||||
|
state = _get_user_state(cu.username)
|
||||||
|
step_idx = state["current_step"]
|
||||||
|
current = ONBOARDING_STEPS[step_idx] if step_idx < len(ONBOARDING_STEPS) else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"username": cu.username,
|
||||||
|
"current_step": step_idx,
|
||||||
|
"total_steps": len(ONBOARDING_STEPS),
|
||||||
|
"completed": state["completed"],
|
||||||
|
"dismissed": state["dismissed"],
|
||||||
|
"show_bot": not state["completed"] and not state["dismissed"],
|
||||||
|
"current": current,
|
||||||
|
"progress_pct": round(step_idx / len(ONBOARDING_STEPS) * 100, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/steps")
|
||||||
|
async def get_steps(_u: User = Depends(get_current_user)):
|
||||||
|
"""전체 온보딩 단계 목록."""
|
||||||
|
return {"steps": ONBOARDING_STEPS, "total": len(ONBOARDING_STEPS)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/step")
|
||||||
|
async def update_step(
|
||||||
|
body: StepUpdateRequest,
|
||||||
|
cu: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""단계 업데이트 (완료/건너뜀/뒤로)."""
|
||||||
|
state = _get_user_state(cu.username)
|
||||||
|
|
||||||
|
current_idx = state["current_step"]
|
||||||
|
|
||||||
|
if body.action in ("complete", "next", "skip"):
|
||||||
|
if current_idx < len(ONBOARDING_STEPS) - 1:
|
||||||
|
state["current_step"] = current_idx + 1
|
||||||
|
state["step_history"].append({
|
||||||
|
"step_id": body.step_id,
|
||||||
|
"action": body.action,
|
||||||
|
"at": datetime.utcnow().isoformat(),
|
||||||
|
})
|
||||||
|
elif body.action == "back" and current_idx > 0:
|
||||||
|
state["current_step"] = current_idx - 1
|
||||||
|
|
||||||
|
new_idx = state["current_step"]
|
||||||
|
new_step = ONBOARDING_STEPS[new_idx] if new_idx < len(ONBOARDING_STEPS) else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"current_step": new_idx,
|
||||||
|
"step": new_step,
|
||||||
|
"completed": state["completed"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/complete")
|
||||||
|
async def complete_onboarding(cu: User = Depends(get_current_user)):
|
||||||
|
"""온보딩 완료 처리."""
|
||||||
|
state = _get_user_state(cu.username)
|
||||||
|
state["completed"] = True
|
||||||
|
state["current_step"] = len(ONBOARDING_STEPS) - 1
|
||||||
|
state["completed_at"] = datetime.utcnow().isoformat()
|
||||||
|
return {"message": "온보딩 완료!", "completed": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dismiss")
|
||||||
|
async def dismiss_onboarding(cu: User = Depends(get_current_user)):
|
||||||
|
"""온보딩 임시 숨기기."""
|
||||||
|
state = _get_user_state(cu.username)
|
||||||
|
state["dismissed"] = True
|
||||||
|
return {"dismissed": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reset")
|
||||||
|
async def reset_onboarding(cu: User = Depends(get_current_user)):
|
||||||
|
"""온보딩 초기화 (재시작)."""
|
||||||
|
_onboarding_state.pop(cu.username, None)
|
||||||
|
return {"message": "온보딩 초기화 완료 — 새로고침하면 다시 시작됩니다."}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/message")
|
||||||
|
async def get_bot_message(
|
||||||
|
body: MessageRequest,
|
||||||
|
cu: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""현재 화면 + 사용자 메시지 기반 봇 응답 (Ollama 연동)."""
|
||||||
|
state = _get_user_state(cu.username)
|
||||||
|
|
||||||
|
# 현재 단계 메시지 반환 (기본)
|
||||||
|
step_idx = state["current_step"]
|
||||||
|
current = ONBOARDING_STEPS[step_idx] if step_idx < len(ONBOARDING_STEPS) else None
|
||||||
|
|
||||||
|
# 사용자 질문이 있으면 Ollama로 답변
|
||||||
|
if body.user_message:
|
||||||
|
try:
|
||||||
|
from core.llm_client import get_llm_client
|
||||||
|
context = (
|
||||||
|
f"당신은 GUARDiA ITSM 설치 가이드 AI입니다. "
|
||||||
|
f"현재 사용자는 '{current['title'] if current else '설치 완료'}' 단계에 있습니다. "
|
||||||
|
f"현재 화면: {body.current_view}\n"
|
||||||
|
f"사용자 질문: {body.user_message}\n\n"
|
||||||
|
f"간결하고 친절하게 한국어로 답변하세요. "
|
||||||
|
f"GUARDiA API나 기능에 대해 모르면 솔직히 말하세요."
|
||||||
|
)
|
||||||
|
client = get_llm_client()
|
||||||
|
resp = await client.chat(context)
|
||||||
|
return {
|
||||||
|
"type": "llm_answer",
|
||||||
|
"message": resp.content.strip()[:500],
|
||||||
|
"current": current,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Ollama 응답 실패: %s", e)
|
||||||
|
|
||||||
|
# 화면별 컨텍스트 힌트
|
||||||
|
view_hints = {
|
||||||
|
"/": "대시보드 화면입니다. 좌측 메뉴에서 다양한 기능에 접근할 수 있어요.",
|
||||||
|
"/change-password":"비밀번호 변경 화면입니다. 현재 비밀번호 1111을 새 비밀번호로 변경하세요.",
|
||||||
|
"/si": "PMS 프로젝트 관리 화면입니다. + 버튼으로 새 프로젝트를 만드세요.",
|
||||||
|
"/incidents": "인시던트 관리 화면입니다. 장애 발생 시 여기서 처리하세요.",
|
||||||
|
"/agents": "AI 에이전트 현황입니다. Ollama 연결 상태를 확인할 수 있어요.",
|
||||||
|
"/license": "라이선스 관리 화면입니다. 현재 체험판 라이선스가 적용되어 있어요.",
|
||||||
|
}
|
||||||
|
hint = view_hints.get(body.current_view, "")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "step_guide",
|
||||||
|
"message": current["message"] if current else "온보딩이 완료되었습니다!",
|
||||||
|
"hint": hint,
|
||||||
|
"current": current,
|
||||||
|
"step_idx":step_idx,
|
||||||
|
"progress":round(step_idx / len(ONBOARDING_STEPS) * 100, 0),
|
||||||
|
}
|
||||||
@ -786,5 +786,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js"></script>
|
||||||
|
<!-- 온보딩 가이드 챗봇 — 설치 완료 후 자동 실행 -->
|
||||||
|
<script src="/static/onboarding.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
630
itsm/static/onboarding.js
Normal file
630
itsm/static/onboarding.js
Normal file
@ -0,0 +1,630 @@
|
|||||||
|
/**
|
||||||
|
* GUARDiA ITSM 온보딩 가이드 챗봇
|
||||||
|
* 설치 완료 후 자동 실행 — 로그인부터 프로젝트 등록까지 단계별 안내
|
||||||
|
*/
|
||||||
|
(function GUARDiAOnboarding() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ── 상태 ──────────────────────────────────────────────────
|
||||||
|
let state = {
|
||||||
|
visible: false,
|
||||||
|
minimized: false,
|
||||||
|
currentStep: null,
|
||||||
|
totalSteps: 8,
|
||||||
|
messages: [],
|
||||||
|
isTyping: false,
|
||||||
|
spotlightEl: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
let _token = null;
|
||||||
|
let _pollId = null;
|
||||||
|
|
||||||
|
// ── 초기화 ────────────────────────────────────────────────
|
||||||
|
function init() {
|
||||||
|
// 토큰 확인 (로그인 상태만)
|
||||||
|
_token = localStorage.getItem('access_token');
|
||||||
|
if (!_token) return;
|
||||||
|
|
||||||
|
// 온보딩 상태 조회
|
||||||
|
fetchStatus().then(status => {
|
||||||
|
if (!status) return;
|
||||||
|
if (status.show_bot) {
|
||||||
|
buildUI();
|
||||||
|
show();
|
||||||
|
loadStep(status.current);
|
||||||
|
// 화면 변화 감지
|
||||||
|
watchNavigation();
|
||||||
|
} else {
|
||||||
|
// 완료됐어도 우측하단 도움말 버튼만 남김
|
||||||
|
buildHelpButton();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API ───────────────────────────────────────────────────
|
||||||
|
async function api(method, path, body) {
|
||||||
|
const opts = {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ' + _token,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (body) opts.body = JSON.stringify(body);
|
||||||
|
try {
|
||||||
|
const r = await fetch(path, opts);
|
||||||
|
return r.ok ? r.json() : null;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStatus() {
|
||||||
|
return api('GET', '/api/onboarding/status');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postStep(stepId, action) {
|
||||||
|
return api('POST', '/api/onboarding/step', { step_id: stepId, action });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postMessage(userMessage) {
|
||||||
|
return api('POST', '/api/onboarding/message', {
|
||||||
|
current_view: location.pathname,
|
||||||
|
current_step: state.currentStep?.id,
|
||||||
|
user_message: userMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeOnboarding() {
|
||||||
|
await api('POST', '/api/onboarding/complete');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dismissOnboarding() {
|
||||||
|
await api('POST', '/api/onboarding/dismiss');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UI 빌드 ───────────────────────────────────────────────
|
||||||
|
function buildUI() {
|
||||||
|
if (document.getElementById('grd-onboarding')) return;
|
||||||
|
|
||||||
|
const panel = document.createElement('div');
|
||||||
|
panel.id = 'grd-onboarding';
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div id="grd-ob-header">
|
||||||
|
<div class="grd-ob-avatar">🤖</div>
|
||||||
|
<div class="grd-ob-header-info">
|
||||||
|
<div class="grd-ob-title">GUARDiA 가이드</div>
|
||||||
|
<div class="grd-ob-subtitle" id="grd-ob-subtitle">초기 설정 안내</div>
|
||||||
|
</div>
|
||||||
|
<div class="grd-ob-header-actions">
|
||||||
|
<button class="grd-ob-btn-icon" id="grd-ob-minimize" title="최소화">─</button>
|
||||||
|
<button class="grd-ob-btn-icon" id="grd-ob-close" title="닫기">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 진행 바 -->
|
||||||
|
<div id="grd-ob-progress-bar">
|
||||||
|
<div id="grd-ob-progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
<div id="grd-ob-progress-label">1 / 8 단계</div>
|
||||||
|
|
||||||
|
<!-- 메시지 영역 -->
|
||||||
|
<div id="grd-ob-messages"></div>
|
||||||
|
|
||||||
|
<!-- 입력 영역 -->
|
||||||
|
<div id="grd-ob-input-area">
|
||||||
|
<input id="grd-ob-input" type="text" placeholder="질문이 있으면 입력하세요..." />
|
||||||
|
<button id="grd-ob-send" title="전송">➤</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 스타일
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
#grd-onboarding {
|
||||||
|
position: fixed;
|
||||||
|
right: 0; top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 360px;
|
||||||
|
max-height: 85vh;
|
||||||
|
background: #1e2333;
|
||||||
|
border-left: 3px solid #818cf8;
|
||||||
|
border-radius: 16px 0 0 16px;
|
||||||
|
box-shadow: -8px 0 40px rgba(0,0,0,.4);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
z-index: 9999;
|
||||||
|
font-family: 'Noto Sans KR', Arial, sans-serif;
|
||||||
|
font-size: 13px; color: #e2e8f0;
|
||||||
|
transition: all .3s cubic-bezier(.4,0,.2,1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#grd-onboarding.minimized {
|
||||||
|
height: 52px; max-height: 52px;
|
||||||
|
border-radius: 16px 0 0 16px;
|
||||||
|
}
|
||||||
|
#grd-onboarding.minimized #grd-ob-messages,
|
||||||
|
#grd-onboarding.minimized #grd-ob-input-area,
|
||||||
|
#grd-onboarding.minimized #grd-ob-progress-bar,
|
||||||
|
#grd-onboarding.minimized #grd-ob-progress-label { display: none; }
|
||||||
|
|
||||||
|
#grd-ob-header {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #252b3b;
|
||||||
|
cursor: pointer; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.grd-ob-avatar {
|
||||||
|
width: 36px; height: 36px; border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg,#818cf8,#6366f1);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 18px; flex-shrink: 0;
|
||||||
|
animation: pulse 2s ease infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,100%{box-shadow:0 0 0 0 rgba(129,140,248,.4)}
|
||||||
|
50%{box-shadow:0 0 0 8px rgba(129,140,248,0)}
|
||||||
|
}
|
||||||
|
.grd-ob-header-info { flex: 1; }
|
||||||
|
.grd-ob-title { font-weight: 700; font-size: 14px; color: #fff; }
|
||||||
|
.grd-ob-subtitle { font-size: 11px; color: #818cf8; margin-top: 1px; }
|
||||||
|
.grd-ob-header-actions { display: flex; gap: 4px; }
|
||||||
|
.grd-ob-btn-icon {
|
||||||
|
width: 26px; height: 26px; border-radius: 6px;
|
||||||
|
background: rgba(255,255,255,.08);
|
||||||
|
color: #94a3b8; border: none; cursor: pointer;
|
||||||
|
font-size: 13px; display: flex; align-items: center; justify-content: center;
|
||||||
|
transition: all .15s;
|
||||||
|
}
|
||||||
|
.grd-ob-btn-icon:hover { background: rgba(255,255,255,.15); color: #fff; }
|
||||||
|
|
||||||
|
#grd-ob-progress-bar {
|
||||||
|
height: 4px; background: rgba(255,255,255,.08);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
#grd-ob-progress-fill {
|
||||||
|
height: 100%; background: linear-gradient(90deg,#818cf8,#6ee7b7);
|
||||||
|
transition: width .5s ease;
|
||||||
|
}
|
||||||
|
#grd-ob-progress-label {
|
||||||
|
font-size: 10px; color: #64748b;
|
||||||
|
text-align: right; padding: 3px 12px 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#grd-ob-messages {
|
||||||
|
flex: 1; overflow-y: auto; padding: 14px 12px;
|
||||||
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
.grd-ob-msg {
|
||||||
|
display: flex; gap: 8px; align-items: flex-start;
|
||||||
|
}
|
||||||
|
.grd-ob-msg.user { flex-direction: row-reverse; }
|
||||||
|
.grd-ob-msg-avatar {
|
||||||
|
width: 28px; height: 28px; border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg,#818cf8,#6366f1);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 14px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.grd-ob-msg.user .grd-ob-msg-avatar {
|
||||||
|
background: rgba(99,102,241,.25);
|
||||||
|
}
|
||||||
|
.grd-ob-msg-bubble {
|
||||||
|
background: #252b3b;
|
||||||
|
border-radius: 4px 14px 14px 14px;
|
||||||
|
padding: 10px 13px;
|
||||||
|
max-width: 270px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
.grd-ob-msg.user .grd-ob-msg-bubble {
|
||||||
|
background: #4f46e5;
|
||||||
|
border-radius: 14px 4px 14px 14px;
|
||||||
|
}
|
||||||
|
.grd-ob-msg-bubble strong { color: #a5b4fc; }
|
||||||
|
.grd-ob-msg-bubble code {
|
||||||
|
background: rgba(255,255,255,.1);
|
||||||
|
padding: 2px 5px; border-radius: 4px;
|
||||||
|
font-family: monospace; font-size: 12px;
|
||||||
|
}
|
||||||
|
.grd-ob-actions {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 6px;
|
||||||
|
margin-top: 8px; padding-left: 36px;
|
||||||
|
}
|
||||||
|
.grd-ob-action-btn {
|
||||||
|
padding: 7px 14px; border-radius: 20px;
|
||||||
|
background: rgba(129,140,248,.18);
|
||||||
|
color: #818cf8; border: 1px solid rgba(129,140,248,.3);
|
||||||
|
font-size: 12px; font-weight: 600; cursor: pointer;
|
||||||
|
transition: all .15s; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.grd-ob-action-btn:hover {
|
||||||
|
background: rgba(129,140,248,.35);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.grd-ob-action-btn.primary {
|
||||||
|
background: #4f46e5; color: #fff; border-color: transparent;
|
||||||
|
}
|
||||||
|
.grd-ob-action-btn.primary:hover { background: #4338ca; }
|
||||||
|
|
||||||
|
.grd-ob-typing {
|
||||||
|
display: flex; gap: 4px; padding: 10px 14px;
|
||||||
|
background: #252b3b; border-radius: 4px 14px 14px 14px;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
.grd-ob-typing span {
|
||||||
|
width: 6px; height: 6px; border-radius: 50%;
|
||||||
|
background: #818cf8; animation: typing .8s ease infinite;
|
||||||
|
}
|
||||||
|
.grd-ob-typing span:nth-child(2) { animation-delay: .2s; }
|
||||||
|
.grd-ob-typing span:nth-child(3) { animation-delay: .4s; }
|
||||||
|
@keyframes typing { 0%,60%,100%{opacity:.3} 30%{opacity:1} }
|
||||||
|
|
||||||
|
#grd-ob-input-area {
|
||||||
|
display: flex; gap: 8px; padding: 10px 12px;
|
||||||
|
border-top: 1px solid rgba(255,255,255,.07);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
#grd-ob-input {
|
||||||
|
flex: 1; background: rgba(255,255,255,.06);
|
||||||
|
border: 1px solid rgba(255,255,255,.1);
|
||||||
|
border-radius: 8px; color: #e2e8f0;
|
||||||
|
padding: 8px 12px; font-size: 13px; outline: none;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
#grd-ob-input:focus { border-color: #818cf8; }
|
||||||
|
#grd-ob-send {
|
||||||
|
width: 34px; height: 34px; border-radius: 8px;
|
||||||
|
background: #4f46e5; color: #fff; border: none;
|
||||||
|
cursor: pointer; font-size: 14px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
#grd-ob-send:hover { background: #4338ca; }
|
||||||
|
|
||||||
|
/* 스포트라이트 */
|
||||||
|
.grd-spotlight {
|
||||||
|
position: fixed; z-index: 9998;
|
||||||
|
border: 2px solid #818cf8;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 0 0 9999px rgba(0,0,0,.5), 0 0 24px rgba(129,140,248,.6);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: all .3s ease;
|
||||||
|
animation: spotlight-pulse 2s ease infinite;
|
||||||
|
}
|
||||||
|
@keyframes spotlight-pulse {
|
||||||
|
0%,100%{box-shadow:0 0 0 9999px rgba(0,0,0,.5),0 0 24px rgba(129,140,248,.4)}
|
||||||
|
50%{box-shadow:0 0 0 9999px rgba(0,0,0,.5),0 0 40px rgba(129,140,248,.8)}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 도움말 버튼 (온보딩 완료 후) */
|
||||||
|
#grd-help-btn {
|
||||||
|
position: fixed; right: 20px; bottom: 20px;
|
||||||
|
width: 48px; height: 48px; border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg,#818cf8,#6366f1);
|
||||||
|
color: #fff; border: none; cursor: pointer;
|
||||||
|
font-size: 22px; z-index: 9000;
|
||||||
|
box-shadow: 0 4px 16px rgba(129,140,248,.4);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
transition: transform .2s;
|
||||||
|
}
|
||||||
|
#grd-help-btn:hover { transform: scale(1.1); }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#grd-onboarding {
|
||||||
|
width: 100%; right: 0; top: auto; bottom: 0;
|
||||||
|
transform: none; border-radius: 16px 16px 0 0;
|
||||||
|
border-left: none; border-top: 3px solid #818cf8;
|
||||||
|
max-height: 65vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.head.appendChild(style);
|
||||||
|
document.body.appendChild(panel);
|
||||||
|
|
||||||
|
// 이벤트 연결
|
||||||
|
document.getElementById('grd-ob-minimize').onclick = toggleMinimize;
|
||||||
|
document.getElementById('grd-ob-close').onclick = closeBotConfirm;
|
||||||
|
document.getElementById('grd-ob-header').ondblclick = toggleMinimize;
|
||||||
|
document.getElementById('grd-ob-send').onclick = sendUserMessage;
|
||||||
|
document.getElementById('grd-ob-input').onkeydown = e => {
|
||||||
|
if (e.key === 'Enter') sendUserMessage();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHelpButton() {
|
||||||
|
if (document.getElementById('grd-help-btn')) return;
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.id = 'grd-help-btn';
|
||||||
|
btn.textContent = '?';
|
||||||
|
btn.title = 'GUARDiA 도움말';
|
||||||
|
btn.onclick = () => {
|
||||||
|
_onboarding_state_dismissed = false;
|
||||||
|
api('POST', '/api/onboarding/reset').then(() => location.reload());
|
||||||
|
};
|
||||||
|
document.body.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 메시지 렌더링 ─────────────────────────────────────────
|
||||||
|
function renderMessage(text, isUser = false, actions = []) {
|
||||||
|
const msgArea = document.getElementById('grd-ob-messages');
|
||||||
|
if (!msgArea) return;
|
||||||
|
|
||||||
|
const msg = document.createElement('div');
|
||||||
|
msg.className = 'grd-ob-msg' + (isUser ? ' user' : '');
|
||||||
|
|
||||||
|
const avi = document.createElement('div');
|
||||||
|
avi.className = 'grd-ob-msg-avatar';
|
||||||
|
avi.textContent = isUser ? '👤' : '🤖';
|
||||||
|
|
||||||
|
const bubble = document.createElement('div');
|
||||||
|
bubble.className = 'grd-ob-msg-bubble';
|
||||||
|
// 마크다운 간단 렌더링
|
||||||
|
bubble.innerHTML = text
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
.replace(/`(.+?)`/g, '<code>$1</code>')
|
||||||
|
.replace(/^```[\s\S]*?```$/gm, m => `<code style="display:block;padding:8px;margin:4px 0;background:rgba(0,0,0,.3);border-radius:6px;font-size:11px;white-space:pre">${m.replace(/```\w*\n?/g,'').trim()}</code>`)
|
||||||
|
.replace(/\n/g, '<br>');
|
||||||
|
|
||||||
|
msg.appendChild(avi);
|
||||||
|
msg.appendChild(bubble);
|
||||||
|
msgArea.appendChild(msg);
|
||||||
|
|
||||||
|
// 액션 버튼
|
||||||
|
if (actions && actions.length > 0) {
|
||||||
|
const actDiv = document.createElement('div');
|
||||||
|
actDiv.className = 'grd-ob-actions';
|
||||||
|
actions.forEach((act, i) => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'grd-ob-action-btn' + (i === 0 ? ' primary' : '');
|
||||||
|
btn.textContent = act.label;
|
||||||
|
btn.onclick = () => handleAction(act);
|
||||||
|
actDiv.appendChild(btn);
|
||||||
|
});
|
||||||
|
msgArea.appendChild(actDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
msgArea.scrollTop = msgArea.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTyping() {
|
||||||
|
const msgArea = document.getElementById('grd-ob-messages');
|
||||||
|
if (!msgArea) return;
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'grd-ob-msg';
|
||||||
|
el.id = 'grd-ob-typing';
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="grd-ob-msg-avatar">🤖</div>
|
||||||
|
<div class="grd-ob-typing">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</div>`;
|
||||||
|
msgArea.appendChild(el);
|
||||||
|
msgArea.scrollTop = msgArea.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideTyping() {
|
||||||
|
document.getElementById('grd-ob-typing')?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 단계 로드 ─────────────────────────────────────────────
|
||||||
|
function loadStep(step) {
|
||||||
|
if (!step) return;
|
||||||
|
state.currentStep = step;
|
||||||
|
|
||||||
|
// 헤더 업데이트
|
||||||
|
const sub = document.getElementById('grd-ob-subtitle');
|
||||||
|
if (sub) sub.textContent = `${step.icon} ${step.title}`;
|
||||||
|
|
||||||
|
// 진행 바 업데이트
|
||||||
|
const pct = Math.round(step.order / 7 * 100);
|
||||||
|
const fill = document.getElementById('grd-ob-progress-fill');
|
||||||
|
const label = document.getElementById('grd-ob-progress-label');
|
||||||
|
if (fill) fill.style.width = pct + '%';
|
||||||
|
if (label) label.textContent = `${step.order + 1} / 8 단계`;
|
||||||
|
|
||||||
|
// 타이핑 효과 후 메시지 표시
|
||||||
|
showTyping();
|
||||||
|
setTimeout(() => {
|
||||||
|
hideTyping();
|
||||||
|
renderMessage(step.message, false, step.actions);
|
||||||
|
// 스포트라이트
|
||||||
|
if (step.target) spotlightElement(step.target);
|
||||||
|
}, 800);
|
||||||
|
|
||||||
|
// 화면 이동 힌트
|
||||||
|
if (step.view && location.pathname !== step.view.split('?')[0]) {
|
||||||
|
setTimeout(() => {
|
||||||
|
renderMessage(`💡 현재 화면: **${location.pathname}**\n이 단계는 **${step.view}** 화면에서 진행됩니다.`, false, [
|
||||||
|
{ label: `${step.view} 이동`, action: 'navigate', path: step.view }
|
||||||
|
]);
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 액션 처리 ─────────────────────────────────────────────
|
||||||
|
async function handleAction(act) {
|
||||||
|
const step = state.currentStep;
|
||||||
|
|
||||||
|
switch (act.action) {
|
||||||
|
case 'next':
|
||||||
|
case 'complete_step':
|
||||||
|
if (step) {
|
||||||
|
showTyping();
|
||||||
|
const result = await postStep(step.id, 'complete');
|
||||||
|
hideTyping();
|
||||||
|
if (result?.step) loadStep(result.step);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'navigate':
|
||||||
|
if (act.path) {
|
||||||
|
if (act.path.startsWith('http')) {
|
||||||
|
window.open(act.path, '_blank');
|
||||||
|
} else if (act.path.includes('?view=')) {
|
||||||
|
const viewName = act.path.split('?view=')[1];
|
||||||
|
// GUARDiA SPA 뷰 전환
|
||||||
|
const navItem = document.querySelector(`[data-view="${viewName}"]`);
|
||||||
|
if (navItem) navItem.click();
|
||||||
|
} else {
|
||||||
|
location.href = act.path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'external':
|
||||||
|
window.open(act.url || act.path, '_blank');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'complete':
|
||||||
|
await completeOnboarding();
|
||||||
|
renderMessage('🎉 모든 설정이 완료되었습니다!\n이제 GUARDiA의 모든 기능을 사용하세요.\n\n우측 하단 **?** 버튼으로 언제든 가이드를 다시 볼 수 있습니다.', false, []);
|
||||||
|
setTimeout(() => {
|
||||||
|
hide();
|
||||||
|
buildHelpButton();
|
||||||
|
}, 3000);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'skip':
|
||||||
|
if (step) {
|
||||||
|
const result = await postStep(step.id, 'skip');
|
||||||
|
if (result?.step) loadStep(result.step);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 사용자 메시지 전송 ────────────────────────────────────
|
||||||
|
async function sendUserMessage() {
|
||||||
|
const input = document.getElementById('grd-ob-input');
|
||||||
|
if (!input) return;
|
||||||
|
const text = input.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
input.value = '';
|
||||||
|
renderMessage(text, true);
|
||||||
|
|
||||||
|
showTyping();
|
||||||
|
const resp = await postMessage(text);
|
||||||
|
hideTyping();
|
||||||
|
|
||||||
|
if (resp?.message) {
|
||||||
|
renderMessage(resp.message, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 스포트라이트 ──────────────────────────────────────────
|
||||||
|
function spotlightElement(selector) {
|
||||||
|
// 기존 스포트라이트 제거
|
||||||
|
clearSpotlight();
|
||||||
|
|
||||||
|
const el = document.querySelector(selector);
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
setTimeout(() => {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const pad = 6;
|
||||||
|
const spot = document.createElement('div');
|
||||||
|
spot.className = 'grd-spotlight';
|
||||||
|
spot.id = 'grd-spotlight';
|
||||||
|
spot.style.cssText = `
|
||||||
|
top: ${rect.top - pad + window.scrollY}px;
|
||||||
|
left: ${rect.left - pad}px;
|
||||||
|
width: ${rect.width + pad * 2}px;
|
||||||
|
height: ${rect.height + pad * 2}px;
|
||||||
|
`;
|
||||||
|
document.body.appendChild(spot);
|
||||||
|
|
||||||
|
// 8초 후 자동 제거
|
||||||
|
setTimeout(clearSpotlight, 8000);
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSpotlight() {
|
||||||
|
document.getElementById('grd-spotlight')?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 화면 변화 감지 ────────────────────────────────────────
|
||||||
|
function watchNavigation() {
|
||||||
|
// SPA 네비게이션 감지 (hash/pushState)
|
||||||
|
let lastPath = location.pathname;
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
if (location.pathname !== lastPath) {
|
||||||
|
lastPath = location.pathname;
|
||||||
|
onViewChange(location.pathname);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
// GUARDiA nav-item 클릭 감지
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
const navItem = e.target.closest('[data-view]');
|
||||||
|
if (navItem) {
|
||||||
|
setTimeout(() => onViewChange(location.pathname, navItem.dataset.view), 300);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onViewChange(path, viewId) {
|
||||||
|
clearSpotlight();
|
||||||
|
const step = state.currentStep;
|
||||||
|
if (!step || !step.view) return;
|
||||||
|
|
||||||
|
const stepView = step.view.split('?')[0];
|
||||||
|
if (path === stepView || (viewId && step.target?.includes(viewId))) {
|
||||||
|
// 현재 단계의 화면으로 이동했으면 힌트 표시
|
||||||
|
setTimeout(() => {
|
||||||
|
if (step.target) spotlightElement(step.target);
|
||||||
|
renderMessage(`✅ 좋아요! 지금 **${step.title}** 단계를 진행 중입니다.\n\n${step.message.split('\n')[0]}`, false);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 패널 제어 ─────────────────────────────────────────────
|
||||||
|
function show() {
|
||||||
|
const panel = document.getElementById('grd-onboarding');
|
||||||
|
if (panel) { panel.style.display = 'flex'; state.visible = true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
const panel = document.getElementById('grd-onboarding');
|
||||||
|
if (panel) { panel.style.display = 'none'; state.visible = false; }
|
||||||
|
clearSpotlight();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMinimize() {
|
||||||
|
const panel = document.getElementById('grd-onboarding');
|
||||||
|
if (!panel) return;
|
||||||
|
state.minimized = !state.minimized;
|
||||||
|
panel.classList.toggle('minimized', state.minimized);
|
||||||
|
document.getElementById('grd-ob-minimize').textContent = state.minimized ? '□' : '─';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBotConfirm() {
|
||||||
|
if (confirm('온보딩 가이드를 닫을까요?\n언제든 우측하단 ? 버튼으로 다시 열 수 있습니다.')) {
|
||||||
|
dismissOnboarding();
|
||||||
|
hide();
|
||||||
|
buildHelpButton();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 진입점 ────────────────────────────────────────────────
|
||||||
|
// DOM 준비 후 실행
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
// 로그인 완료 후 토큰이 설정되면 초기화
|
||||||
|
setTimeout(init, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로그인 이벤트 감지 (localStorage 변화)
|
||||||
|
window.addEventListener('storage', e => {
|
||||||
|
if (e.key === 'access_token' && e.newValue && !_token) {
|
||||||
|
_token = e.newValue;
|
||||||
|
setTimeout(init, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 전역 노출 (수동 재시작)
|
||||||
|
window.GUARDiAOnboarding = { restart: () => { api('POST','/api/onboarding/reset').then(()=>location.reload()); } };
|
||||||
|
|
||||||
|
})();
|
||||||
@ -310,6 +310,13 @@ _auto_start() {
|
|||||||
done
|
done
|
||||||
ok "GUARDiA ITSM 정상 기동 확인!"
|
ok "GUARDiA ITSM 정상 기동 확인!"
|
||||||
|
|
||||||
|
# 온보딩 초기화 (admin 계정 기준으로 온보딩 상태 리셋)
|
||||||
|
curl -sf -X POST http://localhost:8001/api/onboarding/reset \
|
||||||
|
-H "Authorization: Bearer $(curl -sf -X POST http://localhost:8001/api/auth/login \
|
||||||
|
-H 'Content-Type: application/json' -d '{"username":"admin","password":"1111"}' 2>/dev/null | \
|
||||||
|
python3 -c 'import sys,json; print(json.load(sys.stdin).get("access_token",""))' 2>/dev/null)" \
|
||||||
|
-o /dev/null 2>/dev/null && info "온보딩 가이드 활성화 완료" || true
|
||||||
|
|
||||||
# 브라우저 자동 열기 (GUI 환경)
|
# 브라우저 자동 열기 (GUI 환경)
|
||||||
local server_ip
|
local server_ip
|
||||||
server_ip=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "localhost")
|
server_ip=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "localhost")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user