Files
sam-kd/chatbot/rag_index.php

473 lines
22 KiB
PHP
Raw Normal View History

<?php
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>운영자 Vertex RAG 챗봇 - codebridge-x.com</title>
<!-- Fonts: Pretendard -->
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.8/dist/web/static/pretendard.css" />
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['Pretendard', 'sans-serif'],
},
colors: {
background: 'rgb(250, 250, 250)',
primary: {
DEFAULT: '#2563eb', // blue-600
foreground: '#ffffff',
},
},
animation: {
'blob': 'blob 7s infinite',
'fade-in-up': 'fadeInUp 0.5s ease-out forwards',
},
keyframes: {
blob: {
'0%': { transform: 'translate(0px, 0px) scale(1)' },
'33%': { transform: 'translate(30px, -50px) scale(1.1)' },
'66%': { transform: 'translate(-20px, 20px) scale(0.9)' },
'100%': { transform: 'translate(0px, 0px) scale(1)' },
},
fadeInUp: {
'0%': { opacity: '0', transform: 'translateY(10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
}
}
}
}
}
</script>
<style>
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>
<!-- React & ReactDOM -->
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<!-- Babel for JSX -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<!-- Icons: Lucide React -->
<script src="https://unpkg.com/lucide@latest"></script>
</head>
<body class="bg-background text-slate-800 antialiased overflow-hidden">
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useRef } = React;
// --- Types ---
const Sender = {
USER: 'user',
BOT: 'bot',
};
// --- Services ---
const sendMessageToGemini = async (userMessage, history = []) => {
try {
// Pointing to the new RAG API
const response = await fetch('rag_api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: userMessage,
history: history
}),
});
if (!response.ok) {
throw new Error('Network response was not ok');
}
const data = await response.json();
if (data.debug) {
console.group("🤖 RAG Chatbot Debug Info");
console.log("Refined Query:", data.debug.refinedQuery);
console.log("Vector Count:", data.debug.vectorCount);
if (data.debug.loadError) {
console.error("⚠️ VECTOR LOAD ERROR:", data.debug.loadError);
}
console.log("File Status:", data.debug.fileStatus);
console.log("Vector Context:", data.debug.context);
console.log("System Instruction:", data.debug.systemInstruction);
console.log("Raw Response:", data.debug.rawResponse);
console.groupEnd();
}
return data.reply || "죄송합니다. 응답을 받을 수 없습니다.";
} catch (error) {
console.error("API Error:", error);
return "오류가 발생했습니다. 잠시 후 다시 시도해주세요.";
}
};
// --- Icons ---
const ChatMultipleIcon = ({ className = "w-6 h-6" }) => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M14 9a2 2 0 0 1-2 2H6l-4 4V4c0-1.1.9-2 2-2h8a2 2 0 0 1 2 2v5Z"/>
<path d="M18 9h2a2 2 0 0 1 2 2v11l-4-4h-6a2 2 0 0 1-2-2v-1"/>
</svg>
);
const CloseIcon = ({ className = "w-6 h-6" }) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className={className}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
const SendIcon = ({ className = "w-6 h-6" }) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className={className}>
<path d="M3.478 2.405a.75.75 0 00-.926.94l2.432 7.905H13.5a.75.75 0 010 1.5H4.984l-2.432 7.905a.75.75 0 00.926.94 60.519 60.519 0 0018.445-8.986.75.75 0 000-1.218A60.517 60.517 0 003.478 2.405z" />
</svg>
);
// --- Header Component ---
const Header = ({ onOpenHelp }) => {
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
const profileMenuRef = useRef(null);
useEffect(() => {
const handleClickOutside = (event) => {
if (profileMenuRef.current && !profileMenuRef.current.contains(event.target)) {
setIsProfileMenuOpen(false);
}
};
if (isProfileMenuOpen) document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isProfileMenuOpen]);
return (
<header className="bg-white border-b border-gray-100 sticky top-0 z-40">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-lg font-semibold text-slate-900">운영자 Vertex RAG 챗봇</h1>
</div>
<div className="flex items-center gap-4">
<a href="../index.php" className="text-sm text-slate-500 hover:text-slate-900 flex items-center gap-1">
<i data-lucide="home" className="w-4 h-4"></i>
홈으로
</a>
<button onClick={onOpenHelp} className="text-sm text-slate-500 hover:text-slate-900 flex items-center gap-1">
<i data-lucide="help-circle" className="w-4 h-4"></i>
도움말
</button>
</div>
</div>
</header>
);
};
// --- Chat Components ---
const BotAvatar = ({ size = 'md', className = '' }) => {
const sizeClasses = {
sm: 'w-8 h-8 text-xs',
md: 'w-10 h-10 text-sm',
lg: 'w-12 h-12 text-base',
};
return (
<div className={`${sizeClasses[size]} rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-white font-bold shadow-sm ${className}`}>
VR
</div>
);
};
const AvatarGroup = ({ size = 'md', borderColor = 'border-white' }) => {
return (
<div className="flex -space-x-3 relative z-10">
<BotAvatar size={size} className={`border-2 ${borderColor}`} />
</div>
);
};
const ChatTooltip = ({ onClose, onClick }) => {
return (
<div className="relative mb-4 group cursor-pointer animate-fade-in-up" onClick={onClick}>
<div className="absolute -top-5 left-1/2 transform -translate-x-1/2 flex justify-center w-full pointer-events-none">
<AvatarGroup size="md" borderColor="border-white" />
</div>
<div className="bg-white border-2 border-indigo-600 rounded-[2rem] p-6 pt-8 pr-10 shadow-lg max-w-[320px] relative">
<p className="text-gray-700 text-sm leading-relaxed font-medium">
Vertex RAG(Vector Search) 엔진을 테스트합니다. 질문을 입력하세요! 🚀
</p>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="absolute -top-3 -right-2 bg-gray-600 hover:bg-gray-700 text-white rounded-full p-1 shadow-md transition-colors z-20"
>
<CloseIcon className="w-4 h-4" />
</button>
</div>
);
};
const ChatWindow = () => {
const [messages, setMessages] = useState([
{
id: 'welcome',
text: "Vertex RAG(Vector Search) 엔진을 테스트합니다. 무엇을 도와드릴까요? 🚀",
sender: Sender.BOT,
timestamp: new Date(),
}
]);
const [inputText, setInputText] = useState('');
const [isLoading, setIsLoading] = useState(false);
const messagesEndRef = useRef(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
const handleSendMessage = async (e) => {
e?.preventDefault();
if (!inputText.trim() || isLoading) return;
const userMsg = {
id: Date.now().toString(),
text: inputText,
sender: Sender.USER,
timestamp: new Date(),
};
setMessages(prev => [...prev, userMsg]);
setInputText('');
setIsLoading(true);
try {
const history = messages.slice(-10).map(msg => ({
role: msg.sender === Sender.USER ? 'user' : 'model',
parts: [{ text: msg.text }]
}));
const responseText = await sendMessageToGemini(userMsg.text, history);
const botMsg = {
id: (Date.now() + 1).toString(),
text: responseText,
sender: Sender.BOT,
timestamp: new Date(),
};
setMessages(prev => [...prev, botMsg]);
} catch (error) {
console.error(error);
} finally {
setIsLoading(false);
}
};
return (
<div className="flex flex-col h-full w-full bg-white rounded-lg overflow-hidden shadow-2xl">
{/* Header */}
<div className="bg-indigo-600 p-4 pt-6 pb-6 text-white shrink-0 relative">
<div className="flex items-center space-x-3">
<AvatarGroup size="md" borderColor="border-indigo-600" />
<div>
<h2 className="font-bold text-lg leading-tight"> Vertex RAG Bot</h2>
<p className="text-xs text-indigo-100 opacity-90 mt-0.5">
고도화된 벡터 검색 엔진입니다
</p>
</div>
</div>
</div>
{/* Message List */}
<div className="flex-1 overflow-y-auto p-4 bg-gray-50 space-y-4 scrollbar-hide">
{messages.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.sender === Sender.USER ? 'justify-end' : 'justify-start'}`}
>
{msg.sender === Sender.BOT && (
<div className="mr-2 mt-1 flex-shrink-0">
<BotAvatar size="sm" className="border border-gray-200" />
</div>
)}
<div
className={`max-w-[80%] rounded-2xl px-4 py-3 text-sm leading-relaxed shadow-sm whitespace-pre-wrap ${
msg.sender === Sender.USER
? 'bg-indigo-600 text-white rounded-tr-none'
: 'bg-white text-gray-800 border border-gray-100 rounded-tl-none'
}`}
>
{(() => {
const parseTextWithLinks = (text) => {
const parts = [];
let lastIndex = 0;
const regex = /\[([^\]]+)\]\(([^)]+)\)/g;
let match;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(text.substring(lastIndex, match.index));
}
parts.push(
<a
key={lastIndex}
href={match[2]}
target="_blank"
rel="noopener noreferrer"
className={`underline font-medium break-all ${
msg.sender === Sender.USER
? 'text-indigo-200 hover:text-white'
: 'text-indigo-600 hover:text-indigo-800'
}`}
onClick={(e) => e.stopPropagation()}
>
{match[1]}
</a>
);
lastIndex = regex.lastIndex;
}
if (lastIndex < text.length) {
parts.push(text.substring(lastIndex));
}
return parts;
};
return parseTextWithLinks(msg.text);
})()}
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start animate-pulse">
<div className="mr-2 mt-1 flex-shrink-0">
<BotAvatar size="sm" className="border border-gray-200" />
</div>
<div className="bg-white text-gray-400 border border-gray-100 rounded-2xl rounded-tl-none px-4 py-3 text-sm shadow-sm">
답변 작성 ...
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
<div className="p-4 bg-white border-t border-gray-100 shrink-0">
<form
onSubmit={handleSendMessage}
className="flex items-center w-full border border-gray-300 rounded-full px-4 py-2 focus-within:border-indigo-600 focus-within:ring-1 focus-within:ring-indigo-600 transition-all shadow-sm"
>
<input
type="text"
className="flex-1 outline-none text-sm text-gray-700 placeholder-gray-400 bg-transparent"
placeholder="무엇이든 물어보세요..."
value={inputText}
onChange={(e) => setInputText(e.target.value)}
/>
<button
type="submit"
disabled={!inputText.trim() || isLoading}
className={`ml-2 p-1 ${!inputText.trim() ? 'text-gray-300' : 'text-indigo-600 hover:text-indigo-700'} transition-colors`}
>
<SendIcon className="w-5 h-5" />
</button>
</form>
<div className="text-center mt-2">
<a href="https://codebridge-x.com" target="_blank" className="text-[10px] text-gray-400 hover:underline">Powered by Vertex AI Vector Search</a>
</div>
</div>
</div>
);
};
const ChatWidget = () => {
const [isOpen, setIsOpen] = useState(false);
const [isTooltipVisible, setIsTooltipVisible] = useState(true);
const toggleChat = () => {
setIsOpen(!isOpen);
if (!isOpen) setIsTooltipVisible(false);
};
const closeTooltip = () => setIsTooltipVisible(false);
return (
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end gap-3 font-sans">
<div
className={`
transition-all duration-300 ease-in-out origin-bottom-right
${isOpen ? 'scale-100 opacity-100' : 'scale-90 opacity-0 pointer-events-none absolute bottom-16 right-0'}
w-[380px] h-[600px] max-w-[calc(100vw-48px)] max-h-[calc(100vh-120px)]
`}
>
<ChatWindow />
</div>
{!isOpen && isTooltipVisible && (
<ChatTooltip onClose={closeTooltip} onClick={toggleChat} />
)}
<button
onClick={toggleChat}
className={`
w-16 h-16 rounded-full shadow-xl flex items-center justify-center transition-all duration-300
${isOpen ? 'bg-indigo-600 rotate-90' : 'bg-indigo-600 hover:bg-indigo-700 hover:scale-105'}
`}
>
{isOpen ? (
<CloseIcon className="w-8 h-8 text-white" />
) : (
<ChatMultipleIcon className="w-8 h-8 text-white" />
)}
</button>
</div>
);
};
const App = () => {
useEffect(() => {
lucide.createIcons();
}, []);
const handleOpenHelp = () => alert("도움말 기능은 준비중입니다.");
return (
<div className="w-full min-h-screen relative bg-gray-50">
<Header onOpenHelp={handleOpenHelp} />
<div className="w-full h-full flex items-center justify-center p-20 min-h-[80vh]">
<div className="text-center opacity-30 select-none">
<h1 className="text-9xl font-bold text-gray-300 mb-8">Vector RAG</h1>
<p className="text-4xl text-gray-400">Vertex AI Search Mode</p>
</div>
<div className="absolute left-10 top-1/2 w-96 h-96 bg-indigo-300 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob"></div>
<div className="absolute right-10 top-1/3 w-96 h-96 bg-purple-300 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob animation-delay-2000"></div>
</div>
<ChatWidget />
</div>
);
};
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
</script>
</body>
</html>