Spaces:
Sleeping
Sleeping
'use client' | |
import { useState, useRef, useEffect } from 'react' | |
import { useChat } from 'ai/react' | |
import { | |
AcademicCapIcon, | |
ClockIcon, | |
BookOpenIcon, | |
LightBulbIcon, | |
BriefcaseIcon, | |
} from '@heroicons/react/24/outline' | |
type MessageWithLoading = { | |
content: string; | |
role: string; | |
isStreaming?: boolean; | |
} | |
const EXAMPLE_PROMPTS = [ | |
{ | |
title: "教案規劃", | |
prompt: "我想規劃一堂關於數學的課程", | |
icon: AcademicCapIcon | |
}, | |
{ | |
title: "英語對話", | |
prompt: "我想練習英語對話", | |
icon: ClockIcon | |
}, | |
{ | |
title: "作業批改", | |
prompt: "我想批改學生的作文作業", | |
icon: BookOpenIcon | |
}, | |
{ | |
title: "數學解題", | |
prompt: "我想解數學題目", | |
icon: LightBulbIcon | |
}, | |
{ | |
title: "我想學程式", | |
prompt: "我想學習如何寫程式", | |
icon: BriefcaseIcon | |
} | |
] | |
export default function LandingPageChatBot() { | |
const { messages: rawMessages, input, handleInputChange, handleSubmit, isLoading, append } = useChat({ | |
api: '/api/landing_page_chat', | |
streamProtocol: 'data', | |
onError: (error) => { | |
console.error('Chat error:', error); | |
}, | |
onFinish: (message) => { | |
console.log('Chat finished:', message); | |
setMessages(prev => prev.map(msg => ({...msg, isStreaming: false}))) | |
}, | |
keepLastMessageOnError: true, | |
}) | |
const chatContainerRef = useRef<HTMLDivElement>(null) | |
const [hasInteracted, setHasInteracted] = useState(false) | |
const [messages, setMessages] = useState<MessageWithLoading[]>([]) | |
useEffect(() => { | |
setMessages(rawMessages.map((msg, index) => ({ | |
...msg, | |
isStreaming: isLoading && index === rawMessages.length - 1 && msg.role === 'assistant' | |
}))) | |
}, [rawMessages, isLoading]) | |
const scrollToBottom = () => { | |
if (chatContainerRef.current) { | |
const { scrollHeight, clientHeight } = chatContainerRef.current | |
chatContainerRef.current.scrollTop = scrollHeight - clientHeight | |
} | |
} | |
useEffect(() => { | |
scrollToBottom() | |
}, [messages]) | |
const sendMessage = async (text: string) => { | |
setHasInteracted(true) | |
await append({ | |
content: text, | |
role: 'user', | |
}) | |
} | |
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => { | |
setHasInteracted(true) | |
handleSubmit(e) | |
} | |
return ( | |
<section className="w-full h-[1000px] bg-gradient-to-b from-background-secondary to-background-primary flex items-center justify-center px-4"> | |
<div className="w-full max-w-5xl mx-auto h-full flex items-center"> | |
{!hasInteracted && messages.length === 0 ? ( | |
<div className="text-center space-y-8 w-full"> | |
<div className="space-y-4"> | |
<h2 className="text-5xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent"> | |
今天你想學什麼? | |
</h2> | |
<p className="text-text-secondary text-xl max-w-2xl mx-auto leading-relaxed"> | |
探索新主題、獲得教學協助,或發現學習資源 | |
</p> | |
</div> | |
<form onSubmit={onSubmit} className="max-w-3xl mx-auto"> | |
<div className="relative flex items-center"> | |
<input | |
type="text" | |
value={input} | |
onChange={handleInputChange} | |
placeholder="請問任何關於學習的問題..." | |
className="w-full p-6 pr-36 rounded-2xl border border-border/50 bg-background-primary/50 | |
backdrop-blur-sm shadow-lg focus:outline-none focus:ring-2 focus:ring-primary/50 | |
text-lg transition-all duration-200 placeholder:text-text-secondary/50" | |
/> | |
<button | |
type="submit" | |
disabled={isLoading} | |
className="absolute right-2 p-3 bg-primary text-white rounded-full | |
hover:bg-primary/90 transition-all duration-200 | |
shadow-md hover:shadow-lg active:scale-95 disabled:opacity-50 | |
disabled:hover:bg-primary disabled:cursor-not-allowed" | |
> | |
<svg | |
xmlns="http://www.w3.org/2000/svg" | |
fill="none" | |
viewBox="0 0 24 24" | |
strokeWidth="1.5" | |
stroke="currentColor" | |
className={`size-6 ${isLoading ? 'animate-pulse' : ''}`} | |
> | |
<path | |
strokeLinecap="round" | |
strokeLinejoin="round" | |
d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" | |
/> | |
</svg> | |
</button> | |
</div> | |
</form> | |
<div className="mt-12"> | |
<div className="grid grid-cols-1 md:grid-cols-5 gap-4"> | |
{EXAMPLE_PROMPTS.map((prompt, index) => ( | |
<button | |
key={index} | |
onClick={() => sendMessage(prompt.prompt)} | |
className="group p-4 rounded-2xl border border-border/50 bg-background-primary/30 | |
hover:bg-background-primary/50 backdrop-blur-sm transition-all duration-200 | |
hover:border-primary/50 hover:shadow-lg text-left w-full" | |
> | |
<div className="flex items-center justify-between w-full"> | |
<div className="flex items-center gap-3"> | |
<prompt.icon className="w-8 h-8 text-primary" /> | |
<h4 className="font-semibold text-text-primary text-md"> | |
{prompt.title} | |
</h4> | |
</div> | |
<svg | |
className="w-6 h-6 text-primary opacity-0 group-hover:opacity-100 transition-opacity duration-200" | |
fill="none" | |
stroke="currentColor" | |
viewBox="0 0 24 24" | |
strokeWidth={2} | |
> | |
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /> | |
</svg> | |
</div> | |
</button> | |
))} | |
</div> | |
</div> | |
</div> | |
) : ( | |
<div className="bg-background-primary/50 backdrop-blur-sm rounded-2xl shadow-lg border border-border/50 overflow-hidden w-full h-[700px] flex flex-col"> | |
<div className="p-6 border-b border-border/50 bg-background-secondary/30"> | |
<h2 className="text-xl font-semibold text-text-primary">PlayGO 導覽員</h2> | |
</div> | |
<div | |
ref={chatContainerRef} | |
className="flex-1 overflow-y-auto p-6 space-y-6" | |
> | |
{messages.map((message, index) => ( | |
<div | |
key={index} | |
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`} | |
> | |
<div | |
className={`max-w-[80%] p-4 rounded-2xl shadow-sm ${ | |
message.role === 'user' | |
? 'bg-primary text-white rounded-br-none' | |
: 'bg-background-secondary/50 text-text-primary rounded-bl-none' | |
}`} | |
> | |
{message.content} | |
{message.isStreaming && ( | |
<span className="inline-block w-2.5 h-2.5 ml-1.5 bg-primary rounded-full animate-pulse" /> | |
)} | |
</div> | |
</div> | |
))} | |
</div> | |
<div className="p-6 border-t border-border/50 bg-background-secondary/30"> | |
<form onSubmit={onSubmit}> | |
<div className="relative flex items-center"> | |
<input | |
type="text" | |
value={input} | |
onChange={handleInputChange} | |
placeholder={isLoading ? "AI is thinking..." : "Type your message..."} | |
disabled={isLoading} | |
className="w-full p-4 pr-32 rounded-xl border border-border/50 bg-background-primary/50 | |
backdrop-blur-sm focus:outline-none focus:ring-2 focus:ring-primary/50 | |
text-base transition-all duration-200 disabled:opacity-50" | |
/> | |
<button | |
type="submit" | |
disabled={isLoading} | |
className="absolute right-2 p-2 bg-primary text-white rounded-full | |
hover:bg-primary/90 transition-all duration-200 | |
shadow-md hover:shadow-lg active:scale-95 disabled:opacity-50 | |
disabled:hover:bg-primary disabled:cursor-not-allowed" | |
> | |
<svg | |
xmlns="http://www.w3.org/2000/svg" | |
fill="none" | |
viewBox="0 0 24 24" | |
strokeWidth="1.5" | |
stroke="currentColor" | |
className={`size-5 ${isLoading ? 'animate-pulse' : ''}`} | |
> | |
<path | |
strokeLinecap="round" | |
strokeLinejoin="round" | |
d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" | |
/> | |
</svg> | |
</button> | |
</div> | |
</form> | |
</div> | |
</div> | |
)} | |
</div> | |
</section> | |
) | |
} |