|
1 | | -import React, { useState, useEffect, useRef, useCallback } from 'react'; |
2 | | -import { useAlgorithmState } from '@/context/AlgorithmState'; |
3 | | -import { useLanguage } from '@/context/LanguageContext'; |
4 | | -import { useAudio } from '@/hooks/useAudio'; |
| 1 | +import React from 'react'; |
5 | 2 | import { useMobileOverlay } from '@/components/MobileOverlay'; |
6 | | -import { processMessage } from './assistantEngine'; |
7 | 3 | import ChatButton from './ChatButton'; |
8 | 4 | import ChatModal from './ChatModal'; |
9 | | - |
10 | | -const CHAT_WELCOME_MESSAGES = { |
11 | | - en: "Hello! I'm SortBot, your sorting algorithm assistant. How can I help you today?", |
12 | | - es: 'Hola. Soy SortBot, tu asistente de algoritmos de ordenamiento. ¿En qué te ayudo hoy?', |
13 | | - hi: 'नमस्ते। मैं SortBot हूँ, आपका sorting algorithm assistant। आज मैं आपकी कैसे मदद कर सकता हूँ?', |
14 | | - fr: "Bonjour. Je suis SortBot, votre assistant d'algorithmes de tri. Comment puis-je vous aider aujourd'hui ?", |
15 | | - de: 'Hallo. Ich bin SortBot, dein Assistent fuer Sortieralgorithmen. Wie kann ich dir heute helfen?', |
16 | | - zh: '你好。我是 SortBot,你的排序算法助手。今天我可以帮你什么?', |
17 | | - bn: 'হ্যালো। আমি SortBot, তোমার sorting algorithm assistant। আজ কীভাবে সাহায্য করতে পারি?', |
18 | | - ja: 'こんにちは。SortBotです。ソートアルゴリズムの学習を手伝います。今日は何を知りたいですか?', |
19 | | - jp: 'こんにちは。SortBotです。ソートアルゴリズムの学習を手伝います。今日は何を知りたいですか?', |
20 | | -}; |
| 5 | +import { useChatAssistantController } from './hooks/useChatAssistantController'; |
21 | 6 |
|
22 | 7 | export default function ChatAssistant({ |
23 | 8 | isOpen: isOpenProp, |
24 | 9 | onClose, |
25 | 10 | onToggle, |
26 | 11 | }) { |
27 | | - const [isOpenState, setIsOpenState] = useState(false); |
28 | | - const isOpen = typeof isOpenProp === 'boolean' ? isOpenProp : isOpenState; |
29 | | - const [input, setInput] = useState(''); |
30 | | - const [messages, setMessages] = useState([]); |
31 | | - const [isTyping, setIsTyping] = useState(false); |
32 | | - const [typingInterval, setTypingInterval] = useState(null); |
33 | | - const [errorCount, setErrorCount] = useState(0); |
34 | | - const [_retryCount, setRetryCount] = useState(0); |
35 | | - |
36 | | - const { getContextObject, addToHistory } = useAlgorithmState(); |
37 | | - const { language } = useLanguage(); |
38 | | - const { playTypingSound, isAudioEnabled } = useAudio(); |
39 | 12 | const { isMobileOverlayVisible } = useMobileOverlay(); |
40 | | - |
41 | | - const messagesEndRef = useRef(null); |
42 | | - const lastTypingSoundRef = useRef(0); |
43 | | - |
44 | | - // Initialize with welcome message |
45 | | - useEffect(() => { |
46 | | - // Add welcome message with delay |
47 | | - const timer = setTimeout(() => { |
48 | | - setMessages(prevMessages => { |
49 | | - if (prevMessages.length > 0) return prevMessages; |
50 | | - return [ |
51 | | - { |
52 | | - role: 'model', |
53 | | - content: |
54 | | - CHAT_WELCOME_MESSAGES[language] || CHAT_WELCOME_MESSAGES.en, |
55 | | - }, |
56 | | - ]; |
57 | | - }); |
58 | | - }, 1000); |
59 | | - |
60 | | - return () => clearTimeout(timer); |
61 | | - }, [language]); |
62 | | - |
63 | | - // Scroll to bottom on new messages |
64 | | - useEffect(() => { |
65 | | - const scrollToBottom = () => { |
66 | | - messagesEndRef.current?.scrollIntoView({ |
67 | | - behavior: 'smooth', |
68 | | - block: 'end', |
69 | | - }); |
70 | | - }; |
71 | | - |
72 | | - if (messages.length > 0) { |
73 | | - scrollToBottom(); |
74 | | - // Double-check scroll position after any images/content loads |
75 | | - setTimeout(scrollToBottom, 100); |
76 | | - } |
77 | | - }, [messages]); |
78 | | - |
79 | | - // Clean up typing interval on unmount |
80 | | - useEffect(() => { |
81 | | - return () => { |
82 | | - if (typingInterval) { |
83 | | - clearInterval(typingInterval); |
84 | | - } |
85 | | - }; |
86 | | - }, [typingInterval]); |
87 | | - |
88 | | - // Handle chat open/close with animation |
89 | | - const toggleChat = useCallback(() => { |
90 | | - if (typeof isOpenProp === 'boolean') { |
91 | | - // For controlled state, use onToggle if provided, otherwise fallback to onClose |
92 | | - if (onToggle) { |
93 | | - onToggle(); |
94 | | - } else if (isOpen && onClose) { |
95 | | - onClose(); |
96 | | - } |
97 | | - } else { |
98 | | - setIsOpenState(prev => !prev); |
99 | | - } |
100 | | - |
101 | | - // Enable audio interaction and reset error count when opening |
102 | | - if (!isOpen) { |
103 | | - // Enable audio interaction |
104 | | - const event = new MouseEvent('click', { |
105 | | - view: window, |
106 | | - bubbles: true, |
107 | | - cancelable: true, |
108 | | - }); |
109 | | - document.dispatchEvent(event); |
110 | | - |
111 | | - // Reset error count on new session |
112 | | - setErrorCount(0); |
113 | | - } |
114 | | - }, [isOpen, isOpenProp, onClose, onToggle]); |
115 | | - |
116 | | - // Enhanced message display with typing animation |
117 | | - const displayMessageWithTyping = useCallback( |
118 | | - (text, userInput) => { |
119 | | - // Check if this is a pre-computed instant response (contains HTML) |
120 | | - const isInstantResponse = |
121 | | - text.includes('<div') || text.includes('<p class='); |
122 | | - |
123 | | - if (isInstantResponse) { |
124 | | - // Show instant response without typing animation |
125 | | - setMessages(prev => [...prev, { role: 'model', content: text }]); |
126 | | - addToHistory({ question: userInput, answer: text }); |
127 | | - return; |
128 | | - } |
129 | | - |
130 | | - // For other responses, use typing animation |
131 | | - let displayed = ''; |
132 | | - let i = 0; |
133 | | - |
134 | | - setIsTyping(true); |
135 | | - setMessages(prev => [...prev, { role: 'model', content: '' }]); |
136 | | - |
137 | | - const interval = setInterval(() => { |
138 | | - const now = Date.now(); |
139 | | - |
140 | | - if (i < text.length) { |
141 | | - // Play typing sound with rate limiting |
142 | | - if (now - lastTypingSoundRef.current >= 200 && isAudioEnabled) { |
143 | | - playTypingSound(); |
144 | | - lastTypingSoundRef.current = now; |
145 | | - } |
146 | | - |
147 | | - displayed += text[i]; |
148 | | - i++; |
149 | | - |
150 | | - setMessages(prev => { |
151 | | - const last = prev[prev.length - 1]; |
152 | | - if (last.role === 'model') { |
153 | | - return [...prev.slice(0, -1), { ...last, content: displayed }]; |
154 | | - } |
155 | | - return prev; |
156 | | - }); |
157 | | - } else { |
158 | | - clearInterval(interval); |
159 | | - setTypingInterval(null); |
160 | | - setIsTyping(false); |
161 | | - addToHistory({ question: userInput, answer: text }); |
162 | | - } |
163 | | - }, 20); // Faster typing for better responsiveness |
164 | | - |
165 | | - setTypingInterval(interval); |
166 | | - }, |
167 | | - [isAudioEnabled, playTypingSound, addToHistory] |
168 | | - ); |
169 | | - |
170 | | - // Enhanced error handling with better user feedback |
171 | | - const handleError = useCallback( |
172 | | - error => { |
173 | | - console.error('Error: Chat Error:', error); |
174 | | - setErrorCount(prev => prev + 1); |
175 | | - |
176 | | - let errorMessage = ''; |
177 | | - |
178 | | - if (error.message?.includes('TIMEOUT_ERROR')) { |
179 | | - errorMessage = ` |
180 | | - <div class="animate-fade-in space-y-1 max-w-full"> |
181 | | - <p class="m-0 text-orange-400"> Request Timeout</p> |
182 | | - <p class="m-0 text-sm">The request took too long to process. Let me help you with local knowledge instead!</p> |
183 | | - <p class="m-0 text-xs text-blue-300">Tip: Try asking about specific algorithms!</p> |
184 | | - </div>`; |
185 | | - } else if (error.message?.includes('NETWORK_ERROR')) { |
186 | | - errorMessage = ` |
187 | | - <div class="animate-fade-in space-y-1 max-w-full"> |
188 | | - <p class="m-0 text-yellow-400"> Connection Issue</p> |
189 | | - <p class="m-0 text-sm">I'm having trouble connecting. Let me help you with local knowledge instead!</p> |
190 | | - <p class="m-0 text-xs text-blue-300">Tip: Try asking about specific algorithms!</p> |
191 | | - </div>`; |
192 | | - } else if (error.message?.includes('RATE_LIMIT')) { |
193 | | - errorMessage = ` |
194 | | - <div class="animate-fade-in space-y-1 max-w-full"> |
195 | | - <p class="m-0 text-orange-400"> Rate Limit Reached</p> |
196 | | - <p class="m-0 text-sm">I'm getting too many requests. Please wait a moment and try again!</p> |
197 | | - <p class="m-0 text-xs text-blue-300">Tip: In the meantime, try exploring the algorithms above!</p> |
198 | | - </div>`; |
199 | | - } else if (error.message?.includes('SERVER_ERROR')) { |
200 | | - errorMessage = ` |
201 | | - <div class="animate-fade-in space-y-1 max-w-full"> |
202 | | - <p class="m-0 text-red-400"> Server Issue</p> |
203 | | - <p class="m-0 text-sm">There's a temporary server issue. Let me help you with local knowledge instead!</p> |
204 | | - <p class="m-0 text-xs text-blue-300">Tip: Try asking about specific algorithms!</p> |
205 | | - </div>`; |
206 | | - } else { |
207 | | - errorMessage = |
208 | | - errorCount > 2 |
209 | | - ? ` |
210 | | - <div class="animate-fade-in space-y-1 max-w-full"> |
211 | | - <p class="m-0 text-red-400"> Persistent Issue</p> |
212 | | - <p class="m-0 text-sm">I'm having trouble connecting. Please try again later or refresh the page.</p> |
213 | | - <p class="m-0 text-xs text-blue-300">Tip: In the meantime, explore the algorithms above!</p> |
214 | | - </div>` |
215 | | - : ` |
216 | | - <div class="animate-fade-in space-y-1 max-w-full"> |
217 | | - <p class="m-0 text-yellow-400"> Temporary Issue</p> |
218 | | - <p class="m-0 text-sm">I encountered an error. Let me try to help you again.</p> |
219 | | - <p class="m-0 text-xs text-blue-300">Tip: Try asking about specific algorithms!</p> |
220 | | - </div>`; |
221 | | - } |
222 | | - |
223 | | - setMessages(prev => [ |
224 | | - ...prev, |
225 | | - { |
226 | | - role: 'error', |
227 | | - content: errorMessage, |
228 | | - }, |
229 | | - ]); |
230 | | - |
231 | | - setIsTyping(false); |
232 | | - }, |
233 | | - [errorCount] |
234 | | - ); |
235 | | - |
236 | | - // Enhanced message sending with validation and retry mechanism |
237 | | - const handleSend = useCallback( |
238 | | - async (retryAttempt = 0) => { |
239 | | - const trimmedInput = input.trim(); |
240 | | - if (!trimmedInput || isTyping) return; |
241 | | - |
242 | | - // Clear previous interval if exists |
243 | | - if (typingInterval) { |
244 | | - clearInterval(typingInterval); |
245 | | - setTypingInterval(null); |
246 | | - } |
247 | | - |
248 | | - setInput(''); |
249 | | - setMessages(prev => [...prev, { role: 'user', content: trimmedInput }]); |
250 | | - |
251 | | - // Show immediate feedback for instant responses |
252 | | - const isInstantQuery = |
253 | | - /^(support|creator|github|help|thank|hi|hello)$/i.test(trimmedInput); |
254 | | - if (isInstantQuery) { |
255 | | - // Add a brief loading indicator |
256 | | - setMessages(prev => [ |
257 | | - ...prev, |
258 | | - { |
259 | | - role: 'model', |
260 | | - content: '<div class="animate-pulse text-slate-400">...</div>', |
261 | | - }, |
262 | | - ]); |
263 | | - } |
264 | | - |
265 | | - try { |
266 | | - const context = { |
267 | | - ...getContextObject(), |
268 | | - uiLanguage: language || 'en', |
269 | | - }; |
270 | | - if (process.env.NODE_ENV === 'development') { |
271 | | - console.log('Context: Context passed to assistant:', context); |
272 | | - } |
273 | | - |
274 | | - const result = await processMessage(trimmedInput, context); |
275 | | - |
276 | | - if (result.type === 'response') { |
277 | | - // Remove loading indicator if it exists |
278 | | - setMessages(prev => { |
279 | | - const filtered = prev.filter(msg => !msg.content.includes('...')); |
280 | | - return filtered; |
281 | | - }); |
282 | | - |
283 | | - displayMessageWithTyping(result.content, trimmedInput); |
284 | | - setRetryCount(0); // Reset retry count on success |
285 | | - } else { |
286 | | - handleError(new Error('Invalid response type')); |
287 | | - } |
288 | | - } catch (error) { |
289 | | - // Remove loading indicator on error |
290 | | - setMessages(prev => { |
291 | | - const filtered = prev.filter(msg => !msg.content.includes('...')); |
292 | | - return filtered; |
293 | | - }); |
294 | | - |
295 | | - // Retry logic for certain errors |
296 | | - if ( |
297 | | - retryAttempt < 2 && |
298 | | - (error.message?.includes('NETWORK_ERROR') || |
299 | | - error.message?.includes('TIMEOUT_ERROR') || |
300 | | - error.message?.includes('SERVER_ERROR')) |
301 | | - ) { |
302 | | - setRetryCount(prev => prev + 1); |
303 | | - setMessages(prev => [ |
304 | | - ...prev, |
305 | | - { |
306 | | - role: 'error', |
307 | | - content: ` |
308 | | - <div class="animate-fade-in space-y-1 max-w-full"> |
309 | | - <p class="m-0 text-yellow-400"> Retrying... (${retryAttempt + 1}/2)</p> |
310 | | - <p class="m-0 text-sm">Let me try that again for you.</p> |
311 | | - </div>`, |
312 | | - }, |
313 | | - ]); |
314 | | - |
315 | | - // Wait a bit before retrying |
316 | | - setTimeout( |
317 | | - () => { |
318 | | - handleSend(retryAttempt + 1); |
319 | | - }, |
320 | | - 1000 * (retryAttempt + 1) |
321 | | - ); // Exponential backoff |
322 | | - } else { |
323 | | - handleError(error); |
324 | | - } |
325 | | - } |
326 | | - }, |
327 | | - [ |
328 | | - input, |
329 | | - isTyping, |
330 | | - typingInterval, |
331 | | - language, |
332 | | - getContextObject, |
333 | | - displayMessageWithTyping, |
334 | | - handleError, |
335 | | - ] |
336 | | - ); |
| 13 | + const { |
| 14 | + controlledIsOpen, |
| 15 | + setIsOpenState, |
| 16 | + input, |
| 17 | + setInput, |
| 18 | + messages, |
| 19 | + isTyping, |
| 20 | + messagesEndRef, |
| 21 | + toggleChat, |
| 22 | + handleSend, |
| 23 | + } = useChatAssistantController({ |
| 24 | + isOpenProp, |
| 25 | + onClose, |
| 26 | + onToggle, |
| 27 | + }); |
| 28 | + const isOpen = controlledIsOpen; |
337 | 29 |
|
338 | 30 | if (isMobileOverlayVisible) return null; |
339 | 31 |
|
|
0 commit comments