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

Upload 18 files

Browse files
Files changed (4) hide show
  1. app.py +112 -27
  2. public/app.js +197 -106
  3. public/index.html +18 -11
  4. public/styles.css +29 -1
app.py CHANGED
@@ -47,35 +47,70 @@ class ChatResponse(BaseModel):
47
  # Global model variables
48
  tokenizer = None
49
  model = None
 
 
 
 
 
50
 
51
- def load_model():
52
- """Load the Qwen model and tokenizer"""
53
- global tokenizer, model
 
54
 
55
  try:
56
- model_name = "Qwen/Qwen3-Coder-30B-A3B-Instruct" # Adjust model name as needed
 
 
 
 
 
 
 
 
 
 
 
 
 
57
 
58
  logger.info(f"Loading model: {model_name}")
59
  tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
60
- model = AutoModelForCausalLM.from_pretrained(
61
- model_name,
62
- torch_dtype=torch.float16,
63
- device_map="auto",
64
- trust_remote_code=True
65
- )
66
- logger.info("Model loaded successfully")
 
 
 
 
 
 
 
 
 
 
 
 
 
67
 
68
  except Exception as e:
69
- logger.error(f"Error loading model: {e}")
70
  # For development/testing, use a fallback
71
  logger.warning("Using fallback model response")
72
-
73
- def generate_response(messages: List[ChatMessage], temperature: float = 0.7, max_tokens: int = 2048):
74
  """Generate response from the model"""
75
  try:
 
 
 
 
76
  if model is None or tokenizer is None:
77
  # Fallback response for development
78
- return "I'm a Qwen AI assistant. The model is currently loading, please try again in a moment."
79
 
80
  # Format messages for the model
81
  formatted_messages = []
