chat-console 0.2.99__tar.gz → 0.3.0__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 (29) hide show
  1. {chat_console-0.2.99 → chat_console-0.3.0}/PKG-INFO +1 -1
  2. {chat_console-0.2.99 → chat_console-0.3.0}/app/__init__.py +1 -1
  3. {chat_console-0.2.99 → chat_console-0.3.0}/app/api/anthropic.py +96 -72
  4. {chat_console-0.2.99 → chat_console-0.3.0}/app/api/base.py +2 -2
  5. {chat_console-0.2.99 → chat_console-0.3.0}/app/api/ollama.py +21 -10
  6. {chat_console-0.2.99 → chat_console-0.3.0}/app/api/openai.py +71 -24
  7. {chat_console-0.2.99 → chat_console-0.3.0}/app/main.py +55 -14
  8. {chat_console-0.2.99 → chat_console-0.3.0}/app/ui/chat_interface.py +43 -4
  9. {chat_console-0.2.99 → chat_console-0.3.0}/app/ui/model_selector.py +24 -11
  10. {chat_console-0.2.99 → chat_console-0.3.0}/app/utils.py +65 -11
  11. {chat_console-0.2.99 → chat_console-0.3.0}/chat_console.egg-info/PKG-INFO +1 -1
  12. {chat_console-0.2.99 → chat_console-0.3.0}/LICENSE +0 -0
  13. {chat_console-0.2.99 → chat_console-0.3.0}/README.md +0 -0
  14. {chat_console-0.2.99 → chat_console-0.3.0}/app/api/__init__.py +0 -0
  15. {chat_console-0.2.99 → chat_console-0.3.0}/app/config.py +0 -0
  16. {chat_console-0.2.99 → chat_console-0.3.0}/app/database.py +0 -0
  17. {chat_console-0.2.99 → chat_console-0.3.0}/app/models.py +0 -0
  18. {chat_console-0.2.99 → chat_console-0.3.0}/app/ui/__init__.py +0 -0
  19. {chat_console-0.2.99 → chat_console-0.3.0}/app/ui/chat_list.py +0 -0
  20. {chat_console-0.2.99 → chat_console-0.3.0}/app/ui/model_browser.py +0 -0
  21. {chat_console-0.2.99 → chat_console-0.3.0}/app/ui/search.py +0 -0
  22. {chat_console-0.2.99 → chat_console-0.3.0}/app/ui/styles.py +0 -0
  23. {chat_console-0.2.99 → chat_console-0.3.0}/chat_console.egg-info/SOURCES.txt +0 -0
  24. {chat_console-0.2.99 → chat_console-0.3.0}/chat_console.egg-info/dependency_links.txt +0 -0
  25. {chat_console-0.2.99 → chat_console-0.3.0}/chat_console.egg-info/entry_points.txt +0 -0
  26. {chat_console-0.2.99 → chat_console-0.3.0}/chat_console.egg-info/requires.txt +0 -0
  27. {chat_console-0.2.99 → chat_console-0.3.0}/chat_console.egg-info/top_level.txt +0 -0
  28. {chat_console-0.2.99 → chat_console-0.3.0}/setup.cfg +0 -0
  29. {chat_console-0.2.99 → chat_console-0.3.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chat-console
3
- Version: 0.2.99
3
+ Version: 0.3.0
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.2.99"
6
+ __version__ = "0.3.0"
@@ -144,86 +144,110 @@ class AnthropicClient(BaseModelClient):
144
144
  except ImportError:
145
145
  debug_log = lambda msg: None
146
146
 
147
+ # Always include a reliable fallback list in case API calls fail
148
+ fallback_models = [
149
+ {"id": "claude-3-opus-20240229", "name": "Claude 3 Opus"},
150
+ {"id": "claude-3-sonnet-20240229", "name": "Claude 3 Sonnet"},
151
+ {"id": "claude-3-haiku-20240307", "name": "Claude 3 Haiku"},
152
+ {"id": "claude-3-5-sonnet-20240620", "name": "Claude 3.5 Sonnet"},
153
+ {"id": "claude-3-7-sonnet-20250219", "name": "Claude 3.7 Sonnet"},
154
+ ]
155
+
156
+ # If no client is initialized, return fallback immediately
157
+ if not self.client:
158
+ debug_log("Anthropic: No client initialized, using fallback models")
159
+ return fallback_models
160
+
147
161
  try:
148
162
  debug_log("Anthropic: Fetching models from API...")
149
- # The Anthropic Python SDK might not have a direct high-level method for listing models yet.
150
- # We might need to use the underlying HTTP client or make a direct request.
151
- # Let's assume for now the SDK client *does* have a way, like self.client.models.list()
152
- # If this fails, we'd need to implement a direct HTTP GET request.
153
- # response = await self.client.models.list() # Hypothetical SDK method
154
-
155
- # --- Alternative: Direct HTTP Request using httpx (if client exposes it) ---
156
- # Check if the client has an internal http_client we can use
163
+
164
+ # Try using the models.list method if available in newer SDK versions
165
+ if hasattr(self.client, 'models') and hasattr(self.client.models, 'list'):
166
+ try:
167
+ debug_log("Anthropic: Using client.models.list() method")
168
+ models_response = await self.client.models.list()
169
+ if hasattr(models_response, 'data') and isinstance(models_response.data, list):
170
+ formatted_models = [
171
+ {"id": model.id, "name": getattr(model, "name", model.id)}
172
+ for model in models_response.data
173
+ ]
174
+ debug_log(f"Anthropic: Found {len(formatted_models)} models via SDK")
175
+ return formatted_models
176
+ except Exception as sdk_err:
177
+ debug_log(f"Anthropic: Error using models.list(): {str(sdk_err)}")
178
+ # Continue to next method
179
+
180
+ # Try direct HTTP request if client exposes the underlying HTTP client
157
181
  if hasattr(self.client, '_client') and hasattr(self.client._client, 'get'):
158
- response = await self.client._client.get(
159
- "/v1/models",
160
- headers={"anthropic-version": "2023-06-01"} # Add required version header
161
- )
162
- response.raise_for_status() # Raise HTTP errors
163
- models_data = response.json()
164
- debug_log(f"Anthropic: API response received: {models_data}")
165
- if 'data' in models_data and isinstance(models_data['data'], list):
166
- # Format the response as expected: list of {"id": ..., "name": ...}
167
- formatted_models = [
168
- {"id": model.get("id"), "name": model.get("display_name", model.get("id"))}
169
- for model in models_data['data']
170
- if model.get("id") # Ensure model has an ID
171
- ]
172
- # Log each model ID clearly for debugging
173
- debug_log(f"Anthropic: Available models from API:")
174
- for model in formatted_models:
175
- debug_log(f" - ID: {model.get('id')}, Name: {model.get('name')}")
176
- return formatted_models
177
- else:
178
- debug_log("Anthropic: Unexpected API response format for models.")
179
- return []
180
- else:
181
- debug_log("Anthropic: Client does not expose HTTP client for model listing. Returning empty list.")
182
- return [] # Cannot fetch dynamically
182
+ try:
183
+ debug_log("Anthropic: Using direct HTTP request to /v1/models")
184
+ response = await self.client._client.get(
185
+ "/v1/models",
186
+ headers={"anthropic-version": "2023-06-01"}
187
+ )
188
+ response.raise_for_status()
189
+ models_data = response.json()
190
+
191
+ if 'data' in models_data and isinstance(models_data['data'], list):
192
+ formatted_models = [
193
+ {"id": model.get("id"), "name": model.get("display_name", model.get("id"))}
194
+ for model in models_data['data']
195
+ if model.get("id")
196
+ ]
197
+ debug_log(f"Anthropic: Found {len(formatted_models)} models via HTTP request")
198
+ return formatted_models
199
+ else:
200
+ debug_log("Anthropic: Unexpected API response format")
201
+ except Exception as http_err:
202
+ debug_log(f"Anthropic: HTTP request error: {str(http_err)}")
203
+ # Continue to fallback
204
+
205
+ # If we reach here, both methods failed
206
+ debug_log("Anthropic: All API methods failed, using fallback models")
207
+ return fallback_models
183
208
 
184
209
  except Exception as e:
185
210
  debug_log(f"Anthropic: Failed to fetch models from API: {str(e)}")
186
- # Fallback to a minimal hardcoded list in case of API error
187
- # Include Claude 3.7 Sonnet with the correct full ID
188
- fallback_models = [
189
- {"id": "claude-3-opus-20240229", "name": "Claude 3 Opus"},
190
- {"id": "claude-3-sonnet-20240229", "name": "Claude 3 Sonnet"},
191
- {"id": "claude-3-haiku-20240307", "name": "Claude 3 Haiku"},
192
- {"id": "claude-3-5-sonnet-20240620", "name": "Claude 3.5 Sonnet"},
193
- {"id": "claude-3-7-sonnet-20250219", "name": "Claude 3.7 Sonnet"}, # Add Claude 3.7 Sonnet
194
- ]
195
- debug_log("Anthropic: Using fallback model list:")
196
- for model in fallback_models:
197
- debug_log(f" - ID: {model['id']}, Name: {model['name']}")
211
+ debug_log("Anthropic: Using fallback model list")
198
212
  return fallback_models
199
213
 
200
- # Keep this synchronous for now, but make it call the async fetcher
201
- # Note: This is slightly awkward. Ideally, config loading would be async.
202
- # For now, we'll run the async fetcher within the sync method using asyncio.run()
203
- # This is NOT ideal for performance but avoids larger refactoring of config loading.
204
214
  def get_available_models(self) -> List[Dict[str, Any]]:
205
215
  """Get list of available Claude models by fetching from API."""
216
+ # Reliable fallback list that doesn't depend on async operations
217
+ fallback_models = [
218
+ {"id": "claude-3-opus-20240229", "name": "Claude 3 Opus"},
219
+ {"id": "claude-3-sonnet-20240229", "name": "Claude 3 Sonnet"},
220
+ {"id": "claude-3-haiku-20240307", "name": "Claude 3 Haiku"},
221
+ {"id": "claude-3-5-sonnet-20240620", "name": "Claude 3.5 Sonnet"},
222
+ {"id": "claude-3-7-sonnet-20250219", "name": "Claude 3.7 Sonnet"},
223
+ ]
224
+
206
225
  try:
207
- # Run the async fetcher method synchronously
208
- models = asyncio.run(self._fetch_models_from_api())
209
- return models
210
- except RuntimeError as e:
211
- # Handle cases where asyncio.run can't be called (e.g., already in an event loop)
212
- # This might happen during app runtime if called again. Fallback needed.
213
- try:
214
- from app.main import debug_log
215
- except ImportError:
216
- debug_log = lambda msg: None
217
- debug_log(f"Anthropic: Cannot run async model fetch synchronously ({e}). Falling back to hardcoded list.")
218
- # Use the same fallback list as in _fetch_models_from_api
219
- fallback_models = [
220
- {"id": "claude-3-opus-20240229", "name": "Claude 3 Opus"},
221
- {"id": "claude-3-sonnet-20240229", "name": "Claude 3 Sonnet"},
222
- {"id": "claude-3-haiku-20240307", "name": "Claude 3 Haiku"},
223
- {"id": "claude-3-5-sonnet-20240620", "name": "Claude 3.5 Sonnet"},
224
- {"id": "claude-3-7-sonnet-20250219", "name": "Claude 3.7 Sonnet"}, # Add Claude 3.7 Sonnet
225
- ]
226
- debug_log("Anthropic: Using fallback model list in get_available_models:")
227
- for model in fallback_models:
228
- debug_log(f" - ID: {model['id']}, Name: {model['name']}")
229
- return fallback_models
226
+ # Check if we're already in an event loop
227
+ try:
228
+ loop = asyncio.get_running_loop()
229
+ in_loop = True
230
+ except RuntimeError:
231
+ in_loop = False
232
+
233
+ if in_loop:
234
+ # We're already in an event loop, create a future
235
+ try:
236
+ from app.main import debug_log
237
+ except ImportError:
238
+ debug_log = lambda msg: None
239
+
240
+ debug_log("Anthropic: Already in event loop, using fallback models")
241
+ return fallback_models
242
+ else:
243
+ # Not in an event loop, we can use asyncio.run
244
+ models = asyncio.run(self._fetch_models_from_api())
245
+ return models
246
+ except Exception as e:
247
+ try:
248
+ from app.main import debug_log
249
+ except ImportError:
250
+ debug_log = lambda msg: None
251
+
252
+ debug_log(f"Anthropic: Error in get_available_models: {str(e)}")
253
+ return fallback_models
@@ -120,8 +120,8 @@ class BaseModelClient(ABC):
120
120
  if provider == "ollama":
121
121
  return await OllamaClient.create()
122
122
  elif provider == "openai":
123
- return OpenAIClient()
123
+ return await OpenAIClient.create()
124
124
  elif provider == "anthropic":
125
- return AnthropicClient()
125
+ return await AnthropicClient.create()
126
126
  else:
127
127
  raise ValueError(f"Unknown provider: {provider}")
@@ -369,6 +369,10 @@ class OllamaClient(BaseModelClient):
369
369
 
370
370
  # Use a simpler async iteration pattern that's less error-prone
371
371
  debug_log("Starting to process response stream")
372
+
373
+ # Set a flag to track if we've yielded any content
374
+ has_yielded_content = False
375
+
372
376
  async for line in response.content:
373
377
  # Check cancellation periodically
374
378
  if self._active_stream_session is None:
@@ -378,31 +382,38 @@ class OllamaClient(BaseModelClient):
378
382
  try:
379
383
  # Process the chunk
380
384
  if line:
381
- chunk = line.decode().strip()
382
385
  chunk_str = line.decode().strip()
383
386
  # Check if it looks like JSON before trying to parse
384
387
  if chunk_str.startswith('{') and chunk_str.endswith('}'):
385
388
  try:
386
389
  data = json.loads(chunk_str)
387
390
  if isinstance(data, dict) and "response" in data:
388
- chunk_length = len(data["response"]) if data["response"] else 0
389
- debug_log(f"Yielding chunk of length: {chunk_length}")
390
- yield data["response"]
391
+ response_text = data["response"]
392
+ if response_text: # Only yield non-empty responses
393
+ has_yielded_content = True
394
+ chunk_length = len(response_text)
395
+ # Only log occasionally to reduce console spam
396
+ if chunk_length % 20 == 0:
397
+ debug_log(f"Yielding chunk of length: {chunk_length}")
398
+ yield response_text
391
399
  else:
392
- debug_log(f"JSON chunk missing 'response' key: {chunk_str}")
400
+ debug_log(f"JSON chunk missing 'response' key: {chunk_str[:100]}")
393
401
  except json.JSONDecodeError:
394
- debug_log(f"JSON decode error for chunk: {chunk_str}")
402
+ debug_log(f"JSON decode error for chunk: {chunk_str[:100]}")
395
403
  else:
396
404
  # Log unexpected non-JSON lines but don't process them
397
- if chunk_str: # Avoid logging empty lines
398
- debug_log(f"Received unexpected non-JSON line: {chunk_str}")
399
- # Continue processing next line regardless of parsing success/failure of current line
400
- continue
405
+ if chunk_str and len(chunk_str) > 5: # Avoid logging empty or tiny lines
406
+ debug_log(f"Received unexpected non-JSON line: {chunk_str[:100]}")
401
407
  except Exception as chunk_err:
402
408
  debug_log(f"Error processing chunk: {str(chunk_err)}")
403
409
  # Continue instead of breaking to try processing more chunks
404
410
  continue
405
411
 
412
+ # If we didn't yield any content, yield a default message
413
+ if not has_yielded_content:
414
+ debug_log("No content was yielded from stream, providing fallback response")
415
+ yield "I'm sorry, but I couldn't generate a response. Please try again or try a different model."
416
+
406
417
  logger.info("Streaming completed successfully")
407
418
  debug_log("Streaming completed successfully")
408
419
  return
@@ -1,4 +1,5 @@
1
1
  from openai import AsyncOpenAI
2
+ import asyncio
2
3
  from typing import List, Dict, Any, Optional, Generator, AsyncGenerator
3
4
  from .base import BaseModelClient
4
5
  from ..config import OPENAI_API_KEY
@@ -84,41 +85,87 @@ class OpenAIClient(BaseModelClient):
84
85
  debug_log(f"OpenAI: skipping invalid message: {m}")
85
86
 
86
87
  debug_log(f"OpenAI: prepared {len(api_messages)} valid messages")
88
+
89
+ # Check for empty or very short prompts and enhance them slightly
90
+ # This helps with the "hi" case where OpenAI might not generate a meaningful response
91
+ if api_messages and len(api_messages) > 0:
92
+ last_message = api_messages[-1]
93
+ if last_message["role"] == "user" and len(last_message["content"].strip()) <= 3:
94
+ debug_log(f"OpenAI: Enhancing very short user prompt: '{last_message['content']}'")
95
+ last_message["content"] = f"{last_message['content']} - Please respond conversationally."
96
+ debug_log(f"OpenAI: Enhanced to: '{last_message['content']}'")
97
+
87
98
  except Exception as msg_error:
88
99
  debug_log(f"OpenAI: error preparing messages: {str(msg_error)}")
89
100
  # Fallback to a simpler message format if processing fails
90
101
  api_messages = [{"role": "user", "content": "Please respond to my request."}]
91
102
 
92
103
  debug_log("OpenAI: requesting stream")
93
- stream = await self.client.chat.completions.create(
94
- model=model,
95
- messages=api_messages,
96
- temperature=temperature,
97
- max_tokens=max_tokens,
98
- stream=True,
99
- )
100
104
 
101
- debug_log("OpenAI: stream created successfully, processing chunks")
102
- async for chunk in stream:
105
+ # Use more robust error handling with retry for connection issues
106
+ max_retries = 2
107
+ retry_count = 0
108
+
109
+ while retry_count <= max_retries:
103
110
  try:
104
- if chunk.choices and hasattr(chunk.choices[0], 'delta') and hasattr(chunk.choices[0].delta, 'content'):
105
- content = chunk.choices[0].delta.content
106
- if content is not None:
107
- # Ensure we're returning a string
108
- text = str(content)
109
- debug_log(f"OpenAI: yielding chunk of length: {len(text)}")
110
- yield text
111
- else:
112
- debug_log("OpenAI: skipping None content chunk")
113
- else:
114
- debug_log("OpenAI: skipping chunk with missing content")
115
- except Exception as chunk_error:
116
- debug_log(f"OpenAI: error processing chunk: {str(chunk_error)}")
117
- # Skip problematic chunks but continue processing
118
- continue
111
+ stream = await self.client.chat.completions.create(
112
+ model=model,
113
+ messages=api_messages,
114
+ temperature=temperature,
115
+ max_tokens=max_tokens,
116
+ stream=True,
117
+ )
118
+ debug_log("OpenAI: stream created successfully")
119
+
120
+ # Yield a small padding token at the beginning for very short prompts
121
+ # This ensures the UI sees immediate content updates
122
+ if any(m["role"] == "user" and len(m["content"].strip()) <= 3 for m in api_messages):
123
+ debug_log("OpenAI: Adding initial padding token for short message")
124
+ yield "" # Empty string to trigger UI update cycle
119
125
 
126
+ # Process stream chunks
127
+ chunk_count = 0
128
+ debug_log("OpenAI: starting to process chunks")
129
+
130
+ async for chunk in stream:
131
+ chunk_count += 1
132
+ try:
133
+ if chunk.choices and hasattr(chunk.choices[0], 'delta') and hasattr(chunk.choices[0].delta, 'content'):
134
+ content = chunk.choices[0].delta.content
135
+ if content is not None:
136
+ # Ensure we're returning a string
137
+ text = str(content)
138
+ debug_log(f"OpenAI: yielding chunk {chunk_count} of length: {len(text)}")
139
+ yield text
140
+ else:
141
+ debug_log(f"OpenAI: skipping None content chunk {chunk_count}")
142
+ else:
143
+ debug_log(f"OpenAI: skipping chunk {chunk_count} with missing content")
144
+ except Exception as chunk_error:
145
+ debug_log(f"OpenAI: error processing chunk {chunk_count}: {str(chunk_error)}")
146
+ # Skip problematic chunks but continue processing
147
+ continue
148
+
149
+ debug_log(f"OpenAI: stream completed successfully with {chunk_count} chunks")
150
+
151
+ # If we reach this point, we've successfully processed the stream
152
+ break
153
+
154
+ except Exception as e:
155
+ debug_log(f"OpenAI: error in attempt {retry_count+1}/{max_retries+1}: {str(e)}")
156
+ retry_count += 1
157
+ if retry_count <= max_retries:
158
+ debug_log(f"OpenAI: retrying after error (attempt {retry_count+1})")
159
+ # Simple exponential backoff
160
+ await asyncio.sleep(1 * retry_count)
161
+ else:
162
+ debug_log("OpenAI: max retries reached, raising exception")
163
+ raise Exception(f"OpenAI streaming error after {max_retries+1} attempts: {str(e)}")
164
+
120
165
  except Exception as e:
121
166
  debug_log(f"OpenAI: error in generate_stream: {str(e)}")
167
+ # Yield a simple error message as a last resort to ensure UI updates
168
+ yield f"Error: {str(e)}"
122
169
  raise Exception(f"OpenAI streaming error: {str(e)}")
123
170
 
124
171
  def get_available_models(self) -> List[Dict[str, Any]]:
@@ -23,6 +23,8 @@ file_handler.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelnam
23
23
  debug_logger = logging.getLogger("chat-cli-debug")
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)
27
+ debug_logger.propagate = False
26
28
 
