chat-console 0.2.98__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
app/__init__.py CHANGED
@@ -3,4 +3,4 @@ Chat CLI
3
3
  A command-line interface for chatting with various LLM providers like ChatGPT and Claude.
4
4
  """
5
5
 
6
- __version__ = "0.2.98"
6
+ __version__ = "0.3.0"
app/api/anthropic.py CHANGED
@@ -7,7 +7,14 @@ from ..utils import resolve_model_id # Import the resolve_model_id function
7
7
 
8
8
  class AnthropicClient(BaseModelClient):
9
9
  def __init__(self):
10
- self.client = anthropic.AsyncAnthropic(api_key=ANTHROPIC_API_KEY)
10
+ self.client = None # Initialize in create()
11
+
12
+ @classmethod
13
+ async def create(cls) -> 'AnthropicClient':
14
+ """Create a new instance with async initialization."""
15
+ instance = cls()
16
+ instance.client = anthropic.AsyncAnthropic(api_key=ANTHROPIC_API_KEY)
17
+ return instance
11
18
 
12
19
  def _prepare_messages(self, messages: List[Dict[str, str]], style: Optional[str] = None) -> List[Dict[str, str]]:
13
20
  """Prepare messages for Claude API"""
@@ -137,86 +144,110 @@ class AnthropicClient(BaseModelClient):
137
144
  except ImportError:
138
145
  debug_log = lambda msg: None
139
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
+
140
161
  try:
141
162
  debug_log("Anthropic: Fetching models from API...")
142
- # The Anthropic Python SDK might not have a direct high-level method for listing models yet.
143
- # We might need to use the underlying HTTP client or make a direct request.
144
- # Let's assume for now the SDK client *does* have a way, like self.client.models.list()
145
- # If this fails, we'd need to implement a direct HTTP GET request.
146
- # response = await self.client.models.list() # Hypothetical SDK method
147
-
148
- # --- Alternative: Direct HTTP Request using httpx (if client exposes it) ---
149
- # 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
150
181
  if hasattr(self.client, '_client') and hasattr(self.client._client, 'get'):
151
- response = await self.client._client.get(
152
- "/v1/models",
153
- headers={"anthropic-version": "2023-06-01"} # Add required version header
154
- )
155
- response.raise_for_status() # Raise HTTP errors
156
- models_data = response.json()
157
- debug_log(f"Anthropic: API response received: {models_data}")
158
- if 'data' in models_data and isinstance(models_data['data'], list):
159
- # Format the response as expected: list of {"id": ..., "name": ...}
160
- formatted_models = [
161
- {"id": model.get("id"), "name": model.get("display_name", model.get("id"))}
162
- for model in models_data['data']
163
- if model.get("id") # Ensure model has an ID
164
- ]
165
- # Log each model ID clearly for debugging
166
- debug_log(f"Anthropic: Available models from API:")
167
- for model in formatted_models:
168
- debug_log(f" - ID: {model.get('id')}, Name: {model.get('name')}")
169
- return formatted_models
170
- else:
171
- debug_log("Anthropic: Unexpected API response format for models.")
172
- return []
173
- else:
174
- debug_log("Anthropic: Client does not expose HTTP client for model listing. Returning empty list.")
175
- 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
176
208
 
177
209
  except Exception as e:
178
210
  debug_log(f"Anthropic: Failed to fetch models from API: {str(e)}")
179
- # Fallback to a minimal hardcoded list in case of API error
180
- # Include Claude 3.7 Sonnet with the correct full ID
181
- fallback_models = [
182
- {"id": "claude-3-opus-20240229", "name": "Claude 3 Opus"},
183
- {"id": "claude-3-sonnet-20240229", "name": "Claude 3 Sonnet"},
184
- {"id": "claude-3-haiku-20240307", "name": "Claude 3 Haiku"},
185
- {"id": "claude-3-5-sonnet-20240620", "name": "Claude 3.5 Sonnet"},
186
- {"id": "claude-3-7-sonnet-20250219", "name": "Claude 3.7 Sonnet"}, # Add Claude 3.7 Sonnet
187
- ]
188
- debug_log("Anthropic: Using fallback model list:")
189
- for model in fallback_models:
190
- debug_log(f" - ID: {model['id']}, Name: {model['name']}")
211
+ debug_log("Anthropic: Using fallback model list")
191
212
  return fallback_models
192
213
 
193
- # Keep this synchronous for now, but make it call the async fetcher
194
- # Note: This is slightly awkward. Ideally, config loading would be async.
195
- # For now, we'll run the async fetcher within the sync method using asyncio.run()
196
- # This is NOT ideal for performance but avoids larger refactoring of config loading.
197
214
  def get_available_models(self) -> List[Dict[str, Any]]:
198
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
+
199
225
  try:
200
- # Run the async fetcher method synchronously
201
- models = asyncio.run(self._fetch_models_from_api())
202
- return models
203
- except RuntimeError as e:
204
- # Handle cases where asyncio.run can't be called (e.g., already in an event loop)
205
- # This might happen during app runtime if called again. Fallback needed.
206
- try:
207
- from app.main import debug_log
208
- except ImportError:
209
- debug_log = lambda msg: None
210
- debug_log(f"Anthropic: Cannot run async model fetch synchronously ({e}). Falling back to hardcoded list.")
211
- # Use the same fallback list as in _fetch_models_from_api
212
- fallback_models = [
213
- {"id": "claude-3-opus-20240229", "name": "Claude 3 Opus"},
214
- {"id": "claude-3-sonnet-20240229", "name": "Claude 3 Sonnet"},
215
- {"id": "claude-3-haiku-20240307", "name": "Claude 3 Haiku"},
216
- {"id": "claude-3-5-sonnet-20240620", "name": "Claude 3.5 Sonnet"},
217
- {"id": "claude-3-7-sonnet-20250219", "name": "Claude 3.7 Sonnet"}, # Add Claude 3.7 Sonnet
218
- ]
219
- debug_log("Anthropic: Using fallback model list in get_available_models:")
220
- for model in fallback_models:
221
- debug_log(f" - ID: {model['id']}, Name: {model['name']}")
222
- 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
app/api/base.py CHANGED
@@ -71,7 +71,7 @@ class BaseModelClient(ABC):
71
71
  return None
72
72
 
73
73
  @staticmethod
74
- def get_client_for_model(model_name: str) -> 'BaseModelClient':
74
+ async def get_client_for_model(model_name: str) -> 'BaseModelClient':
75
75
  """Factory method to get appropriate client for model"""
76
76
  from ..config import CONFIG, AVAILABLE_PROVIDERS
77
77
  from .anthropic import AnthropicClient
@@ -118,10 +118,10 @@ class BaseModelClient(ABC):
118
118
 
119
119
  # Return appropriate client
120
120
  if provider == "ollama":
121
- return OllamaClient()
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}")
app/api/ollama.py CHANGED
@@ -3,7 +3,6 @@ import asyncio
3
3
  import json
4
4
  import logging
5
5
  import os
6
- import time
7
6
  from datetime import datetime, timedelta
8
7
  from pathlib import Path
9
8
  from typing import List, Dict, Any, Optional, Generator, AsyncGenerator
@@ -15,7 +14,6 @@ logger = logging.getLogger(__name__)
15
14
  class OllamaClient(BaseModelClient):
16
15
  def __init__(self):
17
16
  from ..config import OLLAMA_BASE_URL
18
- from ..utils import ensure_ollama_running
19
17
  self.base_url = OLLAMA_BASE_URL.rstrip('/')
20
18
  logger.info(f"Initializing Ollama client with base URL: {self.base_url}")
21
19
 
@@ -27,10 +25,18 @@ class OllamaClient(BaseModelClient):
27
25
 
28
26
  # Path to the cached models file
29
27
  self.models_cache_path = Path(__file__).parent.parent / "data" / "ollama-models.json"
28
+
29
+ @classmethod
30
+ async def create(cls) -> 'OllamaClient':
31
+ """Factory method to create and initialize an OllamaClient instance"""
32
+ from ..utils import ensure_ollama_running
33
+ client = cls()
30
34
 
31
35
  # Try to start Ollama if not running
32
- if not ensure_ollama_running():
36
+ if not await ensure_ollama_running():
33
37
  raise Exception(f"Failed to start Ollama server. Please ensure Ollama is installed and try again.")
38
+
39
+ return client
34
40
 
35
41
  def _prepare_messages(self, messages: List[Dict[str, str]], style: Optional[str] = None) -> str:
36
42
  """Convert chat messages to Ollama format"""
@@ -363,6 +369,10 @@ class OllamaClient(BaseModelClient):
363
369
 
364
370
  # Use a simpler async iteration pattern that's less error-prone
365
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
+
366
376
  async for line in response.content:
367
377
  # Check cancellation periodically
368
378
  if self._active_stream_session is None:
@@ -372,31 +382,38 @@ class OllamaClient(BaseModelClient):
372
382
  try:
373
383
  # Process the chunk
374
384
  if line:
375
- chunk = line.decode().strip()
376
385
  chunk_str = line.decode().strip()
377
386
  # Check if it looks like JSON before trying to parse
378
387
  if chunk_str.startswith('{') and chunk_str.endswith('}'):
379
388
  try:
380
389
  data = json.loads(chunk_str)
381
390
  if isinstance(data, dict) and "response" in data:
382
- chunk_length = len(data["response"]) if data["response"] else 0
383
- debug_log(f"Yielding chunk of length: {chunk_length}")
384
- 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
385
399
  else:
386
- debug_log(f"JSON chunk missing 'response' key: {chunk_str}")
400
+ debug_log(f"JSON chunk missing 'response' key: {chunk_str[:100]}")
387
401
  except json.JSONDecodeError:
388
- debug_log(f"JSON decode error for chunk: {chunk_str}")
402
+ debug_log(f"JSON decode error for chunk: {chunk_str[:100]}")
389
403
  else:
390
404
  # Log unexpected non-JSON lines but don't process them
391
- if chunk_str: # Avoid logging empty lines
392
- debug_log(f"Received unexpected non-JSON line: {chunk_str}")
393
- # Continue processing next line regardless of parsing success/failure of current line
394
- 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]}")
395
407
  except Exception as chunk_err:
396
408
  debug_log(f"Error processing chunk: {str(chunk_err)}")
397
409
  # Continue instead of breaking to try processing more chunks
398
410
  continue
399
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
+
400
417
  logger.info("Streaming completed successfully")
401
418
  debug_log("Streaming completed successfully")
402
419
  return
app/api/openai.py CHANGED
@@ -1,11 +1,19 @@
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
5
6
 
6
7
  class OpenAIClient(BaseModelClient):
7
8
  def __init__(self):
8
- self.client = AsyncOpenAI(api_key=OPENAI_API_KEY)
9
+ self.client = None # Initialize in create()
10
+
11
+ @classmethod
12
+ async def create(cls) -> 'OpenAIClient':
13
+ """Create a new instance with async initialization."""
14
+ instance = cls()
15
+ instance.client = AsyncOpenAI(api_key=OPENAI_API_KEY)
16
+ return instance
9
17
 
10
18
  def _prepare_messages(self, messages: List[Dict[str, str]], style: Optional[str] = None) -> List[Dict[str, str]]:
11
19
  """Prepare messages for OpenAI API"""
@@ -77,41 +85,87 @@ class OpenAIClient(BaseModelClient):
77
85
  debug_log(f"OpenAI: skipping invalid message: {m}")
78
86
 
79
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
+
80
98
  except Exception as msg_error:
81
99
  debug_log(f"OpenAI: error preparing messages: {str(msg_error)}")
82
100
  # Fallback to a simpler message format if processing fails
83
101
  api_messages = [{"role": "user", "content": "Please respond to my request."}]
84
102
 
85
103
  debug_log("OpenAI: requesting stream")
86
- stream = await self.client.chat.completions.create(
87
- model=model,
88
- messages=api_messages,
89
- temperature=temperature,
90
- max_tokens=max_tokens,
91
- stream=True,
92
- )
93
104
 
94
- debug_log("OpenAI: stream created successfully, processing chunks")
95
- 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:
96
110
  try:
97
- if chunk.choices and hasattr(chunk.choices[0], 'delta') and hasattr(chunk.choices[0].delta, 'content'):
98
- content = chunk.choices[0].delta.content
99
- if content is not None:
100
- # Ensure we're returning a string
101
- text = str(content)
102
- debug_log(f"OpenAI: yielding chunk of length: {len(text)}")
103
- yield text
104
- else:
105
- debug_log("OpenAI: skipping None content chunk")
106
- else:
107
- debug_log("OpenAI: skipping chunk with missing content")
108
- except Exception as chunk_error:
109
- debug_log(f"OpenAI: error processing chunk: {str(chunk_error)}")
110
- # Skip problematic chunks but continue processing
111
- 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
112
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
+
113
165
  except Exception as e:
114
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)}"
115
169
  raise Exception(f"OpenAI streaming error: {str(e)}")
116
170
 
117
171
  def get_available_models(self) -> List[Dict[str, Any]]:
app/main.py CHANGED
@@ -6,6 +6,7 @@ import os
6
6
  import asyncio
7
7
  import typer
8
8
  import logging
9
+ import time
9
10
  from typing import List, Optional, Callable, Awaitable
10
11
  from datetime import datetime
11
12
 
@@ -22,6 +23,8 @@ file_handler.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelnam
22
23
  debug_logger = logging.getLogger("chat-cli-debug")
23
24
  debug_logger.setLevel(logging.DEBUG)
24
25
  debug_logger.addHandler(file_handler)
26
+ # Prevent propagation to the root logger (which would print to console)
27
+ debug_logger.propagate = False
25
28
 
26
29
  # Add a convenience function to log to this file
27
30
  def debug_log(message):
@@ -161,6 +164,15 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
161
164
  TITLE = "Chat Console"
162
165
  SUB_TITLE = "AI Chat Interface" # Keep SimpleChatApp SUB_TITLE
163
166
  DARK = True # Keep SimpleChatApp DARK
167
+
168
+ # Add better terminal handling to fix UI glitches
169
+ SCREENS = {}
170
+
171
+ # Force full screen mode and prevent background terminal showing through
172
+ FULL_SCREEN = True
173
+
174
+ # Force capturing all mouse events for better stability
175
+ CAPTURE_MOUSE = True
164
176
 
165
177
  # Ensure the log directory exists in a standard cache location
166
178
  log_dir = os.path.expanduser("~/.cache/chat-cli")
@@ -424,7 +436,7 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
424
436
  # Check for available models # Keep SimpleChatApp on_mount
425
437
  from app.api.ollama import OllamaClient # Keep SimpleChatApp on_mount
426
438
  try: # Keep SimpleChatApp on_mount
427
- ollama = OllamaClient() # Keep SimpleChatApp on_mount
439
+ ollama = await OllamaClient.create() # Keep SimpleChatApp on_mount
428
440
  models = await ollama.get_available_models() # Keep SimpleChatApp on_mount
429
441
  if not models: # Keep SimpleChatApp on_mount
430
442
  api_issues.append("- No Ollama models found") # Keep SimpleChatApp on_mount
@@ -511,7 +523,7 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
511
523
  # Get the client for the current model first and cancel the connection
512
524
  try:
513
525
  model = self.selected_model
514
- client = BaseModelClient.get_client_for_model(model)
526
+ client = await BaseModelClient.get_client_for_model(model)
515
527
 
516
528
  # Call the client's cancel method if it's supported
517
529
  if hasattr(client, 'cancel_stream'):
@@ -581,19 +593,21 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
581
593
  messages_container = self.query_one("#messages-container") # Keep SimpleChatApp update_messages_ui
582
594
  messages_container.remove_children() # Keep SimpleChatApp update_messages_ui
583
595
 
584
- # Batch add all messages first without scrolling or refreshing between each mount
585
- # This avoids unnecessary layout shifts while adding messages
586
- for message in self.messages: # Keep SimpleChatApp update_messages_ui
587
- display = MessageDisplay(message, highlight_code=CONFIG["highlight_code"]) # Keep SimpleChatApp update_messages_ui
588
- messages_container.mount(display) # Keep SimpleChatApp update_messages_ui
596
+ # Temporarily disable automatic refresh while mounting messages
597
+ # This avoids excessive layout calculations and reduces flickering
598
+ with self.batch_update():
599
+ # Batch add all messages first without any refresh/layout
600
+ for message in self.messages: # Keep SimpleChatApp update_messages_ui
601
+ display = MessageDisplay(message, highlight_code=CONFIG["highlight_code"]) # Keep SimpleChatApp update_messages_ui
602
+ messages_container.mount(display) # Keep SimpleChatApp update_messages_ui
589
603
 
590
- # Perform a single refresh and scroll after mounting all messages
591
- # This significantly reduces the visual bouncing effect
592
- # A small delay before scrolling helps ensure stable layout
593
- await asyncio.sleep(0.05) # Single delay after all messages are mounted
604
+ # A small delay after mounting all messages helps with layout stability
605
+ await asyncio.sleep(0.05)
606
+
607
+ # Scroll after all messages are added without animation
594
608
  messages_container.scroll_end(animate=False) # Keep SimpleChatApp update_messages_ui
595
609
 
596
- # Use layout=False refresh if possible to further reduce bouncing
610
+ # Minimal refresh without full layout recalculation
597
611
  self.refresh(layout=False)
598
612
 
599
613
  async def on_input_submitted(self, event: Input.Submitted) -> None: # Keep SimpleChatApp on_input_submitted
@@ -630,9 +644,10 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
630
644
  await self.update_messages_ui()
631
645
 
632
646
  # If this is the first message and dynamic titles are enabled, generate one
633
- 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:
634
649
  log("First message detected, generating title...")
635
- debug_log("First message detected, attempting to generate conversation title")
650
+ debug_log(f"First message detected with length {len(content)}, generating conversation title")
636
651
  title_generation_in_progress = True # Use a local flag
637
652
  loading = self.query_one("#loading-indicator")
638
653
  loading.remove_class("hidden") # Show loading for title gen
@@ -656,7 +671,7 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
656
671
  # Last resort - check for a common Ollama model
657
672
  try:
658
673
  from app.api.ollama import OllamaClient
659
- ollama = OllamaClient()
674
+ ollama = await OllamaClient.create()
660
675
  models = await ollama.get_available_models()
661
676
  if models and len(models) > 0:
662
677
  debug_log(f"Found {len(models)} Ollama models, using first one")
@@ -670,7 +685,7 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
670
685
  debug_log("Final fallback to llama3")
671
686
 
672
687
  debug_log(f"Getting client for model: {model}")
673
- client = BaseModelClient.get_client_for_model(model)
688
+ client = await BaseModelClient.get_client_for_model(model)
674
689
 
675
690
  if client is None:
676
691
  debug_log(f"No client available for model: {model}, trying to initialize")
@@ -679,7 +694,7 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
679
694
  if client_type:
680
695
  debug_log(f"Found client type {client_type.__name__} for {model}, initializing")
681
696
  try:
682
- client = client_type()
697
+ client = await client_type.create()
683
698
  debug_log("Client initialized successfully")
684
699
  except Exception as init_err:
685
700
  debug_log(f"Error initializing client: {str(init_err)}")
@@ -689,12 +704,12 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
689
704
  # Try a different model as last resort
690
705
  if OPENAI_API_KEY:
691
706
  from app.api.openai import OpenAIClient
692
- client = OpenAIClient()
707
+ client = await OpenAIClient.create()
693
708
  model = "gpt-3.5-turbo"
694
709
  debug_log("Falling back to OpenAI for title generation")
695
710
  elif ANTHROPIC_API_KEY:
696
711
  from app.api.anthropic import AnthropicClient
697
- client = AnthropicClient()
712
+ client = await AnthropicClient.create()
698
713
  model = "claude-instant-1.2"
699
714
  debug_log("Falling back to Anthropic for title generation")
700
715
  else:
@@ -811,7 +826,7 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
811
826
  else:
812
827
  # Check for a common Ollama model
813
828
  try:
814
- ollama = OllamaClient()
829
+ ollama = await OllamaClient.create()
815
830
  models = await ollama.get_available_models()
816
831
  if models and len(models) > 0:
817
832
  debug_log(f"Found {len(models)} Ollama models, using first one")
@@ -856,7 +871,7 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
856
871
  # Get appropriate client
857
872
  debug_log(f"Getting client for model: {model}")
858
873
  try:
859
- client = BaseModelClient.get_client_for_model(model)
874
+ client = await BaseModelClient.get_client_for_model(model)
860
875
  debug_log(f"Client: {client.__class__.__name__ if client else 'None'}")
861
876
 
862
877
  if client is None:
@@ -866,7 +881,7 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
866
881
  if client_type:
867
882
  debug_log(f"Found client type {client_type.__name__} for {model}, initializing")
868
883
  try:
869
- client = client_type()
884
+ client = await client_type.create()
870
885
  debug_log(f"Successfully initialized {client_type.__name__}")
871
886
  except Exception as init_err:
872
887
  debug_log(f"Error initializing client: {str(init_err)}")
@@ -876,12 +891,12 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
876
891
  # Try a different model as last resort
877
892
  if OPENAI_API_KEY:
878
893
  from app.api.openai import OpenAIClient
879
- client = OpenAIClient()
894
+ client = await OpenAIClient.create()
880
895
  model = "gpt-3.5-turbo"
881
896
  debug_log("Falling back to OpenAI client")
882
897
  elif ANTHROPIC_API_KEY:
883
898
  from app.api.anthropic import AnthropicClient
884
- client = AnthropicClient()
899
+ client = await AnthropicClient.create()
885
900
  model = "claude-instant-1.2"
886
901
  debug_log("Falling back to Anthropic client")
887
902
  else:
@@ -907,6 +922,7 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
907
922
 
908
923
  # Stream chunks to the UI with synchronization
909
924
  update_lock = asyncio.Lock()
925
+ last_refresh_time = time.time() # Initialize refresh throttling timer
910
926
 
911
927
  async def update_ui(content: str):
912
928
  # This function remains the same, called by the worker
@@ -914,6 +930,9 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
914
930
  debug_log("update_ui called but is_generating is False, returning.")
915
931
  return
916
932
 
933
+ # Make last_refresh_time accessible in inner scope
934
+ nonlocal last_refresh_time
935
+
917
936
  async with update_lock:
918
937
  try:
919
938
  # Clear thinking indicator on first content
@@ -926,21 +945,40 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
926
945
 
927
946
  # Update UI with the content - this no longer triggers refresh itself
928
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)
929
952
 
930
- # Throttle UI updates to reduce visual jitter and improve performance
931
- # Only refresh visually every ~5 tokens (estimated by content length changes)
953
+ # Scroll after each content update to ensure it's visible
954
+ messages_container.scroll_end(animate=False)
955
+
956
+ # Much more aggressive throttling of UI updates to eliminate visual jitter
957
+ # By using a larger modulo value, we significantly reduce refresh frequency
958
+ # This improves stability at the cost of slightly choppier animations
932
959
  content_length = len(content)
960
+
961
+ # Define some key refresh points - more frequent than before
962
+ new_paragraph = content.endswith("\n") and content.count("\n") > 0
963
+ code_block = "```" in content
933
964
  do_refresh = (
934
- content_length < 20 or # Always refresh for the first few tokens
935
- content_length % 16 == 0 or # Then periodically
936
- content.endswith("\n") # And on newlines
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
937
969
  )
938
970
 
939
- if do_refresh:
940
- # Only scroll without full layout recalculation
971
+ # Check if it's been enough time since last refresh (reduced to 200ms from 250ms)
972
+ current_time = time.time()
973
+ time_since_refresh = current_time - last_refresh_time
974
+
975
+ if do_refresh and time_since_refresh > 0.2:
976
+ # Store the time we did the refresh
977
+ last_refresh_time = current_time
978
+ # Ensure content is still visible by scrolling
941
979
  messages_container.scroll_end(animate=False)
942
- # Light refresh without full layout recalculation
943
- self.refresh(layout=False)
980
+ # Force a more thorough refresh periodically
981
+ self.refresh(layout=True)
944
982
  except Exception as e:
945
983
  debug_log(f"Error updating UI: {str(e)}")
946
984
  log.error(f"Error updating UI: {str(e)}")
@@ -1029,6 +1067,21 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
1029
1067
  # Update the final message object content (optional, UI should be up-to-date)
1030
1068
  if self.messages and self.messages[-1].role == "assistant":
1031
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)}")
1032
1085
  else:
1033
1086
  debug_log("Worker finished successfully but response was empty or invalid.")
1034
1087
  # Handle case where 'Thinking...' might still be the last message
@@ -1036,11 +1089,24 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
1036
1089
  self.messages.pop() # Remove 'Thinking...' if no content arrived
1037
1090
  await self.update_messages_ui()
1038
1091
 
1039
- # Final UI refresh with minimal layout recalculation
1040
- # Use layout=False to prevent UI jumping at the end
1041
- self.refresh(layout=False)
1042
- await asyncio.sleep(0.1) # Allow UI to stabilize
1092
+ # Force a full UI refresh to ensure content is visible
1043
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)
1044
1110
  messages_container.scroll_end(animate=False)
1045
1111
 
1046
1112
  except Exception as e:
app/ui/chat_interface.py CHANGED
@@ -1,5 +1,4 @@
1
1
  from typing import List, Dict, Any, Optional, Callable, Awaitable
2
- import time
3
2
  import asyncio
4
3
  from datetime import datetime
5
4
  import re
@@ -66,9 +65,9 @@ class MessageDisplay(Static): # Inherit from Static instead of RichLog
66
65
  padding: 1;
67
66
  text-wrap: wrap; /* Explicitly enable text wrapping via CSS */
68
67
  content-align: left top; /* Anchor content to top-left */
69
- overflow-y: visible; /* Allow content to expand */
68
+ overflow-y: auto; /* Changed from 'visible' to valid 'auto' value */
70
69
  box-sizing: border-box; /* Include padding in size calculations */
71
- transitions: none; /* Disable any transitions that might cause animation */
70
+ transition: none; /* Fixed property name from 'transitions' to 'transition' */
72
71
  }
73
72
 
74
73
  MessageDisplay.user-message {
@@ -121,7 +120,11 @@ class MessageDisplay(Static): # Inherit from Static instead of RichLog
121
120
  self.update(self._format_content(self.message.content))
122
121
 
123
122
  async def update_content(self, content: str) -> None:
124
- """Update the message content using Static.update()"""
123
+ """Update the message content using Static.update() with optimizations for streaming"""
124
+ # Quick unchanged content check to avoid unnecessary updates
125
+ if self.message.content == content:
126
+ return
127
+
125
128
  # Update the stored message object content first
126
129
  self.message.content = content
127
130
 
@@ -129,16 +132,56 @@ class MessageDisplay(Static): # Inherit from Static instead of RichLog
129
132
  # This avoids text reflowing as new tokens arrive
130
133
  formatted_content = self._format_content(content)
131
134
 
132
- # Update Static widget with minimal refresh
133
- self.update(formatted_content)
135
+ # Use minimal update that doesn't trigger a refresh
136
+ # This allows parent to control refresh timing and avoid flickering
137
+ self.update(formatted_content, refresh=False)
134
138
 
135
- # Important: Don't call refresh() here - let the parent handle timing
136
- # This prevents constant layout recalculation on each token
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
137
166
 
138
167
  def _format_content(self, content: str) -> str:
139
- """Format message content with timestamp"""
168
+ """Format message content with timestamp and handle markdown links"""
140
169
  timestamp = datetime.now().strftime("%H:%M")
141
- 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}"
142
185
 
143
186
  class InputWithFocus(Input):
144
187
  """Enhanced Input that better handles focus and maintains cursor position"""
@@ -179,7 +222,7 @@ class ChatInterface(Container):
179
222
  padding: 0 1;
180
223
  content-align: left top; /* Keep content anchored at top */
181
224
  box-sizing: border-box;
182
- scrollbar-size: 1 1; /* Smaller scrollbars for more stability */
225
+ scrollbar-gutter: stable; /* Better than scrollbar-size which isn't valid */
183
226
  }
184
227
 
185
228
  #input-area {
app/ui/model_selector.py CHANGED
@@ -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
 
app/utils.py CHANGED
@@ -165,6 +165,9 @@ async def generate_streaming_response(
165
165
 
166
166
  debug_log(f"Messages validation complete: {len(messages)} total messages")
167
167
 
168
+ # Import time module within the worker function scope
169
+ import time
170
+
168
171
  full_response = ""
169
172
  buffer = []
170
173
  last_update = time.time()
@@ -288,16 +291,35 @@ async def generate_streaming_response(
288
291
  buffer.append(chunk)
289
292
  current_time = time.time()
290
293
 
291
- # Update UI if enough time has passed or buffer is large
292
- 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
+
293
300
  new_content = ''.join(buffer)
294
301
  full_response += new_content
295
302
  # Send content to UI
296
303
  debug_log(f"Updating UI with content length: {len(full_response)}")
297
- 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)}")
298
310
  buffer = []
299
311
  last_update = current_time
300
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
+
301
323
  # Small delay to let UI catch up
302
324
  await asyncio.sleep(0.05)
303
325
  except asyncio.CancelledError:
@@ -313,7 +335,22 @@ async def generate_streaming_response(
313
335
  new_content = ''.join(buffer)
314
336
  full_response += new_content
315
337
  debug_log(f"Sending final content, total length: {len(full_response)}")
316
- 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)}")
317
354
 
318
355
  debug_log(f"Streaming response completed successfully. Response length: {len(full_response)}")
319
356
  logger.info(f"Streaming response completed successfully. Response length: {len(full_response)}")
@@ -361,7 +398,7 @@ async def generate_streaming_response(
361
398
  return full_response
362
399
  return None # Indicate completion without full response (e.g., error before loop)
363
400
 
364
- def ensure_ollama_running() -> bool:
401
+ async def ensure_ollama_running() -> bool:
365
402
  """
