class AIDrawer { constructor() { this.isOpen = false; this.fontSize = 'normal'; this.avatarType = 'default'; this.sessionId = null; this.currentEventSource = null; // Для SSE соединения this.drawerWidth = 400; // Текущая ширина drawer this.isResizing = false; // Флаг перетаскивания this.init(); // Загружаем историю сразу при инициализации (при загрузке страницы) // чтобы когда пользователь откроет drawer - история уже была готова setTimeout(() => { this.preloadChatHistory(); }, 2000); } init() { console.log('AI Drawer: Простая инициализация начата'); // Создаем простой HTML без inline стилей const drawerHTML = '' + '
' + '
' + '
' + 'AI Ассистент' + '' + '
' + '
' + '' + '' + '' + '' + '' + '
' + '
' + '' + '' + '' + '' + '' + '
' + '
' + '
' + '
' + '
' + '
' + '

Привет! Я ваш AI ассистент. Чем могу помочь?

' + '
' + new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', hour12: false // 24-часовой формат }) + '
' + '
' + '
' + '
' + '
' + '
' + '' + '' + '
' + '
' + '
' + '
' + '
Обрабатываю запрос...
' + '
'; // Добавляем в DOM document.body.insertAdjacentHTML('beforeend', drawerHTML); console.log('AI Drawer: HTML добавлен в DOM'); // Находим элементы this.drawer = document.querySelector('.ai-drawer'); this.toggleBtn = document.querySelector('.ai-drawer-toggle'); this.closeBtn = document.querySelector('.ai-drawer-close'); this.loadingOverlay = document.querySelector('.ai-loading-overlay'); this.fontButtons = document.querySelectorAll('.font-btn'); this.avatarButtons = document.querySelectorAll('.avatar-btn'); this.chatInput = document.querySelector('#ai-chat-input'); this.sendButton = document.querySelector('#ai-send-button'); this.resizeHandle = document.querySelector('.ai-drawer-resize-handle'); // Обработчики событий if (this.toggleBtn) { this.toggleBtn.onclick = () => this.toggle(); } if (this.closeBtn) { this.closeBtn.onclick = () => this.close(); } // Обработчики для кнопок управления шрифтом this.fontButtons.forEach(button => { button.onclick = () => { const size = button.dataset.size; this.setFontSize(size); this.fontButtons.forEach(btn => btn.classList.remove('active')); button.classList.add('active'); }; }); // Обработчики для кнопок управления аватаркой this.avatarButtons.forEach(button => { button.onclick = () => { const type = button.dataset.type; this.setAvatarType(type); this.avatarButtons.forEach(btn => btn.classList.remove('active')); button.classList.add('active'); }; }); // Обработчики для поля ввода if (this.sendButton && this.chatInput) { this.sendButton.onclick = () => this.sendMessage(); this.chatInput.onkeypress = (e) => { if (e.key === 'Enter') { e.preventDefault(); this.sendMessage(); } }; } // Восстанавливаем настройки this.restoreSettings(); // Инициализируем изменение размера this.initResize(); // Обработчик изменения размера окна - ограничиваем ширину если нужно window.addEventListener('resize', () => { if (this.isOpen && this.drawerWidth > window.innerWidth / 2) { const maxWidth = window.innerWidth / 2; this.setDrawerWidth(maxWidth); } }); console.log('AI Drawer: Простая инициализация завершена'); } initResize() { if (!this.resizeHandle || !this.drawer) return; // Загружаем сохраненную ширину const savedWidth = localStorage.getItem('ai-drawer-width'); if (savedWidth) { const width = parseInt(savedWidth, 10); const maxWidth = window.innerWidth / 2; // Проверяем что ширина в допустимых пределах для текущего экрана if (width >= 300 && width <= maxWidth) { this.setDrawerWidth(width); } else if (width > maxWidth) { // Если сохраненная ширина больше максимума - ограничиваем console.log('AI Drawer: Saved width', width, 'exceeds max', maxWidth, ', adjusting'); this.setDrawerWidth(maxWidth); } else { // Если меньше минимума - устанавливаем минимум console.log('AI Drawer: Saved width', width, 'is less than minimum, setting to 300'); this.setDrawerWidth(300); } } // Обработчики для перетаскивания this.resizeHandle.addEventListener('mousedown', (e) => { e.preventDefault(); this.startResize(e); }); } startResize(e) { this.isResizing = true; this.drawer.classList.add('resizing'); document.body.style.cursor = 'ew-resize'; document.body.style.userSelect = 'none'; const startX = e.clientX; const startWidth = this.drawerWidth; const doResize = (e) => { if (!this.isResizing) return; const diff = startX - e.clientX; // Инвертируем, т.к. drawer справа const newWidth = Math.max(300, Math.min(window.innerWidth / 2, startWidth + diff)); this.setDrawerWidth(newWidth); }; const stopResize = () => { this.isResizing = false; this.drawer.classList.remove('resizing'); document.body.style.cursor = ''; document.body.style.userSelect = ''; document.removeEventListener('mousemove', doResize); document.removeEventListener('mouseup', stopResize); // Сохраняем ширину localStorage.setItem('ai-drawer-width', this.drawerWidth.toString()); }; document.addEventListener('mousemove', doResize); document.addEventListener('mouseup', stopResize); } setDrawerWidth(width) { if (this.drawer) { // Ограничиваем ширину максимум до половины экрана и минимум 300px const maxWidth = window.innerWidth / 2; const finalWidth = Math.max(300, Math.min(maxWidth, width)); this.drawerWidth = finalWidth; this.drawer.style.width = finalWidth + 'px'; // Обновляем margin для main-container const mainContainer = document.querySelector('.main-container'); if (mainContainer && this.isOpen) { mainContainer.style.setProperty('--drawer-width', finalWidth + 'px'); mainContainer.style.marginRight = finalWidth + 'px'; mainContainer.setAttribute('data-drawer-width', finalWidth); } } } toggle() { if (this.isOpen) { this.close(); } else { this.open(); } } open() { console.log('AI Drawer: Opening drawer'); if (this.drawer) { // Проверяем и корректируем ширину перед открытием const maxWidth = window.innerWidth / 2; if (this.drawerWidth > maxWidth) { console.log('AI Drawer: Adjusting width from', this.drawerWidth, 'to', maxWidth); this.setDrawerWidth(maxWidth); } else if (this.drawerWidth < 300) { console.log('AI Drawer: Adjusting width from', this.drawerWidth, 'to 300'); this.setDrawerWidth(300); } // Убеждаемся что ширина применена к drawer this.drawer.style.width = this.drawerWidth + 'px'; // Убеждаемся что drawer правильно позиционирован перед открытием this.drawer.style.right = '0'; this.drawer.style.transform = 'translateX(0)'; this.drawer.classList.add('open'); } document.body.classList.add('ai-drawer-open'); this.isOpen = true; // Обновляем margin для main-container с текущей шириной const mainContainer = document.querySelector('.main-container'); if (mainContainer) { mainContainer.style.setProperty('--drawer-width', this.drawerWidth + 'px'); mainContainer.style.marginRight = this.drawerWidth + 'px'; mainContainer.setAttribute('data-drawer-width', this.drawerWidth); } // Прокручиваем вниз к последнему сообщению при открытии const scrollToBottomOnOpen = () => { const drawerContent = this.drawer?.querySelector('.ai-drawer-content'); const chatMessages = this.drawer?.querySelector('.ai-chat-messages'); if (drawerContent) { const scroll = () => { drawerContent.scrollTop = drawerContent.scrollHeight; console.log('AI Drawer: Scrolled on open, scrollTop:', drawerContent.scrollTop, 'scrollHeight:', drawerContent.scrollHeight); }; // Прокручиваем последнее сообщение в видимую область if (chatMessages && chatMessages.lastElementChild) { chatMessages.lastElementChild.scrollIntoView({ behavior: 'smooth', block: 'end' }); } requestAnimationFrame(() => { scroll(); requestAnimationFrame(scroll); }); } }; // Прокручиваем после анимации открытия setTimeout(scrollToBottomOnOpen, 300); setTimeout(scrollToBottomOnOpen, 600); // История уже загружена при инициализации страницы // Не нужно дополнительных запросов при открытии } close() { console.log('AI Drawer: Closing drawer'); if (this.drawer) { this.drawer.classList.remove('open'); // Убеждаемся что drawer скрыт через transform if (!this.drawer.classList.contains('open')) { this.drawer.style.transform = 'translateX(100%)'; } } document.body.classList.remove('ai-drawer-open'); this.isOpen = false; // Убираем margin у main-container const mainContainer = document.querySelector('.main-container'); if (mainContainer) { mainContainer.style.marginRight = ''; mainContainer.removeAttribute('data-drawer-width'); } } setFontSize(size) { if (this.drawer) { this.drawer.classList.remove('font-small', 'font-normal', 'font-large', 'font-extra-large'); this.drawer.classList.add('font-' + size); } this.fontSize = size; localStorage.setItem('ai-drawer-font-size', size); } setAvatarType(type) { this.avatarType = type; // Обновляем существующие аватарки ассистента const existingAvatars = this.drawer.querySelectorAll('.ai-avatar.assistant'); existingAvatars.forEach(avatar => { avatar.classList.remove('friendly', 'helpful', 'smart'); if (type !== 'default') { avatar.classList.add(type); } }); localStorage.setItem('ai-drawer-avatar-type', type); } showLoading(message = 'Обрабатываю запрос...') { if (this.loadingOverlay) { const textElement = this.loadingOverlay.querySelector('div:last-child'); if (textElement) { textElement.textContent = message; } this.loadingOverlay.classList.add('show'); } } hideLoading() { if (this.loadingOverlay) { this.loadingOverlay.classList.remove('show'); } } // Функция для определения читаемого текста ссылки на основе URL getLinkText(url) { const urlLower = url.toLowerCase(); const maxLength = 60; // Максимальная длина ссылки до замены // Если ссылка короткая, показываем её полностью if (url.length <= maxLength) { return url; } // Анализируем URL и определяем тип действия if (urlLower.includes('download') || urlLower.includes('file') || urlLower.includes('скачать')) { return '📥 Скачать документ'; } if (urlLower.includes('edit') || urlLower.includes('редактир') || urlLower.includes('onlyoffice')) { return '✏️ Открыть для редактирования'; } if (urlLower.includes('view') || urlLower.includes('просмотр') || urlLower.includes('preview')) { return '👁️ Открыть для просмотра'; } if (urlLower.includes('document') || urlLower.includes('документ') || urlLower.includes('.docx') || urlLower.includes('.pdf')) { return '📄 Открыть документ'; } if (urlLower.includes('create') || urlLower.includes('создать')) { return '➕ Создать документ'; } // Общие варианты для длинных ссылок const linkTexts = [ '🔗 Открыть ссылку', '👉 Смотреть здесь', '📋 Подробнее', '🔍 Перейти к документу', '📎 Открыть' ]; // Выбираем случайный вариант для разнообразия return linkTexts[Math.floor(Math.random() * linkTexts.length)]; } // Функция для преобразования URL в кликабельные ссылки convertUrlsToLinks(text) { if (!text) return ''; let result = text; // ШАГ 1: Обрабатываем Markdown ссылки [текст](url) const markdownLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; result = result.replace(markdownLinkRegex, (match, linkText, url) => { // Проверяем, что это валидный URL if (url.match(/^https?:\/\//i)) { return `${linkText}`; } return match; // Если не URL, оставляем как есть }); // ШАГ 2: Временно заменяем уже существующие HTML-ссылки на плейсхолдеры const htmlLinks = []; const htmlLinkRegex = /]*href\s*=\s*["']([^"']+)["'][^>]*>([^<]*)<\/a>/gi; result = result.replace(htmlLinkRegex, (match, href, linkText) => { const placeholder = `__HTML_LINK_${htmlLinks.length}__`; htmlLinks.push({ href, linkText, match }); return placeholder; }); // ШАГ 3: Экранируем оставшийся HTML для безопасности const escaped = result .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); // ШАГ 4: Восстанавливаем HTML-ссылки (плейсхолдеры не экранированы, т.к. не содержат < >) let finalResult = escaped; htmlLinks.forEach((link, index) => { const placeholder = `__HTML_LINK_${index}__`; // Используем оригинальную ссылку, но добавляем класс если его нет let htmlLink = link.match; if (!htmlLink.includes('class=')) { htmlLink = htmlLink.replace('"{}|\\^`\[\]]+)/gi; let urlMatches = []; let match; // Сначала находим все URL и проверяем их контекст while ((match = urlRegex.exec(finalResult)) !== null) { const url = match[0]; const offset = match.index; const beforeMatch = finalResult.substring(0, offset); // Проверяем, нет ли открывающего тега '); // Если есть открывающий тег lastCloseTag) { continue; // Пропускаем, это уже часть ссылки } // Проверяем, не является ли это частью href атрибута if (beforeMatch.lastIndexOf('href=') > lastCloseTag) { continue; // Пропускаем, это часть href } urlMatches.push({ url, offset }); } // Заменяем URL в обратном порядке (чтобы не сбить индексы) for (let i = urlMatches.length - 1; i >= 0; i--) { const { url, offset } = urlMatches[i]; const linkText = this.getLinkText(url); const linkHtml = `${linkText}`; finalResult = finalResult.substring(0, offset) + linkHtml + finalResult.substring(offset + url.length); } return finalResult; } addMessage(text, isUser = false, customTime = null) { console.log('AI Drawer: addMessage called with:', {text: text.substring(0, 50), isUser, customTime}); // Ищем контейнер сообщений - может быть .ai-chat-messages или .ai-drawer-content const chatMessages = this.drawer.querySelector('.ai-chat-messages'); const drawerContent = this.drawer.querySelector('.ai-drawer-content'); const content = chatMessages || drawerContent; console.log('AI Drawer: Container search results:', { chatMessages: !!chatMessages, drawerContent: !!drawerContent, selectedContent: !!content, drawerExists: !!this.drawer }); if (content) { const messageDiv = document.createElement('div'); messageDiv.className = `ai-message ${isUser ? 'user' : 'assistant'}`; // Создаем аватарку const avatarDiv = document.createElement('div'); let avatarClass = `ai-avatar ${isUser ? 'user' : 'assistant'}`; if (!isUser && this.avatarType !== 'default') { avatarClass += ` ${this.avatarType}`; } avatarDiv.className = avatarClass; if (isUser) { avatarDiv.textContent = '👤'; } // Создаем контейнер для контента const contentDiv = document.createElement('div'); contentDiv.className = 'ai-message-content'; const textDiv = document.createElement('p'); // Преобразуем URL в кликабельные ссылки textDiv.innerHTML = this.convertUrlsToLinks(text); contentDiv.appendChild(textDiv); const timeDiv = document.createElement('div'); timeDiv.className = 'ai-message-time'; if (customTime) { // Если передано время из истории, используем его try { // Логируем для отладки console.log('AI Drawer: Parsing timestamp:', customTime); const historyTime = new Date(customTime); // Проверяем что дата валидна if (isNaN(historyTime.getTime())) { // Если дата невалидна, пытаемся распарсить как строку времени (старый формат) console.warn('AI Drawer: Invalid timestamp format:', customTime, 'Parsed as:', historyTime); timeDiv.textContent = customTime; // Показываем как есть } else { // Определяем, нужно ли показывать дату const now = new Date(); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const messageDate = new Date(historyTime.getFullYear(), historyTime.getMonth(), historyTime.getDate()); const isToday = messageDate.getTime() === today.getTime(); let formattedTime; if (isToday) { // Если сообщение сегодня - показываем только время formattedTime = historyTime.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', hour12: false }); } else { // Если сообщение не сегодня - показываем дату и время const dateStr = historyTime.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }); const timeStr = historyTime.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', hour12: false }); formattedTime = `${dateStr} ${timeStr}`; } console.log('AI Drawer: Successfully formatted timestamp:', customTime, '->', formattedTime); timeDiv.textContent = formattedTime; } } catch (error) { console.error('AI Drawer: Error parsing timestamp:', customTime, error); timeDiv.textContent = customTime || new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', hour12: false // 24-часовой формат }); } } else { timeDiv.textContent = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', hour12: false // 24-часовой формат }); } contentDiv.appendChild(timeDiv); messageDiv.appendChild(avatarDiv); messageDiv.appendChild(contentDiv); content.appendChild(messageDiv); content.scrollTop = content.scrollHeight; console.log('AI Drawer: Message successfully added to DOM'); // Сохранение происходит автоматически в n8n, поэтому не дублируем } else { console.error('AI Drawer: Content container not found! Cannot add message.'); } } addStreamingMessage(text, isUser = false, speed = 30) { // Ищем контейнер сообщений - может быть .ai-chat-messages или .ai-drawer-content const content = this.drawer.querySelector('.ai-chat-messages') || this.drawer.querySelector('.ai-drawer-content'); if (content) { const messageDiv = document.createElement('div'); messageDiv.className = `ai-message ${isUser ? 'user' : 'assistant'}`; // Создаем аватарку const avatarDiv = document.createElement('div'); let avatarClass = `ai-avatar ${isUser ? 'user' : 'assistant'}`; if (!isUser && this.avatarType !== 'default') { avatarClass += ` ${this.avatarType}`; } avatarDiv.className = avatarClass; if (isUser) { avatarDiv.textContent = '👤'; } // Создаем контейнер для контента const contentDiv = document.createElement('div'); contentDiv.className = 'ai-message-content'; const textDiv = document.createElement('p'); textDiv.textContent = ''; contentDiv.appendChild(textDiv); const timeDiv = document.createElement('div'); timeDiv.className = 'ai-message-time'; timeDiv.textContent = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', hour12: false // 24-часовой формат }); contentDiv.appendChild(timeDiv); messageDiv.appendChild(avatarDiv); messageDiv.appendChild(contentDiv); content.appendChild(messageDiv); content.scrollTop = content.scrollHeight; // Запускаем стриминг this.streamText(textDiv, text, speed); } } streamText(element, text, speed = 30) { let index = 0; let currentText = ''; const interval = setInterval(() => { if (index < text.length) { currentText += text[index]; // Преобразуем URL в кликабельные ссылки по мере добавления текста element.innerHTML = this.convertUrlsToLinks(currentText); index++; const content = this.drawer.querySelector('.ai-drawer-content'); if (content) { content.scrollTop = content.scrollHeight; } } else { clearInterval(interval); } }, speed); } showTypingIndicator() { // Ищем контейнер сообщений - может быть .ai-chat-messages или .ai-drawer-content const content = this.drawer.querySelector('.ai-chat-messages') || this.drawer.querySelector('.ai-drawer-content'); if (content) { const existingIndicator = content.querySelector('.ai-typing-indicator'); if (existingIndicator) { existingIndicator.remove(); } const messageDiv = document.createElement('div'); messageDiv.className = 'ai-message assistant'; const avatarDiv = document.createElement('div'); let avatarClass = `ai-avatar assistant`; if (this.avatarType !== 'default') { avatarClass += ` ${this.avatarType}`; } avatarDiv.className = avatarClass; const contentDiv = document.createElement('div'); contentDiv.className = 'ai-message-content'; const typingDiv = document.createElement('div'); typingDiv.className = 'ai-typing-indicator'; typingDiv.innerHTML = `
печатает... `; contentDiv.appendChild(typingDiv); messageDiv.appendChild(avatarDiv); messageDiv.appendChild(contentDiv); content.appendChild(messageDiv); content.scrollTop = content.scrollHeight; return messageDiv; } } hideTypingIndicator() { // Ищем контейнер сообщений - может быть .ai-chat-messages или .ai-drawer-content const content = this.drawer.querySelector('.ai-chat-messages') || this.drawer.querySelector('.ai-drawer-content'); if (content) { const typingIndicator = content.querySelector('.ai-typing-indicator'); if (typingIndicator) { const messageDiv = typingIndicator.closest('.ai-message'); if (messageDiv) { messageDiv.remove(); } } } } sendMessage() { if (!this.chatInput || !this.sendButton) return; const message = this.chatInput.value.trim(); if (!message) return; console.log('AI Drawer: Sending message:', message); this.addMessage(message, true); this.chatInput.value = ''; this.sendToN8N(message); } async sendToN8N(message) { try { console.log('AI Drawer: Sending to n8n:', message); this.showTypingIndicator(); const context = this.getCurrentContext(); // Используем существующую сессию или создаем новую только один раз if (!this.sessionId) { this.sessionId = 'ai-drawer-session-' + Date.now(); console.log('AI Drawer: Created new session:', this.sessionId); } else { console.log('AI Drawer: Reusing existing session:', this.sessionId); } const sessionId = this.sessionId; const response = await fetch('/aiassist/n8n_proxy.php', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ message: message, context: context, sessionId: sessionId }) }); if (!response.ok) { throw new Error(`N8N Proxy error: ${response.status}`); } const data = await response.json(); console.log('AI Drawer: n8n proxy response:', data); console.log('AI Drawer: data.success =', data.success, 'type:', typeof data.success); if (data.success && data.task_id) { // Запрос принят, подписываемся на SSE события через Redis console.log('AI Drawer: Request accepted, task_id:', data.task_id); this.startSSEListener(data.task_id); } else { throw new Error(data.message || 'Unknown error'); } } catch (error) { console.error('AI Drawer: n8n error:', error); this.hideLoading(); this.hideTypingIndicator(); this.addStreamingMessage('Извините, произошла ошибка при обработке запроса. Попробуйте еще раз.', false, 25); } } // Метод для подписки на SSE события через Redis Pub/Sub startSSEListener(taskId) { console.log('AI Drawer: Starting SSE listener for task:', taskId); // Закрываем предыдущее соединение если есть if (this.currentEventSource) { this.currentEventSource.close(); } // Флаг для отслеживания получения ответа let responseReceived = false; // Создаем новое SSE соединение const sseUrl = `/aiassist/ai_sse.php?task_id=${encodeURIComponent(taskId)}`; this.currentEventSource = new EventSource(sseUrl); // Обработчик подключения this.currentEventSource.onopen = () => { console.log('AI Drawer: SSE connection opened'); }; // Обработчик получения ответа this.currentEventSource.addEventListener('response', (event) => { try { const data = JSON.parse(event.data); console.log('AI Drawer: Received response via SSE:', data); if (data.data && data.data.response) { responseReceived = true; // Отмечаем что получили ответ this.hideLoading(); this.hideTypingIndicator(); this.addStreamingMessage(data.data.response, false, 25); this.currentEventSource.close(); this.currentEventSource = null; } } catch (error) { console.error('AI Drawer: SSE response parse error:', error); } }); // Обработчик ошибок SSE (стандартное событие) this.currentEventSource.onerror = (event) => { console.error('AI Drawer: SSE connection error:', event); console.log('AI Drawer: SSE readyState:', this.currentEventSource?.readyState); // НЕ вызываем fallback если уже получили ответ (SSE закрывается после отправки) if (responseReceived) { console.log('AI Drawer: Response already received, ignoring SSE close error'); return; } // Fallback на polling только если SSE действительно не работает if (this.currentEventSource && this.currentEventSource.readyState === EventSource.CLOSED) { console.log('AI Drawer: SSE closed without response, falling back to Redis check'); this.currentEventSource.close(); this.currentEventSource = null; // Вместо polling БД проверяем Redis напрямую this.checkRedisDirectly(taskId); } }; // Обработчик кастомных ошибок от сервера this.currentEventSource.addEventListener('error', (event) => { try { const data = JSON.parse(event.data); console.error('AI Drawer: SSE error event from server:', data); if (data.data && data.data.error) { responseReceived = true; // Отмечаем что получили ответ (даже если ошибка) this.hideLoading(); this.hideTypingIndicator(); this.addStreamingMessage(data.data.error || 'Произошла ошибка при обработке запроса.', false, 25); this.currentEventSource.close(); this.currentEventSource = null; } } catch (error) { // Игнорируем ошибки парсинга } }); // Обработчик heartbeat (поддержание соединения) this.currentEventSource.addEventListener('heartbeat', (event) => { console.log('AI Drawer: SSE heartbeat received'); }); // Обработчик подключения this.currentEventSource.addEventListener('connected', (event) => { console.log('AI Drawer: SSE connected:', event.data); }); // Таймаут на случай если SSE не работает (fallback на Redis check) setTimeout(() => { if (this.currentEventSource && this.currentEventSource.readyState === EventSource.CONNECTING && !responseReceived) { console.log('AI Drawer: SSE timeout, checking Redis directly'); this.currentEventSource.close(); this.currentEventSource = null; this.checkRedisDirectly(taskId); } }, 5000); // 5 секунд на подключение // Общий таймаут ожидания ответа (5 минут) setTimeout(() => { if (this.currentEventSource && !responseReceived) { this.currentEventSource.close(); this.currentEventSource = null; this.hideLoading(); this.hideTypingIndicator(); this.addStreamingMessage('Время ожидания истекло. Попробуйте еще раз.', false, 25); } }, 300000); } // Метод для прямой проверки Redis (если SSE не работает) async checkRedisDirectly(taskId) { console.log('AI Drawer: Checking Redis directly for task:', taskId); // Проверяем несколько раз с интервалом (на случай если ответ еще обрабатывается) let attempts = 0; const maxAttempts = 30; // 30 попыток = 1 минута (каждые 2 секунды) const checkInterval = setInterval(async () => { attempts++; console.log(`AI Drawer: Redis check attempt ${attempts}/${maxAttempts}`); try { const response = await fetch(`/aiassist/check_redis_response.php?task_id=${encodeURIComponent(taskId)}`); if (!response.ok) { throw new Error(`Redis check failed: ${response.status}`); } const data = await response.json(); if (data.found && data.response) { clearInterval(checkInterval); this.hideLoading(); this.hideTypingIndicator(); this.addStreamingMessage(data.response, false, 25); } else if (attempts >= maxAttempts) { clearInterval(checkInterval); this.hideLoading(); this.hideTypingIndicator(); this.addStreamingMessage('Ответ не получен. Попробуйте отправить запрос еще раз.', false, 25); } } catch (error) { console.error('AI Drawer: Redis direct check error:', error); if (attempts >= maxAttempts) { clearInterval(checkInterval); this.hideLoading(); this.hideTypingIndicator(); this.addStreamingMessage('Ошибка при получении ответа. Попробуйте еще раз.', false, 25); } } }, 2000); // Проверяем каждые 2 секунды } // Fallback метод для polling (если SSE не работает) async startPollingFallback(taskId) { console.log('AI Drawer: Starting polling fallback for task:', taskId); const pollInterval = setInterval(async () => { try { const response = await fetch(`/get_ai_result.php?task_id=${taskId}`); if (!response.ok) { throw new Error(`Result check failed: ${response.status}`); } const data = await response.json(); if (data.status === 'completed') { clearInterval(pollInterval); this.hideLoading(); this.hideTypingIndicator(); this.addStreamingMessage(data.response, false, 25); } else if (data.status === 'error') { clearInterval(pollInterval); this.hideLoading(); this.hideTypingIndicator(); this.addStreamingMessage(data.error || 'Произошла ошибка при обработке запроса.', false, 25); } } catch (error) { console.error('AI Drawer: Polling fallback error:', error); clearInterval(pollInterval); this.hideLoading(); this.hideTypingIndicator(); this.addStreamingMessage('Ошибка при получении ответа. Попробуйте еще раз.', false, 25); } }, 2000); // Проверяем каждые 2 секунды // Ограничиваем время ожидания (5 минут) setTimeout(() => { clearInterval(pollInterval); this.hideLoading(); this.hideTypingIndicator(); this.addStreamingMessage('Время ожидания истекло. Попробуйте еще раз.', false, 25); }, 300000); } getCurrentContext() { const urlParams = new URLSearchParams(window.location.search); const projectId = urlParams.get('record') || ''; const currentModule = urlParams.get('module') || ''; const currentView = urlParams.get('view') || ''; let userId = ''; let userName = ''; let userEmail = ''; if (typeof _USERMETA !== 'undefined') { userId = _USERMETA.id || ''; userName = _USERMETA.name || ''; userEmail = _USERMETA.email || ''; } let projectName = ''; try { const recordLabel = document.querySelector('.recordLabel, .record-name, h1'); if (recordLabel) { projectName = recordLabel.textContent.trim(); } } catch (e) { console.log('AI Drawer: Could not get project name:', e); } return { projectId: projectId, currentModule: currentModule, currentView: currentView, userId: userId, userName: userName, userEmail: userEmail, projectName: projectName, pageTitle: document.title || '', currentDate: new Date().toLocaleDateString('ru-RU'), url: window.location.href, timestamp: Date.now() }; } async initializeChat() { try { console.log('AI Drawer: Initializing chat with context'); const context = this.getCurrentContext(); let contextMessage = 'Привет! Я готов к работе. '; if (context.projectName) { contextMessage += `Сейчас я работаю с записью "${context.projectName}" в модуле ${context.currentModule}. `; } else if (context.currentModule) { contextMessage += `Сейчас я работаю в модуле ${context.currentModule}. `; } contextMessage += 'Чем могу помочь?'; this.showLoading('🤖 Инициализирую ассистента...'); await this.sendToN8N(contextMessage); } catch (error) { console.error('AI Drawer: Chat initialization error:', error); this.hideLoading(); this.addStreamingMessage('Привет! Я готов к работе. Чем могу помочь?', false, 25); } } restoreSettings() { const savedFontSize = localStorage.getItem('ai-drawer-font-size'); if (savedFontSize && savedFontSize !== this.fontSize) { this.setFontSize(savedFontSize); this.fontButtons.forEach(btn => { btn.classList.remove('active'); if (btn.dataset.size === savedFontSize) { btn.classList.add('active'); } }); } const savedAvatarType = localStorage.getItem('ai-drawer-avatar-type'); if (savedAvatarType && savedAvatarType !== this.avatarType) { this.setAvatarType(savedAvatarType); this.avatarButtons.forEach(btn => { btn.classList.remove('active'); if (btn.dataset.type === savedAvatarType) { btn.classList.add('active'); } }); } } // Метод для предзагрузки истории чата через n8n вебхук async preloadChatHistory() { try { console.log('AI Drawer: Preloading chat history from n8n webhook'); const context = this.getCurrentContext(); console.log('AI Drawer: Context for history:', context); // Отправляем запрос на получение истории через локальный эндпоинт const sessionId = 'ai-drawer-session-' + (context.projectId || 'default') + '-' + (context.userId || '1'); const response = await fetch('/get_chat_history.php', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ context: context, sessionId: sessionId }) }); if (!response.ok) { console.log('AI Drawer: Chat history not available from n8n:', response.status); return; } const data = await response.json(); console.log('AI Drawer: Chat history loaded from get_chat_history.php:', data); // Локальный эндпоинт возвращает объект с полем history if (data.success && Array.isArray(data.history) && data.history.length > 0) { console.log('AI Drawer: Found', data.history.length, 'history messages'); // Очищаем текущие сообщения this.clearMessages(); // Добавляем историю data.history.forEach((msg, index) => { try { console.log(`AI Drawer: Adding history message ${index + 1}:`, msg.type, msg.message.substring(0, 30) + '...'); this.addMessage(msg.message, msg.type === 'user', msg.timestamp); console.log(`AI Drawer: Successfully added message ${index + 1}`); } catch (error) { console.error('AI Drawer: Error adding history message:', error, msg); } }); // Прокручиваем вниз к последнему сообщению после загрузки истории const scrollToBottom = () => { const drawerContent = this.drawer?.querySelector('.ai-drawer-content'); const chatMessages = this.drawer?.querySelector('.ai-chat-messages'); if (drawerContent) { // Способ 1: Прокручиваем контейнер const scroll = () => { drawerContent.scrollTop = drawerContent.scrollHeight; console.log('AI Drawer: Scrolled container, scrollTop:', drawerContent.scrollTop, 'scrollHeight:', drawerContent.scrollHeight); }; // Способ 2: Прокручиваем последнее сообщение в видимую область if (chatMessages && chatMessages.lastElementChild) { chatMessages.lastElementChild.scrollIntoView({ behavior: 'smooth', block: 'end' }); console.log('AI Drawer: Scrolled last message into view'); } // Используем requestAnimationFrame для более надежной прокрутки requestAnimationFrame(() => { scroll(); requestAnimationFrame(() => { scroll(); }); }); } else { console.warn('AI Drawer: Drawer content not found for scrolling'); } }; // Прокручиваем с несколькими задержками для надежности setTimeout(scrollToBottom, 100); setTimeout(scrollToBottom, 300); setTimeout(scrollToBottom, 600); console.log('AI Drawer: Chat history restored -', data.history.length, 'messages'); } else { console.log('AI Drawer: No chat history found. Response:', data); } } catch (error) { console.error('AI Drawer: Error loading chat history from n8n:', error); } } // Метод для очистки сообщений clearMessages() { // Ищем контейнер сообщений - может быть .ai-chat-messages или .ai-drawer-content const messagesContainer = this.drawer.querySelector('.ai-chat-messages') || this.drawer.querySelector('.ai-drawer-content'); if (messagesContainer) { // Удаляем все сообщения const messages = messagesContainer.querySelectorAll('.ai-message'); messages.forEach(msg => msg.remove()); console.log('AI Drawer: Messages cleared from', messagesContainer.className); } else { console.log('AI Drawer: Messages container not found'); } } // Метод для обновления истории (при смене страницы) async refreshPreloadedHistory() { console.log('AI Drawer: Refreshing preloaded history'); await this.preloadChatHistory(); } // Сохранение сообщений происходит автоматически в n8n // Поэтому метод saveMessageToHistory не нужен } // Инициализация AI Drawer при загрузке страницы document.addEventListener('DOMContentLoaded', function() { console.log('AI Drawer: DOM loaded, initializing...'); window.aiDrawer = new AIDrawer(); console.log('AI Drawer: Initialized successfully'); });