@@ -92,6 +127,12 @@ def generate_response(messages: List[ChatMessage], temperature: float = 0.7, max
92
  # Tokenize
93
  inputs = tokenizer(text, return_tensors="pt").to(model.device)
94
 
 
 
 
 
 
 
95
  # Generate
96
  with torch.no_grad():
97
  outputs = model.generate(
@@ -110,12 +151,16 @@ def generate_response(messages: List[ChatMessage], temperature: float = 0.7, max
110
  logger.error(f"Error generating response: {e}")
111
  return f"I apologize, but I encountered an error while processing your request: {str(e)}"
112
 
113
- def generate_streaming_response(messages: List[ChatMessage], temperature: float = 0.7, max_tokens: int = 2048):
114
  """Generate streaming response from the model"""
115
  try:
 
 
 
 
116
  if model is None or tokenizer is None:
117
  # Fallback streaming response
118
- response = "I'm a Qwen AI assistant. The model is currently loading, please try again in a moment."
119
  for char in response:
120
  yield f"data: {json.dumps({'choices': [{'delta': {'content': char}}]})}\n\n"
121
  time.sleep(0.05)
@@ -138,6 +183,11 @@ def generate_streaming_response(messages: List[ChatMessage], temperature: float
138
  # Tokenize
139
  inputs = tokenizer(text, return_tensors="pt").to(model.device)
140
 
 
 
 
 
 
141
  # Setup streaming
142
  streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
143
 
@@ -169,12 +219,16 @@ def generate_streaming_response(messages: List[ChatMessage], temperature: float
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)
@@ -193,6 +247,11 @@ def generate_plain_text_stream(messages: List[ChatMessage], temperature: float =
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 = {
@@ -256,7 +315,17 @@ async def list_models():
256
  "id": "qwen-coder-3-30b",
257
  "object": "model",
258
  "created": int(time.time()),
259
- "owned_by": "qwen"
 
 
 
 
 
 
 
 
 
 
260
  }
261
  ]
262
  }
@@ -265,12 +334,19 @@ async def list_models():
265
  async def chat_completion(request: ChatRequest):
266
  """OpenAI compatible chat completion endpoint"""
267
  try:
 
 
 
 
 
 
268
  if request.stream:
269
  return StreamingResponse(
270
  generate_streaming_response(
271
  request.messages,
272
  request.temperature or 0.7,
273
- request.max_tokens or 2048
 
274
  ),
275
  media_type="text/plain"
276
  )
@@ -278,13 +354,14 @@ async def chat_completion(request: ChatRequest):
278
  response_content = generate_response(
279
  request.messages,
280
  request.temperature or 0.7,
281
- request.max_tokens or 2048
 
282
  )
283
 
284
  return ChatResponse(
285
  id=f"chatcmpl-{int(time.time())}",
286
  created=int(time.time()),
287
- model=request.model or "qwen-coder-3-30b",
288
  choices=[{
289
  "index": 0,
290
  "message": {
@@ -299,6 +376,7 @@ async def chat_completion(request: ChatRequest):
299
  logger.error(f"Error in chat completion: {e}")
300
  raise HTTPException(status_code=500, detail=str(e))
301
 
 
302
  @app.post("/v1/chat/completions")
303
  async def openai_chat_completion(request: ChatRequest):
304
  """OpenAI API compatible endpoint"""
@@ -310,6 +388,11 @@ async def chat_stream_compat(payload: Dict[str, Any]):
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:
@@ -325,7 +408,8 @@ async def chat_stream_compat(payload: Dict[str, Any]):
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
  )
@@ -336,12 +420,13 @@ async def chat_stream_compat(payload: Dict[str, Any]):
336
  # Mount static files AFTER API routes
337
  app.mount("/", StaticFiles(directory="public", html=True), name="static")
338
 
 
339
  # Startup event
340
  @app.on_event("startup")
341
  async def startup_event():
342
- """Initialize the model on startup"""
343
- # Load model in background thread to avoid blocking startup
344
- thread = Thread(target=load_model)
345
  thread.daemon = True
346
  thread.start()
347
 
 
47
  # Global model variables
48
  tokenizer = None
49
  model = None
50
+ current_model_name = None
51
+ available_models = {
52
+ "qwen-coder-3-30b": "Qwen/Qwen3-Coder-30B-A3B-Instruct",
53
+ "qwen-4b-thinking": "Qwen/Qwen3-4B-Thinking-2507"
54
+ }
55
 
56
+
57
+ def load_model(model_id: str = "qwen-coder-3-30b"):
58
+ """Load the specified Qwen model and tokenizer"""
59
+ global tokenizer, model, current_model_name
60
 
61
  try:
62
+ if model_id not in available_models:
63
+ raise ValueError(f"Unknown model ID: {model_id}")
64
+
65
+ model_name = available_models[model_id]
66
+
67
+ # If the same model is already loaded, skip
68
+ if current_model_name == model_name:
69
+ logger.info(f"Model {model_name} is already loaded")
70
+ return
71
+
72
+ # Clear previous model from memory
73
+ if model is not None:
74
+ del model
75
+ torch.cuda.empty_cache() if torch.cuda.is_available() else None
76
 
77
  logger.info(f"Loading model: {model_name}")
78
  tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
79
+
80
+ # Use different settings for the lighter model
81
+ if model_id == "qwen-4b-thinking":
82
+ model = AutoModelForCausalLM.from_pretrained(
83
+ model_name,
84
+ torch_dtype=torch.float16,
85
+ device_map="auto",
86
+ trust_remote_code=True,
87
+ low_cpu_mem_usage=True
88
+ )
89
+ else:
90
+ model = AutoModelForCausalLM.from_pretrained(
91
+ model_name,
92
+ torch_dtype=torch.float16,
93
+ device_map="auto",
94
+ trust_remote_code=True
95
+ )
96
+
97
+ current_model_name = model_name
98
+ logger.info(f"Model {model_name} loaded successfully")
99
 
100
  except Exception as e:
101
+ logger.error(f"Error loading model {model_id}: {e}")
102
  # For development/testing, use a fallback
103
  logger.warning("Using fallback model response")
104
+ def generate_response(messages: List[ChatMessage], temperature: float = 0.7, max_tokens: int = 2048, model_id: str = "qwen-coder-3-30b"):
 
105
  """Generate response from the model"""
106
  try:
107
+ # Load model if not loaded or different model requested
108
+ if model is None or current_model_name != available_models.get(model_id):
109
+ load_model(model_id)
110
+
111
  if model is None or tokenizer is None:
112
  # Fallback response for development
113
+ return f"I'm a Qwen AI assistant ({model_id}). The model is currently loading, please try again in a moment."
114
 
115
  # Format messages for the model
116
  formatted_messages = []
 
127
  # Tokenize
128
  inputs = tokenizer(text, return_tensors="pt").to(model.device)
129
 
130
+ # Adjust generation parameters for different models
131
+ if model_id == "qwen-4b-thinking":
132
+ # Use more conservative settings for the smaller model
133
+ max_tokens = min(max_tokens, 1024)
134
+ temperature = min(temperature, 0.8)
135
+
136
  # Generate
137
  with torch.no_grad():
138
  outputs = model.generate(
 
151
  logger.error(f"Error generating response: {e}")
152
  return f"I apologize, but I encountered an error while processing your request: {str(e)}"
153
 
154
+ def generate_streaming_response(messages: List[ChatMessage], temperature: float = 0.7, max_tokens: int = 2048, model_id: str = "qwen-coder-3-30b"):
155
  """Generate streaming response from the model"""
156
  try:
157
+ # Load model if not loaded or different model requested
158
+ if model is None or current_model_name != available_models.get(model_id):
159
+ load_model(model_id)
160
+
161
  if model is None or tokenizer is None:
162
  # Fallback streaming response
163
+ response = f"I'm a Qwen AI assistant ({model_id}). The model is currently loading, please try again in a moment."
164
  for char in response:
165
  yield f"data: {json.dumps({'choices': [{'delta': {'content': char}}]})}\n\n"
166
  time.sleep(0.05)
 
183
  # Tokenize
184
  inputs = tokenizer(text, return_tensors="pt").to(model.device)
185
 
186
+ # Adjust generation parameters for different models
187
+ if model_id == "qwen-4b-thinking":
188
+ max_tokens = min(max_tokens, 1024)
189
+ temperature = min(temperature, 0.8)
190
+
191
  # Setup streaming
192
  streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
193
 
 
219
  yield f"data: {json.dumps({'choices': [{'finish_reason': 'stop'}]})}\n\n"
220
  yield "data: [DONE]\n\n"
221
 
222
+ def generate_plain_text_stream(messages: List[ChatMessage], temperature: float = 0.7, max_tokens: int = 2048, model_id: str = "qwen-coder-3-30b"):
223
  """Plain text streaming generator used by /chat compatibility endpoint (no SSE)."""
224
  try:
225
+ # Load model if not loaded or different model requested
226
+ if model is None or current_model_name != available_models.get(model_id):
227
+ load_model(model_id)
228
+
229
  if model is None or tokenizer is None:
230
  # Fallback streaming: plain text (no SSE)
231
+ response = f"I'm a Qwen AI assistant ({model_id}). The model is currently loading, please try again in a moment."
232
  for ch in response:
233
  yield ch
234
  time.sleep(0.02)
 
247
  # Tokenize
248
  inputs = tokenizer(text, return_tensors="pt").to(model.device)
249
 
250
+ # Adjust parameters for lighter model
251
+ if model_id == "qwen-4b-thinking":
252
+ max_tokens = min(max_tokens, 1024)
253
+ temperature = min(temperature, 0.8)
254
+
255
  # Setup streaming (plain text)
256
  streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
257
  generation_kwargs = {
 
315
  "id": "qwen-coder-3-30b",
316
  "object": "model",
317
  "created": int(time.time()),
318
+ "owned_by": "qwen",
319
+ "name": "Qwen 3 Coder 30B",
320
+ "description": "Výkonný model pro programování"
321
+ },
322
+ {
323
+ "id": "qwen-4b-thinking",
324
+ "object": "model",
325
+ "created": int(time.time()),
326
+ "owned_by": "qwen",
327
+ "name": "Qwen 4B Thinking",
328
+ "description": "Rychlejší odlehčený model"
329
  }
330
  ]
331
  }
 
334
  async def chat_completion(request: ChatRequest):
335
  """OpenAI compatible chat completion endpoint"""
336
  try:
337
+ model_id = request.model or "qwen-coder-3-30b"
338
+
339
+ # Validate model ID
340
+ if model_id not in available_models:
341
+ raise HTTPException(status_code=400, detail=f"Unknown model: {model_id}")
342
+
343
  if request.stream:
344
  return StreamingResponse(
345
  generate_streaming_response(
346
  request.messages,
347
  request.temperature or 0.7,
348
+ request.max_tokens or 2048,
349
+ model_id
350
  ),
351
  media_type="text/plain"
352
  )
 
354
  response_content = generate_response(
355
  request.messages,
356
  request.temperature or 0.7,
357
+ request.max_tokens or 2048,
358
+ model_id
359
  )
360
 
361
  return ChatResponse(
362
  id=f"chatcmpl-{int(time.time())}",
363
  created=int(time.time()),
364
+ model=model_id,
365
  choices=[{
366
  "index": 0,
367
  "message": {
 
376
  logger.error(f"Error in chat completion: {e}")
377
  raise HTTPException(status_code=500, detail=str(e))
378
 
379
+
380
  @app.post("/v1/chat/completions")
381
  async def openai_chat_completion(request: ChatRequest):
382
  """OpenAI API compatible endpoint"""
 
388
  try:
389
  message = str(payload.get("message", "") or "").strip()
390
  history_raw = payload.get("history", []) or []
391
+ model_id = payload.get("model", "qwen-coder-3-30b")
392
+
393
+ # Validate model ID
394
+ if model_id not in available_models:
395
+ model_id = "qwen-coder-3-30b" # fallback
396
 
397
  history_msgs: List[ChatMessage] = []
398
  for item in history_raw:
 
408
  generate_plain_text_stream(
409
  history_msgs,
410
  temperature=0.7,
411
+ max_tokens=2048,
412
+ model_id=model_id
413
  ),
414
  media_type="text/plain; charset=utf-8"
415
  )
 
420
  # Mount static files AFTER API routes
421
  app.mount("/", StaticFiles(directory="public", html=True), name="static")
422
 
423
+ # Startup event
424
  # Startup event
425
  @app.on_event("startup")
426
  async def startup_event():
427
+ """Initialize the default model on startup"""
428
+ # Load default model in background thread to avoid blocking startup
429
+ thread = Thread(target=load_model, args=("qwen-coder-3-30b",))
430
  thread.daemon = True
431
  thread.start()
432
 
public/app.js CHANGED
@@ -79,14 +79,53 @@ class ChatState {
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
 
92
  // Save state to localStorage
@@ -171,8 +210,12 @@ class APIManager {
171
  }
172
  }
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: {
@@ -184,6 +227,7 @@ class APIManager {
184
  role: msg.role,
185
  content: msg.content,
186
  })),
 
187
  }),
188
  });
189
 
@@ -384,15 +428,49 @@ class MessageRenderer {
384
 
385
  // Format message content (handle markdown, code, etc.)
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
@@ -544,95 +622,71 @@ class ChatApp {
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
  }
@@ -737,17 +791,23 @@ class ChatApp {
737
  this.renderMessage(userMessage);
738
  }
739
 
 
 
 
 
 
 
740
  // Show typing indicator
741
  this.renderer.showTyping();
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(
@@ -925,6 +985,10 @@ class ChatApp {
925
  this.renderWelcomeScreen();
926
  this.updateChatHistory();
927
  this.state.saveToStorage();
 
 
 
 
928
  console.log("Created new conversation:", conversation.id);
929
  }
930
 
@@ -1044,6 +1108,9 @@ class ChatApp {
1044
  this.updateChatHistory();
1045
  this.state.saveToStorage();
1046
 
 
 
 
1047
  console.log("Loaded chat session:", sessionId);
1048
  }
1049
 
@@ -1724,19 +1791,43 @@ class AccessibilityManager {
1724
  }: ${message.content}`;
1725
  }
1726
  }
 
 
1727
  }
1728
 
1729
- // Initialize accessibility manager
1730
- const accessibilityManager = new AccessibilityManager();
1731
-
1732
- // Export for debugging and testing
1733
- window.chatDebug = {
1734
- chatApp,
1735
- chatState,
1736
- apiManager,
1737
- messageRenderer,
1738
- performanceMonitor,
1739
- errorReporter,
1740
- accessibilityManager,
1741
- SecurityUtils,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1742
  };
 
79
 
80
  // Generate conversation title from first message
81
  generateTitle(content) {
82
+ // Remove extra whitespace and line breaks
83
+ const cleanContent = content.trim().replace(/\s+/g, ' ');
84
+
85
+ // Generate a more intelligent title
86
+ let title;
87
+
88
+ // Check for common patterns
89
+ if (cleanContent.toLowerCase().includes('napište') || cleanContent.toLowerCase().includes('napiš')) {
90
+ // Extract what user wants to write
91
+ const match = cleanContent.match(/napi[šs]\w*\s+(.+)/i);
92
+ title = match ? `Napsat: ${match[1]}` : cleanContent;
93
+ } else if (cleanContent.toLowerCase().includes('vytvořte') || cleanContent.toLowerCase().includes('vytvoř')) {
94
+ // Extract what user wants to create
95
+ const match = cleanContent.match(/vytvo[řr]\w*\s+(.+)/i);
96
+ title = match ? `Vytvořit: ${match[1]}` : cleanContent;
97
+ } else if (cleanContent.toLowerCase().includes('pomozte') || cleanContent.toLowerCase().includes('pomoc')) {
98
+ // Help requests
99
+ title = `Pomoc: ${cleanContent.replace(/pomozte\s*mi\s*/i, '').replace(/pomoc\s*s\s*/i, '')}`;
100
+ } else if (cleanContent.toLowerCase().includes('vysvětlete') || cleanContent.toLowerCase().includes('vysvětli')) {
101
+ // Explanations
102
+ const match = cleanContent.match(/vysvětl\w*\s+(.+)/i);
103
+ title = match ? `Vysvětlit: ${match[1]}` : cleanContent;
104
+ } else if (cleanContent.toLowerCase().includes('oprav')) {
105
+ // Fixes
106
+ const match = cleanContent.match(/oprav\w*\s+(.+)/i);
107
+ title = match ? `Opravit: ${match[1]}` : cleanContent;
108
+ } else {
109
+ // Default: use first meaningful words
110
+ const words = cleanContent.split(" ");
111
+ const meaningfulWords = words.filter(word =>
112
+ word.length > 2 &&
113
+ !['jak', 'kde', 'kdy', 'proč', 'která', 'který', 'které'].includes(word.toLowerCase())
114
+ );
115
+
116
+ title = meaningfulWords.slice(0, 4).join(" ");
117
+ if (title.length < 10 && words.length > 4) {
118
+ title = words.slice(0, 6).join(" ");
119
+ }
120
+ }
121
 
122
+ // Cleanup and limit length
123
+ title = title.replace(/[^\w\s.,!?-áčďéěíňóřšťúůýž]/gi, '').trim();
124
+ if (title.length > 50) {
125
+ title = title.substring(0, 47) + "...";
126
+ }
127
+
128
+ return title || "New Chat";
129
  }
130
 
131
  // Save state to localStorage
 
210
  }
211
  }
212
 
213
+ // Send chat message using streaming endpoint
214
  async sendMessage(message, history = []) {
215
+ // Get current model from conversation or default
216
+ const conversation = this.state.getCurrentConversation();
217
+ const modelId = conversation?.model || "qwen-coder-3-30b";
218
+
219
  const response = await this.makeRequest("/chat", {
220
  method: "POST",
221
  headers: {
 
227
  role: msg.role,
228
  content: msg.content,
229
  })),
230
+ model: modelId
231
  }),
232
  });
233
 
 
428
 
429
  // Format message content (handle markdown, code, etc.)
430
  formatContent(content) {
431
+ // Enhanced formatting with code block support
432
+ let formatted = content;
433
+
434
+ // First handle code blocks (triple backticks)
435
+ formatted = formatted.replace(
436
+ /```(\w+)?\n?([\s\S]*?)```/g,
437
+ (match, language, code) => {
438
+ const lang = language ? ` data-language="${language}"` : '';
439
+ const escapedCode = code
440
+ .replace(/&/g, '&amp;')
441
+ .replace(/</g, '&lt;')
442
+ .replace(/>/g, '&gt;')
443
+ .replace(/"/g, '&quot;')
444
+ .replace(/'/g, '&#39;');
445
+
446
+ return `<div class="code-block my-4">
447
+ <div class="code-header bg-zinc-100 dark:bg-zinc-800 px-3 py-2 text-xs text-zinc-600 dark:text-zinc-400 border-b border-zinc-200 dark:border-zinc-700 rounded-t-md flex items-center justify-between">
448
+ <span>${language || 'Code'}</span>
449
+ <button class="copy-code-btn text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300" onclick="copyToClipboard(this)" title="Kopírovat kód">
450
+ <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
451
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
452
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
453
+ </svg>
454
+ </button>
455
+ </div>
456
+ <pre class="code-content bg-zinc-50 dark:bg-zinc-900 p-4 rounded-b-md overflow-x-auto text-sm border border-zinc-200 dark:border-zinc-700"${lang}><code>${escapedCode}</code></pre>
457
+ </div>`;
458
+ }
459
+ );
460
+
461
+ // Then handle inline code (single backticks)
462
+ formatted = formatted.replace(
463
+ /`([^`]+)`/g,
464
+ '<code class="bg-zinc-200 dark:bg-zinc-700 px-1.5 py-0.5 rounded text-sm font-mono">$1</code>'
465
+ );
466
+
467
+ // Handle other markdown formatting
468
+ formatted = formatted
469
  .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
470
+ .replace(/\*([^*]+)\*/g, "<em>$1</em>")
471
+ .replace(/\n/g, "<br>");
472
+
473
+ return formatted;
474
  }
475
 
476
  // Format timestamp
 
622
  const modelDropdown = this.elements.modelDropdown;
623
  if (!modelDropdown) return;
624
 
625
+ const modelOptions = modelDropdown.querySelectorAll('.model-option');
626
+ const currentModelName = document.getElementById('current-model-name');
627
+ const responseTimeElement = document.getElementById('model-response-time');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
628
 
629
+ modelOptions.forEach(option => {
630
+ option.addEventListener('click', (e) => {
631
+ e.preventDefault();
632
+ const modelId = option.dataset.modelId;
633
+ const modelName = option.querySelector('.font-medium').textContent;
634
+
635
  // Update UI
636
+ if (currentModelName) {
637
+ currentModelName.textContent = modelName;
638
+ }
639
+
640
+ // Update response time estimate
641
+ if (responseTimeElement) {
642
+ if (modelId === 'qwen-4b-thinking') {
643
+ responseTimeElement.textContent = 'Response time: ~1-3s';
644
+ } else {
645
+ responseTimeElement.textContent = 'Response time: ~2-5s';
646
+ }
647
+ }
648
+
649
  // Update current conversation model
650
  const conversation = this.state.getCurrentConversation();
651
  if (conversation) {
652
+ conversation.model = modelId;
 
 
653
  this.state.saveToStorage();
654
  }
655
+
656
  // Update chat header
657
+ this.updateChatHeader(modelName);
658
+
659
+ // Show model change notification
660
+ this.showModelChangeNotification(modelName);
661
+
662
+ // Close dropdown
663
+ const menu = modelDropdown.querySelector('[data-dd-menu]');
664
+ if (menu) {
665
+ menu.classList.add('hidden');
666
+ }
667
  });
668
  });
669
  }
670
 
671
  // Show model change notification
672
+ // Show model change notification
673
  showModelChangeNotification(modelName) {
674
+ if (this.showNotification) {
675
+ this.showNotification(`Přepnuto na model: ${modelName}`, "success");
676
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
677
  }
678
 
679
+ // Update chat header with current model and conversation title
680
+ updateChatHeader(modelName, conversationTitle = null) {
681
  const chatTitle = document.getElementById("chat-title");
682
  const chatSubtitle = chatTitle?.nextElementSibling;
683
 
684
+ // Update the conversation title if provided
685
+ if (conversationTitle && chatTitle) {
686
+ chatTitle.textContent = conversationTitle;
687
+ }
688
+
689
+ // Update the subtitle with model info
690
  if (chatSubtitle) {
691
  chatSubtitle.textContent = `Using ${modelName} • Ready to help`;
692
  }
 
791
  this.renderMessage(userMessage);
792
  }
793
 
794
+ // Update chat title in header if this was the first user message
795
+ const conversation = this.state.getCurrentConversation();
796
+ if (conversation && conversation.messages.filter(m => m.role === "user").length === 1) {
797
+ this.updateChatHeader(conversation.model || "Qwen 3 Coder (Default)", conversation.title);
798
+ }
799
+
800
  // Show typing indicator
801
  this.renderer.showTyping();
802
 
803
  // Prepare conversation history for API
804
+ const conversationForHistory = this.state.getCurrentConversation();
805
+ const history = conversationForHistory
806
+ ? conversation.messages.map((msg) => ({
807
+ role: msg.role,
808
+ content: msg.content,
809
+ }))
810
+ : [];
811
 
812
  // Use the streaming chat endpoint
813
  const response = await this.api.sendMessage(
 
985
  this.renderWelcomeScreen();
986
  this.updateChatHistory();
987
  this.state.saveToStorage();
988
+
989
+ // Update chat header with default title
990
+ this.updateChatHeader(conversation.model || "Qwen 3 Coder (Default)", "New Chat");
991
+
992
  console.log("Created new conversation:", conversation.id);
993
  }
994
 
 
1108
  this.updateChatHistory();
1109
  this.state.saveToStorage();
1110
 
1111
+ // Update chat header with conversation title
1112
+ this.updateChatHeader(conversation.model || "Qwen 3 Coder (Default)", conversation.title);
1113
+
1114
  console.log("Loaded chat session:", sessionId);
1115
  }
1116
 
 
1791
  }: ${message.content}`;
1792
  }
1793
  }
1794
+
1795
+
1796
  }
1797
 
1798
+ // Global function for copying code to clipboard
1799
+ window.copyToClipboard = function(button) {
1800
+ const codeBlock = button.closest('.code-block');
1801
+ const codeContent = codeBlock.querySelector('code').textContent;
1802
+
1803
+ navigator.clipboard.writeText(codeContent).then(() => {
1804
+ // Změníme ikonu na checkmark
1805
+ button.innerHTML = `
1806
+ <svg class="h-4 w-4 text-green-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1807
+ <path d="M9 12l2 2 4-4"/>
1808
+ <path d="M21 12c-1 0-3-1-3-3s2-3 3-3 3 1 3 3-2 3-3 3"/>
1809
+ <path d="M3 12c1 0 3-1 3-3s-2-3-3-3-3 1-3 3 2 3 3 3"/>
1810
+ </svg>
1811
+ `;
1812
+
1813
+ // Zobrazíme notifikaci
1814
+ if (window.chatApp && window.chatApp.showNotification) {
1815
+ window.chatApp.showNotification("Kód byl zkopírován do schránky", "success");
1816
+ }
1817
+
1818
+ // Vraťme ikonu zpět po 2 sekundách
1819
+ setTimeout(() => {
1820
+ button.innerHTML = `
1821
+ <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1822
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
1823
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
1824
+ </svg>
1825
+ `;
1826
+ }, 2000);
1827
+ }).catch(err => {
1828
+ console.error('Failed to copy code:', err);
1829
+ if (window.chatApp && window.chatApp.showNotification) {
1830
+ window.chatApp.showNotification("Chyba při kopírování kódu", "error");
1831
+ }
1832
+ });
1833
  };
public/index.html CHANGED
@@ -45,24 +45,30 @@
45
  <!-- Model Dropdown -->
46
  <div class="relative" id="model-dd">
47
  <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" data-dd-trigger>
48
- Qwen 3 Coder
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>
@@ -96,7 +102,7 @@
96
  </div>
97
  <div class="h-px bg-zinc-200 dark:bg-zinc-800"></div>
98
  <div class="flex-1 overflow-y-auto p-2 scrollbar-thin">
99
- <!-- chat list -->
100
  <button class="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">
101
  <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"><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>
102
  <div class="min-w-0 flex-1"><div class="truncate text-sm font-medium">Build Tailwind Navbar</div><div class="truncate text-xs text-zinc-500">Today</div></div>
@@ -112,6 +118,7 @@
112
  <div class="min-w-0 flex-1"><div class="truncate text-sm font-medium">Write SQL for analytics</div><div class="truncate text-xs text-zinc-500">Yesterday</div></div>
113
  <svg class="h-4 w-4 opacity-70" 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>
114
  </button>
 
115
  </div>
116
  <div class="h-px bg-zinc-200 dark:bg-zinc-800"></div>
117
  <div class="flex items-center justify-between p-3">
 
45
  <!-- Model Dropdown -->
46
  <div class="relative" id="model-dd">
47
  <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" data-dd-trigger>
48
+ <span id="current-model-name">Qwen 3 Coder 30B</span>
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-64 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">Dostupné modely</div>
53
+ <button class="model-option flex w-full items-center gap-3 rounded-md px-2 py-2 hover:bg-zinc-100 dark:hover:bg-zinc-800" data-model-id="qwen-coder-3-30b">
54
+ <span class="w-2 h-2 bg-green-500 rounded-full flex-shrink-0"></span>
55
+ <div class="flex-1 text-left">
56
+ <div class="font-medium">Qwen 3 Coder 30B</div>
57
+ <div class="text-xs text-zinc-500">Výkonný model pro programování</div>
58
+ </div>
59
  </button>
60
+ <button class="model-option flex w-full items-center gap-3 rounded-md px-2 py-2 hover:bg-zinc-100 dark:hover:bg-zinc-800" data-model-id="qwen-4b-thinking">
61
+ <span class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0"></span>
62
+ <div class="flex-1 text-left">
63
+ <div class="font-medium">Qwen 4B Thinking</div>
64
+ <div class="text-xs text-zinc-500">Rychlejší odlehčený model</div>
65
+ </div>
66
  </button>
67
  <div class="my-1 h-px bg-zinc-200 dark:bg-zinc-800"></div>
68
  <div class="px-2 py-1 text-xs text-zinc-400">
69
  <div class="flex items-center gap-1">
70
  <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>
71
+ <span id="model-response-time">Response time: ~2-5s</span>
72
  </div>
73
  </div>
74
  </div>
 
102
  </div>
103
  <div class="h-px bg-zinc-200 dark:bg-zinc-800"></div>
104
  <div class="flex-1 overflow-y-auto p-2 scrollbar-thin">
105
+ <!-- chat list
106
  <button class="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">
107
  <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"><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>
108
  <div class="min-w-0 flex-1"><div class="truncate text-sm font-medium">Build Tailwind Navbar</div><div class="truncate text-xs text-zinc-500">Today</div></div>
 
118
  <div class="min-w-0 flex-1"><div class="truncate text-sm font-medium">Write SQL for analytics</div><div class="truncate text-xs text-zinc-500">Yesterday</div></div>
119
  <svg class="h-4 w-4 opacity-70" 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>
120
  </button>
121
+ -->
122
  </div>
123
  <div class="h-px bg-zinc-200 dark:bg-zinc-800"></div>
124
  <div class="flex items-center justify-between p-3">
public/styles.css CHANGED
@@ -323,4 +323,32 @@ body {
323
  /* Dark mode toggle improvements */
324
  .dark-mode-transition {
325
  transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
326
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
323
  /* Dark mode toggle improvements */
324
  .dark-mode-transition {
325
  transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
326
+ }
327
+
328
+ .scrollbar-thin{scrollbar-width:thin} .scrollbar-thin::-webkit-scrollbar{height:8px;width:8px} .scrollbar-thin::-webkit-scrollbar-thumb{border-radius:9999px;background-color:#c7c7d0} .scrollbar-thin::-webkit-scrollbar-track{background-color:transparent}
329
+
330
+ /* Code block styles */
331
+ .code-block {
332
+ font-family: 'Fira Code', 'Monaco', 'Cascadia Code', 'Roboto Mono', monospace;
333
+ }
334
+
335
+ .code-content {
336
+ line-height: 1.5;
337
+ font-size: 0.875rem;
338
+ }
339
+
340
+ .code-content code {
341
+ font-family: inherit;
342
+ background: none !important;
343
+ padding: 0 !important;
344
+ border-radius: 0 !important;
345
+ }
346
+
347
+ /* Copy button hover effect */
348
+ .copy-code-btn {
349
+ transition: all 0.2s ease;
350
+ }
351
+
352
+ .copy-code-btn:hover {
353
+ transform: scale(1.1);
354
+ }