chat-console 0.2.6__py3-none-any.whl → 0.2.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
app/__init__.py CHANGED
@@ -3,4 +3,4 @@ Chat CLI
3
3
  A command-line interface for chatting with various LLM providers like ChatGPT and Claude.
4
4
  """
5
5
 
6
- __version__ = "0.2.6"
6
+ __version__ = "0.2.9"
app/api/ollama.py CHANGED
@@ -22,6 +22,9 @@ class OllamaClient(BaseModelClient):
22
22
  # Track active stream session
23
23
  self._active_stream_session = None
24
24
 
25
+ # Track model loading state
26
+ self._model_loading = False
27
+
25
28
  # Path to the cached models file
26
29
  self.models_cache_path = Path(__file__).parent.parent / "data" / "ollama-models.json"
27
30
 
@@ -191,6 +194,10 @@ class OllamaClient(BaseModelClient):
191
194
  raise aiohttp.ClientError("Model not ready")
192
195
  except (aiohttp.ClientError, asyncio.TimeoutError) as e:
193
196
  logger.info(f"Model cold start detected: {str(e)}")
197
+ # Set model loading flag
198
+ self._model_loading = True
199
+ logger.info("Setting model_loading state to True")
200
+
194
201
  # Model might need loading, try pulling it
195
202
  async with session.post(
196
203
  f"{self.base_url}/api/pull",
@@ -199,8 +206,10 @@ class OllamaClient(BaseModelClient):
199
206
  ) as pull_response:
200
207
  if pull_response.status != 200:
201
208
  logger.error("Failed to pull model")
209
+ self._model_loading = False # Reset flag on failure
202
210
  raise Exception("Failed to pull model")
203
211
  logger.info("Model pulled successfully")
212
+ self._model_loading = False # Reset flag after successful pull
204
213
 
205
214
  # Now proceed with actual generation
206
215
  session = aiohttp.ClientSession()
@@ -208,7 +217,7 @@ class OllamaClient(BaseModelClient):
208
217
 
209
218
  try:
210
219
  logger.debug(f"Sending streaming request to {self.base_url}/api/generate")
211
- async with session.post(
220
+ response = await session.post(
212
221
  f"{self.base_url}/api/generate",
213
222
  json={
214
223
  "model": model,
@@ -217,19 +226,36 @@ class OllamaClient(BaseModelClient):
217
226
  "stream": True
218
227
  },
219
228
  timeout=60 # Longer timeout for actual generation
220
- ) as response:
221
- response.raise_for_status()
222
- async for line in response.content:
223
- if line:
224
- chunk = line.decode().strip()
225
- try:
226
- data = json.loads(chunk)
227
- if "response" in data:
228
- yield data["response"]
229
- except json.JSONDecodeError:
230
- continue
231
- logger.info("Streaming completed successfully")
232
- return
229
+ )
230
+ response.raise_for_status()
231
+
232
+ # Process the response stream
233
+ while True:
234
+ if not self._active_stream_session:
235
+ logger.info("Stream session was closed externally")
236
+ break
237
+
238
+ try:
239
+ line = await asyncio.wait_for(response.content.readline(), timeout=0.5)
240
+ if not line: # End of stream
241
+ break
242
+
243
+ chunk = line.decode().strip()
244
+ try:
245
+ data = json.loads(chunk)
246
+ if "response" in data:
247
+ yield data["response"]
248
+ except json.JSONDecodeError:
249
+ continue
250
+ except asyncio.TimeoutError:
251
+ # This allows checking for cancellation regularly
252
+ continue
253
+ except asyncio.CancelledError:
254
+ logger.info("Stream processing was cancelled")
255
+ raise
256
+
257
+ logger.info("Streaming completed successfully")
258
+ return
233
259
  finally:
234
260
  self._active_stream_session = None # Clear reference when done
235
261
  await session.close() # Ensure session is closed
@@ -260,6 +286,12 @@ class OllamaClient(BaseModelClient):
260
286
  logger.info("Cancelling active stream session")
261
287
  await self._active_stream_session.close()
262
288
  self._active_stream_session = None
289
+ self._model_loading = False
290
+ logger.info("Stream session closed successfully")
291
+
292
+ def is_loading_model(self) -> bool:
293
+ """Check if Ollama is currently loading a model"""
294
+ return self._model_loading
263
295
 
264
296
  async def get_model_details(self, model_id: str) -> Dict[str, Any]:
265
297
  """Get detailed information about a specific Ollama model"""
app/main.py CHANGED
@@ -302,7 +302,7 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
302
302
  Binding("q", "quit", "Quit", show=True, key_display="q"),
303
303
  # Removed binding for "n" (new chat) since there's a dedicated button
304
304
  Binding("c", "action_new_conversation", "New Chat", show=False, key_display="c", priority=True), # Keep alias with priority
305
- Binding("escape", "escape", "Cancel / Stop", show=True, key_display="esc"), # Escape might close settings panel too
305
+ Binding("escape", "action_escape", "Cancel / Stop", show=True, key_display="esc"), # Updated to call our async method
306
306
  Binding("ctrl+c", "quit", "Quit", show=False),
307
307
  Binding("h", "view_history", "History", show=True, key_display="h", priority=True), # Add priority
308
308
  Binding("s", "settings", "Settings", show=True, key_display="s", priority=True), # Add priority
@@ -463,7 +463,7 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
463
463
  await self.create_new_conversation() # Keep SimpleChatApp action_new_conversation
464
464
  log("action_new_conversation finished") # Added log
465
465
 
466
- def action_escape(self) -> None:
466
+ async def action_escape(self) -> None:
467
467
  """Handle escape key globally."""
468
468
  log("action_escape triggered")
469
469
  settings_panel = self.query_one("#settings-panel")
@@ -477,18 +477,45 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
477
477
  log("Attempting to cancel generation task")
478
478
  if self.current_generation_task and not self.current_generation_task.done():
479
479
  log("Cancelling active generation task.")
480
- self.current_generation_task.cancel()
481
- # The finally block in generate_response will handle is_generating = False and UI updates
482
- self.notify("Stopping generation...", severity="warning", timeout=2) # Notify user immediately
480
+
481
+ # Get the client for the current model first and cancel the connection
482
+ try:
483
+ model = self.selected_model
484
+ client = BaseModelClient.get_client_for_model(model)
485
+
486
+ # Call the client's cancel method if it's supported
487
+ if hasattr(client, 'cancel_stream'):
488
+ log("Calling client.cancel_stream() to terminate API session")
489
+ try:
490
+ # This will close the HTTP connection to Ollama server
491
+ await client.cancel_stream()
492
+ log("Client stream cancelled successfully")
493
+ except Exception as e:
494
+ log.error(f"Error in client.cancel_stream(): {str(e)}")
495
+ except Exception as e:
496
+ log.error(f"Error setting up client cancellation: {str(e)}")
497
+
498
+ # Now cancel the asyncio task - this should raise CancelledError in the task
499
+ try:
500
+ log("Cancelling asyncio task")
501
+ self.current_generation_task.cancel()
502
+ # Give a moment for cancellation to propagate
503
+ await asyncio.sleep(0.1)
504
+ log(f"Task cancelled. Task done: {self.current_generation_task.done()}")
505
+ except Exception as e:
506
+ log.error(f"Error cancelling task: {str(e)}")
507
+
508
+ # Notify user that we're stopping
509
+ self.notify("Stopping generation...", severity="warning", timeout=2)
483
510
  else:
484
- # This case might happen if is_generating is True, but no active task found to cancel. Resetting flag.")
485
- self.is_generating = False # Reset flag manually if task is missing
511
+ # This happens if is_generating is True, but no active task found to cancel
512
+ log("No active generation task found, but is_generating=True. Resetting state.")
513
+ self.is_generating = False
486
514
  loading = self.query_one("#loading-indicator")
487
515
  loading.add_class("hidden")
488
516
  else:
489
517
  log("Escape pressed, but settings not visible and not actively generating.")
490
- # Optionally add other escape behaviors here if needed for the main screen
491
- # e.g., clear input, deselect item, etc.
518
+ # Optionally add other escape behaviors here if needed
492
519
 
493
520
  def update_app_info(self) -> None:
494
521
  """Update the displayed app information."""
@@ -608,166 +635,143 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
608
635
  # Focus back on input
609
636
  input_widget.focus()
610
637
 
611
- async def generate_response(self) -> None: # Keep SimpleChatApp generate_response
612
- """Generate an AI response.""" # Keep SimpleChatApp generate_response docstring
613
- if not self.current_conversation or not self.messages: # Keep SimpleChatApp generate_response
614
- return # Keep SimpleChatApp generate_response
615
-
616
- self.is_generating = True # Keep SimpleChatApp generate_response
617
- log(f"Setting is_generating to True") # Added log
618
- loading = self.query_one("#loading-indicator") # Keep SimpleChatApp generate_response
619
- loading.remove_class("hidden") # Keep SimpleChatApp generate_response
620
-
621
- try: # Keep SimpleChatApp generate_response
622
- # Get conversation parameters # Keep SimpleChatApp generate_response
623
- model = self.selected_model # Keep SimpleChatApp generate_response
624
- style = self.selected_style # Keep SimpleChatApp generate_response
625
-
626
- # Convert messages to API format # Keep SimpleChatApp generate_response
627
- api_messages = [] # Keep SimpleChatApp generate_response
628
- for msg in self.messages: # Keep SimpleChatApp generate_response
629
- api_messages.append({ # Keep SimpleChatApp generate_response
630
- "role": msg.role, # Keep SimpleChatApp generate_response
631
- "content": msg.content # Keep SimpleChatApp generate_response
632
- }) # Keep SimpleChatApp generate_response
633
-
634
- # Get appropriate client # Keep SimpleChatApp generate_response
635
- try: # Keep SimpleChatApp generate_response
636
- client = BaseModelClient.get_client_for_model(model) # Keep SimpleChatApp generate_response
637
- if client is None: # Keep SimpleChatApp generate_response
638
- raise Exception(f"No client available for model: {model}") # Keep SimpleChatApp generate_response
639
- except Exception as e: # Keep SimpleChatApp generate_response
640
- self.notify(f"Failed to initialize model client: {str(e)}", severity="error") # Keep SimpleChatApp generate_response
641
- return # Keep SimpleChatApp generate_response
642
-
643
- # Start streaming response # Keep SimpleChatApp generate_response
644
- assistant_message = Message(role="assistant", content="Thinking...") # Keep SimpleChatApp generate_response
645
- self.messages.append(assistant_message) # Keep SimpleChatApp generate_response
646
- messages_container = self.query_one("#messages-container") # Keep SimpleChatApp generate_response
647
- message_display = MessageDisplay(assistant_message, highlight_code=CONFIG["highlight_code"]) # Keep SimpleChatApp generate_response
648
- messages_container.mount(message_display) # Keep SimpleChatApp generate_response
649
- messages_container.scroll_end(animate=False) # Keep SimpleChatApp generate_response
650
-
651
- # Add small delay to show thinking state # Keep SimpleChatApp generate_response
652
- await asyncio.sleep(0.5) # Keep SimpleChatApp generate_response
653
-
654
- # Stream chunks to the UI with synchronization # Keep SimpleChatApp generate_response
655
- update_lock = asyncio.Lock() # Keep SimpleChatApp generate_response
656
-
657
- async def update_ui(content: str): # Keep SimpleChatApp generate_response
658
- if not self.is_generating: # Keep SimpleChatApp generate_response
659
- log("update_ui called but is_generating is False, returning.") # Added log
660
- return # Keep SimpleChatApp generate_response
661
-
662
- async with update_lock: # Keep SimpleChatApp generate_response
663
- try: # Keep SimpleChatApp generate_response
664
- # Clear thinking indicator on first content # Keep SimpleChatApp generate_response
665
- if assistant_message.content == "Thinking...": # Keep SimpleChatApp generate_response
666
- assistant_message.content = "" # Keep SimpleChatApp generate_response
667
-
668
- # Update message with full content so far # Keep SimpleChatApp generate_response
669
- assistant_message.content = content # Keep SimpleChatApp generate_response
670
- # Update UI with full content # Keep SimpleChatApp generate_response
671
- await message_display.update_content(content) # Keep SimpleChatApp generate_response
672
- # Force a refresh and scroll # Keep SimpleChatApp generate_response
673
- self.refresh(layout=True) # Keep SimpleChatApp generate_response
674
- await asyncio.sleep(0.05) # Longer delay for UI stability # Keep SimpleChatApp generate_response
675
- messages_container.scroll_end(animate=False) # Keep SimpleChatApp generate_response
676
- # Force another refresh to ensure content is visible # Keep SimpleChatApp generate_response
677
- self.refresh(layout=True) # Keep SimpleChatApp generate_response
678
- except Exception as e: # Keep SimpleChatApp generate_response
679
- log.error(f"Error updating UI: {str(e)}") # Use log instead of logger
680
-
681
- # Generate the response with timeout and cleanup # Keep SimpleChatApp generate_response
682
- self.current_generation_task = None # Clear previous task reference
683
- try: # Keep SimpleChatApp generate_response
684
- # Create a task for the response generation # Keep SimpleChatApp generate_response
685
- self.current_generation_task = asyncio.create_task( # Keep SimpleChatApp generate_response
686
- generate_streaming_response( # Keep SimpleChatApp generate_response
687
- self, # Pass the app instance
688
- api_messages, # Keep SimpleChatApp generate_response
689
- model, # Keep SimpleChatApp generate_response
690
- style, # Keep SimpleChatApp generate_response
691
- client, # Keep SimpleChatApp generate_response
692
- update_ui # Keep SimpleChatApp generate_response
693
- ) # Keep SimpleChatApp generate_response
694
- ) # Keep SimpleChatApp generate_response
695
-
696
- # Wait for response with timeout # Keep SimpleChatApp generate_response
697
- log.info(f"Waiting for generation task {self.current_generation_task} with timeout...") # Add log
698
- full_response = await asyncio.wait_for(self.current_generation_task, timeout=60) # Longer timeout # Keep SimpleChatApp generate_response
699
- log.info(f"Generation task {self.current_generation_task} completed. Full response length: {len(full_response) if full_response else 0}") # Add log
700
-
701
- # Save to database only if we got a complete response and weren't cancelled
702
- if self.is_generating and full_response: # Check is_generating flag here
703
- log("Generation finished normally, saving full response to DB") # Added log
704
- self.db.add_message( # Keep SimpleChatApp generate_response
705
- self.current_conversation.id, # Keep SimpleChatApp generate_response
706
- "assistant", # Keep SimpleChatApp generate_response
707
- full_response # Keep SimpleChatApp generate_response
708
- ) # Keep SimpleChatApp generate_response
709
- # Force a final refresh # Keep SimpleChatApp generate_response
710
- self.refresh(layout=True) # Keep SimpleChatApp generate_response
711
- await asyncio.sleep(0.1) # Wait for UI to update # Keep SimpleChatApp generate_response
712
- elif not full_response and self.is_generating: # Only log if not cancelled
713
- log("Generation finished but full_response is empty/None") # Added log
714
- else:
715
- # This case handles cancellation where full_response might be partial or None
716
- log("Generation was cancelled or finished without a full response.")
717
-
718
- except asyncio.CancelledError: # Handle cancellation explicitly
719
- log.warning("Generation task was cancelled.")
720
- self.notify("Generation stopped by user.", severity="warning")
721
- # Remove the potentially incomplete message from UI state
722
- if self.messages and self.messages[-1].role == "assistant":
723
- self.messages.pop()
724
- await self.update_messages_ui() # Update UI to remove partial message
725
-
726
- except asyncio.TimeoutError: # Keep SimpleChatApp generate_response
727
- log.error(f"Response generation timed out waiting for task {self.current_generation_task}") # Use log instead of logger
728
- # Log state at timeout
729
- log.error(f"Timeout state: is_generating={self.is_generating}, task_done={self.current_generation_task.done() if self.current_generation_task else 'N/A'}")
730
- error_msg = "Response generation timed out. The model may be busy or unresponsive. Please try again." # Keep SimpleChatApp generate_response
731
- self.notify(error_msg, severity="error") # Keep SimpleChatApp generate_response
732
-
733
- # Remove the incomplete message # Keep SimpleChatApp generate_response
734
- if self.messages and self.messages[-1].role == "assistant": # Keep SimpleChatApp generate_response
735
- self.messages.pop() # Keep SimpleChatApp generate_response
736
-
737
- # Update UI to remove the incomplete message # Keep SimpleChatApp generate_response
738
- await self.update_messages_ui() # Keep SimpleChatApp generate_response
739
-
740
- finally: # Keep SimpleChatApp generate_response
741
- # Ensure flag is reset and task reference is cleared
742
- log(f"Setting is_generating to False in finally block") # Added log
743
- self.is_generating = False # Keep SimpleChatApp generate_response
744
- self.current_generation_task = None # Clear task reference
745
- loading = self.query_one("#loading-indicator") # Keep SimpleChatApp generate_response
746
- loading.add_class("hidden") # Keep SimpleChatApp generate_response
747
- # Force a final UI refresh # Keep SimpleChatApp generate_response
748
- self.refresh(layout=True) # Keep SimpleChatApp generate_response
749
-
750
- except Exception as e: # Keep SimpleChatApp generate_response
751
- # Catch any other unexpected errors during generation setup/handling
752
- log.error(f"Unexpected exception during generate_response: {str(e)}") # Added log
753
- self.notify(f"Error generating response: {str(e)}", severity="error") # Keep SimpleChatApp generate_response
754
- # Add error message to UI # Keep SimpleChatApp generate_response
755
- error_msg = f"Error: {str(e)}" # Keep SimpleChatApp generate_response
756
- self.messages.append(Message(role="assistant", content=error_msg)) # Keep SimpleChatApp generate_response
757
- await self.update_messages_ui() # Keep SimpleChatApp generate_response
758
- # The finally block below will handle resetting is_generating and hiding loading
759
-
760
- finally: # Keep SimpleChatApp generate_response - This finally block now primarily handles cleanup
761
- log(f"Ensuring is_generating is False and task is cleared in outer finally block") # Added log
762
- self.is_generating = False # Ensure flag is always reset
763
- self.current_generation_task = None # Ensure task ref is cleared
764
- loading = self.query_one("#loading-indicator") # Keep SimpleChatApp generate_response
765
- loading.add_class("hidden") # Ensure loading indicator is hidden
766
- # Re-focus input after generation attempt (success, failure, or cancel)
638
+ async def generate_response(self) -> None:
639
+ """Generate an AI response using a non-blocking worker."""
640
+ if not self.current_conversation or not self.messages:
641
+ return
642
+
643
+ self.is_generating = True
644
+ log("Setting is_generating to True")
645
+ loading = self.query_one("#loading-indicator")
646
+ loading.remove_class("hidden")
647
+
648
+ try:
649
+ # Get conversation parameters
650
+ model = self.selected_model
651
+ style = self.selected_style
652
+
653
+ # Convert messages to API format
654
+ api_messages = []
655
+ for msg in self.messages:
656
+ api_messages.append({
657
+ "role": msg.role,
658
+ "content": msg.content
659
+ })
660
+
661
+ # Get appropriate client
767
662
  try:
768
- self.query_one("#message-input").focus()
769
- except Exception:
770
- pass # Ignore if input not found
663
+ client = BaseModelClient.get_client_for_model(model)
664
+ if client is None:
665
+ raise Exception(f"No client available for model: {model}")
666
+ except Exception as e:
667
+ self.notify(f"Failed to initialize model client: {str(e)}", severity="error")
668
+ self.is_generating = False
669
+ loading.add_class("hidden")
670
+ return
671
+
672
+ # Start streaming response
673
+ assistant_message = Message(role="assistant", content="Thinking...")
674
+ self.messages.append(assistant_message)
675
+ messages_container = self.query_one("#messages-container")
676
+ message_display = MessageDisplay(assistant_message, highlight_code=CONFIG["highlight_code"])
677
+ messages_container.mount(message_display)
678
+ messages_container.scroll_end(animate=False)
679
+
680
+ # Add small delay to show thinking state
681
+ await asyncio.sleep(0.5)
682
+
683
+ # Stream chunks to the UI with synchronization
684
+ update_lock = asyncio.Lock()
685
+
686
+ async def update_ui(content: str):
687
+ if not self.is_generating:
688
+ log("update_ui called but is_generating is False, returning.")
689
+ return
690
+
691
+ async with update_lock:
692
+ try:
693
+ # Clear thinking indicator on first content
694
+ if assistant_message.content == "Thinking...":
695
+ assistant_message.content = ""
696
+
697
+ # Update message with full content so far
698
+ assistant_message.content = content
699
+ # Update UI with full content
700
+ await message_display.update_content(content)
701
+ # Force a refresh and scroll
702
+ self.refresh(layout=True)
703
+ await asyncio.sleep(0.05) # Longer delay for UI stability
704
+ messages_container.scroll_end(animate=False)
705
+ # Force another refresh to ensure content is visible
706
+ self.refresh(layout=True)
707
+ except Exception as e:
708
+ log.error(f"Error updating UI: {str(e)}")
709
+
710
+ # Define worker for background processing
711
+ @work(exit_on_error=True)
712
+ async def run_generation_worker():
713
+ try:
714
+ # Generate the response in background
715
+ full_response = await generate_streaming_response(
716
+ self,
717
+ api_messages,
718
+ model,
719
+ style,
720
+ client,
721
+ update_ui
722
+ )
723
+
724
+ # Save complete response to database
725
+ if self.is_generating and full_response:
726
+ log("Generation completed normally, saving to database")
727
+ self.db.add_message(
728
+ self.current_conversation.id,
729
+ "assistant",
730
+ full_response
731
+ )
732
+
733
+ # Final UI refresh
734
+ self.refresh(layout=True)
735
+
736
+ except asyncio.CancelledError:
737
+ log.warning("Generation worker was cancelled")
738
+ # Remove the incomplete message
739
+ if self.messages and self.messages[-1].role == "assistant":
740
+ self.messages.pop()
741
+ await self.update_messages_ui()
742
+ self.notify("Generation stopped by user", severity="warning", timeout=2)
743
+
744
+ except Exception as e:
745
+ log.error(f"Error in generation worker: {str(e)}")
746
+ self.notify(f"Generation error: {str(e)}", severity="error", timeout=5)
747
+ # Add error message to UI
748
+ if self.messages and self.messages[-1].role == "assistant":
749
+ self.messages.pop() # Remove thinking message
750
+ error_msg = f"Error: {str(e)}"
751
+ self.messages.append(Message(role="assistant", content=error_msg))
752
+ await self.update_messages_ui()
753
+
754
+ finally:
755
+ # Always clean up state and UI
756
+ log("Generation worker completed, resetting state")
757
+ self.is_generating = False
758
+ self.current_generation_task = None
759
+ loading = self.query_one("#loading-indicator")
760
+ loading.add_class("hidden")
761
+ self.refresh(layout=True)
762
+ self.query_one("#message-input").focus()
763
+
764
+ # Start the worker and keep a reference to it
765
+ worker = run_generation_worker()
766
+ self.current_generation_task = worker
767
+
768
+ except Exception as e:
769
+ log.error(f"Error setting up generation: {str(e)}")
770
+ self.notify(f"Error: {str(e)}", severity="error")
771
+ self.is_generating = False
772
+ loading = self.query_one("#loading-indicator")
773
+ loading.add_class("hidden")
774
+ self.query_one("#message-input").focus()
771
775
 
772
776
  def on_model_selector_model_selected(self, event: ModelSelector.ModelSelected) -> None: # Keep SimpleChatApp on_model_selector_model_selected
773
777
  """Handle model selection""" # Keep SimpleChatApp on_model_selector_model_selected docstring
app/ui/chat_interface.py CHANGED
@@ -204,6 +204,11 @@ class ChatInterface(Container):
204
204
  display: none;
205
205
  padding: 0 1;
206
206
  }
207
+
208
+ #loading-indicator.model-loading {
209
+ background: $warning;
210
+ color: $text;
211
+ }
207
212
  """
