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 "tw-animate-css";
|
||||||
@import "leaflet/dist/leaflet.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 برای موبایل */
|
/* بهبود responsive design برای موبایل */
|
||||||
html {
|
html {
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
|||||||
import { viewport } from './viewport'
|
import { viewport } from './viewport'
|
||||||
import { SHOP_CONFIG } from "@/config/const";
|
import { SHOP_CONFIG } from "@/config/const";
|
||||||
import ManageData from "@/components/ManageData";
|
import ManageData from "@/components/ManageData";
|
||||||
|
import FloatingChatButton from "./chatbot/components/FloatingChatButton";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: `${SHOP_CONFIG.fullName} | ${SHOP_CONFIG.englishName}`,
|
title: `${SHOP_CONFIG.fullName} | ${SHOP_CONFIG.englishName}`,
|
||||||
@@ -33,6 +34,7 @@ export default function RootLayout({
|
|||||||
{children}
|
{children}
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
<MobileBottomMenu />
|
<MobileBottomMenu />
|
||||||
|
<FloatingChatButton />
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
<ManageData />
|
<ManageData />
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ const ReturnPage: NextPage = () => {
|
|||||||
returnReasons={returnReasons?.results.reasons || []}
|
returnReasons={returnReasons?.results.reasons || []}
|
||||||
onSubmit={handleFinalSubmit}
|
onSubmit={handleFinalSubmit}
|
||||||
onBack={handleBackToStep2}
|
onBack={handleBackToStep2}
|
||||||
isLoading={returnOrderMutation.isPending}
|
isLoading={returnOrderMutation.isPending || uploadImageMutation.isPending}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user