Files
ai-showcase-platform/components/chat-bot.tsx

436 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { useState, useRef, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
MessageCircle,
X,
Send,
Bot,
User,
Loader2
} from "lucide-react"
import { generateSystemPrompt } from "@/lib/ai-knowledge-base"
interface Message {
id: string
text: string
sender: "user" | "bot"
timestamp: Date
quickQuestions?: string[]
}
const GEMINI_API_KEY = process.env.NEXT_PUBLIC_GEMINI_API_KEY || "AIzaSyAN3pEJr_Vn2xkCidGZAq9eQqsMVvpj8g4"
const GEMINI_API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash-latest:generateContent"
const systemPrompt = generateSystemPrompt()
export function ChatBot() {
const [isOpen, setIsOpen] = useState(false)
const [messages, setMessages] = useState<Message[]>([
{
id: "1",
text: "您好我是AI助手很高興為您服務。我可以協助您了解競賽管理系統的使用方法包括後台管理和前台功能。請問有什麼可以幫助您的嗎",
sender: "bot",
timestamp: new Date(),
quickQuestions: [
"如何註冊參賽團隊?",
"怎麼提交作品?",
"如何創建新競賽?",
"怎麼管理評審團?"
]
}
])
const [inputValue, setInputValue] = useState("")
const [isTyping, setIsTyping] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}
useEffect(() => {
scrollToBottom()
}, [messages])
// 清理 Markdown 格式和過長文字
const cleanResponse = (text: string): string => {
return text
// 移除 Markdown 格式
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/\*(.*?)\*/g, '$1')
.replace(/`(.*?)`/g, '$1')
.replace(/#{1,6}\s/g, '')
.replace(/^- /g, '• ')
.replace(/^\d+\.\s/g, '')
// 移除多餘的空行
.replace(/\n\s*\n\s*\n/g, '\n\n')
// 限制文字長度,如果太長就截斷並添加省略號
.slice(0, 300)
.trim()
}
const callGeminiAPI = async (userMessage: string): Promise<string> => {
try {
// 構建對話歷史,只保留最近的幾條對話
const recentMessages = messages
.filter(msg => msg.sender === "user")
.slice(-5) // 只保留最近5條用戶消息
.map(msg => msg.text)
// 構建完整的對話內容
const conversationHistory = recentMessages.length > 0
? `之前的對話:\n${recentMessages.map(msg => `用戶:${msg}`).join('\n')}\n\n`
: ''
const fullPrompt = `${systemPrompt}\n\n${conversationHistory}用戶:${userMessage}`
const response = await fetch(`${GEMINI_API_URL}?key=${GEMINI_API_KEY}`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
contents: [{
parts: [{
text: fullPrompt
}]
}],
generationConfig: {
maxOutputTokens: 300,
temperature: 0.7,
topP: 0.8,
topK: 10
}
})
})
if (!response.ok) {
const errorText = await response.text()
console.error("Gemini API Error:", response.status, errorText)
throw new Error(`API request failed: ${response.status} - ${errorText}`)
}
const data = await response.json()
if (!data.candidates || !data.candidates[0] || !data.candidates[0].content) {
console.error("Invalid Gemini API response:", data)
throw new Error("Invalid API response format")
}
const rawResponse = data.candidates[0].content.parts[0].text || "抱歉,我現在無法回答您的問題,請稍後再試。"
return cleanResponse(rawResponse)
} catch (error) {
console.error("Gemini API error:", error)
// 根據錯誤類型提供不同的錯誤信息
if (error instanceof Error) {
if (error.message.includes('401') || error.message.includes('403')) {
return "API 密鑰無效,請聯繫管理員檢查配置。"
} else if (error.message.includes('429')) {
return "API 請求過於頻繁,請稍後再試。"
} else if (error.message.includes('500')) {
return "AI 服務暫時不可用,請稍後再試。"
}
}
return "抱歉我現在無法連接到AI服務請檢查網路連接或稍後再試。"
}
}
// 根據用戶問題生成相關的快速問題
const generateQuickQuestions = (userQuestion: string): string[] => {
const question = userQuestion.toLowerCase()
// 前台相關問題
if (question.includes('註冊') || question.includes('團隊') || question.includes('報名')) {
return [
"如何提交作品?",
"怎麼查看競賽詳情?",
"如何收藏應用?",
"怎麼查看我的參賽記錄?"
]
}
if (question.includes('作品') || question.includes('提交') || question.includes('應用')) {
return [
"如何修改作品信息?",
"怎麼查看作品狀態?",
"如何刪除作品?",
"怎麼查看作品評價?"
]
}
if (question.includes('投票') || question.includes('排行榜') || question.includes('評分')) {
return [
"如何查看排行榜?",
"怎麼收藏喜歡的應用?",
"如何對應用進行評論?",
"怎麼查看應用詳情?"
]
}
if (question.includes('個人') || question.includes('資料') || question.includes('設置')) {
return [
"如何修改個人資料?",
"怎麼查看我的收藏?",
"如何修改密碼?",
"怎麼查看通知?"
]
}
// 後台管理相關問題
if (question.includes('競賽') || question.includes('創建') || question.includes('管理')) {
return [
"如何編輯競賽信息?",
"怎麼設定評分標準?",
"如何管理參賽團隊?",
"怎麼設定獎項?"
]
}
if (question.includes('評審') || question.includes('評分') || question.includes('評委')) {
return [
"如何新增評審成員?",
"怎麼設定評審權限?",
"如何查看評分結果?",
"怎麼生成評審連結?"
]
}
if (question.includes('應用') || question.includes('app') || question.includes('作品管理')) {
return [
"如何審核應用?",
"怎麼管理應用狀態?",
"如何查看應用統計?",
"怎麼處理應用舉報?"
]
}
if (question.includes('用戶') || question.includes('成員') || question.includes('邀請')) {
return [
"如何邀請新用戶?",
"怎麼管理用戶角色?",
"如何查看用戶統計?",
"怎麼處理用戶問題?"
]
}
// 技術支持相關問題
if (question.includes('忘記') || question.includes('密碼') || question.includes('登入')) {
return [
"如何重設密碼?",
"怎麼修改個人資料?",
"如何聯繫管理員?",
"怎麼查看使用說明?"
]
}
if (question.includes('錯誤') || question.includes('問題') || question.includes('無法')) {
return [
"如何聯繫技術支持?",
"怎麼查看常見問題?",
"如何回報問題?",
"怎麼查看系統狀態?"
]
}
// 通用問題
return [
"如何註冊參賽團隊?",
"怎麼提交作品?",
"如何創建新競賽?",
"怎麼管理評審團?"
]
}
const handleSendMessage = async (text: string) => {
if (!text.trim() || isLoading) return
const userMessage: Message = {
id: Date.now().toString(),
text: text.trim(),
sender: "user",
timestamp: new Date()
}
setMessages(prev => [...prev, userMessage])
setInputValue("")
setIsTyping(true)
setIsLoading(true)
try {
const aiResponse = await callGeminiAPI(text)
const botMessage: Message = {
id: (Date.now() + 1).toString(),
text: aiResponse,
sender: "bot",
timestamp: new Date(),
quickQuestions: generateQuickQuestions(text)
}
setMessages(prev => [...prev, botMessage])
} catch (error) {
const errorMessage: Message = {
id: (Date.now() + 1).toString(),
text: "抱歉,發生錯誤,請稍後再試。",
sender: "bot",
timestamp: new Date()
}
setMessages(prev => [...prev, errorMessage])
} finally {
setIsTyping(false)
setIsLoading(false)
}
}
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSendMessage(inputValue)
}
}
return (
<>
{/* 浮動按鈕 */}
<Button
onClick={() => setIsOpen(true)}
className="fixed bottom-6 right-6 w-14 h-14 rounded-full bg-blue-600 hover:bg-blue-700 shadow-lg z-50"
size="icon"
>
<MessageCircle className="w-6 h-6" />
</Button>
{/* 聊天對話框 */}
{isOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-end justify-end p-4">
<Card className="w-96 max-h-[80vh] flex flex-col shadow-2xl">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="flex items-center space-x-2">
<Bot className="w-5 h-5 text-blue-600" />
<span>AI </span>
<Badge variant="secondary" className="text-xs"></Badge>
</CardTitle>
<Button
variant="ghost"
size="icon"
onClick={() => setIsOpen(false)}
className="h-8 w-8"
>
<X className="w-4 h-4" />
</Button>
</CardHeader>
<CardContent className="flex-1 flex flex-col p-0 min-h-0">
{/* 訊息區域 */}
<div className="flex-1 overflow-y-auto px-4" style={{ minHeight: '200px', maxHeight: 'calc(80vh - 200px)' }}>
<div className="space-y-4 pb-4">
{messages.map((message) => (
<div
key={message.id}
className={`flex ${message.sender === "user" ? "justify-end" : "justify-start"}`}
>
<div className={`flex items-start space-x-2 max-w-[85%] ${message.sender === "user" ? "flex-row-reverse space-x-reverse" : ""}`}>
<Avatar className="w-8 h-8 flex-shrink-0">
<AvatarFallback className="text-xs">
{message.sender === "user" ? <User className="w-4 h-4" /> : <Bot className="w-4 h-4" />}
</AvatarFallback>
</Avatar>
<div
className={`rounded-lg px-3 py-2 text-sm break-words whitespace-pre-wrap ${
message.sender === "user"
? "bg-blue-600 text-white"
: "bg-gray-100 text-gray-900"
}`}
style={{
maxWidth: '100%',
wordWrap: 'break-word',
overflowWrap: 'break-word'
}}
>
{message.text}
{/* 快速問題按鈕 */}
{message.sender === "bot" && message.quickQuestions && message.quickQuestions.length > 0 && (
<div className="mt-3 space-y-2">
<div className="text-xs text-gray-500 mb-2"></div>
<div className="flex flex-wrap gap-2">
{message.quickQuestions.map((question, index) => (
<Button
key={index}
variant="outline"
size="sm"
className="text-xs h-7 px-2 py-1 bg-white hover:bg-gray-50 border-gray-200"
onClick={() => handleSendMessage(question)}
disabled={isLoading}
>
{question}
</Button>
))}
</div>
</div>
)}
</div>
</div>
</div>
))}
{isTyping && (
<div className="flex justify-start">
<div className="flex items-start space-x-2">
<Avatar className="w-8 h-8 flex-shrink-0">
<AvatarFallback className="text-xs">
<Bot className="w-4 h-4" />
</AvatarFallback>
</Avatar>
<div className="bg-gray-100 rounded-lg px-3 py-2">
<div className="flex items-center space-x-2">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-sm text-gray-600">AI ...</span>
</div>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
{/* 輸入區域 */}
<div className="p-4 border-t border-gray-200 bg-white flex-shrink-0">
<div className="flex items-center space-x-2">
<div className="flex-1 min-w-0">
<Input
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="輸入您的問題..."
className="w-full"
disabled={isLoading}
/>
</div>
<Button
onClick={() => handleSendMessage(inputValue)}
disabled={!inputValue.trim() || isLoading}
size="icon"
className="w-10 h-10 flex-shrink-0"
>
{isLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
)}
</>
)
}