Qwen3 / public /app.js
Semnykcz's picture
Upload 18 files
e0581b4 verified
/**
* 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 = `
<div class="text-sm font-medium">${
message.role === "user" ? "You" : "Ava"
}</div>
<div class="text-xs text-zinc-500">${this.formatTime(
message.timestamp
)}</div>
${
message.status !== "sent"
? `<div class="text-xs text-zinc-400">${message.status}</div>`
: ""
}
`;
// 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
return `<div class="code-block my-4">
<div class="code-header bg-zinc-100 dark:bg-zinc-800 px-3 py-2 text-xs text-zinc-600 dark:text-zinc-400 border-b border-zinc-200 dark:border-zinc-700 rounded-t-md flex items-center justify-between">
<span>${language || 'Code'}</span>
<button class="copy-code-btn text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300" onclick="copyToClipboard(this)" title="Kopírovat kód">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
</div>
<pre class="code-content bg-zinc-50 dark:bg-zinc-900 p-4 rounded-b-md overflow-x-auto text-sm border border-zinc-200 dark:border-zinc-700"${lang}><code>${escapedCode}</code></pre>
</div>`;
}
);
// Then handle inline code (single backticks)
formatted = formatted.replace(
/`([^`]+)`/g,
'<code class="bg-zinc-200 dark:bg-zinc-700 px-1.5 py-0.5 rounded text-sm font-mono">$1</code>'
);
// Handle other markdown formatting
formatted = formatted
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
.replace(/\n/g, "<br>");
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 =
'<div class="text-sm font-medium">Ava</div><div class="text-xs text-zinc-500">typing...</div>';
const typingDots = document.createElement("div");
typingDots.className = "flex items-center space-x-1 p-4";
typingDots.innerHTML = `
<div class="w-2 h-2 bg-zinc-400 rounded-full animate-bounce" style="animation-delay: 0ms"></div>
<div class="w-2 h-2 bg-zinc-400 rounded-full animate-bounce" style="animation-delay: 150ms"></div>
<div class="w-2 h-2 bg-zinc-400 rounded-full animate-bounce" style="animation-delay: 300ms"></div>
`;
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) +
'<br><em class="text-zinc-400 text-xs">[Response incomplete]</em>';
} 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 = `
<svg class="h-4 w-4 text-zinc-500 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 12c2 0 4 1 5 3-1 2-3 3-5 3s-4-1-5-3c1-2 3-3 5-3Z"/>
<circle cx="12" cy="8" r="3"/>
</svg>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">${conversation.title}</div>
<div class="truncate text-xs text-zinc-500">${this.formatChatDate(
conversation.updated
)}</div>
</div>
<div class="chat-actions opacity-0 group-hover:opacity-100 transition-opacity flex items-center">
<button class="delete-chat p-1.5 hover:bg-red-100 dark:hover:bg-red-900 rounded-md transition-colors" data-chat-id="${
conversation.id
}" title="Smazat chat">
<svg class="h-4 w-4 text-red-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"></path>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</button>
</div>
`;
// 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 = `
<div class="relative flex items-start gap-3 px-4 py-5 sm:px-6">
<div class="mt-1 h-8 w-8 shrink-0 overflow-hidden rounded-full bg-zinc-200"></div>
<div class="min-w-0 flex-1">
<div class="mb-1 flex items-baseline gap-2">
<div class="text-sm font-medium">Ava</div>
<div class="text-xs text-zinc-500">${new Date()
.toLocaleTimeString()
.slice(0, 5)}</div>
</div>
<div class="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">
<p>Ahoj! 👋 Jsem tvůj AI asistent. Jaký úkol dnes řešíš?</p>
<div class="mt-3 flex flex-wrap gap-2">
<span class="inline-flex cursor-pointer select-none items-center rounded-md bg-zinc-100 px-2.5 py-1 text-xs ring-1 ring-inset ring-zinc-200 dark:bg-zinc-800 dark:ring-zinc-700" data-suggest>Refactor code</span>
<span class="inline-flex cursor-pointer select-none items-center rounded-md bg-zinc-100 px-2.5 py-1 text-xs ring-1 ring-inset ring-zinc-200 dark:bg-zinc-800 dark:ring-zinc-700" data-suggest>Generate unit tests</span>
<span class="inline-flex cursor-pointer select-none items-center rounded-md bg-zinc-100 px-2.5 py-1 text-xs ring-1 ring-inset ring-zinc-200 dark:bg-zinc-800 dark:ring-zinc-700" data-suggest>Explain this snippet</span>
<span class="inline-flex cursor-pointer select-none items-center rounded-md bg-zinc-100 px-2.5 py-1 text-xs ring-1 ring-inset ring-zinc-200 dark:bg-zinc-800 dark:ring-zinc-700" data-suggest>Create README</span>
</div>
</div>
</div>
</div>
`;
}
// 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 = `
<svg class="h-4 w-4 text-green-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 12l2 2 4-4"/>
<path d="M21 12c-1 0-3-1-3-3s2-3 3-3 3 1 3 3-2 3-3 3"/>
<path d="M3 12c1 0 3-1 3-3s-2-3-3-3-3 1-3 3 2 3 3 3"/>
</svg>
`;
// 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 = `
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
`;
}, 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");
}
});
};