chat-console 0.2.8__py3-none-any.whl → 0.2.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.2.8"
6
+ __version__ = "0.2.9"
app/api/ollama.py CHANGED
@@ -22,6 +22,9 @@ class OllamaClient(BaseModelClient):
22
22
  # Track active stream session
23
23
  self._active_stream_session = None
24
24
 
25
+ # Track model loading state
26
+ self._model_loading = False
27
+
25
28
  # Path to the cached models file
26
29
  self.models_cache_path = Path(__file__).parent.parent / "data" / "ollama-models.json"
27
30
 
@@ -191,6 +194,10 @@ class OllamaClient(BaseModelClient):
191
194
  raise aiohttp.ClientError("Model not ready")
192
195
  except (aiohttp.ClientError, asyncio.TimeoutError) as e:
193
196
  logger.info(f"Model cold start detected: {str(e)}")
197
+ # Set model loading flag
198
+ self._model_loading = True
199
+ logger.info("Setting model_loading state to True")
200
+
194
201
  # Model might need loading, try pulling it
195
202
  async with session.post(
196
203
  f"{self.base_url}/api/pull",
@@ -199,8 +206,10 @@ class OllamaClient(BaseModelClient):
199
206
  ) as pull_response:
200
207
  if pull_response.status != 200:
201
208
  logger.error("Failed to pull model")
209
+ self._model_loading = False # Reset flag on failure
202
210
  raise Exception("Failed to pull model")
203
211
  logger.info("Model pulled successfully")
212
+ self._model_loading = False # Reset flag after successful pull
204
213
 
205
214
  # Now proceed with actual generation
206
215
  session = aiohttp.ClientSession()
@@ -208,7 +217,7 @@ class OllamaClient(BaseModelClient):
208
217
 
209
218
  try:
210
219
  logger.debug(f"Sending streaming request to {self.base_url}/api/generate")
211
- async with session.post(
220
+ response = await session.post(
212
221
  f"{self.base_url}/api/generate",
213
222
  json={
214
223
  "model": model,
@@ -217,19 +226,36 @@ class OllamaClient(BaseModelClient):
217
226
  "stream": True
218
227
  },
219
228
  timeout=60 # Longer timeout for actual generation
220
- ) as response:
221
- response.raise_for_status()
222
- async for line in response.content:
223
- if line:
224
- chunk = line.decode().strip()
225
- try:
226
- data = json.loads(chunk)
227
- if "response" in data:
228
- yield data["response"]
229
- except json.JSONDecodeError:
230
- continue
231
- logger.info("Streaming completed successfully")
232
- return
229
+ )
230
+ response.raise_for_status()
231
+
232
+ # Process the response stream
233
+ while True:
234
+ if not self._active_stream_session:
235
+ logger.info("Stream session was closed externally")
236
+ break
237
+
238
+ try:
239
+ line = await asyncio.wait_for(response.content.readline(), timeout=0.5)
240
+ if not line: # End of stream
241
+ break
242
+
243
+ chunk = line.decode().strip()
244
+ try:
245
+ data = json.loads(chunk)
246
+ if "response" in data:
247
+ yield data["response"]
248
+ except json.JSONDecodeError:
249
+ continue
250
+ except asyncio.TimeoutError:
251
+ # This allows checking for cancellation regularly
252
+ continue
253
+ except asyncio.CancelledError:
254
+ logger.info("Stream processing was cancelled")
255
+ raise
256
+
257
+ logger.info("Streaming completed successfully")
258
+ return
233
259
  finally:
234
260
  self._active_stream_session = None # Clear reference when done
235
261
  await session.close() # Ensure session is closed
@@ -260,6 +286,12 @@ class OllamaClient(BaseModelClient):
260
286
  logger.info("Cancelling active stream session")
261
287
  await self._active_stream_session.close()
262
288
  self._active_stream_session = None
289
+ self._model_loading = False
290
+ logger.info("Stream session closed successfully")
291
+
292
+ def is_loading_model(self) -> bool:
293
+ """Check if Ollama is currently loading a model"""
294
+ return self._model_loading
263
295
 