27
29
  # Add a convenience function to log to this file
28
30
  def debug_log(message):
@@ -642,9 +644,10 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
642
644
  await self.update_messages_ui()
643
645
 
644
646
  # If this is the first message and dynamic titles are enabled, generate one
645
- if is_first_message and self.current_conversation and CONFIG.get("generate_dynamic_titles", True):
647
+ # Only attempt title generation if the message has sufficient content (at least 3 characters)
648
+ if is_first_message and self.current_conversation and CONFIG.get("generate_dynamic_titles", True) and len(content) >= 3:
646
649
  log("First message detected, generating title...")
647
- debug_log("First message detected, attempting to generate conversation title")
650
+ debug_log(f"First message detected with length {len(content)}, generating conversation title")
648
651
  title_generation_in_progress = True # Use a local flag
649
652
  loading = self.query_one("#loading-indicator")
650
653
  loading.remove_class("hidden") # Show loading for title gen
@@ -942,30 +945,40 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
942
945
 
943
946
  # Update UI with the content - this no longer triggers refresh itself
944
947
  await message_display.update_content(content)
948
+
949
+ # Force a refresh after each update to ensure content is visible
950
+ # This is critical for streaming to work properly
951
+ self.refresh(layout=False)
945
952
 
953
+ # Scroll after each content update to ensure it's visible
954
+ messages_container.scroll_end(animate=False)
955
+
946
956
  # Much more aggressive throttling of UI updates to eliminate visual jitter
