chat-console 0.3.8__tar.gz → 0.3.91__tar.gz

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.
Files changed (30) hide show
  1. {chat_console-0.3.8/chat_console.egg-info → chat_console-0.3.91}/PKG-INFO +1 -1
  2. {chat_console-0.3.8 → chat_console-0.3.91}/app/__init__.py +1 -1
  3. chat_console-0.3.91/app/api/anthropic.py +238 -0
  4. {chat_console-0.3.8 → chat_console-0.3.91}/app/api/base.py +5 -0
  5. {chat_console-0.3.8 → chat_console-0.3.91}/app/api/ollama.py +52 -0
  6. {chat_console-0.3.8 → chat_console-0.3.91}/app/api/openai.py +31 -0
  7. {chat_console-0.3.8 → chat_console-0.3.91}/app/config.py +29 -26
  8. {chat_console-0.3.8 → chat_console-0.3.91}/app/main.py +20 -4
  9. {chat_console-0.3.8 → chat_console-0.3.91}/app/ui/chat_interface.py +55 -48
  10. {chat_console-0.3.8 → chat_console-0.3.91}/app/utils.py +548 -210
  11. {chat_console-0.3.8 → chat_console-0.3.91/chat_console.egg-info}/PKG-INFO +1 -1
  12. chat_console-0.3.8/app/api/anthropic.py +0 -253
  13. {chat_console-0.3.8 → chat_console-0.3.91}/LICENSE +0 -0
  14. {chat_console-0.3.8 → chat_console-0.3.91}/README.md +0 -0
  15. {chat_console-0.3.8 → chat_console-0.3.91}/app/api/__init__.py +0 -0
  16. {chat_console-0.3.8 → chat_console-0.3.91}/app/database.py +0 -0
  17. {chat_console-0.3.8 → chat_console-0.3.91}/app/models.py +0 -0
  18. {chat_console-0.3.8 → chat_console-0.3.91}/app/ui/__init__.py +0 -0
  19. {chat_console-0.3.8 → chat_console-0.3.91}/app/ui/chat_list.py +0 -0
  20. {chat_console-0.3.8 → chat_console-0.3.91}/app/ui/model_browser.py +0 -0
  21. {chat_console-0.3.8 → chat_console-0.3.91}/app/ui/model_selector.py +0 -0
  22. {chat_console-0.3.8 → chat_console-0.3.91}/app/ui/search.py +0 -0
  23. {chat_console-0.3.8 → chat_console-0.3.91}/app/ui/styles.py +0 -0
  24. {chat_console-0.3.8 → chat_console-0.3.91}/chat_console.egg-info/SOURCES.txt +0 -0
  25. {chat_console-0.3.8 → chat_console-0.3.91}/chat_console.egg-info/dependency_links.txt +0 -0
  26. {chat_console-0.3.8 → chat_console-0.3.91}/chat_console.egg-info/entry_points.txt +0 -0
  27. {chat_console-0.3.8 → chat_console-0.3.91}/chat_console.egg-info/requires.txt +0 -0
  28. {chat_console-0.3.8 → chat_console-0.3.91}/chat_console.egg-info/top_level.txt +0 -0
  29. {chat_console-0.3.8 → chat_console-0.3.91}/setup.cfg +0 -0
  30. {chat_console-0.3.8 → chat_console-0.3.91}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chat-console