264
296
  async def get_model_details(self, model_id: str) -> Dict[str, Any]:
265
297
  """Get detailed information about a specific Ollama model"""
app/main.py CHANGED
@@ -477,30 +477,45 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
477
477
  log("Attempting to cancel generation task")
478
478
  if self.current_generation_task and not self.current_generation_task.done():
479
479
  log("Cancelling active generation task.")
480
- # Get the client for the current model
480
+
481
+ # Get the client for the current model first and cancel the connection
481
482
  try:
482
483
  model = self.selected_model
483
484
  client = BaseModelClient.get_client_for_model(model)
484
- # Call the client's cancel method if it's an Ollama client
485
+
486
+ # Call the client's cancel method if it's supported
485
487
  if hasattr(client, 'cancel_stream'):
486
488
  log("Calling client.cancel_stream() to terminate API session")
487
- await client.cancel_stream()
489
+ try:
490
+ # This will close the HTTP connection to Ollama server
491
+ await client.cancel_stream()
492
+ log("Client stream cancelled successfully")
493
+ except Exception as e:
494
+ log.error(f"Error in client.cancel_stream(): {str(e)}")
495
+ except Exception as e:
496
+ log.error(f"Error setting up client cancellation: {str(e)}")
497
+
498
+ # Now cancel the asyncio task - this should raise CancelledError in the task
499
+ try:
500
+ log("Cancelling asyncio task")
501
+ self.current_generation_task.cancel()
502
+ # Give a moment for cancellation to propagate
503
+ await asyncio.sleep(0.1)
504
+ log(f"Task cancelled. Task done: {self.current_generation_task.done()}")
488
505
  except Exception as e:
489
- log.error(f"Error cancelling client stream: {str(e)}")
506
+ log.error(f"Error cancelling task: {str(e)}")
490
507
 
491
- # Now cancel the asyncio task
492
- self.current_generation_task.cancel()
493
- # The finally block in generate_response will handle is_generating = False and UI updates
494
- self.notify("Stopping generation...", severity="warning", timeout=2) # Notify user immediately
508
+ # Notify user that we're stopping
509
+ self.notify("Stopping generation...", severity="warning", timeout=2)
495
510
  else:
496
- # This case might happen if is_generating is True, but no active task found to cancel. Resetting flag.")
497
- self.is_generating = False # Reset flag manually if task is missing
511
+ # This happens if is_generating is True, but no active task found to cancel
512
+ log("No active generation task found, but is_generating=True. Resetting state.")
513
+ self.is_generating = False
498
514
  loading = self.query_one("#loading-indicator")
499
515
  loading.add_class("hidden")
500
516
  else:
501
517
  log("Escape pressed, but settings not visible and not actively generating.")
502
- # Optionally add other escape behaviors here if needed for the main screen
503
- # e.g., clear input, deselect item, etc.
518
+ # Optionally add other escape behaviors here if needed
504
519
 
505
520
  def update_app_info(self) -> None:
506
521
  """Update the displayed app information."""
app/ui/chat_interface.py CHANGED
@@ -204,6 +204,11 @@ class ChatInterface(Container):
204
204
  display: none;
205
205
  padding: 0 1;
206
206
  }
