chat-console 0.3.7__py3-none-any.whl → 0.3.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
app/__init__.py 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.3.7"
6
+ __version__ = "0.3.9"
app/api/anthropic.py CHANGED
@@ -1,13 +1,17 @@
1
1
  import anthropic
2
- import asyncio # Add missing import
2
+ import asyncio
3
+ import logging
3
4
  from typing import List, Dict, Any, Optional, Generator, AsyncGenerator
4
5
  from .base import BaseModelClient
5
6
  from ..config import ANTHROPIC_API_KEY
6
- from ..utils import resolve_model_id # Import the resolve_model_id function
7
+
8
+ # Set up logging
9
+ logger = logging.getLogger(__name__)
7
10
 
8
11
  class AnthropicClient(BaseModelClient):
9
12
  def __init__(self):
10
13
  self.client = None # Initialize in create()
14
+ self._active_stream = None # Track active stream for cancellation
11
15
 
12
16
  @classmethod
13
17
  async def create(cls) -> 'AnthropicClient':
@@ -17,237 +21,218 @@ class AnthropicClient(BaseModelClient):
17
21
  return instance
18
22
 
19
23
  def _prepare_messages(self, messages: List[Dict[str, str]], style: Optional[str] = None) -> List[Dict[str, str]]:
20
- """Prepare messages for Claude API"""
21
- # Anthropic expects role to be 'user' or 'assistant'
24
+ """Prepare messages for Anthropic API"""
22
25
  processed_messages = []
23
26
 
24
- for msg in messages:
25
- role = msg["role"]
26
- if role == "system":
27
- # For Claude, we'll convert system messages to user messages with a special prefix
27
+ # Add style instructions if provided
28
+ if style and style != "default":
29
+ style_instructions = self._get_style_instructions(style)
30
+ processed_messages.append({
31
+ "role": "system",
32
+ "content": style_instructions
33
+ })
34
+
35
+ # Add the rest of the messages
36
+ for message in messages:
37
+ # Ensure message has required fields
38
+ if "role" not in message or "content" not in message:
39
+ continue
40
+
41
+ # Map 'user' and 'assistant' roles directly
42
+ # Anthropic only supports 'user' and 'assistant' roles
43
+ if message["role"] in ["user", "assistant"]:
44
+ processed_messages.append(message)
45
+ elif message["role"] == "system":
46
+ # For system messages, we need to add them as system messages
28
47
  processed_messages.append({
29
- "role": "user",
30
- "content": f"<system>\n{msg['content']}\n</system>"
48
+ "role": "system",
49
+ "content": message["content"]
31
50
  })
32
51
  else:
33
- processed_messages.append(msg)
34
-
35
- # Add style instructions if provided
36
- if style and style != "default":
37
- # Find first non-system message to attach style to
38
- for i, msg in enumerate(processed_messages):
39
- if msg["role"] == "user":
40
- content = msg["content"]
41
- if "<userStyle>" not in content:
42
- style_instructions = self._get_style_instructions(style)
43
- msg["content"] = f"<userStyle>{style_instructions}</userStyle>\n\n{content}"
44
- break
52
+ # For any other role, treat as user message
53
+ processed_messages.append({
54
+ "role": "user",
55
+ "content": message["content"]
56
+ })
45
57
 
46
58
  return processed_messages
47
59
 
48
60
  def _get_style_instructions(self, style: str) -> str:
49
61
  """Get formatting instructions for different styles"""
50
62
  styles = {
51
- "concise": "Be extremely concise and to the point. Use short sentences and paragraphs. Avoid unnecessary details.",
52
- "detailed": "Be comprehensive and thorough in your responses. Provide detailed explanations, examples, and cover all relevant aspects of the topic.",
53
- "technical": "Use precise technical language and terminology. Be formal and focus on accuracy and technical details.",
54
- "friendly": "Be warm, approachable and conversational. Use casual language, personal examples, and a friendly tone.",
63
+ "concise": "Please provide concise, to-the-point responses without unnecessary elaboration.",
64
+ "detailed": "Please provide comprehensive responses with thorough explanations and examples.",
65
+ "technical": "Please use precise technical language and focus on accuracy and technical details.",
66
+ "friendly": "Please use a warm, conversational tone and relatable examples.",
55
67
  }
56
68
 
57
69
  return styles.get(style, "")
58
70
 
