UNPKG

solomdev-chatbot

Version:

Tigerbase Chatbot Client for Product Management

402 lines (401 loc) 26.3 kB
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; /* eslint-disable @typescript-eslint/no-explicit-any */ import { useState, useEffect, useRef } from "react"; import { GoogleGenerativeAI, SchemaType } from "@google/generative-ai"; import { toast } from "react-toastify"; import { X, Maximize2, Minimize2, Send, RefreshCw, ShoppingCart, Trash2, Bot, User, Sparkles, } from "lucide-react"; import { Button } from "./components/ui/button"; import { Input } from "./components/ui/input"; import { Card, CardContent, CardHeader } from "./components/ui/card"; import { Badge } from "./components/ui/badge"; import { ScrollArea } from "./components/ui/scroll-area"; import { getConfig } from "./config"; const ChatbotPopup = () => { const cfg = getConfig(); const MCP_URL = cfg.apiURL; const genAIKey = cfg.aiKey; const defaultLang = cfg.defaultLang || "ar"; if (!genAIKey) { toast.error(defaultLang === "ar" ? "مفتاح Google GenAI غير موجود" : "Google GenAI key not set"); throw new Error("Google GenAI key not set"); } const genAI = new GoogleGenerativeAI(genAIKey); const systemInstruction = `You are a helpful assistant for product management. Always respond in ${defaultLang === "ar" ? "Arabic" : "English"}. ${cfg.customPrompt || ""} When user asks for product information with an ID, use the get-product-info tool and pass the ID in the arguments. When user asks to list products, use the list-products tool. When user asks to add a product, use the add-product tool with name, price, and description.`; const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash", systemInstruction, }); const [isOpen, setIsOpen] = useState(false); const [isExpanded, setIsExpanded] = useState(false); const [messages, setMessages] = useState([ { role: "model", content: defaultLang === "ar" ? "أهلاً وسهلاً! أنا هنا لمساعدتك في إدارة المنتجات. كيف يمكنني مساعدتك؟" : "Hello! I'm here to help with product management. How can I assist you?", timestamp: new Date().toLocaleTimeString(defaultLang === "ar" ? "ar-EG" : "en-US", { hour: "2-digit", minute: "2-digit", hour12: true, }), }, ]); const [input, setInput] = useState(""); const [loading, setLoading] = useState(false); const messagesEndRef = useRef(null); // Auto delete chats useEffect(() => { if (cfg.autoDeleteChats) { const timer = setTimeout(() => { setMessages([ { role: "model", content: defaultLang === "ar" ? "أهلاً وسهلاً! أنا هنا لمساعدتك في إدارة المنتجات. كيف يمكنني مساعدتك؟" : "Hello! I'm here to help with product management. How can I assist you?", timestamp: new Date().toLocaleTimeString(defaultLang === "ar" ? "ar-EG" : "en-US", { hour: "2-digit", minute: "2-digit", hour12: true, }), }, ]); toast.info(defaultLang === "ar" ? "تم مسح المحادثة تلقائيًا" : "Chat cleared automatically"); }, cfg.autoDeleteChats * 60 * 1000); return () => clearTimeout(timer); } }, [messages, cfg.autoDeleteChats, defaultLang]); const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }; useEffect(() => { scrollToBottom(); }, [messages]); const formatTimestamp = () => { return new Date().toLocaleTimeString(defaultLang === "ar" ? "ar-EG" : "en-US", { hour: "2-digit", minute: "2-digit", hour12: true, }); }; const handleMcpToolCall = async (toolName, args, retries = 2) => { if (!cfg.validatedTools.includes(toolName)) { throw new Error(defaultLang === "ar" ? `الأداة ${toolName} غير متاحة لهذا السيريال` : `Tool ${toolName} not available for this serial`); } for (let i = 0; i <= retries; i++) { try { const methodName = "tools/call"; const response = await fetch(MCP_URL, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream", }, body: JSON.stringify({ jsonrpc: "2.0", method: methodName, params: { name: toolName, arguments: args, _meta: { progressToken: 0 }, }, id: Date.now(), }), }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); if (data.error) { throw new Error(`فشل استدعاء ${toolName}: ${data.error.message}`); } if (data.result && data.result.content && data.result.content[0]?.text) { return JSON.parse(data.result.content[0].text); } return (data.result || (defaultLang === "ar" ? "تم تنفيذ الأمر بنجاح" : "Command executed successfully")); } catch (e) { if (i < retries) { await new Promise((resolve) => setTimeout(resolve, 1000 * (i + 1))); continue; } throw new Error(defaultLang === "ar" ? `فشل استدعاء ${toolName} بعد ${retries + 1} محاولة: ${e.message}` : `Failed to call ${toolName} after ${retries + 1} attempts: ${e.message}`); } } }; const handleSend = async (retryCount = 3) => { if (!input.trim()) return; const userMessage = { role: "user", content: input, timestamp: formatTimestamp(), }; setMessages((prev) => [...prev, userMessage]); const userInput = input; setInput(""); setLoading(true); try { const conversationHistory = messages .map((m) => ({ role: m.role, parts: [ { text: typeof m.content === "string" ? m.content : JSON.stringify(m.content), }, ], })) .concat({ role: "user", parts: [{ text: userInput }], }); const result = await model.generateContent({ contents: conversationHistory, generationConfig: { maxOutputTokens: 1024, temperature: 0.7, }, tools: [ { functionDeclarations: [ { name: "mcp_tool", description: "Call MCP server tools for product management", parameters: { type: SchemaType.OBJECT, properties: { tool: { type: SchemaType.STRING, format: "enum", enum: cfg.validatedTools, description: "The MCP tool to call", }, arguments: { type: SchemaType.OBJECT, properties: { id: { type: SchemaType.STRING, description: "Product ID for get-product-info tool", }, name: { type: SchemaType.STRING, description: "Product name for add-product tool", }, price: { type: SchemaType.NUMBER, description: "Product price for add-product tool", }, description: { type: SchemaType.STRING, description: "Product description for add-product tool", }, }, description: "Arguments to pass to the selected tool", }, }, required: ["tool"], }, }, ], }, ], }); let assistantReply = defaultLang === "ar" ? "عذرًا، لم أفهم الطلب." : "Sorry, I didn't understand the request."; const response = await result.response; const candidate = response.candidates?.[0]; if (candidate?.content?.parts) { const parts = candidate.content.parts; const functionCallPart = parts.find((part) => part.functionCall); if (functionCallPart?.functionCall) { const { name, args } = functionCallPart.functionCall; if (name === "mcp_tool") { try { const functionArgs = args; const toolResult = await handleMcpToolCall(functionArgs.tool || functionArgs.name || "list-products", functionArgs.arguments || functionArgs.params || {}); assistantReply = { products: Array.isArray(toolResult) ? toolResult : [toolResult], }; toast.success(defaultLang === "ar" ? "تم جلب البيانات بنجاح!" : "Data fetched successfully!"); } catch (e) { assistantReply = defaultLang === "ar" ? `عذرًا، حدث خطأ أثناء تنفيذ الأمر: ${e.message}` : `Sorry, an error occurred while executing the command: ${e.message}`; toast.error(e.message); } } } else { const textParts = parts .filter((part) => typeof part.text === "string") .map((part) => part.text) .join("\n"); assistantReply = textParts || (defaultLang === "ar" ? "لم أتمكن من الرد." : "Couldn't respond."); } } setMessages((prev) => [ ...prev, { role: "model", content: assistantReply, timestamp: formatTimestamp(), }, ]); } catch (error) { const errorMessage = error instanceof Error ? error.message : ""; const isOverloaded = errorMessage.includes("overloaded") || errorMessage.includes("503"); const waitTime = isOverloaded ? 5000 : 1000; if (retryCount > 0) { toast.warn(defaultLang === "ar" ? `فشل الطلب، جاري إعادة المحاولة... (${retryCount} محاولة متبقية)` : `Request failed, retrying... (${retryCount} attempts left)`); setTimeout(() => handleSend(retryCount - 1), waitTime); return; } const errorMsg = errorMessage || (defaultLang === "ar" ? "حدث خطأ غير متوقع" : "Unexpected error"); toast.error(`${defaultLang === "ar" ? "خطأ" : "Error"}: ${errorMsg}`); setMessages((prev) => [ ...prev, { role: "model", content: `${defaultLang === "ar" ? "عذرًا، حدث خطأ" : "Sorry, an error occurred"}: ${errorMsg}`, timestamp: formatTimestamp(), }, ]); } finally { setLoading(false); } }; const handleKeyPress = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } }; const refreshProducts = async () => { setLoading(true); try { const mcpResult = await handleMcpToolCall("list-products", {}); setMessages((prev) => [ ...prev, { role: "model", content: { products: Array.isArray(mcpResult) ? mcpResult : [mcpResult], }, timestamp: formatTimestamp(), }, ]); toast.success(defaultLang === "ar" ? "تم تحديث قائمة المنتجات!" : "Product list updated!"); } catch (e) { toast.error(defaultLang === "ar" ? `فشل تحديث المنتجات: ${e.message}` : `Failed to update products: ${e.message}`); } finally { setLoading(false); } }; const clearChat = () => { setMessages([ { role: "model", content: defaultLang === "ar" ? "أهلاً وسهلاً! أنا هنا لمساعدتك في إدارة المنتجات. كيف يمكنني مساعدتك؟" : "Hello! I'm here to help with product management. How can I assist you?", timestamp: formatTimestamp(), }, ]); setInput(""); toast.info(defaultLang === "ar" ? "تم مسح المحادثة" : "Chat cleared"); }; const showCart = () => { const cart = JSON.parse(localStorage.getItem("cart") || "[]"); if (cart.length === 0) { toast.warn(defaultLang === "ar" ? "السلة فاضية!" : "Cart is empty!"); setMessages((prev) => [ ...prev, { role: "model", content: defaultLang === "ar" ? "السلة فاضية حالياً! يمكنك إضافة المنتجات من قائمة المنتجات." : "The cart is empty! You can add products from the product list.", timestamp: formatTimestamp(), }, ]); return; } setMessages((prev) => [ ...prev, { role: "model", content: { products: cart }, timestamp: formatTimestamp(), }, ]); toast.info(defaultLang === "ar" ? `عرض السلة (${cart.length} منتج)` : `Showing cart (${cart.length} items)`); }; const ProductCard = ({ product }) => (_jsx(Card, { className: "mb-3 border border-gray-200 shadow-sm hover:shadow-md transition-shadow", children: _jsx(CardContent, { className: "p-4", children: _jsxs("div", { className: "flex justify-between items-start", children: [_jsxs("div", { className: "flex-1", children: [_jsx("h4", { className: "font-semibold text-gray-800 text-sm", children: product.name }), _jsx("p", { className: "text-xs text-gray-600 mt-1 line-clamp-2", children: product.description }), _jsxs("div", { className: "flex items-center gap-2 mt-2", children: [_jsxs(Badge, { variant: "secondary", className: "text-xs", children: [product.price, " ", defaultLang === "ar" ? "جنيه" : "EGP"] }), _jsxs(Badge, { variant: "outline", className: "text-xs", children: ["ID: ", product.id] })] })] }), _jsx(Button, { size: "sm", variant: "outline", className: "text-xs h-8 px-3", onClick: () => { const cart = JSON.parse(localStorage.getItem("cart") || "[]"); cart.push(product); localStorage.setItem("cart", JSON.stringify(cart)); toast.success(defaultLang === "ar" ? "تم إضافة المنتج للسلة!" : "Product added to cart!"); }, children: defaultLang === "ar" ? "إضافة للسلة" : "Add to Cart" })] }) }) })); const MessageBubble = ({ message }) => { const isUser = message.role === "user"; return (_jsx("div", { className: `flex mb-4 ${isUser ? "justify-end" : "justify-start"}`, children: _jsxs("div", { className: `flex max-w-[85%] ${isUser ? "flex-row-reverse" : "flex-row"}`, children: [_jsx("div", { className: `w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${isUser ? "bg-blue-500 ml-3" : "bg-gray-200 mr-3"}`, children: isUser ? (_jsx(User, { className: "w-4 h-4 text-white" })) : (_jsx(Bot, { className: "w-4 h-4 text-gray-600" })) }), _jsxs("div", { className: `p-3 rounded-2xl ${isUser ? "bg-blue-500 text-white rounded-br-md" : "bg-gray-100 text-gray-800 rounded-bl-md"}`, children: [typeof message.content === "string" ? (_jsx("p", { className: "text-sm leading-relaxed", children: message.content })) : message.content.products ? (_jsxs("div", { children: [_jsx("p", { className: "text-sm mb-3 font-medium", children: defaultLang === "ar" ? "المنتجات المتاحة:" : "Available products:" }), _jsx("div", { className: "space-y-2", children: message.content.products.map((product, index) => (_jsx(ProductCard, { product: product }, product.id || index))) })] })) : null, _jsx("p", { className: `text-xs mt-2 ${isUser ? "text-blue-100" : "text-gray-500"}`, children: message.timestamp })] })] }) })); }; if (!isOpen) { return (_jsx("div", { className: "fixed bottom-6 right-6 z-50", children: _jsx(Button, { onClick: () => setIsOpen(true), className: "w-16 h-16 rounded-full shadow-xl bg-black hover:bg-gray-800 transition-all duration-300 hover:scale-105 flex items-center justify-center group cursor-pointer", children: _jsxs("div", { className: "flex items-center", children: [_jsx(Sparkles, { className: "text-white" }), _jsx("span", { className: "text-white font-medium text-sm opacity-0 group-hover:opacity-100 transition-opacity absolute -left-20 bg-black px-3 py-1 rounded-full whitespace-nowrap", children: defaultLang === "ar" ? "اسأل الذكاء الاصطناعي" : "Ask AI" })] }) }) })); } const chatClasses = isExpanded ? "fixed inset-0 z-50 animate-in slide-in-from-right-4 duration-300" : "fixed bottom-0 right-0 w-96 h-screen z-50 animate-in slide-in-right-bottom-4 slide-in-from-right-4 duration-300"; return (_jsx("div", { className: chatClasses, children: _jsxs(Card, { className: "h-full shadow-2xl border-0 overflow-hidden bg-white flex flex-col", children: [_jsx(CardHeader, { className: "bg-white border-b p-4 flex-shrink-0", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center space-x-3 rtl:space-x-reverse", children: [_jsx("div", { className: "w-8 h-8 rounded-full bg-red-500 flex items-center justify-center text-white font-bold text-sm", children: "tb" }), _jsxs("div", { children: [_jsx("h3", { className: "font-semibold text-gray-800 text-sm", children: defaultLang === "ar" ? "اسأل Tigerbase" : "Ask Tigerbase" }), _jsx("p", { className: "text-xs text-gray-500", children: defaultLang === "ar" ? "متاح الآن للمساعدة" : "Available now to assist" })] })] }), _jsxs("div", { className: "flex items-center space-x-2 rtl:space-x-reverse", children: [_jsx(Button, { variant: "ghost", size: "icon", onClick: () => setIsExpanded(!isExpanded), className: "text-gray-500 hover:bg-gray-100 w-8 h-8", children: isExpanded ? (_jsx(Minimize2, { className: "w-4 h-4" })) : (_jsx(Maximize2, { className: "w-4 h-4" })) }), _jsx(Button, { variant: "ghost", size: "icon", onClick: () => setIsOpen(false), className: "text-gray-500 hover:bg-gray-100 w-8 h-8", children: _jsx(X, { className: "w-4 h-4" }) })] })] }) }), _jsxs(CardContent, { className: "flex-1 p-0 flex flex-col overflow-hidden", children: [_jsx("div", { className: "flex-1 overflow-hidden", children: _jsx(ScrollArea, { className: "h-full", children: _jsxs("div", { className: "p-4", children: [messages.length === 1 && (_jsxs("div", { className: "text-center py-12", children: [_jsx("div", { className: "w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-purple-500 to-blue-600 rounded-full flex items-center justify-center", children: _jsx(Sparkles, { className: "w-8 h-8 text-white" }) }), _jsx("h3", { className: "text-lg font-semibold text-gray-800 mb-2", children: defaultLang === "ar" ? "اسأل أي شيء عن Tigerbase" : "Ask anything about Tigerbase" }), _jsx("p", { className: "text-sm text-gray-500 max-w-xs mx-auto", children: defaultLang === "ar" ? "يستخدم Tigerbase أحدث البيانات في التوثيق للإجابة على أسئلتك." : "Tigerbase uses the latest data in the documentation to answer your questions." })] })), messages.map((message, index) => (_jsx(MessageBubble, { message: message }, index))), loading && (_jsx("div", { className: "flex justify-start mb-4", children: _jsxs("div", { className: "flex", children: [_jsx("div", { className: "w-8 h-8 rounded-full bg-gray-200 mr-3 flex items-center justify-center", children: _jsx(Bot, { className: "w-4 h-4 text-gray-600" }) }), _jsx("div", { className: "bg-gray-100 p-3 rounded-2xl rounded-bl-md", children: _jsxs("div", { className: "flex space-x-1", children: [_jsx("div", { className: "w-2 h-2 bg-gray-400 rounded-full animate-bounce" }), _jsx("div", { className: "w-2 h-2 bg-gray-400 rounded-full animate-bounce", style: { animationDelay: "0.1s" } }), _jsx("div", { className: "w-2 h-2 bg-gray-400 rounded-full animate-bounce", style: { animationDelay: "0.2s" } })] }) })] }) })), _jsx("div", { ref: messagesEndRef })] }) }) }), messages.length > 1 && (_jsx("div", { className: "border-t bg-gray-50 p-3 flex-shrink-0", children: _jsxs("div", { className: "flex space-x-2 rtl:space-x-reverse mb-0 flex-wrap gap-2", children: [_jsxs(Button, { variant: "outline", size: "sm", onClick: refreshProducts, className: "text-xs h-8 px-3 flex items-center gap-1", disabled: loading, children: [_jsx(RefreshCw, { className: "w-3 h-3" }), defaultLang === "ar" ? "المنتجات" : "Products"] }), _jsxs(Button, { variant: "outline", size: "sm", onClick: showCart, className: "text-xs h-8 px-3 flex items-center gap-1", children: [_jsx(ShoppingCart, { className: "w-3 h-3" }), defaultLang === "ar" ? "السلة" : "Cart"] }), _jsxs(Button, { variant: "outline", size: "sm", onClick: clearChat, className: "text-xs h-8 px-3 flex items-center gap-1", children: [_jsx(Trash2, { className: "w-3 h-3" }), defaultLang === "ar" ? "مسح" : "Clear"] })] }) })), _jsxs("div", { className: "border-t bg-white p-4 flex-shrink-0", children: [_jsxs("div", { className: "flex space-x-2 rtl:space-x-reverse items-center", children: [_jsx(Input, { value: input, onChange: (e) => setInput(e.target.value), onKeyPress: handleKeyPress, placeholder: defaultLang === "ar" ? "اسأل أي شيء" : "Ask anything", className: "flex-1 border-0 bg-gray-100 focus:bg-white transition-colors text-right placeholder:text-gray-500 text-sm", disabled: loading }), _jsx(Button, { onClick: () => handleSend(1), disabled: loading || !input.trim(), size: "icon", className: "bg-gray-300 hover:bg-gray-400 text-gray-600 w-8 h-8 rounded-full shrink-0", children: _jsx(Send, { className: "w-4 h-4" }) })] }), _jsx("div", { className: "mt-3 text-center", children: _jsxs("p", { className: "text-xs text-gray-500", children: [defaultLang === "ar" ? "مدعوم بواسطة" : "Powered by", " ", _jsx("span", { className: "text-red-500 font-semibold", children: "Tigerbase" })] }) })] })] })] }) })); }; export default ChatbotPopup;