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 +1 -1
- app/api/anthropic.py +104 -73
- app/api/base.py +4 -4
- app/api/ollama.py +30 -13
- app/api/openai.py +79 -25
- app/main.py +103 -37
- app/ui/chat_interface.py +54 -11
- app/ui/model_selector.py +24 -11
- app/utils.py +70 -14
- {chat_console-0.2.98.dist-info → chat_console-0.3.0.dist-info}/METADATA +1 -1
- chat_console-0.3.0.dist-info/RECORD +24 -0
- chat_console-0.2.98.dist-info/RECORD +0 -24
- {chat_console-0.2.98.dist-info → chat_console-0.3.0.dist-info}/WHEEL +0 -0
- {chat_console-0.2.98.dist-info → chat_console-0.3.0.dist-info}/entry_points.txt +0 -0
- {chat_console-0.2.98.dist-info → chat_console-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {chat_console-0.2.98.dist-info → chat_console-0.3.0.dist-info}/top_level.txt +0 -0
app/__init__.py
CHANGED
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 =
|
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
|
-
|
143
|
-
#
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
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
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
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
|
-
|
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
|
-
#
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
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
|
-
|
383
|
-
|
384
|
-
|
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:
|
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 =
|
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
|
-
|
95
|
-
|
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
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
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
|
-
#
|
585
|
-
# This avoids
|
586
|
-
|
587
|
-
|
588
|
-
|
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
|
-
#
|
591
|
-
|
592
|
-
|
593
|
-
|
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
|
-
#
|
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
|
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
|
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
|
-
#
|
931
|
-
|
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 <
|
935
|
-
content_length %
|
936
|
-
|
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
|
940
|
-
|
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
|
-
#
|
943
|
-
self.refresh(layout=
|
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
|
-
#
|
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:
|
68
|
+
overflow-y: auto; /* Changed from 'visible' to valid 'auto' value */
|
70
69
|
box-sizing: border-box; /* Include padding in size calculations */
|
71
|
-
|
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
|
-
#
|
133
|
-
|
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
|
-
#
|
136
|
-
# This
|
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
|
-
|
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-
|
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
|
-
#
|
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
|
|
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
|
292
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
#
|
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
|
-
#
|
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
|
-
#
|
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
|
-
#
|
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
|
-
#
|
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
|
-
#
|
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
|
-
#
|
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.
|
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,,
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|