Semnykcz commited on
Commit
58bd7e3
·
verified ·
1 Parent(s): d342ec0

Upload 18 files

Browse files
Files changed (3) hide show
  1. app.py +80 -1
  2. public/app.js +581 -358
  3. public/index.html +32 -56
app.py CHANGED
@@ -168,7 +168,52 @@ def generate_streaming_response(messages: List[ChatMessage], temperature: float
168
  yield f"data: {json.dumps({'choices': [{'delta': {'content': error_msg}}]})}\n\n"
169
  yield f"data: {json.dumps({'choices': [{'finish_reason': 'stop'}]})}\n\n"
170
  yield "data: [DONE]\n\n"
171
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  # FastAPI app
173
  app = FastAPI(title="AI Chat API", description="OpenAI compatible interface for Qwen model")
174
 
@@ -197,6 +242,11 @@ async def ping():
197
  """Simple ping endpoint"""
198
  return {"status": "pong"}
199
 
 
 
 
 
 
200
  @app.get("/api/models")
201
  async def list_models():
202
  """List available models"""
@@ -254,6 +304,35 @@ async def openai_chat_completion(request: ChatRequest):
254
  """OpenAI API compatible endpoint"""
255
  return await chat_completion(request)
256
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  # Mount static files AFTER API routes
258
  app.mount("/", StaticFiles(directory="public", html=True), name="static")
259
 
 
168
  yield f"data: {json.dumps({'choices': [{'delta': {'content': error_msg}}]})}\n\n"
169
  yield f"data: {json.dumps({'choices': [{'finish_reason': 'stop'}]})}\n\n"
170
  yield "data: [DONE]\n\n"
171
+
172
+ def generate_plain_text_stream(messages: List[ChatMessage], temperature: float = 0.7, max_tokens: int = 2048):
173
+ """Plain text streaming generator used by /chat compatibility endpoint (no SSE)."""
174
+ try:
175
+ if model is None or tokenizer is None:
176
+ # Fallback streaming: plain text (no SSE)
177
+ response = "I'm a Qwen AI assistant. The model is currently loading, please try again in a moment."
178
+ for ch in response:
179
+ yield ch
180
+ time.sleep(0.02)
181
+ return
182
+
183
+ # Format messages
184
+ formatted_messages = [{"role": m.role, "content": m.content} for m in messages]
185
+
186
+ # Apply chat template
187
+ text = tokenizer.apply_chat_template(
188
+ formatted_messages,
189
+ tokenize=False,
190
+ add_generation_prompt=True
191
+ )
192
+
193
+ # Tokenize
194
+ inputs = tokenizer(text, return_tensors="pt").to(model.device)
195
+
196
+ # Setup streaming (plain text)
197
+ streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
198
+ generation_kwargs = {
199
+ **inputs,
200
+ "max_new_tokens": max_tokens,
201
+ "temperature": temperature,
202
+ "do_sample": True,
203
+ "pad_token_id": tokenizer.eos_token_id,
204
+ "streamer": streamer
205
+ }
206
+
207
+ thread = Thread(target=model.generate, kwargs=generation_kwargs)
208
+ thread.start()
209
+
210
+ for new_text in streamer:
211
+ if new_text:
212
+ yield new_text
213
+ except Exception as e:
214
+ logger.error(f"Error in plain streaming generation: {e}")
215
+ yield f"[error] {str(e)}"
216
+
217
  # FastAPI app
218
  app = FastAPI(title="AI Chat API", description="OpenAI compatible interface for Qwen model")
219
 
 
242
  """Simple ping endpoint"""
243
  return {"status": "pong"}
244
 
245
+ @app.head("/ping")
246
+ async def ping_head():
247
+ """HEAD ping for health checks"""
248
+ return Response(status_code=200)
249
+
250
  @app.get("/api/models")
251
  async def list_models():
252
  """List available models"""
 
304
  """OpenAI API compatible endpoint"""
305
  return await chat_completion(request)
306
 
307
+ @app.post("/chat")
308
+ async def chat_stream_compat(payload: Dict[str, Any]):
309
+ """Compatibility endpoint for frontend streaming /chat (plain text stream)."""
310
+ try:
311
+ message = str(payload.get("message", "") or "").strip()
312
+ history_raw = payload.get("history", []) or []
313
+
314
+ history_msgs: List[ChatMessage] = []
315
+ for item in history_raw:
316
+ role = item.get("role")
317
+ content = item.get("content")
318
+ if role and content is not None:
319
+ history_msgs.append(ChatMessage(role=role, content=str(content)))
320
+
321
+ if message:
322
+ history_msgs.append(ChatMessage(role="user", content=message))
323
+
324
+ return StreamingResponse(
325
+ generate_plain_text_stream(
326
+ history_msgs,
327
+ temperature=0.7,
328
+ max_tokens=2048
329
+ ),
330
+ media_type="text/plain; charset=utf-8"
331
+ )
332
+ except Exception as e:
333
+ logger.error(f"/chat compatibility error: {e}")
334
+ raise HTTPException(status_code=400, detail="Invalid request body")
335
+
336
  # Mount static files AFTER API routes
337
  app.mount("/", StaticFiles(directory="public", html=True), name="static")
338
 
public/app.js CHANGED
@@ -1,6 +1,6 @@
1
  /**
2
  * Enhanced AI Chat Application - Core JavaScript Implementation
3
- *
4
  * Features:
5
  * - Real-time chat functionality
6
  * - WebSocket support for live messaging
@@ -19,7 +19,7 @@ class ChatState {
19
  this.isConnected = false;
20
  this.isTyping = false;
21
  this.lastActivity = Date.now();
22
- this.connectionStatus = 'disconnected';
23
  this.messageQueue = [];
24
  this.retryAttempts = 0;
25
  this.maxRetries = 3;
@@ -32,7 +32,7 @@ class ChatState {
32
  }
33
 
34
  // Create new conversation
35
- createConversation(title = 'New Chat') {
36
  const id = `chat_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
37
  const conversation = {
38
  id,
@@ -40,8 +40,8 @@ class ChatState {
40
  messages: [],
41
  created: Date.now(),
42
  updated: Date.now(),
43
- model: 'qwen-coder-3-30b',
44
- metadata: {}
45
  };
46
  this.conversations.set(id, conversation);
47
  this.currentConversationId = id;
@@ -58,15 +58,18 @@ class ChatState {
58
  role,
59
  content,
60
  timestamp: Date.now(),
61
- status: 'sent',
62
- metadata
63
  };
64
 
65
  conversation.messages.push(message);
66
  conversation.updated = Date.now();
67
-
68
  // Update conversation title if it's the first user message
69
- if (role === 'user' && conversation.messages.filter(m => m.role === 'user').length === 1) {
 
 
 
70
  conversation.title = this.generateTitle(content);
71
  }
72
 
@@ -76,12 +79,13 @@ class ChatState {
76
 
77
  // Generate conversation title from first message
78
  generateTitle(content) {
79
- const words = content.trim().split(' ').slice(0, 5).join(' ');
80
- let title = words.length > 35 ? words.substring(0, 32) + '...' : words || 'New Chat';
81
-
 
82
  // Clean up title for better readability
83
- title = title.replace(/[^\w\s.,!?-]/g, '').trim();
84
-
85
  return title;
86
  }
87
 
@@ -91,24 +95,24 @@ class ChatState {
91
  const data = {
92
  conversations: Array.from(this.conversations.entries()),
93
  currentConversationId: this.currentConversationId,
94
- timestamp: Date.now()
95
  };
96
- localStorage.setItem('chatState', JSON.stringify(data));
97
  } catch (error) {
98
- console.error('Failed to save state:', error);
99
  }
100
  }
101
 
102
  // Load state from localStorage
103
  loadFromStorage() {
104
  try {
105
- const data = JSON.parse(localStorage.getItem('chatState') || '{}');
106
  if (data.conversations) {
107
  this.conversations = new Map(data.conversations);
108
  this.currentConversationId = data.currentConversationId;
109
  }
110
  } catch (error) {
111
- console.error('Failed to load state:', error);
112
  }
113
  }
114
  }
@@ -121,26 +125,26 @@ class APIManager {
121
  this.requestTimeout = 60000; // 60 seconds for AI responses
122
  this.retryDelay = 1000; // 1 second
123
  this.maxRetryDelay = 10000; // 10 seconds
124
- this.apiVersion = 'v1';
125
  }
126
 
127
  // Make API request with retry logic
128
  async makeRequest(endpoint, options = {}, retries = 3) {
129
  const url = `${this.baseURL}${endpoint}`;
130
-
131
  for (let attempt = 0; attempt <= retries; attempt++) {
132
  try {
133
  // Create new AbortController for this request
134
  this.abortController = new AbortController();
135
-
136
  // Add timeout handling
137
  const timeoutId = setTimeout(() => {
138
  this.abortController.abort();
139
  }, this.requestTimeout);
140
-
141
  const response = await fetch(url, {
142
  ...options,
143
- signal: this.abortController.signal
144
  });
145
 
146
  clearTimeout(timeoutId);
@@ -152,13 +156,16 @@ class APIManager {
152
  return response;
153
  } catch (error) {
154
  console.warn(`Request attempt ${attempt + 1} failed:`, error);
155
-
156
  if (attempt === retries) {
157
  throw error;
158
  }
159
 
160
  // Exponential backoff
161
- const delay = Math.min(this.retryDelay * Math.pow(2, attempt), this.maxRetryDelay);
 
 
 
162
  await this.sleep(delay);
163
  }
164
  }
@@ -166,18 +173,18 @@ class APIManager {
166
 
167
  // Send chat message using streaming endpoint
168
  async sendMessage(message, history = []) {
169
- const response = await this.makeRequest('/chat', {
170
- method: 'POST',
171
  headers: {
172
- 'Content-Type': 'application/json',
173
  },
174
- body: JSON.stringify({
175
- message,
176
- history: history.map(msg => ({
177
  role: msg.role,
178
- content: msg.content
179
- }))
180
- })
181
  });
182
 
183
  return response;
@@ -186,23 +193,23 @@ class APIManager {
186
  // Send message using OpenAI API compatible endpoint
187
  async sendMessageOpenAI(messages, options = {}) {
188
  const requestBody = {
189
- model: options.model || 'qwen-coder-3-30b',
190
- messages: messages.map(msg => ({
191
  role: msg.role,
192
- content: msg.content
193
  })),
194
  max_tokens: options.maxTokens || 1024,
195
  temperature: options.temperature || 0.7,
196
- stream: options.stream || false
197
  };
198
 
199
- const response = await this.makeRequest('/v1/chat/completions', {
200
- method: 'POST',
201
  headers: {
202
- 'Content-Type': 'application/json',
203
- 'Authorization': `Bearer ${options.apiKey || 'dummy-key'}`
204
  },
205
- body: JSON.stringify(requestBody)
206
  });
207
 
208
  return response;
@@ -211,13 +218,13 @@ class APIManager {
211
  // Health check endpoint
212
  async healthCheck() {
213
  try {
214
- const response = await fetch(`${this.baseURL}/ping`, {
215
- method: 'HEAD',
216
- cache: 'no-cache'
217
  });
218
  return response.ok;
219
  } catch (error) {
220
- console.warn('Health check failed:', error);
221
  return false;
222
  }
223
  }
@@ -232,7 +239,7 @@ class APIManager {
232
 
233
  // Utility sleep function
234
  sleep(ms) {
235
- return new Promise(resolve => setTimeout(resolve, ms));
236
  }
237
  }
238
 
@@ -248,41 +255,41 @@ class ConnectionMonitor {
248
  }
249
 
250
  setupEventListeners() {
251
- window.addEventListener('online', () => {
252
  this.isOnline = true;
253
- this.onStatusChange('online');
254
  // Immediately test connection when coming back online
255
  this.pingServer();
256
  });
257
 
258
- window.addEventListener('offline', () => {
259
  this.isOnline = false;
260
- this.onStatusChange('offline');
261
  });
262
  }
263
 
264
  // Ping server to check actual connectivity
265
  async pingServer() {
266
  if (!this.isOnline) return false;
267
-
268
  try {
269
  const startTime = Date.now();
270
- const response = await fetch('/ping', {
271
- method: 'HEAD',
272
- cache: 'no-cache',
273
- timeout: 5000
274
  });
275
-
276
  const pingTime = Date.now() - startTime;
277
  const isConnected = response.ok;
278
-
279
  this.lastPingTime = pingTime;
280
- this.onStatusChange(isConnected ? 'connected' : 'disconnected');
281
-
282
  return isConnected;
283
  } catch (error) {
284
- console.warn('Ping failed:', error);
285
- this.onStatusChange('disconnected');
286
  return false;
287
  }
288
  }
@@ -291,7 +298,7 @@ class ConnectionMonitor {
291
  startPingTest() {
292
  // Initial ping
293
  setTimeout(() => this.pingServer(), 1000);
294
-
295
  // Regular pings
296
  setInterval(() => {
297
  this.pingServer();
@@ -321,41 +328,50 @@ class MessageRenderer {
321
 
322
  // Render a single message
323
  renderMessage(message) {
324
- const messageDiv = document.createElement('div');
325
  messageDiv.className = `relative flex items-start gap-3 px-4 py-5 sm:px-6`;
326
  messageDiv.dataset.messageId = message.id;
327
 
328
  // Create avatar
329
- const avatar = document.createElement('div');
330
  avatar.className = `mt-1 h-8 w-8 shrink-0 overflow-hidden rounded-full ${
331
- message.role === 'user'
332
- ? 'bg-zinc-300 grid place-items-center text-[10px] font-medium'
333
- : 'bg-zinc-200'
334
  }`;
335
-
336
- if (message.role === 'user') {
337
- avatar.textContent = 'YOU';
338
  }
339
 
340
  // Create message content wrapper
341
- const contentWrapper = document.createElement('div');
342
- contentWrapper.className = 'min-w-0 flex-1';
343
 
344
  // Create message header
345
- const header = document.createElement('div');
346
- header.className = 'mb-1 flex items-baseline gap-2';
347
  header.innerHTML = `
348
- <div class="text-sm font-medium">${message.role === 'user' ? 'You' : 'Ava'}</div>
349
- <div class="text-xs text-zinc-500">${this.formatTime(message.timestamp)}</div>
350
- ${message.status !== 'sent' ? `<div class="text-xs text-zinc-400">${message.status}</div>` : ''}
 
 
 
 
 
 
 
 
351
  `;
352
 
353
  // Create message content
354
- const content = document.createElement('div');
355
- content.className = message.role === 'user'
356
- ? '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'
357
- : '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';
358
-
 
359
  content.innerHTML = this.formatContent(message.content);
360
 
361
  contentWrapper.appendChild(header);
@@ -370,10 +386,13 @@ class MessageRenderer {
370
  formatContent(content) {
371
  // Basic formatting - can be enhanced with a markdown parser
372
  return content
373
- .replace(/\n/g, '<br>')
374
- .replace(/`([^`]+)`/g, '<code class="bg-zinc-200 dark:bg-zinc-700 px-1 py-0.5 rounded text-sm">$1</code>')
375
- .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
376
- .replace(/\*([^*]+)\*/g, '<em>$1</em>');
 
 
 
