chat-console 0.2.9__py3-none-any.whl → 0.2.99__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/anthropic.py +163 -26
- app/api/base.py +45 -2
- app/api/ollama.py +202 -43
- app/api/openai.py +53 -4
- app/config.py +53 -7
- app/main.py +512 -103
- app/ui/chat_interface.py +40 -20
- app/ui/model_browser.py +405 -45
- app/ui/model_selector.py +77 -19
- app/utils.py +359 -85
- {chat_console-0.2.9.dist-info → chat_console-0.2.99.dist-info}/METADATA +1 -1
- chat_console-0.2.99.dist-info/RECORD +24 -0
- chat_console-0.2.9.dist-info/RECORD +0 -24
- {chat_console-0.2.9.dist-info → chat_console-0.2.99.dist-info}/WHEEL +0 -0
- {chat_console-0.2.9.dist-info → chat_console-0.2.99.dist-info}/entry_points.txt +0 -0
- {chat_console-0.2.9.dist-info → chat_console-0.2.99.dist-info}/licenses/LICENSE +0 -0
- {chat_console-0.2.9.dist-info → chat_console-0.2.99.dist-info}/top_level.txt +0 -0
app/main.py
CHANGED
@@ -5,15 +5,36 @@ Simplified version of Chat CLI with AI functionality
|
|
5
5
|
import os
|
6
6
|
import asyncio
|
7
7
|
import typer
|
8
|
+
import logging
|
9
|
+
import time
|
8
10
|
from typing import List, Optional, Callable, Awaitable
|
9
11
|
from datetime import datetime
|
10
12
|
|
13
|
+
# Create a dedicated logger that definitely writes to a file
|
14
|
+
log_dir = os.path.expanduser("~/.cache/chat-cli")
|
15
|
+
os.makedirs(log_dir, exist_ok=True)
|
16
|
+
debug_log_file = os.path.join(log_dir, "debug.log")
|
17
|
+
|
18
|
+
# Configure the logger
|
19
|
+
file_handler = logging.FileHandler(debug_log_file)
|
20
|
+
file_handler.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s'))
|
21
|
+
|
22
|
+
# Get the logger and add the handler
|
23
|
+
debug_logger = logging.getLogger("chat-cli-debug")
|
24
|
+
debug_logger.setLevel(logging.DEBUG)
|
25
|
+
debug_logger.addHandler(file_handler)
|
26
|
+
|
27
|
+
# Add a convenience function to log to this file
|
28
|
+
def debug_log(message):
|
29
|
+
debug_logger.info(message)
|
30
|
+
|
11
31
|
from textual.app import App, ComposeResult
|
12
32
|
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer, Center
|
13
33
|
from textual.reactive import reactive
|
14
34
|
from textual.widgets import Button, Input, Label, Static, Header, Footer, ListView, ListItem
|
15
35
|
from textual.binding import Binding
|
16
36
|
from textual import work, log, on
|
37
|
+
from textual.worker import Worker, WorkerState # Import Worker class and WorkerState enum
|
17
38
|
from textual.screen import Screen
|
18
39
|
from openai import OpenAI
|
19
40
|
from app.models import Message, Conversation
|
@@ -25,7 +46,7 @@ from app.ui.model_selector import ModelSelector, StyleSelector
|
|
25
46
|
from app.ui.chat_list import ChatList
|
26
47
|
from app.ui.model_browser import ModelBrowser
|
27
48
|
from app.api.base import BaseModelClient
|
28
|
-
from app.utils import generate_streaming_response, save_settings_to_config, generate_conversation_title # Import
|
49
|
+
from app.utils import generate_streaming_response, save_settings_to_config, generate_conversation_title, resolve_model_id # Import resolver
|
29
50
|
# Import version here to avoid potential circular import issues at top level
|
30
51
|
from app import __version__
|
31
52
|
|
@@ -141,6 +162,15 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
|
|
141
162
|
TITLE = "Chat Console"
|
142
163
|
SUB_TITLE = "AI Chat Interface" # Keep SimpleChatApp SUB_TITLE
|
143
164
|
DARK = True # Keep SimpleChatApp DARK
|
165
|
+
|
166
|
+
# Add better terminal handling to fix UI glitches
|
167
|
+
SCREENS = {}
|
168
|
+
|
169
|
+
# Force full screen mode and prevent background terminal showing through
|
170
|
+
FULL_SCREEN = True
|
171
|
+
|
172
|
+
# Force capturing all mouse events for better stability
|
173
|
+
CAPTURE_MOUSE = True
|
144
174
|
|
145
175
|
# Ensure the log directory exists in a standard cache location
|
146
176
|
log_dir = os.path.expanduser("~/.cache/chat-cli")
|
@@ -211,11 +241,17 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
|
|
211
241
|
color: $text;
|
212
242
|
content-align: center middle;
|
213
243
|
text-align: center;
|
244
|
+
text-style: bold;
|
214
245
|
}
|
215
246
|
|
216
247
|
#loading-indicator.hidden { # Keep SimpleChatApp CSS
|
217
248
|
display: none;
|
218
249
|
}
|
250
|
+
|
251
|
+
#loading-indicator.model-loading {
|
252
|
+
background: $warning;
|
253
|
+
color: $text;
|
254
|
+
}
|
219
255
|
|
220
256
|
#input-area { # Keep SimpleChatApp CSS
|
221
257
|
width: 100%; # Keep SimpleChatApp CSS
|
@@ -313,12 +349,16 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
|
|
313
349
|
current_conversation = reactive(None) # Keep SimpleChatApp reactive var
|
314
350
|
is_generating = reactive(False) # Keep SimpleChatApp reactive var
|
315
351
|
current_generation_task: Optional[asyncio.Task] = None # Add task reference
|
352
|
+
_loading_frame = 0 # Track animation frame
|
353
|
+
_loading_animation_task: Optional[asyncio.Task] = None # Animation task
|
316
354
|
|
317
355
|
def __init__(self, initial_text: Optional[str] = None): # Keep SimpleChatApp __init__
|
318
356
|
super().__init__() # Keep SimpleChatApp __init__
|
319
357
|
self.db = ChatDatabase() # Keep SimpleChatApp __init__
|
320
358
|
self.messages = [] # Keep SimpleChatApp __init__
|
321
|
-
|
359
|
+
# Resolve the default model ID on initialization
|
360
|
+
default_model_from_config = CONFIG["default_model"]
|
361
|
+
self.selected_model = resolve_model_id(default_model_from_config)
|
322
362
|
self.selected_style = CONFIG["default_style"] # Keep SimpleChatApp __init__
|
323
363
|
self.initial_text = initial_text # Keep SimpleChatApp __init__
|
324
364
|
# Removed self.input_widget instance variable
|
@@ -347,7 +387,7 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
|
|
347
387
|
pass
|
348
388
|
|
349
389
|
# Loading indicator
|
350
|
-
yield Static("Generating response...", id="loading-indicator", classes="hidden")
|
390
|
+
yield Static("▪▪▪ Generating response...", id="loading-indicator", classes="hidden", markup=False)
|
351
391
|
|
352
392
|
# Input area
|
353
393
|
with Container(id="input-area"):
|
@@ -394,7 +434,7 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
|
|
394
434
|
# Check for available models # Keep SimpleChatApp on_mount
|
395
435
|
from app.api.ollama import OllamaClient # Keep SimpleChatApp on_mount
|
396
436
|
try: # Keep SimpleChatApp on_mount
|
397
|
-
ollama = OllamaClient() # Keep SimpleChatApp on_mount
|
437
|
+
ollama = await OllamaClient.create() # Keep SimpleChatApp on_mount
|
398
438
|
models = await ollama.get_available_models() # Keep SimpleChatApp on_mount
|
399
439
|
if not models: # Keep SimpleChatApp on_mount
|
400
440
|
api_issues.append("- No Ollama models found") # Keep SimpleChatApp on_mount
|
@@ -481,7 +521,7 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
|
|
481
521
|
# Get the client for the current model first and cancel the connection
|
482
522
|
try:
|
483
523
|
model = self.selected_model
|
484
|
-
client = BaseModelClient.get_client_for_model(model)
|
524
|
+
client = await BaseModelClient.get_client_for_model(model)
|
485
525
|
|
486
526
|
# Call the client's cancel method if it's supported
|
487
527
|
if hasattr(client, 'cancel_stream'):
|
@@ -511,6 +551,15 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
|
|
511
551
|
# This happens if is_generating is True, but no active task found to cancel
|
512
552
|
log("No active generation task found, but is_generating=True. Resetting state.")
|
513
553
|
self.is_generating = False
|
554
|
+
|
555
|
+
# Make sure to cancel animation task too
|
556
|
+
if self._loading_animation_task and not self._loading_animation_task.done():
|
557
|
+
try:
|
558
|
+
self._loading_animation_task.cancel()
|
559
|
+
except Exception as e:
|
560
|
+
log.error(f"Error cancelling animation task: {str(e)}")
|
561
|
+
self._loading_animation_task = None
|
562
|
+
|
514
563
|
loading = self.query_one("#loading-indicator")
|
515
564
|
loading.add_class("hidden")
|
516
565
|
else:
|
@@ -537,20 +586,27 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
|
|
537
586
|
pass
|
538
587
|
|
539
588
|
async def update_messages_ui(self) -> None: # Keep SimpleChatApp update_messages_ui
|
540
|
-
"""Update the messages UI.""" # Keep SimpleChatApp update_messages_ui docstring
|
589
|
+
"""Update the messages UI with improved stability.""" # Keep SimpleChatApp update_messages_ui docstring
|
541
590
|
# Clear existing messages # Keep SimpleChatApp update_messages_ui
|
542
591
|
messages_container = self.query_one("#messages-container") # Keep SimpleChatApp update_messages_ui
|
543
592
|
messages_container.remove_children() # Keep SimpleChatApp update_messages_ui
|
544
593
|
|
545
|
-
#
|
546
|
-
|
547
|
-
|
548
|
-
|
549
|
-
|
550
|
-
|
551
|
-
|
552
|
-
|
594
|
+
# Temporarily disable automatic refresh while mounting messages
|
595
|
+
# This avoids excessive layout calculations and reduces flickering
|
596
|
+
with self.batch_update():
|
597
|
+
# Batch add all messages first without any refresh/layout
|
598
|
+
for message in self.messages: # Keep SimpleChatApp update_messages_ui
|
599
|
+
display = MessageDisplay(message, highlight_code=CONFIG["highlight_code"]) # Keep SimpleChatApp update_messages_ui
|
600
|
+
messages_container.mount(display) # Keep SimpleChatApp update_messages_ui
|
601
|
+
|
602
|
+
# A small delay after mounting all messages helps with layout stability
|
603
|
+
await asyncio.sleep(0.05)
|
604
|
+
|
605
|
+
# Scroll after all messages are added without animation
|
553
606
|
messages_container.scroll_end(animate=False) # Keep SimpleChatApp update_messages_ui
|
607
|
+
|
608
|
+
# Minimal refresh without full layout recalculation
|
609
|
+
self.refresh(layout=False)
|
554
610
|
|
555
611
|
async def on_input_submitted(self, event: Input.Submitted) -> None: # Keep SimpleChatApp on_input_submitted
|
556
612
|
"""Handle input submission (Enter key in the main input).""" # Keep SimpleChatApp on_input_submitted docstring
|
@@ -588,6 +644,7 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
|
|
588
644
|
# If this is the first message and dynamic titles are enabled, generate one
|
589
645
|
if is_first_message and self.current_conversation and CONFIG.get("generate_dynamic_titles", True):
|
590
646
|
log("First message detected, generating title...")
|
647
|
+
debug_log("First message detected, attempting to generate conversation title")
|
591
648
|
title_generation_in_progress = True # Use a local flag
|
592
649
|
loading = self.query_one("#loading-indicator")
|
593
650
|
loading.remove_class("hidden") # Show loading for title gen
|
@@ -595,13 +652,71 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
|
|
595
652
|
try:
|
596
653
|
# Get appropriate client
|
597
654
|
model = self.selected_model
|
598
|
-
|
655
|
+
debug_log(f"Selected model for title generation: '{model}'")
|
656
|
+
|
657
|
+
# Check if model is valid
|
658
|
+
if not model:
|
659
|
+
debug_log("Model is empty, falling back to default")
|
660
|
+
# Fallback to a safe default model - preferring OpenAI if key exists
|
661
|
+
if OPENAI_API_KEY:
|
662
|
+
model = "gpt-3.5-turbo"
|
663
|
+
debug_log("Falling back to OpenAI gpt-3.5-turbo for title generation")
|
664
|
+
elif ANTHROPIC_API_KEY:
|
665
|
+
model = "claude-instant-1.2"
|
666
|
+
debug_log("Falling back to Anthropic claude-instant-1.2 for title generation")
|
667
|
+
else:
|
668
|
+
# Last resort - check for a common Ollama model
|
669
|
+
try:
|
670
|
+
from app.api.ollama import OllamaClient
|
671
|
+
ollama = await OllamaClient.create()
|
672
|
+
models = await ollama.get_available_models()
|
673
|
+
if models and len(models) > 0:
|
674
|
+
debug_log(f"Found {len(models)} Ollama models, using first one")
|
675
|
+
model = models[0].get("id", "llama3")
|
676
|
+
else:
|
677
|
+
model = "llama3" # Common default
|
678
|
+
debug_log(f"Falling back to Ollama model: {model}")
|
679
|
+
except Exception as ollama_err:
|
680
|
+
debug_log(f"Error getting Ollama models: {str(ollama_err)}")
|
681
|
+
model = "llama3" # Final fallback
|
682
|
+
debug_log("Final fallback to llama3")
|
683
|
+
|
684
|
+
debug_log(f"Getting client for model: {model}")
|
685
|
+
client = await BaseModelClient.get_client_for_model(model)
|
686
|
+
|
599
687
|
if client is None:
|
600
|
-
|
688
|
+
debug_log(f"No client available for model: {model}, trying to initialize")
|
689
|
+
# Try to determine client type and initialize manually
|
690
|
+
client_type = BaseModelClient.get_client_type_for_model(model)
|
691
|
+
if client_type:
|
692
|
+
debug_log(f"Found client type {client_type.__name__} for {model}, initializing")
|
693
|
+
try:
|
694
|
+
client = await client_type.create()
|
695
|
+
debug_log("Client initialized successfully")
|
696
|
+
except Exception as init_err:
|
697
|
+
debug_log(f"Error initializing client: {str(init_err)}")
|
698
|
+
|
699
|
+
if client is None:
|
700
|
+
debug_log("Could not initialize client, falling back to safer model")
|
701
|
+
# Try a different model as last resort
|
702
|
+
if OPENAI_API_KEY:
|
703
|
+
from app.api.openai import OpenAIClient
|
704
|
+
client = await OpenAIClient.create()
|
705
|
+
model = "gpt-3.5-turbo"
|
706
|
+
debug_log("Falling back to OpenAI for title generation")
|
707
|
+
elif ANTHROPIC_API_KEY:
|
708
|
+
from app.api.anthropic import AnthropicClient
|
709
|
+
client = await AnthropicClient.create()
|
710
|
+
model = "claude-instant-1.2"
|
711
|
+
debug_log("Falling back to Anthropic for title generation")
|
712
|
+
else:
|
713
|
+
raise Exception("No valid API clients available for title generation")
|
601
714
|
|
602
715
|
# Generate title
|
603
716
|
log(f"Calling generate_conversation_title with model: {model}")
|
717
|
+
debug_log(f"Calling generate_conversation_title with model: {model}")
|
604
718
|
title = await generate_conversation_title(content, model, client)
|
719
|
+
debug_log(f"Generated title: {title}")
|
605
720
|
log(f"Generated title: {title}")
|
606
721
|
|
607
722
|
# Update conversation title in database
|
@@ -616,10 +731,17 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
|
|
616
731
|
|
617
732
|
# Update conversation object
|
618
733
|
self.current_conversation.title = title
|
734
|
+
|
735
|
+
# IMPORTANT: Save the successful model for consistency
|
736
|
+
# If the title was generated with a different model than initially selected,
|
737
|
+
# update the selected_model to match so the response uses the same model
|
738
|
+
debug_log(f"Using same model for chat response: '{model}'")
|
739
|
+
self.selected_model = model
|
619
740
|
|
620
741
|
self.notify(f"Conversation title set to: {title}", severity="information", timeout=3)
|
621
742
|
|
622
743
|
except Exception as e:
|
744
|
+
debug_log(f"Failed to generate title: {str(e)}")
|
623
745
|
log.error(f"Failed to generate title: {str(e)}")
|
624
746
|
self.notify(f"Failed to generate title: {str(e)}", severity="warning")
|
625
747
|
finally:
|
@@ -628,7 +750,13 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
|
|
628
750
|
# This check might be redundant if generate_response always shows it anyway
|
629
751
|
if not self.is_generating:
|
630
752
|
loading.add_class("hidden")
|
631
|
-
|
753
|
+
|
754
|
+
# Small delay to ensure state is updated
|
755
|
+
await asyncio.sleep(0.1)
|
756
|
+
|
757
|
+
# Log just before generate_response call
|
758
|
+
debug_log(f"About to call generate_response with model: '{self.selected_model}'")
|
759
|
+
|
632
760
|
# Generate AI response (will set self.is_generating and handle loading indicator)
|
633
761
|
await self.generate_response()
|
634
762
|
|
@@ -637,39 +765,148 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
|
|
637
765
|
|
638
766
|
async def generate_response(self) -> None:
|
639
767
|
"""Generate an AI response using a non-blocking worker."""
|
768
|
+
# Import debug_log function from main
|
769
|
+
debug_log(f"Entering generate_response method")
|
770
|
+
|
640
771
|
if not self.current_conversation or not self.messages:
|
772
|
+
debug_log("No current conversation or messages, returning")
|
641
773
|
return
|
642
774
|
|
643
775
|
self.is_generating = True
|
644
776
|
log("Setting is_generating to True")
|
777
|
+
debug_log("Setting is_generating to True")
|
645
778
|
loading = self.query_one("#loading-indicator")
|
646
779
|
loading.remove_class("hidden")
|
780
|
+
|
781
|
+
# For Ollama models, show the loading indicator immediately
|
782
|
+
from app.api.ollama import OllamaClient
|
783
|
+
debug_log(f"Current selected model: '{self.selected_model}'")
|
784
|
+
client_type = BaseModelClient.get_client_type_for_model(self.selected_model)
|
785
|
+
debug_log(f"Client type: {client_type.__name__ if client_type else 'None'}")
|
786
|
+
|
787
|
+
if self.selected_model and client_type == OllamaClient:
|
788
|
+
log("Ollama model detected, showing immediate loading indicator")
|
789
|
+
debug_log("Ollama model detected, showing immediate loading indicator")
|
790
|
+
loading.add_class("model-loading")
|
791
|
+
# Update the loading indicator text directly
|
792
|
+
loading.update("⚙️ Preparing Ollama model...")
|
793
|
+
else:
|
794
|
+
loading.remove_class("model-loading")
|
795
|
+
# Start with a simple animation pattern that won't cause markup issues
|
796
|
+
self._loading_frame = 0
|
797
|
+
# Stop any existing animation task
|
798
|
+
if self._loading_animation_task and not self._loading_animation_task.done():
|
799
|
+
self._loading_animation_task.cancel()
|
800
|
+
# Start the animation
|
801
|
+
self._loading_animation_task = asyncio.create_task(self._animate_loading_task(loading))
|
647
802
|
|
648
803
|
try:
|
649
804
|
# Get conversation parameters
|
650
|
-
model
|
805
|
+
# Ensure the model ID is resolved before passing to the API client
|
806
|
+
unresolved_model = self.selected_model
|
807
|
+
model = resolve_model_id(unresolved_model)
|
808
|
+
log(f"Using model for generation: {model} (Resolved from: {unresolved_model})")
|
651
809
|
style = self.selected_style
|
652
|
-
|
653
|
-
|
810
|
+
|
811
|
+
debug_log(f"Using model: '{model}', style: '{style}'")
|
812
|
+
|
813
|
+
# Ensure we have a valid model
|
814
|
+
if not model:
|
815
|
+
debug_log("Model is empty, selecting a default model")
|
816
|
+
# Same fallback logic as in autotitling - this ensures consistency
|
817
|
+
if OPENAI_API_KEY:
|
818
|
+
model = "gpt-3.5-turbo"
|
819
|
+
debug_log("Falling back to OpenAI gpt-3.5-turbo")
|
820
|
+
elif ANTHROPIC_API_KEY:
|
821
|
+
model = "claude-instant-1.2"
|
822
|
+
debug_log("Falling back to Anthropic claude-instant-1.2")
|
823
|
+
else:
|
824
|
+
# Check for a common Ollama model
|
825
|
+
try:
|
826
|
+
ollama = await OllamaClient.create()
|
827
|
+
models = await ollama.get_available_models()
|
828
|
+
if models and len(models) > 0:
|
829
|
+
debug_log(f"Found {len(models)} Ollama models, using first one")
|
830
|
+
model = models[0].get("id", "llama3")
|
831
|
+
else:
|
832
|
+
model = "llama3" # Common default
|
833
|
+
debug_log(f"Falling back to Ollama model: {model}")
|
834
|
+
except Exception as ollama_err:
|
835
|
+
debug_log(f"Error getting Ollama models: {str(ollama_err)}")
|
836
|
+
model = "llama3" # Final fallback
|
837
|
+
debug_log("Final fallback to llama3")
|
838
|
+
|
839
|
+
# Convert messages to API format with enhanced error checking
|
654
840
|
api_messages = []
|
655
|
-
|
656
|
-
|
657
|
-
|
658
|
-
|
659
|
-
|
841
|
+
debug_log(f"Converting {len(self.messages)} messages to API format")
|
842
|
+
|
843
|
+
for i, msg in enumerate(self.messages):
|
844
|
+
try:
|
845
|
+
debug_log(f"Processing message {i}: type={type(msg).__name__}, dir={dir(msg)}")
|
846
|
+
debug_log(f"Adding message to API format: role={msg.role}, content_len={len(msg.content)}")
|
847
|
+
|
848
|
+
# Create a fully validated message dict
|
849
|
+
message_dict = {
|
850
|
+
"role": msg.role if hasattr(msg, 'role') and msg.role else "user",
|
851
|
+
"content": msg.content if hasattr(msg, 'content') and msg.content else ""
|
852
|
+
}
|
853
|
+
|
854
|
+
api_messages.append(message_dict)
|
855
|
+
debug_log(f"Successfully added message {i}")
|
856
|
+
except Exception as e:
|
857
|
+
debug_log(f"Error adding message {i} to API format: {str(e)}")
|
858
|
+
# Create a safe fallback message
|
859
|
+
fallback_msg = {
|
860
|
+
"role": "user",
|
861
|
+
"content": str(msg) if msg is not None else "Error retrieving message content"
|
862
|
+
}
|
863
|
+
api_messages.append(fallback_msg)
|
864
|
+
debug_log(f"Added fallback message for {i}")
|
865
|
+
|
866
|
+
debug_log(f"Prepared {len(api_messages)} messages for API")
|
660
867
|
|
661
868
|
# Get appropriate client
|
869
|
+
debug_log(f"Getting client for model: {model}")
|
662
870
|
try:
|
663
|
-
client = BaseModelClient.get_client_for_model(model)
|
871
|
+
client = await BaseModelClient.get_client_for_model(model)
|
872
|
+
debug_log(f"Client: {client.__class__.__name__ if client else 'None'}")
|
873
|
+
|
664
874
|
if client is None:
|
665
|
-
|
875
|
+
debug_log(f"No client available for model: {model}, trying to initialize")
|
876
|
+
# Try to determine client type and initialize manually
|
877
|
+
client_type = BaseModelClient.get_client_type_for_model(model)
|
878
|
+
if client_type:
|
879
|
+
debug_log(f"Found client type {client_type.__name__} for {model}, initializing")
|
880
|
+
try:
|
881
|
+
client = await client_type.create()
|
882
|
+
debug_log(f"Successfully initialized {client_type.__name__}")
|
883
|
+
except Exception as init_err:
|
884
|
+
debug_log(f"Error initializing client: {str(init_err)}")
|
885
|
+
|
886
|
+
if client is None:
|
887
|
+
debug_log("Could not initialize client, falling back to safer model")
|
888
|
+
# Try a different model as last resort
|
889
|
+
if OPENAI_API_KEY:
|
890
|
+
from app.api.openai import OpenAIClient
|
891
|
+
client = await OpenAIClient.create()
|
892
|
+
model = "gpt-3.5-turbo"
|
893
|
+
debug_log("Falling back to OpenAI client")
|
894
|
+
elif ANTHROPIC_API_KEY:
|
895
|
+
from app.api.anthropic import AnthropicClient
|
896
|
+
client = await AnthropicClient.create()
|
897
|
+
model = "claude-instant-1.2"
|
898
|
+
debug_log("Falling back to Anthropic client")
|
899
|
+
else:
|
900
|
+
raise Exception("No valid API clients available")
|
666
901
|
except Exception as e:
|
902
|
+
debug_log(f"Failed to initialize model client: {str(e)}")
|
667
903
|
self.notify(f"Failed to initialize model client: {str(e)}", severity="error")
|
668
904
|
self.is_generating = False
|
669
905
|
loading.add_class("hidden")
|
670
906
|
return
|
671
907
|
|
672
908
|
# Start streaming response
|
909
|
+
debug_log("Creating assistant message with 'Thinking...'")
|
673
910
|
assistant_message = Message(role="assistant", content="Thinking...")
|
674
911
|
self.messages.append(assistant_message)
|
675
912
|
messages_container = self.query_one("#messages-container")
|
@@ -682,96 +919,206 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
|
|
682
919
|
|
683
920
|
# Stream chunks to the UI with synchronization
|
684
921
|
update_lock = asyncio.Lock()
|
922
|
+
last_refresh_time = time.time() # Initialize refresh throttling timer
|
685
923
|
|
686
924
|
async def update_ui(content: str):
|
925
|
+
# This function remains the same, called by the worker
|
687
926
|
if not self.is_generating:
|
688
|
-
|
927
|
+
debug_log("update_ui called but is_generating is False, returning.")
|
689
928
|
return
|
690
929
|
|
930
|
+
# Make last_refresh_time accessible in inner scope
|
931
|
+
nonlocal last_refresh_time
|
932
|
+
|
691
933
|
async with update_lock:
|
692
934
|
try:
|
693
935
|
# Clear thinking indicator on first content
|
694
936
|
if assistant_message.content == "Thinking...":
|
937
|
+
debug_log("First content received, clearing 'Thinking...'")
|
695
938
|
assistant_message.content = ""
|
696
939
|
|
697
|
-
# Update message with full content
|
940
|
+
# Update the message object with the full content
|
698
941
|
assistant_message.content = content
|
699
|
-
|
942
|
+
|
943
|
+
# Update UI with the content - this no longer triggers refresh itself
|
700
944
|
await message_display.update_content(content)
|
701
|
-
|
702
|
-
|
703
|
-
|
704
|
-
|
705
|
-
|
706
|
-
|
945
|
+
|
946
|
+
# Much more aggressive throttling of UI updates to eliminate visual jitter
|
947
|
+
# By using a larger modulo value, we significantly reduce refresh frequency
|
948
|
+
# This improves stability at the cost of slightly choppier animations
|
949
|
+
content_length = len(content)
|
950
|
+
|
951
|
+
# Define some key refresh points
|
952
|
+
new_paragraph = content.endswith("\n") and content.count("\n") > 0
|
953
|
+
do_refresh = (
|
954
|
+
content_length < 5 or # Only first few tokens
|
955
|
+
content_length % 64 == 0 or # Very infrequent periodic updates
|
956
|
+
new_paragraph # Refresh on paragraph breaks
|
957
|
+
)
|
958
|
+
|
959
|
+
# Check if it's been enough time since last refresh (250ms minimum)
|
960
|
+
current_time = time.time()
|
961
|
+
time_since_refresh = current_time - last_refresh_time
|
962
|
+
|
963
|
+
if do_refresh and time_since_refresh > 0.25:
|
964
|
+
# Store the time we did the refresh
|
965
|
+
last_refresh_time = current_time
|
966
|
+
# Skip layout updates completely during streaming
|
967
|
+
# Just ensure content is still visible by scrolling
|
968
|
+
messages_container.scroll_end(animate=False)
|
707
969
|
except Exception as e:
|
970
|
+
debug_log(f"Error updating UI: {str(e)}")
|
708
971
|
log.error(f"Error updating UI: {str(e)}")
|
709
972
|
|
710
|
-
#
|
711
|
-
|
712
|
-
|
713
|
-
|
714
|
-
|
715
|
-
|
716
|
-
|
717
|
-
|
718
|
-
|
719
|
-
|
720
|
-
|
721
|
-
|
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()
|
973
|
+
# --- Remove the inner run_generation_worker function ---
|
974
|
+
|
975
|
+
# Start the worker directly using the imported function
|
976
|
+
debug_log("Starting generate_streaming_response worker")
|
977
|
+
# Call the @work decorated function directly
|
978
|
+
worker = generate_streaming_response(
|
979
|
+
self,
|
980
|
+
api_messages,
|
981
|
+
model,
|
982
|
+
style,
|
983
|
+
client,
|
984
|
+
update_ui # Pass the callback function
|
985
|
+
)
|
766
986
|
self.current_generation_task = worker
|
767
|
-
|
987
|
+
# Worker completion will be handled by on_worker_state_changed
|
988
|
+
|
768
989
|
except Exception as e:
|
769
|
-
|
990
|
+
# This catches errors during the *setup* before the worker starts
|
991
|
+
debug_log(f"Error setting up generation worker: {str(e)}")
|
992
|
+
log.error(f"Error setting up generation worker: {str(e)}")
|
770
993
|
self.notify(f"Error: {str(e)}", severity="error")
|
994
|
+
# Ensure cleanup if setup fails
|
995
|
+
self.is_generating = False # Reset state
|
996
|
+
self.current_generation_task = None
|
997
|
+
if self._loading_animation_task and not self._loading_animation_task.done():
|
998
|
+
self._loading_animation_task.cancel()
|
999
|
+
self._loading_animation_task = None
|
1000
|
+
try:
|
1001
|
+
loading = self.query_one("#loading-indicator")
|
1002
|
+
loading.add_class("hidden")
|
1003
|
+
self.query_one("#message-input").focus()
|
1004
|
+
except Exception:
|
1005
|
+
pass # Ignore UI errors during cleanup
|
1006
|
+
|
1007
|
+
# Rename this method slightly to avoid potential conflicts and clarify purpose
|
1008
|
+
async def _handle_generation_result(self, worker: Worker[Optional[str]]) -> None:
|
1009
|
+
"""Handles the result of the generation worker (success, error, cancelled)."""
|
1010
|
+
# Import debug_log again for safety within this callback context
|
1011
|
+
try:
|
1012
|
+
from app.main import debug_log
|
1013
|
+
except ImportError:
|
1014
|
+
debug_log = lambda msg: None
|
1015
|
+
|
1016
|
+
debug_log(f"Generation worker completed. State: {worker.state}")
|
1017
|
+
|
1018
|
+
try:
|
1019
|
+
if worker.state == "cancelled":
|
1020
|
+
debug_log("Generation worker was cancelled")
|
1021
|
+
log.warning("Generation worker was cancelled")
|
1022
|
+
# Remove the incomplete message
|
1023
|
+
if self.messages and self.messages[-1].role == "assistant":
|
1024
|
+
debug_log("Removing incomplete assistant message")
|
1025
|
+
self.messages.pop()
|
1026
|
+
await self.update_messages_ui()
|
1027
|
+
self.notify("Generation stopped by user", severity="warning", timeout=2)
|
1028
|
+
|
1029
|
+
elif worker.state == "error":
|
1030
|
+
error = worker.error
|
1031
|
+
debug_log(f"Error in generation worker: {error}")
|
1032
|
+
log.error(f"Error in generation worker: {error}")
|
1033
|
+
self.notify(f"Generation error: {error}", severity="error", timeout=5)
|
1034
|
+
# Add error message to UI
|
1035
|
+
if self.messages and self.messages[-1].role == "assistant":
|
1036
|
+
debug_log("Removing thinking message")
|
1037
|
+
self.messages.pop() # Remove thinking message
|
1038
|
+
error_msg = f"Error: {error}"
|
1039
|
+
debug_log(f"Adding error message: {error_msg}")
|
1040
|
+
self.messages.append(Message(role="assistant", content=error_msg))
|
1041
|
+
await self.update_messages_ui()
|
1042
|
+
|
1043
|
+
elif worker.state == "success":
|
1044
|
+
full_response = worker.result
|
1045
|
+
debug_log("Generation completed normally, saving to database")
|
1046
|
+
log("Generation completed normally, saving to database")
|
1047
|
+
# Save complete response to database (check if response is valid)
|
1048
|
+
if full_response and isinstance(full_response, str):
|
1049
|
+
self.db.add_message(
|
1050
|
+
self.current_conversation.id,
|
1051
|
+
"assistant",
|
1052
|
+
full_response
|
1053
|
+
)
|
1054
|
+
# Update the final message object content (optional, UI should be up-to-date)
|
1055
|
+
if self.messages and self.messages[-1].role == "assistant":
|
1056
|
+
self.messages[-1].content = full_response
|
1057
|
+
else:
|
1058
|
+
debug_log("Worker finished successfully but response was empty or invalid.")
|
1059
|
+
# Handle case where 'Thinking...' might still be the last message
|
1060
|
+
if self.messages and self.messages[-1].role == "assistant" and self.messages[-1].content == "Thinking...":
|
1061
|
+
self.messages.pop() # Remove 'Thinking...' if no content arrived
|
1062
|
+
await self.update_messages_ui()
|
1063
|
+
|
1064
|
+
# Final UI refresh with minimal layout recalculation
|
1065
|
+
# Use layout=False to prevent UI jumping at the end
|
1066
|
+
self.refresh(layout=False)
|
1067
|
+
await asyncio.sleep(0.1) # Allow UI to stabilize
|
1068
|
+
messages_container = self.query_one("#messages-container")
|
1069
|
+
messages_container.scroll_end(animate=False)
|
1070
|
+
|
1071
|
+
except Exception as e:
|
1072
|
+
# Catch any unexpected errors during the callback itself
|
1073
|
+
debug_log(f"Error in on_generation_complete callback: {str(e)}")
|
1074
|
+
log.error(f"Error in on_generation_complete callback: {str(e)}")
|
1075
|
+
self.notify(f"Internal error handling response: {str(e)}", severity="error")
|
1076
|
+
|
1077
|
+
finally:
|
1078
|
+
# Always clean up state and UI, regardless of worker outcome
|
1079
|
+
debug_log("Cleaning up after generation worker")
|
771
1080
|
self.is_generating = False
|
772
|
-
|
773
|
-
|
774
|
-
|
1081
|
+
self.current_generation_task = None
|
1082
|
+
|
1083
|
+
# Stop the animation task
|
1084
|
+
if self._loading_animation_task and not self._loading_animation_task.done():
|
1085
|
+
debug_log("Cancelling loading animation task")
|
1086
|
+
self._loading_animation_task.cancel()
|
1087
|
+
self._loading_animation_task = None
|
1088
|
+
|
1089
|
+
try:
|
1090
|
+
loading = self.query_one("#loading-indicator")
|
1091
|
+
loading.add_class("hidden")
|
1092
|
+
self.refresh(layout=True) # Refresh after hiding loading
|
1093
|
+
self.query_one("#message-input").focus()
|
1094
|
+
except Exception as ui_err:
|
1095
|
+
debug_log(f"Error during final UI cleanup: {str(ui_err)}")
|
1096
|
+
log.error(f"Error during final UI cleanup: {str(ui_err)}")
|
1097
|
+
|
1098
|
+
@on(Worker.StateChanged)
|
1099
|
+
async def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
1100
|
+
"""Handle worker state changes."""
|
1101
|
+
# Import debug_log again for safety within this callback context
|
1102
|
+
try:
|
1103
|
+
from app.main import debug_log
|
1104
|
+
except ImportError:
|
1105
|
+
debug_log = lambda msg: None
|
1106
|
+
|
1107
|
+
debug_log(f"Worker {event.worker.name} state changed to {event.state}")
|
1108
|
+
|
1109
|
+
# Check if this is the generation worker we are tracking
|
1110
|
+
if event.worker is self.current_generation_task:
|
1111
|
+
# Check if the worker has reached a final state by comparing against enum values
|
1112
|
+
final_states = {WorkerState.SUCCESS, WorkerState.ERROR, WorkerState.CANCELLED}
|
1113
|
+
if event.state in final_states:
|
1114
|
+
debug_log(f"Generation worker ({event.worker.name}) reached final state: {event.state}")
|
1115
|
+
# Call the handler function
|
1116
|
+
await self._handle_generation_result(event.worker)
|
1117
|
+
else:
|
1118
|
+
debug_log(f"Generation worker ({event.worker.name}) is in intermediate state: {event.state}")
|
1119
|
+
else:
|
1120
|
+
debug_log(f"State change event from unrelated worker: {event.worker.name}")
|
1121
|
+
|
775
1122
|
|
776
1123
|
def on_model_selector_model_selected(self, event: ModelSelector.ModelSelected) -> None: # Keep SimpleChatApp on_model_selector_model_selected
|
777
1124
|
"""Handle model selection""" # Keep SimpleChatApp on_model_selector_model_selected docstring
|
@@ -862,8 +1209,23 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
|
|
862
1209
|
await self.update_messages_ui() # Keep SimpleChatApp view_chat_history
|
863
1210
|
|
864
1211
|
# Update model and style selectors # Keep SimpleChatApp view_chat_history
|
865
|
-
|
1212
|
+
# Resolve the model ID loaded from the conversation data
|
1213
|
+
loaded_model_id = self.current_conversation.model
|
1214
|
+
resolved_model_id = resolve_model_id(loaded_model_id)
|
1215
|
+
log(f"Loaded model ID from history: {loaded_model_id}, Resolved to: {resolved_model_id}")
|
1216
|
+
|
1217
|
+
self.selected_model = resolved_model_id # Use the resolved ID
|
866
1218
|
self.selected_style = self.current_conversation.style # Keep SimpleChatApp view_chat_history
|
1219
|
+
|
1220
|
+
# Update settings panel selectors if they exist
|
1221
|
+
try:
|
1222
|
+
model_selector = self.query_one(ModelSelector)
|
1223
|
+
model_selector.set_selected_model(self.selected_model) # Use resolved ID here too
|
1224
|
+
style_selector = self.query_one(StyleSelector)
|
1225
|
+
style_selector.set_selected_style(self.selected_style)
|
1226
|
+
except Exception as e:
|
1227
|
+
log(f"Error updating selectors after history load: {e}")
|
1228
|
+
|
867
1229
|
self.update_app_info() # Update info bar after loading history
|
868
1230
|
|
869
1231
|
self.push_screen(HistoryScreen(conversations, handle_selection)) # Keep SimpleChatApp view_chat_history
|
@@ -879,6 +1241,53 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
|
|
879
1241
|
"""Open the Ollama model browser screen."""
|
880
1242
|
# Always trigger regardless of focus
|
881
1243
|
self.push_screen(ModelBrowserScreen())
|
1244
|
+
|
1245
|
+
async def _animate_loading_task(self, loading_widget: Static) -> None:
|
1246
|
+
"""Animate the loading indicator with a simple text animation"""
|
1247
|
+
try:
|
1248
|
+
# Animation frames (simple text animation)
|
1249
|
+
frames = [
|
1250
|
+
"▪▫▫ Generating response...",
|
1251
|
+
"▪▪▫ Generating response...",
|
1252
|
+
"▪▪▪ Generating response...",
|
1253
|
+
"▫▪▪ Generating response...",
|
1254
|
+
"▫▫▪ Generating response...",
|
1255
|
+
"▫▫▫ Generating response..."
|
1256
|
+
]
|
1257
|
+
|
1258
|
+
while self.is_generating:
|
1259
|
+
try:
|
1260
|
+
# Update the loading text with safety checks
|
1261
|
+
if frames and len(frames) > 0:
|
1262
|
+
frame_idx = self._loading_frame % len(frames)
|
1263
|
+
loading_widget.update(frames[frame_idx])
|
1264
|
+
else:
|
1265
|
+
# Fallback if frames is empty
|
1266
|
+
loading_widget.update("▪▪▪ Generating response...")
|
1267
|
+
|
1268
|
+
self._loading_frame += 1
|
1269
|
+
# Small delay between frames
|
1270
|
+
await asyncio.sleep(0.3)
|
1271
|
+
except Exception as e:
|
1272
|
+
# If any error occurs, use a simple fallback and continue
|
1273
|
+
log.error(f"Animation frame error: {str(e)}")
|
1274
|
+
try:
|
1275
|
+
loading_widget.update("▪▪▪ Generating response...")
|
1276
|
+
except:
|
1277
|
+
pass
|
1278
|
+
await asyncio.sleep(0.3)
|
1279
|
+
|
1280
|
+
except asyncio.CancelledError:
|
1281
|
+
# Normal cancellation
|
1282
|
+
pass
|
1283
|
+
except Exception as e:
|
1284
|
+
# Log any errors but don't crash
|
1285
|
+
log.error(f"Error in loading animation: {str(e)}")
|
1286
|
+
# Reset to basic text
|
1287
|
+
try:
|
1288
|
+
loading_widget.update("▪▪▪ Generating response...")
|
1289
|
+
except:
|
1290
|
+
pass
|
882
1291
|
|
883
1292
|
def action_settings(self) -> None: # Modify SimpleChatApp action_settings
|
884
1293
|
"""Action to open/close settings panel via key binding."""
|
@@ -907,6 +1316,10 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
|
|
907
1316
|
if not self.current_conversation:
|
908
1317
|
self.notify("No active conversation", severity="warning")
|
909
1318
|
return
|
1319
|
+
|
1320
|
+
# Create and mount the title input modal
|
1321
|
+
modal = TitleInputModal(self.current_conversation.title)
|
1322
|
+
await self.mount(modal)
|
910
1323
|
|
911
1324
|
# --- Define the Modal Class ---
|
912
1325
|
class ConfirmDialog(Static):
|
@@ -983,10 +1396,6 @@ class TitleInputModal(Static):
|
|
983
1396
|
"""Focus the input when the modal appears."""
|
984
1397
|
self.query_one("#title-input", Input).focus()
|
985
1398
|
|
986
|
-
# --- Show the modal ---
|
987
|
-
modal = TitleInputModal(self.current_conversation.title)
|
988
|
-
await self.mount(modal) # Use await for mounting
|
989
|
-
|
990
1399
|
async def run_modal(self, modal_type: str, *args, **kwargs) -> bool:
|
991
1400
|
"""Run a modal dialog and return the result."""
|
992
1401
|
if modal_type == "confirm_dialog":
|
@@ -1058,4 +1467,4 @@ def main(initial_text: Optional[str] = typer.Argument(None, help="Initial text t
|
|
1058
1467
|
app.run() # Keep main function
|
1059
1468
|
|
1060
1469
|
if __name__ == "__main__": # Keep main function entry point
|
1061
|
-
typer.run(main) # Keep main function entry point
|
1470
|
+
typer.run(main) # Keep main function entry point
|