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> | |
| ); | |
| } |