From 6ee88b999fb106313d20498d63fc18bf4cc33a40 Mon Sep 17 00:00:00 2001 From: evenif99 Date: Tue, 23 Jun 2026 13:50:14 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(chatbot):=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B3=A0=EA=B0=9D=EB=AC=B8?= =?UTF-8?q?=EC=9D=98=20=EC=B1=97=EB=B4=87=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gemini API 기반 FAQ 챗봇을 마이페이지에 플로팅 버튼으로 추가. GSAP 애니메이션 적용 (열기/닫기, 메시지 등장, 캐릭터 바운스). 대화 내역 DB 저장/조회 기능 포함. - 챗봇 UI 컴포넌트 (플로팅 버튼 + 채팅 화면 + 커스텀 메뉴) - Gemini 2.5 Flash 연동 FAQ 응답 (서비스 안내 범위 제한) - ChatSession/ChatMessage Prisma 모델 추가 - 챗봇 API Route (POST/GET/DELETE) - contact 페이지 및 관련 참조 삭제 - middleware에서 contact 인증 예외 경로 제거 Co-Authored-By: Claude --- prisma/schema.prisma | 23 ++ src/app/(page)/contact/page.tsx | 28 -- src/app/(page)/mypage/page.tsx | 6 +- src/app/api/chatbot/route.ts | 119 ++++++ src/components/chatbot/Chatbot.module.scss | 382 ++++++++++++++++++ src/components/chatbot/Chatbot.tsx | 429 +++++++++++++++++++++ src/domains/common/chatbot.service.ts | 52 +++ src/lib/ai/chatbot.ts | 72 ++++ src/middleware.ts | 2 +- 9 files changed, 1082 insertions(+), 31 deletions(-) delete mode 100644 src/app/(page)/contact/page.tsx create mode 100644 src/app/api/chatbot/route.ts create mode 100644 src/components/chatbot/Chatbot.module.scss create mode 100644 src/components/chatbot/Chatbot.tsx create mode 100644 src/domains/common/chatbot.service.ts create mode 100644 src/lib/ai/chatbot.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 489e674..aad5096 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -38,6 +38,7 @@ model User { aiConversations RoomAiConversation[] aiMessages RoomAiMessage[] termsAgreements UserTermsAgreement[] + chatSessions ChatSession[] profileMediaAsset MediaAsset? @relation("UserProfileAsset", fields: [profileMediaAssetId], references: [id]) @@map("users") @@ -360,6 +361,28 @@ model EmotionDiary { } +model ChatSession { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + userId String @map("user_id") @db.Uuid + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + messages ChatMessage[] + + @@map("chat_sessions") +} + +model ChatMessage { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + sessionId String @map("session_id") @db.Uuid + role String @db.VarChar(10) + content String + createdAt DateTime @default(now()) @map("created_at") + session ChatSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + + @@map("chat_messages") +} + model RoomAccessLog { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid roomId String? @map("room_id") @db.Uuid diff --git a/src/app/(page)/contact/page.tsx b/src/app/(page)/contact/page.tsx deleted file mode 100644 index 286c987..0000000 --- a/src/app/(page)/contact/page.tsx +++ /dev/null @@ -1,28 +0,0 @@ -'use client'; - -import { useEffect } from 'react'; -import { useRouter } from 'next/navigation'; -import { useToastStore } from '@/stores/toastStore'; -import { useHeaderStore } from '@/stores/headerStore'; -import Toast from '@/components/feedback/Toast'; - -export default function ContactPage() { - const router = useRouter(); - const setHeader = useHeaderStore((s) => s.setHeader) - useEffect(() => { - setHeader({ variant: 'title', title: '고객문의', onBack: () => router.back() }) - return () => setHeader(null) - }, []) - const show = useToastStore((s) => s.show); - - useEffect(() => { - show('준비중입니다'); - }, [show]); - - return ( - <> -
- - - ); -} diff --git a/src/app/(page)/mypage/page.tsx b/src/app/(page)/mypage/page.tsx index 0e34c60..f767774 100644 --- a/src/app/(page)/mypage/page.tsx +++ b/src/app/(page)/mypage/page.tsx @@ -11,9 +11,10 @@ import ConfirmModal from '@/components/ui/ConfirmModal'; import { useUserMe } from '@/domains/user/hooks'; import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded'; import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded'; +import Chatbot from '@/components/chatbot/Chatbot'; import styles from './page.module.scss'; -const PREPARING_ITEMS = ['shop', 'contact'] as const; +const PREPARING_ITEMS = ['shop'] as const; type PreparingKey = (typeof PREPARING_ITEMS)[number]; const LINK_ITEMS = [ @@ -21,7 +22,6 @@ const LINK_ITEMS = [ { key: 'about', label: '서비스 소개', href: '/about' }, { key: 'privacy', label: '개인정보처리방침', href: '/privacy' }, { key: 'terms', label: '이용약관', href: '/terms' }, - { key: 'contact', label: '고객문의', href: '/contact' }, ] as const; export default function MyPage() { @@ -104,6 +104,8 @@ export default function MyPage() { onClose={() => setLogoutOpen(false)} onConfirm={handleLogout} /> + + ); } \ No newline at end of file diff --git a/src/app/api/chatbot/route.ts b/src/app/api/chatbot/route.ts new file mode 100644 index 0000000..f537a5f --- /dev/null +++ b/src/app/api/chatbot/route.ts @@ -0,0 +1,119 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { getSessionUserId } from '@/lib/auth/session' +import { getChatbotResponse } from '@/lib/ai/chatbot' +import { + getOrCreateSession, + getSessionMessages, + saveMessage, + clearSession, +} from '@/domains/common/chatbot.service' + +export async function POST(req: NextRequest) { + try { + const session = await getServerSession(authOptions) + const userId = getSessionUserId(session) + if (!userId) { + return NextResponse.json( + { success: false, error: '인증이 필요합니다.' }, + { status: 401 } + ) + } + + const body = await req.json() + const { message } = body as { message: string } + + if (!message || typeof message !== 'string' || message.trim().length === 0) { + return NextResponse.json( + { success: false, error: '메시지를 입력해주세요.' }, + { status: 400 } + ) + } + + if (message.length > 500) { + return NextResponse.json( + { success: false, error: '메시지는 500자 이내로 입력해주세요.' }, + { status: 400 } + ) + } + + const chatSession = await getOrCreateSession(userId) + const dbMessages = await getSessionMessages(chatSession.id) + + const history = dbMessages.map((m) => ({ + role: m.role === 'USER' ? 'user' as const : 'bot' as const, + content: m.content, + })) + + await saveMessage(chatSession.id, 'user', message.trim()) + + const reply = await getChatbotResponse(message.trim(), history) + + await saveMessage(chatSession.id, 'bot', reply) + + return NextResponse.json({ success: true, data: { reply } }) + } catch (error) { + console.error('[Chatbot API Error]', error instanceof Error ? error.message : error) + return NextResponse.json( + { success: false, error: '답변 생성에 실패했습니다. 잠시 후 다시 시도해주세요.' }, + { status: 500 } + ) + } +} + +export async function GET() { + try { + const session = await getServerSession(authOptions) + const userId = getSessionUserId(session) + if (!userId) { + return NextResponse.json( + { success: false, error: '인증이 필요합니다.' }, + { status: 401 } + ) + } + + const chatSession = await getOrCreateSession(userId) + const messages = await getSessionMessages(chatSession.id) + + const data = messages.map((m) => ({ + role: m.role === 'USER' ? 'user' : 'bot', + content: m.content, + timestamp: m.createdAt.toLocaleTimeString('ko-KR', { + hour: '2-digit', + minute: '2-digit', + }), + })) + + return NextResponse.json({ success: true, data: { messages: data } }) + } catch (error) { + console.error('[Chatbot API Error]', error instanceof Error ? error.message : error) + return NextResponse.json( + { success: false, error: '대화 내역을 불러오지 못했습니다.' }, + { status: 500 } + ) + } +} + +export async function DELETE() { + try { + const session = await getServerSession(authOptions) + const userId = getSessionUserId(session) + if (!userId) { + return NextResponse.json( + { success: false, error: '인증이 필요합니다.' }, + { status: 401 } + ) + } + + await clearSession(userId) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('[Chatbot API Error]', error instanceof Error ? error.message : error) + return NextResponse.json( + { success: false, error: '대화 초기화에 실패했습니다.' }, + { status: 500 } + ) + } +} diff --git a/src/components/chatbot/Chatbot.module.scss b/src/components/chatbot/Chatbot.module.scss new file mode 100644 index 0000000..6734639 --- /dev/null +++ b/src/components/chatbot/Chatbot.module.scss @@ -0,0 +1,382 @@ +@use '@/styles/abstracts/variables' as v; +@use '@/styles/abstracts/functions' as fn; +@use '@/styles/abstracts/mixins' as m; + +.floatingButton { + position: fixed; + bottom: fn.r(88); + right: fn.r(20); + width: fn.r(56); + height: fn.r(56); + border-radius: 50%; + background-color: v.$color-primary-600; + color: v.$color-white; + border: none; + cursor: pointer; + @include m.flex-center; + box-shadow: 0 fn.r(4) fn.r(12) rgba(0, 0, 0, 0.15); + z-index: 1000; + + svg { + width: fn.r(28); + height: fn.r(28); + } +} + +.overlay { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.4); + z-index: 1001; +} + +.container { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 85vh; + max-height: fn.r(680); + background-color: v.$color-white; + border-radius: fn.r(20) fn.r(20) 0 0; + display: flex; + flex-direction: column; + z-index: 1002; + overflow: hidden; +} + +// =========================== +// Header +// =========================== + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: fn.r(16) fn.r(20); + border-bottom: 1px solid v.$color-black-100; + flex-shrink: 0; + position: relative; + + &__info { + position: absolute; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: fn.r(2); + } + + &__name { + @include m.text-title-s; + color: v.$color-black-700; + } + + &__status { + display: flex; + align-items: center; + justify-content: center; + gap: fn.r(6); + } + + &__lantern { + width: fn.r(8); + height: fn.r(8); + border-radius: 50%; + background-color: v.$color-primary-500; + display: inline-block; + } + + &__statusText { + @include m.text-caption; + color: v.$color-black-400; + } + + &__spacer { + width: fn.r(32); + flex-shrink: 0; + } + + &__close { + @include m.flex-center; + width: fn.r(32); + height: fn.r(32); + background: none; + border: none; + cursor: pointer; + color: v.$color-black-400; + z-index: 1; + + svg { + width: fn.r(24); + height: fn.r(24); + } + } +} + +// =========================== +// Messages +// =========================== + +.messages { + flex: 1; + overflow-y: auto; + padding: fn.r(16) fn.r(20); + display: flex; + flex-direction: column; + gap: fn.r(16); + background-color: v.$color-primary-100; +} + +.message { + display: flex; + flex-direction: column; + max-width: 80%; + + &--bot { + align-self: flex-start; + } + + &--user { + align-self: flex-end; + } + + &__sender { + @include m.text-caption; + color: v.$color-black-500; + margin-bottom: fn.r(4); + padding-left: fn.r(44); + } +} + +.botRow { + display: flex; + align-items: flex-start; + gap: fn.r(8); +} + +.botContent { + display: flex; + flex-direction: column; + gap: fn.r(4); +} + +.botAvatar { + width: fn.r(36); + height: fn.r(36); + border-radius: 50%; + background-color: v.$color-primary-600; + color: v.$color-white; + @include m.flex-center; + flex-shrink: 0; + @include m.text-label-s; +} + +.bubble { + padding: fn.r(10) fn.r(14); + border-radius: fn.r(16); + @include m.text-body-s; + word-break: break-word; + white-space: pre-wrap; + + &--bot { + background-color: v.$color-white; + color: v.$color-black-700; + border-top-left-radius: fn.r(4); + box-shadow: 0 fn.r(1) fn.r(3) rgba(0, 0, 0, 0.08); + } + + &--user { + background-color: v.$color-primary-600; + color: v.$color-white; + border-top-right-radius: fn.r(4); + } +} + +.timestamp { + @include m.text-caption; + color: v.$color-black-400; + + .message--user & { + text-align: right; + margin-top: fn.r(4); + } +} + +// =========================== +// Typing indicator +// =========================== + +.typing { + display: flex; + align-items: flex-start; + gap: fn.r(8); + align-self: flex-start; +} + +.typingDots { + display: flex; + align-items: center; + gap: fn.r(4); + padding: fn.r(10) fn.r(14); + background-color: v.$color-white; + border-radius: fn.r(16); + border-top-left-radius: fn.r(4); + box-shadow: 0 fn.r(1) fn.r(3) rgba(0, 0, 0, 0.08); +} + +.typingDot { + width: fn.r(8); + height: fn.r(8); + border-radius: 50%; + background-color: v.$color-black-300; +} + +// =========================== +// Quick replies +// =========================== + +.quickReplies { + display: flex; + gap: fn.r(8); + padding: fn.r(10) fn.r(20); + overflow-x: auto; + flex-shrink: 0; + background-color: v.$color-white; + border-top: 1px solid v.$color-black-100; + + &::-webkit-scrollbar { + display: none; + } +} + +.quickReply { + flex-shrink: 0; + padding: fn.r(8) fn.r(16); + border-radius: fn.r(20); + background-color: v.$color-primary-100; + color: v.$color-primary-700; + border: 1px solid v.$color-primary-300; + cursor: pointer; + @include m.text-body-s; + white-space: nowrap; + + &:active { + background-color: v.$color-primary-200; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +// =========================== +// Custom menu +// =========================== + +.customMenu { + flex-shrink: 0; + + &__box { + margin: fn.r(12) fn.r(16); + background-color: v.$color-primary-600; + border-radius: fn.r(16); + padding: fn.r(16); + } + + &__grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: fn.r(8); + } + + &__item { + display: flex; + flex-direction: column; + align-items: center; + gap: fn.r(6); + padding: fn.r(10) fn.r(4); + border-radius: fn.r(12); + background: none; + border: none; + cursor: pointer; + transition: background-color 0.15s; + + &:active { + background-color: v.$color-primary-700; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + + &__icon { + font-size: fn.r(24) !important; + color: v.$color-white; + } + + &__label { + @include m.text-caption; + color: rgba(255, 255, 255, 0.9); + } +} + +// =========================== +// Bottom bar +// =========================== + +.bottomBar { + display: flex; + align-items: center; + gap: fn.r(8); + padding: fn.r(10) fn.r(16); + border-top: 1px solid v.$color-black-100; + flex-shrink: 0; + background-color: v.$color-white; + + &__input { + flex: 1; + padding: fn.r(10) fn.r(16); + border-radius: fn.r(24); + border: 1px solid v.$color-black-200; + background-color: v.$color-black-100; + @include m.text-body-s; + color: v.$color-black-700; + outline: none; + + &::placeholder { + color: v.$color-black-400; + } + + &:focus { + border-color: v.$color-primary-500; + background-color: v.$color-white; + } + } + + &__send { + width: fn.r(40); + height: fn.r(40); + border-radius: 50%; + background-color: v.$color-primary-600; + color: v.$color-white; + border: none; + cursor: pointer; + @include m.flex-center; + flex-shrink: 0; + + &:disabled { + background-color: v.$color-black-200; + cursor: not-allowed; + } + + svg { + width: fn.r(20); + height: fn.r(20); + } + } +} diff --git a/src/components/chatbot/Chatbot.tsx b/src/components/chatbot/Chatbot.tsx new file mode 100644 index 0000000..b6d8666 --- /dev/null +++ b/src/components/chatbot/Chatbot.tsx @@ -0,0 +1,429 @@ +'use client' + +import { useState, useRef, useEffect, useCallback } from 'react' +import gsap from 'gsap' +import { useUserMe } from '@/domains/user/hooks' +import HelpOutlineRoundedIcon from '@mui/icons-material/HelpOutlineRounded' +import GavelRoundedIcon from '@mui/icons-material/GavelRounded' +import PersonOutlineRoundedIcon from '@mui/icons-material/PersonOutlineRounded' +import MenuBookRoundedIcon from '@mui/icons-material/MenuBookRounded' +import ShareRoundedIcon from '@mui/icons-material/ShareRounded' +import RestartAltRoundedIcon from '@mui/icons-material/RestartAltRounded' +import styles from './Chatbot.module.scss' + +interface Message { + role: 'user' | 'bot' + content: string + timestamp: string +} + +const QUICK_REPLIES = [ + '서비스 이용방법', + '자주묻는 질문', + '판결 절차', +] + +const MENU_ITEMS = [ + { icon: HelpOutlineRoundedIcon, label: '이용방법', message: '서비스는 어떻게 이용하나요?' }, + { icon: GavelRoundedIcon, label: '판결 절차', message: '판결은 어떻게 진행되나요?' }, + { icon: PersonOutlineRoundedIcon, label: '계정/로그인', message: '계정 관련 도움이 필요합니다' }, + { icon: MenuBookRoundedIcon, label: '감정일기', message: '감정일기는 어떻게 쓰나요?' }, + { icon: ShareRoundedIcon, label: '결과 공유', message: '판결 결과를 공유할 수 있나요?' }, + { icon: RestartAltRoundedIcon, label: '처음으로', message: '처음으로' }, +] + +const WELCOME_MESSAGE: Message = { + role: 'bot', + content: '안녕하세요! 토키올 고객문의 챗봇입니다.\n궁금한 점이 있으시면 편하게 질문해주세요!', + timestamp: new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' }), +} + +export default function Chatbot() { + const [isOpen, setIsOpen] = useState(false) + const [messages, setMessages] = useState([WELCOME_MESSAGE]) + const [input, setInput] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [historyLoaded, setHistoryLoaded] = useState(false) + + const { data: user } = useUserMe() + const displayName = user?.name ?? user?.nickname ?? '사용자' + + const containerRef = useRef(null) + const overlayRef = useRef(null) + const floatingRef = useRef(null) + const eyesRef = useRef(null) + const floatingTweenRef = useRef(null) + const blinkTweenRef = useRef(null) + const messagesEndRef = useRef(null) + const typingRef = useRef(null) + const dotRefs = useRef<(HTMLSpanElement | null)[]>([]) + const quickRepliesRef = useRef(null) + const prevMessageCountRef = useRef(messages.length) + const lastMessageElRef = useRef(null) + + const scrollToBottom = useCallback(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, []) + + useEffect(() => { + scrollToBottom() + }, [messages, isLoading, scrollToBottom]) + + const animateOpen = useCallback(() => { + const tl = gsap.timeline() + + tl.to(floatingRef.current, { + scale: 0, + rotation: 90, + duration: 0.3, + ease: 'power2.in', + }) + tl.fromTo( + overlayRef.current, + { opacity: 0 }, + { opacity: 1, duration: 0.3, ease: 'power2.out' }, + '-=0.1' + ) + tl.fromTo( + containerRef.current, + { y: '100%', opacity: 0 }, + { y: '0%', opacity: 1, duration: 0.4, ease: 'power3.out' }, + '-=0.2' + ) + + if (quickRepliesRef.current) { + const buttons = quickRepliesRef.current.children + tl.fromTo( + buttons, + { y: 10, opacity: 0 }, + { y: 0, opacity: 1, duration: 0.3, stagger: 0.08, ease: 'power2.out' }, + '-=0.1' + ) + } + }, []) + + const animateClose = useCallback((onComplete: () => void) => { + const tl = gsap.timeline({ onComplete }) + + tl.to(containerRef.current, { + y: '100%', + opacity: 0, + duration: 0.3, + ease: 'power2.in', + }) + tl.to(overlayRef.current, { opacity: 0, duration: 0.2 }, '-=0.15') + tl.to( + floatingRef.current, + { scale: 1, rotation: 0, duration: 0.3, ease: 'back.out(1.7)' }, + '-=0.1' + ) + }, []) + + useEffect(() => { + if (messages.length > prevMessageCountRef.current && lastMessageElRef.current) { + const el = lastMessageElRef.current + const isUser = messages[messages.length - 1].role === 'user' + gsap.fromTo( + el, + { x: isUser ? 20 : -20, opacity: 0 }, + { x: 0, opacity: 1, duration: 0.3, ease: 'power2.out' } + ) + } + prevMessageCountRef.current = messages.length + }, [messages]) + + useEffect(() => { + if (!isLoading || !typingRef.current) return + + const dots = dotRefs.current.filter(Boolean) + const tween = gsap.to(dots, { + y: -5, + repeat: -1, + yoyo: true, + duration: 0.4, + stagger: 0.15, + ease: 'power1.inOut', + }) + + return () => { tween.kill() } + }, [isLoading]) + + + const handleFloatingEnter = useCallback(() => { + if (!eyesRef.current || !floatingRef.current) return + + gsap.to(eyesRef.current, { + opacity: 1, + scale: 1, + duration: 0.2, + ease: 'back.out(1.7)', + }) + + const tl = gsap.timeline({ repeat: -1 }) + tl.to(floatingRef.current, { + y: -8, + scaleY: 0.92, + scaleX: 1.05, + duration: 0.3, + ease: 'power2.out', + }) + tl.to(floatingRef.current, { + y: 0, + scaleY: 1.08, + scaleX: 0.95, + duration: 0.25, + ease: 'power2.in', + }) + tl.to(floatingRef.current, { + y: 0, + scaleY: 1, + scaleX: 1, + duration: 0.35, + ease: 'elastic.out(1, 0.4)', + }) + floatingTweenRef.current = tl + + blinkTweenRef.current = gsap.timeline({ repeat: -1, repeatDelay: 2.5 }) + blinkTweenRef.current + .to(eyesRef.current, { scaleY: 0.1, duration: 0.08, ease: 'power2.in' }) + .to(eyesRef.current, { scaleY: 1, duration: 0.08, ease: 'power2.out' }) + }, []) + + const handleFloatingLeave = useCallback(() => { + if (!eyesRef.current) return + + gsap.to(eyesRef.current, { + opacity: 0, + scale: 0, + duration: 0.15, + ease: 'power2.in', + }) + + floatingTweenRef.current?.kill() + blinkTweenRef.current?.kill() + + if (floatingRef.current) { + gsap.to(floatingRef.current, { y: 0, duration: 0.3, ease: 'power2.out' }) + } + }, []) + + const loadHistory = useCallback(async () => { + if (historyLoaded) return + try { + const res = await fetch('/api/chatbot') + const data = await res.json() + if (data.success && data.data.messages.length > 0) { + setMessages([WELCOME_MESSAGE, ...data.data.messages]) + } + } catch { + // 히스토리 로드 실패 시 웰컴 메시지만 유지 + } finally { + setHistoryLoaded(true) + } + }, [historyLoaded]) + + const handleOpen = () => { + setIsOpen(true) + loadHistory() + requestAnimationFrame(() => animateOpen()) + } + + const handleClose = () => { + animateClose(() => setIsOpen(false)) + } + + const getNow = () => + new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' }) + + const sendMessage = async (text: string) => { + if (!text.trim() || isLoading) return + + if (text.trim() === '처음으로') { + setMessages([WELCOME_MESSAGE]) + fetch('/api/chatbot', { method: 'DELETE' }).catch(() => {}) + return + } + + const userMessage: Message = { role: 'user', content: text.trim(), timestamp: getNow() } + setMessages((prev) => [...prev, userMessage]) + setInput('') + setIsLoading(true) + + try { + const res = await fetch('/api/chatbot', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: text.trim() }), + }) + + const data = await res.json() + + const botMessage: Message = { + role: 'bot', + content: data.success + ? data.data.reply + : '죄송합니다. 답변 생성에 실패했습니다. 잠시 후 다시 시도해주세요.', + timestamp: getNow(), + } + setMessages((prev) => [...prev, botMessage]) + } catch { + setMessages((prev) => [ + ...prev, + { role: 'bot', content: '네트워크 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', timestamp: getNow() }, + ]) + } finally { + setIsLoading(false) + } + } + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + sendMessage(input) + } + + return ( + <> + + + {isOpen && ( + <> +
+
+
+
+
+ {displayName} +
+ + 봇과 대화중 +
+
+ +
+ +
+ {messages.map((msg, i) => ( +
{ lastMessageElRef.current = el } : undefined} + > + {msg.role === 'bot' ? ( + <> + 토키올 +
+
AI
+
+
+ {msg.content} +
+ {msg.timestamp} +
+
+ + ) : ( + <> +
+ {msg.content} +
+ {msg.timestamp} + + )} +
+ ))} + + {isLoading && ( +
+
AI
+
+ {[0, 1, 2].map((i) => ( + { dotRefs.current[i] = el }} + /> + ))} +
+
+ )} + +
+
+ +
+ {QUICK_REPLIES.map((text) => ( + + ))} +
+ +
+
+
+ {MENU_ITEMS.map((item) => ( + + ))} +
+
+
+ +
+ setInput(e.target.value)} + placeholder="메시지를 입력하세요" + maxLength={500} + disabled={isLoading} + /> + +
+
+ + )} + + ) +} diff --git a/src/domains/common/chatbot.service.ts b/src/domains/common/chatbot.service.ts new file mode 100644 index 0000000..f49320f --- /dev/null +++ b/src/domains/common/chatbot.service.ts @@ -0,0 +1,52 @@ +import { prisma } from '@/lib/db' + +export async function getOrCreateSession(userId: string) { + const existing = await prisma.chatSession.findFirst({ + where: { userId }, + orderBy: { updatedAt: 'desc' }, + }) + + if (existing) return existing + + return prisma.chatSession.create({ + data: { userId }, + }) +} + +export async function getSessionMessages(sessionId: string) { + return prisma.chatMessage.findMany({ + where: { sessionId }, + orderBy: { createdAt: 'asc' }, + }) +} + +export async function saveMessage( + sessionId: string, + role: 'user' | 'bot', + content: string +) { + const [message] = await prisma.$transaction([ + prisma.chatMessage.create({ + data: { sessionId, role, content }, + }), + prisma.chatSession.update({ + where: { id: sessionId }, + data: { updatedAt: new Date() }, + }), + ]) + + return message +} + +export async function clearSession(userId: string) { + const session = await prisma.chatSession.findFirst({ + where: { userId }, + orderBy: { updatedAt: 'desc' }, + }) + + if (!session) return + + await prisma.chatMessage.deleteMany({ + where: { sessionId: session.id }, + }) +} diff --git a/src/lib/ai/chatbot.ts b/src/lib/ai/chatbot.ts new file mode 100644 index 0000000..a4f39df --- /dev/null +++ b/src/lib/ai/chatbot.ts @@ -0,0 +1,72 @@ +import { GoogleGenerativeAI } from '@google/generative-ai' + +const MODEL_NAME = 'gemini-2.5-flash' + +const SYSTEM_PROMPT = `당신은 "토키올" AI 갈등 조정 판결 서비스의 고객문의 챗봇입니다. +친절하고 간결하게 답변해주세요. 존댓말을 사용하세요. + +## 서비스 안내 범위 + +### 서비스 이용방법 +- 토키올은 AI 기반 갈등 조정 판결 서비스입니다. +- 갈등 상황을 작성하고 AI 판결을 받을 수 있습니다. +- 단독 판결: 혼자 진술을 작성하고 AI 판결을 받습니다. (제한적 결과 제공) +- 1:1 판결: 상대방을 초대하여 양측 진술 기반으로 AI 판결을 받습니다. (전체 결과 제공) + +### 판결 절차 +- 방 생성 → 진술 작성 → AI 판결 생성 → 결과 확인 +- 1:1 판결 시: 방 생성 → 초대 링크 발급 → 상대방 참여 → 양측 진술 → AI 판결 +- 판결 결과에는 판결 점수, 핵심 쟁점, 판결 근거, 화해 제안 등이 포함됩니다. + +### 카테고리 +- 연애, 가족, 친구, 직장 4가지 카테고리에서 갈등 상황을 작성할 수 있습니다. + +### 계정 +- 카카오 로그인으로 이용할 수 있습니다. +- 마이페이지에서 프로필 수정, 회원탈퇴가 가능합니다. + +### 감정일기 +- 매일의 감정을 기록할 수 있는 기능입니다. +- 감정일기는 본인만 볼 수 있으며 다른 사용자에게 공개되지 않습니다. + +### 결과 확인 +- 판결 결과는 사건기록에서 다시 확인할 수 있습니다. +- 결과 카드를 생성하여 공유할 수 있습니다. + +## 답변 규칙 +- 위 범위 내의 질문에만 답변하세요. +- 서비스와 관련 없는 질문에는 "해당 내용은 안내가 어렵습니다. 서비스 이용 관련 질문을 부탁드려요!" 라고 답변하세요. +- 답변은 3문장 이내로 간결하게 해주세요. +- 기술적 세부사항(API, DB 등)은 답변하지 마세요.` + +export interface ChatMessage { + role: 'user' | 'bot' + content: string +} + +export async function getChatbotResponse( + userMessage: string, + history: ChatMessage[] +): Promise { + const apiKey = process.env.GEMINI_API_KEY + if (!apiKey) throw new Error('GEMINI_API_KEY is not configured') + + const genAI = new GoogleGenerativeAI(apiKey) + const model = genAI.getGenerativeModel({ model: MODEL_NAME }) + + const chat = model.startChat({ + history: [ + { role: 'user', parts: [{ text: '시스템 설정' }] }, + { role: 'model', parts: [{ text: SYSTEM_PROMPT }] }, + ...history.map((msg) => ({ + role: msg.role === 'user' ? 'user' as const : 'model' as const, + parts: [{ text: msg.content }], + })), + ], + }) + + const result = await chat.sendMessage(userMessage) + const response = result.response.text() + + return response +} diff --git a/src/middleware.ts b/src/middleware.ts index 7d4c816..c466dfa 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -2,6 +2,6 @@ export { default } from 'next-auth/middleware' export const config = { matcher: [ - '/((?!login(?:/|$)|auth/callback(?:/|$)|terms(?:/|$)|privacy(?:/|$)|about(?:/|$)|contact(?:/|$)|join(?:/|$)|api(?:/|$)|disputes/[^/]+/type(?:/|$)|_next/static|_next/image|favicon\\.ico$|images(?:/|$)|$).*)', + '/((?!login(?:/|$)|auth/callback(?:/|$)|terms(?:/|$)|privacy(?:/|$)|about(?:/|$)|join(?:/|$)|api(?:/|$)|disputes/[^/]+/type(?:/|$)|_next/static|_next/image|favicon\\.ico$|images(?:/|$)|$).*)', ], } From 4f3e1bab4655dfc0ff4cb14434c216da58e37f84 Mon Sep 17 00:00:00 2001 From: evenif99 Date: Tue, 23 Jun 2026 14:23:58 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(chatbot):=20Gemini=20API=20503/429=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 일시적 서버 과부하(503) 및 요청 제한(429) 발생 시 최대 2회 재시도하여 응답 안정성 향상. Co-Authored-By: Claude --- src/lib/ai/chatbot.ts | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/src/lib/ai/chatbot.ts b/src/lib/ai/chatbot.ts index a4f39df..272cf52 100644 --- a/src/lib/ai/chatbot.ts +++ b/src/lib/ai/chatbot.ts @@ -44,6 +44,13 @@ export interface ChatMessage { content: string } +const MAX_RETRIES = 2 +const RETRY_DELAY_MS = 1500 + +async function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + export async function getChatbotResponse( userMessage: string, history: ChatMessage[] @@ -65,8 +72,26 @@ export async function getChatbotResponse( ], }) - const result = await chat.sendMessage(userMessage) - const response = result.response.text() + let lastError: unknown + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + const result = await chat.sendMessage(userMessage) + return result.response.text() + } catch (error) { + lastError = error + const isRetryable = + error instanceof Error && + (error.message.includes('503') || + error.message.includes('429') || + error.message.includes('Service Unavailable') || + error.message.includes('high demand')) + + if (!isRetryable || attempt === MAX_RETRIES) break + + await sleep(RETRY_DELAY_MS * (attempt + 1)) + } + } - return response + throw lastError } From e596f494abe68fa9875ddce06fef44566bbac687 Mon Sep 17 00:00:00 2001 From: evenif99 Date: Tue, 23 Jun 2026 14:48:51 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(chatbot):=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20-=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EB=B6=84=EA=B8=B0,=20role=20=EB=A7=A4=ED=95=91,=20?= =?UTF-8?q?=ED=9E=88=EC=8A=A4=ED=86=A0=EB=A6=AC=20=EC=A0=9C=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - role 대소문자 불일치 수정 (toLowerCase 비교) - Gemini 실패(502), 타임아웃(504) 에러 유형 분리 - 30초 요청 타임아웃 추가 - 히스토리 조회 최근 30개로 제한 - DELETE 실패 시 사용자 안내 메시지 추가 - 히스토리 로드 실패 시 재시도 가능하도록 수정 Co-Authored-By: Claude --- src/app/api/chatbot/route.ts | 24 ++++++++++++++--- src/components/chatbot/Chatbot.tsx | 18 +++++++++---- src/domains/common/chatbot.service.ts | 9 +++++-- src/lib/ai/chatbot.ts | 37 ++++++++++++++++++++++++--- 4 files changed, 74 insertions(+), 14 deletions(-) diff --git a/src/app/api/chatbot/route.ts b/src/app/api/chatbot/route.ts index f537a5f..1e3d4bc 100644 --- a/src/app/api/chatbot/route.ts +++ b/src/app/api/chatbot/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { getSessionUserId } from '@/lib/auth/session' -import { getChatbotResponse } from '@/lib/ai/chatbot' +import { getChatbotResponse, ChatbotTimeoutError, ChatbotGeminiError } from '@/lib/ai/chatbot' import { getOrCreateSession, getSessionMessages, @@ -10,6 +10,10 @@ import { clearSession, } from '@/domains/common/chatbot.service' +function mapRole(role: string): 'user' | 'bot' { + return role.toLowerCase() === 'user' ? 'user' : 'bot' +} + export async function POST(req: NextRequest) { try { const session = await getServerSession(authOptions) @@ -42,7 +46,7 @@ export async function POST(req: NextRequest) { const dbMessages = await getSessionMessages(chatSession.id) const history = dbMessages.map((m) => ({ - role: m.role === 'USER' ? 'user' as const : 'bot' as const, + role: mapRole(m.role), content: m.content, })) @@ -55,6 +59,20 @@ export async function POST(req: NextRequest) { return NextResponse.json({ success: true, data: { reply } }) } catch (error) { console.error('[Chatbot API Error]', error instanceof Error ? error.message : error) + + if (error instanceof ChatbotTimeoutError) { + return NextResponse.json( + { success: false, error: '응답 시간이 초과되었습니다. 잠시 후 다시 시도해주세요.' }, + { status: 504 } + ) + } + if (error instanceof ChatbotGeminiError) { + return NextResponse.json( + { success: false, error: 'AI 서비스에 일시적인 문제가 발생했습니다. 잠시 후 다시 시도해주세요.' }, + { status: 502 } + ) + } + return NextResponse.json( { success: false, error: '답변 생성에 실패했습니다. 잠시 후 다시 시도해주세요.' }, { status: 500 } @@ -77,7 +95,7 @@ export async function GET() { const messages = await getSessionMessages(chatSession.id) const data = messages.map((m) => ({ - role: m.role === 'USER' ? 'user' : 'bot', + role: mapRole(m.role), content: m.content, timestamp: m.createdAt.toLocaleTimeString('ko-KR', { hour: '2-digit', diff --git a/src/components/chatbot/Chatbot.tsx b/src/components/chatbot/Chatbot.tsx index b6d8666..6594109 100644 --- a/src/components/chatbot/Chatbot.tsx +++ b/src/components/chatbot/Chatbot.tsx @@ -211,14 +211,14 @@ export default function Chatbot() { if (historyLoaded) return try { const res = await fetch('/api/chatbot') + if (!res.ok) return const data = await res.json() if (data.success && data.data.messages.length > 0) { setMessages([WELCOME_MESSAGE, ...data.data.messages]) } - } catch { - // 히스토리 로드 실패 시 웰컴 메시지만 유지 - } finally { setHistoryLoaded(true) + } catch { + // 히스토리 로드 실패 시 웰컴 메시지만 유지, 다음 열기 시 재시도 } }, [historyLoaded]) @@ -239,8 +239,16 @@ export default function Chatbot() { if (!text.trim() || isLoading) return if (text.trim() === '처음으로') { - setMessages([WELCOME_MESSAGE]) - fetch('/api/chatbot', { method: 'DELETE' }).catch(() => {}) + try { + const res = await fetch('/api/chatbot', { method: 'DELETE' }) + if (!res.ok) throw new Error() + setMessages([WELCOME_MESSAGE]) + } catch { + setMessages((prev) => [ + ...prev, + { role: 'bot', content: '대화 초기화에 실패했습니다. 잠시 후 다시 시도해주세요.', timestamp: getNow() }, + ]) + } return } diff --git a/src/domains/common/chatbot.service.ts b/src/domains/common/chatbot.service.ts index f49320f..c0e0a76 100644 --- a/src/domains/common/chatbot.service.ts +++ b/src/domains/common/chatbot.service.ts @@ -13,11 +13,16 @@ export async function getOrCreateSession(userId: string) { }) } +const MAX_HISTORY_MESSAGES = 30 + export async function getSessionMessages(sessionId: string) { - return prisma.chatMessage.findMany({ + const messages = await prisma.chatMessage.findMany({ where: { sessionId }, - orderBy: { createdAt: 'asc' }, + orderBy: { createdAt: 'desc' }, + take: MAX_HISTORY_MESSAGES, }) + + return messages.reverse() } export async function saveMessage( diff --git a/src/lib/ai/chatbot.ts b/src/lib/ai/chatbot.ts index 272cf52..7e5d7b2 100644 --- a/src/lib/ai/chatbot.ts +++ b/src/lib/ai/chatbot.ts @@ -1,6 +1,9 @@ import { GoogleGenerativeAI } from '@google/generative-ai' const MODEL_NAME = 'gemini-2.5-flash' +const MAX_RETRIES = 2 +const RETRY_DELAY_MS = 1500 +const REQUEST_TIMEOUT_MS = 30000 const SYSTEM_PROMPT = `당신은 "토키올" AI 갈등 조정 판결 서비스의 고객문의 챗봇입니다. 친절하고 간결하게 답변해주세요. 존댓말을 사용하세요. @@ -44,13 +47,33 @@ export interface ChatMessage { content: string } -const MAX_RETRIES = 2 -const RETRY_DELAY_MS = 1500 +export class ChatbotTimeoutError extends Error { + constructor() { + super('Gemini request timed out') + this.name = 'ChatbotTimeoutError' + } +} + +export class ChatbotGeminiError extends Error { + constructor(message: string) { + super(message) + this.name = 'ChatbotGeminiError' + } +} async function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } +function withTimeout(promise: Promise, ms: number): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new ChatbotTimeoutError()), ms) + ), + ]) +} + export async function getChatbotResponse( userMessage: string, history: ChatMessage[] @@ -76,10 +99,13 @@ export async function getChatbotResponse( for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { try { - const result = await chat.sendMessage(userMessage) + const result = await withTimeout(chat.sendMessage(userMessage), REQUEST_TIMEOUT_MS) return result.response.text() } catch (error) { lastError = error + + if (error instanceof ChatbotTimeoutError) throw error + const isRetryable = error instanceof Error && (error.message.includes('503') || @@ -93,5 +119,8 @@ export async function getChatbotResponse( } } - throw lastError + if (lastError instanceof Error) { + throw new ChatbotGeminiError(lastError.message) + } + throw new ChatbotGeminiError('Unknown Gemini error') }