Spaces:
Sleeping
Sleeping
"use client"; | |
import { useState, useRef, useEffect } from "react"; | |
import { useChat } from "ai/react"; | |
import { XMarkIcon } from "@heroicons/react/24/outline"; | |
type MessageWithLoading = { | |
content: string; | |
role: string; | |
isStreaming?: boolean; | |
}; | |
// Define theme CSS variables | |
const themeStyles = { | |
light: { | |
'--bg-primary': '#ffffff', | |
'--bg-secondary': 'rgba(243, 244, 246, 0.7)', | |
'--text-primary': '#111827', | |
'--text-secondary': '#6B7280', | |
'--border-color': 'rgba(229, 231, 235, 0.5)', | |
'--shadow-color': 'rgba(0, 0, 0, 0.1)', | |
'--message-bg': 'rgba(243, 244, 246, 0.7)', | |
'--input-bg': 'rgba(255, 255, 255, 0.9)', | |
}, | |
dark: { | |
'--bg-primary': '#1F2937', | |
'--bg-secondary': 'rgba(31, 41, 55, 0.7)', | |
'--text-primary': '#F9FAFB', | |
'--text-secondary': '#D1D5DB', | |
'--border-color': 'rgba(75, 85, 99, 0.5)', | |
'--shadow-color': 'rgba(0, 0, 0, 0.3)', | |
'--message-bg': 'rgba(55, 65, 81, 0.7)', | |
'--input-bg': 'rgba(31, 41, 55, 0.9)', | |
}, | |
} as const; | |
// Inject required styles into iframe | |
const injectStyles = ` | |
* { | |
margin: 0; | |
padding: 0; | |
box-sizing: border-box; | |
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; | |
} | |
.playgo-chat { | |
background-color: var(--bg-primary); | |
color: var(--text-primary); | |
} | |
.playgo-chat-header { | |
background-color: var(--bg-secondary); | |
border-bottom: 1px solid var(--border-color); | |
} | |
.playgo-chat-messages { | |
background-color: var(--bg-primary); | |
} | |
.playgo-chat-input { | |
background-color: var(--bg-secondary); | |
border-top: 1px solid var(--border-color); | |
} | |
.playgo-chat-input-field { | |
background-color: var(--input-bg); | |
color: var(--text-primary); | |
border: 1px solid var(--border-color); | |
border-radius: 0.75rem; | |
padding: 0.75rem 3rem 0.75rem 0.75rem; | |
width: 100%; | |
transition: all 0.2s; | |
} | |
.playgo-chat-input-field:focus { | |
outline: none; | |
border-color: var(--primary-color); | |
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.2); | |
} | |
.playgo-chat-input-field::placeholder { | |
color: var(--text-secondary); | |
} | |
.playgo-message-bubble { | |
box-shadow: 0 1px 2px var(--shadow-color); | |
max-width: 80%; | |
padding: 0.75rem; | |
border-radius: 0.75rem; | |
} | |
.playgo-message-assistant { | |
background-color: var(--message-bg); | |
color: var(--text-primary); | |
margin-right: 1rem; | |
} | |
.playgo-message-user { | |
background-color: var(--primary-color); | |
color: white; | |
margin-left: 1rem; | |
} | |
@media (prefers-color-scheme: dark) { | |
.playgo-chat[data-theme="system"] { | |
--bg-primary: #1F2937; | |
--bg-secondary: rgba(31, 41, 55, 0.7); | |
--text-primary: #F9FAFB; | |
--text-secondary: #D1D5DB; | |
--border-color: rgba(75, 85, 99, 0.5); | |
--shadow-color: rgba(0, 0, 0, 0.3); | |
--message-bg: rgba(55, 65, 81, 0.7); | |
--input-bg: rgba(31, 41, 55, 0.9); | |
} | |
} | |
`; | |
interface EmbeddableChatBotConfig { | |
apiUrl?: string; | |
height?: string | number; | |
width?: string | number; | |
theme?: 'light' | 'dark' | 'system'; | |
primaryColor?: string; | |
placeholder?: string; | |
buttonText?: string; | |
} | |
// First, let's fix the CSS type error by declaring the CSS custom properties | |
declare module 'react' { | |
interface CSSProperties { | |
'--primary-color'?: string; | |
'--primary-rgb'?: string; | |
'--bg-primary'?: string; | |
'--bg-secondary'?: string; | |
'--text-primary'?: string; | |
'--text-secondary'?: string; | |
'--border-color'?: string; | |
'--shadow-color'?: string; | |
'--message-bg'?: string; | |
'--input-bg'?: string; | |
} | |
} | |
export default function EmbeddableChatBot({ | |
apiUrl = "/api/landing_page_chat", | |
theme = 'system', | |
primaryColor = "#FF6B6B", | |
placeholder = "請問任何關於學習的問題...", | |
buttonText = "需要協助嗎?", | |
}: EmbeddableChatBotConfig) { | |
const chatContainerRef = useRef<HTMLDivElement>(null); | |
const [isOpen, setIsOpen] = useState(false); | |
const [messages, setMessages] = useState<MessageWithLoading[]>([]); | |
const [currentTheme, setCurrentTheme] = useState(theme); | |
// Convert hex color to RGB for CSS variables | |
const hexToRgb = (hex: string) => { | |
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); | |
return result ? | |
`${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}` | |
: '255, 107, 107'; // fallback RGB for #FF6B6B | |
}; | |
const { | |
input, | |
handleInputChange, | |
handleSubmit, | |
isLoading, | |
} = useChat({ | |
api: apiUrl, | |
onError: (error) => { | |
console.error("Chat error:", error); | |
}, | |
onFinish: () => { | |
setMessages((prev) => | |
prev.map((msg) => ({ ...msg, isStreaming: false })) | |
); | |
}, | |
}); | |
// Handle system theme changes | |
useEffect(() => { | |
if (theme === 'system') { | |
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); | |
const handleChange = (e: MediaQueryListEvent) => { | |
setCurrentTheme(e.matches ? 'dark' : 'light'); | |
}; | |
mediaQuery.addEventListener('change', handleChange); | |
setCurrentTheme(mediaQuery.matches ? 'dark' : 'light'); | |
return () => mediaQuery.removeEventListener('change', handleChange); | |
} else { | |
setCurrentTheme(theme); | |
} | |
}, [theme]); | |
useEffect(() => { | |
if (chatContainerRef.current) { | |
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight; | |
} | |
}, [messages]); | |
// Inject styles when component mounts | |
useEffect(() => { | |
if (isOpen && chatContainerRef.current) { | |
const styleSheet = document.createElement('style'); | |
styleSheet.textContent = injectStyles; | |
chatContainerRef.current.appendChild(styleSheet); | |
} | |
}, [isOpen]); | |
const containerStyle = { | |
...(themeStyles[currentTheme === 'system' ? 'light' : currentTheme]), | |
'--primary-color': primaryColor, | |
'--primary-rgb': hexToRgb(primaryColor), | |
} as React.CSSProperties; | |
// Send message to parent when chat state changes | |
useEffect(() => { | |
if (typeof window !== 'undefined') { | |
window.parent.postMessage({ | |
type: isOpen ? 'chatOpen' : 'chatClose' | |
}, '*'); | |
} | |
}, [isOpen]); | |
if (!isOpen) { | |
return ( | |
<div className="w-full h-full flex items-center justify-end"> | |
<button | |
onClick={() => setIsOpen(true)} | |
className="bg-primary text-white rounded-full p-4 | |
transition-colors duration-200 | |
inline-flex items-center" | |
style={{ backgroundColor: primaryColor }} | |
> | |
<span className="flex items-center gap-2"> | |
<svg | |
xmlns="http://www.w3.org/2000/svg" | |
fill="none" | |
viewBox="0 0 24 24" | |
strokeWidth={1.5} | |
stroke="currentColor" | |
className="w-6 h-6" | |
> | |
<path | |
strokeLinecap="round" | |
strokeLinejoin="round" | |
d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" | |
/> | |
</svg> | |
{buttonText} | |
</span> | |
</button> | |
</div> | |
); | |
} | |
return ( | |
<div | |
ref={chatContainerRef} | |
className="w-full h-full rounded-2xl" | |
style={{ | |
...containerStyle, | |
overflow: 'hidden', | |
}} | |
data-theme={theme} | |
> | |
<div | |
className="playgo-chat-container rounded-2xl bg-background-primary w-full h-full" | |
style={{ | |
position: 'absolute', | |
top: 0, | |
left: 0, | |
right: 0, | |
bottom: 0, | |
overflow: 'hidden' | |
}} | |
> | |
<div className="flex flex-col h-full absolute inset-0"> | |
<div className="playgo-chat-header p-4 flex justify-between items-center flex-shrink-0"> | |
<h2 className="text-xl font-bold"> | |
<span className="text-[#FF6B6B]">P</span> | |
<span className="text-[#4ECDC4]">l</span> | |
<span className="text-[#45B7D1]">a</span> | |
<span className="text-[#FDCB6E]">y</span> | |
<span className="text-[#FF6B6B]">G</span> | |
<span className="text-[#4ECDC4]">o</span> | |
<span className="ml-2 text-[#45B7D1]">A</span> | |
<span className="text-[#FDCB6E]">I</span> | |
</h2> | |
<button | |
onClick={() => setIsOpen(false)} | |
className="p-1 hover:bg-background-secondary/50 rounded-full transition-colors" | |
> | |
<XMarkIcon className="w-6 h-6 text-text-secondary" /> | |
</button> | |
</div> | |
<div className="flex-1 relative"> | |
<div | |
className="playgo-chat-messages absolute inset-0 overflow-y-auto p-4 space-y-4" | |
style={{ | |
overscrollBehavior: 'contain', | |
WebkitOverflowScrolling: 'touch' | |
}} | |
> | |
{messages.map((message, index) => ( | |
<div | |
key={index} | |
className={`flex ${ | |
message.role === "user" ? "justify-end" : "justify-start" | |
}`} | |
> | |
<div | |
className={`playgo-message-bubble ${ | |
message.role === "user" | |
? "playgo-message-user" | |
: "playgo-message-assistant" | |
}`} | |
> | |
{message.content} | |
{message.isStreaming && ( | |
<span className="ml-1 animate-pulse">...</span> | |
)} | |
</div> | |
</div> | |
))} | |
</div> | |
</div> | |
<form | |
onSubmit={handleSubmit} | |
className="playgo-chat-input p-4 flex-shrink-0" | |
> | |
<div className="relative"> | |
<input | |
type="text" | |
value={input} | |
onChange={handleInputChange} | |
placeholder={placeholder} | |
className="playgo-chat-input-field" | |
/> | |
<button | |
type="submit" | |
disabled={isLoading || !input.trim()} | |
className="absolute right-2 top-1/2 -translate-y-1/2 p-2 | |
text-primary hover:text-primary/80 disabled:opacity-50 | |
transition-colors duration-200" | |
style={{ color: primaryColor }} | |
> | |
<svg | |
xmlns="http://www.w3.org/2000/svg" | |
fill="none" | |
viewBox="0 0 24 24" | |
strokeWidth={1.5} | |
stroke="currentColor" | |
className="w-6 h-6" | |
> | |
<path | |
strokeLinecap="round" | |
strokeLinejoin="round" | |
d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" | |
/> | |
</svg> | |
</button> | |
</div> | |
</form> | |
</div> | |
</div> | |
</div> | |
); | |
} |