947
957
  # By using a larger modulo value, we significantly reduce refresh frequency
948
958
  # This improves stability at the cost of slightly choppier animations
949
959
  content_length = len(content)
950
960
 
951
- # Define some key refresh points
961
+ # Define some key refresh points - more frequent than before
952
962
  new_paragraph = content.endswith("\n") and content.count("\n") > 0
963
+ code_block = "```" in content
953
964
  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
965
+ content_length < 10 or # More frequent on first few tokens
966
+ content_length % 32 == 0 or # More frequent periodic updates (32 vs 64)
967
+ new_paragraph or # Refresh on paragraph breaks
968
+ code_block # Refresh when code blocks are detected
957
969
  )
958
970
 
959
- # Check if it's been enough time since last refresh (250ms minimum)
971
+ # Check if it's been enough time since last refresh (reduced to 200ms from 250ms)
960
972
  current_time = time.time()
961
973
  time_since_refresh = current_time - last_refresh_time
962
974
 
963
- if do_refresh and time_since_refresh > 0.25:
975
+ if do_refresh and time_since_refresh > 0.2:
964
976
  # Store the time we did the refresh
965
977
  last_refresh_time = current_time
966
- # Skip layout updates completely during streaming
967
- # Just ensure content is still visible by scrolling
978
+ # Ensure content is still visible by scrolling
968
979
  messages_container.scroll_end(animate=False)
