Files
crm.clientright.ru/layouts/v7/resources/js/ai-drawer-simple.js

1215 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 =
'<button class="ai-drawer-toggle">AI</button>' +
'<div class="ai-drawer font-normal">' +
'<div class="ai-drawer-resize-handle"></div>' +
'<div class="ai-drawer-header">' +
'<span>AI Ассистент</span>' +
'<button class="ai-drawer-close">&times;</button>' +
'</div>' +
'<div class="ai-font-controls">' +
'<label>Размер шрифта:</label>' +
'<button class="font-btn" data-size="small">Мелкий</button>' +
'<button class="font-btn active" data-size="normal">Обычный</button>' +
'<button class="font-btn" data-size="large">Крупный</button>' +
'<button class="font-btn" data-size="extra-large">Очень крупный</button>' +
'</div>' +
'<div class="ai-avatar-controls">' +
'<label>Аватарка ассистента:</label>' +
'<button class="avatar-btn active" data-type="default">🤖</button>' +
'<button class="avatar-btn" data-type="friendly">😊</button>' +
'<button class="avatar-btn" data-type="helpful">💡</button>' +
'<button class="avatar-btn" data-type="smart">🧠</button>' +
'</div>' +
'<div class="ai-drawer-content">' +
'<div class="ai-chat-messages">' +
'<div class="ai-message assistant">' +
'<div class="ai-avatar assistant"></div>' +
'<div class="ai-message-content">' +
'<p>Привет! Я ваш AI ассистент. Чем могу помочь?</p>' +
'<div class="ai-message-time">' + new Date().toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit',
hour12: false // 24-часовой формат
}) + '</div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="ai-chat-input-container">' +
'<input type="text" id="ai-chat-input" placeholder="Введите сообщение..." class="ai-chat-input">' +
'<button id="ai-send-button" class="ai-send-button">Отправить</button>' +
'</div>' +
'</div>' +
'<div class="ai-loading-overlay">' +
'<div class="ai-loading-spinner"></div>' +
'<div>Обрабатываю запрос...</div>' +
'</div>';
// Добавляем в 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 `<a href="${url}" target="_blank" rel="noopener noreferrer" class="ai-message-link" title="${url}">${linkText}</a>`;
}
return match; // Если не URL, оставляем как есть
});
// ШАГ 2: Временно заменяем уже существующие HTML-ссылки на плейсхолдеры
const htmlLinks = [];
const htmlLinkRegex = /<a\s+[^>]*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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
// ШАГ 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('<a', '<a class="ai-message-link"');
} else if (!htmlLink.includes('ai-message-link')) {
htmlLink = htmlLink.replace('class="', 'class="ai-message-link ');
htmlLink = htmlLink.replace("class='", "class='ai-message-link ");
}
// Убеждаемся что есть target="_blank"
if (!htmlLink.includes('target=')) {
htmlLink = htmlLink.replace('<a', '<a target="_blank" rel="noopener noreferrer"');
}
// Заменяем плейсхолдер на правильный HTML (не экранированный)
finalResult = finalResult.replace(placeholder, htmlLink);
});
// ШАГ 5: Преобразуем обычные URL в ссылки (только те, что не внутри уже существующих ссылок)
const urlRegex = /(https?:\/\/[^\s<>"{}|\\^`\[\]]+)/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);
// Проверяем, нет ли открывающего тега <a перед этим URL
const lastOpenTag = beforeMatch.lastIndexOf('<a');
const lastCloseTag = beforeMatch.lastIndexOf('</a>');
// Если есть открывающий тег <a и нет закрывающего после него - значит мы внутри ссылки
if (lastOpenTag > 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 = `<a href="${url}" target="_blank" rel="noopener noreferrer" class="ai-message-link" title="${url}">${linkText}</a>`;
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 = `
<div class="ai-typing-dots">
<div class="ai-typing-dot"></div>
<div class="ai-typing-dot"></div>
<div class="ai-typing-dot"></div>
</div>
<span class="ai-typing-text">печатает...</span>
`;
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');
});