/
bytevalue
/
optimal
Обзор
Документация
Войти
/
bytevalue
/
optimal
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/main/resources/static/js/ui.js
1 373 строки
50 KB
bytevalue
Edit chat title
08 дек 2025, 13:41
08 дек 2025, 13:41
768e132
Код
Авторство
О чём код?
/** * UI Manager * Handles all UI rendering and interaction */ class UIManager { constructor() { this.activeChat = null; this.isRenamingChat = false; this.renameInput = null; this.originalTitle = ''; // Проверяем, загружен ли уже DOM if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { this.initialize(); }); } else { // DOM уже загружен setTimeout(() => this.initialize(), 0); } } /** * Инициализация */ initialize() { this.handleResize(); console.log('🎨 Initializing UI Manager...'); try { this.setupEventListeners(); this.setupSubscriptions(); this.initializeSidebar(); this.loadChatsOnInit(); console.log('🎨 UI Manager initialized successfully'); } catch (error) { console.error('❌ Failed to initialize UI Manager:', error); } } initializeSidebar() { const isMobile = window.innerWidth <= 768; const mobileMenuBtn = document.getElementById('mobileMenuBtn'); // Показываем мобильную кнопку меню на мобильных if (mobileMenuBtn) { mobileMenuBtn.style.display = isMobile ? 'flex' : 'none'; mobileMenuBtn.addEventListener('click', () => this.toggleMobileSidebar()); } // Обработчик для бэкдропа const sidebarBackdrop = document.getElementById('sidebarBackdrop'); if (sidebarBackdrop) { sidebarBackdrop.addEventListener('click', () => this.closeMobileSidebar()); } // Обработчик изменения размера окна window.addEventListener('resize', () => this.handleResize()); } handleResize() { const isMobile = window.innerWidth <= 768; const mobileMenuBtn = document.getElementById('mobileMenuBtn'); const sidebar = document.getElementById('sidebar'); // Показываем/скрываем мобильную кнопку меню if (mobileMenuBtn) { mobileMenuBtn.style.display = isMobile ? 'flex' : 'none'; } // При переходе с мобильного на десктоп закрываем бэкдроп if (!isMobile) { this.closeMobileSidebar(); // Убираем класс mobile-active if (sidebar) { sidebar.classList.remove('sidebar-mobile-active'); } } } /** * Загрузка чатов при инициализации */ async loadChatsOnInit() { try { console.log('📱 Loading chats on UI init...'); const response = await apiService.getActiveChats(); if (response && response.chats) { stateService.setActiveChats(response.chats); console.log(`✅ Loaded ${response.chats.length} active chats on init`); // Обновляем список недавних чатов в приветственном экране this.updateRecentChats(); } } catch (error) { console.warn('⚠️ Failed to load chats on init:', error.message); } } /** * Обновление списка недавних чатов */ updateRecentChats() { const activeChats = stateService.getState('activeChats'); const recentChatsContainer = document.getElementById('recentChats'); if (!recentChatsContainer) return; recentChatsContainer.innerHTML = ''; // Показываем последние 3 чата const recentChats = activeChats.slice(0, 3); if (recentChats.length === 0) { recentChatsContainer.innerHTML = ` <div class="empty-recent-chats"> <p>У вас пока нет чатов</p> </div> `; return; } recentChats.forEach(chat => { // Проверяем, что чат не удален if (chat.status === CONFIG.CHAT_STATUS.DELETED) { return; } const chatItem = document.createElement('div'); chatItem.className = 'recent-chat-item'; chatItem.innerHTML = ` <div class="recent-chat-icon"> <i class="fas fa-comment"></i> </div> <div class="recent-chat-info"> <div class="recent-chat-title">${chat.title || 'Новый чат'}</div> <div class="recent-chat-time">${this.formatTime(new Date(chat.updatedAt || chat.createdAt))}</div> </div> `; chatItem.addEventListener('click', () => { this.selectChat(chat.id); }); recentChatsContainer.appendChild(chatItem); }); } /** * Setup event listeners */ setupEventListeners() { console.log('🎨 Setting up event listeners...'); // Проверяем существование элементов перед добавлением обработчиков const newChatBtn = document.getElementById('newChatBtn'); if (newChatBtn) { newChatBtn.addEventListener('click', () => this.createNewChat()); console.log('✅ Added listener for newChatBtn'); } const logoContainer = document.getElementById('logoContainer'); if (logoContainer) { logoContainer.addEventListener('click', () => { console.log('🏠 Logo clicked, showing welcome screen'); this.showWelcomeScreen(); // Закрываем сайдбар на мобильных устройствах if (window.innerWidth <= 768) { this.closeMobileSidebar(); } }); // Добавляем курсор указателя для логотипа logoContainer.style.cursor = 'pointer'; console.log('✅ Added listener for logoContainer'); } const emptyCreateChatBtn = document.getElementById('emptyCreateChatBtn'); if (emptyCreateChatBtn) { emptyCreateChatBtn.addEventListener('click', () => this.createNewChat()); console.log('✅ Added listener for emptyCreateChatBtn'); } const sendBtn = document.getElementById('sendBtn'); if (sendBtn) { sendBtn.addEventListener('click', () => this.sendMessage()); console.log('✅ Added listener for sendBtn'); } const newChatBtnCollapsed = document.getElementById('newChatBtnCollapsed'); if (newChatBtnCollapsed) { newChatBtnCollapsed.addEventListener('click', () => this.createNewChat()); console.log('✅ Added listener for newChatBtnCollapsed'); } const messageInput = document.getElementById('messageInput'); if (messageInput) { messageInput.addEventListener('input', () => { this.updateTextareaHeight(messageInput); this.updateCharCount(); }); messageInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.sendMessage(); } if (e.ctrlKey && e.key === 'Enter') { e.preventDefault(); this.sendMessage(); } }); messageInput.addEventListener('focus', () => { this.updateTextareaHeight(messageInput); }); console.log('✅ Added listeners for messageInput'); } const deleteChatBtn = document.getElementById('deleteChatBtn'); if (deleteChatBtn) { deleteChatBtn.addEventListener('click', () => this.deleteCurrentChat()); console.log('✅ Added listener for deleteChatBtn'); } const renameChatBtn = document.getElementById('renameChatBtn'); if (renameChatBtn) { renameChatBtn.addEventListener('click', () => this.startRenamingChat()); console.log('✅ Added listener for renameChatBtn'); } const settingsBtn = document.getElementById('settingsBtn'); if (settingsBtn) { settingsBtn.addEventListener('click', () => this.showSettings()); console.log('✅ Added listener for settingsBtn'); } // Сворачивание/разворачивание сайдбара const toggleSidebarBtn = document.getElementById('toggleSidebarBtn'); if (toggleSidebarBtn) { toggleSidebarBtn.addEventListener('click', () => this.toggleSidebar()); console.log('✅ Added listener for toggleSidebarBtn'); } const expandSidebarBtn = document.getElementById('expandSidebarBtn'); if (expandSidebarBtn) { expandSidebarBtn.addEventListener('click', () => this.toggleSidebar()); console.log('✅ Added listener for expandSidebarBtn'); } const mobileMenuBtn = document.getElementById('mobileMenuBtn'); if (mobileMenuBtn) { mobileMenuBtn.addEventListener('click', () => this.toggleMobileSidebar()); } const sidebarBackdrop = document.getElementById('sidebarBackdrop'); if (sidebarBackdrop) { sidebarBackdrop.addEventListener('click', () => this.closeMobileSidebar()); } // Обработчики для тостов const toastCloseButtons = document.querySelectorAll('.toast-close'); toastCloseButtons.forEach((btn, index) => { btn.addEventListener('click', () => { btn.closest('.toast').style.display = 'none'; }); console.log(`✅ Added listener for toast close button ${index + 1}`); }); this.updateCharCount(); console.log('✅ All event listeners set up'); } /** * Setup subscriptions */ setupSubscriptions() { console.log('🎨 Setting up subscriptions...'); // Подписываемся на обновление списка активных чатов stateService.subscribe('activeChats', () => { console.log('📱 Active chat list updated, rendering...'); this.renderChatList(); this.updateRecentChats(); }); // Подписываемся на обновление сообщений текущего чата stateService.subscribe('activeChatId', (newChatId) => { if (newChatId) { stateService.subscribe(`messages.${newChatId}`, () => { if (this.activeChat?.id === newChatId) { this.renderMessages(); } }); } }); // Подписываемся на непрочитанные чаты stateService.subscribe('unreadChats', () => this.renderChatList()); // Подписываемся на приветственный экран stateService.subscribe('showWelcome', (show) => { this.toggleWelcomeScreen(show); }); // Подписка на этапы пайплайна stateService.subscribe('pipelineStages', () => { if (this.activeChat) { this.renderMessages(); } }); // Подписка на переименование чата eventBus.subscribe(CONFIG.EVENTS.CHAT_RENAMED, (data) => { console.log('✏️ Chat renamed event:', data); // Обновляем UI если переименован активный чат if (this.activeChat?.id === data.chatId) { this.activeChat.title = data.newTitle; this.renderChatHeader(); } // Перерисовываем список чатов this.renderChatList(); }); // Подписка на события WebSocket через eventBus eventBus.subscribe(CONFIG.EVENTS.ASSISTANT_MESSAGE_CREATED, (data) => { if (data.chatId === this.activeChat?.id) { console.log('🤖 Assistant message created, updating UI...'); this.renderMessages(); } }); eventBus.subscribe(CONFIG.EVENTS.PIPELINE_STAGE_UPDATE, (data) => { if (data.chatId === this.activeChat?.id) { console.log(`🔄 Pipeline stage update for message ${data.messageId}: ${data.stage}`); // Просто перерисовываем сообщения - этап будет показан в индикаторе this.renderMessages(); // Автоскролл const container = document.getElementById('messagesContainer'); if (container) { setTimeout(() => { container.scrollTop = container.scrollHeight; }, 100); } } }); eventBus.subscribe(CONFIG.EVENTS.PIPELINE_COMPLETED, (data) => { console.log('Pipeline completed from event:', data); if (data.chatId === this.activeChat?.id) { console.log(`✅ Pipeline completed for message ${data.messageId}`); // Сообщения уже обновлены через stateService, просто рендерим this.renderMessages(); } }); // Подписка на события удаления и восстановления чатов eventBus.subscribe(CONFIG.EVENTS.CHAT_DELETED, (data) => { console.log('🗑️ Chat deleted event:', data); // Перезагружаем список чатов this.loadChatList(); }); eventBus.subscribe(CONFIG.EVENTS.CHAT_RESTORED, (data) => { console.log('♻️ Chat restored event:', data); // Перезагружаем список чатов this.loadChatList(); }); eventBus.subscribe(CONFIG.EVENTS.CONNECTION_ESTABLISHED, () => { console.log('✅ WebSocket connected'); if (this.activeChat) { webSocketService.subscribeToChat(this.activeChat.id); } }); eventBus.subscribe(CONFIG.EVENTS.CONNECTION_LOST, () => { console.log('❌ WebSocket disconnected'); }); console.log('✅ All subscriptions set up'); } /** * Загрузка списка чатов */ async loadChatList() { try { console.log('📱 Loading chat list...'); const response = await apiService.getActiveChats(); if (response && response.chats) { stateService.setActiveChats(response.chats); console.log(`✅ Loaded ${response.chats.length} active chats`); } } catch (error) { console.warn('⚠️ Failed to load chat list:', error.message); } } /** * Валидация сообщения */ validateMessage(message) { if (!message || message.trim().length === 0) { return { valid: false, error: 'Сообщение не может быть пустым' }; } if (message.length > 4000) { return { valid: false, error: 'Сообщение не может превышать 4000 символов' }; } return { valid: true }; } /** * Валидация названия чата */ validateChatTitle(title) { if (!title || title.trim().length === 0) { return { valid: false, error: CONFIG.ERRORS.EMPTY_CHAT_TITLE }; } if (title.length > CONFIG.VALIDATION.MAX_CHAT_TITLE_LENGTH) { return { valid: false, error: CONFIG.ERRORS.CHAT_TITLE_TOO_LONG }; } return { valid: true, trimmedTitle: title.trim() }; } /** * Обновление счетчика символов */ updateCharCount() { const messageInput = document.getElementById('messageInput'); const charCounter = document.querySelector('.char-counter'); if (messageInput && charCounter) { const count = messageInput.value.length; const charCountSpan = document.getElementById('charCount'); if (charCountSpan) { charCountSpan.textContent = count; } charCounter.classList.remove('warning', 'error'); if (count > 4000) { charCounter.classList.add('error'); } else if (count > 3500) { charCounter.classList.add('warning'); } } } /** * Авторесайз textarea */ updateTextareaHeight(textarea) { textarea.style.height = 'auto'; const maxHeight = 192; const newHeight = Math.min(textarea.scrollHeight, maxHeight); textarea.style.height = `${newHeight}px`; } /** * Create new chat */ async createNewChat() { try { this.showLoading(true); const response = await apiService.createChat(); console.log('✅ Chat created response:', response); if (response && response.chatId) { const newChat = { id: response.chatId, title: response.title || 'Новый чат', createdAt: response.createdAt || new Date().toISOString(), updatedAt: response.createdAt || new Date().toISOString(), status: CONFIG.CHAT_STATUS.ACTIVE }; // Добавляем чат в состояние stateService.addOrUpdateChat(newChat); // Выбираем новый чат await this.selectChat(response.chatId); this.showSuccess('Чат создан'); } else { throw new Error('Invalid response from server'); } } catch (error) { console.error('Failed to create chat:', error); this.showError(error.message || 'Не удалось создать чат'); } finally { this.showLoading(false); } } /** * Select chat */ async selectChat(chatId) { console.log(`📱 Selecting chat ${chatId}`); if (!chatId) { console.log('📱 No chatId provided, showing welcome screen'); this.showWelcomeScreen(); return; } try { this.showLoading(true); // Отписываемся от предыдущего чата в WebSocket if (this.activeChat) { webSocketService.unsubscribeFromChat(this.activeChat.id); } // Находим чат в состоянии const activeChats = stateService.getState('activeChats'); this.activeChat = activeChats.find((c) => c.id === chatId); // Если чат не найден в локальном состоянии, пробуем загрузить с сервера if (!this.activeChat) { console.warn(`⚠️ Chat ${chatId} not found in local state`); try { // Пробуем загрузить все активные чаты с сервера const chatsResponse = await apiService.getActiveChats(); if (chatsResponse && chatsResponse.chats) { stateService.setActiveChats(chatsResponse.chats); this.activeChat = chatsResponse.chats.find((c) => c.id === chatId); } } catch (loadError) { console.error('Failed to reload chats:', loadError); } // Если после загрузки чат все еще не найден - показываем ошибку if (!this.activeChat) { console.error(`❌ Chat ${chatId} not found after reload`); this.showError('Чат не найден'); this.showWelcomeScreen(); return; } } // Проверяем, что чат не удален if (this.activeChat.status === CONFIG.CHAT_STATUS.DELETED) { this.showError('Этот чат был удален'); this.showWelcomeScreen(); return; } stateService.setState('activeChatId', chatId); console.log(`✅ Selected chat: ${this.activeChat.title}`); // Помечаем чат как прочитанный stateService.markChatAsRead(chatId); // Отображаем интерфейс чата this.renderChatView(); this.renderChatList(); try { const timestamp = Date.now(); const messagesResponse = await apiService.getMessagesWithCacheBust(chatId, timestamp); if (messagesResponse && messagesResponse.messages) { stateService.setMessages(chatId, messagesResponse.messages); console.log(`✅ Loaded ${messagesResponse.messages.length} messages for chat ${chatId}`); } else { stateService.setMessages(chatId, []); } } catch (error) { console.error('Failed to load messages:', error); stateService.setMessages(chatId, []); } // Подписываемся на WebSocket if (stateService.getState('isConnected')) { webSocketService.subscribeToChat(chatId); } this.renderMessages(); eventBus.publish(CONFIG.EVENTS.CHAT_SELECTED, this.activeChat); } catch (error) { console.error('Failed to select chat:', error); this.showError('Не удалось загрузить чат'); this.showWelcomeScreen(); } finally { this.showLoading(false); } } /** * Send message - ОСНОВНОЙ ИСПРАВЛЕННЫЙ МЕТОД */ async sendMessage() { const messageInput = document.getElementById('messageInput'); if (!messageInput) { console.error('Message input not found'); return; } const message = messageInput.value.trim(); const validation = this.validateMessage(message); if (!validation.valid) { this.showError(validation.error); return; } if (!this.activeChat) { this.showError('Сначала создайте или выберите чат'); return; } try { // Отключаем ввод messageInput.disabled = true; const sendButton = document.getElementById('sendBtn'); if (sendButton) sendButton.disabled = true; // Очищаем поле ввода messageInput.value = ''; this.updateTextareaHeight(messageInput); this.updateCharCount(); // Отправляем на сервер const response = await apiService.sendMessage( this.activeChat.id, message ); if (response && response.messageId) { // Добавляем пользовательское сообщение из ответа сервера const userMessage = { id: response.messageId, chatId: this.activeChat.id, role: 'USER', content: response.content || message, status: response.status || 'COMPLETED', createdAt: response.createdAt || new Date().toISOString(), }; stateService.addMessage(this.activeChat.id, userMessage); this.renderMessages(); // Инвалидируем кэш и запрашиваем актуальные сообщения apiService.invalidateChatCache(this.activeChat.id); const timestamp = Date.now(); const messagesResponse = await apiService.getMessagesWithCacheBust(this.activeChat.id, timestamp); if (messagesResponse && messagesResponse.messages) { stateService.setMessages(this.activeChat.id, messagesResponse.messages); console.log(`✅ Updated messages after sending: ${messagesResponse.messages.length} messages`); } // Подписываемся на обновления чата (если еще не подписаны) if (stateService.getState('isConnected')) { webSocketService.subscribeToChat(this.activeChat.id); } } else { console.error('No messageId in response:', response); this.showError('Не удалось отправить сообщение'); } eventBus.publish(CONFIG.EVENTS.MESSAGE_SENT, { chatId: this.activeChat.id, message }); } catch (error) { console.error('Failed to send message:', error); this.showError(error.message || 'Не удалось отправить сообщение'); } finally { // Включаем ввод messageInput.disabled = false; const sendButton = document.getElementById('sendBtn'); if (sendButton) sendButton.disabled = false; messageInput.focus(); } } /** * Delete current chat - ОБНОВЛЕННЫЙ МЕТОД */ async deleteCurrentChat() { if (!this.activeChat) { return; } if (!confirm(`Удалить "${this.activeChat.title}"?\n\nЧат будет перемещен в корзину. Вы сможете восстановить его позже.`)) { return; } try { this.showLoading(true); // Отправляем запрос на мягкое удаление await apiService.deleteChat(this.activeChat.id); // Отписываемся от WebSocket webSocketService.unsubscribeFromChat(this.activeChat.id); // Удаляем чат из состояния stateService.removeChat(this.activeChat.id); // Показываем приветственный экран this.showWelcomeScreen(); // Перезагружаем список чатов await this.loadChatList(); this.showSuccess('Чат перемещен в корзину'); eventBus.publish(CONFIG.EVENTS.CHAT_DELETED, this.activeChat); } catch (error) { console.error('Failed to delete chat:', error); this.showError(error.message || 'Не удалось удалить чат'); } finally { this.showLoading(false); } } /** * Начать переименование чата */ startRenamingChat() { if (!this.activeChat || this.isRenamingChat) return; this.isRenamingChat = true; this.originalTitle = this.activeChat.title; const chatTitleElement = document.getElementById('chatTitle'); if (!chatTitleElement) return; // Сохраняем текущее содержимое const currentTitle = chatTitleElement.textContent; // Заменяем заголовок на input chatTitleElement.innerHTML = ''; const input = document.createElement('input'); input.type = 'text'; input.className = 'chat-title-input'; input.value = currentTitle; input.maxLength = CONFIG.VALIDATION.MAX_CHAT_TITLE_LENGTH; // Обработчики событий input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { this.finishRenamingChat(input.value.trim()); } else if (e.key === 'Escape') { this.cancelRenamingChat(); } }); input.addEventListener('blur', () => { setTimeout(() => { if (this.isRenamingChat) { this.finishRenamingChat(input.value.trim()); } }, 100); }); chatTitleElement.appendChild(input); input.focus(); input.select(); console.log('✏️ Started renaming chat'); } /** * Завершить переименование чата */ async finishRenamingChat(newTitle) { if (!this.isRenamingChat || !this.activeChat) return; const validation = this.validateChatTitle(newTitle); if (!validation.valid) { this.showError(validation.error); this.cancelRenamingChat(); return; } const trimmedTitle = validation.trimmedTitle; // Если название не изменилось, просто отменяем if (trimmedTitle === this.originalTitle) { this.cancelRenamingChat(); return; } try { this.showLoading(true); // Отправляем запрос на сервер await apiService.renameChat(this.activeChat.id, trimmedTitle); // Обновляем состояние stateService.renameChat(this.activeChat.id, trimmedTitle); // Обновляем активный чат this.activeChat.title = trimmedTitle; this.showSuccess('Чат переименован'); eventBus.publish(CONFIG.EVENTS.CHAT_RENAMED, { chatId: this.activeChat.id, newTitle: trimmedTitle }); this.isRenamingChat = false; this.renderChatHeader(); } catch (error) { console.error('Failed to rename chat:', error); this.showError(error.message || 'Не удалось переименовать чат'); this.cancelRenamingChat(); } finally { this.showLoading(false); } } /** * Отменить переименование чата */ cancelRenamingChat() { if (!this.isRenamingChat) return; this.isRenamingChat = false; this.renderChatHeader(); console.log('❌ Cancelled chat renaming'); } /** * Render chat header with title */ renderChatHeader() { const chatTitle = document.getElementById('chatTitle'); if (!chatTitle || !this.activeChat) return; // Очищаем и устанавливаем заголовок chatTitle.innerHTML = ''; if (this.isRenamingChat) { // Если в процессе переименования, показываем input const input = document.createElement('input'); input.type = 'text'; input.className = 'chat-title-input'; input.value = this.activeChat.title; input.maxLength = CONFIG.VALIDATION.MAX_CHAT_TITLE_LENGTH; input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { this.finishRenamingChat(input.value.trim()); } else if (e.key === 'Escape') { this.cancelRenamingChat(); } }); input.addEventListener('blur', () => { setTimeout(() => { if (this.isRenamingChat) { this.finishRenamingChat(input.value.trim()); } }, 100); }); chatTitle.appendChild(input); input.focus(); input.select(); } else { // Обычный заголовок с возможностью двойного клика const titleSpan = document.createElement('span'); titleSpan.className = 'chat-title-text'; titleSpan.textContent = this.activeChat.title || 'Новый чат'; titleSpan.addEventListener('dblclick', () => this.startRenamingChat()); // Добавляем иконку редактирования при наведении titleSpan.addEventListener('mouseenter', () => { titleSpan.title = 'Двойной клик для переименования'; titleSpan.style.cursor = 'pointer'; }); chatTitle.appendChild(titleSpan); } } /** * Show settings */ showSettings() { const userId = stateService.getState('userId'); alert(`User ID: ${userId}\n\nНастройки скоро будут доступны...`); } /** * Show loading overlay */ showLoading(show) { const overlay = document.getElementById('loadingOverlay'); if (overlay) { overlay.style.display = show ? 'flex' : 'none'; } } /** * Show error toast */ showError(message) { const toast = document.getElementById('errorToast'); const errorMessage = document.getElementById('errorMessage'); if (toast && errorMessage) { errorMessage.textContent = message; toast.style.display = 'flex'; setTimeout(() => { toast.style.display = 'none'; }, 5000); } else { // Fallback alert(`Ошибка: ${message}`); } } /** * Show success toast */ showSuccess(message) { const toast = document.getElementById('successToast'); const successMessage = document.getElementById('successMessage'); if (toast && successMessage) { successMessage.textContent = message; toast.style.display = 'flex'; setTimeout(() => { toast.style.display = 'none'; }, 3000); } else { // Fallback alert(`Успех: ${message}`); } } toggleSidebar() { const isMobile = window.innerWidth <= 768; if (isMobile) { this.toggleMobileSidebar(); } else { this.toggleDesktopSidebar(); } } /** * Переключение сайдбара на десктопе */ toggleDesktopSidebar() { const sidebar = document.getElementById('sidebar'); const toggleBtn = document.getElementById('toggleSidebarBtn'); if (!sidebar || !toggleBtn) return; sidebar.classList.toggle('sidebar-collapsed'); // Меняем иконку стрелки const icon = toggleBtn.querySelector('i'); if (icon) { if (sidebar.classList.contains('sidebar-collapsed')) { icon.className = 'fas fa-chevron-right'; } else { icon.className = 'fas fa-chevron-left'; } } } /** * Открытие сайдбара на мобильных */ openMobileSidebar() { const sidebar = document.getElementById('sidebar'); const backdrop = document.getElementById('sidebarBackdrop'); if (!sidebar || !backdrop) return; // На мобильных всегда показываем развернутый сайдбар sidebar.classList.remove('sidebar-collapsed'); sidebar.classList.add('sidebar-mobile-active'); backdrop.classList.add('active'); // Блокируем скролл страницы document.body.style.overflow = 'hidden'; } /** * Закрытие сайдбара на мобильных */ closeMobileSidebar() { const sidebar = document.getElementById('sidebar'); const backdrop = document.getElementById('sidebarBackdrop'); if (!sidebar || !backdrop) return; sidebar.classList.remove('sidebar-mobile-active'); backdrop.classList.remove('active'); // Восстанавливаем скролл document.body.style.overflow = ''; } /** * Переключение мобильного сайдбара */ toggleMobileSidebar() { const sidebar = document.getElementById('sidebar'); if (!sidebar) return; if (sidebar.classList.contains('sidebar-mobile-active')) { this.closeMobileSidebar(); } else { this.openMobileSidebar(); } } removeBackdrop() { const backdrop = document.querySelector('.sidebar-backdrop'); if (backdrop) { backdrop.remove(); } } /** * Render chat list */ renderChatList() { const chatList = document.getElementById('chatList'); if (!chatList) return; const activeChats = stateService.getState('activeChats'); const activeChatId = stateService.getState('activeChatId'); const unreadChats = stateService.getState('unreadChats'); console.log(`📱 Rendering ${activeChats.length} active chats, active: ${activeChatId}`); chatList.innerHTML = ''; // Обновляем счетчик чатов const chatsCount = document.getElementById('chatsCount'); if (chatsCount) { chatsCount.textContent = activeChats.length; } // Фильтруем только активные чаты (не удаленные) const filteredChats = activeChats.filter(chat => chat.status !== CONFIG.CHAT_STATUS.DELETED ); filteredChats.forEach((chat) => { const li = document.createElement('li'); if (chat.id === activeChatId && activeChatId !== null) { li.className = 'chat-item active'; } else { li.className = 'chat-item'; } const isUnread = !!unreadChats[chat.id]; if (isUnread) { li.classList.add('unread'); } li.addEventListener('click', () => { console.log(`📱 Chat clicked: ${chat.id}`); this.selectChat(chat.id); stateService.markChatAsRead(chat.id); }); const iconDiv = document.createElement('div'); iconDiv.className = 'chat-icon'; iconDiv.innerHTML = '<i class="fas fa-comment"></i>'; const infoDiv = document.createElement('div'); infoDiv.className = 'chat-info'; const titleDiv = document.createElement('div'); titleDiv.className = 'chat-title'; titleDiv.textContent = chat.title || 'Новый чат'; const previewDiv = document.createElement('div'); previewDiv.className = 'chat-preview'; previewDiv.textContent = chat.lastMessagePreview || 'Начните диалог...'; const metaDiv = document.createElement('div'); metaDiv.className = 'chat-meta'; const timeDiv = document.createElement('div'); timeDiv.className = 'chat-time'; if (chat.updatedAt) { const date = new Date(chat.updatedAt); timeDiv.textContent = this.formatTime(date); } infoDiv.appendChild(titleDiv); infoDiv.appendChild(previewDiv); infoDiv.appendChild(metaDiv); metaDiv.appendChild(timeDiv); if (isUnread) { const unreadBadge = document.createElement('div'); unreadBadge.className = 'unread-badge'; metaDiv.appendChild(unreadBadge); } li.appendChild(iconDiv); li.appendChild(infoDiv); chatList.appendChild(li); }); } /** * Render chat view */ renderChatView() { const emptyState = document.getElementById('emptyState'); const chatView = document.getElementById('chatView'); if (this.activeChat) { if (emptyState) emptyState.style.display = 'none'; if (chatView) chatView.style.display = 'flex'; // Рендерим заголовок чата this.renderChatHeader(); } else { if (emptyState) emptyState.style.display = 'flex'; if (chatView) chatView.style.display = 'none'; } } /** * Показать приветственный экран */ showWelcomeScreen() { console.log('📱 Showing welcome screen'); // Отписываемся от WebSocket текущего чата const activeChatId = stateService.getState('activeChatId'); if (activeChatId) { webSocketService.unsubscribeFromChat(activeChatId); } const emptyState = document.getElementById('emptyState'); const chatView = document.getElementById('chatView'); if (emptyState) emptyState.style.display = 'flex'; if (chatView) chatView.style.display = 'none'; // ОЧЕНЬ ВАЖНО: сбрасываем активный чат в null this.activeChat = null; stateService.setState('activeChatId', null); // Перерисовываем список чатов, чтобы убрать подсветку this.renderChatList(); } /** * Переключить приветственный экран */ toggleWelcomeScreen(show) { console.log(`📱 Toggling welcome screen: ${show}`); if (show) { this.showWelcomeScreen(); } else { const emptyState = document.getElementById('emptyState'); const chatView = document.getElementById('chatView'); if (emptyState) emptyState.style.display = 'none'; if (chatView) chatView.style.display = 'flex'; } } /** * Render messages - ОСНОВНОЙ ИСПРАВЛЕННЫЙ МЕТОД */ renderMessages() { const container = document.getElementById('messagesContainer'); if (!container) return; const messages = stateService.getActiveMessages(); const wasAtBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 100; container.innerHTML = ''; if (messages.length === 0) { // Показываем приветственное сообщение в чате const welcomeMessage = document.createElement('div'); welcomeMessage.className = 'chat-welcome-message'; welcomeMessage.innerHTML = ` <div class="welcome-icon-sm"> <i class="fas fa-robot"></i> </div> <h3>Начните диалог</h3> <p>Отправьте сообщение, чтобы начать общение с ассистентом</p> `; container.appendChild(welcomeMessage); } else { messages.forEach((msg) => { const messageEl = this.createMessageElement(msg); container.appendChild(messageEl); }); } // Авто-скролл если пользователь был внизу if (wasAtBottom) { setTimeout(() => { container.scrollTop = container.scrollHeight; }, 10); } } /** * Create message element - КЛЮЧЕВОЙ МЕТОД ДЛЯ ОТОБРАЖЕНИЯ ИНДИКАТОРА */ createMessageElement(msg) { const isOwn = msg.role === 'USER'; const messageWrapper = document.createElement('div'); messageWrapper.className = `message ${isOwn ? 'user' : 'assistant'}`; messageWrapper.id = `message-${msg.id}`; // Проверяем, нужно ли показывать индикатор печати // Правило: ЛЮБОЕ СООБЩЕНИЕ АССИСТЕНТА С ПУСТЫМ ТЕЛОМ ИЛИ СООБЩЕНИЕ АССИСТЕНТА СО СТАТУСОМ PROCESSING const isEmptyAssistantMessage = msg.role === 'ASSISTANT' && (!msg.content || msg.content.trim() === ''); const isProcessing = msg.role === 'ASSISTANT' && msg.status === 'PROCESSING'; // Получаем текущий этап пайплайна (если есть) const currentStage = stateService.getPipelineStage(msg.id); // Проверяем, это ли структурированный ответ const isStructuredResponse = !isEmptyAssistantMessage && responseRenderer.isFinalAnswer(msg.content); // Показываем индикатор если: пустое сообщение ИЛИ статус PROCESSING const showTypingIndicator = isEmptyAssistantMessage || (isProcessing && !isStructuredResponse); if (showTypingIndicator) { // Сообщение с индикатором печати messageWrapper.classList.add('message-typing'); } else if (isStructuredResponse) { messageWrapper.classList.add('message-structured'); } const avatarDiv = document.createElement('div'); avatarDiv.className = 'message-avatar'; avatarDiv.innerHTML = isOwn ? '<i class="fas fa-user"></i>' : '<i class="fas fa-robot"></i>'; const contentDiv = document.createElement('div'); contentDiv.className = 'message-content'; const bubbleDiv = document.createElement('div'); bubbleDiv.className = 'message-bubble'; const textDiv = document.createElement('div'); textDiv.className = 'message-text'; if (showTypingIndicator) { // ИНДИКАТОР ПЕЧАТИ ВНУТРИ СООБЩЕНИЯ const stageText = currentStage || 'Ищу оптимальный подход...'; textDiv.innerHTML = ` <div class="typing-indicator-inline"> <div class="typing-dots"> <div class="typing-dot"></div> <div class="typing-dot"></div> <div class="typing-dot"></div> </div> <div class="typing-stage"> ${stageText} </div> ${currentStage ? `<div class="stage-description">Этап выполнения</div>` : ''} </div> `; // Добавляем атрибут для отслеживания messageWrapper.setAttribute('data-stage', stageText); } else if (isStructuredResponse) { // Обработка структурированного ответа FinalAnswer const finalAnswer = responseRenderer.parseFinalAnswer(msg.content); if (finalAnswer) { const structuredContent = responseRenderer.renderStructuredResponse(finalAnswer); textDiv.appendChild(structuredContent); } else { // Fallback: show raw content if parsing failed textDiv.innerHTML = this.formatMessageContent(msg.content || ''); } } else if (msg.content && msg.content.trim() !== '') { // Обычный текст с форматированием textDiv.innerHTML = this.formatMessageContent(msg.content); } else if (msg.role === 'ASSISTANT' && msg.status === 'FAILED') { textDiv.textContent = '❌ Ошибка при генерации ответа'; } else { textDiv.innerHTML = this.formatMessageContent(msg.content || ''); } const metaDiv = document.createElement('div'); metaDiv.className = 'message-meta'; const timeDiv = document.createElement('div'); timeDiv.className = 'message-time'; const date = new Date(msg.createdAt || Date.now()); timeDiv.textContent = date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); bubbleDiv.appendChild(textDiv); metaDiv.appendChild(timeDiv); bubbleDiv.appendChild(metaDiv); contentDiv.appendChild(bubbleDiv); messageWrapper.appendChild(avatarDiv); messageWrapper.appendChild(contentDiv); return messageWrapper; } /** * Форматирование сообщения */ formatMessageContent(content) { if (!content) return ''; return content .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') .replace(/\*(.*?)\*/g, '<em>$1</em>') .replace(/`(.*?)`/g, '<code>$1</code>') .replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>') .replace(/\n/g, '<br>'); } /** * Format time */ formatTime(date) { const now = new Date(); const diff = now - date; if (diff < 60000) { return 'Только что'; } else if (diff < 3600000) { const mins = Math.floor(diff / 60000); return `${mins} мин назад`; } else if (diff < 86400000) { const hours = Math.floor(diff / 3600000); return `${hours} ч назад`; } else { return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }); } } } const uiManager = new UIManager(); console.log('🎪 UI Manager ready');