377
  }
378
 
379
  // Format timestamp
@@ -383,22 +402,24 @@ class MessageRenderer {
383
 
384
  // Create typing indicator
385
  createTypingIndicator() {
386
- const messageDiv = document.createElement('div');
387
- messageDiv.className = 'relative flex items-start gap-3 px-4 py-5 sm:px-6';
388
- messageDiv.id = 'typing-indicator';
389
 
390
- const avatar = document.createElement('div');
391
- avatar.className = 'mt-1 h-8 w-8 shrink-0 overflow-hidden rounded-full bg-zinc-200';
 
392
 
393
- const contentWrapper = document.createElement('div');
394
- contentWrapper.className = 'min-w-0 flex-1';
395
 
396
- const header = document.createElement('div');
397
- header.className = 'mb-1 flex items-baseline gap-2';
398
- header.innerHTML = '<div class="text-sm font-medium">Ava</div><div class="text-xs text-zinc-500">typing...</div>';
 
399
 
400
- const typingDots = document.createElement('div');
401
- typingDots.className = 'flex items-center space-x-1 p-4';
402
  typingDots.innerHTML = `
403
  <div class="w-2 h-2 bg-zinc-400 rounded-full animate-bounce" style="animation-delay: 0ms"></div>
404
  <div class="w-2 h-2 bg-zinc-400 rounded-full animate-bounce" style="animation-delay: 150ms"></div>
@@ -425,7 +446,7 @@ class MessageRenderer {
425
 
426
  // Hide typing indicator
427
  hideTyping() {
428
- const existing = document.getElementById('typing-indicator');
429
  if (existing) {
430
  existing.remove();
431
  }
@@ -434,7 +455,8 @@ class MessageRenderer {
434
  // Scroll to bottom of message container
435
  scrollToBottom() {
436
  if (this.messageContainer && this.messageContainer.parentElement) {
437
- this.messageContainer.parentElement.scrollTop = this.messageContainer.parentElement.scrollHeight;
 
438
  }
439
  }
440
  }
@@ -461,12 +483,12 @@ class ChatApp {
461
 
462
  // Initialize the application
463
  async init() {
464
- console.log('Initializing Chat Application...');
465
-
466
  // Wait for DOM to be ready
467
- if (document.readyState === 'loading') {
468
- await new Promise(resolve => {
469
- document.addEventListener('DOMContentLoaded', resolve);
470
  });
471
  }
472
 
@@ -475,14 +497,15 @@ class ChatApp {
475
 
476
  // Get DOM elements from existing HTML structure
477
  this.elements = {
478
- messages: document.getElementById('messages'),
479
- composer: document.getElementById('composer'),
480
- sendButton: document.getElementById('btn-send'),
481
- messagesScroller: document.getElementById('msg-scroll'),
482
- leftSidebar: document.getElementById('left-desktop'),
483
- modelDropdown: document.getElementById('model-dd'),
484
- chatMore: document.getElementById('chat-more'),
485
- themeButton: document.getElementById('btn-theme')
 
486
  };
487
 
488
  // Set up message renderer
@@ -499,14 +522,21 @@ class ChatApp {
499
  // Setup model selector
500
  this.setupModelSelector();
501
 
 
 
 
 
 
502
  // Initialize with existing conversation or create new one
503
  if (this.state.conversations.size === 0) {
504
  this.createNewConversation();
505
  } else {
506
  this.loadCurrentConversation();
 
 
507
  }
508
 
509
- console.log('Chat Application initialized successfully');
510
  }
511
 
512
  // Setup model selector functionality
@@ -514,55 +544,126 @@ class ChatApp {
514
  const modelDropdown = this.elements.modelDropdown;
515
  if (!modelDropdown) return;
516
 
517
- const trigger = modelDropdown.querySelector('[data-dd-trigger]');
518
- const menu = modelDropdown.querySelector('[data-dd-menu]');
519
- const modelButtons = menu ? menu.querySelectorAll('button') : [];
520
 
521
  if (!trigger || !menu) return;
522
 
 
 
 
 
 
 
523
  // Set current model from state or default
524
- const currentModel = this.state.getCurrentConversation()?.model || 'Qwen 3 Coder';
 
525
  trigger.childNodes[0].textContent = currentModel;
526
 
527
  // Handle model selection
528
- modelButtons.forEach(button => {
529
- button.addEventListener('click', (e) => {
 
 
 
530
  e.stopPropagation();
531
  const selectedModel = button.textContent.trim();
532
-
533
  // Update UI
534
  trigger.childNodes[0].textContent = selectedModel;
535
- menu.classList.add('hidden');
536
-
537
  // Update current conversation model
538
  const conversation = this.state.getCurrentConversation();
539
  if (conversation) {
540
  conversation.model = selectedModel;
 
 
541
  this.state.saveToStorage();
542
  }
543
-
544
- console.log('Model changed to:', selectedModel);
 
 
 
 
 
 
545
  });
546
  });
547
  }
548
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
549
  // Set up all event listeners
550
  setupEventListeners() {
551
  // Message sending
552
  if (this.elements.sendButton) {
553
- this.elements.sendButton.addEventListener('click', () => this.handleSendMessage());
 
 
 
 
 
 
 
 
 
554
  }
555
 
556
  if (this.elements.composer) {
557
- this.elements.composer.addEventListener('keydown', (e) => {
558
- if (e.key === 'Enter' && !e.shiftKey) {
559
  e.preventDefault();
560
  this.handleSendMessage();
561
  }
562
  });
563
 
564
  // Auto-resize textarea
565
- this.elements.composer.addEventListener('input', () => {
566
  this.autoResizeComposer();
567
  this.updateSendButtonState();
568
  });
@@ -570,8 +671,8 @@ class ChatApp {
570
 
571
  // Handle suggestion clicks from welcome screen
572
  if (this.elements.messages) {
573
- this.elements.messages.addEventListener('click', (e) => {
574
- const suggestion = e.target.closest('[data-suggest]');
575
  if (suggestion) {
576
  const text = suggestion.textContent.trim();
577
  if (text && this.elements.composer) {
@@ -585,9 +686,9 @@ class ChatApp {
585
  }
586
 
587
  // New chat button in sidebar
588
- const newChatBtn = document.querySelector('#left-desktop button');
589
- if (newChatBtn && newChatBtn.textContent.includes('New chat')) {
590
- newChatBtn.addEventListener('click', () => {
591
  this.createNewConversation();
592
  });
593
  }
@@ -598,7 +699,7 @@ class ChatApp {
598
  }, 30000); // Save every 30 seconds
599
 
600
  // Save on page unload
601
- window.addEventListener('beforeunload', () => {
602
  this.state.saveToStorage();
603
  });
604
  }
@@ -614,21 +715,25 @@ class ChatApp {
614
  try {
615
  // Clear composer
616
  if (this.elements.composer) {
617
- this.elements.composer.value = '';
618
  this.autoResizeComposer();
619
  }
620
 
621
  // Check if message was already added by inline script
622
  const lastMessage = this.elements.messages?.lastElementChild;
623
- const isUserMessage = lastMessage?.querySelector('.text-sm')?.textContent === 'You';
624
-
 
625
  let userMessage;
626
- if (isUserMessage && lastMessage.querySelector('.prose')?.textContent?.trim() === message) {
 
 
 
627
  // Message already added by inline script, just update our state
628
- userMessage = this.state.addMessage('user', message);
629
  } else {
630
  // Add user message normally
631
- userMessage = this.state.addMessage('user', message);
632
  this.renderMessage(userMessage);
633
  }
634
 
@@ -637,42 +742,46 @@ class ChatApp {
637
 
638
  // Prepare conversation history for API
639
  const conversation = this.state.getCurrentConversation();
640
- const history = conversation ? conversation.messages.map(msg => ({
641
- role: msg.role,
642
- content: msg.content
643
- })) : [];
 
 
644
 
645
  // Use the streaming chat endpoint
646
- const response = await this.api.sendMessage(message, history.slice(0, -1));
647
-
 
 
 
648
  // Hide typing indicator
649
  this.renderer.hideTyping();
650
 
651
  // Handle streaming response
652
  await this.handleStreamingResponse(response);
653
-
654
  } catch (error) {
655
- console.error('Error sending message:', error);
656
  this.renderer.hideTyping();
657
-
658
  // Add error message based on error type
659
- let errorMessage = 'Sorry, I encountered an error. Please try again.';
660
-
661
- if (error.name === 'AbortError') {
662
- errorMessage = 'Request was cancelled.';
663
- } else if (error.message.includes('HTTP 429')) {
664
- errorMessage = 'Too many requests. Please wait a moment and try again.';
665
- } else if (error.message.includes('HTTP 401')) {
666
- errorMessage = 'Authentication error. Please check your API key.';
667
- } else if (error.message.includes('HTTP 500')) {
668
- errorMessage = 'Server error. The AI service is temporarily unavailable.';
669
- } else if (error.message.includes('Failed to fetch')) {
670
- errorMessage = 'Network error. Please check your internet connection.';
 
671
  }
672
 
673
- const errorMsg = this.state.addMessage('assistant', errorMessage);
674
  this.renderMessage(errorMsg);
675
-
676
  } finally {
677
  this.isProcessingMessage = false;
678
  this.updateSendButtonState();
@@ -683,61 +792,64 @@ class ChatApp {
683
  async handleStreamingResponse(response) {
684
  const reader = response.body.getReader();
685
  const decoder = new TextDecoder();
686
-
687
  // Remove any stub responses first
688
  this.removeStubResponses();
689
-
690
  // Create assistant message
691
- const assistantMessage = this.state.addMessage('assistant', '');
692
  const messageElement = this.renderMessage(assistantMessage);
693
-
694
  // Get content div for streaming updates
695
- const contentDiv = messageElement.querySelector('.prose');
696
-
697
- let accumulatedContent = '';
698
-
699
  try {
700
  while (true) {
701
  const { done, value } = await reader.read();
702
  if (done) break;
703
-
704
  const chunk = decoder.decode(value, { stream: true });
705
  accumulatedContent += chunk;
706
-
707
  // Update message content with real-time formatting
708
  assistantMessage.content = accumulatedContent;
709
  contentDiv.innerHTML = this.renderer.formatContent(accumulatedContent);
710
-
711
  // Scroll to bottom smoothly
712
  this.renderer.scrollToBottom();
713
-
714
  // Add a small delay to make streaming visible
715
- await new Promise(resolve => setTimeout(resolve, 10));
716
  }
717
-
718
  // Final update with complete content
719
  assistantMessage.content = accumulatedContent;
720
- assistantMessage.status = 'delivered';
721
  contentDiv.innerHTML = this.renderer.formatContent(accumulatedContent);
722
-
723
  // Update state with final content
724
  this.state.saveToStorage();
725
-
726
  } catch (error) {
727
- console.error('Error reading stream:', error);
728
-
729
  // If streaming fails, try to get any partial content
730
  if (accumulatedContent.trim()) {
731
  assistantMessage.content = accumulatedContent;
732
- assistantMessage.status = 'partial';
733
- contentDiv.innerHTML = this.renderer.formatContent(accumulatedContent) +
734
- '<br><em class="text-zinc-400 text-xs">[Response incomplete]</em>';
 
735
  } else {
736
- assistantMessage.content = 'Error receiving response. Please try again.';
737
- assistantMessage.status = 'error';
738
- contentDiv.innerHTML = this.renderer.formatContent(assistantMessage.content);
 
 
 
739
  }
740
-
741
  this.state.saveToStorage();
742
  }
743
  }
@@ -745,15 +857,18 @@ class ChatApp {
745
  // Remove stub responses that might have been added by inline script
746
  removeStubResponses() {
747
  if (!this.elements.messages) return;
748
-
749
- const messages = this.elements.messages.querySelectorAll('.relative.flex.items-start');
750
- messages.forEach(messageElement => {
751
- const content = messageElement.querySelector('.prose');
752
- if (content && (
753
- content.textContent.includes('Stubbed response') ||
754
- content.textContent.includes('Sem přijde odpověď modelu') ||
755
- content.textContent.includes('Chat system loading')
756
- )) {
 
 
 
757
  messageElement.remove();
758
  }
759
  });
@@ -763,30 +878,32 @@ class ChatApp {
763
  async sendMessageOpenAI(message, options = {}) {
764
  try {
765
  const conversation = this.state.getCurrentConversation();
766
- const messages = conversation ? conversation.messages.map(msg => ({
767
- role: msg.role,
768
- content: msg.content
769
- })) : [];
770
-
 
 
771
  // Add current message
772
- messages.push({ role: 'user', content: message });
773
 
774
  const response = await this.api.sendMessageOpenAI(messages, {
775
- model: options.model || 'qwen-coder-3-30b',
776
  maxTokens: options.maxTokens || 1024,
777
  temperature: options.temperature || 0.7,
778
- stream: false
779
  });
780
 
781
  const data = await response.json();
782
-
783
  if (data.choices && data.choices[0] && data.choices[0].message) {
784
  return data.choices[0].message.content;
785
  } else {
786
- throw new Error('Invalid response format from OpenAI API');
787
  }
788
  } catch (error) {
789
- console.error('OpenAI API error:', error);
790
  throw error;
791
  }
792
  }
@@ -794,11 +911,11 @@ class ChatApp {
794
  // Render a message to the UI
795
  renderMessage(message) {
796
  if (!this.elements.messages) return null;
797
-
798
  const messageElement = this.renderer.renderMessage(message);
799
  this.elements.messages.appendChild(messageElement);
800
  this.renderer.scrollToBottom();
801
-
802
  return messageElement;
803
  }
804
 
@@ -808,59 +925,79 @@ class ChatApp {
808
  this.renderWelcomeScreen();
809
  this.updateChatHistory();
810
  this.state.saveToStorage();
811
- console.log('Created new conversation:', conversation.id);
812
  }
813
 
814
  // Update chat history in sidebar
 
815
  updateChatHistory() {
816
- const historyContainer = document.querySelector('#left-desktop .overflow-y-auto');
 
 
817
  if (!historyContainer) return;
818
 
819
  // Clear existing history (keep search and new chat button)
820
- const existingChats = historyContainer.querySelectorAll('.chat-item');
821
- existingChats.forEach(item => item.remove());
822
 
823
  // Add conversations
824
- const conversations = Array.from(this.state.conversations.values())
825
- .sort((a, b) => b.updated - a.updated);
826
-
827
- conversations.forEach(conversation => {
828
- const chatItem = document.createElement('button');
829
- 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 ${
830
- conversation.id === this.state.currentConversationId ? 'bg-zinc-100 dark:bg-zinc-800' : ''
 
 
 
831
  }`;
832
-
833
  chatItem.innerHTML = `
834
- <svg class="h-4 w-4 text-zinc-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
835
  <path d="M12 12c2 0 4 1 5 3-1 2-3 3-5 3s-4-1-5-3c1-2 3-3 5-3Z"/>
836
  <circle cx="12" cy="8" r="3"/>
837
  </svg>
838
  <div class="min-w-0 flex-1">
839
  <div class="truncate text-sm font-medium">${conversation.title}</div>
840
- <div class="truncate text-xs text-zinc-500">${this.formatChatDate(conversation.updated)}</div>
 
 
841
  </div>
842
- <div class="chat-actions opacity-0 group-hover:opacity-100 transition-opacity">
843
- <button class="delete-chat p-1 hover:bg-red-100 dark:hover:bg-red-900 rounded" data-chat-id="${conversation.id}">
844
- <svg class="h-3 w-3 text-red-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
845
- <polyline points="3 6 5 6 21 6"/>
846
- <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/>
 
 
 
 
 
847
  </svg>
848
  </button>
849
  </div>
850
  `;
851
 
852
  // Handle chat selection
853
- chatItem.addEventListener('click', (e) => {
854
- if (e.target.closest('.delete-chat')) return;
 
 
 
 
855
  this.loadChatSession(conversation.id);
856
  });
857
 
858
  // Handle chat deletion
859
- const deleteBtn = chatItem.querySelector('.delete-chat');
860
- deleteBtn.addEventListener('click', (e) => {
861
- e.stopPropagation();
862
- this.deleteChatSession(conversation.id);
863
- });
 
 
 
864
 
865
  historyContainer.appendChild(chatItem);
866
  });