3
- Version: 0.3.8
3
+ Version: 0.3.91
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
@@ -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.3.8"
6
+ __version__ = "0.3.91"
@@ -0,0 +1,238 @@
1
+ import anthropic
2
+ import asyncio
3
+ import logging
4
+ from typing import List, Dict, Any, Optional, Generator, AsyncGenerator
5
+ from .base import BaseModelClient
6
+ from ..config import ANTHROPIC_API_KEY
7
+
8
+ # Set up logging
9
+ logger = logging.getLogger(__name__)
10
+
11
+ class AnthropicClient(BaseModelClient):
12
+ def __init__(self):
13
+ self.client = None # Initialize in create()
14
+ self._active_stream = None # Track active stream for cancellation
15
+
16
+ @classmethod
17
+ async def create(cls) -> 'AnthropicClient':
18
+ """Create a new instance with async initialization."""
19
+ instance = cls()
20
+ instance.client = anthropic.AsyncAnthropic(api_key=ANTHROPIC_API_KEY)
21
+ return instance
22
+
23
+ def _prepare_messages(self, messages: List[Dict[str, str]], style: Optional[str] = None) -> List[Dict[str, str]]:
24
+ """Prepare messages for Anthropic API"""
25
+ processed_messages = []
26
+
27
+ # Add style instructions if provided
28
+ if style and style != "default":
29
+ style_instructions = self._get_style_instructions(style)
30
+ processed_messages.append({
31
+ "role": "system",
32
+ "content": style_instructions
33
+ })
34
+
35
+ # Add the rest of the messages
36
+ for message in messages:
37
+ # Ensure message has required fields
38
+ if "role" not in message or "content" not in message:
39
+ continue
40
+
41
+ # Map 'user' and 'assistant' roles directly
42
+ # Anthropic only supports 'user' and 'assistant' roles
43
+ if message["role"] in ["user", "assistant"]:
44
+ processed_messages.append(message)
45
+ elif message["role"] == "system":
46
+ # For system messages, we need to add them as system messages
47
+ processed_messages.append({
48
+ "role": "system",
49
+ "content": message["content"]
50
+ })
51
+ else:
52
+ # For any other role, treat as user message
53
+ processed_messages.append({
54
+ "role": "user",
55
+ "content": message["content"]
56
+ })
57
+
58
+ return processed_messages
59
+
60
+ def _get_style_instructions(self, style: str) -> str:
61
+ """Get formatting instructions for different styles"""
62
+ styles = {
63
+ "concise": "Please provide concise, to-the-point responses without unnecessary elaboration.",
64
+ "detailed": "Please provide comprehensive responses with thorough explanations and examples.",
65
+ "technical": "Please use precise technical language and focus on accuracy and technical details.",
66
+ "friendly": "Please use a warm, conversational tone and relatable examples.",
67
+ }
68
+
69
+ return styles.get(style, "")
70
+
71
+ async def generate_completion(self, messages: List[Dict[str, str]],
72
+ model: str,
73
+ style: Optional[str] = None,
74
+ temperature: float = 0.7,
75
+ max_tokens: Optional[int] = None) -> str:
76
+ """Generate a text completion using Anthropic"""
77
+ processed_messages = self._prepare_messages(messages, style)
78
+
79
+ try:
80
+ response = await self.client.messages.create(
81
+ model=model,
82
+ messages=processed_messages,
83
+ temperature=temperature,
84
+ max_tokens=max_tokens if max_tokens else 4096,
85
+ )
86
+
87
+ return response.content[0].text
88
+ except Exception as e:
89
+ logger.error(f"Error generating completion: {str(e)}")
90
+ raise Exception(f"Anthropic API error: {str(e)}")
91
+
92
+ async def generate_stream(self, messages: List[Dict[str, str]],
93
+ model: str,
94
+ style: Optional[str] = None,
95
+ temperature: float = 0.7,
96
+ max_tokens: Optional[int] = None) -> AsyncGenerator[str, None]:
97
+ """Generate a streaming text completion using Anthropic"""
98
+ try:
99
+ from app.main import debug_log # Import debug logging if available
100
+ debug_log(f"Anthropic: starting streaming generation with model: {model}")
101
+ except ImportError:
102
+ # If debug_log not available, create a no-op function
103
+ debug_log = lambda msg: None
104
+
105
+ processed_messages = self._prepare_messages(messages, style)
106
+
107
+ try:
108
+ debug_log(f"Anthropic: preparing {len(processed_messages)} messages for stream")
109
+
110
+ # Use more robust error handling with retry for connection issues
111
+ max_retries = 2
112
+ retry_count = 0
113
+
114
+ while retry_count <= max_retries:
115
+ try:
116
+ debug_log(f"Anthropic: creating stream with model {model}")
117
+
118
+ # Create the stream
119
+ stream = await self.client.messages.create(
120
+ model=model,
121
+ messages=processed_messages,
122
+ temperature=temperature,
123
+ max_tokens=max_tokens if max_tokens else 4096,
124
+ stream=True
125
+ )
126
+
127
+ # Store the stream for potential cancellation
128
+ self._active_stream = stream
129
+
130
+ debug_log("Anthropic: stream created successfully")
131
+
132
+ # Process stream chunks
133
+ chunk_count = 0
134
+ debug_log("Anthropic: starting to process chunks")
135
+
136
+ async for chunk in stream:
137
+ # Check if stream has been cancelled
138
+ if self._active_stream is None:
139
+ debug_log("Anthropic: stream was cancelled, stopping generation")
140
+ break
141
+
142
+ chunk_count += 1
143
+ try:
144
+ if hasattr(chunk, 'delta') and hasattr(chunk.delta, 'text'):
145
+ content = chunk.delta.text
146
+ if content is not None:
147
+ debug_log(f"Anthropic: yielding chunk {chunk_count} of length: {len(content)}")
148
+ yield content
149
+ else:
150
+ debug_log(f"Anthropic: skipping None content chunk {chunk_count}")
151
+ else:
152
+ debug_log(f"Anthropic: skipping chunk {chunk_count} with missing content")
153
+ except Exception as chunk_error:
154
+ debug_log(f"Anthropic: error processing chunk {chunk_count}: {str(chunk_error)}")
155
+ # Skip problematic chunks but continue processing
156
+ continue
157
+
158
+ debug_log(f"Anthropic: stream completed successfully with {chunk_count} chunks")
159
+
160
+ # Clear the active stream reference when done
161
+ self._active_stream = None
162
+
163
+ # If we reach this point, we've successfully processed the stream
164
+ break
165
+
166
+ except Exception as e:
167
+ debug_log(f"Anthropic: error in attempt {retry_count+1}/{max_retries+1}: {str(e)}")
168
+ retry_count += 1
169
+ if retry_count <= max_retries:
170
+ debug_log(f"Anthropic: retrying after error (attempt {retry_count+1})")
171
+ # Simple exponential backoff
172
+ await asyncio.sleep(1 * retry_count)
173
+ else:
174
+ debug_log("Anthropic: max retries reached, raising exception")
175
+ raise Exception(f"Anthropic streaming error after {max_retries+1} attempts: {str(e)}")
176
+
177
+ except Exception as e:
178
+ debug_log(f"Anthropic: error in generate_stream: {str(e)}")
179
+ # Yield a simple error message as a last resort to ensure UI updates
180
+ yield f"Error: {str(e)}"
181
+ raise Exception(f"Anthropic streaming error: {str(e)}")
182
+
183
+ async def cancel_stream(self) -> None:
184
+ """Cancel any active streaming request"""
185
+ logger.info("Cancelling active Anthropic stream")
186
+ try:
187
+ from app.main import debug_log
188
+ debug_log("Anthropic: cancelling active stream")
189
+ except ImportError:
190
+ pass
191
+
192
+ # Simply set the active stream to None
193
+ # This will cause the generate_stream method to stop processing chunks
194
+ self._active_stream = None
195
+ logger.info("Anthropic stream cancelled successfully")
196
+
197
+ async def get_available_models(self) -> List[Dict[str, Any]]:
198
+ """Get list of available Anthropic models"""
199
+ # Anthropic doesn't have a models endpoint, so we return a static list
200
+ models = [
201
+ {
202
+ "id": "claude-3-opus-20240229",
203
+ "name": "Claude 3 Opus",
204
+ "description": "Most powerful model for highly complex tasks",
205
+ "context_window": 200000,
206
+ "provider": "anthropic"
207
+ },
208
+ {
209
+ "id": "claude-3-sonnet-20240229",
210
+ "name": "Claude 3 Sonnet",
211
+ "description": "Balanced model for most tasks",
212
+ "context_window": 200000,
213
+ "provider": "anthropic"
214
+ },
215
+ {
216
+ "id": "claude-3-haiku-20240307",
217
+ "name": "Claude 3 Haiku",
218
+ "description": "Fastest and most compact model",
219
+ "context_window": 200000,
220
+ "provider": "anthropic"
221
+ },
222
+ {
223
+ "id": "claude-3-5-sonnet-20240620",
224
+ "name": "Claude 3.5 Sonnet",
225
+ "description": "Latest model with improved capabilities",
226
+ "context_window": 200000,
227
+ "provider": "anthropic"
228
+ },
229
+ {
230
+ "id": "claude-3-7-sonnet-20250219",
231
+ "name": "Claude 3.7 Sonnet",
232
+ "description": "Newest model with advanced reasoning",
233
+ "context_window": 200000,
234
+ "provider": "anthropic"
235
+ }
236
+ ]
237
+
238
+ return models
@@ -22,6 +22,11 @@ class BaseModelClient(ABC):
22
22
  """Generate a streaming text completion"""
