added all new chanes

This commit is contained in:
ahmadrezabd
2026-01-05 14:59:18 +03:30
parent 33d26f5af9
commit aa9236fb92
16 changed files with 1289 additions and 79 deletions
+32 -6
View File
@@ -36,32 +36,51 @@ export function useAuth() {
}, [setSession]);
const signInWithGoogle = async () => {
if (!isSupabaseConfigured) {
throw new Error('Supabase not configured');
}
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/account`
}
});
if (error) throw error;
if (error) {
console.error('Google sign in error:', error);
throw error;
}
};
const signInWithPhone = async (phone: string) => {
if (!isSupabaseConfigured) {
throw new Error('Supabase not configured');
}
const { error } = await supabase.auth.signInWithOtp({
phone,
options: {
channel: 'sms'
}
});
if (error) throw error;
if (error) {
console.error('Phone OTP error:', error);
throw error;
}
};
const verifyOTP = async (phone: string, token: string) => {
const { error } = await supabase.auth.verifyOtp({
if (!isSupabaseConfigured) {
throw new Error('Supabase not configured');
}
const { data, error } = await supabase.auth.verifyOtp({
phone,
token,
type: 'sms'
});
if (error) throw error;
if (error) {
console.error('OTP verification error:', error);
throw error;
}
return data;
};
const signUp = async (email: string, password: string) => {
@@ -76,11 +95,18 @@ export function useAuth() {
};
const signIn = async (email: string, password: string) => {
const { error } = await supabase.auth.signInWithPassword({
if (!isSupabaseConfigured) {
throw new Error('Supabase not configured');
}
const { data, error } = await supabase.auth.signInWithPassword({
email,
password
});
if (error) throw error;
if (error) {
console.error('Sign in error:', error);
throw error;
}
return data;
};
const signOut = async () => {
+108 -5
View File
@@ -1,11 +1,40 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { supabase, isSupabaseConfigured, type Message } from '@/services/supabase';
import {
generateAIResponse,
isOpenAIConfigured,
systemMessage,
type ChatCompletionMessage
} from '@/services/openai';
import { sendMessageToN8n, isN8nConfigured, type ChatMessage as N8nChatMessage } from '@/services/n8n';
export type ChatMessage = Message & {
role?: 'user' | 'assistant';
status?: 'pending' | 'sent' | 'error';
};
const createLocalId = () => {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
};
export function useChat() {
const [messages, setMessages] = useState<Message[]>([]);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [loading, setLoading] = useState(true);
const conversationRef = useRef<ChatCompletionMessage[]>([systemMessage]);
const n8nHistoryRef = useRef<N8nChatMessage[]>([]);
const isAIEnabled = isOpenAIConfigured || isN8nConfigured;
useEffect(() => {
if (isAIEnabled) {
// بدون پیام خوش‌آمدگویی - کاربر باید دکمه استارت را بزند
setMessages([]);
setLoading(false);
return;
}
loadMessages();
if (!isSupabaseConfigured) {
@@ -34,6 +63,10 @@ export function useChat() {
}, []);
const loadMessages = async () => {
if (isAIEnabled) {
return;
}
if (!isSupabaseConfigured) {
setMessages([]);
setLoading(false);
@@ -51,7 +84,7 @@ export function useChat() {
// For now, user data will be fetched separately in the component
// You can create a profiles table and join it here if needed
setMessages((data || []) as Message[]);
setMessages((data || []) as ChatMessage[]);
} catch (error) {
console.error('Error loading messages:', error);
setMessages([]);
@@ -60,7 +93,77 @@ export function useChat() {
}
};
const sendMessage = async (content: string, userId: string) => {
const sendMessage = async (content: string, userId: string, skipUserMessage = false) => {
if (isAIEnabled) {
const createdAt = new Date().toISOString();
const userMessage: ChatMessage = {
id: createLocalId(),
user_id: userId,
content,
created_at: createdAt,
role: 'user',
status: 'pending'
};
// فقط اگر skipUserMessage false باشد، پیام کاربر را نمایش بده
if (!skipUserMessage) {
setMessages((prev) => [userMessage, ...prev]);
}
try {
let aiContent: string;
// اولویت با n8n است اگر تنظیم شده باشد
if (isN8nConfigured) {
// اضافه کردن پیام کاربر به تاریخچه n8n
n8nHistoryRef.current.push({ role: 'user', content, timestamp: createdAt });
// ارسال به n8n
aiContent = await sendMessageToN8n(content, userId, n8nHistoryRef.current);
// اضافه کردن پاسخ به تاریخچه n8n
n8nHistoryRef.current.push({
role: 'assistant',
content: aiContent,
timestamp: new Date().toISOString()
});
} else if (isOpenAIConfigured) {
// استفاده از OpenAI
conversationRef.current.push({ role: 'user', content });
aiContent = await generateAIResponse(conversationRef.current);
conversationRef.current.push({ role: 'assistant', content: aiContent });
} else {
throw new Error('هیچ سرویس هوش مصنوعی تنظیم نشده است.');
}
const aiMessage: ChatMessage = {
id: createLocalId(),
user_id: 'ai-assistant',
content: aiContent,
created_at: new Date().toISOString(),
role: 'assistant',
status: 'sent'
};
// فقط اگر پیام کاربر نمایش داده شده، status را update کن
if (!skipUserMessage) {
setMessages((prev) =>
prev.map((msg) => (msg.id === userMessage.id ? { ...msg, status: 'sent' } : msg))
);
}
setMessages((prev) => [aiMessage, ...prev]);
return aiMessage;
} catch (error) {
// فقط اگر پیام کاربر نمایش داده شده، status را update کن
if (!skipUserMessage) {
setMessages((prev) =>
prev.map((msg) => (msg.id === userMessage.id ? { ...msg, status: 'error' } : msg))
);
}
throw error;
}
}
if (!isSupabaseConfigured) {
throw new Error('Supabase is not configured. Cannot send messages in offline mode.');
}
@@ -78,7 +181,7 @@ export function useChat() {
.single();
if (error) throw error;
return data;
return data as ChatMessage;
} catch (error) {
console.error('Error sending message:', error);
throw error;
+77
View File
@@ -0,0 +1,77 @@
import { useState, useEffect, useRef } from 'react';
interface UseTypewriterOptions {
text: string;
speed?: number; // میلی‌ثانیه بین هر کاراکتر
onComplete?: () => void;
}
export function useTypewriter({ text, speed = 30, onComplete }: UseTypewriterOptions) {
const [displayedText, setDisplayedText] = useState('');
const [isTyping, setIsTyping] = useState(false);
const indexRef = useRef(0);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const textRef = useRef(text);
const hasCompletedRef = useRef(false);
useEffect(() => {
// Cleanup previous timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
// If text is empty, reset everything
if (!text || text.trim() === '') {
setDisplayedText('');
setIsTyping(false);
indexRef.current = 0;
hasCompletedRef.current = false;
textRef.current = '';
return;
}
// Only restart if text actually changed
const textChanged = textRef.current !== text;
if (textChanged) {
// Reset when text changes
setDisplayedText('');
indexRef.current = 0;
setIsTyping(true);
hasCompletedRef.current = false;
textRef.current = text;
const typeNextChar = () => {
if (indexRef.current < text.length) {
setDisplayedText(text.substring(0, indexRef.current + 1));
indexRef.current += 1;
timeoutRef.current = setTimeout(typeNextChar, speed);
} else {
setIsTyping(false);
hasCompletedRef.current = true;
if (onComplete) {
onComplete();
}
}
};
// Start typing immediately
timeoutRef.current = setTimeout(typeNextChar, speed);
} else if (hasCompletedRef.current) {
// Text hasn't changed and already completed, show full text
setDisplayedText(text);
setIsTyping(false);
}
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, [text, speed, onComplete]);
return { displayedText, isTyping };
}
+48 -7
View File
@@ -42,15 +42,22 @@ export default function LoginPage() {
});
navigate('/account');
} catch (error: any) {
console.error('Login error:', error);
let errorMessage = 'خطا در ورود';
if (error.message) {
if (error.message.includes('Invalid login credentials')) {
if (error) {
const errorMsg = error.message || error.error?.message || String(error);
if (errorMsg.includes('Invalid login credentials') || errorMsg.includes('invalid_credentials')) {
errorMessage = 'ایمیل یا رمز عبور اشتباه است. لطفا دوباره تلاش کنید یا ثبت نام کنید.';
} else if (error.message.includes('Email not confirmed')) {
} else if (errorMsg.includes('Email not confirmed') || errorMsg.includes('email_not_confirmed')) {
errorMessage = 'لطفا ابتدا ایمیل خود را تأیید کنید.';
} else if (errorMsg.includes('Supabase not configured')) {
errorMessage = 'Supabase تنظیم نشده است. لطفا فایل .env را بررسی کنید.';
} else if (errorMsg.includes('Network') || errorMsg.includes('fetch')) {
errorMessage = 'خطا در اتصال به سرور. لطفا اتصال اینترنت خود را بررسی کنید.';
} else {
errorMessage = error.message;
errorMessage = errorMsg || 'خطا در ورود. لطفا دوباره تلاش کنید.';
}
}
@@ -70,9 +77,13 @@ export default function LoginPage() {
await signInWithGoogle();
// Redirect happens automatically
} catch (error: any) {
console.error('Google login error:', error);
const errorMsg = error?.message || error?.error?.message || String(error) || 'خطا در ورود با گوگل';
toast({
title: 'خطا',
description: error.message || 'خطا در ورود با گوگل',
description: errorMsg.includes('Supabase not configured')
? 'Supabase تنظیم نشده است. لطفا فایل .env را بررسی کنید.'
: errorMsg,
variant: 'destructive'
});
setIsLoading(false);
@@ -99,9 +110,23 @@ export default function LoginPage() {
description: 'کد تأیید ارسال شد'
});
} catch (error: any) {
console.error('Phone OTP error:', error);
const errorMsg = error?.message || error?.error?.message || String(error) || 'خطا در ارسال کد';
let errorMessage = 'خطا در ارسال کد';
if (errorMsg.includes('Supabase not configured')) {
errorMessage = 'Supabase تنظیم نشده است. لطفا فایل .env را بررسی کنید.';
} else if (errorMsg.includes('Invalid phone number') || errorMsg.includes('invalid_phone')) {
errorMessage = 'شماره تلفن نامعتبر است. لطفا شماره را با فرمت صحیح وارد کنید.';
} else if (errorMsg.includes('Network') || errorMsg.includes('fetch')) {
errorMessage = 'خطا در اتصال به سرور. لطفا اتصال اینترنت خود را بررسی کنید.';
} else {
errorMessage = errorMsg;
}
toast({
title: 'خطا',
description: error.message || 'خطا در ارسال کد',
description: errorMessage,
variant: 'destructive'
});
} finally {
@@ -129,9 +154,25 @@ export default function LoginPage() {
});
navigate('/account');
} catch (error: any) {
console.error('OTP verification error:', error);
const errorMsg = error?.message || error?.error?.message || String(error) || 'کد تأیید نامعتبر است';
let errorMessage = 'کد تأیید نامعتبر است';
if (errorMsg.includes('Supabase not configured')) {
errorMessage = 'Supabase تنظیم نشده است. لطفا فایل .env را بررسی کنید.';
} else if (errorMsg.includes('Invalid OTP') || errorMsg.includes('invalid_token')) {
errorMessage = 'کد تأیید نامعتبر است. لطفا دوباره تلاش کنید.';
} else if (errorMsg.includes('expired')) {
errorMessage = 'کد تأیید منقضی شده است. لطفا کد جدید درخواست کنید.';
} else if (errorMsg.includes('Network') || errorMsg.includes('fetch')) {
errorMessage = 'خطا در اتصال به سرور. لطفا اتصال اینترنت خود را بررسی کنید.';
} else {
errorMessage = errorMsg;
}
toast({
title: 'خطا',
description: error.message || 'کد تأیید نامعتبر است',
description: errorMessage,
variant: 'destructive'
});
} finally {
+159 -45
View File
@@ -5,8 +5,9 @@ import { Input } from '@/components/ui/input';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useAuth } from '@/hooks/useAuth';
import { useChat } from '@/hooks/useChat';
import { useTypewriter } from '@/hooks/useTypewriter';
import { useToast } from '@/components/ui/use-toast';
import { Send, MessageSquare, Loader2 } from 'lucide-react';
import { Send, MessageSquare, Loader2, Play } from 'lucide-react';
export default function ChatPage() {
const { user } = useAuth();
@@ -15,13 +16,14 @@ export default function ChatPage() {
const [message, setMessage] = useState('');
const [isSending, setIsSending] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const typedMessagesRef = useRef<Set<string>>(new Set()); // Track which messages have been typed
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
const handleSend = async (e?: React.FormEvent, initialMessage?: string, skipUserMessage = false) => {
e?.preventDefault();
if (!user) {
toast({
title: 'خطا',
@@ -31,18 +33,26 @@ export default function ChatPage() {
return;
}
if (!message.trim()) {
const messageToSend = initialMessage || message;
if (!messageToSend.trim()) {
return;
}
setIsSending(true);
try {
await sendMessage(message, user.id);
setMessage('');
// @ts-ignore - skipUserMessage parameter exists
await sendMessage(messageToSend, user.id, skipUserMessage);
if (!initialMessage) {
setMessage('');
}
} catch (error) {
const description =
error instanceof Error
? error.message
: 'خطا در ارسال پیام';
toast({
title: 'خطا',
description: 'خطا در ارسال پیام',
description,
variant: 'destructive'
});
} finally {
@@ -50,7 +60,13 @@ export default function ChatPage() {
}
};
const handleStartChat = () => {
// skipUserMessage = true یعنی پیام کاربر نمایش داده نمی‌شود
handleSend(undefined, 'سلام', true);
};
const getUserDisplayName = (msg: typeof messages[0]) => {
if (msg?.role === 'assistant') return 'دستیار هوشمند';
if (msg.user_id === user?.id) return 'شما';
// For other users, show a simplified identifier
// In production, you'd fetch from a profiles table
@@ -61,12 +77,113 @@ export default function ChatPage() {
};
const getUserInitials = (msg: typeof messages[0]) => {
if (msg?.role === 'assistant') return 'هو';
const name = getUserDisplayName(msg);
if (name === 'شما') return 'ش';
if (name.length >= 2) return name.substring(0, 2);
return 'ک';
};
// Component for message bubble with typewriter effect for AI messages
const MessageBubble = ({
message,
isOwnMessage,
isAssistant,
getUserDisplayName,
getUserInitials
}: {
message: typeof messages[0];
isOwnMessage: boolean;
isAssistant: boolean;
getUserDisplayName: (msg: typeof messages[0]) => string;
getUserInitials: (msg: typeof messages[0]) => string;
}) => {
// Check if this message has already been typed
const hasBeenTyped = typedMessagesRef.current.has(message.id);
// If it's an assistant message and hasn't been typed yet, use typewriter
const shouldAnimate = isAssistant && !hasBeenTyped && !!message.content;
// Only use typewriter for assistant messages that haven't been typed yet
const typewriterResult = useTypewriter({
text: shouldAnimate ? message.content : '',
speed: 10, // سرعت تایپ سریع‌تر (میلی‌ثانیه)
onComplete: () => {
// Mark this message as typed when animation completes
if (isAssistant && message.id) {
typedMessagesRef.current.add(message.id);
}
}
});
// Determine final text to display
// Priority: animated text (if available) > message.content
let finalText: string;
if (isAssistant && shouldAnimate) {
// For assistant messages with animation, use displayedText if it exists, otherwise fallback to content
finalText = typewriterResult.displayedText.length > 0
? typewriterResult.displayedText
: message.content || '';
} else {
// For user messages or already-typed assistant messages, show full content
finalText = message.content || '';
}
const showCursor = isAssistant && shouldAnimate && typewriterResult.isTyping;
return (
<div
className={`flex gap-3 ${isOwnMessage ? 'flex-row-reverse' : 'flex-row'}`}
>
<Avatar className="h-8 w-8 flex-shrink-0">
<AvatarImage src={message.user?.avatar_url} />
<AvatarFallback className="text-xs">
{getUserInitials(message)}
</AvatarFallback>
</Avatar>
<div className={`flex-1 ${isOwnMessage ? 'text-left' : 'text-right'}`}>
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="text-xs font-medium">
{getUserDisplayName(message)}
</span>
<span className="text-xs text-muted-foreground">
{new Date(message.created_at).toLocaleTimeString('fa-IR', {
hour: '2-digit',
minute: '2-digit'
})}
</span>
</div>
<div
className={`rounded-lg p-3 ${
isOwnMessage
? 'bg-primary text-primary-foreground ml-auto'
: isAssistant
? 'bg-secondary text-secondary-foreground'
: 'bg-muted'
}`}
style={{
maxWidth: '85%',
marginRight: isOwnMessage ? '0' : 'auto',
marginLeft: isOwnMessage ? 'auto' : '0'
}}
>
<p className="text-xs whitespace-pre-wrap break-words">
{finalText}
{showCursor && (
<span className="inline-block w-2 h-4 bg-current ml-1 animate-pulse">|</span>
)}
</p>
{message.status === 'error' && (
<span className="mt-2 block text-[10px] text-destructive">
ارسال ناموفق بود. دوباره تلاش کنید.
</span>
)}
</div>
</div>
</div>
);
};
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted p-4 pb-20">
<div className="mx-auto max-w-sm">
@@ -94,49 +211,46 @@ export default function ChatPage() {
<>
<div className="flex-1 overflow-y-auto space-y-4 mb-4 pr-2">
{messages.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
هیچ پیامی وجود ندارد. اولین پیام را ارسال کنید!
<div className="flex flex-col items-center justify-center h-full py-8">
<div className="text-center mb-6">
<MessageSquare className="h-16 w-16 text-muted-foreground mx-auto mb-4 opacity-50" />
<p className="text-muted-foreground mb-2">به چت هوشمند خوش آمدید</p>
<p className="text-sm text-muted-foreground">
برای شروع گفتگو، دکمه زیر را بزنید
</p>
</div>
<Button
onClick={handleStartChat}
disabled={isSending || !user}
size="lg"
className="gap-2"
>
{isSending ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
در حال اتصال...
</>
) : (
<>
<Play className="h-5 w-5" />
شروع گفتگو
</>
)}
</Button>
</div>
) : (
messages.map((msg) => {
const isOwnMessage = msg.user_id === user?.id;
const isOwnMessage = msg.user_id === user?.id && msg.role !== 'assistant';
const isAssistant = msg.role === 'assistant';
return (
<div
<MessageBubble
key={msg.id}
className={`flex gap-3 ${isOwnMessage ? 'flex-row-reverse' : 'flex-row'}`}
>
<Avatar className="h-8 w-8 flex-shrink-0">
<AvatarImage src={msg.user?.avatar_url} />
<AvatarFallback className="text-xs">
{getUserInitials(msg)}
</AvatarFallback>
</Avatar>
<div
className={`flex-1 ${isOwnMessage ? 'text-left' : 'text-right'}`}
>
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="text-xs font-medium">
{getUserDisplayName(msg)}
</span>
<span className="text-xs text-muted-foreground">
{new Date(msg.created_at).toLocaleTimeString('fa-IR', {
hour: '2-digit',
minute: '2-digit'
})}
</span>
</div>
<div
className={`rounded-lg p-3 ${
isOwnMessage
? 'bg-primary text-primary-foreground ml-auto'
: 'bg-muted'
}`}
style={{ maxWidth: '85%', marginRight: isOwnMessage ? '0' : 'auto', marginLeft: isOwnMessage ? 'auto' : '0' }}
>
<p className="text-xs whitespace-pre-wrap break-words">{msg.content}</p>
</div>
</div>
</div>
message={msg}
isOwnMessage={isOwnMessage}
isAssistant={isAssistant}
getUserDisplayName={getUserDisplayName}
getUserInitials={getUserInitials}
/>
);
})
)}
+297
View File
@@ -0,0 +1,297 @@
// URL پیش‌فرض n8n chat flow
const DEFAULT_N8N_CHAT_URL = 'https://nn.feep.ir/webhook/ad03737e-f857-4b5c-87d0-601327b2081c/chat';
const n8nWebhookUrl = import.meta.env.VITE_N8N_WEBHOOK_URL || DEFAULT_N8N_CHAT_URL;
const n8nApiKey = import.meta.env.VITE_N8N_API_KEY || '';
export type ChatMessage = {
role: 'user' | 'assistant';
content: string;
timestamp?: string;
};
export const isN8nConfigured = Boolean(n8nWebhookUrl);
// ذخیره sessionId برای هر کاربر (برای حفظ context در Simple Memory)
const sessionIdMap = new Map<string, string>();
// تولید یا دریافت sessionId برای یک کاربر
const getOrCreateSessionId = (userId: string): string => {
if (!sessionIdMap.has(userId)) {
// تولید sessionId جدید (UUID-like)
const sessionId = `${userId}-${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
sessionIdMap.set(userId, sessionId);
console.log('🔑 ایجاد sessionId جدید:', sessionId, 'برای کاربر:', userId);
}
return sessionIdMap.get(userId)!;
};
// در development از proxy استفاده می‌کنیم، در production از URL مستقیم
const getN8nUrl = () => {
// اگر در development هستیم و URL شامل nn.feep.ir است، از proxy استفاده کن
const useProxy = import.meta.env.DEV && n8nWebhookUrl.includes('nn.feep.ir');
if (useProxy) {
console.log('🔀 استفاده از proxy برای دور زدن CORS');
// استخراج مسیر webhook از URL کامل
try {
const url = new URL(n8nWebhookUrl);
const urlPath = url.pathname;
const proxyUrl = `/api/n8n${urlPath}`;
console.log('📍 مسیر proxy:', proxyUrl, 'از URL اصلی:', n8nWebhookUrl);
return proxyUrl;
} catch (error) {
console.error('❌ خطا در parse کردن URL:', error);
// اگر URL معتبر نیست، از URL مستقیم استفاده کن
return n8nWebhookUrl;
}
}
console.log('🌐 استفاده از URL مستقیم:', n8nWebhookUrl);
return n8nWebhookUrl;
};
/**
* ارسال پیام به n8n و دریافت پاسخ
* @param message - پیام کاربر
* @param userId - شناسه کاربر
* @param conversationHistory - تاریخچه مکالمه (اختیاری)
* @returns پاسخ از n8n
*/
export async function sendMessageToN8n(
message: string,
userId: string,
_conversationHistory?: ChatMessage[] // n8n Chat با Simple Memory خودش session را مدیریت می‌کند، این پارامتر برای backward compatibility است
): Promise<string> {
if (!isN8nConfigured) {
throw new Error(
'n8n تنظیم نشده است. مقدار VITE_N8N_WEBHOOK_URL را در فایل .env تنظیم کنید.'
);
}
// فرمت n8n Chat Trigger - این workflow از "When chat message received" استفاده می‌کند
// فرمت مورد نیاز: { sessionId, action: "sendMessage", chatInput }
const sessionId = getOrCreateSessionId(userId);
const payload = {
sessionId: sessionId,
action: 'sendMessage',
chatInput: message
};
const headers: Record<string, string> = {
'Content-Type': 'application/json'
};
// اگر API Key تنظیم شده باشد، اضافه می‌کنیم
if (n8nApiKey) {
headers['Authorization'] = `Bearer ${n8nApiKey}`;
// یا می‌توانید از header دیگری استفاده کنید
// headers['X-API-Key'] = n8nApiKey;
}
try {
const actualUrl = getN8nUrl();
console.log('📤 ارسال به n8n:', { url: actualUrl, originalUrl: n8nWebhookUrl, payload });
const response = await fetch(actualUrl, {
method: 'POST',
headers,
body: JSON.stringify(payload),
// اضافه کردن mode برای جلوگیری از CORS issues
mode: 'cors',
credentials: 'omit'
});
console.log('📥 پاسخ از n8n:', { status: response.status, ok: response.ok });
if (!response.ok) {
let errorText = '';
try {
errorText = await response.text();
// اگر پاسخ HTML است (مثل خطای 500)، پیام بهتری نمایش بده
if (errorText.trim().startsWith('<!DOCTYPE') || errorText.includes('<html')) {
console.error('❌ خطا از n8n (HTML Error Page):', { status: response.status });
throw new Error(
`خطا در n8n workflow (${response.status}): خطای داخلی سرور. لطفا workflow را در n8n بررسی کنید:\n` +
`1. آیا workflow فعال است؟\n` +
`2. آیا Message a model node درست تنظیم شده؟\n` +
`3. آیا API Key OpenAI درست است؟\n` +
`4. آیا Respond to Webhook node وجود دارد؟`
);
}
} catch (e) {
if (e instanceof Error && e.message.includes('خطا در n8n workflow')) {
throw e;
}
errorText = response.statusText;
}
console.error('❌ خطا از n8n:', { status: response.status, errorText });
throw new Error(
`خطا در ارتباط با n8n (${response.status}): ${errorText.substring(0, 200) || response.statusText}`
);
}
let data;
try {
// بررسی Content-Type
const contentType = response.headers.get('content-type') || '';
console.log('📋 Content-Type:', contentType);
const responseText = await response.text();
console.log('📄 پاسخ خام از n8n (اولین 500 کاراکتر):', responseText.substring(0, 500));
if (!responseText || responseText.trim() === '') {
throw new Error('پاسخ خالی از n8n دریافت شد.');
}
// اگر پاسخ HTML است (خطای 500)، خطا بده
if (responseText.trim().startsWith('<!DOCTYPE') || responseText.includes('<html')) {
console.error('❌ پاسخ HTML دریافت شد (خطای سرور)');
throw new Error(
'n8n خطای سرور برمی‌گرداند. لطفا بررسی کنید:\n' +
'1. آیا workflow فعال است؟\n' +
'2. آیا همه node ها درست تنظیم شده‌اند؟\n' +
'3. آیا Respond to Webhook node وجود دارد و درست تنظیم شده؟'
);
}
// تلاش برای parse کردن JSON
try {
data = JSON.parse(responseText);
} catch (jsonError) {
// اگر JSON نیست، ممکنه string ساده باشه
console.warn('⚠️ پاسخ JSON نیست، به عنوان string استفاده می‌شود');
return responseText.trim();
}
} catch (parseError) {
if (parseError instanceof Error && parseError.message.includes('n8n خطای سرور')) {
throw parseError;
}
console.error('❌ خطا در parse کردن پاسخ:', parseError);
throw new Error('فرمت پاسخ n8n نامعتبر است (JSON parse failed): ' + String(parseError));
}
console.log('📦 داده parse شده از n8n:', data);
// n8n Chat Trigger + AI Agent معمولاً پاسخ را در فرمت‌های مختلف برمی‌گرداند
// این فرمت‌های رایج را پشتیبانی می‌کنیم:
// 0. فرمت AI Agent output (اولویت اول برای n8n chat)
if (data.output && typeof data.output === 'string' && data.output.trim()) {
console.log('✅ پاسخ از AI Agent (output):', data.output);
return data.output.trim();
}
// 0.1. فرمت AI Agent با object output
if (data.output && typeof data.output === 'object') {
// بررسی فیلدهای مختلف در output
if (data.output.output && typeof data.output.output === 'string' && data.output.output.trim()) {
console.log('✅ پاسخ از AI Agent (output.output):', data.output.output);
return data.output.output.trim();
}
if (data.output.text && typeof data.output.text === 'string' && data.output.text.trim()) {
console.log('✅ پاسخ از AI Agent (output.text):', data.output.text);
return data.output.text.trim();
}
if (data.output.response && typeof data.output.response === 'string' && data.output.response.trim()) {
console.log('✅ پاسخ از AI Agent (output.response):', data.output.response);
return data.output.response.trim();
}
}
// 1. اگر پاسخ از Message a model (GPT) با Simplify Output خاموش است
if (data.choices && Array.isArray(data.choices) && data.choices.length > 0) {
const content = data.choices[0]?.message?.content;
if (content && content.trim()) {
console.log('✅ پاسخ از GPT (choices):', content);
return content.trim();
}
}
// 2. اگر Simplify Output فعال است - پاسخ مستقیم از GPT
if (data.response && typeof data.response === 'string' && data.response.trim()) {
console.log('✅ پاسخ از n8n (response):', data.response);
return data.response.trim();
}
// 3. اگر response خالی است، ببین آیا در جای دیگری هست
if (data.response === '' || data.response === null) {
// شاید پاسخ در جای دیگری باشد
if (data.choices?.[0]?.message?.content) {
const content = data.choices[0].message.content;
if (content && content.trim()) {
console.log('✅ پاسخ از GPT (در choices):', content);
return content.trim();
}
}
}
// 4. فرمت‌های دیگر
if (typeof data === 'string' && data.trim()) {
return data.trim();
}
if (data.message && typeof data.message === 'string' && data.message.trim()) {
return data.message.trim();
}
if (data.text && typeof data.text === 'string' && data.text.trim()) {
return data.text.trim();
}
if (data.data?.response && typeof data.data.response === 'string' && data.data.response.trim()) {
return data.data.response.trim();
}
if (data.data?.message && typeof data.data.message === 'string' && data.data.message.trim()) {
return data.data.message.trim();
}
// 5. اگر پاسخ یک object است و هیچ فیلد متنی نداره، خطا بده
if (typeof data === 'object') {
// بررسی عمیق‌تر برای پیدا کردن متن
const findTextInObject = (obj: any): string | null => {
if (typeof obj === 'string' && obj.trim()) {
return obj.trim();
}
if (Array.isArray(obj)) {
for (const item of obj) {
const text = findTextInObject(item);
if (text) return text;
}
}
if (obj && typeof obj === 'object') {
for (const key in obj) {
if (key === 'content' || key === 'text' || key === 'message' || key === 'response') {
const text = findTextInObject(obj[key]);
if (text) return text;
}
}
}
return null;
};
const foundText = findTextInObject(data);
if (foundText) {
console.log('✅ متن پیدا شد در object:', foundText);
return foundText;
}
// اگر هیچ متنی پیدا نشد، خطا بده
console.error('❌ هیچ متن معتبری در پاسخ پیدا نشد:', JSON.stringify(data, null, 2));
throw new Error('پاسخ از n8n خالی است یا فرمت نامعتبر دارد. لطفا Respond to Webhook را بررسی کنید.');
}
throw new Error('فرمت پاسخ n8n نامعتبر است. ساختار: ' + JSON.stringify(data));
} catch (error) {
console.error('❌ خطا در sendMessageToN8n:', error);
if (error instanceof TypeError && error.message.includes('fetch')) {
throw new Error('خطا در اتصال به n8n. لطفا بررسی کنید:\n1. URL صحیح است؟\n2. n8n در دسترس است؟\n3. مشکل CORS وجود دارد؟');
}
if (error instanceof Error) {
throw error;
}
throw new Error('خطا در ارتباط با n8n: ' + String(error));
}
}
+70
View File
@@ -0,0 +1,70 @@
const openAiApiKey = import.meta.env.VITE_OPENAI_API_KEY || '';
const openAiBaseUrl = import.meta.env.VITE_OPENAI_BASE_URL || 'https://api.openai.com/v1';
const openAiModel = import.meta.env.VITE_OPENAI_MODEL || 'gpt-4o-mini';
const openAiOrg = import.meta.env.VITE_OPENAI_ORG_ID || '';
const openAiProject = import.meta.env.VITE_OPENAI_PROJECT_ID || '';
export type ChatCompletionRole = 'system' | 'user' | 'assistant';
export type ChatCompletionMessage = {
role: ChatCompletionRole;
content: string;
};
export const isOpenAIConfigured = Boolean(openAiApiKey);
const defaultSystemPrompt =
import.meta.env.VITE_OPENAI_SYSTEM_PROMPT ||
'You are an AI assistant that responds in Persian (Farsi) with concise, helpful answers for users of a progressive web app.';
export const systemMessage: ChatCompletionMessage = {
role: 'system',
content: defaultSystemPrompt,
};
export async function generateAIResponse(messages: ChatCompletionMessage[]): Promise<string> {
if (!isOpenAIConfigured) {
throw new Error('کلید OpenAI تعریف نشده است. مقدار VITE_OPENAI_API_KEY را تنظیم کنید.');
}
const payload = {
model: openAiModel,
messages: messages.length ? messages : [systemMessage],
temperature: 0.3,
max_tokens: 800,
};
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Authorization: `Bearer ${openAiApiKey}`,
};
if (openAiOrg) {
headers['OpenAI-Organization'] = openAiOrg;
}
if (openAiProject) {
headers['OpenAI-Project'] = openAiProject;
}
const response = await fetch(`${openAiBaseUrl}/chat/completions`, {
method: 'POST',
headers,
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`خطا در دریافت پاسخ از GPT: ${errorBody}`);
}
const data = await response.json();
const text: string | undefined = data?.choices?.[0]?.message?.content?.trim();
if (!text) {
throw new Error('پاسخی از هوش مصنوعی دریافت نشد.');
}
return text;
}
+22 -5
View File
@@ -49,12 +49,29 @@
}
}
/* Custom Font - فونت سفارشی */
/* Custom Font - فونت سفارشی شبنم */
@font-face {
font-family: 'IRAN Rounded';
src: url('/fonts/IRAN Rounded.ttf') format('truetype');
font-weight: normal;
font-family: 'Shabnam';
src: url('/fonts/shabnam/Shabnam-Light.ttf') format('truetype');
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Shabnam';
src: url('/fonts/shabnam/Shabnam.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Shabnam';
src: url('/fonts/shabnam/Shabnam-Bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@layer base {
@@ -64,7 +81,7 @@
body {
@apply bg-background text-foreground;
font-family: 'IRAN Rounded', 'Tahoma', 'Arial', system-ui, -apple-system, sans-serif;
font-family: 'Shabnam', 'Tahoma', 'Arial', system-ui, -apple-system, sans-serif;
}
}