@@ -872,11 +1009,11 @@ class ChatApp {
872
  const now = new Date();
873
  const diffInHours = (now - date) / (1000 * 60 * 60);
874
 
875
- if (diffInHours < 1) return 'Just now';
876
  if (diffInHours < 24) return `${Math.floor(diffInHours)}h ago`;
877
- if (diffInHours < 48) return 'Yesterday';
878
  if (diffInHours < 168) return `${Math.floor(diffInHours / 24)}d ago`;
879
-
880
  return date.toLocaleDateString();
881
  }
882
 
@@ -892,13 +1029,13 @@ class ChatApp {
892
 
893
  // Clear existing messages
894
  if (this.elements.messages) {
895
- this.elements.messages.innerHTML = '';
896
  }
897
 
898
  if (conversation.messages.length === 0) {
899
  this.renderWelcomeScreen();
900
  } else {
901
- conversation.messages.forEach(message => {
902
  this.renderMessage(message);
903
  });
904
  }
@@ -906,27 +1043,77 @@ class ChatApp {
906
  // Update UI
907
  this.updateChatHistory();
908
  this.state.saveToStorage();
909
-
910
- console.log('Loaded chat session:', sessionId);
911
  }
912
 
913
  // Delete a chat session
 
 
914
  deleteChatSession(sessionId) {
915
- if (confirm('Are you sure you want to delete this chat?')) {
 
916
  this.state.conversations.delete(sessionId);
917
-
918
- // If deleting current conversation, create new one
919
  if (sessionId === this.state.currentConversationId) {
920
- this.createNewConversation();
 
 
 
 
 
 
 
 
 
 
 
 
 
921
  } else {
 
922
  this.updateChatHistory();
923
  }
924
-
 
925
  this.state.saveToStorage();
926
- console.log('Deleted chat session:', sessionId);
 
 
 
 
 
 
927
  }
928
  }
929
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
930
  // Load current conversation
931
  loadCurrentConversation() {
932
  const conversation = this.state.getCurrentConversation();
@@ -937,13 +1124,13 @@ class ChatApp {
937
 
938
  // Clear existing messages
939
  if (this.elements.messages) {
940
- this.elements.messages.innerHTML = '';
941
  }
942
 
943
  if (conversation.messages.length === 0) {
944
  this.renderWelcomeScreen();
945
  } else {
946
- conversation.messages.forEach(message => {
947
  this.renderMessage(message);
948
  });
949
  }
@@ -952,7 +1139,7 @@ class ChatApp {
952
  // Render welcome screen
953
  renderWelcomeScreen() {
954
  if (!this.elements.messages) return;
955
-
956
  // Use the existing welcome message structure from HTML
957
  this.elements.messages.innerHTML = `
958
  <div class="relative flex items-start gap-3 px-4 py-5 sm:px-6">
@@ -960,7 +1147,9 @@ class ChatApp {
960
  <div class="min-w-0 flex-1">
961
  <div class="mb-1 flex items-baseline gap-2">
962
  <div class="text-sm font-medium">Ava</div>
963
- <div class="text-xs text-zinc-500">${new Date().toLocaleTimeString().slice(0, 5)}</div>
 
 
964
  </div>
965
  <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">
966
  <p>Ahoj! 👋 Jsem tvůj AI asistent. Jaký úkol dnes řešíš?</p>
@@ -979,38 +1168,54 @@ class ChatApp {
979
  // Auto-resize composer textarea
980
  autoResizeComposer() {
981
  if (!this.elements.composer) return;
982
-
983
- this.elements.composer.style.height = 'auto';
984
  const maxHeight = 160; // max-h-40 from Tailwind (160px)
985
- this.elements.composer.style.height = Math.min(this.elements.composer.scrollHeight, maxHeight) + 'px';
 
986
  }
987
 
988
  // Update send button state
989
  updateSendButtonState() {
990
  if (!this.elements.sendButton || !this.elements.composer) return;
991
-
992
  const hasText = this.elements.composer.value.trim().length > 0;
993
  const isEnabled = hasText && !this.isProcessingMessage;
994
-
995
  this.elements.sendButton.disabled = !isEnabled;
996
-
997
  if (isEnabled) {
998
- this.elements.sendButton.classList.remove('disabled:cursor-not-allowed', 'disabled:opacity-50');
 
 
 
999
  } else {
1000
- this.elements.sendButton.classList.add('disabled:cursor-not-allowed', 'disabled:opacity-50');
 
 
 
 
 
 
 
 
 
 
 
 
1001
  }
1002
  }
1003
 
1004
  // Handle connection status changes
1005
  handleConnectionStatusChange(status) {
1006
  this.state.connectionStatus = status;
1007
- console.log('Connection status changed to:', status);
1008
-
1009
  // Update UI to show connection status
1010
  this.updateConnectionIndicator(status);
1011
-
1012
  // Retry queued messages when connection is restored
1013
- if (status === 'connected' && this.state.messageQueue.length > 0) {
1014
  this.processMessageQueue();
1015
  }
1016
  }
@@ -1018,43 +1223,47 @@ class ChatApp {
1018
  // Update connection indicator in UI
1019
  updateConnectionIndicator(status) {
1020
  // Find or create connection indicator
1021
- let indicator = document.getElementById('connection-indicator');
1022
  if (!indicator) {
1023
- indicator = document.createElement('div');
1024
- indicator.id = 'connection-indicator';
1025
- indicator.className = 'fixed top-4 right-4 z-50 px-3 py-1 rounded-full text-xs font-medium transition-all duration-300';
 
1026
  document.body.appendChild(indicator);
1027
  }
1028
 
1029
  // Update indicator based on status
1030
  switch (status) {
1031
- case 'connected':
1032
- indicator.className = 'fixed top-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';
1033
- indicator.textContent = '🟢 Connected';
 
1034
  // Hide after 2 seconds
1035
  setTimeout(() => {
1036
- indicator.style.opacity = '0';
1037
- indicator.style.transform = 'translateY(-20px)';
1038
  }, 2000);
1039
  break;
1040
-
1041
- case 'disconnected':
1042
- indicator.className = 'fixed top-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';
1043
- indicator.textContent = '🔴 Disconnected';
1044
- indicator.style.opacity = '1';
1045
- indicator.style.transform = 'translateY(0)';
 
1046
  break;
1047
-
1048
- case 'offline':
1049
- 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';
1050
- indicator.textContent = '⚠️ Offline';
1051
- indicator.style.opacity = '1';
1052
- indicator.style.transform = 'translateY(0)';
 
1053
  break;
1054
-
1055
  default:
1056
- indicator.style.opacity = '0';
1057
- indicator.style.transform = 'translateY(-20px)';
1058
  }
1059
  }
1060
 
@@ -1065,7 +1274,7 @@ class ChatApp {
1065
  try {
1066
  await this.handleSendMessage(queuedMessage);
1067
  } catch (error) {
1068
- console.error('Failed to process queued message:', error);
1069
  // Re-queue if failed
1070
  this.state.messageQueue.unshift(queuedMessage);
1071
  break;
@@ -1086,23 +1295,29 @@ class ChatApp {
1086
  const chatApp = new ChatApp();
1087
 
1088
  // Start the application when DOM is ready and make it globally available
1089
- if (document.readyState === 'loading') {
1090
- document.addEventListener('DOMContentLoaded', () => {
1091
  chatApp.init().then(() => {
1092
  window.chatApp = chatApp; // Make globally available
1093
- console.log('ChatApp is now globally available');
1094
  });
1095
  });
1096
  } else {
1097
  chatApp.init().then(() => {
1098
  window.chatApp = chatApp; // Make globally available
1099
- console.log('ChatApp is now globally available');
1100
  });
1101
  }
1102
 
1103
  // Export for potential module usage
1104
- if (typeof module !== 'undefined' && module.exports) {
1105
- module.exports = { ChatApp, ChatState, APIManager, MessageRenderer, ConnectionMonitor };
 
 
 
 
 
 
1106
  }
1107
 
1108
  // Utility functions for backward compatibility and additional features
@@ -1114,7 +1329,7 @@ async function initializeI18n() {
1114
  isI18nReady = true;
1115
  return true;
1116
  } catch (error) {
1117
- console.error('Failed to initialize i18n:', error);
1118
  isI18nReady = false;
1119
  return false;
1120
  }
@@ -1159,7 +1374,7 @@ class PerformanceMonitor {
1159
  return {
1160
  used: Math.round(performance.memory.usedJSHeapSize / 1048576),
1161
  total: Math.round(performance.memory.totalJSHeapSize / 1048576),
1162
- limit: Math.round(performance.memory.jsHeapSizeLimit / 1048576)
1163
  };
1164
  }
1165
  return null;
@@ -1167,25 +1382,25 @@ class PerformanceMonitor {
1167
 
1168
  // Monitor network timing
1169
  observeNetworkTiming() {
1170
- if ('PerformanceObserver' in window) {
1171
  const observer = new PerformanceObserver((list) => {
1172
  list.getEntries().forEach((entry) => {
1173
- if (entry.entryType === 'navigation') {
1174
- console.log('Navigation timing:', {
1175
  dns: entry.domainLookupEnd - entry.domainLookupStart,
1176
  connection: entry.connectEnd - entry.connectStart,
1177
  request: entry.responseStart - entry.requestStart,
1178
- response: entry.responseEnd - entry.responseStart
1179
  });
1180
  }
1181
  });
1182
  });
1183
 
1184
  try {
1185
- observer.observe({ entryTypes: ['navigation', 'resource'] });
1186
- this.observers.set('network', observer);
1187
  } catch (error) {
1188
- console.warn('Performance observer not supported:', error);
1189
  }
1190
  }
1191
  }
@@ -1210,7 +1425,7 @@ class WebSocketManager {
1210
  this.socket = new WebSocket(this.url);
1211
  this.setupEventListeners();
1212
  } catch (error) {
1213
- console.error('WebSocket connection failed:', error);
1214
  this.handleReconnect();
1215
  }
1216
  }
@@ -1220,32 +1435,32 @@ class WebSocketManager {
1220
  if (!this.socket) return;
1221
 
1222
  this.socket.onopen = () => {
1223
- console.log('WebSocket connected');
1224
  this.reconnectAttempts = 0;
1225
  this.startHeartbeat();
1226
  this.processMessageQueue();
1227
- this.emit('connected');
1228
  };
1229
 
1230
  this.socket.onmessage = (event) => {
1231
  try {
1232
  const data = JSON.parse(event.data);
1233
- this.emit('message', data);
1234
  } catch (error) {
1235
- console.error('Failed to parse WebSocket message:', error);
1236
  }
1237
  };
1238
 
1239
  this.socket.onclose = () => {
1240
- console.log('WebSocket disconnected');
1241
  this.stopHeartbeat();
1242
- this.emit('disconnected');
1243
  this.handleReconnect();
1244
  };
1245
 
1246
  this.socket.onerror = (error) => {
1247
- console.error('WebSocket error:', error);
1248
- this.emit('error', error);
1249
  };
1250
  }
1251
 
@@ -1263,16 +1478,19 @@ class WebSocketManager {
1263
  handleReconnect() {
1264
  if (this.reconnectAttempts < this.maxReconnectAttempts) {
1265
  this.reconnectAttempts++;
1266
- const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
1267
-
1268
- console.log(`Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`);
1269
-
 
 
 
1270
  setTimeout(() => {
1271
  this.connect();
1272
  }, delay);
1273
  } else {
1274
- console.error('Max reconnection attempts reached');
1275
- this.emit('maxReconnectReached');
1276
  }
1277
  }
1278
 
@@ -1280,7 +1498,7 @@ class WebSocketManager {
1280
  startHeartbeat() {
1281
  this.heartbeatInterval = setInterval(() => {
1282
  if (this.socket && this.socket.readyState === WebSocket.OPEN) {
1283
- this.send({ type: 'ping' });
1284
  }
1285
  }, 30000); // Send heartbeat every 30 seconds
1286
  }
@@ -1322,7 +1540,7 @@ class WebSocketManager {
1322
  emit(event, data) {
1323
  const eventListeners = this.listeners.get(event);
1324
  if (eventListeners) {
1325
- eventListeners.forEach(callback => callback(data));
1326
  }
1327
  }
1328
 
@@ -1340,14 +1558,14 @@ class WebSocketManager {
1340
  class SecurityUtils {
1341
  // Sanitize HTML content
1342
  static sanitizeHTML(html) {
1343
- const div = document.createElement('div');
1344
  div.textContent = html;
1345
  return div.innerHTML;
1346
  }
1347
 
1348
  // Validate message content
1349
  static validateMessage(content) {
1350
- if (typeof content !== 'string') return false;
1351
  if (content.length === 0 || content.length > 10000) return false;
1352
  return true;
1353
  }
@@ -1355,25 +1573,28 @@ class SecurityUtils {
1355
  // Rate limiting
1356
  static createRateLimiter(limit, window) {
1357
  const requests = new Map();
1358
-
1359
- return function(identifier) {
1360
  const now = Date.now();
1361
  const windowStart = now - window;
1362
-
1363
  // Clean old requests
1364
  for (const [key, timestamps] of requests.entries()) {
1365
- requests.set(key, timestamps.filter(time => time > windowStart));
 
 
 
1366
  if (requests.get(key).length === 0) {
1367
  requests.delete(key);
1368
  }
1369
  }
1370
-
1371
  // Check current requests
1372
  const userRequests = requests.get(identifier) || [];
1373
  if (userRequests.length >= limit) {
1374
  return false; // Rate limited
1375
  }
1376
-
1377
  // Add current request
1378
  userRequests.push(now);
1379
  requests.set(identifier, userRequests);
@@ -1399,34 +1620,34 @@ class ErrorReporter {
1399
 
1400
  setupGlobalErrorHandling() {
1401
  // Catch unhandled errors
1402
- window.addEventListener('error', (event) => {
1403
  this.reportError({
1404
- type: 'javascript',
1405
  message: event.message,
1406
  filename: event.filename,
1407
  line: event.lineno,
1408
  column: event.colno,
1409
  stack: event.error?.stack,
1410
- timestamp: Date.now()
1411
  });
1412
  });
1413
 
1414
  // Catch unhandled promise rejections
1415
- window.addEventListener('unhandledrejection', (event) => {
1416
  this.reportError({
1417
- type: 'promise',
1418
- message: event.reason?.message || 'Unhandled promise rejection',
1419
  stack: event.reason?.stack,
1420
- timestamp: Date.now()
1421
  });
1422
  });
1423
  }
1424
 
1425
  reportError(error) {
1426
- console.error('Error reported:', error);
1427
-
1428
  this.errors.push(error);
1429
-
1430
  // Keep only recent errors
1431
  if (this.errors.length > this.maxErrors) {
1432
  this.errors.shift();
@@ -1440,9 +1661,9 @@ class ErrorReporter {
1440
  // Placeholder for analytics integration
1441
  // In production, you might send to services like Sentry, LogRocket, etc.
1442
  if (window.gtag) {
1443
- window.gtag('event', 'exception', {
1444
  description: error.message,
1445
- fatal: false
1446
  });
1447
  }
1448
  }
@@ -1463,15 +1684,15 @@ class AccessibilityManager {
1463
  }
1464
 
1465
  setupKeyboardNavigation() {
1466
- document.addEventListener('keydown', (event) => {
1467
  // Handle global keyboard shortcuts
1468
  if (event.ctrlKey || event.metaKey) {
1469
  switch (event.key) {
1470
- case 'n':
1471
  event.preventDefault();
1472
  chatApp.createNewConversation();
1473
  break;
1474
- case '/':
1475
  event.preventDefault();
1476
  chatApp.elements.composer?.focus();
1477
  break;
@@ -1479,7 +1700,7 @@ class AccessibilityManager {
1479
  }
1480
 
1481
  // Escape key to cancel current operation
1482
- if (event.key === 'Escape') {
1483
  chatApp.cancelCurrentMessage();
1484
  }
1485
  });
@@ -1487,10 +1708,10 @@ class AccessibilityManager {
1487
 
1488
  setupScreenReaderSupport() {
1489
  // Announce new messages to screen readers
1490
- const announcer = document.createElement('div');
1491
- announcer.setAttribute('aria-live', 'polite');
1492
- announcer.setAttribute('aria-atomic', 'true');
1493
- announcer.className = 'sr-only';
1494
  document.body.appendChild(announcer);
1495
 
1496
  this.announcer = announcer;
@@ -1498,7 +1719,9 @@ class AccessibilityManager {
1498
 
1499
  announceMessage(message) {
1500
  if (this.announcer) {
1501
- this.announcer.textContent = `${message.role === 'user' ? 'You' : 'Assistant'}: ${message.content}`;
 
 
1502
  }
1503
  }
1504
  }
@@ -1515,5 +1738,5 @@ window.chatDebug = {
1515
  performanceMonitor,
1516
  errorReporter,
1517
  accessibilityManager,
1518
- SecurityUtils
1519
- };
 
1
  /**
2
  * Enhanced AI Chat Application - Core JavaScript Implementation
3
+ *
4
  * Features:
5
  * - Real-time chat functionality
6
  * - WebSocket support for live messaging
 
19
  this.isConnected = false;
20
  this.isTyping = false;
21
  this.lastActivity = Date.now();
22
+ this.connectionStatus = "disconnected";
23
  this.messageQueue = [];
24
  this.retryAttempts = 0;
25
  this.maxRetries = 3;
 
32
  }
33
 
34
  // Create new conversation
35
+ createConversation(title = "New Chat") {
36
  const id = `chat_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
37
  const conversation = {
38
  id,
 
40
  messages: [],
41
  created: Date.now(),
42
  updated: Date.now(),
43
+ model: "qwen-coder-3-30b",
44
+ metadata: {},
45
  };
46
  this.conversations.set(id, conversation);
47
  this.currentConversationId = id;
 
58
  role,
59
  content,
60
  timestamp: Date.now(),
61
+ status: "sent",
62
+ metadata,
63
  };
64
 
65
  conversation.messages.push(message);
66
  conversation.updated = Date.now();
67
+
68
  // Update conversation title if it's the first user message
69
+ if (
70
+ role === "user" &&
71
+ conversation.messages.filter((m) => m.role === "user").length === 1
72
+ ) {
73
  conversation.title = this.generateTitle(content);
74
  }
75
 
 
79
 
80
  // Generate conversation title from first message
81
  generateTitle(content) {
82
+ const words = content.trim().split(" ").slice(0, 5).join(" ");
83
+ let title =
84
+ words.length > 35 ? words.substring(0, 32) + "..." : words || "New Chat";
85
+
86
  // Clean up title for better readability
87
+ title = title.replace(/[^\w\s.,!?-]/g, "").trim();
88
+
89
  return title;
90
  }
91
 
 
95
  const data = {
96
  conversations: Array.from(this.conversations.entries()),
97
  currentConversationId: this.currentConversationId,
98
+ timestamp: Date.now(),
99
  };
100
+ localStorage.setItem("chatState", JSON.stringify(data));
101
  } catch (error) {
102
+ console.error("Failed to save state:", error);
103
  }
104
  }
105
 
106
  // Load state from localStorage
107
  loadFromStorage() {
108
  try {
109
+ const data = JSON.parse(localStorage.getItem("chatState") || "{}");
110
  if (data.conversations) {
111
  this.conversations = new Map(data.conversations);
112
  this.currentConversationId = data.currentConversationId;
113
  }
114
  } catch (error) {
115
+ console.error("Failed to load state:", error);
116
  }
117
  }
118
  }
 
125
  this.requestTimeout = 60000; // 60 seconds for AI responses
126
  this.retryDelay = 1000; // 1 second
127
  this.maxRetryDelay = 10000; // 10 seconds
128
+ this.apiVersion = "v1";
129
  }