207
+
208
+ #loading-indicator.model-loading {
209
+ background: $warning;
210
+ color: $text;
211
+ }
207
212
  """
208
213
 
209
214
  class MessageSent(Message):
@@ -238,7 +243,7 @@ class ChatInterface(Container):
238
243
  yield MessageDisplay(message, highlight_code=CONFIG["highlight_code"])
239
244
  with Container(id="input-area"):
240
245
  yield Container(
241
- Label("Generating response...", id="loading-text"),
246
+ Label("▪▪▪ Generating response...", id="loading-text", markup=True),
242
247
  id="loading-indicator"
243
248
  )
244
249
  with Container(id="controls"):
@@ -328,16 +333,30 @@ class ChatInterface(Container):
328
333
  if input_widget.has_focus:
329
334
  input_widget.focus()
330
335
 
331
- def start_loading(self) -> None:
332
- """Show loading indicator"""
336
+ def start_loading(self, model_loading: bool = False) -> None:
337
+ """Show loading indicator
338
+
339
+ Args:
340
+ model_loading: If True, indicates Ollama is loading a model
341
+ """
333
342
  self.is_loading = True
334
343
  loading = self.query_one("#loading-indicator")
344
+ loading_text = self.query_one("#loading-text")
345
+
346
+ if model_loading:
347
+ loading.add_class("model-loading")
348
+ loading_text.update("⚙️ Loading Ollama model...")
349
+ else:
350
+ loading.remove_class("model-loading")
351
+ loading_text.update("▪▪▪ Generating response...")
352
+
335
353
  loading.display = True
336
354
 
337
355
  def stop_loading(self) -> None:
338
356
  """Hide loading indicator"""
339
357
  self.is_loading = False
340
358
  loading = self.query_one("#loading-indicator")
359
+ loading.remove_class("model-loading")
341
360
  loading.display = False
342
361
 
343
362
  def clear_messages(self) -> None:
app/utils.py CHANGED
@@ -86,12 +86,71 @@ async def generate_streaming_response(app: 'SimpleChatApp', messages: List[Dict]
86
86
  buffer = []
87
87
  last_update = time.time()
88
88
  update_interval = 0.1 # Update UI every 100ms
89
- generation_task = None
90
89
 
91
90
  try:
92
- # The cancellation is now handled by cancelling the asyncio Task in main.py
93
- # which will raise CancelledError here, interrupting the loop.
94
- async for chunk in client.generate_stream(messages, model, style):
91
+ # Update UI with model loading state if it's an Ollama client
92
+ if hasattr(client, 'is_loading_model'):
93
+ # Send signal to update UI for model loading if needed
94
+ try:
95
+ # The client might be in model loading state even before generating
96
+ model_loading = client.is_loading_model()
97
+ logger.info(f"Initial model loading state: {model_loading}")
98
+
99
+ # Get the chat interface and update loading indicator
100
+ if hasattr(app, 'query_one'):
101
+ loading = app.query_one("#loading-indicator")
102
+ if model_loading:
103
+ loading.add_class("model-loading")
104
+ app.query_one("#loading-text").update("Loading Ollama model...")
105
+ else:
106
+ loading.remove_class("model-loading")
107
+ except Exception as e:
108
+ logger.error(f"Error setting initial loading state: {str(e)}")
109
+
110
+ stream_generator = client.generate_stream(messages, model, style)
111
+
112
+ # Check if we just entered model loading state
113
+ if hasattr(client, 'is_loading_model') and client.is_loading_model():
114
+ logger.info("Model loading started during generation")
115
+ try:
116
+ if hasattr(app, 'query_one'):
117
+ loading = app.query_one("#loading-indicator")
118
+ loading.add_class("model-loading")
119
+ app.query_one("#loading-text").update("Loading Ollama model...")
120
+ except Exception as e:
121
+ logger.error(f"Error updating UI for model loading: {str(e)}")
122
+
123
+ # Use asyncio.shield to ensure we can properly interrupt the stream processing
124
+ async for chunk in stream_generator:
125
+ # Check for cancellation frequently
126
+ if asyncio.current_task().cancelled():
127
+ logger.info("Task cancellation detected during chunk processing")
128
+ # Close the client stream if possible
129
+ if hasattr(client, 'cancel_stream'):
130
+ await client.cancel_stream()
131
+ raise asyncio.CancelledError()
132
+
133
+ # Check if model loading state changed
134
+ if hasattr(client, 'is_loading_model'):
135
+ model_loading = client.is_loading_model()
136
+ try:
137
+ if hasattr(app, 'query_one'):
138
+ loading = app.query_one("#loading-indicator")
139
+ loading_text = app.query_one("#loading-text")
140
+
141
+ if model_loading and not loading.has_class("model-loading"):
142
+ # Model loading started
143
+ logger.info("Model loading started during streaming")
144
+ loading.add_class("model-loading")
145
+ loading_text.update("⚙️ Loading Ollama model...")
146
+ elif not model_loading and loading.has_class("model-loading"):
147
+ # Model loading finished
148
+ logger.info("Model loading finished during streaming")
149
+ loading.remove_class("model-loading")
150
+ loading_text.update("▪▪▪ Generating response...")
151
+ except Exception as e:
152
+ logger.error(f"Error updating loading state during streaming: {str(e)}")
153
+
95
154
  if chunk: # Only process non-empty chunks
96
155
  buffer.append(chunk)
97
156
  current_time = time.time()
@@ -100,7 +159,7 @@ async def generate_streaming_response(app: 'SimpleChatApp', messages: List[Dict]
100
159
  if current_time - last_update >= update_interval or len(''.join(buffer)) > 100:
101
160
  new_content = ''.join(buffer)
102
161
  full_response += new_content
103
- # No need to check app.is_generating here, rely on CancelledError
162
+ # Send content to UI
104
163
  await callback(full_response)
105
164
  buffer = []
106
165
  last_update = current_time
@@ -114,23 +173,25 @@ async def generate_streaming_response(app: 'SimpleChatApp', messages: List[Dict]
114
173
  full_response += new_content
115
174
  await callback(full_response)
116
175
 
117
- logger.info("Streaming response loop finished normally.") # Clarify log message
118
- # Add log before returning
119
- logger.info(f"generate_streaming_response returning normally. Full response length: {len(full_response)}")
176
+ logger.info(f"Streaming response completed successfully. Response length: {len(full_response)}")
120
177
  return full_response
178
+
121
179
  except asyncio.CancelledError:
122
180
  # This is expected when the user cancels via Escape
123
- logger.info("Streaming response task cancelled.") # Clarify log message
124
- # Add log before returning
125
- logger.info(f"generate_streaming_response returning after cancellation. Partial response length: {len(full_response)}")
126
- # Do not re-raise CancelledError, let the caller handle it
127
- return full_response # Return whatever was collected so far (might be partial)
181
+ logger.info(f"Streaming response task cancelled. Partial response length: {len(full_response)}")
182
+ # Ensure the client stream is closed
183
+ if hasattr(client, 'cancel_stream'):
184
+ await client.cancel_stream()
185
+ # Return whatever was collected so far
186
+ return full_response
187
+
128
188
  except Exception as e:
129
- logger.error(f"Error during streaming response: {str(e)}") # Clarify log message
130
- # Ensure the app knows generation stopped on error ONLY if it wasn't cancelled
131
- if not isinstance(e, asyncio.CancelledError):
132
- app.is_generating = False # Reset flag on other errors
133
- raise # Re-raise other exceptions
189
+ logger.error(f"Error during streaming response: {str(e)}")
190
+ # Close the client stream if possible
191
+ if hasattr(client, 'cancel_stream'):
192
+ await client.cancel_stream()
193
+ # Re-raise the exception for the caller to handle
194
+ raise
134
195
 
135
196
  def ensure_ollama_running() -> bool:
136
197
  """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chat-console