366
403
  Check if Ollama is running and try to start it if not.
367
404
  Returns True if Ollama is running after check/start attempt.
@@ -388,8 +425,7 @@ def ensure_ollama_running() -> bool:
388
425
  )
389
426
 
390
427
  # Wait a moment for it to start
391
- import time
392
- time.sleep(2)
428
+ await asyncio.sleep(2) # Use asyncio.sleep instead of time.sleep
393
429
 
394
430
  # Check if process is still running
395
431
  if process.poll() is None:
@@ -440,7 +476,27 @@ def resolve_model_id(model_id_or_name: str) -> str:
440
476
  logger.warning("No available_models found in CONFIG to resolve against.")
441
477
  return model_id_or_name # Return original if no models to check
442
478
 
443
- # 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)
444
500
  # Full Claude IDs should have format like "claude-3-opus-20240229" with a date suffix
445
501
  for full_id in available_models:
446
502
  if full_id.lower() == input_lower:
@@ -458,7 +514,7 @@ def resolve_model_id(model_id_or_name: str) -> str:
458
514
  best_match = None
459
515
  match_type = "None"
460
516
 
461
- # 2. Iterate through available models for other matches
517
+ # 3. Iterate through available models for other matches
462
518
  for full_id, model_info in available_models.items():
463
519
  full_id_lower = full_id.lower()