980
+ # Force a more thorough refresh periodically
981
+ self.refresh(layout=True)
969
982
  except Exception as e:
970
983
  debug_log(f"Error updating UI: {str(e)}")
971
984
  log.error(f"Error updating UI: {str(e)}")
@@ -1054,6 +1067,21 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
1054
1067
  # Update the final message object content (optional, UI should be up-to-date)
1055
1068
  if self.messages and self.messages[-1].role == "assistant":
1056
1069
  self.messages[-1].content = full_response
1070
+
1071
+ # Force a UI refresh with the message display to ensure it's fully rendered
1072
+ try:
1073
+ # Get the message display for the assistant message
1074
+ messages_container = self.query_one("#messages-container")
1075
+ message_displays = messages_container.query("MessageDisplay")
1076
+ # Check if we found any message displays
1077
+ if message_displays and len(message_displays) > 0:
1078
+ # Get the last message display which should be our assistant message
1079
+ last_message_display = message_displays[-1]
1080
+ debug_log("Forcing final content update on message display")
1081
+ # Force a final content update
1082
+ await last_message_display.update_content(full_response)
1083
+ except Exception as disp_err:
1084
+ debug_log(f"Error updating final message display: {str(disp_err)}")
1057
1085
  else:
1058
1086
  debug_log("Worker finished successfully but response was empty or invalid.")
