add: ai chatbot

This commit is contained in:
hamid zarghami
2025-11-30 16:35:09 +03:30
parent 32e7989ec6
commit 091df757d0
18 changed files with 1775 additions and 1 deletions
+148
View File
@@ -0,0 +1,148 @@
"use client";
import { useEffect, useRef } from "react";
import { useChatbotStore } from "../store/ChatbotStore";
import { ChatMessage } from "../types/Types";
import { timeAgo } from "@/config/func";
import { Copy, TickCircle, Clock } from "iconsax-react";
import { toast } from "@/components/Toast";
import { SHOP_CONFIG } from "@/config/const";
interface MessageBubbleProps {
message: ChatMessage;
}
function MessageBubble({ message }: MessageBubbleProps) {
const isUser = message.type === "user";
const isSystem = message.type === "system";
const handleCopy = () => {
navigator.clipboard.writeText(message.content);
toast("پیام کپی شد", "success");
};
if (isSystem) {
return (
<div className="flex justify-center my-2">
<div className="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 rounded-full text-xs text-gray-600 dark:text-gray-400">
{message.content}
</div>
</div>
);
}
return (
<div
className={`flex gap-2 mb-4 ${isUser ? "flex-row-reverse" : "flex-row"}`}
>
<div
className={`flex flex-col max-w-[80%] ${isUser ? "items-end" : "items-start"
}`}
>
<div
className={`rounded-xl px-3 py-2 ${isUser
? "bg-primary text-white rounded-tr-sm"
: "bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-tl-sm"
}`}
>
<p className="text-xs leading-relaxed whitespace-pre-wrap" dir="rtl">
{message.content}
</p>
</div>
<div
className={`flex items-center gap-2 mt-1 text-xs text-gray-500 ${isUser ? "flex-row-reverse" : "flex-row"
}`}
>
<span>{timeAgo(message.createdAt)}</span>
{isUser && (
<div className="flex items-center">
{message.status === "sent" && (
<Clock className="size-3" color="#6b7280" variant="Bold" />
)}
{message.status === "delivered" && (
<TickCircle className="size-3" color="#6b7280" variant="Bold" />
)}
{message.status === "read" && (
<TickCircle className="size-3" color="#3b82f6" variant="Bold" />
)}
{message.status === "failed" && (
<span className="text-red-500"></span>
)}
</div>
)}
</div>
</div>
{!isUser && (
<button
onClick={handleCopy}
className="self-start mt-1 p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="کپی پیام"
>
<Copy className="size-4" color="#9ca3af" variant="Outline" />
</button>
)}
</div>
);
}
interface StreamingMessageProps {
content: string;
}
function StreamingMessage({ content }: StreamingMessageProps) {
return (
<div className="flex gap-2 mb-3 flex-row">
<div className="flex flex-col max-w-[80%] items-start">
<div className="bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-xl rounded-tl-sm px-3 py-2">
<p className="text-xs leading-relaxed whitespace-pre-wrap" dir="rtl">
{content}
<span className="inline-block w-2 h-4 bg-gray-400 dark:bg-gray-500 animate-pulse ml-1" />
</p>
</div>
<div className="flex items-center gap-2 mt-1 text-xs text-gray-500 flex-row">
<span>در حال تایپ...</span>
</div>
</div>
</div>
);
}
export default function ChatWindow() {
const messagesEndRef = useRef<HTMLDivElement>(null);
const { messages, streamingMessage } = useChatbotStore();
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
}, [messages, streamingMessage]);
if (messages.length === 0 && !streamingMessage.isStreaming) {
return (
<div className="flex-1 flex items-center justify-center p-4">
<div className="text-center">
<p className="text-gray-500 dark:text-gray-400 text-base mb-1">
خوش آمدید! 👋
</p>
<p className="text-gray-400 dark:text-gray-500 text-xs">
سوال خود را در مورد محصولات {SHOP_CONFIG.fullName} بپرسید
</p>
</div>
</div>
);
}
return (
<div className="flex-1 overflow-y-auto p-3 space-y-1.5">
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
{streamingMessage.isStreaming && (
<StreamingMessage content={streamingMessage.content} />
)}
<div ref={messagesEndRef} />
</div>
);
}
@@ -0,0 +1,152 @@
"use client";
import { useEffect, useState } from "react";
import { useChatbotSocket } from "../hooks/useChatbotSocket";
import { useChatbotStore } from "../store/ChatbotStore";
import ChatWindow from "./ChatWindow";
import MessageInput from "./MessageInput";
import SessionList from "./SessionList";
import ConnectionStatus from "./ConnectionStatus";
import { CloseCircle, Menu } from "iconsax-react";
import { Button } from "@/components/ui/button";
interface ChatbotWidgetProps {
isOpen: boolean;
onClose: () => void;
}
export default function ChatbotWidget({ isOpen, onClose }: ChatbotWidgetProps) {
const { connect, disconnect, createSession, joinSession, sendMessage, connectionStatus } =
useChatbotSocket();
const { currentSession } = useChatbotStore();
const [showSidebar, setShowSidebar] = useState(false);
useEffect(() => {
if (isOpen) {
connect().then(() => {
// اگر سشن موجود نبود، به صورت خودکار یک سشن جدید ایجاد کن
const { currentSession } = useChatbotStore.getState();
if (!currentSession) {
createSession();
}
});
// جلوگیری از scroll صفحه هنگام باز بودن widget
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "unset";
}
return () => {
if (!isOpen) {
disconnect();
document.body.style.overflow = "unset";
}
};
}, [isOpen, connect, disconnect, createSession]);
const handleCreateSession = () => {
createSession();
};
const handleSessionSelect = (sessionId: string) => {
joinSession(sessionId);
if (window.innerWidth < 768) {
setShowSidebar(false);
}
};
const handleSendMessage = (content: string) => {
sendMessage(content, true);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[9999] pointer-events-none">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/30 backdrop-blur-sm transition-opacity pointer-events-auto"
onClick={onClose}
/>
{/* Chat Widget - Positioned above icon (bottom-right) */}
<div className="absolute bottom-32 right-4 md:bottom-28 md:right-8 w-[360px] md:w-[400px] h-[480px] md:h-[520px] bg-white dark:bg-gray-900 rounded-2xl shadow-2xl flex flex-col overflow-hidden animate-slide-up pointer-events-auto">
{/* Header */}
<div className="border-b border-gray-200 dark:border-gray-800 p-3 flex items-center justify-between shrink-0">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="md:hidden h-8 w-8"
onClick={() => setShowSidebar(!showSidebar)}
>
<Menu className="size-4" color="currentColor" variant="Bold" />
</Button>
<div>
<h1 className="text-base font-semibold text-gray-900 dark:text-gray-100">
چتبات راهنما
</h1>
</div>
</div>
<div className="flex items-center gap-2">
<ConnectionStatus status={connectionStatus} />
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="shrink-0 h-8 w-8"
>
<CloseCircle className="size-4" color="currentColor" variant="Bold" />
</Button>
</div>
</div>
{/* Content */}
<div className="flex-1 flex overflow-hidden">
{/* Sidebar - Desktop */}
{showSidebar && (
<div className="hidden md:block">
<SessionList
onSessionSelect={handleSessionSelect}
onCreateSession={handleCreateSession}
/>
</div>
)}
{/* Mobile Sidebar Overlay */}
{showSidebar && (
<div className="md:hidden absolute inset-0 z-50">
<div
className="absolute inset-0 bg-black/50"
onClick={() => setShowSidebar(false)}
/>
<div className="absolute right-0 top-0 bottom-0 w-64">
<SessionList
onSessionSelect={handleSessionSelect}
onCreateSession={handleCreateSession}
/>
</div>
</div>
)}
{/* Main Chat Area */}
<div className="flex-1 flex flex-col bg-white dark:bg-gray-900">
{/* Chat Window */}
<ChatWindow />
{/* Message Input */}
<MessageInput
onSend={handleSendMessage}
disabled={connectionStatus !== "connected"}
placeholder={
connectionStatus !== "connected"
? "در حال اتصال..."
: "پیام خود را بنویسید..."
}
/>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,95 @@
"use client";
import { ConnectionStatus as ConnectionStatusType } from "../types/Types";
import { TickCircle, CloseCircle } from "iconsax-react";
import { ComponentType } from "react";
interface ConnectionStatusProps {
status: ConnectionStatusType;
}
type StatusConfig = {
label: string;
color: string;
iconColor: string;
bgColor: string;
showIcon: boolean;
icon?: ComponentType<{
className?: string;
color?: string;
variant?: "Linear" | "Outline" | "Broken" | "Bold" | "Bulk" | "TwoTone";
}>;
};
const statusConfig: Record<ConnectionStatusType, StatusConfig> = {
connected: {
label: "متصل",
showIcon: true,
icon: TickCircle as ComponentType<{
className?: string;
color?: string;
variant?: "Linear" | "Outline" | "Broken" | "Bold" | "Bulk" | "TwoTone";
}>,
color: "text-green-500",
iconColor: "#10b981",
bgColor: "bg-green-100 dark:bg-green-900/20",
},
disconnected: {
label: "قطع شده",
showIcon: true,
icon: CloseCircle as ComponentType<{
className?: string;
color?: string;
variant?: "Linear" | "Outline" | "Broken" | "Bold" | "Bulk" | "TwoTone";
}>,
color: "text-gray-500",
iconColor: "#6b7280",
bgColor: "bg-gray-100 dark:bg-gray-900/20",
},
connecting: {
label: "در حال اتصال...",
showIcon: false,
color: "text-blue-500",
iconColor: "#3b82f6",
bgColor: "bg-blue-100 dark:bg-blue-900/20",
},
reconnecting: {
label: "در حال اتصال مجدد...",
showIcon: false,
color: "text-yellow-500",
iconColor: "#eab308",
bgColor: "bg-yellow-100 dark:bg-yellow-900/20",
},
error: {
label: "خطا در اتصال",
showIcon: true,
icon: CloseCircle as ComponentType<{
className?: string;
color?: string;
variant?: "Linear" | "Outline" | "Broken" | "Bold" | "Bulk" | "TwoTone";
}>,
color: "text-red-500",
iconColor: "#ef4444",
bgColor: "bg-red-100 dark:bg-red-900/20",
},
};
export default function ConnectionStatus({ status }: ConnectionStatusProps) {
const config = statusConfig[status];
const isSpinning = status === "connecting" || status === "reconnecting";
const IconComponent = config.showIcon ? config.icon : null;
return (
<div
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs ${config.bgColor} ${config.color}`}
>
{isSpinning ? (
<div className="size-4 border-2 border-current/30 border-t-current rounded-full animate-spin" />
) : IconComponent ? (
<IconComponent className="size-4" color={config.iconColor} variant="Bold" />
) : null}
<span>{config.label}</span>
</div>
);
}
@@ -0,0 +1,31 @@
"use client";
import { useState } from "react";
import { MessageQuestion } from "iconsax-react";
import ChatbotWidget from "./ChatbotWidget";
export default function FloatingChatButton() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button
onClick={() => setIsOpen(true)}
className="fixed bottom-24 md:bottom-8 right-4 md:right-8 z-[9998] w-12 h-12 md:w-14 md:h-14 bg-primary hover:bg-primary/90 text-white rounded-full shadow-lg hover:shadow-xl transition-all duration-300 flex items-center justify-center group animate-bounce-subtle hover:scale-110 active:scale-95"
aria-label="باز کردن چت‌بات"
>
<MessageQuestion
className="size-5 md:size-6 group-hover:scale-110 group-hover:rotate-12 transition-all duration-300"
color="#ffffff"
variant="Bold"
/>
{/* Pulse animation */}
<span className="absolute inset-0 rounded-full bg-primary animate-ping opacity-20" />
{/* Ripple effect */}
<span className="absolute inset-0 rounded-full bg-primary/40 animate-pulse" />
</button>
<ChatbotWidget isOpen={isOpen} onClose={() => setIsOpen(false)} />
</>
);
}
+116
View File
@@ -0,0 +1,116 @@
"use client";
import { useState, useRef, useEffect, KeyboardEvent } from "react";
import { Send2 } from "iconsax-react";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
interface MessageInputProps {
onSend: (message: string) => void;
disabled?: boolean;
placeholder?: string;
maxLength?: number;
}
export default function MessageInput({
onSend,
disabled = false,
placeholder = "پیام خود را بنویسید...",
maxLength = 2000,
}: MessageInputProps) {
const [message, setMessage] = useState("");
const [isTyping, setIsTyping] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
// Auto-resize textarea
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
}
}, [message]);
const handleSend = () => {
const trimmedMessage = message.trim();
if (trimmedMessage && !disabled) {
onSend(trimmedMessage);
setMessage("");
setIsTyping(false);
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
}
}
};
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleChange = (value: string) => {
if (value.length <= maxLength) {
setMessage(value);
setIsTyping(true);
// Clear previous timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
// Set new timeout to stop typing indicator after 3 seconds
typingTimeoutRef.current = setTimeout(() => {
setIsTyping(false);
}, 3000);
}
};
useEffect(() => {
return () => {
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
};
}, []);
const remainingChars = maxLength - message.length;
const canSend = message.trim().length > 0 && !disabled;
return (
<div className="border-t border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-3">
<div className="flex items-end gap-2">
<div className="flex-1 relative">
<Textarea
ref={textareaRef}
value={message}
onChange={(e) => handleChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
rows={1}
className="min-h-[40px] max-h-[100px] resize-none text-sm"
dir="rtl"
/>
{message.length > 0 && (
<div className="absolute bottom-2 left-2 text-xs text-gray-400">
{remainingChars}
</div>
)}
</div>
<Button
onClick={handleSend}
disabled={!canSend || disabled}
size="icon"
className="h-10 w-10 shrink-0"
>
<Send2 className="size-4 -rotate-90" color="currentColor" variant="Bold" />
</Button>
</div>
{isTyping && !disabled && (
<div className="mt-1.5 text-xs text-gray-500">در حال تایپ...</div>
)}
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
"use client";
import { useChatbotStore } from "../store/ChatbotStore";
import { ChatSession } from "../types/Types";
import { timeAgo } from "@/config/func";
import { Add, Message2 } from "iconsax-react";
import { Button } from "@/components/ui/button";
interface SessionListProps {
onSessionSelect: (sessionId: string) => void;
onCreateSession: () => void;
}
export default function SessionList({
onSessionSelect,
onCreateSession,
}: SessionListProps) {
const { sessions, currentSession } = useChatbotStore();
const getLastMessagePreview = (session: ChatSession): string => {
if (session.messages.length === 0) {
return "هیچ پیامی وجود ندارد";
}
const lastMessage = session.messages[session.messages.length - 1];
const preview = lastMessage.content.substring(0, 50);
return preview.length < lastMessage.content.length
? `${preview}...`
: preview;
};
return (
<div className="w-full md:w-80 border-l border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 flex flex-col h-full">
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
<Button
onClick={onCreateSession}
className="w-full"
variant="default"
>
<Add className="size-5" color="currentColor" variant="Bold" />
<span>سشن جدید</span>
</Button>
</div>
<div className="flex-1 overflow-y-auto">
{sessions.length === 0 ? (
<div className="p-4 text-center text-gray-500 dark:text-gray-400">
<Message2 className="size-12 mx-auto mb-2 opacity-50" color="#6b7280" variant="Bold" />
<p className="text-sm">هیچ سشنی وجود ندارد</p>
</div>
) : (
<div className="divide-y divide-gray-200 dark:divide-gray-800">
{sessions.map((session) => (
<SessionItem
key={session.id}
session={session}
isActive={currentSession?.id === session.id}
onSelect={() => onSessionSelect(session.id)}
lastMessagePreview={getLastMessagePreview(session)}
/>
))}
</div>
)}
</div>
</div>
);
}
interface SessionItemProps {
session: ChatSession;
isActive: boolean;
onSelect: () => void;
lastMessagePreview: string;
}
function SessionItem({
session,
isActive,
onSelect,
lastMessagePreview,
}: SessionItemProps) {
return (
<button
onClick={onSelect}
className={`w-full p-4 text-right hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors ${isActive ? "bg-primary/10 dark:bg-primary/20" : ""
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs text-gray-500 dark:text-gray-400">
{timeAgo(session.lastMessageAt)}
</span>
{session.status === "closed" && (
<span className="text-xs px-2 py-0.5 bg-gray-200 dark:bg-gray-700 rounded-full">
بسته شده
</span>
)}
</div>
<p className="text-sm text-gray-700 dark:text-gray-300 line-clamp-2">
{lastMessagePreview}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{session.messages.length} پیام
</p>
</div>
</div>
</button>
);
}
+245
View File
@@ -0,0 +1,245 @@
"use client";
import { useEffect, useCallback } from "react";
import { chatbotSocketService } from "../service/SocketService";
import { useChatbotStore } from "../store/ChatbotStore";
import {
SessionCreatedResponse,
ChatJoinedResponse,
MessageReceivedResponse,
BotResponse,
BotResponseStart,
BotResponseChunk,
BotResponseEnd,
ErrorResponse,
} from "../types/Types";
import { toast } from "@/components/Toast";
export function useChatbotSocket() {
const {
setConnectionStatus,
setCurrentSession,
addMessage,
setMessages,
startStreaming,
appendStreamChunk,
endStreaming,
addSession,
setError,
} = useChatbotStore();
const connect = useCallback(async () => {
try {
await chatbotSocketService.connect();
} catch (error) {
console.error("خطا در اتصال:", error);
toast("خطا در اتصال به سرور", "error");
}
}, []);
const disconnect = useCallback(() => {
chatbotSocketService.disconnect();
}, []);
const createSession = useCallback(() => {
if (chatbotSocketService.isConnected()) {
chatbotSocketService.createSession();
} else {
toast("ابتدا به سرور متصل شوید", "error");
}
}, []);
const joinSession = useCallback((sessionId: string) => {
if (chatbotSocketService.isConnected()) {
chatbotSocketService.joinChat(sessionId);
} else {
toast("ابتدا به سرور متصل شوید", "error");
}
}, []);
const sendMessage = useCallback(
(content: string, useStreaming: boolean = true) => {
const { currentSession } = useChatbotStore.getState();
if (!currentSession) {
toast("لطفا ابتدا یک سشن ایجاد کنید", "error");
return;
}
if (chatbotSocketService.isConnected()) {
const payload = {
content,
sessionId: currentSession.id,
};
if (useStreaming) {
chatbotSocketService.sendMessageStream(payload);
} else {
chatbotSocketService.sendMessage(payload);
}
} else {
toast("ابتدا به سرور متصل شوید", "error");
}
},
[]
);
useEffect(() => {
// Connection status listener
const handleConnectionStatus = (status: unknown) => {
setConnectionStatus(
status as
| "disconnected"
| "connecting"
| "connected"
| "reconnecting"
| "error"
);
};
// Session created
const handleSessionCreated = (data: unknown) => {
const response = data as SessionCreatedResponse;
if (response.status === "success") {
setCurrentSession(response.data);
addSession(response.data);
setMessages(response.data.messages);
// toast را حذف کردیم چون به صورت خودکار ایجاد می‌شود
}
};
// Chat joined
const handleChatJoined = (data: unknown) => {
const response = data as ChatJoinedResponse;
if (response.status === "success") {
setCurrentSession(response.data);
setMessages(response.data.messages);
}
};
// Message received
const handleMessageReceived = (data: unknown) => {
const response = data as MessageReceivedResponse;
if (response.status === "success") {
addMessage(response.data);
}
};
// Bot response (non-streaming)
const handleBotResponse = (data: unknown) => {
const response = data as BotResponse;
if (response.status === "success") {
// بررسی اینکه آیا message موقت از streaming وجود دارد
const { messages } = useChatbotStore.getState();
const tempMessageIndex = messages.findIndex(
(msg) =>
msg.id.startsWith("streaming-") &&
msg.responseToId === response.data.responseToId
);
if (tempMessageIndex !== -1) {
// جایگزینی message موقت با message واقعی
useChatbotStore
.getState()
.updateMessage(messages[tempMessageIndex].id, response.data);
} else {
// اگر message موقت پیدا نشد، message جدید اضافه کن
addMessage(response.data);
}
}
};
// Bot response start (streaming)
const handleBotResponseStart = (data: unknown) => {
const response = data as BotResponseStart;
if (response.status === "success") {
startStreaming(response.data.userMessageId);
}
};
// Bot response chunk (streaming)
const handleBotResponseChunk = (data: unknown) => {
const response = data as BotResponseChunk;
if (response.status === "success") {
appendStreamChunk(response.data.chunk);
}
};
// Bot response end (streaming)
const handleBotResponseEnd = (data: unknown) => {
const response = data as BotResponseEnd;
if (response.status === "success") {
endStreaming(response.data.fullResponse);
}
};
// Error handlers
const handleError = (data: unknown) => {
const error = data as ErrorResponse;
setError(error.message);
toast(error.message, "error");
};
const handleSessionError = (data: unknown) => {
const error = data as ErrorResponse;
toast(`خطای سشن: ${error.message}`, "error");
};
const handleMessageError = (data: unknown) => {
const error = data as ErrorResponse;
toast(`خطای پیام: ${error.message}`, "error");
};
// Register listeners
chatbotSocketService.on(
"connection_status_changed",
handleConnectionStatus
);
chatbotSocketService.on("session_created", handleSessionCreated);
chatbotSocketService.on("chat_joined", handleChatJoined);
chatbotSocketService.on("message_received", handleMessageReceived);
chatbotSocketService.on("bot_response", handleBotResponse);
chatbotSocketService.on("bot_response_start", handleBotResponseStart);
chatbotSocketService.on("bot_response_chunk", handleBotResponseChunk);
chatbotSocketService.on("bot_response_end", handleBotResponseEnd);
chatbotSocketService.on("error", handleError);
chatbotSocketService.on("session_error", handleSessionError);
chatbotSocketService.on("message_error", handleMessageError);
// Cleanup
return () => {
chatbotSocketService.off(
"connection_status_changed",
handleConnectionStatus
);
chatbotSocketService.off("session_created", handleSessionCreated);
chatbotSocketService.off("chat_joined", handleChatJoined);
chatbotSocketService.off("message_received", handleMessageReceived);
chatbotSocketService.off("bot_response", handleBotResponse);
chatbotSocketService.off("bot_response_start", handleBotResponseStart);
chatbotSocketService.off("bot_response_chunk", handleBotResponseChunk);
chatbotSocketService.off("bot_response_end", handleBotResponseEnd);
chatbotSocketService.off("error", handleError);
chatbotSocketService.off("session_error", handleSessionError);
chatbotSocketService.off("message_error", handleMessageError);
};
}, [
setConnectionStatus,
setCurrentSession,
addMessage,
setMessages,
startStreaming,
appendStreamChunk,
endStreaming,
addSession,
setError,
]);
return {
connect,
disconnect,
createSession,
joinSession,
sendMessage,
isConnected: chatbotSocketService.isConnected(),
connectionStatus: chatbotSocketService.getConnectionStatus(),
};
}
+116
View File
@@ -0,0 +1,116 @@
"use client";
import { useEffect, useState } from "react";
import { useChatbotSocket } from "./hooks/useChatbotSocket";
import { useChatbotStore } from "./store/ChatbotStore";
import ChatWindow from "./components/ChatWindow";
import MessageInput from "./components/MessageInput";
import SessionList from "./components/SessionList";
import ConnectionStatus from "./components/ConnectionStatus";
import { Menu } from "iconsax-react";
import { Button } from "@/components/ui/button";
export default function ChatbotPage() {
const { connect, disconnect, createSession, joinSession, sendMessage, connectionStatus } =
useChatbotSocket();
const { currentSession } = useChatbotStore();
const [showSidebar, setShowSidebar] = useState(true);
useEffect(() => {
// اتصال خودکار هنگام mount
connect();
// Cleanup هنگام unmount
return () => {
disconnect();
};
}, [connect, disconnect]);
const handleCreateSession = () => {
createSession();
};
const handleSessionSelect = (sessionId: string) => {
joinSession(sessionId);
// در موبایل، بعد از انتخاب سشن، sidebar را ببند
if (window.innerWidth < 768) {
setShowSidebar(false);
}
};
const handleSendMessage = (content: string) => {
sendMessage(content, true);
};
return (
<div className="flex h-screen bg-gray-50 dark:bg-gray-950">
{/* Sidebar - Desktop always visible, Mobile toggleable */}
{showSidebar && (
<div className="hidden md:block">
<SessionList
onSessionSelect={handleSessionSelect}
onCreateSession={handleCreateSession}
/>
</div>
)}
{/* Mobile Sidebar Overlay */}
{showSidebar && (
<div className="md:hidden fixed inset-0 z-50">
<div
className="absolute inset-0 bg-black/50"
onClick={() => setShowSidebar(false)}
/>
<div className="absolute right-0 top-0 bottom-0 w-80">
<SessionList
onSessionSelect={handleSessionSelect}
onCreateSession={handleCreateSession}
/>
</div>
</div>
)}
{/* Main Chat Area */}
<div className="flex-1 flex flex-col bg-white dark:bg-gray-900">
{/* Header */}
<div className="border-b border-gray-200 dark:border-gray-800 p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
className="md:hidden"
onClick={() => setShowSidebar(!showSidebar)}
>
<Menu className="size-5" color="currentColor" variant="Bold" />
</Button>
<div>
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
چتبات راهنما
</h1>
<p className="text-xs text-gray-500 dark:text-gray-400">
سوالات خود را در مورد محصولات تابلو برق بپرسید
</p>
</div>
</div>
<ConnectionStatus status={connectionStatus} />
</div>
{/* Chat Window */}
<ChatWindow />
{/* Message Input */}
<MessageInput
onSend={handleSendMessage}
disabled={!currentSession || connectionStatus !== "connected"}
placeholder={
!currentSession
? "ابتدا یک سشن ایجاد کنید..."
: connectionStatus !== "connected"
? "در حال اتصال..."
: "پیام خود را بنویسید..."
}
/>
</div>
</div>
);
}
+377
View File
@@ -0,0 +1,377 @@
import { io, Socket } from "socket.io-client";
import { BASE_URL } from "@/config/const";
import { getChatbotSessionId, setChatbotSessionId } from "../utils/cookies";
import { generateULID } from "../utils/ulid";
import {
ChatSession,
SessionCreatedResponse,
ChatJoinedResponse,
MessageReceivedResponse,
BotResponse,
BotResponseStart,
BotResponseChunk,
BotResponseEnd,
ErrorResponse,
AuthenticatedResponse,
UnauthorizedResponse,
SendMessagePayload,
ConnectionStatus,
} from "../types/Types";
class ChatbotSocketService {
private socket: Socket | null = null;
private eventListeners: { [key: string]: ((data: unknown) => void)[] } = {};
private connectionStatus: ConnectionStatus = "disconnected";
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private reconnectDelay = 1000;
constructor() {
this.initializeEventListeners();
}
private initializeEventListeners(): void {
this.eventListeners = {
connect: [],
disconnect: [],
connection_failed: [],
authenticated: [],
unauthorized: [],
session_created: [],
chat_joined: [],
chat_left: [],
message_received: [],
bot_response: [],
bot_response_start: [],
bot_response_chunk: [],
bot_response_end: [],
typing_start: [],
typing_stop: [],
user_joined: [],
user_left: [],
error: [],
websocket_error: [],
session_error: [],
message_error: [],
connection_status_changed: [],
};
}
connect(token?: string): Promise<void> {
return new Promise((resolve, reject) => {
try {
this.connectionStatus = "connecting";
this.emit("connection_status_changed", this.connectionStatus);
// اگر session ID وجود ندارد، یکی بساز
let sessionId = getChatbotSessionId();
if (!sessionId) {
sessionId = generateULID();
setChatbotSessionId(sessionId);
}
const connectionOptions: {
path: string;
query?: Record<string, string>;
transports: string[];
timeout: number;
reconnection: boolean;
reconnectionAttempts: number;
reconnectionDelay: number;
reconnectionDelayMax: number;
} = {
path: "/ws-chatbot",
transports: ["websocket"],
timeout: 10000,
reconnection: true,
reconnectionAttempts: this.maxReconnectAttempts,
reconnectionDelay: this.reconnectDelay,
reconnectionDelayMax: 5000,
};
// اگر token وجود دارد، از آن استفاده کن
if (token) {
connectionOptions.query = { token };
}
console.log("🔌 اتصال به chatbot socket:", `${BASE_URL}/ws-chatbot`);
this.socket = io(BASE_URL, connectionOptions);
this.setupSocketListeners();
// مدیریت اتصال موفق
this.socket.on("connect", () => {
console.log("✅ اتصال chatbot socket برقرار شد:", this.socket?.id);
this.connectionStatus = "connected";
this.reconnectAttempts = 0;
this.emit("connection_status_changed", this.connectionStatus);
this.emit("connect", null);
resolve();
});
// مدیریت خطای اتصال
this.socket.on("connect_error", (err) => {
console.error("❌ خطا در اتصال chatbot socket:", err.message);
this.connectionStatus = "error";
this.emit("connection_status_changed", this.connectionStatus);
this.emit("connection_failed", err);
reject(err);
});
// مدیریت قطع اتصال
this.socket.on("disconnect", (reason) => {
console.log("❌ اتصال chatbot socket قطع شد:", reason);
if (reason === "io server disconnect") {
this.connectionStatus = "disconnected";
} else {
this.connectionStatus = "reconnecting";
}
this.emit("connection_status_changed", this.connectionStatus);
this.emit("disconnect", reason);
});
// مدیریت reconnect
this.socket.io.on("reconnect_attempt", () => {
this.reconnectAttempts++;
this.connectionStatus = "reconnecting";
this.emit("connection_status_changed", this.connectionStatus);
console.log(
`🔄 تلاش برای اتصال مجدد (${this.reconnectAttempts}/${this.maxReconnectAttempts})`
);
});
this.socket.io.on("reconnect", () => {
console.log("✅ اتصال مجدد برقرار شد");
this.connectionStatus = "connected";
this.reconnectAttempts = 0;
this.emit("connection_status_changed", this.connectionStatus);
});
this.socket.io.on("reconnect_failed", () => {
console.error("❌ اتصال مجدد ناموفق");
this.connectionStatus = "error";
this.emit("connection_status_changed", this.connectionStatus);
});
} catch (error) {
console.error("خطا در ایجاد اتصال chatbot socket:", error);
this.connectionStatus = "error";
this.emit("connection_status_changed", this.connectionStatus);
reject(error);
}
});
}
private setupSocketListeners(): void {
if (!this.socket) return;
// Authentication events
this.socket.on("authenticated", (data: AuthenticatedResponse) => {
console.log("✅ احراز هویت موفق:", data);
this.emit("authenticated", data);
});
this.socket.on("unauthorized", (data: UnauthorizedResponse) => {
console.error("❌ احراز هویت ناموفق:", data);
this.emit("unauthorized", data);
});
// Session events
this.socket.on("session_created", (data: SessionCreatedResponse) => {
console.log("✅ سشن ایجاد شد:", data);
this.emit("session_created", data);
});
this.socket.on("chat_joined", (data: ChatJoinedResponse) => {
console.log("✅ به چت پیوست:", data);
this.emit("chat_joined", data);
});
this.socket.on("chat_left", () => {
console.log("✅ از چت خارج شد");
this.emit("chat_left", null);
});
// Message events
this.socket.on("message_received", (data: MessageReceivedResponse) => {
console.log("💬 پیام دریافت شد:", data);
this.emit("message_received", data);
});
// Bot response events (non-streaming)
this.socket.on("bot_response", (data: BotResponse) => {
console.log("🤖 پاسخ ربات:", data);
this.emit("bot_response", data);
});
// Bot response events (streaming)
this.socket.on("bot_response_start", (data: BotResponseStart) => {
console.log("🤖 شروع پاسخ ربات:", data);
this.emit("bot_response_start", data);
});
this.socket.on("bot_response_chunk", (data: BotResponseChunk) => {
this.emit("bot_response_chunk", data);
});
this.socket.on("bot_response_end", (data: BotResponseEnd) => {
console.log("🤖 پایان پاسخ ربات:", data);
this.emit("bot_response_end", data);
});
// Typing events
this.socket.on(
"typing_start",
(data: { sessionId: string; userId?: string }) => {
this.emit("typing_start", data);
}
);
this.socket.on(
"typing_stop",
(data: { sessionId: string; userId?: string }) => {
this.emit("typing_stop", data);
}
);
// User presence events
this.socket.on(
"user_joined",
(data: { sessionId: string; userId: string }) => {
console.log("👤 کاربر پیوست:", data);
this.emit("user_joined", data);
}
);
this.socket.on(
"user_left",
(data: { sessionId: string; userId: string }) => {
console.log("👤 کاربر خارج شد:", data);
this.emit("user_left", data);
}
);
// Error events
this.socket.on("error", (data: ErrorResponse) => {
console.error("⚠️ خطای عمومی:", data);
this.emit("error", data);
});
this.socket.on("websocket_error", (data: ErrorResponse) => {
console.error("⚠️ خطای WebSocket:", data);
this.emit("websocket_error", data);
});
this.socket.on("session_error", (data: ErrorResponse) => {
console.error("⚠️ خطای سشن:", data);
this.emit("session_error", data);
});
this.socket.on("message_error", (data: ErrorResponse) => {
console.error("⚠️ خطای پیام:", data);
this.emit("message_error", data);
});
}
// Session management
createSession(): void {
if (this.socket && this.socket.connected) {
console.log("📝 ایجاد سشن جدید");
this.socket.emit("create_session");
} else {
console.error("سوکت متصل نیست - نمی‌توان سشن ایجاد کرد");
}
}
joinChat(sessionId: string): void {
if (this.socket && this.socket.connected) {
console.log("🔗 ورود به چت:", sessionId);
this.socket.emit("join_chat", sessionId);
} else {
console.error("سوکت متصل نیست - نمی‌توان به چت وارد شد");
}
}
leaveChat(sessionId: string): void {
if (this.socket && this.socket.connected) {
console.log("🚪 خروج از چت:", sessionId);
this.socket.emit("leave_chat", sessionId);
}
}
// Message sending
sendMessage(payload: SendMessagePayload): void {
if (this.socket && this.socket.connected) {
console.log("📤 ارسال پیام:", payload);
this.socket.emit("send_message", payload);
} else {
console.error("سوکت متصل نیست - نمی‌توان پیام ارسال کرد");
}
}
sendMessageStream(payload: SendMessagePayload): void {
if (this.socket && this.socket.connected) {
console.log("📤 ارسال پیام با streaming:", payload);
this.socket.emit("send_message_stream", payload);
} else {
console.error("سوکت متصل نیست - نمی‌توان پیام ارسال کرد");
}
}
// Typing indicators
typingStart(sessionId: string): void {
if (this.socket && this.socket.connected) {
this.socket.emit("typing_start", sessionId);
}
}
typingStop(sessionId: string): void {
if (this.socket && this.socket.connected) {
this.socket.emit("typing_stop", sessionId);
}
}
// Connection management
disconnect(): void {
if (this.socket) {
console.log("🔌 قطع اتصال chatbot socket");
this.socket.disconnect();
this.socket = null;
this.connectionStatus = "disconnected";
this.emit("connection_status_changed", this.connectionStatus);
}
}
isConnected(): boolean {
return this.socket?.connected || false;
}
getConnectionStatus(): ConnectionStatus {
return this.connectionStatus;
}
// Event system
on(event: string, callback: (data: unknown) => void): void {
if (!this.eventListeners[event]) {
this.eventListeners[event] = [];
}
this.eventListeners[event].push(callback);
}
off(event: string, callback: (data: unknown) => void): void {
if (this.eventListeners[event]) {
this.eventListeners[event] = this.eventListeners[event].filter(
(cb) => cb !== callback
);
}
}
private emit(event: string, data: unknown): void {
if (this.eventListeners[event]) {
this.eventListeners[event].forEach((callback) => callback(data));
}
}
}
// ایجاد instance singleton
export const chatbotSocketService = new ChatbotSocketService();
+166
View File
@@ -0,0 +1,166 @@
import { create } from "zustand";
import {
ChatSession,
ChatMessage,
ConnectionStatus,
SessionCreatedResponse,
ChatJoinedResponse,
MessageReceivedResponse,
BotResponse,
BotResponseStart,
BotResponseChunk,
BotResponseEnd,
ErrorResponse,
} from "../types/Types";
interface ChatbotState {
// Connection
connectionStatus: ConnectionStatus;
setConnectionStatus: (status: ConnectionStatus) => void;
// Current session
currentSession: ChatSession | null;
setCurrentSession: (session: ChatSession | null) => void;
// Messages
messages: ChatMessage[];
addMessage: (message: ChatMessage) => void;
updateMessage: (messageId: string, updates: Partial<ChatMessage>) => void;
setMessages: (messages: ChatMessage[]) => void;
clearMessages: () => void;
// Streaming message
streamingMessage: {
userMessageId: string | null;
content: string;
isStreaming: boolean;
};
startStreaming: (userMessageId: string) => void;
appendStreamChunk: (chunk: string) => void;
endStreaming: (fullResponse: string) => void;
clearStreaming: () => void;
// Sessions list
sessions: ChatSession[];
setSessions: (sessions: ChatSession[]) => void;
addSession: (session: ChatSession) => void;
updateSession: (sessionId: string, updates: Partial<ChatSession>) => void;
// Typing indicators
typingUsers: Set<string>;
setTyping: (sessionId: string, isTyping: boolean) => void;
clearTyping: () => void;
// Errors
error: string | null;
setError: (error: string | null) => void;
}
export const useChatbotStore = create<ChatbotState>((set, get) => ({
// Connection
connectionStatus: "disconnected",
setConnectionStatus: (status) => set({ connectionStatus: status }),
// Current session
currentSession: null,
setCurrentSession: (session) => set({ currentSession: session }),
// Messages
messages: [],
addMessage: (message) =>
set((state) => ({
messages: [...state.messages, message],
})),
updateMessage: (messageId, updates) =>
set((state) => ({
messages: state.messages.map((msg) =>
msg.id === messageId ? { ...msg, ...updates } : msg
),
})),
setMessages: (messages) => set({ messages }),
clearMessages: () => set({ messages: [] }),
// Streaming message
streamingMessage: {
userMessageId: null,
content: "",
isStreaming: false,
},
startStreaming: (userMessageId) =>
set({
streamingMessage: {
userMessageId,
content: "",
isStreaming: true,
},
}),
appendStreamChunk: (chunk) =>
set((state) => ({
streamingMessage: {
...state.streamingMessage,
content: state.streamingMessage.content + chunk,
},
})),
endStreaming: (fullResponse) => {
const state = get();
// تبدیل streaming message به message معمولی
if (state.streamingMessage.userMessageId) {
const botMessage: ChatMessage = {
id: `temp-${Date.now()}`,
content: fullResponse,
type: "bot",
status: "sent",
createdAt: new Date().toISOString(),
responseToId: state.streamingMessage.userMessageId,
};
set((s) => ({
messages: [...s.messages, botMessage],
streamingMessage: {
userMessageId: null,
content: "",
isStreaming: false,
},
}));
}
},
clearStreaming: () =>
set({
streamingMessage: {
userMessageId: null,
content: "",
isStreaming: false,
},
}),
// Sessions list
sessions: [],
setSessions: (sessions) => set({ sessions }),
addSession: (session) =>
set((state) => ({
sessions: [session, ...state.sessions],
})),
updateSession: (sessionId, updates) =>
set((state) => ({
sessions: state.sessions.map((session) =>
session.id === sessionId ? { ...session, ...updates } : session
),
})),
// Typing indicators
typingUsers: new Set(),
setTyping: (sessionId, isTyping) =>
set((state) => {
const newSet = new Set(state.typingUsers);
if (isTyping) {
newSet.add(sessionId);
} else {
newSet.delete(sessionId);
}
return { typingUsers: newSet };
}),
clearTyping: () => set({ typingUsers: new Set() }),
// Errors
error: null,
setError: (error) => set({ error }),
}));
+116
View File
@@ -0,0 +1,116 @@
export type MessageType = "user" | "bot" | "system";
export type MessageStatus = "sent" | "delivered" | "read" | "failed";
export type SessionStatus = "active" | "closed";
export interface ChatMessage {
id: string;
content: string;
type: MessageType;
status: MessageStatus;
createdAt: string;
responseToId?: string | null;
metadata?: Record<string, unknown>;
tokensUsed?: number;
}
export interface ChatSession {
id: string;
status: SessionStatus;
createdAt: string;
lastMessageAt: string;
messages: ChatMessage[];
}
export interface SessionCreatedResponse {
status: "success";
data: ChatSession;
timestamp: string;
}
export interface ChatJoinedResponse {
status: "success";
data: ChatSession;
timestamp: string;
}
export interface MessageReceivedResponse {
status: "success";
data: ChatMessage;
timestamp: string;
}
export interface BotResponse {
status: "success";
data: {
id: string;
content: string;
type: "bot";
status: MessageStatus;
createdAt: string;
responseToId: string;
metadata?: {
confidence?: number;
sources?: unknown[];
llmContext?: Record<string, unknown>;
};
tokensUsed: number;
};
timestamp: string;
}
export interface BotResponseStart {
status: "success";
data: {
userMessageId: string;
};
timestamp: string;
}
export interface BotResponseChunk {
status: "success";
data: {
chunk: string;
userMessageId: string;
};
timestamp: string;
}
export interface BotResponseEnd {
status: "success";
data: {
userMessageId: string;
fullResponse: string;
};
timestamp: string;
}
export interface ErrorResponse {
status: "error";
message: string;
timestamp: string;
}
export interface AuthenticatedResponse {
message: "authenticated";
userId?: string;
timestamp: string;
}
export interface UnauthorizedResponse {
message: string;
timestamp: string;
}
export interface SendMessagePayload {
content: string;
sessionId: string;
responseToId?: string | null;
metadata?: Record<string, unknown>;
}
export type ConnectionStatus =
| "disconnected"
| "connecting"
| "connected"
| "reconnecting"
| "error";
+35
View File
@@ -0,0 +1,35 @@
const CHATBOT_SESSION_ID_COOKIE = "chatbot_session_id";
export const getCookie = (name: string): string | null => {
if (typeof document === "undefined") return null;
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) {
return parts.pop()?.split(";").shift() || null;
}
return null;
};
export const setCookie = (
name: string,
value: string,
days: number = 365
): void => {
if (typeof document === "undefined") return;
const date = new Date();
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
const expires = `expires=${date.toUTCString()}`;
document.cookie = `${name}=${value};${expires};path=/;SameSite=Lax`;
};
export const getChatbotSessionId = (): string | null => {
return getCookie(CHATBOT_SESSION_ID_COOKIE);
};
export const setChatbotSessionId = (sessionId: string): void => {
setCookie(CHATBOT_SESSION_ID_COOKIE, sessionId);
};
+36
View File
@@ -0,0 +1,36 @@
// Simple ULID generator
// ULID format: 01234567890123456789012345 (26 characters)
// Time (10 chars) + Random (16 chars)
const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
const TIME_LEN = 10;
const RANDOM_LEN = 16;
const getRandomChar = (): string => {
return ENCODING[Math.floor(Math.random() * ENCODING.length)];
};
const encodeTime = (now: number): string => {
let time = "";
let num = now;
for (let i = 0; i < TIME_LEN; i++) {
time = ENCODING[num % ENCODING.length] + time;
num = Math.floor(num / ENCODING.length);
}
return time;
};
const encodeRandom = (): string => {
let random = "";
for (let i = 0; i < RANDOM_LEN; i++) {
random += getRandomChar();
}
return random;
};
export const generateULID = (): string => {
const now = Date.now();
return encodeTime(now) + encodeRandom();
};
+31
View File
@@ -2,6 +2,37 @@
@import "tw-animate-css";
@import "leaflet/dist/leaflet.css";
/* Animation for chatbot widget slide-up */
@keyframes slide-up {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.animate-slide-up {
animation: slide-up 0.3s ease-out;
}
/* Subtle bounce animation for chat button */
@keyframes bounce-subtle {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-5px);
}
}
.animate-bounce-subtle {
animation: bounce-subtle 2s ease-in-out infinite;
}
/* بهبود responsive design برای موبایل */
html {
overflow-x: hidden;
+2
View File
@@ -10,6 +10,7 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { viewport } from './viewport'
import { SHOP_CONFIG } from "@/config/const";
import ManageData from "@/components/ManageData";
import FloatingChatButton from "./chatbot/components/FloatingChatButton";
export const metadata: Metadata = {
title: `${SHOP_CONFIG.fullName} | ${SHOP_CONFIG.englishName}`,
@@ -33,6 +34,7 @@ export default function RootLayout({
{children}
<ToastContainer />
<MobileBottomMenu />
<FloatingChatButton />
<ReactQueryDevtools initialIsOpen={false} />
<ManageData />
</QueryProvider>
+1 -1
View File
@@ -230,7 +230,7 @@ const ReturnPage: NextPage = () => {
returnReasons={returnReasons?.results.reasons || []}
onSubmit={handleFinalSubmit}
onBack={handleBackToStep2}
isLoading={returnOrderMutation.isPending}
isLoading={returnOrderMutation.isPending || uploadImageMutation.isPending}
/>
)}
</div>