3
- Version: 0.2.8
3
+ Version: 0.2.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
@@ -1,24 +1,24 @@
1
- app/__init__.py,sha256=LLUTUQ0_sgXPrC2d7wZMLj-d74v6qfpS2myh6lOjoaY,130
1
+ app/__init__.py,sha256=g2BzewDN5X96Dl5Zzw8uag1TBEdPIU1ceTm7u-BJrjM,130
2
2
  app/config.py,sha256=sKNp6Za4ZfW-CZBOvEv0TncAS77AnKi86hTM51C4KQ4,5227
3
3
  app/database.py,sha256=nt8CVuDpy6zw8mOYqDcfUmNw611t7Ln7pz22M0b6-MI,9967
4
- app/main.py,sha256=QTDXkVweTX8fYb4LGte71RiEBv81l7jwbZwzWBQpUoc,48157
4
+ app/main.py,sha256=k726xRBcuPgbUsUg4s-REhtaljccjDLNzA_C-fPkQk4,48866
5
5
  app/models.py,sha256=4-y9Lytay2exWPFi0FDlVeRL3K2-I7E-jBqNzTfokqY,2644
6
- app/utils.py,sha256=6XSIJBcJPOXPIHnvKvRwnttdRnN9BSlodcKVj57RLeM,8861
6
+ app/utils.py,sha256=IyINMrM6oGXtN5HRPuKoFEyfKg0fR4FVXIi_0e2KxI0,11798
7
7
  app/api/__init__.py,sha256=A8UL84ldYlv8l7O-yKzraVFcfww86SgWfpl4p7R03-w,62