1059
1087
  # Handle case where 'Thinking...' might still be the last message
@@ -1061,11 +1089,24 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
1061
1089
  self.messages.pop() # Remove 'Thinking...' if no content arrived
1062
1090
  await self.update_messages_ui()
1063
1091
 
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
1092
+ # Force a full UI refresh to ensure content is visible
1068
1093
  messages_container = self.query_one("#messages-container")
1094
+
1095
+ # Sequence of UI refreshes to ensure content is properly displayed
1096
+ # 1. First do a lightweight refresh
1097
+ self.refresh(layout=False)
1098
+
1099
+ # 2. Short delay to allow the UI to process
1100
+ await asyncio.sleep(0.1)
1101
+
1102
+ # 3. Ensure we're scrolled to the end
1103
+ messages_container.scroll_end(animate=False)
1104
+
1105
+ # 4. Full layout refresh
1106
+ self.refresh(layout=True)
1107
+
1108
+ # 5. Final delay and scroll to ensure everything is visible
1109
+ await asyncio.sleep(0.1)
1069
1110
  messages_container.scroll_end(animate=False)
1070
1111
 
1071
1112
  except Exception as e:
@@ -136,13 +136,52 @@ class MessageDisplay(Static): # Inherit from Static instead of RichLog
136
136
  # This allows parent to control refresh timing and avoid flickering
