436 lines
16 KiB
TypeScript
436 lines
16 KiB
TypeScript
"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>
|
||
)}
|
||
</>
|
||
)
|
||
}
|