23
23
  yield "" # Placeholder implementation
24
24
 
25
+ @abstractmethod
26
+ async def cancel_stream(self) -> None:
27
+ """Cancel any active streaming request"""
28
+ pass
29
+
25
30
  @abstractmethod
26
31
  def get_available_models(self) -> List[Dict[str, Any]]:
27
32
  """Get list of available models from this provider"""
@@ -11,6 +11,14 @@ from .base import BaseModelClient
11
11
  # Set up logging
12
12
  logger = logging.getLogger(__name__)
13
13
 
14
+ # Custom exception for Ollama API errors
15
+ class OllamaApiError(Exception):
16
+ """Exception raised for errors in the Ollama API."""
17
+ def __init__(self, message: str, status_code: Optional[int] = None):
18
+ self.message = message
19
+ self.status_code = status_code
20
+ super().__init__(self.message)
21
+
14
22
  class OllamaClient(BaseModelClient):
15
23
  def __init__(self):
16
24
  from ..config import OLLAMA_BASE_URL
@@ -266,6 +274,29 @@ class OllamaClient(BaseModelClient):
266
274
  last_error = None
267
275
  self._active_stream_session = None # Track the active session
268
276
 
277
+ # First check if the model exists in our available models
278
+ try:
279
+ available_models = await self.get_available_models()
280
+ model_exists = False
281
+ available_model_names = []
282
+
283
+ for m in available_models:
284
+ model_id = m.get("id", "")
285
+ available_model_names.append(model_id)
286
+ if model_id == model:
287
+ model_exists = True
288
+ break
289
+
290
+ if not model_exists:
291
+ error_msg = f"Model '{model}' not found in available models. Available models include: {', '.join(available_model_names[:5])}"
292
+ if len(available_model_names) > 5:
293
+ error_msg += f" and {len(available_model_names) - 5} more."
294
+ logger.error(error_msg)
295
+ raise OllamaApiError(error_msg)
296
+ except Exception as e:
297
+ debug_log(f"Error checking model availability: {str(e)}")
298
+ # Continue anyway, the main request will handle errors
299
+
269
300
  while retries >= 0:
270
301
  try:
271
302
  # First try a quick test request to check if model is loaded
@@ -299,6 +330,17 @@ class OllamaClient(BaseModelClient):
299
330
  if response.status != 200:
300
331
  logger.warning(f"Model test request failed with status {response.status}")
301
332
  debug_log(f"Model test request failed with status {response.status}")
333
+
334
+ # Check if this is a 404 Not Found error
335
+ if response.status == 404:
336
+ error_text = await response.text()
337
+ debug_log(f"404 error details: {error_text}")
338
+ error_msg = f"Error: Model '{model}' not found on the Ollama server. Please check if the model name is correct or try pulling it first."
339
+ logger.error(error_msg)
340
+ # Instead of raising, yield the error message for user display
341
+ yield error_msg
342
+ return # End the generation
343
+
302
344
  raise aiohttp.ClientError("Model not ready")
303
345
  except (aiohttp.ClientError, asyncio.TimeoutError) as e:
304
346
  logger.info(f"Model cold start detected: {str(e)}")
@@ -326,6 +368,16 @@ class OllamaClient(BaseModelClient):
326
368
  logger.error("Failed to pull model")
327
369
  debug_log("Failed to pull model")
328
370
  self._model_loading = False # Reset flag on failure
371
+
372
+ # Check if this is a 404 Not Found error
373
+ if pull_response.status == 404:
374
+ error_text = await pull_response.text()
375
+ debug_log(f"404 error details: {error_text}")
376
+ # This is likely a model not found in registry
377
+ error_msg = f"Error: Model '{model}' not found in the Ollama registry. Please check if the model name is correct or try a different model."
378
+ logger.error(error_msg)
379
+ raise OllamaApiError(error_msg, status_code=404)
380
+
329
381
  raise Exception("Failed to pull model")