137
137
  self.update(formatted_content, refresh=False)
138
138
 
139
- # No refresh or layout recalculation is performed here
140
- # The parent container will handle refresh timing for better stability
139
+ # Always force a minimal refresh to ensure content is visible
140
+ # This is critical for streaming to work properly
141
+ self.refresh(layout=False)
142
+
143
+ # For Ollama responses, we need more aggressive refresh
144
+ # Check if this is likely an Ollama response by looking at the parent app
145
+ try:
146
+ app = self.app
147
+ if app and hasattr(app, 'selected_model'):
148
+ model = app.selected_model
149
+ if model and ('llama' in model.lower() or 'mistral' in model.lower() or
150
+ 'gemma' in model.lower() or 'phi' in model.lower() or
151
+ 'ollama' in model.lower()):
152
+ # This is likely an Ollama model, force a more thorough refresh
153
+ # Without doing a full layout recalculation
154
+ self.refresh(layout=True)
155
+
156
+ # Force parent container to scroll to end
157
+ try:
158
+ parent = self.parent
159
+ if parent and hasattr(parent, 'scroll_end'):
160
+ parent.scroll_end(animate=False)
161
+ except Exception:
162
+ pass
163
+ except Exception:
164
+ # Ignore any errors in this detection logic
165
+ pass
141
166
 
142
167
  def _format_content(self, content: str) -> str:
143
- """Format message content with timestamp"""
168
+ """Format message content with timestamp and handle markdown links"""
144
169
  timestamp = datetime.now().strftime("%H:%M")
145
- return f"[dim]{timestamp}[/dim] {content}"
170
+
171
+ # Fix markdown-style links that cause markup errors
172
+ # Convert [text](url) to a safe format for Textual markup
173
+ content = re.sub(
174
+ r'\[([^\]]+)\]\(([^)]+)\)',
175
+ lambda m: f"{m.group(1)} ({m.group(2)})",
176
+ content
177
+ )
178
+
179
+ # Escape any other potential markup characters
180
+ content = content.replace("[", "\\[").replace("]", "\\]")
181
+ # But keep our timestamp markup
182
+ timestamp_markup = f"[dim]{timestamp}[/dim]"
183
+
184
+ return f"{timestamp_markup} {content}"
146
185
 
147
186
  class InputWithFocus(Input):
148
187
  """Enhanced Input that better handles focus and maintains cursor position"""
@@ -243,12 +243,14 @@ class ModelSelector(Container):
243
243
 
244
244
  # Set the model if we found one
245
245
  if first_model and len(first_model) >= 2:
246
- # Resolve the model ID before storing and sending
246
+ # Get the original ID from the model option
247
247
  original_id = first_model[1]
248
+ # Resolve the model ID for internal use and messaging
248
249
  resolved_id = resolve_model_id(original_id)
249
250
  logger.info(f"on_select_changed (provider): Original ID '{original_id}' resolved to '{resolved_id}'")
250
251
  self.selected_model = resolved_id
251
- model_select.value = resolved_id
252
+ # Use the original ID for the select widget to avoid invalid value errors
253
+ model_select.value = original_id
252
254
  model_select.remove_class("hide")
