|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
} |
|
|
|
|
|
getCurrentConversation() { |
|
if (!this.currentConversationId) return null; |
|
return this.conversations.get(this.currentConversationId); |
|
} |
|
|
|
|
|
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; |
|
} |
|
|
|
|
|
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(); |
|
|
|
|
|
if ( |
|
role === "user" && |
|
conversation.messages.filter((m) => m.role === "user").length === 1 |
|
) { |
|
conversation.title = this.generateTitle(content); |
|
} |
|
|
|
this.saveToStorage(); |
|
return message; |
|
} |
|
|
|
|
|
generateTitle(content) { |
|
|
|
const cleanContent = content.trim().replace(/\s+/g, ' '); |
|
|
|
|
|
let title; |
|
|
|
|
|
if (cleanContent.toLowerCase().includes('napište') || cleanContent.toLowerCase().includes('napiš')) { |
|
|
|
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ř')) { |
|
|
|
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')) { |
|
|
|
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')) { |
|
|
|
const match = cleanContent.match(/vysvětl\w*\s+(.+)/i); |
|
title = match ? `Vysvětlit: ${match[1]}` : cleanContent; |
|
} else if (cleanContent.toLowerCase().includes('oprav')) { |
|
|
|
const match = cleanContent.match(/oprav\w*\s+(.+)/i); |
|
title = match ? `Opravit: ${match[1]}` : cleanContent; |
|
} else { |
|
|
|
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(" "); |
|
} |
|
} |
|
|
|
|
|
title = title.replace(/[^\w\s.,!?-áčďéěíňóřšťúůýž]/gi, '').trim(); |
|
if (title.length > 50) { |
|
title = title.substring(0, 47) + "..."; |
|
} |
|
|
|
return title || "New Chat"; |
|
} |
|
|
|
|
|
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); |
|
} |
|
} |
|
|
|
|
|
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); |
|
} |
|
} |
|
} |
|
|
|
|
|
class APIManager { |
|
constructor() { |
|
this.baseURL = window.location.origin; |
|
this.abortController = null; |
|
this.requestTimeout = 60000; |
|
this.retryDelay = 1000; |
|
this.maxRetryDelay = 10000; |
|
this.apiVersion = "v1"; |
|
} |
|
|
|
|
|
async makeRequest(endpoint, options = {}, retries = 3) { |
|
const url = `${this.baseURL}${endpoint}`; |
|
|
|
for (let attempt = 0; attempt <= retries; attempt++) { |
|
try { |
|
|
|
this.abortController = new AbortController(); |
|
|
|
|
|
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; |
|
} |
|
|
|
|
|
const delay = Math.min( |
|
this.retryDelay * Math.pow(2, attempt), |
|
this.maxRetryDelay |
|
); |
|
await this.sleep(delay); |
|
} |
|
} |
|
} |
|
|
|
|
|
async sendMessage(message, history = []) { |
|
|
|
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; |
|
} |
|
|
|
|
|
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; |
|
} |
|
|
|
|
|
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; |
|
} |
|
} |
|
|
|
|
|
cancelRequest() { |
|
if (this.abortController) { |
|
this.abortController.abort(); |
|
this.abortController = null; |
|
} |
|
} |
|
|
|
|
|
sleep(ms) { |
|
return new Promise((resolve) => setTimeout(resolve, ms)); |
|
} |
|
} |
|
|
|
|
|
class ConnectionMonitor { |
|
constructor(onStatusChange) { |
|
this.onStatusChange = onStatusChange; |
|
this.isOnline = navigator.onLine; |
|
this.lastPingTime = 0; |
|
this.pingInterval = 15000; |
|
this.setupEventListeners(); |
|
this.startPingTest(); |
|
} |
|
|
|
setupEventListeners() { |
|
window.addEventListener("online", () => { |
|
this.isOnline = true; |
|
this.onStatusChange("online"); |
|
|
|
this.pingServer(); |
|
}); |
|
|
|
window.addEventListener("offline", () => { |
|
this.isOnline = false; |
|
this.onStatusChange("offline"); |
|
}); |
|
} |
|
|
|
|
|
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; |
|
} |
|
} |
|
|
|
|
|
startPingTest() { |
|
|
|
setTimeout(() => this.pingServer(), 1000); |
|
|
|
|
|
setInterval(() => { |
|
this.pingServer(); |
|
}, this.pingInterval); |
|
} |
|
|
|
|
|
getPingTime() { |
|
return this.lastPingTime; |
|
} |
|
|
|
|
|
async checkConnection() { |
|
return await this.pingServer(); |
|
} |
|
} |
|
|
|
|
|
class MessageRenderer { |
|
constructor() { |
|
this.messageContainer = null; |
|
} |
|
|
|
setContainer(container) { |
|
this.messageContainer = container; |
|
} |
|
|
|
|
|
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; |
|
|
|
|
|
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"; |
|
} |
|
|
|
|
|
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">${ |
|
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>` |
|
: "" |
|
} |
|
`; |
|
|
|
|
|
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; |
|
} |
|
|
|
|
|
formatContent(content) { |
|
|
|
let formatted = content; |
|
|
|
|
|
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, '"') |
|
.replace(/'/g, '''); |
|
|
|
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>`; |
|
} |
|
); |
|
|
|
|
|
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>' |
|
); |
|
|
|
|
|
formatted = formatted |
|
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>") |
|
.replace(/\*([^*]+)\*/g, "<em>$1</em>") |
|
.replace(/\n/g, "<br>"); |
|
|
|
return formatted; |
|
} |
|
|
|
|
|
formatTime(timestamp) { |
|
return new Date(timestamp).toLocaleTimeString().slice(0, 5); |
|
} |
|
|
|
|
|
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; |
|
} |
|
|
|
|
|
showTyping() { |
|
this.hideTyping(); |
|
if (this.messageContainer) { |
|
const typingIndicator = this.createTypingIndicator(); |
|
this.messageContainer.appendChild(typingIndicator); |
|
this.scrollToBottom(); |
|
} |
|
} |
|
|
|
|
|
hideTyping() { |
|
const existing = document.getElementById("typing-indicator"); |
|
if (existing) { |
|
existing.remove(); |
|
} |
|
} |
|
|
|
|
|
scrollToBottom() { |
|
if (this.messageContainer && this.messageContainer.parentElement) { |
|
this.messageContainer.parentElement.scrollTop = |
|
this.messageContainer.parentElement.scrollHeight; |
|
} |
|
} |
|
} |
|
|
|
|
|
const chatState = new ChatState(); |
|
const apiManager = new APIManager(); |
|
const messageRenderer = new MessageRenderer(); |
|
|
|
|
|
let isI18nReady = false; |
|
const elements = {}; |
|
|
|
|
|
class ChatApp { |
|
constructor() { |
|
this.state = chatState; |
|
this.api = apiManager; |
|
this.renderer = messageRenderer; |
|
this.connectionMonitor = null; |
|
this.elements = {}; |
|
this.isProcessingMessage = false; |
|
} |
|
|
|
|
|
async init() { |
|
console.log("Initializing Chat Application..."); |
|
|
|
|
|
if (document.readyState === "loading") { |
|
await new Promise((resolve) => { |
|
document.addEventListener("DOMContentLoaded", resolve); |
|
}); |
|
} |
|
|
|
|
|
this.state.loadFromStorage(); |
|
|
|
|
|
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"), |
|
}; |
|
|
|
|
|
this.renderer.setContainer(this.elements.messages); |
|
|
|
|
|
this.connectionMonitor = new ConnectionMonitor((status) => { |
|
this.handleConnectionStatusChange(status); |
|
}); |
|
|
|
|
|
this.setupEventListeners(); |
|
|
|
|
|
this.setupModelSelector(); |
|
|
|
|
|
const currentConversation = this.state.getCurrentConversation(); |
|
const currentModel = currentConversation?.model || "Qwen 3 Coder (Default)"; |
|
this.updateChatHeader(currentModel); |
|
|
|
|
|
if (this.state.conversations.size === 0) { |
|
this.createNewConversation(); |
|
} else { |
|
this.loadCurrentConversation(); |
|
|
|
this.updateChatHistory(); |
|
} |
|
|
|
console.log("Chat Application initialized successfully"); |
|
} |
|
|
|
|
|
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; |
|
|
|
|
|
if (currentModelName) { |
|
currentModelName.textContent = modelName; |
|
} |
|
|
|
|
|
if (responseTimeElement) { |
|
if (modelId === 'qwen-4b-thinking') { |
|
responseTimeElement.textContent = 'Response time: ~1-3s'; |
|
} else { |
|
responseTimeElement.textContent = 'Response time: ~2-5s'; |
|
} |
|
} |
|
|
|
|
|
const conversation = this.state.getCurrentConversation(); |
|
if (conversation) { |
|
conversation.model = modelId; |
|
this.state.saveToStorage(); |
|
} |
|
|
|
|
|
this.updateChatHeader(modelName); |
|
|
|
|
|
this.showModelChangeNotification(modelName); |
|
|
|
|
|
const menu = modelDropdown.querySelector('[data-dd-menu]'); |
|
if (menu) { |
|
menu.classList.add('hidden'); |
|
} |
|
}); |
|
}); |
|
} |
|
|
|
|
|
|
|
showModelChangeNotification(modelName) { |
|
if (this.showNotification) { |
|
this.showNotification(`Přepnuto na model: ${modelName}`, "success"); |
|
} |
|
} |
|
|
|
|
|
updateChatHeader(modelName, conversationTitle = null) { |
|
const chatTitle = document.getElementById("chat-title"); |
|
const chatSubtitle = chatTitle?.nextElementSibling; |
|
|
|
|
|
if (conversationTitle && chatTitle) { |
|
chatTitle.textContent = conversationTitle; |
|
} |
|
|
|
|
|
if (chatSubtitle) { |
|
chatSubtitle.textContent = `Using ${modelName} • Ready to help`; |
|
} |
|
} |
|
|
|
|
|
setupEventListeners() { |
|
|
|
if (this.elements.sendButton) { |
|
this.elements.sendButton.addEventListener("click", () => |
|
this.handleSendMessage() |
|
); |
|
} |
|
|
|
|
|
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(); |
|
} |
|
}); |
|
|
|
|
|
this.elements.composer.addEventListener("input", () => { |
|
this.autoResizeComposer(); |
|
this.updateSendButtonState(); |
|
}); |
|
} |
|
|
|
|
|
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(); |
|
} |
|
} |
|
}); |
|
} |
|
|
|
|
|
const newChatBtn = document.querySelector("#left-desktop button"); |
|
if (newChatBtn && newChatBtn.textContent.includes("New chat")) { |
|
newChatBtn.addEventListener("click", () => { |
|
this.createNewConversation(); |
|
}); |
|
} |
|
|
|
|
|
setInterval(() => { |
|
this.state.saveToStorage(); |
|
}, 30000); |
|
|
|
|
|
window.addEventListener("beforeunload", () => { |
|
this.state.saveToStorage(); |
|
}); |
|
} |
|
|
|
|
|
async handleSendMessage() { |
|
const message = this.elements.composer?.value?.trim(); |
|
if (!message || this.isProcessingMessage) return; |
|
|
|
this.isProcessingMessage = true; |
|
this.updateSendButtonState(); |
|
|
|
try { |
|
|
|
if (this.elements.composer) { |
|
this.elements.composer.value = ""; |
|
this.autoResizeComposer(); |
|
} |
|
|
|
|
|
const lastMessage = this.elements.messages?.lastElementChild; |
|
const isUserMessage = |
|
lastMessage?.querySelector(".text-sm")?.textContent === "You"; |
|
|
|
let userMessage; |
|
if ( |
|
isUserMessage && |
|
lastMessage.querySelector(".prose")?.textContent?.trim() === message |
|
) { |
|
|
|
userMessage = this.state.addMessage("user", message); |
|
} else { |
|
|
|
userMessage = this.state.addMessage("user", message); |
|
this.renderMessage(userMessage); |
|
} |
|
|
|
|
|
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); |
|
} |
|
|
|
|
|
this.renderer.showTyping(); |
|
|
|
|
|
const conversationForHistory = this.state.getCurrentConversation(); |
|
const history = conversationForHistory |
|
? conversation.messages.map((msg) => ({ |
|
role: msg.role, |
|
content: msg.content, |
|
})) |
|
: []; |
|
|
|
|
|
const response = await this.api.sendMessage( |
|
message, |
|
history.slice(0, -1) |
|
); |
|
|
|
|
|
this.renderer.hideTyping(); |
|
|
|
|
|
await this.handleStreamingResponse(response); |
|
} catch (error) { |
|
console.error("Error sending message:", error); |
|
this.renderer.hideTyping(); |
|
|
|
|
|
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(); |
|
} |
|
} |
|
|
|
|
|
async handleStreamingResponse(response) { |
|
const reader = response.body.getReader(); |
|
const decoder = new TextDecoder(); |
|
|
|
|
|
this.removeStubResponses(); |
|
|
|
|
|
const assistantMessage = this.state.addMessage("assistant", ""); |
|
const messageElement = this.renderMessage(assistantMessage); |
|
|
|
|
|
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; |
|
|
|
|
|
assistantMessage.content = accumulatedContent; |
|
contentDiv.innerHTML = this.renderer.formatContent(accumulatedContent); |
|
|
|
|
|
this.renderer.scrollToBottom(); |
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 10)); |
|
} |
|
|
|
|
|
assistantMessage.content = accumulatedContent; |
|
assistantMessage.status = "delivered"; |
|
contentDiv.innerHTML = this.renderer.formatContent(accumulatedContent); |
|
|
|
|
|
this.state.saveToStorage(); |
|
} catch (error) { |
|
console.error("Error reading stream:", error); |
|
|
|
|
|
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(); |
|
} |
|
} |
|
|
|
|
|
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(); |
|
} |
|
}); |
|
} |
|
|
|
|
|
async sendMessageOpenAI(message, options = {}) { |
|
try { |
|
const conversation = this.state.getCurrentConversation(); |
|
const messages = conversation |
|
? conversation.messages.map((msg) => ({ |
|
role: msg.role, |
|
content: msg.content, |
|
})) |
|
: []; |
|
|
|
|
|
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; |
|
} |
|
} |
|
|
|
|
|
renderMessage(message) { |
|
if (!this.elements.messages) return null; |
|
|
|
const messageElement = this.renderer.renderMessage(message); |
|
this.elements.messages.appendChild(messageElement); |
|
this.renderer.scrollToBottom(); |
|
|
|
return messageElement; |
|
} |
|
|
|
|
|
createNewConversation() { |
|
const conversation = this.state.createConversation(); |
|
this.renderWelcomeScreen(); |
|
this.updateChatHistory(); |
|
this.state.saveToStorage(); |
|
|
|
|
|
this.updateChatHeader(conversation.model || "Qwen 3 Coder (Default)", "New Chat"); |
|
|
|
console.log("Created new conversation:", conversation.id); |
|
} |
|
|
|
|
|
|
|
updateChatHistory() { |
|
const historyContainer = document.querySelector( |
|
"#left-desktop .overflow-y-auto" |
|
); |
|
if (!historyContainer) return; |
|
|
|
|
|
const existingChats = historyContainer.querySelectorAll(".chat-item"); |
|
existingChats.forEach((item) => item.remove()); |
|
|
|
|
|
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> |
|
`; |
|
|
|
|
|
chatItem.addEventListener("click", (e) => { |
|
|
|
if (e.target.closest(".delete-chat")) { |
|
e.stopPropagation(); |
|
return; |
|
} |
|
this.loadChatSession(conversation.id); |
|
}); |
|
|
|
|
|
const deleteBtn = chatItem.querySelector(".delete-chat"); |
|
if (deleteBtn) { |
|
deleteBtn.addEventListener("click", (e) => { |
|
e.stopPropagation(); |
|
e.preventDefault(); |
|
this.deleteChatSession(conversation.id); |
|
}); |
|
} |
|
|
|
historyContainer.appendChild(chatItem); |
|
}); |
|
} |
|
|
|
|
|
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(); |
|
} |
|
|
|
|
|
loadChatSession(sessionId) { |
|
this.state.currentConversationId = sessionId; |
|
const conversation = this.state.getCurrentConversation(); |
|
|
|
if (!conversation) { |
|
this.createNewConversation(); |
|
return; |
|
} |
|
|
|
|
|
if (this.elements.messages) { |
|
this.elements.messages.innerHTML = ""; |
|
} |
|
|
|
if (conversation.messages.length === 0) { |
|
this.renderWelcomeScreen(); |
|
} else { |
|
conversation.messages.forEach((message) => { |
|
this.renderMessage(message); |
|
}); |
|
} |
|
|
|
|
|
this.updateChatHistory(); |
|
this.state.saveToStorage(); |
|
|
|
|
|
this.updateChatHeader(conversation.model || "Qwen 3 Coder (Default)", conversation.title); |
|
|
|
console.log("Loaded chat session:", sessionId); |
|
} |
|
|
|
|
|
|
|
|
|
deleteChatSession(sessionId) { |
|
if (confirm("Opravdu chcete smazat tento chat? Tuto akci nelze vrátit zpět.")) { |
|
|
|
this.state.conversations.delete(sessionId); |
|
|
|
|
|
if (sessionId === this.state.currentConversationId) { |
|
this.state.currentConversationId = null; |
|
|
|
|
|
if (this.state.conversations.size > 0) { |
|
|
|
const conversations = Array.from(this.state.conversations.values()); |
|
const latestConversation = conversations.sort((a, b) => b.updated - a.updated)[0]; |
|
|
|
|
|
this.loadChatSession(latestConversation.id); |
|
} else { |
|
|
|
this.createNewConversation(); |
|
} |
|
} else { |
|
|
|
this.updateChatHistory(); |
|
} |
|
|
|
|
|
this.state.saveToStorage(); |
|
|
|
console.log("Chat session deleted:", sessionId); |
|
|
|
|
|
if (this.showNotification) { |
|
this.showNotification("Chat byl úspěšně smazán", "success"); |
|
} |
|
} |
|
} |
|
|
|
|
|
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); |
|
|
|
|
|
requestAnimationFrame(() => { |
|
notification.classList.remove("translate-x-full"); |
|
}); |
|
|
|
|
|
setTimeout(() => { |
|
notification.classList.add("translate-x-full"); |
|
setTimeout(() => { |
|
document.body.removeChild(notification); |
|
}, 300); |
|
}, 3000); |
|
} |
|
|
|
|
|
loadCurrentConversation() { |
|
const conversation = this.state.getCurrentConversation(); |
|
if (!conversation) { |
|
this.createNewConversation(); |
|
return; |
|
} |
|
|
|
|
|
if (this.elements.messages) { |
|
this.elements.messages.innerHTML = ""; |
|
} |
|
|
|
if (conversation.messages.length === 0) { |
|
this.renderWelcomeScreen(); |
|
} else { |
|
conversation.messages.forEach((message) => { |
|
this.renderMessage(message); |
|
}); |
|
} |
|
} |
|
|
|
|
|
renderWelcomeScreen() { |
|
if (!this.elements.messages) return; |
|
|
|
|
|
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> |
|
`; |
|
} |
|
|
|
|
|
autoResizeComposer() { |
|
if (!this.elements.composer) return; |
|
|
|
this.elements.composer.style.height = "auto"; |
|
const maxHeight = 160; |
|
this.elements.composer.style.height = |
|
Math.min(this.elements.composer.scrollHeight, maxHeight) + "px"; |
|
} |
|
|
|
|
|
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" |
|
); |
|
} |
|
|
|
|
|
if (this.elements.stopButton) { |
|
if (this.isProcessingMessage) { |
|
this.elements.stopButton.classList.remove("hidden"); |
|
} else { |
|
this.elements.stopButton.classList.add("hidden"); |
|
} |
|
} |
|
} |
|
|
|
|
|
handleConnectionStatusChange(status) { |
|
this.state.connectionStatus = status; |
|
console.log("Connection status changed to:", status); |
|
|
|
|
|
this.updateConnectionIndicator(status); |
|
|
|
|
|
if (status === "connected" && this.state.messageQueue.length > 0) { |
|
this.processMessageQueue(); |
|
} |
|
} |
|
|
|
|
|
updateConnectionIndicator(status) { |
|
|
|
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); |
|
} |
|
|
|
|
|
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"; |
|
|
|
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)"; |
|
} |
|
} |
|
|
|
|
|
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); |
|
|
|
this.state.messageQueue.unshift(queuedMessage); |
|
break; |
|
} |
|
} |
|
} |
|
|
|
|
|
cancelCurrentMessage() { |
|
this.api.cancelRequest(); |
|
this.renderer.hideTyping(); |
|
this.isProcessingMessage = false; |
|
this.updateSendButtonState(); |
|
} |
|
} |
|
|
|
|
|
const chatApp = new ChatApp(); |
|
|
|
|
|
if (document.readyState === "loading") { |
|
document.addEventListener("DOMContentLoaded", () => { |
|
chatApp.init().then(() => { |
|
window.chatApp = chatApp; |
|
console.log("ChatApp is now globally available"); |
|
}); |
|
}); |
|
} else { |
|
chatApp.init().then(() => { |
|
window.chatApp = chatApp; |
|
console.log("ChatApp is now globally available"); |
|
}); |
|
} |
|
|
|
|
|
if (typeof module !== "undefined" && module.exports) { |
|
module.exports = { |
|
ChatApp, |
|
ChatState, |
|
APIManager, |
|
MessageRenderer, |
|
ConnectionMonitor, |
|
}; |
|
} |
|
|
|
|
|
|
|
|
|
async function initializeI18n() { |
|
try { |
|
|
|
isI18nReady = true; |
|
return true; |
|
} catch (error) { |
|
console.error("Failed to initialize i18n:", error); |
|
isI18nReady = false; |
|
return false; |
|
} |
|
} |
|
|
|
|
|
function getLocalizedText(key, fallback) { |
|
|
|
return fallback || key; |
|
} |
|
|
|
|
|
function t(key, fallback = key) { |
|
return getLocalizedText(key, fallback); |
|
} |
|
|
|
|
|
class PerformanceMonitor { |
|
constructor() { |
|
this.metrics = new Map(); |
|
this.observers = new Map(); |
|
} |
|
|
|
|
|
startTiming(operation) { |
|
this.metrics.set(operation, { start: performance.now() }); |
|
} |
|
|
|
|
|
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`); |
|
} |
|
} |
|
|
|
|
|
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; |
|
} |
|
|
|
|
|
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); |
|
} |
|
} |
|
} |
|
} |
|
|
|
|
|
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() { |
|
try { |
|
this.socket = new WebSocket(this.url); |
|
this.setupEventListeners(); |
|
} catch (error) { |
|
console.error("WebSocket connection failed:", error); |
|
this.handleReconnect(); |
|
} |
|
} |
|
|
|
|
|
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(data) { |
|
if (this.socket && this.socket.readyState === WebSocket.OPEN) { |
|
this.socket.send(JSON.stringify(data)); |
|
} else { |
|
|
|
this.messageQueue.push(data); |
|
} |
|
} |
|
|
|
|
|
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"); |
|
} |
|
} |
|
|
|
|
|
startHeartbeat() { |
|
this.heartbeatInterval = setInterval(() => { |
|
if (this.socket && this.socket.readyState === WebSocket.OPEN) { |
|
this.send({ type: "ping" }); |
|
} |
|
}, 30000); |
|
} |
|
|
|
|
|
stopHeartbeat() { |
|
if (this.heartbeatInterval) { |
|
clearInterval(this.heartbeatInterval); |
|
this.heartbeatInterval = null; |
|
} |
|
} |
|
|
|
|
|
processMessageQueue() { |
|
while (this.messageQueue.length > 0) { |
|
const message = this.messageQueue.shift(); |
|
this.send(message); |
|
} |
|
} |
|
|
|
|
|
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() { |
|
this.stopHeartbeat(); |
|
if (this.socket) { |
|
this.socket.close(); |
|
this.socket = null; |
|
} |
|
} |
|
} |
|
|
|
|
|
class SecurityUtils { |
|
|
|
static sanitizeHTML(html) { |
|
const div = document.createElement("div"); |
|
div.textContent = html; |
|
return div.innerHTML; |
|
} |
|
|
|
|
|
static validateMessage(content) { |
|
if (typeof content !== "string") return false; |
|
if (content.length === 0 || content.length > 10000) return false; |
|
return true; |
|
} |
|
|
|
|
|
static createRateLimiter(limit, window) { |
|
const requests = new Map(); |
|
|
|
return function (identifier) { |
|
const now = Date.now(); |
|
const windowStart = now - window; |
|
|
|
|
|
for (const [key, timestamps] of requests.entries()) { |
|
requests.set( |
|
key, |
|
timestamps.filter((time) => time > windowStart) |
|
); |
|
if (requests.get(key).length === 0) { |
|
requests.delete(key); |
|
} |
|
} |
|
|
|
|
|
const userRequests = requests.get(identifier) || []; |
|
if (userRequests.length >= limit) { |
|
return false; |
|
} |
|
|
|
|
|
userRequests.push(now); |
|
requests.set(identifier, userRequests); |
|
return true; |
|
}; |
|
} |
|
} |
|
|
|
|
|
const performanceMonitor = new PerformanceMonitor(); |
|
performanceMonitor.observeNetworkTiming(); |
|
|
|
|
|
const messageLimiter = SecurityUtils.createRateLimiter(10, 60000); |
|
|
|
|
|
class ErrorReporter { |
|
constructor() { |
|
this.errors = []; |
|
this.maxErrors = 100; |
|
this.setupGlobalErrorHandling(); |
|
} |
|
|
|
setupGlobalErrorHandling() { |
|
|
|
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(), |
|
}); |
|
}); |
|
|
|
|
|
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); |
|
|
|
|
|
if (this.errors.length > this.maxErrors) { |
|
this.errors.shift(); |
|
} |
|
|
|
|
|
this.sendToAnalytics(error); |
|
} |
|
|
|
sendToAnalytics(error) { |
|
|
|
|
|
if (window.gtag) { |
|
window.gtag("event", "exception", { |
|
description: error.message, |
|
fatal: false, |
|
}); |
|
} |
|
} |
|
|
|
getRecentErrors() { |
|
return this.errors.slice(-10); |
|
} |
|
} |
|
|
|
|
|
const errorReporter = new ErrorReporter(); |
|
|
|
|
|
class AccessibilityManager { |
|
constructor() { |
|
this.setupKeyboardNavigation(); |
|
this.setupScreenReaderSupport(); |
|
} |
|
|
|
setupKeyboardNavigation() { |
|
document.addEventListener("keydown", (event) => { |
|
|
|
if (event.ctrlKey || event.metaKey) { |
|
switch (event.key) { |
|
case "n": |
|
event.preventDefault(); |
|
chatApp.createNewConversation(); |
|
break; |
|
case "/": |
|
event.preventDefault(); |
|
chatApp.elements.composer?.focus(); |
|
break; |
|
} |
|
} |
|
|
|
|
|
if (event.key === "Escape") { |
|
chatApp.cancelCurrentMessage(); |
|
} |
|
}); |
|
} |
|
|
|
setupScreenReaderSupport() { |
|
|
|
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}`; |
|
} |
|
} |
|
|
|
|
|
} |
|
|
|
|
|
window.copyToClipboard = function(button) { |
|
const codeBlock = button.closest('.code-block'); |
|
const codeContent = codeBlock.querySelector('code').textContent; |
|
|
|
navigator.clipboard.writeText(codeContent).then(() => { |
|
|
|
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> |
|
`; |
|
|
|
|
|
if (window.chatApp && window.chatApp.showNotification) { |
|
window.chatApp.showNotification("Kód byl zkopírován do schránky", "success"); |
|
} |
|
|
|
|
|
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"); |
|
} |
|
}); |
|
}; |
|
|