208
213
 
209
214
  class MessageSent(Message):
@@ -238,7 +243,7 @@ class ChatInterface(Container):
238
243
  yield MessageDisplay(message, highlight_code=CONFIG["highlight_code"])
239
244
  with Container(id="input-area"):
240
245
  yield Container(
241
- Label("Generating response...", id="loading-text"),
246
+ Label("▪▪▪ Generating response...", id="loading-text", markup=True),
242
247
  id="loading-indicator"
243
248
  )
244
249
  with Container(id="controls"):
@@ -328,16 +333,30 @@ class ChatInterface(Container):
328
333
  if input_widget.has_focus:
329
334
  input_widget.focus()
330
335
 
331
- def start_loading(self) -> None:
332
- """Show loading indicator"""
336
+ def start_loading(self, model_loading: bool = False) -> None:
337
+ """Show loading indicator
338
+
339
+ Args:
340
+ model_loading: If True, indicates Ollama is loading a model
341
+ """
333
342
  self.is_loading = True
334
343
  loading = self.query_one("#loading-indicator")
344
+ loading_text = self.query_one("#loading-text")
345
+
346
+ if model_loading:
347
+ loading.add_class("model-loading")
348
+ loading_text.update("⚙️ Loading Ollama model...")
349
+ else:
350
+ loading.remove_class("model-loading")
351
+ loading_text.update("▪▪▪ Generating response...")
352
+
335
353
  loading.display = True