253
255
  self.query_one("#custom-model-input").add_class("hide")
254
256
  self.post_message(self.ModelSelected(resolved_id))
@@ -310,24 +312,35 @@ class ModelSelector(Container):
310
312
  def set_selected_model(self, model_id: str) -> None:
311
313
  """Set the selected model, ensuring it's properly resolved"""
312
314
  # First resolve the model ID to ensure we're using the full ID
315
+ original_id = model_id
313
316
  resolved_id = resolve_model_id(model_id)
314
- logger.info(f"set_selected_model: Original ID '{model_id}' resolved to '{resolved_id}'")
317
+ logger.info(f"set_selected_model: Original ID '{original_id}' resolved to '{resolved_id}'")
315
318
 
316
- # Store the resolved ID
319
+ # Store the resolved ID internally
317
320
  self.selected_model = resolved_id
318
321
 
319
322
  # Update the UI based on whether this is a known model or custom
320
- if resolved_id in CONFIG["available_models"]:
321
- select = self.query_one("#model-select", Select)
323
+ # Check if the original ID is in the available options
324
+ model_select = self.query_one("#model-select", Select)
325
+ available_options = [opt[1] for opt in model_select.options]
326
+
327
+ if original_id in available_options:
328
+ # Use the original ID for the select widget
329
+ custom_input = self.query_one("#custom-model-input")
330
+ model_select.value = original_id
331
+ model_select.remove_class("hide")
332
+ custom_input.add_class("hide")
333
+ elif resolved_id in available_options:
334
+ # If the resolved ID is in options, use that
322
335
  custom_input = self.query_one("#custom-model-input")
323
- select.value = resolved_id
324
- select.remove_class("hide")
336
+ model_select.value = resolved_id
337
+ model_select.remove_class("hide")
325
338
  custom_input.add_class("hide")
326
339
  else:
327
- select = self.query_one("#model-select", Select)
340
+ # Use custom input for models not in the select options
328
341
  custom_input = self.query_one("#custom-model-input")
329
- select.value = "custom"
330
- select.add_class("hide")
342
+ model_select.value = "custom"
343
+ model_select.add_class("hide")
331
344
  custom_input.value = resolved_id
332
345
  custom_input.remove_class("hide")
333
346
 
