@bash-dev/ai-chat-widget
Version:
Готовый к использованию AI чат-виджет для интеграции на любые сайты с поддержкой русского языка
1,421 lines (1,230 loc) • 44.1 kB
JavaScript
/**
* ИИ Чат-виджет
* Готовый для интеграции на клиентские сайты
*/
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;
}
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; }
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;
}
(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);
}
});