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