@@ -291,16 +291,35 @@ async def generate_streaming_response(
291
291
  buffer.append(chunk)
292
292
  current_time = time.time()
293
293
 
294
- # Update UI if enough time has passed or buffer is large
295
- if current_time - last_update >= update_interval or len(''.join(buffer)) > 100:
294
+ # Update UI with every chunk for short messages, or throttle for longer ones
295
+ # This is especially important for short messages like "hi" that might otherwise not trigger updates
296
+ if (current_time - last_update >= update_interval or
297
+ len(''.join(buffer)) > 10 or # Much more aggressive buffer flush threshold
298
+ len(full_response) < 20): # Always update for very short responses
299
+
296
300
  new_content = ''.join(buffer)
297
301
  full_response += new_content
298
302
  # Send content to UI
299
303
  debug_log(f"Updating UI with content length: {len(full_response)}")
300
- await callback(full_response)
304
+ try:
305
+ await callback(full_response)
306
+ debug_log("UI callback completed successfully")
307
+ except Exception as callback_err:
308
+ debug_log(f"Error in UI callback: {str(callback_err)}")
309
+ logger.error(f"Error in UI callback: {str(callback_err)}")
301
310
  buffer = []
302
311
  last_update = current_time
303
312
 
313
+ # Force UI refresh after each update for Ollama responses
314
+ if is_ollama:
315
+ debug_log("Forcing UI refresh for Ollama response")
316
+ try:
317
+ # Ensure the app refreshes the UI
318
+ if hasattr(app, 'refresh'):
319
+ app.refresh(layout=False)
320
+ except Exception as refresh_err:
321
+ debug_log(f"Error forcing UI refresh: {str(refresh_err)}")
322
+
304
323
  # Small delay to let UI catch up
305
324
  await asyncio.sleep(0.05)
306
325
  except asyncio.CancelledError:
@@ -316,7 +335,22 @@ async def generate_streaming_response(
316
335
  new_content = ''.join(buffer)
317
336
  full_response += new_content
318
337
  debug_log(f"Sending final content, total length: {len(full_response)}")
319
- await callback(full_response)
338
+ try:
339
+ await callback(full_response)
340
+ debug_log("Final UI callback completed successfully")
341
+
342
+ # Force final UI refresh for Ollama responses
343
+ if is_ollama:
344
+ debug_log("Forcing final UI refresh for Ollama response")
345
+ try:
346
+ # Ensure the app refreshes the UI
347
+ if hasattr(app, 'refresh'):
348
+ app.refresh(layout=True) # Use layout=True for final refresh
349
+ except Exception as refresh_err:
350
+ debug_log(f"Error forcing final UI refresh: {str(refresh_err)}")
351
+ except Exception as callback_err:
352
+ debug_log(f"Error in final UI callback: {str(callback_err)}")
353
+ logger.error(f"Error in final UI callback: {str(callback_err)}")
320
354
 
321
355
  debug_log(f"Streaming response completed successfully. Response length: {len(full_response)}")
322
356
  logger.info(f"Streaming response completed successfully. Response length: {len(full_response)}")
@@ -442,7 +476,27 @@ def resolve_model_id(model_id_or_name: str) -> str:
442
476
  logger.warning("No available_models found in CONFIG to resolve against.")
443
477
  return model_id_or_name # Return original if no models to check
444
478
 
445
- # 1. Check if the input is already a valid full ID (must contain a date suffix)
479
+ # Special case for Ollama models with version format (model:version)
480
+ if ":" in input_lower and not input_lower.startswith("claude-"):
481
+ logger.info(f"Input '{input_lower}' appears to be an Ollama model with version, returning as-is")
482
+ return model_id_or_name
483
+
484
+ # Handle special cases for common model formats
485
+ # 1. Handle Ollama models with dot notation (e.g., phi3.latest, llama3.1)
486
+ if "." in input_lower and not input_lower.startswith("claude-"):
487
+ # This is likely an Ollama model with dot notation
488
+ logger.info(f"Input '{input_lower}' appears to be an Ollama model with dot notation")
489
+ # Convert dots to colons for Ollama format if needed
490
+ if ":" not in input_lower:
491
+ parts = input_lower.split(".")
492
+ if len(parts) == 2:
493
+ base_model, version = parts
494
+ ollama_format = f"{base_model}:{version}"
495
+ logger.info(f"Converting '{input_lower}' to Ollama format: '{ollama_format}'")
496
+ return ollama_format
497
+ return model_id_or_name
498
+
499
+ # 2. Check if the input is already a valid full ID (must contain a date suffix)
446
500
  # Full Claude IDs should have format like "claude-3-opus-20240229" with a date suffix
447
501
  for full_id in available_models:
448
502
  if full_id.lower() == input_lower:
@@ -460,7 +514,7 @@ def resolve_model_id(model_id_or_name: str) -> str:
460
514
  best_match = None
461
515
  match_type = "None"
462
516
 
463
- # 2. Iterate through available models for other matches
517
+ # 3. Iterate through available models for other matches
464
518
  for full_id, model_info in available_models.items():
465
519
  full_id_lower = full_id.lower()
466
520
  display_name = model_info.get("display_name", "")
@@ -468,12 +522,12 @@ def resolve_model_id(model_id_or_name: str) -> str:
468
522
 
469
523
  logger.debug(f"Comparing '{input_lower}' against '{full_id_lower}' (Display: '{display_name}')")
470
524
 
471
- # 2a. Exact match on display name (case-insensitive)
525
+ # 3a. Exact match on display name (case-insensitive)
472
526
  if display_name_lower == input_lower:
473
527
  logger.info(f"Resolved '{model_id_or_name}' to '{full_id}' via exact display name match.")
474
528
  return full_id # Exact display name match is high confidence
475
529
 
476
- # 2b. Check if input is a known short alias (handle common cases explicitly)
530
+ # 3b. Check if input is a known short alias (handle common cases explicitly)
477
531
  # Special case for Claude 3.7 Sonnet which seems to be causing issues
478
532
  if input_lower == "claude-3.7-sonnet":
479
533
  # Hardcoded resolution for this specific model
@@ -501,7 +555,7 @@ def resolve_model_id(model_id_or_name: str) -> str:
501
555
  # This is also high confidence
502
556
  return full_id
503
557
 
504
- # 2c. Check if input is a prefix of the full ID (more general, lower confidence)
558
+ # 3c. Check if input is a prefix of the full ID (more general, lower confidence)
505
559
  if full_id_lower.startswith(input_lower):
506
560
  logger.debug(f"Potential prefix match: '{input_lower}' vs '{full_id_lower}'")
507
561
  # Don't return immediately, might find a better match (e.g., display name or alias)
@@ -510,7 +564,7 @@ def resolve_model_id(model_id_or_name: str) -> str:
510
564
  match_type = "Prefix"
511
565
  logger.debug(f"Setting best_match to '{full_id}' based on prefix.")
512
566
 
513
- # 2d. Check derived short name from display name (less reliable, keep as lower priority)
567
+ # 3d. Check derived short name from display name (less reliable, keep as lower priority)
514
568
  # Normalize display name: lower, replace space and dot with hyphen
515
569
  derived_short_name = display_name_lower.replace(" ", "-").replace(".", "-")
516
570
  if derived_short_name == input_lower:
@@ -521,7 +575,7 @@ def resolve_model_id(model_id_or_name: str) -> str:
521
575
  match_type = "Derived Short Name"
522
576
  logger.debug(f"Updating best_match to '{full_id}' based on derived name.")
523
577
 
524
- # 3. Return best match found or original input
578
+ # 4. Return best match found or original input
525
579
  if best_match:
526
580
  logger.info(f"Returning best match found for '{model_id_or_name}': '{best_match}' (Type: {match_type})")
527
581
  return best_match
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chat-console
3
- Version: 0.2.99
3
+ Version: 0.3.0
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
File without changes
File without changes
File without changes
File without changes