330
382
  logger.info("Model pulled successfully")
331
383
  debug_log("Model pulled successfully")
@@ -3,10 +3,15 @@ import asyncio
3
3
  from typing import List, Dict, Any, Optional, Generator, AsyncGenerator
4
4
  from .base import BaseModelClient
5
5
  from ..config import OPENAI_API_KEY
6
+ import logging
7
+
8
+ # Set up logging
9
+ logger = logging.getLogger(__name__)
6
10
 
7
11
  class OpenAIClient(BaseModelClient):
8
12
  def __init__(self):
9
13
  self.client = None # Initialize in create()
14
+ self._active_stream = None # Track active stream for cancellation
10
15
 
11
16
  @classmethod
12
17
  async def create(cls) -> 'OpenAIClient':
@@ -115,6 +120,10 @@ class OpenAIClient(BaseModelClient):
115
120
  max_tokens=max_tokens,
116
121
  stream=True,
117
122
  )
123
+
124
+ # Store the stream for potential cancellation
125
+ self._active_stream = stream
126
+
118
127
  debug_log("OpenAI: stream created successfully")
119
128
 
120
129
  # Yield a small padding token at the beginning for very short prompts
@@ -128,6 +137,11 @@ class OpenAIClient(BaseModelClient):
128
137
  debug_log("OpenAI: starting to process chunks")
129
138
 
130
139
  async for chunk in stream:
140
+ # Check if stream has been cancelled
141
+ if self._active_stream is None:
142
+ debug_log("OpenAI: stream was cancelled, stopping generation")
143
+ break
144
+
131
145
  chunk_count += 1
132
146
  try:
133
147
  if chunk.choices and hasattr(chunk.choices[0], 'delta') and hasattr(chunk.choices[0].delta, 'content'):
@@ -148,6 +162,9 @@ class OpenAIClient(BaseModelClient):
148
162
 
149
163
  debug_log(f"OpenAI: stream completed successfully with {chunk_count} chunks")
150
164
 
165
+ # Clear the active stream reference when done
166
+ self._active_stream = None
167
+
151
168
  # If we reach this point, we've successfully processed the stream
152
169
  break
153
170
 
@@ -168,6 +185,20 @@ class OpenAIClient(BaseModelClient):
168
185
  yield f"Error: {str(e)}"
169
186
  raise Exception(f"OpenAI streaming error: {str(e)}")
170
187
 
