/** * Enhanced AI Chat Application - Core JavaScript Implementation * * Features: * - Real-time chat functionality * - WebSocket support for live messaging * - State management for conversations * - API communication with retry logic * - Typing indicators and message status * - Connection monitoring * - Performance optimizations */ // Application State Management class ChatState { constructor() { this.conversations = new Map(); this.currentConversationId = null; this.isConnected = false; this.isTyping = false; this.lastActivity = Date.now(); this.connectionStatus = "disconnected"; this.messageQueue = []; this.retryAttempts = 0; this.maxRetries = 3; } // Get current conversation getCurrentConversation() { if (!this.currentConversationId) return null; return this.conversations.get(this.currentConversationId); } // Create new conversation createConversation(title = "New Chat") { const id = `chat_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const conversation = { id, title, messages: [], created: Date.now(), updated: Date.now(), model: "qwen-coder-3-30b", metadata: {}, }; this.conversations.set(id, conversation); this.currentConversationId = id; return conversation; } // Add message to current conversation addMessage(role, content, metadata = {}) { const conversation = this.getCurrentConversation(); if (!conversation) return null; const message = { id: `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, role, content, timestamp: Date.now(), status: "sent", metadata, }; conversation.messages.push(message); conversation.updated = Date.now(); // Update conversation title if it's the first user message if ( role === "user" && conversation.messages.filter((m) => m.role === "user").length === 1 ) { conversation.title = this.generateTitle(content); } this.saveToStorage(); return message; } // Generate conversation title from first message generateTitle(content) { // Remove extra whitespace and line breaks const cleanContent = content.trim().replace(/\s+/g, ' '); // Generate a more intelligent title let title; // Check for common patterns if (cleanContent.toLowerCase().includes('napište') || cleanContent.toLowerCase().includes('napiš')) { // Extract what user wants to write const match = cleanContent.match(/napi[šs]\w*\s+(.+)/i); title = match ? `Napsat: ${match[1]}` : cleanContent; } else if (cleanContent.toLowerCase().includes('vytvořte') || cleanContent.toLowerCase().includes('vytvoř')) { // Extract what user wants to create const match = cleanContent.match(/vytvo[řr]\w*\s+(.+)/i); title = match ? `Vytvořit: ${match[1]}` : cleanContent; } else if (cleanContent.toLowerCase().includes('pomozte') || cleanContent.toLowerCase().includes('pomoc')) { // Help requests title = `Pomoc: ${cleanContent.replace(/pomozte\s*mi\s*/i, '').replace(/pomoc\s*s\s*/i, '')}`; } else if (cleanContent.toLowerCase().includes('vysvětlete') || cleanContent.toLowerCase().includes('vysvětli')) { // Explanations const match = cleanContent.match(/vysvětl\w*\s+(.+)/i); title = match ? `Vysvětlit: ${match[1]}` : cleanContent; } else if (cleanContent.toLowerCase().includes('oprav')) { // Fixes const match = cleanContent.match(/oprav\w*\s+(.+)/i); title = match ? `Opravit: ${match[1]}` : cleanContent; } else { // Default: use first meaningful words const words = cleanContent.split(" "); const meaningfulWords = words.filter(word => word.length > 2 && !['jak', 'kde', 'kdy', 'proč', 'která', 'který', 'které'].includes(word.toLowerCase()) ); title = meaningfulWords.slice(0, 4).join(" "); if (title.length < 10 && words.length > 4) { title = words.slice(0, 6).join(" "); } } // Cleanup and limit length title = title.replace(/[^\w\s.,!?-áčďéěíňóřšťúůýž]/gi, '').trim(); if (title.length > 50) { title = title.substring(0, 47) + "..."; } return title || "New Chat"; } // Save state to localStorage saveToStorage() { try { const data = { conversations: Array.from(this.conversations.entries()), currentConversationId: this.currentConversationId, timestamp: Date.now(), }; localStorage.setItem("chatState", JSON.stringify(data)); } catch (error) { console.error("Failed to save state:", error); } } // Load state from localStorage loadFromStorage() { try { const data = JSON.parse(localStorage.getItem("chatState") || "{}"); if (data.conversations) { this.conversations = new Map(data.conversations); this.currentConversationId = data.currentConversationId; } } catch (error) { console.error("Failed to load state:", error); } } } // API Communication Manager class APIManager { constructor() { this.baseURL = window.location.origin; this.abortController = null; this.requestTimeout = 60000; // 60 seconds for AI responses this.retryDelay = 1000; // 1 second this.maxRetryDelay = 10000; // 10 seconds this.apiVersion = "v1"; } // Make API request with retry logic async makeRequest(endpoint, options = {}, retries = 3) { const url = `${this.baseURL}${endpoint}`; for (let attempt = 0; attempt <= retries; attempt++) { try { // Create new AbortController for this request this.abortController = new AbortController(); // Add timeout handling const timeoutId = setTimeout(() => { this.abortController.abort(); }, this.requestTimeout); const response = await fetch(url, { ...options, signal: this.abortController.signal, }); clearTimeout(timeoutId); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } return response; } catch (error) { console.warn(`Request attempt ${attempt + 1} failed:`, error); if (attempt === retries) { throw error; } // Exponential backoff const delay = Math.min( this.retryDelay * Math.pow(2, attempt), this.maxRetryDelay ); await this.sleep(delay); } } } // Send chat message using streaming endpoint async sendMessage(message, history = []) { // Get current model from conversation or default const conversation = this.state.getCurrentConversation(); const modelId = conversation?.model || "qwen-coder-3-30b"; const response = await this.makeRequest("/chat", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ message, history: history.map((msg) => ({ role: msg.role, content: msg.content, })), model: modelId }), }); return response; } // Send message using OpenAI API compatible endpoint async sendMessageOpenAI(messages, options = {}) { const requestBody = { model: options.model || "qwen-coder-3-30b", messages: messages.map((msg) => ({ role: msg.role, content: msg.content, })), max_tokens: options.maxTokens || 1024, temperature: options.temperature || 0.7, stream: options.stream || false, }; const response = await this.makeRequest("/v1/chat/completions", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${options.apiKey || "dummy-key"}`, }, body: JSON.stringify(requestBody), }); return response; } // Health check endpoint async healthCheck() { try { const response = await fetch(`${this.baseURL}/ping`, { method: "HEAD", cache: "no-cache", }); return response.ok; } catch (error) { console.warn("Health check failed:", error); return false; } } // Cancel current request cancelRequest() { if (this.abortController) { this.abortController.abort(); this.abortController = null; } } // Utility sleep function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } } // Connection Monitor class ConnectionMonitor { constructor(onStatusChange) { this.onStatusChange = onStatusChange; this.isOnline = navigator.onLine; this.lastPingTime = 0; this.pingInterval = 15000; // Check every 15 seconds this.setupEventListeners(); this.startPingTest(); } setupEventListeners() { window.addEventListener("online", () => { this.isOnline = true; this.onStatusChange("online"); // Immediately test connection when coming back online this.pingServer(); }); window.addEventListener("offline", () => { this.isOnline = false; this.onStatusChange("offline"); }); } // Ping server to check actual connectivity async pingServer() { if (!this.isOnline) return false; try { const startTime = Date.now(); const response = await fetch("/ping", { method: "HEAD", cache: "no-cache", timeout: 5000, }); const pingTime = Date.now() - startTime; const isConnected = response.ok; this.lastPingTime = pingTime; this.onStatusChange(isConnected ? "connected" : "disconnected"); return isConnected; } catch (error) { console.warn("Ping failed:", error); this.onStatusChange("disconnected"); return false; } } // Periodic connectivity test startPingTest() { // Initial ping setTimeout(() => this.pingServer(), 1000); // Regular pings setInterval(() => { this.pingServer(); }, this.pingInterval); } // Get current ping time getPingTime() { return this.lastPingTime; } // Manual connectivity check async checkConnection() { return await this.pingServer(); } } // Message Renderer class MessageRenderer { constructor() { this.messageContainer = null; } setContainer(container) { this.messageContainer = container; } // Render a single message renderMessage(message) { const messageDiv = document.createElement("div"); messageDiv.className = `relative flex items-start gap-3 px-4 py-5 sm:px-6`; messageDiv.dataset.messageId = message.id; // Create avatar const avatar = document.createElement("div"); avatar.className = `mt-1 h-8 w-8 shrink-0 overflow-hidden rounded-full ${ message.role === "user" ? "bg-zinc-300 grid place-items-center text-[10px] font-medium" : "bg-zinc-200" }`; if (message.role === "user") { avatar.textContent = "YOU"; } // Create message content wrapper const contentWrapper = document.createElement("div"); contentWrapper.className = "min-w-0 flex-1"; // Create message header const header = document.createElement("div"); header.className = "mb-1 flex items-baseline gap-2"; header.innerHTML = `
${ message.role === "user" ? "You" : "Ava" }
${this.formatTime( message.timestamp )}
${ message.status !== "sent" ? `
${message.status}
` : "" } `; // Create message content const content = document.createElement("div"); content.className = message.role === "user" ? "prose prose-sm max-w-none rounded-2xl border border-zinc-200 bg-white p-4 dark:prose-invert dark:border-zinc-800 dark:bg-zinc-900" : "prose prose-sm max-w-none rounded-2xl border border-zinc-200 bg-zinc-50 p-4 dark:prose-invert dark:border-zinc-800 dark:bg-zinc-800/60"; content.innerHTML = this.formatContent(message.content); contentWrapper.appendChild(header); contentWrapper.appendChild(content); messageDiv.appendChild(avatar); messageDiv.appendChild(contentWrapper); return messageDiv; } // Format message content (handle markdown, code, etc.) formatContent(content) { // Enhanced formatting with code block support let formatted = content; // First handle code blocks (triple backticks) formatted = formatted.replace( /```(\w+)?\n?([\s\S]*?)```/g, (match, language, code) => { const lang = language ? ` data-language="${language}"` : ''; const escapedCode = code .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); return `
${language || 'Code'}
${escapedCode}
`; } ); // Then handle inline code (single backticks) formatted = formatted.replace( /`([^`]+)`/g, '$1' ); // Handle other markdown formatting formatted = formatted .replace(/\*\*([^*]+)\*\*/g, "$1") .replace(/\*([^*]+)\*/g, "$1") .replace(/\n/g, "
"); return formatted; } // Format timestamp formatTime(timestamp) { return new Date(timestamp).toLocaleTimeString().slice(0, 5); } // Create typing indicator createTypingIndicator() { const messageDiv = document.createElement("div"); messageDiv.className = "relative flex items-start gap-3 px-4 py-5 sm:px-6"; messageDiv.id = "typing-indicator"; const avatar = document.createElement("div"); avatar.className = "mt-1 h-8 w-8 shrink-0 overflow-hidden rounded-full bg-zinc-200"; const contentWrapper = document.createElement("div"); contentWrapper.className = "min-w-0 flex-1"; const header = document.createElement("div"); header.className = "mb-1 flex items-baseline gap-2"; header.innerHTML = '
Ava
typing...
'; const typingDots = document.createElement("div"); typingDots.className = "flex items-center space-x-1 p-4"; typingDots.innerHTML = `
`; contentWrapper.appendChild(header); contentWrapper.appendChild(typingDots); messageDiv.appendChild(avatar); messageDiv.appendChild(contentWrapper); return messageDiv; } // Show typing indicator showTyping() { this.hideTyping(); // Remove any existing typing indicator if (this.messageContainer) { const typingIndicator = this.createTypingIndicator(); this.messageContainer.appendChild(typingIndicator); this.scrollToBottom(); } } // Hide typing indicator hideTyping() { const existing = document.getElementById("typing-indicator"); if (existing) { existing.remove(); } } // Scroll to bottom of message container scrollToBottom() { if (this.messageContainer && this.messageContainer.parentElement) { this.messageContainer.parentElement.scrollTop = this.messageContainer.parentElement.scrollHeight; } } } // Initialize global instances const chatState = new ChatState(); const apiManager = new APIManager(); const messageRenderer = new MessageRenderer(); // DOM element references let isI18nReady = false; const elements = {}; // Will be populated in DOMContentLoaded // Chat Application Controller class ChatApp { constructor() { this.state = chatState; this.api = apiManager; this.renderer = messageRenderer; this.connectionMonitor = null; this.elements = {}; this.isProcessingMessage = false; } // Initialize the application async init() { console.log("Initializing Chat Application..."); // Wait for DOM to be ready if (document.readyState === "loading") { await new Promise((resolve) => { document.addEventListener("DOMContentLoaded", resolve); }); } // Load state from storage this.state.loadFromStorage(); // Get DOM elements from existing HTML structure this.elements = { messages: document.getElementById("messages"), composer: document.getElementById("composer"), sendButton: document.getElementById("btn-send"), stopButton: document.getElementById("btn-stop"), messagesScroller: document.getElementById("msg-scroll"), leftSidebar: document.getElementById("left-desktop"), modelDropdown: document.getElementById("model-dd"), chatMore: document.getElementById("chat-more"), themeButton: document.getElementById("btn-theme"), }; // Set up message renderer this.renderer.setContainer(this.elements.messages); // Initialize connection monitoring this.connectionMonitor = new ConnectionMonitor((status) => { this.handleConnectionStatusChange(status); }); // Set up event listeners this.setupEventListeners(); // Setup model selector this.setupModelSelector(); // Initialize chat header with current model const currentConversation = this.state.getCurrentConversation(); const currentModel = currentConversation?.model || "Qwen 3 Coder (Default)"; this.updateChatHeader(currentModel); // Initialize with existing conversation or create new one if (this.state.conversations.size === 0) { this.createNewConversation(); } else { this.loadCurrentConversation(); // Zobrazíme historii chatů hned při načtení this.updateChatHistory(); } console.log("Chat Application initialized successfully"); } // Setup model selector functionality setupModelSelector() { const modelDropdown = this.elements.modelDropdown; if (!modelDropdown) return; const modelOptions = modelDropdown.querySelectorAll('.model-option'); const currentModelName = document.getElementById('current-model-name'); const responseTimeElement = document.getElementById('model-response-time'); modelOptions.forEach(option => { option.addEventListener('click', (e) => { e.preventDefault(); const modelId = option.dataset.modelId; const modelName = option.querySelector('.font-medium').textContent; // Update UI if (currentModelName) { currentModelName.textContent = modelName; } // Update response time estimate if (responseTimeElement) { if (modelId === 'qwen-4b-thinking') { responseTimeElement.textContent = 'Response time: ~1-3s'; } else { responseTimeElement.textContent = 'Response time: ~2-5s'; } } // Update current conversation model const conversation = this.state.getCurrentConversation(); if (conversation) { conversation.model = modelId; this.state.saveToStorage(); } // Update chat header this.updateChatHeader(modelName); // Show model change notification this.showModelChangeNotification(modelName); // Close dropdown const menu = modelDropdown.querySelector('[data-dd-menu]'); if (menu) { menu.classList.add('hidden'); } }); }); } // Show model change notification // Show model change notification showModelChangeNotification(modelName) { if (this.showNotification) { this.showNotification(`Přepnuto na model: ${modelName}`, "success"); } } // Update chat header with current model and conversation title updateChatHeader(modelName, conversationTitle = null) { const chatTitle = document.getElementById("chat-title"); const chatSubtitle = chatTitle?.nextElementSibling; // Update the conversation title if provided if (conversationTitle && chatTitle) { chatTitle.textContent = conversationTitle; } // Update the subtitle with model info if (chatSubtitle) { chatSubtitle.textContent = `Using ${modelName} • Ready to help`; } } // Set up all event listeners setupEventListeners() { // Message sending if (this.elements.sendButton) { this.elements.sendButton.addEventListener("click", () => this.handleSendMessage() ); } // Stop button if (this.elements.stopButton) { this.elements.stopButton.addEventListener("click", () => this.cancelCurrentMessage() ); } if (this.elements.composer) { this.elements.composer.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); this.handleSendMessage(); } }); // Auto-resize textarea this.elements.composer.addEventListener("input", () => { this.autoResizeComposer(); this.updateSendButtonState(); }); } // Handle suggestion clicks from welcome screen if (this.elements.messages) { this.elements.messages.addEventListener("click", (e) => { const suggestion = e.target.closest("[data-suggest]"); if (suggestion) { const text = suggestion.textContent.trim(); if (text && this.elements.composer) { this.elements.composer.value = text; this.autoResizeComposer(); this.updateSendButtonState(); this.elements.composer.focus(); } } }); } // New chat button in sidebar const newChatBtn = document.querySelector("#left-desktop button"); if (newChatBtn && newChatBtn.textContent.includes("New chat")) { newChatBtn.addEventListener("click", () => { this.createNewConversation(); }); } // Auto-save state periodically setInterval(() => { this.state.saveToStorage(); }, 30000); // Save every 30 seconds // Save on page unload window.addEventListener("beforeunload", () => { this.state.saveToStorage(); }); } // Handle sending messages async handleSendMessage() { const message = this.elements.composer?.value?.trim(); if (!message || this.isProcessingMessage) return; this.isProcessingMessage = true; this.updateSendButtonState(); try { // Clear composer if (this.elements.composer) { this.elements.composer.value = ""; this.autoResizeComposer(); } // Check if message was already added by inline script const lastMessage = this.elements.messages?.lastElementChild; const isUserMessage = lastMessage?.querySelector(".text-sm")?.textContent === "You"; let userMessage; if ( isUserMessage && lastMessage.querySelector(".prose")?.textContent?.trim() === message ) { // Message already added by inline script, just update our state userMessage = this.state.addMessage("user", message); } else { // Add user message normally userMessage = this.state.addMessage("user", message); this.renderMessage(userMessage); } // Update chat title in header if this was the first user message const conversation = this.state.getCurrentConversation(); if (conversation && conversation.messages.filter(m => m.role === "user").length === 1) { this.updateChatHeader(conversation.model || "Qwen 3 Coder (Default)", conversation.title); } // Show typing indicator this.renderer.showTyping(); // Prepare conversation history for API const conversationForHistory = this.state.getCurrentConversation(); const history = conversationForHistory ? conversation.messages.map((msg) => ({ role: msg.role, content: msg.content, })) : []; // Use the streaming chat endpoint const response = await this.api.sendMessage( message, history.slice(0, -1) ); // Hide typing indicator this.renderer.hideTyping(); // Handle streaming response await this.handleStreamingResponse(response); } catch (error) { console.error("Error sending message:", error); this.renderer.hideTyping(); // Add error message based on error type let errorMessage = "Sorry, I encountered an error. Please try again."; if (error.name === "AbortError") { errorMessage = "Request was cancelled."; } else if (error.message.includes("HTTP 429")) { errorMessage = "Too many requests. Please wait a moment and try again."; } else if (error.message.includes("HTTP 401")) { errorMessage = "Authentication error. Please check your API key."; } else if (error.message.includes("HTTP 500")) { errorMessage = "Server error. The AI service is temporarily unavailable."; } else if (error.message.includes("Failed to fetch")) { errorMessage = "Network error. Please check your internet connection."; } const errorMsg = this.state.addMessage("assistant", errorMessage); this.renderMessage(errorMsg); } finally { this.isProcessingMessage = false; this.updateSendButtonState(); } } // Handle streaming API response async handleStreamingResponse(response) { const reader = response.body.getReader(); const decoder = new TextDecoder(); // Remove any stub responses first this.removeStubResponses(); // Create assistant message const assistantMessage = this.state.addMessage("assistant", ""); const messageElement = this.renderMessage(assistantMessage); // Get content div for streaming updates const contentDiv = messageElement.querySelector(".prose"); let accumulatedContent = ""; try { while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); accumulatedContent += chunk; // Update message content with real-time formatting assistantMessage.content = accumulatedContent; contentDiv.innerHTML = this.renderer.formatContent(accumulatedContent); // Scroll to bottom smoothly this.renderer.scrollToBottom(); // Add a small delay to make streaming visible await new Promise((resolve) => setTimeout(resolve, 10)); } // Final update with complete content assistantMessage.content = accumulatedContent; assistantMessage.status = "delivered"; contentDiv.innerHTML = this.renderer.formatContent(accumulatedContent); // Update state with final content this.state.saveToStorage(); } catch (error) { console.error("Error reading stream:", error); // If streaming fails, try to get any partial content if (accumulatedContent.trim()) { assistantMessage.content = accumulatedContent; assistantMessage.status = "partial"; contentDiv.innerHTML = this.renderer.formatContent(accumulatedContent) + '
[Response incomplete]'; } else { assistantMessage.content = "Error receiving response. Please try again."; assistantMessage.status = "error"; contentDiv.innerHTML = this.renderer.formatContent( assistantMessage.content ); } this.state.saveToStorage(); } } // Remove stub responses that might have been added by inline script removeStubResponses() { if (!this.elements.messages) return; const messages = this.elements.messages.querySelectorAll( ".relative.flex.items-start" ); messages.forEach((messageElement) => { const content = messageElement.querySelector(".prose"); if ( content && (content.textContent.includes("Stubbed response") || content.textContent.includes("Sem přijde odpověď modelu") || content.textContent.includes("Chat system loading")) ) { messageElement.remove(); } }); } // Alternative method using OpenAI API format (for future use) async sendMessageOpenAI(message, options = {}) { try { const conversation = this.state.getCurrentConversation(); const messages = conversation ? conversation.messages.map((msg) => ({ role: msg.role, content: msg.content, })) : []; // Add current message messages.push({ role: "user", content: message }); const response = await this.api.sendMessageOpenAI(messages, { model: options.model || "qwen-coder-3-30b", maxTokens: options.maxTokens || 1024, temperature: options.temperature || 0.7, stream: false, }); const data = await response.json(); if (data.choices && data.choices[0] && data.choices[0].message) { return data.choices[0].message.content; } else { throw new Error("Invalid response format from OpenAI API"); } } catch (error) { console.error("OpenAI API error:", error); throw error; } } // Render a message to the UI renderMessage(message) { if (!this.elements.messages) return null; const messageElement = this.renderer.renderMessage(message); this.elements.messages.appendChild(messageElement); this.renderer.scrollToBottom(); return messageElement; } // Create new conversation createNewConversation() { const conversation = this.state.createConversation(); this.renderWelcomeScreen(); this.updateChatHistory(); this.state.saveToStorage(); // Update chat header with default title this.updateChatHeader(conversation.model || "Qwen 3 Coder (Default)", "New Chat"); console.log("Created new conversation:", conversation.id); } // Update chat history in sidebar // Update chat history in sidebar updateChatHistory() { const historyContainer = document.querySelector( "#left-desktop .overflow-y-auto" ); if (!historyContainer) return; // Clear existing history (keep search and new chat button) const existingChats = historyContainer.querySelectorAll(".chat-item"); existingChats.forEach((item) => item.remove()); // Add conversations const conversations = Array.from(this.state.conversations.values()).sort( (a, b) => b.updated - a.updated ); conversations.forEach((conversation) => { const chatItem = document.createElement("button"); chatItem.className = `chat-item group flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left hover:bg-zinc-100 dark:hover:bg-zinc-800 relative ${ conversation.id === this.state.currentConversationId ? "bg-zinc-100 dark:bg-zinc-800" : "" }`; chatItem.innerHTML = `
${conversation.title}
${this.formatChatDate( conversation.updated )}
`; // Handle chat selection chatItem.addEventListener("click", (e) => { // Prevent click if clicking on delete button if (e.target.closest(".delete-chat")) { e.stopPropagation(); return; } this.loadChatSession(conversation.id); }); // Handle chat deletion const deleteBtn = chatItem.querySelector(".delete-chat"); if (deleteBtn) { deleteBtn.addEventListener("click", (e) => { e.stopPropagation(); e.preventDefault(); this.deleteChatSession(conversation.id); }); } historyContainer.appendChild(chatItem); }); } // Format chat date for display formatChatDate(timestamp) { const date = new Date(timestamp); const now = new Date(); const diffInHours = (now - date) / (1000 * 60 * 60); if (diffInHours < 1) return "Just now"; if (diffInHours < 24) return `${Math.floor(diffInHours)}h ago`; if (diffInHours < 48) return "Yesterday"; if (diffInHours < 168) return `${Math.floor(diffInHours / 24)}d ago`; return date.toLocaleDateString(); } // Load a chat session loadChatSession(sessionId) { this.state.currentConversationId = sessionId; const conversation = this.state.getCurrentConversation(); if (!conversation) { this.createNewConversation(); return; } // Clear existing messages if (this.elements.messages) { this.elements.messages.innerHTML = ""; } if (conversation.messages.length === 0) { this.renderWelcomeScreen(); } else { conversation.messages.forEach((message) => { this.renderMessage(message); }); } // Update UI this.updateChatHistory(); this.state.saveToStorage(); // Update chat header with conversation title this.updateChatHeader(conversation.model || "Qwen 3 Coder (Default)", conversation.title); console.log("Loaded chat session:", sessionId); } // Delete a chat session // Delete a chat session // Delete a chat session deleteChatSession(sessionId) { if (confirm("Opravdu chcete smazat tento chat? Tuto akci nelze vrátit zpět.")) { // Smažeme konverzaci ze stavu this.state.conversations.delete(sessionId); // Pokud mažeme současnou konverzaci if (sessionId === this.state.currentConversationId) { this.state.currentConversationId = null; // Zkontrolujeme, jestli existují jiné konverzace if (this.state.conversations.size > 0) { // Najdeme nejnovější konverzaci const conversations = Array.from(this.state.conversations.values()); const latestConversation = conversations.sort((a, b) => b.updated - a.updated)[0]; // Přepneme na nejnovější konverzaci this.loadChatSession(latestConversation.id); } else { // Pokud neexistují žádné konverzace, vytvoříme novou this.createNewConversation(); } } else { // Jinak jen aktualizujeme historii this.updateChatHistory(); } // Uložíme stav this.state.saveToStorage(); console.log("Chat session deleted:", sessionId); // Zobrazíme notifikaci if (this.showNotification) { this.showNotification("Chat byl úspěšně smazán", "success"); } } } // Show notification (simple implementation) showNotification(message, type = "info") { const notification = document.createElement("div"); notification.className = `fixed top-20 right-4 z-50 px-4 py-2 rounded-lg ${ type === "success" ? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200" : "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200" } text-sm font-medium transition-all duration-300 transform translate-x-full`; notification.textContent = message; document.body.appendChild(notification); // Animate in requestAnimationFrame(() => { notification.classList.remove("translate-x-full"); }); // Animate out after 3 seconds setTimeout(() => { notification.classList.add("translate-x-full"); setTimeout(() => { document.body.removeChild(notification); }, 300); }, 3000); } // Load current conversation loadCurrentConversation() { const conversation = this.state.getCurrentConversation(); if (!conversation) { this.createNewConversation(); return; } // Clear existing messages if (this.elements.messages) { this.elements.messages.innerHTML = ""; } if (conversation.messages.length === 0) { this.renderWelcomeScreen(); } else { conversation.messages.forEach((message) => { this.renderMessage(message); }); } } // Render welcome screen renderWelcomeScreen() { if (!this.elements.messages) return; // Use the existing welcome message structure from HTML this.elements.messages.innerHTML = `
Ava
${new Date() .toLocaleTimeString() .slice(0, 5)}

Ahoj! 👋 Jsem tvůj AI asistent. Jaký úkol dnes řešíš?

Refactor code Generate unit tests Explain this snippet Create README
`; } // Auto-resize composer textarea autoResizeComposer() { if (!this.elements.composer) return; this.elements.composer.style.height = "auto"; const maxHeight = 160; // max-h-40 from Tailwind (160px) this.elements.composer.style.height = Math.min(this.elements.composer.scrollHeight, maxHeight) + "px"; } // Update send button state updateSendButtonState() { if (!this.elements.sendButton || !this.elements.composer) return; const hasText = this.elements.composer.value.trim().length > 0; const isEnabled = hasText && !this.isProcessingMessage; this.elements.sendButton.disabled = !isEnabled; if (isEnabled) { this.elements.sendButton.classList.remove( "disabled:cursor-not-allowed", "disabled:opacity-50" ); } else { this.elements.sendButton.classList.add( "disabled:cursor-not-allowed", "disabled:opacity-50" ); } // Show/hide stop button based on processing state if (this.elements.stopButton) { if (this.isProcessingMessage) { this.elements.stopButton.classList.remove("hidden"); } else { this.elements.stopButton.classList.add("hidden"); } } } // Handle connection status changes handleConnectionStatusChange(status) { this.state.connectionStatus = status; console.log("Connection status changed to:", status); // Update UI to show connection status this.updateConnectionIndicator(status); // Retry queued messages when connection is restored if (status === "connected" && this.state.messageQueue.length > 0) { this.processMessageQueue(); } } // Update connection indicator in UI updateConnectionIndicator(status) { // Find or create connection indicator let indicator = document.getElementById("connection-indicator"); if (!indicator) { indicator = document.createElement("div"); indicator.id = "connection-indicator"; indicator.className = "fixed bottom-4 right-4 z-50 px-3 py-1 rounded-full text-xs font-medium transition-all duration-300"; document.body.appendChild(indicator); } // Update indicator based on status switch (status) { case "connected": indicator.className = "fixed bottom-4 right-4 z-50 px-3 py-1 rounded-full text-xs font-medium transition-all duration-300 bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"; indicator.textContent = "🟢 Connected"; // Hide after 2 seconds setTimeout(() => { indicator.style.opacity = "0"; indicator.style.transform = "translateY(20px)"; }, 2000); break; case "disconnected": indicator.className = "fixed bottom-4 right-4 z-50 px-3 py-1 rounded-full text-xs font-medium transition-all duration-300 bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"; indicator.textContent = "🔴 Disconnected"; indicator.style.opacity = "1"; indicator.style.transform = "translateY(0)"; break; case "offline": indicator.className = "fixed top-4 right-4 z-50 px-3 py-1 rounded-full text-xs font-medium transition-all duration-300 bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"; indicator.textContent = "⚠️ Offline"; indicator.style.opacity = "1"; indicator.style.transform = "translateY(0)"; break; default: indicator.style.opacity = "0"; indicator.style.transform = "translateY(-20px)"; } } // Process queued messages when connection is restored async processMessageQueue() { while (this.state.messageQueue.length > 0) { const queuedMessage = this.state.messageQueue.shift(); try { await this.handleSendMessage(queuedMessage); } catch (error) { console.error("Failed to process queued message:", error); // Re-queue if failed this.state.messageQueue.unshift(queuedMessage); break; } } } // Cancel current message processing cancelCurrentMessage() { this.api.cancelRequest(); this.renderer.hideTyping(); this.isProcessingMessage = false; this.updateSendButtonState(); } } // Initialize the chat application const chatApp = new ChatApp(); // Start the application when DOM is ready and make it globally available if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", () => { chatApp.init().then(() => { window.chatApp = chatApp; // Make globally available console.log("ChatApp is now globally available"); }); }); } else { chatApp.init().then(() => { window.chatApp = chatApp; // Make globally available console.log("ChatApp is now globally available"); }); } // Export for potential module usage if (typeof module !== "undefined" && module.exports) { module.exports = { ChatApp, ChatState, APIManager, MessageRenderer, ConnectionMonitor, }; } // Utility functions for backward compatibility and additional features // Initialize i18n system (simplified for this implementation) async function initializeI18n() { try { // Simple mock implementation - can be enhanced with actual i18n isI18nReady = true; return true; } catch (error) { console.error("Failed to initialize i18n:", error); isI18nReady = false; return false; } } // Utility function for localized text function getLocalizedText(key, fallback) { // Simple implementation - return fallback for now return fallback || key; } // Helper function for translations function t(key, fallback = key) { return getLocalizedText(key, fallback); } // Performance monitoring class PerformanceMonitor { constructor() { this.metrics = new Map(); this.observers = new Map(); } // Start timing an operation startTiming(operation) { this.metrics.set(operation, { start: performance.now() }); } // End timing an operation endTiming(operation) { const metric = this.metrics.get(operation); if (metric) { metric.end = performance.now(); metric.duration = metric.end - metric.start; console.log(`${operation} took ${metric.duration.toFixed(2)}ms`); } } // Monitor memory usage getMemoryUsage() { if (performance.memory) { return { used: Math.round(performance.memory.usedJSHeapSize / 1048576), total: Math.round(performance.memory.totalJSHeapSize / 1048576), limit: Math.round(performance.memory.jsHeapSizeLimit / 1048576), }; } return null; } // Monitor network timing observeNetworkTiming() { if ("PerformanceObserver" in window) { const observer = new PerformanceObserver((list) => { list.getEntries().forEach((entry) => { if (entry.entryType === "navigation") { console.log("Navigation timing:", { dns: entry.domainLookupEnd - entry.domainLookupStart, connection: entry.connectEnd - entry.connectStart, request: entry.responseStart - entry.requestStart, response: entry.responseEnd - entry.responseStart, }); } }); }); try { observer.observe({ entryTypes: ["navigation", "resource"] }); this.observers.set("network", observer); } catch (error) { console.warn("Performance observer not supported:", error); } } } } // Enhanced WebSocket support for real-time features class WebSocketManager { constructor(url) { this.url = url; this.socket = null; this.reconnectAttempts = 0; this.maxReconnectAttempts = 5; this.reconnectDelay = 1000; this.heartbeatInterval = null; this.messageQueue = []; this.listeners = new Map(); } // Connect to WebSocket connect() { try { this.socket = new WebSocket(this.url); this.setupEventListeners(); } catch (error) { console.error("WebSocket connection failed:", error); this.handleReconnect(); } } // Setup WebSocket event listeners setupEventListeners() { if (!this.socket) return; this.socket.onopen = () => { console.log("WebSocket connected"); this.reconnectAttempts = 0; this.startHeartbeat(); this.processMessageQueue(); this.emit("connected"); }; this.socket.onmessage = (event) => { try { const data = JSON.parse(event.data); this.emit("message", data); } catch (error) { console.error("Failed to parse WebSocket message:", error); } }; this.socket.onclose = () => { console.log("WebSocket disconnected"); this.stopHeartbeat(); this.emit("disconnected"); this.handleReconnect(); }; this.socket.onerror = (error) => { console.error("WebSocket error:", error); this.emit("error", error); }; } // Send message via WebSocket send(data) { if (this.socket && this.socket.readyState === WebSocket.OPEN) { this.socket.send(JSON.stringify(data)); } else { // Queue message for later sending this.messageQueue.push(data); } } // Handle reconnection logic handleReconnect() { if (this.reconnectAttempts < this.maxReconnectAttempts) { this.reconnectAttempts++; const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1); console.log( `Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts})` ); setTimeout(() => { this.connect(); }, delay); } else { console.error("Max reconnection attempts reached"); this.emit("maxReconnectReached"); } } // Start heartbeat to keep connection alive startHeartbeat() { this.heartbeatInterval = setInterval(() => { if (this.socket && this.socket.readyState === WebSocket.OPEN) { this.send({ type: "ping" }); } }, 30000); // Send heartbeat every 30 seconds } // Stop heartbeat stopHeartbeat() { if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); this.heartbeatInterval = null; } } // Process queued messages processMessageQueue() { while (this.messageQueue.length > 0) { const message = this.messageQueue.shift(); this.send(message); } } // Event emitter functionality on(event, callback) { if (!this.listeners.has(event)) { this.listeners.set(event, []); } this.listeners.get(event).push(callback); } off(event, callback) { const eventListeners = this.listeners.get(event); if (eventListeners) { const index = eventListeners.indexOf(callback); if (index > -1) { eventListeners.splice(index, 1); } } } emit(event, data) { const eventListeners = this.listeners.get(event); if (eventListeners) { eventListeners.forEach((callback) => callback(data)); } } // Disconnect WebSocket disconnect() { this.stopHeartbeat(); if (this.socket) { this.socket.close(); this.socket = null; } } } // Security utilities class SecurityUtils { // Sanitize HTML content static sanitizeHTML(html) { const div = document.createElement("div"); div.textContent = html; return div.innerHTML; } // Validate message content static validateMessage(content) { if (typeof content !== "string") return false; if (content.length === 0 || content.length > 10000) return false; return true; } // Rate limiting static createRateLimiter(limit, window) { const requests = new Map(); return function (identifier) { const now = Date.now(); const windowStart = now - window; // Clean old requests for (const [key, timestamps] of requests.entries()) { requests.set( key, timestamps.filter((time) => time > windowStart) ); if (requests.get(key).length === 0) { requests.delete(key); } } // Check current requests const userRequests = requests.get(identifier) || []; if (userRequests.length >= limit) { return false; // Rate limited } // Add current request userRequests.push(now); requests.set(identifier, userRequests); return true; // Allowed }; } } // Initialize performance monitoring const performanceMonitor = new PerformanceMonitor(); performanceMonitor.observeNetworkTiming(); // Rate limiter for message sending (max 10 messages per minute) const messageLimiter = SecurityUtils.createRateLimiter(10, 60000); // Enhanced error reporting class ErrorReporter { constructor() { this.errors = []; this.maxErrors = 100; this.setupGlobalErrorHandling(); } setupGlobalErrorHandling() { // Catch unhandled errors window.addEventListener("error", (event) => { this.reportError({ type: "javascript", message: event.message, filename: event.filename, line: event.lineno, column: event.colno, stack: event.error?.stack, timestamp: Date.now(), }); }); // Catch unhandled promise rejections window.addEventListener("unhandledrejection", (event) => { this.reportError({ type: "promise", message: event.reason?.message || "Unhandled promise rejection", stack: event.reason?.stack, timestamp: Date.now(), }); }); } reportError(error) { console.error("Error reported:", error); this.errors.push(error); // Keep only recent errors if (this.errors.length > this.maxErrors) { this.errors.shift(); } // Send to analytics service if available this.sendToAnalytics(error); } sendToAnalytics(error) { // Placeholder for analytics integration // In production, you might send to services like Sentry, LogRocket, etc. if (window.gtag) { window.gtag("event", "exception", { description: error.message, fatal: false, }); } } getRecentErrors() { return this.errors.slice(-10); // Return last 10 errors } } // Initialize error reporter const errorReporter = new ErrorReporter(); // Accessibility helpers class AccessibilityManager { constructor() { this.setupKeyboardNavigation(); this.setupScreenReaderSupport(); } setupKeyboardNavigation() { document.addEventListener("keydown", (event) => { // Handle global keyboard shortcuts if (event.ctrlKey || event.metaKey) { switch (event.key) { case "n": event.preventDefault(); chatApp.createNewConversation(); break; case "/": event.preventDefault(); chatApp.elements.composer?.focus(); break; } } // Escape key to cancel current operation if (event.key === "Escape") { chatApp.cancelCurrentMessage(); } }); } setupScreenReaderSupport() { // Announce new messages to screen readers const announcer = document.createElement("div"); announcer.setAttribute("aria-live", "polite"); announcer.setAttribute("aria-atomic", "true"); announcer.className = "sr-only"; document.body.appendChild(announcer); this.announcer = announcer; } announceMessage(message) { if (this.announcer) { this.announcer.textContent = `${ message.role === "user" ? "You" : "Assistant" }: ${message.content}`; } } } // Global function for copying code to clipboard window.copyToClipboard = function(button) { const codeBlock = button.closest('.code-block'); const codeContent = codeBlock.querySelector('code').textContent; navigator.clipboard.writeText(codeContent).then(() => { // Změníme ikonu na checkmark button.innerHTML = ` `; // Zobrazíme notifikaci if (window.chatApp && window.chatApp.showNotification) { window.chatApp.showNotification("Kód byl zkopírován do schránky", "success"); } // Vraťme ikonu zpět po 2 sekundách setTimeout(() => { button.innerHTML = ` `; }, 2000); }).catch(err => { console.error('Failed to copy code:', err); if (window.chatApp && window.chatApp.showNotification) { window.chatApp.showNotification("Chyba při kopírování kódu", "error"); } }); };