464
520
  display_name = model_info.get("display_name", "")
@@ -466,12 +522,12 @@ def resolve_model_id(model_id_or_name: str) -> str:
466
522
 
467
523
  logger.debug(f"Comparing '{input_lower}' against '{full_id_lower}' (Display: '{display_name}')")
468
524
 
469
- # 2a. Exact match on display name (case-insensitive)
525
+ # 3a. Exact match on display name (case-insensitive)
470
526
  if display_name_lower == input_lower:
471
527
  logger.info(f"Resolved '{model_id_or_name}' to '{full_id}' via exact display name match.")
472
528
  return full_id # Exact display name match is high confidence
473
529
 
474
- # 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)
475
531
  # Special case for Claude 3.7 Sonnet which seems to be causing issues
476
532
  if input_lower == "claude-3.7-sonnet":
477
533
  # Hardcoded resolution for this specific model
@@ -499,7 +555,7 @@ def resolve_model_id(model_id_or_name: str) -> str:
499
555
  # This is also high confidence
500
556
  return full_id
501
557
 
502
- # 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)
503
559
  if full_id_lower.startswith(input_lower):
504
560
  logger.debug(f"Potential prefix match: '{input_lower}' vs '{full_id_lower}'")
505
561
  # Don't return immediately, might find a better match (e.g., display name or alias)
