add: ai chatbot
This commit is contained in:
@@ -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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -230,7 +230,7 @@ const ReturnPage: NextPage = () => {
|
||||
returnReasons={returnReasons?.results.reasons || []}
|
||||
onSubmit={handleFinalSubmit}
|
||||
onBack={handleBackToStep2}
|
||||
isLoading={returnOrderMutation.isPending}
|
||||
isLoading={returnOrderMutation.isPending || uploadImageMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user