188
+ async def cancel_stream(self) -> None:
189
+ """Cancel any active streaming request"""
190
+ logger.info("Cancelling active OpenAI stream")
191
+ try:
192
+ from app.main import debug_log
193
+ debug_log("OpenAI: cancelling active stream")
194
+ except ImportError:
195
+ pass
196
+
197
+ # Simply set the active stream to None
198
+ # This will cause the generate_stream method to stop processing chunks
199
+ self._active_stream = None
200
+ logger.info("OpenAI stream cancelled successfully")
201
+
171
202
  async def get_available_models(self) -> List[Dict[str, Any]]:
172
203
  """Fetch list of available OpenAI models from the /models endpoint"""
173
204
  try:
@@ -175,35 +175,38 @@ CONFIG = load_config()
175
175
 
176
176
  # --- Dynamically update Anthropic models after initial load ---
177
177
  def update_anthropic_models(config):
178
- """Fetch models from Anthropic API and update the config dict."""
178
+ """Update the config with Anthropic models."""
179
179
  if AVAILABLE_PROVIDERS["anthropic"]:
180
180
  try:
181
- from app.api.anthropic import AnthropicClient # Import here to avoid circular dependency at top level
182
- client = AnthropicClient()
183
- fetched_models = client.get_available_models() # This now fetches (or uses fallback)
184
-
185
- if fetched_models:
186
- # Remove old hardcoded anthropic models first
187
- models_to_remove = [
188
- model_id for model_id, info in config["available_models"].items()
189
- if info.get("provider") == "anthropic"
190
- ]
191
- for model_id in models_to_remove:
192
- del config["available_models"][model_id]
193
-
194
- # Add fetched models
195
- for model in fetched_models:
196
- config["available_models"][model["id"]] = {
197
- "provider": "anthropic",
198
- "max_tokens": 4096, # Assign a default max_tokens
199
- "display_name": model["name"]
200
- }
201
- print(f"Updated Anthropic models in config: {[m['id'] for m in fetched_models]}") # Add print statement
202
- else:
203
- print("Could not fetch or find Anthropic models to update config.") # Add print statement
204
-
181
+ # Instead of calling an async method, use a hardcoded fallback list
182
+ # that matches what's in the AnthropicClient class
183
+ fallback_models = [
184
+ {"id": "claude-3-opus-20240229", "name": "Claude 3 Opus"},
185
+ {"id": "claude-3-sonnet-20240229", "name": "Claude 3 Sonnet"},
186
+ {"id": "claude-3-haiku-20240307", "name": "Claude 3 Haiku"},
187
+ {"id": "claude-3-5-sonnet-20240620", "name": "Claude 3.5 Sonnet"},
188
+ {"id": "claude-3-7-sonnet-20250219", "name": "Claude 3.7 Sonnet"},
189
+ ]
190
+
191
+ # Remove old models first
192
+ models_to_remove = [
193
+ model_id for model_id, info in config["available_models"].items()
194
+ if info.get("provider") == "anthropic"
195
+ ]
196
+ for model_id in models_to_remove:
197
+ del config["available_models"][model_id]
198
+
199
+ # Add the fallback models
200
+ for model in fallback_models:
201
+ config["available_models"][model["id"]] = {
202
+ "provider": "anthropic",
203
+ "max_tokens": 4096,
204
+ "display_name": model["name"]
205
+ }
206
+ print(f"Updated Anthropic models in config with fallback list")
207
+
205
208
  except Exception as e:
206
- print(f"Error updating Anthropic models in config: {e}") # Add print statement
209
+ print(f"Error updating Anthropic models in config: {e}")
207
210
  # Keep existing config if update fails
208
211
 
209
212
  return config
@@ -20,10 +20,10 @@ file_handler = logging.FileHandler(debug_log_file)
20
20
  file_handler.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s'))
21
21
 
22
22
  # Get the logger and add the handler