130
 
131
  // Make API request with retry logic
132
  async makeRequest(endpoint, options = {}, retries = 3) {
133
  const url = `${this.baseURL}${endpoint}`;
134
+
135
  for (let attempt = 0; attempt <= retries; attempt++) {
136
  try {
137
  // Create new AbortController for this request
138
  this.abortController = new AbortController();
139
+
140
  // Add timeout handling
141
  const timeoutId = setTimeout(() => {
142
  this.abortController.abort();
143
  }, this.requestTimeout);
144
+
145
  const response = await fetch(url, {
146
  ...options,
147
+ signal: this.abortController.signal,
148
  });
149
 
150
  clearTimeout(timeoutId);
 
156
  return response;
157
  } catch (error) {
158
  console.warn(`Request attempt ${attempt + 1} failed:`, error);
159
+
160
  if (attempt === retries) {
161
  throw error;
162
  }
163
 
164
  // Exponential backoff
165
+ const delay = Math.min(
166
+ this.retryDelay * Math.pow(2, attempt),
167
+ this.maxRetryDelay
168
+ );
169
  await this.sleep(delay);
170
  }
171
  }
 
173
 
174
  // Send chat message using streaming endpoint
175
  async sendMessage(message, history = []) {
176
+ const response = await this.makeRequest("/chat", {
177
+ method: "POST",
178
  headers: {
179
+ "Content-Type": "application/json",
180
  },
181
+ body: JSON.stringify({
182
+ message,
183
+ history: history.map((msg) => ({
184
  role: msg.role,
185
+ content: msg.content,
186
+ })),
187
+ }),
188
  });
189
 
190
  return response;
 
193
  // Send message using OpenAI API compatible endpoint
194
  async sendMessageOpenAI(messages, options = {}) {
195
  const requestBody = {
196
+ model: options.model || "qwen-coder-3-30b",
197
+ messages: messages.map((msg) => ({
198
  role: msg.role,
199
+ content: msg.content,
200
  })),
201
  max_tokens: options.maxTokens || 1024,
202
  temperature: options.temperature || 0.7,
203
+ stream: options.stream || false,
204
  };
205
 
206
+ const response = await this.makeRequest("/v1/chat/completions", {
207
+ method: "POST",
208
  headers: {
209
+ "Content-Type": "application/json",
210
+ Authorization: `Bearer ${options.apiKey || "dummy-key"}`,
211
  },
212
+ body: JSON.stringify(requestBody),
213
  });
214
 
215
  return response;
 
218
  // Health check endpoint
219
  async healthCheck() {
220
  try {
221
+ const response = await fetch(`${this.baseURL}/ping`, {
222
+ method: "HEAD",
223
+ cache: "no-cache",
224
  });
225
  return response.ok;
226
  } catch (error) {
227
+ console.warn("Health check failed:", error);
228
  return false;
229
  }
230
  }
 
239
 
240
  // Utility sleep function
241
  sleep(ms) {
242
+ return new Promise((resolve) => setTimeout(resolve, ms));
243
  }
244
  }
245
 
 
255
  }
256
 
257
  setupEventListeners() {
258
+ window.addEventListener("online", () => {
259
  this.isOnline = true;
260
+ this.onStatusChange("online");
261
  // Immediately test connection when coming back online
262
  this.pingServer();
263
  });
264
 
265
+ window.addEventListener("offline", () => {
266
  this.isOnline = false;
267
+ this.onStatusChange("offline");
268
  });
269
  }
270
 
271
  // Ping server to check actual connectivity
272
  async pingServer() {
273
  if (!this.isOnline) return false;
274
+
275
  try {
276
  const startTime = Date.now();
277
+ const response = await fetch("/ping", {
278
+ method: "HEAD",
279
+ cache: "no-cache",
280
+ timeout: 5000,
281
  });
282
+
283
  const pingTime = Date.now() - startTime;
284
  const isConnected = response.ok;
285
+
286
  this.lastPingTime = pingTime;
287
+ this.onStatusChange(isConnected ? "connected" : "disconnected");
288
+
289
  return isConnected;
290
  } catch (error) {
291
+ console.warn("Ping failed:", error);
292
+ this.onStatusChange("disconnected");
293
  return false;
294
  }
295
  }
 
298
  startPingTest() {
299
  // Initial ping
300
  setTimeout(() => this.pingServer(), 1000);
301
+
302
  // Regular pings
303
  setInterval(() => {
304
  this.pingServer();
 
328
 
329
  // Render a single message
330
  renderMessage(message) {
331
+ const messageDiv = document.createElement("div");
332
  messageDiv.className = `relative flex items-start gap-3 px-4 py-5 sm:px-6`;
333
  messageDiv.dataset.messageId = message.id;
334
 
335
  // Create avatar
336
+ const avatar = document.createElement("div");
337
  avatar.className = `mt-1 h-8 w-8 shrink-0 overflow-hidden rounded-full ${
338
+ message.role === "user"
339
+ ? "bg-zinc-300 grid place-items-center text-[10px] font-medium"
340
+ : "bg-zinc-200"
341
  }`;
342
+
343
+ if (message.role === "user") {
344
+ avatar.textContent = "YOU";
345
  }
346
 
347
  // Create message content wrapper
348
+ const contentWrapper = document.createElement("div");
349
+ contentWrapper.className = "min-w-0 flex-1";
350
 
351
  // Create message header
352
+ const header = document.createElement("div");
353
+ header.className = "mb-1 flex items-baseline gap-2";
354
  header.innerHTML = `
355
+ <div class="text-sm font-medium">${
356
+ message.role === "user" ? "You" : "Ava"
357
+ }</div>
358
+ <div class="text-xs text-zinc-500">${this.formatTime(
359
+ message.timestamp
360
+ )}</div>
361
+ ${
362
+ message.status !== "sent"
363
+ ? `<div class="text-xs text-zinc-400">${message.status}</div>`
364
+ : ""
365
+ }
366
  `;
367
 
368
  // Create message content
369
+ const content = document.createElement("div");
370
+ content.className =
371
+ message.role === "user"
372
+ ? "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"
373
+ : "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";
374
+
375
  content.innerHTML = this.formatContent(message.content);
376
 
377
  contentWrapper.appendChild(header);
 
386
  formatContent(content) {
387
  // Basic formatting - can be enhanced with a markdown parser
388
  return content
389
+ .replace(/\n/g, "<br>")
390
+ .replace(
391
+ /`([^`]+)`/g,
392
+ '<code class="bg-zinc-200 dark:bg-zinc-700 px-1 py-0.5 rounded text-sm">$1</code>'
393
+ )
394
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
395
+ .replace(/\*([^*]+)\*/g, "<em>$1</em>");
396
  }
397
 
398
  // Format timestamp
 
402
 
403
  // Create typing indicator
404
  createTypingIndicator() {
405
+ const messageDiv = document.createElement("div");
406
+ messageDiv.className = "relative flex items-start gap-3 px-4 py-5 sm:px-6";
407
+ messageDiv.id = "typing-indicator";
408
 
409
+ const avatar = document.createElement("div");
410
+ avatar.className =
411
+ "mt-1 h-8 w-8 shrink-0 overflow-hidden rounded-full bg-zinc-200";
412
 
413
+ const contentWrapper = document.createElement("div");
414
+ contentWrapper.className = "min-w-0 flex-1";
415
 
416
+ const header = document.createElement("div");
417
+ header.className = "mb-1 flex items-baseline gap-2";
418
+ header.innerHTML =
419
+ '<div class="text-sm font-medium">Ava</div><div class="text-xs text-zinc-500">typing...</div>';
420
 
421
+ const typingDots = document.createElement("div");
422
+ typingDots.className = "flex items-center space-x-1 p-4";
423
  typingDots.innerHTML = `
424
  <div class="w-2 h-2 bg-zinc-400 rounded-full animate-bounce" style="animation-delay: 0ms"></div>
425
  <div class="w-2 h-2 bg-zinc-400 rounded-full animate-bounce" style="animation-delay: 150ms"></div>
 
446
 
447
  // Hide typing indicator
448
  hideTyping() {
449
+ const existing = document.getElementById("typing-indicator");
450
  if (existing) {
451
  existing.remove();
452
  }
 
455
  // Scroll to bottom of message container
456
  scrollToBottom() {
457
  if (this.messageContainer && this.messageContainer.parentElement) {
458
+ this.messageContainer.parentElement.scrollTop =
459
+ this.messageContainer.parentElement.scrollHeight;
460
  }
461
  }
462
  }
 
483
 
484
  // Initialize the application
485
  async init() {
486
+ console.log("Initializing Chat Application...");
487
+
488
  // Wait for DOM to be ready
489
+ if (document.readyState === "loading") {
490
+ await new Promise((resolve) => {
491
+ document.addEventListener("DOMContentLoaded", resolve);
492
  });
493
  }
494
 
 
497
 
498
  // Get DOM elements from existing HTML structure
499
  this.elements = {
500
+ messages: document.getElementById("messages"),
501
+ composer: document.getElementById("composer"),
502
+ sendButton: document.getElementById("btn-send"),
503
+ stopButton: document.getElementById("btn-stop"),
504
+ messagesScroller: document.getElementById("msg-scroll"),
505
+ leftSidebar: document.getElementById("left-desktop"),
506
+ modelDropdown: document.getElementById("model-dd"),
507
+ chatMore: document.getElementById("chat-more"),
508
+ themeButton: document.getElementById("btn-theme"),
509
  };
510
 
511
  // Set up message renderer
 
522
  // Setup model selector
523
  this.setupModelSelector();
524
 
525
+ // Initialize chat header with current model
526
+ const currentConversation = this.state.getCurrentConversation();
527
+ const currentModel = currentConversation?.model || "Qwen 3 Coder (Default)";
528
+ this.updateChatHeader(currentModel);
529
+
530
  // Initialize with existing conversation or create new one
531
  if (this.state.conversations.size === 0) {
532
  this.createNewConversation();
533
  } else {
534
  this.loadCurrentConversation();
535
+ // Zobrazíme historii chatů hned při načtení
536
+ this.updateChatHistory();
537
  }
538
 
539
+ console.log("Chat Application initialized successfully");
540
  }
541
 
542
  // Setup model selector functionality
 
544
  const modelDropdown = this.elements.modelDropdown;
545
  if (!modelDropdown) return;
546
 
547
+ const trigger = modelDropdown.querySelector("[data-dd-trigger]");
548
+ const menu = modelDropdown.querySelector("[data-dd-menu]");
549
+ const modelButtons = menu ? menu.querySelectorAll("button") : [];
550
 
551
  if (!trigger || !menu) return;
552
 
553
+ // Available models mapping
554
+ const modelMap = {
555
+ "Qwen 3 Coder 30B": "qwen-coder-3-30b",
556
+ "Qwen 3 Coder (Default)": "qwen-coder-3-default",
557
+ };
558
+
559
  // Set current model from state or default
560
+ const currentModel =
561
+ this.state.getCurrentConversation()?.model || "Qwen 3 Coder (Default)";
562
  trigger.childNodes[0].textContent = currentModel;
563
 
564
  // Handle model selection
565
+ modelButtons.forEach((button) => {
566
+ // Skip info/status buttons
567
+ if (!button.textContent.includes("Qwen")) return;
568
+
569
+ button.addEventListener("click", (e) => {
570
  e.stopPropagation();
571
  const selectedModel = button.textContent.trim();
572
+
573
  // Update UI
574
  trigger.childNodes[0].textContent = selectedModel;
575
+ menu.classList.add("hidden");
576
+
577
  // Update current conversation model
578
  const conversation = this.state.getCurrentConversation();
579
  if (conversation) {
580
  conversation.model = selectedModel;
581
+ conversation.modelId =
582
+ modelMap[selectedModel] || "qwen-coder-3-default";
583
  this.state.saveToStorage();
584
  }
585
+
586
+ // Update chat header
587
+ this.updateChatHeader(selectedModel);
588
+
589
+ console.log("Model changed to:", selectedModel);
590
+
591
+ // Show brief notification
592
+ this.showModelChangeNotification(selectedModel);
593
  });
594
  });
595
  }
596
 
597
+ // Show model change notification
598
+ showModelChangeNotification(modelName) {
599
+ // Create notification element
600
+ const notification = document.createElement("div");
601
+ notification.className =
602
+ "fixed top-20 right-4 z-50 px-4 py-2 rounded-lg 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";
603
+ notification.innerHTML = `
604
+ <div class="flex items-center gap-2">
605
+ <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
606
+ <path d="M9 12l2 2 4-4"/>
607
+ <path d="M21 12c-1 0-3-1-3-3s2-3 3-3 3 1 3 3-2 3-3 3"/>
608
+ <path d="M3 12c1 0 3-1 3-3s-2-3-3-3-3 1-3 3 2 3 3 3"/>
609
+ <path d="M3 12h6m6 0h6"/>
610
+ </svg>
611
+ Model switched to ${modelName}
612
+ </div>
613
+ `;
614
+
615
+ document.body.appendChild(notification);
616
+
617
+ // Animate in
618
+ requestAnimationFrame(() => {
619
+ notification.classList.remove("translate-x-full");
620
+ });
621
+
622
+ // Animate out after 3 seconds
623
+ setTimeout(() => {
624
+ notification.classList.add("translate-x-full");
625
+ setTimeout(() => {
626
+ document.body.removeChild(notification);
627
+ }, 300);
628
+ }, 3000);
629
+ }
630
+
631
+ // Update chat header with current model
632
+ updateChatHeader(modelName) {
633
+ const chatTitle = document.getElementById("chat-title");
634
+ const chatSubtitle = chatTitle?.nextElementSibling;
635
+
636
+ if (chatSubtitle) {
637
+ chatSubtitle.textContent = `Using ${modelName} • Ready to help`;
638
+ }
639
+ }
640
+
641
  // Set up all event listeners
642
  setupEventListeners() {
643
  // Message sending
644
  if (this.elements.sendButton) {
645
+ this.elements.sendButton.addEventListener("click", () =>
646
+ this.handleSendMessage()
647
+ );
648
+ }
649
+
650
+ // Stop button
651
+ if (this.elements.stopButton) {
652
+ this.elements.stopButton.addEventListener("click", () =>
653
+ this.cancelCurrentMessage()
654
+ );
655
  }
656
 
657
  if (this.elements.composer) {
658
+ this.elements.composer.addEventListener("keydown", (e) => {
659
+ if (e.key === "Enter" && !e.shiftKey) {
660
  e.preventDefault();
661
  this.handleSendMessage();
662
  }
663
  });
664
 
665
  // Auto-resize textarea
666
+ this.elements.composer.addEventListener("input", () => {
667
  this.autoResizeComposer();
668
  this.updateSendButtonState();
669
  });
 
671
 
672
  // Handle suggestion clicks from welcome screen
673
  if (this.elements.messages) {
674
+ this.elements.messages.addEventListener("click", (e) => {
675
+ const suggestion = e.target.closest("[data-suggest]");
676
  if (suggestion) {
677
  const text = suggestion.textContent.trim();
678
  if (text && this.elements.composer) {
 
686
  }
687
 
688
  // New chat button in sidebar
689
+ const newChatBtn = document.querySelector("#left-desktop button");
690
+ if (newChatBtn && newChatBtn.textContent.includes("New chat")) {
691
+ newChatBtn.addEventListener("click", () => {
692
  this.createNewConversation();
693
  });
694
  }
 
699
  }, 30000); // Save every 30 seconds
700
 
701
  // Save on page unload
702
+ window.addEventListener("beforeunload", () => {
703
  this.state.saveToStorage();
704
  });
705
  }
 
715
  try {
716
  // Clear composer
717
  if (this.elements.composer) {
718
+ this.elements.composer.value = "";
719
  this.autoResizeComposer();
720
  }
721
 
722
  // Check if message was already added by inline script
723
  const lastMessage = this.elements.messages?.lastElementChild;
724
+ const isUserMessage =
725
+ lastMessage?.querySelector(".text-sm")?.textContent === "You";
726
+
727
  let userMessage;
728
+ if (
729
+ isUserMessage &&
730
+ lastMessage.querySelector(".prose")?.textContent?.trim() === message
731
+ ) {
732
  // Message already added by inline script, just update our state
733
+ userMessage = this.state.addMessage("user", message);
734
  } else {
735
  // Add user message normally
736
+ userMessage = this.state.addMessage("user", message);
737
  this.renderMessage(userMessage);
738
  }
739
 
 
742
 
743
  // Prepare conversation history for API
744
  const conversation = this.state.getCurrentConversation();
745
+ const history = conversation
746
+ ? conversation.messages.map((msg) => ({
747
+ role: msg.role,
748
+ content: msg.content,
749
+ }))
750
+ : [];
751
 
752
  // Use the streaming chat endpoint
753
+ const response = await this.api.sendMessage(
754
+ message,
755
+ history.slice(0, -1)
756
+ );
757
+
758
  // Hide typing indicator
759
  this.renderer.hideTyping();
760
 
761
  // Handle streaming response
762
  await this.handleStreamingResponse(response);
 
763
  } catch (error) {
764
+ console.error("Error sending message:", error);
765
  this.renderer.hideTyping();
766
+
767
  // Add error message based on error type
768
+ let errorMessage = "Sorry, I encountered an error. Please try again.";
769
+
770
+ if (error.name === "AbortError") {
771
+ errorMessage = "Request was cancelled.";
772
+ } else if (error.message.includes("HTTP 429")) {
773
+ errorMessage = "Too many requests. Please wait a moment and try again.";
774
+ } else if (error.message.includes("HTTP 401")) {
775
+ errorMessage = "Authentication error. Please check your API key.";
776
+ } else if (error.message.includes("HTTP 500")) {
777
+ errorMessage =
778
+ "Server error. The AI service is temporarily unavailable.";
779
+ } else if (error.message.includes("Failed to fetch")) {
780
+ errorMessage = "Network error. Please check your internet connection.";
781
  }
782
 
783
+ const errorMsg = this.state.addMessage("assistant", errorMessage);
784
  this.renderMessage(errorMsg);
 
785
  } finally {
786
  this.isProcessingMessage = false;
787
  this.updateSendButtonState();
 
792
  async handleStreamingResponse(response) {
793
  const reader = response.body.getReader();
794
  const decoder = new TextDecoder();
795
+
796
  // Remove any stub responses first
797
  this.removeStubResponses();
798
+
799
  // Create assistant message
800
+ const assistantMessage = this.state.addMessage("assistant", "");
801
  const messageElement = this.renderMessage(assistantMessage);
802
+
803
  // Get content div for streaming updates
804
+ const contentDiv = messageElement.querySelector(".prose");
805
+
806
+ let accumulatedContent = "";
807
+
808
  try {
809
  while (true) {
810
  const { done, value } = await reader.read();
811
  if (done) break;
812
+
813
  const chunk = decoder.decode(value, { stream: true });
814
  accumulatedContent += chunk;
815
+
816
  // Update message content with real-time formatting
817
  assistantMessage.content = accumulatedContent;
818
  contentDiv.innerHTML = this.renderer.formatContent(accumulatedContent);
819
+
820
  // Scroll to bottom smoothly
821
  this.renderer.scrollToBottom();
822
+
823
  // Add a small delay to make streaming visible
824
+ await new Promise((resolve) => setTimeout(resolve, 10));
825
  }
826
+
827
  // Final update with complete content
828
  assistantMessage.content = accumulatedContent;
829
+ assistantMessage.status = "delivered";
830
  contentDiv.innerHTML = this.renderer.formatContent(accumulatedContent);
831
+
832
  // Update state with final content
833
  this.state.saveToStorage();
 
834
  } catch (error) {
835
+ console.error("Error reading stream:", error);
836
+
837
  // If streaming fails, try to get any partial content
838
  if (accumulatedContent.trim()) {
839
  assistantMessage.content = accumulatedContent;
840
+ assistantMessage.status = "partial";
841
+ contentDiv.innerHTML =
842
+ this.renderer.formatContent(accumulatedContent) +
843
+ '<br><em class="text-zinc-400 text-xs">[Response incomplete]</em>';
844
  } else {
845
+ assistantMessage.content =
846
+ "Error receiving response. Please try again.";
847
+ assistantMessage.status = "error";
848
+ contentDiv.innerHTML = this.renderer.formatContent(
849
+ assistantMessage.content
850
+ );
851
  }
852
+
853
  this.state.saveToStorage();
854
  }
855
  }
 
857
  // Remove stub responses that might have been added by inline script
858
  removeStubResponses() {
859
  if (!this.elements.messages) return;
860
+
861
+ const messages = this.elements.messages.querySelectorAll(
862
+ ".relative.flex.items-start"
863
+ );
864
+ messages.forEach((messageElement) => {
865
+ const content = messageElement.querySelector(".prose");
866
+ if (
867
+ content &&
868
+ (content.textContent.includes("Stubbed response") ||
869
+ content.textContent.includes("Sem přijde odpověď modelu") ||
870
+ content.textContent.includes("Chat system loading"))
871
+ ) {
872
  messageElement.remove();
873
  }
874
  });
 
878
  async sendMessageOpenAI(message, options = {}) {
879
  try {
880
  const conversation = this.state.getCurrentConversation();
881
+ const messages = conversation
882
+ ? conversation.messages.map((msg) => ({
883
+ role: msg.role,
884
+ content: msg.content,
885
+ }))
886
+ : [];
887
+
888
  // Add current message
889
+ messages.push({ role: "user", content: message });
890
 
891
  const response = await this.api.sendMessageOpenAI(messages, {
892
+ model: options.model || "qwen-coder-3-30b",
893
  maxTokens: options.maxTokens || 1024,
894
  temperature: options.temperature || 0.7,
895
+ stream: false,
896
  });
897
 
898
  const data = await response.json();
899
+
900
  if (data.choices && data.choices[0] && data.choices[0].message) {
901
  return data.choices[0].message.content;
902
  } else {
903
+ throw new Error("Invalid response format from OpenAI API");
904
  }
905
  } catch (error) {
906
+ console.error("OpenAI API error:", error);
907
  throw error;
908
  }
909
  }
 
911
  // Render a message to the UI
912
  renderMessage(message) {
913
  if (!this.elements.messages) return null;
914
+
915
  const messageElement = this.renderer.renderMessage(message);
916
  this.elements.messages.appendChild(messageElement);
917
  this.renderer.scrollToBottom();
918
+
919
  return messageElement;
920
  }
921
 
 
925
  this.renderWelcomeScreen();
926
  this.updateChatHistory();
927
  this.state.saveToStorage();
928
+ console.log("Created new conversation:", conversation.id);
929
  }
930
 
931
  // Update chat history in sidebar
932
+ // Update chat history in sidebar
933
  updateChatHistory() {
934
+ const historyContainer = document.querySelector(
935
+ "#left-desktop .overflow-y-auto"
936
+ );
937
  if (!historyContainer) return;
938
 
939
  // Clear existing history (keep search and new chat button)
940
+ const existingChats = historyContainer.querySelectorAll(".chat-item");
941
+ existingChats.forEach((item) => item.remove());
942
 
943
  // Add conversations
944
+ const conversations = Array.from(this.state.conversations.values()).sort(
945
+ (a, b) => b.updated - a.updated
946
+ );
947
+
948
+ conversations.forEach((conversation) => {
949
+ const chatItem = document.createElement("button");
950
+ 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 ${
951
+ conversation.id === this.state.currentConversationId
952
+ ? "bg-zinc-100 dark:bg-zinc-800"
953
+ : ""
954
  }`;
955
+
956
  chatItem.innerHTML = `
957
+ <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">
958
  <path d="M12 12c2 0 4 1 5 3-1 2-3 3-5 3s-4-1-5-3c1-2 3-3 5-3Z"/>
959
  <circle cx="12" cy="8" r="3"/>
960
  </svg>
961
  <div class="min-w-0 flex-1">
962
  <div class="truncate text-sm font-medium">${conversation.title}</div>
963
+ <div class="truncate text-xs text-zinc-500">${this.formatChatDate(
964
+ conversation.updated
965
+ )}</div>
966
  </div>
967
+ <div class="chat-actions opacity-0 group-hover:opacity-100 transition-opacity flex items-center">
968
+ <button class="delete-chat p-1.5 hover:bg-red-100 dark:hover:bg-red-900 rounded-md transition-colors" data-chat-id="${
969
+ conversation.id
970
+ }" title="Smazat chat">
971
+ <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">
972
+ <path d="M3 6h18"></path>
973
+ <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
974
+ <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
975
+ <line x1="10" y1="11" x2="10" y2="17"></line>
976
+ <line x1="14" y1="11" x2="14" y2="17"></line>
977
  </svg>
978
  </button>
979
  </div>
980
  `;
981
 
982
  // Handle chat selection
983
+ chatItem.addEventListener("click", (e) => {
984
+ // Prevent click if clicking on delete button
985
+ if (e.target.closest(".delete-chat")) {
986
+ e.stopPropagation();
987
+ return;
988
+ }
989
  this.loadChatSession(conversation.id);
990
  });
991
 
992
  // Handle chat deletion
993
+ const deleteBtn = chatItem.querySelector(".delete-chat");
994
+ if (deleteBtn) {
995
+ deleteBtn.addEventListener("click", (e) => {
996
+ e.stopPropagation();
997
+ e.preventDefault();
998
+ this.deleteChatSession(conversation.id);
999
+ });
1000
+ }
1001
 
1002
  historyContainer.appendChild(chatItem);
1003
  });
 
1009
  const now = new Date();
1010
  const diffInHours = (now - date) / (1000 * 60 * 60);
1011
 
1012
+ if (diffInHours < 1) return "Just now";
1013
  if (diffInHours < 24) return `${Math.floor(diffInHours)}h ago`;
1014
+ if (diffInHours < 48) return "Yesterday";
1015
  if (diffInHours < 168) return `${Math.floor(diffInHours / 24)}d ago`;
1016
+
1017
  return date.toLocaleDateString();
1018
  }
1019
 
 
1029
 
1030
  // Clear existing messages
1031
  if (this.elements.messages) {
1032
+ this.elements.messages.innerHTML = "";
1033
  }
1034
 
1035
  if (conversation.messages.length === 0) {
1036
  this.renderWelcomeScreen();
1037
  } else {
1038
+ conversation.messages.forEach((message) => {
1039
  this.renderMessage(message);
1040
  });
1041
  }
 
1043
  // Update UI
1044
  this.updateChatHistory();
1045
  this.state.saveToStorage();
1046
+
1047
+ console.log("Loaded chat session:", sessionId);
1048
  }
1049
 
1050
  // Delete a chat session
1051
+ // Delete a chat session
1052
+ // Delete a chat session
1053
  deleteChatSession(sessionId) {
1054
+ if (confirm("Opravdu chcete smazat tento chat? Tuto akci nelze vrátit zpět.")) {
1055
+ // Smažeme konverzaci ze stavu
1056
  this.state.conversations.delete(sessionId);
1057
+
1058
+ // Pokud mažeme současnou konverzaci
1059
  if (sessionId === this.state.currentConversationId) {
1060
+ this.state.currentConversationId = null;
1061
+
1062
+ // Zkontrolujeme, jestli existují jiné konverzace
1063
+ if (this.state.conversations.size > 0) {
1064
+ // Najdeme nejnovější konverzaci
1065
+ const conversations = Array.from(this.state.conversations.values());
1066
+ const latestConversation = conversations.sort((a, b) => b.updated - a.updated)[0];
1067
+
1068
+ // Přepneme na nejnovější konverzaci
1069
+ this.loadChatSession(latestConversation.id);
1070
+ } else {
1071
+ // Pokud neexistují žádné konverzace, vytvoříme novou
1072
+ this.createNewConversation();
1073
+ }
1074
  } else {
1075
+ // Jinak jen aktualizujeme historii
1076
  this.updateChatHistory();
1077
  }
1078
+
1079
+ // Uložíme stav
1080
  this.state.saveToStorage();
1081
+
1082
+ console.log("Chat session deleted:", sessionId);
1083
+
1084
+ // Zobrazíme notifikaci
1085
+ if (this.showNotification) {
1086
+ this.showNotification("Chat byl úspěšně smazán", "success");
1087
+ }
1088
  }
1089
  }
1090
 
1091
+ // Show notification (simple implementation)
1092
+ showNotification(message, type = "info") {
1093
+ const notification = document.createElement("div");
1094
+ notification.className = `fixed top-20 right-4 z-50 px-4 py-2 rounded-lg ${
1095
+ type === "success"
1096
+ ? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
1097
+ : "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
1098
+ } text-sm font-medium transition-all duration-300 transform translate-x-full`;
1099
+ notification.textContent = message;
1100
+
1101
+ document.body.appendChild(notification);
1102
+
1103
+ // Animate in
1104
+ requestAnimationFrame(() => {
1105
+ notification.classList.remove("translate-x-full");
1106
+ });
1107
+
1108
+ // Animate out after 3 seconds
1109
+ setTimeout(() => {
1110
+ notification.classList.add("translate-x-full");
1111
+ setTimeout(() => {
1112
+ document.body.removeChild(notification);
1113
+ }, 300);
1114
+ }, 3000);
1115
+ }
1116
+
1117
  // Load current conversation
1118
  loadCurrentConversation() {
1119
  const conversation = this.state.getCurrentConversation();
 
1124
 
1125
  // Clear existing messages
1126
  if (this.elements.messages) {
1127
+ this.elements.messages.innerHTML = "";
1128
  }
1129
 
1130
  if (conversation.messages.length === 0) {
1131
  this.renderWelcomeScreen();
1132
  } else {
1133
+ conversation.messages.forEach((message) => {
1134
  this.renderMessage(message);
1135
  });
1136
  }
 
1139
  // Render welcome screen
1140
  renderWelcomeScreen() {
1141
  if (!this.elements.messages) return;
1142
+
1143
  // Use the existing welcome message structure from HTML
1144
  this.elements.messages.innerHTML = `
1145
  <div class="relative flex items-start gap-3 px-4 py-5 sm:px-6">
 
1147
  <div class="min-w-0 flex-1">
1148
  <div class="mb-1 flex items-baseline gap-2">
1149
  <div class="text-sm font-medium">Ava</div>
1150
+ <div class="text-xs text-zinc-500">${new Date()
1151
+ .toLocaleTimeString()
1152
+ .slice(0, 5)}</div>
1153
  </div>
1154
  <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">
1155
  <p>Ahoj! 👋 Jsem tvůj AI asistent. Jaký úkol dnes řešíš?</p>
 
1168
  // Auto-resize composer textarea
1169
  autoResizeComposer() {
1170
  if (!this.elements.composer) return;
1171
+
1172
+ this.elements.composer.style.height = "auto";
1173
  const maxHeight = 160; // max-h-40 from Tailwind (160px)
1174
+ this.elements.composer.style.height =
1175
+ Math.min(this.elements.composer.scrollHeight, maxHeight) + "px";
1176
  }
1177
 
1178
  // Update send button state
1179
  updateSendButtonState() {
1180
  if (!this.elements.sendButton || !this.elements.composer) return;
1181
+
1182
  const hasText = this.elements.composer.value.trim().length > 0;
1183
  const isEnabled = hasText && !this.isProcessingMessage;
1184
+
1185
  this.elements.sendButton.disabled = !isEnabled;
1186
+
1187
  if (isEnabled) {
1188
+ this.elements.sendButton.classList.remove(
1189
+ "disabled:cursor-not-allowed",
1190
+ "disabled:opacity-50"
1191
+ );
1192
  } else {
1193
+ this.elements.sendButton.classList.add(
1194
+ "disabled:cursor-not-allowed",
1195
+ "disabled:opacity-50"
1196
+ );
1197
+ }
1198
+
1199
+ // Show/hide stop button based on processing state
1200
+ if (this.elements.stopButton) {
1201
+ if (this.isProcessingMessage) {
1202
+ this.elements.stopButton.classList.remove("hidden");
1203
+ } else {
1204
+ this.elements.stopButton.classList.add("hidden");
1205
+ }
1206
  }
1207
  }
1208
 
1209
  // Handle connection status changes
1210
  handleConnectionStatusChange(status) {
1211
  this.state.connectionStatus = status;
1212
+ console.log("Connection status changed to:", status);
1213
+
1214
  // Update UI to show connection status
1215
  this.updateConnectionIndicator(status);
1216
+
1217
  // Retry queued messages when connection is restored
1218
+ if (status === "connected" && this.state.messageQueue.length > 0) {
1219
  this.processMessageQueue();
1220
  }
1221
  }
 
1223
  // Update connection indicator in UI
1224
  updateConnectionIndicator(status) {
1225
  // Find or create connection indicator
1226
+ let indicator = document.getElementById("connection-indicator");
1227
  if (!indicator) {
1228
+ indicator = document.createElement("div");
1229
+ indicator.id = "connection-indicator";
1230
+ indicator.className =
1231
+ "fixed bottom-4 right-4 z-50 px-3 py-1 rounded-full text-xs font-medium transition-all duration-300";
1232
  document.body.appendChild(indicator);
1233
  }
1234
 
1235
  // Update indicator based on status
1236
  switch (status) {
1237
+ case "connected":
1238
+ indicator.className =
1239
+ "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";
1240
+ indicator.textContent = "🟢 Connected";
1241
  // Hide after 2 seconds
1242
  setTimeout(() => {
1243
+ indicator.style.opacity = "0";
1244
+ indicator.style.transform = "translateY(20px)";
1245
  }, 2000);
1246
  break;
1247
+
1248
+ case "disconnected":
1249
+ indicator.className =
1250
+ "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";
1251
+ indicator.textContent = "🔴 Disconnected";
1252
+ indicator.style.opacity = "1";
1253
+ indicator.style.transform = "translateY(0)";
1254
  break;
1255
+
1256
+ case "offline":
1257
+ indicator.className =
1258
+ "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";
1259
+ indicator.textContent = "⚠️ Offline";
1260
+ indicator.style.opacity = "1";
1261
+ indicator.style.transform = "translateY(0)";
1262
  break;
1263
+
1264
  default:
1265
+ indicator.style.opacity = "0";
1266
+ indicator.style.transform = "translateY(-20px)";
1267
  }
1268
  }
1269
 
 
1274
  try {
1275
  await this.handleSendMessage(queuedMessage);
1276
  } catch (error) {
1277
+ console.error("Failed to process queued message:", error);
1278
  // Re-queue if failed
1279
  this.state.messageQueue.unshift(queuedMessage);
1280
  break;
 
1295
  const chatApp = new ChatApp();
1296
 
1297
  // Start the application when DOM is ready and make it globally available
1298
+ if (document.readyState === "loading") {
1299
+ document.addEventListener("DOMContentLoaded", () => {
1300
  chatApp.init().then(() => {
1301
  window.chatApp = chatApp; // Make globally available
1302
+ console.log("ChatApp is now globally available");
1303
  });
1304
  });
1305
  } else {
1306
  chatApp.init().then(() => {
1307
  window.chatApp = chatApp; // Make globally available
1308
+ console.log("ChatApp is now globally available");
1309
  });
1310
  }
1311
 
1312
  // Export for potential module usage
1313
+ if (typeof module !== "undefined" && module.exports) {
1314
+ module.exports = {
1315
+ ChatApp,
1316
+ ChatState,
1317
+ APIManager,
1318
+ MessageRenderer,
1319
+ ConnectionMonitor,
1320
+ };
1321
  }
1322
 
1323
  // Utility functions for backward compatibility and additional features
 
1329
  isI18nReady = true;
1330
  return true;
1331
  } catch (error) {
1332
+ console.error("Failed to initialize i18n:", error);
1333
  isI18nReady = false;
1334
  return false;
1335
  }
 
1374
  return {
1375
  used: Math.round(performance.memory.usedJSHeapSize / 1048576),
1376
  total: Math.round(performance.memory.totalJSHeapSize / 1048576),
1377
+ limit: Math.round(performance.memory.jsHeapSizeLimit / 1048576),
1378
  };
1379
  }
1380
  return null;
 
1382
 
1383
  // Monitor network timing
1384
  observeNetworkTiming() {
1385
+ if ("PerformanceObserver" in window) {
1386
  const observer = new PerformanceObserver((list) => {
1387
  list.getEntries().forEach((entry) => {
1388
+ if (entry.entryType === "navigation") {
1389
+ console.log("Navigation timing:", {
1390
  dns: entry.domainLookupEnd - entry.domainLookupStart,
1391
  connection: entry.connectEnd - entry.connectStart,
1392
  request: entry.responseStart - entry.requestStart,
1393
+ response: entry.responseEnd - entry.responseStart,
1394
  });
1395
  }
1396
  });
1397
  });
1398
 
1399
  try {
1400
+ observer.observe({ entryTypes: ["navigation", "resource"] });
1401
+ this.observers.set("network", observer);
1402
  } catch (error) {
1403
+ console.warn("Performance observer not supported:", error);
1404
  }
1405
  }
1406
  }
 
1425
  this.socket = new WebSocket(this.url);
1426
  this.setupEventListeners();
1427
  } catch (error) {
1428
+ console.error("WebSocket connection failed:", error);
1429
  this.handleReconnect();
1430
  }
1431
  }
 
1435
  if (!this.socket) return;
1436
 
1437
  this.socket.onopen = () => {
1438
+ console.log("WebSocket connected");
1439
  this.reconnectAttempts = 0;
1440
  this.startHeartbeat();
1441
  this.processMessageQueue();
1442
+ this.emit("connected");
1443
  };
1444
 
1445
  this.socket.onmessage = (event) => {
1446
  try {
1447
  const data = JSON.parse(event.data);
1448
+ this.emit("message", data);
1449
  } catch (error) {
1450
+ console.error("Failed to parse WebSocket message:", error);
1451
  }
1452
  };
1453
 
1454
  this.socket.onclose = () => {
1455
+ console.log("WebSocket disconnected");
1456
  this.stopHeartbeat();
1457
+ this.emit("disconnected");
1458
  this.handleReconnect();
1459
  };
1460
 
1461
  this.socket.onerror = (error) => {
1462
+ console.error("WebSocket error:", error);
1463
+ this.emit("error", error);
1464
  };
1465
  }
1466
 
 
1478
  handleReconnect() {
1479
  if (this.reconnectAttempts < this.maxReconnectAttempts) {
1480
  this.reconnectAttempts++;
1481
+ const delay =
1482
+ this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
1483
+
1484
+ console.log(
1485
+ `Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`
1486
+ );
1487
+
1488
  setTimeout(() => {
1489
  this.connect();
1490
  }, delay);
1491
  } else {
1492
+ console.error("Max reconnection attempts reached");
1493
+ this.emit("maxReconnectReached");
1494
  }
1495
  }
1496
 
 
1498
  startHeartbeat() {
1499
  this.heartbeatInterval = setInterval(() => {
1500
  if (this.socket && this.socket.readyState === WebSocket.OPEN) {
1501
+ this.send({ type: "ping" });
1502
  }
1503
  }, 30000); // Send heartbeat every 30 seconds
1504
  }
 
1540
  emit(event, data) {
1541
  const eventListeners = this.listeners.get(event);
1542
  if (eventListeners) {
1543
+ eventListeners.forEach((callback) => callback(data));
1544
  }
1545
  }
1546
 
 
1558
  class SecurityUtils {
1559
  // Sanitize HTML content
1560
  static sanitizeHTML(html) {
1561
+ const div = document.createElement("div");
1562
  div.textContent = html;
1563
  return div.innerHTML;
1564
  }
1565
 
1566
  // Validate message content
1567
  static validateMessage(content) {
1568
+ if (typeof content !== "string") return false;
1569
  if (content.length === 0 || content.length > 10000) return false;
1570
  return true;
1571
  }
 
1573
  // Rate limiting
1574
  static createRateLimiter(limit, window) {
1575
  const requests = new Map();
1576
+
1577
+ return function (identifier) {
1578
  const now = Date.now();
1579
  const windowStart = now - window;
1580
+
1581
  // Clean old requests
1582
  for (const [key, timestamps] of requests.entries()) {
1583
+ requests.set(
1584
+ key,
1585
+ timestamps.filter((time) => time > windowStart)
1586
+ );
1587
  if (requests.get(key).length === 0) {
1588
  requests.delete(key);
1589
  }
1590
  }
1591
+
1592
  // Check current requests
1593
  const userRequests = requests.get(identifier) || [];
1594
  if (userRequests.length >= limit) {
1595
  return false; // Rate limited
1596
  }
1597
+
1598
  // Add current request
1599
  userRequests.push(now);
1600
  requests.set(identifier, userRequests);
 
1620
 
1621
  setupGlobalErrorHandling() {
1622
  // Catch unhandled errors
1623
+ window.addEventListener("error", (event) => {
1624
  this.reportError({
1625
+ type: "javascript",
1626
  message: event.message,
1627
  filename: event.filename,
1628
  line: event.lineno,
1629
  column: event.colno,
1630
  stack: event.error?.stack,
1631
+ timestamp: Date.now(),
1632
  });
1633
  });
1634
 
1635
  // Catch unhandled promise rejections
1636
+ window.addEventListener("unhandledrejection", (event) => {
1637
  this.reportError({
1638
+ type: "promise",
1639
+ message: event.reason?.message || "Unhandled promise rejection",
1640
  stack: event.reason?.stack,
1641
+ timestamp: Date.now(),
1642
  });
1643
  });
1644
  }
1645
 
1646
  reportError(error) {
1647
+ console.error("Error reported:", error);
1648
+
1649
  this.errors.push(error);
1650
+
1651
  // Keep only recent errors
1652
  if (this.errors.length > this.maxErrors) {
1653
  this.errors.shift();
 
1661
  // Placeholder for analytics integration
1662
  // In production, you might send to services like Sentry, LogRocket, etc.
1663
  if (window.gtag) {
1664
+ window.gtag("event", "exception", {
1665
  description: error.message,
1666
+ fatal: false,
1667
  });
1668
  }
1669
  }
 
1684
  }
1685
 
1686
  setupKeyboardNavigation() {
1687
+ document.addEventListener("keydown", (event) => {
1688
  // Handle global keyboard shortcuts
1689
  if (event.ctrlKey || event.metaKey) {
1690
  switch (event.key) {
1691
+ case "n":
1692
  event.preventDefault();
1693
  chatApp.createNewConversation();
1694
  break;
1695
+ case "/":
1696
  event.preventDefault();
1697
  chatApp.elements.composer?.focus();
1698
  break;
 
1700
  }
1701
 
1702
  // Escape key to cancel current operation
1703
+ if (event.key === "Escape") {
1704
  chatApp.cancelCurrentMessage();
1705
  }
1706
  });
 
1708
 
1709
  setupScreenReaderSupport() {
1710
  // Announce new messages to screen readers
1711
+ const announcer = document.createElement("div");
1712
+ announcer.setAttribute("aria-live", "polite");
1713
+ announcer.setAttribute("aria-atomic", "true");
1714
+ announcer.className = "sr-only";
1715
  document.body.appendChild(announcer);
1716
 
1717
  this.announcer = announcer;
 
1719
 
1720
  announceMessage(message) {
1721
  if (this.announcer) {
1722
+ this.announcer.textContent = `${
1723
+ message.role === "user" ? "You" : "Assistant"
1724
+ }: ${message.content}`;
1725
  }
1726
  }
1727
  }
 
1738
  performanceMonitor,
1739
  errorReporter,
1740
  accessibilityManager,
1741
+ SecurityUtils,
1742
+ };
public/index.html CHANGED
@@ -49,15 +49,22 @@
49
  <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
50
  </button>
51
  <div class="absolute right-0 z-40 mt-2 hidden w-56 overflow-hidden rounded-md border border-zinc-200 bg-white p-1 text-sm shadow-lg dark:border-zinc-800 dark:bg-zinc-900" data-dd-menu>
52
- <div class="px-2 py-1 text-xs font-medium text-zinc-500">Models</div>
53
- <button class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-800">GPT-4.1</button>
54
- <button class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-800">Claude 3.5 Sonnet</button>
55
- <button class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-800">Qwen 3 Coder</button>
56
- <div class="my-1 h-px bg-zinc-200 dark:bg-zinc-800"></div>
57
  <button class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-800">
58
- <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13v-2"/><path d="M14 13v-2"/><path d="M7 22v-4a4 4 0 0 1 4-4h2a4 4 0 0 1 4 4v4"/><circle cx="12" cy="7" r="4"/></svg>
59
- Manage keys
60
  </button>
 
 
 
 
 
 
 
61
  </div>
62
  </div>
63
  <!-- Theme toggle -->
@@ -150,11 +157,11 @@
150
  <svg class="h-5 w-5" 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>
151
  </div>
152
  <div class="min-w-0">
153
- <div class="truncate text-sm font-semibold">Design prompt library</div>
154
- <div class="truncate text-xs text-zinc-500 dark:text-zinc-400">Using Qwen 3 Coder • Web + Files enabled</div>
155
  </div>
156
  <div class="ml-auto flex items-center gap-2">
157
- <span class="hidden rounded-md border border-zinc-200 px-2 py-1 text-xs sm:inline-flex dark:border-zinc-800">/public</span>
158
  <button class="inline-flex items-center gap-2 rounded-md border border-zinc-200 px-3 py-1.5 text-sm hover:bg-zinc-50 dark:border-zinc-800 dark:hover:bg-zinc-800"><svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12v7a1 1 0 0 0 1 1h7"/><path d="m21 3-9 9"/><path d="M15 3h6v6"/></svg>Share</button>
159
  <div class="relative" id="chat-more">
160
  <button class="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-zinc-100 dark:hover:bg-zinc-800" data-dd-trigger aria-label="More"><svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg></button>
@@ -185,51 +192,9 @@
185
  </div>
186
  </div>
187
  </div>
188
- <!-- user message -->
189
- <div class="relative flex items-start gap-3 px-4 py-5 sm:px-6">
190
- <div class="mt-1 h-8 w-8 shrink-0 overflow-hidden rounded-full bg-zinc-300 grid place-items-center text-[10px] font-medium">YOU</div>
191
- <div class="min-w-0 flex-1">
192
- <div class="mb-1 flex items-baseline gap-2"><div class="text-sm font-medium">You</div><div class="text-xs text-zinc-500">19:05</div></div>
193
- <div class="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">Potřebuji minimalistické UI pro chat (jako ChatGPT), prosím v Tailwindu.</div>
194
- </div>
195
- </div>
196
 
197
- <!-- assistant message 2 (card demo) -->
198
- <div class="relative flex items-start gap-3 px-4 py-5 sm:px-6">
199
- <div class="mt-1 h-8 w-8 shrink-0 overflow-hidden rounded-full bg-zinc-200"></div>
200
- <div class="min-w-0 flex-1">
201
- <div class="mb-1 flex items-baseline gap-2"><div class="text-sm font-medium">Ava</div><div class="text-xs text-zinc-500">19:05</div></div>
202
- <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">
203
- <p>Jasně! Níže je kostra komponenty. Zahrnuje hlavičku, seznam zpráv a composer.</p>
204
- <div class="mt-3 overflow-hidden rounded-lg border border-zinc-200 dark:border-zinc-800">
205
- <div class="border-b border-zinc-200 px-4 py-2 text-xs text-zinc-500 dark:border-zinc-800">Example component</div>
206
- <pre class="max-h-80 overflow-auto p-4 text-xs scrollbar-thin"><code>export default function Chat(){/* ... */}</code></pre>
207
- <div class="flex items-center justify-between gap-2 border-t border-zinc-200 px-4 py-2 text-xs text-zinc-500 dark:border-zinc-800">
208
- <div class="flex items-center gap-2"><span class="rounded border px-1.5 py-0.5">TypeScript</span><span>•</span><span>Copy &amp; adapt</span></div>
209
- <button class="inline-flex items-center gap-2 rounded-md border border-zinc-200 px-2 py-1 text-xs hover:bg-zinc-50 dark:border-zinc-800 dark:hover:bg-zinc-800"><svg class="h-3.5 w-3.5" 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"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>Copy</button>
210
- </div>
211
- </div>
212
- <div class="mt-3 flex gap-2 text-xs text-zinc-500"><span class="rounded border px-1.5 py-0.5">#ui</span><span class="rounded border px-1.5 py-0.5">#tailwind</span><span class="rounded border px-1.5 py-0.5">#shadcn</span></div>
213
- </div>
214
- </div>
215
- </div>
216
-
217
- <!-- assistant message 3 (quick actions) -->
218
- <div class="relative flex items-start gap-3 px-4 py-5 sm:px-6">
219
- <div class="mt-1 h-8 w-8 shrink-0 overflow-hidden rounded-full bg-zinc-200"></div>
220
- <div class="min-w-0 flex-1">
221
- <div class="mb-1 flex items-baseline gap-2"><div class="text-sm font-medium">Ava</div><div class="text-xs text-zinc-500">19:06</div></div>
222
- <div class="rounded-2xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-800/60">
223
- <p class="text-sm">Mohu také vytvořit sadu rychlých příkazů. Vyber jeden z nich:</p>
224
- <div class="mt-3 grid gap-2 sm:grid-cols-2">
225
- <button class="inline-flex items-center rounded-md bg-zinc-100 px-3 py-2 text-sm hover:bg-zinc-200 dark:bg-zinc-700 dark:hover:bg-zinc-600"><svg class="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20"/><path d="M5 7h14"/></svg>Summarize this page</button>
226
- <button class="inline-flex items-center rounded-md bg-zinc-100 px-3 py-2 text-sm hover:bg-zinc-200 dark:bg-zinc-700 dark:hover:bg-zinc-600"><svg class="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16v4H4z"/><path d="M4 12h16v8H4z"/></svg>Draft an email</button>
227
- <button class="inline-flex items-center rounded-md bg-zinc-100 px-3 py-2 text-sm hover:bg-zinc-200 dark:bg-zinc-700 dark:hover:bg-zinc-600"><svg class="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>Explain like I'm 5</button>
228
- <button class="inline-flex items-center rounded-md bg-zinc-100 px-3 py-2 text-sm hover:bg-zinc-200 dark:bg-zinc-700 dark:hover:bg-zinc-600"><svg class="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M3 12h18"/><path d="M3 18h18"/></svg>Create tasks</button>
229
- </div>
230
- </div>
231
- </div>
232
- </div>
233
  </div>
234
  </div>
235
 
@@ -251,7 +216,7 @@
251
  <div class="mt-2 flex items-center justify-between">
252
  <div class="text-xs text-zinc-500">Shift+Enter = new line</div>
253
  <div class="flex items-center gap-2">
254
- <button class="inline-flex items-center gap-2 rounded-md border border-zinc-200 px-3 py-1.5 text-sm hover:bg-zinc-50 dark:border-zinc-800 dark:hover:bg-zinc-800"><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="2" y="2" width="20" height="20" rx="2"/><path d="M12 8v8"/><path d="M8 12h8"/></svg>Stop</button>
255
  <button id="btn-send" class="inline-flex items-center gap-2 rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50"><svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13"/><path d="M22 2 15 22 11 13 2 9 22 2z"/></svg>Send</button>
256
  </div>
257
  </div>
@@ -355,13 +320,24 @@
355
  // Composer enable/disable and send
356
  const ta = document.getElementById('composer');
357
  const send = document.getElementById('btn-send');
 
358
  const messages = document.getElementById('messages');
359
  const scroller = document.getElementById('msg-scroll');
360
  function autoResize(){ ta.style.height='auto'; ta.style.height=Math.min(ta.scrollHeight,160)+'px'; }
361
- function sync(){ send.disabled = ta.value.trim().length===0; }
 
 
 
362
  ta.addEventListener('input', ()=>{ autoResize(); sync(); });
363
  ta.addEventListener('keydown', (e)=>{ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); if(!send.disabled) doSend(); }});
364
  send.addEventListener('click', doSend);
 
 
 
 
 
 
 
365
  function bubble(role, text){
366
  const wrap = document.createElement('div');
367
  wrap.className = 'relative flex items-start gap-3 px-4 py-5 sm:px-6';
 
49
  <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
50
  </button>
51
  <div class="absolute right-0 z-40 mt-2 hidden w-56 overflow-hidden rounded-md border border-zinc-200 bg-white p-1 text-sm shadow-lg dark:border-zinc-800 dark:bg-zinc-900" data-dd-menu>
52
+ <div class="px-2 py-1 text-xs font-medium text-zinc-500">Available Models</div>
53
+ <button class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-800">
54
+ <span class="w-2 h-2 bg-green-500 rounded-full"></span>
55
+ Qwen 3 Coder 30B
56
+ </button>
57
  <button class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-800">
58
+ <span class="w-2 h-2 bg-green-500 rounded-full"></span>
59
+ Qwen 3 Coder (Default)
60
  </button>
61
+ <div class="my-1 h-px bg-zinc-200 dark:bg-zinc-800"></div>
62
+ <div class="px-2 py-1 text-xs text-zinc-400">
63
+ <div class="flex items-center gap-1">
64
+ <svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
65
+ Response time: ~2-5s
66
+ </div>
67
+ </div>
68
  </div>
69
  </div>
70
  <!-- Theme toggle -->
 
157
  <svg class="h-5 w-5" 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>
158
  </div>
159
  <div class="min-w-0">
160
+ <div class="truncate text-sm font-semibold" id="chat-title">New Chat</div>
161
+ <div class="truncate text-xs text-zinc-500 dark:text-zinc-400">Using Qwen 3 Coder • Ready to help</div>
162
  </div>
163
  <div class="ml-auto flex items-center gap-2">
164
+ <span class="hidden rounded-md border border-zinc-200 px-2 py-1 text-xs sm:inline-flex dark:border-zinc-800">/chat</span>
165
  <button class="inline-flex items-center gap-2 rounded-md border border-zinc-200 px-3 py-1.5 text-sm hover:bg-zinc-50 dark:border-zinc-800 dark:hover:bg-zinc-800"><svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12v7a1 1 0 0 0 1 1h7"/><path d="m21 3-9 9"/><path d="M15 3h6v6"/></svg>Share</button>
166
  <div class="relative" id="chat-more">
167
  <button class="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-zinc-100 dark:hover:bg-zinc-800" data-dd-trigger aria-label="More"><svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg></button>
 
192
  </div>
193
  </div>
194
  </div>
195
+
 
 
 
 
 
 
 
196
 
197
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  </div>
199
  </div>
200
 
 
216
  <div class="mt-2 flex items-center justify-between">
217
  <div class="text-xs text-zinc-500">Shift+Enter = new line</div>
218
  <div class="flex items-center gap-2">
219
+ <button id="btn-stop" class="inline-flex items-center gap-2 rounded-md border border-zinc-200 px-3 py-1.5 text-sm hover:bg-zinc-50 dark:border-zinc-800 dark:hover:bg-zinc-800 hidden"><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="2" y="2" width="20" height="20" rx="2"/><path d="M12 8v8"/><path d="M8 12h8"/></svg>Stop</button>
220
  <button id="btn-send" class="inline-flex items-center gap-2 rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50"><svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13"/><path d="M22 2 15 22 11 13 2 9 22 2z"/></svg>Send</button>
221
  </div>
222
  </div>
 
320
  // Composer enable/disable and send
321
  const ta = document.getElementById('composer');
322
  const send = document.getElementById('btn-send');
323
+ const stop = document.getElementById('btn-stop');
324
  const messages = document.getElementById('messages');
325
  const scroller = document.getElementById('msg-scroll');
326
  function autoResize(){ ta.style.height='auto'; ta.style.height=Math.min(ta.scrollHeight,160)+'px'; }
327
+ function sync(){
328
+ send.disabled = ta.value.trim().length===0;
329
+ // Don't show stop button here - let ChatApp handle it
330
+ }
331
  ta.addEventListener('input', ()=>{ autoResize(); sync(); });
332
  ta.addEventListener('keydown', (e)=>{ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); if(!send.disabled) doSend(); }});
333
  send.addEventListener('click', doSend);
334
+
335
+ // Stop button handler
336
+ stop.addEventListener('click', ()=>{
337
+ if (window.chatApp && window.chatApp.cancelCurrentMessage) {
338
+ window.chatApp.cancelCurrentMessage();
339
+ }
340
+ });
341
  function bubble(role, text){
342
  const wrap = document.createElement('div');
343
  wrap.className = 'relative flex items-start gap-3 px-4 py-5 sm:px-6';