336
354
 
337
355
  def stop_loading(self) -> None:
338
356
  """Hide loading indicator"""
339
357
  self.is_loading = False
340
358
  loading = self.query_one("#loading-indicator")
359
+ loading.remove_class("model-loading")
341
360
  loading.display = False
342
361
 
343
362
  def clear_messages(self) -> None:
app/utils.py CHANGED
@@ -86,12 +86,71 @@ async def generate_streaming_response(app: 'SimpleChatApp', messages: List[Dict]
86
86
  buffer = []
87
87
  last_update = time.time()
88
88
  update_interval = 0.1 # Update UI every 100ms
89
- generation_task = None
90
89
 
91
90
  try:
92
- # The cancellation is now handled by cancelling the asyncio Task in main.py
93
- # which will raise CancelledError here, interrupting the loop.
94
- async for chunk in client.generate_stream(messages, model, style):
91
+ # Update UI with model loading state if it's an Ollama client
92
+ if hasattr(client, 'is_loading_model'):
93
+ # Send signal to update UI for model loading if needed
94
+ try:
95
+ # The client might be in model loading state even before generating
96
+ model_loading = client.is_loading_model()
97
+ logger.info(f"Initial model loading state: {model_loading}")
98
+
99
+ # Get the chat interface and update loading indicator
100
+ if hasattr(app, 'query_one'):
101
+ loading = app.query_one("#loading-indicator")
102
+ if model_loading:
103
+ loading.add_class("model-loading")
104
+ app.query_one("#loading-text").update("Loading Ollama model...")
105
+ else:
106
+ loading.remove_class("model-loading")
107
+ except Exception as e:
108
+ logger.error(f"Error setting initial loading state: {str(e)}")
109
+
110
+ stream_generator = client.generate_stream(messages, model, style)
111
+
112
+ # Check if we just entered model loading state
113
+ if hasattr(client, 'is_loading_model') and client.is_loading_model():
114
+ logger.info("Model loading started during generation")
115
+ try:
116
+ if hasattr(app, 'query_one'):
117
+ loading = app.query_one("#loading-indicator")
118
+ loading.add_class("model-loading")
119
+ app.query_one("#loading-text").update("Loading Ollama model...")
120
+ except Exception as e:
121
+ logger.error(f"Error updating UI for model loading: {str(e)}")
122
+
123
+ # Use asyncio.shield to ensure we can properly interrupt the stream processing
124
+ async for chunk in stream_generator:
125
+ # Check for cancellation frequently
126
+ if asyncio.current_task().cancelled():
127
+ logger.info("Task cancellation detected during chunk processing")
128
+ # Close the client stream if possible
129
+ if hasattr(client, 'cancel_stream'):
130
+ await client.cancel_stream()
131
+ raise asyncio.CancelledError()
132
+
133
+ # Check if model loading state changed
134
+ if hasattr(client, 'is_loading_model'):
135
+ model_loading = client.is_loading_model()
136
+ try:
137
+ if hasattr(app, 'query_one'):
138
+ loading = app.query_one("#loading-indicator")
139
+ loading_text = app.query_one("#loading-text")
140
+
141
+ if model_loading and not loading.has_class("model-loading"):
142
+ # Model loading started
143
+ logger.info("Model loading started during streaming")
144
+ loading.add_class("model-loading")
145
+ loading_text.update("⚙️ Loading Ollama model...")
146
+ elif not model_loading and loading.has_class("model-loading"):
147
+ # Model loading finished
148
+ logger.info("Model loading finished during streaming")
149
+ loading.remove_class("model-loading")
150
+ loading_text.update("▪▪▪ Generating response...")
151
+ except Exception as e:
152
+ logger.error(f"Error updating loading state during streaming: {str(e)}")
153
+
95
154
  if chunk: # Only process non-empty chunks
96
155
  buffer.append(chunk)
97
156
  current_time = time.time()
@@ -100,7 +159,7 @@ async def generate_streaming_response(app: 'SimpleChatApp', messages: List[Dict]
100
159
  if current_time - last_update >= update_interval or len(''.join(buffer)) > 100:
101
160
  new_content = ''.join(buffer)
102
161
  full_response += new_content
103
- # No need to check app.is_generating here, rely on CancelledError
162
+ # Send content to UI
104
163
  await callback(full_response)
105
164
  buffer = []
106
165
  last_update = current_time
@@ -114,23 +173,25 @@ async def generate_streaming_response(app: 'SimpleChatApp', messages: List[Dict]
114
173
  full_response += new_content
115
174
  await callback(full_response)
116
175
 
117
- logger.info("Streaming response loop finished normally.") # Clarify log message
118
- # Add log before returning
119
- logger.info(f"generate_streaming_response returning normally. Full response length: {len(full_response)}")
176
+ logger.info(f"Streaming response completed successfully. Response length: {len(full_response)}")
120
177
  return full_response
178
+
121
179
  except asyncio.CancelledError:
122
180
  # This is expected when the user cancels via Escape
123
- logger.info("Streaming response task cancelled.") # Clarify log message
124
- # Add log before returning
125
- logger.info(f"generate_streaming_response returning after cancellation. Partial response length: {len(full_response)}")
126
- # Do not re-raise CancelledError, let the caller handle it
127
- return full_response # Return whatever was collected so far (might be partial)
181
+ logger.info(f"Streaming response task cancelled. Partial response length: {len(full_response)}")
182
+ # Ensure the client stream is closed
183
+ if hasattr(client, 'cancel_stream'):
184
+ await client.cancel_stream()
185
+ # Return whatever was collected so far
186
+ return full_response
187
+
128
188
  except Exception as e:
129
- logger.error(f"Error during streaming response: {str(e)}") # Clarify log message
130
- # Ensure the app knows generation stopped on error ONLY if it wasn't cancelled
131
- if not isinstance(e, asyncio.CancelledError):
132
- app.is_generating = False # Reset flag on other errors
133
- raise # Re-raise other exceptions
189
+ logger.error(f"Error during streaming response: {str(e)}")
190
+ # Close the client stream if possible
191
+ if hasattr(client, 'cancel_stream'):
192
+ await client.cancel_stream()
193
+ # Re-raise the exception for the caller to handle
194
+ raise
134
195
 
135
196
  def ensure_ollama_running() -> bool:
136
197
  """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chat-console
3
- Version: 0.2.6
3
+ Version: 0.2.9
4
4
  Summary: A command-line interface for chatting with LLMs, storing chats and (future) rag interactions
5
5
  Home-page: https://github.com/wazacraftrfid/chat-console
6
6
  Author: Johnathan Greenaway
@@ -1,24 +1,24 @@
1
- app/__init__.py,sha256=RrJwTK4_61saurFka_5PJFu9CCSfTuI8Y7POMR59MOk,130
1
+ app/__init__.py,sha256=g2BzewDN5X96Dl5Zzw8uag1TBEdPIU1ceTm7u-BJrjM,130
2
2
  app/config.py,sha256=sKNp6Za4ZfW-CZBOvEv0TncAS77AnKi86hTM51C4KQ4,5227
3
3
  app/database.py,sha256=nt8CVuDpy6zw8mOYqDcfUmNw611t7Ln7pz22M0b6-MI,9967
4
- app/main.py,sha256=uuh8z__950Rs-U0X5NmgRAupcSZFREyOazsDbK1eFYM,53365
4
+ app/main.py,sha256=k726xRBcuPgbUsUg4s-REhtaljccjDLNzA_C-fPkQk4,48866
5
5
  app/models.py,sha256=4-y9Lytay2exWPFi0FDlVeRL3K2-I7E-jBqNzTfokqY,2644
6
- app/utils.py,sha256=6XSIJBcJPOXPIHnvKvRwnttdRnN9BSlodcKVj57RLeM,8861
6
+ app/utils.py,sha256=IyINMrM6oGXtN5HRPuKoFEyfKg0fR4FVXIi_0e2KxI0,11798
7
7
  app/api/__init__.py,sha256=A8UL84ldYlv8l7O-yKzraVFcfww86SgWfpl4p7R03-w,62
8
8
  app/api/anthropic.py,sha256=x5PmBXEKe_ow2NWk8XdqSPR0hLOdCc_ypY5QAySeA78,4234
9
9
  app/api/base.py,sha256=-6RSxSpqe-OMwkaq1wVWbu3pVkte-ZYy8rmdvt-Qh48,3953
10
- app/api/ollama.py,sha256=NgfETreb7EdFIux9fvkDfIBj77wcJvic77ObUV95TlI,49866
10
+ app/api/ollama.py,sha256=FTIlgZmvpZd6K4HL2nUD19-p9Xb1TA859LfnCgewpcU,51354
11
11
  app/api/openai.py,sha256=1fYgFXXL6yj_7lQ893Yj28RYG4M8d6gt_q1gzhhjcig,3641
12
12
  app/ui/__init__.py,sha256=RndfbQ1Tv47qdSiuQzvWP96lPS547SDaGE-BgOtiP_w,55
13
- app/ui/chat_interface.py,sha256=VwmVvltxS9l18DI9U7kL43t8kSPPNsrkkrrUSoGu16Q,13623
13
+ app/ui/chat_interface.py,sha256=R8tdy72TcT7veemUzcJOjbPY32WizBdNHgfmq69EFfA,14275
14
14
  app/ui/chat_list.py,sha256=WQTYVNSSXlx_gQal3YqILZZKL9UiTjmNMIDX2I9pAMM,11205
15
15
  app/ui/model_browser.py,sha256=5h3gVsuGIUrXjYVF-QclZFhYtX2kH14LvT22Ufm9etg,49453
16
16
  app/ui/model_selector.py,sha256=Aj1irAs9DQMn8wfcPsFZGxWmx0JTzHjSe7pVdDMwqTQ,13182
17
17
  app/ui/search.py,sha256=b-m14kG3ovqW1-i0qDQ8KnAqFJbi5b1FLM9dOnbTyIs,9763
18
18
  app/ui/styles.py,sha256=04AhPuLrOd2yenfRySFRestPeuTPeMLzhmMB67NdGvw,5615
19
- chat_console-0.2.6.dist-info/licenses/LICENSE,sha256=srHZ3fvcAuZY1LHxE7P6XWju2njRCHyK6h_ftEbzxSE,1057
20
- chat_console-0.2.6.dist-info/METADATA,sha256=xgVw6VxjSqNqA8hdPzltWYtax_Hmui9jbSq1GQBxU7w,2921
21
- chat_console-0.2.6.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
22
- chat_console-0.2.6.dist-info/entry_points.txt,sha256=kkVdEc22U9PAi2AeruoKklfkng_a_aHAP6VRVwrAD7c,67
23
- chat_console-0.2.6.dist-info/top_level.txt,sha256=io9g7LCbfmTG1SFKgEOGXmCFB9uMP2H5lerm0HiHWQE,4
24
- chat_console-0.2.6.dist-info/RECORD,,
19
+ chat_console-0.2.9.dist-info/licenses/LICENSE,sha256=srHZ3fvcAuZY1LHxE7P6XWju2njRCHyK6h_ftEbzxSE,1057
20
+ chat_console-0.2.9.dist-info/METADATA,sha256=zTSJePqMsi0n6fEz8s4gtLwHe_726-ijfTjPwH_Mumw,2921
21
+ chat_console-0.2.9.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
22
+ chat_console-0.2.9.dist-info/entry_points.txt,sha256=kkVdEc22U9PAi2AeruoKklfkng_a_aHAP6VRVwrAD7c,67
23
+ chat_console-0.2.9.dist-info/top_level.txt,sha256=io9g7LCbfmTG1SFKgEOGXmCFB9uMP2H5lerm0HiHWQE,4
24
+ chat_console-0.2.9.dist-info/RECORD,,