8
8
  app/api/anthropic.py,sha256=x5PmBXEKe_ow2NWk8XdqSPR0hLOdCc_ypY5QAySeA78,4234
9
9
  app/api/base.py,sha256=-6RSxSpqe-OMwkaq1wVWbu3pVkte-ZYy8rmdvt-Qh48,3953
10
- app/api/ollama.py,sha256=NgfETreb7EdFIux9fvkDfIBj77wcJvic77ObUV95TlI,49866
10
+ app/api/ollama.py,sha256=FTIlgZmvpZd6K4HL2nUD19-p9Xb1TA859LfnCgewpcU,51354
11
11
  app/api/openai.py,sha256=1fYgFXXL6yj_7lQ893Yj28RYG4M8d6gt_q1gzhhjcig,3641
12
12
  app/ui/__init__.py,sha256=RndfbQ1Tv47qdSiuQzvWP96lPS547SDaGE-BgOtiP_w,55
13
- app/ui/chat_interface.py,sha256=VwmVvltxS9l18DI9U7kL43t8kSPPNsrkkrrUSoGu16Q,13623
13
+ app/ui/chat_interface.py,sha256=R8tdy72TcT7veemUzcJOjbPY32WizBdNHgfmq69EFfA,14275
14
14
  app/ui/chat_list.py,sha256=WQTYVNSSXlx_gQal3YqILZZKL9UiTjmNMIDX2I9pAMM,11205
15
15
  app/ui/model_browser.py,sha256=5h3gVsuGIUrXjYVF-QclZFhYtX2kH14LvT22Ufm9etg,49453
16
16
  app/ui/model_selector.py,sha256=Aj1irAs9DQMn8wfcPsFZGxWmx0JTzHjSe7pVdDMwqTQ,13182
17
17
  app/ui/search.py,sha256=b-m14kG3ovqW1-i0qDQ8KnAqFJbi5b1FLM9dOnbTyIs,9763
18
18
  app/ui/styles.py,sha256=04AhPuLrOd2yenfRySFRestPeuTPeMLzhmMB67NdGvw,5615
19
- chat_console-0.2.8.dist-info/licenses/LICENSE,sha256=srHZ3fvcAuZY1LHxE7P6XWju2njRCHyK6h_ftEbzxSE,1057
20
- chat_console-0.2.8.dist-info/METADATA,sha256=rQ7BLE3Ne3YsLxrL8SkIfqNpUpxvUwMm2Rhrb5uDirY,2921
21
- chat_console-0.2.8.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
22
- chat_console-0.2.8.dist-info/entry_points.txt,sha256=kkVdEc22U9PAi2AeruoKklfkng_a_aHAP6VRVwrAD7c,67
23
- chat_console-0.2.8.dist-info/top_level.txt,sha256=io9g7LCbfmTG1SFKgEOGXmCFB9uMP2H5lerm0HiHWQE,4
24
- chat_console-0.2.8.dist-info/RECORD,,
19
+ chat_console-0.2.9.dist-info/licenses/LICENSE,sha256=srHZ3fvcAuZY1LHxE7P6XWju2njRCHyK6h_ftEbzxSE,1057
20
+ chat_console-0.2.9.dist-info/METADATA,sha256=zTSJePqMsi0n6fEz8s4gtLwHe_726-ijfTjPwH_Mumw,2921
21
+ chat_console-0.2.9.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
22
+ chat_console-0.2.9.dist-info/entry_points.txt,sha256=kkVdEc22U9PAi2AeruoKklfkng_a_aHAP6VRVwrAD7c,67
23
+ chat_console-0.2.9.dist-info/top_level.txt,sha256=io9g7LCbfmTG1SFKgEOGXmCFB9uMP2H5lerm0HiHWQE,4
24
+ chat_console-0.2.9.dist-info/RECORD,,