23
- debug_logger = logging.getLogger("chat-cli-debug")
23
+ debug_logger = logging.getLogger() # Root logger
24
24
  debug_logger.setLevel(logging.DEBUG)
25
25
  debug_logger.addHandler(file_handler)
26
- # Prevent propagation to the root logger (which would print to console)
26
+ # CRITICAL: Force all output to the file, not stdout
27
27
  debug_logger.propagate = False
28
28
 
29
29
  # Add a convenience function to log to this file
@@ -1010,11 +1010,15 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
1010
1010
  self._loading_animation_task.cancel()
1011
1011
  self._loading_animation_task = None
1012
1012
  try:
1013
+ # Explicitly hide loading indicator
1013
1014
  loading = self.query_one("#loading-indicator")
1014
1015
  loading.add_class("hidden")
1016
+ loading.remove_class("model-loading") # Also remove model-loading class if present
1017
+ self.refresh(layout=True) # Force a refresh to ensure UI updates
1015
1018
  self.query_one("#message-input").focus()
1016
- except Exception:
1017
- pass # Ignore UI errors during cleanup
1019
+ except Exception as ui_err:
1020
+ debug_log(f"Error hiding loading indicator: {str(ui_err)}")
1021
+ log.error(f"Error hiding loading indicator: {str(ui_err)}")
1018
1022
 
1019
1023
  # Rename this method slightly to avoid potential conflicts and clarify purpose
1020
1024
  async def _handle_generation_result(self, worker: Worker[Optional[str]]) -> None:
@@ -1043,6 +1047,15 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
1043
1047
  debug_log(f"Error in generation worker: {error}")
1044
1048
  log.error(f"Error in generation worker: {error}")
1045
1049
 
1050
+ # Explicitly hide loading indicator
1051
+ try:
1052
+ loading = self.query_one("#loading-indicator")
1053
+ loading.add_class("hidden")
1054
+ loading.remove_class("model-loading") # Also remove model-loading class if present
1055
+ except Exception as ui_err:
1056
+ debug_log(f"Error hiding loading indicator: {str(ui_err)}")
1057
+ log.error(f"Error hiding loading indicator: {str(ui_err)}")
1058
+
1046
1059
  # Sanitize error message for UI display
1047
1060
  error_str = str(error)
1048
1061
 
@@ -1069,6 +1082,9 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
1069
1082
  debug_log(f"Adding error message: {user_error}")
1070
1083
  self.messages.append(Message(role="assistant", content=user_error))
1071
1084
  await self.update_messages_ui()
1085
+
1086
+ # Force a refresh to ensure UI updates
1087
+ self.refresh(layout=True)
1072
1088
 
1073
1089
  elif worker.state == "success":
1074
1090
  full_response = worker.result
@@ -120,54 +120,61 @@ class MessageDisplay(Static): # Inherit from Static instead of RichLog
120
120
  self.update(self._format_content(self.message.content))
121
121
 
122
122
  async def update_content(self, content: str) -> None:
123
- """Update the message content using Static.update() with optimizations for streaming"""
124
- # Debug print to verify method is being called with content
125
- print(f"MessageDisplay.update_content called with content length: {len(content)}")
126
-
127
- # Quick unchanged content check to avoid unnecessary updates
128
- if self.message.content == content:
129
- print("Content unchanged, skipping update")
130
- return
131
-
132
- # Special handling for "Thinking..." to ensure it gets replaced
133
- if self.message.content == "Thinking..." and content:
134
- print("Replacing 'Thinking...' with actual content")
135
- # Force a complete replacement rather than an append
136
- self.message.content = ""
137
- # Add a debug print to confirm this branch is executed
138
- print("CRITICAL FIX: Replacing 'Thinking...' placeholder with actual content")
139
-
140
- # Update the stored message object content
141
- self.message.content = content
142
-
143
- # Format with fixed-width placeholder to minimize layout shifts
144
- # This avoids text reflowing as new tokens arrive
145
- formatted_content = self._format_content(content)
146
-
147
- # Use a direct update that forces refresh - critical fix for streaming
148
- # This ensures content is immediately visible
149
- print(f"Updating widget with formatted content length: {len(formatted_content)}")
150
- self.update(formatted_content, refresh=True)
151
-
152
- # Force app-level refresh and scroll to ensure visibility
153
- try:
154
- # Always force app refresh for every update
155
- if self.app:
156
- # Force a full layout refresh to ensure content is visible
157
- self.app.refresh(layout=True)
123
+ """Update the message content."""
124
+ import logging
125
+ logger = logging.getLogger(__name__)
126
+ logger.debug(f"MessageDisplay.update_content called with content length: {len(content)}")
127
+
128
+ # Use a lock to prevent race conditions during updates
129
+ if not hasattr(self, '_update_lock'):
130
+ self._update_lock = asyncio.Lock()
131
+
132
+ async with self._update_lock:
133
+ # For initial update from "Thinking..."
134
+ if self.message.content == "Thinking..." and content:
135
+ logger.debug("Replacing 'Thinking...' with initial content")
136
+ self.message.content = content # Update the stored content
137
+ formatted = self._format_content(content)
138
+ self.update(formatted, refresh=True)
158
139
 
159
- # Find the messages container and scroll to end
160
- containers = self.app.query("ScrollableContainer")
161
- for container in containers:
162
- if hasattr(container, 'scroll_end'):
163
- container.scroll_end(animate=False)
140
+ # Force a clean layout update
141
+ try:
142
+ if self.app:
143
+ self.app.refresh(layout=True)
144
+ await asyncio.sleep(0.05) # Small delay for layout to update
164
145
 
165
- # Add an additional refresh after scrolling
166
- self.app.refresh(layout=True)
167
- except Exception as e:
168
- # Log the error and fallback to local refresh
169
- print(f"Error refreshing app: {str(e)}")
170
- self.refresh(layout=True)
146
+ # Find container and scroll
147
+ messages_container = self.app.query_one("#messages-container")
148
+ if messages_container:
149
+ messages_container.scroll_end(animate=False)
150
+ except Exception as e:
151
+ logger.error(f"Error in initial UI update: {str(e)}")
152
+ return
153
+
154
+ # Quick unchanged content check to avoid unnecessary updates
155
+ if self.message.content == content:
156
+ logger.debug("Content unchanged, skipping update")
157
+ return
158
+
159
+ # For subsequent updates
160
+ if self.message.content != content:
161
+ self.message.content = content
162
+ formatted = self._format_content(content)
163
+ self.update(formatted, refresh=True)
164
+
165
+ # Use a more targeted refresh approach
166
+ try:
167
+ if self.app:
168
+ self.app.refresh(layout=False) # Lightweight refresh first
169
+ # Find container and scroll
170
+ messages_container = self.app.query_one("#messages-container")
171
+ if messages_container:
172
+ messages_container.scroll_end(animate=False)
173
+
174
+ # Final full refresh only at end
175
+ self.app.refresh(layout=True)
176
+ except Exception as e:
177
+ logger.error(f"Error refreshing UI: {str(e)}")
171
178
 
172
179
  def _format_content(self, content: str) -> str:
173
180
  """Format message content with timestamp and handle markdown links"""
@@ -191,8 +198,8 @@ class MessageDisplay(Static): # Inherit from Static instead of RichLog
191
198
  # But keep our timestamp markup
192
199
  timestamp_markup = f"[dim]{timestamp}[/dim]"
193
200
 
194
- # Debug print to verify content is being formatted
195
- print(f"Formatting content: {len(content)} chars")
201
+ # Use proper logging instead of print
202
+ logger.debug(f"Formatting content: {len(content)} chars")
196
203
 
197
204
  return f"{timestamp_markup} {content}"
198
205