UNPKG

@bash-dev/ai-chat-widget

Version:

Готовый к использованию AI чат-виджет для интеграции на любые сайты с поддержкой русского языка

1,421 lines (1,230 loc) 44.1 kB
/** * ИИ Чат-виджет * Готовый для интеграции на клиентские сайты */ class AIChatWidget { constructor(config = {}) { this.config = { apiUrl: config.apiUrl || 'http://localhost:3000/api', siteId: config.siteId || 'сайт 1', title: config.title || 'ИИ Помощник', subtitle: config.subtitle || 'Задайте любой вопрос 😊', primaryColor: config.primaryColor || '#1890ff', position: config.position || 'bottom-right', greeting: config.greeting || 'Привет! Чем могу помочь?', placeholder: config.placeholder || 'Введите сообщение...', showToast: config.showToast !== false, toastMessage: config.toastMessage || 'Привет! Есть вопросы? Готов помочь! 😊', toastDelay: config.toastDelay || 2000, toastDuration: config.toastDuration || 10000, ...config }; this.messages = []; this.sessionId = this.initializeSession(); this.isOpen = true; this.isLoading = false; this.toastShown = false; this.lastMessageTimestamp = null; this.pollingInterval = null; this.hasUserSentMessage = false; // Флаг для отслеживания отправки сообщений пользователем this.shouldLoadHistory = true; // Флаг для загрузки истории при восстановлении сессии this.unreadCount = 0; // Счетчик непрочитанных сообщений this.lastReadTimestamp = localStorage.getItem(`ai-chat-lastread-${this.config.siteId}`) || null; // Убрали сбор данных пользователя - сразу открываем чат this.init(); } init() { this.createStyles(); this.createWidget(); this.bindEvents(); this.validateSiteAndInit(); // Запускаем polling для получения новых сообщений после инициализации setTimeout(() => { if (this.sessionId) { this.startPolling(); } }, 1000); } async validateSiteAndInit() { try { const currentUrl = window.location.href; // Для локальных файлов или test-site показываем без валидации if (currentUrl.startsWith('file') || this.config.siteId === 'test-site') { console.log('file or test-site'); // Сразу загружаем чат без сбора данных пользователя if (this.shouldLoadHistory) { await this.loadChatHistory(); } else { this.addGreeting(); } this.initToast(); // Автооткрытие чата если включено if (this.config.autoOpen) { setTimeout(() => { this.openChat(); }, 1000); } return; } const response = await fetch(`${this.config.apiUrl}/site/${encodeURIComponent(this.config.siteId)}/validate?url=${encodeURIComponent(currentUrl)}`); console.log('response response', response); if (response.ok) { const data = await response.json(); console.log('data', data); if (data.valid) { // Сразу загружаем чат без сбора данных пользователя if (this.shouldLoadHistory) { await this.loadChatHistory(); } else { this.addGreeting(); } this.initToast(); } else { this.hideWidget(); } } else { this.hideWidget(); } } catch (error) { // Fallback для ошибок сети - показываем toast для тестов if (this.config.siteId === 'test-site') { // Сразу загружаем чат без сбора данных пользователя if (this.shouldLoadHistory) { await this.loadChatHistory(); } else { this.addGreeting(); } this.initToast(); } else { console.log('hideWidget'); this.hideWidget(); } } } hideWidget() { console.log('hideWidget'); const widget = document.querySelector('.ai-chat-widget'); if (widget) { widget.style.display = 'none'; } } generateSessionId() { // Получаем номер клиента из локального хранилища или создаем новый const clientCounterKey = `ai_chat_client_counter_${this.config.siteId}`; let clientNumber = localStorage.getItem(clientCounterKey); if (!clientNumber) { // Генерируем случайный номер от 1001 до 9999 для новых клиентов clientNumber = Math.floor(Math.random() * (9999 - 1001 + 1)) + 1001; localStorage.setItem(clientCounterKey, clientNumber); } // Создаем ID сессии с читаемым названием const timestamp = Date.now(); const randomPart = Math.random().toString(36).substr(2, 5); return `client_${clientNumber}_${timestamp}_${randomPart}`; } initializeSession() { // Пытаемся получить существующий sessionId из localStorage const storageKey = `ai_chat_session_${this.config.siteId}`; const storedSessionId = localStorage.getItem(storageKey); console.log('storedSessionId', storedSessionId); if (storedSessionId) { this.shouldLoadHistory = true; return storedSessionId; } // Создаем новую сессию const newSessionId = this.generateSessionId(); localStorage.setItem(storageKey, newSessionId); return newSessionId; } saveSessionToStorage() { const storageKey = `ai_chat_session_${this.config.siteId}`; localStorage.setItem(storageKey, this.sessionId); } // // Методы для работы с данными пользователя // loadUserInfo() { // const storageKey = `ai_chat_userinfo_${this.config.siteId}`; // const storedInfo = localStorage.getItem(storageKey); // if (storedInfo) { // try { // const userInfo = JSON.parse(storedInfo); // // Убрали проверку данных пользователя // return userInfo; // } catch (e) { // console.warn('Failed to parse stored user info:', e); // } // } // return {}; // } saveUserInfo(userInfo) { const storageKey = `ai_chat_userinfo_${this.config.siteId}`; localStorage.setItem(storageKey, JSON.stringify(userInfo)); this.userInfo = userInfo; // Убрали проверку данных пользователя } // showUserInfoForm() { // // Функция больше не используется - убрали сбор данных пользователя // return false; // const formHtml = ` // <div class="ai-chat-user-form" id="aiChatUserForm"> // <div class="user-form-header"> // <h3>👋 Добро пожаловать!</h3> // <p>Представьтесь, пожалуйста, чтобы мы могли лучше вам помочь:</p> // </div> // <form class="user-form" id="userInfoForm"> // <div class="form-group"> // <label for="userName">Ваше имя *</label> // <input type="text" id="userName" name="user_name" placeholder="Например: Иван" required // value="${this.userInfo.user_name || ''}"> // </div> // <div class="form-group"> // <label for="userEmail">Email *</label> // <input type="email" id="userEmail" name="user_email" placeholder="ivan@example.com" required // value="${this.userInfo.user_email || ''}"> // </div> // <div class="form-group"> // <label for="userPhone">Телефон (необязательно)</label> // <input type="tel" id="userPhone" name="user_phone" placeholder="+7 (999) 123-45-67" // value="${this.userInfo.user_phone || ''}"> // </div> // <div class="form-actions"> // <button type="submit" class="submit-btn">Продолжить чат</button> // </div> // </form> // </div> // `; // const messagesContainer = this.elements.messages; // messagesContainer.innerHTML = formHtml; // // Обработчик отправки формы // const form = document.getElementById('userInfoForm'); // form.addEventListener('submit', (e) => { // e.preventDefault(); // this.handleUserInfoSubmit(form); // }); // // Обработчик изменения полей для автосохранения // ['userName', 'userEmail', 'userPhone'].forEach(fieldId => { // const field = document.getElementById(fieldId); // field.addEventListener('input', () => { // this.autoSaveUserInfo(); // }); // }); // return true; // } // autoSaveUserInfo() { // const form = document.getElementById('userInfoForm'); // if (!form) return; // const formData = new FormData(form); // const userInfo = { // user_name: formData.get('user_name')?.trim() || '', // user_email: formData.get('user_email')?.trim() || '', // user_phone: formData.get('user_phone')?.trim() || '' // }; // // Сохраняем промежуточные данные // this.userInfo = userInfo; // const storageKey = `ai_chat_userinfo_${this.config.siteId}`; // localStorage.setItem(storageKey, JSON.stringify(userInfo)); // } // handleUserInfoSubmit(form) { // const formData = new FormData(form); // const userInfo = { // user_name: formData.get('user_name')?.trim(), // user_email: formData.get('user_email')?.trim(), // user_phone: formData.get('user_phone')?.trim() || '' // }; // // Валидация // if (!userInfo.user_name || userInfo.user_name.length < 2) { // this.showFormError('Пожалуйста, введите ваше имя (минимум 2 символа)'); // return; // } // if (!userInfo.user_email || !this.isValidEmail(userInfo.user_email)) { // this.showFormError('Пожалуйста, введите корректный email'); // return; // } // if (userInfo.user_phone && !this.isValidPhone(userInfo.user_phone)) { // this.showFormError('Пожалуйста, введите корректный номер телефона или оставьте поле пустым'); // return; // } // // Сохраняем данные // this.saveUserInfo(userInfo); // // Удаляем форму и показываем чат // this.hideUserInfoForm(); // // Проверяем, есть ли сохраненное сообщение для отправки // const pendingMessage = this.elements.input.value.trim(); // if (pendingMessage) { // // Отправляем сохраненное сообщение // setTimeout(() => { // this.sendMessage(); // }, 100); // } else { // // Добавляем приветствие если нет сообщения // this.addGreeting(); // } // } showFormError(message) { let errorDiv = document.querySelector('.form-error'); if (!errorDiv) { errorDiv = document.createElement('div'); errorDiv.className = 'form-error'; const form = document.getElementById('userInfoForm'); form.appendChild(errorDiv); } errorDiv.textContent = message; errorDiv.style.display = 'block'; } // hideUserInfoForm() { // const form = document.getElementById('aiChatUserForm'); // if (form) { // form.remove(); // } // } // isValidEmail(email) { // const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; // return emailRegex.test(email); // } // isValidPhone(phone) { // // Простая проверка телефона - только цифры, пробелы, скобки, дефисы и плюс // const phoneRegex = /^[\+]?[\d\s\-\(\)]{10,15}$/; // return phoneRegex.test(phone); // } createStyles() { const style = document.createElement('style'); style.textContent = this.getStyles(); document.head.appendChild(style); } getStyles() { const { primaryColor, position } = this.config; const isRight = position.includes('right'); const isBottom = position.includes('bottom'); return ` /* Защита от Tilda CSS конфликтов */ .ai-chat-widget *, .ai-chat-widget *:before, .ai-chat-widget *:after { box-sizing: border-box !important; } /* Дополнительные фиксы для Tilda */ .ai-chat-widget { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important; line-height: normal !important; display: flex; flex-direction: column; align-items: flex-end; gap: 10px; } .ai-chat-widget input, .ai-chat-widget button, .ai-chat-widget textarea { font-family: inherit !important; line-height: normal !important; } .ai-chat-widget .chat-window { border: none !important; outline: none !important; } .chat-link { padding-bottom: 10px; text-decoration: none; color:rgb(153, 152, 152); font-size: 12px; margin-top: 10px; display: block; text-align: center; text-underline-offset: 3px; text-decoration-thickness: 1px; text-decoration-color: #000; text-decoration: underline; } .ai-chat-widget { position: fixed; ${isRight ? 'right: 20px' : 'left: 20px'}; ${isBottom ? 'bottom: 20px' : 'top: 20px'}; z-index: 999999; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } .chat-toggle { position: relative; width: 60px; height: 60px; border-radius: 50%; background: ${primaryColor}; color: white; border: none; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.3); display: flex; align-items: center; justify-content: center; font-size: 24px; transition: all 0.3s ease; } .chat-toggle:hover { transform: scale(1.1); box-shadow: 0 6px 16px rgba(0,0,0,0.4); } .unread-badge { position: absolute; top: -5px; right: -5px; background: #ff4d4f; color: white; border-radius: 10px; min-width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: bold; border: 2px solid white; box-shadow: 0 2px 6px rgba(0,0,0,0.3); } .chat-window { width: 350px; height: 500px; background: white; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,0.3); display: none; flex-direction: column; overflow: hidden; position: relative; } .chat-window.open { display: flex; animation: slideUp 0.3s ease; } @keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } .chat-header { background: ${primaryColor}; color: white; padding: 15px 20px; display: flex; justify-content: space-between; align-items: center; text-align: left; } .chat-header h3 { margin: 0; font-size: 16px; font-weight: 600; } .chat-header p { margin: 2px 0 0 0; font-size: 12px; opacity: 0.9; text-align: left !important; } .chat-close { background: none; border: none; color: white; font-size: 20px; cursor: pointer; padding: 0; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; } .chat-messages { flex: 1; overflow-y: auto; padding: 15px; background: #f8f9fa; } .chat-message { margin-bottom: 15px; display: flex; align-items: flex-start; flex-direction: column; } .chat-message.user { justify-content: flex-end; align-items: flex-end; } .message-bubble { padding: 10px 15px; border-radius: 18px; word-wrap: break-word; line-height: 1.4; } .message-bubble.user { background: linear-gradient(135deg, ${primaryColor}, #0f7ae5); color: white; border-bottom-right-radius: 4px; box-shadow: 0 2px 8px rgba(24, 144, 255, 0.2); } .message-bubble.ai { background: linear-gradient(135deg, #ffffff, #f8f9fa); color: #333; border: 1px solid #e1e5e9; border-bottom-left-radius: 4px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); } .message-bubble.admin { background: linear-gradient(135deg, #fff3cd, #ffeaa7); color: #856404; border: 1px solid #ffeaa7; border-bottom-left-radius: 4px; position: relative; box-shadow: 0 2px 8px rgba(255, 193, 7, 0.2); } .sender-badge { display: inline-block; color: white; font-size: 10px; padding: 2px 6px; border-radius: 8px; margin-bottom: 4px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px; } .sender-badge.admin { background: linear-gradient(45deg, #ff6b35, #f39c12); box-shadow: 0 2px 4px rgba(255, 107, 53, 0.3); } .sender-badge.ai { background: linear-gradient(45deg, #1890ff, #40a9ff); box-shadow: 0 2px 4px rgba(24, 144, 255, 0.3); } .admin-badge { display: inline-block; background: linear-gradient(45deg, #ff6b35, #f39c12); color: white; font-size: 10px; padding: 3px 8px; border-radius: 12px; margin-bottom: 6px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; box-shadow: 0 2px 4px rgba(255, 107, 53, 0.3); } .chat-input-area { padding: 15px; padding-bottom: 0; background: white; border-top: 1px solid #e1e5e9; display: flex; gap: 10px; } .chat-input { flex: 1; border: 1px solid #e1e5e9; border-radius: 20px; padding: 10px 15px; outline: none; font-size: 14px; resize: none; min-height: 20px; max-height: 80px; } .chat-input:focus { border-color: ${primaryColor}; } .chat-send { background: ${primaryColor}; color: white; border: none; border-radius: 50%; width: 40px; height: 40px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.2s ease; } .chat-send:hover:not(:disabled) { background: #0f7ae5; } .chat-send:disabled { opacity: 0.6; cursor: not-allowed; } .typing-indicator { display: flex; align-items: center; gap: 4px; padding: 10px 15px; background: white; border: 1px solid #e1e5e9; border-radius: 18px; border-bottom-left-radius: 4px; max-width: 80px; } .typing-dot { width: 6px; height: 6px; background: #999; border-radius: 50%; animation: typing 1.4s infinite ease-in-out; } .typing-dot:nth-child(2) { animation-delay: 0.2s; } .typing-dot:nth-child(3) { animation-delay: 0.4s; } @keyframes typing { 0%, 60%, 100% { transform: translateY(0); } 30% { transform: translateY(-10px); } } .chat-toast { position: fixed; ${isRight ? 'right: 100px' : 'left: 100px'}; ${isBottom ? 'bottom: 35px' : 'top: 35px'}; background: linear-gradient(135deg, #fff, #fafbfc); color: #2c3e50; padding: 20px; border-radius: 18px; box-shadow: 0 8px 32px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.08); border: 1px solid rgba(255,255,255,0.8); max-width: 320px; min-width: 280px; z-index: 999998; opacity: 0; transform: translateY(15px) scale(0.96); transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; line-height: 1.5; display: none; backdrop-filter: blur(20px); } .toast-content { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 16px; } .toast-icon { background: linear-gradient(135deg, ${primaryColor}, #40a9ff); color: white; width: 36px; height: 36px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 18px; flex-shrink: 0; box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3); } .chat-toast.show { opacity: 1; transform: translateY(0) scale(1); display: block; } .chat-toast::before { content: ''; position: absolute; ${isRight ? 'right: 25px' : 'left: 25px'}; ${isBottom ? 'bottom: -8px' : 'top: -8px'}; width: 0; height: 0; border-left: 8px solid transparent; border-right: 8px solid transparent; ${isBottom ? 'border-top: 8px solid white;' : 'border-bottom: 8px solid white;'} } .chat-toast-close { position: absolute; top: 12px; right: 12px; background: rgba(0,0,0,0.05); border: none; color: #999; font-size: 18px; cursor: pointer; padding: 8px; line-height: 1; border-radius: 50%; transition: all 0.2s ease; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; } .chat-toast-close:hover { background: rgba(0,0,0,0.1); color: #666; transform: scale(1.1); } .chat-toast-action { background: ${primaryColor}; color: white; border: none; padding: 12px 24px; border-radius: 12px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 3px 12px rgba(24, 144, 255, 0.25); text-transform: none; letter-spacing: 0.2px; width: 100%; margin-top: 0; } .chat-toast-action:hover { background: #0f7ae5; transform: translateY(-1px); box-shadow: 0 4px 16px rgba(24, 144, 255, 0.35); } /* Стили для формы сбора данных пользователя */ .ai-chat-user-form { padding: 25px; background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); border-radius: 12px; margin: 10px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); } .user-form-header { text-align: center; margin-bottom: 25px; } .user-form-header h3 { color: #2c3e50; font-size: 20px; font-weight: 600; margin-bottom: 8px; } .user-form-header p { color: #6c757d; font-size: 14px; line-height: 1.4; } .user-form .form-group { margin-bottom: 20px; } .user-form label { display: block; font-weight: 600; color: #495057; margin-bottom: 6px; font-size: 13px; } .user-form input { width: 100%; padding: 12px 16px; border: 2px solid #e9ecef; border-radius: 8px; font-size: 14px; background: white; transition: all 0.3s ease; box-sizing: border-box; } .user-form input:focus { outline: none; border-color: ${primaryColor}; box-shadow: 0 0 0 3px rgba(24, 144, 255, 0.1); transform: translateY(-1px); } .user-form input:invalid { border-color: #dc3545; } .form-actions { text-align: center; margin-top: 25px; } .submit-btn { background: linear-gradient(135deg, ${primaryColor}, #40a9ff); color: white; border: none; padding: 12px 30px; border-radius: 25px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.3s ease; box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3); text-transform: none; } .submit-btn:hover { background: linear-gradient(135deg, #0f7ae5, #1890ff); transform: translateY(-2px); box-shadow: 0 6px 20px rgba(24, 144, 255, 0.4); } .submit-btn:active { transform: translateY(0); } .form-error { background: #f8d7da; color: #721c24; padding: 10px 15px; border-radius: 6px; margin-top: 15px; font-size: 13px; border: 1px solid #f5c6cb; display: none; } @media (max-width: 400px) { .chat-window { width: calc(100vw - 40px); height: calc(100vh - 100px); } .chat-toast { ${isRight ? 'right: 20px' : 'left: 20px'}; max-width: calc(100vw - 120px); min-width: auto; } .ai-chat-user-form { margin: 5px; padding: 20px; } .user-form input { padding: 10px 12px; } } `; } createWidget() { const widget = document.createElement('div'); widget.className = 'ai-chat-widget'; widget.innerHTML = ` <div class="chat-window open" id="aiChatWindow"> <div class="chat-header"> <div> <h3>${this.config.title}</h3> <p>${this.config.subtitle}</p> </div> <button class="chat-close" id="aiChatClose">×</button> </div> <div class="chat-messages" id="aiChatMessages"></div> <div class="chat-input-area"> <textarea class="chat-input" id="aiChatInput" placeholder="${this.config.placeholder}" rows="1" ></textarea> <button class="chat-send" id="aiChatSend"> <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> <path d="M2,21L23,12L2,3V10L17,12L2,14V21Z"/> </svg> </button> </div> <a href="https://kanonpartners.ru/aisaleschat?utm_source=chat" target="_blank" class="chat-link">Установить на свой сайт</a> </div> <button class="chat-toggle" id="aiChatToggle"> 💬 <span class="unread-badge" id="aiChatUnreadBadge" style="display: none;">0</span> </button> <div class="chat-toast" id="aiChatToast"> <button class="chat-toast-close" id="aiChatToastClose">×</button> <div class="toast-content"> <div class="toast-icon">💬</div> <div id="aiChatToastMessage">${this.config.toastMessage}</div> </div> <button class="chat-toast-action" id="aiChatToastAction">Начать чат</button> </div> `; document.body.appendChild(widget); this.elements = { toggle: document.getElementById('aiChatToggle'), window: document.getElementById('aiChatWindow'), close: document.getElementById('aiChatClose'), messages: document.getElementById('aiChatMessages'), input: document.getElementById('aiChatInput'), send: document.getElementById('aiChatSend'), toast: document.getElementById('aiChatToast'), toastClose: document.getElementById('aiChatToastClose'), toastAction: document.getElementById('aiChatToastAction'), toastMessage: document.getElementById('aiChatToastMessage'), unreadBadge: document.getElementById('aiChatUnreadBadge') }; } bindEvents() { this.elements.toggle.addEventListener('click', () => this.toggleChat()); this.elements.close.addEventListener('click', () => this.closeChat()); this.elements.send.addEventListener('click', () => this.sendMessage()); this.elements.input.addEventListener('keypress', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.sendMessage(); } }); this.elements.input.addEventListener('input', () => { this.elements.input.style.height = 'auto'; this.elements.input.style.height = Math.min(this.elements.input.scrollHeight, 80) + 'px'; }); this.elements.toastClose.addEventListener('click', () => this.hideToast()); this.elements.toastAction.addEventListener('click', () => { this.hideToast(); this.openChat(); }); } toggleChat() { this.isOpen ? this.closeChat() : this.openChat(); } openChat() { this.isOpen = true; this.elements.window.classList.add('open'); this.elements.input.focus(); this.hideToast(); // Если есть непрочитанные сообщения, загружаем их в чат if (this.unreadCount > 0) { this.loadChatHistory(); } // Сбрасываем счетчик непрочитанных сообщений this.markAllAsRead(); } closeChat() { this.isOpen = false; this.elements.window.classList.remove('open'); // НЕ останавливаем polling - он должен работать постоянно для уведомлений } async loadChatHistory() { try { const response = await fetch(`${this.config.apiUrl}/chat/${encodeURIComponent(this.sessionId)}/messages`); if (!response.ok) { // Если не удалось загрузить историю, показываем приветствие this.addGreeting(); return; } console.log('loadChatHistory', this.sessionId); const data = await response.json(); const messages = data.messages || []; if (messages.length === 0) { // Если истории нет, показываем приветствие this.addGreeting(); return; } // Очищаем текущие сообщения перед загрузкой истории this.messages = []; this.elements.messages.innerHTML = ''; // Отображаем сообщения из истории messages.forEach(message => { let senderType = message.sender; // 'user', 'admin', 'ai' let adminInfo = null; if (senderType === 'admin') { adminInfo = message.adminInfo || { adminName: 'Поддержка' }; } // Добавляем сообщение только если есть контент if (message.content && message.content.trim()) { this.addMessage(message.content, senderType, adminInfo); } // Обновляем timestamp последнего сообщения if (message.timestamp) { this.lastMessageTimestamp = message.timestamp; } }); // Если в истории нет приветствия от ИИ, добавляем его const hasAIGreeting = messages.length != 0 if (!hasAIGreeting) { this.addGreeting(); } } catch (error) { console.error('Ошибка загрузки истории чата:', error); // При ошибке показываем приветствие this.addGreeting(); } } addGreeting() { this.addMessage(this.config.greeting, 'ai'); } addMessage(text, sender = 'user', adminInfo = null) { const message = { text, sender, timestamp: new Date(), adminInfo }; this.messages.push(message); // НЕ обновляем lastMessageTimestamp здесь для админских сообщений // Это делается в checkForNewMessages() для избежания конфликтов const messageElement = document.createElement('div'); messageElement.className = `chat-message ${sender}`; let messageContent = ''; let senderLabel = ''; // Определяем метку отправителя switch (sender) { case 'admin': senderLabel = 'Поддержка'; break; case 'ai': senderLabel = 'Продавец'; break; case 'user': senderLabel = ''; // Для пользователя не показываем метку break; } if (sender === 'admin' && adminInfo) { messageContent = ` <div class="admin-badge">${adminInfo.adminName || 'Администратор'}</div> <div class="message-bubble ${sender}"> ${text.replace(/\n/g, '<br>')} </div> `; } else if (sender !== 'user' && senderLabel) { messageContent = ` <div class="sender-badge ${sender}">${senderLabel}</div> <div class="message-bubble ${sender}"> ${text.replace(/\n/g, '<br>')} </div> `; } else { messageContent = ` <div class="message-bubble ${sender}"> ${text.replace(/\n/g, '<br>')} </div> `; } messageElement.innerHTML = messageContent; this.elements.messages.appendChild(messageElement); this.scrollToBottom(); return messageElement; } showTypingIndicator() { const indicator = document.createElement('div'); indicator.className = 'chat-message ai'; indicator.innerHTML = ` <div class="typing-indicator"> <div class="typing-dot"></div> <div class="typing-dot"></div> <div class="typing-dot"></div> </div> `; indicator.id = 'typingIndicator'; this.elements.messages.appendChild(indicator); this.scrollToBottom(); return indicator; } hideTypingIndicator() { const indicator = document.getElementById('typingIndicator'); if (indicator) { indicator.remove(); } } async sendMessage() { const text = this.elements.input.value.trim(); if (!text || this.isLoading) return; // Пропускаем проверку данных пользователя - сразу отправляем сообщение this.addMessage(text, 'user'); this.elements.input.value = ''; this.elements.input.style.height = 'auto'; // Отмечаем, что пользователь отправил сообщение if (!this.hasUserSentMessage) { this.hasUserSentMessage = true; } this.isLoading = true; this.elements.send.disabled = true; const typingIndicator = this.showTypingIndicator(); try { const response = await this.callAPI(text); this.hideTypingIndicator(); if (response.sessionId) { this.sessionId = response.sessionId; this.saveSessionToStorage(); } // Проверяем, включен ли ИИ if (response.aiEnabled === false) { // ИИ отключен - показываем сообщение о том, что сообщение получено this.addMessage('Ваше сообщение получено. С вами свяжется оператор.', 'admin', { adminName: 'Система', adminId: 'system' }); } else if (response.message) { // ИИ включен и есть ответ this.addMessage(response.message, 'ai'); } } catch (error) { this.hideTypingIndicator(); // Проверяем, является ли ошибка связанной с отсутствием сайта if (error.code === 'SITE_NOT_FOUND') { this.addMessage('Извините, сайт не найден в системе. Чат недоступен.', 'ai'); } else { this.addMessage('Извините, произошла ошибка. Попробуйте позже.', 'ai'); } } finally { this.isLoading = false; this.elements.send.disabled = false; } } async callAPI(message) { const response = await fetch(`${this.config.apiUrl}/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ message, siteId: this.config.siteId, sessionId: this.sessionId, userInfo: this.getUserInfo() }) }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); const error = new Error(`HTTP ${response.status}: ${response.statusText}`); error.code = errorData.code; error.status = response.status; throw error; } return response.json(); } getUserInfo() { return { ...this.userInfo, // Данные пользователя (user_name, user_email, user_phone) userAgent: navigator.userAgent, language: navigator.language, url: window.location.href, referrer: document.referrer, timestamp: new Date().toISOString() }; } scrollToBottom() { this.elements.messages.scrollTop = this.elements.messages.scrollHeight; } initToast() { if (!this.config.showToast) return; // Для test-site всегда показываем toast if (this.config.siteId === 'test-site') { setTimeout(() => { this.showToast(); }, this.config.toastDelay); return; } const toastKey = `ai-chat-toast-shown-${this.config.siteId}`; const lastShown = sessionStorage.getItem(toastKey); if (!lastShown) { setTimeout(() => { this.showToast(); sessionStorage.setItem(toastKey, Date.now().toString()); }, this.config.toastDelay); } } showToast() { if (this.toastShown || this.isOpen) return; this.toastShown = true; this.elements.toast.style.display = 'block'; setTimeout(() => { this.elements.toast.classList.add('show'); }, 50); if (this.config.toastDuration > 0) { setTimeout(() => { this.hideToast(); }, this.config.toastDuration); } } hideToast() { if (!this.toastShown) return; this.elements.toast.classList.remove('show'); setTimeout(() => { this.elements.toast.style.display = 'none'; this.toastShown = false; }, 400); } forceShowToast() { if (!this.isOpen) { this.showToast(); } } // Методы для polling админских сообщений startPolling() { if (this.pollingInterval) { clearInterval(this.pollingInterval); } // Проверяем новые сообщения каждые 5 секунд this.pollingInterval = setInterval(() => { this.checkForNewMessages(); }, 5000); } stopPolling() { if (this.pollingInterval) { clearInterval(this.pollingInterval); this.pollingInterval = null; } } async checkForNewMessages() { // Проверяем новые сообщения если есть sessionId (независимо от того, открыт ли чат) if (!this.sessionId) return; try { const url = `${this.config.apiUrl}/chat/${encodeURIComponent(this.sessionId)}/messages` + (this.lastMessageTimestamp ? `?lastTimestamp=${encodeURIComponent(this.lastMessageTimestamp)}` : ''); const response = await fetch(url); if (!response.ok) return; const data = await response.json(); if (data.messages && data.messages.length > 0) { data.messages.forEach(message => { if (message.senderType === 'admin') { console.log('message.senderType === admin'); if (this.isOpen) { // Если чат открыт - добавляем сообщение как обычно this.addMessage( message.content, 'admin', message.adminInfo ); } else { // Если чат закрыт - увеличиваем счетчик и показываем уведомление this.unreadCount++; this.updateUnreadBadge(); const isWidgetOpen = document.querySelector('.ai-chat-widget.open'); if (!isWidgetOpen) { this.showNewMessageToast(message.content); } } // Обновляем timestamp последнего полученного сообщения if (message.timestamp) { this.lastMessageTimestamp = message.timestamp; } } }); } // Обновляем общий timestamp из ответа сервера if (data.timestamp) { this.lastMessageTimestamp = data.timestamp; } } catch (error) { // Тихо обрабатываем ошибки polling } } // Обновление индикатора непрочитанных сообщений updateUnreadBadge() { if (this.unreadCount > 0) { this.elements.unreadBadge.textContent = this.unreadCount > 99 ? '99+' : this.unreadCount; this.elements.unreadBadge.style.display = 'flex'; } else { this.elements.unreadBadge.style.display = 'none'; } } // Показать toast уведомление о новом сообщении showNewMessageToast(messageContent) { // Обновляем содержимое toast const shortMessage = messageContent.length > 50 ? messageContent.substring(0, 50) + '...' : messageContent; this.elements.toastMessage.textContent = `Новое сообщение: ${shortMessage}`; // Показываем toast this.showToast(); } // Сброс счетчика непрочитанных сообщений markAllAsRead() { this.unreadCount = 0; this.updateUnreadBadge(); this.lastReadTimestamp = new Date().toISOString(); localStorage.setItem(`ai-chat-lastread-${this.config.siteId}`, this.lastReadTimestamp); } static init(config) { return new AIChatWidget(config); } } // Глобальный доступ window.AIChatWidget = AIChatWidget; // Автоматическая инициализация document.addEventListener('DOMContentLoaded', () => { const script = document.querySelector('script[data-ai-chat]'); if (script) { const config = { apiUrl: script.dataset.apiUrl, siteId: script.dataset.siteId, title: script.dataset.title, primaryColor: script.dataset.primaryColor, autoOpen: script.dataset.autoOpen === 'true', showToast: script.dataset.showToast === 'true', subtitle: script.dataset.subtitle, }; AIChatWidget.init(config); } });