59
- async def generate_completion(self, messages: List[Dict[str, str]],
60
- model: str,
61
- style: Optional[str] = None,
62
- temperature: float = 0.7,
71
+ async def generate_completion(self, messages: List[Dict[str, str]],
72
+ model: str,
73
+ style: Optional[str] = None,
74
+ temperature: float = 0.7,
63
75
  max_tokens: Optional[int] = None) -> str:
64
- """Generate a text completion using Claude"""
65
- try:
66
- from app.main import debug_log
67
- except ImportError:
68
- debug_log = lambda msg: None
69
-
70
- # Resolve the model ID right before making the API call
71
- original_model = model
72
- resolved_model = resolve_model_id(model)
73
- debug_log(f"Anthropic: Original model ID '{original_model}' resolved to '{resolved_model}' in generate_completion")
74
-
76
+ """Generate a text completion using Anthropic"""
75
77
  processed_messages = self._prepare_messages(messages, style)
76
78
 
77
- response = await self.client.messages.create(
78
- model=resolved_model, # Use the resolved model ID
79
- messages=processed_messages,
80
- temperature=temperature,
81
- max_tokens=max_tokens or 1024,
82
- )
83
-
84
- return response.content[0].text
79
+ try:
80
+ response = await self.client.messages.create(
81
+ model=model,
82
+ messages=processed_messages,
83
+ temperature=temperature,
84
+ max_tokens=max_tokens if max_tokens else 4096,
85
+ )
86
+
87
+ return response.content[0].text
88
+ except Exception as e:
89
+ logger.error(f"Error generating completion: {str(e)}")
90
+ raise Exception(f"Anthropic API error: {str(e)}")
85
91
 
86
- async def generate_stream(self, messages: List[Dict[str, str]],
87
- model: str,
92
+ async def generate_stream(self, messages: List[Dict[str, str]],
93
+ model: str,
88
94
  style: Optional[str] = None,
89
- temperature: float = 0.7,
95
+ temperature: float = 0.7,
90
96
  max_tokens: Optional[int] = None) -> AsyncGenerator[str, None]:
91
- """Generate a streaming text completion using Claude"""
97
+ """Generate a streaming text completion using Anthropic"""
92
98
  try:
93
99
  from app.main import debug_log # Import debug logging if available
100
+ debug_log(f"Anthropic: starting streaming generation with model: {model}")
94
101
  except ImportError:
95
102
  # If debug_log not available, create a no-op function
96
103
  debug_log = lambda msg: None
97
104
 
98
- # Resolve the model ID right before making the API call
99
- original_model = model
100
- resolved_model = resolve_model_id(model)
101
- debug_log(f"Anthropic: Original model ID '{original_model}' resolved to '{resolved_model}'")
102
- debug_log(f"Anthropic: starting streaming generation with model: {resolved_model}")
103
-
104
105
  processed_messages = self._prepare_messages(messages, style)
105
106
 
106
107
  try:
107
- debug_log(f"Anthropic: requesting stream with {len(processed_messages)} messages")
108
- # Remove await from this line - it returns the context manager, not an awaitable
109
- stream = self.client.messages.stream(
110
- model=resolved_model, # Use the resolved model ID
111
- messages=processed_messages,
112
- temperature=temperature,
113
- max_tokens=max_tokens or 1024,
114
- )
108
+ debug_log(f"Anthropic: preparing {len(processed_messages)} messages for stream")
109
+
110
+ # Use more robust error handling with retry for connection issues
111
+ max_retries = 2
112
+ retry_count = 0
115
113
 
116
- debug_log("Anthropic: stream created successfully, processing chunks using async with")
117
- async with stream as stream_context: # Use async with
118
- async for chunk in stream_context: # Iterate over the context
119
- try:
120
- if chunk.type == "content_block_delta": # Check for delta type
121
- # Ensure we always return a string
122
- if chunk.delta.text is None:
123
- debug_log("Anthropic: skipping empty text delta chunk")
124
- continue
125
-
126
- text = str(chunk.delta.text) # Get text from delta
127
- debug_log(f"Anthropic: yielding chunk of length: {len(text)}")
128
- yield text
129
- else:
130
- debug_log(f"Anthropic: skipping non-content_delta chunk of type: {chunk.type}")
131
- except Exception as chunk_error: # Restore the except block for chunk processing
132
- debug_log(f"Anthropic: error processing chunk: {str(chunk_error)}")
133
- # Skip problematic chunks but continue processing
134
- continue # This continue is now correctly inside the loop and except block
114
+ while retry_count <= max_retries:
115
+ try:
116
+ debug_log(f"Anthropic: creating stream with model {model}")
117
+
118
+ # Create the stream
119
+ stream = await self.client.messages.create(
120
+ model=model,
121
+ messages=processed_messages,
122
+ temperature=temperature,
123
+ max_tokens=max_tokens if max_tokens else 4096,
124
+ stream=True
125
+ )
126
+
127
+ # Store the stream for potential cancellation
128
+ self._active_stream = stream
135
129
 
130
+ debug_log("Anthropic: stream created successfully")
131
+
132
+ # Process stream chunks
133
+ chunk_count = 0
134
+ debug_log("Anthropic: starting to process chunks")
135
+
136
+ async for chunk in stream:
137
+ # Check if stream has been cancelled
138
+ if self._active_stream is None:
139
+ debug_log("Anthropic: stream was cancelled, stopping generation")
140
+ break
141
+
142
+ chunk_count += 1
143
+ try:
144
+ if hasattr(chunk, 'delta') and hasattr(chunk.delta, 'text'):
145
+ content = chunk.delta.text
146
+ if content is not None:
147
+ debug_log(f"Anthropic: yielding chunk {chunk_count} of length: {len(content)}")
148
+ yield content
149
+ else:
150
+ debug_log(f"Anthropic: skipping None content chunk {chunk_count}")
151
+ else:
152
+ debug_log(f"Anthropic: skipping chunk {chunk_count} with missing content")
153
+ except Exception as chunk_error:
154
+ debug_log(f"Anthropic: error processing chunk {chunk_count}: {str(chunk_error)}")
155
+ # Skip problematic chunks but continue processing
156
+ continue
157
+
158
+ debug_log(f"Anthropic: stream completed successfully with {chunk_count} chunks")
159
+
160
+ # Clear the active stream reference when done
161
+ self._active_stream = None
162
+
163
+ # If we reach this point, we've successfully processed the stream
164
+ break
165
+
166
+ except Exception as e:
167
+ debug_log(f"Anthropic: error in attempt {retry_count+1}/{max_retries+1}: {str(e)}")
168
+ retry_count += 1
169
+ if retry_count <= max_retries:
170
+ debug_log(f"Anthropic: retrying after error (attempt {retry_count+1})")
171
+ # Simple exponential backoff
172
+ await asyncio.sleep(1 * retry_count)
173
+ else:
174
+ debug_log("Anthropic: max retries reached, raising exception")
175
+ raise Exception(f"Anthropic streaming error after {max_retries+1} attempts: {str(e)}")
176
+
136
177
  except Exception as e:
137
178
  debug_log(f"Anthropic: error in generate_stream: {str(e)}")
179
+ # Yield a simple error message as a last resort to ensure UI updates
180
+ yield f"Error: {str(e)}"
138
181
  raise Exception(f"Anthropic streaming error: {str(e)}")
139
-
140
- async def _fetch_models_from_api(self) -> List[Dict[str, Any]]:
141
- """Fetch available models directly from the Anthropic API."""
182
+
183
+ async def cancel_stream(self) -> None:
184
+ """Cancel any active streaming request"""
185
+ logger.info("Cancelling active Anthropic stream")
142
186
  try:
143
187
  from app.main import debug_log
188
+ debug_log("Anthropic: cancelling active stream")
144
189
  except ImportError:
145
- debug_log = lambda msg: None
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
-
161
- try:
162
- debug_log("Anthropic: Fetching models from API...")
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
190
+ pass
179
191
 
180
- # Try direct HTTP request if client exposes the underlying HTTP client
181
- if hasattr(self.client, '_client') and hasattr(self.client._client, 'get'):
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
208
-
209
- except Exception as e:
210
- debug_log(f"Anthropic: Failed to fetch models from API: {str(e)}")
211
- debug_log("Anthropic: Using fallback model list")
212
- return fallback_models
213
-
214
- def get_available_models(self) -> List[Dict[str, Any]]:
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"},
192
+ # Simply set the active stream to None
193
+ # This will cause the generate_stream method to stop processing chunks
194
+ self._active_stream = None
195
+ logger.info("Anthropic stream cancelled successfully")
196
+
197
+ async def get_available_models(self) -> List[Dict[str, Any]]:
198
+ """Get list of available Anthropic models"""
199
+ # Anthropic doesn't have a models endpoint, so we return a static list
200
+ models = [
201
+ {
202
+ "id": "claude-3-opus-20240229",
203
+ "name": "Claude 3 Opus",
204
+ "description": "Most powerful model for highly complex tasks",
205
+ "context_window": 200000,
206
+ "provider": "anthropic"
207
+ },
208
+ {
209
+ "id": "claude-3-sonnet-20240229",
210
+ "name": "Claude 3 Sonnet",
211
+ "description": "Balanced model for most tasks",
212
+ "context_window": 200000,
213
+ "provider": "anthropic"
214
+ },
215
+ {
216
+ "id": "claude-3-haiku-20240307",
217
+ "name": "Claude 3 Haiku",
218
+ "description": "Fastest and most compact model",
219
+ "context_window": 200000,
220
+ "provider": "anthropic"
221
+ },
222
+ {
223
+ "id": "claude-3-5-sonnet-20240620",
224
+ "name": "Claude 3.5 Sonnet",
225
+ "description": "Latest model with improved capabilities",
226
+ "context_window": 200000,
227
+ "provider": "anthropic"
228
+ },
229
+ {
230
+ "id": "claude-3-7-sonnet-20250219",
231
+ "name": "Claude 3.7 Sonnet",
232
+ "description": "Newest model with advanced reasoning",
233
+ "context_window": 200000,
234
+ "provider": "anthropic"
235
+ }
223
236
  ]
224
237
 
225
- try:
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
238
+ return models
app/api/base.py CHANGED
@@ -22,6 +22,11 @@ class BaseModelClient(ABC):
22
22
  """Generate a streaming text completion"""
23
23
  yield "" # Placeholder implementation
24
24
 
25
+ @abstractmethod
26
+ async def cancel_stream(self) -> None:
27
+ """Cancel any active streaming request"""
28
+ pass
29
+
25
30
  @abstractmethod
26
31
  def get_available_models(self) -> List[Dict[str, Any]]:
27
32
  """Get list of available models from this provider"""
app/api/ollama.py CHANGED
@@ -266,6 +266,31 @@ class OllamaClient(BaseModelClient):
266
266
  last_error = None
267
267
  self._active_stream_session = None # Track the active session
268
268
 
269
+ # First check if the model exists in our available models
270
+ try:
271
+ available_models = await self.get_available_models()
272
+ model_exists = False
273
+ available_model_names = []
274
+
275
+ for m in available_models:
276
+ model_id = m.get("id", "")
277
+ available_model_names.append(model_id)
278
+ if model_id == model:
279
+ model_exists = True
280
+ break
281
+
282
+ if not model_exists:
283
+ debug_log(f"Model '{model}' not found in available models")
284
+ # Instead of failing, yield a helpful error message
285
+ yield f"Model '{model}' not found. Available models include: {', '.join(available_model_names[:5])}"
286
+ if len(available_model_names) > 5:
287
+ yield f" and {len(available_model_names) - 5} more."
288
+ yield "\n\nPlease try a different model or check your spelling."
289
+ return
290
+ except Exception as e:
291
+ debug_log(f"Error checking model availability: {str(e)}")
292
+ # Continue anyway, the main request will handle errors
293
+
269
294
  while retries >= 0:
270
295
  try:
271
296
  # First try a quick test request to check if model is loaded
@@ -299,6 +324,16 @@ class OllamaClient(BaseModelClient):
299
324
  if response.status != 200:
300
325
  logger.warning(f"Model test request failed with status {response.status}")
301
326
  debug_log(f"Model test request failed with status {response.status}")
327
+
328
+ # Check if this is a 404 Not Found error
329
+ if response.status == 404:
330
+ error_text = await response.text()
331
+ debug_log(f"404 error details: {error_text}")
332
+ # This is likely a model not found error
333
+ yield f"Error: Model '{model}' not found on the Ollama server."
334
+ yield "\nPlease check if the model name is correct or try pulling it first."
335
+ return
336
+
302
337
  raise aiohttp.ClientError("Model not ready")
303
338
  except (aiohttp.ClientError, asyncio.TimeoutError) as e:
304
339
  logger.info(f"Model cold start detected: {str(e)}")
@@ -326,6 +361,16 @@ class OllamaClient(BaseModelClient):
326
361
  logger.error("Failed to pull model")
327
362
  debug_log("Failed to pull model")
328
363
  self._model_loading = False # Reset flag on failure
364
+
365
+ # Check if this is a 404 Not Found error
366
+ if pull_response.status == 404:
367
+ error_text = await pull_response.text()
368
+ debug_log(f"404 error details: {error_text}")
369
+ # This is likely a model not found in registry
370
+ yield f"Error: Model '{model}' not found in the Ollama registry."
371
+ yield "\nPlease check if the model name is correct or try a different model."
372
+ return
373
+
329
374
  raise Exception("Failed to pull model")
330
375
  logger.info("Model pulled successfully")
331
376
  debug_log("Model pulled successfully")
app/api/openai.py CHANGED
@@ -3,10 +3,15 @@ import asyncio
3
3
  from typing import List, Dict, Any, Optional, Generator, AsyncGenerator
4
4
  from .base import BaseModelClient
5
5
  from ..config import OPENAI_API_KEY
6
+ import logging
7
+
8
+ # Set up logging
9
+ logger = logging.getLogger(__name__)
6
10
 
7
11
  class OpenAIClient(BaseModelClient):
8
12
  def __init__(self):
9
13
  self.client = None # Initialize in create()
14
+ self._active_stream = None # Track active stream for cancellation
10
15
 
11
16
  @classmethod
12
17
  async def create(cls) -> 'OpenAIClient':
@@ -115,6 +120,10 @@ class OpenAIClient(BaseModelClient):
115
120
  max_tokens=max_tokens,
116
121
  stream=True,
117
122
  )
123
+
124
+ # Store the stream for potential cancellation
125
+ self._active_stream = stream
126
+
118
127
  debug_log("OpenAI: stream created successfully")
119
128
 
120
129
  # Yield a small padding token at the beginning for very short prompts
@@ -128,6 +137,11 @@ class OpenAIClient(BaseModelClient):
128
137
  debug_log("OpenAI: starting to process chunks")
129
138
 
130
139
  async for chunk in stream:
140
+ # Check if stream has been cancelled
141
+ if self._active_stream is None:
142
+ debug_log("OpenAI: stream was cancelled, stopping generation")
143
+ break
144
+
131
145
  chunk_count += 1
132
146
  try:
133
147
  if chunk.choices and hasattr(chunk.choices[0], 'delta') and hasattr(chunk.choices[0].delta, 'content'):
@@ -148,6 +162,9 @@ class OpenAIClient(BaseModelClient):
148
162
 
149
163
  debug_log(f"OpenAI: stream completed successfully with {chunk_count} chunks")
150
164
 
165
+ # Clear the active stream reference when done
166
+ self._active_stream = None
167
+
151
168
  # If we reach this point, we've successfully processed the stream
152
169
  break
153
170
 
@@ -168,6 +185,20 @@ class OpenAIClient(BaseModelClient):
168
185
  yield f"Error: {str(e)}"
169
186
  raise Exception(f"OpenAI streaming error: {str(e)}")
170
187
 
188
+ async def cancel_stream(self) -> None:
189
+ """Cancel any active streaming request"""
190
+ logger.info("Cancelling active OpenAI stream")
191
+ try:
192
+ from app.main import debug_log
193
+ debug_log("OpenAI: cancelling active stream")
194
+ except ImportError:
195
+ pass
196
+
197
+ # Simply set the active stream to None
198
+ # This will cause the generate_stream method to stop processing chunks
199
+ self._active_stream = None
200
+ logger.info("OpenAI stream cancelled successfully")
201
+
171
202
  async def get_available_models(self) -> List[Dict[str, Any]]:
172
203
  """Fetch list of available OpenAI models from the /models endpoint"""
173
204
  try:
app/main.py CHANGED
@@ -940,27 +940,39 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
940
940
  last_refresh_time = time.time() # Initialize refresh throttling timer
941
941
 
942
942
  async def update_ui(content: str):
943
- # This function remains the same, called by the worker
943
+ # This function is called by the worker with each content update
944
944
  if not self.is_generating:
945
945
  debug_log("update_ui called but is_generating is False, returning.")
946
946
  return
947
947
 
948
948
  async with update_lock:
949
949
  try:
950
+ # Add more verbose logging
951
+ debug_log(f"update_ui called with content length: {len(content)}")
952
+ print(f"update_ui: Updating with content length {len(content)}")
953
+
950
954
  # Clear thinking indicator on first content
951
955
  if assistant_message.content == "Thinking...":
952
956
  debug_log("First content received, clearing 'Thinking...'")
953
957
  print("First content received, clearing 'Thinking...'")
954
- assistant_message.content = ""
955
-
958
+ # We'll let the MessageDisplay.update_content handle this special case
959
+
956
960
  # Update the message object with the full content
957
961
  assistant_message.content = content
958
962
 
959
- # Update UI with the content
963
+ # Update UI with the content - this now has special handling for "Thinking..."
964
+ debug_log("Calling message_display.update_content")
960
965
  await message_display.update_content(content)
961
966
 
962
- # Simple refresh approach - just force a layout refresh
967
+ # More aggressive UI refresh sequence
968
+ debug_log("Performing UI refresh sequence")
969
+ # First do a lightweight refresh
970
+ self.refresh(layout=False)
971
+ # Then scroll to end
972
+ messages_container.scroll_end(animate=False)
973
+ # Then do a full layout refresh
963
974
  self.refresh(layout=True)
975
+ # Final scroll to ensure visibility
964
976
  messages_container.scroll_end(animate=False)
965
977
 
966
978
  except Exception as e:
@@ -1030,14 +1042,32 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
1030
1042
  error = worker.error
1031
1043
  debug_log(f"Error in generation worker: {error}")
1032
1044
  log.error(f"Error in generation worker: {error}")
1033
- self.notify(f"Generation error: {error}", severity="error", timeout=5)
1045
+
1046
+ # Sanitize error message for UI display
1047
+ error_str = str(error)
1048
+
1049
+ # Check if this is an Ollama error
1050
+ is_ollama_error = "ollama" in error_str.lower() or "404" in error_str
1051
+
1052
+ # Create a user-friendly error message
1053
+ if is_ollama_error:
1054
+ # For Ollama errors, provide a more user-friendly message
1055
+ user_error = "Unable to generate response. The selected model may not be available."
1056
+ debug_log(f"Sanitizing Ollama error to user-friendly message: {user_error}")
1057
+ # Show technical details only in notification, not in chat
1058
+ self.notify(f"Model error: {error_str}", severity="error", timeout=5)
1059
+ else:
1060
+ # For other errors, show a generic message
1061
+ user_error = f"Error generating response: {error_str}"
1062
+ self.notify(f"Generation error: {error_str}", severity="error", timeout=5)
1063
+
1034
1064
  # Add error message to UI
1035
1065
  if self.messages and self.messages[-1].role == "assistant":
1036
1066
  debug_log("Removing thinking message")
1037
1067
  self.messages.pop() # Remove thinking message
1038
- error_msg = f"Error: {error}"
1039
- debug_log(f"Adding error message: {error_msg}")
1040
- self.messages.append(Message(role="assistant", content=error_msg))
1068
+
1069
+ debug_log(f"Adding error message: {user_error}")
1070
+ self.messages.append(Message(role="assistant", content=user_error))
1041
1071
  await self.update_messages_ui()
1042
1072
 
1043
1073
  elif worker.state == "success":
app/ui/chat_interface.py CHANGED
@@ -121,11 +121,50 @@ class MessageDisplay(Static): # Inherit from Static instead of RichLog
121
121
 
122
122
  async def update_content(self, content: str) -> None:
123
123
  """Update the message content using Static.update() with optimizations for streaming"""
124
+ # Use proper logging instead of print statements
125
+ import logging
126
+ logger = logging.getLogger(__name__)
127
+ logger.debug(f"MessageDisplay.update_content called with content length: {len(content)}")
128
+
124
129
  # Quick unchanged content check to avoid unnecessary updates
125
130
  if self.message.content == content:
131
+ logger.debug("Content unchanged, skipping update")
132
+ return
133
+
134
+ # Special handling for "Thinking..." to ensure it gets replaced
135
+ if self.message.content == "Thinking..." and content:
136
+ logger.debug("Replacing 'Thinking...' with actual content")
137
+ # Force a complete replacement rather than an append
138
+ self.message.content = ""
139
+
140
+ # Update the stored message object content
141
+ self.message.content = content
142
+
143
+ # Format with fixed-width placeholder to minimize layout shifts
144
+ formatted_content = self._format_content(content)
145
+
146
+ # Use a direct update that forces refresh - critical fix for streaming
147
+ self.update(formatted_content, refresh=True)
148
+
149
+ # Force app-level refresh and scroll to ensure visibility
150
+ try:
151
+ if self.app:
152
+ # Force a full layout refresh to ensure content is visible
153
+ self.app.refresh(layout=True)
154
+
155
+ # Find the messages container and scroll to end
156
+ containers = self.app.query("ScrollableContainer")
157
+ for container in containers:
158
+ if hasattr(container, 'scroll_end'):
159
+ container.scroll_end(animate=False)
160
+ except Exception as e:
161
+ logger.error(f"Error refreshing app: {str(e)}")
162
+ self.refresh(layout=True)
163
+
164
+ # Return early to avoid duplicate updates
126
165
  return
127
166
 
128
- # Update the stored message object content first
167
+ # Update the stored message object content
129
168
  self.message.content = content
130
169
 
131
170
  # Format with fixed-width placeholder to minimize layout shifts
@@ -134,6 +173,7 @@ class MessageDisplay(Static): # Inherit from Static instead of RichLog
134
173
 
135
174
  # Use a direct update that forces refresh - critical fix for streaming
136
175
  # This ensures content is immediately visible
176
+ logger.debug(f"Updating widget with formatted content length: {len(formatted_content)}")
137
177
  self.update(formatted_content, refresh=True)
138
178
 
139
179
  # Force app-level refresh and scroll to ensure visibility
@@ -150,13 +190,18 @@ class MessageDisplay(Static): # Inherit from Static instead of RichLog
150
190
  container.scroll_end(animate=False)
151
191
  except Exception as e:
152
192
  # Log the error and fallback to local refresh
153
- print(f"Error refreshing app: {str(e)}")
193
+ logger.error(f"Error refreshing app: {str(e)}")
154
194
  self.refresh(layout=True)
155
195
 
156
196
  def _format_content(self, content: str) -> str:
157
197
  """Format message content with timestamp and handle markdown links"""
158
198
  timestamp = datetime.now().strftime("%H:%M")
159
199
 
200
+ # Special handling for "Thinking..." to make it visually distinct
201
+ if content == "Thinking...":
202
+ # Use italic style for the thinking indicator
203
+ return f"[dim]{timestamp}[/dim] [italic]{content}[/italic]"
204
+
160
205
  # Fix markdown-style links that cause markup errors
161
206
  # Convert [text](url) to a safe format for Textual markup
162
207
  content = re.sub(
@@ -170,8 +215,8 @@ class MessageDisplay(Static): # Inherit from Static instead of RichLog
170
215
  # But keep our timestamp markup
171
216
  timestamp_markup = f"[dim]{timestamp}[/dim]"
172
217
 
173
- # Debug print to verify content is being formatted
174
- print(f"Formatting content: {len(content)} chars")
218
+ # Use proper logging instead of print
219
+ logger.debug(f"Formatting content: {len(content)} chars")
175
220
 
176
221
  return f"{timestamp_markup} {content}"
177
222
 
app/utils.py CHANGED
@@ -63,17 +63,62 @@ async def generate_conversation_title(message: str, model: str, client: Any) ->
63
63
 
64
64
  # Check if client is OpenAI
65
65
  is_openai = 'openai' in str(type(client)).lower()
66
- if is_openai and not title_model_id:
66
+ if is_openai:
67
67
  debug_log("Using OpenAI client for title generation")
68
68
  # Use GPT-3.5 for title generation (fast and cost-effective)
69
69
  title_model_id = "gpt-3.5-turbo"
70
70
  debug_log(f"Using OpenAI model for title generation: {title_model_id}")
71
+ # For OpenAI, we'll always use their model, not fall back to the passed model
72
+ # This prevents trying to use Ollama models with OpenAI client
73
+
74
+ # Check if client is Ollama
75
+ is_ollama = 'ollama' in str(type(client)).lower()
76
+ if is_ollama and not title_model_id:
77
+ debug_log("Using Ollama client for title generation")
78
+ # For Ollama, check if the model exists before using it
79
+ try:
80
+ # Try a quick test request to check if model exists
81
+ debug_log(f"Testing if Ollama model exists: {model}")
82
+ import aiohttp
83
+ async with aiohttp.ClientSession() as session:
84
+ try:
85
+ base_url = "http://localhost:11434"
86
+ async with session.post(
87
+ f"{base_url}/api/generate",
88
+ json={"model": model, "prompt": "test", "stream": False},
89
+ timeout=2
90
+ ) as response:
91
+ if response.status == 200:
92
+ # Model exists, use it
93
+ title_model_id = model
94
+ debug_log(f"Ollama model {model} exists, using it for title generation")
95
+ else:
96
+ debug_log(f"Ollama model {model} returned status {response.status}, falling back to default")
97
+ # Fall back to a common model
98
+ title_model_id = "llama3"
99
+ except Exception as e:
100
+ debug_log(f"Error testing Ollama model: {str(e)}, falling back to default")
101
+ # Fall back to a common model
102
+ title_model_id = "llama3"
103
+ except Exception as e:
104
+ debug_log(f"Error checking Ollama model: {str(e)}")
105
+ # Fall back to a common model
106
+ title_model_id = "llama3"
71
107
 
72
108
  # Fallback logic if no specific model was found
73
109
  if not title_model_id:
74
- # Use the originally passed model as the final fallback
75
- title_model_id = model
76
- debug_log(f"Falling back to originally selected model for title generation: {title_model_id}")
110
+ # Use a safe default based on client type
111
+ if is_openai:
112
+ title_model_id = "gpt-3.5-turbo"
113
+ elif is_anthropic:
114
+ title_model_id = "claude-3-haiku-20240307"
115
+ elif is_ollama:
116
+ title_model_id = "llama3" # Common default
117
+ else:
118
+ # Last resort - use the originally passed model
119
+ title_model_id = model
120
+
121
+ debug_log(f"No specific model found, using fallback model for title generation: {title_model_id}")
77
122
 
78
123
  logger.info(f"Generating title for conversation using model: {title_model_id}")
79
124
  debug_log(f"Final model selected for title generation: {title_model_id}")
@@ -325,25 +370,26 @@ async def generate_streaming_response(
325
370
  full_response += new_content
326
371
  debug_log(f"Updating UI with content length: {len(full_response)}")
327
372
 
328
- # Only print to console for debugging if not OpenAI
329
- # This prevents Ollama debug output from appearing in OpenAI responses
330
- if not is_openai:
331
- print(f"Streaming update: +{len(new_content)} chars, total: {len(full_response)}")
373
+ # Enhanced debug logging
374
+ print(f"STREAM DEBUG: +{len(new_content)} chars, total: {len(full_response)}")
375
+ # Print first few characters of content for debugging
376
+ if len(full_response) < 100:
377
+ print(f"STREAM CONTENT: '{full_response}'")
332
378
 
333
379
  try:
334
380
  # Call the UI callback with the full response so far
381
+ debug_log("Calling UI callback with content")
335
382
  await callback(full_response)
336
383
  debug_log("UI callback completed successfully")
337
384
 
338
385
  # Force app refresh after each update
339
386
  if hasattr(app, 'refresh'):
387
+ debug_log("Forcing app refresh")
340
388
  app.refresh(layout=True) # Force layout refresh
341
389
  except Exception as callback_err:
342
390
  debug_log(f"Error in UI callback: {str(callback_err)}")
343
391
  logger.error(f"Error in UI callback: {str(callback_err)}")
344
- # Only print error to console if not OpenAI
345
- if not is_openai:
346
- print(f"Error updating UI: {str(callback_err)}")
392
+ print(f"STREAM ERROR: Error updating UI: {str(callback_err)}")
347
393
 
348
394
  buffer = []
349
395
  last_update = current_time
@@ -509,6 +555,23 @@ def resolve_model_id(model_id_or_name: str) -> str:
509
555
  input_lower = model_id_or_name.lower().strip()
510
556
  logger.info(f"Attempting to resolve model identifier: '{input_lower}'")
511
557
 
558
+ # Special case handling for common typos and model name variations
559
+ typo_corrections = {
560
+ "o4-mini": "04-mini",
561
+ "o1": "01",
562
+ "o1-mini": "01-mini",
563
+ "o1-preview": "01-preview",
564
+ "o4": "04",
565
+ "o4-preview": "04-preview",
566
+ "o4-vision": "04-vision"
567
+ }
568
+
569
+ if input_lower in typo_corrections:
570
+ corrected = typo_corrections[input_lower]
571
+ logger.info(f"Converting '{input_lower}' to '{corrected}' (letter 'o' to zero '0')")
572
+ input_lower = corrected
573
+ model_id_or_name = corrected
574
+
512
575
  # First, check if this is an OpenAI model - if so, return as-is to ensure correct provider
513
576
  if any(name in input_lower for name in ["gpt", "text-", "davinci"]):
514
577
  logger.info(f"Input '{input_lower}' appears to be an OpenAI model, returning as-is")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chat-console
3
- Version: 0.3.7
3
+ Version: 0.3.9
4
4
  Summary: A command-line interface for chatting with LLMs, storing chats and (future) rag interactions
5
5
  Home-page: https://github.com/wazacraftrfid/chat-console
6
6
  Author: Johnathan Greenaway
@@ -0,0 +1,24 @@
1
+ app/__init__.py,sha256=EjqUVXPPqxbEvf8FYWy5IflGPyCeiFKVc6roYG8q77k,130
2
+ app/config.py,sha256=KawltE7cK2bR9wbe1NSlepwWIjkiFw2bg3vbLmUnP38,7626
3
+ app/database.py,sha256=nt8CVuDpy6zw8mOYqDcfUmNw611t7Ln7pz22M0b6-MI,9967
4
+ app/main.py,sha256=KEkM7wMG7gQ4jFTRNWTTm7HQL5av6fVHFzg-uFyroZw,74654
5
+ app/models.py,sha256=4-y9Lytay2exWPFi0FDlVeRL3K2-I7E-jBqNzTfokqY,2644
6
+ app/utils.py,sha256=6za9f3USUiYvjTiwPDP7swPamRmlwApCYPyCKc9drNY,35228
7
+ app/api/__init__.py,sha256=A8UL84ldYlv8l7O-yKzraVFcfww86SgWfpl4p7R03-w,62
8
+ app/api/anthropic.py,sha256=uInwNvGLJ_iPUs4BjdwaqXTU6NfmK1SzX7498Pt44fI,10667
9
+ app/api/base.py,sha256=Oqu674v0NkrJY91tvxGd6YWgyi6XrFvi03quzWGswg8,7425
10
+ app/api/ollama.py,sha256=uBCdfie04zdp1UGePpz7m0XuOwMB71ynz9CulnKUDHg,64284
11
+ app/api/openai.py,sha256=hLPr955tUx_2vwRuLP8Zrl3vu7kQZgUETi4cJuaYnFE,10810
12
+ app/ui/__init__.py,sha256=RndfbQ1Tv47qdSiuQzvWP96lPS547SDaGE-BgOtiP_w,55
13
+ app/ui/chat_interface.py,sha256=xJe3LoKbXJe1XHREevkMHL9ATpRg6y0ayu2hVGWELQM,19459
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=ue3rbZfjVsjli-rJN5mfSqq23Ci7NshmTb4xWS-uG5k,18685
17
+ app/ui/search.py,sha256=b-m14kG3ovqW1-i0qDQ8KnAqFJbi5b1FLM9dOnbTyIs,9763
18
+ app/ui/styles.py,sha256=04AhPuLrOd2yenfRySFRestPeuTPeMLzhmMB67NdGvw,5615
19
+ chat_console-0.3.9.dist-info/licenses/LICENSE,sha256=srHZ3fvcAuZY1LHxE7P6XWju2njRCHyK6h_ftEbzxSE,1057
20
+ chat_console-0.3.9.dist-info/METADATA,sha256=hqzrcRA8zI4qKGLdUEp7j4Y9sLSsFA6lTyhdM4f1GHY,2921
21
+ chat_console-0.3.9.dist-info/WHEEL,sha256=SmOxYU7pzNKBqASvQJ7DjX3XGUF92lrGhMb3R6_iiqI,91
22
+ chat_console-0.3.9.dist-info/entry_points.txt,sha256=kkVdEc22U9PAi2AeruoKklfkng_a_aHAP6VRVwrAD7c,67
23
+ chat_console-0.3.9.dist-info/top_level.txt,sha256=io9g7LCbfmTG1SFKgEOGXmCFB9uMP2H5lerm0HiHWQE,4
24
+ chat_console-0.3.9.dist-info/RECORD,,
@@ -1,24 +0,0 @@
1
- app/__init__.py,sha256=ZSZR6xIuPhvv1zB4p63eSeGQX8bTkhxBWk2Gn0peFaw,130
2
- app/config.py,sha256=KawltE7cK2bR9wbe1NSlepwWIjkiFw2bg3vbLmUnP38,7626
3
- app/database.py,sha256=nt8CVuDpy6zw8mOYqDcfUmNw611t7Ln7pz22M0b6-MI,9967
4
- app/main.py,sha256=clcRjXwySxVjrPtqvPOIfl7r8KbHVLZ1woxyEnvl3JI,72829
5
- app/models.py,sha256=4-y9Lytay2exWPFi0FDlVeRL3K2-I7E-jBqNzTfokqY,2644
6
- app/utils.py,sha256=htktBl1JucYEHo1WBrWkfdip4yzRtvyVl24Aaj445xA,32421
7
- app/api/__init__.py,sha256=A8UL84ldYlv8l7O-yKzraVFcfww86SgWfpl4p7R03-w,62
8
- app/api/anthropic.py,sha256=UpIP3CgAOUimdVyif41MhBOCAgOyFO8mX9SFQMKRAmc,12483
9
- app/api/base.py,sha256=eShCiZIcW3yeZLONt1xnkP0vU6v5MEaDj3YZ3xcPle8,7294
10
- app/api/ollama.py,sha256=EBEEKXbgAYWEg_zF5PO_UKO5l_aoU3J_7tfCj9e-fqs,61699
11
- app/api/openai.py,sha256=6ORruzuuZtIjME3WK-g7kXf7cBmM4td5Njv9JLaWh7E,9557
12
- app/ui/__init__.py,sha256=RndfbQ1Tv47qdSiuQzvWP96lPS547SDaGE-BgOtiP_w,55
13
- app/ui/chat_interface.py,sha256=TJlMzVmrKzr3t0JIhto0vKBvyik7gJ7UEyW3Vqbn3cE,17262
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=ue3rbZfjVsjli-rJN5mfSqq23Ci7NshmTb4xWS-uG5k,18685
17
- app/ui/search.py,sha256=b-m14kG3ovqW1-i0qDQ8KnAqFJbi5b1FLM9dOnbTyIs,9763
18
- app/ui/styles.py,sha256=04AhPuLrOd2yenfRySFRestPeuTPeMLzhmMB67NdGvw,5615
19
- chat_console-0.3.7.dist-info/licenses/LICENSE,sha256=srHZ3fvcAuZY1LHxE7P6XWju2njRCHyK6h_ftEbzxSE,1057
20
- chat_console-0.3.7.dist-info/METADATA,sha256=eDQRUghh8Ihp8z38oAlI0___RBBDJHpLmhBGF0VgZ1w,2921
21
- chat_console-0.3.7.dist-info/WHEEL,sha256=SmOxYU7pzNKBqASvQJ7DjX3XGUF92lrGhMb3R6_iiqI,91
22
- chat_console-0.3.7.dist-info/entry_points.txt,sha256=kkVdEc22U9PAi2AeruoKklfkng_a_aHAP6VRVwrAD7c,67
23
- chat_console-0.3.7.dist-info/top_level.txt,sha256=io9g7LCbfmTG1SFKgEOGXmCFB9uMP2H5lerm0HiHWQE,4
24
- chat_console-0.3.7.dist-info/RECORD,,