- URL 하드코딩 → .env APP_URL 기반 동적 URL로 변경 - DB 연결 하드코딩 → .env 기반으로 변경 - MySQL strict mode DATE 오류 수정
489 lines
22 KiB
PHP
489 lines
22 KiB
PHP
<?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>운영자용 챗봇 - 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 {
|
|
const response = await fetch('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("🤖 Chatbot Debug Info");
|
|
console.log("Refined Query:", data.debug.refinedQuery); // New: Log refined query
|
|
console.log("Context from Notion:", data.debug.context);
|
|
console.log("System Instruction:", data.debug.systemInstruction);
|
|
console.log("Raw Response from Gemini:", 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">운영자용 챗봇</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-blue-500 to-indigo-600 flex items-center justify-center text-white font-bold shadow-sm ${className}`}>
|
|
CB
|
|
</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-blue-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">
|
|
운영자 문서를 탐색합니다. 궁금한 것을 검색하세요! 😉
|
|
</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"
|
|
aria-label="Close tooltip"
|
|
>
|
|
<CloseIcon className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ChatWindow = () => {
|
|
const [messages, setMessages] = useState([
|
|
{
|
|
id: 'welcome',
|
|
text: "운영자 문서를 탐색합니다. 궁금한 것을 검색하세요! 😉",
|
|
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 {
|
|
// Pass existing messages as history (limit to last 10 for context)
|
|
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-blue-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-blue-600" />
|
|
<div>
|
|
<h2 className="font-bold text-lg leading-tight"> codebridge-x.com</h2>
|
|
<p className="text-xs text-blue-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-blue-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-blue-200 hover:text-white'
|
|
: 'text-blue-600 hover:text-blue-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-blue-600 focus-within:ring-1 focus-within:ring-blue-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-blue-600 hover:text-blue-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 codebridge-x.com</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-blue-600 rotate-90' : 'bg-blue-600 hover:bg-blue-700 hover:scale-105'}
|
|
`}
|
|
aria-label={isOpen ? "Close chat" : "Open chat"}
|
|
>
|
|
{isOpen ? (
|
|
<CloseIcon className="w-8 h-8 text-white" />
|
|
) : (
|
|
<ChatMultipleIcon className="w-8 h-8 text-white" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// --- Main App ---
|
|
const App = () => {
|
|
useEffect(() => {
|
|
lucide.createIcons();
|
|
}, []);
|
|
|
|
const handleOpenHelp = () => {
|
|
alert("도움말 기능은 준비중입니다.");
|
|
};
|
|
|
|
return (
|
|
<div className="w-full min-h-screen relative bg-gray-50">
|
|
<Header onOpenHelp={handleOpenHelp} />
|
|
|
|
{/* Background Content */}
|
|
<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">codebridge-x.com</h1>
|
|
<p className="text-4xl text-gray-400">Host Website Content</p>
|
|
</div>
|
|
<div className="absolute left-10 top-1/2 w-96 h-96 bg-blue-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>
|
|
|
|
{/* The Widget */}
|
|
<ChatWidget />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
|
root.render(<App />);
|
|
</script>
|
|
</body>
|
|
</html>
|