@@ -508,7 +564,7 @@ def resolve_model_id(model_id_or_name: str) -> str:
508
564
  match_type = "Prefix"
509
565
  logger.debug(f"Setting best_match to '{full_id}' based on prefix.")
510
566
 
511
- # 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)
512
568
  # Normalize display name: lower, replace space and dot with hyphen
513
569
  derived_short_name = display_name_lower.replace(" ", "-").replace(".", "-")
514
570
  if derived_short_name == input_lower:
@@ -519,7 +575,7 @@ def resolve_model_id(model_id_or_name: str) -> str:
519
575
  match_type = "Derived Short Name"
520
576
  logger.debug(f"Updating best_match to '{full_id}' based on derived name.")
521
577
 
522
- # 3. Return best match found or original input
578
+ # 4. Return best match found or original input
523
579
  if best_match:
524
580
  logger.info(f"Returning best match found for '{model_id_or_name}': '{best_match}' (Type: {match_type})")
525
581
  return best_match
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chat-console
3
- Version: 0.2.98
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
@@ -0,0 +1,24 @@
1
+ app/__init__.py,sha256=r5cQDqO-BZ0zQldUI9laK2sHxNsMaRUP_KzKS5u3YCk,130
2
+ app/config.py,sha256=KawltE7cK2bR9wbe1NSlepwWIjkiFw2bg3vbLmUnP38,7626
3
+ app/database.py,sha256=nt8CVuDpy6zw8mOYqDcfUmNw611t7Ln7pz22M0b6-MI,9967
4
+ app/main.py,sha256=y3sb3iSEQ-Dg6_Cr6EPtqCgYb7116Vr-RpmO2VYRuWk,73194
5
+ app/models.py,sha256=4-y9Lytay2exWPFi0FDlVeRL3K2-I7E-jBqNzTfokqY,2644
6
+ app/utils.py,sha256=TzY5I-A99FUIh-c1-OO7ZFea62Ex3XQCTUVXkXIoU04,30733
7
+ app/api/__init__.py,sha256=A8UL84ldYlv8l7O-yKzraVFcfww86SgWfpl4p7R03-w,62
8
+ app/api/anthropic.py,sha256=UpIP3CgAOUimdVyif41MhBOCAgOyFO8mX9SFQMKRAmc,12483
9
+ app/api/base.py,sha256=bqBT4jne_W6Cvj_GoWWclV4Uk95fQvt-kkYqqZFJd8M,5769
10
+ app/api/ollama.py,sha256=EBEEKXbgAYWEg_zF5PO_UKO5l_aoU3J_7tfCj9e-fqs,61699
11
+ app/api/openai.py,sha256=JIRzCIcc5efvXhqWmSwISMcxTHDF4jYBCgAgnIivbpU,9018
12
+ app/ui/__init__.py,sha256=RndfbQ1Tv47qdSiuQzvWP96lPS547SDaGE-BgOtiP_w,55
13
+ app/ui/chat_interface.py,sha256=KbabnVVokC7sHjvAa5ddcLVPhuFE-oa68fKTprZbIJU,17696
14
+ app/ui/chat_list.py,sha256=WQTYVNSSXlx_gQal3YqILZZKL9UiTjmNMIDX2I9pAMM,11205
15
+ app/ui/model_browser.py,sha256=pdblLVkdyVF0_Bo02bqbErGAtieyH-y6IfhMOPEqIso,71124
16
+ app/ui/model_selector.py,sha256=MfR4dbzjjf72FMWhyhSZXxx5CFdg5CY9cb9JlIkux5I,17695
17
+ app/ui/search.py,sha256=b-m14kG3ovqW1-i0qDQ8KnAqFJbi5b1FLM9dOnbTyIs,9763
18
+ app/ui/styles.py,sha256=04AhPuLrOd2yenfRySFRestPeuTPeMLzhmMB67NdGvw,5615
19
+ chat_console-0.3.0.dist-info/licenses/LICENSE,sha256=srHZ3fvcAuZY1LHxE7P6XWju2njRCHyK6h_ftEbzxSE,1057
20
+ chat_console-0.3.0.dist-info/METADATA,sha256=th7sGo5ZG71_QM7WC4zeyPAWbEhQzJkzVj_fu1ngWU8,2921
21
+ chat_console-0.3.0.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
22
+ chat_console-0.3.0.dist-info/entry_points.txt,sha256=kkVdEc22U9PAi2AeruoKklfkng_a_aHAP6VRVwrAD7c,67
23
+ chat_console-0.3.0.dist-info/top_level.txt,sha256=io9g7LCbfmTG1SFKgEOGXmCFB9uMP2H5lerm0HiHWQE,4
24
+ chat_console-0.3.0.dist-info/RECORD,,
@@ -1,24 +0,0 @@
1
- app/__init__.py,sha256=Mx4VF_U7IhLbSFel6dTS0LmWyZ6eBpnmhRlOw9sXLfE,131
2
- app/config.py,sha256=KawltE7cK2bR9wbe1NSlepwWIjkiFw2bg3vbLmUnP38,7626
3
- app/database.py,sha256=nt8CVuDpy6zw8mOYqDcfUmNw611t7Ln7pz22M0b6-MI,9967
4
- app/main.py,sha256=cvAdboaSLNB_eilgrPe0nuAa1bCtsSHnaSURFyJt5zk,69475
5
- app/models.py,sha256=4-y9Lytay2exWPFi0FDlVeRL3K2-I7E-jBqNzTfokqY,2644
6
- app/utils.py,sha256=y-U3vWGeJaaynQ1vNkht_DYLnRdzJDJh-u2bAinfj2Y,27428
7
- app/api/__init__.py,sha256=A8UL84ldYlv8l7O-yKzraVFcfww86SgWfpl4p7R03-w,62
8
- app/api/anthropic.py,sha256=jpvx_eKd5WqKc2KvpxjbInEfEmgw9o4YX1SXoUOaQ3M,12082
9
- app/api/base.py,sha256=PB6loU2_SbnKvYuA-KFqR86xUZg1sX-1IgfMl9HKhR8,5724
10
- app/api/ollama.py,sha256=B9jTeOmJpeAOg6UvvkcDt0xIe5PDkyUryMlhHBt3plA,60744
11
- app/api/openai.py,sha256=K_fVJ6YNFgUyE_sRAZMnUaCXuiXNm4iEqzTI0I1sdic,5842
12
- app/ui/__init__.py,sha256=RndfbQ1Tv47qdSiuQzvWP96lPS547SDaGE-BgOtiP_w,55
13
- app/ui/chat_interface.py,sha256=xU4yFcVS4etS5kx7cmnnUnF5p_nWDNmf68VKbYemJRg,15677
14
- app/ui/chat_list.py,sha256=WQTYVNSSXlx_gQal3YqILZZKL9UiTjmNMIDX2I9pAMM,11205
15
- app/ui/model_browser.py,sha256=pdblLVkdyVF0_Bo02bqbErGAtieyH-y6IfhMOPEqIso,71124
16
- app/ui/model_selector.py,sha256=eqwJamLddgt4fS0pJbCyCBe-_shqESm3gM8vJTOWDAs,16956
17
- app/ui/search.py,sha256=b-m14kG3ovqW1-i0qDQ8KnAqFJbi5b1FLM9dOnbTyIs,9763
18
- app/ui/styles.py,sha256=04AhPuLrOd2yenfRySFRestPeuTPeMLzhmMB67NdGvw,5615
19
- chat_console-0.2.98.dist-info/licenses/LICENSE,sha256=srHZ3fvcAuZY1LHxE7P6XWju2njRCHyK6h_ftEbzxSE,1057
20
- chat_console-0.2.98.dist-info/METADATA,sha256=qJwneYlSKgSj2HrjWs9Gj8sLYFiV5nULI31Xv_kmE68,2922
21
- chat_console-0.2.98.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
22
- chat_console-0.2.98.dist-info/entry_points.txt,sha256=kkVdEc22U9PAi2AeruoKklfkng_a_aHAP6VRVwrAD7c,67
23
- chat_console-0.2.98.dist-info/top_level.txt,sha256=io9g7LCbfmTG1SFKgEOGXmCFB9uMP2H5lerm0HiHWQE,4
24
- chat_console-0.2.98.dist-info/RECORD,,