"use client"; import React, { useState, useEffect } from "react"; import { Button } from "../ui/Button"; import { cn } from "../../utils/cn"; // 메시지 타입 정의 interface Message { id: string; content: string; sender: "user" | "ai"; timestamp: Date; type?: "text" | "error" | "system"; } // 에이전트 설정 타입 interface AgentConfig { name: string; description: string; greeting: string; personality: string; updatedAt: string; } // 답변에서 링크를 버튼으로 렌더링하는 함수 function renderAnswerWithLinks(text: string) { if (!text) return {text}; // 네이버 블로그 링크 패턴 감지 const linkPattern = /(https?:\/\/blog\.naver\.com\/[^\s\n]+)/g; // 링크가 실제로 존재하는지 확인 if (!/(https?:\/\/blog\.naver\.com\/[^\s\n]+)/.test(text)) { return {text}; } const parts = text.split(linkPattern); return ( {parts.map((part, index) => { if (part.match(linkPattern)) { return ( 🔗 원문 보기 ); } return part; })} ); } // 마크다운 링크를 JSX로 변환하는 함수 function renderMessageContent(content: string) { const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; const parts: (string | React.ReactElement)[] = []; let lastIndex = 0; let match; while ((match = linkRegex.exec(content)) !== null) { // 링크 이전 텍스트 추가 if (match.index > lastIndex) { parts.push(content.slice(lastIndex, match.index)); } // 링크 추가 parts.push( {match[1]} ); lastIndex = match.index + match[0].length; } // 남은 텍스트 추가 if (lastIndex < content.length) { parts.push(content.slice(lastIndex)); } return parts.length > 1 ? parts : content; } export interface ChatWidgetProps { agentConfigEndpoint?: string; chatEndpoint?: string; buttonColor?: string; position?: "bottom-right" | "bottom-left"; generateFallbackResponse?: (input: string) => string; } export function ChatWidget({ agentConfigEndpoint = "/api/rag/agent-config", chatEndpoint = "/api/rag/answer/stream", buttonColor = "#ef4444", position = "bottom-right", generateFallbackResponse, }: ChatWidgetProps) { const [isOpen, setIsOpen] = useState(false); const [inputValue, setInputValue] = useState(""); const [agentConfig, setAgentConfig] = useState({ name: "RAG 어시스턴트", description: "문서 기반 질문 답변 AI 어시스턴트", greeting: "안녕하세요! 업로드된 문서를 기반으로 질문에 답변해드립니다. 궁금한 것이 있으시면 언제든 물어보세요!", personality: "친근하고 도움이 되는", updatedAt: new Date().toISOString(), }); const [messages, setMessages] = useState([]); // 에이전트 설정 로드 const loadAgentConfig = async () => { try { const response = await fetch(agentConfigEndpoint); const data = await response.json(); if (data.success) { setAgentConfig(data.config); // 초기 인사말 메시지 설정 setMessages([ { id: "1", content: data.config.greeting, sender: "ai", timestamp: new Date(), type: "text", }, ]); } } catch (error) { console.error("에이전트 설정 로드 실패:", error); // 기본 인사말 사용 setMessages([ { id: "1", content: agentConfig.greeting, sender: "ai", timestamp: new Date(), type: "text", }, ]); } }; // 컴포넌트 마운트 시 에이전트 설정 로드 useEffect(() => { loadAgentConfig(); }, []); // 기본 폴백 응답 생성 함수 const defaultFallbackResponse = (userInput: string): string => { const input = userInput.toLowerCase(); if (input.includes("안녕") || input.includes("hello")) { return "안녕하세요! 현재 문서 검색에 문제가 있어 기본 응답을 드립니다. 관리자에게 문의해주세요."; } else if (input.includes("agentc") || input.includes("에이전트")) { return "AgentC는 AI 기반의 스마트 CMS 시스템입니다. 현재 문서 검색 기능에 문제가 있어 자세한 정보를 제공하지 못하고 있습니다."; } else { return `죄송합니다. 현재 문서 검색 시스템에 문제가 발생하여 "${userInput}"에 대한 정확한 답변을 드릴 수 없습니다.\n\n시스템이 복구되면 다시 시도해주세요. 또는 관리자에게 문의해주세요.`; } }; // 메시지 전송 핸들러 const handleSendMessage = async () => { if (!inputValue.trim()) return; const userQuery = inputValue; // 사용자 메시지 추가 const userMessage: Message = { id: Date.now().toString(), content: userQuery, sender: "user", timestamp: new Date(), type: "text", }; setMessages((prev) => [...prev, userMessage]); setInputValue(""); // 로딩 메시지 추가 const loadingMessage: Message = { id: (Date.now() + 1).toString(), content: "답변을 생성하고 있습니다...", sender: "ai", timestamp: new Date(), type: "system", }; setMessages((prev) => [...prev, loadingMessage]); try { // RAG 스트리밍 API 호출 const response = await fetch(chatEndpoint, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ query: userQuery, k: 5, }), }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } // 로딩 메시지 제거 setMessages((prev) => prev.filter((msg) => msg.id !== loadingMessage.id)); // AI 응답 메시지 생성 (빈 내용으로 시작) const aiResponseId = (Date.now() + 2).toString(); const aiResponse: Message = { id: aiResponseId, content: "", sender: "ai", timestamp: new Date(), type: "text", }; setMessages((prev) => [...prev, aiResponse]); // 스트리밍 처리 const reader = response.body?.getReader(); if (!reader) { throw new Error("스트림을 읽을 수 없습니다."); } const decoder = new TextDecoder(); let buffer = ""; let currentAnswer = ""; let sources: any[] = []; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { if (line.trim()) { try { const data = JSON.parse(line); if (data.type === "sources") { sources = data.content || []; } else if (data.type === "content") { currentAnswer += data.content; // 실시간으로 메시지 업데이트 setMessages((prev) => prev.map((msg) => msg.id === aiResponseId ? { ...msg, content: currentAnswer } : msg ) ); } else if (data.type === "error") { setMessages((prev) => prev.map((msg) => msg.id === aiResponseId ? { ...msg, content: `오류: ${data.content}`, type: "error", } : msg ) ); break; } } catch (e) { // JSON 파싱 오류 무시 } } } } // 소스 정보가 있으면 추가 (RSS가 아닌 문서만) const nonRSSSources = sources.filter((source: any) => !source.isRSS); if (nonRSSSources.length > 0) { const sourcesText = `\n\n📚 **참고 문서:**\n${nonRSSSources .map( (source: any) => `• ${source.title} (${Math.round(source.score * 100)}%)` ) .join("\n")}`; const sourcesMessage: Message = { id: (Date.now() + 3).toString(), content: sourcesText, sender: "ai", timestamp: new Date(), type: "system", }; setMessages((prev) => [...prev, sourcesMessage]); } } catch (error) { console.error("RAG 요청 실패:", error); // 로딩 메시지 제거 setMessages((prev) => prev.filter((msg) => msg.id !== loadingMessage.id)); // 폴백 응답 const fallbackMessage: Message = { id: (Date.now() + 2).toString(), content: generateFallbackResponse ? generateFallbackResponse(userQuery) : defaultFallbackResponse(userQuery), sender: "ai", timestamp: new Date(), type: "text", }; setMessages((prev) => [...prev, fallbackMessage]); } }; // 엔터키 핸들러 const handleKeyPress = (e: React.KeyboardEvent) => { if (e.key === "Enter") { handleSendMessage(); } }; const positionClasses = { "bottom-right": "bottom-6 right-6", "bottom-left": "bottom-6 left-6", }; return ( <> {/* 플로팅 버튼 */} setIsOpen(!isOpen)} className="w-16 h-16 text-white rounded-full shadow-2xl transition-all duration-200 hover:scale-110 border-4 border-white flex items-center justify-center" style={{ backgroundColor: buttonColor, boxShadow: "0 10px 25px rgba(0,0,0,0.3)", }} aria-label="채팅 열기" > {isOpen ? ( ) : ( )} {/* 채팅 UI - 반응형 */} {isOpen && ( {/* 헤더 */} {/* 모바일에서만 뒤로가기 버튼 표시 */} setIsOpen(false)} className="md:hidden w-8 h-8 rounded-full hover:bg-gray-200 flex items-center justify-center transition-colors" aria-label="뒤로가기" > {agentConfig.name} {agentConfig.description && ( {agentConfig.description} )} {/* 데스크톱에서만 닫기 버튼 표시 */} setIsOpen(false)} className="hidden md:flex w-8 h-8 rounded-full hover:bg-gray-200 items-center justify-center transition-colors" aria-label="닫기" > {/* 메시지 영역 */} {messages.map((message) => ( {message.sender === "ai" && message.type === "text" ? renderAnswerWithLinks(message.content) : renderMessageContent(message.content)} {message.type === "system" && ( {message.content.includes("답변을 생성") ? "🤖" : "📚"} )} ))} {/* 입력 영역 */} setInputValue(e.target.value)} onKeyPress={handleKeyPress} placeholder="메시지를 입력하세요..." className="flex-1 p-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-base" /> 전송 )} > ); }
{agentConfig.description}