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 +1 -1
- app/api/ollama.py +46 -14
- app/main.py +172 -168
- app/ui/chat_interface.py +22 -3
- app/utils.py +79 -18
- {chat_console-0.2.6.dist-info → chat_console-0.2.9.dist-info}/METADATA +1 -1
- {chat_console-0.2.6.dist-info → chat_console-0.2.9.dist-info}/RECORD +11 -11
- {chat_console-0.2.6.dist-info → chat_console-0.2.9.dist-info}/WHEEL +0 -0
- {chat_console-0.2.6.dist-info → chat_console-0.2.9.dist-info}/entry_points.txt +0 -0
- {chat_console-0.2.6.dist-info → chat_console-0.2.9.dist-info}/licenses/LICENSE +0 -0
- {chat_console-0.2.6.dist-info → chat_console-0.2.9.dist-info}/top_level.txt +0 -0
app/__init__.py
CHANGED
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
|
-
|
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
|
-
)
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
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", "
|
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
|
-
|
481
|
-
#
|
482
|
-
|
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
|
485
|
-
|
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
|
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:
|
612
|
-
"""Generate an AI response."""
|
613
|
-
if not self.current_conversation or not self.messages:
|
614
|
-
return
|
615
|
-
|
616
|
-
self.is_generating = True
|
617
|
-
log(
|
618
|
-
loading = self.query_one("#loading-indicator")
|
619
|
-
loading.remove_class("hidden")
|
620
|
-
|
621
|
-
try:
|
622
|
-
# Get conversation parameters
|
623
|
-
model = self.selected_model
|
624
|
-
style = self.selected_style
|
625
|
-
|
626
|
-
# Convert messages to API format
|
627
|
-
api_messages = []
|
628
|
-
for msg in self.messages:
|
629
|
-
api_messages.append({
|
630
|
-
"role": msg.role,
|
631
|
-
"content": msg.content
|
632
|
-
})
|
633
|
-
|
634
|
-
# Get appropriate client
|
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
|
-
|
769
|
-
|
770
|
-
|
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
|
-
#
|
93
|
-
|
94
|
-
|
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
|
-
#
|
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
|
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.
|
124
|
-
#
|
125
|
-
|
126
|
-
|
127
|
-
|
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)}")
|
130
|
-
#
|
131
|
-
if
|
132
|
-
|
133
|
-
|
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.
|
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=
|
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=
|
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=
|
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=
|
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=
|
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.
|
20
|
-
chat_console-0.2.
|
21
|
-
chat_console-0.2.
|
22
|
-
chat_console-0.2.
|
23
|
-
chat_console-0.2.
|
24
|
-
chat_console-0.2.
|
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,,
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|